diff --git a/apps/server/src/routes/permits.ts b/apps/server/src/routes/permits.ts new file mode 100644 index 0000000..2835bbe --- /dev/null +++ b/apps/server/src/routes/permits.ts @@ -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 { + // 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(); + }, + ); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d29d5f0..8f954fe 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -18,6 +18,7 @@ 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"; @@ -118,6 +119,9 @@ export async function buildServer(opts: BuildOptions = {}): Promise { // 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 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 885da80..afc731e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,7 @@ 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"; @@ -43,6 +44,7 @@ export function App() { <> + ) : (

Signed in. (Operator console coming soon.)

diff --git a/apps/web/src/PermitManager.tsx b/apps/web/src/PermitManager.tsx new file mode 100644 index 0000000..ba4f58f --- /dev/null +++ b/apps/web/src/PermitManager.tsx @@ -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(null); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(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) { + setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) })); + } + + if (!permits) return null; + + return ( +
+

Permits

+
    + {permits.map((p) => ( +
  • + {p.holderName ?? "(unnamed)"} + {p.status} + + {p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "} + {p.credentials.length} cred · {p.plates.length} plate(s) + + + + {p.status !== "revoked" && } + +
  • + ))} + {permits.length === 0 &&
  • No permits yet.
  • } +
+ + {editing == null ? ( + + ) : ( +
+

{editing === "new" ? "New permit" : "Edit permit"}

+
+ + setForm((f) => ({ ...f, holderName: e.target.value }))} /> + + setForm((f) => ({ ...f, contact: e.target.value }))} /> + + + + {form.carBound && ( + setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} /> + )} + + + setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" /> + + setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" /> + + setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" /> +
+ +

Credentials (card / QR)

+ {form.credentials.map((c, i) => ( +
+ + setCred(i, { value: e.target.value })} placeholder="credential value" style={{ flex: 1 }} /> + +
+ ))} + +

+ A permit needs at least one credential OR one bound plate. +

+ +
+ + +
+
+ )} + {msg &&

{msg.text}

} +
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index c7f8cf8..15ca433 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -240,3 +240,40 @@ export function publishTariffVersion(body: { }): Promise { 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 & { + status?: Permit["status"]; +}; + +export function fetchPermits(): Promise<{ permits: Permit[] }> { + return apiFetch("/api/permits"); +} +export function createPermit(body: PermitInput): Promise { + return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) }); +} +export function updatePermit(id: string, body: PermitInput): Promise { + return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) }); +} +export function revokePermit(id: string): Promise { + return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" }); +} +export function deletePermit(id: string): Promise { + return apiFetch(`/api/permits/${id}`, { method: "DELETE" }); +} diff --git a/wiki/entities/permit.md b/wiki/entities/permit.md index b848daa..5e9b108 100644 --- a/wiki/entities/permit.md +++ b/wiki/entities/permit.md @@ -119,6 +119,16 @@ serves both populations ([[entry-exit-readers]]), disambiguated by *what the cre - 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 diff --git a/wiki/log.md b/wiki/log.md index 2671a42..56cfadf 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -564,3 +564,16 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section). 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).