Improve terminal entitlement resilience
This commit is contained in:
@@ -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",
|
||||
);
|
||||
}
|
||||
+36
-6
@@ -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;
|
||||
})(),
|
||||
|
||||
Reference in New Issue
Block a user