Speed up entitlement auth sync
This commit is contained in:
@@ -92,6 +92,14 @@ function formatServerTiming(stages: AuthMeTimingStage[], totalMs: number) {
|
|||||||
.join(", ");
|
.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(
|
function finishAuthMeResponse(
|
||||||
response: NextResponse,
|
response: NextResponse,
|
||||||
timer: AuthMeTimer,
|
timer: AuthMeTimer,
|
||||||
@@ -486,7 +494,7 @@ export async function GET(req: NextRequest) {
|
|||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await timer.measure("backend_fetch", async () =>
|
res = await timer.measure("backend_fetch", async () =>
|
||||||
await fetch(`${API_BASE}/api/auth/me`, {
|
await fetch(buildBackendAuthMeUrl(req), {
|
||||||
headers: backendAuth.headers,
|
headers: backendAuth.headers,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async function warmSignupTrial(accessToken: string) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 2500);
|
const timeoutId = setTimeout(() => controller.abort(), 2500);
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_BASE}/api/auth/me`, {
|
await fetch(`${API_BASE.replace(/\/+$/, "")}/api/auth/me?scope=entitlement`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers,
|
headers,
|
||||||
signal: controller.signal,
|
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("!auth.authUserId") &&
|
||||||
authMeRouteSource.includes('req.headers.get("authorization")') &&
|
authMeRouteSource.includes('req.headers.get("authorization")') &&
|
||||||
authMeRouteSource.indexOf("authenticated: false") <
|
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",
|
"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(
|
assert(
|
||||||
authMeRouteSource.includes('reason: "prefer_snapshot_fast_path"') &&
|
authMeRouteSource.includes('reason: "prefer_snapshot_fast_path"') &&
|
||||||
authMeRouteSource.indexOf('reason: "prefer_snapshot_fast_path"') <
|
authMeRouteSource.indexOf('reason: "prefer_snapshot_fast_path"') <
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export type AuthMeResponse = {
|
|||||||
subscription_queued_count?: number | null;
|
subscription_queued_count?: number | null;
|
||||||
telegram_pricing?: TelegramPricing | null;
|
telegram_pricing?: TelegramPricing | null;
|
||||||
referral?: ReferralSummary | 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 = {
|
export type ReferralSummary = {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import type { User } from "@supabase/supabase-js";
|
import type { User } from "@supabase/supabase-js";
|
||||||
|
import {
|
||||||
|
buildAuthMePath,
|
||||||
|
mergeAccountAuthSnapshot,
|
||||||
|
} from "@/lib/auth-snapshot";
|
||||||
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -25,7 +30,7 @@ export interface UseAccountPaymentParams {
|
|||||||
backend: AuthMeResponse | null;
|
backend: AuthMeResponse | null;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
setUser: (user: User | null) => void;
|
setUser: (user: User | null) => void;
|
||||||
setBackend: (backend: AuthMeResponse | null) => void;
|
setBackend: Dispatch<SetStateAction<AuthMeResponse | null>>;
|
||||||
setErrorText: (text: string) => void;
|
setErrorText: (text: string) => void;
|
||||||
setUpdatedAt: (text: string) => void;
|
setUpdatedAt: (text: string) => void;
|
||||||
showOverlay: boolean;
|
showOverlay: boolean;
|
||||||
@@ -155,21 +160,45 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
|||||||
.then(({ data: { session } }) => session?.user ?? null)
|
.then(({ data: { session } }) => session?.user ?? null)
|
||||||
.catch(() => null as User | null)
|
.catch(() => null as User | null)
|
||||||
: Promise.resolve(null as User | null);
|
: Promise.resolve(null as User | null);
|
||||||
const authHeadersPromise = buildAuthedHeaders(false);
|
const [localUser, authHeaders] = await Promise.all([
|
||||||
const backendPromise = authHeadersPromise.then((headers) =>
|
userPromise,
|
||||||
fetch("/api/auth/me", { cache: "no-store", headers }),
|
buildAuthedHeaders(false),
|
||||||
);
|
]);
|
||||||
const [localUser, backendResult] = await Promise.all([userPromise, backendPromise]);
|
|
||||||
setUser(localUser);
|
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));
|
await new Promise((r) => setTimeout(r, 1200));
|
||||||
return fetchAuthSnapshot(false);
|
return fetchAuthSnapshot(false);
|
||||||
}
|
}
|
||||||
const raw = (await backendResult.text()).slice(0, 260);
|
throw error;
|
||||||
throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw));
|
|
||||||
}
|
}
|
||||||
let backendJson = (await backendResult.json()) as AuthMeResponse;
|
|
||||||
if (
|
if (
|
||||||
retry &&
|
retry &&
|
||||||
supabaseReady &&
|
supabaseReady &&
|
||||||
@@ -184,23 +213,31 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
|||||||
refreshedSession?.access_token || "",
|
refreshedSession?.access_token || "",
|
||||||
).trim();
|
).trim();
|
||||||
if (refreshedToken) {
|
if (refreshedToken) {
|
||||||
const retriedBackendResult = await fetch("/api/auth/me", {
|
latestHeaders = { Authorization: `Bearer ${refreshedToken}` };
|
||||||
cache: "no-store",
|
const retriedBackendJson = await readAuthSnapshot(latestHeaders, {
|
||||||
headers: { Authorization: `Bearer ${refreshedToken}` },
|
scope: "entitlement",
|
||||||
});
|
});
|
||||||
if (retriedBackendResult.ok) {
|
backendJson = retriedBackendJson;
|
||||||
const retriedBackendJson =
|
|
||||||
(await retriedBackendResult.json()) as AuthMeResponse;
|
|
||||||
backendJson = retriedBackendJson;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the first response; the UI treats a logged-in local user with
|
// Keep the first response; the UI treats a logged-in local user with
|
||||||
// an unauthenticated backend snapshot as a temporary sync state.
|
// an unauthenticated backend snapshot as a temporary sync state.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setBackend(backendJson);
|
setBackend((previous) => mergeAccountAuthSnapshot(previous, backendJson));
|
||||||
setUpdatedAt(new Date().toISOString());
|
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;
|
return backendJson;
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
requestWalletWithTimeout,
|
requestWalletWithTimeout,
|
||||||
} from "./payment-utils";
|
} from "./payment-utils";
|
||||||
import { trackAppEvent } from "@/lib/app-analytics";
|
import { trackAppEvent } from "@/lib/app-analytics";
|
||||||
|
import { buildAuthMePath } from "@/lib/auth-snapshot";
|
||||||
import {
|
import {
|
||||||
assertExpectedPaymentReceiver,
|
assertExpectedPaymentReceiver,
|
||||||
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
|
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
|
||||||
@@ -189,7 +190,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
};
|
};
|
||||||
const authRes = await fetch("/api/auth/me", {
|
const authRes = await fetch(buildAuthMePath({ scope: "entitlement" }), {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
|
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
createAuthProfileRequestCache,
|
createAuthProfileRequestCache,
|
||||||
loadTerminalAuthProfile,
|
loadTerminalAuthProfile,
|
||||||
} from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap";
|
} from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap";
|
||||||
|
import { buildAuthMePath } from "@/lib/auth-snapshot";
|
||||||
import {
|
import {
|
||||||
cityListItemsToScanRows,
|
cityListItemsToScanRows,
|
||||||
mergeScanRowsWithCityFallbackRows,
|
mergeScanRowsWithCityFallbackRows,
|
||||||
@@ -1217,9 +1218,10 @@ function ScanTerminalScreen() {
|
|||||||
: null;
|
: null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
options?.preferSnapshot
|
buildAuthMePath({
|
||||||
? "/api/auth/me?prefer_snapshot=1"
|
preferSnapshot: options?.preferSnapshot,
|
||||||
: "/api/auth/me",
|
scope: "entitlement",
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers,
|
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
|
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):
|
def test_backend_entitlement_token_binds_forwarded_supabase_identity(monkeypatch):
|
||||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
|
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
|
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
|
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]:
|
def get_auth_me_payload(request: Request) -> Dict[str, Any]:
|
||||||
timer = _AuthMeTimer(request)
|
timer = _AuthMeTimer(request)
|
||||||
authenticated_for_log: Optional[bool] = None
|
authenticated_for_log: Optional[bool] = None
|
||||||
outcome = "ok"
|
outcome = "ok"
|
||||||
status_code = 200
|
status_code = 200
|
||||||
subscription_active_for_log: Optional[bool] = None
|
subscription_active_for_log: Optional[bool] = None
|
||||||
|
entitlement_scope = _is_entitlement_scope(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request.state.skip_subscription_gate = True
|
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_queued_count = int(
|
||||||
subscription_window.get("queued_count") or 0
|
subscription_window.get("queued_count") or 0
|
||||||
)
|
)
|
||||||
referral = timer.measure(
|
if not entitlement_scope:
|
||||||
"referral_summary",
|
referral = timer.measure(
|
||||||
lambda: legacy_routes.SUPABASE_ENTITLEMENT.get_referral_summary(
|
"referral_summary",
|
||||||
user_id
|
lambda: legacy_routes.SUPABASE_ENTITLEMENT.get_referral_summary(
|
||||||
),
|
user_id
|
||||||
)
|
),
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -253,14 +269,18 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
|
|||||||
subscription_queued_count = 0
|
subscription_queued_count = 0
|
||||||
referral = None
|
referral = None
|
||||||
|
|
||||||
points = timer.measure(
|
if entitlement_scope:
|
||||||
"auth_points",
|
points = _state_points(request)
|
||||||
lambda: legacy_routes._resolve_auth_points(request),
|
weekly_profile = {"weekly_points": 0, "weekly_rank": None}
|
||||||
)
|
else:
|
||||||
weekly_profile = timer.measure(
|
points = timer.measure(
|
||||||
"weekly_profile",
|
"auth_points",
|
||||||
lambda: legacy_routes._resolve_weekly_profile(request),
|
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:
|
def resolve_telegram_pricing() -> Any:
|
||||||
if not user_id:
|
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)
|
return pricing.resolve_price_for_telegram_id(telegram_id or None)
|
||||||
|
|
||||||
telegram_pricing = None
|
telegram_pricing = None
|
||||||
if user_id:
|
if user_id and not entitlement_scope:
|
||||||
try:
|
try:
|
||||||
telegram_pricing = timer.measure(
|
telegram_pricing = timer.measure(
|
||||||
"telegram_pricing",
|
"telegram_pricing",
|
||||||
|
|||||||
Reference in New Issue
Block a user