feat(booth): refusal snapshots, subscriber access medium, one-car-one-ticket entry
Three booth-integrity improvements that share the entry/exit flows and activity log. Refusal snapshots: previously only an accepted open captured a camera image; now every refusal/hold anomaly fires the directional camera too (a turned-away car is exactly the evidence wanted) — entry refused-full/held, exit refused closed/no-session/unpaid/grace-expired (booth + reader paths), refused subscription. A refused entry has no ticket id, so a synthetic REFUSED- ref keys the anomaly + photo together. Same fire-and-forget contract; failed captures still show as tiles. Subscriber access medium: the subscription flow already signed `via` (qr|card|plate) into entry/exit payloads; surface it as a typed LedgerPayload.via, a cyan chip in the ticker, and an "Entry medium" modal row (sq+en). Display-only. One car = one ticket: the entry button could be mashed to mint many tickets per car (corrupting occupancy + enabling ticket-shopping at exit) — the old #inFlight guard only blocked overlapping presses. Add a per-relay guard configured on the relay spec: PRESENCE mode (presenceInput ties ticketing to a vehicle loop on a Dingtian input — one ticket per car, re-armed when the loop clears) or COOLDOWN fallback (entryCooldownSec) when there's no barrier feedback. A suppressed press is unsigned device_events telemetry, not a signed anomaly. SetupWizard exposes both fields. Fail-closed entry and barrier-is-not-a-door invariants untouched; guard state is in-memory/rebuildable, starts armed after restart. Wiki: new entry-double-press; updated entry-exit-points, booth-console, index. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -98,8 +98,11 @@ export class SubscriptionFlow {
|
||||
}
|
||||
|
||||
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
|
||||
// The physical side the reader sits at — used to fire the right camera on a refusal
|
||||
// that happens BEFORE we infer the entry/exit verb ("both" defers to entry).
|
||||
const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry";
|
||||
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
|
||||
if (!sub) return { accepted: false, reason: await this.#reject(m, "sub.refused.notFound") };
|
||||
if (!sub) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
|
||||
|
||||
// Validity: active + within the coverage window.
|
||||
const now = new Date().toISOString();
|
||||
@@ -108,7 +111,7 @@ export class SubscriptionFlow {
|
||||
(sub.validFrom != null && now < sub.validFrom) ||
|
||||
(sub.validTo != null && now > sub.validTo);
|
||||
if (invalid) {
|
||||
const reason = await this.#reject(m, "sub.refused.outOfWindow", { status: sub.status });
|
||||
const reason = await this.#reject(m, lane, "sub.refused.outOfWindow", { status: sub.status });
|
||||
return { accepted: false, reason };
|
||||
}
|
||||
|
||||
@@ -136,7 +139,7 @@ export class SubscriptionFlow {
|
||||
// subscription has NOTHING open, an exit read is a no-op anti-passback signal.
|
||||
const oldest = open[0];
|
||||
if (!oldest) {
|
||||
const reason = await this.#reject(m, "sub.refused.noSession");
|
||||
const reason = await this.#reject(m, "exit", "sub.refused.noSession");
|
||||
return { accepted: false, direction: "exit", reason };
|
||||
}
|
||||
const occurrenceId = oldest.identity;
|
||||
@@ -156,7 +159,7 @@ export class SubscriptionFlow {
|
||||
// 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 = await this.#reject(m, "sub.refused.atCapacity", {
|
||||
const reason = await this.#reject(m, "entry", "sub.refused.atCapacity", {
|
||||
inUse: open.length,
|
||||
max: sub.maxConcurrent,
|
||||
});
|
||||
@@ -227,10 +230,14 @@ export class SubscriptionFlow {
|
||||
return open;
|
||||
}
|
||||
|
||||
/** Sign a refused-subscription anomaly with a localizable reason code, and return
|
||||
* the rendered English reason for the caller's ReadOutcome. */
|
||||
/** Sign a refused-subscription anomaly with a localizable reason code, fire the
|
||||
* directional evidence camera, and return the rendered English reason for the
|
||||
* caller's ReadOutcome. `dir` is the lane the refusal happened at (entry/exit) so
|
||||
* the right camera captures the turned-away subscriber. `via` records which
|
||||
* credential was presented. */
|
||||
async #reject(
|
||||
m: SubscriptionMatch,
|
||||
dir: FlowDirection,
|
||||
code: ReasonCode,
|
||||
params?: Record<string, string | number>,
|
||||
): Promise<string> {
|
||||
@@ -239,24 +246,28 @@ export class SubscriptionFlow {
|
||||
type: "anomaly",
|
||||
identity: m.carKey,
|
||||
// `permitId`/`permitRefused` are the on-chain field names (immutable).
|
||||
payload: { ...rp, permitId: m.subscriptionId, permitRefused: true },
|
||||
payload: { ...rp, permitId: m.subscriptionId, permitRefused: true, via: m.via },
|
||||
});
|
||||
this.#fireSnapshot(dir, m.carKey);
|
||||
this.#logger.warn(`subscription refused (${m.carKey}): ${rp.reason}`);
|
||||
return rp.reason;
|
||||
}
|
||||
|
||||
/** Fire the directional camera(s) for a refused-subscription event; never awaited
|
||||
* (evidence, not a gate). The accepted entry/exit paths snapshot inside #open. */
|
||||
#fireSnapshot(dir: FlowDirection, identity: string): void {
|
||||
void snapshotAsync({ db: this.#db, direction: dir, identity, logger: this.#logger }).catch((err) =>
|
||||
this.#logger.error(`subscription snapshot error: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
if (access) await access.pulseOpen(resolved.relay);
|
||||
else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`);
|
||||
|
||||
// SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate).
|
||||
void snapshotAsync({
|
||||
db: this.#db,
|
||||
direction: dir,
|
||||
identity: carKey,
|
||||
logger: this.#logger,
|
||||
}).catch((err) => this.#logger.error(`subscription snapshot error: ${(err as Error).message}`));
|
||||
this.#fireSnapshot(dir, carKey);
|
||||
}
|
||||
|
||||
#closeCache(carKey: string): void {
|
||||
|
||||
Reference in New Issue
Block a user