// Credential capture ("enroll a card"): lets an operator present a physical RFID // card/chip (or a QR) to ONE chosen reader and have its value captured for a // subscription credential, instead of typing it. SINGLE-SHOT + short TTL so the // chosen reader is only "borrowed" for one read / a few seconds; the OTHER reader is // never affected and keeps serving the live entry/exit flow. // // Flow: arm(deviceId) → the reader route checks tryConsume() on each read; the next // read from that armed reader is captured (NOT dispatched to the access flow — the // barrier must not open for a card being enrolled) and capture auto-disarms. The // booth form polls result() until the value appears (or it times out / is cancelled). // // In-memory + single-site single-writer (one booth) → no DB, no cross-process // concerns. See wiki/entities/subscription.md. const CAPTURE_TTL_MS = Number(process.env.CAPTURE_TTL_MS ?? 30_000); export type CaptureState = | { status: "idle" } | { status: "armed"; deviceId: string; armedAt: number; expiresAt: number } | { status: "captured"; deviceId: string; value: string; capturedAt: number } | { status: "expired"; deviceId: string }; export class CredentialCapture { #armedDeviceId: string | null = null; #expiresAt = 0; #captured: { deviceId: string; value: string; capturedAt: number } | null = null; #lastExpiredDeviceId: string | null = null; /** Arm a single-shot capture on one reader (by its `devices.id`). Replaces any * prior arming (only one capture at a time). Clears a stale captured/expired * result so the form starts fresh. */ arm(deviceId: string): { expiresAt: number } { this.#armedDeviceId = deviceId; this.#expiresAt = Date.now() + CAPTURE_TTL_MS; this.#captured = null; this.#lastExpiredDeviceId = null; return { expiresAt: this.#expiresAt }; } /** Cancel any pending arming (operator closed the form / clicked cancel). */ cancel(): void { this.#armedDeviceId = null; this.#expiresAt = 0; } /** * Called by the reader route on EVERY read. If this reader is the armed one (and * not expired), capture the value, disarm, and return true → the caller must NOT * dispatch this read to the access flow. Otherwise false → dispatch normally. */ tryConsume(deviceId: string, value: string): boolean { if (this.#armedDeviceId == null) return false; if (Date.now() > this.#expiresAt) { // Window lapsed before a card was presented — disarm, mark expired. this.#lastExpiredDeviceId = this.#armedDeviceId; this.#armedDeviceId = null; this.#expiresAt = 0; return false; } if (deviceId !== this.#armedDeviceId) return false; // a read from the OTHER reader if (!value) return false; this.#captured = { deviceId, value, capturedAt: Date.now() }; this.#armedDeviceId = null; // single-shot this.#expiresAt = 0; return true; } /** Current state for the booth form's poll. Lazily transitions armed→expired. */ state(): CaptureState { if (this.#captured) return { status: "captured", ...this.#captured }; if (this.#armedDeviceId != null) { if (Date.now() > this.#expiresAt) { this.#lastExpiredDeviceId = this.#armedDeviceId; this.#armedDeviceId = null; this.#expiresAt = 0; return { status: "expired", deviceId: this.#lastExpiredDeviceId }; } return { status: "armed", deviceId: this.#armedDeviceId, armedAt: this.#expiresAt - CAPTURE_TTL_MS, expiresAt: this.#expiresAt }; } if (this.#lastExpiredDeviceId) return { status: "expired", deviceId: this.#lastExpiredDeviceId }; return { status: "idle" }; } /** Clear a consumed/expired result once the form has read it. */ clear(): void { this.#captured = null; this.#lastExpiredDeviceId = null; } }