fix ops local admin redirects
This commit is contained in:
@@ -216,9 +216,13 @@ export function runTests() {
|
|||||||
const optionalRefreshIndex = middlewareSource.indexOf(
|
const optionalRefreshIndex = middlewareSource.indexOf(
|
||||||
"function shouldRefreshOptionalSupabaseSession",
|
"function shouldRefreshOptionalSupabaseSession",
|
||||||
);
|
);
|
||||||
|
const publicApiFunction = middlewareSource.slice(
|
||||||
|
middlewareSource.indexOf("function isPublicApi"),
|
||||||
|
middlewareSource.indexOf("function shouldRefreshOptionalSupabaseSession"),
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
optionalRefreshIndex >= 0 &&
|
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",
|
"middleware must not treat system status as public after it becomes an ops-only API",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { isLocalOpsAccessHost } from "@/lib/ops-local-access";
|
||||||
import {
|
import {
|
||||||
createSupabaseServerClient,
|
createSupabaseServerClient,
|
||||||
hasSupabaseServerEnv,
|
hasSupabaseServerEnv,
|
||||||
hasSupabaseSessionCookieValues,
|
hasSupabaseSessionCookieValues,
|
||||||
} from "@/lib/supabase/server";
|
} from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
const LOCAL_DEV_OPS_EMAIL = "local-dev@polyweather.local";
|
||||||
|
|
||||||
function parseAdminEmails() {
|
function parseAdminEmails() {
|
||||||
return String(process.env.POLYWEATHER_OPS_ADMIN_EMAILS || "")
|
return String(process.env.POLYWEATHER_OPS_ADMIN_EMAILS || "")
|
||||||
.split(",")
|
.split(",")
|
||||||
@@ -14,6 +17,13 @@ function parseAdminEmails() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function requireOpsAdmin(nextPath = "/ops") {
|
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();
|
const allowedEmails = parseAdminEmails();
|
||||||
if (!allowedEmails.length || !hasSupabaseServerEnv()) {
|
if (!allowedEmails.length || !hasSupabaseServerEnv()) {
|
||||||
redirect("/");
|
redirect("/");
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
type BackendHeaderBuildResult,
|
type BackendHeaderBuildResult,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
|
import { isLocalOpsAccessHost } from "@/lib/ops-local-access";
|
||||||
|
|
||||||
function hasBearerAuth(request: NextRequest) {
|
function hasBearerAuth(request: NextRequest) {
|
||||||
const raw = request.headers.get("authorization");
|
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]);
|
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(
|
export function requireOpsProxyAuth(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
auth: BackendHeaderBuildResult,
|
auth: BackendHeaderBuildResult,
|
||||||
): NextResponse | null {
|
): NextResponse | null {
|
||||||
|
if (isLocalOpsAccessRequest(request)) return null;
|
||||||
if (auth.authUserId || hasBearerAuth(request)) return null;
|
if (auth.authUserId || hasBearerAuth(request)) return null;
|
||||||
return applyAuthResponseCookies(
|
return applyAuthResponseCookies(
|
||||||
NextResponse.json(
|
NextResponse.json(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
refreshMiddlewareSession,
|
refreshMiddlewareSession,
|
||||||
} from "@/lib/supabase/server";
|
} from "@/lib/supabase/server";
|
||||||
import { isLocalFullAccessHost } from "@/lib/local-dev-access";
|
import { isLocalFullAccessHost } from "@/lib/local-dev-access";
|
||||||
|
import { isLocalOpsAccessHost } from "@/lib/ops-local-access";
|
||||||
|
|
||||||
function readEnvBool(name: string, fallback: boolean) {
|
function readEnvBool(name: string, fallback: boolean) {
|
||||||
const raw = process.env[name];
|
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) {
|
function hasSupabaseSessionCookie(request: NextRequest) {
|
||||||
return hasSupabaseSessionCookieValues(
|
return hasSupabaseSessionCookieValues(
|
||||||
request.cookies.getAll().map((cookie) => ({
|
request.cookies.getAll().map((cookie) => ({
|
||||||
@@ -170,6 +179,14 @@ export async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
const { pathname } = request.nextUrl;
|
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 ──────────
|
// ── Terminal gate runs first, independently of global auth mode ──────────
|
||||||
// This is the Koyfin-style Layer 1: send unauthenticated users to /auth/login
|
// This is the Koyfin-style Layer 1: send unauthenticated users to /auth/login
|
||||||
// before they ever reach the terminal, eliminating the jarring "enter product
|
// before they ever reach the terminal, eliminating the jarring "enter product
|
||||||
|
|||||||
Reference in New Issue
Block a user