Ctrl K
ComunidadGuías…

middleware.ts

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

Archivo original ↗Preguntar a KiBot
import { NextResponse, type NextRequest } from "next/server";
import { jwtVerify } from "jose";

// Fail closed: never fall back to a default secret from this public repository.
if (!process.env.SESSION_SECRET) {
  throw new Error("SESSION_SECRET environment variable is required");
}
const JWT_SECRET = new TextEncoder().encode(process.env.SESSION_SECRET);
const COOKIE_NAME = "dough-session";

// The reference is served on the api. subdomain of whatever domain an instance runs on, so every
// self-hosted Dough has its own docs at its own address with nothing to configure. DOUGH_DOCS_HOST
// pins an exact host instead, for an instance that already lives on an api. name.
function isDocsHost(host: string): boolean {
  const pinned = process.env.DOUGH_DOCS_HOST;
  if (pinned) return host === pinned;
  return host === "api.localhost:3000" || /^api\./.test(host);
}

// Paths the docs host serves itself: the page's own assets, and the key-authed public API, so a
// client can call the API on the same host the reference documents. The cookie-authed internal
// routes are deliberately not here - this host skips the session gate, so only routes that
// authenticate inside themselves may answer on it. Everything else belongs to the app and is sent
// to the app's own host rather than resolving to the reference again.
const DOCS_PASSTHROUGH = ["/_next", "/api/v1", "/favicon", "/icon", "/apple", "/manifest"];

function serveDocs(request: NextRequest, host: string) {
  const { pathname, search } = request.nextUrl;

  if (DOCS_PASSTHROUGH.some((prefix) => pathname.startsWith(prefix))) {
    return NextResponse.next();
  }

  if (pathname === "/" || pathname === "/api-docs") {
    const url = request.nextUrl.clone();
    url.pathname = "/api-docs";
    return NextResponse.rewrite(url);
  }

  const appHost = host.replace(/^api\./, "");
  const scheme = request.nextUrl.protocol === "http:" ? "http" : "https";
  return NextResponse.redirect(`${scheme}://${appHost}${pathname}${search}`, 308);
}

export async function middleware(request: NextRequest) {
  // The docs host is public: it never reads the database and must not be sent to /login.
  const host = request.headers.get("host") || "";
  if (isDocsHost(host)) {
    return serveDocs(request, host);
  }

  const token = request.cookies.get(COOKIE_NAME)?.value;
  const isLoginPage = request.nextUrl.pathname.startsWith("/login");
  const isApiAuth = request.nextUrl.pathname.startsWith("/api/auth");
  const isEvents = request.nextUrl.pathname === "/api/events";
  // Discovery, registration, token and revocation are reached by a client that has no session by
  // definition. The consent screen at /oauth/authorize is deliberately not here: it needs the
  // person signed in, which is the whole point of it.
  const isOAuthPublic =
    request.nextUrl.pathname.startsWith("/.well-known/oauth") ||
    request.nextUrl.pathname === "/api/oauth/register" ||
    request.nextUrl.pathname === "/api/oauth/token" ||
    request.nextUrl.pathname === "/api/oauth/revoke";
  // The reference is public wherever it is asked for: it holds no data, only the API's shape.
  const isApiDocs = request.nextUrl.pathname === "/api-docs";
  // Sync timers carry no session; both routes check the timer secret or a session themselves.
  const isSyncRoute = request.nextUrl.pathname === "/api/synci/sync" || request.nextUrl.pathname === "/api/ynab/sync";
  // The public v1 API authenticates with an API key inside each route (see lib/api-auth), not the
  // session cookie, so it must bypass this cookie gate and never be redirected to /login.
  const isApiV1 = request.nextUrl.pathname.startsWith("/api/v1");
  // The MCP endpoint authenticates each request itself, like the v1 API it wraps.
  const isMcp = request.nextUrl.pathname === "/mcp";

  // Allow auth API, the key-authed public API, SSE events, cron endpoints, and static assets
  if (isApiAuth || isApiV1 || isMcp || isApiDocs || isOAuthPublic || isEvents || isSyncRoute) {
    return NextResponse.next();
  }

  // Check session
  let isValid = false;
  if (token) {
    try {
      await jwtVerify(token, JWT_SECRET);
      isValid = true;
    } catch {
      isValid = false;
    }
  }

  // Redirect unauthenticated to login
  if (!isValid && !isLoginPage) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    url.search = "";
    // Signing in should land where the person was headed, which for a consent screen is the whole
    // flow. Only a path is carried, never an absolute URL, so this cannot become an open redirect.
    const intended = request.nextUrl.pathname + request.nextUrl.search;
    if (intended !== "/" && !intended.startsWith("/api/")) {
      url.searchParams.set("next", intended);
    }
    return NextResponse.redirect(url);
  }

  // Redirect authenticated away from login
  if (isValid && isLoginPage) {
    const url = request.nextUrl.clone();
    url.pathname = "/dashboard";
    return NextResponse.redirect(url);
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    // Only manifest.json is exempt by name - a blanket *.json exemption would let any future
    // .json route or static file bypass the auth gate.
    "/((?!_next/static|_next/image|favicon\\.ico|favicon\\.png|manifest\\.json|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)",
  ],
};