API index and search · Build metadata
Source snapshot
packages/affinity/src/index.ts
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