b8ddda86e7
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
90 lines
3.8 KiB
TypeScript
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;
|
|
}
|
|
}
|