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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -17,5 +17,6 @@
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user