feat: implement /api/auth/me proxy route with subscription-required state and fallback entitlement snapshots

This commit is contained in:
2569718930@qq.com
2026-05-30 21:01:48 +08:00
parent aa583e7440
commit d9eea721ef
6 changed files with 205 additions and 13 deletions
+43 -9
View File
@@ -17,6 +17,10 @@ import {
entitlementSnapshotToAuthPayload, entitlementSnapshotToAuthPayload,
readEntitlementSnapshot, readEntitlementSnapshot,
} from "@/lib/entitlement-snapshot"; } from "@/lib/entitlement-snapshot";
import {
buildSubscriptionRequiredAuthProfile,
isSubscriptionRequiredBackendResponse,
} from "@/lib/auth-profile-proxy";
import { hasSupabaseServerEnv } from "@/lib/supabase/server"; import { hasSupabaseServerEnv } from "@/lib/supabase/server";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -113,6 +117,22 @@ function degradedAuthProfileResponse({
return applyAuthResponseCookies(degraded, response); return applyAuthResponseCookies(degraded, response);
} }
function subscriptionRequiredAuthProfileResponse({
email,
response,
userId,
}: {
email: string | null;
response: NextResponse | null;
userId: string;
}) {
const inactive = NextResponse.json(
buildSubscriptionRequiredAuthProfile({ email, userId }),
);
clearEntitlementSnapshotCookie(inactive);
return applyAuthResponseCookies(inactive, response);
}
function snapshotAuthProfileResponse({ function snapshotAuthProfileResponse({
email, email,
reason, reason,
@@ -223,16 +243,30 @@ export async function GET(req: NextRequest) {
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
if ((res.status === 401 || res.status === 403) && auth.authUserId) {
return degradedAuthProfileResponse({
email: auth.authEmail || null,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: auth.authUserId,
});
}
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
const raw = await res.text();
const authIdentity = auth.authUserId
? { email: auth.authEmail || null, userId: auth.authUserId }
: await getBearerIdentityOnce();
if (
authIdentity?.userId &&
isSubscriptionRequiredBackendResponse(res.status, raw)
) {
return subscriptionRequiredAuthProfileResponse({
email: authIdentity.email,
response: auth.response,
userId: authIdentity.userId,
});
}
if (auth.authUserId) {
return degradedAuthProfileResponse({
email: auth.authEmail || null,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: auth.authUserId,
});
}
const identity = await getBearerIdentityOnce(); const identity = await getBearerIdentityOnce();
if (identity) { if (identity) {
return degradedAuthProfileResponse({ return degradedAuthProfileResponse({
@@ -282,11 +282,12 @@ export function runTests() {
"ops subscription grant route must fall back to direct Supabase grant and resolve users via indexed profiles before Auth Admin", "ops subscription grant route must fall back to direct Supabase grant and resolve users via indexed profiles before Auth Admin",
); );
assert( assert(
authMeRouteSource.includes("if ((res.status === 401 || res.status === 403) && auth.authUserId)") && authMeRouteSource.includes("isSubscriptionRequiredBackendResponse(res.status, raw)") &&
authMeRouteSource.includes("subscriptionRequiredAuthProfileResponse") &&
authMeRouteSource.includes("degradedAuthProfileResponse") && authMeRouteSource.includes("degradedAuthProfileResponse") &&
authMeRouteSource.includes("reason: `backend_${res.status}`") && authMeRouteSource.includes("reason: `backend_${res.status}`") &&
authMeRouteSource.includes("subscription_active: null"), authMeRouteSource.includes("subscription_active: null"),
"auth profile proxy must preserve authenticated identity with unknown subscription on backend 401/403 instead of forcing a false paywall", "auth profile proxy must only convert explicit subscription-required 403 responses to inactive access while preserving unrelated backend 401/403 responses as unknown subscription",
); );
assert( assert(
(authMeRouteSource.match(/buildBackendRequestHeaders\(req\)/g) || []).length === 1 && (authMeRouteSource.match(/buildBackendRequestHeaders\(req\)/g) || []).length === 1 &&
@@ -28,6 +28,46 @@ type CityThreshold = {
kind: "gte" | "lte"; kind: "gte" | "lte";
}; };
function isFiniteChartValue(value: unknown) {
return typeof value === "number" && Number.isFinite(value);
}
function hasDrawableTemperatureChartContent({
activeSeries,
probabilityOverlay,
zoomedData,
}: {
activeSeries: EvidenceSeries[];
probabilityOverlay: ProbabilityOverlay | null;
zoomedData: Array<Record<string, any>>;
}) {
if (probabilityOverlay?.muLine || probabilityOverlay?.bands.length) return true;
return activeSeries.some((series) =>
zoomedData.some((point, index) => {
const value = point?.[series.key] ?? series.values[index];
return isFiniteChartValue(value);
}),
);
}
function shouldKeepTemperatureChartLoading({
row,
isHourlyLoading,
activeSeries,
probabilityOverlay,
zoomedData,
}: {
row: ScanOpportunityRow | null;
isHourlyLoading: boolean;
activeSeries: EvidenceSeries[];
probabilityOverlay: ProbabilityOverlay | null;
zoomedData: Array<Record<string, any>>;
}) {
if (!row?.city) return false;
if (isHourlyLoading) return true;
return !hasDrawableTemperatureChartContent({ activeSeries, probabilityOverlay, zoomedData });
}
function TemperatureChartCanvasComponent({ function TemperatureChartCanvasComponent({
isEn, isEn,
compact, compact,
@@ -133,6 +173,13 @@ function TemperatureChartCanvasComponent({
hasRunwayData && hasRunwayData &&
individualRunwaySeriesCount > 1 && individualRunwaySeriesCount > 1 &&
collapsedRunwaySeries.length < chartSeries.length; collapsedRunwaySeries.length < chartSeries.length;
const shouldShowChartLoading = shouldKeepTemperatureChartLoading({
row,
isHourlyLoading,
activeSeries,
probabilityOverlay,
zoomedData,
});
return ( return (
<div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}> <div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}>
@@ -195,7 +242,7 @@ function TemperatureChartCanvasComponent({
)} )}
</div> </div>
<div ref={chartHostRef} className={clsx("relative flex-1", compact ? "min-h-[120px]" : "min-h-[220px]")}> <div ref={chartHostRef} className={clsx("relative flex-1", compact ? "min-h-[120px]" : "min-h-[220px]")}>
{canRenderChart && ( {canRenderChart && !shouldShowChartLoading && (
<ReComposedChart <ReComposedChart
width={chartWidth} width={chartWidth}
height={chartHeight} height={chartHeight}
@@ -338,7 +385,7 @@ function TemperatureChartCanvasComponent({
</ReComposedChart> </ReComposedChart>
)} )}
</div> </div>
{isHourlyLoading && ( {shouldShowChartLoading && (
<div className="pointer-events-none absolute inset-2 z-10 grid place-items-center bg-white/65 backdrop-blur-[1px]"> <div className="pointer-events-none absolute inset-2 z-10 grid place-items-center bg-white/65 backdrop-blur-[1px]">
<div className="flex items-center gap-2 rounded border border-slate-200 bg-white px-3 py-2 text-[11px] font-semibold text-slate-600 shadow-sm"> <div className="flex items-center gap-2 rounded border border-slate-200 bg-white px-3 py-2 text-[11px] font-semibold text-slate-600 shadow-sm">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-slate-300 border-t-blue-500" /> <span className="h-3 w-3 animate-spin rounded-full border-2 border-slate-300 border-t-blue-500" />
@@ -351,3 +398,4 @@ function TemperatureChartCanvasComponent({
} }
export const TemperatureChartCanvas = memo(TemperatureChartCanvasComponent); export const TemperatureChartCanvas = memo(TemperatureChartCanvasComponent);
export const __shouldKeepTemperatureChartLoadingForTest = shouldKeepTemperatureChartLoading;
@@ -2,6 +2,10 @@ import {
loadTerminalAuthProfile, loadTerminalAuthProfile,
type TerminalAuthProfilePayload, type TerminalAuthProfilePayload,
} from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap"; } from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap";
import {
buildSubscriptionRequiredAuthProfile,
isSubscriptionRequiredBackendResponse,
} from "@/lib/auth-profile-proxy";
function assert(condition: unknown, message: string) { function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message); if (!condition) throw new Error(message);
@@ -148,4 +152,31 @@ export async function runTests() {
failedWithTransientAuthError, failedWithTransientAuthError,
"terminal auth bootstrap must not resolve to an anonymous paywall when a bearer session exists but the auth profile request is transiently failing", "terminal auth bootstrap must not resolve to an anonymous paywall when a bearer session exists but the auth profile request is transiently failing",
); );
assert(
isSubscriptionRequiredBackendResponse(
403,
'{"detail":"Subscription required"}',
) === true,
"auth profile proxy should recognize backend subscription-required responses as confirmed inactive access",
);
assert(
isSubscriptionRequiredBackendResponse(
403,
'{"detail":"temporary entitlement outage"}',
) === false,
"auth profile proxy should keep unrelated backend 403 responses in the transient/degraded path",
);
const subscriptionRequiredProfile = buildSubscriptionRequiredAuthProfile({
email: "user@example.com",
userId: "user-1",
});
assert(
subscriptionRequiredProfile.authenticated === true &&
subscriptionRequiredProfile.user_id === "user-1" &&
subscriptionRequiredProfile.subscription_active === false &&
!("degraded_auth_profile" in subscriptionRequiredProfile),
"auth profile proxy must return confirmed inactive access instead of an endless unknown subscription state",
);
} }
@@ -1,5 +1,6 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { __shouldKeepTemperatureChartLoadingForTest } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas";
function assert(condition: unknown, message: string) { function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message); if (!condition) throw new Error(message);
@@ -118,4 +119,38 @@ export function runTests() {
chartCanvasSource.includes("TemperatureChartCanvasComponent"), chartCanvasSource.includes("TemperatureChartCanvasComponent"),
"temperature chart canvas must be memoized so unrelated terminal state does not remount Recharts", "temperature chart canvas must be memoized so unrelated terminal state does not remount Recharts",
); );
assert(
__shouldKeepTemperatureChartLoadingForTest({
row: { city: "Moscow" } as any,
isHourlyLoading: false,
activeSeries: [],
probabilityOverlay: null,
zoomedData: [
{ label: "00:00", ts: 1 },
{ label: "05:00", ts: 2 },
],
}),
"temperature chart must keep loading instead of rendering an empty axis grid when no drawable series is available",
);
assert(
!__shouldKeepTemperatureChartLoadingForTest({
row: { city: "Moscow" } as any,
isHourlyLoading: false,
activeSeries: [
{
key: "current",
label: "Current reference",
source: "Live",
color: "#009688",
values: [13, 13],
},
] as any,
probabilityOverlay: null,
zoomedData: [
{ label: "00:00", ts: 1, current: 13 },
{ label: "05:00", ts: 2, current: 13 },
],
}),
"temperature chart should render once a visible series has drawable values",
);
} }
+43
View File
@@ -0,0 +1,43 @@
export type AuthProfileIdentity = {
email: string | null;
userId: string;
};
function extractErrorDetail(raw: string) {
const text = String(raw || "").trim();
if (!text) return "";
try {
const parsed = JSON.parse(text);
if (typeof parsed?.detail === "string") return parsed.detail;
if (typeof parsed?.error === "string") return parsed.error;
} catch {}
return text;
}
export function isSubscriptionRequiredBackendResponse(
status: number,
raw: string,
) {
return (
status === 403 &&
extractErrorDetail(raw).trim().toLowerCase() === "subscription required"
);
}
export function buildSubscriptionRequiredAuthProfile(
identity: AuthProfileIdentity,
) {
return {
authenticated: true,
user_id: identity.userId,
email: identity.email,
subscription_active: false,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
subscription_required: true,
};
}