How TripMatch is put together after the launch hardening, why the previous setup would not have survived the Haas cohort, and what to watch as it grows.
Written August 26, 2026, the day before public launch.
The shape of it
┌────────────────────────┐ ┌──────────────────────────┐
│ GitHub Pages │ │ Google Identity │
│ (static, free) │ │ Services │
│ │ │ │
│ index.html │──1────▶│ Sign in, hd=berkeley.edu│
│ logs.html │◀───────│ → signed ID token │
└────────┬───────────────┘ └──────────────────────────┘
│
│ 2. POST the ID token, get a TripMatch session
│ 3. Bearer <session> on every request
▼
┌────────────────────────────────────────────────────────────┐
│ Cloudflare Worker (tripmatch-api) │
│ │
│ • verifies the Google token against Google's JWKS │
│ • enforces the berkeley.edu domain │
│ • mints / verifies its own HMAC session token │
│ • owns every write; the browser never writes directly │
│ • appends an audit row per mutation │
│ • rate-limits per account │
│ • daily cron archives past-dated posts │
└────────┬───────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ Cloudflare D1 (SQLite) │
│ users · posts · comments · plus_ones · audit_log │
└────────────────────────────────────────────────────────────┘
Everything above is on a free tier. There is no monthly bill at this scale.
The rule that shapes the design: the browser holds no secret and performs no write. It holds a session token that proves who the user is; the Worker decides what that person is allowed to do. Before this change the browser held a key that could erase the entire board.
What was wrong before
1. The API key was published to every visitor
index.html carried a JSONBin master key in plain source:
var JSONBIN_KEY = "$2a$10$REDACTED"; // visible in View Source
var JSONBIN_AUTH_HEADER = "X-Master-Key";
Anyone who opened the page could read it, and a master key is account-wide — not scoped to this one bin. With it, any visitor could rewrite the board, delete every post, or reach other bins on the same account. No exploit was needed; the credential was simply handed out with the page.
The file’s own comment acknowledged this and recommended a scoped Access Key instead. That would have been an improvement, but not a fix: a scoped key is still public, so any visitor could still wipe the board. Only a server can hold a credential the browser doesn’t see.
Action required regardless of anything else here: revoke that master key in the JSONBin dashboard. It is in this repository’s git history and must be treated as compromised. Rotating it is step 0 of the deploy runbook.
2. Concurrent writes silently lost posts
Every mutation followed the same pattern — read the entire board, change one thing in memory, write the entire board back:
var current = await loadPosts(); // read all
current.push(post); // change one
await savePosts(current); // write all
Two people acting inside the same round trip produced this:
| time | Alice | Bob | stored |
|---|---|---|---|
| t0 | reads board (12 posts) | 12 | |
| t1 | reads board (12 posts) | 12 | |
| t2 | writes 13 (hers) | 13 | |
| t3 | writes 13 (his) | 13 — Alice’s post is gone |
No error is raised. Alice sees her post appear (she rendered from her own copy), then it quietly vanishes on her next refresh. The window is one HTTP round trip — roughly 200–600 ms on mobile — and it applied to posting, commenting, +1ing, and deleting alike.
This is not a rare race. Launch day means a WhatsApp link landing in front of ~400 students at once, which is exactly the burst that produces overlapping writes. The same flaw also meant a stale delete could resurrect posts: a tab open for ten minutes still held the old array, and any action from it wrote that whole stale array back, restoring posts other people had deleted.
The fix is structural. Every mutation is now a single targeted statement —
INSERT INTO comments …, DELETE FROM plus_ones WHERE post_id = ? AND author_email = ? — so concurrent writers touch different rows and cannot
overwrite each other. The regression test fires 12 simultaneous posts,
comments and +1s and asserts all 36 survive; against the old design they
would not have.
3. Ownership was a name typed into a box
Edit and Delete compared the post’s name against a name in localStorage.
That meant, all at once: you lost control of your post when you switched from
laptop to phone; anyone could type your name and gain control of it; and the
audit trail was worthless, because the “who” was self-declared.
4. Failures were invisible
A failed request set an error string inside whichever form was open. If the
backend was down at page load, loadPosts() returned [] on error — so a
total outage rendered as “Nothing posted yet”, which reads as an empty
board. Users would respond by posting again into a backend that wasn’t
accepting writes.
Would JSONBin have broken? Yes — three ways, all before 200 posts
You asked specifically about ~400 students and a peak of ~200 live posts.
Bin size — the wall you hit first
JSONBin’s free tier caps a single bin at 100 KB. The whole board is one bin. Measured against the actual record shape:
| Posts | No engagement | 2 comments + 2 +1s each | 4 comments + 3 +1s each |
|---|---|---|---|
| 100 | 33 KB | 76 KB | 113 KB ✗ |
| 150 | 49 KB | 113 KB ✗ | 170 KB ✗ |
| 200 | 66 KB | 151 KB ✗ | 226 KB ✗ |
A board of bare posts fits. A board people actually use does not. At the engagement levels the current design encourages — comments and +1s are two of the three features added after user testing — the cap is reached somewhere between 100 and 150 posts, roughly half your expected peak.
The failure mode is ugly: the write is rejected, and because the client wrote the whole array every time, every subsequent write fails too. The board freezes for everyone at once. Paid tiers raise the cap to 1 MB, which buys time but not a different shape — it’s still one document, rewritten in full, by every user, for every action.
Request quota
The free tier allows on the order of 10,000 requests/month, and the old client spent them carelessly — every mutation cost two (a read then a write), and the opportunistic expiry sweep added another.
A conservative week for 400 students:
| Activity | Requests |
|---|---|
| 400 students × 3 board opens | 1,200 |
| 200 posts × 2 | 400 |
| 400 comments × 2 | 800 |
| 400 +1s × 2 | 800 |
| expiry sweeps | ~100 |
| weekly | ~3,300 |
| monthly | ~13,200 |
That is over the free quota inside the first month, and it assumes each student opens the board only three times a week — implausibly low for a tool meant to replace scrolling a group chat.
Concurrency
Covered above: no compare-and-swap, no row-level writes, no way to make the read-modify-write cycle safe from a browser. This one has no tier to upgrade to. It is a property of storing a shared mutable list as a single JSON document that every client rewrites.
Conclusion: JSONBin was a good choice for a prototype and a bad one for a launch. All three limits bite well inside your stated numbers.
Why Cloudflare Workers + D1
Weighed against Supabase, Firebase, and a small VPS:
| Workers + D1 | Supabase | Firebase | VPS | |
|---|---|---|---|---|
| Cost at this scale | $0 | $0 | $0 | ~$5–12/mo |
| Ops burden | none | none | none | patching, uptime, TLS |
| Sleeps when idle | no | yes, after ~1 week | no | no |
| Row-level writes | yes | yes | yes | yes |
| Server-side secrets | yes | yes | yes | yes |
| Build step | none | none | none | varies |
| Lock-in | SQL, portable | Postgres | proprietary | none |
Workers + D1 won on three points that matter here. It keeps the project’s
“one file, no build step” character — the Worker is a single worker.js you
deploy with one command. It doesn’t idle out, so a board that goes quiet over
winter break still answers instantly in January. And the data is ordinary
SQLite: if you ever outgrow it, wrangler d1 export gives you a .sql file
that restores into Postgres or anything else.
Headroom against the free tier
| Limit | Free allowance | Expected peak | Headroom |
|---|---|---|---|
| Worker requests | 100,000/day | ~3,000/day | 33× |
| D1 rows written | 100,000/day | ~2,000/day | 50× |
| D1 storage | 5 GB | < 5 MB | 1000× |
| D1 rows read | 5,000,000/day | ~1,200,000/day | 4× |
Three of these are not worth thinking about. The fourth is worth naming honestly: row reads are the tightest constraint. Each board load reads every live post plus its comments and +1s — roughly 600 rows at peak — so 2,000 board loads a day lands near 1.2 M. Comfortable, but it’s 4× headroom rather than 30×, and it’s the number that would move if usage doubled.
If it ever gets close, the fix is small and doesn’t change the architecture:
cache the assembled board JSON in Workers KV for 10–15 seconds and serve
repeat loads from it. At 400 students the same board is being fetched over and
over within seconds of itself, so even a very short cache would cut row reads
by an order of magnitude. Not needed at launch — this is the thing to
implement if wrangler d1 insights shows reads climbing past ~3 M/day.
Identity: why not literal CalNet
You asked for CalNet. Here is the honest position.
Real CalNet SSO is Shibboleth/SAML (with a CAS endpoint alongside). Using it requires UC Berkeley IT to register TripMatch as an approved Service Provider — an application, a review, a security questionnaire, and a production-hosted SP with a metadata endpoint and certificate management. That is a multi-week process at best, and a static GitHub Pages site cannot be an SP at all. Your own PRD already recorded this as a v1 non-goal for exactly these reasons.
What ships instead is Google Sign-In restricted to the berkeley.edu
Workspace domain. Berkeley’s bMail runs on Google Workspace, so every CalNet
holder already has a @berkeley.edu Google account, and Google’s own login
sits behind CalNet-managed credentials. The Worker verifies the ID token’s
RSA signature against Google’s published keys, then checks issuer, audience,
expiry, email_verified, and the hd (hosted domain) claim.
What this gives you that a typed name did not:
- A verified, unforgeable
@berkeley.eduaddress per user - Ownership that follows the person, not the browser — the laptop/phone problem in the PRD’s open questions is solved
- An audit log where “who” is evidence rather than a claim
- A closed community: a
gmail.comaccount is rejected at the door
What it does not give you:
- It proves Berkeley, not Haas. Any berkeley.edu account can sign in.
Given the link circulates in the Haas group chat this is a small gap, and
ALLOWED_HDplus an allow-list of emails could tighten it if it ever matters. - It is not CalNet, so it won’t satisfy a requirement that says “CalNet” literally — e.g. if this ever becomes an official campus service.
- It depends on Google. If
accounts.google.comis unreachable on a user’s network, they cannot sign in; the gate detects this and says so explicitly rather than showing an empty box.
Migrating to real CalNet later does not require a data migration. Identity
is keyed on the @berkeley.edu email address, and CalNet’s Shibboleth
assertion carries that same address as mail. Swapping the token-verification
function in handleAuthSession is the whole change; every existing post,
comment, and log row keeps working. If you decide to pursue it, the request
goes to UC Berkeley IST’s Identity and Access Management group.
The audit log
Every mutation appends one row to audit_log, in the same request that
performs it, recording: timestamp, actor email, actor name, action, entity
type and id, an action-specific JSON detail, plus IP, country, and user agent.
Actions recorded: login, login.rejected, post.create, post.update,
post.delete, post.update.denied, post.delete.denied, comment.create,
plusone.add, plusone.remove, post.expire, rate_limited, logs.denied,
migration.import.
Three deliberate choices:
post.updaterecords a field-level diff (seats: 2 → 4), not the whole row. Whole-row snapshots are unreadable when you’re trying to answer “who changed this, and to what?”- Denied attempts are logged too. A log of only successful actions cannot show you someone trying to delete other people’s posts.
- Logging never fails a request.
logEventswallows its own errors: a dropped log line is a smaller problem than a lost trip post.
Posts are soft-deleted (deleted_at is set, the row stays), so a log
entry always points at something real and a mistaken delete is one UPDATE
away from being undone.
Read it at logs.html, restricted to the emails in the ADMIN_EMAILS secret
and enforced server-side — not by hiding the link. It filters by action,
actor, and time range, and exports CSV straight from the API rather than from
what’s on screen, so an export is the authoritative record.
Retention: nothing prunes audit_log today. At this volume it will take
years to matter. If you’d rather it not accumulate indefinitely, add a delete
of rows older than a year to the existing daily cron.
Failure handling
One banner, above the board, in four states:
| State | Trigger | What it says | Action |
|---|---|---|---|
unconfigured |
API_BASE still a placeholder |
Deployment isn’t wired up | — |
offline |
navigator.onLine === false |
You’re offline; the board may be stale | auto-clears |
unreachable |
fetch rejected |
It’s us, not you; posting is paused | Retry |
server |
HTTP 5xx | Nothing was saved | Retry |
rateLimited |
HTTP 429 | Paused about a minute | — |
expired |
HTTP 401 mid-session | Sign in again to post | Sign in |
Principles behind it, each one a response to how the old build failed:
- Transport failures go to the banner; action failures stay inline. “You can only edit your own posts” belongs next to the button you pressed, not in a page-level notice.
- An empty board and an unreachable board never look alike. If no load has succeeded, the board says “Couldn’t load the board — this isn’t an empty board, it’s a board we couldn’t reach.”
- A stale session at boot goes to the sign-in gate, not to a banner hovering over a board we never loaded.
- The connection dot in the header stays amber or red after a failure, so the state survives even once the banner is gone.
- Recovery is automatic where it can be: the app re-fetches on
onlineand when a backgrounded tab is brought forward.
Known limits and what to watch
Sessions cannot be revoked. Session tokens are stateless and signed; there
is no server-side session list to invalidate. A token stays valid for 14 days.
Rotating SESSION_SECRET invalidates every session at once, which is the
blunt instrument if you ever need one. A sessions table would fix it
properly; it isn’t worth the round trip at this size.
Rate limiting is per account, and approximate. It counts that account’s
recent audit_log rows — 20 writes/minute. It stops accidental floods and
casual abuse, not a determined attacker with many berkeley.edu accounts.
The token lives in localStorage, not an HttpOnly cookie. The front-end
and API are on different origins, so a cookie would be third-party and
therefore blocked by Safari and increasingly by Chrome. localStorage is the
right trade here, and it means an XSS bug would expose sessions — which is why
every user-supplied string is escaped at render. Putting both behind one
custom domain would allow a cookie later.
The board is fetched, not pushed. No websockets: you see new posts on load, on tab focus, and after your own actions. Right for a board people check a few times a day; if it ever needs to be live, Durable Objects are the path.
hd is checked but the account picker’s hd hint is only a hint. A user
can still submit a personal account; the Worker rejects it. Client-side hints
are convenience, never a control.
What to check after launch day
cd worker
npx wrangler tail # live request log
npx wrangler d1 insights tripmatch # query volume and slow queries
- Row reads — the one number with only 4× headroom. Add the KV cache if it approaches 3 M/day.
login.rejectedentries — a cluster ofwrong_domainmeans people are arriving with personal Google accounts and may need telling.rate_limitedentries — if honest users hit it, raiseRATE_LIMIT_MAX_WRITES.post.update.denied/post.delete.denied— a burst suggests the legacy-post ownership fallback is confusing someone.
If Cloudflare is ever the wrong answer
The data is plain SQLite and the API is ~800 lines of standard JavaScript with
no Cloudflare-specific dependencies beyond the D1 binding and request.cf.
npx wrangler d1 export tripmatch --remote --output backup.sql
That file restores into Postgres, SQLite, or anything that speaks SQL. Do this periodically anyway — there is no automated backup configured, and one line in the deploy runbook covers it.