ticket: site metadata header + scannable Albanian ticket; widen barcode

- site_config gains optional park identity (park_name, operator_name, nius,
  address, phone, email); additive Drizzle migration 0001. GET/PUT
  /api/site-config read/write the full config (PUT partial patch, admin only);
  SiteSettings + SetupWizard expose the fields.
- renderTicket() prints an Albanian header sourced from site_config, the
  all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits,
  and a lost-ticket footer. CP852 codepage so ë/ç render.
- Widen the Code128 module width 2->3 and height 80->100 dots so the
  short-range "Simple" QR/barcode reader decodes reliably (was barely reading
  at module width 2 on the 80mm head).

See wiki/concepts/site-metadata.md and ticket-encoding.md.
This commit is contained in:
2026-06-17 12:17:21 +02:00
parent 1efa77bf56
commit 727c62da90
20 changed files with 1596 additions and 155 deletions
+7
View File
@@ -8,6 +8,13 @@
# Generate one with: openssl rand -hex 32 # Generate one with: openssl rand -hex 32
JWT_SECRET= JWT_SECRET=
# Dedicated HMAC key for signing the append-only event ledger (>=16 chars).
# Generate with: openssl rand -hex 32
# If unset, the server falls back to JWT_SECRET (logged as a warning) — fine for
# dev, but set a dedicated key before production. Events store the key that signed
# them (keyId), so verifyChain still validates a chain that spans a key change.
EVENT_SIGNING_KEY=
# Optional ---------------------------------------------------------------- # Optional ----------------------------------------------------------------
# PORT=3000 # PORT=3000
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only. # HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
+64 -5
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomInt } from "node:crypto";
import { sessions, type Db, type DeviceRow } from "@parking/db"; import { eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
import { import {
NoPrinterAvailableError, NoPrinterAvailableError,
printWithFailover, printWithFailover,
@@ -8,6 +8,7 @@ import {
type PrinterDevice, type PrinterDevice,
type PrinterInstance, type PrinterInstance,
type TicketData, type TicketData,
type TicketHeader,
} from "@parking/devices"; } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { DeviceInputEvent } from "./device-events.js"; import type { DeviceInputEvent } from "./device-events.js";
@@ -91,7 +92,7 @@ export class EntryFlow {
const printers = this.#loadPrinters(); const printers = this.#loadPrinters();
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry. // 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
const ticket: TicketData = { ticketId, issuedAt }; const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
try { try {
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) => const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
d.printTicket(ticket), d.printTicket(ticket),
@@ -182,9 +183,67 @@ export class EntryFlow {
} }
return out; return out;
} }
/** Park identity for the ticket header, from site_config (all fields optional;
* the driver prints only what's set). See wiki/concepts/site-metadata.md. */
#ticketHeader(): TicketHeader | undefined {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
} }
/** Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). */ /**
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
*
* Format: 13 digits = 12 cryptographically-random digits + 1 trailing Luhn check
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the
* id must stay unguessable so an attacker can't iterate to claim a cheaper session
* — the anti-fraud property the wiki settles. 12 random digits = 10^12 space, so
* collisions are negligible at lot scale; the unique constraints on
* ledger_events.index / sessions.id are the backstop. The Luhn digit lets a manual
* entry reject a typo (validateTicketCode) instead of failing as "session not found".
*/
function newTicketId(): string { function newTicketId(): string {
return `T-${randomUUID()}`; let body = "";
for (let i = 0; i < 12; i += 1) body += String(randomInt(10));
return body + luhnCheckDigit(body);
}
/** The Luhn (mod-10) check digit for an all-digit string. */
function luhnCheckDigit(digits: string): string {
let sum = 0;
// Walk right-to-left; the check digit sits at position 0 from the right, so the
// last body digit is an "even" position that gets doubled.
let double = true;
for (let i = digits.length - 1; i >= 0; i -= 1) {
let d = digits.charCodeAt(i) - 48;
if (double) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
double = !double;
}
return String((10 - (sum % 10)) % 10);
}
/**
* True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum.
* Lets a manual-entry path (operator types the code off the ticket when readers are
* down) reject a typo up front. A scanned/looked-up id that predates this format
* (e.g. legacy `T-<uuid>`) won't pass — callers should only gate MANUAL entry on it,
* never reject an id that already exists in the ledger. See ticket-encoding.md.
*/
export function validateTicketCode(code: string): boolean {
if (!/^\d{13}$/.test(code)) return false;
const body = code.slice(0, 12);
return luhnCheckDigit(body) === code[12];
} }
+26 -3
View File
@@ -81,15 +81,24 @@ export function hashEvent(canonical: string): string {
return createHash("sha256").update(canonical, "utf8").digest("hex"); return createHash("sha256").update(canonical, "utf8").digest("hex");
} }
/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier).
* Returns undefined when the key that signed an event is not available. */
export type SignerResolver = (keyId: string) => Signer | undefined;
export class EventLog { export class EventLog {
readonly #db: Db; readonly #db: Db;
readonly #signer: Signer; readonly #signer: Signer;
/** Picks the verifying signer per event keyId; lets a chain span key rotations
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
* callers that don't pass one (single-key chains, tests). */
readonly #resolveVerifier: SignerResolver;
/** Serialize appends: each waits for the previous to finish. */ /** Serialize appends: each waits for the previous to finish. */
#tail: Promise<unknown> = Promise.resolve(); #tail: Promise<unknown> = Promise.resolve();
constructor(db: Db, signer: Signer) { constructor(db: Db, signer: Signer, resolveVerifier?: SignerResolver) {
this.#db = db; this.#db = db;
this.#signer = signer; this.#signer = signer;
this.#resolveVerifier = resolveVerifier ?? (() => signer);
} }
/** Append one event to the chain. Returns the persisted row. Serialized. */ /** Append one event to the chain. Returns the persisted row. Serialized. */
@@ -146,7 +155,13 @@ export class EventLog {
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the * Walk the chain oldest→newest and recompute hashes + signatures. Returns the
* first detected break, or { ok: true }. This is what reconciliation and an * first detected break, or { ok: true }. This is what reconciliation and an
* integrity self-check call. Catches: tampered content, reordering, a deleted * integrity self-check call. Catches: tampered content, reordering, a deleted
* row (index gap), and a forged/invalid signature. * row (index gap), a forged/invalid signature, and an event signed under a key
* that is no longer configured.
*
* Each row is verified against the signer for ITS OWN `keyId`, not the current
* append signer — so a chain that spans a key rotation (e.g. early events under
* the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still
* verifies end to end. See signer.buildVerifier.
*/ */
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } { verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
@@ -159,8 +174,16 @@ export class EventLog {
if ((row.prevHash ?? null) !== prevHash) { if ((row.prevHash ?? null) !== prevHash) {
return { ok: false, index: row.index, reason: "prevHash does not match chain" }; return { ok: false, index: row.index, reason: "prevHash does not match chain" };
} }
const verifier = this.#resolveVerifier(row.keyId);
if (!verifier) {
return {
ok: false,
index: row.index,
reason: `no signer for keyId "${row.keyId}" (key not configured)`,
};
}
const canonical = canonicalize(row); const canonical = canonicalize(row);
if (!this.#signer.verify(canonical, row.signature)) { if (!verifier.verify(canonical, row.signature)) {
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" }; return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
} }
prevHash = hashEvent(canonical); prevHash = hashEvent(canonical);
+172 -82
View File
@@ -51,6 +51,127 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
return out; return out;
} }
/** Result of the device configure pipeline: a ready-to-persist config, or an
* HTTP error to send back. Shared by assign (create) and patch (edit). */
type ConfigureOutcome =
| { config: Record<string, unknown>; warnings: string[] }
| { error: { code: number; message: string } };
/**
* Validate + configure a device, returning the config to persist. Runs the same
* pipeline for both create and edit: validate the driver config, fix
* preconditions, harden (relay password + protocol lockdown), and set up input
* push (Digest creds + push URLs). Each step is a device write (the device
* reboots on apply). The caller owns the DB row; this never touches the DB.
*
* `id` is the assignment id (stable across an edit) — it's baked into the push
* URL, so editing in place keeps the device pushing to the same path.
* `existingConfig` carries forward secrets the client never sees on edit
* (push/relay passwords), so a PATCH that omits them doesn't wipe them.
*/
async function configureDevice(
app: FastifyInstance,
args: {
id: string;
driverId: string;
config: DeviceConfig;
backendIp?: string;
existingConfig?: Record<string, unknown>;
},
): Promise<ConfigureOutcome> {
const { id, driverId, config, backendIp, existingConfig } = args;
// Start from any machine-only secrets already on the row (push/relay passwords
// are redacted out of the client's copy, so an edit would otherwise drop them),
// then layer the submitted config on top.
const fullConfig: Record<string, unknown> = { ...existingConfig, ...config };
// The web password the admin typed is a DESIRED value, not a stored fact:
// it's passed to the driver (via create(config) below) as the rotation
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return { error: { code: 400, message: (err as Error).message } };
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return {
error: {
code: 502,
message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
},
};
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return {
error: {
code: 400,
message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
},
};
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } };
}
return { config: fullConfig, warnings: hardenWarnings };
}
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
registerBuiltinDrivers(); registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line)); setDeviceLogSink((line) => app.log.info(line));
@@ -166,100 +287,69 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
const id = randomUUID(); const id = randomUUID();
const fullConfig: Record<string, unknown> = { ...config }; const outcome = await configureDevice(app, { id, driverId, config, backendIp });
// The web password the admin typed is a DESIRED value, not a stored fact: if ("error" in outcome) {
// it's passed to the driver (via create(config) below) as the rotation return reply.code(outcome.error.code).send({ error: outcome.error.message });
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return reply.code(502).send({
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
});
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return reply.code(400).send({
error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
});
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return reply
.code(502)
.send({ error: `device configuration failed: ${(err as Error).message}` });
} }
const row = { const row = {
id, id,
category, category,
driverId, driverId,
config: fullConfig, config: outcome.config,
enabled: true, enabled: true,
}; };
await db.insert(devices).values(row); await db.insert(devices).values(row);
// Don't echo device secrets back (push Digest password, web-UI login, …). // Don't echo device secrets back (push Digest password, web-UI login, …).
return reply.code(201).send({ return reply.code(201).send({
...row, ...row,
config: redactSecrets(fullConfig), config: redactSecrets(outcome.config),
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}), ...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
});
},
);
// Edit an assigned device in place. Same configure pipeline as assign, but it
// UPDATEs the existing row and KEEPS the id — which matters for controllers,
// since the id is baked into the device's input-push URL
// (/api/devices/:driverId/:id/input). Delete+re-add would mint a new id and
// break push until reconfigured; PATCH re-runs harden/push against the same id.
// The category and driver are fixed at create time (an edit can't change what
// KIND of device a slot is); only config changes. Admin-only.
app.patch<{ Params: { id: string }; Body: Omit<AssignBody, "category" | "driverId"> }>(
"/api/setup/assign/:id",
{ preHandler: adminGuard },
async (req, reply) => {
const existing = await db
.select()
.from(devices)
.where(eq(devices.id, req.params.id))
.get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
const { config, backendIp } = req.body;
const outcome = await configureDevice(app, {
id: existing.id,
driverId: existing.driverId,
config,
backendIp,
// Carry forward machine-only secrets the client never received, so an
// edit that omits them doesn't blank out push/relay passwords.
existingConfig: existing.config,
});
if ("error" in outcome) {
return reply.code(outcome.error.code).send({ error: outcome.error.message });
}
await db.update(devices).set({ config: outcome.config }).where(eq(devices.id, existing.id));
app.log.info(`reconfigured device ${existing.id} (${existing.category}/${existing.driverId})`);
return reply.code(200).send({
id: existing.id,
category: existing.category,
driverId: existing.driverId,
config: redactSecrets(outcome.config),
enabled: existing.enabled,
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
}); });
}, },
); );
+49 -10
View File
@@ -7,9 +7,36 @@ import { getOccupancy } from "../occupancy.js";
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at // ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md. // capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
interface SiteConfigBody { // Optional park-metadata text fields (all nullable). Trimmed; "" → null.
const TEXT_FIELDS = [
"parkName",
"operatorName",
"nius",
"address",
"phone",
"email",
] as const;
type TextField = (typeof TEXT_FIELDS)[number];
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
/** Nominal capacity; null = no limit. */ /** Nominal capacity; null = no limit. */
capacity: number | null; capacity?: number | null;
}
/** Shape returned by GET/PUT: capacity + every metadata field (null when unset). */
type SiteConfig = { capacity: number | null } & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
const out = { capacity: row?.capacity ?? null } as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out;
}
/** Trim a text field; empty string becomes null so blank input clears it. */
function normText(v: unknown): string | null {
if (v == null) return null;
const s = String(v).trim();
return s === "" ? null : s;
} }
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
@@ -19,25 +46,37 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Live occupancy: cars inside, capacity, free, full. Any signed-in role. // Live occupancy: cars inside, capacity, free, full. Any signed-in role.
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db)); app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
// Read site config (capacity). // Read site config (capacity + park metadata).
app.get("/api/site-config", { preHandler: readGuard }, async () => { app.get("/api/site-config", { preHandler: readGuard }, async () => {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return { capacity: row?.capacity ?? null }; return toSiteConfig(row);
}); });
// Set capacity (admin). null or 0+ integer. // Set site config (admin). Capacity: null or 0+ integer. Metadata: optional text
// (only the fields PRESENT in the body are updated; absent fields are untouched).
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => { app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
const { capacity } = req.body ?? ({} as SiteConfigBody); const body = req.body ?? ({} as SiteConfigBody);
if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) {
const patch: Partial<typeof siteConfig.$inferInsert> = {};
if ("capacity" in body) {
const c = body.capacity;
if (c != null && (!Number.isInteger(c) || c < 0)) {
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" }); return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
} }
patch.capacity = c ?? null;
}
for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]);
}
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const updatedAt = new Date().toISOString(); const updatedAt = new Date().toISOString();
if (existing) { if (existing) {
db.update(siteConfig).set({ capacity: capacity ?? null, updatedAt }).where(eq(siteConfig.id, 1)).run(); db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else { } else {
db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run(); db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
} }
return { capacity: capacity ?? null }; const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return toSiteConfig(row);
}); });
} }
+2 -2
View File
@@ -13,7 +13,7 @@ import { PermitFlow } from "./permit-flow.js";
import { ShiftService } from "./shift-service.js"; import { ShiftService } from "./shift-service.js";
import { ReadDispatcher } from "./read-dispatch.js"; import { ReadDispatcher } from "./read-dispatch.js";
import { PrinterMonitor } from "./printer-monitor.js"; import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js"; import { buildSigner, buildVerifier } 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";
@@ -86,7 +86,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// in device_events. The entry flow (TODO) turns an input into a signed // in device_events. The entry flow (TODO) turns an input into a signed
// vehicle_entry once a ticket prints + the barrier is commanded. // vehicle_entry once a ticket prints + the barrier is commanded.
// See wiki/decisions/event-streams-split.md. // See wiki/decisions/event-streams-split.md.
const eventLog = new EventLog(db, buildSigner(app.log)); const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier);
await eventRoutes(app, db, eventLog); await eventRoutes(app, db, eventLog);
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts. // Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
+27
View File
@@ -58,3 +58,30 @@ export function buildSigner(log?: { warn: (msg: string) => void }): Signer {
"event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.", "event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.",
); );
} }
/**
* Resolve the signer that can VERIFY an existing event, by its stored `keyId`.
* Appends always use the one signer from buildSigner(), but a chain can contain
* events signed under different keys across a rotation (e.g. the JWT_SECRET
* fallback before a dedicated EVENT_SIGNING_KEY was set, or an ATECC608 swap).
* Each event stores its own `keyId`, so verifyChain() must check each row against
* the key that produced it — not the current append-signer. Returns undefined for
* an unknown keyId (the key is gone / not configured), which verifyChain surfaces
* as a distinct failure rather than a false "tampered" alarm.
*
* TODO(atecc608): add an "atecc608-slotN" case returning a public-key verifier.
*/
export function buildVerifier(keyId: string): Signer | undefined {
switch (keyId) {
case "sw-hmac-v2": {
const k = process.env.EVENT_SIGNING_KEY;
return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-v2") : undefined;
}
case "sw-hmac-jwtfallback": {
const k = process.env.JWT_SECRET;
return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-jwtfallback") : undefined;
}
default:
return undefined;
}
}
+81 -14
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { import {
assignDevice, assignDevice,
editDevice,
discoverDevices, discoverDevices,
fetchBackendIps, fetchBackendIps,
fetchCatalog, fetchCatalog,
@@ -127,8 +128,12 @@ function CategorySection({
onChanged: () => Promise<void> | void; onChanged: () => Promise<void> | void;
}) { }) {
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [warnings, setWarnings] = useState<string[]>([]); const [warnings, setWarnings] = useState<string[]>([]);
const showForm = adding || assignments.length === 0; const editing = editingId ? assignments.find((a) => a.id === editingId) : undefined;
// Show the add form for an empty category or an explicit "+ Add", but not while
// editing an existing row (that row renders its own inline form).
const showForm = !editing && (adding || assignments.length === 0);
// Binding categories need a controller to point at first. // Binding categories need a controller to point at first.
const isBound = category !== "access"; const isBound = category !== "access";
@@ -162,15 +167,43 @@ function CategorySection({
{assignments.length > 0 && ( {assignments.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}> <ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
{assignments.map((a) => ( {assignments.map((a) =>
<AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} /> editingId === a.id ? (
))} <li key={a.id} style={{ listStyle: "none", padding: 0 }}>
<DeviceForm
category={category}
entries={entries}
discoverableIds={discoverableIds}
pushCapableIds={pushCapableIds}
controllers={controllers}
editing={a}
onSaved={async (w) => {
setWarnings(w);
await onChanged();
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
/>
</li>
) : (
<AssignmentRow
key={a.id}
assignment={a}
controllers={controllers}
onChanged={onChanged}
onEdit={() => {
setAdding(false);
setEditingId(a.id);
}}
/>
),
)}
</ul> </ul>
)} )}
{blockedNoController ? ( {blockedNoController ? (
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p> <p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
) : showForm ? ( ) : editing ? null : showForm ? (
<DeviceForm <DeviceForm
category={category} category={category}
entries={entries} entries={entries}
@@ -197,10 +230,12 @@ function AssignmentRow({
assignment, assignment,
controllers, controllers,
onChanged, onChanged,
onEdit,
}: { }: {
assignment: Assignment; assignment: Assignment;
controllers: Assignment[]; controllers: Assignment[];
onChanged: () => Promise<void> | void; onChanged: () => Promise<void> | void;
onEdit: () => void;
}) { }) {
const [removing, setRemoving] = useState(false); const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -237,6 +272,9 @@ function AssignmentRow({
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>} {!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
{error && <span style={{ color: "crimson" }}>{error}</span>} {error && <span style={{ color: "crimson" }}>{error}</span>}
<button type="button" onClick={onEdit} disabled={removing}>
Edit
</button>
<button type="button" onClick={remove} disabled={removing}> <button type="button" onClick={remove} disabled={removing}>
{removing ? "Removing…" : "Remove"} {removing ? "Removing…" : "Remove"}
</button> </button>
@@ -280,6 +318,7 @@ function DeviceForm({
discoverableIds, discoverableIds,
pushCapableIds, pushCapableIds,
controllers, controllers,
editing,
onSaved, onSaved,
onCancel, onCancel,
}: { }: {
@@ -288,21 +327,42 @@ function DeviceForm({
discoverableIds: string[]; discoverableIds: string[];
pushCapableIds: string[]; pushCapableIds: string[];
controllers: Assignment[]; controllers: Assignment[];
/** When set, the form edits this assignment in place (driver locked, config
* pre-filled) instead of adding a new device. */
editing?: Assignment;
onSaved: (warnings: string[]) => Promise<void> | void; onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void; onCancel?: () => void;
}) { }) {
const [selectedId, setSelectedId] = useState<string>(""); // On edit the driver is fixed (you can't change what KIND of device a slot is —
// that's a remove + re-add); pre-select it and lock the picker.
const editCfg = editing?.config as Record<string, unknown> | undefined;
const [selectedId, setSelectedId] = useState<string>(editing?.driverId ?? "");
const selected = entries.find((e) => e.id === selectedId); const selected = entries.find((e) => e.id === selectedId);
const canDiscover = selected != null && discoverableIds.includes(selected.id); const canDiscover = !editing && selected != null && discoverableIds.includes(selected.id);
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id); const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
const isController = category === "access"; const isController = category === "access";
const [config, setConfig] = useState<Record<string, string | number>>({}); // Pre-fill scalar config fields from the existing assignment when editing.
// (relays/controllerId/relay are model fields handled by their own state below.)
const [config, setConfig] = useState<Record<string, string | number>>(() => {
if (!editCfg) return {};
const out: Record<string, string | number> = {};
for (const [k, v] of Object.entries(editCfg)) {
if (typeof v === "string" || typeof v === "number") out[k] = v;
}
return out;
});
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal). // Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
const [relays, setRelays] = useState<RelaySpec[]>([{ relay: 1, direction: "both" }]); const [relays, setRelays] = useState<RelaySpec[]>(() =>
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
);
// Bound devices: which controller + relay this device sits at. // Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>(""); const [controllerId, setControllerId] = useState<string>(
const [boundRelay, setBoundRelay] = useState<number | "">(""); typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
);
const [boundRelay, setBoundRelay] = useState<number | "">(
typeof editCfg?.relay === "number" ? editCfg.relay : "",
);
const [tested, setTested] = useState<TestResult | null>(null); const [tested, setTested] = useState<TestResult | null>(null);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
@@ -420,7 +480,12 @@ function DeviceForm({
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
try { try {
const result = await assignDevice({ const result = editing
? await editDevice(editing.id, {
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
})
: await assignDevice({
category, category,
driverId: selected.id, driverId: selected.id,
config: mergedConfig(), config: mergedConfig(),
@@ -439,7 +504,9 @@ function DeviceForm({
{entries.length === 0 ? ( {entries.length === 0 ? (
<em>No drivers registered.</em> <em>No drivers registered.</em>
) : ( ) : (
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)}> // Driver is locked when editing — changing the kind of device is a
// remove + re-add, not an in-place edit.
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
<option value="" disabled> <option value="" disabled>
Choose a device… Choose a device…
</option> </option>
@@ -538,7 +605,7 @@ function DeviceForm({
{testing ? "Testing…" : "Test connection"} {testing ? "Testing…" : "Test connection"}
</button> </button>
<button type="button" onClick={save} disabled={saving}> <button type="button" onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save & configure"} {saving ? "Saving…" : editing ? "Save changes" : "Save & configure"}
</button> </button>
{onCancel && ( {onCancel && (
<button type="button" onClick={onCancel} disabled={saving}> <button type="button" onClick={onCancel} disabled={saving}>
+54 -11
View File
@@ -1,14 +1,26 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchOccupancy, fetchSiteConfig, setCapacity, type Occupancy } from "./api.js"; import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
// Live occupancy + capacity. Occupancy is shown to everyone (it's a fold over the // Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
// signed ledger); the capacity field is admin-editable. The FULL gate (refuse // fold over the signed ledger); capacity and the metadata fields are admin-editable.
// transient entry at capacity) is enforced server-side in the entry flow. // The FULL gate (refuse transient entry at capacity) is enforced server-side in the
// See wiki/concepts/capacity-occupancy.md. // entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
// The optional text fields, in display order, with labels + placeholders.
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; label: string; placeholder?: string; multiline?: boolean }> = [
{ key: "parkName", label: "Park name", placeholder: "e.g. Acme Parking" },
{ key: "operatorName", label: "Operator (legal name)", placeholder: "operating company" },
{ key: "nius", label: "NIUS", placeholder: "e.g. L01234567A" },
{ key: "address", label: "Address", multiline: true },
{ key: "phone", label: "Phone" },
{ key: "email", label: "Email" },
];
export function SiteSettings({ canEdit }: { canEdit: boolean }) { export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [occ, setOcc] = useState<Occupancy | null>(null); const [occ, setOcc] = useState<Occupancy | null>(null);
const [capInput, setCapInput] = useState(""); const [capInput, setCapInput] = useState("");
const [meta, setMeta] = useState<Record<string, string>>({});
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
function reload() { function reload() {
@@ -17,18 +29,25 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
useEffect(() => { useEffect(() => {
reload(); reload();
fetchSiteConfig() fetchSiteConfig()
.then((c) => setCapInput(c.capacity == null ? "" : String(c.capacity))) .then((c) => {
setCapInput(c.capacity == null ? "" : String(c.capacity));
const m: Record<string, string> = {};
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
setMeta(m);
})
.catch(() => {}); .catch(() => {});
}, []); }, []);
async function save() { async function save() {
setMsg(null); setMsg(null);
const raw = capInput.trim(); const raw = capInput.trim();
const capacity = raw === "" ? null : Math.round(Number(raw)); const patch: Partial<SiteConfig> = { capacity: raw === "" ? null : Math.round(Number(raw)) };
// Send each metadata field; "" → null is applied server-side.
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
try { try {
await setCapacity(capacity); await saveSiteConfig(patch);
reload(); reload();
setMsg("Capacity saved."); setMsg("Saved.");
} catch (e) { } catch (e) {
setMsg((e as Error).message); setMsg((e as Error).message);
} }
@@ -51,14 +70,38 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
</> </>
)} )}
{canEdit && ( {canEdit && (
<div style={{ marginTop: "0.6rem" }}> <div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
<label> <label>
Capacity (blank = no limit):{" "} Capacity (blank = no limit):{" "}
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" /> <input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
</label>{" "} </label>
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
Park details (optional — shown on tickets/receipts)
</div>
{META_FIELDS.map(({ key, label, placeholder, multiline }) => (
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
{label}
{multiline ? (
<textarea
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
rows={2}
placeholder={placeholder}
/>
) : (
<input
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
placeholder={placeholder}
/>
)}
</label>
))}
<div>
<button type="button" onClick={save}>Save</button> <button type="button" onClick={save}>Save</button>
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>} {msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
</div> </div>
</div>
)} )}
</section> </section>
); );
+28 -3
View File
@@ -186,6 +186,15 @@ export function assignDevice(body: AssignBody): Promise<AssignResult> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
} }
/** Re-configure an existing device in place, keeping its id (and so its push
* URL). Category/driver are fixed at create time, so only config changes. */
export function editDevice(
id: string,
body: Omit<AssignBody, "category" | "driverId">,
): Promise<AssignResult> {
return apiFetch(`/api/setup/assign/${id}`, { method: "PATCH", body: JSON.stringify(body) });
}
/** A persisted device assignment (one per instance; machine-only secrets stripped). */ /** A persisted device assignment (one per instance; machine-only secrets stripped). */
export interface Assignment { export interface Assignment {
id: string; id: string;
@@ -333,12 +342,28 @@ export interface Occupancy {
full: boolean; full: boolean;
} }
/** Capacity + optional park metadata (all nullable). Mirrors site_config. */
export interface SiteConfig {
capacity: number | null;
parkName: string | null;
operatorName: string | null;
/** NIUS — Albanian tax/identification number. */
nius: string | null;
address: string | null;
phone: string | null;
email: string | null;
}
export function fetchOccupancy(): Promise<Occupancy> { export function fetchOccupancy(): Promise<Occupancy> {
return apiFetch("/api/occupancy"); return apiFetch("/api/occupancy");
} }
export function fetchSiteConfig(): Promise<{ capacity: number | null }> { export function fetchSiteConfig(): Promise<SiteConfig> {
return apiFetch("/api/site-config"); return apiFetch("/api/site-config");
} }
export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> { /** PUT a partial config — only the fields supplied are changed. */
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) }); export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
}
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
return saveSiteConfig({ capacity });
} }
@@ -0,0 +1,6 @@
ALTER TABLE `site_config` ADD `park_name` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `operator_name` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `nius` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `address` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `phone` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `email` text;
+797
View File
@@ -0,0 +1,797 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2cfc13fa-43fc-4f89-8438-7b9bcaf7ea3b",
"prevId": "a6d81d46-c4a4-4ee7-8565-ec012bbe0252",
"tables": {
"blocklist": {
"name": "blocklist",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"device_events": {
"name": "device_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ledger_events": {
"name": "ledger_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"ledger_events_index_unique": {
"name": "ledger_events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_credentials": {
"name": "permit_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_plates": {
"name": "permit_plates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plate": {
"name": "plate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permits": {
"name": "permits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"holder_name": {
"name": "holder_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_concurrent": {
"name": "max_concurrent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"valid_from": {
"name": "valid_from",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"valid_to": {
"name": "valid_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entered_at": {
"name": "entered_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exited_at": {
"name": "exited_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"last_event_index": {
"name": "last_event_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"capacity": {
"name": "capacity",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"park_name": {
"name": "park_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"operator_name": {
"name": "operator_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"nius": {
"name": "nius",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"snapshots": {
"name": "snapshots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bytes": {
"name": "bytes",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"captured_at": {
"name": "captured_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": {
"name": "tariff_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tariff_id": {
"name": "tariff_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_from": {
"name": "effective_from",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"structure": {
"name": "structure",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariffs": {
"name": "tariffs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'site'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1781632874398, "when": 1781632874398,
"tag": "0000_baseline", "tag": "0000_baseline",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1781682176094,
"tag": "0001_neat_slipstream",
"breakpoints": true
} }
] ]
} }
+17
View File
@@ -131,9 +131,26 @@ export const setupState = sqliteTable("setup_state", {
// Single-row site settings (admin-configurable). The home for site-wide knobs; // Single-row site settings (admin-configurable). The home for site-wide knobs;
// `capacity` is the nominal space count the FULL gate refuses transient entry at // `capacity` is the nominal space count the FULL gate refuses transient entry at
// (null = no cap). See wiki/concepts/capacity-occupancy.md. // (null = no cap). See wiki/concepts/capacity-occupancy.md.
// Park identity/metadata (all optional) lives here too — display name, the legal
// operator, the NIUS tax id, address and contact. These feed the ticket/receipt
// header (park name + NIUS are commonly required on an Albanian parking receipt)
// and admin display. All nullable: the lot runs fine with none set.
// See wiki/concepts/site-metadata.md.
export const siteConfig = sqliteTable("site_config", { export const siteConfig = sqliteTable("site_config", {
id: integer("id").primaryKey(), // always 1 id: integer("id").primaryKey(), // always 1
capacity: integer("capacity"), // null = no capacity limit capacity: integer("capacity"), // null = no capacity limit
/** Park display name shown on the ticket header / UI (e.g. "Acme Parking"). */
parkName: text("park_name"),
/** Legal entity operating the lot, for receipts (may differ from parkName). */
operatorName: text("operator_name"),
/** NIUS — Albanian tax/identification number, printed on the receipt when set. */
nius: text("nius"),
/** Free-text postal address (multi-line allowed). */
address: text("address"),
/** Contact phone — also used for the ticket "lost ticket? call …" footer. */
phone: text("phone"),
/** Contact email. */
email: text("email"),
updatedAt: text("updated_at") updatedAt: text("updated_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
+108 -11
View File
@@ -40,15 +40,91 @@ const DOUBLE_ON = Buffer.from([GS, 0x21, 0x11]); // GS ! — double width+height
const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]); const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]);
const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut
/** Encode a printable line as bytes (CP437/ASCII subset) + a line feed. */ // Select code page 852 (Latin-2) for the character set: ESC t n, n=18 (0x12).
// CP852 carries the Albanian letters we print (ë, ç, …); without it the printer
// would interpret our high bytes as CP437 glyphs. Sent in every print's INIT
// preamble. See wiki/concepts/site-metadata.md (i18n / codepage).
const SELECT_CP852 = Buffer.from([ESC, 0x74, 0x12]);
// Minimal Unicode → CP852 byte map for the characters Albanian text actually uses
// beyond ASCII. Anything not listed is transliterated to an ASCII fallback (below)
// so we never emit a byte that renders as the wrong glyph. Extend as needed.
const CP852: Record<string, number> = {
ë: 0x89, Ë: 0xeb,
ç: 0x87, Ç: 0x80,
// common Latin-2 extras that may appear in a park name/address:
ä: 0x84, ö: 0x94, ü: 0x81, é: 0x82, á: 0xa0, í: 0xa1, ó: 0xa2, ú: 0xa3,
};
// ASCII transliteration for any char with no CP852 mapping (last-resort, so an
// odd glyph degrades to a readable letter rather than garbage).
const ASCII_FALLBACK: Record<string, string> = {
ë: "e", Ë: "E", ç: "c", Ç: "C", ä: "a", ö: "o", ü: "u",
é: "e", á: "a", í: "i", ó: "o", ú: "u",
};
/** Encode one line of text to CP852 bytes + a line feed. ASCII (<0x80) passes
* through; mapped chars use their CP852 byte; unmapped non-ASCII falls back to an
* ASCII letter. Pair with SELECT_CP852 in the print preamble. */
function line(text = ""): Buffer { function line(text = ""): Buffer {
return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]); const out: number[] = [];
for (const ch of text) {
const code = ch.codePointAt(0) ?? 0;
const mapped = CP852[ch];
const fallback = ASCII_FALLBACK[ch];
if (code < 0x80) {
out.push(code);
} else if (mapped !== undefined) {
out.push(mapped);
} else if (fallback !== undefined) {
out.push(...Buffer.from(fallback, "ascii"));
} else {
out.push(0x3f); // "?" — unknown char, never a wrong glyph
} }
}
out.push(LF);
return Buffer.from(out);
}
// --- Scannable symbol (printer-generated, no image rendering) -----------------
// The ticket id is the session key (wiki/concepts/ticket-encoding.md). We print it
// as a 1D Code128 barcode so ANY legacy laser barcode scanner the booth might have
// can read it. The barcode is rendered by the Rongta board from these ESC/POS
// commands — we send the data, the firmware draws the bars (no bitmap, no
// dependency). The same code is printed as large human-readable digits below, so
// the operator can hand-key it if every reader fails. (A QR for phone scanning may
// be added later behind an admin toggle.)
/** GS k — Code128 1D barcode. Height/width set first, then HRI off, then data. */
function code128(data: string): Buffer {
// Code128 code set B (printable ASCII) — prefix the data with the {B selector.
const payload = Buffer.from(`{B${data}`, "ascii");
return Buffer.concat([
Buffer.from([GS, 0x68, 0x64]), // GS h 100 — barcode height = 100 dots (taller = tolerant of scan angle)
Buffer.from([GS, 0x77, 0x03]), // GS w 3 — module width = 3 (wider bars for the short-range "Simple" QR/barcode engine; 13-digit Code128 ≈ 495/576 dots, fits 80mm with quiet zones)
Buffer.from([GS, 0x48, 0x00]), // GS H 0 — HRI text off (we print the id ourselves)
// GS k 73 n <data> — function B form: 73 = Code128, n = data byte length.
Buffer.from([GS, 0x6b, 0x49, payload.length]),
payload,
]);
}
// Ticket/receipt strings — Albanian (the site prints in Albanian for now). Kept in
// one place so a real i18n layer (per-locale tables + a t() helper) can replace this
// later without touching the render functions. See wiki/concepts/site-metadata.md.
const STR = {
/** NIUS label prefix; printed only when the park has a NIUS. */
nius: (v: string) => `NIUS: ${v}`,
/** "Printed at:" — precedes the issue timestamp. */
issuedAt: (v: string) => `Printuar më: ${v}`,
/** "Lost your ticket? <phone>" footer; printed only when a phone is set. */
lostTicket: (phone: string) => `Keni humbur biletën? ${phone}`,
} as const;
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */ /** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
function renderReport(report: PrintReport): Buffer { function renderReport(report: PrintReport): Buffer {
return Buffer.concat([ return Buffer.concat([
INIT, INIT,
SELECT_CP852,
ALIGN_CENTER, ALIGN_CENTER,
BOLD_ON, BOLD_ON,
line(report.title), line(report.title),
@@ -60,23 +136,44 @@ function renderReport(report: PrintReport): Buffer {
]); ]);
} }
/** Build the full ESC/POS byte stream for an entry ticket. */ /** Render the park-identity header from site metadata. Prints the park name large
* (or "PARKING" if unset), then operator / NIUS / address lines that are present.
* NIUS and the rest only print when set. Non-ASCII renders via CP852 (see line()). */
function renderHeader(h: TicketData["header"]): Buffer {
const parts: Buffer[] = [ALIGN_CENTER, BOLD_ON, DOUBLE_ON, line(h?.parkName || "PARKING"), DOUBLE_OFF, BOLD_OFF];
if (h?.operatorName) parts.push(line(h.operatorName));
if (h?.nius) parts.push(line(STR.nius(h.nius)));
if (h?.address) {
// Address may be multi-line; print each line centered.
for (const ln of h.address.split(/\r?\n/)) if (ln.trim()) parts.push(line(ln.trim()));
}
return Buffer.concat(parts);
}
/** Build the full ESC/POS byte stream for an entry ticket.
* Header (park identity) → 1D Code128 barcode of the ticket id → the id in large
* digits → issue time → optional lost-ticket footer. Code128 is read by ANY legacy
* 1D barcode scanner the booth might have; the printed digits are the fallback if
* every reader fails (operator hand-keys the all-numeric code). Text is Albanian.
* See wiki/concepts/ticket-encoding.md and site-metadata.md. */
function renderTicket(data: TicketData): Buffer { function renderTicket(data: TicketData): Buffer {
return Buffer.concat([ return Buffer.concat([
INIT, INIT,
ALIGN_CENTER, SELECT_CP852,
renderHeader(data.header),
line(),
// The scannable barcode + the same code in large human-readable digits.
code128(data.ticketId),
line(),
BOLD_ON, BOLD_ON,
DOUBLE_ON, DOUBLE_ON,
line("PARKING"), line(data.ticketId),
DOUBLE_OFF, DOUBLE_OFF,
BOLD_OFF, BOLD_OFF,
line(), line(),
BOLD_ON, line(STR.issuedAt(data.issuedAt)),
line(data.ticketId), // Contact footer (lost-ticket help) if a phone is set.
BOLD_OFF, ...(data.header?.phone ? [line(STR.lostTicket(data.header.phone))] : []),
ALIGN_LEFT,
line(),
line(`Issued: ${data.issuedAt}`),
FEED_AND_CUT, FEED_AND_CUT,
]); ]);
} }
+14
View File
@@ -190,9 +190,23 @@ export interface Snapshot {
} }
// --- Printers (ticket dispenser / booth printer) ------------------------- // --- Printers (ticket dispenser / booth printer) -------------------------
/** Optional park identity printed at the top of a ticket/receipt. All fields
* optional — the driver prints only what's set. Sourced from site_config; an
* Albanian parking receipt commonly must show the park name + NIUS. */
export interface TicketHeader {
readonly parkName?: string | null;
readonly operatorName?: string | null;
/** NIUS — Albanian tax/identification number. */
readonly nius?: string | null;
readonly address?: string | null;
readonly phone?: string | null;
}
export interface TicketData { export interface TicketData {
readonly ticketId: string; readonly ticketId: string;
readonly issuedAt: string; // ISO-8601 readonly issuedAt: string; // ISO-8601
/** Park identity for the header. Absent → driver prints the generic "PARKING". */
readonly header?: TicketHeader;
} }
export interface PrinterDevice extends Device { export interface PrinterDevice extends Device {
+78
View File
@@ -0,0 +1,78 @@
---
type: concept
tags: [parking, domain, config, devices]
sources: []
updated: 2026-06-17
status: settled
---
# Site Metadata (Park Identity)
Optional, admin-set identity/metadata for the park itself, beyond the operational
`capacity` knob. Feeds the ticket/receipt header and admin display. All fields are
**optional** — the lot runs fine with none set (the ticket falls back to a generic
`PARKING` header).
## Where it lives
A single-row extension of the existing **`site_config`** table (`id` always 1) — the
established home for site-wide knobs ([[capacity-occupancy]]). **Not** a new table:
park identity is one-per-site, same cardinality as capacity, so it shares the row.
| Column | Purpose |
| --- | --- |
| `park_name` | Display name on the ticket header / UI (e.g. "Acme Parking"). |
| `operator_name` | Legal entity operating the lot — for receipts; may differ from the display name. |
| `nius` | **NIUS** — Albanian tax/identification number, printed on the receipt when set (commonly required). |
| `address` | Free-text postal address (multi-line allowed; printed line-by-line). |
| `phone` | Contact phone — also the ticket "Keni humbur biletën?" footer. |
| `email` | Contact email (stored; not yet printed). |
All are **nullable `text`**. Added in migration `0001` (additive `ADD COLUMN`, no
data loss). A **metadata change is not a schema change for the ticket id**, but
adding these *fields* IS a schema change — done via a Drizzle migration.
> **Field history.** The first cut (2026-06-17) had `vat_number` + `registration_number`.
> Renamed/trimmed the same day to a single `nius` column (Albanian deployments call the
> tax id NIUS; registration number dropped as unused). Migration `0001` was regenerated
> in place — it had not shipped beyond the dev DB, so there is no migration debt.
## Read / write path
- **API**: `GET /api/site-config` returns capacity + every metadata field (null when
unset). `PUT /api/site-config` (**admin only**) accepts a **partial** body — only the
fields present are updated; blank string → null (clears). `apps/server/src/routes/site.ts`.
- **UI**: `apps/web/src/SiteSettings.tsx` — admin edits capacity + the metadata fields
in one form (`saveSiteConfig`).
- **API client**: `SiteConfig` type + `fetchSiteConfig` / `saveSiteConfig` in `apps/web/src/api.ts`.
## On the ticket
`renderTicket()` ([[rongta-printer]]) prints a header from a `TicketHeader` (the metadata):
park name large (or `PARKING` if unset), then operator / `NIUS: <n>` / address lines
that are present; a `Keni humbur biletën? <phone>` footer if a phone is set. The entry
flow (`#ticketHeader()` in `apps/server/src/entry-flow.ts`) reads `site_config` per print.
See [[ticket-encoding]].
## Localisation (Albanian)
The ticket prints in **Albanian** for now. Strings are centralised in a `STR` table in
[[rongta-printer]] (`Printuar më:`, `Keni humbur biletën?`, `NIUS:`) so a real i18n layer
(per-locale tables + a `t()` helper, covering the web UI too) can replace them later
without touching the render functions — that broader site translation is the next step.
**Codepage (resolved 2026-06-17).** Albanian text needs `ë`/`ç`, which ASCII can't carry.
The driver now selects **CP852 (Latin-2)** via `ESC t 18` in each print preamble and
`line()` encodes text to CP852 (with an ASCII transliteration fallback for anything
unmapped, and `?` as a last resort — never a wrong glyph). Verified at byte level: `ë` →
`0x89` in "Printuar më" / "biletën" / a sample address.
## Open
- **Receipt vs entry ticket** — the same header is used for the entry ticket today;
a paid receipt may want more (fee, tariff version, paid-at). Design with [[tariff]].
- **Email** is stored but not yet printed (no use decided).
- **Full i18n** — only the ticket is Albanian so far; the web UI is still English. A
proper locale system (and admin language choice) is the broader task this seeds.
- **CP852 coverage** — the map covers the common Albanian/Latin-2 letters; extend if a
park name/address uses a glyph outside it (currently transliterated to ASCII).
+33 -5
View File
@@ -21,14 +21,35 @@ must have:
- **Opaque + unguessable** — a random id (not a sequential count an attacker could iterate to claim - **Opaque + unguessable** — a random id (not a sequential count an attacker could iterate to claim
someone else's cheaper session). Sequential **physical** stock numbering is a separate someone else's cheaper session). Sequential **physical** stock numbering is a separate
reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key. reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key.
- **All-numeric** (as-built 2026-06-17) — so ANY legacy 1D barcode scanner reads it and an operator
can hand-key it. Random (not sequential), so "all-numeric" does not weaken the unguessable
property. Format: **13 digits = 12 cryptographically-random digits + 1 Luhn check digit**
(10^12 space → negligible collisions at lot scale; the Luhn digit lets manual entry reject a typo
rather than fail as "session not found"). `newTicketId()` in `apps/server/src/entry-flow.ts`;
validate with `validateTicketCode()` (gate MANUAL entry only — a scanned/looked-up id already in
the ledger is authoritative regardless of format).
- **Format is a property of minting, not the schema** — `identity` / `sessions.id` are free-form
`text`, so changing the id format is a code change with **no migration**. Legacy `T-<uuid>` ids
(pre-2026-06-17) remain valid keys and coexist with numeric ones.
- **Single logical session** — scanning it at the pay station finds the open session; after payment - **Single logical session** — scanning it at the pay station finds the open session; after payment
it's the proof-of-paid the exit checks. it's the proof-of-paid the exit checks.
## Encoding: QR (preferred) — printed by the booth dispenser ## Encoding: Code128 numeric barcode — printed by the booth dispenser
- The [[rongta-printer]] prints the ticket id as a **2D barcode (QR)** plus human-readable text and - The [[rongta-printer]] prints the ticket id as a **1D Code128 barcode** (the all-numeric code),
entry time. QR over 1D barcode: denser, tolerant of crumpling/partial reads, easy for a cheap with the **same code in large human-readable digits below it**, then the entry time. Code128 over
camera/imager to read. QR for the primary symbology because the booth's reader hardware is unknown and a legacy 1D laser
scanner is the lowest common denominator — and the printed digits mean total reader failure still
leaves a hand-keyable code. A **QR for phone/imager scanning may be added later behind an admin
toggle** (deferred — see Open).
> **As-built (2026-06-17).** `renderTicket()` in [[rongta-printer]]
> (`packages/devices/src/drivers/printer-rongta.ts`) emits the Code128 via ESC/POS `GS k` (code set
> B) — **rendered by the printer firmware**, so there is no image-rendering step and no new
> dependency (keeps the MIT/Apache/BSD constraint). Resilience rationale: the booth's reader is
> uncertain, so the id is carried in two independently-readable forms (1D barcode / printed digits).
> The "operator scans with a phone" path reuses the
> existing dispatch flow ([[entry-exit-readers]]) and is tracked separately (not yet built).
- **Scan points** (both host-side reads — [[entry-exit-readers]]): - **Scan points** (both host-side reads — [[entry-exit-readers]]):
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes - **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
payment ([[tariff]], pay-on-foot) → appends `payment`. payment ([[tariff]], pay-on-foot) → appends `payment`.
@@ -51,7 +72,14 @@ isn't captured or is low-confidence (recognition is advisory — [[opencv-anpr-s
## Open ## Open
- QR symbology/error-correction level + what else prints (site name, tariff summary, help number). - Primary symbology **decided**: Code128 set B over the all-numeric id (as-built above). Still open:
what *else* prints (site name, tariff summary, help number).
- **Optional QR (deferred)** — an admin toggle to ALSO print a QR for phone/imager users. The
`code128()`/`qrCode()` ESC/POS helpers were prototyped 2026-06-16; QR was dropped 2026-06-17 in
favor of "1D barcode + hand-keyable numeric code" because the booth's reader hardware is unknown.
Revisit when mobile scanning is wanted.
- **Phone-scan fallback** (operator scans a ticket with a phone when a reader is down) — designed
but not built: an authenticated route feeding the same dispatcher + a minimal mobile scan UI.
- Scanner hardware (imager model; same unit at pay station and exit?). - Scanner hardware (imager model; same unit at pay station and exit?).
- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary - Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary
amount). amount).
+2 -1
View File
@@ -7,7 +7,7 @@ updated: 2026-06-14
# Index # Index
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest. Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
Counts: 3 sources · 19 entities · 24 concepts · 5 decision records. Counts: 3 sources · 19 entities · 25 concepts · 5 decision records.
## Overview & navigation ## Overview & navigation
- [[overview]] — the top-level synthesis and entry point. - [[overview]] — the top-level synthesis and entry point.
@@ -80,6 +80,7 @@ Counts: 3 sources · 19 entities · 24 concepts · 5 decision records.
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window. - [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
- [[shift]] — manned-only accountability period; explicit Start/End (not time-based); End → signed + printed Z-report (cash + POS). - [[shift]] — manned-only accountability period; explicit Start/End (not time-based); End → signed + printed Z-report (cash + POS).
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked. - [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred. - [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
- [[validation-discounts]] — merchant validates a ticket → signed discount event applied at fee time. - [[validation-discounts]] — merchant validates a ticket → signed discount event applied at fee time.
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log. - [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
+16
View File
@@ -744,3 +744,19 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
builds; no test suite in-repo. builds; no test suite in-repo.
- Residual: incidental `lane_devices` / "per-lane" mentions remain in some secondary wiki pages - Residual: incidental `lane_devices` / "per-lane" mentions remain in some secondary wiki pages
(device-events, device-input-flow, ticket-encoding, etc.) — flagged for a later lint pass. (device-events, device-input-flow, ticket-encoding, etc.) — flagged for a later lint pass.
## [2026-06-16] build | Scannable ticket — QR + Code128 on the Rongta dispenser
`renderTicket()` now emits the ticket id as a printer-generated QR (ESC/POS `GS ( k`, model 2, ECC M) AND a Code128 1D barcode (`GS k`, set B), plus the human-readable id. Three independently-readable forms so a dead reader is recoverable (imager / 1D laser / phone camera / hand-keyed). No image rendering, no new dependency. Phone-scan operator fallback deferred (reuses the existing dispatch path). See [[ticket-encoding]].
## [2026-06-17] build | Ticket id -> all-numeric 13-digit (12 random + Luhn); barcode-only ticket
Replaced the `T-<uuid>` ticket id with a 13-digit all-numeric code (12 crypto-random digits + Luhn check) in `newTicketId()` so ANY legacy 1D barcode scanner reads it and the operator can hand-key it on total reader failure. Random keeps the unguessable anti-fraud property; Luhn lets manual entry reject typos (`validateTicketCode()`). `renderTicket()` now prints a centered Code128 barcode, the code in large digits below, then the issue time — QR dropped (may return as an admin toggle for mobile users). NOT a schema change: `identity`/`sessions.id` are free-form text; legacy ids coexist. See [[ticket-encoding]].
## [2026-06-17] build | Park metadata in site_config + ticket header
Extended `site_config` (single-row) with optional park identity: `park_name`, `operator_name`, `vat_number`, `registration_number`, `address`, `phone`, `email` — all nullable text (Drizzle migration 0001, additive). `GET`/`PUT /api/site-config` now read/write the full config (PUT is a partial patch; admin only); `SiteSettings.tsx` gained the fields. `renderTicket()` prints a header (park name large or "PARKING", then operator/VAT/Reg/address, plus a "Lost ticket? <phone>" footer) sourced from `site_config` via `EntryFlow.#ticketHeader()`. Open: non-ASCII (accent) chars need a printer codepage. See [[site-metadata]], [[ticket-encoding]].
## [2026-06-17] build | Ticket in Albanian; VAT->NIUS, drop registration; CP852 codepage
Ticket header now prints in Albanian and uses NIUS instead of VAT. Renamed `site_config.vat_number` -> `nius` and DROPPED `registration_number` (regenerated migration 0001 in place; only the dev DB had it, so no migration debt; dev DB reset + re-migrated). `renderTicket()`: NIUS line (only if set), "Printuar më:" before the timestamp, "Keni humbur biletën? <phone>" footer; strings centralised in a `STR` table for future i18n. Added CP852 (Latin-2) codepage support (`ESC t 18` + a Unicode->CP852 `line()` encoder with ASCII fallback) so `ë`/`ç` render. Touched: schema, migration, routes/site.ts, web api.ts + SiteSettings.tsx, devices interfaces + printer-rongta.ts, entry-flow.ts. Byte-verified ë->0x89. See [[site-metadata]], [[ticket-encoding]].