From 2960b0984a6b18896afdd648ff1327e5cab8f881 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 14 Jun 2026 11:26:13 +0200 Subject: [PATCH] feat(bus): createRoom + control-plane shape fixes, verified by live E2E round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/bus/client.ts | 53 +++++++++++++++++++++++++++++++-- web/src/bus/integration.test.ts | 44 ++++++++++++++++++++++++++- web/tsconfig.app.json | 3 +- 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/web/src/bus/client.ts b/web/src/bus/client.ts index 19f2469..45b5c8b 100644 --- a/web/src/bus/client.ts +++ b/web/src/bus/client.ts @@ -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 { - return this.request("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 { + const r = await this.request("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 = { + 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 diff --git a/web/src/bus/integration.test.ts b/web/src/bus/integration.test.ts index a486450..0538c75 100644 --- a/web/src/bus/integration.test.ts +++ b/web/src/bus/integration.test.ts @@ -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((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((_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); +}); diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json index d7e60cb..3e955e0 100644 --- a/web/tsconfig.app.json +++ b/web/tsconfig.app.json @@ -17,5 +17,6 @@ "noUnusedParameters": false, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] }