proxy.ts166 lines · main
1import { NextResponse, type NextRequest } from 'next/server';
2
3// Better Auth picks the cookie name based on secure mode: in production
4// it prefixes `__Secure-` (per the RFC-6265bis host-only rules). We
5// accept either so the proxy works behind TLS in prod and HTTP in dev.
6const SESSION_COOKIE_PROD = '__Secure-briven.session_token';
7const SESSION_COOKIE_DEV = 'briven.session_token';
8
9// Public api origin, safe to read at the edge (NEXT_PUBLIC_* is inlined at
10// build time). This is the same value client components fetch against.
11const API_ORIGIN = process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? '';
12
13// The proxy runs on every request, so a fetch-per-request would hammer the
14// api. Cache the maintenance flag in module scope — middleware module scope
15// persists across requests inside a worker — and only re-fetch after the TTL.
16const MAINTENANCE_TTL_MS = 30_000;
17let maintenanceCache: { value: boolean; fetchedAt: number } | null = null;
18
19const AUTH_DOMAIN_TTL_MS = 60_000;
20const authDomainCache = new Map<string, { projectId: string | null; fetchedAt: number }>();
21
22async function resolveAuthDomain(host: string): Promise<string | null> {
23 const cached = authDomainCache.get(host);
24 if (cached && Date.now() - cached.fetchedAt < AUTH_DOMAIN_TTL_MS) {
25 return cached.projectId;
26 }
27 if (!API_ORIGIN) return null;
28 try {
29 const res = await fetch(`${API_ORIGIN}/v1/auth-service/resolve-domain?domain=${encodeURIComponent(host)}`, {
30 cache: 'no-store',
31 headers: { accept: 'application/json' },
32 });
33 if (!res.ok) {
34 authDomainCache.set(host, { projectId: null, fetchedAt: Date.now() });
35 return null;
36 }
37 const body = (await res.json()) as { projectId?: string };
38 const projectId = body.projectId ?? null;
39 authDomainCache.set(host, { projectId, fetchedAt: Date.now() });
40 return projectId;
41 } catch {
42 authDomainCache.set(host, { projectId: null, fetchedAt: Date.now() });
43 return null;
44 }
45}
46
47/**
48 * Is the platform in maintenance right now? Cached for ~30s to keep the
49 * per-request cost near zero. On any fetch failure we FAIL OPEN (return
50 * false) — a transient api hiccup must never lock the whole marketing site
51 * behind the maintenance splash. The maintenance page itself renders the
52 * authoritative state fetched server-side.
53 */
54async function isMaintenanceActive(): Promise<boolean> {
55 const now = Date.now();
56 if (maintenanceCache && now - maintenanceCache.fetchedAt < MAINTENANCE_TTL_MS) {
57 return maintenanceCache.value;
58 }
59 if (!API_ORIGIN) return false;
60 try {
61 const res = await fetch(`${API_ORIGIN}/v1/status/maintenance`, {
62 // Never let Next cache this — we manage freshness with the module cache.
63 cache: 'no-store',
64 headers: { accept: 'application/json' },
65 });
66 if (!res.ok) throw new Error(`status ${res.status}`);
67 const body = (await res.json()) as { active?: boolean };
68 const value = body.active === true;
69 maintenanceCache = { value, fetchedAt: now };
70 return value;
71 } catch {
72 // Fail open. Cache the "not in maintenance" result briefly too so a
73 // flapping api doesn't turn into a fetch storm.
74 maintenanceCache = { value: false, fetchedAt: now };
75 return false;
76 }
77}
78
79/**
80 * Request-time gating. In Next.js 16 this file replaces middleware.ts —
81 * the named/default export must be `proxy`, not `middleware`. Matcher
82 * shape is unchanged from 15.x.
83 *
84 * Two jobs:
85 * 1. Admin subdomain: requests on `admin.<domain>` serve the
86 * (admin)/admin cockpit without the /admin path prefix —
87 * admin.briven.tech/users renders /admin/users. Paths already under
88 * /admin pass through, so internal /admin/... links keep working on
89 * the subdomain. Sessions carry over via cross-subdomain cookies.
90 * 2. Dashboard gate: any path under `/dashboard` requires a Better Auth
91 * session cookie. Missing cookie → 302 to /signin?next=<path>. We
92 * intentionally do not validate the cookie here — cheap check at the
93 * edge, authoritative validation happens in the page via
94 * `requireUser()` calling apps/api.
95 * 3. Maintenance gate: when maintenance is active, public visitors get the
96 * /maintenance splash. The admin host, /signin, /maintenance itself and
97 * Next internals/assets/api stay exempt so an operator can still sign in,
98 * reach admin.briven.tech and flip it back off.
99 */
100export default async function proxy(req: NextRequest): Promise<NextResponse> {
101 const { nextUrl } = req;
102
103 const host = (req.headers.get('host') ?? '').split(':')[0] ?? '';
104 const isAdminHost = host.startsWith('admin.');
105 const isAuthHost = host.endsWith('.auth.briven.tech');
106
107 if (isAdminHost && !nextUrl.pathname.startsWith('/admin')) {
108 const url = nextUrl.clone();
109 url.pathname = `/admin${nextUrl.pathname === '/' ? '' : nextUrl.pathname}`;
110 return NextResponse.rewrite(url);
111 }
112
113 // Auth subdomain: <projectId>.auth.briven.tech/<flow> → serve hosted auth pages.
114 if (isAuthHost) {
115 const projectId = host.replace(/\.auth\.briven\.tech$/, '');
116 // Valid project ids start with p_ and contain alphanumeric chars.
117 if (/^p_[a-zA-Z0-9_]+$/.test(projectId)) {
118 const url = nextUrl.clone();
119 url.pathname = `/auth/${projectId}${nextUrl.pathname === '/' ? '/sign-in' : nextUrl.pathname}`;
120 return NextResponse.rewrite(url);
121 }
122 }
123
124 // Custom auth domain: auth.murphus.eu → resolve via API, then serve hosted pages.
125 const isCustomAuthHost = host.startsWith('auth.');
126 if (isCustomAuthHost && !isAuthHost) {
127 const projectId = await resolveAuthDomain(host);
128 if (projectId) {
129 const url = nextUrl.clone();
130 url.pathname = `/auth/${projectId}${nextUrl.pathname === '/' ? '/sign-in' : nextUrl.pathname}`;
131 return NextResponse.rewrite(url);
132 }
133 }
134
135 if (nextUrl.pathname.startsWith('/dashboard')) {
136 const hasSession =
137 req.cookies.has(SESSION_COOKIE_PROD) || req.cookies.has(SESSION_COOKIE_DEV);
138 if (!hasSession) {
139 const url = nextUrl.clone();
140 url.pathname = '/signin';
141 url.searchParams.set('next', nextUrl.pathname + nextUrl.search);
142 return NextResponse.redirect(url);
143 }
144 }
145
146 // Maintenance splash for public visitors. Exemptions keep the operator's
147 // escape hatch open: the admin host (so admin.briven.tech works), /signin
148 // (so they can authenticate), and /maintenance itself (so the splash can
149 // render). Next internals/api/asset paths never hit this matcher, so we
150 // don't need to re-exclude them here.
151 const path = nextUrl.pathname;
152 const maintenanceExempt =
153 isAdminHost || isAuthHost || isCustomAuthHost || path === '/signin' || path.startsWith('/maintenance');
154 if (!maintenanceExempt && (await isMaintenanceActive())) {
155 return NextResponse.rewrite(new URL('/maintenance', req.url));
156 }
157
158 return NextResponse.next();
159}
160
161export const config = {
162 // Everything except Next internals, API routes, and file-looking paths
163 // (favicon, images, fonts). The admin-host rewrite needs page
164 // navigations broadly; non-matching requests fall through untouched.
165 matcher: ['/((?!_next/|api/|.*\\..*).*)'],
166};