diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx
index 0d8e14be..9b08d3a2 100644
--- a/frontend/components/dashboard/ScanTerminalDashboard.tsx
+++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx
@@ -127,7 +127,7 @@ const TERM = {
logIn: { en: "Log in", zh: "登录" },
createAccount: { en: "Create an account", zh: "注册账号" },
learnAbout: { en: "Learn about PolyWeather", zh: "了解 PolyWeather" },
- proAccessRequired: { en: "Pro Access Required", zh: "需要付费订阅" },
+ proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
proDesc: {
en: "The PolyWeather terminal is a paid product. Subscribe to unlock real-time weather-signal intelligence.",
zh: "PolyWeather 决策台为付费产品。订阅以解锁实时天气信号情报。",
@@ -137,7 +137,7 @@ const TERM = {
zh: "按月计费,随时可取消。通过 Polygon 链 USDC 支付。",
},
month: { en: "/ month", zh: "/ 月" },
- subscribeNow: { en: "Subscribe Now — $10/mo", zh: "立即订阅 — $10/月" },
+ subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" },
subscribePrompt: {
en: "You need an active subscription to access the terminal.",
zh: "你需要开通有效订阅才能访问决策台。",
@@ -1078,6 +1078,62 @@ function ScanTerminalScreen() {
};
}, [loadAuthProfile]);
+ useEffect(() => {
+ if (
+ !hydrated ||
+ canUseLocalFullAccess ||
+ !proAccess.authenticated ||
+ !proAccess.loading ||
+ proAccess.subscriptionActive ||
+ typeof fetch !== "function"
+ ) {
+ return;
+ }
+
+ let cancelled = false;
+ const supabaseEnabled = hasSupabasePublicEnv();
+ const retryAuthProfile = async () => {
+ try {
+ const payload = await loadTerminalAuthProfile({
+ getSession: () =>
+ supabaseEnabled
+ ? getSupabaseBrowserClient().auth.getSession()
+ : Promise.resolve({ data: { session: null } }),
+ hasSupabasePublicEnv: supabaseEnabled,
+ loadAuthProfile,
+ });
+ if (cancelled) return;
+ setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
+ } catch (error) {
+ if (cancelled) return;
+ setProAccess((prev) =>
+ prev.loading && prev.authenticated && !prev.subscriptionActive
+ ? { ...prev, error: String(error) }
+ : prev,
+ );
+ }
+ };
+
+ const firstRetry = window.setTimeout(() => {
+ void retryAuthProfile();
+ }, 1500);
+ const interval = window.setInterval(() => {
+ void retryAuthProfile();
+ }, 5000);
+ return () => {
+ cancelled = true;
+ window.clearTimeout(firstRetry);
+ window.clearInterval(interval);
+ };
+ }, [
+ canUseLocalFullAccess,
+ hydrated,
+ loadAuthProfile,
+ proAccess.authenticated,
+ proAccess.loading,
+ proAccess.subscriptionActive,
+ ]);
+
useEffect(() => {
setSelectedRegionKey("all");
setLocalTimezoneOffsetSeconds(-new Date().getTimezoneOffset() * 60);
diff --git a/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx b/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx
index aa6cf5d6..3c5cd7df 100644
--- a/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx
+++ b/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx
@@ -5,9 +5,9 @@ import { LockKeyhole, CreditCard, LogIn } from "lucide-react";
const ACCESS_TERM = {
signInToContinue: { en: "Sign in to continue", zh: "请先登录" },
- proAccessRequired: { en: "Pro Access Required", zh: "需要付费订阅" },
- month: { en: "/ month", zh: "/ 月" },
- subscribeNow: { en: "Subscribe Now — $10/mo", zh: "立即订阅 — $10/月" },
+ proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
+ month: { en: "/ 30 days", zh: "/ 30 天" },
+ subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" },
backToProduct: { en: "Back to product overview", zh: "返回产品介绍页" },
} as const;
@@ -19,16 +19,16 @@ function t(key: keyof typeof ACCESS_TERM, isEn: boolean) {
function SubscriptionGate({ isEn }: { isEn: boolean }) {
const features = isEn
? [
- "Real-time METAR observations across 500+ stations",
- "DEB forecast blends with 0–240h horizon",
- "AI decision cards with Poly-score ranking",
- "Historical backtesting & weather signals",
+ "Real-time station, METAR, and runway signals",
+ "DEB forecast curves and model comparison",
+ "Full terminal grid with high-frequency refresh",
+ "API and paid Telegram group access on paid Pro",
]
: [
- "500+ 气象站实时 METAR 实况",
- "DEB 智能融合预测(0–240 小时)",
- "AI 决策卡片 + Poly-score 排名",
- "历史回测与天气信号",
+ "实时气象站、METAR 与跑道信号",
+ "DEB 预测曲线与模型对比",
+ "完整终端网格与高频刷新",
+ "付费 Pro 可进入 API 与付费 Telegram 群",
];
return (
@@ -57,7 +57,7 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
- $10
+ 29.9 USDC
{t("month", isEn)}
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalAccessState.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalAccessState.test.ts
index ac89db31..4487244e 100644
--- a/frontend/components/dashboard/scan-terminal/__tests__/terminalAccessState.test.ts
+++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalAccessState.test.ts
@@ -65,7 +65,9 @@ export function runTests() {
degraded_auth_profile: true,
});
assert(
- coldUnknown.subscriptionActive === false && coldUnknown.authenticated === true,
- "cold-start unknown subscription state must not fabricate Pro access",
+ coldUnknown.subscriptionActive === false &&
+ coldUnknown.authenticated === true &&
+ coldUnknown.loading === true,
+ "cold-start unknown subscription state must keep the terminal gate loading instead of showing a false paywall",
);
}
diff --git a/frontend/components/dashboard/scan-terminal/terminal-access-state.ts b/frontend/components/dashboard/scan-terminal/terminal-access-state.ts
index ee591c95..97293887 100644
--- a/frontend/components/dashboard/scan-terminal/terminal-access-state.ts
+++ b/frontend/components/dashboard/scan-terminal/terminal-access-state.ts
@@ -16,11 +16,21 @@ function queuedDays(value: unknown) {
return Math.max(0, Number(value ?? 0));
}
+export function isSubscriptionStatusUnknown(payload: AuthProfilePayload) {
+ return (
+ payload.subscription_active === null ||
+ payload.subscription_active === undefined ||
+ payload.degraded_auth_profile === true
+ );
+}
+
export function createAccessStateFromAuthPayload(
payload: AuthProfilePayload,
): ProAccessState {
+ const subscriptionUnknown =
+ Boolean(payload.authenticated) && isSubscriptionStatusUnknown(payload);
return {
- loading: false,
+ loading: subscriptionUnknown,
authenticated: Boolean(payload.authenticated),
userId: payload.user_id ?? null,
subscriptionActive: payload.subscription_active === true,
@@ -41,10 +51,7 @@ export function mergeAccessStateWithAuthPayload(
payload: AuthProfilePayload,
): ProAccessState {
const next = createAccessStateFromAuthPayload(payload);
- const subscriptionUnknown =
- payload.subscription_active === null ||
- payload.subscription_active === undefined ||
- payload.degraded_auth_profile === true;
+ const subscriptionUnknown = isSubscriptionStatusUnknown(payload);
if (!subscriptionUnknown || !previous.subscriptionActive || !next.authenticated) {
return next;
@@ -52,6 +59,7 @@ export function mergeAccessStateWithAuthPayload(
return {
...next,
+ loading: false,
subscriptionActive: true,
subscriptionPlanCode: previous.subscriptionPlanCode,
subscriptionExpiresAt: previous.subscriptionExpiresAt,
diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py
index b33148be..be34599d 100644
--- a/src/auth/supabase_entitlement.py
+++ b/src/auth/supabase_entitlement.py
@@ -7,7 +7,7 @@ import threading
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Tuple
import requests
from loguru import logger
@@ -1495,16 +1495,16 @@ class SupabaseEntitlementService:
logger.warning(f"supabase subscription query error user_id={user_id}: {exc}")
return None
- def _query_active_subscription_rows(
+ def _query_active_subscription_rows_result(
self,
user_id: str,
bypass_cache: bool = False,
- ) -> List[Dict[str, object]]:
+ ) -> Tuple[List[Dict[str, object]], bool]:
if not user_id:
- return []
+ return [], True
if not self.service_role_key:
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
- return []
+ return [], False
now_ts = time.time()
if not bypass_cache:
@@ -1513,7 +1513,7 @@ class SupabaseEntitlementService:
if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
rows = cached.get("rows")
if isinstance(rows, list):
- return [row for row in rows if isinstance(row, dict)]
+ return [row for row in rows if isinstance(row, dict)], True
try:
now = datetime.now(timezone.utc)
@@ -1538,7 +1538,7 @@ class SupabaseEntitlementService:
user_id,
response.status_code,
)
- rows: List[Dict[str, object]] = []
+ return [], False
else:
data = response.json() if response.content else []
rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else []
@@ -1551,10 +1551,21 @@ class SupabaseEntitlementService:
"rows": rows,
"ts": now_ts,
}
- return rows
+ return rows, True
except Exception as exc:
logger.warning(f"supabase active subscription rows query error user_id={user_id}: {exc}")
- return []
+ return [], False
+
+ def _query_active_subscription_rows(
+ self,
+ user_id: str,
+ bypass_cache: bool = False,
+ ) -> List[Dict[str, object]]:
+ rows, _ok = self._query_active_subscription_rows_result(
+ user_id,
+ bypass_cache=bypass_cache,
+ )
+ return rows
def _query_latest_subscription_any_status(
self,
@@ -1726,10 +1737,25 @@ class SupabaseEntitlementService:
user_id: str,
respect_requirement: bool = True,
bypass_cache: bool = False,
+ unknown_on_error: bool = False,
) -> Dict[str, object]:
if respect_requirement and not self.require_subscription:
return {}
- rows = self._query_active_subscription_rows(user_id, bypass_cache=bypass_cache)
+ rows, query_ok = self._query_active_subscription_rows_result(
+ user_id,
+ bypass_cache=bypass_cache,
+ )
+ if not query_ok and unknown_on_error:
+ return {
+ "unknown": True,
+ "current": None,
+ "current_expires_at": None,
+ "current_starts_at": None,
+ "total_expires_at": None,
+ "queued_days": 0,
+ "queued_count": 0,
+ "rows": None,
+ }
return self._subscription_window_from_rows(rows)
def _subscription_window_from_rows(
diff --git a/tests/test_supabase_entitlement.py b/tests/test_supabase_entitlement.py
index a7981b5b..a280b4c7 100644
--- a/tests/test_supabase_entitlement.py
+++ b/tests/test_supabase_entitlement.py
@@ -164,6 +164,29 @@ def test_subscription_window_query_selects_only_window_fields(monkeypatch):
assert window["current"]["plan_code"] == "pro_monthly"
+def test_subscription_window_can_report_unknown_on_transient_query_failure(monkeypatch):
+ monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
+ monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
+
+ service = SupabaseEntitlementService()
+
+ def _fake_get(url, headers=None, params=None, timeout=None):
+ return _Response(503, {"message": "temporarily unavailable"})
+
+ monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
+
+ window = service.get_subscription_window(
+ "user-1",
+ respect_requirement=False,
+ bypass_cache=True,
+ unknown_on_error=True,
+ )
+
+ assert window["unknown"] is True
+ assert window["rows"] is None
+
+
def test_list_subscription_windows_selects_only_batch_window_fields(monkeypatch):
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py
index 9b38f6e7..06aa9972 100644
--- a/tests/test_web_observability.py
+++ b/tests/test_web_observability.py
@@ -382,6 +382,51 @@ def test_auth_me_uses_subscription_window_as_required_subscription_gate(monkeypa
assert payload["subscription_queued_days"] == 30
+def test_auth_me_preserves_unknown_subscription_window(monkeypatch):
+ monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
+ monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", False)
+ monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", False)
+ monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None})
+ monkeypatch.setattr(routes, "_resolve_auth_points", lambda request: 0)
+
+ def _bind_identity(request):
+ request.state.auth_user_id = "user-1"
+ request.state.auth_email = "user@example.com"
+
+ monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
+ monkeypatch.setattr(routes, "_bind_optional_supabase_identity", _bind_identity)
+ monkeypatch.setattr(
+ routes.SUPABASE_ENTITLEMENT,
+ "get_subscription_window",
+ lambda user_id, respect_requirement=False, bypass_cache=False, unknown_on_error=False: {
+ "unknown": True,
+ "rows": None,
+ },
+ )
+ monkeypatch.setattr(
+ routes.SUPABASE_ENTITLEMENT,
+ "get_latest_active_subscription",
+ lambda *args, **kwargs: (_ for _ in ()).throw(
+ AssertionError("unknown subscription window must not be downgraded to inactive"),
+ ),
+ )
+ monkeypatch.setattr(
+ routes.SUPABASE_ENTITLEMENT,
+ "get_latest_subscription_any_status",
+ lambda *args, **kwargs: (_ for _ in ()).throw(
+ AssertionError("unknown subscription window must not be treated as subscription history"),
+ ),
+ )
+
+ response = client.get("/api/auth/me")
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["authenticated"] is True
+ assert payload["subscription_active"] is None
+ assert payload["subscription_plan_code"] is None
+
+
def test_auth_me_uses_window_rows_for_non_required_latest_known_subscription(monkeypatch):
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", False)
diff --git a/web/services/auth_api.py b/web/services/auth_api.py
index e20b3edb..2259c011 100644
--- a/web/services/auth_api.py
+++ b/web/services/auth_api.py
@@ -57,6 +57,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
user_id,
respect_requirement=False,
bypass_cache=True,
+ unknown_on_error=True,
)
except TypeError:
subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window(
@@ -65,8 +66,15 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
)
latest_subscription = None
latest_known_subscription = None
- subscription_window_known = isinstance(subscription_window, dict)
- if isinstance(subscription_window, dict):
+ subscription_window_unknown = (
+ isinstance(subscription_window, dict)
+ and subscription_window.get("unknown") is True
+ )
+ subscription_window_known = (
+ isinstance(subscription_window, dict)
+ and not subscription_window_unknown
+ )
+ if subscription_window_known:
current_subscription = subscription_window.get("current")
if isinstance(current_subscription, dict):
latest_subscription = current_subscription
@@ -79,6 +87,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
if (
not latest_subscription
and not latest_known_subscription
+ and not subscription_window_unknown
and not subscription_window_known
and not subscription_required
):
@@ -89,13 +98,18 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
)
)
- subscription_active = bool(latest_subscription)
- if subscription_required and not subscription_active:
+ subscription_active = (
+ None if subscription_window_unknown else bool(latest_subscription)
+ )
+ if (
+ subscription_required
+ and subscription_active is False
+ ):
raise HTTPException(status_code=403, detail="Subscription required")
- if not latest_known_subscription:
+ if not subscription_window_unknown and not latest_known_subscription:
latest_known_subscription = latest_subscription
- if not latest_known_subscription:
+ if not subscription_window_unknown and not latest_known_subscription:
latest_known_subscription = (
legacy_routes.SUPABASE_ENTITLEMENT.get_latest_subscription_any_status(
user_id
@@ -113,7 +127,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
subscription_is_trial = _subscription_row_is_trial(latest_known_subscription)
subscription_starts_at = latest_known_subscription.get("starts_at")
subscription_expires_at = latest_known_subscription.get("expires_at")
- if isinstance(subscription_window, dict):
+ if subscription_window_known:
subscription_total_expires_at = subscription_window.get("total_expires_at")
subscription_queued_days = int(subscription_window.get("queued_days") or 0)
subscription_queued_count = int(subscription_window.get("queued_count") or 0)