Compare commits
13 Commits
main
..
3429642edb
| Author | SHA1 | Date | |
|---|---|---|---|
| 3429642edb | |||
| c24d99b0f4 | |||
| b4d0dfadd6 | |||
| f18e28eeca | |||
| a8c6d6e714 | |||
| 2a36830880 | |||
| 2696d281ce | |||
| 648d3254d6 | |||
| 8c2cf93067 | |||
| 9a4c7ee27b | |||
| 8a8e74561d | |||
| 2ab5a39a57 | |||
| fa65b2df86 |
+11
-5
@@ -18,9 +18,15 @@ export const TOKEN_COOKIE = "parking_token";
|
||||
export const CSRF_COOKIE = "parking_csrf";
|
||||
export const CSRF_HEADER = "x-csrf-token";
|
||||
|
||||
/** Token lifetime, also used as the cookie maxAge. */
|
||||
export const TOKEN_TTL = "8h";
|
||||
export const TOKEN_TTL_SECONDS = 8 * 60 * 60;
|
||||
// Session lifetime: the JWT has NO expiry — a login is valid until explicit
|
||||
// logout. Booth reality breaks any fixed clock (relief late/absent, forced double
|
||||
// shifts), and a shift is a separate explicit boundary, not the token's lifetime.
|
||||
// See wiki/entities/local-jwt-auth.md + wiki/concepts/shift.md.
|
||||
//
|
||||
// The cookie still needs a maxAge so it survives a browser restart (a session
|
||||
// cookie would log out an active operator on browser close — the opposite of
|
||||
// "until logout"). Use a long fixed window; the server clears it on logout.
|
||||
export const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
||||
|
||||
/**
|
||||
* Resolve the JWT signing secret, refusing to start without a strong one.
|
||||
@@ -55,7 +61,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
||||
sameSite: "strict",
|
||||
secure,
|
||||
path: "/",
|
||||
maxAge: TOKEN_TTL_SECONDS,
|
||||
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||
});
|
||||
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
|
||||
reply.setCookie(CSRF_COOKIE, csrf, {
|
||||
@@ -63,7 +69,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
||||
sameSite: "strict",
|
||||
secure,
|
||||
path: "/",
|
||||
maxAge: TOKEN_TTL_SECONDS,
|
||||
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,17 @@ export interface DeviceInputEvent {
|
||||
readonly source: "push" | "poll";
|
||||
}
|
||||
|
||||
// A credential read at a lane: a ticket scanned at exit, a plate from LPR, a card
|
||||
// at a reader. Drives identity-based flows (exit validation, and later permits /
|
||||
// pay-station lookup). `kind` mirrors IdentitySource. See parking-session.md.
|
||||
export interface DeviceReadEvent {
|
||||
readonly driverId: string;
|
||||
readonly deviceId: string; // lane_devices id of the reader/scanner/camera
|
||||
readonly value: string; // the ticket id / plate / card number
|
||||
readonly kind: "ticket" | "plate" | "qr" | "card";
|
||||
readonly at: string; // ISO-8601
|
||||
}
|
||||
|
||||
/** A printer's status as tracked by the live monitor (status + identity). */
|
||||
export interface PrinterStatusEvent {
|
||||
readonly deviceId: string; // lane_devices id
|
||||
@@ -33,6 +44,15 @@ class DeviceEventBus extends EventEmitter {
|
||||
return () => this.off("input", cb);
|
||||
}
|
||||
|
||||
/** A credential read (ticket scan, plate, card) at a lane. */
|
||||
emitRead(event: DeviceReadEvent): void {
|
||||
this.emit("read", event);
|
||||
}
|
||||
onRead(cb: (event: DeviceReadEvent) => void): () => void {
|
||||
this.on("read", cb);
|
||||
return () => this.off("read", cb);
|
||||
}
|
||||
|
||||
/** Emitted by the printer monitor whenever a printer's status CHANGES. */
|
||||
emitPrinterStatus(event: PrinterStatusEvent): void {
|
||||
this.emit("printer-status", event);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, laneDevices, sessions, type Db } from "@parking/db";
|
||||
import {
|
||||
NoPrinterAvailableError,
|
||||
printWithFailover,
|
||||
registry,
|
||||
type AccessControlDevice,
|
||||
type PrinterDevice,
|
||||
type PrinterInstance,
|
||||
type TicketData,
|
||||
} from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent } from "./device-events.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import type { LaneMap } from "./lane-map.js";
|
||||
|
||||
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
|
||||
// → open the barrier. This is the step the device layer left dangling
|
||||
// (wiki/concepts/device-input-flow.md "the entry flow itself is the next build").
|
||||
//
|
||||
// Two invariants from the threat model + safety analysis:
|
||||
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger
|
||||
// BEFORE pulseOpen fires; an open with no matching signed event is the fraud
|
||||
// signal (wiki/concepts/append-only-event-chain.md).
|
||||
// 2. HOLD ON PRINT FAILURE — a transient with no ticket can't pay on exit, so if
|
||||
// all printers are down we do NOT open. We sign an `anomaly` (attempt, ticket
|
||||
// unprinted) and leave the barrier closed; the operator handles the held car.
|
||||
// Crucially, NO vehicle_entry is written in that case — we never record an
|
||||
// "entered" event for a car that didn't get in (decision 2026-06-15).
|
||||
//
|
||||
// Ordering, therefore: print → (ok) sign vehicle_entry → pulseOpen → cache session.
|
||||
// (fail) sign anomaly, stop.
|
||||
|
||||
/** Map a 1-based entry input to the relay/door it opens. Default: same channel. */
|
||||
function doorForInput(input: number): number {
|
||||
return input;
|
||||
}
|
||||
|
||||
export class EntryFlow {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #laneMap: LaneMap;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
/** Guard against double-fire from the same physical press (on edge only). */
|
||||
readonly #inFlight = new Set<string>();
|
||||
|
||||
constructor(db: Db, log: EventLog, laneMap: LaneMap, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#laneMap = laneMap;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Handle a device input edge. Acts only on the rising ("on") edge of an entry
|
||||
* button in a lane that has an access (barrier) device. */
|
||||
async onInput(e: DeviceInputEvent): Promise<void> {
|
||||
if (e.edge !== "on") return; // release edge is just telemetry
|
||||
|
||||
const lane = this.#laneMap.laneFor(e.deviceId);
|
||||
if (lane == null) return; // unmapped device — telemetry already recorded, no entry
|
||||
|
||||
// Only treat this as an entry trigger if the firing device IS the lane's
|
||||
// access controller (a reader/printer input edge isn't an entry button).
|
||||
const access = await this.#loadAccess(lane, e.deviceId);
|
||||
if (!access) return;
|
||||
|
||||
const key = `${e.deviceId}:${e.input}`;
|
||||
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
await this.#runEntry(lane, e.input, access);
|
||||
} catch (err) {
|
||||
this.#logger.error(`entry-flow failed (lane ${lane}): ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #runEntry(lane: number, input: number, access: AccessControlDevice): Promise<void> {
|
||||
const ticketId = newTicketId();
|
||||
const issuedAt = new Date().toISOString();
|
||||
const printers = await this.#loadPrinters(lane);
|
||||
|
||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||
const ticket: TicketData = { ticketId, lane, issuedAt };
|
||||
try {
|
||||
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
||||
d.printTicket(ticket),
|
||||
);
|
||||
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy} (lane ${lane})`);
|
||||
} catch (err) {
|
||||
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
|
||||
// failed attempt is in the tamper-evident record for the operator.
|
||||
const reason =
|
||||
err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
lane,
|
||||
identity: ticketId,
|
||||
payload: { reason: `entry held — ticket not printed: ${reason}`, ticketPrinted: false },
|
||||
});
|
||||
this.#logger.warn(`entry HELD on lane ${lane}: ${reason} (barrier NOT opened)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
lane,
|
||||
direction: "entry",
|
||||
source: "ticket",
|
||||
identity: ticketId,
|
||||
payload: { sessionRef: ticketId, ticketPrinted: true },
|
||||
occurredAt: issuedAt,
|
||||
});
|
||||
|
||||
// 3. OPEN the barrier (intent only; the barrier owns the close).
|
||||
await access.pulseOpen(doorForInput(input));
|
||||
|
||||
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
||||
// just a fast read-model, never the source of truth).
|
||||
try {
|
||||
this.#db
|
||||
.insert(sessions)
|
||||
.values({ id: ticketId, lane, identity: ticketId, source: "ticket", 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The lane's access device, but only if it's the one that fired (the entry
|
||||
* button). Returns a live adapter or null. */
|
||||
async #loadAccess(lane: number, deviceId: string): Promise<AccessControlDevice | null> {
|
||||
const row = await this.#db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(and(eq(laneDevices.id, deviceId), eq(laneDevices.category, "access")))
|
||||
.get();
|
||||
if (!row || !row.enabled || row.lane !== lane) return null;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
return driver.create(row.config as never) as AccessControlDevice;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build live printer instances for a lane (for failover selection). */
|
||||
async #loadPrinters(lane: number): Promise<PrinterInstance[]> {
|
||||
const rows = await this.#db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(and(eq(laneDevices.category, "printer"), eq(laneDevices.lane, lane)))
|
||||
.all();
|
||||
const out: PrinterInstance[] = [];
|
||||
for (const row of rows) {
|
||||
if (!row.enabled) continue;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) continue;
|
||||
const cfg = row.config as Record<string, unknown>;
|
||||
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
role,
|
||||
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
|
||||
device: driver.create(cfg as never) as PrinterDevice,
|
||||
});
|
||||
} catch {
|
||||
// skip a printer whose config won't build
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). */
|
||||
function newTicketId(): string {
|
||||
return `T-${randomUUID()}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { desc, events, type Db, type EventRow } from "@parking/db";
|
||||
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared";
|
||||
import { desc, ledgerEvents, type Db, type LedgerEventRow } from "@parking/db";
|
||||
import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer } from "@parking/shared";
|
||||
|
||||
// The append-only, hash-chained, signed event log — the system's core anti-fraud
|
||||
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
|
||||
@@ -16,11 +16,13 @@ import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parki
|
||||
// so we guard it with an in-process async lock as well.
|
||||
|
||||
export interface AppendInput {
|
||||
readonly type: ParkingEventType;
|
||||
readonly type: LedgerEventType;
|
||||
readonly lane: number;
|
||||
readonly direction?: Direction | null;
|
||||
readonly source?: IdentitySource | null;
|
||||
readonly identity?: string | null;
|
||||
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
|
||||
readonly payload?: LedgerPayload | null;
|
||||
/** Event time (ISO-8601). Defaults to now. */
|
||||
readonly occurredAt?: string;
|
||||
}
|
||||
@@ -39,6 +41,7 @@ export function canonicalize(e: {
|
||||
lane: number;
|
||||
source: string | null;
|
||||
identity: string | null;
|
||||
payload: Record<string, unknown> | null;
|
||||
occurredAt: string;
|
||||
prevHash: string | null;
|
||||
}): string {
|
||||
@@ -49,11 +52,33 @@ export function canonicalize(e: {
|
||||
e.lane,
|
||||
e.source ?? null,
|
||||
e.identity ?? null,
|
||||
// Payload is part of the signed form so business data is tamper-evident.
|
||||
// Serialize with sorted keys for byte-stability (object key order must not
|
||||
// change a signature). null when the event type carries no payload.
|
||||
canonicalPayload(e.payload),
|
||||
e.occurredAt,
|
||||
e.prevHash ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Deterministic (key-sorted, recursive) JSON for the payload slot. */
|
||||
function canonicalPayload(p: Record<string, unknown> | null | undefined): unknown {
|
||||
if (p == null) return null;
|
||||
const sort = (v: unknown): unknown => {
|
||||
if (Array.isArray(v)) return v.map(sort);
|
||||
if (v && typeof v === "object") {
|
||||
return Object.keys(v as Record<string, unknown>)
|
||||
.sort()
|
||||
.reduce<Record<string, unknown>>((o, k) => {
|
||||
o[k] = sort((v as Record<string, unknown>)[k]);
|
||||
return o;
|
||||
}, {});
|
||||
}
|
||||
return v;
|
||||
};
|
||||
return sort(p);
|
||||
}
|
||||
|
||||
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
|
||||
export function hashEvent(canonical: string): string {
|
||||
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
||||
@@ -71,24 +96,25 @@ export class EventLog {
|
||||
}
|
||||
|
||||
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
||||
append(input: AppendInput): Promise<EventRow> {
|
||||
append(input: AppendInput): Promise<LedgerEventRow> {
|
||||
const run = this.#tail.then(() => this.#appendNow(input));
|
||||
// Keep the chain going even if one append rejects (don't wedge the lock).
|
||||
this.#tail = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
#appendNow(input: AppendInput): EventRow {
|
||||
#appendNow(input: AppendInput): LedgerEventRow {
|
||||
const prev = this.#db
|
||||
.select()
|
||||
.from(events)
|
||||
.orderBy(desc(events.index))
|
||||
.from(ledgerEvents)
|
||||
.orderBy(desc(ledgerEvents.index))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
const index = (prev?.index ?? 0) + 1;
|
||||
const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
|
||||
const occurredAt = input.occurredAt ?? new Date().toISOString();
|
||||
const payload = input.payload ?? null;
|
||||
|
||||
const canonical = canonicalize({
|
||||
index,
|
||||
@@ -97,6 +123,7 @@ export class EventLog {
|
||||
lane: input.lane,
|
||||
source: input.source ?? null,
|
||||
identity: input.identity ?? null,
|
||||
payload,
|
||||
occurredAt,
|
||||
prevHash,
|
||||
});
|
||||
@@ -109,13 +136,15 @@ export class EventLog {
|
||||
lane: input.lane,
|
||||
source: input.source ?? null,
|
||||
identity: input.identity ?? null,
|
||||
payload,
|
||||
occurredAt,
|
||||
prevHash,
|
||||
signature: this.#signer.sign(canonical),
|
||||
keyId: this.#signer.keyId,
|
||||
};
|
||||
|
||||
this.#db.insert(events).values(row).run();
|
||||
return row as EventRow;
|
||||
this.#db.insert(ledgerEvents).values(row).run();
|
||||
return row as LedgerEventRow;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +154,7 @@ export class EventLog {
|
||||
* row (index gap), and a forged/invalid signature.
|
||||
*/
|
||||
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
|
||||
const rows = this.#db.select().from(events).orderBy(events.index).all();
|
||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
let expectedIndex = 1;
|
||||
let prevHash: string | null = null;
|
||||
for (const row of rows) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { and, eq, laneDevices, ledgerEvents, sessions, type Db } from "@parking/db";
|
||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||
import type { LedgerPayload } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent } from "./device-events.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
// The EXIT flow (pay-on-foot model): a credential read at the exit lane → look up
|
||||
// the session → validate it is PAID and within the walk-back grace → sign a
|
||||
// vehicle_exit → open. Payment is decoupled from exit (it happens earlier at the
|
||||
// pay station); the exit lane only VALIDATES. See wiki/concepts/parking-session.md.
|
||||
//
|
||||
// Validation is a fold over the SIGNED ledger (the authoritative record), not the
|
||||
// projection cache: find the open vehicle_entry for this identity, then a covering
|
||||
// payment within grace. The cache is updated after, for fast reads.
|
||||
//
|
||||
// REJECT (barrier stays closed) when unpaid / over grace — this is correct business
|
||||
// logic, NOT a fail-state. "Exit fails OPEN" (fail-state-safety) is about the SYSTEM
|
||||
// being unable to decide (power/host loss), not about an unpaid car; an unpaid driver
|
||||
// is sent back to the pay station, the rejection is logged.
|
||||
//
|
||||
// NOTE: payments / the pay station don't exist yet, so no session is ever PAID — every
|
||||
// transient exit currently REJECTS (logged). That's the correct end-state; it becomes
|
||||
// passable once the pay-station + `payment` events land.
|
||||
|
||||
interface SessionView {
|
||||
readonly identity: string;
|
||||
readonly lane: number;
|
||||
readonly enteredAt: string;
|
||||
readonly open: boolean; // no vehicle_exit yet
|
||||
readonly paidAt: string | null; // latest payment time, if any
|
||||
readonly graceExitMin: number | null; // from the payment's tariff context, if known
|
||||
}
|
||||
|
||||
export class ExitFlow {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #inFlight = new Set<string>();
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Handle a transient-ticket read at a known exit lane (lane pre-resolved by the
|
||||
* read dispatcher, which has already ruled out a permit match). */
|
||||
async handleAt(lane: number, e: DeviceReadEvent): Promise<void> {
|
||||
const key = `${e.deviceId}:${e.value}`;
|
||||
if (this.#inFlight.has(key)) return;
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
await this.#runExit(lane, e);
|
||||
} catch (err) {
|
||||
this.#logger.error(`exit-flow failed (lane ${lane}): ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #runExit(lane: number, e: DeviceReadEvent): Promise<void> {
|
||||
const view = this.#sessionFor(e.value);
|
||||
|
||||
// No matching open session — unknown/duplicate ticket. Reject + log.
|
||||
if (!view || !view.open) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
lane,
|
||||
identity: e.value,
|
||||
payload: { reason: view ? "exit refused — session already closed" : "exit refused — no open session for credential", exitRefused: true },
|
||||
});
|
||||
this.#logger.warn(`exit refused (lane ${lane}): no open session for ${e.value}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// PAID + within walk-back grace?
|
||||
const paid = view.paidAt != null;
|
||||
const withinGrace =
|
||||
paid &&
|
||||
view.graceExitMin != null &&
|
||||
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||
|
||||
if (!paid || !withinGrace) {
|
||||
const reason = !paid
|
||||
? "exit refused — not paid (pay at the station)"
|
||||
: "exit refused — walk-back grace expired (top-up required)";
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
lane,
|
||||
identity: e.value,
|
||||
payload: { reason, exitRefused: true, sessionRef: e.value },
|
||||
});
|
||||
this.#logger.warn(`exit refused (lane ${lane}, ${e.value}): ${reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid: sign the exit BEFORE opening, then open, then update the cache.
|
||||
await this.#log.append({
|
||||
type: "vehicle_exit",
|
||||
lane,
|
||||
direction: "exit",
|
||||
source: e.kind === "plate" ? "lpr" : "ticket",
|
||||
identity: e.value,
|
||||
payload: { sessionRef: e.value },
|
||||
});
|
||||
|
||||
const access = await this.#exitAccess(lane);
|
||||
if (access) {
|
||||
await access.pulseOpen(1); // exit barrier; door mapping is config-driven later
|
||||
} else {
|
||||
this.#logger.warn(`exit signed for ${e.value} but lane ${lane} has no access device to open`);
|
||||
}
|
||||
|
||||
try {
|
||||
this.#db
|
||||
.update(sessions)
|
||||
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
||||
.where(eq(sessions.id, e.value))
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||
#sessionFor(identity: string): SessionView | null {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, identity))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||
if (!entry) return null;
|
||||
const exited = rows.some((r) => r.type === "vehicle_exit");
|
||||
|
||||
let paidAt: string | null = null;
|
||||
let graceExitMin: number | null = null;
|
||||
for (const r of rows) {
|
||||
if (r.type === "payment") {
|
||||
paidAt = r.occurredAt;
|
||||
const p = (r.payload ?? {}) as LedgerPayload & { graceExitMin?: number };
|
||||
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
identity,
|
||||
lane: entry.lane,
|
||||
enteredAt: entry.occurredAt,
|
||||
open: !exited,
|
||||
paidAt,
|
||||
graceExitMin,
|
||||
};
|
||||
}
|
||||
|
||||
/** The lane's access device, to open the exit barrier. */
|
||||
async #exitAccess(lane: number): Promise<AccessControlDevice | null> {
|
||||
const row = await this.#db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, lane)))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
return driver.create(row.config as never) as AccessControlDevice;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { laneDevices, type Db } from "@parking/db";
|
||||
import { and, eq, laneDevices, type Db } from "@parking/db";
|
||||
|
||||
// Resolves a device instance id (lane_devices.id) to its lane number.
|
||||
//
|
||||
@@ -28,3 +28,21 @@ export class LaneMap {
|
||||
return this.#byDeviceId.get(deviceId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane a reader/scanner belongs to, IF that lane has an access (barrier)
|
||||
* device to open — shared by the read-driven flows (exit + permit). A read is an
|
||||
* identity signal; it only drives a barrier where there's one to drive. Returns
|
||||
* the lane number or null. (Distinguishing entry- vs. exit-readers per lane is a
|
||||
* later lane-direction model.)
|
||||
*/
|
||||
export async function readerLaneWithAccess(db: Db, deviceId: string): Promise<number | null> {
|
||||
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const access = await db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, row.lane)))
|
||||
.get();
|
||||
return access && access.enabled ? row.lane : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
|
||||
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
||||
// 1. quote(identity) → look up the open session, price it against the tariff in
|
||||
// force at entry, return the amount due (no side effect).
|
||||
// 2. pay(identity, tender) → re-price, append a SIGNED `payment` event carrying
|
||||
// the amount, currency, tender, tariffVersionId, and graceExitMin (so the exit
|
||||
// flow can validate paid + within walk-back grace). Payment is a signed ledger
|
||||
// event, never a mutable "paid" flag — an operator can't forge or delete it.
|
||||
// See wiki/concepts/tariff.md, parking-session.md.
|
||||
|
||||
export class NoOpenSessionError extends Error {
|
||||
constructor(identity: string) {
|
||||
super(`no open session for ${identity}`);
|
||||
this.name = "NoOpenSessionError";
|
||||
}
|
||||
}
|
||||
export class NoTariffError extends Error {
|
||||
constructor() {
|
||||
super("no active tariff configured");
|
||||
this.name = "NoTariffError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface Quote {
|
||||
readonly identity: string;
|
||||
readonly enteredAt: string;
|
||||
readonly amountMinor: number;
|
||||
readonly currency: string;
|
||||
readonly tariffVersionId: string;
|
||||
readonly graceExitMin: number;
|
||||
}
|
||||
|
||||
export class PayStation {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Price an open session against the tariff in force at its entry. No side effect. */
|
||||
quote(identity: string): Quote {
|
||||
const entry = this.#openEntry(identity);
|
||||
if (!entry) throw new NoOpenSessionError(identity);
|
||||
|
||||
const tv = this.#tariffVersionFor(entry.occurredAt);
|
||||
if (!tv) throw new NoTariffError();
|
||||
const structure = tv.structure as unknown as TariffStructure;
|
||||
|
||||
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure);
|
||||
return {
|
||||
identity,
|
||||
enteredAt: entry.occurredAt,
|
||||
amountMinor,
|
||||
currency: tv.currency,
|
||||
tariffVersionId: tv.id,
|
||||
graceExitMin: structure.gracePeriodExitMin,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take payment for a session and append the signed `payment` event. Re-quotes at
|
||||
* the moment of payment (the customer pays for time parked SO FAR). For an
|
||||
* overstay top-up the same call re-prices entry→now and the exit flow's
|
||||
* grace-window restarts from this payment. `overrideMinor` lets the operator set
|
||||
* an arbitrary amount (lost ticket / dispute) — recorded as the charged amount.
|
||||
*/
|
||||
async pay(
|
||||
identity: string,
|
||||
tender: Tender,
|
||||
overrideMinor?: number,
|
||||
): Promise<{ amountMinor: number; currency: string }> {
|
||||
const q = this.quote(identity);
|
||||
const amountMinor = overrideMinor ?? q.amountMinor;
|
||||
|
||||
await this.#log.append({
|
||||
type: "payment",
|
||||
lane: -1, // payment happens at a central station, not a lane
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
amountMinor,
|
||||
currency: q.currency,
|
||||
tender,
|
||||
tariffVersionId: q.tariffVersionId,
|
||||
// The exit flow reads graceExitMin off the payment to validate the
|
||||
// walk-back window without re-resolving the tariff.
|
||||
graceExitMin: q.graceExitMin,
|
||||
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Update the projection cache (rebuildable; not the source of truth).
|
||||
try {
|
||||
this.#db.update(sessions).set({ state: "paid" }).where(eq(sessions.id, identity)).run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
|
||||
return { amountMinor, currency: q.currency };
|
||||
}
|
||||
|
||||
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
|
||||
#openEntry(identity: string) {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, identity))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||
if (!entry) return null;
|
||||
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already closed
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
|
||||
* (single, for now) active site tariff. */
|
||||
#tariffVersionFor(at: string) {
|
||||
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||
if (!tariff) return null;
|
||||
const versions = this.#db
|
||||
.select()
|
||||
.from(tariffVersions)
|
||||
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||
.all();
|
||||
return versions.find((v) => v.effectiveFrom <= at) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { and, eq, laneDevices, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db } from "@parking/db";
|
||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent } from "./device-events.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
// PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying.
|
||||
// Reached from the read dispatcher when a read matches a permit (not an open ticket).
|
||||
// See wiki/entities/permit.md.
|
||||
//
|
||||
// Two optional, independent bindings:
|
||||
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the
|
||||
// permit's cars may be inside at once; enforced over the session projection.
|
||||
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
|
||||
// too (card/QR OR plate). When unset, any car may use the permit's card/QR.
|
||||
//
|
||||
// Direction is inferred from session state for THAT car (the read credential value
|
||||
// is the per-car session key): no open session → ENTRY; open session → EXIT. So a
|
||||
// fleet permit can have several cars in at once, each its own session, and
|
||||
// anti-passback falls out (a second "entry" on a car already in becomes its exit).
|
||||
|
||||
export interface PermitMatch {
|
||||
readonly permitId: string;
|
||||
/** The specific credential/plate value read — the per-car session key. */
|
||||
readonly carKey: string;
|
||||
readonly via: "card" | "qr" | "plate";
|
||||
}
|
||||
|
||||
export class PermitFlow {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #inFlight = new Set<string>();
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Resolve a read to a permit (by card/QR credential, or by a bound plate), or null. */
|
||||
match(e: DeviceReadEvent): PermitMatch | null {
|
||||
// Card / QR / generic credential value.
|
||||
const cred = this.#db
|
||||
.select()
|
||||
.from(permitCredentials)
|
||||
.where(eq(permitCredentials.value, e.value))
|
||||
.get();
|
||||
if (cred) {
|
||||
return { permitId: cred.permitId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
|
||||
}
|
||||
// Plate binding: a read plate that matches a permit's bound plate is an identity.
|
||||
if (e.kind === "plate") {
|
||||
const plate = this.#db.select().from(permitPlates).where(eq(permitPlates.plate, e.value)).get();
|
||||
if (plate) return { permitId: plate.permitId, carKey: e.value, via: "plate" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Run the permit entry/exit for a matched read at a lane. */
|
||||
async run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise<void> {
|
||||
const key = `${m.permitId}:${m.carKey}`;
|
||||
if (this.#inFlight.has(key)) return;
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
await this.#run(lane, e, m);
|
||||
} catch (err) {
|
||||
this.#logger.error(`permit-flow failed (lane ${lane}): ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #run(lane: number, e: DeviceReadEvent, m: PermitMatch): Promise<void> {
|
||||
const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get();
|
||||
if (!permit) return;
|
||||
|
||||
// Validity: active + within the coverage window.
|
||||
const now = new Date().toISOString();
|
||||
const invalid =
|
||||
permit.status !== "active" ||
|
||||
(permit.validFrom != null && now < permit.validFrom) ||
|
||||
(permit.validTo != null && now > permit.validTo);
|
||||
if (invalid) {
|
||||
await this.#reject(lane, m, `permit ${permit.status}/out-of-window`);
|
||||
return;
|
||||
}
|
||||
|
||||
const carOpen = this.#carHasOpenSession(m.carKey);
|
||||
|
||||
if (carOpen) {
|
||||
// EXIT: this car is already inside → the read is its exit.
|
||||
await this.#log.append({
|
||||
type: "vehicle_exit",
|
||||
lane,
|
||||
direction: "exit",
|
||||
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
|
||||
identity: m.carKey,
|
||||
payload: { sessionRef: m.carKey, permitId: m.permitId },
|
||||
});
|
||||
await this.#open(lane, m.carKey, "permit exit");
|
||||
this.#closeCache(m.carKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open.
|
||||
if (permit.maxConcurrent != null) {
|
||||
const open = this.#permitOpenCount(m.permitId);
|
||||
if (open >= permit.maxConcurrent) {
|
||||
await this.#reject(lane, m, `permit at capacity (${open}/${permit.maxConcurrent} cars in)`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
lane,
|
||||
direction: "entry",
|
||||
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
|
||||
identity: m.carKey,
|
||||
// No ticket, no fee — the permit IS the authorization. Recorded for audit.
|
||||
payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true },
|
||||
occurredAt: now,
|
||||
});
|
||||
await this.#open(lane, m.carKey, "permit entry");
|
||||
try {
|
||||
this.#db
|
||||
.insert(sessions)
|
||||
.values({ id: m.carKey, lane, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" })
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Does this specific car (credential value) have an open session right now? */
|
||||
#carHasOpenSession(carKey: string): boolean {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, carKey))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
const entries = rows.filter((r) => r.type === "vehicle_entry").length;
|
||||
const exits = rows.filter((r) => r.type === "vehicle_exit").length;
|
||||
return entries > exits;
|
||||
}
|
||||
|
||||
/** How many of this permit's cars are inside right now (fold over the ledger). */
|
||||
#permitOpenCount(permitId: string): number {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(and(eq(ledgerEvents.type, "vehicle_entry")))
|
||||
.all()
|
||||
.filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId);
|
||||
let open = 0;
|
||||
for (const entry of rows) {
|
||||
if (!this.#carHasOpenSession(entry.identity ?? "")) continue;
|
||||
open += 1;
|
||||
}
|
||||
return open;
|
||||
}
|
||||
|
||||
async #reject(lane: number, m: PermitMatch, reason: string): Promise<void> {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
lane,
|
||||
identity: m.carKey,
|
||||
payload: { reason: `permit refused — ${reason}`, permitId: m.permitId, permitRefused: true },
|
||||
});
|
||||
this.#logger.warn(`permit refused (lane ${lane}, ${m.carKey}): ${reason}`);
|
||||
}
|
||||
|
||||
async #open(lane: number, carKey: string, what: string): Promise<void> {
|
||||
const access = await this.#access(lane);
|
||||
if (access) await access.pulseOpen(1);
|
||||
else this.#logger.warn(`${what} signed for ${carKey} but lane ${lane} has no access device`);
|
||||
}
|
||||
|
||||
#closeCache(carKey: string): void {
|
||||
try {
|
||||
this.#db.update(sessions).set({ exitedAt: new Date().toISOString(), state: "closed" }).where(eq(sessions.id, carKey)).run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`session-cache close failed for ${carKey}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async #access(lane: number): Promise<AccessControlDevice | null> {
|
||||
const row = await this.#db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, lane)))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
return driver.create(row.config as never) as AccessControlDevice;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent } from "./device-events.js";
|
||||
import type { ExitFlow } from "./exit-flow.js";
|
||||
import type { PermitFlow } from "./permit-flow.js";
|
||||
import { readerLaneWithAccess } from "./lane-map.js";
|
||||
|
||||
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
|
||||
// can mean a permit entry/exit OR a transient exit, so we dispatch by WHAT the
|
||||
// credential is (decision 2026-06-15):
|
||||
// - matches a permit (card/QR/bound plate) → PERMIT flow (direction inferred from
|
||||
// the car's open-session state),
|
||||
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
|
||||
// Lane is resolved once here; both flows act on a known access-equipped lane.
|
||||
|
||||
export class ReadDispatcher {
|
||||
readonly #db: Db;
|
||||
readonly #exit: ExitFlow;
|
||||
readonly #permit: PermitFlow;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
|
||||
constructor(db: Db, exit: ExitFlow, permit: PermitFlow, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#exit = exit;
|
||||
this.#permit = permit;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
async dispatch(e: DeviceReadEvent): Promise<void> {
|
||||
const lane = await readerLaneWithAccess(this.#db, e.deviceId);
|
||||
if (lane == null) return; // reader not on an access-equipped lane — ignore
|
||||
|
||||
const permit = this.#permit.match(e);
|
||||
if (permit) {
|
||||
await this.#permit.run(lane, e, permit);
|
||||
return;
|
||||
}
|
||||
// Not a permit → transient ticket exit (the exit flow rejects+logs if unknown).
|
||||
await this.#exit.handleAt(lane, e);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import bcrypt from "bcrypt";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, users, type Db } from "@parking/db";
|
||||
import {
|
||||
TOKEN_TTL,
|
||||
clearAuthCookies,
|
||||
newCsrfToken,
|
||||
requireRole,
|
||||
@@ -34,10 +33,13 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
|
||||
const csrf = newCsrfToken();
|
||||
const token = await reply.jwtSign(
|
||||
{ sub: user.id, username: user.username, role: user.role, csrf },
|
||||
{ expiresIn: TOKEN_TTL },
|
||||
);
|
||||
// No expiresIn: the token is valid until explicit logout (see auth.ts).
|
||||
const token = await reply.jwtSign({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
csrf,
|
||||
});
|
||||
setAuthCookies(reply, token, csrf);
|
||||
return { id: user.id, username: user.username, role: user.role };
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, events, type Db } from "@parking/db";
|
||||
import { desc, ledgerEvents, type Db } from "@parking/db";
|
||||
import { requireRole } from "../auth.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function eventRoutes(
|
||||
{ preHandler: guard },
|
||||
async (req) => {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||
const rows = db.select().from(events).orderBy(desc(events.index)).limit(limit).all();
|
||||
const rows = db.select().from(ledgerEvents).orderBy(desc(ledgerEvents.index)).limit(limit).all();
|
||||
return { events: rows };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requireRole } from "../auth.js";
|
||||
import {
|
||||
NoOpenSessionError,
|
||||
NoTariffError,
|
||||
type PayStation,
|
||||
} from "../pay-station.js";
|
||||
|
||||
// Pay-station endpoints (pay-on-foot). The terminal/operator UI quotes a session
|
||||
// then takes payment; the payment becomes a signed ledger event. PCI scope stays
|
||||
// OUT of the app — actual card capture is a standalone P2PE terminal; here `tender`
|
||||
// just records cash vs. card. See wiki/concepts/tariff.md, parking-session.md, bom.md.
|
||||
|
||||
interface QuoteQuery {
|
||||
identity: string;
|
||||
}
|
||||
interface PayBody {
|
||||
identity: string;
|
||||
tender: "cash" | "card";
|
||||
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
|
||||
overrideMinor?: number;
|
||||
}
|
||||
|
||||
export async function payRoutes(app: FastifyInstance, payStation: PayStation): Promise<void> {
|
||||
// Cashier/operator/admin operate the pay station; readonly may not.
|
||||
const guard = requireRole("admin", "operator", "cashier");
|
||||
|
||||
// Quote: what does this session owe right now? (No side effect.)
|
||||
app.get<{ Querystring: QuoteQuery }>(
|
||||
"/api/pay/quote",
|
||||
{ preHandler: guard },
|
||||
async (req, reply) => {
|
||||
const identity = (req.query.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
try {
|
||||
return payStation.quote(identity);
|
||||
} catch (err) {
|
||||
return mapError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Pay: take payment and append the signed `payment` event.
|
||||
app.post<{ Body: PayBody }>(
|
||||
"/api/pay",
|
||||
{ preHandler: guard },
|
||||
async (req, reply) => {
|
||||
const { identity, tender, overrideMinor } = req.body ?? {};
|
||||
if (!identity || (tender !== "cash" && tender !== "card")) {
|
||||
return reply.code(400).send({ error: "identity and tender (cash|card) required" });
|
||||
}
|
||||
if (overrideMinor != null && (!Number.isInteger(overrideMinor) || overrideMinor < 0)) {
|
||||
return reply.code(400).send({ error: "overrideMinor must be a non-negative integer (minor units)" });
|
||||
}
|
||||
try {
|
||||
const res = await payStation.pay(identity, tender, overrideMinor);
|
||||
return reply.code(201).send(res);
|
||||
} catch (err) {
|
||||
return mapError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function mapError(reply: import("fastify").FastifyReply, err: unknown) {
|
||||
if (err instanceof NoOpenSessionError) return reply.code(404).send({ error: err.message });
|
||||
if (err instanceof NoTariffError) return reply.code(409).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, permitCredentials, permitPlates, permits, type Db } from "@parking/db";
|
||||
import { requireRole } from "../auth.js";
|
||||
|
||||
// Permit (subscription) admin CRUD. A permit is mutable master data — admins
|
||||
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
|
||||
// trail stays append-only (see wiki/entities/permit.md). A permit is an aggregate:
|
||||
// the permit row + its credentials (card/QR) + its bound plates. The API treats them
|
||||
// as one unit (create/update replace the child sets; delete removes all).
|
||||
|
||||
interface Credential {
|
||||
kind: "rf" | "qr";
|
||||
value: string;
|
||||
}
|
||||
interface PermitBody {
|
||||
holderName?: string;
|
||||
contact?: string;
|
||||
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
|
||||
maxConcurrent?: number | null;
|
||||
validFrom?: string | null;
|
||||
validTo?: string | null;
|
||||
status?: "active" | "suspended" | "revoked";
|
||||
credentials?: Credential[];
|
||||
/** Plate binding (optional): bound plates that also serve as identity. */
|
||||
plates?: string[];
|
||||
}
|
||||
|
||||
export async function permitRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Admin manages permits; operator/cashier/readonly may LIST (to look one up).
|
||||
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
const writeGuard = requireRole("admin");
|
||||
|
||||
// Validate the body; returns problems (empty = ok). Shared by create + update.
|
||||
function validate(b: PermitBody): string[] {
|
||||
const errs: string[] = [];
|
||||
if (b.maxConcurrent != null) {
|
||||
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
|
||||
errs.push("maxConcurrent must be a positive integer, or null for unbound");
|
||||
}
|
||||
}
|
||||
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
||||
errs.push("status must be active|suspended|revoked");
|
||||
}
|
||||
for (const c of b.credentials ?? []) {
|
||||
if ((c.kind !== "rf" && c.kind !== "qr") || !c.value?.trim()) {
|
||||
errs.push("each credential needs kind (rf|qr) and a non-empty value");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
|
||||
errs.push("a permit needs at least one credential or one bound plate (else nothing identifies it)");
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
function loadAggregate(id: string) {
|
||||
const permit = db.select().from(permits).where(eq(permits.id, id)).get();
|
||||
if (!permit) return null;
|
||||
const credentials = db.select().from(permitCredentials).where(eq(permitCredentials.permitId, id)).all();
|
||||
const plates = db.select().from(permitPlates).where(eq(permitPlates.permitId, id)).all();
|
||||
return {
|
||||
...permit,
|
||||
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
|
||||
plates: plates.map((p) => p.plate),
|
||||
};
|
||||
}
|
||||
|
||||
// Replace a permit's child rows (credentials + plates) from the body.
|
||||
function writeChildren(id: string, b: PermitBody) {
|
||||
db.delete(permitCredentials).where(eq(permitCredentials.permitId, id)).run();
|
||||
db.delete(permitPlates).where(eq(permitPlates.permitId, id)).run();
|
||||
for (const c of b.credentials ?? []) {
|
||||
db.insert(permitCredentials).values({ id: randomUUID(), permitId: id, kind: c.kind, value: c.value.trim() }).run();
|
||||
}
|
||||
for (const p of b.plates ?? []) {
|
||||
if (p.trim()) db.insert(permitPlates).values({ id: randomUUID(), permitId: id, plate: p.trim() }).run();
|
||||
}
|
||||
}
|
||||
|
||||
// List all permits (with their credentials + plates).
|
||||
app.get("/api/permits", { preHandler: readGuard }, async () => {
|
||||
const rows = db.select().from(permits).all();
|
||||
return { permits: rows.map((r) => loadAggregate(r.id)) };
|
||||
});
|
||||
|
||||
// Create a permit.
|
||||
app.post<{ Body: PermitBody }>("/api/permits", { preHandler: writeGuard }, async (req, reply) => {
|
||||
const b = req.body ?? {};
|
||||
const problems = validate(b);
|
||||
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
|
||||
const id = randomUUID();
|
||||
db.insert(permits)
|
||||
.values({
|
||||
id,
|
||||
holderName: b.holderName ?? null,
|
||||
contact: b.contact ?? null,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
|
||||
validFrom: b.validFrom ?? null,
|
||||
validTo: b.validTo ?? null,
|
||||
status: b.status ?? "active",
|
||||
})
|
||||
.run();
|
||||
writeChildren(id, b);
|
||||
return reply.code(201).send(loadAggregate(id));
|
||||
});
|
||||
|
||||
// Update a permit (replaces fields + child sets).
|
||||
app.put<{ Params: { id: string }; Body: PermitBody }>(
|
||||
"/api/permits/:id",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const existing = db.select().from(permits).where(eq(permits.id, req.params.id)).get();
|
||||
if (!existing) return reply.code(404).send({ error: "permit not found" });
|
||||
const b = req.body ?? {};
|
||||
const problems = validate(b);
|
||||
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
|
||||
db.update(permits)
|
||||
.set({
|
||||
holderName: b.holderName ?? null,
|
||||
contact: b.contact ?? null,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
|
||||
validFrom: b.validFrom ?? null,
|
||||
validTo: b.validTo ?? null,
|
||||
status: b.status ?? existing.status,
|
||||
})
|
||||
.where(eq(permits.id, req.params.id))
|
||||
.run();
|
||||
writeChildren(req.params.id, b);
|
||||
return loadAggregate(req.params.id);
|
||||
},
|
||||
);
|
||||
|
||||
// Revoke (soft): the common case — keeps the permit + its history, just bars it.
|
||||
// A revoked permit fails the entry check (see permit-flow.ts). Use DELETE only to
|
||||
// fully remove a permit created in error.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/api/permits/:id/revoke",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const r = db.update(permits).set({ status: "revoked" }).where(eq(permits.id, req.params.id)).run();
|
||||
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
|
||||
return loadAggregate(req.params.id);
|
||||
},
|
||||
);
|
||||
|
||||
// Hard delete a permit + its child rows. (Past ledger events that reference it
|
||||
// are untouched — the audit trail is append-only and independent of this row.)
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/permits/:id",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const r = db.delete(permits).where(eq(permits.id, req.params.id)).run();
|
||||
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
|
||||
db.delete(permitCredentials).where(eq(permitCredentials.permitId, req.params.id)).run();
|
||||
db.delete(permitPlates).where(eq(permitPlates.permitId, req.params.id)).run();
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -62,11 +62,13 @@ export async function setupRoutes(
|
||||
const adminGuard = requireRole("admin");
|
||||
|
||||
// Catalog of selectable drivers per category (no secrets — schema only).
|
||||
// `discoverable` flags drivers that can scan the LAN.
|
||||
// `discoverable` flags drivers that can scan the LAN; `pushCapable` flags
|
||||
// drivers that push to the backend (and thus need a backend IP at assign time).
|
||||
app.get("/api/setup/catalog", async () => {
|
||||
const catalog = registry.catalog();
|
||||
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
||||
return { ...catalog, discoverable };
|
||||
const pushCapable = registry.pushCapable();
|
||||
return { ...catalog, discoverable, pushCapable };
|
||||
});
|
||||
|
||||
// Scan the LAN for devices a driver can discover (UDP broadcast, etc).
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, eq, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { validateTariffStructure, type TariffStructure } from "@parking/shared";
|
||||
import { requireRole } from "../auth.js";
|
||||
|
||||
// Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs
|
||||
// are EFFECTIVE-DATED IMMUTABLE VERSIONS: editing publishes a new version, never
|
||||
// mutates one; a session reprices against the version in force at its entry, and
|
||||
// the `payment` event records the tariffVersionId. "One active tariff per site" for
|
||||
// now (a single `tariffs` row, lazily created). See wiki/concepts/tariff.md.
|
||||
|
||||
interface PublishBody {
|
||||
currency: string;
|
||||
structure: TariffStructure;
|
||||
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||
effectiveFrom?: string;
|
||||
}
|
||||
|
||||
const SITE_TARIFF_NAME = "Site tariff";
|
||||
|
||||
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Any signed-in role may READ the tariff (the pay station / operator UI needs it).
|
||||
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
// Only an admin may PUBLISH a new version (it changes what customers are charged).
|
||||
const writeGuard = requireRole("admin");
|
||||
|
||||
// The single site tariff row, created on first read/publish.
|
||||
function ensureSiteTariff(): string {
|
||||
const existing = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||
if (existing) return existing.id;
|
||||
const id = randomUUID();
|
||||
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
|
||||
return id;
|
||||
}
|
||||
|
||||
// Current state: the active (latest-effective, ≤ now) version + the full history.
|
||||
app.get("/api/tariff", { preHandler: readGuard }, async () => {
|
||||
const tariffId = ensureSiteTariff();
|
||||
const versions = db
|
||||
.select()
|
||||
.from(tariffVersions)
|
||||
.where(eq(tariffVersions.tariffId, tariffId))
|
||||
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||
.all();
|
||||
const now = new Date().toISOString();
|
||||
const active = versions.find((v) => v.effectiveFrom <= now) ?? null;
|
||||
return { tariffId, active, versions };
|
||||
});
|
||||
|
||||
// Publish a new immutable version. Validates the structure first — a malformed
|
||||
// rate card can never be published (the fee calc + the chain depend on it).
|
||||
app.post<{ Body: PublishBody }>(
|
||||
"/api/tariff/versions",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
|
||||
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
||||
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
||||
}
|
||||
const problems = validateTariffStructure(structure);
|
||||
if (problems.length) {
|
||||
return reply.code(400).send({ error: "invalid tariff structure", problems });
|
||||
}
|
||||
const tariffId = ensureSiteTariff();
|
||||
const id = randomUUID();
|
||||
const row = {
|
||||
id,
|
||||
tariffId,
|
||||
effectiveFrom: effectiveFrom ?? new Date().toISOString(),
|
||||
currency,
|
||||
structure: structure as unknown as Record<string, unknown>,
|
||||
createdBy: req.user?.username ?? null,
|
||||
};
|
||||
db.insert(tariffVersions).values(row).run();
|
||||
return reply.code(201).send(row);
|
||||
},
|
||||
);
|
||||
}
|
||||
+69
-23
@@ -1,16 +1,25 @@
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { createDb, type Db } from "@parking/db";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
||||
import { deviceEvents } from "./device-events.js";
|
||||
import { EntryFlow } from "./entry-flow.js";
|
||||
import { EventLog } from "./event-log.js";
|
||||
import { ExitFlow } from "./exit-flow.js";
|
||||
import { PayStation } from "./pay-station.js";
|
||||
import { PermitFlow } from "./permit-flow.js";
|
||||
import { ReadDispatcher } from "./read-dispatch.js";
|
||||
import { LaneMap } from "./lane-map.js";
|
||||
import { PrinterMonitor } from "./printer-monitor.js";
|
||||
import { buildSigner } from "./signer.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { deviceRoutes } from "./routes/devices.js";
|
||||
import { eventRoutes } from "./routes/events.js";
|
||||
import { payRoutes } from "./routes/pay.js";
|
||||
import { permitRoutes } from "./routes/permits.js";
|
||||
import { tariffRoutes } from "./routes/tariffs.js";
|
||||
import { printerRoutes } from "./routes/printers.js";
|
||||
import { setupRoutes } from "./routes/setup.js";
|
||||
|
||||
@@ -38,7 +47,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// The token is carried in an HttpOnly cookie (not the Authorization header).
|
||||
await app.register(jwt, {
|
||||
secret: requireJwtSecret(),
|
||||
sign: { expiresIn: "8h" }, // bound to a shift; minted tokens must expire
|
||||
// No expiry: a login is valid until explicit logout — a shift is a separate
|
||||
// boundary, not the token lifetime (see auth.ts + wiki/concepts/shift.md).
|
||||
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
||||
});
|
||||
|
||||
@@ -70,39 +80,75 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
app.addHook("onReady", async () => printerMonitor.start());
|
||||
app.addHook("onClose", async () => printerMonitor.stop());
|
||||
|
||||
// Append-only signed event log. Subscribe device pushes (e.g. Dingtian button
|
||||
// presses) into the hash-chained, signed `events` table — the anti-fraud audit
|
||||
// trail. The device is NOT trusted; the host record is the source of truth, and
|
||||
// a relay open with no matching signed event is itself the anomaly. We record
|
||||
// the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that
|
||||
// comes with the full entry flow). See wiki/concepts/append-only-event-chain.md.
|
||||
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||
// in device_events. The entry flow (TODO) turns an input into a signed
|
||||
// vehicle_entry once a ticket prints + the barrier is commanded.
|
||||
// See wiki/decisions/event-streams-split.md.
|
||||
const eventLog = new EventLog(db, buildSigner(app.log));
|
||||
await eventRoutes(app, db, eventLog);
|
||||
|
||||
// Entry flow: a button press → print ticket → signed vehicle_entry → pulseOpen.
|
||||
// Subscribes to the SAME input bus as the telemetry writer below; the two are
|
||||
// independent (telemetry always records; the entry flow acts only on an access
|
||||
// device's rising edge). See wiki/concepts/device-input-flow.md + parking-session.md.
|
||||
const entryFlow = new EntryFlow(db, eventLog, laneMap, app.log);
|
||||
const unsubscribeEntry = deviceEvents.onInput((e) => {
|
||||
void entryFlow.onInput(e);
|
||||
});
|
||||
app.addHook("onClose", async () => unsubscribeEntry());
|
||||
|
||||
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||
// dispatcher to either the PERMIT flow (if it matches a permit) or the transient
|
||||
// EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md.
|
||||
const exitFlow = new ExitFlow(db, eventLog, app.log);
|
||||
const permitFlow = new PermitFlow(db, eventLog, app.log);
|
||||
const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log);
|
||||
const unsubscribeRead = deviceEvents.onRead((e) => {
|
||||
void readDispatcher.dispatch(e);
|
||||
});
|
||||
app.addHook("onClose", async () => unsubscribeRead());
|
||||
|
||||
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
||||
// take payment → signed `payment` event. See wiki/concepts/tariff.md.
|
||||
const payStation = new PayStation(db, eventLog, app.log);
|
||||
await payRoutes(app, payStation);
|
||||
|
||||
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||
await tariffRoutes(app, db);
|
||||
|
||||
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
||||
await permitRoutes(app, db);
|
||||
|
||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
||||
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
||||
// faithfully (the chain is append-only) rather than silently dropped or
|
||||
// mis-stamped as lane 0, which is a real lane.
|
||||
// faithfully rather than silently dropped or mis-stamped as lane 0 (a real lane).
|
||||
const lane = laneMap.laneFor(e.deviceId) ?? -1;
|
||||
if (lane === -1) {
|
||||
app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`);
|
||||
}
|
||||
eventLog
|
||||
.append({
|
||||
type: "input_received",
|
||||
lane,
|
||||
// `source` is an IdentitySource (wiegand/lpr/qr/ticket/manual) — how a
|
||||
// VEHICLE was identified. A raw input has none, so it stays null. The
|
||||
// device provenance lives in `identity` instead.
|
||||
source: null,
|
||||
identity: `${e.driverId}:${e.deviceId} input:${e.input}/${e.edge}`,
|
||||
occurredAt: e.at,
|
||||
})
|
||||
.catch((err) => app.log.error(`event-log append failed: ${(err as Error).message}`));
|
||||
try {
|
||||
db.insert(deviceEventsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
deviceId: e.deviceId,
|
||||
lane,
|
||||
category: "access",
|
||||
kind: "input",
|
||||
detail: { driverId: e.driverId, input: e.input, edge: e.edge },
|
||||
occurredAt: e.at,
|
||||
})
|
||||
.run();
|
||||
} catch (err) {
|
||||
app.log.error(`device-event insert failed: ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
app.addHook("onClose", async () => unsubscribeInput());
|
||||
|
||||
// TODO: entry flow (input event → signed event → print → relay).
|
||||
// TODO: entry flow (device input → signed vehicle_entry → print → relay).
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchMe, logout, type SessionUser } from "./api.js";
|
||||
import { Login } from "./Login.js";
|
||||
import { PermitManager } from "./PermitManager.js";
|
||||
import { SetupWizard } from "./SetupWizard.js";
|
||||
import { TariffComposer } from "./TariffComposer.js";
|
||||
|
||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
||||
// simple enough that a framework's abstractions cost more than they save.
|
||||
@@ -39,7 +41,11 @@ export function App() {
|
||||
</span>
|
||||
</header>
|
||||
{user.role === "admin" ? (
|
||||
<SetupWizard />
|
||||
<>
|
||||
<SetupWizard />
|
||||
<TariffComposer />
|
||||
<PermitManager />
|
||||
</>
|
||||
) : (
|
||||
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ApiError,
|
||||
createPermit,
|
||||
deletePermit,
|
||||
fetchPermits,
|
||||
revokePermit,
|
||||
updatePermit,
|
||||
type Permit,
|
||||
type PermitCredential,
|
||||
type PermitInput,
|
||||
} from "./api.js";
|
||||
|
||||
// Permit (subscription) admin. Create/edit/revoke/delete permits + their
|
||||
// credentials (card/QR) and bound plates. A permit is mutable master data; every
|
||||
// USE of it is a signed ledger event elsewhere. See wiki/entities/permit.md.
|
||||
|
||||
interface FormState {
|
||||
holderName: string;
|
||||
contact: string;
|
||||
carBound: boolean; // false = unbound (maxConcurrent null)
|
||||
maxConcurrent: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
credentials: PermitCredential[];
|
||||
platesText: string; // comma/space separated
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return { holderName: "", contact: "", carBound: true, maxConcurrent: "1", validFrom: "", validTo: "", credentials: [{ kind: "rf", value: "" }], platesText: "" };
|
||||
}
|
||||
function formFrom(p: Permit): FormState {
|
||||
return {
|
||||
holderName: p.holderName ?? "",
|
||||
contact: p.contact ?? "",
|
||||
carBound: p.maxConcurrent != null,
|
||||
maxConcurrent: p.maxConcurrent != null ? String(p.maxConcurrent) : "1",
|
||||
validFrom: p.validFrom ?? "",
|
||||
validTo: p.validTo ?? "",
|
||||
credentials: p.credentials.length ? p.credentials : [{ kind: "rf", value: "" }],
|
||||
platesText: p.plates.join(", "),
|
||||
};
|
||||
}
|
||||
function toInput(f: FormState): PermitInput {
|
||||
return {
|
||||
holderName: f.holderName.trim() || null,
|
||||
contact: f.contact.trim() || null,
|
||||
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
|
||||
validFrom: f.validFrom.trim() || null,
|
||||
validTo: f.validTo.trim() || null,
|
||||
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
|
||||
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export function PermitManager() {
|
||||
const [permits, setPermits] = useState<Permit[] | null>(null);
|
||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
function reload() {
|
||||
fetchPermits()
|
||||
.then((r) => setPermits(r.permits))
|
||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
}
|
||||
useEffect(reload, []);
|
||||
|
||||
function startNew() {
|
||||
setForm(emptyForm());
|
||||
setEditing("new");
|
||||
setMsg(null);
|
||||
}
|
||||
function startEdit(p: Permit) {
|
||||
setForm(formFrom(p));
|
||||
setEditing(p.id);
|
||||
setMsg(null);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
if (editing === "new") await createPermit(toInput(form));
|
||||
else if (editing) await updatePermit(editing, toInput(form));
|
||||
setEditing(null);
|
||||
reload();
|
||||
setMsg({ kind: "ok", text: "Permit saved." });
|
||||
} catch (e) {
|
||||
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
|
||||
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
|
||||
}
|
||||
}
|
||||
async function doRevoke(p: Permit) {
|
||||
if (!confirm(`Revoke permit for ${p.holderName ?? p.id}? It will be refused at the barrier.`)) return;
|
||||
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
reload();
|
||||
}
|
||||
async function doDelete(p: Permit) {
|
||||
if (!confirm(`Delete permit for ${p.holderName ?? p.id}? (Past events are kept.)`)) return;
|
||||
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
reload();
|
||||
}
|
||||
|
||||
function setCred(i: number, patch: Partial<PermitCredential>) {
|
||||
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
|
||||
}
|
||||
|
||||
if (!permits) return null;
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>Permits</h2>
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{permits.map((p) => (
|
||||
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
|
||||
<strong>{p.holderName ?? "(unnamed)"}</strong>
|
||||
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{p.status}</span>
|
||||
<span style={{ color: "#666" }}>
|
||||
{p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "}
|
||||
{p.credentials.length} cred · {p.plates.length} plate(s)
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" onClick={() => startEdit(p)}>Edit</button>
|
||||
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>Revoke</button>}
|
||||
<button type="button" onClick={() => doDelete(p)}>Delete</button>
|
||||
</li>
|
||||
))}
|
||||
{permits.length === 0 && <li style={{ color: "#777" }}>No permits yet.</li>}
|
||||
</ul>
|
||||
|
||||
{editing == null ? (
|
||||
<button type="button" onClick={startNew}>+ Add permit</button>
|
||||
) : (
|
||||
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
|
||||
<h3 style={{ marginTop: 0 }}>{editing === "new" ? "New permit" : "Edit permit"}</h3>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
|
||||
<label>Holder name</label>
|
||||
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||||
<label>Contact</label>
|
||||
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||
<label>Car limit</label>
|
||||
<span>
|
||||
<label style={{ marginRight: "0.5rem" }}>
|
||||
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> limit cars in at once
|
||||
</label>
|
||||
{form.carBound && (
|
||||
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
|
||||
)}
|
||||
</span>
|
||||
<label>Valid from</label>
|
||||
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" />
|
||||
<label>Valid to</label>
|
||||
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" />
|
||||
<label>Bound plates</label>
|
||||
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" />
|
||||
</div>
|
||||
|
||||
<h4 style={{ marginBottom: "0.25rem" }}>Credentials (card / QR)</h4>
|
||||
{form.credentials.map((c, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
||||
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||||
<option value="rf">RF card/tag</option>
|
||||
<option value="qr">QR</option>
|
||||
</select>
|
||||
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder="credential value" style={{ flex: 1 }} />
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>+ credential</button>
|
||||
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||
A permit needs at least one credential OR one bound plate.
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||||
<button type="button" onClick={save}>Save</button>
|
||||
<button type="button" onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export function SetupWizard() {
|
||||
noun={noun}
|
||||
entries={catalog[key]}
|
||||
discoverableIds={catalog.discoverable}
|
||||
pushCapableIds={catalog.pushCapable}
|
||||
assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
|
||||
onChanged={reloadState}
|
||||
/>
|
||||
@@ -93,6 +94,7 @@ function CategorySection({
|
||||
noun,
|
||||
entries,
|
||||
discoverableIds,
|
||||
pushCapableIds,
|
||||
assignments,
|
||||
onChanged,
|
||||
}: {
|
||||
@@ -102,6 +104,7 @@ function CategorySection({
|
||||
noun: string;
|
||||
entries: CatalogEntry[];
|
||||
discoverableIds: string[];
|
||||
pushCapableIds: string[];
|
||||
assignments: Assignment[];
|
||||
onChanged: () => Promise<void> | void;
|
||||
}) {
|
||||
@@ -155,6 +158,7 @@ function CategorySection({
|
||||
category={category}
|
||||
entries={entries}
|
||||
discoverableIds={discoverableIds}
|
||||
pushCapableIds={pushCapableIds}
|
||||
onSaved={async (w) => {
|
||||
setWarnings(w);
|
||||
await onChanged();
|
||||
@@ -227,6 +231,7 @@ function DeviceForm({
|
||||
category,
|
||||
entries,
|
||||
discoverableIds,
|
||||
pushCapableIds,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
@@ -234,12 +239,17 @@ function DeviceForm({
|
||||
category: DeviceCategory;
|
||||
entries: CatalogEntry[];
|
||||
discoverableIds: string[];
|
||||
pushCapableIds: string[];
|
||||
onSaved: (warnings: string[]) => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const selected = entries.find((e) => e.id === selectedId);
|
||||
const canDiscover = selected != null && discoverableIds.includes(selected.id);
|
||||
// Only push-capable drivers (e.g. the Dingtian relay) call back to the
|
||||
// backend and need a backend IP. Pull-only devices (cameras, commanded relays)
|
||||
// must NOT show the field. See wiki/concepts/device-input-flow.md.
|
||||
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
||||
|
||||
// Config values (auto-filled by discovery, editable by hand).
|
||||
const [config, setConfig] = useState<Record<string, string | number>>({});
|
||||
@@ -255,15 +265,16 @@ function DeviceForm({
|
||||
// Backend push IP: which of OUR addresses the device should call back on. We
|
||||
// auto-pick the NIC on the device's subnet, but surface it editable here so a
|
||||
// multi-NIC host can be corrected (the chosen IP is baked into the device on
|
||||
// save). Only relevant for drivers that push (the field hides if no candidates).
|
||||
// save). Only relevant for drivers that push back to us (pushesToBackend).
|
||||
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
||||
const [backendIp, setBackendIp] = useState<string>("");
|
||||
|
||||
// (Re)load backend-IP candidates whenever the device host changes after a
|
||||
// successful test (the test confirms the host is real + reachable).
|
||||
// successful test (the test confirms the host is real + reachable) — but only
|
||||
// for push-capable drivers; a pull-only device never calls back.
|
||||
const testedHost = tested ? String(mergedConfig().host ?? "") : "";
|
||||
useEffect(() => {
|
||||
if (!testedHost) {
|
||||
if (!testedHost || !pushesToBackend) {
|
||||
setBackendIps(null);
|
||||
return;
|
||||
}
|
||||
@@ -281,7 +292,7 @@ function DeviceForm({
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [testedHost]);
|
||||
}, [testedHost, pushesToBackend]);
|
||||
|
||||
function selectDriver(id: string) {
|
||||
setSelectedId(id);
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ApiError,
|
||||
fetchTariff,
|
||||
publishTariffVersion,
|
||||
type TariffBlock,
|
||||
type TariffStructure,
|
||||
type TariffState,
|
||||
} from "./api.js";
|
||||
|
||||
// Tariff composer — the admin builds + edits the rate card at runtime. Publishing
|
||||
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
||||
// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for
|
||||
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
|
||||
|
||||
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
||||
interface BlockForm {
|
||||
uptoMin: string; // "" = open-ended (last block)
|
||||
price: string; // major units, e.g. "2.00"
|
||||
}
|
||||
interface FormState {
|
||||
currency: string;
|
||||
gracePeriodEntryMin: string;
|
||||
incrementMin: string;
|
||||
dailyCap: string; // "" = no cap
|
||||
lostTicket: string;
|
||||
gracePeriodExitMin: string;
|
||||
blocks: BlockForm[];
|
||||
}
|
||||
|
||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
currency: "EUR",
|
||||
gracePeriodEntryMin: "15",
|
||||
incrementMin: "60",
|
||||
dailyCap: "",
|
||||
lostTicket: "20.00",
|
||||
gracePeriodExitMin: "15",
|
||||
blocks: [{ uptoMin: "60", price: "2.00" }, { uptoMin: "", price: "1.00" }],
|
||||
};
|
||||
}
|
||||
|
||||
function formFromActive(s: TariffState): FormState {
|
||||
const v = s.active;
|
||||
if (!v) return emptyForm();
|
||||
const st = v.structure;
|
||||
return {
|
||||
currency: v.currency,
|
||||
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
||||
incrementMin: String(st.incrementMin),
|
||||
dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor),
|
||||
lostTicket: toMajor(st.lostTicketMinor),
|
||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||
blocks: st.blocks.map((b) => ({
|
||||
uptoMin: b.uptoMin == null ? "" : String(b.uptoMin),
|
||||
price: toMajor(b.priceMinorPerIncrement),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function toStructure(f: FormState): TariffStructure {
|
||||
const blocks: TariffBlock[] = f.blocks.map((b) => ({
|
||||
uptoMin: b.uptoMin.trim() === "" ? null : Math.round(Number(b.uptoMin)),
|
||||
priceMinorPerIncrement: toMinor(b.price),
|
||||
}));
|
||||
return {
|
||||
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||
incrementMin: Math.round(Number(f.incrementMin)),
|
||||
blocks,
|
||||
dailyCapMinor: f.dailyCap.trim() === "" ? null : toMinor(f.dailyCap),
|
||||
lostTicketMinor: toMinor(f.lostTicket),
|
||||
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
||||
overstay: "reprice",
|
||||
};
|
||||
}
|
||||
|
||||
export function TariffComposer() {
|
||||
const [state, setState] = useState<TariffState | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTariff()
|
||||
.then((s) => {
|
||||
setState(s);
|
||||
setForm(formFromActive(s));
|
||||
})
|
||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
}, []);
|
||||
|
||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
function setBlock(i: number, patch: Partial<BlockForm>) {
|
||||
setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||
}
|
||||
function addBlock() {
|
||||
setForm((f) => ({ ...f, blocks: [...f.blocks, { uptoMin: "", price: "0.00" }] }));
|
||||
}
|
||||
function removeBlock(i: number) {
|
||||
setForm((f) => ({ ...f, blocks: f.blocks.filter((_, j) => j !== i) }));
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
|
||||
const fresh = await fetchTariff();
|
||||
setState(fresh);
|
||||
setMsg({ kind: "ok", text: "New tariff version published — it's now the active rate card." });
|
||||
} catch (e) {
|
||||
const text =
|
||||
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
|
||||
? `${e.message}: ${((e as ApiError & { problems?: string[] }).problems ?? []).join("; ")}`
|
||||
: (e as Error).message;
|
||||
setMsg({ kind: "err", text });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>Tariff</h2>
|
||||
{!state?.active ? (
|
||||
<p style={{ color: "#b45309" }}>
|
||||
No rate card published yet — the pay station can't charge until you publish one.
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ color: "#555" }}>
|
||||
Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "}
|
||||
{state.versions.length} version(s) in history. Publishing creates a new version; past
|
||||
sessions keep their original pricing.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
|
||||
<label>Currency</label>
|
||||
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
|
||||
<label>Free entry grace (min)</label>
|
||||
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||
<label>Billing increment (min)</label>
|
||||
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||
<label>Daily cap (blank = none)</label>
|
||||
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" />
|
||||
<label>Lost-ticket fee</label>
|
||||
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||
<label>Exit walk-back grace (min)</label>
|
||||
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3>
|
||||
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>
|
||||
Consumed in order as time accrues. "Up to (min)" is the block's upper bound; leave the last
|
||||
block's bound blank for "thereafter". Price is per billing increment.
|
||||
</p>
|
||||
<table style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>Price / increment</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{form.blocks.map((b, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
<input
|
||||
value={b.uptoMin}
|
||||
onChange={(e) => setBlock(i, { uptoMin: e.target.value })}
|
||||
placeholder={i === form.blocks.length - 1 ? "thereafter" : "e.g. 60"}
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
||||
+ Add block
|
||||
</button>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<button type="button" onClick={publish} disabled={saving}>
|
||||
{saving ? "Publishing…" : "Publish new version"}
|
||||
</button>
|
||||
</div>
|
||||
{msg && (
|
||||
<p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson", marginTop: "0.5rem" }}>{msg.text}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -96,6 +96,8 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
||||
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
||||
/** Driver ids that support LAN discovery. */
|
||||
discoverable: string[];
|
||||
/** Driver ids that push to the backend (need a backend IP at assign time). */
|
||||
pushCapable: string[];
|
||||
};
|
||||
|
||||
export function fetchCatalog(): Promise<Catalog> {
|
||||
@@ -195,3 +197,83 @@ export function fetchState(): Promise<SetupState> {
|
||||
export function unassignDevice(id: string): Promise<void> {
|
||||
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// --- Tariff composer ------------------------------------------------------
|
||||
|
||||
export interface TariffBlock {
|
||||
uptoMin: number | null;
|
||||
priceMinorPerIncrement: number;
|
||||
}
|
||||
export interface TariffStructure {
|
||||
gracePeriodEntryMin: number;
|
||||
incrementMin: number;
|
||||
blocks: TariffBlock[];
|
||||
dailyCapMinor: number | null;
|
||||
lostTicketMinor: number;
|
||||
gracePeriodExitMin: number;
|
||||
overstay: "reprice";
|
||||
}
|
||||
export interface TariffVersion {
|
||||
id: string;
|
||||
tariffId: string;
|
||||
effectiveFrom: string;
|
||||
currency: string;
|
||||
structure: TariffStructure;
|
||||
createdBy?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
export interface TariffState {
|
||||
tariffId: string;
|
||||
active: TariffVersion | null;
|
||||
versions: TariffVersion[];
|
||||
}
|
||||
|
||||
export function fetchTariff(): Promise<TariffState> {
|
||||
return apiFetch<TariffState>("/api/tariff");
|
||||
}
|
||||
|
||||
/** Publish a new immutable tariff version (becomes the active rate card). */
|
||||
export function publishTariffVersion(body: {
|
||||
currency: string;
|
||||
structure: TariffStructure;
|
||||
effectiveFrom?: string;
|
||||
}): Promise<TariffVersion> {
|
||||
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
// --- Permits --------------------------------------------------------------
|
||||
|
||||
export interface PermitCredential {
|
||||
kind: "rf" | "qr";
|
||||
value: string;
|
||||
}
|
||||
export interface Permit {
|
||||
id: string;
|
||||
holderName: string | null;
|
||||
contact: string | null;
|
||||
maxConcurrent: number | null;
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
status: "active" | "suspended" | "revoked";
|
||||
credentials: PermitCredential[];
|
||||
plates: string[];
|
||||
}
|
||||
export type PermitInput = Omit<Permit, "id" | "status"> & {
|
||||
status?: Permit["status"];
|
||||
};
|
||||
|
||||
export function fetchPermits(): Promise<{ permits: Permit[] }> {
|
||||
return apiFetch("/api/permits");
|
||||
}
|
||||
export function createPermit(body: PermitInput): Promise<Permit> {
|
||||
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function updatePermit(id: string, body: PermitInput): Promise<Permit> {
|
||||
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
export function revokePermit(id: string): Promise<Permit> {
|
||||
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" });
|
||||
}
|
||||
export function deletePermit(id: string): Promise<void> {
|
||||
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Parking dev: pin route source addresses (WSL2 mirrored-mode fix)
|
||||
# Run after WSL has populated the mirrored interfaces/addresses.
|
||||
After=network.target wsl-pro.service
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
# Idempotent; safe to re-run. Path is the repo checkout on this dev box.
|
||||
ExecStart=/home/julian/projects/JS/parking-system/deploy/wsl-fix-route-source.sh eth1
|
||||
# Mirrored-mode addresses can land slightly after boot; one retry covers the race.
|
||||
ExecStartPost=/bin/sh -c 'sleep 3; /home/julian/projects/JS/parking-system/deploy/wsl-fix-route-source.sh eth1 || true'
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# WSL2 mirrored-mode source-address fix (dev box only).
|
||||
#
|
||||
# Problem: in WSL2 mirrored networking the Windows host's interfaces — and ALL
|
||||
# their IPs — are cloned into Linux on every boot. When two device subnets land
|
||||
# on one NIC (e.g. 192.168.1.x AND 10.0.10.x on eth1), the kernel's connected
|
||||
# routes come up `scope link` with NO preferred source, and source selection can
|
||||
# pick the WRONG address (sourcing 10.0.10.x traffic from 192.168.1.123). ARP
|
||||
# still resolves (L2), so the device looks REACHABLE while every ping/TCP times
|
||||
# out. See wiki/concepts/wsl-dev-networking.md.
|
||||
#
|
||||
# Fix: for each connected `scope link` route, pin its preferred `src` to THIS
|
||||
# host's own address in that same subnet. No hardcoded IPs — derived at runtime,
|
||||
# so it also covers future device subnets. Idempotent; a no-op when nothing needs
|
||||
# fixing. Runs at boot via parking-net.service.
|
||||
#
|
||||
# Production note: the real appliance is bare-metal Linux, not WSL — there this
|
||||
# is just static networkd/netplan config. This script exists only for the dev box.
|
||||
# NB: intentionally NOT `set -e`. This is a best-effort boot fixer; an individual
|
||||
# `ip` call failing (e.g. a route not up yet) must not abort the rest.
|
||||
set -uo pipefail
|
||||
|
||||
fix_iface() {
|
||||
local iface="$1"
|
||||
# Each connected /N route on this iface that the kernel manages (proto kernel,
|
||||
# scope link) — i.e. the directly-attached subnets. Capture the full line so we
|
||||
# can preserve attributes (notably `metric`) when we replace the route.
|
||||
ip -4 route show dev "$iface" proto kernel scope link | while read -r line; do
|
||||
local subnet="${line%% *}" # e.g. "10.0.10.0/24"
|
||||
local prefix="${subnet%/*}"
|
||||
# Preserve a metric if the route has one (mirrored-mode routes carry e.g. 281);
|
||||
# replacing without it would change the route's priority.
|
||||
local metric=""
|
||||
case "$line" in *" metric "*) metric="metric ${line##* metric }";; esac
|
||||
|
||||
# Find THIS host's own address inside the same subnet — the correct src.
|
||||
local hostip=""
|
||||
local cidr
|
||||
for cidr in $(ip -4 -o addr show dev "$iface" | awk '{print $4}'); do
|
||||
if ipcalc_net "$cidr" "$subnet"; then hostip="${cidr%/*}"; break; fi
|
||||
done
|
||||
[ -n "$hostip" ] || continue
|
||||
|
||||
local current
|
||||
current=$(ip -4 route get "$prefix" 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' | head -1)
|
||||
[ "$current" = "$hostip" ] && continue # already correct — no-op
|
||||
|
||||
# `replace` creates-or-updates, so it works whether or not the route is
|
||||
# present yet (avoids the boot-race RTNETLINK "No such file" that `change` hits).
|
||||
# Non-fatal: a single failure must not abort the whole boot fixer.
|
||||
if ip route replace "$subnet" dev "$iface" proto kernel scope link src "$hostip" $metric; then
|
||||
echo "pinned $subnet -> src $hostip (was ${current:-none})"
|
||||
else
|
||||
echo "warn: could not pin $subnet -> src $hostip" >&2
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# True if address $1 (a.b.c.d/p) is inside subnet $2 (n.n.n.0/p), same prefix len.
|
||||
ipcalc_net() {
|
||||
local addr="${1%/*}" alen="${1#*/}"
|
||||
local net="${2%/*}" nlen="${2#*/}"
|
||||
[ "$alen" = "$nlen" ] || return 1
|
||||
# Compare the network part by masking both to /nlen.
|
||||
local a n
|
||||
a=$(mask_to_net "$addr" "$nlen")
|
||||
n=$(mask_to_net "$net" "$nlen")
|
||||
[ "$a" = "$n" ]
|
||||
}
|
||||
|
||||
# Mask an IPv4 dotted-quad to its /len network address.
|
||||
mask_to_net() {
|
||||
local ip="$1" len="$2"
|
||||
local IFS=. ; read -r o1 o2 o3 o4 <<<"$ip"
|
||||
local int=$(( (o1<<24) + (o2<<16) + (o3<<8) + o4 ))
|
||||
local mask=$(( len == 0 ? 0 : (0xFFFFFFFF << (32 - len)) & 0xFFFFFFFF ))
|
||||
local net=$(( int & mask ))
|
||||
echo "$(( (net>>24)&255 )).$(( (net>>16)&255 )).$(( (net>>8)&255 )).$(( net&255 ))"
|
||||
}
|
||||
|
||||
main() {
|
||||
# Default to eth1 (the mirrored LAN NIC here); accept overrides as args.
|
||||
local ifaces=("${@:-eth1}")
|
||||
# Boot race: WSL mirrored mode can populate the interface's addresses/routes a
|
||||
# beat after the unit starts. Wait (bounded) for at least one connected route
|
||||
# to appear on the first interface before pinning.
|
||||
local i tries=0
|
||||
for i in "${ifaces[@]}"; do
|
||||
while [ "$tries" -lt 15 ] \
|
||||
&& [ -z "$(ip -4 route show dev "$i" proto kernel scope link 2>/dev/null)" ]; do
|
||||
sleep 1; tries=$((tries + 1))
|
||||
done
|
||||
break
|
||||
done
|
||||
for i in "${ifaces[@]}"; do
|
||||
ip link show "$i" >/dev/null 2>&1 && fix_iface "$i"
|
||||
done
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,23 +0,0 @@
|
||||
CREATE TABLE `events` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`index` integer NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`direction` text,
|
||||
`lane` integer NOT NULL,
|
||||
`source` text,
|
||||
`identity` text,
|
||||
`occurred_at` text NOT NULL,
|
||||
`prev_hash` text,
|
||||
`signature` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `events_index_unique` ON `events` (`index`);--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||
@@ -0,0 +1,113 @@
|
||||
CREATE TABLE `blocklist` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`reason` text,
|
||||
`active` integer DEFAULT true NOT NULL,
|
||||
`added_by` text,
|
||||
`added_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `device_events` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`device_id` text,
|
||||
`lane` integer,
|
||||
`category` text,
|
||||
`kind` text NOT NULL,
|
||||
`detail` text,
|
||||
`occurred_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `lane_devices` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`lane` integer NOT NULL,
|
||||
`category` text NOT NULL,
|
||||
`driver_id` text NOT NULL,
|
||||
`config` text NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `ledger_events` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`index` integer NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`direction` text,
|
||||
`lane` integer NOT NULL,
|
||||
`source` text,
|
||||
`identity` text,
|
||||
`payload` text,
|
||||
`occurred_at` text NOT NULL,
|
||||
`prev_hash` text,
|
||||
`signature` text NOT NULL,
|
||||
`key_id` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `ledger_events_index_unique` ON `ledger_events` (`index`);--> statement-breakpoint
|
||||
CREATE TABLE `permit_credentials` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`permit_id` text NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`value` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `permit_plates` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`permit_id` text NOT NULL,
|
||||
`plate` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `permits` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`holder_name` text,
|
||||
`contact` text,
|
||||
`max_concurrent` integer DEFAULT 1,
|
||||
`valid_from` text,
|
||||
`valid_to` text,
|
||||
`status` text DEFAULT 'active' NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`lane` integer,
|
||||
`identity` text,
|
||||
`source` text,
|
||||
`permit_id` text,
|
||||
`entered_at` text NOT NULL,
|
||||
`exited_at` text,
|
||||
`state` text DEFAULT 'open' NOT NULL,
|
||||
`last_event_index` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `setup_state` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`completed_at` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `tariff_versions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`tariff_id` text NOT NULL,
|
||||
`effective_from` text NOT NULL,
|
||||
`currency` text NOT NULL,
|
||||
`structure` text NOT NULL,
|
||||
`created_by` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `tariffs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`scope` text DEFAULT 'site' NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||
@@ -1,14 +0,0 @@
|
||||
CREATE TABLE `lane_devices` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`lane` integer NOT NULL,
|
||||
`category` text NOT NULL,
|
||||
`driver_id` text NOT NULL,
|
||||
`config` text NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `setup_state` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`completed_at` text
|
||||
);
|
||||
@@ -1,11 +1,193 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "721bbb8f-b929-4018-9420-0ae75b03ff93",
|
||||
"id": "cd09c11f-4306-4ac8-a335-7c050d080ab6",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"events": {
|
||||
"name": "events",
|
||||
"blocklist": {
|
||||
"name": "blocklist",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reason": {
|
||||
"name": "reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active": {
|
||||
"name": "active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"added_by": {
|
||||
"name": "added_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"added_at": {
|
||||
"name": "added_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"device_events": {
|
||||
"name": "device_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"device_id": {
|
||||
"name": "device_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lane": {
|
||||
"name": "lane",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"detail": {
|
||||
"name": "detail",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"occurred_at": {
|
||||
"name": "occurred_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"lane_devices": {
|
||||
"name": "lane_devices",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lane": {
|
||||
"name": "lane",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"driver_id": {
|
||||
"name": "driver_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"ledger_events": {
|
||||
"name": "ledger_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
@@ -56,6 +238,13 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"payload": {
|
||||
"name": "payload",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"occurred_at": {
|
||||
"name": "occurred_at",
|
||||
"type": "text",
|
||||
@@ -76,11 +265,18 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key_id": {
|
||||
"name": "key_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"events_index_unique": {
|
||||
"name": "events_index_unique",
|
||||
"ledger_events_index_unique": {
|
||||
"name": "ledger_events_index_unique",
|
||||
"columns": [
|
||||
"index"
|
||||
],
|
||||
@@ -92,6 +288,342 @@
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"permit_credentials": {
|
||||
"name": "permit_credentials",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"permit_id": {
|
||||
"name": "permit_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"permit_plates": {
|
||||
"name": "permit_plates",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"permit_id": {
|
||||
"name": "permit_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plate": {
|
||||
"name": "plate",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"permits": {
|
||||
"name": "permits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"holder_name": {
|
||||
"name": "holder_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"contact": {
|
||||
"name": "contact",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"max_concurrent": {
|
||||
"name": "max_concurrent",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"valid_from": {
|
||||
"name": "valid_from",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"valid_to": {
|
||||
"name": "valid_to",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lane": {
|
||||
"name": "lane",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identity": {
|
||||
"name": "identity",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"permit_id": {
|
||||
"name": "permit_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entered_at": {
|
||||
"name": "entered_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"exited_at": {
|
||||
"name": "exited_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'open'"
|
||||
},
|
||||
"last_event_index": {
|
||||
"name": "last_event_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setup_state": {
|
||||
"name": "setup_state",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tariff_versions": {
|
||||
"name": "tariff_versions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tariff_id": {
|
||||
"name": "tariff_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"effective_from": {
|
||||
"name": "effective_from",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"currency": {
|
||||
"name": "currency",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"structure": {
|
||||
"name": "structure",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tariffs": {
|
||||
"name": "tariffs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'site'"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "1073123c-0df9-4109-84bf-7f23b95ec5bd",
|
||||
"prevId": "721bbb8f-b929-4018-9420-0ae75b03ff93",
|
||||
"tables": {
|
||||
"events": {
|
||||
"name": "events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"index": {
|
||||
"name": "index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"direction": {
|
||||
"name": "direction",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lane": {
|
||||
"name": "lane",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identity": {
|
||||
"name": "identity",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"occurred_at": {
|
||||
"name": "occurred_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"prev_hash": {
|
||||
"name": "prev_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"signature": {
|
||||
"name": "signature",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"events_index_unique": {
|
||||
"name": "events_index_unique",
|
||||
"columns": [
|
||||
"index"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"lane_devices": {
|
||||
"name": "lane_devices",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lane": {
|
||||
"name": "lane",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"driver_id": {
|
||||
"name": "driver_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setup_state": {
|
||||
"name": "setup_state",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1781389618205,
|
||||
"tag": "0000_absent_rocket_raccoon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1781416636098,
|
||||
"tag": "0001_cuddly_maria_hill",
|
||||
"when": 1781539958008,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
+164
-10
@@ -2,10 +2,16 @@ import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// Schema notes:
|
||||
// - `events` is APPEND-ONLY. Never expose UPDATE/DELETE on it. A correction or
|
||||
// void is a new row of type 'void'. Each row chains to the previous via
|
||||
// `prevHash` and is signed by the ATECC608 (`signature`). This is the core
|
||||
// anti-fraud integrity mechanism. See wiki/concepts/append-only-event-chain.md.
|
||||
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
|
||||
// • `ledger_events` — the APPEND-ONLY, hash-chained, ATECC608-SIGNED business ledger.
|
||||
// Never UPDATE/DELETE. A correction or void is a new row of type 'void'. Each row
|
||||
// chains via `prevHash` and is signed (`signature`). The anti-fraud record; sessions,
|
||||
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
|
||||
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
|
||||
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
|
||||
// - Business master data (tariffs/permits/blocklist) IS mutable, but its USE is fixed in a
|
||||
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
|
||||
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
|
||||
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
|
||||
// See wiki/entities/local-jwt-auth.md.
|
||||
|
||||
@@ -21,7 +27,13 @@ export const users = sqliteTable("users", {
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
export const events = sqliteTable("events", {
|
||||
// --- The signed business ledger (formerly `events`) ----------------------
|
||||
// Holds ONLY business/accountability facts: vehicle_entry, vehicle_exit, payment,
|
||||
// void, shift_z_report, plus witness-grade barrier_open_command/observed, anomaly.
|
||||
// `payload` carries type-specific data (amount, tariffVersionId, sessionRef, tender,
|
||||
// plate confidence…) and is part of the SIGNED canonical form, so it is tamper-evident
|
||||
// like the rest of the row. See packages/shared ParkingEventType + LedgerPayload.
|
||||
export const ledgerEvents = sqliteTable("ledger_events", {
|
||||
id: text("id").primaryKey(),
|
||||
// Monotonic chain index. Gaps are alarms (see event-log-ingestion).
|
||||
index: integer("index").notNull().unique(),
|
||||
@@ -30,18 +42,43 @@ export const events = sqliteTable("events", {
|
||||
lane: integer("lane").notNull(),
|
||||
source: text("source"),
|
||||
identity: text("identity"),
|
||||
// Type-specific business payload (JSON). Signed as part of the canonical form.
|
||||
payload: text("payload", { mode: "json" }).$type<Record<string, unknown>>(),
|
||||
occurredAt: text("occurred_at").notNull(),
|
||||
// Hash of the previous event (hex). Null only for the genesis event.
|
||||
prevHash: text("prev_hash"),
|
||||
// ATECC608 signature over the canonical event payload (hex).
|
||||
signature: text("signature").notNull(),
|
||||
// Which signer/key produced `signature` (e.g. "sw-hmac-v1", "atecc608-slot0"),
|
||||
// so old events stay verifiable across a signer swap. See packages/shared Signer.
|
||||
keyId: text("key_id").notNull(),
|
||||
});
|
||||
|
||||
// Per-lane device assignments chosen by the admin during first-run setup.
|
||||
// --- Device telemetry (unsigned, prunable) -------------------------------
|
||||
// Operational monitoring, NOT anti-fraud: relay fired, printer paper-out, camera
|
||||
// offline, reader read, raw input edges. Keyed to a lane_devices instance; lane is
|
||||
// resolved via the LaneMap. No prevHash/signature — this stream may rotate/prune.
|
||||
export const deviceEvents = sqliteTable("device_events", {
|
||||
id: text("id").primaryKey(),
|
||||
// The lane_devices instance that produced it (raw provenance).
|
||||
deviceId: text("device_id"),
|
||||
lane: integer("lane"),
|
||||
category: text("category", {
|
||||
enum: ["access", "reader", "camera", "printer"],
|
||||
}),
|
||||
// e.g. "input", "relay", "status", "read", "snapshot".
|
||||
kind: text("kind").notNull(),
|
||||
// Free-form telemetry detail (input number + edge, status flags, error…).
|
||||
detail: text("detail", { mode: "json" }).$type<Record<string, unknown>>(),
|
||||
occurredAt: text("occurred_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// --- Per-lane device assignments (first-run setup) -----------------------
|
||||
// One row per (lane, category, instance). `driverId` references a driver in the
|
||||
// @parking/devices registry; `config` is that driver's JSON config (host, port,
|
||||
// credentials…). Lets the system stay device-agnostic and admin-configurable.
|
||||
// See wiki/concepts/device-registry.md and first-run-setup.md.
|
||||
// @parking/devices registry; `config` is that driver's JSON config. Keeps the
|
||||
// system device-agnostic + admin-configurable. See device-registry.md, first-run-setup.md.
|
||||
export const laneDevices = sqliteTable("lane_devices", {
|
||||
id: text("id").primaryKey(),
|
||||
lane: integer("lane").notNull(),
|
||||
@@ -64,7 +101,124 @@ export const setupState = sqliteTable("setup_state", {
|
||||
completedAt: text("completed_at"),
|
||||
});
|
||||
|
||||
// --- Tariffs (composable, versioned) -------------------------------------
|
||||
// A `tariffs` row is a logical rate card; its pricing lives in immutable, effective-
|
||||
// dated `tariff_versions`. Editing prices PUBLISHES a new version, never mutates one.
|
||||
// A session reprices against the version in force at its entry time; the `payment`
|
||||
// ledger event records the tariffVersionId used. "One active tariff per site" today;
|
||||
// `scope` lets multiple be added later without migration. See wiki/concepts/tariff.md.
|
||||
export const tariffs = sqliteTable("tariffs", {
|
||||
id: text("id").primaryKey(),
|
||||
// Only "site" used now; "lane"/"zone" reserved for multi-tariff later.
|
||||
scope: text("scope", { enum: ["site", "lane", "zone"] }).notNull().default("site"),
|
||||
name: text("name").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
export const tariffVersions = sqliteTable("tariff_versions", {
|
||||
id: text("id").primaryKey(),
|
||||
tariffId: text("tariff_id").notNull(),
|
||||
// The version is in force from this instant (latest with effectiveFrom ≤ entry wins).
|
||||
effectiveFrom: text("effective_from").notNull(),
|
||||
// ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float.
|
||||
currency: text("currency").notNull(),
|
||||
// The composable rate card (stepped blocks + caps/grace). Shape: TariffStructure
|
||||
// in packages/shared. Immutable once published.
|
||||
structure: text("structure", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
|
||||
createdBy: text("created_by"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// --- Permits (subscriptions) ---------------------------------------------
|
||||
// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event.
|
||||
// Two optional, independent bindings: car-count (maxConcurrent, default 1, null =
|
||||
// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a
|
||||
// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md.
|
||||
export const permits = sqliteTable("permits", {
|
||||
id: text("id").primaryKey(),
|
||||
holderName: text("holder_name"),
|
||||
contact: text("contact"),
|
||||
// Car-count binding: how many of the permit's cars may be inside at once.
|
||||
// null = unbound. Default 1.
|
||||
maxConcurrent: integer("max_concurrent").default(1),
|
||||
validFrom: text("valid_from"),
|
||||
validTo: text("valid_to"),
|
||||
status: text("status", { enum: ["active", "suspended", "revoked"] })
|
||||
.notNull()
|
||||
.default("active"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane.
|
||||
export const permitCredentials = sqliteTable("permit_credentials", {
|
||||
id: text("id").primaryKey(),
|
||||
permitId: text("permit_id").notNull(),
|
||||
kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
// Plate binding (optional). When a permit has plate rows, a matching plate read is
|
||||
// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
|
||||
export const permitPlates = sqliteTable("permit_plates", {
|
||||
id: text("id").primaryKey(),
|
||||
permitId: text("permit_id").notNull(),
|
||||
plate: text("plate").notNull(),
|
||||
});
|
||||
|
||||
// --- Blocklist (banlist) -------------------------------------------------
|
||||
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
|
||||
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
|
||||
export const blocklist = sqliteTable("blocklist", {
|
||||
id: text("id").primaryKey(),
|
||||
kind: text("kind", { enum: ["plate", "card", "qr"] }).notNull(),
|
||||
value: text("value").notNull(),
|
||||
reason: text("reason"),
|
||||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||
addedBy: text("added_by"),
|
||||
addedAt: text("added_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// --- Sessions (PROJECTION cache) -----------------------------------------
|
||||
// NOT a source of truth — a rebuildable fold over ledger_events for fast queries
|
||||
// (occupancy, pay-station lookup, anti-passback, plate search). Always reconstructable
|
||||
// from the signed chain; never the authority for "paid". See wiki/concepts/parking-session.md.
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
// The session key = the entry's identity (ticket id or plate).
|
||||
id: text("id").primaryKey(),
|
||||
lane: integer("lane"),
|
||||
// Identity that opened the session, and how it was read.
|
||||
identity: text("identity"),
|
||||
source: text("source"),
|
||||
// null while transient; set when matched to a permit.
|
||||
permitId: text("permit_id"),
|
||||
enteredAt: text("entered_at").notNull(),
|
||||
// null until exit; presence = CLOSED.
|
||||
exitedAt: text("exited_at"),
|
||||
// Derived state for quick filtering: open | paid | closed | voided.
|
||||
state: text("state", { enum: ["open", "paid", "closed", "voided"] })
|
||||
.notNull()
|
||||
.default("open"),
|
||||
// Index of the last ledger event folded into this row (cache freshness / rebuild).
|
||||
lastEventIndex: integer("last_event_index"),
|
||||
});
|
||||
|
||||
export type UserRow = typeof users.$inferSelect;
|
||||
export type EventRow = typeof events.$inferSelect;
|
||||
export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
|
||||
export type DeviceEventRow = typeof deviceEvents.$inferSelect;
|
||||
export type LaneDeviceRow = typeof laneDevices.$inferSelect;
|
||||
export type SetupStateRow = typeof setupState.$inferSelect;
|
||||
export type TariffRow = typeof tariffs.$inferSelect;
|
||||
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
||||
export type PermitRow = typeof permits.$inferSelect;
|
||||
export type PermitCredentialRow = typeof permitCredentials.$inferSelect;
|
||||
export type PermitPlateRow = typeof permitPlates.$inferSelect;
|
||||
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||||
export type SessionRow = typeof sessions.$inferSelect;
|
||||
|
||||
@@ -721,6 +721,7 @@ export const dingtianDriver: AccessDriver = {
|
||||
description:
|
||||
"Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.",
|
||||
transports: ["udp"],
|
||||
pushesToBackend: true, // HTTP-pushes input/button events to the backend (Input Link URL)
|
||||
configFields: [
|
||||
hostField,
|
||||
{ ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." },
|
||||
|
||||
@@ -1,57 +1,120 @@
|
||||
import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js";
|
||||
import type { CameraDriver, DeviceConfig } from "../registry.js";
|
||||
import type { CameraDriver, ConfigField, DeviceConfig } from "../registry.js";
|
||||
import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js";
|
||||
import { digestGet } from "./http-digest.js";
|
||||
|
||||
// Camera drivers — entry/exit snapshot-on-event. The image is stored and
|
||||
// referenced from the signed event as an independent fraud-control record.
|
||||
// Hikvision (ISAPI) and Dahua (CGI) differ only in the snapshot URL. STUBS only.
|
||||
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
|
||||
// HTTP when an event fires; the bytes are stored and referenced from the signed
|
||||
// event as an independent fraud-control record (the camera PULLS, it never pushes
|
||||
// to us). Hikvision (ISAPI) and Dahua (CGI) differ only in the snapshot URL and
|
||||
// channel encoding. Both use HTTP Digest auth (see ./http-digest.ts).
|
||||
//
|
||||
// VERIFIED on hardware (2026-06-15): a Hikvision unit at 10.0.10.121 returns a
|
||||
// 2688×1520 JPEG from /ISAPI/Streaming/channels/101/picture with Digest auth.
|
||||
// See wiki/entities/lpr-camera.md.
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 8000;
|
||||
|
||||
class HttpCamera implements CameraDevice {
|
||||
readonly #host: string;
|
||||
readonly #port: number;
|
||||
readonly #user: string;
|
||||
readonly #password: string;
|
||||
readonly #channel: number;
|
||||
readonly #timeout: number;
|
||||
// Source outbound from the device-facing NIC on a multi-homed host (the
|
||||
// multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md).
|
||||
readonly #localAddress: string | undefined;
|
||||
|
||||
class StubCamera implements CameraDevice {
|
||||
constructor(
|
||||
readonly driverId: string,
|
||||
protected readonly config: DeviceConfig,
|
||||
protected readonly snapshotPath: string,
|
||||
) {}
|
||||
async connect(): Promise<void> {
|
||||
stubLog(this.driverId, `connect ${this.config.host} (${this.snapshotPath})`);
|
||||
}
|
||||
async disconnect(): Promise<void> {
|
||||
stubLog(this.driverId, "disconnect");
|
||||
config: DeviceConfig,
|
||||
/** Builds the snapshot path from the configured channel. */
|
||||
private readonly snapshotPath: (channel: number) => string,
|
||||
) {
|
||||
this.#host = String(config.host);
|
||||
this.#port = Number(config.port ?? 80);
|
||||
this.#user = String(config.username ?? "");
|
||||
this.#password = String(config.password ?? "");
|
||||
this.#channel = Number(config.channel ?? 1);
|
||||
this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
this.#localAddress = config.localAddress ? String(config.localAddress) : undefined;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {}
|
||||
async disconnect(): Promise<void> {}
|
||||
|
||||
async healthCheck(): Promise<DeviceHealth> {
|
||||
return { status: "ready", detail: "stub" };
|
||||
// The only honest liveness probe for a snapshot camera is to actually pull a
|
||||
// frame: it exercises reachability + auth + the path/channel in one shot.
|
||||
try {
|
||||
const res = await this.#get();
|
||||
if (res.status === 200) return { status: "ready", detail: `${res.body.length} bytes` };
|
||||
if (res.status === 401) return { status: "degraded", detail: "auth rejected (check username/password)" };
|
||||
return { status: "degraded", detail: `HTTP ${res.status}` };
|
||||
} catch (err) {
|
||||
return { status: "offline", detail: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
|
||||
// Real driver: GET http(s)://host{snapshotPath}, store bytes, return ref.
|
||||
stubLog(this.driverId, `captureSnapshot lane=${ctx.lane} ${ctx.direction}`);
|
||||
const res = await this.#get();
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`${this.driverId} snapshot failed (lane=${ctx.lane} ${ctx.direction}): HTTP ${res.status}`,
|
||||
);
|
||||
}
|
||||
stubLog(this.driverId, `captureSnapshot lane=${ctx.lane} ${ctx.direction} (${res.body.length} bytes)`);
|
||||
return {
|
||||
imageRef: `stub://${this.driverId}/lane${ctx.lane}/${ctx.direction}/${Date.now()}`,
|
||||
contentType: "image/jpeg",
|
||||
bytes: res.body,
|
||||
contentType: res.contentType || "image/jpeg",
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
#get() {
|
||||
return digestGet({
|
||||
host: this.#host,
|
||||
port: this.#port,
|
||||
path: this.snapshotPath(this.#channel),
|
||||
user: this.#user,
|
||||
password: this.#password,
|
||||
timeoutMs: this.#timeout,
|
||||
localAddress: this.#localAddress,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, { key: "channel", label: "Channel", type: "number" as const, required: false, default: 1 }];
|
||||
const channelField: ConfigField = {
|
||||
key: "channel",
|
||||
label: "Channel",
|
||||
type: "number",
|
||||
required: false,
|
||||
default: 1,
|
||||
};
|
||||
|
||||
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
|
||||
|
||||
export const hikvisionDriver: CameraDriver = {
|
||||
id: "hikvision",
|
||||
category: "camera",
|
||||
label: "Hikvision camera",
|
||||
description: "Hikvision snapshot via ISAPI.",
|
||||
description: "Hikvision snapshot via ISAPI (HTTP Digest).",
|
||||
transports: ["tcp-ip"],
|
||||
configFields: cameraConfigFields,
|
||||
// /ISAPI/Streaming/channels/<id>/picture
|
||||
create: (c) => new StubCamera("hikvision", c, "/ISAPI/Streaming/channels/101/picture"),
|
||||
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
|
||||
create: (c) =>
|
||||
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
|
||||
};
|
||||
|
||||
export const dahuaDriver: CameraDriver = {
|
||||
id: "dahua",
|
||||
category: "camera",
|
||||
label: "Dahua camera",
|
||||
description: "Dahua snapshot via CGI.",
|
||||
description: "Dahua snapshot via CGI (HTTP Digest).",
|
||||
transports: ["tcp-ip"],
|
||||
configFields: cameraConfigFields,
|
||||
// /cgi-bin/snapshot.cgi?channel=<n>
|
||||
create: (c) => new StubCamera("dahua", c, "/cgi-bin/snapshot.cgi"),
|
||||
// Dahua channels are 0-based on the CGI; the admin enters 1-based.
|
||||
create: (c) =>
|
||||
new HttpCamera("dahua", c, (ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
|
||||
// Client-side HTTP Digest auth (RFC 2617, MD5, qop=auth) for talking TO devices
|
||||
// that challenge with `WWW-Authenticate: Digest` — e.g. Hikvision ISAPI cameras.
|
||||
// (The server-side counterpart, which VERIFIES device→backend pushes, lives in
|
||||
// apps/server/src/digest-auth.ts.) Devices on the isolated VLAN can't present a
|
||||
// trusted TLS cert, so plain-HTTP Digest is the available auth: the password is
|
||||
// never on the wire, only a nonce-keyed hash. See wiki/concepts/network-isolation.md.
|
||||
|
||||
const md5 = (s: string) => createHash("md5").update(s).digest("hex");
|
||||
|
||||
/** Parse a `WWW-Authenticate: Digest …` header into its k=v fields. */
|
||||
function parseChallenge(header: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
const re = /(\w+)=(?:"([^"]*)"|([^,]*))/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(header))) out[m[1]!] = (m[2] ?? m[3] ?? "").trim();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build the `Authorization: Digest …` response value for a challenge. */
|
||||
function buildAuthHeader(
|
||||
c: Record<string, string>,
|
||||
user: string,
|
||||
password: string,
|
||||
method: string,
|
||||
uri: string,
|
||||
): string {
|
||||
const realm = c.realm ?? "";
|
||||
const nonce = c.nonce ?? "";
|
||||
const qop = c.qop?.split(",")[0]?.trim(); // server may offer "auth,auth-int"
|
||||
const ha1 = md5(`${user}:${realm}:${password}`);
|
||||
const ha2 = md5(`${method}:${uri}`);
|
||||
|
||||
const parts: string[] = [
|
||||
`username="${user}"`,
|
||||
`realm="${realm}"`,
|
||||
`nonce="${nonce}"`,
|
||||
`uri="${uri}"`,
|
||||
];
|
||||
|
||||
let response: string;
|
||||
if (qop === "auth") {
|
||||
const cnonce = randomBytes(8).toString("hex");
|
||||
const nc = "00000001";
|
||||
response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
|
||||
parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`);
|
||||
} else {
|
||||
// Legacy RFC 2069 (no qop) — Hikvision uses qop=auth, but be tolerant.
|
||||
response = md5(`${ha1}:${nonce}:${ha2}`);
|
||||
}
|
||||
parts.push(`response="${response}"`);
|
||||
if (c.opaque) parts.push(`opaque="${c.opaque}"`);
|
||||
return `Digest ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
export interface DigestGetResult {
|
||||
readonly status: number;
|
||||
readonly contentType: string;
|
||||
readonly body: Buffer;
|
||||
}
|
||||
|
||||
export interface DigestGetOptions {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
readonly path: string;
|
||||
readonly user: string;
|
||||
readonly password: string;
|
||||
readonly timeoutMs: number;
|
||||
/** Bind outbound to the device-facing NIC on a multi-homed host. */
|
||||
readonly localAddress?: string;
|
||||
}
|
||||
|
||||
function getOnce(
|
||||
o: DigestGetOptions,
|
||||
authHeader?: string,
|
||||
): Promise<{ res: IncomingMessage; body: Buffer }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (authHeader) headers["authorization"] = authHeader;
|
||||
const req = httpRequest(
|
||||
{
|
||||
host: o.host,
|
||||
port: o.port,
|
||||
path: o.path,
|
||||
method: "GET",
|
||||
timeout: o.timeoutMs,
|
||||
localAddress: o.localAddress,
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (c) => chunks.push(c as Buffer));
|
||||
res.on("end", () => resolve({ res, body: Buffer.concat(chunks) }));
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => req.destroy(new Error("digest GET timeout")));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a resource with HTTP Digest auth. Does the standard two-shot handshake:
|
||||
* the first request (no Authorization) draws a 401 + challenge, the second
|
||||
* carries the computed response. If the server doesn't challenge (200 straight
|
||||
* away, or no auth required), the first response is returned as-is.
|
||||
*/
|
||||
export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
||||
const first = await getOnce(o);
|
||||
if (first.res.statusCode !== 401) {
|
||||
return {
|
||||
status: first.res.statusCode ?? 0,
|
||||
contentType: String(first.res.headers["content-type"] ?? ""),
|
||||
body: first.body,
|
||||
};
|
||||
}
|
||||
|
||||
const challengeHeader = String(first.res.headers["www-authenticate"] ?? "");
|
||||
if (!/^digest/i.test(challengeHeader)) {
|
||||
// 401 but not Digest (e.g. Basic-only) — surface it; caller decides.
|
||||
return {
|
||||
status: 401,
|
||||
contentType: String(first.res.headers["content-type"] ?? ""),
|
||||
body: first.body,
|
||||
};
|
||||
}
|
||||
|
||||
const challenge = parseChallenge(challengeHeader);
|
||||
const auth = buildAuthHeader(challenge, o.user, o.password, "GET", o.path);
|
||||
const second = await getOnce(o, auth);
|
||||
return {
|
||||
status: second.res.statusCode ?? 0,
|
||||
contentType: String(second.res.headers["content-type"] ?? ""),
|
||||
body: second.body,
|
||||
};
|
||||
}
|
||||
@@ -179,10 +179,15 @@ export interface SnapshotContext {
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
/** Storage reference for the captured image (file path / blob id). */
|
||||
readonly imageRef: string;
|
||||
/** The captured image bytes. The DRIVER fetches them over the network; the
|
||||
* CALLER (entry/exit flow) owns storage and minting a durable reference —
|
||||
* keeping the device adapter free of any filesystem/blob-store dependency. */
|
||||
readonly bytes: Buffer;
|
||||
readonly contentType: string;
|
||||
readonly capturedAt: string; // ISO-8601
|
||||
/** Storage reference (file path / blob id), set once the caller has stored
|
||||
* the bytes. Absent on the value the driver returns. */
|
||||
readonly imageRef?: string;
|
||||
}
|
||||
|
||||
// --- Printers (ticket dispenser / booth printer) -------------------------
|
||||
|
||||
@@ -42,6 +42,13 @@ export interface DeviceDriver<T extends Device = Device> {
|
||||
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
|
||||
readonly transports: readonly string[];
|
||||
readonly configFields: readonly ConfigField[];
|
||||
/**
|
||||
* True if the device calls BACK to our backend (HTTP push) and therefore needs
|
||||
* a backend IP configured at assign time. Pull-only devices (cameras poll a
|
||||
* snapshot, the relay is commanded) leave this false so the setup wizard hides
|
||||
* the "Backend push IP" field. See wiki/concepts/device-input-flow.md.
|
||||
*/
|
||||
readonly pushesToBackend?: boolean;
|
||||
/** Build a live adapter instance from validated config. */
|
||||
create(config: DeviceConfig): T;
|
||||
}
|
||||
@@ -130,6 +137,11 @@ class DeviceRegistry {
|
||||
}
|
||||
return byCategory;
|
||||
}
|
||||
|
||||
/** Driver ids that push to the backend (need a backend IP at assign time). */
|
||||
pushCapable(): string[] {
|
||||
return [...this.#drivers.values()].filter((d) => d.pushesToBackend).map((d) => d.id);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CatalogEntry {
|
||||
|
||||
@@ -13,39 +13,203 @@ export type Direction = "entry" | "exit";
|
||||
export type IdentitySource = "wiegand" | "lpr" | "qr" | "ticket" | "manual";
|
||||
|
||||
/**
|
||||
* An append-only parking event. Records are never mutated; corrections are new
|
||||
* A signed business-LEDGER event. Records are never mutated; corrections are new
|
||||
* events. `prevHash` chains each event to the previous one; `signature` is the
|
||||
* ATECC608 signature over the event contents. See wiki/append-only-event-chain.
|
||||
* ATECC608 signature over the canonical contents (which INCLUDE `payload`).
|
||||
* Distinct from device telemetry — see wiki/decisions/event-streams-split.md.
|
||||
*/
|
||||
export interface ParkingEvent {
|
||||
export interface LedgerEvent {
|
||||
readonly id: string;
|
||||
readonly index: number;
|
||||
readonly type: ParkingEventType;
|
||||
readonly type: LedgerEventType;
|
||||
readonly direction: Direction | null;
|
||||
readonly lane: number;
|
||||
readonly source: IdentitySource | null;
|
||||
/** Card number, plate, ticket id, etc. — depends on `source`. */
|
||||
readonly identity: string | null;
|
||||
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
|
||||
readonly payload: LedgerPayload | null;
|
||||
readonly occurredAt: string; // ISO-8601
|
||||
/** Hash of the previous event in the chain (hex). Null only for genesis. */
|
||||
readonly prevHash: string | null;
|
||||
/** ATECC608 signature over the canonical event payload (hex). */
|
||||
readonly signature: string;
|
||||
/** Which signer/key produced `signature` (verifiable across a signer swap). */
|
||||
readonly keyId: string;
|
||||
}
|
||||
|
||||
export type ParkingEventType =
|
||||
// A raw device input (e.g. a Dingtian button press) was received and recorded.
|
||||
// NOT a confirmed entry — the richer `vehicle_entry` is appended later by the
|
||||
// entry flow once a ticket prints and the barrier is commanded.
|
||||
| "input_received"
|
||||
/** Business/accountability events that live in the SIGNED, hash-chained ledger. */
|
||||
export type LedgerEventType =
|
||||
| "vehicle_entry"
|
||||
| "vehicle_exit"
|
||||
| "payment"
|
||||
| "void"
|
||||
// Witness-grade: a host-commanded open, and an independently-observed open
|
||||
// (loop/sensor) — reconciled against each other.
|
||||
| "barrier_open_command"
|
||||
| "barrier_open_observed"
|
||||
| "shift_z_report"
|
||||
| "anomaly";
|
||||
|
||||
/** How money was tendered (for payment events + the shift Z-report). */
|
||||
export type Tender = "cash" | "card";
|
||||
|
||||
/**
|
||||
* Type-specific data carried on a ledger event's `payload`. All amounts are
|
||||
* integer minor units in the named currency — never floats. Fields are optional
|
||||
* because they're event-type-specific; the producer fills what applies.
|
||||
*/
|
||||
export interface LedgerPayload {
|
||||
/** The parking_session this event concerns (entry/exit/payment/void). */
|
||||
readonly sessionRef?: string;
|
||||
/** payment: amount in minor units, its currency, and how it was tendered. */
|
||||
readonly amountMinor?: number;
|
||||
readonly currency?: string;
|
||||
readonly tender?: Tender;
|
||||
/** payment: which tariff_version priced it (reproducible repricing). */
|
||||
readonly tariffVersionId?: string;
|
||||
/** payment: gross/discount/net split when a validation applied. */
|
||||
readonly grossMinor?: number;
|
||||
readonly discountMinor?: number;
|
||||
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
|
||||
readonly fxRate?: number | null;
|
||||
/** void / anomaly / override: a human/machine reason code. */
|
||||
readonly reason?: string;
|
||||
/** plate/vehicle from the vision service (advisory). */
|
||||
readonly plate?: string;
|
||||
readonly plateConfidence?: number;
|
||||
/** Free-form for forward-compat without a schema change. */
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
|
||||
/** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */
|
||||
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
|
||||
|
||||
/**
|
||||
* The composable rate card stored in a tariff_version.structure. Pure data the
|
||||
* fee function interprets — no rates in code. Stepped duration blocks + caps/grace;
|
||||
* a flat rate is just one block. See wiki/concepts/tariff.md.
|
||||
*/
|
||||
export interface TariffStructure {
|
||||
/** Free if exited within this (drop-off/turnaround). */
|
||||
readonly gracePeriodEntryMin: number;
|
||||
/** Billing granularity; partial increments round UP. */
|
||||
readonly incrementMin: number;
|
||||
/** Consumed in order as duration accrues; last block may be open-ended. */
|
||||
readonly blocks: readonly TariffBlock[];
|
||||
/** Cap per rolling 24h (null = no cap). */
|
||||
readonly dailyCapMinor: number | null;
|
||||
/** Flat charge when there's no entry id (admin may override at the moment). */
|
||||
readonly lostTicketMinor: number;
|
||||
/** Pay-on-foot walk-back window: minutes after payment to reach the car. */
|
||||
readonly gracePeriodExitMin: number;
|
||||
/** How an overstay top-up is charged. "reprice" = recompute(entry→now) − paid. */
|
||||
readonly overstay: "reprice";
|
||||
}
|
||||
|
||||
export interface TariffBlock {
|
||||
/** Upper bound of this block in minutes; null = open-ended (thereafter). */
|
||||
readonly uptoMin: number | null;
|
||||
readonly priceMinorPerIncrement: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the parking fee (integer minor units) for a stay, from a TariffStructure.
|
||||
* PURE + deterministic + offline — the pay station calls it with asOf = now; the
|
||||
* result is fixed into a signed `payment` event, so it must be reproducible.
|
||||
*
|
||||
* Algorithm (wiki/concepts/tariff.md): round duration UP to incrementMin; free if
|
||||
* within entry grace; else walk the stay one rolling-24h segment at a time, charging
|
||||
* each increment at its block's rate (blocks consumed in order by cumulative minutes),
|
||||
* capping each segment at dailyCapMinor. Times are ISO-8601; bad input → 0 (caller
|
||||
* validates the tariff exists first).
|
||||
*/
|
||||
export function computeFee(
|
||||
enteredAt: string,
|
||||
asOf: string,
|
||||
tariff: TariffStructure,
|
||||
): number {
|
||||
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||
const rawMinutes = ms / 60_000;
|
||||
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
|
||||
// 60 min — otherwise rounding-up would defeat the grace window).
|
||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
|
||||
const inc = Math.max(1, tariff.incrementMin);
|
||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
||||
|
||||
const DAY = 24 * 60;
|
||||
let total = 0;
|
||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||
const segEnd = Math.min(segStart + DAY, minutes);
|
||||
let segFee = 0;
|
||||
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
|
||||
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
|
||||
for (let within = 0; segStart + within < segEnd; within += inc) {
|
||||
segFee += rateAt(tariff.blocks, within);
|
||||
}
|
||||
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
|
||||
total += segFee;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an admin-authored tariff structure. Returns [] if valid, else a list
|
||||
* of human-readable problems. Pure — used by the composer route (and any caller)
|
||||
* so a malformed rate card can never be published. See wiki/concepts/tariff.md.
|
||||
*/
|
||||
export function validateTariffStructure(s: unknown): string[] {
|
||||
const errs: string[] = [];
|
||||
if (!s || typeof s !== "object") return ["structure must be an object"];
|
||||
const t = s as Partial<TariffStructure>;
|
||||
|
||||
const nonNegInt = (v: unknown, label: string) => {
|
||||
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`);
|
||||
};
|
||||
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin");
|
||||
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin");
|
||||
nonNegInt(t.lostTicketMinor, "lostTicketMinor");
|
||||
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
|
||||
errs.push("incrementMin must be a positive integer");
|
||||
}
|
||||
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor");
|
||||
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
||||
|
||||
if (!Array.isArray(t.blocks) || t.blocks.length === 0) {
|
||||
errs.push("blocks must be a non-empty array");
|
||||
} else {
|
||||
let prevBound = 0;
|
||||
t.blocks.forEach((b, i) => {
|
||||
const last = i === t.blocks!.length - 1;
|
||||
nonNegInt(b?.priceMinorPerIncrement, `blocks[${i}].priceMinorPerIncrement`);
|
||||
if (b?.uptoMin == null) {
|
||||
if (!last) errs.push(`blocks[${i}] is open-ended (uptoMin null) but not last`);
|
||||
} else {
|
||||
if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) {
|
||||
errs.push(`blocks[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`);
|
||||
} else {
|
||||
prevBound = b.uptoMin;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
/** Price of the increment that starts at `cumulativeMin` — the block whose range
|
||||
* [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */
|
||||
function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
|
||||
let prev = 0;
|
||||
for (const b of blocks) {
|
||||
if (b.uptoMin == null || cumulativeMin < b.uptoMin) return b.priceMinorPerIncrement;
|
||||
prev = b.uptoMin;
|
||||
void prev;
|
||||
}
|
||||
// No open-ended block and past the last bound: charge the last block's rate.
|
||||
return blocks.length ? blocks[blocks.length - 1]!.priceMinorPerIncrement : 0;
|
||||
}
|
||||
|
||||
export const ROLES: readonly Role[] = [
|
||||
"admin",
|
||||
"operator",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, anti-fraud, access-control]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Anti-Passback
|
||||
|
||||
Stop one credential/ticket from getting **two cars in** without an exit between — the classic
|
||||
"pass the card/ticket back over the fence" abuse. A control on the entry validation, leaning on the
|
||||
session projection.
|
||||
|
||||
## The rule
|
||||
|
||||
An identity (ticket id, [[permit]] credential, or plate) **must not enter while it already has an
|
||||
OPEN [[parking-session|session]].** At entry:
|
||||
|
||||
```
|
||||
identify vehicle → is there already an OPEN session for this id?
|
||||
no → proceed (mint vehicle_entry, open)
|
||||
yes → passback violation → refuse or flag (see policy)
|
||||
```
|
||||
|
||||
This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist
|
||||
with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that
|
||||
powers [[capacity-occupancy]] and [[permit]] `maxConcurrent`.
|
||||
|
||||
## Interaction with the limits already designed
|
||||
|
||||
- **Transient ticket** — a single ticket id is inherently one session; a second entry on the same
|
||||
id is always a violation (or a re-print/duplication attempt).
|
||||
- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[permit]]): a
|
||||
multi-car permit legitimately has several open sessions, but **the same car/credential** entering
|
||||
twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance.
|
||||
|
||||
## Policy (operator choice)
|
||||
|
||||
- **Hard** — refuse the second entry (strict; risks stranding a legitimate car after a *missed
|
||||
exit*, which is common — tailgated out, sensor missed).
|
||||
- **Soft** — allow but **flag an `anomaly`** (the type exists) for review. Safer against
|
||||
false-positives from missed exits, consistent with the append-only "record + flag, don't block"
|
||||
ethos elsewhere.
|
||||
- Likely **soft by default**, hard as an opt-in for high-control sites.
|
||||
|
||||
## Honest limits
|
||||
|
||||
- Depends on **reliable exit detection** — if exits are routinely missed (no exit loop/plate read),
|
||||
passback produces false positives; tune to the site's exit fidelity.
|
||||
- A spoofed/duplicated ticket QR is caught here (same id already open) — complements
|
||||
[[ticket-encoding]]'s opaque-id requirement.
|
||||
|
||||
## Open
|
||||
|
||||
- Default policy (soft/hard) and per-site override.
|
||||
- Grace for legitimate quick re-entry vs. the missed-exit false-positive.
|
||||
@@ -21,9 +21,25 @@ Three layered properties:
|
||||
self-consistent — someone who owns the machine still cannot forge a valid entry.
|
||||
|
||||
It only becomes trustworthy as an external fraud control when paired with [[reconciliation]]
|
||||
against an authority the operator can't alter. Every device event — including those ingested
|
||||
from the [[uhppote-controller]] via [[event-log-ingestion]] — should land in this host-side
|
||||
chain.
|
||||
against an authority the operator can't alter.
|
||||
|
||||
## Two event streams — the signed ledger vs. device telemetry (decision 2026-06-15)
|
||||
|
||||
These are **different concerns and live in different tables**:
|
||||
|
||||
- **`ledger_events`** — this signed, hash-chained, [[atecc608]]-signed **business ledger**:
|
||||
`vehicle_entry` / `vehicle_exit` / `payment` / `void` / `shift_z_report`, plus the witness-grade
|
||||
`barrier_open_command` / `barrier_open_observed` and `anomaly`. This is the anti-fraud record that
|
||||
[[reconciliation]] runs against; sessions/[[tariff]]/occupancy are projections over it. (This is
|
||||
the table formerly called `events`.)
|
||||
- **`device_events`** — **unsigned operational telemetry**: relay fired, printer paper-out, camera
|
||||
offline, reader read, raw input edges. High-volume, churny, **not** anti-fraud; may rotate/prune.
|
||||
Keeping it out of the signed chain keeps the ledger small and high-value.
|
||||
|
||||
> A raw button press is **device telemetry**, not a business fact. It lands in `device_events`; the
|
||||
> entry flow then mints a **signed `vehicle_entry`** in the ledger once a ticket prints and the
|
||||
> barrier is commanded. (This supersedes the earlier "every device event lands in the chain" framing
|
||||
> and the `input_received`-as-signed-event approach — see [[device-input-flow]].)
|
||||
|
||||
## Implementation (apps/server)
|
||||
|
||||
@@ -58,22 +74,31 @@ so old events stay verifiable.
|
||||
> *accidental* corruption, but an operator with the signing key + DB access could re-sign a
|
||||
> forged chain. This is the central reason #6 matters.
|
||||
|
||||
### What currently feeds the log
|
||||
### Business-layer event types (the ledger)
|
||||
|
||||
Dingtian **input (button) pushes** → bus → `input_received` events (see [[device-input-flow]],
|
||||
[[dingtian-relay]]). These are recorded faithfully as raw inputs, **not** as `vehicle_entry` —
|
||||
the richer entry event waits for the entry flow (ticket print + barrier command).
|
||||
The [[parking-session]] domain folds over these **signed ledger** events:
|
||||
|
||||
- **`lane`** is now resolved from the firing device. A `LaneMap` (`apps/server/src/lane-map.ts`)
|
||||
caches `lane_devices.id → lane`, built at startup and refreshed by the setup routes on every
|
||||
assign/unassign. Device events carry the device instance id, not a lane; the handler looks it
|
||||
up. A device with no mapping (assigned without a lane, or a stale id) logs **`lane: -1`** and a
|
||||
warning — never `0`, which is a real lane — and is still recorded (the chain is append-only;
|
||||
nothing is dropped).
|
||||
- **`source` stays `null`** for `input_received`, and deliberately so: `source` is an
|
||||
`IdentitySource` (`wiegand | lpr | qr | ticket | manual`) — *how a vehicle was identified* — not
|
||||
a device/IP field. A raw button push has no vehicle identity. The device provenance lives in
|
||||
**`identity`** (e.g. `dingtian:<id> input:1/on`).
|
||||
- `vehicle_entry` / `vehicle_exit` — a stay's endpoints; `identity` carries the ticket id or plate.
|
||||
- `payment` — a settled fee at the pay station, referencing the session it pays for (amount in
|
||||
integer minor units; see [[tariff]]). Making "paid" a signed event — not a mutable row — is the
|
||||
whole point: an operator can't forge it or silently delete it.
|
||||
- `void` — a correction / lost-ticket write-off; like every other void here it is an **appended
|
||||
event, never an erasure**.
|
||||
- `shift_z_report` — the signed per-[[shift]] takings summary.
|
||||
|
||||
A session is a **projection** over this chain, never a mutable table — the same anti-fraud reason
|
||||
the chain exists. See [[parking-session]].
|
||||
|
||||
### ⚠️ As-built vs. the table split (pending)
|
||||
|
||||
The current code records Dingtian **input (button) pushes** as `input_received` rows **in the
|
||||
signed chain** (with `lane` resolved via the `LaneMap`, `source` null, device provenance in
|
||||
`identity`). Per the 2026-06-15 split (above), a raw button press is **device telemetry** and
|
||||
belongs in **`device_events`**, *not* the signed ledger — only the business `vehicle_entry` it
|
||||
drives gets signed. So `input_received`-in-the-ledger is **transitional**; the pending refactor
|
||||
moves raw inputs to `device_events` and renames the chain table to `ledger_events`. (`LaneMap`
|
||||
lane-resolution and the "never stamp `lane: 0` for an unmapped device" rule carry over to whichever
|
||||
stream records the event.)
|
||||
|
||||
### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only
|
||||
|
||||
@@ -91,7 +116,9 @@ host. **Proven on hardware**: a binary relay command sent directly to the device
|
||||
So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** —
|
||||
the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an
|
||||
**independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input
|
||||
→ which DOES push + log; the [[lpr-camera]]; payment/Z-report). **A physical open with no matching
|
||||
signed command is the fraud signal.** Both the witness sources and the reconciliation logic are
|
||||
**NOT yet built** — this is the main open gap. Prevention (VLAN isolation so the attacker can't
|
||||
→ which DOES push + log; the [[opencv-anpr-service|vision service]]'s plate **and vehicle** read;
|
||||
payment/Z-report). **A physical open with no matching signed command is the fraud signal** — and,
|
||||
with vehicle verification, **a plate that enters/exits on a different car** is too (the
|
||||
plate-spoofing case). Both the witness sources and the reconciliation logic are **NOT yet built** —
|
||||
this is the main open gap. Prevention (VLAN isolation so the attacker can't
|
||||
reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, occupancy]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Capacity & Occupancy
|
||||
|
||||
How many vehicles are inside, how many spaces remain, and what happens when the lot is full.
|
||||
|
||||
## Occupancy is a projection (like everything else)
|
||||
|
||||
`occupancy = count(open [[parking-session|sessions]])` — an entry with no matching exit. It is a
|
||||
**fold over the signed [[append-only-event-chain]]**, never a hand-maintained counter (a counter is
|
||||
editable and drifts; the chain is the truth). Spaces-free = `capacity − occupancy`.
|
||||
|
||||
- **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a
|
||||
`zone` on capacity + on the entry so multi-level is a later addition, not a rewrite).
|
||||
- Permit concurrency (`maxConcurrent`, see [[permit]]) is the same kind of fold, scoped to one
|
||||
permit's open sessions.
|
||||
|
||||
## Full → refuse entry + FULL sign
|
||||
|
||||
- When `occupancy ≥ capacity`, the entry flow **refuses** (no `vehicle_entry`, no barrier open) and
|
||||
can drive a **"FULL" sign** (a relay/output, via the device adapter layer).
|
||||
- **Safety/policy nuance:** "full" blocks *entry* only — **exit always works** ([[fail-state-safety]]:
|
||||
exit fails open; never trap a vehicle). Permit holders may be allowed in past a "transient full"
|
||||
threshold (reserve spaces for subscribers) — an optional policy knob.
|
||||
- **Counting drift is real:** tailgating (two cars, one entry) and missed reads make the live count
|
||||
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.
|
||||
|
||||
## "Full" is a soft, operator-configurable policy
|
||||
|
||||
Refusing at capacity is the **default**, not an absolute. An operator may opt into
|
||||
**[[valet-overcapacity|valet over-capacity]]** — accept the car into operator custody (keys handed
|
||||
over, stacked beyond the marked count) instead of refusing. So the FULL gate is a policy knob
|
||||
(refuse vs. valet-accept), set by the operator per site. Valet is a manned-mode feature with its
|
||||
own custody/session shape — see [[valet-overcapacity]] (deferred).
|
||||
|
||||
## Open
|
||||
|
||||
- Zone/level granularity at launch vs. single capacity number.
|
||||
- Reserve-for-permits threshold.
|
||||
- The valet over-capacity mode + custody model ([[valet-overcapacity]]).
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, security, integrity, offline-first, anti-fraud]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Clock Integrity
|
||||
|
||||
Fees are a function of **time** ([[tariff]]: `fee = f(enteredAt, asOf)`), and the event chain is
|
||||
ordered/timestamped. So **the host clock is part of the trust model** — and on an offline appliance
|
||||
([[offline-first]], no NTP guarantee) it's a real attack surface, fitting the
|
||||
[[threat-model|operator-as-adversary]] frame:
|
||||
|
||||
- **Backdating to cut a fee** — wind the clock back so a long stay computes as short, or so an exit
|
||||
timestamps before its entry.
|
||||
- **Forward/backward jumps** that corrupt durations, the rolling-24h cap, or shift boundaries
|
||||
([[shift]]).
|
||||
- An operator with host access changing the system time deliberately.
|
||||
|
||||
## What protects it
|
||||
|
||||
- **Monotonic chain order is independent of wall-clock.** The [[append-only-event-chain]] `index`
|
||||
is strictly increasing regardless of timestamps, so **reordering** is caught even if timestamps
|
||||
are forged. But the *durations* used for pricing still rely on the wall clock — so:
|
||||
- **Detect clock anomalies and record them as events.** A timestamp that goes **backwards** between
|
||||
consecutive chain events, or jumps implausibly, is an `anomaly` (the type already exists) — signed
|
||||
and surfaced to [[reconciliation]], not silently accepted.
|
||||
- **Hardware-backed time where possible.** A battery-backed RTC on the appliance; the
|
||||
[[atecc608]]/secure element and [[disk-os-hardening]] reduce casual tampering. An operator
|
||||
changing time should require privilege the booth login doesn't have.
|
||||
- **Opportunistic trusted sync** when a [[reconciliation]] channel is briefly online (the same
|
||||
USB/hotspot path) — set/check the clock against an external authority, log any correction as an
|
||||
event.
|
||||
|
||||
## Stance
|
||||
|
||||
Like the rest of the system: **prevention (hardened host, privileged-only time change) first,
|
||||
detection (anomaly on clock regression, reconciliation) as the backstop.** The clock can't be made
|
||||
unforgeable on an offline box, but a forged clock can be made **visible**.
|
||||
|
||||
## Open
|
||||
|
||||
- RTC / time source on the chosen appliance ([[bom]]).
|
||||
- Tolerance thresholds for "implausible" jumps before flagging.
|
||||
- Whether to hard-refuse an event on a backwards clock vs. record-and-flag (record-and-flag matches
|
||||
the append-only ethos — never drop).
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, devices, monitoring, telemetry]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Device Events (telemetry)
|
||||
|
||||
The **unsigned** operational record of what the hardware did and reported — distinct from the
|
||||
signed business [[append-only-event-chain|ledger]] (see [[event-streams-split]]). For monitoring,
|
||||
diagnostics, and live booth status — **not** anti-fraud.
|
||||
|
||||
## What lands here
|
||||
|
||||
- **Relays/barriers:** relay fired/released, pulseOpen issued (the *device-side* echo; the
|
||||
authoritative `barrier_open_command` is a signed ledger event).
|
||||
- **Printers:** paper-out / near-end / cover-open / cutter / offline (already polled —
|
||||
[[printer-status-monitoring]]).
|
||||
- **Cameras:** reachable/offline, snapshot success/failure ([[lpr-camera]]).
|
||||
- **Readers / inputs:** a raw read, raw input edges (Dingtian button `input N on/off` —
|
||||
[[device-input-flow]]).
|
||||
|
||||
## Properties
|
||||
|
||||
- **Unsigned, not chained** — no `prevHash`/`signature`. It's telemetry, so it carries none of the
|
||||
ledger's integrity machinery.
|
||||
- **Disposable** — high-volume and churny; **may rotate/prune** on a retention policy (the ledger
|
||||
never does).
|
||||
- **Device-keyed** — references the `lane_devices` instance; `lane` resolved via the same `LaneMap`
|
||||
as before. Stores raw device provenance.
|
||||
|
||||
## The boundary that matters
|
||||
|
||||
A device event is *evidence the host saw something happen*; it does **not** by itself authorize or
|
||||
record a business fact. A button press here becomes a **signed `vehicle_entry`** in the ledger only
|
||||
after the entry flow runs (ticket + barrier command). This keeps device chatter on the device side
|
||||
of the [[device-adapter-pattern|adapter boundary]] and the signed ledger focused on money/access.
|
||||
|
||||
## Open
|
||||
|
||||
- Retention/rotation policy (size- or age-based).
|
||||
- Whether any witness-grade device fact (e.g. a loop-sensor `barrier_open_observed`) should *also*
|
||||
write a signed ledger entry for [[reconciliation]] — see [[append-only-event-chain]].
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, anti-fraud]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Parking Session
|
||||
|
||||
The core business-domain entity: one vehicle's stay, from entry to exit, plus the money owed and
|
||||
paid for it. Everything on the business side — [[tariff|tariffs]], payment, [[reconciliation]],
|
||||
revenue reporting — hangs off the session. This page defines what a session **is** and, just as
|
||||
importantly, what it is **not**.
|
||||
|
||||
> Scope decision (2026-06-15): build the **transient** (casual, pay-for-duration) session first;
|
||||
> layer **permit holders** on top as a second identity source that short-circuits payment. Mixed
|
||||
> site, transient-first — see [[entry-exit-readers]] ("two populations, one shared relay") and
|
||||
> [[session-model]].
|
||||
|
||||
## A session is a PROJECTION over the signed event log — not a mutable table
|
||||
|
||||
This is the single most important rule, and it falls straight out of the [[threat-model]] (the
|
||||
adversary is the insider who can edit the database) and the [[append-only-event-chain]]:
|
||||
|
||||
- The **events** table is the ledger and the **only** source of truth. `vehicle_entry`,
|
||||
`vehicle_exit`, `payment`, `void` are all **appended + signed**, never updated or deleted.
|
||||
- A **session** is a **read-model folded from those events** — open when an entry has no matching
|
||||
exit, paid when a `payment` event references it, closed when an exit lands. It MAY be cached in
|
||||
a table for query speed (dashboards, "cars currently in"), but that cache is **always rebuildable
|
||||
from the chain and never authoritative**. Same pattern as the `LaneMap`
|
||||
([[append-only-event-chain]]), scaled to the business domain.
|
||||
- **Why this matters:** a mutable `sessions` row that stored "amount owed / paid" would reopen
|
||||
exactly the fraud hole the whole system exists to close (operator marks a session paid, pockets
|
||||
the cash). With sessions as a projection, "paid" is a **signed `payment` event** an operator
|
||||
can't forge or silently delete — a deletion breaks the chain visibly. See [[session-model]] for
|
||||
the rejected mutable-table alternative.
|
||||
|
||||
## Identity — how an entry is tied to its exit
|
||||
|
||||
A session needs a key that survives from entry to exit. Two populations, two keys
|
||||
([[entry-exit-readers]]):
|
||||
|
||||
- **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]])
|
||||
or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field.
|
||||
- **Permit holder:** a **credential** (card / plate / QR) matched to a [[permit]] record. A valid
|
||||
permit means the session owes nothing — the PAY step is skipped (see below).
|
||||
|
||||
## Lifecycle (pay-on-foot / pay station model)
|
||||
|
||||
Payment is **decoupled from exit** (decision 2026-06-15, matching the [[autonomous-direction|
|
||||
unmanned]] roadmap): the customer pays at a central station before walking back to the car; the
|
||||
exit lane only *validates* that the session is settled.
|
||||
|
||||
```
|
||||
ENTRY (lane) vehicle_entry event → session OPEN
|
||||
(ticket printed / plate read; barrier opens)
|
||||
PAY (pay station) payment event {sessionRef, fee, paidAt}
|
||||
→ session PAID (grace window starts)
|
||||
EXIT (lane) validate: PAID && now ≤ paidAt + graceMinutes ?
|
||||
yes → vehicle_exit event → session CLOSED → pulseOpen
|
||||
no → reject → re-pay overstay top-up at station, then exit
|
||||
```
|
||||
|
||||
States, as derived from events:
|
||||
|
||||
| State | Condition (over the event chain) |
|
||||
| --- | --- |
|
||||
| **OPEN** | a `vehicle_entry` with no later matching `vehicle_exit` |
|
||||
| **PAID** | OPEN + a `payment` event covering the fee due, within its grace window |
|
||||
| **CLOSED** | a matching `vehicle_exit` event exists |
|
||||
| **VOIDED** | a `void` event references the session (lost ticket written off, error correction) |
|
||||
|
||||
Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorization to close.
|
||||
|
||||
## Edge cases the model must name (not yet designed in full)
|
||||
|
||||
- **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely
|
||||
stateful rule; handled as a second `payment` event, fee = f(time since paid).
|
||||
- **Lost ticket** — no entry id to match. A default flat "lost ticket" fee (see [[tariff]]), **or
|
||||
an amount the admin sets at the moment** (operator judgement — e.g. they can establish entry time
|
||||
from [[opencv-anpr-service|plate]] capture or CCTV and charge accordingly, or apply a fixed
|
||||
penalty). Recorded as a `payment` (with the chosen amount + a reason) + a `void`/annotation so it
|
||||
reconciles; the admin-set amount is captured in the signed event, attributed.
|
||||
- **Manual override** — an operator/admin opens the barrier for a stuck or disputed car, or writes
|
||||
off a session, as a deliberate act. Each is a **signed, reason-coded event**
|
||||
(`barrier_open_command` / a void with reason) — so an override is *authorized and logged*, while
|
||||
an open with **no** such signed event remains the fraud signal ([[append-only-event-chain]]). The
|
||||
override is the legitimate counterpart to the out-of-band-open anomaly.
|
||||
- **Forced / fail-open exit** — barrier failed open ([[fail-state-safety]]): the vehicle leaves with
|
||||
**no `vehicle_exit`**. This is an open session that never closes — a **reconciliation anomaly by
|
||||
design** ([[append-only-event-chain]]'s "physical open with no signed command"), not something to
|
||||
paper over. (A *manual* override above is the signed, non-anomalous version.)
|
||||
- **Re-entry / never-exited** — stale open sessions (drove out tailgating, sensor missed). Surface
|
||||
as anomalies; never auto-close silently.
|
||||
|
||||
## What this unblocks (build order)
|
||||
|
||||
The device layer left the entry flow dangling — the session domain is that next step. Schema + code
|
||||
follow this page and [[tariff]]; the decision is recorded in [[session-model]].
|
||||
|
||||
### As-built (2026-06-15)
|
||||
|
||||
- **Entry flow** (`apps/server/src/entry-flow.ts`): access-device input edge → print ticket
|
||||
(failover) → signed `vehicle_entry` → `pulseOpen`. Holds (anomaly, no open, no entry) if printing
|
||||
fails. See [[device-input-flow]].
|
||||
- **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the
|
||||
**permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**.
|
||||
Lane resolved once (`readerLaneWithAccess`). See [[permit]] as-built.
|
||||
- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) →
|
||||
fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`**
|
||||
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
|
||||
stays closed. Validation folds the **ledger** (authoritative), then updates the `sessions` cache.
|
||||
- **Not a fail-state:** an unpaid reject keeps the barrier closed deliberately (driver returns to
|
||||
the pay station); "exit fails open" ([[fail-state-safety]]) is about the *system* being unable
|
||||
to decide (host/power loss), not an unpaid car.
|
||||
- **Pay station** (`apps/server/src/pay-station.ts`, routes `GET /api/pay/quote` + `POST /api/pay`):
|
||||
look up the open session → resolve the active tariff version (latest `effectiveFrom ≤ entry`) →
|
||||
`computeFee` → append a signed `payment` event (amount, currency, tender, `tariffVersionId`,
|
||||
`graceExitMin`). An operator `overrideMinor` covers lost-ticket/dispute (recorded as the charged
|
||||
amount + the quoted amount). Pay-on-foot: payment is decoupled from the exit lane. PCI scope stays
|
||||
out of the app — `tender` only records cash/card; card capture is the standalone P2PE terminal.
|
||||
- **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens,
|
||||
session closed, `verifyChain` ok.
|
||||
|
||||
> **Design gap (flagged):** `lane_devices` has **no entry/exit direction** model. Entry is
|
||||
> button-driven and exit is read-driven, so they don't currently collide — but a lane with both an
|
||||
> entry reader and an exit reader can't yet be distinguished. A lane-direction/role model is needed
|
||||
> before multi-reader lanes (relates to [[open-questions]] #1 topology).
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, reporting]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Reporting & Analytics
|
||||
|
||||
Turning the signed event log into the numbers an owner runs the business on. All reports are
|
||||
**projections over the [[append-only-event-chain]]** — the chain is the single source, reports are
|
||||
derived and rebuildable, never a separate ledger.
|
||||
|
||||
## Reports (driven by the events already designed)
|
||||
|
||||
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
|
||||
`payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]).
|
||||
- **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover.
|
||||
- **Stay analytics** — average/median duration, distribution; transient vs. [[permit]] split.
|
||||
- **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`.
|
||||
- **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation —
|
||||
the `anomaly` events + reconciliation findings ([[reconciliation]]).
|
||||
|
||||
## Plate / entry search (admin lookup) — user-requested 2026-06-15
|
||||
|
||||
The admin can **search for an entry/session by licence plate** — *if the plate was captured* (by
|
||||
the [[opencv-anpr-service|vision service]] or an LPR read; a pure-ticket transient has no plate).
|
||||
Returns the matching session(s): entry/exit times, fee, payment, snapshot image. Useful for
|
||||
disputes ("I was charged for a car that left earlier"), lost-ticket lookup, and incident review.
|
||||
|
||||
- Search keys: plate (when captured), ticket id, session id, time range.
|
||||
- Read-only over the chain; surfaces the linked snapshot ([[lpr-camera]] `imageRef`) as evidence.
|
||||
- Honest limit: **no plate → no plate-search hit.** The UI must say "not captured", not "no such
|
||||
car", so the absence isn't mistaken for a missing record.
|
||||
|
||||
## Properties
|
||||
|
||||
- **Offline** ([[offline-first]]): all computed locally from the local DB; no cloud BI dependency.
|
||||
- **Reproducible**: a report run twice over the same chain gives the same answer; figures trace to
|
||||
signed events.
|
||||
- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path
|
||||
([[open-questions]] #4).
|
||||
|
||||
## Open
|
||||
|
||||
- Which reports matter at launch vs. later; the export format/cadence.
|
||||
- Dashboard (live) vs. on-demand reports.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, shifts, anti-fraud]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Shift (manned mode) & the Z-Report
|
||||
|
||||
A **shift** is one operator's accountability period at a manned booth: from the moment they take
|
||||
over to the moment they hand over, however long that is. At the end, the system signs and **prints
|
||||
a Z-report** — the cash and POS totals taken during the shift. (Decisions 2026-06-15.)
|
||||
|
||||
## Shifts exist ONLY in manned mode
|
||||
|
||||
A shift is fundamentally a **human accountability boundary** — "this person was responsible for the
|
||||
takings from here to here." In the [[autonomous-direction|fully-automated / unmanned]] system there
|
||||
is **no operator and no shift**; what replaces it is the pay station's **cash-collection cycle**
|
||||
(who emptied the vault, when, how much vs. what the signed log expected) plus ongoing
|
||||
[[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation;
|
||||
don't force one model across both.
|
||||
|
||||
## A shift is NOT time-based
|
||||
|
||||
It is delimited by **explicit operator action**, never by a clock:
|
||||
|
||||
- Booth reality: relief comes late, doesn't show, or one operator is **forced to work two shifts in
|
||||
a row**. A fixed 8h boundary (or an 8h token expiry) would be wrong — it could strand an active
|
||||
operator. So the [[local-jwt-auth|login token has no time expiry]] (valid until logout).
|
||||
- **Start Shift / End Shift are explicit, and independent of login.** One login can span many
|
||||
shifts; a back-to-back double is simply *End Shift → Start Shift again*, no re-login. The
|
||||
operator (the same person or the next) marks the boundary.
|
||||
|
||||
```
|
||||
login ——————————————————————————————————————————————→ (until logout)
|
||||
[Start shift] … takings … [End shift→sign+print Z] [Start shift] … [End shift] …
|
||||
```
|
||||
|
||||
## What End Shift does
|
||||
|
||||
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
|
||||
[[append-only-event-chain]]) between this shift's start mark and now.
|
||||
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
|
||||
(the card line is omitted when there's no terminal).
|
||||
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
|
||||
startedAt, endedAt, cashTotal, cardTotal?, paymentCount, eventRange, prevZHash }` — chained to
|
||||
the prior Z so a missing/out-of-order Z-report is itself visible.
|
||||
4. **Print the Z-report** (cash total, POS total if any, counts, shift window, operator) on the
|
||||
booth printer.
|
||||
|
||||
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
|
||||
no variance gate, no manager override.
|
||||
|
||||
## Where the fraud control actually lives
|
||||
|
||||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
||||
append-only chain**, the printed cash figure *is* the system's tamper-evident truth. A manager
|
||||
reconciles the signed Z-report against the actual drawer and the bank/POS batch **later** — that's
|
||||
[[reconciliation]], the real control (deferred). The tradeoff vs. a heavier control is purely
|
||||
*when* a skim is caught (after the fact, by a human), not *whether*.
|
||||
|
||||
> **Optional enhancement (not building now): blind cash count.** Have the operator enter the
|
||||
> counted cash *before* the system reveals the expected figure, and record the variance into the
|
||||
> `shift_z_report`. Blindness removes the operator's ability to back-fill their declaration to match
|
||||
> expectation, catching a skim **at close** rather than later. Explicitly out of scope per
|
||||
> 2026-06-15; documented as a clean add-on if ever wanted.
|
||||
|
||||
## Open
|
||||
|
||||
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
||||
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
||||
the money. Confirm that's the intended accountability (vs. by entry).
|
||||
- **Mid-shift report / X-report** (read-only "so far" total without closing) — add if booths want
|
||||
it; the sum is the same projection.
|
||||
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
|
||||
(relates to [[open-questions]] #1 lane topology).
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, pricing]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Tariff (Fee Model)
|
||||
|
||||
How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data,
|
||||
not code** — the park owner builds and constantly edits the rate card at runtime (like a
|
||||
[[permit]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to
|
||||
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
|
||||
beyond the host).
|
||||
|
||||
> Decisions (2026-06-15): (1) tariffs are **effective-dated, immutable versions** — editing
|
||||
> publishes a new version, never mutates an old one; (2) **one active tariff per site** (versioned
|
||||
> over time), modelled with an id/scope so multiple rate cards can be added later without migration;
|
||||
> (3) **currency is selectable** (ISO 4217) and the money model is **FX-ready but FX is deferred**.
|
||||
|
||||
## Design principles
|
||||
|
||||
- **Pure function of (entry time, charge time, tariff).** `fee = f(enteredAt, asOf, tariff)`. No
|
||||
side effects, deterministic, unit-testable. The pay station calls it with `asOf = now`; the exit
|
||||
lane re-checks against the recorded payment.
|
||||
- **Data-driven.** The tariff lives as a config record (its own table or seeded config), versioned,
|
||||
so a historical session always reprices against the tariff in force when it was incurred. Never
|
||||
hard-code rates (this is an [[open-questions|open-question]]-adjacent procurement input — sites
|
||||
differ).
|
||||
- **Integer minor units.** Money is integer cents (or the site currency's minor unit) — never
|
||||
floats. Avoids rounding drift across a revenue ledger.
|
||||
- **The fee, once paid, is a signed `payment` event** ([[parking-session]]) — the computation is
|
||||
reproducible, but the *charged* amount is fixed in the chain.
|
||||
|
||||
## The composable structure — stepped blocks + daily cap
|
||||
|
||||
The admin composes a **rate card** the fee function interprets. The general model is an **ordered
|
||||
list of duration blocks** (flat rate is just one block) plus a daily cap — chosen because it
|
||||
expresses every common operator shape (first-hour pricing, tapering, caps) with no special cases in
|
||||
code. All amounts are **integer minor units** in the tariff's currency.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"currency": "EUR", // ISO 4217; selectable per tariff version
|
||||
"gracePeriodEntryMin": 15, // free if exited within this (drop-off/turnaround)
|
||||
"incrementMin": 60, // billing granularity; partial increments round UP
|
||||
"blocks": [ // consumed in order as duration accrues
|
||||
{ "uptoMin": 60, "priceMinorPerIncrement": 200 }, // first hour
|
||||
{ "uptoMin": 180, "priceMinorPerIncrement": 150 }, // 60→180 min
|
||||
{ "uptoMin": null, "priceMinorPerIncrement": 100 } // null = open-ended, thereafter
|
||||
],
|
||||
"dailyCapMinor": 1200, // cap per rolling 24h (null = no cap)
|
||||
"lostTicketMinor": 2000, // flat charge when there's no entry id
|
||||
"gracePeriodExitMin": 15, // pay-on-foot walk-back window
|
||||
"overstay": "reprice" // top-up = recompute(entry→now) − alreadyPaid (decided)
|
||||
}
|
||||
```
|
||||
|
||||
> **The numbers above are illustrative, not defaults to ship.** "No one knows the pricing and it
|
||||
> changes constantly" — so the admin authors all of it; the system ships with **no rate card** and
|
||||
> the owner must compose + publish one before the lot can charge (until then: free, or gated —
|
||||
> operator policy, see Open).
|
||||
|
||||
**Lost ticket** is not just the flat `lostTicketMinor`: the admin may **override with an arbitrary
|
||||
amount** at the moment (operator judgement — establish entry time from [[opencv-anpr-service|plate]]
|
||||
capture/CCTV and charge real duration, or apply a set penalty). The configured flat fee is the
|
||||
default; the chosen amount is recorded in the signed `payment` event ([[parking-session]]).
|
||||
|
||||
## The fee algorithm (pure, integer, offline)
|
||||
|
||||
```
|
||||
fee(enteredAt, asOf, tariff):
|
||||
minutes = roundUp(asOf − enteredAt, incrementMin)
|
||||
if minutes ≤ gracePeriodEntryMin: return 0
|
||||
total = 0
|
||||
for each rolling 24h segment of the stay:
|
||||
segMinutes = minutes within this segment
|
||||
segFee = walk `blocks` in order, charging priceMinorPerIncrement for each
|
||||
incrementMin that falls in each block's [prevUpto, uptoMin) range
|
||||
if dailyCapMinor: segFee = min(segFee, dailyCapMinor)
|
||||
total += segFee
|
||||
return total
|
||||
```
|
||||
|
||||
Deterministic, side-effect-free, unit-testable; the daily cap is applied **per rolling 24h** (so an
|
||||
overnight stay doesn't hit the cap twice). Rounding and segment edges are part of the settled spec
|
||||
because the chain + reconciliation depend on the result being reproducible.
|
||||
|
||||
**Settled edges (2026-06-15, with tests):**
|
||||
- **Grace uses RAW duration** — a stay within `gracePeriodEntryMin` is free even though the
|
||||
increment would round it up (else rounding defeats the grace window).
|
||||
- **The block ladder RESETS each rolling-24h day** — day 2 starts at the first block again (a 25h
|
||||
stay = day-1 capped + day-2 first-hour rate), so the "daily" rate truly resets daily.
|
||||
|
||||
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
|
||||
across grace, block steps, daily cap, and multi-day reset.
|
||||
|
||||
### Composer (as-built 2026-06-15)
|
||||
|
||||
The admin authors the rate card at runtime — no hand-seeding:
|
||||
|
||||
- **API** (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active version + history; any
|
||||
signed-in role) and `POST /api/tariff/versions` (publish a new immutable version; **admin only**).
|
||||
Publishing validates the structure via `validateTariffStructure` (shared) — non-negative integers,
|
||||
ordered/ascending block bounds, only the last block open-ended — so a malformed card can never be
|
||||
published. The single site `tariffs` row is created lazily on first read/publish.
|
||||
- **UI** (`apps/web/src/TariffComposer.tsx`, admin shell): edit currency, grace windows, increment,
|
||||
daily cap, lost-ticket fee, and add/remove rate blocks; amounts entered in major units, converted
|
||||
to integer minor units on submit. Shows the active version + history; "Publish" creates a new
|
||||
version (past sessions keep their pricing).
|
||||
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
||||
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
||||
|
||||
## The pay-on-foot consequence
|
||||
|
||||
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
|
||||
time references**, not one:
|
||||
|
||||
1. At the **pay station**: `fee = f(enteredAt, now, tariff)` — charge for time parked so far.
|
||||
2. At the **exit lane**: the session is valid to leave iff `now ≤ paidAt + gracePeriodExit`.
|
||||
Past that, an **overstay top-up** = `f(paidAt, now, tariff.overstayRate)` is due before exit.
|
||||
|
||||
`gracePeriodExit` is therefore a real revenue/UX parameter, not a nicety: too short traps people
|
||||
who paid; too long gives free parking between pay and exit.
|
||||
|
||||
## Permit holders
|
||||
|
||||
A valid [[permit]] bypasses tariff computation entirely for the covered period (subscription
|
||||
already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for
|
||||
the uncovered time — an edge case to design with [[permit]].
|
||||
|
||||
## Versioning — edits publish immutable, effective-dated versions
|
||||
|
||||
Prices change constantly, **and** a historical [[parking-session]] must reprice against the rate
|
||||
that was in force when it was incurred — never today's. So a tariff is **never edited in place**:
|
||||
|
||||
- Each save **publishes a new version** with an `effectiveFrom` timestamp; prior versions are
|
||||
**immutable**. Picking the version for a session = "the latest version with `effectiveFrom ≤
|
||||
session entry time`".
|
||||
- The session's **`payment` event records the `tariffVersionId`** it was priced under
|
||||
([[parking-session]], [[append-only-event-chain]]). The charged amount is then both reproducible
|
||||
*and* fixed in the signed chain — an admin can't retroactively rewrite prices to alter what a past
|
||||
session "should have" paid without it being visible.
|
||||
- An **in-progress** session that crosses a version boundary uses the version in force at **entry**
|
||||
(consistent, predictable) — confirm vs. pro-rating if an operator ever wants the latter.
|
||||
|
||||
## Data model (first cut — with [[session-model]])
|
||||
|
||||
| Table / field | Notes |
|
||||
| --- | --- |
|
||||
| `tariffs` | a logical rate card: `id`, `scope` (site/lane/zone — only "site" used now), `name`. |
|
||||
| `tariff_versions` | `id`, `tariffId`, `effectiveFrom`, `currency`, `structure` (the JSON above), `createdBy`, `createdAt`. **Immutable.** |
|
||||
| (active) | "one active tariff per site" = one `tariffs` row; multiple `tariff_versions` over time. The `scope`/`id` exist so multiple rate cards can be added later **without migration**. |
|
||||
|
||||
Unlike the event log, tariff data is **mutable master data** in the sense that new versions are
|
||||
*added*; but each version row, once published, is never changed — close to append-only, and the
|
||||
*use* of it is fixed in the signed `payment` event.
|
||||
|
||||
## Currency & FX — selectable now, FX deferred
|
||||
|
||||
- Each `tariff_version` names its **`currency`** (ISO 4217), admin-selectable. Amounts everywhere
|
||||
are `{ minorUnits, currency }` — never a bare number, never a float.
|
||||
- A `payment` event stores its **`currency`** and a reserved **`fxRate` (null for now)** + optional
|
||||
`baseCurrency`. So when an exchange-rate system is added later, historical payments stay
|
||||
reproducible (you know the currency charged and, once FX exists, the rate applied) — **no
|
||||
migration** of stored amounts.
|
||||
- **FX engine is NOT built now.** When it is, it needs an *offline* rate source (rates can't depend
|
||||
on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to
|
||||
[[open-questions]].
|
||||
|
||||
## Open
|
||||
|
||||
- The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the
|
||||
composer UI + validation (sane blocks, non-negative, ordered `uptoMin`) is the work.
|
||||
- **Time-of-day / weekday tiers** — not in the block model yet; add as a tier wrapper if a site
|
||||
needs day/night/weekend cards (deferred until asked).
|
||||
- **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy).
|
||||
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
|
||||
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, devices, entry-flow]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Ticket Encoding & Scanning
|
||||
|
||||
How a transient [[parking-session]]'s **ticket id** is printed, carried by the customer, and read
|
||||
back at the pay station and exit. This is the **physical backbone of the transient flow** — the
|
||||
thing that links entry → pay → exit when there's no plate.
|
||||
|
||||
## The ticket id is the session key
|
||||
|
||||
At entry the system mints a `vehicle_entry` event with a **ticket id** (`identity`) and prints a
|
||||
ticket the customer keeps. That same id is read back later to find the session. Properties the id
|
||||
must have:
|
||||
|
||||
- **Opaque + unguessable** — a random id (not a sequential count an attacker could iterate to claim
|
||||
someone else's cheaper session). Sequential **physical** stock numbering is a separate
|
||||
reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key.
|
||||
- **Single logical session** — scanning it at the pay station finds the open session; after payment
|
||||
it's the proof-of-paid the exit checks.
|
||||
|
||||
## Encoding: QR (preferred) — printed by the booth dispenser
|
||||
|
||||
- The [[rongta-printer]] prints the ticket id as a **2D barcode (QR)** plus human-readable text and
|
||||
entry time. QR over 1D barcode: denser, tolerant of crumpling/partial reads, easy for a cheap
|
||||
camera/imager to read.
|
||||
- **Scan points** (both host-side reads — [[entry-exit-readers]]):
|
||||
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
|
||||
payment ([[tariff]], pay-on-foot) → appends `payment`.
|
||||
- **Exit lane** — customer scans the (now paid) ticket → host validates paid + within
|
||||
`gracePeriodExit` → `vehicle_exit` → `pulseOpen`.
|
||||
- The **scanner is a device behind an adapter** ([[device-adapter-pattern]]): a new `ReaderDevice`
|
||||
kind (QR/barcode imager) — likely the same `IdentitySource = "ticket"` / `"qr"` path. Keeps the
|
||||
app device-agnostic; hardware model is procurement ([[bom]], [[open-questions]]).
|
||||
|
||||
## Ticketless alternative (plate as the ticket)
|
||||
|
||||
Where the [[opencv-anpr-service|vision service]]/LPR captures the plate, the **plate can be the
|
||||
session key** instead of a printed ticket — drive in, plate read, drive to pay station and enter
|
||||
plate (or it's looked up), pay, exit by plate. No paper. The two can coexist per lane
|
||||
([[entry-exit-readers]] "both share a relay"); a printed QR ticket is the fallback when a plate
|
||||
isn't captured or is low-confidence (recognition is advisory — [[opencv-anpr-service]]).
|
||||
|
||||
## Open
|
||||
|
||||
- QR symbology/error-correction level + what else prints (site name, tariff summary, help number).
|
||||
- Scanner hardware (imager model; same unit at pay station and exit?).
|
||||
- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary
|
||||
amount).
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, capacity, manned]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Valet / Over-Capacity Mode
|
||||
|
||||
"Full" is **not** necessarily a hard stop. If the operator opts in, a lot at nominal capacity can
|
||||
still accept cars via **valet**: the customer hands over the keys and leaves, and the operator
|
||||
stacks/double-parks the vehicle beyond the marked space count. (User direction, 2026-06-15.)
|
||||
|
||||
## "Full" is a soft, operator-configurable policy
|
||||
|
||||
The [[capacity-occupancy]] FULL gate is therefore a **policy knob**, not a physical absolute:
|
||||
|
||||
- **Refuse** — hard stop at nominal capacity (the default/strict behaviour).
|
||||
- **Valet over-capacity** — accept beyond capacity into operator custody.
|
||||
|
||||
The choice is the operator's, per site (and possibly per time/condition).
|
||||
|
||||
## Valet is a manned-mode feature with a different session shape
|
||||
|
||||
Valet only exists when there's an operator (cf. [[shift]] — manned-only). It adds a **custody**
|
||||
dimension the normal [[parking-session]] doesn't have:
|
||||
|
||||
- The **operator takes custody** of the car — identity is a **claim/valet ticket**, and the
|
||||
operator (not the driver) is accountable for the vehicle between handover and return.
|
||||
- New facts to record (as signed [[append-only-event-chain]] events when built): **key handover**,
|
||||
where/when parked, and **return** to the customer. The operator's accountability ties into the
|
||||
[[shift]] Z-report and [[reconciliation]] (a valet car with no return record is an anomaly).
|
||||
- Payment still flows through the normal [[tariff]] (duration-based) unless a separate valet fee
|
||||
applies.
|
||||
|
||||
## Status — deferred
|
||||
|
||||
Captured now so the [[capacity-occupancy]] design treats "full" as soft and the entry flow leaves a
|
||||
clean seam. **Not** built into the current transient entry flow (decision 2026-06-15). Full design —
|
||||
the valet session/custody model, the claim ticket, the over-capacity accept path — is future work.
|
||||
|
||||
## Open
|
||||
|
||||
- Valet session/custody data model (claim ticket, parked location, return event).
|
||||
- Whether a distinct valet fee/tariff applies, or normal duration pricing.
|
||||
- Operator UI for handover/return; how it ties to the [[shift]] accountability record.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, pricing, revenue]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Validation & Discounts
|
||||
|
||||
A merchant (shop, hotel, clinic) **validates** a customer's parking so they pay less or nothing —
|
||||
a common revenue/retention feature that modifies what a [[parking-session]] owes.
|
||||
|
||||
## Model: a discount is a signed event, applied at fee time
|
||||
|
||||
A validation is **not** an edit to the session or a mutable "discount applied" flag — same reason
|
||||
as everything else ([[threat-model]]: an operator/merchant could otherwise fake free parking). It's
|
||||
recorded so the fee computation and the audit both see it:
|
||||
|
||||
- A **discount/validation event** references the session: `{ sessionRef, kind, value, issuedBy,
|
||||
ts }` — e.g. *2 hours free*, *€5 off*, *flat €1*, *100% off*. Appended + signed
|
||||
([[append-only-event-chain]]).
|
||||
- The [[tariff]] fee function applies eligible validations when computing what's due at the pay
|
||||
station: `due = max(0, tariff_fee − discounts)` (or time-based: subtract validated minutes before
|
||||
pricing). Pure + reproducible, like the base fee.
|
||||
- The `payment` event then records gross fee, discount total, and net paid — so revenue reporting
|
||||
([[reporting-analytics]]) can show **discount leakage** (how much was given away, by whom).
|
||||
|
||||
## How a validation is presented
|
||||
|
||||
- **Merchant terminal / portal** stamps the customer's ticket id (or plate) — issues the validation
|
||||
event for that session.
|
||||
- Or a **validation code** the customer enters at the pay station.
|
||||
- Either way it ties to the session by **ticket id or plate** ([[parking-session]] identity).
|
||||
|
||||
## Anti-abuse
|
||||
|
||||
Because each validation is signed and attributed (`issuedBy`), over-validation by a colluding
|
||||
merchant is **visible to [[reconciliation]]** (a merchant validating far more than their footfall is
|
||||
an anomaly), rather than invisible free parking.
|
||||
|
||||
## Open
|
||||
|
||||
- Validation types the site needs (free hours / fixed amount / percentage / flat rate).
|
||||
- Whether merchants self-serve (portal/terminal) or the operator applies it.
|
||||
- Caps (max discount, max per merchant/day).
|
||||
@@ -56,6 +56,59 @@ Mirrored networking is necessary but **not sufficient** — these still bit us:
|
||||
(`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the
|
||||
proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].)
|
||||
|
||||
## Multi-subnet source-address trap (the "ARP works but ping/TCP dies" bug)
|
||||
|
||||
Field devices arrive **statically configured on assorted `/24`s** by whoever installed them last
|
||||
(e.g. a camera on `10.0.10.121`, a printer on `10.0.10.6`, others on `192.168.1.x`). The host
|
||||
copes by carrying **one IP per device subnet on a single NIC** (this is correct — you do **not**
|
||||
need a NIC per subnet). But stacking subnets on one interface exposes a Linux source-selection
|
||||
trap:
|
||||
|
||||
- Connected routes come up as `proto kernel scope link` **with no preferred source**. With two
|
||||
such subnets on one NIC, the kernel may pick the **wrong source address** — e.g. sourcing
|
||||
traffic to `10.0.10.121` from `192.168.1.123`.
|
||||
- Symptom is baffling: **ARP resolves and the neighbor shows `REACHABLE`** (L2 is fine, source
|
||||
address is irrelevant to ARP) while **every ping and TCP connect times out** (replies have a
|
||||
wrong/unroutable source → dropped, possibly by uRPF). Looks like "the device is down / the whole
|
||||
subnet is unreachable" when nothing is actually broken.
|
||||
- **Diagnose:** `ip route get <device-ip>` shows the chosen `src` — if it's an address on a
|
||||
*different* subnet, that's the bug. Confirm by forcing the right source:
|
||||
`ping -I <correct-src> <device-ip>` (or `curl --interface <correct-src> …`) — instant replies.
|
||||
- **Fix (runtime):** pin the preferred source on the connected route, per subnet:
|
||||
`sudo ip route replace <subnet>/24 dev <nic> proto kernel scope link src <correct-host-ip> metric <m>`
|
||||
(use `replace`, not `change` — `change` errors `RTNETLINK: No such file` if the route isn't up
|
||||
yet). Do **not** delete the other subnet's address unless it's genuinely unwanted — you need all
|
||||
of them to reach all the devices.
|
||||
|
||||
- **Fix (permanent, this box):** `deploy/wsl-fix-route-source.sh` + `deploy/parking-net.service`.
|
||||
The script walks each `proto kernel scope link` route on the NIC and pins `src` to THIS host's own
|
||||
address in that same subnet — **no hardcoded IPs**, so it also covers future device subnets; it's
|
||||
idempotent, preserves the route metric, and tolerates a missing route. The systemd unit (oneshot,
|
||||
`enabled`) reapplies it on every WSL boot — which is the point, since `wsl --shutdown` otherwise
|
||||
wipes the runtime fix (mirrored mode re-clones the Windows addresses fresh each boot, see below).
|
||||
Install once: copy the unit to `/etc/systemd/system/`, `systemctl enable --now parking-net`.
|
||||
Gotchas hit while building it: `network.target` is too early for mirrored-mode addresses (the
|
||||
script waits up to 15s for a route to appear); and it must NOT `set -e` or one failed `ip` call
|
||||
aborts the whole boot fixer.
|
||||
|
||||
> **Root cause is on the Windows side.** Mirrored mode clones the Windows host NIC's addresses into
|
||||
> Linux at every boot, so the stray `192.168.1.x` lives on Windows — the truly permanent fix is to
|
||||
> remove/reconfigure it there (or set `SkipAsSource`/interface metric). The systemd hook is the
|
||||
> self-contained Linux-side answer that needs no Windows changes.
|
||||
|
||||
Verified on hardware (2026-06-15): after the hook, `10.0.10.121` pings and the real [[lpr-camera]]
|
||||
Hikvision driver pulls a snapshot with **no** source-forcing (`localAddress` becomes optional).
|
||||
|
||||
## On the real appliance: multi-subnet is a deployment config, not a WSL hack
|
||||
|
||||
Production is a **dedicated hardened Linux appliance** ([[disk-os-hardening]]), so the WSL story
|
||||
above is dev-only. The device-subnet problem persists, though, and is solved the same way at the
|
||||
OS level: the appliance NIC carries **one address per device subnet**, each connected route with a
|
||||
pinned `src`, made persistent (systemd-networkd / netplan). Per the threat model this still rides
|
||||
on **[[network-isolation]]** — device subnets are isolated segments reachable only by the host.
|
||||
The long-term clean answer is to **re-IP the devices onto one planned parking-system subnet** at
|
||||
install so the host needs only one address; the multi-subnet config is what you run until then.
|
||||
|
||||
## Alternative if you can't use mirrored mode
|
||||
|
||||
Windows 10 / old WSL can't do mirrored mode. Options: run the **backend natively on Windows**
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
type: decision
|
||||
tags: [parking, decisions, integrity, devices, schema]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Decision: Split the signed business ledger from device telemetry
|
||||
|
||||
Taken 2026-06-15, at the start of the business-layer schema work.
|
||||
|
||||
## The problem
|
||||
|
||||
The existing `events` table (signed, hash-chained — [[append-only-event-chain]]) had grown to carry
|
||||
**two unrelated concerns**: the financial/accountability ledger *and* raw device telemetry (button
|
||||
pushes recorded as `input_received`). They have opposite requirements — the ledger must be small,
|
||||
signed, and reconciled; telemetry is high-volume, churny, and disposable.
|
||||
|
||||
## Decision — two tables
|
||||
|
||||
- **`ledger_events`** — the signed, hash-chained, [[atecc608]]-signed **business ledger** (rename of
|
||||
`events`). Holds only business/accountability facts: `vehicle_entry`, `vehicle_exit`, `payment`,
|
||||
`void`, `shift_z_report`, and the witness-grade `barrier_open_command` / `barrier_open_observed` /
|
||||
`anomaly`. [[reconciliation]] runs against this; sessions/[[tariff]]/occupancy are projections of
|
||||
it.
|
||||
- **`device_events`** — **unsigned** operational telemetry (see [[device-events]]): relay fired,
|
||||
printer paper-out, camera offline, reader read, raw input edges. May rotate/prune. Never signed,
|
||||
never reconciled.
|
||||
|
||||
A raw button press is **telemetry** → `device_events`. The entry flow then mints a **signed
|
||||
`vehicle_entry`** in the ledger once a ticket prints + the barrier is commanded. So
|
||||
`input_received`-as-a-signed-event is **dropped** (it was transitional).
|
||||
|
||||
## Why
|
||||
|
||||
- Keeps the **signed ledger small and high-value** — fewer rows to sign, hash, verify, reconcile,
|
||||
and export; signal isn't drowned in device noise.
|
||||
- Right **durability semantics per stream**: the ledger is precious + append-only forever; telemetry
|
||||
can age out.
|
||||
- Clean separation matches the [[device-adapter-pattern]] philosophy — device chatter stays on the
|
||||
device side of the boundary.
|
||||
|
||||
## Consequences / migration (no production data yet)
|
||||
|
||||
- No `.sqlite` with real chain data exists, so renaming + restructuring is safe now (no signatures
|
||||
to invalidate). This is the moment to do it.
|
||||
- Code: rename `events` → `ledger_events`; `EventLog`/`canonicalize`/`verifyChain` and the
|
||||
`/api/events` routes follow the rename; add an unsigned `device_events` writer; move the Dingtian
|
||||
input-push handler to emit `device_events` (+ the entry flow signs `vehicle_entry`).
|
||||
- `ParkingEventType` in `packages/shared` splits into ledger types vs. a device-event type set.
|
||||
|
||||
## Open
|
||||
|
||||
- `device_events` retention/rotation policy.
|
||||
- Which device facts (if any) are witness-grade enough to *also* warrant a signed ledger entry
|
||||
(e.g. `barrier_open_observed` from a loop sensor) — see [[append-only-event-chain]] witness gap.
|
||||
@@ -24,7 +24,12 @@ status: open
|
||||
manager visit) to reconcile the signed log against an external authority — the real anti-fraud
|
||||
control. See [[reconciliation]].
|
||||
5. **Durability / backup.** Backup strategy for the [[sqlite]] database + recovery plan; "sync
|
||||
later" currently leaves a disk failure as **total revenue-history loss**.
|
||||
later" currently leaves a disk failure as **total revenue-history loss**. _(Confirmed in-scope
|
||||
to design, 2026-06-15.)_ Because the DB is the signed [[append-only-event-chain]], a backup must
|
||||
preserve the chain intact (a restored copy must still `verifyChain`); options include SQLite
|
||||
WAL/online-backup snapshots to a second disk/USB + the periodic external export that doubles as
|
||||
the [[reconciliation]] channel (#4). Encryption at rest already applies ([[disk-os-hardening]]).
|
||||
Design TBD.
|
||||
6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event
|
||||
signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not
|
||||
being implemented for now** (access control is the [[dingtian-relay]] behind
|
||||
@@ -39,3 +44,15 @@ status: open
|
||||
compromising a verifying host yields nothing that can forge a token. Decide before
|
||||
multi-host / multi-lane deployment (see #1 lane topology), since that's when shared-secret
|
||||
distribution becomes the liability.
|
||||
8. **Exchange-rate (FX) system.** _(Raised by the [[tariff]] design, 2026-06-15.)_ Currency is
|
||||
selectable per tariff version and the money model is FX-ready (`payment` stores currency + a
|
||||
reserved `fxRate`), but **no conversion is built**. If multi-currency pricing/charging is ever
|
||||
needed, it requires an **offline** rate source (rates can't depend on the network —
|
||||
[[offline-first]]), a base currency, and a rounding policy. Deferred; nothing blocks adding it
|
||||
later without migrating stored amounts.
|
||||
9. **Pay-station money corners — receipts & refunds/change.** _(Raised by the scope sweep,
|
||||
2026-06-15; deferred until pay-station hardware is chosen.)_ Not yet designed: **receipts / VAT
|
||||
invoices** (fiscal receipt with tax number + sequential numbering may be legally required — could
|
||||
change what the `payment` event must store) and **refunds / overpayment / change** (cash change,
|
||||
"exact change only", a refund as a signed reversal event). Both depend on the unmanned-vs-manned
|
||||
payment subsystem (#3) and the note/coin/card acceptor hardware. Revisit at procurement.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
type: decision
|
||||
tags: [parking, decisions, domain, business]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Decision: Parking Session Model
|
||||
|
||||
The starting decision for the **business layer**, taken 2026-06-15 as the project pivots from the
|
||||
(now hardware-verified) device/integrity layer to the parking *operation*.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **A session is a projection over the signed event log, not a mutable table.** The
|
||||
[[append-only-event-chain]] `events` table stays the only source of truth; a
|
||||
[[parking-session]] is folded from `vehicle_entry` / `vehicle_exit` / `payment` / `void`
|
||||
events. A cache table is allowed for query speed but is always rebuildable and never
|
||||
authoritative.
|
||||
2. **Transient-first, mixed site.** Model the casual pay-for-duration session + [[tariff]] first;
|
||||
layer [[permit]] holders on top as a second identity source that short-circuits payment
|
||||
([[entry-exit-readers]]).
|
||||
3. **Pay-on-foot / pay station.** Payment is **decoupled from exit**: the customer pays at a
|
||||
central station; the exit lane only validates the session is paid and within the walk-back
|
||||
grace window before opening ([[parking-session]] lifecycle). Matches the
|
||||
[[autonomous-direction|unmanned]] roadmap and sharpens [[open-questions]] #3 toward an unmanned
|
||||
pay station (PCI scope still kept out of the app via a certified terminal).
|
||||
4. **New signed event types:** `vehicle_entry`, `vehicle_exit`, `payment`, `void` — extend the
|
||||
existing `input_received`. Recorded in [[append-only-event-chain]].
|
||||
|
||||
## Why (rejected alternative)
|
||||
|
||||
A **mutable `sessions` table** carrying `amountOwed` / `paidStatus` as the source of truth was
|
||||
rejected: it reopens the exact fraud vector the system exists to close ([[threat-model]] — the
|
||||
insider edits the row, marks it paid, pockets the cash). Making "paid" a **signed `payment`
|
||||
event** means it can't be forged and can't be silently deleted (a deletion breaks the chain). The
|
||||
projection approach costs a fold/cache but keeps the anti-fraud guarantee intact end-to-end.
|
||||
|
||||
## What this unblocks
|
||||
|
||||
Closes the dangling thread from [[device-input-flow]] ("the entry flow itself is the next
|
||||
build"): `input_received` → signed `vehicle_entry` → ticket print → `pulseOpen`, then the
|
||||
pay-station and exit-validation flows. Schema (`packages/db`) + shared types follow the
|
||||
[[parking-session]] + [[tariff]] design pages.
|
||||
|
||||
## Open / next
|
||||
|
||||
- Rate card, currency, grace windows, caps — operator/procurement input ([[tariff]]).
|
||||
- Tariff versioning (effective-dated) for historical repricing.
|
||||
- [[permit]] data model + lapsed-mid-stay handling.
|
||||
- Wire payment capture to a concrete pay-station terminal ([[open-questions]] #3) — kept abstract
|
||||
(payment = an independent signed event referencing a session) until procurement settles.
|
||||
- Reconciliation of sessions/payments against an external authority remains [[open-questions]] #4
|
||||
+ the unbuilt witness/reconciliation gap in [[append-only-event-chain]].
|
||||
@@ -14,6 +14,10 @@ The decisions treated as settled in the design notes. (See [[parking-system-arch
|
||||
- **Stack:** [[turborepo]] · [[fastify]] (Node) · [[react-vite-spa]] · [[sqlite]] +
|
||||
[[drizzle-orm]] · [[local-jwt-auth]]. All MIT/Apache/BSD — **no vendor lock, no rug-pull
|
||||
risk** (see [[payload-cms]]). Full table in [[technology-stack]].
|
||||
- **Scoped exception (2026-06-15):** the [[opencv-anpr-service]] — a **separate local process**,
|
||||
not linked into the app — **may use AGPL** components (plate/vehicle models). The exception is
|
||||
bounded to that process; the Node/React app stays strictly MIT/Apache/BSD. See
|
||||
[[vision-service]].
|
||||
- **Platform:** a **dedicated, hardened Linux appliance** (LUKS + GRUB password + Secure Boot),
|
||||
**not Windows/WSL** — see [[disk-os-hardening]].
|
||||
- **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
type: decision
|
||||
tags: [parking, decisions, vision, anpr, anti-fraud]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Decision: Host-side Vision Service (ANPR + vehicle verification)
|
||||
|
||||
Taken 2026-06-15, as part of the business-layer build ([[session-model]]).
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Build a host-side vision service** ([[opencv-anpr-service]]) that does ANPR (plate → identity)
|
||||
**and** vehicle-attribute verification (anti-spoofing witness) on snapshots from ordinary
|
||||
Hikvision/Dahua cameras.
|
||||
2. **It replaces the dedicated edge-AI [[lpr-camera]]** as the recognition path: ordinary IP cam →
|
||||
snapshot (`Snapshot.bytes`, already pulled by the camera driver) → vision service → plate +
|
||||
vehicle. Removes the special LPR camera from the [[bom]] as a requirement (still allowed as an
|
||||
option).
|
||||
3. **Deployment: a separate local Python/OpenCV microservice** on the appliance, called over
|
||||
**localhost HTTP** by the Node backend. Fully offline ([[offline-first]]); its own process and
|
||||
failure domain; the host falls back to the ticket path if it's unavailable.
|
||||
4. **Licensing exception:** AGPL components (e.g. YOLO plate/vehicle models, OpenALPR) are
|
||||
**permitted inside this service only**, because it's a separate process not linked into the app —
|
||||
the app stays strictly MIT/Apache/BSD. Amends [[standing-decisions]].
|
||||
5. **Recognition is advisory, evidence is authoritative.** A read never single-handedly authorizes
|
||||
a paid/access barrier open; it flags for [[reconciliation]] and attaches (with the source image)
|
||||
to the signed [[append-only-event-chain]] entry. Low confidence → fallback, never strand a car
|
||||
([[fail-state-safety]]).
|
||||
|
||||
## Why
|
||||
|
||||
- **Replace vs. edge-AI camera:** host-side recognition on cheap IP cams shifts cost from per-lane
|
||||
smart cameras to one compute box + our software; gives us the raw image for the second job below.
|
||||
- **Vehicle verification is the real prize (user-driven, 2026-06-15):** plate-only ANPR can't catch
|
||||
a **printed/spoofed plate on a different car**. Extracting vehicle attributes/fingerprint lets the
|
||||
system reconcile *the car*, not just the number — directly filling the independent-witness gap the
|
||||
[[append-only-event-chain]] calls out as unbuilt.
|
||||
- **Separate-process + AGPL-scoped** keeps the app's permissive-license guarantee intact while not
|
||||
crippling accuracy (the strict permissive-only ANPR path is markedly weaker — that tradeoff was
|
||||
weighed and the scoped exception chosen).
|
||||
|
||||
## Rejected / alternatives
|
||||
|
||||
- **Strict permissive-only ANPR in-app** — license-clean but weaker accuracy and more build; the
|
||||
separate-process AGPL exception was chosen instead.
|
||||
- **Keep the edge-AI LPR camera as primary** — viable fallback if host-side accuracy disappoints;
|
||||
not chosen now, kept on the table in [[opencv-anpr-service]].
|
||||
- **Embed OpenCV in Node** (opencv4nodejs/WASM) — rejected: native-build pain, weaker model
|
||||
ecosystem, no process isolation, shares the app's failure + license surface.
|
||||
|
||||
## Open / next
|
||||
|
||||
- Recognizer + vehicle-model selection and accuracy targets; fingerprint method + anomaly
|
||||
threshold ([[opencv-anpr-service]]).
|
||||
- Appliance compute footprint (CPU vs. small GPU/NPU) — [[bom]] / [[open-questions]].
|
||||
- Service API + the Node-side adapter; per-camera opt-in wiring.
|
||||
- Reconciliation logic that consumes plate+vehicle witness vs. commanded opens (still unbuilt — see
|
||||
[[append-only-event-chain]], [[reconciliation]]).
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: entity
|
||||
tags: [parking, domain, business, access-control]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Blocklist (Banlist)
|
||||
|
||||
Plates or credentials the lot **refuses** — barred vehicles (non-payers, abusers, court orders) and
|
||||
revoked/stolen cards. Checked in the entry flow.
|
||||
|
||||
## Model
|
||||
|
||||
- A `blocklist` table of `{ kind: 'plate' | 'card' | 'qr', value, reason, addedBy, addedAt }` —
|
||||
admin-managed master data (mutable: add/lift a ban).
|
||||
- **Entry check:** after identifying the vehicle ([[parking-session]] identity — plate via
|
||||
[[opencv-anpr-service|vision]]/LPR, or card/QR), if it matches an active blocklist entry, **refuse
|
||||
entry** and append a signed event (`anomaly` / a refused-entry record) so the attempt is logged.
|
||||
- **Exit is never blocked** — a barred car already inside must still leave ([[fail-state-safety]]:
|
||||
never trap a vehicle). A blocklist hit at exit is logged for follow-up, not used to detain.
|
||||
|
||||
## Notes
|
||||
|
||||
- Plate matching depends on capture quality — a blocklist-by-plate is only as good as the
|
||||
[[opencv-anpr-service|vision]] read; treat a near-miss as a flag for a human, not an automatic
|
||||
refusal that could strand a misread innocent car.
|
||||
- Bans are attributed (`addedBy`) and their enforcement is logged, so the control is auditable
|
||||
([[reconciliation]]) rather than an invisible operator lever.
|
||||
|
||||
## Open
|
||||
|
||||
- Plate-match tolerance (exact vs. fuzzy) and the false-positive handling.
|
||||
- Expiry / review of bans.
|
||||
@@ -13,7 +13,14 @@ Authentication and authorization, kept **fully local** — a direct consequence
|
||||
|
||||
- `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to
|
||||
start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no
|
||||
insecure default — and mints tokens with an **8h expiry** (bound to a shift).
|
||||
insecure default.
|
||||
- **Session lifetime: valid until explicit logout — no time expiry** (decision 2026-06-15, built).
|
||||
Booth reality breaks any fixed clock: relief arrives late, fails to show, or one operator is
|
||||
forced to work two shifts in a row — a token that expired mid-duty would strand an active
|
||||
operator. So the login persists until logout; a **[[shift]] is a separate, explicit boundary**,
|
||||
not tied to token lifetime. (Superseded the earlier "8h expiry, bound to a shift" assumption.)
|
||||
The JWT carries no `exp`; the cookie has a long fixed `maxAge` (30 days) so a browser restart
|
||||
doesn't log out an active operator, and `logout` clears it.
|
||||
- A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. The
|
||||
first admin is seeded via `pnpm --filter @parking/server seed-admin` (no bootstrap endpoint).
|
||||
- Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier /
|
||||
|
||||
@@ -2,17 +2,22 @@
|
||||
type: entity
|
||||
tags: [parking, hardware, readers, offline-first]
|
||||
sources: [parking-system-architecture]
|
||||
updated: 2026-06-14
|
||||
updated: 2026-06-15
|
||||
---
|
||||
|
||||
# LPR Camera
|
||||
|
||||
License-plate-recognition camera (recommended: **Milesight edge-AI LPR**). For
|
||||
**casual/transient** vehicles, the **plate acts as ticket + an independent record**. (See
|
||||
[[parking-system-architecture]] §8, §9.)
|
||||
License-plate-recognition camera. For **casual/transient** vehicles, the **plate acts as ticket +
|
||||
an independent record**. (See [[parking-system-architecture]] §8, §9.)
|
||||
|
||||
- **Edge AI**: recognition runs **on-device**, so it keeps working with no internet — fits
|
||||
[[offline-first]].
|
||||
> **Superseded direction (2026-06-15):** recognition now runs **host-side** on snapshots from
|
||||
> ordinary Hikvision/Dahua cameras via the [[opencv-anpr-service]], **not** on a dedicated edge-AI
|
||||
> LPR camera — see [[vision-service]]. The edge-AI camera below is kept as the original assumption /
|
||||
> a fallback option, but is no longer the planned path. The host-side service also does **vehicle
|
||||
> verification** (anti-plate-spoofing), which an edge-LPR camera does not.
|
||||
|
||||
- **Edge AI (original assumption)**: recognition runs **on-device**, so it keeps working with no
|
||||
internet — fits [[offline-first]].
|
||||
- It's a **host-side** identity source: only the host sees the read; the host decides and
|
||||
commands the relay open (the [[uhppote-controller]] is demoted to a commanded relay for that
|
||||
lane). See [[entry-exit-readers]].
|
||||
@@ -20,3 +25,36 @@ License-plate-recognition camera (recommended: **Milesight edge-AI LPR**). For
|
||||
host's signed [[append-only-event-chain]] entry + the controller's remote-open event) that
|
||||
should reconcile one-to-one; any mismatch is an anomaly.
|
||||
- Mounting: within ~15° of vehicle travel at a controlled chokepoint for best reads.
|
||||
|
||||
## Snapshot driver (entry/exit fraud-control record)
|
||||
|
||||
Separate from edge-AI LPR: the camera driver (`packages/devices/src/drivers/camera.ts`) does
|
||||
**snapshot-on-event** — the host pulls a still over HTTP when an entry/exit fires and stores it,
|
||||
referenced from the signed [[append-only-event-chain]] entry as an independent record. The camera
|
||||
**pulls, it does not push** — so it is NOT `pushesToBackend` and the setup wizard correctly hides
|
||||
the "Backend push IP" field for it (gated on the driver's `pushesToBackend` flag; only
|
||||
[[dingtian-relay]] sets it).
|
||||
|
||||
- **Hikvision** uses **ISAPI**: `GET /ISAPI/Streaming/channels/<id>/picture` (`101` = ch1 main
|
||||
stream) with **HTTP Digest** auth. The "Enable Hikvision-CGI" toggle (Network → Advanced →
|
||||
Integration Protocol) is a *different* legacy CGI surface — **not** needed for ISAPI.
|
||||
- **Dahua** uses CGI: `GET /cgi-bin/snapshot.cgi?channel=<n>` (0-based channel; the wizard's
|
||||
1-based channel is decremented).
|
||||
|
||||
**Driver / storage boundary:** the driver FETCHES the image bytes (client-side HTTP Digest in
|
||||
`drivers/http-digest.ts`) and returns them on `Snapshot.bytes`; **storage is the caller's job**
|
||||
(the future entry/exit flow stores the bytes + mints a durable `imageRef`). This keeps the device
|
||||
adapter free of any filesystem/blob-store dependency. `healthCheck()` is honest — it actually pulls
|
||||
a frame (exercising reachability + auth + path/channel in one shot), not a fake `ready/stub`.
|
||||
|
||||
### Verified on hardware (2026-06-15)
|
||||
|
||||
A **Hikvision** unit ("Camera 20", MAC `94:e1:ac:…`, Hikvision OUI) at `10.0.10.121`, creds
|
||||
`admin` / `admin123` (Digest), TCP 80:
|
||||
|
||||
- Initial `curl` test confirmed the ISAPI path returns a 2688×1520 JPEG (~306 KB).
|
||||
- The **real driver** (no longer a stub) was then run end to end against it:
|
||||
`healthCheck()` → `ready` (pulled a frame), `captureSnapshot()` → valid `image/jpeg`, ~322 KB,
|
||||
correct JPEG magic. Digest handshake works through `HttpCamera`.
|
||||
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
|
||||
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
type: entity
|
||||
tags: [parking, vision, anpr, anti-fraud, service]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# OpenCV ANPR / Vision Service
|
||||
|
||||
A **local microservice** that analyses camera snapshots: reads the licence **plate** (ANPR) and
|
||||
extracts **vehicle attributes** for verification. Built by us (decision 2026-06-15) to do
|
||||
recognition **host-side on ordinary IP-camera snapshots**, replacing the dedicated edge-AI
|
||||
[[lpr-camera]]. See decision [[vision-service]].
|
||||
|
||||
## Two jobs
|
||||
|
||||
1. **Identity (ANPR).** snapshot → `{ plate, confidence, bbox }`. Feeds the existing
|
||||
`IdentitySource = "lpr"` ([[parking-session]]): the plate is a session/identity key and the way
|
||||
a plate-bound [[permit]] is matched.
|
||||
2. **Verification (anti-fraud witness).** snapshot → vehicle attributes — at minimum
|
||||
`{ make?, model?, colour, bodyType }`, ideally a compact **visual fingerprint** (an embedding).
|
||||
This is the answer to **plate-spoofing**: *a fraudster prints a registered/paid plate and drives
|
||||
in with a different car.* Plate-reading alone can't catch that; comparing the **vehicle** seen at
|
||||
entry vs. exit (and vs. the [[permit]]'s known car) can. A plate that entered on a red hatchback
|
||||
but exits on a black SUV is a **reconciliation anomaly** — exactly the independent-witness role
|
||||
the [[append-only-event-chain]] flags as the unbuilt gap. See [[reconciliation]].
|
||||
|
||||
> The two jobs are why this is worth building rather than just plate-OCR: the service is both an
|
||||
> **identity source** and an **independent witness**, the visual analogue of the whole system's
|
||||
> "two records that must reconcile" thesis.
|
||||
|
||||
## Architecture — separate localhost process
|
||||
|
||||
- A **Python service** (e.g. FastAPI) running **on the appliance**, called by the Node backend over
|
||||
**localhost HTTP** (`POST /analyze` with the JPEG bytes the camera driver already pulls — see
|
||||
[[lpr-camera]] "driver/storage boundary": `Snapshot.bytes`).
|
||||
- **Fully offline** ([[offline-first]]): all inference is local, no cloud. Model weights ship on the
|
||||
appliance.
|
||||
- **Process isolation is deliberate** — it keeps a heavy Python/native/AGPL stack out of the
|
||||
Node app's process and license surface (see licensing below), and gives it its own failure
|
||||
domain. If the service is down/slow, the host falls back (transient ticket path) rather than
|
||||
blocking the lane.
|
||||
- **Request/response (first cut):**
|
||||
- `POST /analyze` → `{ plate: {text, confidence, bbox}|null, vehicle: {colour, bodyType, make?, model?, embedding?}, modelVersion, tookMs }`
|
||||
- `GET /health` → readiness + model versions.
|
||||
- The Node side wraps it behind an internal interface (like a device adapter) so the recognizer can
|
||||
be swapped without touching business logic.
|
||||
|
||||
## Licensing — scoped AGPL exception (amends the standing rule)
|
||||
|
||||
The app is strictly **MIT/Apache/BSD** ([[technology-stack]], [[standing-decisions]]). Accurate
|
||||
ANPR/vehicle models are mostly **AGPL** (YOLO/Ultralytics detectors, OpenALPR) or commercial.
|
||||
Decision (2026-06-15): **allow AGPL inside this service only.** It is a **separate process**, not
|
||||
linked into the app, so its obligations don't reach the Node/React codebase; the app's permissive
|
||||
guarantee is preserved. Recorded as an explicit exception in [[standing-decisions]] /
|
||||
[[vision-service]].
|
||||
|
||||
- OpenCV core itself is **Apache-2.0** (clean either way).
|
||||
- AGPL note: if the appliance is ever offered as a network service to third parties, AGPL's
|
||||
network-use clause could require offering the service's source — relevant only if productised
|
||||
beyond the on-site appliance; flag at that point.
|
||||
|
||||
## Anti-fraud / threat-model fit
|
||||
|
||||
- **Plate spoofing** (the motivating case): vehicle-attribute / fingerprint mismatch entry↔exit or
|
||||
vs. a [[permit]]'s registered car → anomaly. Doesn't *block* on its own (recognition is
|
||||
probabilistic) — it **flags for [[reconciliation]]** and is captured in the signed record.
|
||||
- The recognition result and the source image both attach to the signed [[append-only-event-chain]]
|
||||
entry, so the *evidence* is tamper-evident even though recognition itself is host-side and
|
||||
fallible.
|
||||
- Recognition is **advisory, never the sole authority** to open a barrier where money/access is at
|
||||
stake — confidence thresholds + fallback to ticket/manual; a low-confidence read must not strand a
|
||||
car ([[fail-state-safety]]).
|
||||
|
||||
## Open
|
||||
|
||||
- **Recognizer choice** (permissive-only vs. AGPL model) and accuracy targets — see
|
||||
[[vision-service]]; AGPL now permitted in-service.
|
||||
- **Vehicle fingerprint**: attribute classifier vs. embedding-similarity; what threshold makes a
|
||||
mismatch an anomaly without false-positiving on lighting/angle.
|
||||
- **Compute footprint** on the appliance (CPU-only vs. a small GPU/NPU) — procurement input
|
||||
([[bom]], [[open-questions]]).
|
||||
- Per-camera **opt-in** ("optionally bound", user's word): which lanes/cameras route snapshots to
|
||||
the service.
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
type: entity
|
||||
tags: [parking, domain, business, subscriptions, identity]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Permit (Subscription)
|
||||
|
||||
A **subscription**: a known holder authorized to enter/exit without paying per-stay, for a covered
|
||||
period. The second of the "two populations" ([[entry-exit-readers]]); a valid permit
|
||||
**short-circuits the payment step** of a [[parking-session]] ([[session-model]]). Transient is
|
||||
built first; permits layer on top.
|
||||
|
||||
## Credentials (how a permit is presented) — confirmed with operator 2026-06-15
|
||||
|
||||
A permit is recognized by a credential read at the lane. Two kinds, mapping to the two identity
|
||||
paths:
|
||||
|
||||
- **RF tag / chip / card.** An RFID/proximity credential. Read **host-side** (reader → host →
|
||||
`pulseOpen`): autonomy isn't required (resolved below), and the [[dingtian-relay]] has no onboard
|
||||
card list anyway, so there's no need to route RF into a controller. A Wiegand-out reader is still
|
||||
fine and keeps a future autonomous path open ([[entry-exit-readers]]), but isn't required.
|
||||
- **QR code.** Read by the **optical reader** — inherently **host-side** ([[entry-exit-readers]]:
|
||||
pure optical/network readers are invisible to a controller). Host decodes the QR → looks up the
|
||||
permit → decides.
|
||||
|
||||
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
|
||||
already in the model) and whose value is the credential id.
|
||||
|
||||
## Two optional, independent bindings — confirmed 2026-06-15
|
||||
|
||||
A permit has **two constraints the admin may or may not apply**, orthogonally. Either, both, or
|
||||
neither — the four combinations are all valid.
|
||||
|
||||
### 1. Car-count binding (default: 1)
|
||||
|
||||
- **Optional.** By default a permit is bound to **1 car at a time**. The admin may raise the limit
|
||||
(a household, a company fleet) or **unbind it entirely** (no cap on how many cars use it).
|
||||
- The limit is on **cars inside at once** (`maxConcurrent`), enforced over the
|
||||
[[parking-session]] projection: at entry, count the permit's currently-open sessions; if
|
||||
`< maxConcurrent` (or unbound) allow, else reject (allowance full). This is exactly why
|
||||
sessions-as-projection matters — "how many of this permit's cars are inside right now" is a fold
|
||||
over open entry/exit events, **not a counter someone can edit**.
|
||||
|
||||
### 2. Plate binding (default: off)
|
||||
|
||||
- **Optional.** By default a permit is **not** plate-bound — any car may use it (identity is the
|
||||
card/QR). The admin may bind it to a set of specific licence plates.
|
||||
- When **bound**, an allowed plate is an **accepted identity in its own right** — a valid
|
||||
**card/QR OR a matching plate** opens the lane (either, not a second factor):
|
||||
|
||||
```
|
||||
entry: read card/QR → find permit → car-count ok → open
|
||||
OR LPR plate ∈ permit's bound plates → find permit → car-count ok → open
|
||||
```
|
||||
|
||||
- **Accepted tradeoff:** card-OR-plate is the most convenient but does **not** prevent
|
||||
card-sharing (a lent card still opens). Fine for a trusted permit population; the signed
|
||||
[[append-only-event-chain]] records exactly which credential/plate entered, so abuse is visible
|
||||
to [[reconciliation]] after the fact.
|
||||
- **Plate-spoofing defence:** a printed copy of a registered plate on a *different* car is caught
|
||||
not here but by the [[opencv-anpr-service]]'s **vehicle-attribute verification** — the seen car
|
||||
must reconcile with the permit's known car, not just the plate string.
|
||||
|
||||
> The two are independent: a plate-bound permit may have no car cap; a car-capped permit may accept
|
||||
> any plate. The binding fields are simply absent/null when a constraint isn't applied.
|
||||
|
||||
## Data model (first cut — to firm up with [[session-model]])
|
||||
|
||||
A `permits` table (and supporting rows). Unlike the event log, reference/master data like permits
|
||||
**is** mutable (an admin grants/revokes/renews) — but every *use* of a permit still produces a
|
||||
signed `vehicle_entry`/`vehicle_exit` event in the [[append-only-event-chain]], so the audit trail
|
||||
stays append-only even though the permit record itself is editable.
|
||||
|
||||
| Field | Notes |
|
||||
| --- | --- |
|
||||
| `id`, `holderName`/contact | the subscriber |
|
||||
| `credentials[]` | one or more: `{ kind: 'rf' \| 'qr', value }` |
|
||||
| `maxConcurrent` | car-count binding; **default 1**, raise for fleets, or `null` = unbound |
|
||||
| `plates[]` | plate binding; **default empty/false** = any car; when set, these plates are accepted identities |
|
||||
| `validFrom`, `validTo` | coverage window |
|
||||
| `status` | active / suspended / revoked |
|
||||
|
||||
> Both bindings are nullable/empty by default — a bare permit is "1 car at a time, any plate,
|
||||
> identified by its card/QR".
|
||||
|
||||
## Interaction with the session model
|
||||
|
||||
- **Entry:** credential read → permit lookup → valid (active, in window, plate allowed **if
|
||||
plate-bound**, concurrent cars `< maxConcurrent` **if car-bound**) → signed `vehicle_entry`
|
||||
(source = `wiegand`/`qr`/`lpr`), open barrier. No ticket, no fee. (A bare permit applies neither
|
||||
extra check — just active + in window.)
|
||||
- **Exit:** credential/plate read → matching open permit session → signed `vehicle_exit`, open. No
|
||||
payment required.
|
||||
- **Lapsed mid-stay:** permit expires while a car is parked → the uncovered time falls back to the
|
||||
transient [[tariff]] (edge case to design).
|
||||
- **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or
|
||||
refused, per policy (OPEN).
|
||||
|
||||
## As-built (2026-06-15)
|
||||
|
||||
`apps/server/src/permit-flow.ts`, reached via the **read dispatcher**
|
||||
(`read-dispatch.ts`): a credential read routes to the permit flow if it **matches a permit**
|
||||
(card/QR credential, or a bound plate) — otherwise to the transient exit flow. So one read handler
|
||||
serves both populations ([[entry-exit-readers]]), disambiguated by *what the credential is*.
|
||||
|
||||
- **Direction is inferred from session state for that car** — the read credential value is the
|
||||
per-car session key. No open session for that car → **ENTRY** (check `maxConcurrent`, sign
|
||||
`vehicle_entry`, open); an open session → **EXIT** (sign `vehicle_exit`, open, close). A fleet
|
||||
permit thus has one session per car concurrently, and anti-passback falls out (a re-read of an
|
||||
inside car is its exit, never a second entry).
|
||||
- **`maxConcurrent`** is enforced as a **fold over the signed ledger** — count the permit's
|
||||
`vehicle_entry` events whose car has no later exit; reject at the limit (`null` = unbound).
|
||||
- **Validity** (active + within `validFrom`/`validTo`) and **plate-OR-card identity** as designed.
|
||||
No ticket, no fee — the permit is the authorization; every use is still a signed ledger event
|
||||
carrying `permitId`.
|
||||
- Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events; the barrier stays
|
||||
closed. Verified end to end (entry, inferred exit, fleet cap, plate-bound, revoked, dispatch).
|
||||
|
||||
**Admin CRUD** (`apps/server/src/routes/permits.ts` + `apps/web/src/PermitManager.tsx`): a permit is
|
||||
an **aggregate** (the row + its credentials + bound plates); create/update treat it as one unit
|
||||
(child sets are replaced on update). `GET /api/permits` (any signed-in role — for lookup),
|
||||
`POST/PUT/DELETE /api/permits[/:id]` + `POST /api/permits/:id/revoke` (**admin only**). Validation:
|
||||
`maxConcurrent` is a positive int or `null` (unbound); a permit must have **at least one credential
|
||||
or one bound plate** (else nothing identifies it). Revoke is the soft, common case (keeps history,
|
||||
barred at the barrier); DELETE hard-removes — past ledger events that reference the permit are
|
||||
untouched (the audit trail is append-only and independent). Verified via inject (validation, child
|
||||
replacement, RBAC, revoke/delete).
|
||||
|
||||
## Resolved (2026-06-15)
|
||||
|
||||
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
|
||||
unbound) and plate-binding (`plates[]`, **default off** = any car). Either, both, or neither.
|
||||
- **Plate vs. credential:** when plate-bound, **card/QR OR matching plate** — either is accepted
|
||||
identity (not a second factor); card-sharing not prevented by design, caught by
|
||||
[[reconciliation]] after.
|
||||
- **Autonomy:** **host-in-the-loop for everything** — no onboard card list needed, so the
|
||||
[[dingtian-relay]] stays sufficient (no new controller). Permit entry **fails closed** if the
|
||||
host is down ([[fail-state-safety]]). One code path for transient + permit.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Reader hardware** — confirm the RF reader and the QR/optical reader models (procurement;
|
||||
relates to [[bom]] and [[open-questions]]). RF need not be Wiegand now that autonomy isn't
|
||||
required, but a Wiegand-out reader keeps options open.
|
||||
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm
|
||||
with operator.
|
||||
+21
-2
@@ -7,7 +7,7 @@ updated: 2026-06-14
|
||||
# Index
|
||||
|
||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
||||
Counts: 1 source · 15 entities · 12 concepts · 2 decision records.
|
||||
Counts: 1 source · 18 entities · 24 concepts · 5 decision records.
|
||||
|
||||
## Overview & navigation
|
||||
- [[overview]] — the top-level synthesis and entry point.
|
||||
@@ -71,13 +71,32 @@ Counts: 1 source · 15 entities · 12 concepts · 2 decision records.
|
||||
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
|
||||
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
|
||||
|
||||
## Concepts — business domain
|
||||
- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table.
|
||||
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
||||
- [[shift]] — manned-only accountability period; explicit Start/End (not time-based); End → signed + printed Z-report (cash + POS).
|
||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
||||
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
|
||||
- [[validation-discounts]] — merchant validates a ticket → signed discount event applied at fee time.
|
||||
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
|
||||
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
|
||||
- [[ticket-encoding]] — transient ticket id as QR; printed at entry, scanned at pay station + exit; plate-as-ticket alt.
|
||||
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
|
||||
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
|
||||
- [[permit]] — subscription; RF/QR or plate identity, registered-cars + max-concurrent, host-in-loop; short-circuits payment.
|
||||
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness).
|
||||
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
|
||||
|
||||
## Dev environment (reference)
|
||||
- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin.
|
||||
- [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after.
|
||||
|
||||
## Decisions
|
||||
- [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers).
|
||||
- [[open-questions]] — 7 open items (6 procurement + JWT key choice); ESP32 device auth deferred.
|
||||
- [[open-questions]] — 9 open items (procurement + JWT key + FX + pay-station money corners); ESP32 device auth deferred.
|
||||
- [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker).
|
||||
- [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state.
|
||||
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
|
||||
- [[session-model]] — business layer start: session = projection; transient-first; pay-on-foot. New event types.
|
||||
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
|
||||
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
||||
|
||||
+280
@@ -297,3 +297,283 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
||||
- Documented that `source` stays null for raw inputs by design (it's an IdentitySource, not a
|
||||
device field); device provenance is in `identity`.
|
||||
- Updated [[append-only-event-chain]].
|
||||
|
||||
## [2026-06-15] test+lesson | Hikvision camera verified; multi-subnet source-address trap
|
||||
- Pulled a real snapshot from a Hikvision camera on the bench: `GET
|
||||
http://10.0.10.121/ISAPI/Streaming/channels/101/picture`, Digest auth, admin/admin123 → HTTP 200,
|
||||
2688×1520 JPEG. Path + auth + creds confirmed. ISAPI is the right surface; the device's
|
||||
"Enable Hikvision-CGI" toggle is a *different* legacy CGI API and is NOT needed.
|
||||
- Caveat recorded: the camera driver is still a STUB — the wizard's "● ready — stub / ●
|
||||
preconditions OK" contacts nothing; cameras have no preconditions (only [[dingtian-relay]]
|
||||
implements checkPreconditions). Noted the cosmetic "Backend push IP" bug (camera pulls, doesn't
|
||||
push; field should gate on a `pushesToBackend` capability).
|
||||
- LESSON (cost an hour of "why can't we ping the subnet"): with two device subnets stacked on one
|
||||
NIC (`192.168.1.123` + `10.0.10.203` on eth1), Linux picked the WRONG source address for
|
||||
`10.0.10.x` → ARP shows REACHABLE but all ping/TCP times out. Fix: pin `src` on the connected
|
||||
route (`ip route change <subnet>/24 dev <nic> proto kernel scope link src <host-ip>`), or force
|
||||
source per-call (`ping -I` / `curl --interface`). Devices arrive on assorted static `/24`s; the
|
||||
host carries one IP per subnet — this trap is the recurring cost of that.
|
||||
- Decision context: production is a dedicated hardened **Linux appliance** (this WSL2 box is a dev
|
||||
stand-in). Multi-subnet config + `src` pinning is an appliance deployment concern (made
|
||||
persistent via networkd/netplan), riding on [[network-isolation]]; long-term answer is to re-IP
|
||||
devices onto one planned parking subnet at install.
|
||||
- Updated [[lpr-camera]] (snapshot driver + verified-on-hardware section), [[wsl-dev-networking]]
|
||||
(multi-subnet source-address trap + appliance pattern).
|
||||
|
||||
## [2026-06-15] driver+fix | Real Hikvision/Dahua camera driver; push-IP field gated
|
||||
- Replaced the camera STUB with a real `HttpCamera` (`packages/devices/src/drivers/camera.ts`):
|
||||
Hikvision ISAPI (`/ISAPI/Streaming/channels/<ch>01/picture`) + Dahua CGI (0-based channel), both
|
||||
over client-side HTTP Digest (new `drivers/http-digest.ts`, two-shot 401→challenge→response,
|
||||
qop=auth MD5 — the client counterpart to the server's digest-auth.ts). `healthCheck()` now
|
||||
actually pulls a frame instead of returning `ready/stub`. Added `localAddress` + `timeoutMs` +
|
||||
`channel` config; threads the device-facing NIC for the multi-subnet trap.
|
||||
- Snapshot interface: `Snapshot` now carries `bytes: Buffer` (driver fetches); `imageRef` is
|
||||
optional and set by the CALLER once stored — keeps the adapter free of storage deps. Nothing
|
||||
consumed captureSnapshot yet, so no migration needed.
|
||||
- Cosmetic bug fixed: "Backend push IP" showed for any reachable host. Added a `pushesToBackend`
|
||||
flag to `DeviceDriver` (only [[dingtian-relay]] sets it), exposed as `pushCapable` in the catalog
|
||||
(mirrors `discoverable`), and gated both the wizard's backend-IP fetch and the field on it.
|
||||
Cameras/printers/readers no longer show it.
|
||||
- VERIFIED on hardware: built clean (5/5 packages); ran the real driver against the Hikvision at
|
||||
10.0.10.121 → healthCheck ready, captureSnapshot returned a valid 322 KB JPEG (correct magic).
|
||||
- Updated [[lpr-camera]].
|
||||
|
||||
## [2026-06-15] fix | Permanent WSL2 source-address fix (systemd hook)
|
||||
- The multi-subnet source-address trap kept recurring (every `wsl --shutdown` wipes the runtime
|
||||
`ip route` pin — mirrored mode re-clones the Windows NIC's addresses fresh each boot, and NOTHING
|
||||
inside Linux owns them: networkd/NM/netplan all inactive). Made it permanent on the dev box.
|
||||
- `deploy/wsl-fix-route-source.sh`: walks each `proto kernel scope link` route on the NIC and pins
|
||||
`src` to the host's own address in that same subnet — no hardcoded IPs (covers future device
|
||||
subnets), idempotent, preserves route metric, non-fatal per route. `deploy/parking-net.service`:
|
||||
oneshot, enabled, reapplies on every boot.
|
||||
- BUGS hit + fixed while building it: (1) `ip route change` errors `RTNETLINK: No such file` when
|
||||
the route isn't up yet at boot → use `replace`; (2) `set -e` made one failed `ip` abort the whole
|
||||
unit → dropped it, per-route warnings instead; (3) `network.target` fires before mirrored-mode
|
||||
addresses land → script waits up to 15s for a route.
|
||||
- VERIFIED: service enabled+active, journal shows `pinned 10.0.10.0/24 -> src 10.0.10.203`, camera
|
||||
pings with NO -I flag (0% loss), and the real Hikvision driver pulls a snapshot with NO
|
||||
`localAddress` set. Root cause noted as Windows-side (stray 192.168.1.x); this is the
|
||||
self-contained Linux answer.
|
||||
- Updated [[wsl-dev-networking]].
|
||||
|
||||
## [2026-06-15] design | Business layer kickoff — parking session model
|
||||
- Pivoted from the (hardware-verified) device/integrity layer to the business domain. Wiki-first.
|
||||
- KEY DECISION: a [[parking-session]] is a PROJECTION over the signed [[append-only-event-chain]],
|
||||
never a mutable table — a mutable sessions row with paid/owed would reopen the operator-fraud
|
||||
hole the whole system closes. "Paid" = a signed `payment` event (unforgeable, undeletable).
|
||||
- Scope (user): mixed site, TRANSIENT-FIRST; [[permit]] holders layered as a 2nd identity source
|
||||
that short-circuits payment. Payment = PAY-ON-FOOT / pay station (decoupled from exit; exit lane
|
||||
only validates paid + within walk-back grace). Matches [[autonomous-direction]].
|
||||
- New signed event types designed (not yet built): `vehicle_entry`, `vehicle_exit`, `payment`,
|
||||
`void` — extend `input_received`. Lifecycle OPEN→PAID→CLOSED (+VOIDED); overstay top-up is the
|
||||
one genuinely stateful edge case.
|
||||
- New pages: [[parking-session]], [[tariff]] (pure/data-driven fee fn; gracePeriodExit is a real
|
||||
pay-on-foot revenue param), decision [[session-model]]. Updated [[append-only-event-chain]],
|
||||
[[index]]. Closes the dangling entry-flow thread from [[device-input-flow]].
|
||||
- [[permit]] drafted + RESOLVED from user input: credentials = RF tag/chip/card + QR (optical
|
||||
reader). Car limits = two numbers: `registeredCars[]` whitelist + admin-set `maxConcurrent` (in
|
||||
at once) — enforced as a fold over the permit's open sessions. Identity = card/QR OR matching
|
||||
plate (either opens; card-sharing not prevented by design, caught by reconciliation). Autonomy =
|
||||
host-in-the-loop for everything → Dingtian stays sufficient, no new controller; permit entry
|
||||
fails closed if host down. Remaining open: reader hardware models; lapsed/revoked policy.
|
||||
- NEXT: schema (`packages/db`: permits/tariffs + session projection) + the
|
||||
input_received→vehicle_entry flow (closes the [[device-input-flow]] thread).
|
||||
|
||||
## [2026-06-15] design | Host-side vision service (ANPR + vehicle verification)
|
||||
- User: optionally bind camera images to an OpenCV service we build. Resolved scope: ANPR (plate →
|
||||
`IdentitySource='lpr'`); a **separate local Python/OpenCV microservice** on the appliance (Node →
|
||||
localhost HTTP), offline; it **replaces the dedicated edge-AI [[lpr-camera]]** (recognition on
|
||||
ordinary Hikvision/Dahua snapshots — reuses `Snapshot.bytes`).
|
||||
- LICENSING: best ANPR/vehicle models are AGPL/commercial vs. the MIT/Apache/BSD standing rule.
|
||||
Decision: **scoped AGPL exception** — allowed INSIDE the vision service only (separate process,
|
||||
not linked); app stays permissive. Amended [[standing-decisions]].
|
||||
- USER ANTI-FRAUD INSIGHT: a fraudster can print a registered plate and enter with a different car.
|
||||
→ service also does **vehicle-attribute / fingerprint verification**, so the *car* reconciles, not
|
||||
just the plate. This fills the independent-witness gap [[append-only-event-chain]] calls out:
|
||||
plate-on-different-car = anomaly. Recognition is advisory (confidence + ticket fallback), evidence
|
||||
(read + image) attaches to the signed event.
|
||||
- New pages: [[opencv-anpr-service]], decision [[vision-service]]. Updated [[standing-decisions]],
|
||||
[[lpr-camera]] (host-side supersedes edge-AI), [[permit]] (plate-spoof defence),
|
||||
[[append-only-event-chain]] (vision as witness), [[index]].
|
||||
- Open: recognizer/vehicle-model choice + accuracy; fingerprint method + anomaly threshold; appliance
|
||||
compute (CPU vs GPU/NPU); per-camera opt-in; the still-unbuilt reconciliation logic.
|
||||
|
||||
## [2026-06-15] design | Transient pricing — composable, versioned tariff
|
||||
- User: pricing is unknown + constantly changing → must be **admin-composable at runtime**, currency
|
||||
selectable, FX later. Reframed [[tariff]] from "config we ship with numbers" to a first-class
|
||||
editable entity.
|
||||
- DECISIONS: (1) rate structure = **stepped duration blocks + rolling-24h daily cap** (flat rate is
|
||||
one block; expresses first-hour/taper/cap with no special cases); (2) overstay top-up =
|
||||
**reprice the difference** (recompute entry→now − alreadyPaid); (3) tariffs are **effective-dated
|
||||
immutable versions** — edits publish a new version, sessions reprice against the version in force,
|
||||
the `payment` event records `tariffVersionId` (reproducible + fixed in the signed chain); (4)
|
||||
**one active tariff per site**, but modelled with id/scope so multi-tariff needs no migration;
|
||||
(5) **currency selectable (ISO 4217)**, money = `{minorUnits, currency}`, payment reserves a null
|
||||
`fxRate` → FX-ready, **FX engine deferred** (needs offline rate source — new [[open-questions]] #8).
|
||||
- Ships with **no rate card**; owner must compose+publish one (blank = free or gated, operator
|
||||
policy — open). Numbers in the page are illustrative, not defaults.
|
||||
- Wrote the pure integer fee algorithm into [[tariff]] (data model: `tariffs` + immutable
|
||||
`tariff_versions`). Updated [[open-questions]] (#8 FX), [[index]].
|
||||
- NEXT: schema (`packages/db`) for tariffs/versions + permits + session projection, then the
|
||||
composer UI + the input_received→vehicle_entry flow.
|
||||
|
||||
## [2026-06-15] design | Shifts (manned-only) + Z-report; drop time-based token
|
||||
- Q: what happens at operator shift end? Resolved scope, deliberately small.
|
||||
- Shifts exist ONLY in manned mode — a human accountability boundary. The fully-automated/unmanned
|
||||
system has NO shifts; the pay-station cash-collection cycle + [[reconciliation]] replace it.
|
||||
- Shift is NOT time-based: relief arrives late / no-shows / one operator forced into a double.
|
||||
→ **drop the 8h token expiry**; login valid **until explicit logout** (updated [[local-jwt-auth]];
|
||||
code change pending). Start/End Shift are **explicit, independent of login** — one login spans many
|
||||
shifts; a double = End then Start again, no re-login.
|
||||
- End Shift = sum signed `payment` events in the shift by tender → append a signed `shift_z_report`
|
||||
(type already in packages/shared, chained to prior Z) → **PRINT cash total + POS total (if a POS
|
||||
is configured)**. That's the whole human-side ask. No blind count / variance gate / manager
|
||||
override. Fraud control stays in the signed chain + later [[reconciliation]] (catch a skim after
|
||||
the fact, not at close). Blind-count documented as an explicit optional add-on, not built.
|
||||
- New page [[shift]]; updated [[local-jwt-auth]], [[index]].
|
||||
- Open: Z sums by payment-time (the operator who took the money) — confirm; X-report (read-only
|
||||
mid-shift); per-operator vs per-booth vs per-site (ties to [[open-questions]] #1). `payment` event
|
||||
needs a `tender` field (cash/card) — fold into the schema step.
|
||||
|
||||
## [2026-06-15] design | Scope sweep — capacity, validation, reporting, integrity gaps
|
||||
- "What else can a PMS do?" — swept the full feature surface against the design; user picked the
|
||||
in-scope gaps. New pages:
|
||||
- [[capacity-occupancy]] — occupancy = fold over open sessions; refuse entry + drive a FULL sign
|
||||
when full; **exit never blocked** ([[fail-state-safety]]); zone-ready; counting-drift = anomaly.
|
||||
- [[validation-discounts]] — merchant validates a ticket → **signed discount event** applied at
|
||||
fee time ([[tariff]]); over-validation visible to [[reconciliation]]; payment records gross/disc/net.
|
||||
- [[reporting-analytics]] — revenue/occupancy/stay/permit/anomaly reports as projections over the
|
||||
chain; **plate-search** (admin looks up a session by plate IF captured — honest "not captured").
|
||||
- [[clock-integrity]] — fees depend on the host clock; offline box → backdating attack; monotonic
|
||||
index catches reorder, clock-regression = `anomaly`, RTC + privileged-only time change.
|
||||
- [[blocklist]] — barred plates/cards refused at **entry only**; signed + attributed.
|
||||
- Folded into existing pages: **manual overrides** = signed reason-coded events (legitimate
|
||||
counterpart to the out-of-band-open anomaly) + **lost-ticket admin-arbitrary amount** →
|
||||
[[parking-session]] + [[tariff]]; **backup/restore** confirmed in-scope, expanded [[open-questions]]
|
||||
#5 (restored copy must still verifyChain; doubles as the reconciliation export).
|
||||
- NOT captured (flagged): **intercom/help-call** — user didn't select it, but it's the only human
|
||||
fallback for an unmanned lane; revisit. Deferred roadmap: reservations, mobile app, EV, loyalty.
|
||||
- Updated [[index]].
|
||||
|
||||
## [2026-06-15] design | Second sweep — ticket encoding + anti-passback; money corners deferred
|
||||
- More gap-hunting. New pages:
|
||||
- [[ticket-encoding]] — the transient session key: opaque/unguessable **ticket id printed as QR**
|
||||
by [[rongta-printer]], **scanned at pay station + exit** (new ReaderDevice/imager behind the
|
||||
adapter); plate-as-ticket ticketless alt coexists per lane. The physical backbone of the
|
||||
transient flow (was only implied).
|
||||
- [[anti-passback]] — one id can't enter while it already has an OPEN session (card/ticket-passing
|
||||
over the fence); a fold over the chain, *under* permit `maxConcurrent`. Soft (flag `anomaly`) by
|
||||
default vs. hard (refuse); honest dependence on reliable exit detection.
|
||||
- DEFERRED (user): **receipts/VAT invoices** + **refunds/change/overpay** — depend on pay-station
|
||||
hardware + manned/unmanned payment subsystem; recorded as [[open-questions]] #9, revisit at
|
||||
procurement (may change what the `payment` event stores → flagged before schema).
|
||||
- Still open & load-bearing: **lane topology** (#1) — not resolved; scopes sessions/occupancy/shifts.
|
||||
- Updated [[open-questions]] (#9), [[index]].
|
||||
|
||||
## [2026-06-15] decision | Split signed business ledger from device telemetry
|
||||
- User correction before schema: the `events` table conflated TWO things — the anti-fraud business
|
||||
ledger AND device telemetry (button pushes as `input_received`). Split them.
|
||||
- `ledger_events` (rename of `events`): signed, hash-chained, ATECC608-signed business facts only
|
||||
(vehicle_entry/exit, payment, void, shift_z_report + witness barrier_open_command/observed,
|
||||
anomaly). Reconciliation + session/tariff/occupancy projections run on this.
|
||||
- `device_events` (new, [[device-events]]): UNSIGNED hardware telemetry (relay fired, paper-out,
|
||||
camera offline, reader read, raw input edges); high-volume, may rotate/prune; never reconciled.
|
||||
- A raw button press is telemetry → device_events; the entry flow then mints a SIGNED vehicle_entry.
|
||||
So `input_received`-as-signed-event is dropped (was transitional). No prod chain data exists, so
|
||||
the rename/restructure is safe now (no signatures to invalidate).
|
||||
- New: decision [[event-streams-split]], concept [[device-events]]; updated [[append-only-event-chain]]
|
||||
(two streams + as-built-vs-pending), [[index]].
|
||||
- NEXT (schema): rename events→ledger_events; add device_events; split ParkingEventType in shared;
|
||||
then tariffs/versions, permits, blocklist, sessions projection. EventLog/canonicalize/verifyChain
|
||||
+ /api/events follow the rename (code refactor, separate from this wiki commit).
|
||||
|
||||
## [2026-06-15] design+build | Entry flow (start) + valet/over-capacity captured
|
||||
- Building the entry flow: device input → signed `vehicle_entry` → print ticket → pulseOpen.
|
||||
- DECISION (print failure): **hold** — if all printers are down, sign an `anomaly` (entry attempt,
|
||||
ticket unprinted) and do NOT open (no unticketed transient — couldn't pay on exit; operator
|
||||
handles the held car). The `vehicle_entry` is appended ONLY on the success path, right before
|
||||
pulseOpen — preserving "signed before open" and never logging an entry for a car that didn't get in.
|
||||
- DECISION (capacity): wire transient entry now; the FULL gate comes later (needs capacity config +
|
||||
occupancy fold).
|
||||
- VALET / OVER-CAPACITY (user): "full" is a **soft, operator-configurable** policy — operator may
|
||||
valet-accept over capacity (customer hands over keys + leaves, operator stacks the car). Manned-only,
|
||||
new custody/session shape. Captured as [[valet-overcapacity]] + made [[capacity-occupancy]] FULL a
|
||||
soft policy; NOT built into the entry flow (clean seam left). Deferred.
|
||||
- New page [[valet-overcapacity]]; updated [[capacity-occupancy]], [[index]].
|
||||
|
||||
## [2026-06-15] build | Exit flow (pay-on-foot validation)
|
||||
- Built `apps/server/src/exit-flow.ts`. Added a `read` channel to the device bus (DeviceReadEvent:
|
||||
ticket/plate/qr/card) — readers/LPR emit reads; entry stays button-driven, so reads are
|
||||
unambiguously exit/identity events for now.
|
||||
- Flow: read → fold the SIGNED ledger for that identity → validate open + PAID + within
|
||||
`gracePeriodExitMin` → signed `vehicle_exit` → pulseOpen → close the session cache. Unpaid /
|
||||
grace-expired / unknown → signed `anomaly`, barrier stays closed (a deliberate business reject,
|
||||
NOT a fail-state; "exit fails open" is about host/power loss). Validation reads the ledger
|
||||
(authoritative), not the cache.
|
||||
- Pay station doesn't exist yet → no `payment` events → every transient exit currently REJECTS.
|
||||
Correct end-state, not passable until pay-station lands (decided).
|
||||
- VERIFIED against stubs: unpaid→anomaly+no-open; paid+grace→vehicle_exit+open+closed; grace-expired
|
||||
→anomaly; unknown ticket→anomaly; verifyChain ok across entry→pay→exit.
|
||||
- GAP flagged: lane_devices has no entry/exit DIRECTION model (door mapping hardcoded to 1 for exit);
|
||||
fine while entry=button/exit=read, but multi-reader lanes need a lane-direction/role model (ties to
|
||||
[[open-questions]] #1). Updated [[parking-session]] as-built + gap, [[index]].
|
||||
|
||||
## [2026-06-15] build | Pay station + fee calc; JWT 8h → until-logout
|
||||
- JWT: dropped the 8h `expiresIn` (server.ts global + login). Token now valid **until explicit
|
||||
logout**; cookie maxAge = 30 days so a browser restart doesn't log out an active operator
|
||||
(auth.ts `COOKIE_MAX_AGE_SECONDS`). Closes the pending change from the shift decision; updated
|
||||
[[local-jwt-auth]].
|
||||
- `computeFee(enteredAt, asOf, structure)` in `packages/shared` — pure integer fee calc.
|
||||
TWO BUGS caught by tests: (1) grace must use RAW duration, not the rounded-up minutes (a 10-min
|
||||
stay was being charged a full hour); (2) the block ladder must RESET each rolling-24h day (decision:
|
||||
day 2 restarts at first-block pricing → 25h = 1200 cap + 200). Both fixed; 9 cases pass.
|
||||
- Pay station (`apps/server/src/pay-station.ts` + routes `GET /api/pay/quote`, `POST /api/pay`):
|
||||
open session → active tariff version → computeFee → signed `payment` event (amount/currency/tender/
|
||||
tariffVersionId/graceExitMin); `overrideMinor` for lost-ticket/dispute. Cashier/operator/admin guard.
|
||||
- VERIFIED: full loop entry→quote(300 for 90min)→pay→exit opens+closes, verifyChain ok. (A
|
||||
raw-SQL backdate in one test correctly broke the chain — the tamper-evidence working, not a flow bug.)
|
||||
- Updated [[tariff]] (settled edges + as-built), [[parking-session]] (pay station as-built; full
|
||||
loop passes).
|
||||
|
||||
## [2026-06-15] build | Tariff composer (makes the pay station operable)
|
||||
- `validateTariffStructure` in `packages/shared` — non-negative ints, ascending block bounds, only
|
||||
the last block open-ended; a malformed card can't be published.
|
||||
- Routes (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active + history, any role) and
|
||||
`POST /api/tariff/versions` (publish immutable version, ADMIN only). Single site `tariffs` row
|
||||
created lazily. Editing = publish a new version (effective-dated, immutable).
|
||||
- UI (`apps/web/src/TariffComposer.tsx`, admin shell next to SetupWizard): currency, grace windows,
|
||||
increment, daily cap, lost-ticket, add/remove rate blocks; major-unit input → minor on submit;
|
||||
shows active + history.
|
||||
- VERIFIED via Fastify inject: GET empty→active null; invalid (out-of-order blocks)→400 w/ problem;
|
||||
valid→201 createdBy=admin; readonly publish→403; after publish the pay station quote returns 404
|
||||
(session) not 409 (no tariff) — i.e. it now sees the active card. Full build 5/5.
|
||||
- Updated [[tariff]] (composer as-built).
|
||||
|
||||
## [2026-06-15] build | Permit entry/exit branch + read dispatcher
|
||||
- `apps/server/src/permit-flow.ts` + `read-dispatch.ts`. A credential read now routes by WHAT the
|
||||
credential is: matches a permit (card/QR credential, or a bound plate) → permit flow; else →
|
||||
transient exit flow. Lane resolved once (`readerLaneWithAccess`, shared in lane-map.ts). Refactored
|
||||
ExitFlow.onRead → handleAt(lane,e) so the dispatcher owns lane resolution.
|
||||
- Permit DIRECTION inferred from session state for that car (the read value is the per-car session
|
||||
key): no open session → ENTRY (enforce maxConcurrent, sign vehicle_entry, open); open → EXIT (sign
|
||||
vehicle_exit, open, close). Fleet permit = one session per car; anti-passback falls out.
|
||||
- maxConcurrent enforced as a fold over the signed ledger (count the permit's entries whose car has
|
||||
no later exit); null = unbound. Validity window + status + plate-OR-card identity as designed.
|
||||
No ticket/fee; every use is a signed event carrying permitId. Refusals = signed anomaly, no open.
|
||||
- VERIFIED against stubs: card entry → inferred exit; fleet maxConcurrent=2 (F1,F2 in, F3 rejected,
|
||||
F1 exits → F3 enters); plate-bound permit opens; revoked → reject; unknown credential falls through
|
||||
to exit-flow reject (not mis-read as permit); verifyChain ok. Full build 5/5.
|
||||
- Updated [[permit]] (as-built), [[parking-session]] (read dispatch).
|
||||
|
||||
## [2026-06-15] build | Permit admin CRUD (route + UI)
|
||||
- `apps/server/src/routes/permits.ts`: a permit is an aggregate (row + credentials + bound plates);
|
||||
create/update replace the child sets as one unit. GET (any role, for lookup), POST/PUT/DELETE +
|
||||
POST /:id/revoke (admin only). Validation: maxConcurrent positive-int-or-null; must have ≥1
|
||||
credential OR ≥1 plate. Revoke = soft (keeps history); DELETE = hard (past ledger events untouched).
|
||||
- `apps/web/src/PermitManager.tsx` in the admin shell: list + add/edit (holder, car-bound toggle →
|
||||
maxConcurrent or unbound, validity window, credentials add/remove, plates as a list), revoke, delete.
|
||||
- Makes permits usable without hand-seeding (companion to the tariff composer).
|
||||
- VERIFIED via inject: empty + maxConcurrent=0 → 400 w/ messages; valid → 201; operator LIST 200 but
|
||||
create 403; update unbinds + REPLACES child rows (old cred gone); revoke→revoked; delete→204 then
|
||||
404, children cleaned. Full build 5/5.
|
||||
- Updated [[permit]] (CRUD as-built).
|
||||
|
||||
Reference in New Issue
Block a user