MATTES.DEV

← Blog

· 6 min read

Coffee Time: a passkey PWA for the shared office coffee tab

The tally sheet on the office fridge, moved onto the phone that is in everyone's hand anyway: Coffee Time is an installable PWA for a shared coffee tab. It runs on plain PHP 8.2 without a framework, needs no Node runtime in production, stores its data in SQLite by default and is published under the MIT licence. Its entire dataset is one staff roster with an amount owed next to each name. That roster lives on a server nobody looks after full time, and it should not be readable there; most of the decisions below follow from this constraint.

Names the server cannot decrypt

Account names are encrypted with the administrator's RSA public key before they are stored; the server holds neither the private key nor any decryption code. The admin screen imports a selected PKCS#8 key with Web Crypto as a non-extractable key and decrypts names in memory, including for the CSV export. No request ever carries a plaintext roster. An offline CLI does the same against a SQLite file for accounting purposes.

Duplicate registrations still have to be rejected, and that means recognising a name the server cannot read. users.name_hash is HMAC-SHA256(namePepper, normalizedName). Names come from a small, guessable population, which makes this a deterministic fingerprint rather than a one-way function: anyone holding both the fingerprints and the pepper can recover the roster by hashing candidates from a staff list. The two therefore should not sit in the same place. Configured in config.php, the pepper stays out of database dumps. The setup wizard is the one exception — it exists precisely so that no file has to be edited by hand, and it has nowhere else to write the value. A backup taken from a wizard install carries the key to its own fingerprints. The RSA-sealed names are unaffected either way, and a missing or placeholder pepper is refused at construction instead of silently producing fingerprints anyone can recompute.

Neither the public key nor the pepper can be rotated. A new RSA key cannot decrypt what the old one sealed; a new pepper invalidates every stored fingerprint and with it duplicate detection. The application therefore refuses to change either value after initial setup. Rotating in practice means exporting the decrypted roster with the old private key and starting a fresh database.

Passkeys without usernames

Registration and login use WebAuthn with discoverable credentials and user verification. The login ceremony sends an empty allow-list and the authenticator selects the account: no username field, no email address and, for regular accounts, no password anywhere in the schema. The user row and its first passkey are written in a single transaction. Split across two, a failure in between would leave a name reserved by a row that no credential can ever sign in as; for the very first account it would also strand the admin flag on an unusable row, with the setup wizard already closed.

A signed-in device can issue a short-lived code (15 minutes) to add a passkey on a second device; an administrator can issue a longer one (60 minutes) for someone who lost every device they had. Codes are single-use, and only their hash is stored. Completing an admin-issued code also ends the account's other sessions — that step is what actually revokes a lost device. A self-issued code deliberately leaves the other sessions alone. Sessions renew on use but carry an absolute ceiling of 180 days on top of the 30-day idle window; a stolen token cannot stay valid indefinitely just by being used.

There is one exception to the passkey-only rule. Managed workstations sometimes block authenticators outright, and that would lock out exactly the person who looks after the tab. An administrator can therefore set a password on their own account: admin-only, opt-in, removable again, at least twelve characters, and re-checked at the login endpoint, not only where it is set. With no username in the schema, the account is found by the same keyed HMAC that duplicate detection uses. An unknown name, an account without a password and a wrong password all answer a bare 401; the first two compare against a dummy hash and do not answer measurably faster. Two rate-limit counters apply, one per caller and one per account, which keeps guessing from a pool of addresses as expensive as guessing from a single one. Passwords are pre-hashed with SHA-256 before password_hash(), because PASSWORD_DEFAULT is still bcrypt on the shared hosts this application targets and bcrypt truncates at 72 bytes.

Frozen prices and a bounded undo

Each booking reads the current price once and freezes it in two places: into the event row and into the account's running tab. A later price change applies only to bookings made after it; the balance is the tab minus recorded payments, never a figure recomputed from the live price. Undo reverses the frozen price of the specific event it removes.

Undo is bounded to five minutes by default, configurable within 30 seconds to 24 hours. It exists for the mis-tap and the double tap. Without a bound it is also a way to walk one's own counter back to zero one press at a time — which is what it was being used for. The window is evaluated in the same transaction that removes the event; simultaneous presses from two devices cannot take back more bookings than exist. The remaining time is reported as a relative value because a device with a skewed clock would misread an absolute timestamp. A request that arrives after the window closes is answered 409 undo_expired.

The day boundary for streaks, the 28-day history chart and the month-end reminder is a configurable offset in minutes east of UTC; a coffee at eleven at night counts towards the day it was actually had. The offset is fixed and does not follow daylight saving time. It is a configuration value, not a timezone database.

Offline bookings on a shared device

The service worker caches only the static app shell; API calls always go to the network. A coffee booked without a connection is queued in localStorage with a client-generated event ID and retried under that same ID until it succeeds. The server keeps a unique index on the ID and treats a repeat as a no-op that returns the current state. That is what makes retries under flaky connectivity safe. The index is unique per account, not globally: the ID comes from the client, and a global key would let one account's ID collide with another's and swallow the second booking behind a success response. The existence check and the insert are not one atomic step, and a duplicate that reaches the index anyway is caught and reported as the idempotent success it represents.

Queue entries also record the account they were made under, and the queue is cleared on sign-out. The device this is built for is a tablet in a kitchen that several people use. Without both measures, a coffee booked offline by one person and flushed after somebody else had signed in would be charged to that next account.

Build and deployment

The frontend is written in TypeScript under frontend/ and compiled to the plain scripts the server ships. The production host has no Node runtime, and the compiled output is committed like any other static asset. CI runs the same build and diffs it against the commit; a stale build fails the pipeline before it can reach production. SQLite is the default and MySQL/MariaDB an option. Migration steps are listed per driver, additive and idempotent; the MySQL path takes an advisory lock because its DDL cannot run inside a transaction and concurrent cold starts would otherwise race through the steps.

Deployment is composer install --no-dev and a web root pointed at public/. Every green build on main also publishes a multi-architecture image to the GitHub Container Registry, with a signed build provenance attestation. The footer and GET /api/version report the commit that is actually running, read from the server rather than from the cached shell. Source, documented architecture decisions and the changelog are on GitHub.