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