Rindle

API index and search · Build metadata

Source snapshot

packages/affinity/src/index.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// Follower-affinity tickets — the TypeScript twin of the `rindle-affinity` Rust crate2// (`rust/rindle-affinity/src/lib.rs`; `designs-deprecated/FOLLOWER-AFFINITY-DESIGN.md` §3, §9). Used by the3// local dev-edge (S4) and any JS-side verify; the daemon mints/verifies with the Rust crate.4//5// # Byte-compatibility6//7// A ticket is `aff.<base64url(payload)>.<base64url(hmac_sha256(base64url(payload), key))>` where8// `payload` is COMPACT JSON with the field order below. `mint` here produces byte-for-byte the same9// token as the Rust crate for the same input — pinned by the shared `frozenTestVector` in the tests10// (mirrored from the crate's `frozen_test_vector`). Because `verify` authenticates the transmitted11// base64url payload segment BEFORE parsing it, a Rust-minted ticket verifies here and vice-versa12// regardless of either side's JSON handling.13//14// Placement, not authorization (design §3, §9): a forged ticket only *aims* a connection at a15// machine, granting no data access. The key is a symmetric per-fleet secret.1617import { createHmac, timingSafeEqual } from "node:crypto";1819/** The token namespace marker — the first dot-segment of every ticket. */20export const TICKET_PREFIX = "aff";2122/** The base ws subprotocol offered alongside the `aff.*` ticket; the daemon echoes it on the 101. */23export const WS_SUBPROTOCOL = "rindle.v1";2425/** The api-server → fleet control-plane header carrying the ticket on `/materialize` and `/query`. */26export const HEADER_NAME = "Rindle-Affinity";2728/**29 * The signed ticket body. Field order here is the canonical mint order — it is exactly the compact30 * JSON that gets base64url-encoded and signed, so it must match the Rust `Payload` struct order.31 */32export interface Payload {33  /** App id the ticket is scoped to. */34  app: string;35  /** Placement target id. */36  mid: string;37  /** Region code of `mid`. */38  region: string;39  /** Subject: the connection identity (clientInstanceId). */40  sub: string;41  /** Issued-at, unix seconds (informational). */42  iat: number;43  /** Expiry, unix seconds. `verify` rejects once `now > exp`. */44  exp: number;45  /** Generation minted under; a fleet-wide bump drains older tickets. */46  gen: number;47}4849/** What {@link verify} holds a ticket against, supplied by the verifier. */50export interface Expect {51  /** The verifier's app id; must equal `payload.app`. */52  app: string;53  /** Current wall-clock, unix seconds. `exp < now` ⇒ rejected. */54  now: number;55  /** Minimum accepted generation; `gen < minGen` ⇒ rejected. `0` accepts any. */56  minGen: number;57}5859/** Why a ticket failed {@link verify}. Every variant is a terminal reject (fall back to re-pin). */60export type VerifyError =61  | "malformed"62  | "bad-signature"63  | "wrong-app"64  | "expired"65  | "stale-generation";6667/** {@link verify}'s result: the authenticated payload, or a reason. */68export type VerifyResult =69  | { readonly ok: true; readonly payload: Payload }70  | { readonly ok: false; readonly error: VerifyError };7172type Key = string | Uint8Array;7374const B64URL = /^[A-Za-z0-9_-]+$/;7576function keyBytes(key: Key): Uint8Array {77  return typeof key === "string" ? new TextEncoder().encode(key) : key;78}7980function b64url(bytes: Uint8Array): string {81  return Buffer.from(bytes).toString("base64url");82}8384/**85 * Mint a ticket over `payload`, signed with `key`. Deterministic (no randomness): the same86 * `(payload, key)` always yields the same token. The compact JSON is built in the fixed field order87 * so the bytes match the Rust crate's `serde_json` compact output.88 */89export function mint(payload: Payload, key: Key): string {90  const json = JSON.stringify({91    app: payload.app,92    mid: payload.mid,93    region: payload.region,94    sub: payload.sub,95    iat: payload.iat,96    exp: payload.exp,97    gen: payload.gen,98  });99  const payloadB64 = Buffer.from(json, "utf8").toString("base64url");100  const sig = createHmac("sha256", keyBytes(key)).update(payloadB64).digest();101  return `${TICKET_PREFIX}.${payloadB64}.${b64url(sig)}`;102}103104/**105 * Verify `token` against `keys` (current first, then rotation-window predecessors) and `expect`.106 * The HMAC is checked (constant-time) over the transmitted base64url payload segment before the107 * payload is parsed, so a tampered payload fails as `bad-signature`, never as a parse of attacker108 * bytes.109 */110export function verify(token: string, keys: readonly Key[], expect: Expect): VerifyResult {111  const parts = splitToken(token);112  if (!parts) return { ok: false, error: "malformed" };113  const [payloadB64, sigB64] = parts;114  if (!B64URL.test(payloadB64) || !B64URL.test(sigB64)) return { ok: false, error: "malformed" };115116  const sig = Buffer.from(sigB64, "base64url");117  const msg = Buffer.from(payloadB64, "utf8");118  const matches = keys.some((k) => {119    const mac = createHmac("sha256", keyBytes(k)).update(msg).digest();120    return mac.length === sig.length && timingSafeEqual(mac, sig);121  });122  if (!matches) return { ok: false, error: "bad-signature" };123124  const payload = parsePayload(payloadB64);125  if (!payload) return { ok: false, error: "malformed" };126  if (payload.app !== expect.app) return { ok: false, error: "wrong-app" };127  if (expect.now > payload.exp) return { ok: false, error: "expired" };128  if (payload.gen < expect.minGen) return { ok: false, error: "stale-generation" };129  return { ok: true, payload };130}131132/**133 * Extract the `aff.*` ticket from a `Sec-WebSocket-Protocol` header value (a comma list, e.g.134 * `rindle.v1, aff.<…>`). Returns the raw ticket segment for {@link verify}, or `null`. Does not verify.135 */136export function wsSubprotocolTicket(header: string): string | null {137  for (const raw of header.split(",")) {138    const p = raw.trim();139    if (isTicketShape(p)) return p;140  }141  return null;142}143144/** Whether the header offers the base {@link WS_SUBPROTOCOL} — the daemon must echo it on the 101. */145export function wsOffersBase(header: string): boolean {146  return header.split(",").some((p) => p.trim() === WS_SUBPROTOCOL);147}148149/** Extract the ticket from a {@link HEADER_NAME} value. Returns the raw segment, or `null`. */150export function headerTicket(value: string): string | null {151  const v = value.trim();152  return isTicketShape(v) ? v : null;153}154155/** A syntactic ticket = `aff.<seg>.<seg>` with non-empty segments. Cheap prefilter, like the crate. */156function isTicketShape(s: string): boolean {157  return splitToken(s) !== null;158}159160/** Split into (payloadB64, sigB64), validating the `aff.` prefix and exact 3-segment shape. */161function splitToken(token: string): [string, string] | null {162  const parts = token.split(".");163  if (parts.length !== 3) return null;164  const [prefix, payloadB64, sigB64] = parts;165  if (prefix !== TICKET_PREFIX || payloadB64.length === 0 || sigB64.length === 0) return null;166  return [payloadB64, sigB64];167}168169function parsePayload(payloadB64: string): Payload | null {170  let value: unknown;171  try {172    value = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));173  } catch {174    return null;175  }176  if (typeof value !== "object" || value === null) return null;177  const p = value as Record<string, unknown>;178  if (179    typeof p.app !== "string" ||180    typeof p.mid !== "string" ||181    typeof p.region !== "string" ||182    typeof p.sub !== "string" ||183    typeof p.iat !== "number" ||184    typeof p.exp !== "number" ||185    typeof p.gen !== "number"186  ) {187    return null;188  }189  return { app: p.app, mid: p.mid, region: p.region, sub: p.sub, iat: p.iat, exp: p.exp, gen: p.gen };190}191