web: fewer Stack calls on invitation and device-withdrawal polls; Stack 429 is final for the SDK

Merged
LA
24d ago
+1949-208

Summary

This PR cuts three sources of Stack Auth calls. None of them changes who may do what. Split from the analysis in https://github.com/manaflow-ai/cmux/pull/20035.

  • GET /api/teams/invitations made 3 Stack calls per request: users/me, users/{id} and contact-channels. It now reads contact-channels?user_id= directly, because the route has already authenticated the user and needs only the user's verified emails. Each team is read once per poll, and its member list (for inviter names) is read once per team, however many invitations come from that team. Result: 2 calls per poll. That is about 110k fewer Stack calls a day (about 120k requests a day after correcting for the 2% sample).
  • PATCH /api/devices answered 99% of requests with 403 device_not_owned. Every build on a Mac shares one device id, so a second account on that Mac tries to withdraw a device row that it does not own. Shipped clients repeat a non-2xx withdrawal on every status tick, so each tick cost one Stack verification, without end. Withdrawal is idempotent, so "nothing of yours is published there" now answers 200 { ok: true, withdrawn: false }, and those clients stop. Authentication and team checks are unchanged. Registration (POST) keeps its 403.
  • The Stack SDK answers a 429 by sleeping for Retry-After and then walking its three hosts twice. On cmux-lawrence-2 one 429 became 7 fetches, each with a wait. A fetch guard, installed in instrumentation.ts, now makes a 429 final for SDK requests. It matches only requests that carry this project's id and the SDK's random-nonce header. The caller gets an error with status 429 in its cause chain, which our routes already map to a retryable 429/503. Our own REST helpers (webhook mirror, contact channels) send no nonce and are not affected.

Testing

Red commit 20fca075b4: 3 failing tests. Green on 7cd849d6dc. All runs used bun test --isolate on cmux-lawrence-2.

  • teams-received.test.ts: two invitations from one team cost exactly listUserContactChannels, getTeam and listUsers.
  • devices-withdraw-not-owned.test.ts: a device that the caller does not own answers 200 withdrawn: false.
  • stack-throttle-fetch-guard.test.ts: uses the real @hexclave/js StackServerApp against a throttling fetch. Result: one users/{id} fetch, under 500 ms, with a rate-limit signal. Other hosts, other statuses and our own helpers pass through unchanged.
  • 72 related test files: 672 pass, 0 fail. typecheck, lint:complexity, eslint and check-test-determinism.py --strict pass.
  • devices-route.test.ts with CMUX_DB_TEST=1 against a temporary Postgres 17 on cmux-lawrence-2, after scripts/db-migrate.mjs applied 103 migrations: 36 pass, 0 fail on 76d4c248a2. A new case shows that withdrawing another owner's device and withdrawing a device that does not exist give equal answers: same status, every header, and the same body bytes. Both run the same single owner-scoped lookup, so the answer reveals neither ownership nor existence.

Review follow-ups (head c563554d26)

  • /handler/after-sign-in: a Stack throttle now answers a 503 page with Retry-After: 30. The page is in all 20 locales, and its link is the exact callback URL, so a retry resumes the same flow. Before, the throttle fell into a catch and the flow continued with the old cookie tokens, which skipped anonymous promotion and the billing claim. A throttle during promotion also gets the retry page. Other promotion failures keep the existing recovery page. The session block moved into helpers; GET is now under the complexity limit, so its baseline line is removed.
  • GET /api/teams/invitations: a user that Stack does not know (USER_NOT_FOUND) is 401, not 503.
  • Stub-fetch tests for the contact-channel read (URL and headers, is_verified, 429 → retryable 503, unknown user → 401) and for the register() install of the guard.

Audit: other Stack SDK callers when a 429 now throws at once

Before the guard, these catches were already reachable on a long throttle. Now one 429 reaches them.

Continue with a wrong answer (follow-up needed, not changed here):

  • app/lib/dashboard-auth.ts:88-95 join link: FIXED (see below).
  • app/api/vm/route.ts:743-754: the inline Pro reconcile is swallowed, so a just-paid user gets a plan refusal instead of a retryable 429.
  • services/vms/privateNetwork.ts:381-383 via teamDirectory: a VM create falls back to the personal network.
  • Display or degraded with a later re-check: billing/subscription pending-claim catch, billing/teamCatalog and teamBillingAccess (admin shows as member), and the pricing pages (render as signed out).

Fail after a write:

  • app/api/vm/access-grants/[id]/route.ts:79-83: FIXED (see below).
  • services/teams/accept.ts:71-81: the invitation is consumed, but the admin role is not applied.
  • app/api/subrouter/teams POST: the team is created, then a 500, so a client retry can create a duplicate.

Every other caller propagates the throttle as a retryable error, or catches it harmlessly by design (best-effort display, queued retry).

