diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index a818c60..c64a877 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -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). diff --git a/apps/server/src/exit-flow.test.ts b/apps/server/src/exit-flow.test.ts index bc01b5e..3ca93c0 100644 --- a/apps/server/src/exit-flow.test.ts +++ b/apps/server/src/exit-flow.test.ts @@ -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 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); + }); +}); diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index 24f6bd3..ee0f5fa 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -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 { + async exitForBooth(identity: string, opts?: { override?: boolean; operator?: string }): Promise { 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 diff --git a/apps/server/src/routes/entry.ts b/apps/server/src/routes/entry.ts new file mode 100644 index 0000000..da40d77 --- /dev/null +++ b/apps/server/src/routes/entry.ts @@ -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 { + 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; + }); +} diff --git a/apps/server/src/routes/pay.ts b/apps/server/src/routes/pay.ts index c7adc85..2489b02 100644 --- a/apps/server/src/routes/pay.ts +++ b/apps/server/src/routes/pay.ts @@ -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); }, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 48c934f..44a7b89 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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(null); const [voidReason, setVoidReason] = useState(""); const s: SessionLookup | undefined = session.data; @@ -178,7 +181,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose } } - async function handlePayAndExit() { + async function handlePayAndExit(override = false) { if (!s) return; setError(null); try { @@ -186,7 +189,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose // session is "already paid" but a new period accrued — we still charge (canPay // is true). A settled within-grace session is not payable (canPay false) and is // skipped. The server re-quotes authoritatively (overstay → from grace-expiry). - if (canPay) { + // On an OVERRIDE re-submit the payment already happened; don't double-charge. + if (canPay && !override) { setPhase("paying"); await paySession(identity, tender); } @@ -197,7 +201,14 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose const r = await printVoucher(identity); setResult(t("pay.voucherPrinted", { printer: r.printedBy })); } else { - const r = await boothExit(identity); + const r = await boothExit(identity, override); + // PLATE-SWAP suspected → don't exit; surface the warning + offer an override. + if (!r.ok) { + setSwap({ plate: r.plate, otherIdentity: r.otherIdentity, otherEnteredAt: r.otherEnteredAt }); + setPhase("review"); + return; + } + setSwap(null); // No voucher → auto-print a standalone payment receipt for transparency. // Best-effort: a printer fault must NOT block the exit that already happened; // the operator can reprint from the done screen. @@ -493,6 +504,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose )} + {/* PLATE-SWAP warning: the exiting plate is already inside under another + ticket. A prominent, deliberate hold — the operator must consciously + override to release. See wiki/concepts/plate-reconciliation.md. */} + {swap && ( +
+
+ {t("pay.swapTitle")} +
+
+ {t("pay.swapBody", { + plate: swap.plate, + other: swap.otherIdentity, + when: swap.otherEnteredAt ? formatRelativeDateTime(swap.otherEnteredAt, t) : "—", + })} +
+
{t("pay.swapHint")}
+
+ )} + {error &&
{error}
} {result && (
{result}
@@ -602,26 +632,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose {t("pay.cancelTicket")} )} - + ) : ( + + ? t("pay.printingVoucher") + : t("pay.opening") + : alreadyPaid + ? voucher + ? t("pay.printVoucher") + : t("pay.openBarrier") + : voucher + ? t("pay.payAndVoucher") + : t("pay.payAndOpen")} + + )} )} diff --git a/apps/web/src/BoothScreen.tsx b/apps/web/src/BoothScreen.tsx index b43e139..f9685da 100644 --- a/apps/web/src/BoothScreen.tsx +++ b/apps/web/src/BoothScreen.tsx @@ -1,7 +1,8 @@ import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; -import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { can, fetchEvents, fetchOccupancy, issueEntryTicket, type LedgerEvent, type Occupancy } from "./api.js"; +import { rootRoute } from "./router.js"; import { qk } from "./lib/query.js"; import { useLiveStore } from "./lib/live-store.js"; import { useShift } from "./lib/use-shift.js"; @@ -116,16 +117,40 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) { * - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed" * - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity * - otherwise → SOLID green: free - * Advisory only; it gates nothing. The blink uses the `.lane-blink` keyframe (index.css), - * whose children inherit the alternating colour via `currentColor`. */ -function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; radar: boolean }) { + * Advisory only; it gates nothing. On the ENTRY light, when the operator holds `session:create` + * and BOTH presence conditions meet (radar present AND camera busy = a real car at the entry), + * the light becomes a CLICKABLE issue-ticket control (broken physical button). Same presence + * rule as the physical button; the server re-checks it. See operator-issued-entry.md. */ +function BarrierLight({ + label, + busy, + radar, + onIssue, + issuing, +}: { + label: string; + busy: boolean; + radar: boolean; + /** When set (entry light + permission), clicking issues an entry ticket — only enabled + * when both presence conditions meet (radar && busy). */ + onIssue?: () => void; + issuing?: boolean; +}) { + const { t } = useTranslation(); // Blink only when the radar sees something the camera hasn't confirmed. const blinking = radar && !busy; const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green"; + // The issue control is active only with a REAL car present (radar AND camera). + const canIssue = !!onIssue && radar && busy && !issuing; + const clickable = !!onIssue && radar && busy; return (
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */} @@ -135,22 +160,58 @@ function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; ra
{label}
-
{busy ? "●" : blinking ? "◐" : "○"}
+
+ {issuing ? "…" : clickable ? t("booth.issueEntry") : busy ? "●" : blinking ? "◐" : "○"} +
); } /** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free) - * and lane-presence (radar). */ + * and lane-presence (radar). The ENTRY light doubles as an operator issue-ticket control when + * the physical button is broken (permission + presence gated). */ function LaneIndicators() { const { t } = useTranslation(); const lanes = useLiveStore((s) => s.lanes); const radar = useLiveStore((s) => s.radar); + const { user } = rootRoute.useRouteContext(); + const { isOpen: shiftOpen, isMine } = useShift(); + const qc = useQueryClient(); + const canIssue = can(user, "session:create") && shiftOpen && isMine; + const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); + + const issue = useMutation({ + mutationFn: issueEntryTicket, + onSuccess: (r) => { + setMsg({ ok: true, text: t("booth.issueEntryOk", { ticket: r.ticketId }) }); + void qc.invalidateQueries({ queryKey: qk.events }); + void qc.invalidateQueries({ queryKey: qk.occupancy }); + setTimeout(() => setMsg(null), 4000); + }, + onError: (e) => { + setMsg({ ok: false, text: (e as Error).message }); + setTimeout(() => setMsg(null), 4000); + }, + }); + + function onIssue() { + if (window.confirm(t("booth.issueEntryConfirm"))) issue.mutate(); + } + return (
- + + {msg && ( + {msg.text} + )}
); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 91580fc..6a57731 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -30,7 +30,7 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise } const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" }); if (!res.ok) { - const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[] }; + const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown }; const error = msg.error ?? `${path}: ${res.status}`; // Ship the failed request to the backend log store (best-effort, loop-safe — the // logger itself never logs the /api/logs call). 401s are normal pre-login churn, @@ -38,7 +38,7 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise if (res.status !== 401) { logFailedRequest({ path, method, status: res.status, error }); } - throw new ApiError(error, res.status, msg.problems); + throw new ApiError(error, res.status, msg.problems, msg); } if (res.status === 204) return undefined as T; return res.json() as Promise; @@ -50,6 +50,9 @@ export class ApiError extends Error { readonly status: number, /** Field-level problems from a validation error (e.g. tariff publish), if any. */ readonly problems?: string[], + /** The full parsed error body, for callers that need extra fields (e.g. a booth + * exit's plate-swap detail: { status, plate, otherIdentity, otherEnteredAt }). */ + readonly body?: Record, ) { super(message); } @@ -1284,12 +1287,43 @@ export function voidTicket(identity: string, reason: string): Promise<{ ok: bool } /** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't - * open (payment stands; operator opens manually). */ -export type BoothExitResult = { ok: true; opened: boolean; reason?: string }; + * open (payment stands; operator opens manually). `swapSuspected` = the exiting car's + * plate is already inside under a DIFFERENT ticket (possible ticket-swap); the operator + * must review and re-call with override:true to release. See plate-reconciliation.md. */ +export type BoothExitResult = + | { ok: true; opened: boolean; reason?: string } + | { ok: false; swapSuspected: true; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }; -/** Validate + open the barrier for a session from the booth (when near the exit). */ -export function boothExit(identity: string): Promise { - return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) }); +/** Validate + open the barrier for a session from the booth (when near the exit). + * Pass override:true to consciously release a suspected plate-swap exit. */ +export async function boothExit(identity: string, override = false): Promise { + try { + return await apiFetch<{ ok: true; opened: boolean; reason?: string }>("/api/exit", { + method: "POST", + body: JSON.stringify({ identity, ...(override ? { override: true } : {}) }), + }); + } catch (e) { + // A suspected plate-swap comes back 409 with status:"swap_suspected" + detail — surface + // it as a structured result (not a thrown error) so the modal can warn + offer override. + if (e instanceof ApiError && e.body?.status === "swap_suspected") { + const b = e.body; + return { + ok: false, + swapSuspected: true, + reason: String(b.error ?? ""), + plate: String(b.plate ?? ""), + otherIdentity: String(b.otherIdentity ?? ""), + otherEnteredAt: (b.otherEnteredAt as string | null) ?? null, + }; + } + throw e; + } +} + +/** Operator issues an entry ticket when the physical button is broken. A FLAGGED mint, + * server-gated on real vehicle presence (radar + camera). Returns the new ticket id. */ +export function issueEntryTicket(): Promise<{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean }> { + return apiFetch("/api/entry/issue", { method: "POST", body: JSON.stringify({}) }); } /** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail, diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index c8ac358..61ae09d 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -191,6 +191,10 @@ export const en: Catalog = { fEvtAnomaly: "Anomaly", openPayExit: "Open pay / exit", openReopenBarrier: "Open — paid, awaiting barrier", + issueEntry: "Issue ticket", + issueEntryTitle: "Issue an entry ticket & open the barrier (physical button broken)", + issueEntryConfirm: "A vehicle is at the entry. Issue an entry ticket and open the barrier?", + issueEntryOk: "Entry ticket {{ticket}} issued.", exitedGrace: "exited · grace", exitedGraceLeft: "exited · {{time}}", exitedGraceTitle: "Paid and exited — barrier not confirmed; waiting out the grace period.", @@ -275,6 +279,8 @@ export const en: Catalog = { reason: { "entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})", "entry.held.noTicket": "Entry held — ticket not printed: {{detail}}", + "entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)", + "entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry", "exit.refused.closed": "Exit refused — session already closed", "exit.refused.noSession": "Exit refused — unknown ticket", "exit.refused.unpaid": "Exit refused — not paid (take payment first)", @@ -284,6 +290,8 @@ export const en: Catalog = { "exit.open.failed": "Exit recorded, but the barrier did not open — open manually", "exit.freeGrace": "Free entry-grace (no charge)", "exit.manualOpen": "Manual barrier open (human intervention)", + "exit.plateSwapSuspected": "Possible ticket swap — plate {{plate}} is already inside under ticket {{otherIdentity}}", + "exit.plateSwapOverride": "Operator {{operator}} released a suspected ticket-swap exit (plate {{plate}}, also open under {{otherIdentity}})", "sub.refused.notFound": "Subscription refused — not found", "sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window", "sub.refused.noSession": "Subscription exit with no open session (already out / never entered)", @@ -944,6 +952,10 @@ export const en: Catalog = { lookingUp: "looking up…", paidBarrierOpened: "Paid — barrier opened. Car may exit.", paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.", + swapTitle: "Possible ticket swap", + swapBody: "Plate {{plate}} is already inside under ticket {{other}} (entered {{when}}). This car may be exiting on a different ticket than it entered on.", + swapHint: "Verify the vehicle before releasing. Overriding is recorded against you.", + swapOverride: "Override & release", subscription: "SUBSCRIPTION", plan: "Plan", prepaid: "PREPAID", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index c8ed5d4..a571331 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -193,6 +193,10 @@ export const sq = { fEvtAnomaly: "Anomali", openPayExit: "Hap pagesën / daljen", openReopenBarrier: "Hap — paguar, pret barrierën", + issueEntry: "Lësho biletë", + issueEntryTitle: "Lësho një biletë hyrjeje & hap barrierën (butoni fizik i prishur)", + issueEntryConfirm: "Një automjet është te hyrja. Të lëshohet një biletë hyrjeje dhe të hapet barriera?", + issueEntryOk: "Bileta e hyrjes {{ticket}} u lëshua.", exitedGrace: "doli · në afat", exitedGraceLeft: "doli · {{time}}", exitedGraceTitle: "Paguar dhe dalur — barriera nuk u konfirmua; po pret afatin kohor.", @@ -278,6 +282,8 @@ export const sq = { reason: { "entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})", "entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}", + "entry.operatorIssued": "Biletë hyrjeje e lëshuar nga operatori {{operator}} (butoni fizik i prishur)", + "entry.issue.noPresence": "Hyrja nga operatori u refuzua — asnjë automjet te hyrja", "exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë", "exit.refused.noSession": "Dalja u refuzua — biletë e panjohur", "exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)", @@ -287,6 +293,8 @@ export const sq = { "exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape manualisht", "exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)", "exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)", + "exit.plateSwapSuspected": "Mundësi ndërrimi biletash — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}", + "exit.plateSwapOverride": "Operatori {{operator}} lëshoi një dalje me dyshim ndërrimi biletash (targa {{plate}}, edhe e hapur me {{otherIdentity}})", "sub.refused.notFound": "Abonimi u refuzua — nuk u gjet", "sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit", "sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)", @@ -960,6 +968,10 @@ export const sq = { lookingUp: "Duke kërkuar…", paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.", paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.", + swapTitle: "Mundësi ndërrimi biletash", + swapBody: "Targa {{plate}} është tashmë brenda me biletën {{other}} (hyri {{when}}). Ky automjet mund të jetë duke dalë me një biletë tjetër nga ajo me të cilën hyri.", + swapHint: "Verifiko automjetin para se ta lëshosh. Anashkalimi regjistrohet në emrin tënd.", + swapOverride: "Anashkalo & lësho", subscription: "ABONIM", plan: "Plani", prepaid: "I PARAPAGUAR", diff --git a/packages/db/drizzle/0019_operator_session_create.sql b/packages/db/drizzle/0019_operator_session_create.sql new file mode 100644 index 0000000..6211ef9 --- /dev/null +++ b/packages/db/drizzle/0019_operator_session_create.sql @@ -0,0 +1,9 @@ +-- Operator-issued entry (2026-07-01): when the physical entry button is broken, an operator +-- may ISSUE an entry ticket (a flagged mint, gated on real vehicle presence — radar + camera). +-- New permission `session:create` in @parking/shared. The built-in `admin` role gets ALL +-- permissions in code (auth.ts ADMIN_PERMS), so no seed row is needed for it. This grants the +-- default `operator` role the ability to issue — an admin can revoke it per-role in the Roles +-- UI (it's data). Idempotent via the UNIQUE(role_id, permission) index. +-- See wiki/concepts/operator-issued-entry.md. +INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES + ('operator','session:create'); diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 89bb580..d934776 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1781886100000, "tag": "0018_drawer_permissions", "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1781886200000, + "tag": "0019_operator_session_create", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3838287..ee54fa8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -62,7 +62,10 @@ export const PERMISSIONS: readonly Permission[] = [ // a judgment about the operator, settled outside the app. See wiki/concepts/shift.md. "drawer:create", "drawer:review", "payment:read", "payment:create", - "session:read", + // session:create = the operator ISSUES an entry ticket when the physical entry button + // is broken (a flagged mint, gated on real vehicle presence). Admin-revocable per role. + // See wiki/concepts/operator-issued-entry.md. + "session:read", "session:create", "event:read", "event:void", "report:read", "log:read", @@ -360,6 +363,10 @@ export const REASON_CODES = [ // entry "entry.refused.full", "entry.held.noTicket", + // operator-issued entry (physical button broken) — a flagged mint, gated on real + // vehicle presence (radar + camera). See wiki/concepts/operator-issued-entry.md. + "entry.operatorIssued", + "entry.issue.noPresence", // exit refusals "exit.refused.closed", "exit.refused.noSession", @@ -373,6 +380,11 @@ export const REASON_CODES = [ "exit.freeGrace", // manual / human-intervention barrier open "exit.manualOpen", + // plate reconciliation: the exiting car's plate is already OPEN under a DIFFERENT + // ticket (possible ticket-swap fraud). Suspected = flagged; Override = operator + // consciously released it. See wiki/concepts/plate-reconciliation.md. + "exit.plateSwapSuspected", + "exit.plateSwapOverride", // subscriptions "sub.refused.notFound", "sub.refused.outOfWindow", @@ -397,6 +409,8 @@ export type ReasonCode = (typeof REASON_CODES)[number]; export const REASON_EN: Record = { "entry.refused.full": "entry refused — lot full ({count}/{capacity})", "entry.held.noTicket": "entry held — ticket not printed: {detail}", + "entry.operatorIssued": "entry ticket issued by operator {operator} (physical button)", + "entry.issue.noPresence": "operator entry refused — no vehicle detected at the entry", "exit.refused.closed": "exit refused — session already closed", "exit.refused.noSession": "exit refused — no open session for ticket", "exit.refused.unpaid": "exit refused — not paid (take payment first)", @@ -406,6 +420,8 @@ export const REASON_EN: Record = { "exit.open.failed": "exit recorded, but the barrier did not open — open manually", "exit.freeGrace": "free entry-grace (no charge)", "exit.manualOpen": "manual barrier open (human intervention)", + "exit.plateSwapSuspected": "possible ticket swap — plate {plate} is already inside under ticket {otherIdentity}", + "exit.plateSwapOverride": "operator {operator} released a suspected ticket-swap exit (plate {plate}, also open under {otherIdentity})", "sub.refused.notFound": "subscription refused — not found", "sub.refused.outOfWindow": "subscription refused — {status}/out-of-window", "sub.refused.noSession": "subscription exit with no open session (already out / never entered)", diff --git a/wiki/concepts/capacity-occupancy.md b/wiki/concepts/capacity-occupancy.md index 7677890..e3fe4e4 100644 --- a/wiki/concepts/capacity-occupancy.md +++ b/wiki/concepts/capacity-occupancy.md @@ -32,6 +32,9 @@ editable and drifts; the chain is the truth). Spaces-free = `capacity − occupa diverge from physical reality. The count is the *system's* occupancy; periodic ground-truth (a loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly, not silently corrected. +- **A DELIBERATE drift attack — the ticket swap:** a paid car let out on a fresh $0 ticket leaves its + original ticket "inside" forever, inflating occupancy by phantom cars. Defended by + [[plate-reconciliation]] (the exiting plate is already open under the original ticket → flag/hold). ## Reserved subscriber spots (admin toggle, built 2026-06-20) diff --git a/wiki/concepts/entry-exit-points.md b/wiki/concepts/entry-exit-points.md index 5a8ffcd..0475e86 100644 --- a/wiki/concepts/entry-exit-points.md +++ b/wiki/concepts/entry-exit-points.md @@ -157,4 +157,5 @@ as "⚠ camera unreachable" tiles (see [[booth-console]]). [[entry-exit-readers]] · [[device-events]] · [[parking-session]] · [[anti-passback]] · [[append-only-event-chain]] · [[barrier-not-a-door]] · [[opencv-anpr-service]] · -[[dingtian-relay]] · [[first-run-setup]] +[[dingtian-relay]] · [[first-run-setup]] · [[operator-issued-entry]] (mint when the button +is broken) · [[plate-reconciliation]] (the entry snapshot's plate defends the exit) diff --git a/wiki/concepts/operator-issued-entry.md b/wiki/concepts/operator-issued-entry.md new file mode 100644 index 0000000..85f474c --- /dev/null +++ b/wiki/concepts/operator-issued-entry.md @@ -0,0 +1,70 @@ +--- +type: concept +tags: [parking, booth, entry, threat-model, anpr, presence] +sources: [] +updated: 2026-07-01 +status: settled +--- + +# Operator-issued entry (broken entry button) + +When the physical entry button is broken, an operator can **issue an entry ticket** from the booth so +a real car isn't blocked out of the lot. This hands the [[threat-model|operator (the adversary)]] a way +to mint entries — so it is **flagged, presence-gated, and paired with an exit defense** +([[plate-reconciliation]]). Built 2026-07-01. Companion to [[entry-exit-points]] (the entry flow it +reuses) and [[capacity-occupancy]]. + +## Why give the operator this at all +An operator *could* mint tickets to defraud — but a broken entry button otherwise **blocks the whole +lot**, which is worse and more common. So the feature exists, and the fraud it enables is defended +downstream (see the "ticket-swap" scenario in [[plate-reconciliation]]) rather than by withholding the +capability. + +## The three controls that make it safe + +### 1. PRESENCE-GATED — a real car must be there (radar AND camera) +The operator button obeys the **same rule as the physical button**: it is only active when **BOTH** +presence conditions meet — +- **radar/loop present** (a presence input is shorted at the entry barrier), AND +- **camera confirms** a vehicle in the zone (the entry lane is "busy"). + +This ties every mint to a **real vehicle physically at the entry** — the operator can't pad occupancy +with phantom tickets, and (crucially) it guarantees the entry snapshot captures a **plate**, which is +what [[plate-reconciliation]] reads at exit. **No presence loop configured → the feature is +unavailable** at that site (we require both; no weaker camera-only fallback). + +**Enforced on BOTH sides.** The UI only enables the entry [[booth-console|BarrierLight]] as a clickable +issue-control when `radar.entry && lanes.entry` (both true) and the operator holds `session:create`. +The **server re-checks** current presence (`LaneStatus.snapshot().entry === true` AND the entry relay's +guard `present === true`) and **refuses** otherwise — so a direct `POST /api/entry/issue` by the +operator-adversary can't bypass a disabled button. A refused (no-presence) attempt signs an +`anomaly` (`entry.issue.noPresence`) so probing the endpoint is itself in the tamper-evident record. + +### 2. FLAGGED — every operator mint leaves a red-flag row +The issued entry is a **real** `vehicle_entry` (so occupancy/tariff/exit all work), but: +- `source: "manual"` + `operatorInitiated: true` + `operator` on the signed payload, AND +- a **companion `anomaly`** (`entry.operatorIssued`) — mirroring the [[booth-exit-flow|barrier + re-open]]: the operator-adversary path always leaves an explicit anomaly for [[reconciliation]]. + +### 3. Capacity OVERRIDE is allowed but recorded +Unlike the physical button (which refuses transient entry when the lot is [[capacity-occupancy|full]]), +the operator **can** issue over capacity — a broken button mustn't trap a legit car when the count is +near/at the cap (and the count may itself be inflated by the very fraud this defends). But an over-cap +mint stamps `lotFull: true` + the occupancy on the events, so the override is visible. + +## Wiring +- **Permission:** `session:create` (new; migration 0019 grants it to the default `operator` role; + admin-revocable per role, so an admin can turn off an operator's ability to mint). Admin has it in code. +- **Route:** `POST /api/entry/issue` — `session:create` + an **open shift** (a minted entry belongs to + an accountable operator, like the money path). +- **Server:** `EntryFlow.issueForOperator(operator, cameraBusy)`. The fraud-critical + print → sign(vehicle_entry) → pulseOpen → snapshot → cache sequence is a **single shared + `#issueTicket`** used by both the physical button and this path (no divergent copy). +- **UI:** the entry `BarrierLight` becomes clickable (confirm → issue) only when presence + permission + + shift are satisfied; the exit light stays a pure indicator. + +## Relates +- [[plate-reconciliation]] — the exit-side defense against the ticket-swap this capability enables. +- [[entry-exit-points]] — the entry flow + snapshot/ANPR path reused here. +- [[capacity-occupancy]] — why occupancy integrity matters (the swap fraud drifts it upward). +- [[threat-model]] — the operator-adversary framing all three controls serve. diff --git a/wiki/concepts/plate-reconciliation.md b/wiki/concepts/plate-reconciliation.md new file mode 100644 index 0000000..00b49c8 --- /dev/null +++ b/wiki/concepts/plate-reconciliation.md @@ -0,0 +1,75 @@ +--- +type: concept +tags: [parking, anpr, exit, threat-model, reconciliation, fraud] +sources: [] +updated: 2026-07-01 +status: settled +--- + +# Plate reconciliation at exit (ticket-swap defense) + +Uses the ANPR **plate as an invariant** to catch a **ticket-swap fraud**: the car's plate is the same +regardless of which ticket it holds, so if a car tries to exit on a ticket whose plate is **already +inside under a different ticket**, something is wrong. Built 2026-07-01 alongside +[[operator-issued-entry]] (the capability that makes the fraud easy). The [[threat-model|adversary is +the operator]], but the same swap happens innocently (two people mix up tickets). + +## The fraud (worked scenario) +A lot with 1000 spots: +1. Real car enters on ticket **1234** → ANPR records plate **AA123BB** at entry. +2. Car comes to exit owing 10,000 ALL. Operator scans 1234, **pockets the cash, does NOT record the + payment**. +3. Operator **mints a fresh ticket 1237** (age ≈ 0 → owes ~0) and lets the car out on 1237. +4. **1234 lingers "inside" forever** — a phantom car. Repeat → +100, +200 phantom cars; occupancy + becomes meaningless and the operator skims cash while the books look internally consistent (a ticket + was "paid" — 1237 for 0; a ticket is "inside" — 1234). + +The plate is what the swap can't hide: entry-1234 = AA123BB, and the car exiting on 1237 **is** AA123BB. + +## The check +`ExitFlow.#reconcilePlateAtExit(exitingId)`: +1. Resolve the **exiting** ticket's plate (its own exit read, else its entry read). +2. Enumerate all **currently-open** sessions (projection cache) and their **entry** plates + (`platesForIdentities`). +3. If the exiting plate **exactly** matches an open session under a **DIFFERENT** identity → **swap + suspected**, returning `{ plate, otherIdentity, otherEnteredAt }`. + +**EXACT, HIGH-CONFIDENCE only.** Both the exiting read AND the matched session's entry read must be +≥ `PLATE_MATCH_MIN_CONFIDENCE` (0.85), normalized exact string match. No fuzzy/edit-distance matching. +Rationale: ANPR is **advisory and misses** (G3H snapshot 503s, camera-side push failures, no-plate +reads — see the ANPR memory notes). A fuzzy/low-confidence read must **never** be the reason a car is +held — so a shaky read simply doesn't trigger the warning (fails toward not-annoying). + +## What happens on a suspected swap + +### Booth path (operator-mediated) — FLAG LOUDLY + require an override +Exit fails-OPEN for safety and a plate is **never the sole gate**, so we do **not** silently hard-block +(that would trap a legit car on a bad read). Instead: +- `exitForBooth` returns status **`swap_suspected`** with the detail; the barrier does **not** open. +- A **`anomaly` (`exit.plateSwapSuspected`)** is signed immediately — so even if the operator walks + away, the suspicion is in the tamper-evident record. +- The pay/exit modal shows a **prominent red warning** ("Plate AA123BB is already inside under ticket + 1234, entered 3h ago") with an explicit **"Override & release"** action. +- On override, `exitForBooth(id, { override, operator })` proceeds AND signs an attributed + **`anomaly` (`exit.plateSwapOverride`)** — the override is itself a signed, named decision. + +### Reader path (automated, no operator) — LOG-ONLY, fail-open +At an unmanned exit lane there's no one to make the override decision, and exit fails-open, so the +reader path **signs the `exit.plateSwapSuspected` anomaly and still lets the car out**. The anomaly is +the control there (a manager reconciles it later). This is a smaller surface — the fraud scenario is +booth-mediated. + +## Why this is the right shape +- **Occupancy stops drifting.** A swap can no longer silently strand ticket 1234 "inside" — the exit + attempt on 1237 surfaces it. Directly serves [[capacity-occupancy]] integrity. +- **The signed anomaly is the audit signal** a manager reconciles ([[reconciliation]]) — consistent + with "the fraud control lives in the signed chain + human review, not a real-time hard gate". +- **Advisory-not-a-gate is preserved both ways:** a plate never *opens* a barrier by itself, and now a + plate never *traps* a car by itself either (flag + override, never a silent hard block). + +## Relates +- [[operator-issued-entry]] — the capability whose fraud this defends. +- [[capacity-occupancy]] — occupancy integrity the swap attacks. +- [[reconciliation]] — where the signed anomalies are ultimately settled. +- [[entry-exit-points]] — the ANPR-on-snapshot path that records the plates compared here. +- [[threat-model]] — operator-as-adversary. diff --git a/wiki/index.md b/wiki/index.md index ce2ce48..fe616bf 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -88,6 +88,8 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records. - [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window. - [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version. - [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open. +- [[operator-issued-entry]] — operator mints an entry ticket when the physical button is broken; presence-gated (radar AND camera, both sides), flagged (source=manual + operatorInitiated + anomaly), capacity-override allowed; needs `session:create` (2026-07-01). +- [[plate-reconciliation]] — ANPR plate-as-invariant catches the ticket-swap fraud (paid car let out on a fresh $0 ticket, original lingers "inside"); exact/high-conf match vs open sessions; booth = flag + operator override, reader = log-only fail-open (2026-07-01). - [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts. Drawer cash movements (operator records, admin reviews via signed cash_review — a flag, not a reversal) live at the /drawer route (2026-07-01). - [[card-payments]] — card tender DISABLED (no P2PE POS on-site yet, 2026-07-01); cash-only UI gate (`CARD_PAYMENTS_ENABLED`); future POS keeps PCI scope out of the app; how to re-enable. - [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked. diff --git a/wiki/log.md b/wiki/log.md index 9d196ca..85373a2 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2108,3 +2108,34 @@ DrawerManager.tsx, drawer.* i18n (sq+en). Verified: full monorepo build/lint/tes tests incl. the op1-denied → op2-drawer-unchanged regression); Playwright end-to-end on /drawer (record disbursement → pending → authorize → status flips, ledger shows cash_out + cash_review with no authorizedBy). Recorded in shift.md "Drawer review". + +## [2026-07-01] feat | Operator-issued entry + exit plate-swap reconciliation (one anti-fraud design) + +Two halves of one design. (A) 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 — but this hands the operator-adversary a mint, so +it's (1) PRESENCE-GATED exactly like the physical button (radar/loop present AND camera busy = a real +car; enforced BOTH sides, server re-checks so a direct POST can't bypass a disabled button; no presence +loop → feature unavailable; a no-presence attempt signs an entry.issue.noPresence anomaly), (2) FLAGGED +(vehicle_entry source=manual + operatorInitiated + operator, PLUS a companion entry.operatorIssued +anomaly), (3) 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 +factored into one shared #issueTicket (button + operator). UI: the entry BarrierLight becomes a +clickable issue-control when presence+permission+shift meet (confirm → issue). + +(B) Plate-swap fraud (user's scenario): operator scans exiting ticket 1234 (owes 10000), pockets cash +WITHOUT recording payment, mints fresh 1237 (owes ~0), lets the car out on 1237 → 1234 lingers "inside" +forever, occupancy drifts up by phantom cars. Defense = ANPR plate as invariant: the car's plate is the +same either way. ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN sessions' +entry plates — EXACT, HIGH-CONFIDENCE only (≥0.85; a fuzzy/low read never gates, ANPR is advisory). On +a match under a DIFFERENT ticket: BOOTH path returns swap_suspected + signs exit.plateSwapSuspected +anomaly + the pay/exit modal shows a red warning with "Override & release" (override signs an attributed +exit.plateSwapOverride) — flag+override, never a silent hard block (exit fails-open, plate never the +sole gate). READER path (no operator) = log-only anomaly + fail-open (user's call). Extended +BoothExitResult + /api/exit (override param), 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 pages +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."