Files
parking_solution/apps/server/src/credential-capture.ts
T
julian b8ddda86e7 feat(subscription): RFID enrollment, any-credential exit, prepaid booth handling
Rounds out subscriptions across enrollment, the barrier flow, and the booth.

- RFID credentials enabled with a "Read card" enrollment flow: the operator
  arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
  reader's next read is captured into the form and NOT dispatched to the access
  flow — the OTHER reader keeps serving live entry/exit. Routes:
  /api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
  per-occurrence id (SUBSESS-<short>), not the credential value, with
  permitId in the payload. Direction is decided by the barrier the reader sits
  at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
  admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
  pay/exit modal shows a subscription mode (snapshots + a single audited
  Open-barrier action) to assist a faulty exit reader / missing card;
  reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
  "abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
  verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.

Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 16:26:48 +02:00

90 lines
3.8 KiB
TypeScript

// 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;
}
}