web: answer polled reads from the identity snapshot

LA
24d ago
+543-70

Summary

Do not merge: the trust-policy change is under discussion in https://github.com/manaflow-ai/cmux/issues/13742.

Our server makes ~1.43M observed Stack API calls a day (Axiom fetch spans to api.hexclave.com, 24 h to 2026-10-12 22:00Z). Read-only polls from the Mac app make most of them:

RouteStack calls / 24 hCalls per request
GET /api/vm479k0.77
GET /api/coderouter/organizations375k1.8 (users/me + teams)
GET /api/coderouter/vm-usage/team175k0.94
GET /api/vm/[id]/stats173k0.82

GET /api/teams/invitations and GET /api/device-tokens are smaller (Axiom, 6 h on 2026-10-13: 5.8k and 5.6k requests that called Stack for auth).

These six routes now go through verifyPolledReadRequest: a native request whose access token verifies locally (ES256 against Stack's JWKS, already used by the iroh and device routes) and whose user has a fresh stack_identity_snapshots row is answered with no Stack call for auth. Everything else keeps the route's old Stack path: cookie sessions, tokens that do not verify locally, a requested team the snapshot does not list, and misses. A miss whose token verified locally lists every team when Stack can (a broken page chain still answers the request and is not stored) so the snapshot is refreshed for the next poll. A token that does not verify locally, a requested team the fresh snapshot does not list, and a snapshot or tombstone read that fails or takes more than 750 ms keep the route's own Stack lookup, with no extra team listing, so the database never spends the coderouter Stack deadline. Mutations, billing and admin are unchanged. PATCH/POST /api/subrouter/teams (team switch and create) now delete the user's snapshot so a switch is visible on the next poll. A snapshot answer carries the token's signed is_anonymous flag (the snapshot row does not store it), so the team routes still refuse anonymous users.

Why invitations and device-tokens are included: both are GETs scoped to the caller's own user id (device-tokens: the caller's own rows, native only, allowCookie: false kept; invitations: the user id only, the verified emails are still read from Stack on every request). That is the same trust model as /api/devices, which already answers from the snapshot. Invitations still costs one Stack call per poll (contact channels); only the auth call goes.

Merged current main, including #20041 (Stack 429 fetch guard, contact-channel invitation read). Both behaviors are kept.

Trade-offs (issue #13742)

On the snapshot path only, and only for these six read-only GETs:

  • A session revoked at Stack outside our own sign-out routes stays accepted until its access token expires (Stack access tokens live one hour). Our sign-out routes delete the snapshot.
  • A removed team membership whose Stack webhook was missed or is still retrying stays visible for up to the snapshot TTL (CMUX_STACK_IDENTITY_SNAPSHOT_TTL_MS, 10 minutes). A delivered webhook deletes the snapshot at once.
  • The account-deletion tombstone is still read on every request.
    /api/devices already accepts the same two windows.

Measured at the HTTP boundary

Dev backend stacks on cmux-dev-backend-1 built from origin/main 370c51e401 (before) and this branch (after; measured on 663c483c74 and repeated on 602033a5e3 with a new user, same numbers), against the real dev Stack project. Each web build had an uncommitted probe in instrumentation.ts that logs every outbound fetch to a Stack host; calls were counted per request window. A throwaway dev Stack user with one team; a native session minted through api.stack-auth.com like the Mac client (Authorization: Bearer + x-stack-refresh-token + x-cmux-team-id). All polls answered 200 (stats 404: no such VM, auth runs first).

Stack calls per poll, steady state:

Routebefore, burst (20 polls, one instance)before, 35 s apart (cache expired, like a new Vercel instance)after (burst and 35 s apart)
GET /api/vm0.00 (30 s per-instance cache)0.750.00
GET /api/vm/[id]/stats0.00 (same cache)not run0.00
GET /api/coderouter/organizations2.00 (users/me + teams)2.000.00
GET /api/coderouter/vm-usage/team1.00 (users/me)not run0.00
GET /api/teams/invitations1.00 (contact-channels; auth cached)1.75 (contact-channels + users/me)1.00 (contact-channels only)
GET /api/device-tokens0.00 (same cache)0.750.00

Server-side diagnostics in the after build (uncommitted log lines in answerFromIdentitySnapshot): 145 snapshot hits, 1 miss (the user's first request) on 663c483c74. The first request for a user costs users/me + teams (+ one JWKS fetch per instance); then the snapshot answers until the TTL. A poll 21 minutes later missed (no_snapshot, age over the 10 minute TTL) and refreshed with users/me + teams, as designed.

A token issued by api.hexclave.com does not verify locally (its iss is the hexclave origin; stackTokenIssuerBaseURL() expects api.stack-auth.com). With such a token every route measured the same before and after (orgs 2.00, vm-usage 1.00, invitations 1.00, others 0.00 burst), so it gets no saving and no extra cost. Production tokens verify: Axiom shows 23 local_verify_miss=rejected against 11k snapshot answers on /api/devices in 6 h.

Testing

  • On a Freestyle VM (node snapshot, bun 1.4.2), bun test --isolate over stack-polled-route-auth, device-tokens-route, teams-invitations, teams-received, teams-accept, vm-auth-snapshot, stack-identity-snapshot, coderouter-organizations-deadline, coderouter-signed-organization-route, coderouter-vm-usage-route, hosted-subrouter-routes, cli-config-route, stack-throttle-fetch-guard, devices-route: 126 pass, 48 skip (database tests, no Postgres on the VM), 0 fail. bun run typecheck exit 0, bun run lint:complexity exit 0 (39 grandfathered), eslint on the changed files exit 0.
  • vm-auth-snapshot.test.ts: the snapshot answer now includes the token's anonymous flag.
  • Not verified: a preview deploy with a real Mac client.

Changelog

Fixed: Cloud sidebar, coderouter organization, invitation and push-recipient polling no longer fail with authorization timeouts when Stack Auth is slow or rate limited