Files
DeskcommCRM/proxy.ts
T
webtecnica 265f159c63 fix(1082): keep the public origin in Supabase links, internal URL in transport
The client base URL is the origin of every link the server hands to third
parties. With SUPABASE_SERVER_URL filled, `signedUrl` and `data.url` came out
as `http://kong:8000` — media, avatars, product photos, the LGPD PDF and the
Google login pointed at an address those callers cannot reach.

server.ts, admin.ts and proxy.ts now keep `NEXT_PUBLIC_SUPABASE_URL` as the
base and pass `global.fetch = fetchDoServidor(interna, publica)`, a fetch that
rewrites the public prefix to the internal one: requests (storage sign,
rest/v1, auth/v1/user) take the short path while links stay public. Raw
fetches (health, request-deps, agent worker) keep swapping the URL only —
they generate no link. provedor-google and convite-no-gotrue now go through
the same resolver.

Adds lib/supabase/fetch-do-servidor.test.ts: with the variable filled,
createSignedUrl and signInWithOAuth return the public origin and the request
still goes to the internal one — red before this commit. The three
`presente:` cases in tests/unit/supabase-server-url-opcional.test.ts follow
the new contract (public base, internal transport).

docs/deploy-selfhost/README.md and .env.example no longer claim REST, Realtime
and Storage stop needing to be public: the browser still uses Realtime and
Storage on the public URL, so it keeps serving Auth, Realtime and Storage. The
gain is the server path.
2026-09-28 07:58:01 -03:00

155 lines
5.8 KiB
TypeScript

import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookieSecure } from "@/lib/supabase/cookie-secure";
import { NextResponse, type NextRequest } from "next/server";
import { env } from "@/lib/env";
import { fetchDoServidor } from "@/lib/supabase/fetch-do-servidor";
import { urlDoSupabaseNoServidor } from "@/lib/supabase/url-do-servidor";
import { isPublicPath } from "@/lib/auth/public-paths";
import {
verifyImpersonateCookieEdge,
IMPERSONATE_COOKIE_NAME_EDGE,
} from "@/lib/impersonate/cookie-edge";
const COOKIE_NAME = "sb-deskcomm-auth";
export async function proxy(request: NextRequest) {
const response = NextResponse.next({ request: { headers: request.headers } });
// Inject X-Request-Id for downstream correlation (audit log, error wrappers).
const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
response.headers.set("x-request-id", requestId);
const { pathname, search } = request.nextUrl;
// Recupera retornos de OAuth social já emitidos antes da landing pública existir.
// Apenas a navegação é tratada: o vínculo de conta segue protegido pelos guards canônicos.
// Passa adiante só o SINAL `connected=1` — nunca o `connect_token` nem o valor recebido.
if (
request.method === "GET" &&
pathname === "/app/connections" &&
request.nextUrl.searchParams.has("connected") &&
request.nextUrl.searchParams.has("connect_token")
) {
const landing = NextResponse.redirect(new URL("/auth/social-return?connected=1", request.url));
landing.headers.set("Cache-Control", "no-store");
landing.headers.set("Referrer-Policy", "no-referrer");
return landing;
}
// Expose pathname to Server Components via header (used by onboarding layout).
response.headers.set("x-pathname", pathname);
request.headers.set("x-pathname", pathname);
// EPIC-11: the admin surface is reached by PATH (`/admin/*`) — the self-host kit
// points `NEXT_PUBLIC_ADMIN_URL` at the same host as the app and maps no `admin.`
// sub-domain. The host-based branch below stays a NOOP today and only exists as
// documentation of the intended deploy topology.
const host = request.headers.get("host") ?? "";
const isAdminSurface = host.startsWith("admin.") || pathname.startsWith("/admin");
if (isPublicPath(pathname)) {
return response;
}
const supabase = createServerClient(
// #1082: base na URL pública, endereço interno só no transporte (ver
// `lib/supabase/fetch-do-servidor.ts`). O middleware não gera link, mas
// segue a mesma regra dos outros dois clients — um desenho só.
env.NEXT_PUBLIC_SUPABASE_URL,
env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{
global: {
fetch: fetchDoServidor(
urlDoSupabaseNoServidor(env.SUPABASE_SERVER_URL, env.NEXT_PUBLIC_SUPABASE_URL),
env.NEXT_PUBLIC_SUPABASE_URL,
),
},
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
cookiesToSet.forEach(({ name, value, options }) => {
request.cookies.set(name, value);
response.cookies.set(name, value, options);
});
},
},
cookieOptions: {
name: COOKIE_NAME,
sameSite: "strict",
httpOnly: true,
secure: cookieSecure(),
path: "/",
},
},
);
// Validate JWT server-side (NEVER use getSession on backend per CLAUDE.md).
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
// API routes must respond with JSON envelope (contract: {error:{code,message}})
// — never redirect HTML to JSON consumers. UI routes redirect to /login as before.
if (pathname.startsWith("/api/")) {
return new NextResponse(
JSON.stringify({
error: {
code: "unauthenticated",
message: "Authentication required",
},
}),
{
status: 401,
headers: {
"content-type": "application/json",
"x-request-id": requestId,
},
},
);
}
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("next", pathname + search);
return NextResponse.redirect(loginUrl);
}
// EPIC-11 S-11.07: validate impersonate cookie on /app/* paths. Middleware
// runs in Edge — no DB access, only HMAC + expiry. On any failure we delete
// the presentation cookie. The database support session remains authoritative:
// expired/revoked support still blocks the app until explicit exit.
if (pathname.startsWith("/app")) {
const impCookie = request.cookies.get(IMPERSONATE_COOKIE_NAME_EDGE)?.value;
if (impCookie) {
const result = await verifyImpersonateCookieEdge(
impCookie,
env.IMPERSONATE_COOKIE_SECRET ?? "",
);
if (!result.valid) {
console.warn(
`[middleware] impersonate cookie invalid (${result.reason ?? "unknown"}) — clearing`,
);
response.cookies.delete(IMPERSONATE_COOKIE_NAME_EDGE);
}
}
}
// /admin/* additionally requires platform_admin (early gate — authoritative
// check is server-side in `requirePlatformAdmin`). Skip the RPC for
// `/admin/forbidden` (rendered to non-admins, would otherwise loop).
if (isAdminSurface && pathname.startsWith("/admin") && pathname !== "/admin/forbidden") {
const { data: isAdmin, error } = await supabase.rpc("fn_is_platform_admin");
if (error || !isAdmin) {
return NextResponse.redirect(new URL("/admin/forbidden", request.url));
}
}
return response;
}
export const config = {
matcher: [
// Run on all paths except static assets / Next internals.
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*)",
],
};