proxy.ts166 lines · main
| 1 | import { 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. |
| 6 | const SESSION_COOKIE_PROD = '__Secure-briven.session_token'; |
| 7 | const 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. |
| 11 | const 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. |
| 16 | const MAINTENANCE_TTL_MS = 30_000; |
| 17 | let maintenanceCache: { value: boolean; fetchedAt: number } | null = null; |
| 18 | |
| 19 | const AUTH_DOMAIN_TTL_MS = 60_000; |
| 20 | const authDomainCache = new Map<string, { projectId: string | null; fetchedAt: number }>(); |
| 21 | |
| 22 | async 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 | */ |
| 54 | async 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 | */ |
| 100 | export 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 | |
| 161 | export 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 | }; |