feat(server): live booth WebSocket feed (/api/ws)

Add @fastify/websocket. EventLog fires an onAppended callback after each durable
append; device-events gains a ledger channel (emitLedger). /api/ws fans out
ledger + occupancy + printer-status to authenticated booth clients. Origin
allowlist (WS_ALLOWED_ORIGINS) replaces CSRF for the handshake (anti-CSWSH).

Note: server.ts also reflects later booth route wiring; the final HEAD builds.
This commit is contained in:
2026-06-18 11:00:22 +02:00
parent 58d8f06ba0
commit c2f06a5d2a
6 changed files with 162 additions and 4 deletions
+21 -2
View File
@@ -92,13 +92,23 @@ export class EventLog {
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
* callers that don't pass one (single-key chains, tests). */
readonly #resolveVerifier: SignerResolver;
/** Optional read-side notification, fired AFTER a row is durably inserted. Used
* to fan the event out to live booth clients (WS). It is best-effort and must
* NOT influence the append/sign/chain path — a throwing/absent sink is ignored. */
readonly #onAppended?: (row: LedgerEventRow) => void;
/** Serialize appends: each waits for the previous to finish. */
#tail: Promise<unknown> = Promise.resolve();
constructor(db: Db, signer: Signer, resolveVerifier?: SignerResolver) {
constructor(
db: Db,
signer: Signer,
resolveVerifier?: SignerResolver,
onAppended?: (row: LedgerEventRow) => void,
) {
this.#db = db;
this.#signer = signer;
this.#resolveVerifier = resolveVerifier ?? (() => signer);
this.#onAppended = onAppended;
}
/** Append one event to the chain. Returns the persisted row. Serialized. */
@@ -106,7 +116,16 @@ export class EventLog {
const run = this.#tail.then(() => this.#appendNow(input));
// Keep the chain going even if one append rejects (don't wedge the lock).
this.#tail = run.catch(() => undefined);
return run;
// Read-side notification, AFTER the row is durably written. Wrapped so a
// failing sink can never reject the append or break the chain lock above.
return run.then((row) => {
try {
this.#onAppended?.(row);
} catch {
// best-effort fan-out only — swallow.
}
return row;
});
}
#appendNow(input: AppendInput): LedgerEventRow {