import { NextResponse } from "next/server"; import { supabaseAdmin } from "@/lib/supabase/admin"; import { lookupApiKeyAccess } from "@/lib/auth/api-key"; import { allowsDelegatedInternalApiPath, isPublicRoutePath, isSentryTunnelEnvelopeUrl, isStraySentryTunnelRequest, isUnscopedAuthedRoutePath, } from "@/lib/auth-route-policy"; import { buildMachineApiAuthFailureResponse, getDelegatedUserIdFromRequest, getMachineApiAuthResult, hasPlaywrightAuthBypass, buildIntegrationCredentialRejection, isCliPatApiRequest, isAutomationOnlyKeyOnDirectPath, isMogplexBearerApiRequest, } from "@/lib/internal-api-auth"; import { isDashboardScopedFirstSegment } from "@/lib/dashboard-rescue"; import { getCanonicalAppUrl } from "@/lib/app-url"; import { resolveScope, type ScopeLookup } from "@/lib/middleware-scope"; import type { NextRequest } from "next/server"; // supabaseAdmin is the pg-backed PostgREST shim over Neon. proxy.ts runs in // the Node.js runtime, so the pg path is available here. function makeScopeDb(): ScopeLookup { return { findProfileBySlug: async (slug) => { const { data } = await supabaseAdmin .from("profiles") .select("id") .eq("slug", slug) .maybeSingle(); return data ? { id: data.id as string } : null; }, findTeamBySlug: async (slug) => { const { data } = await supabaseAdmin .from("teams") .select("id") .eq("slug", slug) .maybeSingle(); return data ? { id: data.id as string } : null; }, isTeamMember: async (teamId, userId) => { const { data } = await supabaseAdmin .from("team_members") .select("team_id") .eq("team_id", teamId) .eq("user_id", userId) .maybeSingle(); return Boolean(data); }, }; } let warnedTunnelMissing = false; /** * A well-formed envelope refused here means the SDK is tunnelling but this * build installed no tunnel, so browser error reports are being dropped. */ function warnOnceIfTunnelMissing(url: URL) { if (warnedTunnelMissing || !isSentryTunnelEnvelopeUrl(url)) return; warnedTunnelMissing = true; console.warn( "[proxy] Sentry tunnel request refused: this build installed no tunnel route" ); } function notFoundResponse(request: NextRequest) { return NextResponse.rewrite(new URL("/_scope-not-found", request.url), { status: 404, }); } export function buildLoginRedirectUrl( request: NextRequest, isAppNavigation: boolean ) { const loginUrl = new URL("/login", request.url); if (isAppNavigation) { loginUrl.searchParams.set("expired", "true"); } loginUrl.searchParams.set( "next", `${request.nextUrl.pathname}${request.nextUrl.search}` ); return loginUrl; } /** * Collapse the legacy www host onto the configured application origin before * auth runs. Better Auth cookies are intentionally host-only; allowing both * hosts to serve the app would otherwise create two unrelated session jars. */ export function buildCanonicalHostRedirectUrl(request: NextRequest) { const canonical = getCanonicalAppUrl(request); if (request.nextUrl.hostname !== `www.${canonical.hostname}`) return null; const redirect = new URL(request.nextUrl.pathname, canonical); redirect.search = request.nextUrl.search; return redirect; } type ProxyDeps = { /** Reads a Mogplex API key's access level; injected in tests. */ lookupApiKeyAccess: typeof lookupApiKeyAccess; }; export async function proxy(request: NextRequest) { return handleProxy(request, { lookupApiKeyAccess }); } /** The proxy with injectable lookups, for tests. */ export function createProxy(deps: ProxyDeps) { return (request: NextRequest) => handleProxy(request, deps); } async function handleProxy(request: NextRequest, deps: ProxyDeps) { const canonicalHostRedirect = buildCanonicalHostRedirectUrl(request); if (canonicalHostRedirect) { return NextResponse.redirect(canonicalHostRedirect, 308); } const { pathname } = request.nextUrl; if (hasPlaywrightAuthBypass(request)) { return NextResponse.next(); } // Public paths run before the machine-auth gate so a request to /install.sh // that happens to carry an Authorization header can't get converted into a // machine-auth failure response instead of the install script. // // The root is public (anonymous visitors get the landing page) but still // flows through session resolution below: signed-in users landing on "/" — // notably the post-login callback, whose fallback destination is "/" — // bounce to their personal scope instead of the marketing page. if (isPublicRoutePath(pathname) && pathname !== "/") { // OAuth authorize requests that need a login land on /login carrying the // raw authorize query, and Better Auth also plants an oidc_login_prompt // cookie so a hook can resume authorization on the sign-in POST. That // hook turns the sign-in fetch's response into a 302 to the client's // redirect URI, which fetch then follows cross-origin and dies on CORS // (for the CLI's loopback URI, also on Chrome's local-network gating). // Drop the cookie here so the sign-in POST completes normally and the // login page's `next`-based resume — a top-level navigation immune to // both — carries the flow instead. if ( pathname === "/login" && request.nextUrl.searchParams.has("client_id") && request.nextUrl.searchParams.has("redirect_uri") && request.nextUrl.searchParams.has("response_type") ) { const response = NextResponse.next(); response.cookies.set("oidc_login_prompt", "", { maxAge: 0, path: "/" }); return response; } // Bodyless: a rendered 404 would reach the /[scope] catch-all instead. if (isStraySentryTunnelRequest(request.nextUrl)) { warnOnceIfTunnelMissing(request.nextUrl); return new NextResponse(null, { status: 404 }); } return NextResponse.next(); } const delegatedUserId = allowsDelegatedInternalApiPath(pathname) ? getDelegatedUserIdFromRequest(request) : null; if (delegatedUserId) { return NextResponse.next(); } const machineAuthResult = getMachineApiAuthResult(request, pathname); if (machineAuthResult.type === "authorized") { return NextResponse.next(); } if ( machineAuthResult.type === "missing_secret" || machineAuthResult.type === "unauthorized" ) { return buildMachineApiAuthFailureResponse(machineAuthResult); } if ( await isAutomationOnlyKeyOnDirectPath( request, pathname, deps.lookupApiKeyAccess ) ) { return buildIntegrationCredentialRejection(); } // CLI PATs (Bearer mog_*) are only delegated on the narrow set of PAT-aware // hosted endpoints. Machine-auth routes stay hard-gated above, and any other // API path still requires the normal browser session. if ( isCliPatApiRequest(request, pathname) || isMogplexBearerApiRequest(request, pathname) ) { return NextResponse.next(); } const passThrough = NextResponse.next({ request }); // better-auth session. With cookieCache enabled this usually verifies the // signed cookie snapshot without a database round-trip; a stale cache // falls through to the real session lookup. const { auth } = await import("@/lib/better-auth/server"); const session = await auth.api.getSession({ headers: request.headers }); const user = session?.user ? { id: session.user.id } : null; if (!user) { // Anonymous root renders the public landing page — never a login bounce. if (pathname === "/") { return passThrough; } const referer = request.headers.get("referer"); let isAppNavigation = false; try { isAppNavigation = Boolean( referer && new URL(referer).origin === request.nextUrl.origin ); } catch { /* malformed referer */ } return NextResponse.redirect( buildLoginRedirectUrl(request, isAppNavigation) ); } // API routes are authed but unscoped — no slug resolution. if (pathname.startsWith("/api/")) { return passThrough; } const { data: profile } = await supabaseAdmin .from("profiles") .select("id, slug") .eq("auth_user_id", user.id) .single(); // Authed root: jump to the user's personal scope when slug is known, // otherwise let the landing page render (we logged a warning when slug // is missing — that's a data-state bug, not a routing bug). if (pathname === "/") { if (profile?.slug) { const url = new URL(`/${profile.slug as string}`, request.url); url.search = request.nextUrl.search; return NextResponse.redirect(url); } if (profile && !profile.slug) { console.warn( "[proxy] authed user has no personal slug; rendering landing page", { profileId: profile.id } ); } return passThrough; } const firstSegment = pathname.split("/")[1]; if (!firstSegment) return passThrough; if (isUnscopedAuthedRoutePath(pathname)) return passThrough; const result = await resolveScope( firstSegment, (profile?.id as string | undefined) ?? null, makeScopeDb() ); if (result.status === "not_found") { // Rescue unscoped dashboard routes (e.g. `/agents/skills`) when the user // has a personal slug — a stale link or sub-nav that dropped the scope // prefix shouldn't dead-end on a 404. A real team owning the slug // ("agents", "settings", …) still wins because resolveScope would have // returned a `team` result above; we only land here when no scope matched. // // The `profileSlug &&` guard intentionally rejects both a missing profile // row and a profile with a blank slug — either would produce a `//path` // redirect target that we never want to emit. // // `profileSlug !== firstSegment` is belt-and-suspenders: if the slug // matches, `resolveScope` should already have returned `personal` and we // wouldn't be here. Keep the guard so a corrupt profile row (slug present // but `auth_user_id` mismatched) can't produce a self-redirect loop. const profileSlug = profile?.slug as string | undefined; if ( profileSlug && profileSlug !== firstSegment && isDashboardScopedFirstSegment(firstSegment) ) { const url = new URL(`/${profileSlug}${pathname}`, request.url); url.search = request.nextUrl.search; return NextResponse.redirect(url); } return notFoundResponse(request); } // redirect_login can't fire here — we already short-circuited unauthed // requests above — but handle it defensively. if (result.status === "redirect_login") { const loginUrl = new URL("/login", request.url); return NextResponse.redirect(loginUrl); } const scopeId = result.status === "personal" ? result.profileId : result.teamId; const requestHeaders = new Headers(request.headers); requestHeaders.set("x-mogplex-scope-kind", result.status); requestHeaders.set("x-mogplex-scope-slug", result.slug); requestHeaders.set("x-mogplex-scope-id", scopeId); return NextResponse.next({ request: { headers: requestHeaders } }); } // Font assets live in `/public/fonts/**` and are served at the root. Without // a `fonts/` prefix in the negative lookahead, the proxy treats font requests // as scope segments and redirects them to /login, breaking `@font-face` on // every page (notably the Mondwest hero on the marketing landing). // // The `fonts/` entry is a path-prefix exemption, so /api/foo.woff still goes // through the proxy. The image group is different: it is extension-based and // unanchored, so ANY path ending in one of these extensions (including under // /api/) bypasses the proxy entirely — no auth, no scope resolution, and no // header injection, which means inbound x-mogplex-scope-* headers on those // requests are client-controlled. ScopeLayout defends against that by // 404ing image-like scope segments before parsing scope headers. // Keep the image extension group aligned with IMAGE_ASSET_SCOPE_EXTENSIONS in // lib/scope-context.ts; tests/unit/scope-context.test.ts pins the invariant. export const config = { matcher: [ "/((?!_next/static|_next/image|favicon.ico|fonts/|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], };