From 9856c04b754672092c33fb96d793a184f8fa28ad Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 10 Jun 2026 18:43:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20login-gated=20BFF=20auth=20?= =?UTF-8?q?=E2=80=94=20sealed=20session=20cookie=20+=20server-side=20token?= =?UTF-8?q?=20injection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-user, LAN-reachable-but-gated. The web server is a backend-for-frontend: - Login: POST /_auth/login {password} checks PUNKTFUNK_UI_PASSWORD (constant-time) and sets a SEALED session cookie (h3 useSession / AES-GCM). server/middleware/auth.ts gates every request — pages 302 → /login, /api → 401 — and FAILS CLOSED (503) when PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one. - The management API stays loopback-only + token (never LAN-exposed). The proxy (server/routes/api/[...].ts) injects PUNKTFUNK_MGMT_TOKEN server-side and drops the browser's cookie before forwarding — the token never reaches the browser, which only holds the session cookie. Nitro doesn't auto-scan a server/ dir, so the Nitro plugin gets an explicit scanDirs to pick up middleware + routes. Client: removed the localStorage token (server injects it); the fetcher bounces to /login on 401; new /login page (bare, no shell); Settings drops the token field and gains a Sign-out button; en/de strings. Validated live end to end: unauth /→302, /api→401; wrong pw→401; right pw→200+cookie; authed /api/v1/status→200 (proxied, mgmt token injected — the host required it); logout→ session cleared→401. tsc + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/.env.example | 23 +++++++ web/.gitignore | 3 + web/README.md | 28 +++++++-- web/messages/de.json | 9 ++- web/messages/en.json | 9 ++- web/server/middleware/auth.ts | 27 +++++++++ web/server/routes/_auth/login.post.ts | 20 +++++++ web/server/routes/_auth/logout.post.ts | 9 +++ web/server/routes/api/[...].ts | 21 +++++++ web/server/util/auth.ts | 62 +++++++++++++++++++ web/src/api/fetcher.ts | 36 +++++------ web/src/routes/__root.tsx | 11 +++- web/src/routes/login.tsx | 83 ++++++++++++++++++++++++++ web/src/routes/settings.tsx | 61 ++++++------------- web/tsconfig.json | 2 +- web/vite.config.ts | 17 ++++-- 16 files changed, 340 insertions(+), 81 deletions(-) create mode 100644 web/.env.example create mode 100644 web/server/middleware/auth.ts create mode 100644 web/server/routes/_auth/login.post.ts create mode 100644 web/server/routes/_auth/logout.post.ts create mode 100644 web/server/routes/api/[...].ts create mode 100644 web/server/util/auth.ts create mode 100644 web/src/routes/login.tsx diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..f90488f --- /dev/null +++ b/web/.env.example @@ -0,0 +1,23 @@ +# punktfunk web — management console (Bun/Nitro server) configuration. +# Copy to `.env` (gitignored) or set these in the environment of `bun run start`. + +# REQUIRED in production: the shared login password for the console. The built Nitro +# server fails CLOSED (503 on every request) if this is unset, so a LAN-exposed server +# never admits anyone by accident. +PUNKTFUNK_UI_PASSWORD=change-me + +# Management API the console proxies to. Keep it loopback — it should NOT be LAN-exposed; +# the login-gated web server is the only path to it. +PUNKTFUNK_MGMT_URL=http://127.0.0.1:47990 + +# Bearer token for the management API, injected server-side by the /api proxy (never sent +# to the browser). Must match the host's `--mgmt-token` / PUNKTFUNK_MGMT_TOKEN. +PUNKTFUNK_MGMT_TOKEN= + +# OPTIONAL: explicit cookie-sealing secret (>= 32 chars). If unset, a stable key is derived +# from PUNKTFUNK_UI_PASSWORD (changing the password then invalidates sessions). +# PUNKTFUNK_UI_SECRET= + +# The Bun server binds these (standard Nitro env): +# PORT=3000 +# HOST=0.0.0.0 diff --git a/web/.gitignore b/web/.gitignore index 7c0692b..b43183e 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -9,3 +9,6 @@ dist src/routeTree.gen.ts src/api/gen src/paraglide + +# local env (PUNKTFUNK_UI_PASSWORD etc.) +.env diff --git a/web/README.md b/web/README.md index 261e129..3264117 100644 --- a/web/README.md +++ b/web/README.md @@ -35,14 +35,32 @@ If the host runs with `--mgmt-token`, set it under **Settings → API token** (s ```sh bun run build # → .output/ (Nitro server, `bun` preset, + .output/public assets) -PORT=3000 HOST=0.0.0.0 bun run start # = bun run .output/server/index.mjs +PORT=3000 HOST=0.0.0.0 \ + PUNKTFUNK_UI_PASSWORD=… PUNKTFUNK_MGMT_TOKEN=… \ + bun run start # = bun run .output/server/index.mjs bun run lint # tsc --noEmit ``` -The built **Nitro Bun server** SSR-renders the app and proxies `/api/**` to the management -host (a Nitro `routeRules` proxy → `PUNKTFUNK_MGMT_URL`, default `127.0.0.1:47990`), so the -browser stays same-origin (bearer token rides along, no CORS). Run it on the same box as -the host; it serves the console on `:3000` (or `$PORT`). +The built **Nitro Bun server** SSR-renders the app and is the only thing exposed on the LAN. +Run it on the same box as the host; it serves the console on `:3000` (or `$PORT`). + +## Auth (backend-for-frontend) + +Single-user, login-gated. Config via env (see `.env.example`): + +- The console requires a **login** (`PUNKTFUNK_UI_PASSWORD`). On success the server sets a + **sealed session cookie** (h3 `useSession`, AES-GCM). `server/middleware/auth.ts` gates + *every* request — pages redirect to `/login`, `/api` returns 401 — and **fails closed** + (503) if `PUNKTFUNK_UI_PASSWORD` is unset, so a misconfigured LAN server admits no one. +- The **management API stays loopback-only + token** — never LAN-exposed. The web server + holds `PUNKTFUNK_MGMT_TOKEN` server-side and injects it when proxying `/api/**` → + `PUNKTFUNK_MGMT_URL` (`server/routes/api/[...].ts`). **The token never reaches the + browser**; the browser only ever holds the session cookie. + +So: `browser ──password──▶ web server (session cookie) ──mgmt token, server-side──▶ mgmt API`. +Run the host with a matching token: `cargo run -rp punktfunk-host -- serve` + +`PUNKTFUNK_MGMT_TOKEN=…` (or `--mgmt-token …`). `vite dev` has no gate (localhost-only) and +proxies straight to the loopback mgmt API. > Toolchain notes (load-bearing): TanStack Start's `start-plugin-core` peer-requires > **Vite ≥ 7** — on Vite 6 the build's prerender/post-build hook silently doesn't run. diff --git a/web/messages/de.json b/web/messages/de.json index ccf82cb..d158ff2 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -54,5 +54,12 @@ "common_retry": "Erneut versuchen", "common_yes": "Ja", "common_cancel": "Abbrechen", - "common_unauthorized": "Nicht autorisiert — gültiges API-Token in den Einstellungen setzen." + "common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…", + "login_title": "Anmelden", + "login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren.", + "login_password": "Passwort", + "login_submit": "Anmelden", + "login_error": "Falsches Passwort.", + "login_signing_in": "Anmeldung läuft…", + "action_logout": "Abmelden" } diff --git a/web/messages/en.json b/web/messages/en.json index 6b99cc0..76b2199 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -54,5 +54,12 @@ "common_retry": "Retry", "common_yes": "Yes", "common_cancel": "Cancel", - "common_unauthorized": "Unauthorized — set a valid API token in Settings." + "common_unauthorized": "Session expired — redirecting to sign in…", + "login_title": "Sign in", + "login_subtitle": "Enter the management password to continue.", + "login_password": "Password", + "login_submit": "Sign in", + "login_error": "Wrong password.", + "login_signing_in": "Signing in…", + "action_logout": "Sign out" } diff --git a/web/server/middleware/auth.ts b/web/server/middleware/auth.ts new file mode 100644 index 0000000..c6c2690 --- /dev/null +++ b/web/server/middleware/auth.ts @@ -0,0 +1,27 @@ +// The single server-side gate. Runs for EVERY request to the deployed Bun/Nitro server +// (pages, the /api proxy, everything) before routing. Unauthenticated requests are +// redirected to /login (page navigations) or rejected 401 (/api). Fails CLOSED if +// PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one. +import { defineEventHandler, getRequestURL, sendRedirect, setResponseStatus, useSession } from 'h3' +import { isPublicPath, sessionConfig, uiPassword, type SessionData } from '../util/auth' + +export default defineEventHandler(async (event) => { + const { pathname } = getRequestURL(event) + if (isPublicPath(pathname)) return + + // Misconfigured: refuse everything rather than serve open on the LAN. + if (!uiPassword()) { + setResponseStatus(event, 503) + return { error: 'auth not configured: set PUNKTFUNK_UI_PASSWORD' } + } + + const session = await useSession(event, sessionConfig()) + if (session.data.authenticated) return // authenticated — let it through + + if (pathname.startsWith('/api')) { + setResponseStatus(event, 401) + return { error: 'unauthorized' } + } + // Page navigation → bounce to the login screen, remembering where they were headed. + return sendRedirect(event, `/login?next=${encodeURIComponent(pathname)}`, 302) +}) diff --git a/web/server/routes/_auth/login.post.ts b/web/server/routes/_auth/login.post.ts new file mode 100644 index 0000000..0767345 --- /dev/null +++ b/web/server/routes/_auth/login.post.ts @@ -0,0 +1,20 @@ +// POST /_auth/login {password} — verify the shared password (constant-time), then seal an +// authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user +// can actually log in. +import { defineEventHandler, readBody, createError, useSession } from 'h3' +import { sessionConfig, timingSafeEqual, uiPassword, type SessionData } from '../../util/auth' + +export default defineEventHandler(async (event) => { + const expected = uiPassword() + if (!expected) { + throw createError({ statusCode: 503, statusMessage: 'auth not configured' }) + } + const body = await readBody<{ password?: string }>(event) + const password = String(body?.password ?? '') + if (!timingSafeEqual(password, expected)) { + throw createError({ statusCode: 401, statusMessage: 'invalid password' }) + } + const session = await useSession(event, sessionConfig()) + await session.update({ authenticated: true }) + return { ok: true } +}) diff --git a/web/server/routes/_auth/logout.post.ts b/web/server/routes/_auth/logout.post.ts new file mode 100644 index 0000000..42cbb7c --- /dev/null +++ b/web/server/routes/_auth/logout.post.ts @@ -0,0 +1,9 @@ +// POST /_auth/logout — clear the session cookie. +import { defineEventHandler, useSession } from 'h3' +import { sessionConfig, type SessionData } from '../../util/auth' + +export default defineEventHandler(async (event) => { + const session = await useSession(event, sessionConfig()) + await session.clear() + return { ok: true } +}) diff --git a/web/server/routes/api/[...].ts b/web/server/routes/api/[...].ts new file mode 100644 index 0000000..95e6544 --- /dev/null +++ b/web/server/routes/api/[...].ts @@ -0,0 +1,21 @@ +// /api/** → the management API. By the time we get here the gate (middleware/auth.ts) has +// confirmed an authenticated session. We inject the management bearer token server-side +// (the browser never sees it) and drop the browser's own cookies/auth from the upstream +// request, then proxy. The management API itself binds loopback only — this proxy is the +// ONLY path to it from the LAN, and it's authenticated. +import { defineEventHandler, getRequestURL, proxyRequest } from 'h3' +import { mgmtToken, mgmtUrl } from '../../util/auth' + +export default defineEventHandler((event) => { + const { pathname, search } = getRequestURL(event) + const target = `${mgmtUrl()}${pathname}${search}` + const token = mgmtToken() + return proxyRequest(event, target, { + headers: { + // Overwrite, not append: the host-held token replaces anything the browser sent. + authorization: token ? `Bearer ${token}` : '', + // Don't forward the session cookie to the management API. + cookie: '', + }, + }) +}) diff --git a/web/server/util/auth.ts b/web/server/util/auth.ts new file mode 100644 index 0000000..9f32b03 --- /dev/null +++ b/web/server/util/auth.ts @@ -0,0 +1,62 @@ +// Shared auth helpers for the Nitro server (the deployed Bun server). Single-user, +// shared-password gate: the user logs in with PUNKTFUNK_UI_PASSWORD, which sets a SEALED +// (h3 useSession — AES-GCM) cookie; every request is gated by server/middleware/auth.ts. +// +// The management token never reaches the browser: server/routes/api/[...].ts injects it +// server-side when proxying to the loopback management API. +import { createHash, timingSafeEqual as nodeTimingSafeEqual } from 'node:crypto' +import type { SessionConfig } from 'h3' + +export const SESSION_NAME = 'pf_session' + +/** The login password. Empty string ⇒ auth is MISCONFIGURED (the gate fails closed). */ +export function uiPassword(): string { + return process.env.PUNKTFUNK_UI_PASSWORD ?? '' +} + +/** The management API the proxy forwards to (loopback by default — never LAN-exposed). */ +export function mgmtUrl(): string { + return process.env.PUNKTFUNK_MGMT_URL ?? 'http://127.0.0.1:47990' +} + +/** Bearer token for the management API, injected server-side. */ +export function mgmtToken(): string { + return process.env.PUNKTFUNK_MGMT_TOKEN ?? '' +} + +/** + * The cookie-sealing key for h3 `useSession` (must be ≥ 32 chars). Use PUNKTFUNK_UI_SECRET + * if set; otherwise derive a stable 64-hex key from the password so single-var config works + * (changing the password then invalidates existing sessions, which is fine). + */ +export function sessionConfig(): SessionConfig { + const secret = process.env.PUNKTFUNK_UI_SECRET + const password = secret && secret.length >= 32 + ? secret + : createHash('sha256').update(`punktfunk-session-v1:${uiPassword()}`).digest('hex') + return { name: SESSION_NAME, password } +} + +/** Constant-time string comparison (avoids leaking the password via timing). */ +export function timingSafeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a) + const bb = Buffer.from(b) + if (ab.length !== bb.length) return false + return nodeTimingSafeEqual(ab, bb) +} + +/** Paths reachable WITHOUT a session: the login page, the auth endpoints, and static + * assets (the login page needs its own CSS/JS). Everything else is gated. */ +export function isPublicPath(pathname: string): boolean { + if (pathname === '/login') return true + if (pathname.startsWith('/_auth/')) return true + if (pathname.startsWith('/assets/')) return true + if (pathname === '/favicon.ico' || pathname === '/robots.txt') return true + // Vite/TanStack client chunks and source maps requested by the login page. + if (/\.(js|css|map|ico|svg|png|woff2?|json)$/.test(pathname)) return true + return false +} + +export interface SessionData { + authenticated?: boolean +} diff --git a/web/src/api/fetcher.ts b/web/src/api/fetcher.ts index acc38e8..2e223e0 100644 --- a/web/src/api/fetcher.ts +++ b/web/src/api/fetcher.ts @@ -1,24 +1,11 @@ // The fetch mutator orval-generated hooks call: `apiFetch(url, RequestInit)`. orval is // configured (includeHttpResponseReturnType: false) so `T` is the response BODY; on an HTTP -// error we THROW an `ApiError` so React Query's `isError` works (the query client is -// configured not to retry 4xx — see src/router.tsx). +// error we THROW an `ApiError` so React Query's `isError` works (the query client skips +// retries on 4xx — see src/router.tsx). // -// Centralizes the bearer token (from Settings → localStorage). In dev, requests use a -// relative `/api/...` path that Vite proxies to the management host (same-origin, no CORS, -// the token rides along); a production build served by the host hits the same path. - -const TOKEN_KEY = 'punktfunk.apiToken' - -export function getApiToken(): string { - if (typeof localStorage === 'undefined') return '' - return localStorage.getItem(TOKEN_KEY) ?? '' -} - -export function setApiToken(token: string): void { - if (typeof localStorage === 'undefined') return - if (token) localStorage.setItem(TOKEN_KEY, token) - else localStorage.removeItem(TOKEN_KEY) -} +// Auth: requests are same-origin to `/api/...`; the browser sends only the session cookie +// (the server-side proxy injects the management bearer token — the token never lives in the +// browser). A 401 means the session is gone → bounce to /login. /** A failed API call. `status` is the HTTP code; `data` is the parsed `ApiError` body if any. */ export class ApiError extends Error { @@ -33,19 +20,26 @@ export class ApiError extends Error { } export async function apiFetch(url: string, options?: RequestInit): Promise { - const token = getApiToken() const headers = new Headers(options?.headers) headers.set('Accept', 'application/json') - if (token) headers.set('Authorization', `Bearer ${token}`) - const res = await fetch(url, { ...options, headers }) + const res = await fetch(url, { ...options, headers, credentials: 'same-origin' }) const text = await res.text() const body = text ? safeJson(text) : undefined + if (res.status === 401) redirectToLogin() if (!res.ok) throw new ApiError(res.status, body, res.statusText) return body as T } +/** On lost session, send the user to the login screen, remembering where they were. */ +function redirectToLogin(): void { + if (typeof window === 'undefined') return + if (window.location.pathname === '/login') return + const next = encodeURIComponent(window.location.pathname) + window.location.href = `/login?next=${next}` +} + function safeJson(text: string): unknown { try { return JSON.parse(text) diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index e14395a..c4a7c43 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -4,6 +4,7 @@ import { HeadContent, Outlet, Scripts, + useRouterState, } from '@tanstack/react-router' import type { QueryClient } from '@tanstack/react-query' import { AppShell } from '@/components/app-shell' @@ -27,15 +28,21 @@ export const Route = createRootRouteWithContext()({ }) function RootComponent() { + // The login screen renders bare (no sidebar); everything else gets the app shell. + const isLogin = useRouterState({ select: (s) => s.location.pathname === '/login' }) return ( - + {isLogin ? ( - + ) : ( + + + + )} diff --git a/web/src/routes/login.tsx b/web/src/routes/login.tsx new file mode 100644 index 0000000..f4d4fd2 --- /dev/null +++ b/web/src/routes/login.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react' +import { createFileRoute, useRouter } from '@tanstack/react-router' +import { Radio } from 'lucide-react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { m } from '@/paraglide/messages' +import { useLocale } from '@/lib/i18n' + +export const Route = createFileRoute('/login')({ + validateSearch: (s: Record): { next?: string } => ({ + next: typeof s.next === 'string' ? s.next : undefined, + }), + component: LoginPage, +}) + +function LoginPage() { + useLocale() + const router = useRouter() + const { next } = Route.useSearch() + const [password, setPassword] = useState('') + const [error, setError] = useState(false) + const [busy, setBusy] = useState(false) + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setBusy(true) + setError(false) + try { + const res = await fetch('/_auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }) + if (!res.ok) { + setError(true) + setBusy(false) + return + } + // Full reload to the target so SSR re-runs WITH the new session cookie. + window.location.href = next && next.startsWith('/') ? next : '/' + } catch { + setError(true) + setBusy(false) + } + void router + } + + return ( +
+ + +
+ + {m.app_name()} +
+ {m.login_title()} +

