Rindle

API index and search · Build metadata

Source snapshot

packages/room/src/token.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
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 named5 * query to an approved AST, and signs this token. The token then IS the lease — the6 * room materializes on first presentation, so no pre-placement `/materialize` control7 * call ever touches it (the §10.1 inversion; on the DO shell the Worker verifies the8 * same signature statelessly before `get(id)`).9 *10 * Shape: `rt1.<base64url(payload)>.<base64url(hmac-sha256(prefix.payload))>` with11 * payload `{ v: 1, doc, ast, sub, iat, exp, kid }`:12 *13 * - `doc` — the room/document id the token authorizes (a token for one room presented14 *   to another is refused);15 * - `ast` — the approved wire `Ast` (the client never composes this; §4's "ASTs never16 *   cross the public wire" holds because the token is opaque TO THE CLIENT — it carries17 *   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: clients20 *   obtain a fresh token through the API server, never extend this one. `iat` is what21 *   lets a revocation refuse pre-revocation tokens while a genuine re-grant (a newer22 *   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 (the26 * test shell) and in a Cloudflare Worker/DO (P4) — no `node:crypto` import.27 */2829export 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 time38   *  ({@link scopeSpecsHash}). Advisory, not a credential: the room's §3.3 gate is the39   *  contract regardless. It lets the shell detect SCOPE SKEW — a room profile edited40   *  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 silently42   *  deopt-loops. Optional so a pre-stamp api-server / older token still verifies. */43  scopesHash?: string;44}4546export 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 the59   *  shell can flag scope skew (see {@link RoomTokenPayload.scopesHash}). Omit to not stamp. */60  scopesHash?: string;61}6263const PREFIX = "rt1";6465/** Canonical JSON: object keys sorted recursively (arrays keep order), so structurally66 *  identical specs serialize identically regardless of key INSERTION order across code67 *  versions — a cosmetic reorder must not read as a scope change. `undefined`-valued keys68 *  are dropped, matching `JSON.stringify` and the `footprintWhere?`/`where?` optionals. */69function 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}7980/** 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 shell83 *  (hashing the boot-wire scopes it armed the gate with) run this over the SAME compiler84 *  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 either86 *  wire's spec array (`RoomScopeSpec[]` / `RoomTableSpec[]` — structurally identical). */87export 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}9697function 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}102103// Return type inferred (`Uint8Array<ArrayBuffer>` under TS ≥5.7 libs) — an explicit104// `Uint8Array` annotation widens to `ArrayBufferLike` and fails `crypto.subtle`'s105// `BufferSource` under consumers compiling this source with newer lib types (the DO shell).106function 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}112113// Return type inferred: this package's tsconfig has no DOM lib, so the WebCrypto114// interface names aren't ambient — but `crypto.subtle`'s own types carry through.115async 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}124125/** Sign a room lease token. This runs on the API-server side (or a test playing it). */126export 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}147148/** Why a token was refused. The `reason` is terse and safe to echo in a queryError. */149export class RoomTokenError extends Error {150  readonly reason: string;151152  constructor(reason: string) {153    super(`lease token refused: ${reason}`);154    this.reason = reason;155  }156}157158export 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}166167/**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 trusted170 * (not even `kid`'s existence beyond the key lookup) before the MAC passes.171 */172export 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;182183  // Parse only far enough to find `kid` (the signature covers everything, so a lying184  // 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  }205206  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}220