diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts index d50701cf..1b493393 100644 --- a/frontend/app/api/auth/me/route.ts +++ b/frontend/app/api/auth/me/route.ts @@ -92,6 +92,14 @@ function formatServerTiming(stages: AuthMeTimingStage[], totalMs: number) { .join(", "); } +function buildBackendAuthMeUrl(req: NextRequest) { + const url = new URL(`${API_BASE!.replace(/\/+$/, "")}/api/auth/me`); + if (req.nextUrl.searchParams.get("scope") === "entitlement") { + url.searchParams.set("scope", "entitlement"); + } + return url.toString(); +} + function finishAuthMeResponse( response: NextResponse, timer: AuthMeTimer, @@ -486,7 +494,7 @@ export async function GET(req: NextRequest) { let res: Response; try { res = await timer.measure("backend_fetch", async () => - await fetch(`${API_BASE}/api/auth/me`, { + await fetch(buildBackendAuthMeUrl(req), { headers: backendAuth.headers, cache: "no-store", signal: controller.signal, diff --git a/frontend/app/auth/callback/route.ts b/frontend/app/auth/callback/route.ts index 0da7f129..7dd471e9 100644 --- a/frontend/app/auth/callback/route.ts +++ b/frontend/app/auth/callback/route.ts @@ -30,7 +30,7 @@ async function warmSignupTrial(accessToken: string) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 2500); try { - await fetch(`${API_BASE}/api/auth/me`, { + await fetch(`${API_BASE.replace(/\/+$/, "")}/api/auth/me?scope=entitlement`, { cache: "no-store", headers, signal: controller.signal, diff --git a/frontend/components/account/__tests__/authSnapshot.test.ts b/frontend/components/account/__tests__/authSnapshot.test.ts new file mode 100644 index 00000000..e506468d --- /dev/null +++ b/frontend/components/account/__tests__/authSnapshot.test.ts @@ -0,0 +1,67 @@ +import { + buildAuthMePath, + mergeAccountAuthSnapshot, +} from "@/lib/auth-snapshot"; +import type { AuthSnapshotLike } from "@/lib/auth-snapshot"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +export function runTests() { + assert( + buildAuthMePath({ scope: "entitlement" }) === + "/api/auth/me?scope=entitlement", + "account entitlement probe must use the lightweight auth/me scope", + ); + assert( + buildAuthMePath({ preferSnapshot: true, scope: "entitlement" }) === + "/api/auth/me?prefer_snapshot=1&scope=entitlement", + "terminal auth probe must combine snapshot preference with lightweight entitlement scope", + ); + + const active: AuthSnapshotLike & { referral?: { code?: string } } = { + authenticated: true, + user_id: "user-1", + subscription_active: true, + subscription_plan_code: "pro_monthly", + subscription_expires_at: "2026-07-01T00:00:00Z", + subscription_total_expires_at: "2026-07-01T00:00:00Z", + subscription_queued_days: 0, + points: 120, + referral: { code: "PW-ABC" }, + }; + + const degraded = mergeAccountAuthSnapshot(active, { + authenticated: true, + user_id: "user-1", + subscription_active: null, + subscription_plan_code: null, + subscription_expires_at: null, + subscription_total_expires_at: null, + subscription_queued_days: 0, + points: 0, + degraded_auth_profile: true, + }); + + assert( + degraded.subscription_active === true && + degraded.subscription_plan_code === "pro_monthly" && + degraded.points === 120 && + degraded.referral?.code === "PW-ABC", + "account snapshot merge must preserve confirmed Pro status when a later auth/me response is degraded", + ); + + const inactive = mergeAccountAuthSnapshot(active, { + authenticated: true, + user_id: "user-1", + subscription_active: false, + subscription_plan_code: null, + points: 0, + }); + + assert( + inactive.subscription_active === false, + "account snapshot merge must still accept a confirmed inactive subscription response", + ); +} diff --git a/frontend/components/account/__tests__/paymentShell.test.ts b/frontend/components/account/__tests__/paymentShell.test.ts index b424658f..f5e2cebf 100644 --- a/frontend/components/account/__tests__/paymentShell.test.ts +++ b/frontend/components/account/__tests__/paymentShell.test.ts @@ -299,9 +299,14 @@ export function runTests() { authMeRouteSource.includes("!auth.authUserId") && authMeRouteSource.includes('req.headers.get("authorization")') && authMeRouteSource.indexOf("authenticated: false") < - authMeRouteSource.indexOf("await fetch(`${API_BASE}/api/auth/me`"), + authMeRouteSource.indexOf("await fetch(buildBackendAuthMeUrl(req)"), "auth profile proxy must return unauthenticated locally for no-session Supabase requests instead of forwarding the backend entitlement token", ); + assert( + authMeRouteSource.includes("function buildBackendAuthMeUrl") && + authMeRouteSource.includes('url.searchParams.set("scope", "entitlement")'), + "auth profile proxy must forward the lightweight entitlement scope to the backend auth/me endpoint", + ); assert( authMeRouteSource.includes('reason: "prefer_snapshot_fast_path"') && authMeRouteSource.indexOf('reason: "prefer_snapshot_fast_path"') < diff --git a/frontend/components/account/types.ts b/frontend/components/account/types.ts index 1b26fe3a..d85ca6e5 100644 --- a/frontend/components/account/types.ts +++ b/frontend/components/account/types.ts @@ -19,6 +19,10 @@ export type AuthMeResponse = { subscription_queued_count?: number | null; telegram_pricing?: TelegramPricing | null; referral?: ReferralSummary | null; + degraded_auth_profile?: boolean | null; + degraded_reason?: string | null; + entitlement_snapshot?: boolean | null; + entitlement_snapshot_reason?: string | null; }; export type ReferralSummary = { diff --git a/frontend/components/account/useAccountPayment.ts b/frontend/components/account/useAccountPayment.ts index c1d93f47..5a935628 100644 --- a/frontend/components/account/useAccountPayment.ts +++ b/frontend/components/account/useAccountPayment.ts @@ -1,7 +1,12 @@ "use client"; import { useCallback, useRef, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; import type { User } from "@supabase/supabase-js"; +import { + buildAuthMePath, + mergeAccountAuthSnapshot, +} from "@/lib/auth-snapshot"; import { getSupabaseBrowserClient } from "@/lib/supabase/client"; import type { @@ -25,7 +30,7 @@ export interface UseAccountPaymentParams { backend: AuthMeResponse | null; user: User | null; setUser: (user: User | null) => void; - setBackend: (backend: AuthMeResponse | null) => void; + setBackend: Dispatch>; setErrorText: (text: string) => void; setUpdatedAt: (text: string) => void; showOverlay: boolean; @@ -155,21 +160,45 @@ export function useAccountPayment(params: UseAccountPaymentParams) { .then(({ data: { session } }) => session?.user ?? null) .catch(() => null as User | null) : Promise.resolve(null as User | null); - const authHeadersPromise = buildAuthedHeaders(false); - const backendPromise = authHeadersPromise.then((headers) => - fetch("/api/auth/me", { cache: "no-store", headers }), - ); - const [localUser, backendResult] = await Promise.all([userPromise, backendPromise]); + const [localUser, authHeaders] = await Promise.all([ + userPromise, + buildAuthedHeaders(false), + ]); setUser(localUser); - if (!backendResult.ok) { - if (retry && backendResult.status === 401) { + + const readAuthSnapshot = async ( + headers: Record, + options: Parameters[0] = {}, + ) => { + const response = await fetch(buildAuthMePath(options), { + cache: "no-store", + headers, + }); + if (!response.ok) { + const raw = (await response.text()).slice(0, 260); + const message = copy.httpError + .replace("{status}", String(response.status)) + .replace("{raw}", raw); + const error = new Error(message) as Error & { status?: number }; + error.status = response.status; + throw error; + } + return (await response.json()) as AuthMeResponse; + }; + + let latestHeaders = authHeaders; + let backendJson: AuthMeResponse; + try { + backendJson = await readAuthSnapshot(authHeaders, { + scope: "entitlement", + }); + } catch (error) { + if (retry && (error as { status?: number }).status === 401) { await new Promise((r) => setTimeout(r, 1200)); return fetchAuthSnapshot(false); } - const raw = (await backendResult.text()).slice(0, 260); - throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw)); + throw error; } - let backendJson = (await backendResult.json()) as AuthMeResponse; if ( retry && supabaseReady && @@ -184,23 +213,31 @@ export function useAccountPayment(params: UseAccountPaymentParams) { refreshedSession?.access_token || "", ).trim(); if (refreshedToken) { - const retriedBackendResult = await fetch("/api/auth/me", { - cache: "no-store", - headers: { Authorization: `Bearer ${refreshedToken}` }, + latestHeaders = { Authorization: `Bearer ${refreshedToken}` }; + const retriedBackendJson = await readAuthSnapshot(latestHeaders, { + scope: "entitlement", }); - if (retriedBackendResult.ok) { - const retriedBackendJson = - (await retriedBackendResult.json()) as AuthMeResponse; - backendJson = retriedBackendJson; - } + backendJson = retriedBackendJson; } } catch { // Keep the first response; the UI treats a logged-in local user with // an unauthenticated backend snapshot as a temporary sync state. } } - setBackend(backendJson); + setBackend((previous) => mergeAccountAuthSnapshot(previous, backendJson)); setUpdatedAt(new Date().toISOString()); + if (backendJson.authenticated !== false) { + void readAuthSnapshot(latestHeaders) + .then((fullJson) => { + setBackend((previous) => + mergeAccountAuthSnapshot(previous, fullJson), + ); + setUpdatedAt(new Date().toISOString()); + }) + .catch(() => { + // The lightweight entitlement snapshot already resolved the UI. + }); + } return backendJson; }, [ diff --git a/frontend/components/account/usePaymentFlow.ts b/frontend/components/account/usePaymentFlow.ts index 14069fcb..6c655b10 100644 --- a/frontend/components/account/usePaymentFlow.ts +++ b/frontend/components/account/usePaymentFlow.ts @@ -27,6 +27,7 @@ import { requestWalletWithTimeout, } from "./payment-utils"; import { trackAppEvent } from "@/lib/app-analytics"; +import { buildAuthMePath } from "@/lib/auth-snapshot"; import { assertExpectedPaymentReceiver, EXPECTED_PAYMENT_RECEIVER_ADDRESS, @@ -189,7 +190,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }; - const authRes = await fetch("/api/auth/me", { + const authRes = await fetch(buildAuthMePath({ scope: "entitlement" }), { cache: "no-store", headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" }, }); diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 2b850a3f..249ce039 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -68,6 +68,7 @@ import { createAuthProfileRequestCache, loadTerminalAuthProfile, } from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap"; +import { buildAuthMePath } from "@/lib/auth-snapshot"; import { cityListItemsToScanRows, mergeScanRowsWithCityFallbackRows, @@ -1217,9 +1218,10 @@ function ScanTerminalScreen() { : null; try { const response = await fetch( - options?.preferSnapshot - ? "/api/auth/me?prefer_snapshot=1" - : "/api/auth/me", + buildAuthMePath({ + preferSnapshot: options?.preferSnapshot, + scope: "entitlement", + }), { cache: "no-store", headers, diff --git a/frontend/lib/auth-snapshot.ts b/frontend/lib/auth-snapshot.ts new file mode 100644 index 00000000..03b3397c --- /dev/null +++ b/frontend/lib/auth-snapshot.ts @@ -0,0 +1,83 @@ +export type AuthSnapshotLike = { + authenticated?: boolean; + user_id?: string | null; + subscription_active?: boolean | null; + subscription_plan_code?: string | null; + subscription_source?: string | null; + subscription_is_trial?: boolean | null; + subscription_starts_at?: string | null; + subscription_expires_at?: string | null; + subscription_total_expires_at?: string | null; + subscription_queued_days?: number | null; + subscription_queued_count?: number | null; + points?: number | null; + referral?: unknown; + telegram_pricing?: unknown; + degraded_auth_profile?: boolean | null; + entitlement_snapshot?: boolean | null; +}; + +type AuthMePathOptions = { + preferSnapshot?: boolean; + scope?: "entitlement"; +}; + +export function buildAuthMePath(options: AuthMePathOptions = {}) { + const params = new URLSearchParams(); + if (options.preferSnapshot) params.set("prefer_snapshot", "1"); + if (options.scope === "entitlement") params.set("scope", "entitlement"); + const query = params.toString(); + return query ? `/api/auth/me?${query}` : "/api/auth/me"; +} + +export function isUnknownSubscriptionSnapshot( + snapshot: AuthSnapshotLike | null | undefined, +) { + return ( + snapshot?.subscription_active === null || + snapshot?.subscription_active === undefined || + snapshot?.degraded_auth_profile === true + ); +} + +export function mergeAccountAuthSnapshot( + previous: T | null | undefined, + next: T, +): T { + if ( + !previous?.subscription_active || + !next?.authenticated || + !isUnknownSubscriptionSnapshot(next) + ) { + return next; + } + + return { + ...next, + subscription_active: true, + subscription_plan_code: + next.subscription_plan_code ?? previous.subscription_plan_code ?? null, + subscription_source: next.subscription_source ?? previous.subscription_source ?? null, + subscription_is_trial: + next.subscription_is_trial ?? previous.subscription_is_trial ?? null, + subscription_starts_at: + next.subscription_starts_at ?? previous.subscription_starts_at ?? null, + subscription_expires_at: + next.subscription_expires_at ?? previous.subscription_expires_at ?? null, + subscription_total_expires_at: + next.subscription_total_expires_at ?? + previous.subscription_total_expires_at ?? + previous.subscription_expires_at ?? + null, + subscription_queued_days: + next.subscription_queued_days ?? previous.subscription_queued_days ?? 0, + subscription_queued_count: + next.subscription_queued_count ?? previous.subscription_queued_count ?? 0, + points: + Number.isFinite(Number(next.points)) && Number(next.points) > 0 + ? next.points + : previous.points, + referral: next.referral ?? previous.referral, + telegram_pricing: next.telegram_pricing ?? previous.telegram_pricing, + }; +} diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 28bfaecd..7071c073 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -1817,6 +1817,71 @@ def test_auth_me_preserves_required_subscription_403_from_window(monkeypatch): assert latest_any_calls["count"] == 0 +def test_auth_me_entitlement_scope_skips_non_access_profile_sections(monkeypatch): + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True) + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co") + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key") + monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True) + + class _Identity: + user_id = "user-1" + email = "user@example.com" + points = 7 + created_at = "2026-05-01T00:00:00+00:00" + + monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity()) + monkeypatch.setattr( + web_core.SUPABASE_ENTITLEMENT, + "get_subscription_window", + lambda user_id, respect_requirement=False, bypass_cache=False, unknown_on_error=False: { + "current": { + "plan_code": "pro_monthly", + "starts_at": "2026-06-01T00:00:00+00:00", + "expires_at": "2026-07-01T00:00:00+00:00", + }, + "total_expires_at": "2026-07-01T00:00:00+00:00", + "queued_days": 0, + "queued_count": 0, + }, + ) + monkeypatch.setattr( + web_core.SUPABASE_ENTITLEMENT, + "get_referral_summary", + lambda user_id: (_ for _ in ()).throw( + AssertionError("entitlement scope must not block on referral summary"), + ), + ) + monkeypatch.setattr( + routes, + "_resolve_auth_points", + lambda request: (_ for _ in ()).throw( + AssertionError("entitlement scope must not block on points summary"), + ), + ) + monkeypatch.setattr( + routes, + "_resolve_weekly_profile", + lambda request: (_ for _ in ()).throw( + AssertionError("entitlement scope must not block on weekly profile"), + ), + ) + + response = client.get( + "/api/auth/me?scope=entitlement", + headers={"Authorization": "Bearer access-token"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["authenticated"] is True + assert payload["subscription_active"] is True + assert payload["subscription_plan_code"] == "pro_monthly" + assert payload["points"] == 7 + assert payload["weekly_points"] == 0 + assert payload["referral"] is None + + def test_backend_entitlement_token_binds_forwarded_supabase_identity(monkeypatch): monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co") diff --git a/web/services/auth_api.py b/web/services/auth_api.py index 96372638..ba96b869 100644 --- a/web/services/auth_api.py +++ b/web/services/auth_api.py @@ -92,12 +92,27 @@ def _subscription_row_is_trial(row: Any) -> bool: return "trial" in plan_code or "trial" in source +def _is_entitlement_scope(request: Request) -> bool: + return ( + str(request.query_params.get("scope") or "").strip().lower() + == "entitlement" + ) + + +def _state_points(request: Request) -> int: + try: + return max(0, int(getattr(request.state, "auth_points", 0) or 0)) + except Exception: + return 0 + + def get_auth_me_payload(request: Request) -> Dict[str, Any]: timer = _AuthMeTimer(request) authenticated_for_log: Optional[bool] = None outcome = "ok" status_code = 200 subscription_active_for_log: Optional[bool] = None + entitlement_scope = _is_entitlement_scope(request) try: request.state.skip_subscription_gate = True @@ -230,12 +245,13 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: subscription_queued_count = int( subscription_window.get("queued_count") or 0 ) - referral = timer.measure( - "referral_summary", - lambda: legacy_routes.SUPABASE_ENTITLEMENT.get_referral_summary( - user_id - ), - ) + if not entitlement_scope: + referral = timer.measure( + "referral_summary", + lambda: legacy_routes.SUPABASE_ENTITLEMENT.get_referral_summary( + user_id + ), + ) except HTTPException: raise except Exception: @@ -253,14 +269,18 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: subscription_queued_count = 0 referral = None - points = timer.measure( - "auth_points", - lambda: legacy_routes._resolve_auth_points(request), - ) - weekly_profile = timer.measure( - "weekly_profile", - lambda: legacy_routes._resolve_weekly_profile(request), - ) + if entitlement_scope: + points = _state_points(request) + weekly_profile = {"weekly_points": 0, "weekly_rank": None} + else: + points = timer.measure( + "auth_points", + lambda: legacy_routes._resolve_auth_points(request), + ) + weekly_profile = timer.measure( + "weekly_profile", + lambda: legacy_routes._resolve_weekly_profile(request), + ) def resolve_telegram_pricing() -> Any: if not user_id: @@ -275,7 +295,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: return pricing.resolve_price_for_telegram_id(telegram_id or None) telegram_pricing = None - if user_id: + if user_id and not entitlement_scope: try: telegram_pricing = timer.measure( "telegram_pricing",