feat(entry): operator-issued entry + exit plate-swap reconciliation
Build desktop / desktop (push) Successful in 4m29s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 37s

Two halves of one anti-fraud design.

(A) Operator-issued entry — when the physical entry button is broken, an
operator can issue an entry ticket so a real car isn't blocked out of the lot.
This hands the operator-adversary a mint, so it is:
  - PRESENCE-GATED like the physical button: a real car must be present (radar/
    loop AND camera busy). Enforced BOTH sides — the server re-checks current
    presence so a direct POST can't bypass a disabled button; no presence loop
    => feature unavailable; a no-presence attempt signs an anomaly.
  - FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
    companion entry.operatorIssued anomaly (the adversary path always leaves a
    red-flag row).
  - capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap
    a legit car).
  New session:create permission (migration 0019 -> operator role, admin-
  revocable), POST /api/entry/issue (open-shift gated), EntryFlow.
  issueForOperator; the fraud-critical print->sign->open->snapshot sequence is
  factored into one shared #issueTicket (button + operator). UI: the entry
  BarrierLight becomes a clickable issue-control when presence+permission+shift
  meet (confirm -> issue).

(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
  - BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
    pay/exit modal shows a red warning + "Override & release" (override signs an
    attributed exit.plateSwapOverride). Flag+override, never a silent hard block
    (exit fails-open; a plate is never the sole gate).
  - READER path (no operator): log-only anomaly + fail-open.
  Extended BoothExitResult + /api/exit (override); boothExit client returns a
  structured swap result.

Verified: full monorepo build/lint/test green (229 server tests incl. 4 new:
hold-on-swap, override-releases-with-attribution, low-confidence-no-warning,
own-plate-no-warning). New wiki: operator-issued-entry.md +
plate-reconciliation.md; cross-linked from entry-exit-points, capacity-
occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never
TRAPS a car alone either."

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-01 12:17:52 +02:00
parent 114a32e6f2
commit 33c4ea1e91
20 changed files with 760 additions and 62 deletions
+114 -17
View File
@@ -15,7 +15,7 @@ import type { FastifyBaseLogger } from "fastify";
import type { DeviceInputEvent } from "./device-events.js";
import { getOccupancy } from "./occupancy.js";
import type { EventLog } from "./event-log.js";
import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import type { VisionClient } from "./vision-client.js";
@@ -229,9 +229,29 @@ export class EntryFlow {
return;
}
await this.#issueTicket(resolved, { source: "ticket" });
}
/**
* The shared "issue a transient ticket" sequence used by BOTH the physical button
* (#runEntry) and the operator-initiated path (issueForOperator) — ONE copy of the
* fraud-critical ordering (print → sign vehicle_entry BEFORE open → open → snapshot →
* cache), never a divergent second copy. `opts.source` is "ticket" (button) or "booth"
* (operator). For an operator mint we stamp `operatorInitiated` + `operator` on the
* signed entry AND append a companion `anomaly` (the operator-adversary path always
* leaves a red-flag row); `overCapacity` records a full-lot override. Returns the
* outcome so the operator route can report it. See wiki/concepts/operator-issued-entry.md.
*/
async #issueTicket(
resolved: ResolvedRelay,
opts: { source: "ticket" | "manual"; operator?: string; overCapacity?: { count: number; capacity: number | null } },
): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> {
const ticketId = newTicketId();
const issuedAt = new Date().toISOString();
const printers = this.#loadPrinters();
// Operator mint = ledger source "manual" (human intervention, like the barrier re-open)
// + operatorInitiated:true in the payload. The button path is source "ticket".
const operatorInitiated = opts.source === "manual";
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
@@ -260,17 +280,14 @@ export class EntryFlow {
// Capture who is held at the barrier (evidence for the operator handling the car).
this.#fireSnapshot("entry", ticketId);
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
return;
return { ok: false, reason };
}
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
// `category` is FROZEN here (in the signed payload) so the tariff prices and
// later reprices the same way at exit. Today every transient takes the SITE
// default category (operator policy, site_config.default_vehicle_category;
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
// is the future seam — source it from `resolved` then. A V1/no-category tariff
// ignores it; only V2 category cards consult it.
// falls back to the shared DEFAULT_VEHICLE_CATEGORY).
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const category =
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
@@ -279,33 +296,113 @@ export class EntryFlow {
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source: "ticket",
source: opts.source,
identity: ticketId,
payload: { sessionRef: ticketId, ticketPrinted: true, category },
payload: {
sessionRef: ticketId,
ticketPrinted: true,
category,
...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}),
...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}),
},
occurredAt: issuedAt,
});
// 2b. For an operator mint, append a companion ANOMALY — the operator-adversary path
// always leaves a red-flag row in the tamper-evident record for reconciliation.
if (operatorInitiated) {
await this.#log.append({
type: "anomaly",
identity: ticketId,
payload: {
...reasonPayload("entry.operatorIssued", { operator: opts.operator ?? "?" }),
source: "booth",
operatorInitiated: true,
...(opts.operator ? { operator: opts.operator } : {}),
...(opts.overCapacity ? { lotFull: true } : {}),
},
});
}
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
let opened = false;
if (access) {
await access.pulseOpen(resolved.relay);
opened = true;
} else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
// a camera failure must not delay or block the already-open barrier).
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate; a
// camera failure must not delay or block the already-open barrier). This is ALSO
// what records the plate that plate-reconciliation reads at exit.
this.#fireSnapshot("entry", ticketId);
// 4. Update the session projection cache (rebuildable from the ledger; this is
// just a fast read-model, never the source of truth).
// 4. Update the session projection cache (rebuildable from the ledger; a read-model).
try {
this.#db
.insert(sessions)
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
.values({ id: ticketId, identity: ticketId, source: opts.source, enteredAt: issuedAt, state: "open" })
.run();
} catch (err) {
// Cache miss is non-fatal — the ledger is authoritative and the projection
// can be rebuilt. Log it; don't fail the (already-open) entry.
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
}
return { ok: true, ticketId, opened };
}
/**
* OPERATOR-ISSUED entry (physical entry button broken). Gated exactly like the button:
* a REAL vehicle must be present at the entry — BOTH radar/loop presence AND camera
* confirmation. `cameraBusy` is the current LaneStatus.entry (passed by the route); loop
* presence is this flow's own per-relay guard state. If a site has no presence loop the
* feature is unavailable (we require both — no weaker fallback). Refuses (+ signs an
* anomaly) when no vehicle is present, so probing the endpoint is itself recorded. Over
* capacity is ALLOWED but flagged (a broken button mustn't trap a legit car). The mint
* itself is flagged (source:"booth" + operatorInitiated + a companion anomaly).
* See wiki/concepts/operator-issued-entry.md.
*/
async issueForOperator(operator: string, cameraBusy: boolean): Promise<
{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean } | { ok: false; reason: string }
> {
const resolved = firstRelayByDirection(this.#db, "entry");
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
// PRESENCE GATE — require BOTH a presence loop (configured + currently occupied) AND
// the camera confirming a vehicle. No loop configured → feature unavailable here.
if (typeof resolved.presenceInput !== "number") {
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable" };
}
const present = this.#guardState(resolved).present;
if (!present || !cameraBusy) {
await this.#log.append({
type: "anomaly",
identity: `ENTRY-ATTEMPT-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
payload: {
...reasonPayload("entry.issue.noPresence", { operator }),
source: "booth",
operator,
radarPresent: present,
cameraBusy,
},
});
this.#logger.warn(`operator entry refused by ${operator}: no vehicle present (radar=${present}, camera=${cameraBusy})`);
return { ok: false, reason: "no vehicle detected at the entry" };
}
const key = `operator-issue:${this.#relayKey(resolved)}`;
if (this.#inFlight.has(key)) return { ok: false, reason: "an entry is already in progress" };
this.#inFlight.add(key);
try {
const occ = getOccupancy(this.#db);
const res = await this.#issueTicket(resolved, {
source: "manual",
operator,
...(occ.full ? { overCapacity: { count: occ.count, capacity: occ.capacity ?? null } } : {}),
});
if (!res.ok) return res;
return { ok: true, ticketId: res.ticketId, opened: res.opened, overCapacity: occ.full };
} finally {
this.#inFlight.delete(key);
}
}
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
+75 -1
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, eq, type Db } from "@parking/db";
import { ledgerEvents, deviceEvents as deviceEventsTable, sessions as sessionsTable, eq, type Db } from "@parking/db";
import { randomUUID } from "node:crypto";
import { ExitFlow } from "./exit-flow.js";
import { PayStation } from "./pay-station.js";
import type { EventLog } from "./event-log.js";
@@ -33,6 +34,19 @@ async function enter(identity: string, enteredAt: string, payload?: Record<strin
function exitsSigned(identity: string) {
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
}
function anomalies(reason?: string) {
return db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all()
.filter((r) => !reason || (r.payload as { reason?: string } | null)?.reason?.includes(reason));
}
/** Seed the projection-cache open-session row + an ANPR plate read (device_events) so the
* plate-reconciliation check can see this identity's plate against open sessions. */
function seedOpenWithPlate(identity: string, plate: string, confidence: number, enteredAt: string) {
db.insert(sessionsTable).values({ id: identity, identity, source: "ticket", enteredAt, state: "open" }).run();
db.insert(deviceEventsTable).values({
id: randomUUID(), deviceId: "cam-entry", category: "camera", kind: "read", occurredAt: enteredAt,
detail: { identity, direction: "entry", plate, confidence },
}).run();
}
describe("exitForBooth — refusal gates", () => {
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
@@ -116,3 +130,63 @@ describe("reopenBarrier — no unpaid re-open", () => {
expect(exitsSigned("T1")).toHaveLength(1);
});
});
describe("exitForBooth — plate-swap reconciliation (ticket-swap fraud)", () => {
// The fraud: a paid car is let out on a fresh $0 ticket while the original lingers "inside".
// The plate is the invariant — the exiting car's plate is already open under the old ticket.
it("HOLDS a paid exit when the plate is already open under a DIFFERENT ticket", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
// Original car entered on 1234, plate AA123BB, still open (never paid/exited).
await enter("1234", minutesAgo(120));
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
// A fresh ticket 1237 (same physical car, same plate) is paid and tries to exit.
await enter("1237", minutesAgo(1));
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
await pay.pay("1237", "cash");
const r = await exit.exitForBooth("1237");
expect(r).toMatchObject({ ok: false, status: "swap_suspected", plate: "AA123BB", otherIdentity: "1234" });
expect(exitsSigned("1237")).toHaveLength(0); // NOT let out
expect(anomalies("plate AA123BB is already inside").length).toBeGreaterThanOrEqual(1);
});
it("RELEASES on explicit operator override + signs an attributed override anomaly", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("1234", minutesAgo(120));
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
await enter("1237", minutesAgo(1));
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
await pay.pay("1237", "cash");
const r = await exit.exitForBooth("1237", { override: true, operator: "op1" });
expect(r.ok).toBe(true);
expect(exitsSigned("1237")).toHaveLength(1); // released
const ov = anomalies("released a suspected ticket-swap");
expect(ov.length).toBe(1);
expect((ov[0].payload as { operator?: string }).operator).toBe("op1");
});
it("does NOT warn on a LOW-confidence plate read (advisory, never a gate)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("1234", minutesAgo(120));
seedOpenWithPlate("1234", "AA123BB", 0.5, minutesAgo(120)); // low conf
await enter("1237", minutesAgo(1));
seedOpenWithPlate("1237", "AA123BB", 0.5, minutesAgo(1)); // low conf
await pay.pay("1237", "cash");
const r = await exit.exitForBooth("1237");
expect(r.ok).toBe(true); // no warning — exits normally
expect(exitsSigned("1237")).toHaveLength(1);
});
it("does NOT warn a normal exit whose OWN plate is only open under its OWN ticket", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("1237", minutesAgo(90));
seedOpenWithPlate("1237", "AA999ZZ", 0.99, minutesAgo(90));
await pay.pay("1237", "cash");
const r = await exit.exitForBooth("1237");
expect(r.ok).toBe(true); // its own plate under its own ticket is not a swap
expect(exitsSigned("1237")).toHaveLength(1);
});
});
+102 -1
View File
@@ -1,6 +1,7 @@
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
import { snapshotAsync } from "./snapshot.js";
import type { VisionClient } from "./vision-client.js";
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
@@ -47,6 +48,11 @@ interface SessionView {
* the barrier didn't open (payment stands; operator opens manually). */
export type BoothExitResult =
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
// PLATE-SWAP suspected: the exiting car's plate is already OPEN under a DIFFERENT ticket
// (possible ticket-swap fraud / mixed-up tickets). Not opened — the operator must review
// and either resolve the tickets or consciously OVERRIDE (re-submit with override:true).
// See wiki/concepts/plate-reconciliation.md.
| { ok: false; status: "swap_suspected"; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }
| { ok: true; opened: true }
| { ok: true; opened: false; reason: string };
@@ -57,6 +63,11 @@ export type BoothReopenResult =
| { ok: false; reason: string }
| { ok: true; opened: boolean; reason?: string };
/** Minimum ANPR confidence for a plate to participate in swap reconciliation, both for the
* exiting read and the matched open session's entry read. Below this, the read is advisory-
* only and never triggers a swap warning (a fuzzy read must not block a legit car). */
const PLATE_MATCH_MIN_CONFIDENCE = 0.85;
export class ExitFlow {
readonly #db: Db;
readonly #log: EventLog;
@@ -88,7 +99,7 @@ export class ExitFlow {
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
* operator opens manually. Payment is never rolled back.
*/
async exitForBooth(identity: string): Promise<BoothExitResult> {
async exitForBooth(identity: string, opts?: { override?: boolean; operator?: string }): Promise<BoothExitResult> {
const id = identity.trim();
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
@@ -122,6 +133,40 @@ export class ExitFlow {
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
}
// PLATE-SWAP CHECK — after the money/grace validation, before we sign the exit. If
// the plate is already open under a DIFFERENT ticket, HOLD for the operator to review
// (unless they consciously override). A denial here never traps the car — exit fails
// open and the operator can override; the anomaly is the control either way.
const swap = this.#reconcilePlateAtExit(id);
if (swap) {
if (!opts?.override) {
// Sign the SUSPICION even if the operator walks away (tamper-evident record).
const rp = reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity });
await this.#log.append({
type: "anomaly",
identity: id,
payload: { ...rp, source: "booth", plateSwapSuspected: true, plate: swap.plate, otherIdentity: swap.otherIdentity },
});
this.#fireExitSnapshot(id);
this.#logger.warn(`booth exit HELD (${id}): plate ${swap.plate} already open under ${swap.otherIdentity}`);
return { ok: false, status: "swap_suspected", reason: rp.reason, plate: swap.plate, otherIdentity: swap.otherIdentity, otherEnteredAt: swap.otherEnteredAt };
}
// OVERRIDE: the operator consciously releases it. Sign the override (attributed).
await this.#log.append({
type: "anomaly",
identity: id,
payload: {
...reasonPayload("exit.plateSwapOverride", { operator: opts.operator ?? "?", plate: swap.plate, otherIdentity: swap.otherIdentity }),
source: "booth",
plateSwapOverride: true,
plate: swap.plate,
otherIdentity: swap.otherIdentity,
...(opts.operator ? { operator: opts.operator } : {}),
},
});
this.#logger.warn(`booth exit OVERRIDE (${id}) by ${opts.operator ?? "?"}: plate-swap released (${swap.plate}, also open under ${swap.otherIdentity})`);
}
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
// reader path does.
if (freeGrace && view.freeGrace) {
@@ -336,6 +381,25 @@ export class ExitFlow {
return { accepted: false, direction: "exit", reason: rp.reason };
}
// PLATE-SWAP (reader path): detect + LOG, but FAIL OPEN. There's no operator at an
// automated lane to make the override decision, and exit fails open for safety, so we
// sign the suspicion anomaly (the control here) and still let the car out. The booth
// path (operator-mediated) is where the hold + override lives.
const swap = this.#reconcilePlateAtExit(e.value);
if (swap) {
await this.#log.append({
type: "anomaly",
identity: e.value,
payload: {
...reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity }),
plateSwapSuspected: true,
plate: swap.plate,
otherIdentity: swap.otherIdentity,
},
});
this.#logger.warn(`reader exit: plate ${swap.plate} already open under ${swap.otherIdentity} (${e.value}) — logged, fail-open`);
}
// Valid (a real payment within walk-back grace): sign + open.
return this.#signExitAndOpen(resolved, e);
}
@@ -406,6 +470,43 @@ export class ExitFlow {
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
}
/**
* PLATE-SWAP reconciliation. The car's PLATE is the invariant a ticket-swap can't hide:
* if this exiting ticket's plate is already OPEN under a DIFFERENT ticket, someone let a
* paid car out on a fresh $0 ticket while the original lingers "inside" (occupancy fraud),
* or two tickets were mixed up. We compare the EXITING plate against every open session's
* ENTRY plate, EXACT normalized match, HIGH-CONFIDENCE reads only (a fuzzy/absent read is
* advisory — never a gate, so it can't trap a legit car). Returns the matched open session
* or null. See wiki/concepts/plate-reconciliation.md.
*/
#reconcilePlateAtExit(exitingId: string): { plate: string; otherIdentity: string; otherEnteredAt: string | null } | null {
// The exiting car's plate: prefer its own exit read, else its entry read.
const mine = plateForIdentity(this.#db, exitingId);
if (!mine || !mine.plate || (mine.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) return null;
const wanted = mine.plate.trim().toUpperCase();
// All currently-open sessions (from the projection cache — a fast read-model; the check
// is advisory so a slightly-stale cache is acceptable), excluding this ticket.
const openIds = this.#db
.select({ id: sessions.id })
.from(sessions)
.where(eq(sessions.state, "open"))
.all()
.map((r) => r.id)
.filter((id) => id !== exitingId);
if (openIds.length === 0) return null;
const plates = platesForIdentities(this.#db, openIds);
for (const [otherId, pv] of plates) {
if ((pv.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) continue;
if (pv.plate.trim().toUpperCase() !== wanted) continue;
// A high-confidence exact match under a DIFFERENT open ticket → swap suspected.
const enteredAt = this.#db.select({ enteredAt: sessions.enteredAt }).from(sessions).where(eq(sessions.id, otherId)).get()?.enteredAt ?? null;
return { plate: wanted, otherIdentity: otherId, otherEnteredAt: enteredAt };
}
return null;
}
/** Fold the signed ledger into a session view for one identity (authoritative). */
#sessionFor(identity: string): SessionView | null {
const rows = this.#db
+35
View File
@@ -0,0 +1,35 @@
import type { FastifyInstance } from "fastify";
import { requirePermission } from "../auth.js";
import type { EntryFlow } from "../entry-flow.js";
import type { LaneStatus } from "../lane-status.js";
import type { ShiftService } from "../shift-service.js";
import { NoShiftOpenError } from "../shift-service.js";
// Operator-issued entry (2026-07-01). When the physical entry button is broken, an operator
// may issue an entry ticket — a FLAGGED mint (vehicle_entry source=manual + operatorInitiated
// + a companion anomaly), gated EXACTLY like the physical button: a real vehicle must be
// present (radar/loop AND camera). The presence gate is enforced HERE (server-side), so a
// direct POST can't bypass a disabled UI button. Money-adjacent → requires an open shift.
// See wiki/concepts/operator-issued-entry.md.
export async function entryRoutes(
app: FastifyInstance,
entryFlow: EntryFlow,
laneStatus: LaneStatus,
shift: ShiftService,
): Promise<void> {
const guard = requirePermission("session:create");
app.post("/api/entry/issue", { preHandler: guard }, async (req, reply) => {
// Gate on an open shift (a minted entry belongs to an accountable operator).
if (!shift.currentOpenShift()) {
return reply.code(409).send({ error: new NoShiftOpenError().message });
}
// The camera side of the presence gate = the live entry lane-busy state; the radar/loop
// side is checked inside the flow (its per-relay presence guard).
const cameraBusy = laneStatus.snapshot().entry;
const res = await entryFlow.issueForOperator(req.user.username, cameraBusy);
if (!res.ok) return reply.code(409).send({ error: res.reason });
return res;
});
}
+14 -2
View File
@@ -30,6 +30,9 @@ interface PayBody {
}
interface ExitBody {
identity: string;
/** Operator consciously releases a suspected plate-swap exit (re-submit after the
* first call returned status "swap_suspected"). Signs an attributed override anomaly. */
override?: boolean;
}
interface VoucherBody {
identity: string;
@@ -109,8 +112,17 @@ export async function payRoutes(
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const res = await exitFlow.exitForBooth(identity);
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
const res = await exitFlow.exitForBooth(identity, {
override: req.body?.override === true,
operator: req.user?.username,
});
// A suspected plate-swap returns the full detail so the modal can warn + offer override.
if (!res.ok) {
if (res.status === "swap_suspected") {
return reply.code(409).send({ error: res.reason, status: res.status, plate: res.plate, otherIdentity: res.otherIdentity, otherEnteredAt: res.otherEnteredAt });
}
return reply.code(409).send({ error: res.reason, status: res.status });
}
return reply.code(200).send(res);
},
);
+3
View File
@@ -43,6 +43,7 @@ import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
import { qrReaderRoutes } from "./routes/qr-reader.js";
import { shiftRoutes } from "./routes/shift.js";
import { drawerRoutes } from "./routes/drawer.js";
import { entryRoutes } from "./routes/entry.js";
import { siteRoutes } from "./routes/site.js";
import { snapshotRoutes } from "./routes/snapshots.js";
import { tariffRoutes } from "./routes/tariffs.js";
@@ -270,6 +271,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
await shiftRoutes(app, shiftService);
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
await drawerRoutes(app, shiftService);
// Operator-issued entry (broken physical button) — flagged mint, presence-gated.
await entryRoutes(app, entryFlow, laneStatus, shiftService);
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.