Ctrl K
ComunidadGuías…

proxy.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)$).*)",
  ],
};