Three StaffUser constants carried plaintext PINs (1234/2345/3456) in
auth_controller.dart. Every build shipped every till's credentials, readable
by anyone who unzipped the APK. Across 100 deployed devices that is one
credential, not a hundred.
- Schema v5 adds a staff table. Only a PBKDF2-HMAC-SHA256 hash and a per-user
random salt are stored; the PIN itself exists nowhere, including there.
12,000 iterations, tuned so one sign-in is imperceptible while working
through all 10,000 four-digit PINs against a stolen database takes ~15
minutes per account instead of milliseconds.
- Verification is constant-time. String == returns at the first differing
byte, and that timing leaks how much of a guess was right.
- StaffUser no longer has a pin field at all, so the credential cannot drift
back into memory, into widgets, or into a const declaration.
- Weak PINs are refused: under four digits, non-numeric, repeated digits, and
sequences. Two staff cannot share a PIN — the till identifies a cashier by
PIN alone, so a shared one would attribute bills to whichever row was
checked first.
- The last admin cannot be demoted or deactivated. A till with no admin cannot
be administered, including to appoint one, and recovering means editing the
database by hand.
- Staff are deactivated, never deleted, so bills already rung keep naming a
real person.
Seed accounts are now 4821/5093/6274 rather than 1234/2345/3456 — the weak-PIN
rule refuses the old ones, and a default the rule itself would reject is not a
defensible default. All three are flagged must-change-pin so they get a shop
trading on day one without becoming permanent.
Store details are now editable data, not compile-time constants. Name,
address, GSTIN and phone persist to the database and are read back rather than
falling through to the build's constants, which would silently undo a failed
save. GSTIN is format-validated including the state code — it prints on every
invoice as a legal requirement, so a typo is a compliance problem across
hundreds of bills before anyone notices.
Tests: 141 -> 160. Includes a test that reads every column of every staff row
and asserts no seed PIN appears anywhere in the database.
Migration test now asserts v5 and that an upgraded terminal comes up with the
staff table present but empty — seeding is the store's job on first open, so
an existing shop is never handed accounts it did not create.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers "which of my 100 tills are alive and healthy", and fixes three things
that were fine on one device and broken on a hundred.
Terminal identity (lib/data/local/terminal_identity.dart)
- Every device mints a UUID on first run, stored in its own database, plus a
short code (T4A9) derived from it. Renaming keeps the device id, so history
keeps pointing at the same physical till.
- Replaces the literal 'TERM-01', which was hardcoded in five places. The whole
fleet reported as one terminal: shift reports merged, MQTT topics collided,
and a second connection with the same client id evicts the first from the
broker — so two tills would have knocked each other offline in a loop.
Invoice numbers now carry the terminal code
- INV-2608-T4A9-00042. The sequence counter lives in each till's own database
and starts at 1, so without this every terminal in the fleet minted
INV-2608-00001 for its first sale of the month. The order UUID kept the data
distinct; the number a customer quotes on a receipt was not.
SQLite pragmas
- WAL, so the product grid refreshing does not block the sale being written,
and the file is never left mid-rewrite by a power cut.
- busy_timeout 5s, so a contended lock waits instead of throwing "database is
locked" — which at checkout is a failed sale with a customer standing there.
- synchronous NORMAL, the right trade under WAL for a till.
Fleet presence (lib/data/sync/presence_reporter.dart)
- Retained status record on connect and once a minute: device id, code, name,
app version, pending bill count, last upload, catalogue revision, sync halt
state. Retained so a dashboard connecting at noon gets all 100 terminals
immediately rather than a blank board.
- The Last Will already said "reachable". A till can be connected and still be
holding 200 unsent bills or running last month's prices; only pending_bills
and catalogue_revision say so.
NATS
- The MQTT gateway maps / to . so the existing transport works unchanged.
SyncConfig.asNatsSubject() exposes the translation, and the contract doc
gives the JetStream subjects (pos.*.*.order, pos.*.*.status) plus the two
server-side requirements: a file-backed stream, and the ack published by the
consumer after commit rather than by the ingest handler.
Tests: 129 -> 140. New coverage for identity minting and stability, per-device
invoice uniqueness, topic and client-id separation, NATS subject mapping, and
the two pragmas. Suite run three times clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the orders table into a queue that empties itself. Bills were only
uploaded when a cashier pressed Sync at end of day; a till that was never
pressed held a day's takings indefinitely.
Drain engine (lib/data/sync/sync_engine.dart)
- Triggers on sale committed, network regained, 5-minute poll, head-office
request, and the manual button.
- Single flight: a busy till firing a trigger per sale would otherwise have
several passes reading the same pending rows and send every bill twice.
A trigger arriving mid-drain is queued and replayed, so nothing is dropped.
- Exponential backoff with +/-20% jitter to a 5-minute ceiling. The jitter
matters: a store's terminals all fail at the same instant when the line
drops, and would retry in lockstep without it.
- Halts rather than loops on a failure retrying cannot fix (bad credential,
refused batch). Pressing Sync clears the halt.
Transports (lib/data/remote/)
- OrderTransport interface; MQTT, HTTP and simulated implementations. The
repository does not know which is in use.
- MQTT: QoS 1 uplink, application-level ACK correlated by batch_id on a return
topic, retained Last Will for terminal-offline detection, downlink for
catalogue pushes and remote sync requests.
- A broker PUBACK is never treated as acceptance. It means the broker holds
the bytes, not that the ledger took the sale. Only ids the back office names
are marked synced; silence leaves a bill pending.
- HTTP carries a stable idempotency key across retries of the same bills.
Retention
- Accepted bills are kept 7 days instead of deleted, so a batch the back
office later loses can be re-sent in full. Purged after that; archived
totals stay forever.
- forBusinessDate now reads pending rows only. A retained bill exists in both
the orders table and day_archive, and summing both would overstate the day.
Fixes found while building this
- SyncEngine._refreshPending wrote state.copyWith(pending: await ...). Dart
evaluates the receiver before the awaited argument, so a connectivity drop
during the wait was silently overwritten by the stale snapshot. Caught by
the first run of the new engine tests.
- PrinterSettingsController wrote state after four awaits with no mounted
check, throwing "used after dispose" when Settings was left mid-load. This
was pre-existing and reached the cashier as a red screen.
Also
- Header pill now reports real sync state: LIVE / n QUEUED / SYNCING /
SYNC HALTED, with an explanation of where the bills are.
- Settings shows the route, last upload, next retry and retention window.
- docs/sync-contract.md states what the back office must implement, including
the idempotency requirement that at-least-once delivery makes mandatory.
Tests: 90 -> 129 passing. New coverage for backoff shape and jitter band,
single flight, halting, ACK correlation and partial acceptance, at-least-once
duplicate handling, retention and purge, and no double-counting after a sync.
Suite run six times clean.
Not addressed: bills already synced by an older build went up overstated and
still need server-side reconciliation. Broker credentials have no Settings
editor yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>