diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts index aef51c9b..1f87bf35 100644 --- a/frontend/app/api/auth/me/route.ts +++ b/frontend/app/api/auth/me/route.ts @@ -11,6 +11,12 @@ import { getLocalDevAuthPayload, isLocalFullAccessHost, } from "@/lib/local-dev-access"; +import { + applyEntitlementSnapshotCookie, + clearEntitlementSnapshotCookie, + entitlementSnapshotToAuthPayload, + readEntitlementSnapshot, +} from "@/lib/entitlement-snapshot"; import { hasSupabaseServerEnv } from "@/lib/supabase/server"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -68,14 +74,28 @@ async function getVerifiedBearerIdentity( function degradedAuthProfileResponse({ email, reason, + req, response, userId, }: { email: string | null; reason: string; + req: NextRequest; response: NextResponse | null; userId: string; }) { + const snapshotPayload = entitlementSnapshotToAuthPayload( + readEntitlementSnapshot(req, userId), + ); + if (snapshotPayload) { + const snapshotResponse = NextResponse.json({ + ...snapshotPayload, + email: snapshotPayload.email || email, + entitlement_snapshot_reason: reason, + }); + return applyAuthResponseCookies(snapshotResponse, response); + } + const degraded = NextResponse.json({ authenticated: true, user_id: userId, @@ -93,6 +113,44 @@ function degradedAuthProfileResponse({ return applyAuthResponseCookies(degraded, response); } +function snapshotAuthProfileResponse({ + email, + reason, + req, + response, + userId, +}: { + email: string | null; + reason: string; + req: NextRequest; + response: NextResponse | null; + userId: string; +}) { + const snapshotPayload = entitlementSnapshotToAuthPayload( + readEntitlementSnapshot(req, userId), + ); + if (!snapshotPayload) return null; + const snapshotResponse = NextResponse.json({ + ...snapshotPayload, + email: snapshotPayload.email || email, + entitlement_snapshot_reason: reason, + }); + return applyAuthResponseCookies(snapshotResponse, response); +} + +function applyEntitlementSnapshotFromAuthPayload( + response: NextResponse, + data: Record, +) { + if (data.authenticated === true && data.subscription_active === true) { + return applyEntitlementSnapshotCookie(response, data); + } + if (data.authenticated === false || data.subscription_active === false) { + return clearEntitlementSnapshotCookie(response); + } + return response; +} + export async function GET(req: NextRequest) { const requestHost = req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host; @@ -113,6 +171,13 @@ export async function GET(req: NextRequest) { } let auth: Awaited> | null = null; + let bearerIdentity: VerifiedBearerIdentity | null | undefined; + const preferSnapshot = req.nextUrl.searchParams.get("prefer_snapshot") === "1"; + const getBearerIdentityOnce = async () => { + if (bearerIdentity !== undefined) return bearerIdentity; + bearerIdentity = await getVerifiedBearerIdentity(req); + return bearerIdentity; + }; try { auth = await buildBackendRequestHeaders(req); if ( @@ -125,9 +190,27 @@ export async function GET(req: NextRequest) { subscription_active: false, points: 0, }); + if (!preferSnapshot) clearEntitlementSnapshotCookie(response); return applyAuthResponseCookies(response, auth.response); } + if (preferSnapshot) { + const identity = + auth.authUserId + ? { email: auth.authEmail || null, userId: auth.authUserId } + : await getBearerIdentityOnce(); + if (identity?.userId) { + const snapshotResponse = snapshotAuthProfileResponse({ + email: identity.email, + reason: "prefer_snapshot", + req, + response: auth.response, + userId: identity.userId, + }); + if (snapshotResponse) return snapshotResponse; + } + } + const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 6000); let res: Response; @@ -144,18 +227,20 @@ export async function GET(req: NextRequest) { return degradedAuthProfileResponse({ email: auth.authEmail || null, reason: `backend_${res.status}`, + req, response: auth.response, userId: auth.authUserId, }); } if (res.status === 401 || res.status === 403) { - const bearerIdentity = await getVerifiedBearerIdentity(req); - if (bearerIdentity) { + const identity = await getBearerIdentityOnce(); + if (identity) { return degradedAuthProfileResponse({ - email: bearerIdentity.email, + email: identity.email, reason: `backend_${res.status}`, + req, response: auth.response, - userId: bearerIdentity.userId, + userId: identity.userId, }); } const response = NextResponse.json({ @@ -163,6 +248,7 @@ export async function GET(req: NextRequest) { subscription_active: false, points: 0, }); + clearEntitlementSnapshotCookie(response); return applyAuthResponseCookies(response, auth.response); } if (!res.ok) { @@ -171,41 +257,59 @@ export async function GET(req: NextRequest) { return degradedAuthProfileResponse({ email: auth.authEmail || null, reason: `backend_${res.status}`, + req, response: auth.response, userId: auth.authUserId, }); } - const bearerIdentity = await getVerifiedBearerIdentity(req); - if (bearerIdentity) { + const identity = await getBearerIdentityOnce(); + if (identity) { return degradedAuthProfileResponse({ - email: bearerIdentity.email, + email: identity.email, reason: `backend_${res.status}`, + req, response: auth.response, - userId: bearerIdentity.userId, + userId: identity.userId, }); } const response = buildUpstreamErrorResponse(res.status, raw); return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); + if (data?.authenticated === true && data?.subscription_active == null) { + const userId = String(data.user_id || auth.authUserId || "").trim(); + if (userId) { + const snapshotResponse = snapshotAuthProfileResponse({ + email: String(data.email || auth.authEmail || "").trim() || null, + reason: "subscription_unknown", + req, + response: auth.response, + userId, + }); + if (snapshotResponse) return snapshotResponse; + } + } const response = NextResponse.json(data); + applyEntitlementSnapshotFromAuthPayload(response, data); return applyAuthResponseCookies(response, auth.response); } catch (error) { if (auth?.authUserId) { return degradedAuthProfileResponse({ email: auth.authEmail || null, reason: String(error), + req, response: auth.response, userId: auth.authUserId, }); } - const bearerIdentity = await getVerifiedBearerIdentity(req); - if (bearerIdentity) { + const identity = await getBearerIdentityOnce(); + if (identity) { return degradedAuthProfileResponse({ - email: bearerIdentity.email, + email: identity.email, reason: String(error), + req, response: auth?.response || null, - userId: bearerIdentity.userId, + userId: identity.userId, }); } return buildProxyExceptionResponse(error, { diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 9b08d3a2..e3616d06 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -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 => { + async ( + accessToken?: string | null, + options?: { preferSnapshot?: boolean }, + ): Promise => { const headers: Record = { 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; }, [], ); + 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 ( diff --git a/frontend/components/dashboard/scan-terminal/__tests__/entitlementSnapshot.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/entitlementSnapshot.test.ts new file mode 100644 index 00000000..e7d424a9 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/entitlementSnapshot.test.ts @@ -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", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts index 9e4ddd6e..97fd3cf1 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalAuthBootstrap.test.ts @@ -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", + ); } diff --git a/frontend/components/dashboard/scan-terminal/terminal-access-state.ts b/frontend/components/dashboard/scan-terminal/terminal-access-state.ts index 97293887..52e61723 100644 --- a/frontend/components/dashboard/scan-terminal/terminal-access-state.ts +++ b/frontend/components/dashboard/scan-terminal/terminal-access-state.ts @@ -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) { diff --git a/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts b/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts index 9b80afb1..85f2727e 100644 --- a/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts +++ b/frontend/components/dashboard/scan-terminal/terminal-auth-bootstrap.ts @@ -13,7 +13,10 @@ type SupabaseSessionResult = { type LoadTerminalAuthProfileOptions = { getSession: () => Promise; hasSupabasePublicEnv: boolean; - loadAuthProfile: (accessToken?: string | null) => Promise; + loadAuthProfile: ( + accessToken?: string | null, + options?: { preferSnapshot?: boolean }, + ) => Promise; }; 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; })(), diff --git a/frontend/lib/entitlement-snapshot.ts b/frontend/lib/entitlement-snapshot.ts new file mode 100644 index 00000000..a80ecffc --- /dev/null +++ b/frontend/lib/entitlement-snapshot.ts @@ -0,0 +1,283 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { NextRequest, NextResponse } from "next/server"; + +export const ENTITLEMENT_SNAPSHOT_COOKIE = "polyweather_entitlement_snapshot"; +const DEFAULT_MAX_AGE_SECONDS = 15 * 60; + +export type EntitlementSnapshotPayload = { + v: 1; + user_id: string; + email?: string | null; + status: "active"; + subscription_plan_code?: string | null; + subscription_expires_at?: string | null; + subscription_total_expires_at?: string | null; + subscription_queued_days?: number | null; + points?: number | null; + issued_at: string; +}; + +export type EntitlementSnapshotDecodeOptions = { + expectedUserId?: string | null; + maxAgeSeconds?: number; + nowMs?: number; +}; + +export type SnapshotAuthPayload = { + authenticated: true; + user_id: string; + email: string | null; + subscription_active: true; + subscription_plan_code: string | null; + subscription_expires_at: string | null; + subscription_total_expires_at: string | null; + subscription_queued_days: number; + subscription_queued_count: 0; + points: number; + entitlement_snapshot: true; +}; + +function encodeBase64Url(value: string) { + return Buffer.from(value, "utf8").toString("base64url"); +} + +function decodeBase64Url(value: string) { + return Buffer.from(value, "base64url").toString("utf8"); +} + +function hmac(value: string, secret: string) { + return createHmac("sha256", secret).update(value).digest("base64url"); +} + +function safeEqual(left: string, right: string) { + try { + const leftBytes = Buffer.from(left); + const rightBytes = Buffer.from(right); + return ( + leftBytes.length === rightBytes.length && + timingSafeEqual(leftBytes, rightBytes) + ); + } catch { + return false; + } +} + +function parseDateMs(value: string | null | undefined) { + const ms = Date.parse(String(value || "")); + return Number.isFinite(ms) ? ms : null; +} + +function snapshotMaxAgeSeconds() { + const raw = Number( + process.env.POLYWEATHER_ENTITLEMENT_SNAPSHOT_MAX_AGE_SEC || "", + ); + if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_MAX_AGE_SECONDS; + return Math.max(60, Math.min(Math.floor(raw), 6 * 60 * 60)); +} + +function expireSnapshotCookie(response: NextResponse) { + response.cookies.set(ENTITLEMENT_SNAPSHOT_COOKIE, "", { + httpOnly: true, + maxAge: 0, + path: "/", + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + }); +} + +export function getEntitlementSnapshotSecret() { + return ( + process.env.POLYWEATHER_ENTITLEMENT_SNAPSHOT_SECRET?.trim() || + process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || + process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || + "" + ); +} + +export function encodeEntitlementSnapshot( + payload: EntitlementSnapshotPayload, + secret = getEntitlementSnapshotSecret(), +) { + if (!secret) return ""; + const body = encodeBase64Url(JSON.stringify(payload)); + return `${body}.${hmac(body, secret)}`; +} + +export function decodeEntitlementSnapshot( + token: string | null | undefined, + secret = getEntitlementSnapshotSecret(), + options: EntitlementSnapshotDecodeOptions = {}, +): EntitlementSnapshotPayload | null { + if (!token || !secret) return null; + const [body, signature, extra] = String(token).split("."); + if (!body || !signature || extra != null) return null; + if (!safeEqual(signature, hmac(body, secret))) return null; + + let payload: unknown; + try { + payload = JSON.parse(decodeBase64Url(body)); + } catch { + return null; + } + if (!payload || typeof payload !== "object") return null; + const record = payload as Record; + const userId = String(record.user_id || "").trim(); + if ( + record.v !== 1 || + record.status !== "active" || + !userId || + (options.expectedUserId && + userId !== String(options.expectedUserId || "").trim()) + ) { + return null; + } + + const nowMs = options.nowMs ?? Date.now(); + const maxAgeSeconds = Math.max( + 1, + options.maxAgeSeconds ?? snapshotMaxAgeSeconds(), + ); + const issuedAtMs = parseDateMs(String(record.issued_at || "")); + const expiresAtMs = parseDateMs( + String( + record.subscription_total_expires_at || + record.subscription_expires_at || + "", + ), + ); + if (issuedAtMs == null || nowMs - issuedAtMs > maxAgeSeconds * 1000) { + return null; + } + if (expiresAtMs == null || expiresAtMs <= nowMs) { + return null; + } + + return { + v: 1, + user_id: userId, + email: String(record.email || "").trim() || null, + status: "active", + subscription_plan_code: + String(record.subscription_plan_code || "").trim() || null, + subscription_expires_at: + String(record.subscription_expires_at || "").trim() || null, + subscription_total_expires_at: + String(record.subscription_total_expires_at || "").trim() || null, + subscription_queued_days: Math.max( + 0, + Number(record.subscription_queued_days ?? 0) || 0, + ), + points: Math.max(0, Number(record.points ?? 0) || 0), + issued_at: String(record.issued_at || "").trim(), + }; +} + +export function entitlementSnapshotToAuthPayload( + snapshot: EntitlementSnapshotPayload | null, +): SnapshotAuthPayload | null { + if (!snapshot) return null; + return { + authenticated: true, + user_id: snapshot.user_id, + email: snapshot.email || null, + points: Number(snapshot.points ?? 0), + subscription_active: true, + subscription_plan_code: snapshot.subscription_plan_code ?? null, + subscription_expires_at: snapshot.subscription_expires_at ?? null, + subscription_total_expires_at: + snapshot.subscription_total_expires_at ?? + snapshot.subscription_expires_at ?? + null, + subscription_queued_days: Math.max( + 0, + Number(snapshot.subscription_queued_days ?? 0), + ), + subscription_queued_count: 0, + entitlement_snapshot: true, + }; +} + +export function authPayloadToEntitlementSnapshot( + payload: Record, +): EntitlementSnapshotPayload | null { + const userId = String(payload.user_id || "").trim(); + if ( + payload.authenticated !== true || + payload.subscription_active !== true || + !userId + ) { + return null; + } + const expiresAt = + String(payload.subscription_total_expires_at || "").trim() || + String(payload.subscription_expires_at || "").trim(); + const expiresAtMs = parseDateMs(expiresAt); + if (expiresAtMs == null || expiresAtMs <= Date.now()) return null; + return { + v: 1, + user_id: userId, + email: String(payload.email || "").trim() || null, + status: "active", + subscription_plan_code: + String(payload.subscription_plan_code || "").trim() || null, + subscription_expires_at: + String(payload.subscription_expires_at || "").trim() || null, + subscription_total_expires_at: + String(payload.subscription_total_expires_at || "").trim() || + String(payload.subscription_expires_at || "").trim() || + null, + subscription_queued_days: Math.max( + 0, + Number(payload.subscription_queued_days ?? 0) || 0, + ), + points: Math.max(0, Number(payload.points ?? 0) || 0), + issued_at: new Date().toISOString(), + }; +} + +export function readEntitlementSnapshot( + req: NextRequest, + expectedUserId?: string | null, +) { + return decodeEntitlementSnapshot( + req.cookies.get(ENTITLEMENT_SNAPSHOT_COOKIE)?.value || "", + getEntitlementSnapshotSecret(), + { expectedUserId }, + ); +} + +export function applyEntitlementSnapshotCookie( + response: NextResponse, + payload: Record, +) { + const snapshot = authPayloadToEntitlementSnapshot(payload); + const token = snapshot ? encodeEntitlementSnapshot(snapshot) : ""; + if (!snapshot || !token) { + expireSnapshotCookie(response); + return response; + } + const totalExpiryMs = parseDateMs(snapshot.subscription_total_expires_at); + const nowMs = Date.now(); + const maxAge = Math.max( + 1, + Math.min( + snapshotMaxAgeSeconds(), + totalExpiryMs == null + ? snapshotMaxAgeSeconds() + : Math.floor((totalExpiryMs - nowMs) / 1000), + ), + ); + response.cookies.set(ENTITLEMENT_SNAPSHOT_COOKIE, token, { + httpOnly: true, + maxAge, + path: "/", + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + }); + return response; +} + +export function clearEntitlementSnapshotCookie(response: NextResponse) { + expireSnapshotCookie(response); + return response; +}