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:
agent
2026-06-14 11:26:13 +02:00
parent b44aa02326
commit 2960b0984a
3 changed files with 95 additions and 5 deletions
+50 -3
View File
@@ -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
+43 -1
View File
@@ -19,7 +19,8 @@ import { concatBytes } from "@noble/hashes/utils.js";
import { signedHeaders, freshNonce } from "./busauth.js";
import { hexToBytes } from "./crypto.js";
import { WsNatsTransport } from "./wstransport.js";
import type { Identity } from "./client.js";
import { BusClient, ControlPlane, type Identity } from "./client.js";
import type { Frame } from "./frame.js";
const BUS_HTTP = process.env.BUS_HTTP;
const BUS_WS = process.env.BUS_WS;
@@ -124,3 +125,44 @@ describe.skipIf(!live || !regId)("live cluster smoke — REGISTERED identity is
expect(connected).toBe(true);
});
});
describe.skipIf(!live || !regId)("live cluster — end-to-end encrypted round-trip", () => {
const id = regId!;
it("creates an encrypted room, publishes, and receives its own decrypted message", async () => {
const control = new ControlPlane(BUS_HTTP!, id);
// Encrypted + signed, but EPHEMERAL (no JetStream persistence) to keep the smoke
// to core NATS pub/sub. A unique subject avoids colliding with prior runs.
const subject = `room.smoke-${id.signPub[0]}-${Math.floor(Date.now() / 1000)}`;
const { roomID } = await control.createRoom(subject, { encrypt: true, persist: false, signMsgs: true });
// eslint-disable-next-line no-console
console.log(`[round-trip] created room ${roomID} subject=${subject}`);
// Connect the data plane AFTER creating the room: the per-subject ACL freezes a
// peer's publishable/subscribable subjects at connect time, so the room's subject
// is in our grant only once we connect post-creation.
const transport = await WsNatsTransport.connect([BUS_WS!], id);
const bus = new BusClient(id, transport, control);
const got = new Promise<string>((resolve) => {
bus.subscribe(roomID, (_f: Frame, plaintext: Uint8Array) => {
resolve(new TextDecoder().decode(plaintext));
});
});
// Give the subscription a moment to register on the server before publishing.
await new Promise((r) => setTimeout(r, 600));
const message = "hello from the browser SDK, end to end";
await bus.publish(roomID, new TextEncoder().encode(message));
const received = await Promise.race([
got,
new Promise<string>((_r, reject) => setTimeout(() => reject(new Error("timeout waiting for message")), 8000)),
]);
// eslint-disable-next-line no-console
console.log(`[round-trip] received="${received}"`);
await transport.close();
expect(received).toBe(message);
}, 20000);
});
+2 -1
View File
@@ -17,5 +17,6 @@
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}