Fixed from the audit (head da06527b2d)

  • Access-grant session revocation. DELETE /api/vm/access-grants/[id] cuts the grant and its tunnels in our database first, so the Mac loses Cloud network access whatever Stack answers.
    • repo.revokeAccessGrant inserts the grant's Stack sessions into stack_session_revocations in the same transaction as the grant update. A crash or a failed insert rolls both back, so sessions are never forgotten.
    • The Mac's own sign-out path (DELETE /api/vm/tunnel) passes no queue and keeps revoking only its own session.
    • The request makes the first attempt with the server key and answers { revoked: true, sessionRevocation: "revoked" | "pending" }.
    • The vm-reconcile cron (maxDuration 300) claims due rows in one UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) statement with a 2-minute lease, so concurrent runs claim disjoint rows. It retries at most 20 rows per run, concurrently.
    • Backoff is 1, 2, 4 … minutes, capped at 1 h. A row stops at 20 attempts or after 24 h (gave_up_at) and is reported once through reportCronFailure (stage stack_session_revocation_gave_up, grouped in Sentry).
    • A row is deleted once Stack accepts. Account deletion deletes the user's rows in its data transaction.
  • Stack API contract (checked against the SDK source, @hexclave/shared HexclaveServerInterface.deleteServerSession, used by ServerUser.revokeSession): DELETE /api/v1/auth/sessions/{id} with server-access headers, no body and no user_id. The SDK's known-error list has no session-not-found code; REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED counts as already revoked. Any other refusal is retried and finally reported. A contract test pins the exact request.
  • Join link. Only a Stack 429, 5xx, timeout or unreachable host shows the localized retry state. Any other failure is thrown to its real handling. The transient marker is in the error message, because errors leaving the private use cache scope lose their prototype and cause. Subrouter authorization errors now keep their cause, and the local overload gate (circuit open, queue full) is marked transient.
  • CodeRabbit threads: environment values are restored per file, the wall-clock bound is replaced by a behavioral assertion, and the dead createSession mock is removed.
  • Migration (still additive, and still unmerged, so it was edited in place): web/db/migrations/20261012120000_stack_session_revocations/migration.sql. It adds one table, a partial due index and a user_id index. Rehearse it on PlanetScale staging and apply it to production before merge.

Tests: the red commit is cbb5f9a91c (5 failures), green on da06527b2d. On a temporary Postgres 17 on cmux-lawrence-2 (104 migrations, then a no-op re-run), with CMUX_DB_TEST=1:

  • stack-session-revocation-store-db.test.ts 8/8: same-transaction queue, simulated crash on insert rolls back the grant revocation, sign-out path queues nothing, two concurrent cron runs revoke each session exactly once, lease expiry, backoff and cap, 24 h give-up, account purge.
  • devices-route 36/36 and db-schema 6/6.

This PR changes web/db/, so CI's DB behavior job (ci-web.yml, bun run test:db:behavior) runs these files on the PR. Unit tests: 244 related files, 2631 pass. The 3 failures are in vm-devbox-image.test.ts and fail the same way on main. Typecheck, complexity, eslint and the determinism guard pass.

Changelog

none


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Cuts three sources of Stack Auth calls without changing who may do what, makes a throttled Stack answer retryable instead of silently wrong, and records access-grant Stack session revocations durably so a throttle can be retried.

  • GET /api/teams/invitations reads contact channels directly and each team once per poll: 2 Stack calls instead of 3, about 110k fewer calls a day. An unknown Stack user still answers 401; a 429 surfaces as a retryable outage.
  • PATCH /api/devices answers { ok: true, withdrawn: false } when the caller owns no such device in the team, byte-identical to the missing-device case. Registration (POST) keeps its 403.
  • A fetch guard in instrumentation.ts makes a Stack 429 final for SDK requests, replacing Retry-After sleeps and the two-host walk; our own REST helpers send no nonce and are unaffected.
  • The after-sign-in route and the team join link answer a Stack throttle with a localized retry page that resumes the original callback. The join retry appears only for transient Stack failures; a missing session still redirects to sign-in.
  • Revoking a Cloud access grant queues its Stack sessions in a new table in the same transaction that cuts the grant; the vm-reconcile cron claims due rows with a lease (SKIP LOCKED, deterministic order) and revokes with exponential backoff, deleting rows on success and giving up after the attempt cap or 24 hours. The route reports pending until Stack accepts, and the grant is cut on our side at once.
  • The migration is timestamped after hexclave_webhook_inbox so drizzle applies it even where that migration already ran.

Summary by CodeRabbit

  • New Features
    • Team invitations can now be reopened or retried when sign-in confirmation is temporarily unavailable. Sign-in pages also offer guidance to continue without signing in again.
    • Revoked access grants now trigger session revocation, with automatic retries if the revocation service is temporarily unavailable.
  • Bug Fixes
    • Device withdrawal requests for devices that are missing or not owned by the requester now return a consistent response without revealing ownership details.
    • Invitation lists now use verified contact addresses and avoid repeated team lookups.
  • Localization
    • Added temporary sign-in interruption guidance and retry actions across supported languages.