@rindle/room 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/token.ts ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The room's **self-authorizing signed lease token** (RINDLE-REALTIME-DESIGN.md §10.1).
3
+ *
4
+ * The API server is the authority (§4): it authenticates the user, resolves the named
5
+ * query to an approved AST, and signs this token. The token then IS the lease — the
6
+ * room materializes on first presentation, so no pre-placement `/materialize` control
7
+ * call ever touches it (the §10.1 inversion; on the DO shell the Worker verifies the
8
+ * same signature statelessly before `get(id)`).
9
+ *
10
+ * Shape: `rt1.<base64url(payload)>.<base64url(hmac-sha256(prefix.payload))>` with
11
+ * payload `{ v: 1, doc, ast, sub, iat, exp, kid }`:
12
+ *
13
+ * - `doc` — the room/document id the token authorizes (a token for one room presented
14
+ * to another is refused);
15
+ * - `ast` — the approved wire `Ast` (the client never composes this; §4's "ASTs never
16
+ * cross the public wire" holds because the token is opaque TO THE CLIENT — it carries
17
+ * it, it cannot mint or alter it);
18
+ * - `sub` — the subject (user id): the revocation key (§4.1);
19
+ * - `iat`/`exp` — issued-at / expiry, ms epoch. Renewal is re-authorization: clients
20
+ * obtain a fresh token through the API server, never extend this one. `iat` is what
21
+ * lets a revocation refuse pre-revocation tokens while a genuine re-grant (a newer
22
+ * token) passes immediately;
23
+ * - `kid` — which shared secret signed it (rotation).
24
+ *
25
+ * HMAC via WebCrypto (`crypto.subtle`) so the exact same code verifies in Node (the
26
+ * test shell) and in a Cloudflare Worker/DO (P4) — no `node:crypto` import.
27
+ */
28
+
29
+ export interface RoomTokenPayload {
30
+ v: 1;
31
+ doc: string;
32
+ ast: unknown;
33
+ sub: string;
34
+ iat: number;
35
+ exp: number;
36
+ kid: string;
37
+ /** A short fingerprint of the room's compiled scope specs at mint time
38
+ * ({@link scopeSpecsHash}). Advisory, not a credential: the room's §3.3 gate is the
39
+ * contract regardless. It lets the shell detect SCOPE SKEW — a room profile edited
40
+ * while a room is already live arms the gate with the OLD specs (a one-shot at boot)
41
+ * while fresh leases prove against the NEW ones, so every routed write silently
42
+ * deopt-loops. Optional so a pre-stamp api-server / older token still verifies. */
43
+ scopesHash?: string;
44
+ }
45
+
46
+ export interface MintRoomTokenOptions {
47
+ doc: string;
48
+ ast: unknown;
49
+ /** The subject (user id) this token authorizes — the §4.1 revocation key. */
50
+ sub: string;
51
+ /** Key id + its secret (utf-8; give every room the same `keys` map). */
52
+ kid: string;
53
+ key: string;
54
+ /** Expiry, ms from `now`. Keep short (minutes) — the §4.1 TTL backstop. */
55
+ ttlMs: number;
56
+ /** Mint time; defaults to `Date.now()`. Injectable for tests. */
57
+ now?: number;
58
+ /** The room's {@link scopeSpecsHash} for the profile this lease serves — stamped so the
59
+ * shell can flag scope skew (see {@link RoomTokenPayload.scopesHash}). Omit to not stamp. */
60
+ scopesHash?: string;
61
+ }
62
+
63
+ const PREFIX = "rt1";
64
+
65
+ /** Canonical JSON: object keys sorted recursively (arrays keep order), so structurally
66
+ * identical specs serialize identically regardless of key INSERTION order across code
67
+ * versions — a cosmetic reorder must not read as a scope change. `undefined`-valued keys
68
+ * are dropped, matching `JSON.stringify` and the `footprintWhere?`/`where?` optionals. */
69
+ function canonicalJson(v: unknown): string {
70
+ if (v === undefined) return "null";
71
+ if (v === null || typeof v !== "object") return JSON.stringify(v);
72
+ if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
73
+ const o = v as Record<string, unknown>;
74
+ const keys = Object.keys(o)
75
+ .filter((k) => o[k] !== undefined)
76
+ .sort();
77
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(",")}}`;
78
+ }
79
+
80
+ /** A stable short fingerprint of the compiled scope specs — the scope-skew tripwire.
81
+ * NOT security (the gate re-proves every write): FNV-1a-32 over the {@link canonicalJson}
82
+ * form, 8 hex chars. Both the api-server (stamping the lease token) and the room shell
83
+ * (hashing the boot-wire scopes it armed the gate with) run this over the SAME compiler
84
+ * output, so an unchanged profile ⇒ equal hash and a profile edited under a live room ⇒
85
+ * mismatch. A collision only costs a missed diagnostic, never correctness. Accepts either
86
+ * wire's spec array (`RoomScopeSpec[]` / `RoomTableSpec[]` — structurally identical). */
87
+ export function scopeSpecsHash(specs: readonly unknown[]): string {
88
+ const json = canonicalJson(specs);
89
+ let h = 0x811c9dc5;
90
+ for (let i = 0; i < json.length; i++) {
91
+ h ^= json.charCodeAt(i);
92
+ h = Math.imul(h, 0x01000193);
93
+ }
94
+ return (h >>> 0).toString(16).padStart(8, "0");
95
+ }
96
+
97
+ function b64url(bytes: Uint8Array): string {
98
+ let bin = "";
99
+ for (const b of bytes) bin += String.fromCharCode(b);
100
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
101
+ }
102
+
103
+ // Return type inferred (`Uint8Array<ArrayBuffer>` under TS ≥5.7 libs) — an explicit
104
+ // `Uint8Array` annotation widens to `ArrayBufferLike` and fails `crypto.subtle`'s
105
+ // `BufferSource` under consumers compiling this source with newer lib types (the DO shell).
106
+ function unb64url(s: string) {
107
+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
108
+ const out = new Uint8Array(bin.length);
109
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
110
+ return out;
111
+ }
112
+
113
+ // Return type inferred: this package's tsconfig has no DOM lib, so the WebCrypto
114
+ // interface names aren't ambient — but `crypto.subtle`'s own types carry through.
115
+ async function hmacKey(secret: string, usage: "sign" | "verify") {
116
+ return crypto.subtle.importKey(
117
+ "raw",
118
+ new TextEncoder().encode(secret),
119
+ { name: "HMAC", hash: "SHA-256" },
120
+ false,
121
+ [usage],
122
+ );
123
+ }
124
+
125
+ /** Sign a room lease token. This runs on the API-server side (or a test playing it). */
126
+ export async function mintRoomToken(opts: MintRoomTokenOptions): Promise<string> {
127
+ const now = opts.now ?? Date.now();
128
+ const payload: RoomTokenPayload = {
129
+ v: 1,
130
+ doc: opts.doc,
131
+ ast: opts.ast,
132
+ sub: opts.sub,
133
+ iat: now,
134
+ exp: now + opts.ttlMs,
135
+ kid: opts.kid,
136
+ // Stamped only when supplied — an unstamped mint keeps the pre-scopesHash byte shape.
137
+ ...(opts.scopesHash !== undefined ? { scopesHash: opts.scopesHash } : {}),
138
+ };
139
+ const body = b64url(new TextEncoder().encode(JSON.stringify(payload)));
140
+ const signed = `${PREFIX}.${body}`;
141
+ const key = await hmacKey(opts.key, "sign");
142
+ const sig = new Uint8Array(
143
+ await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signed)),
144
+ );
145
+ return `${signed}.${b64url(sig)}`;
146
+ }
147
+
148
+ /** Why a token was refused. The `reason` is terse and safe to echo in a queryError. */
149
+ export class RoomTokenError extends Error {
150
+ readonly reason: string;
151
+
152
+ constructor(reason: string) {
153
+ super(`lease token refused: ${reason}`);
154
+ this.reason = reason;
155
+ }
156
+ }
157
+
158
+ export interface VerifyRoomTokenOptions {
159
+ /** The room's own doc id — a token for any other doc is refused. */
160
+ doc: string;
161
+ /** kid → shared secret. Unknown kids are refused (never "try them all"). */
162
+ keys: Record<string, string>;
163
+ /** Verification time; defaults to `Date.now()`. Injectable for tests. */
164
+ now?: number;
165
+ }
166
+
167
+ /**
168
+ * Verify a token's signature and claims; returns the payload (with the approved AST)
169
+ * or throws {@link RoomTokenError}. Signature is checked FIRST — no claim is trusted
170
+ * (not even `kid`'s existence beyond the key lookup) before the MAC passes.
171
+ */
172
+ export async function verifyRoomToken(
173
+ token: string,
174
+ opts: VerifyRoomTokenOptions,
175
+ ): Promise<RoomTokenPayload> {
176
+ const now = opts.now ?? Date.now();
177
+ const parts = token.split(".");
178
+ if (parts.length !== 3 || parts[0] !== PREFIX) {
179
+ throw new RoomTokenError("not a room token");
180
+ }
181
+ const [, body, sig] = parts;
182
+
183
+ // Parse only far enough to find `kid` (the signature covers everything, so a lying
184
+ // kid can only select a key that then fails the MAC).
185
+ let payload: RoomTokenPayload;
186
+ try {
187
+ payload = JSON.parse(new TextDecoder().decode(unb64url(body))) as RoomTokenPayload;
188
+ } catch {
189
+ throw new RoomTokenError("malformed payload");
190
+ }
191
+ const secret = typeof payload.kid === "string" ? opts.keys[payload.kid] : undefined;
192
+ if (secret === undefined) {
193
+ throw new RoomTokenError("unknown key id");
194
+ }
195
+ const key = await hmacKey(secret, "verify");
196
+ const ok = await crypto.subtle.verify(
197
+ "HMAC",
198
+ key,
199
+ unb64url(sig),
200
+ new TextEncoder().encode(`${PREFIX}.${body}`),
201
+ );
202
+ if (!ok) {
203
+ throw new RoomTokenError("bad signature");
204
+ }
205
+
206
+ if (payload.v !== 1) throw new RoomTokenError("unknown version");
207
+ if (payload.doc !== opts.doc) throw new RoomTokenError("token is for another doc");
208
+ if (typeof payload.sub !== "string" || payload.sub.length === 0) {
209
+ throw new RoomTokenError("missing subject");
210
+ }
211
+ if (typeof payload.exp !== "number" || now >= payload.exp) {
212
+ throw new RoomTokenError("expired lease");
213
+ }
214
+ if (typeof payload.iat !== "number") throw new RoomTokenError("missing iat");
215
+ if (payload.ast === undefined || payload.ast === null) {
216
+ throw new RoomTokenError("missing ast");
217
+ }
218
+ return payload;
219
+ }
package/src/wasm.ts ADDED
@@ -0,0 +1,32 @@
1
+ // Loader for the room wasm artifact (pkg/, built by ./build.sh) — the same init-once
2
+ // pattern as @rindle/wasm: a `--target web` ESM artifact needs one explicit init before
3
+ // the classes are usable. Node reads the bytes from the package; a browser/Worker host
4
+ // fetches (or passes a precompiled module).
5
+
6
+ import init, { WasmRoom } from "../pkg/rindle_room.js";
7
+
8
+ export { WasmRoom };
9
+
10
+ let initialized: Promise<void> | null = null;
11
+
12
+ /** Initialize the room wasm module (idempotent). Call once before `WasmRoom.open`.
13
+ * Node: bytes are read from the package. Browser/bundler: the wasm is fetched. Pass
14
+ * `moduleOrPath` to override (a `WebAssembly.Module`, URL, or bytes). */
15
+ export function initRoomWasm(moduleOrPath?: unknown): Promise<void> {
16
+ if (!initialized) {
17
+ initialized = (async () => {
18
+ if (moduleOrPath !== undefined) {
19
+ await init({ module_or_path: moduleOrPath });
20
+ } else if (
21
+ (globalThis as { process?: { versions?: { node?: string } } }).process?.versions?.node
22
+ ) {
23
+ const { readFile } = await import("node:fs/promises");
24
+ const bytes = await readFile(new URL("../pkg/rindle_room_bg.wasm", import.meta.url));
25
+ await init({ module_or_path: bytes });
26
+ } else {
27
+ await init();
28
+ }
29
+ })();
30
+ }
31
+ return initialized;
32
+ }