A realtime reverse auction tool — suppliers bid down on contract terms, the lowest bid leads, and a blind "Last Call" window decides it. It also runs forward (highest bid wins) if you flip one setting.
This is a ground-up rewrite of auction-machine, a Python 2.7 /
Google App Engine / AngularJS app that can no longer be run. The rewrite keeps
what was genuinely good about the original — an event-sourced timeline with
bidirectional validation — and replaces everything that had aged out.
It runs as a static site against Firestore, with no backend of our own: no server to operate, no database to back up, and Firestore's own realtime listeners in place of the polling the original did. That choice has one significant consequence, spelled out under Trust model below — read it before running a real auction.
npm install && npm run devOpen the printed URL. / is the admin panel, open only to the two addresses
in src/shared/admins.ts; the first time, an admin sets
their own password and clicks one verification email. From the panel an admin
creates an auction and, per auction, creates each supplier's and client's
email/password account — a password is generated and shown once, and the admin
relays it (with the board link) out of band. The app sends no email itself.
To drive both sides on one machine, run against the emulators
(npm run dev:emulator). Verification/reset mail is not sent there; grab the
link from the Auth emulator at
http://127.0.0.1:9099/emulator/v1/projects/<project-id>/oobCodes.
Then: Add contract term → Start auction → place bids as the suppliers → watch the clock cross into Extended Time and Last Call → Release results to everyone (which reveals the Last Call bids on every screen) → Download results (which only saves a CSV here).
npm run dev talks to the live Firebase project. To work entirely offline,
start the emulator in one terminal and point the client at it in another:
npm run emulatornpm run dev:emulator| Command | What it does |
|---|---|
npm run dev |
Vite dev server on :5173, against the live Firebase project |
npm run dev:emulator |
The same, against the local Auth + Firestore emulators |
npm run emulator |
Firebase Auth (:9099) + Firestore (:8080) emulators, UI on :4000 |
npm test |
Vitest: domain rules, blind bidding, validation. No services needed |
npm run test:rules |
Firestore security rules, against a throwaway emulator |
npm run test:e2e |
Playwright: one whole auction in three real browsers, against throwaway emulators (first run: npx playwright install chromium) |
npm run build |
Bundles the client to dist/client/ |
npm run deploy |
Builds, then deploys hosting + Firestore rules |
All four suites also run on every push and pull request in GitHub Actions
(.github/workflows/ci.yml), against throwaway
emulators there too.
The emulator scripts need a Java runtime; they put Homebrew's openjdk on PATH
themselves, so brew install openjdk is the only setup.
Firebase config lives in src/client/firebase.ts (a web
API key is not a secret — access is governed entirely by
firestore.rules). The project needs the
Email/Password provider enabled under Authentication → Sign-in method, and
whatever domain you serve from listed under Authentication → Settings →
Authorized domains. Admin verification and password-reset mail goes out through
Firebase Auth, so configure a custom SMTP sender under Authentication →
Templates if the built-in one is unreliable for your recipients — but ordinary
participant sign-in and provisioning send no mail at all.
The admin addresses are hard-coded in both src/shared/admins.ts
and firestore.rules (isAdmin()); change one, change the other.
State is never stored — it is derived. Every change is an event appended to
an immutable log, and the auction's state is a pure fold over that log
(src/shared/aggregate.ts). Replaying the log always
reproduces the same board, including the clock.
Firestore holds one document per event under auctions/{id}/events/{seq}, plus a
small mirror on the auction document (nextSeq, startedAt, auctionLength,
showResults) that the security rules need to reason about in isolation. Every
browser subscribes to the log and folds it locally, so all participants converge
on the same board without anything to broadcast.
Every event passes through validation in both directions
(src/shared/validation.ts):
- inbound — may this event be appended at all? (Is the auction running? Does this bid beat the leader? Is this person allowed to do this?)
- outbound — may this particular viewer see it, and in what form?
Appends are transactional: submit() reads nextSeq, writes the event at that
seq, and bumps the counter in one commit, so two simultaneous bids can never
share a sequence number or be folded in different orders on different screens.
There are three roles — owner (the auctioneer), bidder (a supplier), and
viewer (the client, watching) — and they are meant to look different. Each
screen says which one it is in the top-right corner.
| Sees rival firms' names | Sees Last Call bids | Can do | |
|---|---|---|---|
| Auctioneer | yes | yes, live | everything: run the clock, bid on a supplier's behalf, withdraw any bid, release results |
| Client | yes | only once results are released | nothing at all — no bidding, no withdrawing, no controls |
| Supplier | never — a rival is a colour | only their own | bid, and withdraw a bid of their own |
A supplier's own cards carry their own firm name, so they can find themselves on the board; every other card is bare colour and a price. In Last Call a supplier's board narrows to their own bids alone, so the final window is bid blind from both directions.
Withdrawal is deliberately available to both ends: suppliers mistype bids, and sometimes enter a wrong one on purpose to spoil an auction. A supplier can take back only their own bid; the auctioneer can take back anyone's, for the mistakes that go unnoticed long enough to distort the board.
The UI renames each role rather than showing internal vocabulary.
A participant's identity is their email address. Two admins (see
src/shared/admins.ts) run every auction; they sign in
with email/password and are the owner of every auction, resolved straight from
their verified address. Everyone else's account is created by an admin from
the admin panel, with a generated password the admin relays out of band — which
also writes a seat at auctions/{id}/seats/{email} naming the slot and role.
A signed-in client's token carries the address, so firestore.rules resolves
"who is this" by reading that one document — no query, and no secret in a URL.
email_verified is required of an admin (an admin address is one anyone
could register, so the inbox check is what makes it trustworthy) but not of
a participant: a participant account can only be created by an admin, who is
already the authority on which address maps to which seat.
Consequences worth stating plainly, because they are the point:
- Nothing in a link grants access. The board URL is not a credential; a forwarded one signs the recipient in as themselves, which gets them nowhere.
- A participant can sign in again, on any device, as often as they like. An admin can also issue a new password from the panel at any time.
- Revoking is immediate. Deleting the seat cuts access off on whatever device the person is already using, because every rule resolves through it.
The admin panel is the operational view of this: every participant, their address, whether they have actually signed in yet, the password on file, and buttons to re-credential, correct a mistyped address, or remove access. The board's own People panel is read-only.
The panel's auction list says where each auction stands (not started, running, paused, ended, results released) and is where an auction is put away. Archive takes it off the list and changes nothing else — the board and its links keep working, and it can be restored. Delete is only offered on an archived auction, asks for the name typed back, and then removes every bid, participant and result for good; the board link stops working on every screen. The two steps are deliberate: the security rules refuse to delete any part of an auction that is not archived, so a live log cannot be lost to a stray click (see Trust model).
(Earlier designs used a bearer invite link, then a passwordless email-link sign-in. The link bound a slot to one browser and could not tell a supplier from whoever they forwarded it to; the email-link flow is capped by Firebase at five sends a day on the free plan, far too few to seat a real auction. Both were replaced rather than patched.)
A supplier never learns who the other suppliers are — not before the auction, not during it, and not after results are released. What they take away is market pricing: where their number sits against the field. Whose number it sits against is not theirs to know, because a supplier who can attach a firm to a price walks in with a view of what that firm charges.
So each supplier is assigned a colour when the auctioneer signs them up, and on
a rival's screen that colour is the whole of their identity — no name, no
label, nothing to attach a firm to. (They also carry a nondescript label,
Supplier A, Supplier B, … in signup order, but it never appears on a
bidder-facing screen; it exists so the CSV downloads have a stable anonymous
handle the auctioneer can hand to the client.)
This is structural, not a render-time filter:
validateInboundstrips the name and email off anaddUserevent and puts the label and colour on instead, so real names are never in the event log. The log is world-readable to anyone signed in; there is simply nothing in it to read.auctions/{id}/users/{publicKey}— readable by any signed-in client, because a viewer resolves their own slot there — carries only role, label and colour.- Names and emails live in
auctions/{id}/identities/{publicKey}, andfirestore.rulesscopes that collection to the auctioneer, observers on the buying side, and the participant themselves. A supplier reading Firestore by hand gets exactly as far as one using the UI. - The seat table is scoped the same way.
{address → supplier}is precisely the mapping the auction exists to hide, so a supplier may read only their own seat and only the auctioneer may enumerate them.
The auctioneer's screen is the only one where a price and a firm appear
together; the client's shows the same. Both CSV exports carry the anonymous
label alongside the name so the two views can be reconciled afterwards.
npm run test:rules asserts each of the reads above, in both directions.
| Setting | Default | Meaning |
|---|---|---|
bidDirection |
reverse |
reverse = lowest bid leads; forward = highest leads |
auctionLengthSec |
300 | The main bidding clock. Last Call runs after it, on top |
extendedTimeThresholdSec |
90 | A mark on that clock: a leading bid under it pushes the clock back out to it |
lastCallSec |
60 | The final blind window |
lastCallBidders |
2 | How many leaders per term may bid in that window |
minBidStep |
0 | How far a bid must beat the leader by |
Every number is read off the clock on screen. 300 + 90 + 60 means five minutes of bidding, the Extended Time mark at 1:30 showing, then a minute of Last Call — 6:00 end to end. (These used to be measured against the whole run including Last Call, so a threshold of 90 put the mark at 0:30 and the setting disagreed with the clock beside it.) The auctioneer can change all of them before starting; once the auction is running they are frozen, by the rules as well as the UI. Up to 12 suppliers and 5 contract terms.
Terms are ranked independently but share one clock. One board, one countdown, a separate leader per term.
Extended Time. A new leading bid with 0:40 showing against a 90s threshold pushes the clock back out to 1:30. The recomputed length is written onto the bid event itself, so replaying the log reproduces the clock exactly rather than depending on when the replay happens.
Last Call. In the final window, only each term's leading suppliers may bid (2 by default; the auctioneer can set any number, and 0 leaves it open to everyone). It is blind either way: a supplier's board narrows to their own bids for the duration, the client's board freezes at the standings that stood when the window opened, and only the auctioneer watches it live. When the auctioneer releases results, everything becomes visible at once.
Inbound bids are validated against what the bidder can actually see, not the true leader. That matters: if improvement were enforced against a hidden rival bid, a rejection would tell the bidder that a better bid exists. Validating against the visible best closes that channel. (There is a test for it.) The accepted cost is that two Last Call bids can briefly coexist out of true order until results are released.
Bid comparison is quantised. minBidStep is compared against a delta rounded
to 10 decimal places, because raw IEEE-754 subtraction of hand-typed decimals
lands just under a legal step about as often as just over it — 0.0701 - 0.07 is
0.00009999999999998899, which would refuse a bid that is exactly one step
better. See src/shared/rules.ts.
A few things exist purely because a real auction is five tense minutes with twelve people on twelve machines:
- One clock. Every browser writes a heartbeat doc stamped with the
server's time, reads it back, and keeps the offset from its own clock
(
Connection.now). A supplier whose laptop is half a minute out still sees Last Call open when everyone else does, and their bids carry the corrected time. The estimate is re-taken every 30 seconds and kept NTP-style — the reading over the shortest round trip wins — unless a new reading disagrees by more than both error bars, which means the machine's clock actually moved. - Who is here. The same heartbeats tell the auctioneer who is connected right now, not just who has ever signed in: the People button reads "3 of 5 online" and each roster row says online, left N minutes ago, or not signed in yet. The rules let only an auctioneer list them.
- Outbid. When a supplier loses the lead on a term, the term says so, pulses, a toast names it, and the tab title gains "Outbid ·" so it shows from another tab. Nothing in the log says "you were outbid" — it is a comparison between two paintings of the board — so it is spotted at render time. Inside blind Last Call a rival's bid never reaches that screen, so nothing fires there.
- The number to beat sits in the bid box as its placeholder. It is the visible best, the same one the bid will be validated against, so in Last Call it can only ever name a price the supplier had already seen.
- Fat fingers. A bid more than half again past the current best (0.0537 typed as 0.0057), or a bid of zero, gets one plain question before it lands. Withdrawal exists for the ones that get through, but a wrong bid that sits on the board for a minute moves everyone else.
- Pause. A dropped call, a dispute, a supplier who cannot get in: the
auctioneer can stop the clock while it runs and restart it later. Every
screen shows Paused, the bid boxes go away, and the pause's length is
added to the run so the clock resumes at the reading it stopped on. Paused
inside Last Call it stays Last Call, and stays blind. Both are events in
the log like everything else (
pauseAuction,resumeAuction), so a replay reproduces the stretched clock, the audit checks the arithmetic, and — the one piece of clock-keeping the rules can enforce — Firestore refuses any bid while the auction doc says the clock is stopped. - Audit log. Once the auction has started the auctioneer has an Audit
log button that replays the whole log through the same
validateInboundevery screen uses and lists anything it would have refused — see Trust model for why that matters.
With no backend, the browser is the only thing running validateInbound, and
firestore.rules is the only layer a participant cannot
bypass. The rules enforce what a client can prove from its own uid and document
reads:
- only an admin (a hard-coded, verified address) can create an auction, issue admin events, seat or unseat anyone, or read a participant's stored password;
- a supplier cannot read another supplier's name, email or password by any route;
- a supplier can bid only as itself, only while the clock is not paused, and
cannot forge the
placedBymarker that denotes an auctioneer bidding on someone's behalf; - a supplier can withdraw only their own bid, and the watching client can write nothing at all;
- events are append-only and gapless — no overwrite, no delete, no seq gaps. The one exception: an admin may delete, piece by piece, an auction that has first been archived, which is how the panel's permanent delete works; nothing in a live auction can be deleted by anyone;
- a supplier's paired clock bump is bounded, so it can neither end the auction early nor stall it;
- a seat holder can change nothing about their own seat but the sign-in stamp. A participant's address need not be verified (only an admin can create the account), but an admin address on an unverified token gets no admin powers.
What they cannot enforce, and what is therefore cosmetic rather than secret:
- Blind Last Call. Rules cannot filter documents per reader, so a technical participant can read the event collection directly and see hidden bids. The client hides them at render time only. (Taking a supplier's pre-Last-Call view off their board is presentation by nature — those prices were public while the main clock ran, and they had already seen them.)
- Bid arithmetic. Beats-current-best, Extended Time and
minBidStepare client-enforced. A hand-crafted write can place an illegal bid — visibly, in the append-only log, but it will land.
Anonymity used to be on that list and no longer is — see below.
Closing the remaining two needs a trusted writer: a Cloud Function (Blaze plan) taking
bids and running validateInbound server-side, with clients denied direct write
access to events. The shared code is already structured for it — validation.ts
has no browser dependencies. Until then, treat the auction as auditable rather
than sealed, which is fine among identified counterparties and not fine against
a motivated adversary.
"Auditable" is a button, not a promise: the auctioneer's Audit log
(src/shared/audit.ts) replays the stored log from the
start and asks, of each event, whether validateInbound would have accepted it
given everything before it — a bid that did not beat the best its bidder could
see, a bid outside the clock, an Extended Time push that does not match the
arithmetic, a withdrawal naming the wrong owner, a gap in the numbering. A
hand-crafted write lands, but it lands in an append-only log that says exactly
what it is. Two genuine bids submitted in the same instant do not end up
here: submit() validates once for instant feedback, then again inside the
append transaction against whatever landed in between, so the second to arrive
is judged against the first.
npm run test:rules asserts each guarantee above, and documents the two gaps
as explicit tests so a future change that closes one is noticed.
| Original | This version | |
|---|---|---|
| Runtime | Python 2.7 on App Engine | Static TypeScript SPA, no server to run |
| Realtime | 2-second polling | Firestore snapshot listeners |
| Storage | pickled validator state in the datastore |
Firestore event log; state is always re-derivable |
| Auth | Private key in the URL | Firebase email/password; two admins run everything, each participant account admin-provisioned |
| Rules | Hardcoded constants in validation.py |
Per-auction config, editable before start |
| Direction | Reverse only | Reverse or forward |
| Input | window.prompt / window.confirm |
Inline forms, live validation |
| Frontend | AngularJS 1.2 + Foundation + nvd3 over HTTP CDNs | Vite + TypeScript SPA, Chart.js, no CDNs |
| Export | None | results.csv and bids.csv, auctioneer only |
| Enforcement | Server-side validator | Security rules + client validation (see above) |
src/shared/ types, rules, config, schemas, aggregate, validation, audit, admins
src/client/ firebase, auth (email/password + secondary-app account
provisioning), store (document shapes + refs), connection (the
live board: transactional append, listeners, seat resolution,
heartbeats/clock), management (the admin panel: create, list,
archive and delete auctions, seat / re-credential / unseat
participants, roster),
views (sign-in, create, admin panel, auction board, chart,
presence), export, format
firestore.rules the enforcement boundary — read the header comment
tests/ domain rules, bid comparison, validation, audit
tests/rules/ security rules, against the emulator
tests/e2e/ Playwright smoke test: admin seats two suppliers, they trade the
lead, the clock is paused and resumed, Last Call goes blind,
results release, the audit passes, the auction is archived
and deleted
.github/ CI: every suite, on every push
- The app sends no email. An admin creates each participant's account with a generated password and relays it (with the board link) however they like. The only mail Firebase sends is an admin's one-time address verification and any password reset an admin triggers on their own account — configure a custom SMTP sender under Authentication → Templates if the built-in one is unreliable.
- Participant accounts are created on a throwaway secondary Firebase app so the admin's own session is never disturbed. A leftover Firebase Auth account after a revoke is harmless — no seat, no access.
- Password reset from the panel works only while the password on file is current; if a participant changed their own, remove and re-add them.
- Clock skew is corrected from the server's stamp on each heartbeat (see On the day), so every screen counts down together. The security rules still allow a 5-second grace window on the deadline, for a bid in flight as the clock runs out.
- Testing several participants in one browser against the emulator: each
tab needs its own origin for its own sign-in (
localhost:5173,:5174, … from separatevite --portinstances), and keep it to two or three tabs. The emulator speaks HTTP/1.1, every tab holds a listen stream and a write stream open, and a browser allows six connections per host — a fourth tab simply stops receiving events. Production is HTTP/2 and unaffected. - The bundle is ~830KB eager (most of it the Firestore SDK) plus a ~210KB Chart.js chunk that loads after the first paint, on the board only.