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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user