feat: implement /api/auth/me proxy route with subscription-required state and fallback entitlement snapshots
This commit is contained in:
@@ -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",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user