Improve terminal entitlement resilience

This commit is contained in:
2569718930@qq.com
2026-05-30 19:10:19 +08:00
parent 097971f107
commit e864181c3c
7 changed files with 594 additions and 29 deletions
@@ -91,7 +91,13 @@ function createLocalAccess(): ProAccessState {
};
}
function createTransientAccess(error: unknown): ProAccessState {
return {
...createEmptyAccess(true),
authenticated: true,
error: String(error),
};
}
const TERM = {
cityThreshold: { en: "City / Threshold", zh: "城市 / 阈值" },
@@ -931,20 +937,42 @@ function ScanTerminalScreen() {
);
const loadAuthProfile = useCallback(
async (accessToken?: string | null): Promise<AuthProfilePayload> => {
async (
accessToken?: string | null,
options?: { preferSnapshot?: boolean },
): Promise<AuthProfilePayload> => {
const headers: Record<string, string> = { Accept: "application/json" };
const token = String(accessToken || "").trim();
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch("/api/auth/me", {
cache: "no-store",
headers,
});
const response = await fetch(
options?.preferSnapshot
? "/api/auth/me?prefer_snapshot=1"
: "/api/auth/me",
{
cache: "no-store",
headers,
},
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AuthProfilePayload>;
},
[],
);
const refreshLiveAuthProfile = useCallback(async () => {
const supabaseEnabled = hasSupabasePublicEnv();
const payload = await loadTerminalAuthProfile({
getSession: () =>
supabaseEnabled
? getSupabaseBrowserClient().auth.getSession()
: Promise.resolve({ data: { session: null } }),
hasSupabasePublicEnv: supabaseEnabled,
loadAuthProfile: (accessToken) =>
loadAuthProfile(accessToken, { preferSnapshot: false }),
});
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
}, [loadAuthProfile]);
// Listen to Supabase auth events (e.g. token refreshed, signed out)
useEffect(() => {
if (!hasSupabasePublicEnv()) return;
@@ -1064,19 +1092,24 @@ function ScanTerminalScreen() {
.then((payload) => {
if (cancelled) return;
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
if (payload.entitlement_snapshot === true) {
window.setTimeout(() => {
if (!cancelled) void refreshLiveAuthProfile();
}, 0);
}
})
.catch((error) => {
if (cancelled) return;
setProAccess((prev) => (
prev.subscriptionActive
? { ...prev, loading: false, error: String(error) }
: { ...createEmptyAccess(false), error: String(error) }
: createTransientAccess(error)
));
});
return () => {
cancelled = true;
};
}, [loadAuthProfile]);
}, [loadAuthProfile, refreshLiveAuthProfile]);
useEffect(() => {
if (
@@ -0,0 +1,104 @@
import {
authPayloadToEntitlementSnapshot,
decodeEntitlementSnapshot,
encodeEntitlementSnapshot,
entitlementSnapshotToAuthPayload,
type EntitlementSnapshotPayload,
} from "@/lib/entitlement-snapshot";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const now = Date.parse("2026-05-30T10:00:00.000Z");
const payload: EntitlementSnapshotPayload = {
v: 1,
user_id: "user-1",
email: "user@example.com",
status: "active",
subscription_plan_code: "pro_monthly",
subscription_expires_at: "2026-06-30T00:00:00.000Z",
subscription_total_expires_at: "2026-06-30T00:00:00.000Z",
subscription_queued_days: 0,
points: 3500,
issued_at: "2026-05-30T10:00:00.000Z",
};
const token = encodeEntitlementSnapshot(payload, "snapshot-secret");
const decoded = decodeEntitlementSnapshot(token, "snapshot-secret", {
maxAgeSeconds: 15 * 60,
nowMs: now + 60_000,
expectedUserId: "user-1",
});
assert(
decoded?.user_id === "user-1",
"signed entitlement snapshot should decode for the matching user",
);
assert(
decoded?.subscription_plan_code === "pro_monthly",
"snapshot should preserve subscription metadata",
);
const authPayload = entitlementSnapshotToAuthPayload(decoded);
if (!authPayload) {
throw new Error("valid snapshot should convert to auth payload");
}
assert(
authPayload.subscription_active === true &&
authPayload.entitlement_snapshot === true &&
!("degraded_auth_profile" in authPayload),
"snapshot auth payload should grant only a snapshot-backed active terminal state",
);
const [body, signature] = token.split(".");
const tamperedBody =
`${body.slice(0, -1)}${body.endsWith("A") ? "B" : "A"}`;
const tampered = `${tamperedBody}.${signature}`;
assert(
decodeEntitlementSnapshot(tampered, "snapshot-secret", {
maxAgeSeconds: 15 * 60,
nowMs: now + 60_000,
expectedUserId: "user-1",
}) === null,
"tampered entitlement snapshots must be rejected",
);
assert(
decodeEntitlementSnapshot(token, "wrong-secret", {
maxAgeSeconds: 15 * 60,
nowMs: now + 60_000,
expectedUserId: "user-1",
}) === null,
"snapshots signed with another secret must be rejected",
);
assert(
decodeEntitlementSnapshot(token, "snapshot-secret", {
maxAgeSeconds: 15 * 60,
nowMs: now + 20 * 60_000,
expectedUserId: "user-1",
}) === null,
"old entitlement snapshots must expire quickly",
);
assert(
decodeEntitlementSnapshot(token, "snapshot-secret", {
maxAgeSeconds: 15 * 60,
nowMs: now + 60_000,
expectedUserId: "other-user",
}) === null,
"snapshots must be bound to the current Supabase user id",
);
assert(
authPayloadToEntitlementSnapshot({
authenticated: true,
user_id: "expired-user",
subscription_active: true,
subscription_total_expires_at: "2020-01-01T00:00:00.000Z",
}) === null,
"expired subscription payloads must not be cached as entitlement snapshots",
);
}
@@ -33,9 +33,13 @@ export async function runTests() {
calls.push("getSession");
return fastSession.promise;
},
loadAuthProfile: (accessToken) => {
loadAuthProfile: (accessToken, options) => {
const token = String(accessToken || "");
calls.push(token ? `profile:${token}` : "profile:cookie");
calls.push(
token
? `profile:${token}:${options?.preferSnapshot ? "snapshot" : "live"}`
: `profile:cookie:${options?.preferSnapshot ? "snapshot" : "live"}`,
);
if (!token) return slowCookieProfile.promise;
return Promise.resolve({
authenticated: true,
@@ -47,15 +51,15 @@ export async function runTests() {
await flushMicrotasks();
assert(
calls.includes("profile:cookie") && calls.includes("getSession"),
"terminal auth bootstrap should start cookie profile and Supabase session in parallel",
calls.includes("profile:cookie:snapshot") && calls.includes("getSession"),
"terminal auth bootstrap should start a snapshot-preferred cookie profile and Supabase session in parallel",
);
fastSession.resolve({ data: { session: { access_token: "fast-token" } } });
const result = await resultPromise;
assert(
calls.includes("profile:fast-token"),
"terminal auth bootstrap should retry auth profile with the Supabase bearer token",
calls.includes("profile:fast-token:snapshot"),
"terminal auth bootstrap should retry auth profile with the Supabase bearer token and snapshot hint",
);
assert(
result.authenticated === true && result.user_id === "bearer-user",
@@ -118,4 +122,30 @@ export async function runTests() {
coldStartResult.subscription_active === true,
"terminal auth bootstrap should prefer the bearer-confirmed active Pro profile over a degraded cookie profile",
);
const failingBearerResult = loadTerminalAuthProfile({
hasSupabasePublicEnv: true,
getSession: () =>
Promise.resolve({ data: { session: { access_token: "paid-token" } } }),
loadAuthProfile: (accessToken) => {
if (!accessToken) {
return Promise.resolve({
authenticated: false,
subscription_active: false,
points: 0,
});
}
return Promise.reject(new Error("HTTP 500"));
},
});
let failedWithTransientAuthError = false;
try {
await failingBearerResult;
} catch (error) {
failedWithTransientAuthError = String(error).includes("HTTP 500");
}
assert(
failedWithTransientAuthError,
"terminal auth bootstrap must not resolve to an anonymous paywall when a bearer session exists but the auth profile request is transiently failing",
);
}
@@ -10,6 +10,7 @@ export type AuthProfilePayload = {
subscription_queued_days?: number | null;
points?: number | null;
degraded_auth_profile?: boolean | null;
entitlement_snapshot?: boolean | null;
};
function queuedDays(value: unknown) {
@@ -13,7 +13,10 @@ type SupabaseSessionResult = {
type LoadTerminalAuthProfileOptions = {
getSession: () => Promise<SupabaseSessionResult>;
hasSupabasePublicEnv: boolean;
loadAuthProfile: (accessToken?: string | null) => Promise<TerminalAuthProfilePayload>;
loadAuthProfile: (
accessToken?: string | null,
options?: { preferSnapshot?: boolean },
) => Promise<TerminalAuthProfilePayload>;
};
type SettledProfile =
@@ -30,6 +33,13 @@ function settleProfile(
function firstKnownProfile(cookieResult: SettledProfile, bearerResult: SettledProfile) {
if (bearerResult.ok && bearerResult.payload?.authenticated) return bearerResult.payload;
if (
!bearerResult.ok &&
cookieResult.ok &&
cookieResult.payload?.authenticated === false
) {
throw bearerResult.error;
}
if (cookieResult.ok && cookieResult.payload) return cookieResult.payload;
if (bearerResult.ok && bearerResult.payload) return bearerResult.payload;
if (!cookieResult.ok) throw cookieResult.error;
@@ -68,7 +78,7 @@ export async function loadTerminalAuthProfile({
};
const cookieProfile = settleProfile(
loadAuthProfile(null).then((payload) => {
loadAuthProfile(null, { preferSnapshot: true }).then((payload) => {
resolveIfAuthenticated(payload);
return payload;
}),
@@ -83,7 +93,7 @@ export async function loadTerminalAuthProfile({
sessionResult?.data?.session?.access_token || "",
).trim();
if (!accessToken) return null;
const payload = await loadAuthProfile(accessToken);
const payload = await loadAuthProfile(accessToken, { preferSnapshot: true });
resolveIfAuthenticated(payload);
return payload;
})(),