The matchmaking server for The Vault. It does one thing: aggregate presence across all signed-in players so the macOS app can render a "who's around right now and how do I reach them?" feed.
It deliberately does NOT host games, route invites, or model lobbies. STS2 multiplayer goes through Steam friends — the Worker just helps two players find each other before that handoff.
Runs on Cloudflare's free tier (100k requests/day). For an STS2-mod-sized audience this is permanently free; even a viral moment would cost a few dollars per month at most.
KV (LOBBIES namespace, name kept for backward compat):
| Key | Value | TTL | Purpose |
|---|---|---|---|
session:<token> |
verified SteamID64 | 30 d | bearer auth |
session-profile:<steamID> |
{ personaName, avatarURL } |
30 d | render persona w/o re-hitting Steam |
presence:roster |
{ entries: PresenceEntry[] } |
30 d | the public co-op feed |
user:<steamID> |
"1" |
none | ever-signed-in marker (forever) |
user-meta:<steamID> |
{ persona, firstSeen, lastSeen } |
none | persona history |
signin:<ISO>:<steamID> |
"1" |
30 d | recent-sign-ins event log |
seen:<YYYYMMDD>:<steamID> |
"1" |
10 d | DAU rollup |
funnel:start:<YYYYMMDD> |
counter | 90 d | "Sign in with Steam" clicks |
funnel:cb-attempt:<YYYYMMDD> |
counter | 90 d | OpenID callbacks received |
funnel:cb-ok:<YYYYMMDD> |
counter | 90 d | successful Steam verifications |
funnel:cb-fail:<reason>:<day> |
counter | 90 d | callback failures by reason |
funnel:diag:<reason>:<day> |
counter | 90 d | client-side bounces (in-app browser, etc) |
funnel:diag-ev:<ISO>:<reason> |
detail string | 30 d | recent diag events with user agent |
funnel:roster-first:<steamID> |
ISO timestamp | none | first time on roster (forever) |
Nothing private — every field originates either from the user's own input (status / note / Discord handle) or from the public Steam Web API. There is no run history, deck data, payment info, or moderation log.
Steam-authed users land on the roster the moment OpenID verification succeeds (server-side, before the browser even bounces back to the client) and stay there until they explicitly sign out. Closing the tab, sleeping the laptop, putting the phone down — none of that removes them. The feed is a living roster of everyone who's ever signed up, not a "who's tapping keys this exact second" list.
Two safety nets keep the roster bounded:
MAX_ROSTER_ENTRIES = 200— when the roster fills, the entries with the oldestupdatedAtget evicted to make room for newer activity. New arrivals always get in; the most stale signups roll out as the new ones roll in.ROSTER_TTL_SECONDS = 30 days— the blob is re-written on every heartbeat, restarting its TTL. Only triggers if literally nobody heartbeats for 30 straight days.
If a user disappears from the feed, the cause is one of (in order): explicit DELETE /presence (sign-out button), explicit eviction by roster cap, or 30-day total quiet across all users. Nothing else.
The single most important question a launch-day operator asks is "I have
2,000 page views, why do I only see N users?" The full answer lives in
the /admin dashboard (bearer-gated, indistinguishable from any other
404 to the public).
The funnel surfaces a per-day breakdown:
clicked sign in → back from steam → verified → on roster
If clicked sign in is high but back from steam is low, users are
bouncing AT Steam (closed tab, blocked at school, etc).
If back from steam is high but verified is low, Steam is rejecting
us (rare; check funnel:cb-fail:* for reasons).
If verified is high but on roster is low, KV writes are failing
during auth — funnel:cb-fail:auto-roster-failed will be non-zero.
If everything is high but client-side funnel:diag:nonce-missing is
also high, in-app browsers (Reddit, X, Facebook) are stripping
sessionStorage across the OpenID redirect — the most common silent
failure mode.
The dashboard also lists every Steam user who has ever signed in (persona + first/last seen + on-roster flag) so you can answer "did Player X actually try?" with proof.
cd Backend
npm install
npx wrangler login
# 1) Create the KV namespace; paste both IDs into wrangler.toml
npx wrangler kv:namespace create LOBBIES
npx wrangler kv:namespace create LOBBIES --preview
# 2) Steam Web API key (https://steamcommunity.com/dev/apikey)
npx wrangler secret put STEAM_WEB_API_KEY
# 3) Deploy
npm run deployAfter deploy your Worker is live at
https://vault-coop.<your-subdomain>.workers.dev. Set that URL as
PUBLIC_BASE_URL in wrangler.toml, then redeploy so OpenID round-trips
back to the right host.
- Open
VaultApp/App/Coop/AppConfig.swift. - Replace
defaultServerURLwith your deployed Worker URL. - Rebuild The Vault. End users never have to configure anything — they just sign in with Steam.
Power users can override under Settings → Co-op matchmaking → Advanced. That escape hatch is for forks, private groups, and contributors testing their own Worker deployment.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | / |
public | health check |
| GET | /presence |
public | list everyone currently online |
| POST | /presence |
session | upsert your presence (heartbeat) |
| DELETE | /presence |
session | drop your presence (sign-out) |
| GET | /auth/steam/start?return=&nonce= |
public | Steam OpenID kickoff |
| GET | /auth/steam/callback?... |
public | Steam OpenID return target |
session routes require Authorization: Bearer <token> issued by
/auth/steam/callback. The Worker resolves the token to a verified SteamID
server-side and ignores any SteamID the client tries to put in the body.
The ?return= parameter on /auth/steam/start is allowlisted so a
malicious link can't harvest sessions to a domain you don't control. Allowed
by default:
thevault://*— macOS app custom schemehttps://app.spirevault.app/*— official web companionhttp://127.0.0.1:*andhttp://localhost:*— local dev
If you self-host the web companion on a different domain, set the optional
secret/var ALLOWED_RETURN_HOSTS=mydomain.tld,another.tld and those hosts
become valid return targets. Anything else gets coerced to the safe
macOS-app default.
- No mocks anywhere. No seed data, no fixtures, no offline simulator. The feed is the unfiltered, live KV listing — empty if nobody's online.
- Identity is server-verified. SteamID is set by Steam OpenID, never by the client. Persona name + avatar are pulled from Steam at sign-in time and pinned in KV; the client cannot overwrite them.
- Heartbeat-driven liveness. Presence entries auto-expire after 5 min with no heartbeat. A crashed/quit client cannot leave a stale ghost.
- Stateless. Everything lives in KV with TTLs; nothing to compromise.
- Rate-limited by design. A single SteamID can only have one presence entry; re-POST replaces. KV writes are cheap but capped.
- API key never leaves the Worker. The Steam Web API key is a Cloudflare secret; the macOS app never sees it.
- Open & forkable. If the maintainer disappears, anyone can deploy this
same Worker code, change one line in The Vault's
AppConfig.swift(or use the Settings override), and the community keeps playing.
Common scenarios and the exact thing to check first.
This is the most common "is it broken?" question, and almost always the answer is mobile in-app browsers stripping sessionStorage — Reddit, X, Facebook, Instagram, TikTok and Discord all open links inside their own webviews, and several of those silently break OpenID flows.
- Pull live roster:
curl -s https://vault-coop.coreycrooks.workers.dev/presence | jq .
- Open
/admin(bearer-gated):- Look at the Sign-in funnel · today card.
- High
Clicked sign inand lowVerified by Steam→ users are bouncing through Steam, almost always in-app browsers. - The Where people are getting stuck section breaks failures down
by reason.
nonce-missingandinapp-browser-detectedconfirm the in-app browser theory.
- The signed-out hero already shows an "open in Safari/Chrome" warning when the UA matches a known in-app browser; nothing for you to do.
- Open
/admin→ the Every Steam user who has signed in table. - Search the persona name. If they're there, the row says whether
they're currently
on rosteroroff roster.- on roster → ask them to refresh; they exist, the feed is just stale on their side. Edge cache is 15s.
- off roster → they signed in but their roster row was evicted. Either they hit the 200-entry cap (rare; needs 200+ users) or they hit Sign Out at some point.
- If they're NOT in the table, they never finished OpenID. Check
the recent diagnostic events table for
nonce-missingornonce-mismatchnear the time they tried.
- Pull
/presencefrom both their colos by curling from each location. If both responses contain both Steam IDs, the server is fine and the client is the issue (browser cache, JS error, broken localStorage). - If one colo's response is missing entries, KV is eventually consistent — wait 60s and re-pull. The edge cache is 15s and KV propagation is typically <30s; sustained inconsistency is unusual.
- Check
/admin→presence-read-failedcounter. If non-zero, KV is failing reads and the worker's failing open with an empty array.
curl -s https://vault-coop.coreycrooks.workers.dev/presence | jq '.[] | "\(.personaName) \(.steamID) \(.updatedAt)"'Or hit /admin for the rendered version with last-heartbeat times.
There's a Playwright test that exercises persistence + multi-browser
visibility against the live deploy in /tmp/sv-roster-verify/test.mjs
(committed copies live in the local audit folder). It:
- Confirms the roster has real users
- Confirms identical snapshots from two cold-loaded browsers
- Confirms users last seen >1h ago are still listed (proves persistence)
- Confirms /auth/steam/start redirects to Steam (funnel counters bump)
- Confirms /auth/diag accepts client-side beacons
Re-run after any backend change you're nervous about.
Three categories of "the feed is empty even though users exist" bugs have hit production. Each one was invisible from the dashboard while it was happening, which is exactly why they're listed here.
-
Fire-and-forget KV writes outside
ctx.waitUntil. Cloudflare Workers terminate any unawaited promise the moment the request handler returns itsResponse. Funnel counters, sign-in events, and roster markers all silently dropped on the floor for weeks before this was spotted. Fix: every background side effect goes through thebg(ctx, p)helper insrc/index.ts. If you add another counter, usebg. -
bumpCounterwithcacheTtl: 0. Cloudflare's KVget()rejectscacheTtl < 60silently — the read returnsnullinstead of throwing, so the increment didnull + 1 = 1repeatedly and counters never moved past 1. Fix: never pass{ cacheTtl: 0 }to KV reads. The default is fine. -
Roster blob shape drift. The canonical storage shape is
{ entries: PresenceEntry[] }. A cleanup script once wrote a barePresenceEntry[]topresence:roster. OldreadRostercast the array as Roster, soroster.entriesresolved toArray.prototype.entries(a function),function.length === 0, and the worker returned[]to every client without ever throwing. Real users were on the roster but the public feed was permanently empty. Fix:readRosternow normalizes both shapes and filters non-conforming entries;writeRosteralways serializes the canonical shape. If you must edit the roster manually, write{"entries": [...]}— never a bare array. -
CORS wildcard breaking
sendBeacon.navigator.sendBeaconalways includes credentials. Browsers rejectAccess-Control-Allow-Origin: *responses to credentialed requests, so the diagnostic beacon from the real web app was being silently rejected by the browser before it ever reached the worker. Fix:corsHeadersFor(req)echoes the actual origin for known origins (app.spirevault.app,spirevault.app, localhost) and pairs it withAccess-Control-Allow-Credentials: true. Random origins still get*for public reads, just without credentials.
Cloudflare free tier:
- Workers: 100,000 requests/day
- KV reads: 100,000/day; writes 1,000/day; storage 1 GB
- Workers cache: free
Worst-case math for 200 concurrent users polling every 12 s + heartbeating every 2 min: ~1.5M requests/day → ~$5/mo on the Workers paid tier (and KV bumps to $0.50 per million reads). For an STS2 mod's actual audience (~tens of users) you stay on free tier indefinitely.