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
This commit is contained in:
2026-06-18 16:26:48 +02:00
parent bba988c4e8
commit b8ddda86e7
16 changed files with 663 additions and 143 deletions
+92 -68
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import {
eq,
ledgerEvents,
@@ -26,18 +27,24 @@ import { snapshotAsync } from "./snapshot.js";
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
// too (card/QR OR plate). When unset, any car may use the subscription's card/QR.
//
// Direction is inferred from session state for THAT car (the read credential value
// is the per-car session key): no open session → ENTRY; open session → EXIT. So a
// fleet subscription can have several cars in at once, each its own session, and
// anti-passback falls out (a second "entry" on a car already in becomes its exit).
// Direction is inferred from the SUBSCRIPTION's open-session state, NOT the specific
// credential read — so ANY of a subscription's credentials (QR / RFID / NFC / plate)
// may open or close a session. Entry mints a fresh per-occurrence session id (the
// ledger `identity`); a read with no open occurrence → ENTRY; with ≥1 open → EXIT the
// OLDEST open occurrence (FIFO). A fleet (maxConcurrent > 1) thus has several open
// occurrences at once; each read closes one. This decouples exit from the entry
// credential (you can enter with QR and leave with the card).
//
// NB: the SIGNED ledger payload still carries `permitId` (immutable history — see the
// schema note). The mutable master data / code is "subscription"; the on-chain field
// name is intentionally left as-is so historical events keep verifying.
// schema note); the per-occurrence `identity` is the session key. The mutable master
// data / code is "subscription"; the on-chain field name is left as-is so historical
// events keep verifying.
export interface SubscriptionMatch {
readonly subscriptionId: string;
/** The specific credential/plate value read — the per-car session key. */
/** The specific credential/plate value read (for logging/anomalies). NOT the
* session key — sessions are keyed by subscription occurrence, so a different
* credential of the same subscription can close the session it opened. */
readonly carKey: string;
readonly via: "card" | "qr" | "plate";
}
@@ -105,61 +112,76 @@ export class SubscriptionFlow {
return { accepted: false, reason };
}
// Direction: the car's open-session state is the natural verb (in→exit, out→entry).
// The barrier the car is at (resolved.direction) must AGREE — a car at an exit
// barrier that isn't inside (or at an entry barrier while already in) is a
// wrong-barrier / anti-passback signal, refused + logged. A "both" barrier follows
// the session state.
const carOpen = this.#carHasOpenSession(m.carKey);
const inferred: FlowDirection = carOpen ? "exit" : "entry";
if (resolved.direction !== "both" && resolved.direction !== inferred) {
const reason = `subscription wrong barrier — ${resolved.direction} barrier but car would ${inferred}`;
await this.#reject(m, reason);
return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason };
}
// Direction: the BARRIER the reader sits at decides the verb — an entry-lane read
// is an ENTRY, an exit-lane read is an EXIT. (The credential is decoupled from the
// session, so we can't and needn't infer from "which credential".) A "both" barrier
// has no physical side, so there we infer from state: open occurrence → exit, else
// entry. This is what lets a FLEET admit several cars (each entry-lane read is an
// entry) yet exit any of them with ANY credential (FIFO).
const open = this.#openOccurrences(m.subscriptionId);
const verb: FlowDirection =
resolved.direction === "entry"
? "entry"
: resolved.direction === "exit"
? "exit"
: open.length > 0
? "exit"
: "entry";
if (carOpen) {
// EXIT: this car is already inside → the read is its exit.
const source = m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand";
if (verb === "exit") {
// EXIT: close the OLDEST open occurrence (FIFO). Its occurrence id is the session
// key; the credential just read may differ from the one that opened it. If the
// subscription has NOTHING open, an exit read is a no-op anti-passback signal.
const oldest = open[0];
if (!oldest) {
const reason = "subscription exit with no open session (already out / never entered)";
await this.#reject(m, reason);
return { accepted: false, direction: "exit", reason };
}
const occurrenceId = oldest.identity;
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey,
// `permitId` is the on-chain field name (immutable) — carries the subscription id.
payload: { sessionRef: m.carKey, permitId: m.subscriptionId },
source,
identity: occurrenceId,
// `permitId` carries the subscription id; `via` records which credential left.
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, via: m.via },
});
await this.#open(resolved, "exit", m.carKey, "subscription exit");
this.#closeCache(m.carKey);
await this.#open(resolved, "exit", occurrenceId, "subscription exit");
this.#closeCache(occurrenceId);
return { accepted: true, direction: "exit" };
}
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open.
if (sub.maxConcurrent != null) {
const open = this.#subscriptionOpenCount(m.subscriptionId);
if (open >= sub.maxConcurrent) {
const reason = `subscription at capacity (${open}/${sub.maxConcurrent} cars in)`;
await this.#reject(m, reason);
return { accepted: false, direction: "entry", reason };
}
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. Mint a
// fresh per-occurrence id so a fleet can have several open at once.
if (sub.maxConcurrent != null && open.length >= sub.maxConcurrent) {
const reason = `subscription at capacity (${open.length}/${sub.maxConcurrent} cars in)`;
await this.#reject(m, reason);
return { accepted: false, direction: "entry", reason };
}
// A short, unique occurrence id. The subscription id is NOT embedded — it rides in
// the payload's `permitId` (which every fold matches on), so the key stays compact.
const occurrenceId = `SUBSESS-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey,
source,
identity: occurrenceId,
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
// `permitId`/`permit` are the on-chain field names (immutable).
payload: { sessionRef: m.carKey, permitId: m.subscriptionId, permit: true },
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, permit: true, via: m.via },
occurredAt: now,
});
await this.#open(resolved, "entry", m.carKey, "subscription entry");
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
try {
this.#db
.insert(sessions)
.values({
id: m.carKey,
identity: m.carKey,
id: occurrenceId,
identity: occurrenceId,
source: m.via === "plate" ? "lpr" : "wiegand",
subscriptionId: m.subscriptionId,
enteredAt: now,
@@ -167,38 +189,40 @@ export class SubscriptionFlow {
})
.run();
} catch (err) {
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`);
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
}
return { accepted: true, direction: "entry" };
}
/** Does this specific car (credential value) have an open session right now? */
#carHasOpenSession(carKey: string): boolean {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, carKey))
.orderBy(ledgerEvents.index)
.all();
const entries = rows.filter((r) => r.type === "vehicle_entry").length;
const exits = rows.filter((r) => r.type === "vehicle_exit").length;
return entries > exits;
}
/** How many of this subscription's cars are inside right now (fold over the ledger).
* The on-chain field is `permitId`, so we match against that. */
#subscriptionOpenCount(subscriptionId: string): number {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "vehicle_entry"))
.all()
.filter((r) => (r.payload as { permitId?: string } | null)?.permitId === subscriptionId);
let open = 0;
for (const entry of rows) {
if (!this.#carHasOpenSession(entry.identity ?? "")) continue;
open += 1;
/**
* The OPEN occurrences of a subscription right now, **oldest first** (FIFO) — a
* fold over the signed ledger. An occurrence is a `vehicle_entry` (whose
* `payload.permitId` is this subscription) with no later `vehicle_exit` on the same
* `identity`. Used to (a) infer entry vs. exit for ANY credential of the
* subscription, (b) pick which occurrence a read closes, and (c) enforce
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
*/
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
const net = new Map<string, number>();
const firstIndex = new Map<string, number>();
for (const r of rows) {
const id = r.identity;
if (!id) continue;
const pl = (r.payload ?? {}) as { permitId?: string };
if (r.type === "vehicle_entry") {
if (pl.permitId !== subscriptionId) continue;
net.set(id, (net.get(id) ?? 0) + 1);
if (!firstIndex.has(id)) firstIndex.set(id, r.index);
} else if (r.type === "vehicle_exit") {
if (!net.has(id)) continue; // not one of this subscription's occurrences
net.set(id, (net.get(id) ?? 0) - 1);
}
}
const open: { identity: string; index: number }[] = [];
for (const [id, n] of net) if (n > 0) open.push({ identity: id, index: firstIndex.get(id) ?? 0 });
open.sort((a, b) => a.index - b.index); // oldest first → FIFO
return open;
}