diff --git a/frontend/components/account/__tests__/paymentSecurity.test.ts b/frontend/components/account/__tests__/paymentSecurity.test.ts index 1c1c6d24..09406c4b 100644 --- a/frontend/components/account/__tests__/paymentSecurity.test.ts +++ b/frontend/components/account/__tests__/paymentSecurity.test.ts @@ -216,9 +216,13 @@ export function runTests() { const optionalRefreshIndex = middlewareSource.indexOf( "function shouldRefreshOptionalSupabaseSession", ); + const publicApiFunction = middlewareSource.slice( + middlewareSource.indexOf("function isPublicApi"), + middlewareSource.indexOf("function shouldRefreshOptionalSupabaseSession"), + ); assert( optionalRefreshIndex >= 0 && - !middlewareSource.includes('pathname === "/api/system/status"'), + !publicApiFunction.includes('pathname === "/api/system/status"'), "middleware must not treat system status as public after it becomes an ops-only API", ); diff --git a/frontend/components/ops/__tests__/opsLocalAccess.test.ts b/frontend/components/ops/__tests__/opsLocalAccess.test.ts new file mode 100644 index 00000000..4d50f7a0 --- /dev/null +++ b/frontend/components/ops/__tests__/opsLocalAccess.test.ts @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +export function runTests() { + const projectRoot = process.cwd(); + const opsAdminSource = fs.readFileSync( + path.join(projectRoot, "lib", "ops-admin.ts"), + "utf8", + ); + const middlewareSource = fs.readFileSync( + path.join(projectRoot, "middleware.ts"), + "utf8", + ); + const opsProxyAuthSource = fs.readFileSync( + path.join(projectRoot, "lib", "ops-proxy-auth.ts"), + "utf8", + ); + const opsLocalAccessSource = fs.readFileSync( + path.join(projectRoot, "lib", "ops-local-access.ts"), + "utf8", + ); + + assert( + middlewareSource.includes("isLocalOpsAccessHost") && + middlewareSource.includes('pathname.startsWith("/ops")') && + middlewareSource.includes('pathname.startsWith("/api/ops")') && + middlewareSource.includes('pathname === "/api/system/status"') && + middlewareSource.indexOf("isLocalOpsAccessHost") < + middlewareSource.indexOf("handleTerminalGate"), + "middleware must honor localhost ops access before redirecting /ops to login", + ); + + assert( + opsAdminSource.includes("headers") && + opsAdminSource.includes("isLocalOpsAccessHost") && + opsAdminSource.includes("local-dev@polyweather.local") && + opsAdminSource.indexOf("isLocalOpsAccessHost") < + opsAdminSource.indexOf("parseAdminEmails"), + "ops server page gate must honor localhost ops access before Supabase/admin-email redirects", + ); + + assert( + opsProxyAuthSource.includes("isLocalOpsAccessHost") && + opsProxyAuthSource.includes("x-forwarded-host") && + opsProxyAuthSource.includes("request.nextUrl.hostname") && + opsProxyAuthSource.indexOf("isLocalOpsAccessHost") < + opsProxyAuthSource.indexOf("auth.authUserId"), + "ops API proxy auth must allow localhost ops access before requiring a Supabase session", + ); + + assert( + opsLocalAccessSource.includes("POLYWEATHER_LOCAL_OPS_FULL_ACCESS") && + opsLocalAccessSource.includes('process.env.NODE_ENV !== "production"') && + opsLocalAccessSource.includes("isLocalHostname") && + !opsLocalAccessSource.includes("NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS"), + "ops local access must be a server-side dev-only switch independent from the public product full-access flag", + ); +} diff --git a/frontend/lib/ops-admin.ts b/frontend/lib/ops-admin.ts index 7cd9015b..c59fddd2 100644 --- a/frontend/lib/ops-admin.ts +++ b/frontend/lib/ops-admin.ts @@ -1,11 +1,14 @@ -import { cookies } from "next/headers"; +import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; +import { isLocalOpsAccessHost } from "@/lib/ops-local-access"; import { createSupabaseServerClient, hasSupabaseServerEnv, hasSupabaseSessionCookieValues, } from "@/lib/supabase/server"; +const LOCAL_DEV_OPS_EMAIL = "local-dev@polyweather.local"; + function parseAdminEmails() { return String(process.env.POLYWEATHER_OPS_ADMIN_EMAILS || "") .split(",") @@ -14,6 +17,13 @@ function parseAdminEmails() { } export async function requireOpsAdmin(nextPath = "/ops") { + const headerStore = await headers(); + const requestHost = + headerStore.get("x-forwarded-host") || headerStore.get("host") || ""; + if (isLocalOpsAccessHost(requestHost)) { + return { email: LOCAL_DEV_OPS_EMAIL }; + } + const allowedEmails = parseAdminEmails(); if (!allowedEmails.length || !hasSupabaseServerEnv()) { redirect("/"); diff --git a/frontend/lib/ops-local-access.ts b/frontend/lib/ops-local-access.ts new file mode 100644 index 00000000..ce6b7018 --- /dev/null +++ b/frontend/lib/ops-local-access.ts @@ -0,0 +1,11 @@ +import { extractHostname, isLocalHostname } from "@/lib/local-dev-access"; + +function readLocalOpsAccessFlag() { + const raw = process.env.POLYWEATHER_LOCAL_OPS_FULL_ACCESS; + if (raw == null) return process.env.NODE_ENV !== "production"; + return !/^(0|false|off|no)$/i.test(String(raw).trim()); +} + +export function isLocalOpsAccessHost(value?: string | null) { + return readLocalOpsAccessFlag() && isLocalHostname(extractHostname(value)); +} diff --git a/frontend/lib/ops-proxy-auth.ts b/frontend/lib/ops-proxy-auth.ts index f976d86c..1159afc0 100644 --- a/frontend/lib/ops-proxy-auth.ts +++ b/frontend/lib/ops-proxy-auth.ts @@ -4,6 +4,7 @@ import { applyAuthResponseCookies, type BackendHeaderBuildResult, } from "@/lib/backend-auth"; +import { isLocalOpsAccessHost } from "@/lib/ops-local-access"; function hasBearerAuth(request: NextRequest) { const raw = request.headers.get("authorization"); @@ -12,10 +13,22 @@ function hasBearerAuth(request: NextRequest) { return parts.length === 2 && parts[0].toLowerCase() === "bearer" && Boolean(parts[1]); } +function isLocalOpsAccessRequest(request: NextRequest) { + const requestHost = + request.headers.get("x-forwarded-host") || + request.headers.get("host") || + request.nextUrl.host; + return ( + isLocalOpsAccessHost(requestHost) || + isLocalOpsAccessHost(request.nextUrl.hostname) + ); +} + export function requireOpsProxyAuth( request: NextRequest, auth: BackendHeaderBuildResult, ): NextResponse | null { + if (isLocalOpsAccessRequest(request)) return null; if (auth.authUserId || hasBearerAuth(request)) return null; return applyAuthResponseCookies( NextResponse.json( diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 54b5cc2f..568c517d 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -5,6 +5,7 @@ import { refreshMiddlewareSession, } from "@/lib/supabase/server"; import { isLocalFullAccessHost } from "@/lib/local-dev-access"; +import { isLocalOpsAccessHost } from "@/lib/ops-local-access"; function readEnvBool(name: string, fallback: boolean) { const raw = process.env[name]; @@ -51,6 +52,14 @@ function shouldRefreshOptionalSupabaseSession(pathname: string) { ); } +function isOpsSurface(pathname: string) { + return ( + pathname.startsWith("/ops") || + pathname.startsWith("/api/ops") || + pathname === "/api/system/status" + ); +} + function hasSupabaseSessionCookie(request: NextRequest) { return hasSupabaseSessionCookieValues( request.cookies.getAll().map((cookie) => ({ @@ -170,6 +179,14 @@ export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; + if ( + isOpsSurface(pathname) && + (isLocalOpsAccessHost(requestHost) || + isLocalOpsAccessHost(request.nextUrl.hostname)) + ) { + return NextResponse.next(); + } + // ── Terminal gate runs first, independently of global auth mode ────────── // This is the Koyfin-style Layer 1: send unauthenticated users to /auth/login // before they ever reach the terminal, eliminating the jarring "enter product