6 Commits

Author SHA1 Message Date
julian 3429642edb permits: admin CRUD (route + UI)
A permit is an aggregate (row + credentials + bound plates); create/update
treat it as one unit (child sets replaced on update). GET /api/permits (any
signed-in role, for lookup); POST/PUT/DELETE + POST /:id/revoke (admin only).
Validation: maxConcurrent positive-int-or-null (unbound); a permit must have at
least one credential OR one bound plate. Revoke is the soft common case (keeps
history, barred at the barrier); DELETE hard-removes — past ledger events that
reference it are untouched (append-only audit trail, independent of this row).

Web PermitManager in the admin shell: list + add/edit (holder, car-bound toggle,
validity, credentials, plates), revoke, delete. Makes permits usable without
hand-seeding (companion to the tariff composer).

Verified via inject: validation (empty / maxConcurrent=0 -> 400), create -> 201,
operator can LIST but not write (403), update replaces child rows, revoke ->
revoked, delete -> 204 then 404 with children cleaned.
2026-06-15 19:53:03 +02:00
julian c24d99b0f4 server: permit entry/exit branch + read dispatcher
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); ExitFlow.onRead -> handleAt so
the dispatcher owns lane resolution.

Permit direction is 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 naturally.

maxConcurrent enforced as a fold over the signed ledger (null = unbound).
Validity window + status + plate-OR-card identity as designed. No ticket/fee;
every use is a signed event carrying permitId. Refusals (revoked / out-of-window
/ at-capacity) are signed anomalies, barrier stays closed.

Verified against stubs: card entry -> inferred exit; fleet cap 2 (F3 rejected
at 2/2, then admitted after F1 exits); plate-bound opens; revoked rejects;
unknown credential falls through to exit reject; verifyChain ok.
2026-06-15 19:47:01 +02:00
julian b4d0dfadd6 tariff composer: admin publishes rate-card versions (pay station now operable)
validateTariffStructure (shared): non-negative ints, ascending block bounds,
only the last block open-ended — a malformed card can't be published.

Routes: GET /api/tariff (active + history, any signed-in role), POST
/api/tariff/versions (publish an immutable, effective-dated version; admin
only). The single site tariff row is created lazily. Editing = publish a new
version; past sessions keep their pricing.

Web: TariffComposer in the admin shell — edit currency, grace windows,
increment, daily cap, lost-ticket fee, and add/remove rate blocks (major-unit
input -> minor on submit); shows active version + history.

Verified via inject: empty -> active null; invalid blocks -> 400 with problem;
valid -> 201; readonly publish -> 403; after publishing, the pay station quote
returns 404 (no session) instead of 409 (no tariff) -- it now prices against the
active card.
2026-06-15 19:35:33 +02:00
julian f18e28eeca server: pay station + fee calc — full transient loop now passes
computeFee() in @parking/shared: pure integer fee over a TariffStructure
(stepped blocks, rolling-24h cap). Two edges fixed under test: grace uses RAW
duration (not rounded-up minutes), and the block ladder resets each 24h day.

PayStation + 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). overrideMinor handles lost-ticket/dispute. PCI
stays out of the app: tender only records cash/card.

Verified end to end: entry -> quote (300 for 90min) -> pay -> exit opens and
closes the session, verifyChain ok.
2026-06-15 19:15:53 +02:00
julian a8c6d6e714 auth: JWT valid until logout (drop 8h expiry)
Booth reality breaks a fixed clock (relief late/absent, forced double shifts),
and a shift is a separate explicit boundary. Drop expiresIn from the global jwt
config and from login; the token carries no exp. Cookie maxAge = 30 days so a
browser restart doesn't log out an active operator; logout still clears it.
2026-06-15 19:15:53 +02:00
julian 2a36830880 server: exit flow (pay-on-foot validation)
A credential read at an exit lane validates the session, then opens. Adds a
'read' channel to the device bus (DeviceReadEvent: ticket/plate/qr/card);
entry stays button-driven so reads are exit/identity events.

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.

No payment events exist until the pay station is built, so every transient exit
currently rejects -- the correct end-state, not yet passable. Verified against
stubs: unpaid->anomaly+no-open; paid+grace->exit+open+closed; expired->anomaly;
unknown->anomaly; verifyChain ok across entry->pay->exit.

Flagged: lane_devices has no entry/exit direction model (exit door hardcoded to
1); needs a lane-direction/role model before multi-reader lanes.
2026-06-15 18:57:14 +02:00
22 changed files with 1698 additions and 21 deletions
+11 -5
View File
@@ -18,9 +18,15 @@ export const TOKEN_COOKIE = "parking_token";
export const CSRF_COOKIE = "parking_csrf"; export const CSRF_COOKIE = "parking_csrf";
export const CSRF_HEADER = "x-csrf-token"; export const CSRF_HEADER = "x-csrf-token";
/** Token lifetime, also used as the cookie maxAge. */ // Session lifetime: the JWT has NO expiry — a login is valid until explicit
export const TOKEN_TTL = "8h"; // logout. Booth reality breaks any fixed clock (relief late/absent, forced double
export const TOKEN_TTL_SECONDS = 8 * 60 * 60; // 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. * 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", sameSite: "strict",
secure, secure,
path: "/", 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). // Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
reply.setCookie(CSRF_COOKIE, csrf, { reply.setCookie(CSRF_COOKIE, csrf, {
@@ -63,7 +69,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
sameSite: "strict", sameSite: "strict",
secure, secure,
path: "/", path: "/",
maxAge: TOKEN_TTL_SECONDS, maxAge: COOKIE_MAX_AGE_SECONDS,
}); });
} }
+20
View File
@@ -15,6 +15,17 @@ export interface DeviceInputEvent {
readonly source: "push" | "poll"; 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). */ /** A printer's status as tracked by the live monitor (status + identity). */
export interface PrinterStatusEvent { export interface PrinterStatusEvent {
readonly deviceId: string; // lane_devices id readonly deviceId: string; // lane_devices id
@@ -33,6 +44,15 @@ class DeviceEventBus extends EventEmitter {
return () => this.off("input", cb); 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. */ /** Emitted by the printer monitor whenever a printer's status CHANGES. */
emitPrinterStatus(event: PrinterStatusEvent): void { emitPrinterStatus(event: PrinterStatusEvent): void {
this.emit("printer-status", event); this.emit("printer-status", event);
+176
View File
@@ -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;
}
}
}
+19 -1
View File
@@ -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. // 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; 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;
}
+140
View File
@@ -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;
}
}
+204
View File
@@ -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;
}
}
}
+41
View File
@@ -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);
}
}
+7 -5
View File
@@ -2,7 +2,6 @@ import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, users, type Db } from "@parking/db"; import { eq, users, type Db } from "@parking/db";
import { import {
TOKEN_TTL,
clearAuthCookies, clearAuthCookies,
newCsrfToken, newCsrfToken,
requireRole, requireRole,
@@ -34,10 +33,13 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
const csrf = newCsrfToken(); const csrf = newCsrfToken();
const token = await reply.jwtSign( // No expiresIn: the token is valid until explicit logout (see auth.ts).
{ sub: user.id, username: user.username, role: user.role, csrf }, const token = await reply.jwtSign({
{ expiresIn: TOKEN_TTL }, sub: user.id,
); username: user.username,
role: user.role,
csrf,
});
setAuthCookies(reply, token, csrf); setAuthCookies(reply, token, csrf);
return { id: user.id, username: user.username, role: user.role }; return { id: user.id, username: user.username, role: user.role };
}); });
+69
View File
@@ -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 });
}
+160
View File
@@ -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();
},
);
}
+79
View File
@@ -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);
},
);
}
+32 -1
View File
@@ -7,12 +7,19 @@ import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
import { deviceEvents } from "./device-events.js"; import { deviceEvents } from "./device-events.js";
import { EntryFlow } from "./entry-flow.js"; import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.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 { LaneMap } from "./lane-map.js";
import { PrinterMonitor } from "./printer-monitor.js"; import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js"; import { buildSigner } from "./signer.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.js";
import { deviceRoutes } from "./routes/devices.js"; import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.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 { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js"; import { setupRoutes } from "./routes/setup.js";
@@ -40,7 +47,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// The token is carried in an HttpOnly cookie (not the Authorization header). // The token is carried in an HttpOnly cookie (not the Authorization header).
await app.register(jwt, { await app.register(jwt, {
secret: requireJwtSecret(), 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 }, cookie: { cookieName: TOKEN_COOKIE, signed: false },
}); });
@@ -91,6 +99,29 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
}); });
app.addHook("onClose", async () => unsubscribeEntry()); 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) => { const unsubscribeInput = deviceEvents.onInput((e) => {
// Resolve which lane the device belongs to. -1 marks "device fired but isn't // 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 // mapped to a lane" (assigned without a lane, or a stale id) — still recorded
+6
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchMe, logout, type SessionUser } from "./api.js"; import { fetchMe, logout, type SessionUser } from "./api.js";
import { Login } from "./Login.js"; import { Login } from "./Login.js";
import { PermitManager } from "./PermitManager.js";
import { SetupWizard } from "./SetupWizard.js"; import { SetupWizard } from "./SetupWizard.js";
import { TariffComposer } from "./TariffComposer.js";
// Operator UI shell. Plain React (no admin framework) — the operator UI is // Operator UI shell. Plain React (no admin framework) — the operator UI is
// simple enough that a framework's abstractions cost more than they save. // simple enough that a framework's abstractions cost more than they save.
@@ -39,7 +41,11 @@ export function App() {
</span> </span>
</header> </header>
{user.role === "admin" ? ( {user.role === "admin" ? (
<>
<SetupWizard /> <SetupWizard />
<TariffComposer />
<PermitManager />
</>
) : ( ) : (
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p> <p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
)} )}
+183
View File
@@ -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>
);
}
+207
View File
@@ -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>
);
}
+80
View File
@@ -197,3 +197,83 @@ export function fetchState(): Promise<SetupState> {
export function unassignDevice(id: string): Promise<void> { export function unassignDevice(id: string): Promise<void> {
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" }); 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" });
}
+97
View File
@@ -113,6 +113,103 @@ export interface TariffBlock {
readonly priceMinorPerIncrement: number; 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[] = [ export const ROLES: readonly Role[] = [
"admin", "admin",
"operator", "operator",
+31 -5
View File
@@ -96,8 +96,34 @@ Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorizatio
## What this unblocks (build order) ## What this unblocks (build order)
The device layer left the entry flow dangling — `input_received` events land in the log and stop The device layer left the entry flow dangling — the session domain is that next step. Schema + code
([[device-input-flow]] "the entry flow itself is the next build"). The session domain is that next follow this page and [[tariff]]; the decision is recorded in [[session-model]].
step: consume `input_received` / a reader event → mint a signed `vehicle_entry` → print + open.
Then the pay-station and exit-validation flows. Schema + code follow this page and [[tariff]]; ### As-built (2026-06-15)
the decision is recorded in [[session-model]].
- **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).
+25
View File
@@ -87,6 +87,31 @@ Deterministic, side-effect-free, unit-testable; the daily cap is applied **per r
overnight stay doesn't hit the cap twice). Rounding and segment edges are part of the settled spec 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. 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 ## The pay-on-foot consequence
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
+3 -3
View File
@@ -14,13 +14,13 @@ Authentication and authorization, kept **fully local** — a direct consequence
- `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to - `@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 start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no
insecure default. insecure default.
- **Session lifetime: valid until explicit logout — no time expiry** (decision 2026-06-15). - **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 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 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**, 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.) not tied to token lifetime. (Superseded the earlier "8h expiry, bound to a shift" assumption.)
> ⚠️ Code still mints an 8h-expiry token — this page records the decided design; the server The JWT carries no `exp`; the cookie has a long fixed `maxAge` (30 days) so a browser restart
> change (drop `expiresIn`, persist until logout) is pending. 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 - 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). 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 / - Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier /
+30
View File
@@ -99,6 +99,36 @@ stays append-only even though the permit record itself is editable.
- **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or - **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or
refused, per policy (OPEN). 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) ## Resolved (2026-06-15)
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or - **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
+77
View File
@@ -500,3 +500,80 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
new custody/session shape. Captured as [[valet-overcapacity]] + made [[capacity-occupancy]] FULL a 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. soft policy; NOT built into the entry flow (clean seam left). Deferred.
- New page [[valet-overcapacity]]; updated [[capacity-occupancy]], [[index]]. - 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).