feat(bus): createRoom + control-plane shape fixes, verified by live E2E round-trip
Validating the SDK against the real cluster surfaced the control-plane wire shapes:
the room/policy JSON is snake_case (sign_msgs) and GET /rooms/{id} omits the id.
Fix ControlPlane.fetchRoom to map the wire shape to the SDK Room type, and add
ControlPlane.createRoom (mint a room key, seal it to the owner via sealed box, POST
/rooms) so a browser peer can own an encrypted room.
The live smoke now does a full end-to-end round-trip against the 3-node cluster:
create an encrypted+signed room, connect over nats.ws, publish, and receive the
SDK's own message decrypted with the signature verified. Verified 2026-06-14:
room 01KV2Q…, plaintext round-tripped intact. The whole seal/sign/open happens in
the client; the private key never leaves it.
Exclude *.test.ts from the app tsconfig so the Node-API integration test does not
break the production build (vitest transpiles tests independently). Issue 0001,
Phase 3.
This commit is contained in:
+50
-3
@@ -27,8 +27,10 @@ import {
|
||||
randomNonce,
|
||||
signEd25519,
|
||||
verifyEd25519,
|
||||
sealKeyBox,
|
||||
openKeyBox,
|
||||
endpointID,
|
||||
bytesToBase64,
|
||||
} from "./crypto.js";
|
||||
import { signedHeaders, freshNonce } from "./busauth.js";
|
||||
|
||||
@@ -150,6 +152,21 @@ interface RoomKeyResponse {
|
||||
epoch: number;
|
||||
}
|
||||
|
||||
// PolicyWire is the control-plane JSON shape of a policy (snake_case sign_msgs).
|
||||
interface PolicyWire {
|
||||
encrypt: boolean;
|
||||
persist: boolean;
|
||||
sign_msgs: boolean;
|
||||
}
|
||||
|
||||
// RoomResp is GET /rooms/{id}: the room metadata WITHOUT the id (the caller knows it)
|
||||
// and with the policy nested under snake_case keys.
|
||||
interface RoomResp {
|
||||
subject: string;
|
||||
epoch: number;
|
||||
policy: PolicyWire;
|
||||
}
|
||||
|
||||
interface MemberJSON {
|
||||
endpoint: string;
|
||||
sign_pub: string; // base64
|
||||
@@ -194,9 +211,39 @@ export class ControlPlane {
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
// fetchRoom resolves room metadata (subject, epoch, policy).
|
||||
fetchRoom(roomID: string): Promise<Room> {
|
||||
return this.request<Room>("GET", `/rooms/${roomID}`);
|
||||
// fetchRoom resolves room metadata, mapping the control-plane wire shape
|
||||
// (snake_case policy, no id) to the SDK's Room type.
|
||||
async fetchRoom(roomID: string): Promise<Room> {
|
||||
const r = await this.request<RoomResp>("GET", `/rooms/${roomID}`);
|
||||
return {
|
||||
id: roomID,
|
||||
subject: r.subject,
|
||||
epoch: r.epoch,
|
||||
policy: { encrypt: r.policy.encrypt, persist: r.policy.persist, signMsgs: r.policy.sign_msgs },
|
||||
};
|
||||
}
|
||||
|
||||
// createRoom creates a room owned by this peer. For an encrypted room it mints a
|
||||
// fresh 32-byte room key, seals it to the owner's own X25519 key (sealed box), and
|
||||
// ships it as sealed_key_self so the server can store the owner's copy without ever
|
||||
// seeing the key. Returns the new room id and (for encrypted rooms) the key.
|
||||
async createRoom(subject: string, policy: Policy): Promise<{ roomID: string; key?: Uint8Array }> {
|
||||
const body: Record<string, unknown> = {
|
||||
subject,
|
||||
policy: { encrypt: policy.encrypt, persist: policy.persist, sign_msgs: policy.signMsgs },
|
||||
owner: {
|
||||
endpoint: endpointID(this.id.signPub),
|
||||
sign_pub: bytesToBase64(this.id.signPub),
|
||||
kex_pub: bytesToBase64(this.id.kexPub),
|
||||
},
|
||||
};
|
||||
let key: Uint8Array | undefined;
|
||||
if (policy.encrypt) {
|
||||
key = crypto.getRandomValues(new Uint8Array(32));
|
||||
body.sealed_key_self = bytesToBase64(sealKeyBox(this.id.kexPub, key));
|
||||
}
|
||||
const resp = await this.request<{ room_id: string }>("POST", "/rooms", body);
|
||||
return { roomID: resp.room_id, key };
|
||||
}
|
||||
|
||||
// fetchRoomKey fetches the sealed room key for this peer and opens it with the
|
||||
|
||||
Reference in New Issue
Block a user