{m.login_subtitle()}

+
+ +
+
+ + setPassword(e.target.value)} + /> +
+ {error &&

{m.login_error()}

} + +
+
+
+
+ ) +} diff --git a/web/src/routes/settings.tsx b/web/src/routes/settings.tsx index 3aea6b0..0ed505d 100644 --- a/web/src/routes/settings.tsx +++ b/web/src/routes/settings.tsx @@ -1,63 +1,24 @@ -import { useState } from 'react' import { createFileRoute } from '@tanstack/react-router' -import { useQueryClient } from '@tanstack/react-query' -import { Check } from 'lucide-react' +import { LogOut } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { getApiToken, setApiToken } from '@/api/fetcher' import { m } from '@/paraglide/messages' import { useLocale, changeLocale, locales, type Locale } from '@/lib/i18n' -import { cn } from '@/lib/utils' export const Route = createFileRoute('/settings')({ component: SettingsPage }) function SettingsPage() { const current = useLocale() - const qc = useQueryClient() - const [token, setToken] = useState(getApiToken()) - const [saved, setSaved] = useState(false) - const onSave = (e: React.FormEvent) => { - e.preventDefault() - setApiToken(token.trim()) - // Re-fetch everything with the new credential. - qc.invalidateQueries() - setSaved(true) - setTimeout(() => setSaved(false), 2_000) + const onLogout = async () => { + await fetch('/_auth/logout', { method: 'POST' }) + window.location.href = '/login' } return (

{m.settings_title()}

- - - {m.settings_token_label()} - - -
-
- - setToken(e.target.value)} - placeholder="••••••••" - /> -

{m.settings_token_help()}

-
- -
-
-
- {m.settings_language()} @@ -68,7 +29,7 @@ function SettingsPage() { key={l} variant={l === current ? 'default' : 'outline'} size="sm" - className={cn('uppercase')} + className="uppercase" onClick={() => changeLocale(l)} > {l} @@ -76,6 +37,18 @@ function SettingsPage() { ))} + + + + {m.nav_settings()} + + + + +
) } diff --git a/web/tsconfig.json b/web/tsconfig.json index 992c484..0682137 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -20,5 +20,5 @@ "@/*": ["./src/*"] } }, - "include": ["src", "vite.config.ts", "orval.config.ts"] + "include": ["src", "server", "vite.config.ts", "orval.config.ts"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index e85ba2d..24e13d5 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import { nitroV2Plugin } from '@tanstack/nitro-v2-vite-plugin' @@ -6,6 +7,10 @@ import viteTsConfigPaths from 'vite-tsconfig-paths' import tailwindcss from '@tailwindcss/vite' import { paraglideVitePlugin } from '@inlang/paraglide-js' +// Absolute path to our Nitro server source (middleware + routes). Passed as a scanDir +// because the TanStack Nitro plugin doesn't auto-scan a server/ dir. +const serverDir = fileURLToPath(new URL('./server', import.meta.url)) + // The management API the console drives. The browser always talks same-origin (/api/...): // in `vite dev` the dev server proxies it (below); in the built Bun/Nitro server a Nitro // route-rule proxies it (below). Override the upstream with PUNKTFUNK_MGMT_URL. @@ -30,15 +35,15 @@ export default defineConfig({ // renders a data-free shell that hydrates in the browser). tanstackStart(), // Nitro v2 is the deployment target: the `bun` preset bundles a Bun-runnable server to - // .output/ (`bun run .output/server/index.mjs`). The route-rule keeps the browser - // same-origin by proxying /api/** to the management host, so the bearer token and - // cookies ride along with no CORS. + // .output/ (`bun run .output/server/index.mjs`). Auth + the /api proxy live in the + // scanned `server/` dir (middleware/auth.ts gates every request; routes/api/[...].ts + // proxies to the management host injecting the bearer token server-side) — NOT a static + // routeRule, so the proxy runs behind the login gate and reads env at runtime. nitroV2Plugin({ preset: 'bun', compatibilityDate: '2026-06-10', - routeRules: { - '/api/**': { proxy: `${MGMT_URL}/api/**` }, - }, + // Scan server/{middleware,routes} for the auth gate + the /api proxy. + scanDirs: [serverDir], }), // Must come AFTER tanstackStart — provides the React JSX transform + Refresh runtime // that Start's dev mode requires (omitting it leaves the client JS unable to load).