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
+26 -3
View File
@@ -81,15 +81,24 @@ export function hashEvent(canonical: string): string {
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 {
readonly #db: Db;
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. */
#tail: Promise<unknown> = Promise.resolve();
constructor(db: Db, signer: Signer) {
constructor(db: Db, signer: Signer, resolveVerifier?: SignerResolver) {
this.#db = db;
this.#signer = signer;
this.#resolveVerifier = resolveVerifier ?? (() => signer);
}
/** 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
* first detected break, or { ok: true }. This is what reconciliation and an
* 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 } {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
@@ -159,8 +174,16 @@ export class EventLog {
if ((row.prevHash ?? null) !== prevHash) {
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);
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)" };
}
prevHash = hashEvent(canonical);