Speed up entitlement auth sync
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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"') <
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<SetStateAction<AuthMeResponse | null>>;
|
||||
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<string, string>,
|
||||
options: Parameters<typeof buildAuthMePath>[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;
|
||||
},
|
||||
[
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<T extends AuthSnapshotLike>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
+35
-15
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user