Fix terminal subscription gate on unknown auth state
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
<div className="p-8">
|
||||
<div className="mb-6 flex items-baseline gap-1">
|
||||
<span className="text-4xl font-black text-slate-900">$10</span>
|
||||
<span className="text-4xl font-black text-slate-900">29.9 USDC</span>
|
||||
<span className="text-base text-slate-500">
|
||||
{t("month", isEn)}
|
||||
</span>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user