File: //workspace/rollout/mission-control-frontend.patch
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index 8c034bc..e54d28c 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -1,11 +1,24 @@
import type { NextConfig } from "next";
+function normalizeBasePath(raw: string | undefined): string {
+ const trimmed = (raw ?? "").trim();
+ if (!trimmed || trimmed === "/") return "";
+ const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
+ return withLeadingSlash.replace(/\/+$/, "");
+}
+
+const basePath = normalizeBasePath(process.env.NEXT_PUBLIC_BASE_PATH);
+const internalApiUrl = (process.env.INTERNAL_API_URL ?? "")
+ .trim()
+ .replace(/\/+$/, "");
+
const nextConfig: NextConfig = {
// In dev, Next may proxy requests based on the request origin/host.
// Allow common local origins so `next dev --hostname 127.0.0.1` works
// when users access via http://localhost:3000 or http://127.0.0.1:3000.
// Keep the LAN IP as well for dev on the local network.
allowedDevOrigins: ["192.168.1.101", "localhost", "127.0.0.1"],
+ ...(basePath ? { basePath } : {}),
images: {
remotePatterns: [
{
@@ -14,6 +27,36 @@ const nextConfig: NextConfig = {
},
],
},
+ async headers() {
+ return [
+ {
+ source: "/((?!_next/static|_next/image|favicon.ico).*)",
+ headers: [
+ {
+ key: "Cache-Control",
+ value: "no-store, no-cache, must-revalidate, max-age=0",
+ },
+ ],
+ },
+ ];
+ },
+ async rewrites() {
+ if (!internalApiUrl) return [];
+ return [
+ {
+ source: "/healthz",
+ destination: `${internalApiUrl}/healthz`,
+ },
+ {
+ source: "/readyz",
+ destination: `${internalApiUrl}/readyz`,
+ },
+ {
+ source: "/openapi.json",
+ destination: `${internalApiUrl}/openapi.json`,
+ },
+ ];
+ },
};
export default nextConfig;
diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts
new file mode 100644
index 0000000..12d6b8d
--- /dev/null
+++ b/frontend/src/app/api/[...path]/route.ts
@@ -0,0 +1,139 @@
+import { NextRequest } from "next/server";
+
+const RAW_INTERNAL_API_URL = process.env.INTERNAL_API_URL ?? "";
+const INTERNAL_API_URL =
+ RAW_INTERNAL_API_URL.trim().replace(/\/+$/, "") || "http://127.0.0.1:3411";
+const LOCAL_MODE = (process.env.NEXT_PUBLIC_AUTH_MODE ?? "").trim() === "local";
+const CONFIGURED_LOCAL_TOKEN = (
+ process.env.NEXT_PUBLIC_LOCAL_AUTH_TOKEN ?? process.env.LOCAL_AUTH_TOKEN ?? ""
+)
+ .trim();
+
+const HOP_BY_HOP_REQUEST_HEADERS = new Set([
+ "connection",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade",
+ "host",
+]);
+
+const HOP_BY_HOP_RESPONSE_HEADERS = new Set([
+ "connection",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade",
+]);
+
+function targetUrlFrom(req: NextRequest, path: string[] | undefined): string {
+ const joined = (path ?? []).join("/");
+ const suffix = joined ? `/${joined}` : "";
+ const search = req.nextUrl.search || "";
+ return `${INTERNAL_API_URL}/api${suffix}${search}`;
+}
+
+function prepareRequestHeaders(req: NextRequest): Headers {
+ const headers = new Headers(req.headers);
+
+ for (const name of HOP_BY_HOP_REQUEST_HEADERS) {
+ headers.delete(name);
+ }
+
+ const overrideToken = (headers.get("x-mc-local-token") ?? "").trim();
+ headers.delete("x-mc-local-token");
+
+ if (LOCAL_MODE) {
+ if (overrideToken) {
+ headers.set("authorization", `Bearer ${overrideToken}`);
+ } else if (!headers.has("authorization")) {
+ const effective = CONFIGURED_LOCAL_TOKEN;
+ if (!effective) {
+ throw new Error(
+ "Local auth mode is enabled but no token is configured in frontend environment.",
+ );
+ }
+ headers.set("authorization", `Bearer ${effective}`);
+ }
+ }
+
+ return headers;
+}
+
+function prepareResponseHeaders(upstream: Headers): Headers {
+ const headers = new Headers(upstream);
+ for (const name of HOP_BY_HOP_RESPONSE_HEADERS) {
+ headers.delete(name);
+ }
+ return headers;
+}
+
+async function proxy(req: NextRequest, path: string[] | undefined): Promise<Response> {
+ let requestHeaders: Headers;
+ try {
+ requestHeaders = prepareRequestHeaders(req);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Proxy auth setup failed";
+ return Response.json({ detail: message }, { status: 500 });
+ }
+
+ const method = req.method.toUpperCase();
+ const hasBody = method !== "GET" && method !== "HEAD";
+ const body = hasBody ? await req.arrayBuffer() : undefined;
+
+ const upstream = await fetch(targetUrlFrom(req, path), {
+ method,
+ headers: requestHeaders,
+ body,
+ redirect: "manual",
+ cache: "no-store",
+ });
+
+ return new Response(upstream.body, {
+ status: upstream.status,
+ statusText: upstream.statusText,
+ headers: prepareResponseHeaders(upstream.headers),
+ });
+}
+
+type RouteContext = {
+ params: Promise<{
+ path: string[];
+ }>;
+};
+
+export async function GET(req: NextRequest, ctx: RouteContext): Promise<Response> {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
+
+export async function POST(req: NextRequest, ctx: RouteContext): Promise<Response> {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
+
+export async function PUT(req: NextRequest, ctx: RouteContext): Promise<Response> {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
+
+export async function PATCH(req: NextRequest, ctx: RouteContext): Promise<Response> {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
+
+export async function DELETE(req: NextRequest, ctx: RouteContext): Promise<Response> {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
+
+export async function OPTIONS(req: NextRequest, ctx: RouteContext): Promise<Response> {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}