diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index 1f0c79e..9b88055 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -219,18 +219,24 @@ export class EntryFlow { /** * Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). * - * Format: 13 digits = 12 cryptographically-random digits + 1 trailing Luhn check + * Format: 11 digits = 10 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". + * — the anti-fraud property the wiki settles. + * + * Length is driven by GUESS-RESISTANCE, not volume: with 10^10 valid ids and the + * Luhn digit rejecting 9/10 of malformed guesses, a blind attempt at a currently-OPEN + * ticket lands at ~1-in-10^7 even with thousands parked — comfortably safe — while + * being two digits (≈2 barcode modules) narrower than the old 13. Collisions are + * negligible at lot scale; the unique constraints on ledger_events.index / sessions.id + * are the backstop. (Older 13-digit ids stay valid — the id is opaque, length-agnostic.) + * The Luhn digit lets a manual entry reject a typo (validateTicketCode) instead of + * failing as "session not found". */ function newTicketId(): string { let body = ""; - for (let i = 0; i < 12; i += 1) body += String(randomInt(10)); + for (let i = 0; i < 10; i += 1) body += String(randomInt(10)); return body + luhnCheckDigit(body); } @@ -260,7 +266,11 @@ function luhnCheckDigit(digits: string): string { * 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]; + // Length-agnostic: an all-digit code whose last digit is the Luhn check of the rest. + // Accepts the current 11-digit ids AND any legacy 13-digit ones still in circulation + // (the id is opaque; only the digits+checksum shape matters). The 10..14 bound keeps + // a stray short/long string from being mistaken for a ticket. See ticket-encoding.md. + if (!/^\d{10,14}$/.test(code)) return false; + const body = code.slice(0, -1); + return luhnCheckDigit(body) === code[code.length - 1]; } diff --git a/packages/devices/src/drivers/printer-escpos.ts b/packages/devices/src/drivers/printer-escpos.ts index 7661300..af76d46 100644 --- a/packages/devices/src/drivers/printer-escpos.ts +++ b/packages/devices/src/drivers/printer-escpos.ts @@ -409,7 +409,15 @@ export function renderReceipt(data: ReceiptData): Buffer { return Buffer.concat(parts); } -/** Open a TCP socket, write the bytes, wait for flush, then close. */ +/** Open a TCP socket, write the bytes, and close GRACEFULLY so the printer reads the + * whole stream before the connection tears down. + * + * Why not write-then-destroy: a Socket.write() callback fires when the data reaches + * the local kernel buffer, NOT when the peer has read it. Calling destroy() at that + * point sends a TCP RST that can truncate the job in flight — the printer then has a + * desynced ESC/POS stream and prints raster garbage (solid black bars / banding). + * Instead we `end(payload)` (write + FIN) and wait for the socket to fully close, + * which only happens after the peer has drained our bytes and the FIN is acked. */ export function sendRaw( host: string, port: number, @@ -419,17 +427,37 @@ export function sendRaw( return new Promise((resolve, reject) => { const sock = new Socket(); let settled = false; - const done = (err?: Error) => { + // True once the payload + FIN have been handed off (flushed locally). After this, + // we've done our part; a slow/absent peer-FIN should NOT fail an already-sent job. + let written = false; + const fail = (err: Error) => { if (settled) return; settled = true; sock.destroy(); - err ? reject(err) : resolve(); + reject(err); + }; + const succeed = () => { + if (settled) return; + settled = true; + sock.destroy(); + resolve(); }; sock.setTimeout(timeoutMs); - sock.on("timeout", () => done(new Error("timeout"))); - sock.on("error", done); + // A timeout BEFORE the bytes are out is a real failure; one AFTER (some printers + // never send their FIN, holding the socket open) means the job was delivered — + // succeed rather than reject a ticket that already printed. + sock.on("timeout", () => (written ? succeed() : fail(new Error("timeout")))); + sock.on("error", fail); + // `close` fires after the bytes are flushed AND the connection is fully torn down + // (our FIN sent, peer's FIN received) — the job has been delivered by then. + sock.on("close", (hadError) => (hadError ? undefined : succeed())); sock.connect(port, host, () => { - sock.write(payload, (err) => (err ? done(err) : done())); + // end() writes the payload then sends FIN — a graceful half-close that lets the + // printer finish reading before the socket closes. No abrupt destroy(). The + // write callback confirms the bytes left our buffer. + sock.end(payload, () => { + written = true; + }); }); }); }