proxy.ts
Rama main · réplica de lectura del repositorio original.
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)$).*)",
],
};