Reduce Supabase disk IO

This commit is contained in:
2569718930@qq.com
2026-05-29 17:22:33 +08:00
parent 9e3a4e2f45
commit 2269aaefd7
63 changed files with 4637 additions and 410 deletions
+10
View File
@@ -22,6 +22,7 @@
在 Supabase SQL Editor 执行: 在 Supabase SQL Editor 执行:
- `scripts/supabase/schema.sql` - `scripts/supabase/schema.sql`
- 既有生产项目遇到 Disk IO Budget 告警时,再执行 `scripts/supabase/io_budget_indexes.sql`
会创建支付与订阅相关表: 会创建支付与订阅相关表:
@@ -33,6 +34,15 @@
- `payment_intents` - `payment_intents`
- `payment_transactions` - `payment_transactions`
### 3.1 Disk IO 告警处理
如果 Supabase 提示项目正在耗尽 Disk IO Budget,先在 SQL Editor 执行:
1. `scripts/supabase/io_budget_indexes.sql`
2. `scripts/supabase/disk_io_diagnostics.sql`
第一个脚本会把热查询索引收敛为更低写放大的部分索引,移除已被唯一约束覆盖或无热路径使用的冗余索引,并对相关表执行 `ANALYZE`。第二个脚本用于查看表扫描、dead tuples、索引命中和 `pg_stat_statements` 中的高读块 SQL。生产执行后,继续观察 Supabase daily/hourly Disk IO 图表,确认请求延迟和 IO wait 是否下降。
## 4. 环境变量 ## 4. 环境变量
### 4.1 前端(Vercel / frontend/.env.local ### 4.1 前端(Vercel / frontend/.env.local
+17 -3
View File
@@ -11,6 +11,7 @@ import {
getLocalDevAuthPayload, getLocalDevAuthPayload,
isLocalFullAccessHost, isLocalFullAccessHost,
} from "@/lib/local-dev-access"; } from "@/lib/local-dev-access";
import { hasSupabaseServerEnv } from "@/lib/supabase/server";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -33,8 +34,22 @@ export async function GET(req: NextRequest) {
); );
} }
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try { try {
const auth = await buildBackendRequestHeaders(req); auth = await buildBackendRequestHeaders(req);
if (
hasSupabaseServerEnv() &&
!auth.authUserId &&
!req.headers.get("authorization")
) {
const response = NextResponse.json({
authenticated: false,
subscription_active: false,
points: 0,
});
return applyAuthResponseCookies(response, auth.response);
}
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000); const timeoutId = setTimeout(() => controller.abort(), 6000);
let res: Response; let res: Response;
@@ -98,8 +113,7 @@ export async function GET(req: NextRequest) {
const response = NextResponse.json(data); const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response); return applyAuthResponseCookies(response, auth.response);
} catch (error) { } catch (error) {
const auth = await buildBackendRequestHeaders(req); if (auth?.authUserId) {
if (auth.authUserId) {
const response = NextResponse.json({ const response = NextResponse.json({
authenticated: true, authenticated: true,
user_id: auth.authUserId, user_id: auth.authUserId,
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/analytics/funnel`); const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
const days = req.nextUrl.searchParams.get("days"); const days = req.nextUrl.searchParams.get("days");
if (days) { if (days) {
+7
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : ""; const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : "";
@@ -9,6 +10,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" }); const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -20,6 +24,9 @@ export async function PUT(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text(); const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" }); const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" }); const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`); const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`);
const limit = req.nextUrl.searchParams.get("limit"); const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit); if (limit) url.searchParams.set("limit", limit);
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,8 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships/growth`); const url = new URL(`${API_BASE}/api/ops/memberships/growth`);
const days = req.nextUrl.searchParams.get("days"); const days = req.nextUrl.searchParams.get("days");
if (days) url.searchParams.set("days", days); if (days) url.searchParams.set("days", days);
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships/overview`);
const limit = req.nextUrl.searchParams.get("limit");
const days = req.nextUrl.searchParams.get("days");
if (limit) url.searchParams.set("limit", limit);
if (days) url.searchParams.set("days", days);
const res = await fetch(url.toString(), {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch memberships overview",
});
}
}
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,8 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships`); const url = new URL(`${API_BASE}/api/ops/memberships`);
const limit = req.nextUrl.searchParams.get("limit"); const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit); if (limit) url.searchParams.set("limit", limit);
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/online-users`, { const res = await fetch(`${API_BASE}/api/ops/online-users`, {
headers: auth.headers, headers: auth.headers,
cache: "no-store", cache: "no-store",
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -21,6 +22,9 @@ export async function POST(req: NextRequest, context: RouteContext) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const { eventId } = await context.params; const { eventId } = await context.params;
const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, { const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, {
method: "POST", method: "POST",
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/payments/incidents`); const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
const limit = req.nextUrl.searchParams.get("limit"); const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit); if (limit) url.searchParams.set("limit", limit);
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || ""; const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || "";
@@ -9,6 +10,9 @@ export async function POST(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text(); const body = await req.text();
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" }; const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
if (ENTITLEMENT_TOKEN) { if (ENTITLEMENT_TOKEN) {
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -41,6 +42,23 @@ async function findSupabaseUserIdByEmail(email: string) {
if (!supabaseUrl || !serviceRoleKey) { if (!supabaseUrl || !serviceRoleKey) {
throw new Error("Supabase service role is not configured on Vercel"); throw new Error("Supabase service role is not configured on Vercel");
} }
const profileRes = await fetch(
`${supabaseUrl}/rest/v1/profiles?select=id&email=eq.${encodeURIComponent(email)}&limit=1`,
{
headers: {
apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`,
Accept: "application/json",
},
cache: "no-store",
},
);
const profileData = (await profileRes.json().catch(() => [])) as Array<{ id?: string }>;
if (profileRes.ok) {
const profileUserId = String(profileData?.[0]?.id || "").trim();
if (profileUserId) return { supabaseUrl, serviceRoleKey, userId: profileUserId };
}
const res = await fetch( const res = await fetch(
`${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`, `${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`,
{ {
@@ -109,7 +127,7 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
apikey: serviceRoleKey, apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`, Authorization: `Bearer ${serviceRoleKey}`,
"Content-Type": "application/json", "Content-Type": "application/json",
Prefer: "return=representation", Prefer: "return=minimal",
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
cache: "no-store", cache: "no-store",
@@ -138,6 +156,9 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text(); const body = await req.text();
if (!API_BASE) { if (!API_BASE) {
return grantSubscriptionDirectly(req, body, auth.authEmail); return grantSubscriptionDirectly(req, body, auth.authEmail);
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" }); const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" }); const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/truth-history`); const url = new URL(`${API_BASE}/api/ops/truth-history`);
for (const key of ["city", "date_from", "date_to", "limit"]) { for (const key of ["city", "date_from", "date_to", "limit"]) {
const value = req.nextUrl.searchParams.get(key); const value = req.nextUrl.searchParams.get(key);
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function POST(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text(); const body = await req.text();
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, { const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
method: "POST", method: "POST",
+4
View File
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/users`); const url = new URL(`${API_BASE}/api/ops/users`);
const q = req.nextUrl.searchParams.get("q"); const q = req.nextUrl.searchParams.get("q");
const limit = req.nextUrl.searchParams.get("limit"); const limit = req.nextUrl.searchParams.get("limit");
+4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/logs`); const url = new URL(`${API_BASE}/api/ops/logs`);
const level = req.nextUrl.searchParams.get("level"); const level = req.nextUrl.searchParams.get("level");
const lines = req.nextUrl.searchParams.get("lines"); const lines = req.nextUrl.searchParams.get("lines");
+1 -1
View File
@@ -13,7 +13,7 @@ export async function GET(req: NextRequest) {
return proxyBackendJsonGet(req, { return proxyBackendJsonGet(req, {
cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900", cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900",
detailLimit: 350, detailLimit: 350,
includeSupabaseIdentity: true, includeSupabaseIdentity: false,
publicMessage: "Failed to fetch payment config", publicMessage: "Failed to fetch payment config",
revalidateSeconds: 300, revalidateSeconds: 300,
url: `${API_BASE}/api/payments/config`, url: `${API_BASE}/api/payments/config`,
+1 -1
View File
@@ -16,7 +16,7 @@ export async function GET(req: NextRequest) {
conditionalResponse: false, conditionalResponse: false,
detailLimit: 500, detailLimit: 500,
fetchCache: "no-store", fetchCache: "no-store",
includeSupabaseIdentity: true, includeSupabaseIdentity: false,
publicMessage: "Failed to fetch payment runtime", publicMessage: "Failed to fetch payment runtime",
url: `${API_BASE}/api/payments/runtime`, url: `${API_BASE}/api/payments/runtime`,
}); });
@@ -37,7 +37,9 @@ export async function POST(req: NextRequest) {
const timeoutId = setTimeout(() => controller.abort(), OVERVIEW_PROXY_TIMEOUT_MS); const timeoutId = setTimeout(() => controller.abort(), OVERVIEW_PROXY_TIMEOUT_MS);
try { try {
auth = await buildBackendRequestHeaders(req); auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const headers = new Headers(auth.headers); const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
headers.set("Accept", "application/json"); headers.set("Accept", "application/json");
@@ -107,6 +107,75 @@ export function runTests() {
!middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"), !middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"),
"middleware must not treat the mere presence of a bearer token as authenticated", "middleware must not treat the mere presence of a bearer token as authenticated",
); );
assert(
middlewareSource.includes("function hasSupabaseSessionCookie") &&
middlewareSource.includes("request.cookies.getAll()") &&
middlewareSource.includes("hasSupabaseSessionCookieValues"),
"middleware must detect non-empty Supabase session cookies locally via the shared helper before refreshing auth",
);
assert(
middlewareSource.includes("redirectToLogin(request, pathname)") &&
middlewareSource.indexOf("if (!hasSupabaseSessionCookie(request))") <
middlewareSource.indexOf("await refreshMiddlewareSession(request)"),
"middleware must redirect no-cookie page requests without calling Supabase auth",
);
assert(
middlewareSource.includes("unauthorizedSupabaseSessionResponse()"),
"middleware must reject no-cookie protected API requests without calling Supabase auth",
);
for (const route of [
"app/api/ops/analytics/funnel/route.ts",
"app/api/ops/config/route.ts",
"app/api/ops/health-check/route.ts",
"app/api/ops/leaderboard/weekly/route.ts",
"app/api/ops/memberships/route.ts",
"app/api/ops/memberships/growth/route.ts",
"app/api/ops/memberships/overview/route.ts",
"app/api/ops/online-users/route.ts",
"app/api/ops/payments/incidents/route.ts",
"app/api/ops/payments/incidents/[eventId]/resolve/route.ts",
"app/api/ops/subscriptions/extend/route.ts",
"app/api/ops/subscriptions/grant/route.ts",
"app/api/ops/telegram/members-audit/route.ts",
"app/api/ops/training/accuracy/route.ts",
"app/api/ops/truth-history/route.ts",
"app/api/ops/users/route.ts",
"app/api/ops/users/grant-points/route.ts",
"app/api/ops/view-logs/route.ts",
]) {
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
assert(
routeSource.includes("requireOpsProxyAuth(req, auth)") &&
routeSource.indexOf("requireOpsProxyAuth(req, auth)") >
routeSource.indexOf("buildBackendRequestHeaders(req"),
`${route} must reject requests without Supabase identity before forwarding the backend entitlement token`,
);
}
assert(
middlewareSource.includes('pathname === "/api/payments/config"'),
"middleware must treat public payment config as public API so cached config requests do not refresh Supabase sessions",
);
const optionalRefreshFunction = middlewareSource.slice(
middlewareSource.indexOf("function shouldRefreshOptionalSupabaseSession"),
middlewareSource.indexOf("function hasSupabaseSessionCookie"),
);
assert(
!optionalRefreshFunction.includes('pathname.startsWith("/api/ops/")') &&
!optionalRefreshFunction.includes('pathname.startsWith("/api/payments/")'),
"optional Supabase middleware refresh must not pre-read sessions for API routes that already build backend auth headers",
);
const optionalRefreshIndex = middlewareSource.indexOf(
"function shouldRefreshOptionalSupabaseSession",
);
const systemStatusPublicIndex = middlewareSource.indexOf(
'pathname === "/api/system/status"',
);
assert(
systemStatusPublicIndex >= 0 &&
optionalRefreshIndex >= 0 &&
systemStatusPublicIndex < optionalRefreshIndex,
"middleware must treat public system status as public API instead of optional Supabase session refresh",
);
for (const route of paymentRoutes) { for (const route of paymentRoutes) {
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8"); const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
@@ -92,6 +92,26 @@ export function runTests() {
path.join(projectRoot, "app", "api", "auth", "me", "route.ts"), path.join(projectRoot, "app", "api", "auth", "me", "route.ts"),
"utf8", "utf8",
); );
const paymentConfigRouteSource = fs.readFileSync(
path.join(projectRoot, "app", "api", "payments", "config", "route.ts"),
"utf8",
);
const paymentRuntimeRouteSource = fs.readFileSync(
path.join(projectRoot, "app", "api", "payments", "runtime", "route.ts"),
"utf8",
);
const backendAuthSource = fs.readFileSync(
path.join(projectRoot, "lib", "backend-auth.ts"),
"utf8",
);
const opsAdminSource = fs.readFileSync(
path.join(projectRoot, "lib", "ops-admin.ts"),
"utf8",
);
const supabaseServerSource = fs.readFileSync(
path.join(projectRoot, "lib", "supabase", "server.ts"),
"utf8",
);
const subscriptionsPageSource = fs.readFileSync( const subscriptionsPageSource = fs.readFileSync(
path.join( path.join(
projectRoot, projectRoot,
@@ -102,6 +122,26 @@ export function runTests() {
), ),
"utf8", "utf8",
); );
const membershipsPageSource = fs.readFileSync(
path.join(
projectRoot,
"components",
"ops",
"memberships",
"MembershipsPageClient.tsx",
),
"utf8",
);
const opsOverviewSource = fs.readFileSync(
path.join(
projectRoot,
"components",
"ops",
"overview",
"OverviewPageClient.tsx",
),
"utf8",
);
assert( assert(
accountCenterSource.includes( accountCenterSource.includes(
@@ -157,6 +197,11 @@ export function runTests() {
hookSource.includes("retriedBackendJson"), hookSource.includes("retriedBackendJson"),
"account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated", "account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated",
); );
assert(
!hookSource.includes(".auth.getUser()") &&
hookSource.includes(".auth.getSession()"),
"account snapshot loader must use the local Supabase session instead of calling getUser before /api/auth/me validates the bearer",
);
assert( assert(
subscriptionsPageSource.includes("getSupabaseBrowserClient") && subscriptionsPageSource.includes("getSupabaseBrowserClient") &&
subscriptionsPageSource.includes("refreshSession") && subscriptionsPageSource.includes("refreshSession") &&
@@ -165,11 +210,28 @@ export function runTests() {
subscriptionsPageSource.includes("/api/ops/subscriptions/extend"), subscriptionsPageSource.includes("/api/ops/subscriptions/extend"),
"ops manual subscription grant/extend must send the Supabase bearer token to avoid 401 when route cookies are stale", "ops manual subscription grant/extend must send the Supabase bearer token to avoid 401 when route cookies are stale",
); );
assert(
membershipsPageSource.includes("opsApi.membershipsOverview") &&
!membershipsPageSource.includes('fetch("/api/ops/memberships/growth?days=90"') &&
!membershipsPageSource.includes("Promise.all"),
"ops memberships page must load memberships and growth through one overview proxy request to avoid duplicate Supabase session/subscription reads",
);
assert(
opsOverviewSource.includes("opsApi.membershipsOverview(200, 30)") &&
!opsOverviewSource.includes("opsApi.memberships()") &&
!opsOverviewSource.includes("opsApi.membershipsGrowth(30)"),
"ops overview page must reuse membershipsOverview for table and growth data instead of issuing separate membership/growth proxy requests",
);
assert( assert(
grantRouteSource.includes("grantSubscriptionDirectly") && grantRouteSource.includes("grantSubscriptionDirectly") &&
grantRouteSource.includes("res.status === 404") && grantRouteSource.includes("res.status === 404") &&
grantRouteSource.includes('"status": "active"'), grantRouteSource.includes('"status": "active"') &&
"ops subscription grant route must fall back to direct Supabase grant when the VPS backend route is missing", grantRouteSource.includes("/rest/v1/profiles") &&
grantRouteSource.includes("select=id&email=eq.") &&
grantRouteSource.indexOf("/rest/v1/profiles") <
grantRouteSource.indexOf("/auth/v1/admin/users") &&
grantRouteSource.includes('Prefer: "return=minimal"'),
"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("if ((res.status === 401 || res.status === 403) && auth.authUserId)") &&
@@ -177,4 +239,76 @@ export function runTests() {
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 preserve authenticated identity with unknown subscription on backend 401/403 instead of forcing a false paywall",
); );
assert(
(authMeRouteSource.match(/buildBackendRequestHeaders\(req\)/g) || []).length === 1 &&
authMeRouteSource.includes("let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null"),
"auth profile proxy must build backend auth headers once and reuse them on timeout/error fallback",
);
assert(
authMeRouteSource.includes("hasSupabaseServerEnv()") &&
authMeRouteSource.includes("!auth.authUserId") &&
authMeRouteSource.includes('req.headers.get("authorization")') &&
authMeRouteSource.indexOf("authenticated: false") <
authMeRouteSource.indexOf("await fetch(`${API_BASE}/api/auth/me`"),
"auth profile proxy must return unauthenticated locally for no-session Supabase requests instead of forwarding the backend entitlement token",
);
assert(
backendAuthSource.includes("if (incomingAuth) {") &&
backendAuthSource.indexOf("if (incomingAuth) {") <
backendAuthSource.indexOf("const supabase = createSupabaseRouteClient"),
"backend proxy must forward caller bearer tokens before creating a Supabase route client to avoid duplicate getUser calls",
);
assert(
backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") &&
backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_EMAIL_HEADER") &&
backendAuthSource.indexOf("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") >
backendAuthSource.indexOf("const sessionUser = session?.user"),
"backend proxy must forward Supabase session user id/email with the backend token so Python can skip duplicate /auth/v1/user validation",
);
assert(
backendAuthSource.includes("function hasSupabaseSessionCookie") &&
backendAuthSource.includes("String(cookie.value || \"\").trim()") &&
backendAuthSource.includes("if (!hasSupabaseSessionCookie(request))") &&
backendAuthSource.indexOf("if (!hasSupabaseSessionCookie(request))") <
backendAuthSource.indexOf("const supabase = createSupabaseRouteClient"),
"backend proxy must skip Supabase route client/session reads when no auth cookie is present",
);
assert(
!backendAuthSource.includes(".auth.getUser()") &&
backendAuthSource.includes(".auth.getSession()"),
"backend proxy must not call Supabase getUser before forwarding a bearer token that the backend validates again",
);
assert(
supabaseServerSource.includes("export function hasSupabaseSessionCookieValues") &&
supabaseServerSource.includes('name === "supabase-auth-token"') &&
supabaseServerSource.includes('name.startsWith("sb-")') &&
supabaseServerSource.indexOf("if (!hasSupabaseSessionCookieValues") <
supabaseServerSource.indexOf("const supabase = createSupabaseServerClient"),
"Supabase server helpers must expose one cookie detector and skip refresh getUser calls when no session cookie exists",
);
assert(
supabaseServerSource.includes(".auth.getClaims()") &&
!supabaseServerSource.includes(".auth.getUser()"),
"Supabase middleware refresh must validate JWTs with getClaims so asymmetric JWT projects avoid per-request /auth/v1/user reads",
);
assert(
opsAdminSource.includes("hasSupabaseSessionCookieValues") &&
opsAdminSource.indexOf("if (!hasSupabaseSessionCookieValues") <
opsAdminSource.indexOf("const supabase = createSupabaseServerClient"),
"ops admin page gate must redirect before creating a Supabase client/getUser call when no session cookie exists",
);
assert(
opsAdminSource.includes(".auth.getClaims()") &&
!opsAdminSource.includes(".auth.getUser()") &&
opsAdminSource.includes("claims?.email"),
"ops admin page gate must use verified JWT claims instead of a per-page getUser auth lookup",
);
assert(
paymentConfigRouteSource.includes("includeSupabaseIdentity: false"),
"payment config proxy must not read Supabase session cookies for public cached config",
);
assert(
paymentRuntimeRouteSource.includes("includeSupabaseIdentity: false"),
"payment runtime proxy must not read Supabase session cookies because backend entitlement token already protects the runtime status payload",
);
} }
@@ -151,14 +151,17 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
setErrorText(""); setErrorText("");
const attempt = async (retry: boolean): Promise<void> => { const attempt = async (retry: boolean): Promise<void> => {
const userPromise = supabaseReady const userPromise = supabaseReady
? getSupabaseBrowserClient().auth.getUser() ? getSupabaseBrowserClient()
: Promise.resolve({ data: { user: null as User | null } }); .auth.getSession()
.then(({ data: { session } }) => session?.user ?? null)
.catch(() => null as User | null)
: Promise.resolve(null as User | null);
const authHeadersPromise = buildAuthedHeaders(false); const authHeadersPromise = buildAuthedHeaders(false);
const backendPromise = authHeadersPromise.then((headers) => const backendPromise = authHeadersPromise.then((headers) =>
fetch("/api/auth/me", { cache: "no-store", headers }), fetch("/api/auth/me", { cache: "no-store", headers }),
); );
const [userResult, backendResult] = await Promise.all([userPromise, backendPromise]); const [localUser, backendResult] = await Promise.all([userPromise, backendPromise]);
setUser(userResult.data?.user ?? null); setUser(localUser);
if (!backendResult.ok) { if (!backendResult.ok) {
if (retry && backendResult.status === 401) { if (retry && backendResult.status === 401) {
await new Promise((r) => setTimeout(r, 1200)); await new Promise((r) => setTimeout(r, 1200));
@@ -171,7 +174,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
if ( if (
retry && retry &&
supabaseReady && supabaseReady &&
userResult.data?.user && localUser &&
backendJson.authenticated === false backendJson.authenticated === false
) { ) {
try { try {
@@ -1,4 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { import {
buildCityDetailProxyCachePolicy, buildCityDetailProxyCachePolicy,
buildForceRefreshProxyCachePolicy, buildForceRefreshProxyCachePolicy,
@@ -22,4 +24,32 @@ export function runTests() {
const scanForced = buildForceRefreshProxyCachePolicy("true", 10); const scanForced = buildForceRefreshProxyCachePolicy("true", 10);
assert.equal(scanForced.fetchMode, "no-store"); assert.equal(scanForced.fetchMode, "no-store");
const overviewProxySource = fs.readFileSync(
path.join(
process.cwd(),
"app",
"api",
"scan",
"terminal",
"overview",
"route.ts",
),
"utf8",
);
assert.match(
overviewProxySource,
/buildBackendRequestHeaders\(req,\s*\{\s*includeSupabaseIdentity:\s*false,\s*\}\)/s,
"scan terminal overview proxy must not read Supabase sessions for public overview payloads",
);
const priorityWarmProxySource = fs.readFileSync(
path.join(process.cwd(), "lib", "system-priority-proxy.ts"),
"utf8",
);
assert.match(
priorityWarmProxySource,
/buildBackendRequestHeaders\(req,\s*\{\s*includeSupabaseIdentity:\s*false,\s*\}\)/s,
"priority warm proxy must not read Supabase sessions for backend-token warm hints",
);
} }
@@ -41,12 +41,9 @@ export function MembershipsPageClient() {
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
try { try {
const [mData, gData] = await Promise.all([ const data = await opsApi.membershipsOverview(200, 90);
opsApi.memberships(), setMemberships((data as unknown as { memberships?: MembershipEntry[] }).memberships ?? []);
fetch("/api/ops/memberships/growth?days=90", { cache: "no-store" }).then(r => r.ok ? r.json() : null), setGrowth((data as { daily?: GrowthPoint[] })?.daily ?? []);
]);
setMemberships((mData as unknown as { memberships?: MembershipEntry[] }).memberships ?? []);
setGrowth((gData as { daily?: GrowthPoint[] })?.daily ?? []);
} catch { /* */ } } catch { /* */ }
setLoading(false); setLoading(false);
}; };
@@ -44,16 +44,15 @@ export function OverviewPageClient() {
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
try { try {
const [s, m, f, g] = await Promise.all([ const [s, m, f] = await Promise.all([
opsApi.systemStatus() as Promise<SystemStatusPayload>, opsApi.systemStatus() as Promise<SystemStatusPayload>,
opsApi.memberships() as Promise<MembershipsPayload>, opsApi.membershipsOverview(200, 30) as Promise<MembershipsPayload & { daily?: { date: string; trial: number; paid: number; total: number; cumulative: number }[] }>,
opsApi.funnel(30), opsApi.funnel(30),
opsApi.membershipsGrowth(30),
]); ]);
setStatus(s); setStatus(s);
setMemberships((m as MembershipsPayload).memberships ?? []); setMemberships((m as MembershipsPayload).memberships ?? []);
setFunnel(f); setFunnel(f);
setGrowth(g?.daily ?? []); setGrowth(m?.daily ?? []);
} catch { /* */ } } catch { /* */ }
setLoading(false); setLoading(false);
}; };
+33 -27
View File
@@ -6,7 +6,7 @@ export const BACKEND_ENTITLEMENT_HEADER = "x-polyweather-entitlement";
export const FORWARDED_SUPABASE_USER_ID_HEADER = "x-polyweather-auth-user-id"; export const FORWARDED_SUPABASE_USER_ID_HEADER = "x-polyweather-auth-user-id";
export const FORWARDED_SUPABASE_EMAIL_HEADER = "x-polyweather-auth-email"; export const FORWARDED_SUPABASE_EMAIL_HEADER = "x-polyweather-auth-email";
type HeaderBuildResult = { export type BackendHeaderBuildResult = {
headers: HeadersInit; headers: HeadersInit;
response: NextResponse | null; response: NextResponse | null;
authUserId?: string | null; authUserId?: string | null;
@@ -26,10 +26,22 @@ function extractBearerToken(headerValue: string | null) {
return ""; return "";
} }
function hasSupabaseSessionCookie(request: NextRequest) {
return request.cookies.getAll().some((cookie) => {
const name = cookie.name.toLowerCase();
const value = String(cookie.value || "").trim();
if (!value) return false;
return (
name === "supabase-auth-token" ||
(name.startsWith("sb-") && name.includes("-auth-token"))
);
});
}
export async function buildBackendRequestHeaders( export async function buildBackendRequestHeaders(
request: NextRequest, request: NextRequest,
options?: HeaderBuildOptions, options?: HeaderBuildOptions,
): Promise<HeaderBuildResult> { ): Promise<BackendHeaderBuildResult> {
const headers = new Headers({ const headers = new Headers({
Accept: "application/json", Accept: "application/json",
}); });
@@ -39,31 +51,19 @@ export async function buildBackendRequestHeaders(
} }
const incomingAuth = extractBearerToken(request.headers.get("authorization")); const incomingAuth = extractBearerToken(request.headers.get("authorization"));
if (incomingAuth) {
headers.set("Authorization", `Bearer ${incomingAuth}`);
return { headers, response: null, authUserId: null, authEmail: null };
}
const includeSupabaseIdentity = options?.includeSupabaseIdentity !== false; const includeSupabaseIdentity = options?.includeSupabaseIdentity !== false;
if (hasSupabaseServerEnv() && includeSupabaseIdentity) { if (hasSupabaseServerEnv() && includeSupabaseIdentity) {
if (!hasSupabaseSessionCookie(request)) {
return { headers, response: null, authUserId: null, authEmail: null };
}
const passthroughResponse = new NextResponse(null, { status: 200 }); const passthroughResponse = new NextResponse(null, { status: 200 });
const supabase = createSupabaseRouteClient(request, passthroughResponse); const supabase = createSupabaseRouteClient(request, passthroughResponse);
// Always call getUser() to ensure token is validated and refreshed if expired
const {
data: { user },
} = await supabase.auth.getUser();
const forwardedUserId = String(user?.id || "").trim();
const forwardedEmail = String(user?.email || "").trim();
if (forwardedUserId) {
headers.set(FORWARDED_SUPABASE_USER_ID_HEADER, forwardedUserId);
}
if (forwardedEmail) {
headers.set(FORWARDED_SUPABASE_EMAIL_HEADER, forwardedEmail);
}
if (incomingAuth) {
headers.set("Authorization", `Bearer ${incomingAuth}`);
return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null };
}
// Call getSession() to get the updated access token (after getUser() has refreshed it if needed)
const { const {
data: { session }, data: { session },
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
@@ -72,12 +72,18 @@ export async function buildBackendRequestHeaders(
// Fallback to cookie-backed session when request does not carry bearer. // Fallback to cookie-backed session when request does not carry bearer.
headers.set("Authorization", `Bearer ${accessToken}`); headers.set("Authorization", `Bearer ${accessToken}`);
} }
const sessionUser = session?.user;
const forwardedUserId = String(sessionUser?.id || "").trim();
const forwardedEmail = String(sessionUser?.email || "").trim();
if (forwardedUserId) {
headers.set(FORWARDED_SUPABASE_USER_ID_HEADER, forwardedUserId);
}
if (forwardedEmail) {
headers.set(FORWARDED_SUPABASE_EMAIL_HEADER, forwardedEmail);
}
return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null }; return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null };
} }
if (incomingAuth) {
headers.set("Authorization", `Bearer ${incomingAuth}`);
}
return { headers, response: null, authUserId: null, authEmail: null }; return { headers, response: null, authUserId: null, authEmail: null };
} }
@@ -94,7 +100,7 @@ export function applyAuthResponseCookies(
return target; return target;
} }
export function requireBackendAuthUser(auth: HeaderBuildResult) { export function requireBackendAuthUser(auth: BackendHeaderBuildResult) {
if (auth.authUserId) return null; if (auth.authUserId) return null;
return applyAuthResponseCookies( return applyAuthResponseCookies(
NextResponse.json( NextResponse.json(
+18 -8
View File
@@ -1,6 +1,10 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createSupabaseServerClient, hasSupabaseServerEnv } from "@/lib/supabase/server"; import {
createSupabaseServerClient,
hasSupabaseServerEnv,
hasSupabaseSessionCookieValues,
} from "@/lib/supabase/server";
function parseAdminEmails() { function parseAdminEmails() {
return String(process.env.POLYWEATHER_OPS_ADMIN_EMAILS || "") return String(process.env.POLYWEATHER_OPS_ADMIN_EMAILS || "")
@@ -16,12 +20,17 @@ export async function requireOpsAdmin(nextPath = "/ops") {
} }
const cookieStore = await cookies(); const cookieStore = await cookies();
const supabaseCookies = cookieStore.getAll().map((item) => ({
name: item.name,
value: item.value,
}));
if (!hasSupabaseSessionCookieValues(supabaseCookies)) {
redirect(`/auth/login?next=${encodeURIComponent(nextPath)}`);
}
const supabase = createSupabaseServerClient({ const supabase = createSupabaseServerClient({
getAll() { getAll() {
return cookieStore.getAll().map((item) => ({ return supabaseCookies;
name: item.name,
value: item.value,
}));
}, },
setAll() { setAll() {
// Server components cannot persist refreshed cookies. Route handlers keep // Server components cannot persist refreshed cookies. Route handlers keep
@@ -30,10 +39,11 @@ export async function requireOpsAdmin(nextPath = "/ops") {
}); });
const { const {
data: { user }, data,
} = await supabase.auth.getUser(); error,
} = await supabase.auth.getClaims();
const email = String(user?.email || "").trim().toLowerCase(); const email = error ? "" : String(data?.claims?.email || "").trim().toLowerCase();
if (!email) { if (!email) {
redirect(`/auth/login?next=${encodeURIComponent(nextPath)}`); redirect(`/auth/login?next=${encodeURIComponent(nextPath)}`);
} }
+7
View File
@@ -67,6 +67,13 @@ export const opsApi = {
memberships() { memberships() {
return opsFetch<Record<string, unknown>>("/api/ops/memberships?limit=200"); return opsFetch<Record<string, unknown>>("/api/ops/memberships?limit=200");
}, },
membershipsOverview(limit = 200, days = 90) {
return opsFetch<{
memberships?: Array<Record<string, unknown>>;
days?: number;
daily?: { date: string; trial: number; paid: number; total: number; cumulative: number }[];
}>(`/api/ops/memberships/overview?limit=${limit}&days=${days}`);
},
membershipsGrowth(days = 90) { membershipsGrowth(days = 90) {
return opsFetch<{ return opsFetch<{
days: number; days: number;
+27
View File
@@ -0,0 +1,27 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
applyAuthResponseCookies,
type BackendHeaderBuildResult,
} from "@/lib/backend-auth";
function hasBearerAuth(request: NextRequest) {
const raw = request.headers.get("authorization");
if (!raw) return false;
const parts = raw.trim().split(/\s+/);
return parts.length === 2 && parts[0].toLowerCase() === "bearer" && Boolean(parts[1]);
}
export function requireOpsProxyAuth(
request: NextRequest,
auth: BackendHeaderBuildResult,
): NextResponse | null {
if (auth.authUserId || hasBearerAuth(request)) return null;
return applyAuthResponseCookies(
NextResponse.json(
{ error: "Unauthorized", detail: "Supabase session required" },
{ status: 401 },
),
auth.response,
);
}
+33 -7
View File
@@ -17,6 +17,20 @@ export function hasSupabaseServerEnv() {
return Boolean(url && anonKey); return Boolean(url && anonKey);
} }
export function hasSupabaseSessionCookieValues(
cookies: { name: string; value: string }[],
) {
return cookies.some((cookie) => {
const name = cookie.name.toLowerCase();
const value = String(cookie.value || "").trim();
if (!value) return false;
return (
name === "supabase-auth-token" ||
(name.startsWith("sb-") && name.includes("-auth-token"))
);
});
}
export function createSupabaseServerClient( export function createSupabaseServerClient(
cookieAdapter: CookieAdapter, cookieAdapter: CookieAdapter,
) { ) {
@@ -79,12 +93,17 @@ export async function refreshMiddlewareSession(request: NextRequest) {
return { response, user: null }; return { response, user: null };
} }
const supabaseCookies = request.cookies.getAll().map((item) => ({
name: item.name,
value: item.value,
}));
if (!hasSupabaseSessionCookieValues(supabaseCookies)) {
return { response, user: null };
}
const supabase = createSupabaseServerClient({ const supabase = createSupabaseServerClient({
getAll() { getAll() {
return request.cookies.getAll().map((item) => ({ return supabaseCookies;
name: item.name,
value: item.value,
}));
}, },
setAll(cookiesToSet) { setAll(cookiesToSet) {
for (const cookie of cookiesToSet) { for (const cookie of cookiesToSet) {
@@ -103,11 +122,18 @@ export async function refreshMiddlewareSession(request: NextRequest) {
try { try {
const { const {
data: { user }, data,
} = await supabase.auth.getUser(); error,
} = await supabase.auth.getClaims();
if (error || !data?.claims?.sub) {
return { response, user: null };
}
const user = {
id: String(data.claims.sub || ""),
email: String(data.claims.email || ""),
};
return { response, user }; return { response, user };
} catch { } catch {
return { response, user: null }; return { response, user: null };
} }
} }
+3 -1
View File
@@ -34,7 +34,9 @@ export async function forwardPriorityWarmHint(req: NextRequest) {
} }
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const res = await fetch( const res = await fetch(
`${API_BASE}/api/system/priority-warm${ `${API_BASE}/api/system/priority-warm${
params.size ? `?${params.toString()}` : "" params.size ? `?${params.toString()}` : ""
+46 -18
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { import {
hasSupabaseSessionCookieValues,
hasSupabaseServerEnv, hasSupabaseServerEnv,
refreshMiddlewareSession, refreshMiddlewareSession,
} from "@/lib/supabase/server"; } from "@/lib/supabase/server";
@@ -33,7 +34,9 @@ function isPublicApi(pathname: string) {
pathname === "/api/auth/me" || pathname === "/api/auth/me" ||
pathname === "/api/analytics/events" || pathname === "/api/analytics/events" ||
pathname === "/api/cities" || pathname === "/api/cities" ||
pathname === "/api/payments/config" ||
pathname === "/api/scan/terminal" || pathname === "/api/scan/terminal" ||
pathname === "/api/system/status" ||
pathname === "/api/vitals" || pathname === "/api/vitals" ||
/^\/api\/city\/[^/]+$/i.test(pathname) || /^\/api\/city\/[^/]+$/i.test(pathname) ||
/^\/api\/city\/[^/]+\/summary$/i.test(pathname) || /^\/api\/city\/[^/]+\/summary$/i.test(pathname) ||
@@ -45,10 +48,31 @@ function isPublicApi(pathname: string) {
function shouldRefreshOptionalSupabaseSession(pathname: string) { function shouldRefreshOptionalSupabaseSession(pathname: string) {
return ( return (
pathname.startsWith("/account") || pathname.startsWith("/account") ||
pathname.startsWith("/ops") || pathname.startsWith("/ops")
pathname.startsWith("/api/ops/") || );
pathname.startsWith("/api/payments/") || }
pathname === "/api/system/status"
function hasSupabaseSessionCookie(request: NextRequest) {
return hasSupabaseSessionCookieValues(
request.cookies.getAll().map((cookie) => ({
name: cookie.name,
value: cookie.value,
})),
);
}
function redirectToLogin(request: NextRequest, pathname: string) {
const loginUrl = request.nextUrl.clone();
loginUrl.pathname = "/auth/login";
loginUrl.search = "";
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
function unauthorizedSupabaseSessionResponse() {
return NextResponse.json(
{ error: "Unauthorized", detail: "Supabase session required" },
{ status: 401 },
); );
} }
@@ -69,6 +93,10 @@ async function handleTerminalGate(request: NextRequest): Promise<NextResponse> {
return NextResponse.next(); return NextResponse.next();
} }
if (!hasSupabaseSessionCookie(request)) {
return redirectToLogin(request, pathname);
}
const { response, user } = await refreshMiddlewareSession(request); const { response, user } = await refreshMiddlewareSession(request);
if (user) { if (user) {
@@ -77,11 +105,7 @@ async function handleTerminalGate(request: NextRequest): Promise<NextResponse> {
} }
// Layer 1: Not logged in → redirect to /auth/login?next=/terminal // Layer 1: Not logged in → redirect to /auth/login?next=/terminal
const loginUrl = request.nextUrl.clone(); return redirectToLogin(request, pathname);
loginUrl.pathname = "/auth/login";
loginUrl.search = "";
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
} }
async function handleSupabaseAuthGate(request: NextRequest) { async function handleSupabaseAuthGate(request: NextRequest) {
@@ -90,6 +114,13 @@ async function handleSupabaseAuthGate(request: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
if (!hasSupabaseSessionCookie(request)) {
if (pathname.startsWith("/api/")) {
return unauthorizedSupabaseSessionResponse();
}
return redirectToLogin(request, pathname);
}
const { response, user } = await refreshMiddlewareSession(request); const { response, user } = await refreshMiddlewareSession(request);
if (user) { if (user) {
@@ -97,17 +128,10 @@ async function handleSupabaseAuthGate(request: NextRequest) {
} }
if (pathname.startsWith("/api/")) { if (pathname.startsWith("/api/")) {
return NextResponse.json( return unauthorizedSupabaseSessionResponse();
{ error: "Unauthorized", detail: "Supabase session required" },
{ status: 401 },
);
} }
const loginUrl = request.nextUrl.clone(); return redirectToLogin(request, pathname);
loginUrl.pathname = "/auth/login";
loginUrl.search = "";
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
} }
async function handleSupabaseOptionalSession(request: NextRequest) { async function handleSupabaseOptionalSession(request: NextRequest) {
@@ -120,6 +144,10 @@ async function handleSupabaseOptionalSession(request: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
if (!hasSupabaseSessionCookie(request)) {
return NextResponse.next();
}
const { response } = await refreshMiddlewareSession(request); const { response } = await refreshMiddlewareSession(request);
return response; return response;
} }
+40 -23
View File
@@ -49,6 +49,22 @@ def _select_exact_user_id(payload: object, email: str) -> str:
def _lookup_user_id_by_email(email: str) -> str: def _lookup_user_id_by_email(email: str) -> str:
from src.payments.contract_checkout import PAYMENT_CHECKOUT from src.payments.contract_checkout import PAYMENT_CHECKOUT
normalized_email = str(email or "").strip().lower()
profile_rows = PAYMENT_CHECKOUT._rest( # noqa: SLF001
"GET",
"profiles",
params={
"select": "id",
"email": f"eq.{normalized_email}",
"limit": "1",
},
allowed_status=[200],
)
if isinstance(profile_rows, list) and profile_rows:
user_id = str((profile_rows[0] or {}).get("id") or "").strip()
if user_id:
return user_id
payload = PAYMENT_CHECKOUT._auth_admin_request( # noqa: SLF001 payload = PAYMENT_CHECKOUT._auth_admin_request( # noqa: SLF001
"GET", "GET",
f"/admin/users?email={email}", f"/admin/users?email={email}",
@@ -117,7 +133,7 @@ def main() -> int:
"GET", "GET",
"subscriptions", "subscriptions",
params={ params={
"select": "id,expires_at,status,plan_code,starts_at,source,created_at", "select": "id,plan_code,source,starts_at,expires_at",
"user_id": f"eq.{user_id}", "user_id": f"eq.{user_id}",
"status": "eq.active", "status": "eq.active",
"order": "expires_at.desc", "order": "expires_at.desc",
@@ -169,37 +185,38 @@ def main() -> int:
expires_at = starts_at + timedelta(days=days) expires_at = starts_at + timedelta(days=days)
if isinstance(upcoming, dict) and str(upcoming.get("id") or "").strip(): if isinstance(upcoming, dict) and str(upcoming.get("id") or "").strip():
updated = PAYMENT_CHECKOUT._rest( # noqa: SLF001 subscription_payload = {
"starts_at": starts_at.isoformat(),
"expires_at": expires_at.isoformat(),
"updated_at": now.isoformat(),
}
PAYMENT_CHECKOUT._rest( # noqa: SLF001
"PATCH", "PATCH",
"subscriptions", "subscriptions",
params={"id": f"eq.{upcoming['id']}"}, params={"id": f"eq.{upcoming['id']}"},
payload={ payload=subscription_payload,
"starts_at": starts_at.isoformat(), prefer="return=minimal",
"expires_at": expires_at.isoformat(),
"updated_at": now.isoformat(),
},
prefer="return=representation",
allowed_status=[200], allowed_status=[200],
) )
subscription = updated[0] if isinstance(updated, list) and updated else {} subscription = {**upcoming, **subscription_payload}
else: else:
created = PAYMENT_CHECKOUT._rest( # noqa: SLF001 subscription = {
"user_id": user_id,
"plan_code": plan_code,
"status": "active",
"starts_at": starts_at.isoformat(),
"expires_at": expires_at.isoformat(),
"source": actor,
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
}
PAYMENT_CHECKOUT._rest( # noqa: SLF001
"POST", "POST",
"subscriptions", "subscriptions",
payload={ payload=subscription,
"user_id": user_id, prefer="return=minimal",
"plan_code": plan_code,
"status": "active",
"starts_at": starts_at.isoformat(),
"expires_at": expires_at.isoformat(),
"source": actor,
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
},
prefer="return=representation",
allowed_status=[201], allowed_status=[201],
) )
subscription = created[0] if isinstance(created, list) and created else {}
PAYMENT_CHECKOUT._rest( # noqa: SLF001 PAYMENT_CHECKOUT._rest( # noqa: SLF001
"POST", "POST",
@@ -219,7 +236,7 @@ def main() -> int:
}, },
"created_at": now.isoformat(), "created_at": now.isoformat(),
}, },
prefer="return=representation", prefer="return=minimal",
allowed_status=[201], allowed_status=[201],
) )
SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id) SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
+34 -17
View File
@@ -50,31 +50,48 @@ def _list_recent_intents(user_id: str, limit: int = 20) -> List[Dict[str, Any]]:
def _find_intent_by_tx(user_id: str, tx_hash: str) -> Optional[Dict[str, Any]]: def _find_intent_by_tx(user_id: str, tx_hash: str) -> Optional[Dict[str, Any]]:
rows = _list_recent_intents(user_id, limit=50)
tx_norm = str(tx_hash or "").strip().lower() tx_norm = str(tx_hash or "").strip().lower()
for row in rows: if not tx_norm:
if str(row.get("tx_hash") or "").strip().lower() == tx_norm: return None
return row
return None
def _find_intent_by_order_id(user_id: str, order_id_hex: str) -> Optional[Dict[str, Any]]:
rows = PAYMENT_CHECKOUT._rest( rows = PAYMENT_CHECKOUT._rest(
"GET", "GET",
"payment_intents", "payment_intents",
params={ params={
"select": ( "select": "id,user_id",
"id,user_id,plan_code,status,tx_hash,order_id_hex," "tx_hash": f"eq.{tx_norm}",
"amount_units,token_address,created_at,updated_at" "limit": "1",
),
"user_id": f"eq.{user_id}",
"order_id_hex": f"eq.{order_id_hex.lower()}",
"order": "created_at.desc",
"limit": "5",
}, },
allowed_status=[200], allowed_status=[200],
) )
return rows[0] if isinstance(rows, list) and rows else None row = rows[0] if isinstance(rows, list) and rows else None
if not isinstance(row, dict):
return None
if str(row.get("user_id") or "").strip() != str(user_id or "").strip():
return None
return row
def _find_intent_by_order_id(user_id: str, order_id_hex: str) -> Optional[Dict[str, Any]]:
normalized_user_id = str(user_id or "").strip()
normalized_order_id = str(order_id_hex or "").strip().lower()
if not normalized_user_id or not normalized_order_id:
return None
rows = PAYMENT_CHECKOUT._rest(
"GET",
"payment_intents",
params={
"select": "id,user_id",
"order_id_hex": f"eq.{normalized_order_id}",
"limit": "1",
},
allowed_status=[200],
)
row = rows[0] if isinstance(rows, list) and rows else None
if not isinstance(row, dict):
return None
if str(row.get("user_id") or "").strip() != normalized_user_id:
return None
return row
def _decode_pay_call(tx_hash: str) -> Optional[Dict[str, Any]]: def _decode_pay_call(tx_hash: str) -> Optional[Dict[str, Any]]:
@@ -16,6 +16,21 @@ def _lookup_user_id_by_email(email: str) -> str:
from src.payments.contract_checkout import PAYMENT_CHECKOUT, PaymentCheckoutError from src.payments.contract_checkout import PAYMENT_CHECKOUT, PaymentCheckoutError
normalized_email = str(email or "").strip().lower() normalized_email = str(email or "").strip().lower()
profile_rows = PAYMENT_CHECKOUT._rest( # noqa: SLF001
"GET",
"profiles",
params={
"select": "id",
"email": f"eq.{normalized_email}",
"limit": "1",
},
allowed_status=[200],
)
if isinstance(profile_rows, list) and profile_rows:
user_id = str((profile_rows[0] or {}).get("id") or "").strip()
if user_id:
return user_id
payload = PAYMENT_CHECKOUT._auth_admin_request( # noqa: SLF001 payload = PAYMENT_CHECKOUT._auth_admin_request( # noqa: SLF001
"GET", "GET",
f"/admin/users?email={email}", f"/admin/users?email={email}",
+98
View File
@@ -0,0 +1,98 @@
-- Supabase Disk IO diagnostics for PolyWeather.
-- Run sections independently if your project does not expose every stats view.
-- 1) Tables with high sequential scan pressure.
select
schemaname,
relname,
seq_scan,
seq_tup_read,
idx_scan,
n_tup_ins,
n_tup_upd,
n_tup_del,
n_dead_tup
from pg_stat_user_tables
where schemaname = 'public'
order by seq_tup_read desc
limit 30;
-- 2) Largest public tables and indexes.
select
relname,
pg_size_pretty(pg_total_relation_size(relid)) as total_size,
pg_size_pretty(pg_relation_size(relid)) as table_size,
pg_size_pretty(pg_indexes_size(relid)) as index_size
from pg_catalog.pg_statio_user_tables
where schemaname = 'public'
order by pg_total_relation_size(relid) desc
limit 30;
-- 3) Slow/high-read statements, if pg_stat_statements is available.
select
calls,
round(total_exec_time::numeric, 2) as total_exec_ms,
round(mean_exec_time::numeric, 2) as mean_exec_ms,
rows,
shared_blks_read,
shared_blks_hit,
temp_blks_read,
temp_blks_written,
left(query, 500) as query_sample
from pg_stat_statements
order by shared_blks_read desc, total_exec_time desc
limit 30;
-- 4) Dead tuple pressure that can trigger heavier autovacuum work.
select
schemaname,
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
from pg_stat_user_tables
where schemaname = 'public'
order by n_dead_tup desc
limit 30;
-- 5) Indexes that still read the most disk blocks.
select
s.schemaname,
s.relname,
s.indexrelname,
s.idx_scan,
s.idx_tup_read,
s.idx_tup_fetch,
io.idx_blks_read,
io.idx_blks_hit,
pg_size_pretty(pg_relation_size(s.indexrelid)) as index_size
from pg_stat_user_indexes s
join pg_statio_user_indexes io
on io.indexrelid = s.indexrelid
where s.schemaname = 'public'
order by io.idx_blks_read desc, s.idx_scan desc
limit 50;
-- 6) Large non-unique indexes with no recorded scans since stats reset.
-- Treat this as a candidate list only; confirm with production traffic history
-- before dropping anything.
select
s.schemaname,
s.relname,
s.indexrelname,
s.idx_scan,
pg_size_pretty(pg_relation_size(s.indexrelid)) as index_size,
i.indisunique,
i.indisprimary
from pg_stat_user_indexes s
join pg_index i
on i.indexrelid = s.indexrelid
where s.schemaname = 'public'
and s.idx_scan = 0
and not i.indisprimary
and not i.indisunique
order by pg_relation_size(s.indexrelid) desc
limit 30;
+89
View File
@@ -0,0 +1,89 @@
-- PolyWeather Supabase Disk IO mitigation indexes.
-- Run this in the Supabase SQL Editor for an existing production project.
drop index if exists public.idx_subscriptions_user_status_expiry;
create index if not exists idx_subscriptions_user_status_expiry
on public.subscriptions(user_id, expires_at desc)
include (id, starts_at, plan_code, source)
where status = 'active';
drop index if exists public.idx_profiles_email;
create index if not exists idx_profiles_email
on public.profiles(email)
include (id);
drop index if exists public.idx_profiles_id_lookup;
create index if not exists idx_profiles_id_lookup
on public.profiles(id)
include (email, created_at);
drop index if exists public.idx_subscriptions_status_expiry;
create index if not exists idx_subscriptions_status_expiry
on public.subscriptions(expires_at asc)
include (user_id, starts_at, plan_code)
where status = 'active';
drop index if exists public.idx_subscriptions_user_created;
create index if not exists idx_subscriptions_user_created
on public.subscriptions(user_id, created_at desc)
include (id, status, plan_code, source, starts_at, expires_at, updated_at);
drop index if exists public.idx_payments_created_at;
create index if not exists idx_payments_created_at
on public.payments(created_at desc)
include (id, user_id, amount, currency, chain, tx_hash, status);
drop index if exists public.idx_user_wallets_user_chain;
create index if not exists idx_user_wallets_user_chain
on public.user_wallets(user_id, chain_id, is_primary desc, verified_at desc)
include (id, address)
where status = 'active';
drop index if exists public.idx_user_wallets_chain_address_owner;
create index if not exists idx_user_wallets_chain_address_owner
on public.user_wallets(chain_id, address)
include (user_id, status);
drop index if exists public.idx_wallet_link_challenges_lookup;
drop index if exists public.idx_payment_intents_user_status;
drop index if exists public.idx_payment_intents_status_updated;
create index if not exists idx_payment_intents_status_updated
on public.payment_intents(status, updated_at desc)
include (user_id)
where status in ('submitted', 'confirmed');
create index if not exists idx_payment_intents_user_status_updated
on public.payment_intents(user_id, status, updated_at desc);
drop index if exists public.idx_payment_intents_submitted_tx_updated;
create index if not exists idx_payment_intents_submitted_tx_updated
on public.payment_intents(updated_at asc)
include (id, user_id, tx_hash, chain_id)
where status = 'submitted' and tx_hash is not null;
create index if not exists idx_payment_intents_user_created
on public.payment_intents(user_id, created_at desc);
drop index if exists public.idx_payment_intents_tx_hash;
create index if not exists idx_payment_intents_tx_hash
on public.payment_intents(tx_hash)
include (id, user_id)
where tx_hash is not null;
create index if not exists idx_payment_transactions_intent
on public.payment_transactions(intent_id, created_at desc);
drop index if exists public.idx_payment_transactions_tx_hash_intent;
create index if not exists idx_payment_transactions_tx_hash_intent
on public.payment_transactions(tx_hash)
include (intent_id);
analyze public.subscriptions;
analyze public.profiles;
analyze public.payments;
analyze public.user_wallets;
analyze public.wallet_link_challenges;
analyze public.payment_intents;
analyze public.payment_transactions;
+53 -8
View File
@@ -12,6 +12,14 @@ create table if not exists public.profiles (
updated_at timestamptz not null default now() updated_at timestamptz not null default now()
); );
create index if not exists idx_profiles_email
on public.profiles(email)
include (id);
create index if not exists idx_profiles_id_lookup
on public.profiles(id)
include (email, created_at);
create table if not exists public.subscriptions ( create table if not exists public.subscriptions (
id bigserial primary key, id bigserial primary key,
user_id uuid not null references auth.users(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade,
@@ -25,7 +33,18 @@ create table if not exists public.subscriptions (
); );
create index if not exists idx_subscriptions_user_status_expiry create index if not exists idx_subscriptions_user_status_expiry
on public.subscriptions(user_id, status, expires_at desc); on public.subscriptions(user_id, expires_at desc)
include (id, starts_at, plan_code, source)
where status = 'active';
create index if not exists idx_subscriptions_status_expiry
on public.subscriptions(expires_at asc)
include (user_id, starts_at, plan_code)
where status = 'active';
create index if not exists idx_subscriptions_user_created
on public.subscriptions(user_id, created_at desc)
include (id, status, plan_code, source, starts_at, expires_at, updated_at);
create table if not exists public.payments ( create table if not exists public.payments (
id bigserial primary key, id bigserial primary key,
@@ -40,6 +59,10 @@ create table if not exists public.payments (
updated_at timestamptz not null default now() updated_at timestamptz not null default now()
); );
create index if not exists idx_payments_created_at
on public.payments(created_at desc)
include (id, user_id, amount, currency, chain, tx_hash, status);
create table if not exists public.entitlement_events ( create table if not exists public.entitlement_events (
id bigserial primary key, id bigserial primary key,
user_id uuid not null references auth.users(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade,
@@ -65,7 +88,13 @@ create table if not exists public.user_wallets (
); );
create index if not exists idx_user_wallets_user_chain create index if not exists idx_user_wallets_user_chain
on public.user_wallets(user_id, chain_id, status, is_primary desc, verified_at desc); on public.user_wallets(user_id, chain_id, is_primary desc, verified_at desc)
include (id, address)
where status = 'active';
create index if not exists idx_user_wallets_chain_address_owner
on public.user_wallets(chain_id, address)
include (user_id, status);
create table if not exists public.wallet_link_challenges ( create table if not exists public.wallet_link_challenges (
id bigserial primary key, id bigserial primary key,
@@ -79,9 +108,6 @@ create table if not exists public.wallet_link_challenges (
created_at timestamptz not null default now() created_at timestamptz not null default now()
); );
create index if not exists idx_wallet_link_challenges_lookup
on public.wallet_link_challenges(user_id, chain_id, address, nonce, created_at desc);
create table if not exists public.payment_intents ( create table if not exists public.payment_intents (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade,
@@ -103,11 +129,26 @@ create table if not exists public.payment_intents (
updated_at timestamptz not null default now() updated_at timestamptz not null default now()
); );
create index if not exists idx_payment_intents_user_status create index if not exists idx_payment_intents_status_updated
on public.payment_intents(user_id, status, created_at desc); on public.payment_intents(status, updated_at desc)
include (user_id)
where status in ('submitted', 'confirmed');
create index if not exists idx_payment_intents_user_status_updated
on public.payment_intents(user_id, status, updated_at desc);
create index if not exists idx_payment_intents_submitted_tx_updated
on public.payment_intents(updated_at asc)
include (id, user_id, tx_hash, chain_id)
where status = 'submitted' and tx_hash is not null;
create index if not exists idx_payment_intents_user_created
on public.payment_intents(user_id, created_at desc);
create index if not exists idx_payment_intents_tx_hash create index if not exists idx_payment_intents_tx_hash
on public.payment_intents(tx_hash); on public.payment_intents(tx_hash)
include (id, user_id)
where tx_hash is not null;
create table if not exists public.payment_transactions ( create table if not exists public.payment_transactions (
id bigserial primary key, id bigserial primary key,
@@ -128,6 +169,10 @@ create table if not exists public.payment_transactions (
create index if not exists idx_payment_transactions_intent create index if not exists idx_payment_transactions_intent
on public.payment_transactions(intent_id, created_at desc); on public.payment_transactions(intent_id, created_at desc);
create index if not exists idx_payment_transactions_tx_hash_intent
on public.payment_transactions(tx_hash)
include (intent_id);
create or replace function public.sync_profile_from_auth() create or replace function public.sync_profile_from_auth()
returns trigger returns trigger
language plpgsql language plpgsql
+371 -18
View File
@@ -69,6 +69,14 @@ class SupabaseEntitlementService:
self._identity_cache_lock = threading.Lock() self._identity_cache_lock = threading.Lock()
self._sub_cache: Dict[str, Dict[str, object]] = {} self._sub_cache: Dict[str, Dict[str, object]] = {}
self._sub_cache_lock = threading.Lock() self._sub_cache_lock = threading.Lock()
self._latest_subscription_cache: Dict[str, Dict[str, object]] = {}
self._latest_subscription_cache_lock = threading.Lock()
self._active_subscription_bool_cache: Dict[str, Dict[str, object]] = {}
self._active_subscription_bool_cache_lock = threading.Lock()
self._active_subscriptions_cache: Dict[str, object] = {}
self._active_subscriptions_cache_lock = threading.Lock()
self._auth_users_cache: Dict[str, Dict[str, object]] = {}
self._auth_users_cache_lock = threading.Lock()
def invalidate_subscription_cache(self, user_id: str) -> None: def invalidate_subscription_cache(self, user_id: str) -> None:
key = str(user_id or "").strip() key = str(user_id or "").strip()
@@ -76,6 +84,12 @@ class SupabaseEntitlementService:
return return
with self._sub_cache_lock: with self._sub_cache_lock:
self._sub_cache.pop(key, None) self._sub_cache.pop(key, None)
with self._latest_subscription_cache_lock:
self._latest_subscription_cache.pop(key, None)
with self._active_subscription_bool_cache_lock:
self._active_subscription_bool_cache.pop(key, None)
with self._active_subscriptions_cache_lock:
self._active_subscriptions_cache.clear()
@property @property
def configured(self) -> bool: def configured(self) -> bool:
@@ -90,6 +104,9 @@ class SupabaseEntitlementService:
def _entitlement_events_endpoint(self) -> str: def _entitlement_events_endpoint(self) -> str:
return f"{self.supabase_url}/rest/v1/entitlement_events" return f"{self.supabase_url}/rest/v1/entitlement_events"
def _profiles_endpoint(self) -> str:
return f"{self.supabase_url}/rest/v1/profiles"
def _request_headers_for_user(self, access_token: str) -> Dict[str, str]: def _request_headers_for_user(self, access_token: str) -> Dict[str, str]:
return { return {
"apikey": self.anon_key, "apikey": self.anon_key,
@@ -116,8 +133,7 @@ class SupabaseEntitlementService:
cached = self._identity_cache.get(access_token) cached = self._identity_cache.get(access_token)
if cached and now_ts - float(cached.get("ts") or 0) < self.cache_ttl_sec: if cached and now_ts - float(cached.get("ts") or 0) < self.cache_ttl_sec:
identity = cached.get("identity") identity = cached.get("identity")
if isinstance(identity, SupabaseIdentity): return identity if isinstance(identity, SupabaseIdentity) else None
return identity
if not self.configured: if not self.configured:
return None return None
@@ -129,10 +145,21 @@ class SupabaseEntitlementService:
timeout=self.timeout_sec, timeout=self.timeout_sec,
) )
if response.status_code != 200: if response.status_code != 200:
if response.status_code in {401, 403}:
with self._identity_cache_lock:
self._identity_cache[access_token] = {
"identity": None,
"ts": now_ts,
}
return None return None
data = response.json() if response.content else {} data = response.json() if response.content else {}
user_id = str(data.get("id") or "").strip() user_id = str(data.get("id") or "").strip()
if not user_id: if not user_id:
with self._identity_cache_lock:
self._identity_cache[access_token] = {
"identity": None,
"ts": now_ts,
}
return None return None
# Extract points from user_metadata # Extract points from user_metadata
@@ -176,17 +203,26 @@ class SupabaseEntitlementService:
if isinstance(row, dict): if isinstance(row, dict):
return row return row
return None return None
with self._active_subscription_bool_cache_lock:
cached_bool = self._active_subscription_bool_cache.get(user_id)
if (
cached_bool
and now_ts - float(cached_bool.get("ts") or 0) < self.sub_cache_ttl_sec
and cached_bool.get("active") is False
):
return None
try: try:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
now_iso = now.isoformat() now_iso = now.isoformat()
params = { params = {
"select": "id,user_id,status,plan_code,starts_at,expires_at", "select": "plan_code,source,starts_at,expires_at",
"user_id": f"eq.{user_id}", "user_id": f"eq.{user_id}",
"status": "eq.active", "status": "eq.active",
"starts_at": f"lte.{now_iso}",
"expires_at": f"gt.{now_iso}", "expires_at": f"gt.{now_iso}",
"order": "expires_at.desc", "order": "expires_at.desc",
"limit": "20", "limit": "1",
} }
response = requests.get( response = requests.get(
self._subscription_endpoint(), self._subscription_endpoint(),
@@ -205,13 +241,12 @@ class SupabaseEntitlementService:
else: else:
data = response.json() if response.content else [] data = response.json() if response.content else []
rows = [item for item in data if isinstance(item, dict)] if isinstance(data, list) else [] rows = [item for item in data if isinstance(item, dict)] if isinstance(data, list) else []
row = self._pick_latest_current_subscription(rows, now=now) row = rows[0] if rows else None
with self._sub_cache_lock: with self._sub_cache_lock:
self._sub_cache[user_id] = { self._sub_cache[user_id] = {
"active": bool(row), "active": bool(row),
"row": row, "row": row,
"rows": rows,
"ts": now_ts, "ts": now_ts,
} }
return row return row
@@ -238,16 +273,12 @@ class SupabaseEntitlementService:
rows = cached.get("rows") rows = cached.get("rows")
if isinstance(rows, list): if isinstance(rows, list):
return [row for row in rows if isinstance(row, dict)] return [row for row in rows if isinstance(row, dict)]
row = cached.get("row")
if isinstance(row, dict):
return [row]
return []
try: try:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
now_iso = now.isoformat() now_iso = now.isoformat()
params = { params = {
"select": "id,user_id,status,plan_code,starts_at,expires_at,source,created_at,updated_at", "select": "plan_code,source,starts_at,expires_at",
"user_id": f"eq.{user_id}", "user_id": f"eq.{user_id}",
"status": "eq.active", "status": "eq.active",
"expires_at": f"gt.{now_iso}", "expires_at": f"gt.{now_iso}",
@@ -290,9 +321,15 @@ class SupabaseEntitlementService:
) -> Optional[Dict[str, object]]: ) -> Optional[Dict[str, object]]:
if not user_id or not self.service_role_key: if not user_id or not self.service_role_key:
return None return None
now_ts = time.time()
with self._latest_subscription_cache_lock:
cached = self._latest_subscription_cache.get(user_id)
if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
row = cached.get("row")
return row if isinstance(row, dict) else None
try: try:
params = { params = {
"select": "id,user_id,status,plan_code,starts_at,expires_at,source,created_at,updated_at", "select": "plan_code,starts_at,expires_at",
"user_id": f"eq.{user_id}", "user_id": f"eq.{user_id}",
"order": "created_at.desc", "order": "created_at.desc",
"limit": "1", "limit": "1",
@@ -312,7 +349,13 @@ class SupabaseEntitlementService:
return None return None
data = response.json() if response.content else [] data = response.json() if response.content else []
row = data[0] if isinstance(data, list) and data else None row = data[0] if isinstance(data, list) and data else None
return row if isinstance(row, dict) else None result = row if isinstance(row, dict) else None
with self._latest_subscription_cache_lock:
self._latest_subscription_cache[user_id] = {
"row": result,
"ts": now_ts,
}
return result
except Exception as exc: except Exception as exc:
logger.warning(f"supabase subscription history query error user_id={user_id}: {exc}") logger.warning(f"supabase subscription history query error user_id={user_id}: {exc}")
return None return None
@@ -359,7 +402,68 @@ class SupabaseEntitlementService:
return None return None
def _query_active_subscription(self, user_id: str) -> bool: def _query_active_subscription(self, user_id: str) -> bool:
return self._query_latest_active_subscription(user_id) is not None if not user_id:
return False
if not self.service_role_key:
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
return False
now_ts = time.time()
with self._sub_cache_lock:
cached_detail = self._sub_cache.get(user_id)
if cached_detail and now_ts - float(cached_detail.get("ts") or 0) < self.sub_cache_ttl_sec:
rows = cached_detail.get("rows")
if isinstance(rows, list):
return self._pick_latest_current_subscription(
[row for row in rows if isinstance(row, dict)]
) is not None
if "row" in cached_detail:
return isinstance(cached_detail.get("row"), dict)
with self._active_subscription_bool_cache_lock:
cached = self._active_subscription_bool_cache.get(user_id)
if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
return bool(cached.get("active"))
try:
now = datetime.now(timezone.utc)
now_iso = now.isoformat()
params = {
"select": "expires_at",
"user_id": f"eq.{user_id}",
"status": "eq.active",
"starts_at": f"lte.{now_iso}",
"expires_at": f"gt.{now_iso}",
"order": "expires_at.desc",
"limit": "1",
}
response = requests.get(
self._subscription_endpoint(),
headers=self._request_headers_for_service_role(),
params=params,
timeout=self.timeout_sec,
)
if response.status_code != 200:
logger.warning(
"supabase active subscription bool query failed user_id={} status={}",
user_id,
response.status_code,
)
active = False
else:
data = response.json() if response.content else []
rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else []
active = bool(rows)
with self._active_subscription_bool_cache_lock:
self._active_subscription_bool_cache[user_id] = {
"active": bool(active),
"ts": now_ts,
}
return bool(active)
except Exception as exc:
logger.warning(f"supabase active subscription bool query error user_id={user_id}: {exc}")
return False
def get_latest_active_subscription( def get_latest_active_subscription(
self, self,
@@ -385,9 +489,14 @@ class SupabaseEntitlementService:
if respect_requirement and not self.require_subscription: if respect_requirement and not self.require_subscription:
return {} return {}
rows = self._query_active_subscription_rows(user_id, bypass_cache=bypass_cache) rows = self._query_active_subscription_rows(user_id, bypass_cache=bypass_cache)
return self._subscription_window_from_rows(rows)
def _subscription_window_from_rows(
self,
rows: List[Dict[str, object]],
) -> Dict[str, object]:
if not rows: if not rows:
return {} return {}
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
current = self._pick_latest_current_subscription(rows, now=now) current = self._pick_latest_current_subscription(rows, now=now)
total_expiry: Optional[datetime] = None total_expiry: Optional[datetime] = None
@@ -422,6 +531,153 @@ class SupabaseEntitlementService:
"rows": rows, "rows": rows,
} }
def list_subscription_windows(
self,
user_ids: List[str],
bypass_cache: bool = False,
) -> Dict[str, Dict[str, object]]:
keys: List[str] = []
for item in user_ids or []:
key = str(item or "").strip().lower()
if key and key not in keys:
keys.append(key)
if not keys:
return {}
out: Dict[str, Dict[str, object]] = {}
if not bypass_cache:
missing: List[str] = []
now_ts = time.time()
with self._sub_cache_lock:
for key in keys:
cached = self._sub_cache.get(key)
if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
rows = cached.get("rows")
if isinstance(rows, list):
out[key] = self._subscription_window_from_rows(
[row for row in rows if isinstance(row, dict)]
)
continue
missing.append(key)
keys = missing
if not keys:
return out
if not self.service_role_key:
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
return out
try:
now = datetime.now(timezone.utc)
now_iso = now.isoformat()
params = {
"select": "user_id,plan_code,source,starts_at,expires_at",
"user_id": f"in.({','.join(keys)})",
"status": "eq.active",
"expires_at": f"gt.{now_iso}",
"order": "user_id.asc,expires_at.desc",
"limit": str(max(1, min(len(keys) * 20, 1000))),
}
response = requests.get(
self._subscription_endpoint(),
headers=self._request_headers_for_service_role(),
params=params,
timeout=self.timeout_sec,
)
if response.status_code != 200:
logger.warning(
"supabase subscription window batch query failed users={} status={}",
len(keys),
response.status_code,
)
return out
data = response.json() if response.content else []
rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else []
grouped: Dict[str, List[Dict[str, object]]] = {key: [] for key in keys}
for row in rows:
key = str(row.get("user_id") or "").strip().lower()
if key in grouped:
grouped[key].append(row)
now_ts = time.time()
with self._sub_cache_lock:
for key, user_rows in grouped.items():
current_row = self._pick_latest_current_subscription(user_rows, now=now)
self._sub_cache[key] = {
"active": bool(current_row),
"row": current_row,
"rows": user_rows,
"ts": now_ts,
}
out[key] = self._subscription_window_from_rows(user_rows)
return out
except Exception as exc:
logger.warning(f"supabase subscription window batch query error users={len(keys)}: {exc}")
return out
def list_active_subscription_windows(self, limit: int = 200) -> Dict[str, object]:
if not self.service_role_key:
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
return {"subscriptions": [], "windows": {}}
safe_limit = max(1, min(int(limit or 200), 1000))
try:
now = datetime.now(timezone.utc)
now_iso = now.isoformat()
params = {
"select": "user_id,plan_code,source,starts_at,expires_at",
"status": "eq.active",
"expires_at": f"gt.{now_iso}",
"order": "user_id.asc,expires_at.desc",
"limit": str(max(1, min(safe_limit * 20, 5000))),
}
response = requests.get(
self._subscription_endpoint(),
headers=self._request_headers_for_service_role(),
params=params,
timeout=self.timeout_sec,
)
if response.status_code != 200:
logger.warning(
"supabase active subscription window query failed status={}",
response.status_code,
)
return {"subscriptions": [], "windows": {}}
data = response.json() if response.content else []
rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else []
grouped: Dict[str, List[Dict[str, object]]] = {}
for row in rows:
key = str(row.get("user_id") or "").strip().lower()
if key:
grouped.setdefault(key, []).append(row)
windows: Dict[str, Dict[str, object]] = {}
current_rows: List[Dict[str, object]] = []
now_ts = time.time()
with self._sub_cache_lock:
for key, user_rows in grouped.items():
current_row = self._pick_latest_current_subscription(user_rows, now=now)
self._sub_cache[key] = {
"active": bool(current_row),
"row": current_row,
"rows": user_rows,
"ts": now_ts,
}
windows[key] = self._subscription_window_from_rows(user_rows)
if isinstance(current_row, dict):
current_rows.append(current_row)
current_rows.sort(key=lambda row: str(row.get("expires_at") or ""))
current_rows = current_rows[:safe_limit]
with self._active_subscriptions_cache_lock:
self._active_subscriptions_cache[str(safe_limit)] = {
"rows": current_rows,
"ts": now_ts,
}
return {"subscriptions": current_rows, "windows": windows}
except Exception as exc:
logger.warning(f"supabase active subscription window query error: {exc}")
return {"subscriptions": [], "windows": {}}
def has_active_subscription( def has_active_subscription(
self, self,
user_id: str, user_id: str,
@@ -435,12 +691,20 @@ class SupabaseEntitlementService:
if not self.service_role_key: if not self.service_role_key:
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing") logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
return [] return []
safe_limit = max(1, min(int(limit or 200), 1000))
cache_key = str(safe_limit)
now_ts = time.time()
with self._active_subscriptions_cache_lock:
cached = self._active_subscriptions_cache.get(cache_key)
if isinstance(cached, dict) 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)]
try: try:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
safe_limit = max(1, min(int(limit or 200), 1000))
now_iso = now.isoformat() now_iso = now.isoformat()
params = { params = {
"select": "id,user_id,status,plan_code,starts_at,expires_at", "select": "user_id,plan_code,starts_at,expires_at",
"status": "eq.active", "status": "eq.active",
"expires_at": f"gt.{now_iso}", "expires_at": f"gt.{now_iso}",
"order": "expires_at.asc", "order": "expires_at.asc",
@@ -461,11 +725,17 @@ class SupabaseEntitlementService:
data = response.json() if response.content else [] data = response.json() if response.content else []
if not isinstance(data, list): if not isinstance(data, list):
return [] return []
return [ rows = [
row row
for row in data for row in data
if isinstance(row, dict) and self._is_subscription_started(row, now=now) if isinstance(row, dict) and self._is_subscription_started(row, now=now)
] ]
with self._active_subscriptions_cache_lock:
self._active_subscriptions_cache[cache_key] = {
"rows": rows,
"ts": now_ts,
}
return rows
except Exception as exc: except Exception as exc:
logger.warning(f"supabase active subscriptions query error: {exc}") logger.warning(f"supabase active subscriptions query error: {exc}")
return [] return []
@@ -484,6 +754,29 @@ class SupabaseEntitlementService:
return {} return {}
out: Dict[str, Dict[str, object]] = {} out: Dict[str, Dict[str, object]] = {}
now_ts = time.time()
missing_keys: List[str] = []
with self._auth_users_cache_lock:
for key in keys:
cached = self._auth_users_cache.get(key)
if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
user = cached.get("user")
if isinstance(user, dict):
out[key] = dict(user)
continue
missing_keys.append(key)
keys = missing_keys
if not keys:
return out
profile_users = self._get_profile_users(keys)
if profile_users:
self._remember_auth_users(profile_users)
out.update(profile_users)
keys = [key for key in keys if key not in out]
if not keys:
return out
for user_id in keys: for user_id in keys:
try: try:
response = requests.get( response = requests.get(
@@ -506,9 +799,69 @@ class SupabaseEntitlementService:
"email": str(payload.get("email") or "").strip(), "email": str(payload.get("email") or "").strip(),
"created_at": payload.get("created_at"), "created_at": payload.get("created_at"),
} }
self._remember_auth_users({user_id: out[user_id]})
except Exception as exc: except Exception as exc:
logger.warning(f"supabase admin user query error user_id={user_id}: {exc}") logger.warning(f"supabase admin user query error user_id={user_id}: {exc}")
return out return out
def _remember_auth_users(self, users: Dict[str, Dict[str, object]]) -> None:
if not users:
return
now_ts = time.time()
with self._auth_users_cache_lock:
for raw_key, user in users.items():
key = str(raw_key or "").strip().lower()
if key and isinstance(user, dict):
self._auth_users_cache[key] = {
"user": dict(user),
"ts": now_ts,
}
if len(self._auth_users_cache) > 4096:
oldest_keys = sorted(
self._auth_users_cache,
key=lambda key: float(
self._auth_users_cache[key].get("ts") or 0.0
),
)
for key in oldest_keys[: len(self._auth_users_cache) - 4096]:
self._auth_users_cache.pop(key, None)
def _get_profile_users(self, user_ids: List[str]) -> Dict[str, Dict[str, object]]:
if not user_ids or not self.service_role_key:
return {}
try:
response = requests.get(
self._profiles_endpoint(),
headers=self._request_headers_for_service_role(),
params={
"select": "id,email,created_at",
"id": f"in.({','.join(user_ids)})",
"limit": str(max(1, min(len(user_ids), 1000))),
},
timeout=self.timeout_sec,
)
if response.status_code != 200:
logger.warning(
"supabase profile users batch query failed users={} status={}",
len(user_ids),
response.status_code,
)
return {}
data = response.json() if response.content else []
rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else []
out: Dict[str, Dict[str, object]] = {}
for row in rows:
user_id = str(row.get("id") or "").strip().lower()
if not user_id:
continue
out[user_id] = {
"email": str(row.get("email") or "").strip(),
"created_at": row.get("created_at"),
}
return out
except Exception as exc:
logger.warning(f"supabase profile users batch query error users={len(user_ids)}: {exc}")
return {}
SUPABASE_ENTITLEMENT = SupabaseEntitlementService() SUPABASE_ENTITLEMENT = SupabaseEntitlementService()
+1
View File
@@ -457,6 +457,7 @@ class BasicCommandHandler:
for row in rows: for row in rows:
if self._subscription_row_is_paid(row): if self._subscription_row_is_paid(row):
return True return True
return False
if hasattr(self.entitlement_service, "get_latest_active_subscription"): if hasattr(self.entitlement_service, "get_latest_active_subscription"):
row = self.entitlement_service.get_latest_active_subscription( row = self.entitlement_service.get_latest_active_subscription(
supabase_user_id, supabase_user_id,
+1 -1
View File
@@ -169,7 +169,7 @@ def _grant_bonus_subscription_days(
} }
ins = requests.post( ins = requests.post(
f"{base}/rest/v1/subscriptions", f"{base}/rest/v1/subscriptions",
headers={**headers, "Prefer": "return=representation"}, headers={**headers, "Prefer": "return=minimal"},
json=create_payload, json=create_payload,
timeout=timeout_sec, timeout=timeout_sec,
) )
+169 -6
View File
@@ -4,6 +4,7 @@ import hashlib
import json import json
import secrets import secrets
import threading import threading
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List, Set from typing import Optional, Dict, Any, List, Set
@@ -14,6 +15,10 @@ from loguru import logger
class DBManager: class DBManager:
_init_lock = threading.Lock() _init_lock = threading.Lock()
_initialized_paths: Set[str] = set() _initialized_paths: Set[str] = set()
_points_sync_lock = threading.Lock()
_points_sync_cache: Dict[str, Dict[str, Any]] = {}
_profile_sync_lock = threading.Lock()
_profile_sync_cache: Dict[str, Dict[str, Any]] = {}
def __init__(self, db_path: Optional[str] = None): def __init__(self, db_path: Optional[str] = None):
self.db_path = self._resolve_db_path(db_path) self.db_path = self._resolve_db_path(db_path)
@@ -67,7 +72,135 @@ class DBManager:
return "" return ""
return f"{supabase_url}/auth/v1/admin/users" return f"{supabase_url}/auth/v1/admin/users"
def _sync_points_to_supabase_user_metadata(self, telegram_id: int) -> bool: def _profile_sync_min_interval_sec(self) -> float:
raw = str(
os.getenv("POLYWEATHER_SUPABASE_PROFILE_SYNC_MIN_INTERVAL_SEC", "3600")
or ""
).strip()
try:
return max(0.0, float(raw))
except ValueError:
return 3600.0
def _profile_sync_cache_key(self, supabase_user_id: str) -> str:
endpoint = self._supabase_profiles_endpoint()
return f"{endpoint}:{str(supabase_user_id or '').strip().lower()}"
def _should_skip_profile_sync(
self,
*,
supabase_user_id: str,
telegram_id: Optional[int],
telegram_username: Optional[str],
force: bool = False,
) -> bool:
if force:
return False
min_interval = self._profile_sync_min_interval_sec()
if min_interval <= 0:
return False
payload_key = {
"telegram_id": int(telegram_id) if telegram_id is not None else None,
"telegram_username": str(telegram_username or "").strip() or None,
}
cache_key = self._profile_sync_cache_key(supabase_user_id)
now_ts = time.monotonic()
with self._profile_sync_lock:
cached = self._profile_sync_cache.get(cache_key)
if not isinstance(cached, dict):
return False
cached_payload = cached.get("payload")
cached_ts = float(cached.get("ts") or 0.0)
return cached_payload == payload_key and now_ts - cached_ts < min_interval
def _remember_profile_sync(
self,
*,
supabase_user_id: str,
telegram_id: Optional[int],
telegram_username: Optional[str],
) -> None:
cache_key = self._profile_sync_cache_key(supabase_user_id)
payload_key = {
"telegram_id": int(telegram_id) if telegram_id is not None else None,
"telegram_username": str(telegram_username or "").strip() or None,
}
with self._profile_sync_lock:
self._profile_sync_cache[cache_key] = {
"payload": payload_key,
"ts": time.monotonic(),
}
if len(self._profile_sync_cache) > 4096:
oldest_key = min(
self._profile_sync_cache,
key=lambda key: float(
self._profile_sync_cache[key].get("ts") or 0.0
),
)
self._profile_sync_cache.pop(oldest_key, None)
def _points_sync_cache_key(self, telegram_id: int) -> str:
return f"{os.path.abspath(self.db_path)}:{int(telegram_id)}"
def _points_sync_min_interval_sec(self) -> float:
raw = str(
os.getenv("POLYWEATHER_SUPABASE_POINTS_SYNC_MIN_INTERVAL_SEC", "60")
or ""
).strip()
try:
return max(0.0, float(raw))
except Exception:
return 60.0
def _should_skip_points_metadata_sync(
self,
*,
telegram_id: int,
points: int,
force: bool,
) -> bool:
if force:
return False
cache_key = self._points_sync_cache_key(telegram_id)
now_ts = time.monotonic()
min_interval = self._points_sync_min_interval_sec()
with self._points_sync_lock:
cached = self._points_sync_cache.get(cache_key)
if not cached:
return False
cached_points = int(cached.get("points") or 0)
cached_ts = float(cached.get("ts") or 0.0)
if cached_points == int(points):
return True
return min_interval > 0 and (now_ts - cached_ts) < min_interval
def _remember_points_metadata_sync(
self,
*,
telegram_id: int,
points: int,
) -> None:
cache_key = self._points_sync_cache_key(telegram_id)
with self._points_sync_lock:
self._points_sync_cache[cache_key] = {
"points": int(points),
"ts": time.monotonic(),
}
if len(self._points_sync_cache) > 4096:
oldest_key = min(
self._points_sync_cache,
key=lambda key: float(
self._points_sync_cache[key].get("ts") or 0.0
),
)
self._points_sync_cache.pop(oldest_key, None)
def _sync_points_to_supabase_user_metadata(
self,
telegram_id: int,
*,
force: bool = False,
) -> bool:
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
if not supabase_url: if not supabase_url:
return False return False
@@ -104,6 +237,13 @@ class DBManager:
if pts_row: if pts_row:
points = max(0, int(pts_row["points"] or 0)) points = max(0, int(pts_row["points"] or 0))
if self._should_skip_points_metadata_sync(
telegram_id=int(telegram_id),
points=points,
force=force,
):
return False
try: try:
resp = requests.patch( resp = requests.patch(
f"{endpoint}/{supabase_user_id}", f"{endpoint}/{supabase_user_id}",
@@ -120,6 +260,10 @@ class DBManager:
(resp.text or "")[:200], (resp.text or "")[:200],
) )
return False return False
self._remember_points_metadata_sync(
telegram_id=int(telegram_id),
points=points,
)
return True return True
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
@@ -136,12 +280,20 @@ class DBManager:
supabase_user_id: str, supabase_user_id: str,
telegram_id: Optional[int], telegram_id: Optional[int],
telegram_username: Optional[str], telegram_username: Optional[str],
force: bool = False,
) -> bool: ) -> bool:
normalized_uid = str(supabase_user_id or "").strip().lower() normalized_uid = str(supabase_user_id or "").strip().lower()
endpoint = self._supabase_profiles_endpoint() endpoint = self._supabase_profiles_endpoint()
headers = self._supabase_service_headers() headers = self._supabase_service_headers()
if not normalized_uid or not endpoint or not headers: if not normalized_uid or not endpoint or not headers:
return False return False
if self._should_skip_profile_sync(
supabase_user_id=normalized_uid,
telegram_id=telegram_id,
telegram_username=telegram_username,
force=force,
):
return False
payload = { payload = {
"telegram_user_id": int(telegram_id) if telegram_id is not None else None, "telegram_user_id": int(telegram_id) if telegram_id is not None else None,
@@ -164,6 +316,11 @@ class DBManager:
(response.text or "")[:240], (response.text or "")[:240],
) )
return False return False
self._remember_profile_sync(
supabase_user_id=normalized_uid,
telegram_id=telegram_id,
telegram_username=telegram_username,
)
return True return True
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
@@ -1274,7 +1431,7 @@ class DBManager:
(after, telegram_id), (after, telegram_id),
) )
conn.commit() conn.commit()
self._sync_points_to_supabase_user_metadata(telegram_id) self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
return { return {
"ok": True, "ok": True,
"telegram_id": telegram_id, "telegram_id": telegram_id,
@@ -1326,7 +1483,7 @@ class DBManager:
(after, telegram_id), (after, telegram_id),
) )
conn.commit() conn.commit()
self._sync_points_to_supabase_user_metadata(telegram_id) self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
return { return {
"ok": True, "ok": True,
"telegram_id": telegram_id, "telegram_id": telegram_id,
@@ -1471,6 +1628,7 @@ class DBManager:
supabase_user_id=normalized_uid, supabase_user_id=normalized_uid,
telegram_id=int(telegram_id), telegram_id=int(telegram_id),
telegram_username=str((user_row["username"] if user_row else "") or "").strip(), telegram_username=str((user_row["username"] if user_row else "") or "").strip(),
force=True,
) )
return {"ok": True, "reason": "already_bound_same"} return {"ok": True, "reason": "already_bound_same"}
@@ -1496,6 +1654,7 @@ class DBManager:
supabase_user_id=normalized_uid, supabase_user_id=normalized_uid,
telegram_id=int(telegram_id), telegram_id=int(telegram_id),
telegram_username=str((user_row["username"] if user_row else "") or "").strip(), telegram_username=str((user_row["username"] if user_row else "") or "").strip(),
force=True,
) )
return {"ok": True, "reason": "bound"} return {"ok": True, "reason": "bound"}
@@ -1551,6 +1710,7 @@ class DBManager:
supabase_user_id=user_id, supabase_user_id=user_id,
telegram_id=None, telegram_id=None,
telegram_username=None, telegram_username=None,
force=True,
) )
return {"ok": True, "reason": "unbound", "previous_supabase_user_id": current_uid} return {"ok": True, "reason": "unbound", "previous_supabase_user_id": current_uid}
@@ -1940,7 +2100,7 @@ class DBManager:
(new_balance, telegram_id), (new_balance, telegram_id),
) )
conn.commit() conn.commit()
self._sync_points_to_supabase_user_metadata(telegram_id) self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
return {"ok": True, "balance": new_balance, "spent": amount} return {"ok": True, "balance": new_balance, "spent": amount}
def spend_points_by_supabase_user_id(self, supabase_user_id: str, amount: int) -> Dict[str, Any]: def spend_points_by_supabase_user_id(self, supabase_user_id: str, amount: int) -> Dict[str, Any]:
@@ -1978,7 +2138,7 @@ class DBManager:
(new_balance, telegram_id), (new_balance, telegram_id),
) )
conn.commit() conn.commit()
self._sync_points_to_supabase_user_metadata(telegram_id) self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
return {"ok": True, "balance": new_balance, "spent": amount} return {"ok": True, "balance": new_balance, "spent": amount}
def set_premium(self, telegram_id: int, plan: str, months: int = 1): def set_premium(self, telegram_id: int, plan: str, months: int = 1):
@@ -2290,7 +2450,10 @@ class DBManager:
) )
conn.commit() conn.commit()
if bonus > 0: if bonus > 0:
self._sync_points_to_supabase_user_metadata(int(telegram_id)) self._sync_points_to_supabase_user_metadata(
int(telegram_id),
force=True,
)
return True return True
def append_airport_obs( def append_airport_obs(
+123 -123
View File
@@ -4,6 +4,7 @@ import json
import os import os
import secrets import secrets
import threading import threading
import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation, ROUND_FLOOR from decimal import Decimal, InvalidOperation, ROUND_FLOOR
@@ -1307,7 +1308,7 @@ class PaymentContractCheckoutService:
"GET", "GET",
"user_wallets", "user_wallets",
params={ params={
"select": "chain_id,address,status,is_primary,verified_at", "select": "chain_id,address,is_primary,verified_at",
"user_id": f"eq.{user_id}", "user_id": f"eq.{user_id}",
"chain_id": f"eq.{self.chain_id}", "chain_id": f"eq.{self.chain_id}",
"status": "eq.active", "status": "eq.active",
@@ -1323,7 +1324,7 @@ class PaymentContractCheckoutService:
WalletBindingRecord( WalletBindingRecord(
chain_id=int(row.get("chain_id") or self.chain_id), chain_id=int(row.get("chain_id") or self.chain_id),
address=_normalize_address(row.get("address") or ""), address=_normalize_address(row.get("address") or ""),
status=str(row.get("status") or "active"), status="active",
is_primary=bool(row.get("is_primary")), is_primary=bool(row.get("is_primary")),
verified_at=row.get("verified_at"), verified_at=row.get("verified_at"),
) )
@@ -1338,7 +1339,7 @@ class PaymentContractCheckoutService:
"GET", "GET",
"user_wallets", "user_wallets",
params={ params={
"select": "id,user_id,address,chain_id,status,is_primary", "select": "status",
"user_id": f"eq.{user_id}", "user_id": f"eq.{user_id}",
"chain_id": f"eq.{self.chain_id}", "chain_id": f"eq.{self.chain_id}",
"address": f"eq.{normalized}", "address": f"eq.{normalized}",
@@ -1381,7 +1382,7 @@ class PaymentContractCheckoutService:
"message": message, "message": message,
"expires_at": _to_iso(expires), "expires_at": _to_iso(expires),
}, },
prefer="return=representation", prefer="return=minimal",
allowed_status=[201], allowed_status=[201],
) )
return { return {
@@ -1414,13 +1415,12 @@ class PaymentContractCheckoutService:
"GET", "GET",
"wallet_link_challenges", "wallet_link_challenges",
params={ params={
"select": "id,user_id,address,nonce,message,expires_at,consumed_at", "select": "id,message,expires_at",
"user_id": f"eq.{user_id}", "user_id": f"eq.{user_id}",
"chain_id": f"eq.{self.chain_id}", "chain_id": f"eq.{self.chain_id}",
"address": f"eq.{normalized}", "address": f"eq.{normalized}",
"nonce": f"eq.{nonce_text}", "nonce": f"eq.{nonce_text}",
"consumed_at": "is.null", "consumed_at": "is.null",
"order": "created_at.desc",
"limit": "1", "limit": "1",
}, },
allowed_status=[200], allowed_status=[200],
@@ -1455,7 +1455,7 @@ class PaymentContractCheckoutService:
"GET", "GET",
"user_wallets", "user_wallets",
params={ params={
"select": "id,user_id,address,status,is_primary", "select": "user_id,status",
"chain_id": f"eq.{self.chain_id}", "chain_id": f"eq.{self.chain_id}",
"address": f"eq.{normalized}", "address": f"eq.{normalized}",
"limit": "1", "limit": "1",
@@ -1501,7 +1501,7 @@ class PaymentContractCheckoutService:
"verified_at": now_iso, "verified_at": now_iso,
"updated_at": now_iso, "updated_at": now_iso,
}, },
prefer="resolution=merge-duplicates,return=representation", prefer="resolution=merge-duplicates,return=minimal",
allowed_status=[200, 201], allowed_status=[200, 201],
) )
self._rest( self._rest(
@@ -1509,7 +1509,7 @@ class PaymentContractCheckoutService:
"wallet_link_challenges", "wallet_link_challenges",
params={"id": f"eq.{challenge.get('id')}"}, params={"id": f"eq.{challenge.get('id')}"},
payload={"consumed_at": now_iso}, payload={"consumed_at": now_iso},
prefer="return=representation", prefer="return=minimal",
allowed_status=[200], allowed_status=[200],
) )
return WalletBindingRecord( return WalletBindingRecord(
@@ -1543,7 +1543,7 @@ class PaymentContractCheckoutService:
"is_primary": False, "is_primary": False,
"updated_at": now_iso, "updated_at": now_iso,
}, },
prefer="return=representation", prefer="return=minimal",
allowed_status=[200], allowed_status=[200],
) )
@@ -1591,7 +1591,7 @@ class PaymentContractCheckoutService:
"user_wallets", "user_wallets",
params={"id": f"eq.{candidate_id}"}, params={"id": f"eq.{candidate_id}"},
payload={"is_primary": True, "updated_at": now_iso}, payload={"is_primary": True, "updated_at": now_iso},
prefer="return=representation", prefer="return=minimal",
allowed_status=[200], allowed_status=[200],
) )
new_primary = candidate_addr new_primary = candidate_addr
@@ -1780,32 +1780,32 @@ class PaymentContractCheckoutService:
order_id_hex = "0x" + secrets.token_hex(32) order_id_hex = "0x" + secrets.token_hex(32)
now = _now_utc() now = _now_utc()
expires_at = now + timedelta(seconds=self.intent_ttl_sec) expires_at = now + timedelta(seconds=self.intent_ttl_sec)
rows = self._rest( intent_payload = {
"id": str(uuid.uuid4()),
"user_id": user_id,
"plan_code": plan["plan_code"],
"plan_id": plan["plan_id"],
"chain_id": selected_chain_id,
"token_address": selected_token.address,
"receiver_address": receiver_address,
"amount_units": str(amount_units),
"payment_mode": mode,
"allowed_wallet": target_wallet or None,
"order_id_hex": order_id_hex,
"status": "created",
"expires_at": _to_iso(expires_at),
"metadata": combined_metadata,
"created_at": _to_iso(now),
"updated_at": _to_iso(now),
}
self._rest(
"POST", "POST",
"payment_intents", "payment_intents",
payload={ payload=intent_payload,
"user_id": user_id, prefer="return=minimal",
"plan_code": plan["plan_code"],
"plan_id": plan["plan_id"],
"chain_id": selected_chain_id,
"token_address": selected_token.address,
"receiver_address": receiver_address,
"amount_units": str(amount_units),
"payment_mode": mode,
"allowed_wallet": target_wallet or None,
"order_id_hex": order_id_hex,
"status": "created",
"expires_at": _to_iso(expires_at),
"metadata": combined_metadata,
"created_at": _to_iso(now),
"updated_at": _to_iso(now),
},
prefer="return=representation",
allowed_status=[201], allowed_status=[201],
) )
if not isinstance(rows, list) or not rows: intent = self._serialize_intent(intent_payload)
raise PaymentCheckoutError(500, "failed to create payment intent")
intent = self._serialize_intent(rows[0])
response = { response = {
"intent": intent.__dict__, "intent": intent.__dict__,
"tx_payload": None if mode == "direct" else self._build_tx_payload(intent), "tx_payload": None if mode == "direct" else self._build_tx_payload(intent),
@@ -1885,7 +1885,7 @@ class PaymentContractCheckoutService:
"GET", "GET",
"payment_intents", "payment_intents",
params={ params={
"select": "id,user_id,tx_hash,status,updated_at,created_at,chain_id", "select": "id,user_id,tx_hash,chain_id",
"status": "eq.submitted", "status": "eq.submitted",
"tx_hash": "not.is.null", "tx_hash": "not.is.null",
"order": "updated_at.asc", "order": "updated_at.asc",
@@ -1911,9 +1911,6 @@ class PaymentContractCheckoutService:
"user_id": user_id, "user_id": user_id,
"tx_hash": tx_hash, "tx_hash": tx_hash,
"chain_id": int(row.get("chain_id") or self.chain_id), "chain_id": int(row.get("chain_id") or self.chain_id),
"status": str(row.get("status") or ""),
"updated_at": row.get("updated_at"),
"created_at": row.get("created_at"),
} }
) )
return out return out
@@ -1937,8 +1934,7 @@ class PaymentContractCheckoutService:
"payment_intents", "payment_intents",
params={ params={
"select": ( "select": (
"id,user_id,status,tx_hash,order_id_hex,plan_id,token_address," "id,user_id,status,tx_hash,plan_id,token_address,amount_units"
"amount_units,payment_mode,allowed_wallet,chain_id,created_at,updated_at"
), ),
"order_id_hex": f"eq.{normalized_order}", "order_id_hex": f"eq.{normalized_order}",
"status": "in.(created,submitted,confirmed)", "status": "in.(created,submitted,confirmed)",
@@ -1965,15 +1961,9 @@ class PaymentContractCheckoutService:
"user_id": user_id, "user_id": user_id,
"status": status, "status": status,
"tx_hash": str(row.get("tx_hash") or "").strip().lower(), "tx_hash": str(row.get("tx_hash") or "").strip().lower(),
"order_id_hex": str(row.get("order_id_hex") or "").strip().lower(),
"plan_id": int(row.get("plan_id") or 0), "plan_id": int(row.get("plan_id") or 0),
"chain_id": int(row.get("chain_id") or self.chain_id),
"token_address": _normalize_address(row.get("token_address")), "token_address": _normalize_address(row.get("token_address")),
"amount_units": int(row.get("amount_units") or 0), "amount_units": int(row.get("amount_units") or 0),
"payment_mode": str(row.get("payment_mode") or "").strip().lower(),
"allowed_wallet": _normalize_address(row.get("allowed_wallet")),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
} }
) )
return out return out
@@ -1986,7 +1976,7 @@ class PaymentContractCheckoutService:
"GET", "GET",
"payment_transactions", "payment_transactions",
params={ params={
"select": "intent_id,tx_hash,status", "select": "intent_id",
"tx_hash": f"eq.{tx_hash_text}", "tx_hash": f"eq.{tx_hash_text}",
"limit": "5", "limit": "5",
}, },
@@ -2018,7 +2008,7 @@ class PaymentContractCheckoutService:
return {} return {}
now_iso = _to_iso(_now_utc()) now_iso = _to_iso(_now_utc())
try: try:
rows = self._rest( self._rest(
"POST", "POST",
"payment_transactions", "payment_transactions",
params={"on_conflict": "tx_hash"}, params={"on_conflict": "tx_hash"},
@@ -2040,10 +2030,10 @@ class PaymentContractCheckoutService:
}, },
"updated_at": now_iso, "updated_at": now_iso,
}, },
prefer="resolution=merge-duplicates,return=representation", prefer="resolution=merge-duplicates,return=minimal",
allowed_status=[200, 201], allowed_status=[200, 201],
) )
return rows[0] if isinstance(rows, list) and rows else {} return {}
except Exception: except Exception:
return {} return {}
@@ -2060,6 +2050,13 @@ class PaymentContractCheckoutService:
""" """
self._ensure_enabled() self._ensure_enabled()
intent = self.get_intent(user_id, intent_id) intent = self.get_intent(user_id, intent_id)
return self._validate_loaded_intent_tx(intent, tx_hash)
def _validate_loaded_intent_tx(
self,
intent: PaymentIntentRecord,
tx_hash: str,
) -> Dict[str, Any]:
tx_hash_text = str(tx_hash or "").strip().lower() tx_hash_text = str(tx_hash or "").strip().lower()
if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66): if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66):
return { return {
@@ -2266,7 +2263,7 @@ class PaymentContractCheckoutService:
"payment_intents", "payment_intents",
params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"}, params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"},
payload={"status": "expired", "updated_at": _to_iso(now)}, payload={"status": "expired", "updated_at": _to_iso(now)},
prefer="return=representation", prefer="return=minimal",
allowed_status=[200], allowed_status=[200],
) )
raise PaymentCheckoutError(409, "payment intent expired") raise PaymentCheckoutError(409, "payment intent expired")
@@ -2283,7 +2280,7 @@ class PaymentContractCheckoutService:
self._require_user_wallet(user_id, from_addr) self._require_user_wallet(user_id, from_addr)
try: try:
validation = self.validate_intent_tx(user_id, intent.intent_id, tx_hash_text) validation = self._validate_loaded_intent_tx(intent, tx_hash_text)
except Exception as exc: except Exception as exc:
raise PaymentCheckoutError( raise PaymentCheckoutError(
400, 400,
@@ -2305,26 +2302,25 @@ class PaymentContractCheckoutService:
"tx_hash": tx_hash_text, "tx_hash": tx_hash_text,
"updated_at": now_iso, "updated_at": now_iso,
}, },
prefer="return=representation", prefer="return=minimal",
allowed_status=[200], allowed_status=[200],
) )
tx_rows = self._rest( tx_payload = {
"intent_id": intent.intent_id,
"chain_id": int(intent.chain_id),
"tx_hash": tx_hash_text,
"from_address": from_addr,
"to_address": intent.receiver_address,
"payment_method": "direct" if intent.payment_mode == "direct" else "wallet",
"status": "submitted",
"updated_at": now_iso,
}
self._rest(
"POST", "POST",
"payment_transactions", "payment_transactions",
params={"on_conflict": "tx_hash"}, params={"on_conflict": "tx_hash"},
payload={ payload=tx_payload,
"intent_id": intent.intent_id, prefer="resolution=merge-duplicates,return=minimal",
"chain_id": int(intent.chain_id),
"tx_hash": tx_hash_text,
"from_address": from_addr,
"to_address": intent.receiver_address,
"payment_method": "direct"
if intent.payment_mode == "direct"
else "wallet",
"status": "submitted",
"updated_at": now_iso,
},
prefer="resolution=merge-duplicates,return=representation",
allowed_status=[200, 201], allowed_status=[200, 201],
) )
return { return {
@@ -2332,9 +2328,7 @@ class PaymentContractCheckoutService:
"status": "submitted", "status": "submitted",
"tx_hash": tx_hash_text, "tx_hash": tx_hash_text,
"from_address": from_addr, "from_address": from_addr,
"transaction": tx_rows[0] "transaction": tx_payload,
if isinstance(tx_rows, list) and tx_rows
else None,
} }
def _wait_receipt(self, tx_hash: str, chain_id: Optional[int] = None) -> Any: def _wait_receipt(self, tx_hash: str, chain_id: Optional[int] = None) -> Any:
@@ -2466,24 +2460,25 @@ class PaymentContractCheckoutService:
token_decimals = self._token_decimals_for(token_address, payment_chain_id) token_decimals = self._token_decimals_for(token_address, payment_chain_id)
amount_dec = _units_to_decimal(amount_units, token_decimals) amount_dec = _units_to_decimal(amount_units, token_decimals)
currency = self._token_symbol_for(token_address, payment_chain_id) currency = self._token_symbol_for(token_address, payment_chain_id)
rows = self._rest( payment_payload = {
"user_id": user_id,
"amount": str(amount_dec),
"currency": currency,
"chain": self._chain_label_for(payment_chain_id),
"tx_hash": tx_hash,
"status": "confirmed",
"raw_payload": payload,
"updated_at": _to_iso(_now_utc()),
}
self._rest(
"POST", "POST",
"payments", "payments",
params={"on_conflict": "tx_hash"}, params={"on_conflict": "tx_hash"},
payload={ payload=payment_payload,
"user_id": user_id, prefer="resolution=merge-duplicates,return=minimal",
"amount": str(amount_dec),
"currency": currency,
"chain": self._chain_label_for(payment_chain_id),
"tx_hash": tx_hash,
"status": "confirmed",
"raw_payload": payload,
"updated_at": _to_iso(_now_utc()),
},
prefer="resolution=merge-duplicates,return=representation",
allowed_status=[200, 201], allowed_status=[200, 201],
) )
return rows[0] if isinstance(rows, list) and rows else {} return payment_payload
def _grant_subscription( def _grant_subscription(
self, self,
@@ -2498,7 +2493,7 @@ class PaymentContractCheckoutService:
"GET", "GET",
"subscriptions", "subscriptions",
params={ params={
"select": "id,expires_at,status,plan_code,source,starts_at", "select": "starts_at,expires_at",
"user_id": f"eq.{user_id}", "user_id": f"eq.{user_id}",
"status": "eq.active", "status": "eq.active",
"order": "expires_at.desc", "order": "expires_at.desc",
@@ -2539,20 +2534,21 @@ class PaymentContractCheckoutService:
except Exception: except Exception:
pass pass
expires = starts + timedelta(days=max(1, duration_days)) expires = starts + timedelta(days=max(1, duration_days))
sub_rows = self._rest( subscription_payload = {
"user_id": user_id,
"plan_code": plan_code,
"status": "active",
"starts_at": _to_iso(starts),
"expires_at": _to_iso(expires),
"source": "payment_contract",
"created_at": _to_iso(now),
"updated_at": _to_iso(now),
}
self._rest(
"POST", "POST",
"subscriptions", "subscriptions",
payload={ payload=subscription_payload,
"user_id": user_id, prefer="return=minimal",
"plan_code": plan_code,
"status": "active",
"starts_at": _to_iso(starts),
"expires_at": _to_iso(expires),
"source": "payment_contract",
"created_at": _to_iso(now),
"updated_at": _to_iso(now),
},
prefer="return=representation",
allowed_status=[201], allowed_status=[201],
) )
self._rest( self._rest(
@@ -2566,11 +2562,11 @@ class PaymentContractCheckoutService:
"payload": {"tx_hash": tx_hash, **payload}, "payload": {"tx_hash": tx_hash, **payload},
"created_at": _to_iso(now), "created_at": _to_iso(now),
}, },
prefer="return=representation", prefer="return=minimal",
allowed_status=[201], allowed_status=[201],
) )
SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id) SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
return sub_rows[0] if isinstance(sub_rows, list) and sub_rows else {} return subscription_payload
def _ensure_confirmed_subscription( def _ensure_confirmed_subscription(
self, self,
@@ -2578,7 +2574,6 @@ class PaymentContractCheckoutService:
intent: PaymentIntentRecord, intent: PaymentIntentRecord,
tx_hash: str, tx_hash: str,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription( latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription(
user_id, user_id,
respect_requirement=False, respect_requirement=False,
@@ -2696,7 +2691,7 @@ class PaymentContractCheckoutService:
"metadata": metadata, "metadata": metadata,
"updated_at": now_iso, "updated_at": now_iso,
}, },
prefer="return=representation", prefer="return=minimal",
allowed_status=[200], allowed_status=[200],
) )
if tx_hash: if tx_hash:
@@ -2713,7 +2708,7 @@ class PaymentContractCheckoutService:
"status": "failed", "status": "failed",
"updated_at": now_iso, "updated_at": now_iso,
}, },
prefer="resolution=merge-duplicates,return=representation", prefer="resolution=merge-duplicates,return=minimal",
allowed_status=[200, 201], allowed_status=[200, 201],
) )
self._db.append_payment_audit_event( self._db.append_payment_audit_event(
@@ -2931,6 +2926,7 @@ class PaymentContractCheckoutService:
"PATCH", "PATCH",
"payment_intents", "payment_intents",
params={ params={
"select": "id",
"id": f"eq.{intent.intent_id}", "id": f"eq.{intent.intent_id}",
"user_id": f"eq.{user_id}", "user_id": f"eq.{user_id}",
"status": "in.(created,submitted,failed)", "status": "in.(created,submitted,failed)",
@@ -2972,24 +2968,25 @@ class PaymentContractCheckoutService:
raise PaymentCheckoutError( raise PaymentCheckoutError(
409, f"intent status is {refreshed.status}, cannot confirm" 409, f"intent status is {refreshed.status}, cannot confirm"
) )
tx_rows = self._rest( tx_payload = {
"intent_id": intent.intent_id,
"tx_hash": tx_hash_text,
"chain_id": int(intent.chain_id),
"from_address": tx_from,
"to_address": tx_to,
"block_number": block_number,
"payment_method": "direct" if is_direct else "wallet",
"status": "confirmed",
"raw_receipt": json.loads(Web3.to_json(receipt)),
"raw_tx": json.loads(Web3.to_json(tx)) if tx is not None else None,
"updated_at": now_iso,
}
self._rest(
"POST", "POST",
"payment_transactions", "payment_transactions",
params={"on_conflict": "tx_hash"}, params={"on_conflict": "tx_hash"},
payload={ payload=tx_payload,
"intent_id": intent.intent_id, prefer="resolution=merge-duplicates,return=minimal",
"tx_hash": tx_hash_text,
"chain_id": int(intent.chain_id),
"from_address": tx_from,
"to_address": tx_to,
"block_number": block_number,
"payment_method": "direct" if is_direct else "wallet",
"status": "confirmed",
"raw_receipt": json.loads(Web3.to_json(receipt)),
"raw_tx": json.loads(Web3.to_json(tx)) if tx is not None else None,
"updated_at": now_iso,
},
prefer="resolution=merge-duplicates,return=representation",
allowed_status=[200, 201], allowed_status=[200, 201],
) )
payload = { payload = {
@@ -3036,12 +3033,17 @@ class PaymentContractCheckoutService:
amount_usdc=intent.amount_usdc, amount_usdc=intent.amount_usdc,
tx_hash=tx_hash_text, tx_hash=tx_hash_text,
) )
refreshed = self.get_intent(user_id, intent.intent_id) refreshed = PaymentIntentRecord(
**{
**intent.__dict__,
"status": "confirmed",
"tx_hash": tx_hash_text,
"metadata": confirmed_metadata,
}
)
return { return {
"intent": refreshed.__dict__, "intent": refreshed.__dict__,
"transaction": tx_rows[0] "transaction": tx_payload,
if isinstance(tx_rows, list) and tx_rows
else None,
"payment": payment_row, "payment": payment_row,
"subscription": subscription_row, "subscription": subscription_row,
"points_redemption": points_result, "points_redemption": points_result,
@@ -3091,11 +3093,10 @@ class PaymentContractCheckoutService:
repaired = self._ensure_confirm_side_effects( repaired = self._ensure_confirm_side_effects(
user_id, intent, tx_hash_text user_id, intent, tx_hash_text
) )
refreshed = self.get_intent(user_id, intent.intent_id)
return { return {
"ok": True, "ok": True,
"action": "reconciled_confirmed_intent", "action": "reconciled_confirmed_intent",
"intent": refreshed.__dict__, "intent": intent.__dict__,
"payment": repaired.get("payment"), "payment": repaired.get("payment"),
"subscription": repaired.get("subscription"), "subscription": repaired.get("subscription"),
} }
@@ -3109,7 +3110,6 @@ class PaymentContractCheckoutService:
} }
) )
SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription( latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription(
user_id, user_id,
respect_requirement=False, respect_requirement=False,
@@ -3128,7 +3128,7 @@ class PaymentContractCheckoutService:
"GET", "GET",
"payment_intents", "payment_intents",
params={ params={
"select": "id,user_id,status,updated_at", "select": "user_id",
"status": "in.(submitted,confirmed)", "status": "in.(submitted,confirmed)",
"order": "updated_at.desc", "order": "updated_at.desc",
"limit": str(safe_limit), "limit": str(safe_limit),
+40
View File
@@ -381,6 +381,46 @@ def test_join_request_approves_trial_user_with_queued_paid_subscription(monkeypa
assert bot.approved_join_requests == [{"chat_id": -100123, "user_id": 12345}] assert bot.approved_join_requests == [{"chat_id": -100123, "user_id": 12345}]
def test_join_request_uses_subscription_window_without_latest_fallback(monkeypatch):
monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
bot = DummyBot()
db = SimpleNamespace(list_supabase_user_ids_for_telegram=lambda telegram_id: ["user-1"])
io_layer = SimpleNamespace(
build_welcome_text=lambda: "WELCOME",
build_points_rank_text=lambda _user: "TOP",
db=db,
)
def _fail_latest(*_args, **_kwargs):
raise AssertionError("subscription window rows should avoid latest subscription fallback")
entitlement = SimpleNamespace(
get_subscription_window=lambda user_id, respect_requirement=False: {
"rows": [
{"plan_code": "signup_trial_3d", "source": "signup_trial"},
]
},
get_latest_active_subscription=_fail_latest,
)
handler = BasicCommandHandler(
bot=bot,
io_layer=io_layer,
runtime_status_provider=lambda: RuntimeStatus(
started_at="2026-03-12 00:00:00 UTC",
loops=[],
command_access_mode="group_member",
protected_commands=["/city", "/deb"],
required_group_chat_id="-100123",
),
entitlement_service=entitlement,
)
result = handler.handle_chat_join_request(_join_request())
assert result == "pending:no_active_subscription"
assert bot.approved_join_requests == []
def test_join_request_can_decline_ineligible_user_when_configured(monkeypatch): def test_join_request_can_decline_ineligible_user_when_configured(monkeypatch):
monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123") monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
monkeypatch.setenv("POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION", "decline") monkeypatch.setenv("POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION", "decline")
+21 -11
View File
@@ -186,14 +186,15 @@ def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path):
) )
confirmed_intent = PaymentIntentRecord(**{**intent.__dict__, "status": "confirmed"}) confirmed_intent = PaymentIntentRecord(**{**intent.__dict__, "status": "confirmed"})
intents = [intent, confirmed_intent] intents = [intent, confirmed_intent]
get_intent_calls = []
requested_chains = [] requested_chains = []
tx_rows = [] tx_rows = []
monkeypatch.setattr( def fake_get_intent(user_id, intent_id):
service, get_intent_calls.append((user_id, intent_id))
"get_intent", return intents.pop(0) if intents else confirmed_intent
lambda user_id, intent_id: intents.pop(0) if intents else confirmed_intent,
) monkeypatch.setattr(service, "get_intent", fake_get_intent)
class _Eth: class _Eth:
chain_id = 1 chain_id = 1
@@ -260,7 +261,9 @@ def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path):
if method == "GET" and table == "payment_transactions": if method == "GET" and table == "payment_transactions":
return [] return []
if method == "PATCH" and table == "payment_intents": if method == "PATCH" and table == "payment_intents":
return [{"id": intent.intent_id, "status": "confirmed"}] assert kwargs["prefer"] == "return=representation"
assert kwargs["params"]["select"] == "id"
return [{"id": intent.intent_id}]
if method == "POST" and table == "payment_transactions": if method == "POST" and table == "payment_transactions":
tx_rows.append(kwargs["payload"]) tx_rows.append(kwargs["payload"])
return [kwargs["payload"]] return [kwargs["payload"]]
@@ -273,6 +276,7 @@ def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path):
assert 1 in requested_chains assert 1 in requested_chains
assert tx_rows[0]["chain_id"] == 1 assert tx_rows[0]["chain_id"] == 1
assert result["payment"]["chain_id"] == 1 assert result["payment"]["chain_id"] == 1
assert get_intent_calls == [("user-1", intent.intent_id)]
def test_direct_intent_does_not_require_bound_wallet(monkeypatch, tmp_path): def test_direct_intent_does_not_require_bound_wallet(monkeypatch, tmp_path):
@@ -344,7 +348,7 @@ def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path):
monkeypatch.setattr(service, "_rest", fake_rest) monkeypatch.setattr(service, "_rest", fake_rest)
monkeypatch.setattr( monkeypatch.setattr(
service, service,
"validate_intent_tx", "_validate_loaded_intent_tx",
lambda *args, **kwargs: {"valid": True, "checks": {"tx_mined": True}}, lambda *args, **kwargs: {"valid": True, "checks": {"tx_mined": True}},
) )
@@ -384,7 +388,7 @@ def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path)
) )
monkeypatch.setattr( monkeypatch.setattr(
service, service,
"validate_intent_tx", "_validate_loaded_intent_tx",
lambda *args, **kwargs: { lambda *args, **kwargs: {
"valid": False, "valid": False,
"reason": "tx_not_mined", "reason": "tx_not_mined",
@@ -437,7 +441,7 @@ def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tm
) )
monkeypatch.setattr( monkeypatch.setattr(
service, service,
"validate_intent_tx", "_validate_loaded_intent_tx",
lambda *args, **kwargs: { lambda *args, **kwargs: {
"valid": False, "valid": False,
"reason": "receiver_mismatch", "reason": "receiver_mismatch",
@@ -543,7 +547,7 @@ def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monk
if method == "PATCH" and table == "payment_intents": if method == "PATCH" and table == "payment_intents":
return [{"id": intent.intent_id, "status": "confirmed"}] return [{"id": intent.intent_id, "status": "confirmed"}]
if method == "POST" and table == "payment_transactions": if method == "POST" and table == "payment_transactions":
return [kwargs["payload"]] return []
raise AssertionError((method, table, kwargs)) raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", fake_rest) monkeypatch.setattr(service, "_rest", fake_rest)
@@ -552,8 +556,14 @@ def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monk
result = service.confirm_intent_tx("user-1", intent.intent_id, tx_hash) result = service.confirm_intent_tx("user-1", intent.intent_id, tx_hash)
assert result["subscription"]["status"] == "active" assert result["subscription"]["status"] == "active"
assert result["transaction"]["tx_hash"] == tx_hash
assert result["tx"]["event"]["amount_units"] == 5000000 assert result["tx"]["event"]["amount_units"] == 5000000
assert any(call[1] == "payment_transactions" for call in rest_calls) transaction_write = next(
call
for call in rest_calls
if call[0] == "POST" and call[1] == "payment_transactions"
)
assert transaction_write[2]["prefer"] == "resolution=merge-duplicates,return=minimal"
def test_submit_rejects_tx_hash_used_by_another_intent(monkeypatch, tmp_path): def test_submit_rejects_tx_hash_used_by_another_intent(monkeypatch, tmp_path):
_setup_env(monkeypatch, tmp_path) _setup_env(monkeypatch, tmp_path)
+738 -4
View File
@@ -21,6 +21,517 @@ def _payment_env(monkeypatch, tmp_path):
monkeypatch.delenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS", raising=False) monkeypatch.delenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS", raising=False)
def test_wallet_challenge_insert_uses_minimal_return(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
return []
monkeypatch.setattr(service, "_rest", _fake_rest)
result = service.create_wallet_challenge(
"user-1",
"0x1111111111111111111111111111111111111111",
)
assert result["address"] == "0x1111111111111111111111111111111111111111"
assert calls[0]["table"] == "wallet_link_challenges"
assert calls[0]["prefer"] == "return=minimal"
def test_entitlement_event_insert_uses_minimal_return(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
if method == "GET" and table == "subscriptions":
return []
if method == "POST" and table == "subscriptions":
return [kwargs.get("payload") or {}]
return []
monkeypatch.setattr(service, "_rest", _fake_rest)
service._grant_subscription(
user_id="user-1",
plan_code="pro_monthly",
duration_days=30,
tx_hash="0x" + "1" * 64,
payload={"kind": "test"},
)
entitlement_event = next(call for call in calls if call["table"] == "entitlement_events")
assert entitlement_event["prefer"] == "return=minimal"
def test_subscription_insert_uses_minimal_return_and_local_payload(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
if method == "GET" and table == "subscriptions":
return []
if method == "POST" and table == "subscriptions":
return []
if method == "POST" and table == "entitlement_events":
return []
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", _fake_rest)
result = service._grant_subscription(
user_id="user-1",
plan_code="pro_monthly",
duration_days=30,
tx_hash="0x" + "1" * 64,
payload={"kind": "test"},
)
subscription_write = next(
call for call in calls if call["method"] == "POST" and call["table"] == "subscriptions"
)
assert subscription_write["prefer"] == "return=minimal"
assert result == subscription_write["payload"]
assert result["user_id"] == "user-1"
assert result["plan_code"] == "pro_monthly"
assert result["status"] == "active"
def test_wallet_binding_writes_use_minimal_return(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
address = "0x1111111111111111111111111111111111111111"
calls = []
monkeypatch.setattr(
"src.payments.contract_checkout.Account.recover_message",
lambda *args, **kwargs: address,
)
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
if method == "GET" and table == "wallet_link_challenges":
return [
{
"id": "challenge-1",
"user_id": "user-1",
"address": address,
"nonce": "nonce-1",
"message": "message",
"expires_at": "2099-01-01T00:00:00+00:00",
"consumed_at": None,
}
]
if method == "GET" and table == "user_wallets":
return []
return []
monkeypatch.setattr(service, "_rest", _fake_rest)
result = service.verify_wallet_binding("user-1", address, "nonce-1", "0xsig")
assert result.address == address
writes = [call for call in calls if call["method"] in {"POST", "PATCH"}]
assert writes[0]["table"] == "user_wallets"
assert writes[0]["prefer"] == "resolution=merge-duplicates,return=minimal"
assert writes[1]["table"] == "wallet_link_challenges"
assert writes[1]["prefer"] == "return=minimal"
def test_require_user_wallet_selects_only_status(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
return [{"status": "active"}]
monkeypatch.setattr(service, "_rest", _fake_rest)
result = service._require_user_wallet(
"user-1",
"0x1111111111111111111111111111111111111111",
)
assert result == {"status": "active"}
assert calls[0]["params"]["select"] == "status"
def test_list_wallets_omits_status_from_active_wallet_query(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
return [
{
"chain_id": 137,
"address": "0x1111111111111111111111111111111111111111",
"is_primary": True,
"verified_at": "2099-01-01T00:00:00+00:00",
}
]
monkeypatch.setattr(service, "_rest", _fake_rest)
wallets = service.list_wallets("user-1")
assert calls[0]["params"]["select"] == "chain_id,address,is_primary,verified_at"
assert wallets[0].status == "active"
def test_wallet_binding_existing_lookup_selects_only_owner_status(
monkeypatch,
tmp_path,
):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
address = "0x1111111111111111111111111111111111111111"
calls = []
monkeypatch.setattr(
"src.payments.contract_checkout.Account.recover_message",
lambda *args, **kwargs: address,
)
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
if method == "GET" and table == "wallet_link_challenges":
return [
{
"id": "challenge-1",
"user_id": "user-1",
"address": address,
"nonce": "nonce-1",
"message": "message",
"expires_at": "2099-01-01T00:00:00+00:00",
"consumed_at": None,
}
]
if method == "GET" and table == "user_wallets":
return []
return []
monkeypatch.setattr(service, "_rest", _fake_rest)
service.verify_wallet_binding("user-1", address, "nonce-1", "0xsig")
existing_lookup = [
call
for call in calls
if call["method"] == "GET"
and call["table"] == "user_wallets"
and "address" in call["params"]
][0]
assert existing_lookup["params"]["select"] == "user_id,status"
challenge_lookup = [
call
for call in calls
if call["method"] == "GET"
and call["table"] == "wallet_link_challenges"
][0]
assert challenge_lookup["params"]["select"] == "id,message,expires_at"
assert "order" not in challenge_lookup["params"]
def test_wallet_unbind_writes_use_minimal_return(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
monkeypatch.setattr(service, "_require_user_wallet", lambda *args, **kwargs: {})
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
if method == "GET" and table == "user_wallets" and kwargs["params"].get("is_primary") == "eq.true":
return []
if method == "GET" and table == "user_wallets":
return [{"id": "wallet-2", "address": "0x2222222222222222222222222222222222222222"}]
return []
monkeypatch.setattr(service, "_rest", _fake_rest)
result = service.unbind_wallet("user-1", "0x1111111111111111111111111111111111111111")
assert result["new_primary"] == "0x2222222222222222222222222222222222222222"
writes = [call for call in calls if call["method"] == "PATCH"]
assert [call["prefer"] for call in writes] == ["return=minimal", "return=minimal"]
def test_submit_intent_status_patch_uses_minimal_return(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
monkeypatch.setattr(
service,
"get_intent",
lambda user_id, intent_id: service._serialize_intent(
{
"id": intent_id,
"plan_code": "pro_monthly",
"plan_id": 101,
"chain_id": 137,
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
"amount_units": "5000000",
"payment_mode": "direct",
"allowed_wallet": None,
"order_id_hex": "0x" + "1" * 64,
"status": "created",
"expires_at": "2099-01-01T00:00:00+00:00",
"tx_hash": None,
"metadata": {},
}
),
)
monkeypatch.setattr(
service,
"_validate_loaded_intent_tx",
lambda *args, **kwargs: {"valid": True, "checks": {"tx_mined": True}},
)
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
if method == "GET" and table == "payment_transactions":
return []
if method == "POST" and table == "payment_transactions":
return []
return []
monkeypatch.setattr(service, "_rest", _fake_rest)
result = service.submit_intent_tx("user-1", "intent-1", "0x" + "2" * 64, "")
assert result["status"] == "submitted"
intent_patch = next(call for call in calls if call["method"] == "PATCH")
assert intent_patch["table"] == "payment_intents"
assert intent_patch["prefer"] == "return=minimal"
transaction_write = next(
call
for call in calls
if call["method"] == "POST" and call["table"] == "payment_transactions"
)
assert transaction_write["prefer"] == "resolution=merge-duplicates,return=minimal"
assert result["transaction"]["tx_hash"] == "0x" + "2" * 64
def test_submit_intent_tx_reuses_loaded_intent_for_validation(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
intent = service._serialize_intent(
{
"id": "intent-1",
"plan_code": "pro_monthly",
"plan_id": 101,
"chain_id": 137,
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
"amount_units": "5000000",
"payment_mode": "direct",
"allowed_wallet": None,
"order_id_hex": "0x" + "1" * 64,
"status": "created",
"expires_at": "2099-01-01T00:00:00+00:00",
"tx_hash": None,
"metadata": {},
}
)
calls = {"get_intent": 0}
rest_calls = []
class _FakeEth:
def get_transaction_receipt(self, tx_hash):
return {"status": 1, "to": intent.receiver_address, "blockNumber": 123}
class _FakeWeb3:
eth = _FakeEth()
def _fake_get_intent(user_id, intent_id):
calls["get_intent"] += 1
return intent
def _fake_rest(method, table, **kwargs):
rest_calls.append({"method": method, "table": table, **kwargs})
if method == "GET" and table == "payment_transactions":
return []
if method == "POST" and table == "payment_transactions":
return [kwargs["payload"]]
return []
monkeypatch.setattr(service, "get_intent", _fake_get_intent)
monkeypatch.setattr(service, "_get_web3", lambda *args, **kwargs: _FakeWeb3())
monkeypatch.setattr(
service,
"_extract_direct_transfer_event",
lambda receipt, loaded_intent: {
"from": "0x2222222222222222222222222222222222222222",
"to": loaded_intent.receiver_address,
"amount_units": int(loaded_intent.amount_units),
},
)
monkeypatch.setattr(service, "_rest", _fake_rest)
result = service.submit_intent_tx("user-1", "intent-1", "0x" + "2" * 64, "")
assert result["status"] == "submitted"
assert calls["get_intent"] == 1
assert [call["table"] for call in rest_calls].count("payment_transactions") == 2
def test_failed_intent_writes_use_minimal_return(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
intent = PaymentIntentRecord(
intent_id="intent-1",
order_id_hex="0x" + "1" * 64,
plan_code="pro_monthly",
plan_id=101,
chain_id=137,
amount_units=5_000_000,
amount_usdc="5",
token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
token_decimals=6,
token_symbol="USDC",
receiver_address="0xed2f13aa5ff033c58fb436e178451cd07f693f32",
status="submitted",
payment_mode="direct",
allowed_wallet=None,
expires_at="2099-01-01T00:00:00+00:00",
tx_hash=None,
metadata={},
)
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
return []
monkeypatch.setattr(service, "_rest", _fake_rest)
service._mark_intent_failed(
user_id="user-1",
intent=intent,
tx_hash="0x" + "3" * 64,
reason="test_failure",
detail="test",
)
assert calls[0]["table"] == "payment_intents"
assert calls[0]["prefer"] == "return=minimal"
assert calls[1]["table"] == "payment_transactions"
assert calls[1]["prefer"] == "resolution=merge-duplicates,return=minimal"
def test_duplicate_transaction_write_uses_minimal_return(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
intent = PaymentIntentRecord(
intent_id="intent-1",
order_id_hex="0x" + "1" * 64,
plan_code="pro_monthly",
plan_id=101,
chain_id=137,
amount_units=5_000_000,
amount_usdc="5",
token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
token_decimals=6,
token_symbol="USDC",
receiver_address="0xed2f13aa5ff033c58fb436e178451cd07f693f32",
status="confirmed",
payment_mode="direct",
allowed_wallet=None,
expires_at="2099-01-01T00:00:00+00:00",
tx_hash="0x" + "2" * 64,
metadata={},
)
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
return []
monkeypatch.setattr(service, "_rest", _fake_rest)
result = service._record_duplicate_transaction(
intent=intent,
tx_hash="0x" + "3" * 64,
from_address="0x2222222222222222222222222222222222222222",
status="refund_required",
)
assert result == {}
assert calls[0]["table"] == "payment_transactions"
assert calls[0]["prefer"] == "resolution=merge-duplicates,return=minimal"
def test_payment_record_upsert_uses_minimal_return(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
return []
monkeypatch.setattr(service, "_rest", _fake_rest)
result = service._insert_payment_record(
user_id="user-1",
tx_hash="0x" + "4" * 64,
amount_units=5_000_000,
token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
chain_id=137,
payload={"kind": "test"},
)
assert calls[0]["table"] == "payments"
assert calls[0]["prefer"] == "resolution=merge-duplicates,return=minimal"
assert result["tx_hash"] == "0x" + "4" * 64
assert result["status"] == "confirmed"
assert result["amount"] == "5"
def test_create_intent_insert_uses_minimal_return_and_local_payload(
monkeypatch, tmp_path
):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
if method == "POST" and table == "payment_intents":
return []
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", _fake_rest)
result = service.create_intent(
"00000000-0000-0000-0000-000000000001",
"pro_monthly",
payment_mode="direct",
)
intent_write = calls[0]
payload = intent_write["payload"]
assert intent_write["prefer"] == "return=minimal"
assert payload["id"] == result["intent"]["intent_id"]
assert payload["order_id_hex"] == result["intent"]["order_id_hex"]
assert result["intent"]["status"] == "created"
assert result["direct_payment"]["amount_units"] == str(payload["amount_units"])
def test_payment_runtime_state_and_audit_event_roundtrip(tmp_path): def test_payment_runtime_state_and_audit_event_roundtrip(tmp_path):
db_path = tmp_path / "payments.db" db_path = tmp_path / "payments.db"
db = DBManager(str(db_path)) db = DBManager(str(db_path))
@@ -246,6 +757,121 @@ def test_reconcile_latest_intent_confirms_submitted_first(monkeypatch, tmp_path)
assert result["action"] == "confirmed_submitted_intent" assert result["action"] == "confirmed_submitted_intent"
def test_reconcile_latest_intent_reuses_confirmed_row_after_repair(monkeypatch, tmp_path):
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://rpc-1.example")
monkeypatch.setenv(
"POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON",
'[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]',
)
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
service = PaymentContractCheckoutService()
monkeypatch.setattr(
service,
"_rest",
lambda method, table, **kwargs: [
{
"id": "intent-1",
"plan_code": "pro_monthly",
"plan_id": 101,
"chain_id": 137,
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
"amount_units": "5000000",
"payment_mode": "strict",
"allowed_wallet": "0x1111111111111111111111111111111111111111",
"order_id_hex": "0x" + "1" * 64,
"status": "confirmed",
"expires_at": "2099-01-01T00:00:00+00:00",
"tx_hash": "0x" + "2" * 64,
"metadata": {},
}
],
)
monkeypatch.setattr(
service,
"get_intent",
lambda user_id, intent_id: (_ for _ in ()).throw(
AssertionError("confirmed repair should reuse loaded row")
),
)
monkeypatch.setattr(
service,
"_ensure_confirm_side_effects",
lambda user_id, local_intent, tx_hash: {
"payment": {"tx_hash": tx_hash},
"subscription": {"plan_code": local_intent.plan_code},
},
)
result = service.reconcile_latest_intent("user-1")
assert result["ok"] is True
assert result["action"] == "reconciled_confirmed_intent"
assert result["intent"]["intent_id"] == "intent-1"
def test_reconcile_latest_intent_without_candidates_does_not_clear_subscription_cache(
monkeypatch,
tmp_path,
):
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://rpc-1.example")
monkeypatch.setenv(
"POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON",
'[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]',
)
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
service = PaymentContractCheckoutService()
monkeypatch.setattr(
service,
"_rest",
lambda method, table, **kwargs: [
{
"id": "intent-created",
"plan_code": "pro_monthly",
"plan_id": 101,
"chain_id": 137,
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
"amount_units": "5000000",
"payment_mode": "strict",
"allowed_wallet": "0x1111111111111111111111111111111111111111",
"order_id_hex": "0x" + "1" * 64,
"status": "created",
"expires_at": "2099-01-01T00:00:00+00:00",
"tx_hash": None,
"metadata": {},
}
],
)
invalidations = []
monkeypatch.setattr(
"src.payments.contract_checkout.SUPABASE_ENTITLEMENT.invalidate_subscription_cache",
lambda user_id: invalidations.append(user_id),
)
monkeypatch.setattr(
"src.payments.contract_checkout.SUPABASE_ENTITLEMENT.get_latest_active_subscription",
lambda user_id, respect_requirement=False: {
"plan_code": "pro_monthly",
"expires_at": "2099-01-01T00:00:00+00:00",
},
)
result = service.reconcile_latest_intent("user-1")
assert result["ok"] is True
assert result["action"] == "checked_without_repair"
assert invalidations == []
def test_confirm_intent_tx_repairs_side_effect_failure(monkeypatch, tmp_path): def test_confirm_intent_tx_repairs_side_effect_failure(monkeypatch, tmp_path):
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true") monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
@@ -386,6 +1012,117 @@ def test_reconcile_recent_intents_dedupes_users(monkeypatch, tmp_path):
assert seen == ["user-1", "user-2"] assert seen == ["user-1", "user-2"]
def test_reconcile_recent_intents_selects_only_user_ids(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
return [{"user_id": "user-1"}, {"user_id": "user-1"}, {"user_id": "user-2"}]
seen = []
monkeypatch.setattr(service, "_rest", _fake_rest)
monkeypatch.setattr(
service,
"reconcile_latest_intent",
lambda user_id: seen.append(user_id) or {"ok": True, "subscription": {"user_id": user_id}},
)
result = service.reconcile_recent_intents(limit=10)
assert calls[0]["params"]["select"] == "user_id"
assert seen == ["user-1", "user-2"]
assert result["processed_users"] == 2
def test_pending_confirm_intents_selects_only_confirm_loop_fields(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
return [
{
"id": "intent-1",
"user_id": "user-1",
"chain_id": 137,
"tx_hash": "0x" + "2" * 64,
}
]
monkeypatch.setattr(service, "_rest", _fake_rest)
result = service.list_pending_confirm_intents(limit=20)
assert result == [
{
"intent_id": "intent-1",
"user_id": "user-1",
"chain_id": 137,
"tx_hash": "0x" + "2" * 64,
}
]
assert calls[0]["params"]["select"] == "id,user_id,tx_hash,chain_id"
def test_open_intents_by_order_id_selects_only_event_loop_fields(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
return [
{
"id": "intent-1",
"user_id": "user-1",
"status": "submitted",
"tx_hash": "0x" + "2" * 64,
"plan_id": 101,
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
"amount_units": "5000000",
}
]
monkeypatch.setattr(service, "_rest", _fake_rest)
result = service.list_open_intents_by_order_id("0x" + "1" * 64)
assert result == [
{
"intent_id": "intent-1",
"user_id": "user-1",
"status": "submitted",
"tx_hash": "0x" + "2" * 64,
"plan_id": 101,
"token_address": "0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
"amount_units": 5000000,
}
]
assert (
calls[0]["params"]["select"]
== "id,user_id,status,tx_hash,plan_id,token_address,amount_units"
)
def test_tx_hash_unused_check_selects_only_intent_id(monkeypatch, tmp_path):
_payment_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
calls = []
def _fake_rest(method, table, **kwargs):
calls.append({"method": method, "table": table, **kwargs})
return [{"intent_id": "intent-1"}]
monkeypatch.setattr(service, "_rest", _fake_rest)
service._ensure_tx_hash_unused("0x" + "1" * 64, "intent-1")
assert calls[0]["params"]["select"] == "intent_id"
def test_grant_subscription_starts_after_trial_when_only_trial_is_active(monkeypatch, tmp_path): def test_grant_subscription_starts_after_trial_when_only_trial_is_active(monkeypatch, tmp_path):
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true") monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
@@ -403,12 +1140,9 @@ def test_grant_subscription_starts_after_trial_when_only_trial_is_active(monkeyp
def _fake_rest(method, table, **kwargs): def _fake_rest(method, table, **kwargs):
if method == "GET" and table == "subscriptions": if method == "GET" and table == "subscriptions":
assert kwargs["params"]["select"] == "starts_at,expires_at"
return [ return [
{ {
"id": 1,
"status": "active",
"plan_code": "signup_trial_3d",
"source": "signup_trial",
"starts_at": (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(), "starts_at": (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(),
"expires_at": trial_end.isoformat(), "expires_at": trial_end.isoformat(),
} }
+119
View File
@@ -0,0 +1,119 @@
import importlib.util
from pathlib import Path
def _load_script():
root = Path(__file__).resolve().parents[1]
path = root / "scripts" / "reconcile_payment_tx.py"
spec = importlib.util.spec_from_file_location("reconcile_payment_tx", path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def test_find_intent_by_tx_queries_tx_hash_directly(monkeypatch):
module = _load_script()
tx_hash = "0x" + "a" * 64
calls = []
class FakeCheckout:
def _rest(self, method, table, *, params, allowed_status):
calls.append(
{
"method": method,
"table": table,
"params": params,
"allowed_status": allowed_status,
}
)
return [{"id": "intent-1", "user_id": "user-1", "tx_hash": tx_hash}]
monkeypatch.setattr(module, "PAYMENT_CHECKOUT", FakeCheckout())
result = module._find_intent_by_tx("user-1", tx_hash.upper())
assert result["id"] == "intent-1"
assert calls == [
{
"method": "GET",
"table": "payment_intents",
"params": {
"select": "id,user_id",
"tx_hash": f"eq.{tx_hash}",
"limit": "1",
},
"allowed_status": [200],
}
]
def test_find_intent_by_tx_rejects_other_user(monkeypatch):
module = _load_script()
class FakeCheckout:
def _rest(self, method, table, *, params, allowed_status):
return [{"id": "intent-other", "user_id": "user-2"}]
monkeypatch.setattr(module, "PAYMENT_CHECKOUT", FakeCheckout())
assert module._find_intent_by_tx("user-1", "0x" + "a" * 64) is None
def test_find_intent_by_order_id_uses_unique_order_lookup(monkeypatch):
module = _load_script()
calls = []
class FakeCheckout:
def _rest(self, method, table, *, params, allowed_status):
calls.append(
{
"method": method,
"table": table,
"params": params,
"allowed_status": allowed_status,
}
)
return [
{
"id": "intent-1",
"user_id": "user-1",
"order_id_hex": "0x" + "b" * 64,
}
]
monkeypatch.setattr(module, "PAYMENT_CHECKOUT", FakeCheckout())
result = module._find_intent_by_order_id("user-1", "0X" + "B" * 64)
assert result["id"] == "intent-1"
assert calls == [
{
"method": "GET",
"table": "payment_intents",
"params": {
"select": "id,user_id",
"order_id_hex": "eq.0x" + "b" * 64,
"limit": "1",
},
"allowed_status": [200],
}
]
def test_find_intent_by_order_id_rejects_other_user(monkeypatch):
module = _load_script()
class FakeCheckout:
def _rest(self, method, table, *, params, allowed_status):
return [
{
"id": "intent-other",
"user_id": "user-2",
"order_id_hex": "0x" + "b" * 64,
}
]
monkeypatch.setattr(module, "PAYMENT_CHECKOUT", FakeCheckout())
assert module._find_intent_by_order_id("user-1", "0x" + "b" * 64) is None
+104
View File
@@ -0,0 +1,104 @@
import importlib.util
from pathlib import Path
def _load_script(name: str):
root = Path(__file__).resolve().parents[1]
path = root / "scripts" / f"{name}.py"
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
grant_script = _load_script("grant_subscription_by_email")
reconcile_script = _load_script("reconcile_subscription_by_email")
class _Checkout:
supabase_url = "https://example.supabase.co"
supabase_service_role_key = "service-role"
def __init__(self):
self.rest_calls = []
self.admin_calls = []
def _rest(self, method, table, params=None, **kwargs):
self.rest_calls.append((method, table, params))
assert table == "profiles"
return [{"id": "user-1", "email": "user@example.com"}]
def _auth_admin_request(self, *args, **kwargs):
self.admin_calls.append((args, kwargs))
raise AssertionError("profile-backed lookup should avoid Auth Admin")
def test_grant_script_email_lookup_prefers_profiles(monkeypatch):
checkout = _Checkout()
monkeypatch.setattr("src.payments.contract_checkout.PAYMENT_CHECKOUT", checkout)
assert grant_script._lookup_user_id_by_email("user@example.com") == "user-1"
assert checkout.rest_calls == [
(
"GET",
"profiles",
{
"select": "id",
"email": "eq.user@example.com",
"limit": "1",
},
)
]
assert checkout.admin_calls == []
def test_reconcile_script_email_lookup_prefers_profiles(monkeypatch):
checkout = _Checkout()
monkeypatch.setattr("src.payments.contract_checkout.PAYMENT_CHECKOUT", checkout)
assert reconcile_script._lookup_user_id_by_email("user@example.com") == "user-1"
assert checkout.rest_calls == [
(
"GET",
"profiles",
{
"select": "id",
"email": "eq.user@example.com",
"limit": "1",
},
)
]
assert checkout.admin_calls == []
def test_grant_script_entitlement_event_uses_minimal_return():
root = Path(__file__).resolve().parents[1]
source = (root / "scripts" / "grant_subscription_by_email.py").read_text(
encoding="utf-8"
)
entitlement_event_pos = source.index('"entitlement_events"')
minimal_pos = source.index('prefer="return=minimal"', entitlement_event_pos)
assert minimal_pos > entitlement_event_pos
def test_grant_script_subscription_writes_use_minimal_return():
root = Path(__file__).resolve().parents[1]
source = (root / "scripts" / "grant_subscription_by_email.py").read_text(
encoding="utf-8"
)
subscription_write_count = source.count('prefer="return=minimal"')
assert subscription_write_count >= 3
assert 'prefer="return=representation"' not in source
def test_grant_script_subscription_lookup_selects_only_grant_fields():
root = Path(__file__).resolve().parents[1]
source = (root / "scripts" / "grant_subscription_by_email.py").read_text(
encoding="utf-8"
)
assert '"select": "id,plan_code,source,starts_at,expires_at"' in source
assert '"select": "id,expires_at,status,plan_code,starts_at,source,created_at"' not in source
+460 -10
View File
@@ -30,17 +30,11 @@ def test_latest_active_subscription_ignores_future_start(monkeypatch):
"starts_at": (now - timedelta(days=1)).isoformat(), "starts_at": (now - timedelta(days=1)).isoformat(),
"expires_at": (now + timedelta(days=2)).isoformat(), "expires_at": (now + timedelta(days=2)).isoformat(),
} }
future_paid = {
"id": 2,
"user_id": "user-1",
"status": "active",
"plan_code": "pro_monthly",
"starts_at": (now + timedelta(days=2)).isoformat(),
"expires_at": (now + timedelta(days=32)).isoformat(),
}
def _fake_get(url, headers=None, params=None, timeout=None): def _fake_get(url, headers=None, params=None, timeout=None):
return _Response(200, [future_paid, current_trial]) assert params["select"] == "plan_code,source,starts_at,expires_at"
assert str(params["starts_at"]).startswith("lte.")
assert params["limit"] == "1"
return _Response(200, [current_trial])
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
@@ -50,6 +44,44 @@ def test_latest_active_subscription_ignores_future_start(monkeypatch):
assert result["plan_code"] == "signup_trial_3d" assert result["plan_code"] == "signup_trial_3d"
def test_get_identity_caches_invalid_token_result(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()
calls = {"count": 0}
def _fake_get(url, headers=None, params=None, timeout=None):
calls["count"] += 1
return _Response(401, {"message": "invalid token"})
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
assert service.get_identity("bad-token") is None
assert service.get_identity("bad-token") is None
assert calls["count"] == 1
def test_get_identity_does_not_cache_transient_auth_errors(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()
calls = {"count": 0}
def _fake_get(url, headers=None, params=None, timeout=None):
calls["count"] += 1
return _Response(503, {"message": "temporarily unavailable"})
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
assert service.get_identity("temporarily-bad-token") is None
assert service.get_identity("temporarily-bad-token") is None
assert calls["count"] == 2
def test_subscription_window_keeps_queued_renewal_after_current_cache(monkeypatch): def test_subscription_window_keeps_queued_renewal_after_current_cache(monkeypatch):
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
@@ -75,7 +107,16 @@ def test_subscription_window_keeps_queued_renewal_after_current_cache(monkeypatc
"expires_at": (now + timedelta(days=31)).isoformat(), "expires_at": (now + timedelta(days=31)).isoformat(),
} }
calls = []
def _fake_get(url, headers=None, params=None, timeout=None): def _fake_get(url, headers=None, params=None, timeout=None):
calls.append(params)
assert params["select"] == "plan_code,source,starts_at,expires_at"
if params["limit"] == "1":
assert str(params["starts_at"]).startswith("lte.")
return _Response(200, [current])
assert params["limit"] == "100"
assert "starts_at" not in params
return _Response(200, [queued, current]) return _Response(200, [queued, current])
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
@@ -88,3 +129,412 @@ def test_subscription_window_keeps_queued_renewal_after_current_cache(monkeypatc
assert window["total_expires_at"] == queued["expires_at"] assert window["total_expires_at"] == queued["expires_at"]
assert window["queued_days"] == 30 assert window["queued_days"] == 30
assert window["queued_count"] == 1 assert window["queued_count"] == 1
assert len(calls) == 2
def test_subscription_window_query_selects_only_window_fields(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):
assert params["select"] == "plan_code,source,starts_at,expires_at"
return _Response(
200,
[
{
"plan_code": "pro_monthly",
"source": "payment_contract",
"starts_at": "2026-03-01T00:00:00+00:00",
"expires_at": "2099-04-01T00:00:00+00:00",
}
],
)
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
window = service.get_subscription_window(
"user-1",
respect_requirement=False,
bypass_cache=True,
)
assert window["current"]["plan_code"] == "pro_monthly"
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")
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
service = SupabaseEntitlementService()
def _fake_get(url, headers=None, params=None, timeout=None):
assert params["select"] == "user_id,plan_code,source,starts_at,expires_at"
assert params["user_id"] == "in.(user-1,user-2)"
return _Response(
200,
[
{
"user_id": "user-1",
"plan_code": "pro_monthly",
"source": "payment_contract",
"starts_at": "2026-03-01T00:00:00+00:00",
"expires_at": "2099-04-01T00:00:00+00:00",
},
{
"user_id": "user-2",
"plan_code": "pro_monthly",
"source": "payment_contract",
"starts_at": "2026-03-02T00:00:00+00:00",
"expires_at": "2099-04-02T00:00:00+00:00",
},
],
)
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
windows = service.list_subscription_windows(
["user-1", "user-2"],
bypass_cache=True,
)
assert set(windows) == {"user-1", "user-2"}
def test_list_active_subscription_windows_uses_single_window_query(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()
calls = []
now = datetime.now(timezone.utc)
current = {
"user_id": "user-1",
"plan_code": "pro_monthly",
"source": "payment_contract",
"starts_at": (now - timedelta(days=1)).isoformat(),
"expires_at": (now + timedelta(days=10)).isoformat(),
}
queued = {
"user_id": "user-1",
"plan_code": "pro_monthly",
"source": "payment_contract",
"starts_at": (now + timedelta(days=10)).isoformat(),
"expires_at": (now + timedelta(days=40)).isoformat(),
}
def _fake_get(url, headers=None, params=None, timeout=None):
calls.append(params)
assert params["select"] == "user_id,plan_code,source,starts_at,expires_at"
assert params["status"] == "eq.active"
assert params["order"] == "user_id.asc,expires_at.desc"
return _Response(200, [queued, current])
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
result = service.list_active_subscription_windows(limit=200)
assert result["subscriptions"] == [current]
assert result["windows"]["user-1"]["queued_count"] == 1
assert calls and len(calls) == 1
def test_latest_subscription_any_status_uses_cache(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()
calls = {"count": 0}
latest = {
"id": 3,
"user_id": "user-1",
"status": "expired",
"plan_code": "pro_monthly",
"starts_at": "2026-03-01T00:00:00+00:00",
"expires_at": "2026-04-01T00:00:00+00:00",
"created_at": "2026-03-01T00:00:00+00:00",
"updated_at": "2026-04-01T00:00:00+00:00",
}
def _fake_get(url, headers=None, params=None, timeout=None):
calls["count"] += 1
assert params["user_id"] == "eq.user-1"
assert params["order"] == "created_at.desc"
assert params["select"] == "plan_code,starts_at,expires_at"
return _Response(200, [latest])
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
assert service.get_latest_subscription_any_status("user-1") == latest
assert service.get_latest_subscription_any_status("user-1") == latest
assert calls["count"] == 1
def test_get_auth_users_batches_profiles_before_admin_fallback(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()
calls = []
def _fake_get(url, headers=None, params=None, timeout=None):
calls.append((url, params))
if url.endswith("/rest/v1/profiles"):
assert params["id"] == "in.(user-1,user-2)"
return _Response(
200,
[
{
"id": "user-1",
"email": "one@example.com",
"created_at": "2026-03-01T00:00:00+00:00",
},
{
"id": "user-2",
"email": "two@example.com",
"created_at": "2026-03-02T00:00:00+00:00",
},
],
)
raise AssertionError(f"unexpected admin fallback call: {url}")
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
result = service.get_auth_users(["user-1", "user-2"])
assert result == {
"user-1": {
"email": "one@example.com",
"created_at": "2026-03-01T00:00:00+00:00",
},
"user-2": {
"email": "two@example.com",
"created_at": "2026-03-02T00:00:00+00:00",
},
}
assert len(calls) == 1
def test_get_auth_users_uses_short_cache_for_profile_results(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()
calls = {"count": 0}
def _fake_get(url, headers=None, params=None, timeout=None):
calls["count"] += 1
assert url.endswith("/rest/v1/profiles")
return _Response(
200,
[
{
"id": "user-1",
"email": "one@example.com",
"created_at": "2026-03-01T00:00:00+00:00",
},
],
)
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
assert service.get_auth_users(["user-1"]) == {
"user-1": {
"email": "one@example.com",
"created_at": "2026-03-01T00:00:00+00:00",
},
}
assert service.get_auth_users(["user-1"]) == {
"user-1": {
"email": "one@example.com",
"created_at": "2026-03-01T00:00:00+00:00",
},
}
assert calls["count"] == 1
def test_list_active_subscriptions_uses_cache_and_invalidation(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()
calls = {"count": 0}
row = {
"id": 1,
"user_id": "user-1",
"status": "active",
"plan_code": "pro_monthly",
"starts_at": "2026-03-01T00:00:00+00:00",
"expires_at": "2099-04-01T00:00:00+00:00",
}
def _fake_get(url, headers=None, params=None, timeout=None):
calls["count"] += 1
assert params["status"] == "eq.active"
assert params["select"] == "user_id,plan_code,starts_at,expires_at"
return _Response(200, [row])
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
assert service.list_active_subscriptions(limit=200) == [row]
assert service.list_active_subscriptions(limit=200) == [row]
assert calls["count"] == 1
service.invalidate_subscription_cache("user-1")
assert service.list_active_subscriptions(limit=200) == [row]
assert calls["count"] == 2
def test_has_active_subscription_uses_lightweight_query_without_polluting_detail_cache(
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()
calls = []
def _fake_get(url, headers=None, params=None, timeout=None):
calls.append(params["select"])
if params["select"] == "expires_at":
assert str(params["starts_at"]).startswith("lte.")
assert params["limit"] == "1"
return _Response(
200,
[
{
"expires_at": "2099-04-01T00:00:00+00:00",
}
],
)
if params["select"] == "plan_code,source,starts_at,expires_at":
return _Response(
200,
[
{
"plan_code": "pro_monthly",
"source": "payment_contract",
"starts_at": "2026-03-01T00:00:00+00:00",
"expires_at": "2099-04-01T00:00:00+00:00",
}
],
)
raise AssertionError(params["select"])
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
assert service.has_active_subscription("user-1", respect_requirement=False) is True
assert service.has_active_subscription("user-1", respect_requirement=False) is True
assert service.get_latest_active_subscription(
"user-1",
respect_requirement=False,
)["plan_code"] == "pro_monthly"
assert calls == [
"expires_at",
"plan_code,source,starts_at,expires_at",
]
def test_has_active_subscription_lightweight_cache_invalidates(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()
calls = {"count": 0}
def _fake_get(url, headers=None, params=None, timeout=None):
calls["count"] += 1
assert params["select"] == "expires_at"
assert str(params["starts_at"]).startswith("lte.")
assert params["limit"] == "1"
return _Response(
200,
[
{
"expires_at": "2099-04-01T00:00:00+00:00",
}
],
)
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
assert service.has_active_subscription("user-1", respect_requirement=False) is True
assert service.has_active_subscription("user-1", respect_requirement=False) is True
assert calls["count"] == 1
service.invalidate_subscription_cache("user-1")
assert service.has_active_subscription("user-1", respect_requirement=False) is True
assert calls["count"] == 2
def test_has_active_subscription_reuses_detailed_subscription_cache(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()
calls = []
def _fake_get(url, headers=None, params=None, timeout=None):
calls.append(params["select"])
assert params["select"] == "plan_code,source,starts_at,expires_at"
return _Response(
200,
[
{
"plan_code": "pro_monthly",
"source": "payment_contract",
"starts_at": "2026-03-01T00:00:00+00:00",
"expires_at": "2099-04-01T00:00:00+00:00",
}
],
)
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
assert service.get_latest_active_subscription(
"user-1",
respect_requirement=False,
)["plan_code"] == "pro_monthly"
assert service.has_active_subscription("user-1", respect_requirement=False) is True
assert calls == ["plan_code,source,starts_at,expires_at"]
def test_latest_active_subscription_reuses_negative_lightweight_cache(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()
calls = []
def _fake_get(url, headers=None, params=None, timeout=None):
calls.append(params["select"])
assert params["select"] == "expires_at"
assert str(params["starts_at"]).startswith("lte.")
assert params["limit"] == "1"
return _Response(200, [])
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
assert service.has_active_subscription("user-1", respect_requirement=False) is False
assert service.get_latest_active_subscription(
"user-1",
respect_requirement=False,
) is None
assert calls == ["expires_at"]
+93
View File
@@ -0,0 +1,93 @@
from types import SimpleNamespace
import src.database.db_manager as db_manager_module
from src.database.db_manager import DBManager
def _bound_db(tmp_path, monkeypatch):
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
db = DBManager(str(tmp_path / "points-sync.db"))
db.upsert_user(1001, "eraer")
with db._get_connection() as conn: # noqa: SLF001
conn.execute(
"""
UPDATE users
SET supabase_user_id = ?, supabase_email = ?
WHERE telegram_id = ?
""",
("supabase-user-1", "eraer@example.com", 1001),
)
conn.execute(
"""
INSERT INTO supabase_bindings (supabase_user_id, telegram_id, supabase_email)
VALUES (?, ?, ?)
""",
("supabase-user-1", 1001, "eraer@example.com"),
)
conn.commit()
return db
def test_message_points_sync_to_supabase_metadata_is_throttled(tmp_path, monkeypatch):
db = _bound_db(tmp_path, monkeypatch)
calls = []
now = {"value": 1000.0}
monkeypatch.setattr(
db_manager_module,
"time",
SimpleNamespace(monotonic=lambda: now["value"]),
raising=False,
)
monkeypatch.setattr(
db_manager_module.requests,
"patch",
lambda *args, **kwargs: calls.append((args, kwargs))
or SimpleNamespace(status_code=204, text="", content=b""),
)
first = db.add_message_activity(
1001,
"第一条有效发言",
cooldown_sec=0,
daily_cap=1000,
)
now["value"] += 10.0
second = db.add_message_activity(
1001,
"第二条有效发言",
cooldown_sec=0,
daily_cap=1000,
)
assert first["awarded"] is True
assert second["awarded"] is True
assert len(calls) == 1
def test_manual_point_grant_forces_supabase_metadata_sync(tmp_path, monkeypatch):
db = _bound_db(tmp_path, monkeypatch)
calls = []
now = {"value": 1000.0}
monkeypatch.setattr(
db_manager_module,
"time",
SimpleNamespace(monotonic=lambda: now["value"]),
raising=False,
)
monkeypatch.setattr(
db_manager_module.requests,
"patch",
lambda *args, **kwargs: calls.append((args, kwargs))
or SimpleNamespace(status_code=204, text="", content=b""),
)
db.add_message_activity(1001, "第一条有效发言", cooldown_sec=0, daily_cap=1000)
now["value"] += 10.0
result = db.grant_points_by_supabase_email("eraer@example.com", 300)
assert result["ok"] is True
assert len(calls) == 2
assert calls[-1][1]["json"]["user_metadata"]["points"] == result["points_after"]
+83
View File
@@ -0,0 +1,83 @@
from types import SimpleNamespace
import src.database.db_manager as db_manager_module
from src.database.db_manager import DBManager
def _bound_db(tmp_path, monkeypatch):
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
monkeypatch.setenv("POLYWEATHER_SUPABASE_PROFILE_SYNC_MIN_INTERVAL_SEC", "3600")
DBManager._profile_sync_cache.clear() # noqa: SLF001
db = DBManager(str(tmp_path / "profile-sync.db"))
db.upsert_user(1001, "eraer")
with db._get_connection() as conn: # noqa: SLF001
conn.execute(
"""
UPDATE users
SET supabase_user_id = ?, supabase_email = ?
WHERE telegram_id = ?
""",
("supabase-user-1", "eraer@example.com", 1001),
)
conn.execute(
"""
INSERT INTO supabase_bindings (supabase_user_id, telegram_id, supabase_email)
VALUES (?, ?, ?)
""",
("supabase-user-1", 1001, "eraer@example.com"),
)
conn.commit()
return db
def test_repeated_user_upsert_coalesces_supabase_profile_sync(tmp_path, monkeypatch):
db = _bound_db(tmp_path, monkeypatch)
calls = []
now = {"value": 1000.0}
monkeypatch.setattr(
db_manager_module,
"time",
SimpleNamespace(monotonic=lambda: now["value"]),
raising=False,
)
monkeypatch.setattr(
db_manager_module.requests,
"patch",
lambda *args, **kwargs: calls.append((args, kwargs))
or SimpleNamespace(status_code=204, text="", content=b""),
)
db.upsert_user(1001, "eraer")
now["value"] += 10.0
db.upsert_user(1001, "eraer")
assert len(calls) == 1
assert calls[0][1]["headers"]["Prefer"] == "return=minimal"
def test_changed_username_bypasses_supabase_profile_sync_coalescing(tmp_path, monkeypatch):
db = _bound_db(tmp_path, monkeypatch)
calls = []
now = {"value": 1000.0}
monkeypatch.setattr(
db_manager_module,
"time",
SimpleNamespace(monotonic=lambda: now["value"]),
raising=False,
)
monkeypatch.setattr(
db_manager_module.requests,
"patch",
lambda *args, **kwargs: calls.append((args, kwargs))
or SimpleNamespace(status_code=204, text="", content=b""),
)
db.upsert_user(1001, "eraer")
now["value"] += 10.0
db.upsert_user(1001, "new-name")
assert len(calls) == 2
assert calls[-1][1]["json"]["telegram_username"] == "new-name"
+137
View File
@@ -0,0 +1,137 @@
from pathlib import Path
def _schema_sql() -> str:
root = Path(__file__).resolve().parents[1]
return (root / "scripts" / "supabase" / "schema.sql").read_text(encoding="utf-8").lower()
def test_supabase_schema_has_io_friendly_indexes_for_hot_ops_queries():
schema = _schema_sql()
assert (
"create index if not exists idx_profiles_email\n"
" on public.profiles(email)\n"
" include (id)"
) in schema
assert (
"create index if not exists idx_profiles_id_lookup\n"
" on public.profiles(id)\n"
" include (email, created_at)"
) in schema
assert "idx_subscriptions_status_expiry" in schema
assert "on public.subscriptions(expires_at asc)" in schema
assert "idx_subscriptions_user_status_expiry" in schema
assert "on public.subscriptions(user_id, expires_at desc)" in schema
assert "include (id, starts_at, plan_code, source)" in schema
assert schema.count("where status = 'active'") >= 2
assert "idx_subscriptions_user_created" in schema
assert "on public.subscriptions(user_id, created_at desc)" in schema
assert "include (id, status, plan_code, source, starts_at, expires_at, updated_at)" in schema
assert "idx_payment_intents_status_updated" in schema
assert "on public.payment_intents(status, updated_at desc)" in schema
assert "include (user_id)" in schema
assert "where status in ('submitted', 'confirmed')" in schema
assert "idx_payment_intents_user_status_updated" in schema
assert "on public.payment_intents(user_id, status, updated_at desc)" in schema
assert "idx_payment_intents_user_status\n" not in schema
assert "idx_payment_intents_submitted_tx_updated" in schema
assert "include (id, user_id, tx_hash, chain_id)" in schema
assert "where status = 'submitted' and tx_hash is not null" in schema
assert "idx_payment_intents_user_created" in schema
assert "on public.payment_intents(user_id, created_at desc)" in schema
assert "idx_payment_intents_tx_hash" in schema
assert "on public.payment_intents(tx_hash)" in schema
assert "include (id, user_id)" in schema
assert "where tx_hash is not null" in schema
assert "idx_payments_created_at" in schema
assert "on public.payments(created_at desc)" in schema
assert "include (id, user_id, amount, currency, chain, tx_hash, status)" in schema
assert "idx_user_wallets_user_chain" in schema
assert "on public.user_wallets(user_id, chain_id, is_primary desc, verified_at desc)" in schema
assert "include (id, address)" in schema
assert (
"create index if not exists idx_user_wallets_chain_address_owner\n"
" on public.user_wallets(chain_id, address)\n"
" include (user_id, status)"
) in schema
assert schema.count("where status = 'active'") >= 3
assert "idx_wallet_link_challenges_lookup" not in schema
assert (
"create index if not exists idx_payment_transactions_tx_hash_intent\n"
" on public.payment_transactions(tx_hash)\n"
" include (intent_id)"
) in schema
def test_supabase_io_budget_scripts_are_production_runnable():
root = Path(__file__).resolve().parents[1]
indexes = (root / "scripts" / "supabase" / "io_budget_indexes.sql").read_text(encoding="utf-8").lower()
diagnostics = (root / "scripts" / "supabase" / "disk_io_diagnostics.sql").read_text(encoding="utf-8").lower()
assert "drop index if exists public.idx_profiles_email" in indexes
assert (
"create index if not exists idx_profiles_email\n"
" on public.profiles(email)\n"
" include (id)"
) in indexes
assert "drop index if exists public.idx_profiles_id_lookup" in indexes
assert (
"create index if not exists idx_profiles_id_lookup\n"
" on public.profiles(id)\n"
" include (email, created_at)"
) in indexes
assert "idx_subscriptions_user_created" in indexes
assert "drop index if exists public.idx_subscriptions_user_status_expiry" in indexes
assert "drop index if exists public.idx_subscriptions_status_expiry" in indexes
assert "on public.subscriptions(user_id, expires_at desc)" in indexes
assert "on public.subscriptions(expires_at asc)" in indexes
assert "include (id, starts_at, plan_code, source)" in indexes
assert "include (user_id, starts_at, plan_code)" in indexes
assert "include (id, status, plan_code, source, starts_at, expires_at, updated_at)" in indexes
assert "drop index if exists public.idx_payment_intents_user_status" in indexes
assert "drop index if exists public.idx_payment_intents_status_updated" in indexes
assert "include (user_id)" in indexes
assert "where status in ('submitted', 'confirmed')" in indexes
assert "drop index if exists public.idx_payment_intents_submitted_tx_updated" in indexes
assert "include (id, user_id, tx_hash, chain_id)" in indexes
assert "drop index if exists public.idx_payment_intents_tx_hash" in indexes
assert "include (id, user_id)" in indexes
assert "where tx_hash is not null" in indexes
assert "drop index if exists public.idx_payments_created_at" in indexes
assert "include (id, user_id, amount, currency, chain, tx_hash, status)" in indexes
assert "drop index if exists public.idx_user_wallets_user_chain" in indexes
assert "on public.user_wallets(user_id, chain_id, is_primary desc, verified_at desc)" in indexes
assert "include (id, address)" in indexes
assert "drop index if exists public.idx_user_wallets_chain_address_owner" in indexes
assert (
"create index if not exists idx_user_wallets_chain_address_owner\n"
" on public.user_wallets(chain_id, address)\n"
" include (user_id, status)"
) in indexes
assert "drop index if exists public.idx_wallet_link_challenges_lookup" in indexes
assert "create index if not exists idx_wallet_link_challenges_lookup" not in indexes
assert "drop index if exists public.idx_payment_transactions_tx_hash_intent" in indexes
assert (
"create index if not exists idx_payment_transactions_tx_hash_intent\n"
" on public.payment_transactions(tx_hash)\n"
" include (intent_id)"
) in indexes
assert "analyze public.payment_intents" in indexes
assert "pg_stat_user_tables" in diagnostics
assert "pg_stat_statements" in diagnostics
assert "shared_blks_read" in diagnostics
assert "pg_stat_user_indexes" in diagnostics
assert "pg_statio_user_indexes" in diagnostics
assert "indexrelname" in diagnostics
assert "idx_blks_read" in diagnostics
assert "idx_scan = 0" in diagnostics
def test_supabase_setup_doc_includes_io_budget_runbook():
root = Path(__file__).resolve().parents[1]
doc = (root / "docs" / "SUPABASE_SETUP_ZH.md").read_text(encoding="utf-8")
assert "scripts/supabase/io_budget_indexes.sql" in doc
assert "scripts/supabase/disk_io_diagnostics.sql" in doc
assert "Disk IO" in doc
+940 -8
View File
@@ -1,10 +1,14 @@
from datetime import datetime
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.requests import Request from starlette.requests import Request
import web.core as web_core import web.core as web_core
import web.services.auth_api as auth_api
from web.app import app from web.app import app
import web.routes as routes import web.routes as routes
import web.services.ops_api as ops_api
import web.scan_terminal_cache as scan_terminal_cache import web.scan_terminal_cache as scan_terminal_cache
import web.scan_terminal_service as scan_terminal_service import web.scan_terminal_service as scan_terminal_service
from web.scan_terminal_cache import scan_terminal_cache_key from web.scan_terminal_cache import scan_terminal_cache_key
@@ -94,6 +98,138 @@ def test_payment_runtime_endpoint_returns_shape():
assert 'recent_audit_events' in payload assert 'recent_audit_events' in payload
def test_payment_config_does_not_require_entitlement(monkeypatch):
monkeypatch.setattr(
routes,
"_assert_entitlement",
lambda request: (_ for _ in ()).throw(
AssertionError("public payment config should not validate Supabase auth"),
),
)
monkeypatch.setattr(
routes.PAYMENT_CHECKOUT,
"get_config_payload",
lambda: {"enabled": True, "plans": []},
)
response = client.get("/api/payments/config")
assert response.status_code == 200
assert response.json()["enabled"] is True
def test_payment_wallets_requires_identity_without_subscription_gate(monkeypatch):
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True)
class _Identity:
user_id = "user-1"
email = "user@example.com"
points = 0
created_at = "2026-05-01T00:00:00+00:00"
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity())
monkeypatch.setattr(
web_core.SUPABASE_ENTITLEMENT,
"has_active_subscription",
lambda user_id: (_ for _ in ()).throw(
AssertionError("payment identity endpoints should not query subscription gate"),
),
)
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "list_wallets", lambda user_id: [])
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "chain_id", 137)
response = client.get(
"/api/payments/wallets",
headers={"Authorization": "Bearer access-token"},
)
assert response.status_code == 200
assert response.json() == {"wallets": [], "chain_id": 137}
def test_telegram_identity_endpoints_skip_subscription_gate(monkeypatch):
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True)
class _Identity:
user_id = "user-1"
email = "user@example.com"
points = 0
created_at = "2026-05-01T00:00:00+00:00"
class _FakePricing:
configured = True
@staticmethod
def verify_login_payload(payload):
return {"telegram_id": int(payload["id"]), "username": "tester"}
@staticmethod
def get_member_status(telegram_id):
return "member"
@staticmethod
def resolve_price_for_telegram_id(telegram_id):
return {"telegram_id": telegram_id, "pricing_source": "telegram_group_member"}
class _FakeDB:
@staticmethod
def upsert_user(telegram_id, username):
return None
@staticmethod
def bind_supabase_identity(*, telegram_id, supabase_user_id, supabase_email):
return {"ok": True}
@staticmethod
def consume_bind_token(token):
return 12345
@staticmethod
def create_web_bind_token(*, supabase_user_id, supabase_email, ttl_minutes):
return "bind-token"
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity())
monkeypatch.setattr(
web_core.SUPABASE_ENTITLEMENT,
"has_active_subscription",
lambda user_id: (_ for _ in ()).throw(
AssertionError("telegram identity endpoints should not query subscription gate"),
),
)
monkeypatch.setattr(auth_api, "TelegramGroupPricing", lambda: _FakePricing())
monkeypatch.setattr(auth_api, "DBManager", lambda: _FakeDB())
auth_headers = {"Authorization": "Bearer access-token"}
login_response = client.post(
"/api/auth/telegram/login",
headers=auth_headers,
json={"id": 12345, "username": "tester", "auth_date": 1770000000, "hash": "x" * 64},
)
assert login_response.status_code == 200
token_response = client.post(
"/api/auth/telegram/bind-by-token",
headers=auth_headers,
json={"token": "token-12345"},
)
assert token_response.status_code == 200
link_response = client.post(
"/api/auth/telegram/bot-bind-link",
headers=auth_headers,
)
assert link_response.status_code == 200
def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch): def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
@@ -106,21 +242,21 @@ def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch):
monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None}) monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None})
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "enabled", True) monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "enabled", True)
calls = {"count": 0}
reconcile_calls = {"count": 0} reconcile_calls = {"count": 0}
def _latest_subscription(user_id, respect_requirement=False): def _subscription_window(user_id, respect_requirement=False):
calls["count"] += 1
return { return {
"plan_code": "pro_monthly", "current": {
"starts_at": "2026-03-22T00:00:00+00:00", "plan_code": "pro_monthly",
"expires_at": "2026-04-21T00:00:00+00:00", "starts_at": "2026-03-22T00:00:00+00:00",
"expires_at": "2026-04-21T00:00:00+00:00",
},
} }
monkeypatch.setattr( monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT, routes.SUPABASE_ENTITLEMENT,
"get_latest_active_subscription", "get_subscription_window",
_latest_subscription, _subscription_window,
) )
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", True) monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", True)
@@ -143,6 +279,265 @@ def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch):
assert reconcile_calls["count"] == 0 assert reconcile_calls["count"] == 0
def test_auth_me_reuses_identity_bound_by_entitlement(monkeypatch):
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True)
monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None})
class _Identity:
user_id = "user-1"
email = "user@example.com"
points = 7
created_at = "2026-05-01T00:00:00+00:00"
calls = {"identity": 0}
def _get_identity(token):
calls["identity"] += 1
assert token == "access-token"
return _Identity()
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", _get_identity)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "has_active_subscription", lambda user_id: True)
monkeypatch.setattr(
web_core.SUPABASE_ENTITLEMENT,
"get_subscription_window",
lambda user_id, respect_requirement=False: {
"current": {
"plan_code": "pro_monthly",
"starts_at": "2026-03-22T00:00:00+00:00",
"expires_at": "2026-04-21T00:00:00+00:00",
},
},
)
response = client.get(
"/api/auth/me",
headers={"Authorization": "Bearer access-token"},
)
assert response.status_code == 200
payload = response.json()
assert payload["authenticated"] is True
assert payload["points"] == 7
assert calls["identity"] == 1
def test_auth_me_uses_subscription_window_as_required_subscription_gate(monkeypatch):
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True)
monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None})
class _Identity:
user_id = "user-1"
email = "user@example.com"
points = 7
created_at = "2026-05-01T00:00:00+00:00"
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity())
monkeypatch.setattr(
web_core.SUPABASE_ENTITLEMENT,
"has_active_subscription",
lambda user_id: (_ for _ in ()).throw(
AssertionError("auth/me should not run a separate lightweight subscription gate"),
),
)
monkeypatch.setattr(
web_core.SUPABASE_ENTITLEMENT,
"get_latest_active_subscription",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("auth/me should derive current subscription from the window query"),
),
)
monkeypatch.setattr(
web_core.SUPABASE_ENTITLEMENT,
"get_subscription_window",
lambda user_id, respect_requirement=False: {
"current": {
"plan_code": "pro_monthly",
"starts_at": "2026-03-22T00:00:00+00:00",
"expires_at": "2026-04-21T00:00:00+00:00",
},
"total_expires_at": "2026-05-21T00:00:00+00:00",
"queued_days": 30,
"queued_count": 1,
},
)
response = client.get(
"/api/auth/me",
headers={"Authorization": "Bearer access-token"},
)
assert response.status_code == 200
payload = response.json()
assert payload["subscription_active"] is True
assert payload["subscription_plan_code"] == "pro_monthly"
assert payload["subscription_queued_days"] == 30
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)
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: {
"current": None,
"rows": [
{
"plan_code": "pro_monthly",
"starts_at": "2026-06-01T00:00:00+00:00",
"expires_at": "2026-07-01T00:00:00+00:00",
}
],
"total_expires_at": "2026-07-01T00:00:00+00:00",
"queued_days": 0,
"queued_count": 0,
},
)
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"get_latest_active_subscription",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("auth/me should reuse window rows before latest active fallback"),
),
)
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"get_latest_subscription_any_status",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("auth/me should reuse window rows before historical fallback"),
),
)
response = client.get("/api/auth/me")
assert response.status_code == 200
payload = response.json()
assert payload["subscription_active"] is False
assert payload["subscription_plan_code"] == "pro_monthly"
assert payload["subscription_expires_at"] == "2026-07-01T00:00:00+00:00"
def test_auth_me_skips_latest_active_after_empty_non_required_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"
latest_any_calls = {"count": 0}
def _latest_any_status(user_id):
latest_any_calls["count"] += 1
return {
"plan_code": "expired_pro",
"starts_at": "2026-03-01T00:00:00+00:00",
"expires_at": "2026-04-01T00:00:00+00:00",
}
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: {},
)
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"get_latest_active_subscription",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("empty subscription window should skip latest active fallback"),
),
)
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"get_latest_subscription_any_status",
_latest_any_status,
)
response = client.get("/api/auth/me")
assert response.status_code == 200
payload = response.json()
assert payload["subscription_active"] is False
assert payload["subscription_plan_code"] == "expired_pro"
assert latest_any_calls["count"] == 1
def test_auth_me_preserves_required_subscription_403_from_window(monkeypatch):
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True)
class _Identity:
user_id = "user-1"
email = "user@example.com"
points = 0
created_at = "2026-05-01T00:00:00+00:00"
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity())
monkeypatch.setattr(
web_core.SUPABASE_ENTITLEMENT,
"has_active_subscription",
lambda user_id: (_ for _ in ()).throw(
AssertionError("auth/me should not run a separate lightweight subscription gate"),
),
)
monkeypatch.setattr(
web_core.SUPABASE_ENTITLEMENT,
"get_subscription_window",
lambda user_id, respect_requirement=False: {},
)
latest_any_calls = {"count": 0}
def _latest_any_status(user_id):
latest_any_calls["count"] += 1
return {
"plan_code": "expired_pro",
"starts_at": "2026-03-01T00:00:00+00:00",
"expires_at": "2026-04-01T00:00:00+00:00",
}
monkeypatch.setattr(
web_core.SUPABASE_ENTITLEMENT,
"get_latest_subscription_any_status",
_latest_any_status,
)
response = client.get(
"/api/auth/me",
headers={"Authorization": "Bearer access-token"},
)
assert response.status_code == 403
assert response.json()["detail"] == "Subscription required"
assert latest_any_calls["count"] == 0
def test_backend_entitlement_token_binds_forwarded_supabase_identity(monkeypatch): def test_backend_entitlement_token_binds_forwarded_supabase_identity(monkeypatch):
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True) monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co") monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
@@ -167,6 +562,45 @@ def test_backend_entitlement_token_binds_forwarded_supabase_identity(monkeypatch
assert request.state.auth_email == "user@example.com" assert request.state.auth_email == "user@example.com"
def test_backend_entitlement_token_without_forwarded_identity_validates_bearer(monkeypatch):
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", False)
monkeypatch.setattr(web_core, "_ENTITLEMENT_TOKEN", "backend-token")
class _Identity:
user_id = "user-1"
email = "user@example.com"
points = 7
created_at = "2026-05-01T00:00:00+00:00"
calls = {"count": 0}
def _get_identity(token):
calls["count"] += 1
assert token == "access-token"
return _Identity()
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", _get_identity)
request = Request(
{
"type": "http",
"headers": [
(b"x-polyweather-entitlement", b"backend-token"),
(b"authorization", b"Bearer access-token"),
],
}
)
web_core._assert_entitlement(request)
assert calls["count"] == 1
assert request.state.auth_user_id == "user-1"
assert request.state.auth_email == "user@example.com"
def test_ops_memberships_prefers_supabase_auth_email(monkeypatch): def test_ops_memberships_prefers_supabase_auth_email(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None) monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
@@ -216,6 +650,504 @@ def test_ops_memberships_prefers_supabase_auth_email(monkeypatch):
assert payload["memberships"][0]["email"] == "fresh@example.com" assert payload["memberships"][0]["email"] == "fresh@example.com"
def test_ops_memberships_uses_batched_subscription_windows(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", False)
class _FakeDB:
@staticmethod
def get_users_by_supabase_user_ids(user_ids):
return {
"user-1": {"supabase_email": "one@example.com"},
"user-2": {"supabase_email": "two@example.com"},
}
import src.database.db_manager as db_module
monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB())
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"list_active_subscriptions",
lambda limit=200: [
{
"user_id": "user-1",
"plan_code": "pro_monthly",
"starts_at": "2026-03-01T00:00:00+00:00",
"expires_at": "2026-04-01T00:00:00+00:00",
},
{
"user_id": "user-2",
"plan_code": "pro_monthly",
"starts_at": "2026-03-02T00:00:00+00:00",
"expires_at": "2026-04-02T00:00:00+00:00",
},
],
)
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "get_auth_users", lambda user_ids: {})
def _fail_per_user_window(*args, **kwargs):
raise AssertionError("per-user subscription window query should not run")
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"get_subscription_window",
_fail_per_user_window,
)
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"list_subscription_windows",
lambda user_ids, bypass_cache=True: {
"user-1": {"total_expires_at": "2026-04-15T00:00:00+00:00", "queued_days": 14, "queued_count": 1},
"user-2": {"total_expires_at": "2026-04-02T00:00:00+00:00", "queued_days": 0, "queued_count": 0},
},
raising=False,
)
response = client.get("/api/ops/memberships")
assert response.status_code == 200
payload = response.json()
rows = {row["user_id"]: row for row in payload["memberships"]}
assert rows["user-1"]["queued_days"] == 14
assert rows["user-2"]["queued_days"] == 0
def test_ops_memberships_prefers_single_active_subscription_window_query(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", False)
class _FakeDB:
@staticmethod
def get_users_by_supabase_user_ids(user_ids):
return {"user-1": {"supabase_email": "one@example.com"}}
import src.database.db_manager as db_module
monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB())
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"list_active_subscriptions",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("memberships should not run a separate active subscription query"),
),
)
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"list_subscription_windows",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("memberships should not run a second window query"),
),
)
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "get_auth_users", lambda user_ids: {})
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"list_active_subscription_windows",
lambda limit=200: {
"subscriptions": [
{
"user_id": "user-1",
"plan_code": "pro_monthly",
"source": "payment_contract",
"starts_at": "2026-03-01T00:00:00+00:00",
"expires_at": "2026-04-01T00:00:00+00:00",
}
],
"windows": {
"user-1": {
"total_expires_at": "2026-05-01T00:00:00+00:00",
"queued_days": 30,
"queued_count": 1,
}
},
},
raising=False,
)
response = client.get("/api/ops/memberships")
assert response.status_code == 200
row = response.json()["memberships"][0]
assert row["queued_days"] == 30
assert row["expires_at"] == "2026-05-01T00:00:00+00:00"
def test_ops_memberships_growth_reuses_active_subscription_window_query(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
starts_at = datetime.utcnow().replace(microsecond=0).isoformat()
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"list_active_subscriptions",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("growth should not run a separate active subscription query"),
),
)
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"list_active_subscription_windows",
lambda limit=5000: {
"subscriptions": [
{
"user_id": "user-1",
"plan_code": "pro_monthly",
"source": "payment_contract",
"starts_at": starts_at,
"expires_at": "2099-01-01T00:00:00+00:00",
}
],
"windows": {},
},
raising=False,
)
response = client.get("/api/ops/memberships/growth?days=7")
assert response.status_code == 200
assert any(day["paid"] == 1 for day in response.json()["daily"])
def test_ops_telegram_audit_reuses_active_subscription_window_query(monkeypatch):
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "bot-token")
monkeypatch.setenv("TELEGRAM_CHAT_IDS", "chat-1")
monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False)
monkeypatch.delenv("POLYWEATHER_TELEGRAM_GROUP_ID", raising=False)
monkeypatch.delenv("POLYWEATHER_TELEGRAM_TOPICS_GROUP_ID", raising=False)
monkeypatch.setattr(ops_api, "_require_ops", lambda request: None)
class _FakeRows(list):
def fetchall(self):
return self
class _FakeConnection:
row_factory = None
def __enter__(self):
return self
def __exit__(self, *args):
return False
def execute(self, sql):
if "FROM users" in sql:
return _FakeRows([{"telegram_id": 1, "username": "tester"}])
if "FROM supabase_bindings" in sql:
return _FakeRows(
[
{
"telegram_id": 1,
"supabase_user_id": "user-1",
"supabase_email": "one@example.com",
}
]
)
raise AssertionError(sql)
class _FakeDB:
@staticmethod
def _get_connection():
return _FakeConnection()
class _Response:
status_code = 200
@staticmethod
def json():
return {"ok": True, "result": {"status": "member"}}
import requests as requests_module
import src.database.db_manager as db_module
monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB())
monkeypatch.setattr(requests_module, "get", lambda *args, **kwargs: _Response())
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"list_active_subscriptions",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("telegram audit should not run a separate active subscription query"),
),
)
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"list_active_subscription_windows",
lambda limit=5000: {
"subscriptions": [
{
"user_id": "user-1",
"plan_code": "pro_monthly",
"source": "payment_contract",
"starts_at": "2026-03-01T00:00:00+00:00",
"expires_at": "2099-01-01T00:00:00+00:00",
}
],
"windows": {},
},
raising=False,
)
result = ops_api.get_ops_telegram_audit(object())
assert result["valid_count"] == 1
assert result["anomaly_count"] == 0
def test_ops_memberships_overview_combines_memberships_and_growth_in_one_subscription_query(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", False)
starts_at = datetime.utcnow().replace(microsecond=0).isoformat()
calls = {"active_windows": 0}
class _FakeDB:
@staticmethod
def get_users_by_supabase_user_ids(user_ids):
return {"user-1": {"supabase_email": "one@example.com"}}
import src.database.db_manager as db_module
monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB())
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"list_active_subscriptions",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("overview should not run a separate active subscription query"),
),
)
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"list_subscription_windows",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("overview should not run a second window query"),
),
)
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "get_auth_users", lambda user_ids: {})
def _active_windows(limit=5000):
calls["active_windows"] += 1
assert limit == 5000
return {
"subscriptions": [
{
"user_id": "user-1",
"plan_code": "pro_monthly",
"source": "payment_contract",
"starts_at": starts_at,
"expires_at": "2099-01-01T00:00:00+00:00",
},
{
"user_id": "user-2",
"plan_code": "signup_trial_3d",
"source": "signup_trial",
"starts_at": starts_at,
"expires_at": "2099-01-02T00:00:00+00:00",
},
],
"windows": {
"user-1": {
"total_expires_at": "2099-01-01T00:00:00+00:00",
"queued_days": 0,
"queued_count": 0,
},
"user-2": {
"total_expires_at": "2099-01-02T00:00:00+00:00",
"queued_days": 0,
"queued_count": 0,
},
},
}
monkeypatch.setattr(
routes.SUPABASE_ENTITLEMENT,
"list_active_subscription_windows",
_active_windows,
raising=False,
)
response = client.get("/api/ops/memberships/overview?limit=1&days=7")
assert response.status_code == 200
payload = response.json()
assert len(payload["memberships"]) == 1
assert payload["memberships"][0]["user_id"] == "user-1"
assert any(day["paid"] == 1 and day["trial"] == 1 for day in payload["daily"])
assert calls["active_windows"] == 1
def test_ops_memberships_does_not_reconcile_payments_by_default(monkeypatch):
monkeypatch.delenv("POLYWEATHER_OPS_MEMBERSHIPS_RECONCILE_ENABLED", raising=False)
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", True)
calls = {"count": 0}
def _count_reconcile(*args, **kwargs):
calls["count"] += 1
return {"ok": True}
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "reconcile_recent_intents", _count_reconcile)
class _FakeDB:
@staticmethod
def get_users_by_supabase_user_ids(user_ids):
return {}
import src.database.db_manager as db_module
monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB())
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "list_active_subscriptions", lambda limit=200: [])
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "get_auth_users", lambda user_ids: {})
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "list_subscription_windows", lambda user_ids, bypass_cache=True: {})
response = client.get("/api/ops/memberships")
assert response.status_code == 200
assert response.json()["memberships"] == []
assert calls["count"] == 0
def test_ops_email_lookup_prefers_profiles_over_auth_admin(monkeypatch):
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
calls = []
class _Response:
ok = True
status_code = 200
content = b"1"
text = ""
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def _fake_get(url, headers=None, params=None, timeout=None):
calls.append((url, params))
if url.endswith("/rest/v1/profiles"):
assert headers is not None
assert headers.get("Prefer") != "return=representation"
assert params["select"] == "id"
assert params["email"] == "eq.user@example.com"
return _Response([{"id": "user-1"}])
raise AssertionError(f"unexpected auth admin lookup: {url}")
monkeypatch.setattr(ops_api._requests, "get", _fake_get)
assert (
ops_api._lookup_supabase_user_id_by_email(
"https://example.supabase.co",
"service-role",
"user@example.com",
)
== "user-1"
)
assert len(calls) == 1
def test_ops_subscription_grant_invalidates_subscription_cache(monkeypatch):
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
monkeypatch.setattr(ops_api, "_require_ops", lambda request: {"email": "admin@example.com"})
class _Response:
ok = True
status_code = 200
content = b"1"
text = ""
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def _fake_get(url, headers=None, params=None, timeout=None):
assert url.endswith("/rest/v1/profiles")
assert params["select"] == "id"
return _Response([{"id": "user-1"}])
def _fake_post(url, headers=None, json=None, timeout=None):
assert url.endswith("/rest/v1/subscriptions")
assert headers["Prefer"] == "return=minimal"
return _Response([{"id": 1, "user_id": "user-1"}])
invalidated = []
monkeypatch.setattr(ops_api._requests, "get", _fake_get)
monkeypatch.setattr(ops_api._requests, "post", _fake_post)
monkeypatch.setattr(
ops_api.legacy_routes.SUPABASE_ENTITLEMENT,
"invalidate_subscription_cache",
lambda user_id: invalidated.append(user_id),
)
result = ops_api.grant_ops_subscription(object(), "user@example.com")
assert result["ok"] is True
assert invalidated == ["user-1"]
def test_ops_subscription_extend_uses_minimal_return_and_invalidates_cache(monkeypatch):
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
monkeypatch.setattr(ops_api, "_require_ops", lambda request: {"email": "admin@example.com"})
class _Response:
ok = True
status_code = 200
content = b"1"
text = ""
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def _fake_get(url, headers=None, params=None, timeout=None):
if url.endswith("/rest/v1/profiles"):
assert params["select"] == "id"
return _Response([{"id": "user-1"}])
if url.endswith("/rest/v1/subscriptions"):
assert params["select"] == "id,expires_at"
return _Response(
[
{
"id": 7,
"expires_at": "2026-04-01T00:00:00+00:00",
}
]
)
raise AssertionError(url)
def _fake_patch(url, headers=None, json=None, timeout=None):
assert url.endswith("/rest/v1/subscriptions?id=eq.7")
assert headers["Prefer"] == "return=minimal"
assert "expires_at" in json
return _Response([])
invalidated = []
monkeypatch.setattr(ops_api._requests, "get", _fake_get)
monkeypatch.setattr(ops_api._requests, "patch", _fake_patch)
monkeypatch.setattr(
ops_api.legacy_routes.SUPABASE_ENTITLEMENT,
"invalidate_subscription_cache",
lambda user_id: invalidated.append(user_id),
)
result = ops_api.extend_ops_subscription(object(), "user@example.com", additional_days=7)
assert result["ok"] is True
assert result["new_expires_at"].startswith("2026-04-08")
assert invalidated == ["user-1"]
def test_ops_truth_history_returns_filtered_rows(monkeypatch): def test_ops_truth_history_returns_filtered_rows(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None) monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
import src.bot.weekly_reward_loop as weekly_reward_loop
class _Response:
def __init__(self, status_code=200, payload=None):
self.status_code = status_code
self._payload = payload
self.content = b"1"
def json(self):
return self._payload
def test_weekly_reward_bonus_subscription_insert_uses_minimal_return(monkeypatch):
calls = []
def _fake_get(url, headers=None, params=None, timeout=None):
calls.append(("GET", url, headers, params))
return _Response(200, [])
def _fake_post(url, headers=None, json=None, timeout=None):
calls.append(("POST", url, headers, json))
assert headers["Prefer"] == "return=minimal"
return _Response(201, [])
monkeypatch.setattr(weekly_reward_loop.requests, "get", _fake_get)
monkeypatch.setattr(weekly_reward_loop.requests, "post", _fake_post)
ok, reason, expires_at = weekly_reward_loop._grant_bonus_subscription_days(
supabase_url="https://example.supabase.co",
service_role_key="service-role",
user_id="user-1",
days=7,
timeout_sec=5,
)
assert ok is True
assert reason == ""
assert datetime.fromisoformat(str(expires_at)).tzinfo is not None
assert [call[0] for call in calls] == ["GET", "POST"]
+13 -3
View File
@@ -303,8 +303,10 @@ def _resolve_weekly_profile(request: Request) -> Dict[str, Any]:
def _assert_entitlement(request: Request) -> None: def _assert_entitlement(request: Request) -> None:
if SUPABASE_ENTITLEMENT.enabled: if SUPABASE_ENTITLEMENT.enabled:
if _legacy_service_token_valid(request): if _legacy_service_token_valid(request):
_bind_forwarded_supabase_identity(request) if _bind_forwarded_supabase_identity(request):
return return
if not extract_bearer_token(request.headers.get("authorization")):
return
if not _SUPABASE_AUTH_REQUIRED: if not _SUPABASE_AUTH_REQUIRED:
_bind_optional_supabase_identity(request) _bind_optional_supabase_identity(request)
return return
@@ -321,11 +323,19 @@ def _assert_entitlement(request: Request) -> None:
identity = SUPABASE_ENTITLEMENT.get_identity(access_token) identity = SUPABASE_ENTITLEMENT.get_identity(access_token)
if not identity: if not identity:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
if not SUPABASE_ENTITLEMENT.has_active_subscription(identity.user_id): skip_subscription_gate = bool(
getattr(request.state, "skip_subscription_gate", False)
)
if (
not skip_subscription_gate
and not SUPABASE_ENTITLEMENT.has_active_subscription(identity.user_id)
):
raise HTTPException(status_code=403, detail="Subscription required") raise HTTPException(status_code=403, detail="Subscription required")
request.state.auth_user_id = identity.user_id request.state.auth_user_id = identity.user_id
request.state.auth_email = identity.email request.state.auth_email = identity.email
request.state.auth_points = identity.points
request.state.auth_created_at = identity.created_at
from src.utils.online_tracker import record_activity from src.utils.online_tracker import record_activity
record_activity(identity.user_id) record_activity(identity.user_id)
return return
+10
View File
@@ -8,6 +8,7 @@ from web.services.ops_api import (
get_ops_analytics_funnel, get_ops_analytics_funnel,
get_ops_config, get_ops_config,
get_ops_memberships_growth, get_ops_memberships_growth,
get_ops_memberships_overview,
get_ops_health_check, get_ops_health_check,
get_ops_logs, get_ops_logs,
get_ops_truth_history, get_ops_truth_history,
@@ -55,6 +56,15 @@ async def ops_memberships_growth(request: Request, days: int = 90):
return get_ops_memberships_growth(request, days=days) return get_ops_memberships_growth(request, days=days)
@router.get("/api/ops/memberships/overview")
async def ops_memberships_overview(
request: Request,
limit: int = 200,
days: int = 90,
):
return get_ops_memberships_overview(request, limit=limit, days=days)
@router.get("/api/ops/payments/incidents") @router.get("/api/ops/payments/incidents")
async def ops_payment_incidents( async def ops_payment_incidents(
request: Request, request: Request,
+51 -19
View File
@@ -12,9 +12,17 @@ from web.core import TelegramLoginRequest
import web.routes as legacy_routes import web.routes as legacy_routes
def get_auth_me_payload(request: Request) -> Dict[str, Any]: def _require_auth_identity_without_subscription_gate(request: Request) -> Dict[str, str]:
request.state.skip_subscription_gate = True
legacy_routes._assert_entitlement(request) legacy_routes._assert_entitlement(request)
legacy_routes._bind_optional_supabase_identity(request) return legacy_routes._require_supabase_identity(request)
def get_auth_me_payload(request: Request) -> Dict[str, Any]:
request.state.skip_subscription_gate = True
legacy_routes._assert_entitlement(request)
if not str(getattr(request.state, "auth_user_id", "") or "").strip():
legacy_routes._bind_optional_supabase_identity(request)
user_id = getattr(request.state, "auth_user_id", None) user_id = getattr(request.state, "auth_user_id", None)
subscription_required = bool( subscription_required = bool(
@@ -31,25 +39,48 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
if legacy_routes.SUPABASE_ENTITLEMENT.enabled and user_id: if legacy_routes.SUPABASE_ENTITLEMENT.enabled and user_id:
try: try:
latest_subscription = ( subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window(
legacy_routes.SUPABASE_ENTITLEMENT.get_latest_active_subscription( user_id,
user_id, respect_requirement=False,
respect_requirement=False,
)
) )
latest_subscription = None
latest_known_subscription = None
subscription_window_known = isinstance(subscription_window, dict)
if isinstance(subscription_window, dict):
current_subscription = subscription_window.get("current")
if isinstance(current_subscription, dict):
latest_subscription = current_subscription
rows = subscription_window.get("rows")
if not latest_subscription and isinstance(rows, list):
latest_known_subscription = next(
(row for row in rows if isinstance(row, dict)),
None,
)
if (
not latest_subscription
and not latest_known_subscription
and not subscription_window_known
and not subscription_required
):
latest_subscription = (
legacy_routes.SUPABASE_ENTITLEMENT.get_latest_active_subscription(
user_id,
respect_requirement=False,
)
)
latest_known_subscription = latest_subscription subscription_active = bool(latest_subscription)
if subscription_required and not subscription_active:
raise HTTPException(status_code=403, detail="Subscription required")
if not latest_known_subscription:
latest_known_subscription = latest_subscription
if not latest_known_subscription: if not latest_known_subscription:
latest_known_subscription = ( latest_known_subscription = (
legacy_routes.SUPABASE_ENTITLEMENT.get_latest_subscription_any_status( legacy_routes.SUPABASE_ENTITLEMENT.get_latest_subscription_any_status(
user_id user_id
) )
) )
subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window(
user_id,
respect_requirement=False,
)
subscription_active = bool(latest_subscription)
if isinstance(latest_subscription, dict): if isinstance(latest_subscription, dict):
subscription_plan_code = latest_subscription.get("plan_code") subscription_plan_code = latest_subscription.get("plan_code")
subscription_starts_at = latest_subscription.get("starts_at") subscription_starts_at = latest_subscription.get("starts_at")
@@ -62,7 +93,11 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
subscription_total_expires_at = subscription_window.get("total_expires_at") subscription_total_expires_at = subscription_window.get("total_expires_at")
subscription_queued_days = int(subscription_window.get("queued_days") or 0) subscription_queued_days = int(subscription_window.get("queued_days") or 0)
subscription_queued_count = int(subscription_window.get("queued_count") or 0) subscription_queued_count = int(subscription_window.get("queued_count") or 0)
except HTTPException:
raise
except Exception: except Exception:
if subscription_required:
raise HTTPException(status_code=403, detail="Subscription required")
subscription_active = None subscription_active = None
subscription_plan_code = None subscription_plan_code = None
subscription_starts_at = None subscription_starts_at = None
@@ -124,8 +159,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
def login_with_telegram(request: Request, body: TelegramLoginRequest) -> Dict[str, Any]: def login_with_telegram(request: Request, body: TelegramLoginRequest) -> Dict[str, Any]:
legacy_routes._assert_entitlement(request) identity = _require_auth_identity_without_subscription_gate(request)
identity = legacy_routes._require_supabase_identity(request)
pricing = TelegramGroupPricing() pricing = TelegramGroupPricing()
if not pricing.configured: if not pricing.configured:
raise HTTPException(status_code=503, detail="telegram login is not configured") raise HTTPException(status_code=503, detail="telegram login is not configured")
@@ -156,8 +190,7 @@ def login_with_telegram(request: Request, body: TelegramLoginRequest) -> Dict[st
def bind_telegram_by_token(request: Request, body) -> Dict[str, Any]: def bind_telegram_by_token(request: Request, body) -> Dict[str, Any]:
"""Bind Telegram identity using a one-time token from the bot /bind command.""" """Bind Telegram identity using a one-time token from the bot /bind command."""
legacy_routes._assert_entitlement(request) identity = _require_auth_identity_without_subscription_gate(request)
identity = legacy_routes._require_supabase_identity(request)
token = str(getattr(body, "token", "") or "").strip() token = str(getattr(body, "token", "") or "").strip()
if not token: if not token:
@@ -196,8 +229,7 @@ def bind_telegram_by_token(request: Request, body) -> Dict[str, Any]:
def create_telegram_bot_bind_link(request: Request) -> Dict[str, Any]: def create_telegram_bot_bind_link(request: Request) -> Dict[str, Any]:
"""Create a one-time web-to-bot bind deep link for the authenticated account.""" """Create a one-time web-to-bot bind deep link for the authenticated account."""
legacy_routes._assert_entitlement(request) identity = _require_auth_identity_without_subscription_gate(request)
identity = legacy_routes._require_supabase_identity(request)
db = DBManager() db = DBManager()
token = db.create_web_bind_token( token = db.create_web_bind_token(
+209 -73
View File
@@ -2,9 +2,11 @@
from __future__ import annotations from __future__ import annotations
import os
from typing import Any, Dict from typing import Any, Dict
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
import requests as _requests
from src.database.db_manager import DBManager from src.database.db_manager import DBManager
from web.core import GrantPointsRequest from web.core import GrantPointsRequest
@@ -29,19 +31,46 @@ def get_ops_weekly_leaderboard(request: Request, limit: int = 20) -> Dict[str, A
return {"leaderboard": db.get_weekly_leaderboard(limit=limit)} return {"leaderboard": db.get_weekly_leaderboard(limit=limit)}
def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]: def _list_active_subscriptions_with_windows(
_require_ops(request) limit: int,
db = DBManager() ) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]], bool]:
if getattr(legacy_routes.PAYMENT_CHECKOUT, "enabled", False): active_window_query = getattr(
try: legacy_routes.SUPABASE_ENTITLEMENT,
legacy_routes.PAYMENT_CHECKOUT.reconcile_recent_intents( "list_active_subscription_windows",
limit=min(max(int(limit or 200), 20), 200) None,
)
except Exception:
pass
subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
limit=limit
) )
if not callable(active_window_query):
return [], {}, False
try:
active_window_payload = active_window_query(limit=limit)
except Exception:
return [], {}, False
if not isinstance(active_window_payload, dict):
return [], {}, False
active_window_subscriptions = active_window_payload.get("subscriptions")
active_window_windows = active_window_payload.get("windows")
if not isinstance(active_window_subscriptions, list):
return [], {}, False
if not active_window_subscriptions and not (
isinstance(active_window_windows, dict) and active_window_windows
):
return [], {}, False
subscriptions = [
item for item in active_window_subscriptions if isinstance(item, dict)
]
subscription_windows = (
active_window_windows if isinstance(active_window_windows, dict) else {}
)
return subscriptions, subscription_windows, True
def _build_membership_rows(
db: DBManager,
subscriptions: list[dict[str, Any]],
subscription_windows: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
subscription_user_ids = [str(item.get("user_id") or "") for item in subscriptions] subscription_user_ids = [str(item.get("user_id") or "") for item in subscriptions]
user_map = db.get_users_by_supabase_user_ids(subscription_user_ids) user_map = db.get_users_by_supabase_user_ids(subscription_user_ids)
unresolved_user_ids = [ unresolved_user_ids = [
@@ -56,18 +85,17 @@ def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]:
auth_user_map = legacy_routes.SUPABASE_ENTITLEMENT.get_auth_users( auth_user_map = legacy_routes.SUPABASE_ENTITLEMENT.get_auth_users(
unresolved_user_ids unresolved_user_ids
) )
if not subscription_windows:
subscription_windows = legacy_routes.SUPABASE_ENTITLEMENT.list_subscription_windows(
subscription_user_ids,
bypass_cache=True,
)
deduped: dict[str, dict] = {} deduped: dict[str, dict] = {}
for item in subscriptions: for item in subscriptions:
user_id = str(item.get("user_id") or "").strip().lower() user_id = str(item.get("user_id") or "").strip().lower()
local_user = user_map.get(user_id, {}) local_user = user_map.get(user_id, {})
auth_user = auth_user_map.get(user_id, {}) auth_user = auth_user_map.get(user_id, {})
subscription_window = ( subscription_window = subscription_windows.get(user_id, {})
legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window(
user_id,
respect_requirement=False,
bypass_cache=True,
)
)
current_expires_at = item.get("expires_at") current_expires_at = item.get("expires_at")
total_expires_at = ( total_expires_at = (
subscription_window.get("total_expires_at") subscription_window.get("total_expires_at")
@@ -112,22 +140,20 @@ def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]:
current_expires = str(row.get("expires_at") or "") current_expires = str(row.get("expires_at") or "")
if existing is None or current_expires > existing_expires: if existing is None or current_expires > existing_expires:
deduped[user_id] = row deduped[user_id] = row
rows = sorted( return sorted(
deduped.values(), deduped.values(),
key=lambda item: str(item.get("expires_at") or ""), key=lambda item: str(item.get("expires_at") or ""),
) )
return {"memberships": rows}
def get_ops_memberships_growth(request: Request, days: int = 90) -> dict[str, Any]: def _build_membership_growth(
_require_ops(request) subscriptions: list[dict[str, Any]],
days: int,
) -> dict[str, Any]:
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
safe_days = max(7, min(365, int(days or 90))) safe_days = max(7, min(365, int(days or 90)))
subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
limit=5000
)
now = datetime.utcnow() now = datetime.utcnow()
cutoff = now - timedelta(days=safe_days) cutoff = now - timedelta(days=safe_days)
@@ -178,6 +204,78 @@ def get_ops_memberships_growth(request: Request, days: int = 90) -> dict[str, An
return {"days": safe_days, "daily": daily} return {"days": safe_days, "daily": daily}
def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]:
_require_ops(request)
db = DBManager()
reconcile_enabled = (
str(os.getenv("POLYWEATHER_OPS_MEMBERSHIPS_RECONCILE_ENABLED") or "")
.strip()
.lower()
in {"1", "true", "yes", "on"}
)
if reconcile_enabled and getattr(legacy_routes.PAYMENT_CHECKOUT, "enabled", False):
try:
legacy_routes.PAYMENT_CHECKOUT.reconcile_recent_intents(
limit=min(max(int(limit or 200), 20), 200)
)
except Exception:
pass
subscriptions, subscription_windows, used_active_window_query = (
_list_active_subscriptions_with_windows(limit)
)
if not used_active_window_query:
subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
limit=limit
)
return {
"memberships": _build_membership_rows(
db,
subscriptions,
subscription_windows,
)
}
def get_ops_memberships_growth(request: Request, days: int = 90) -> dict[str, Any]:
_require_ops(request)
subscriptions, _, used_active_window_query = _list_active_subscriptions_with_windows(
limit=5000
)
if not used_active_window_query:
subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
limit=5000
)
return _build_membership_growth(subscriptions, days)
def get_ops_memberships_overview(
request: Request,
limit: int = 200,
days: int = 90,
) -> dict[str, Any]:
_require_ops(request)
db = DBManager()
safe_limit = max(1, min(int(limit or 200), 1000))
query_limit = max(safe_limit, 5000)
subscriptions, subscription_windows, used_active_window_query = (
_list_active_subscriptions_with_windows(limit=query_limit)
)
if not used_active_window_query:
subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
limit=query_limit
)
membership_subscriptions = subscriptions[:safe_limit]
return {
"memberships": _build_membership_rows(
db,
membership_subscriptions,
subscription_windows,
),
**_build_membership_growth(subscriptions, days),
}
def list_ops_payment_incidents( def list_ops_payment_incidents(
request: Request, request: Request,
limit: int = 50, limit: int = 50,
@@ -415,6 +513,59 @@ def update_ops_config(request: Request, key: str, value: str) -> dict[str, Any]:
# ── Subscriptions ─────────────────────────────────────────────────── # ── Subscriptions ───────────────────────────────────────────────────
def _supabase_service_headers(
service_role_key: str,
*,
prefer: str | None = None,
) -> dict[str, str]:
headers = {
"apikey": service_role_key,
"Authorization": f"Bearer {service_role_key}",
"Content-Type": "application/json",
}
if prefer:
headers["Prefer"] = prefer
return headers
def _lookup_supabase_user_id_by_email(
supabase_url: str,
service_role_key: str,
email: str,
) -> str:
normalized_email = str(email or "").strip().lower()
if not normalized_email:
return ""
base = str(supabase_url or "").strip().rstrip("/")
headers = _supabase_service_headers(service_role_key)
profile_resp = _requests.get(
f"{base}/rest/v1/profiles",
headers=headers,
params={
"select": "id",
"email": f"eq.{normalized_email}",
"limit": "1",
},
timeout=10,
)
if profile_resp.ok:
profiles = profile_resp.json() if profile_resp.content else []
if isinstance(profiles, list) and profiles:
user_id = str((profiles[0] or {}).get("id") or "").strip()
if user_id:
return user_id
user_resp = _requests.get(
f"{base}/auth/v1/admin/users",
headers=headers,
params={"filter": f"email.eq.{normalized_email}"},
timeout=10,
)
users = user_resp.json().get("users", []) if user_resp.ok else []
return str(users[0].get("id") or "").strip() if users else ""
def grant_ops_subscription( def grant_ops_subscription(
request: Request, request: Request,
email: str, email: str,
@@ -423,9 +574,7 @@ def grant_ops_subscription(
deduct_points: int = 0, deduct_points: int = 0,
) -> dict[str, Any]: ) -> dict[str, Any]:
_require_ops(request) _require_ops(request)
import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
import requests as _requests
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
@@ -444,22 +593,11 @@ def grant_ops_subscription(
if not normalized_email: if not normalized_email:
raise HTTPException(status_code=400, detail="email is required") raise HTTPException(status_code=400, detail="email is required")
headers = { user_id = _lookup_supabase_user_id_by_email(
"apikey": service_role_key, supabase_url,
"Authorization": f"Bearer {service_role_key}", service_role_key,
"Content-Type": "application/json", normalized_email,
"Prefer": "return=representation",
}
# Look up user by email
user_resp = _requests.get(
f"{supabase_url}/auth/v1/admin/users",
headers=headers,
params={"filter": f"email.eq.{normalized_email}"},
timeout=10,
) )
users = user_resp.json().get("users", []) if user_resp.ok else []
user_id = users[0].get("id") if users else None
if not user_id: if not user_id:
raise HTTPException( raise HTTPException(
status_code=404, detail=f"user not found: {normalized_email}" status_code=404, detail=f"user not found: {normalized_email}"
@@ -481,7 +619,7 @@ def grant_ops_subscription(
resp = _requests.post( resp = _requests.post(
f"{supabase_url}/rest/v1/subscriptions", f"{supabase_url}/rest/v1/subscriptions",
headers=headers, headers=_supabase_service_headers(service_role_key, prefer="return=minimal"),
json=payload, json=payload,
timeout=10, timeout=10,
) )
@@ -489,6 +627,7 @@ def grant_ops_subscription(
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Supabase insert failed: {resp.text[:200]}" status_code=500, detail=f"Supabase insert failed: {resp.text[:200]}"
) )
legacy_routes.SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
result: dict[str, Any] = { result: dict[str, Any] = {
"ok": True, "ok": True,
@@ -516,9 +655,7 @@ def extend_ops_subscription(
additional_days: int = 30, additional_days: int = 30,
) -> dict[str, Any]: ) -> dict[str, Any]:
_require_ops(request) _require_ops(request)
import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
import requests as _requests
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
@@ -530,20 +667,26 @@ def extend_ops_subscription(
if not normalized_email: if not normalized_email:
raise HTTPException(status_code=400, detail="email is required") raise HTTPException(status_code=400, detail="email is required")
headers = { headers = _supabase_service_headers(service_role_key)
"apikey": service_role_key,
"Authorization": f"Bearer {service_role_key}", user_id = _lookup_supabase_user_id_by_email(
"Content-Type": "application/json", supabase_url,
"Prefer": "return=representation", service_role_key,
} normalized_email,
)
if not user_id:
raise HTTPException(
status_code=404, detail=f"user not found: {normalized_email}"
)
# Find latest active subscription # Find latest active subscription
subs_resp = _requests.get( subs_resp = _requests.get(
f"{supabase_url}/rest/v1/subscriptions", f"{supabase_url}/rest/v1/subscriptions",
headers=headers, headers=headers,
params={ params={
"select": "*", "select": "id,expires_at",
"email": f"eq.{normalized_email}", "user_id": f"eq.{user_id}",
"status": "eq.active",
"order": "expires_at.desc", "order": "expires_at.desc",
"limit": "1", "limit": "1",
}, },
@@ -565,11 +708,12 @@ def extend_ops_subscription(
patch_resp = _requests.patch( patch_resp = _requests.patch(
f"{supabase_url}/rest/v1/subscriptions?id=eq.{sub['id']}", f"{supabase_url}/rest/v1/subscriptions?id=eq.{sub['id']}",
headers=headers, headers=_supabase_service_headers(service_role_key, prefer="return=minimal"),
json={"expires_at": new_expiry}, json={"expires_at": new_expiry},
timeout=10, timeout=10,
) )
if patch_resp.ok: if patch_resp.ok:
legacy_routes.SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
return { return {
"ok": True, "ok": True,
"email": normalized_email, "email": normalized_email,
@@ -587,8 +731,6 @@ def get_ops_user_subscriptions(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return ALL subscription rows for a user (by email), regardless of status.""" """Return ALL subscription rows for a user (by email), regardless of status."""
_require_ops(request) _require_ops(request)
import os
import requests as _requests
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
@@ -599,21 +741,13 @@ def get_ops_user_subscriptions(
if not normalized_email: if not normalized_email:
raise HTTPException(status_code=400, detail="email is required") raise HTTPException(status_code=400, detail="email is required")
headers = { headers = _supabase_service_headers(service_role_key)
"apikey": service_role_key,
"Authorization": f"Bearer {service_role_key}",
"Content-Type": "application/json",
}
# Resolve user_id from email via auth admin API user_id = _lookup_supabase_user_id_by_email(
user_resp = _requests.get( supabase_url,
f"{supabase_url}/auth/v1/admin/users", service_role_key,
headers=headers, normalized_email,
params={"filter": f"email.eq.{normalized_email}"},
timeout=10,
) )
users = user_resp.json().get("users", []) if user_resp.ok else []
user_id = users[0].get("id") if users else None
if not user_id: if not user_id:
raise HTTPException( raise HTTPException(
status_code=404, detail=f"user not found: {normalized_email}" status_code=404, detail=f"user not found: {normalized_email}"
@@ -1134,11 +1268,13 @@ def get_ops_telegram_audit(request: Request) -> Dict[str, Any]:
anomalies = [] anomalies = []
valid_members = [] valid_members = []
from web.services.ops_api import legacy_routes active_subs, _, used_active_window_query = _list_active_subscriptions_with_windows(
active_subs = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
limit=5000 limit=5000
) )
if not used_active_window_query:
active_subs = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
limit=5000
)
active_subs_map = {} active_subs_map = {}
for sub in active_subs: for sub in active_subs:
uid = str(sub.get("user_id") or "").strip().lower() uid = str(sub.get("user_id") or "").strip().lower()
+1 -1
View File
@@ -27,6 +27,7 @@ def _raise_payment_error(exc: Exception) -> None:
def _require_payment_identity(request: Request) -> Dict[str, Any]: def _require_payment_identity(request: Request) -> Dict[str, Any]:
request.state.skip_subscription_gate = True
legacy_routes._assert_entitlement(request) legacy_routes._assert_entitlement(request)
identity = legacy_routes._require_supabase_identity(request) identity = legacy_routes._require_supabase_identity(request)
user_id = str(identity.get("user_id") or "").strip() user_id = str(identity.get("user_id") or "").strip()
@@ -36,7 +37,6 @@ def _require_payment_identity(request: Request) -> Dict[str, Any]:
def get_payment_config(request: Request) -> Dict[str, Any]: def get_payment_config(request: Request) -> Dict[str, Any]:
legacy_routes._assert_entitlement(request)
try: try:
return legacy_routes.PAYMENT_CHECKOUT.get_config_payload() return legacy_routes.PAYMENT_CHECKOUT.get_config_payload()
except legacy_routes.PaymentCheckoutError as exc: except legacy_routes.PaymentCheckoutError as exc: