| name | heater-appb-auth-billing |
| description | Use when working on HEATER App B auth or billing — Clerk JWT verification, unexpected 401/402, HEATER_API_WRITE_TOKEN, Stripe checkout/webhooks/subscription tiers, require_pro gating, tier-based AI spend caps — or when auth "works locally but not live" (or vice versa), or a page bounces to /sign-in on hard load. |
App B Auth & Billing — seams, dormancy, fail-closed
Everything here is ADDITIVE + DORMANT: built, shipped, inert until env vars flip it on. Absent Stripe env on the live beta is intentional (friends are free) — do not "fix" it.
The verifier seam (api/auth.py)
AuthVerifier Protocol (one method: verify(authorization) -> Principal). get_auth_verifier() (auth.py:173-184) returns:
ClerkVerifier (auth.py:99-163) when clerk_configured() — local RS256 via PyJWKClient JWKS (10s timeout), requires exp/iss/sub, fail-closed: ANY error → 401. JWKS outage logs WARNING (operator problem), bad user token logs debug.
EnvTokenVerifier (auth.py:70-93) otherwise — deny-by-default (HEATER_API_WRITE_TOKEN unset → 401), and compares bytes: hmac.compare_digest(token.encode(), expected.encode()) (auth.py:87) because str/str compare_digest raises TypeError on non-ASCII — a hostile header would 500 instead of deny.
clerk_configured() (auth.py:166-170, reads CLERK_ISSUER at call time) is the SINGLE predicate for "auth is live" — get_auth_verifier, require_login, and the tenancy read-gate all share it so they can't disagree. Never invent a second "is auth on" check.
Billing (M2, $7.99/mo Pro, 7-day trial)
billing_env_configured() (api/billing_config.py:13-18): true only when BOTH STRIPE_SECRET_KEY and STRIPE_PRO_PRICE_ID set — the same predicate the checkout service uses, so the paywall can't activate before checkout works (a half-configured deploy must not lock users out).
require_pro (api/gating.py:34-49): no-op while billing dormant; live → verify Clerk (401), then subscription store tier != "pro" → 402. Attached to the 7 heavy endpoints (lineup/optimize, trade/evaluate, trade-finder, playoff-odds, draft/*). Structural guard: tests/api/test_api_pro_gating.py introspects router objects (_route_map/_dep_calls, lines 105-116) because FastAPI 0.137's lazy include_router hides nested routes from app.routes.
- Webhook (
POST /api/billing/webhook): signature-verified fail-closed 400; checkout.session.completed is link-only (out-of-order events can't re-activate a canceled user); event-id dedup via api_processed_events. stripe==15.2.1 pinned (webhook schema drift).
- AI caps:
get_managed_ai_cap (api/gating.py:52-80) never raises — unverifiable caller or store failure → None = admin default cap (bounded, WARNING-logged over-grant beats a hard failure in chat).
Frontend half
web/src/lib/api/client.ts: authToken() (client.ts:43-54) awaits clerkReady() — polls window.Clerk?.loaded (50ms, 5s timeout, client.ts:30-37) — and calls getToken() as a method (destructuring loses the this receiver). Every apiGet/apiPost/... attaches Authorization: Bearer.
Failure story (M-2/M-3, 2026-06-26): on hard page load the app decided "no token" before the Clerk script finished loading → spurious 401 → bounce to /sign-in for signed-in users. Root cause: reading Clerk.session before Clerk.loaded. The await-loaded gate is the fix; don't remove it to "simplify".
Quick diagnosis
| Symptom | Check |
|---|
| 401 on reads locally | CLERK_ISSUER set locally flips the read gate — unset it for open local reads |
| 401 on live reads with a real login | token audience/issuer vs CLERK_ISSUER/CLERK_AUDIENCE; JWKS WARNING in logs = misconfig, not user error |
| 402 anywhere | billing env got set — is that intended? |
| 409 TEAM_NOT_LINKED | not auth — tenancy assignment (heater-appb-two-dbs-tenancy) |
| writes 401 on live | expected: Yahoo fspt-w scope unavailable + write token unset by design |
When NOT to apply
Owner-gated activations (Clerk PROD instance needs a custom domain; Stripe live keys) — flag to owner, don't flip. Identity/team resolution beyond auth → heater-appb-two-dbs-tenancy.