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,
|
randomNonce,
|
||||||
signEd25519,
|
signEd25519,
|
||||||
verifyEd25519,
|
verifyEd25519,
|
||||||
|
sealKeyBox,
|
||||||
openKeyBox,
|
openKeyBox,
|
||||||
endpointID,
|
endpointID,
|
||||||
|
bytesToBase64,
|
||||||
} from "./crypto.js";
|
} from "./crypto.js";
|
||||||
import { signedHeaders, freshNonce } from "./busauth.js";
|
import { signedHeaders, freshNonce } from "./busauth.js";
|
||||||
|
|
||||||
@@ -150,6 +152,21 @@ interface RoomKeyResponse {
|
|||||||
epoch: number;
|
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 {
|
interface MemberJSON {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
sign_pub: string; // base64
|
sign_pub: string; // base64
|
||||||
@@ -194,9 +211,39 @@ export class ControlPlane {
|
|||||||
return (await resp.json()) as T;
|
return (await resp.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchRoom resolves room metadata (subject, epoch, policy).
|
// fetchRoom resolves room metadata, mapping the control-plane wire shape
|
||||||
fetchRoom(roomID: string): Promise<Room> {
|
// (snake_case policy, no id) to the SDK's Room type.
|
||||||
return this.request<Room>("GET", `/rooms/${roomID}`);
|
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
|
// 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 { signedHeaders, freshNonce } from "./busauth.js";
|
||||||
import { hexToBytes } from "./crypto.js";
|
import { hexToBytes } from "./crypto.js";
|
||||||
import { WsNatsTransport } from "./wstransport.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_HTTP = process.env.BUS_HTTP;
|
||||||
const BUS_WS = process.env.BUS_WS;
|
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);
|
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,
|
"noUnusedParameters": false,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user