From 71d1730284e67587e78ebfc590bb08f7f1211017 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 10 Jul 2026 04:50:25 +0200 Subject: [PATCH] Login feature flag added. --- Decisions.md | 20 ++++++++++++++++++++ Dockerfile | 4 ++++ src/App.tsx | 4 +++- src/components/AuthForms.tsx | 6 +++++- src/index.ts | 4 ++++ src/lib/authClient.ts | 33 ++++++++++++++++++++++++--------- src/lib/useAuth.ts | 22 ++++++++++++++++------ tests/auth-client.test.ts | 24 +++++++++++++++--------- 8 files changed, 91 insertions(+), 26 deletions(-) diff --git a/Decisions.md b/Decisions.md index e25351c..174f9ea 100644 --- a/Decisions.md +++ b/Decisions.md @@ -137,3 +137,23 @@ native `MatMulNBits` ops (no QDQ), so skipping level-2 costs the larger WASM models nothing. **Revisit:** when Transformers.js bumps its onnxruntime-web dependency past 1.26.0-dev.20260416, retest q8-on-WASM at the default level and drop this. + +## D-015 — Runtime feature flags via server env vars; first flag gates auth +**Status:** Accepted (2026-07-10) +Feature flags are read from the **server's** process environment at startup +(`src/server/flags.ts`, `envFlag()`: unset/empty → the flag's default; +`false`/`0`/`no`/`off` any case → off; anything else → on). Runtime-only by +design: one Docker image serves every configuration — flags are flipped in +docker-compose.yml / `docker run -e`, never at build time, so the client +bundle stays identical across deployments. +**First flag:** `AUTH_ENABLED` (default **on**) gates the M4 login/register +feature. Off, the server 404s every `/api/auth/*` route. The client is never +told separately: `fetchAuthStatus` (the `GET /api/auth/me` probe `useAuth` +already makes on mount) reads 404 as "feature off" and `` renders +nothing. The Sign in button therefore only appears after the probe confirms +the endpoints exist — a disabled deployment never flashes auth UI, and an +offline/unreachable server also hides it (signing in would be impossible +anyway), preserving the anonymous-first invariant (no error surfaced). +**Consequence:** any future flag that must reach the browser needs a +server-derived signal (an endpoint the client can probe, like /me here) — +`BUN_PUBLIC_*` build-time inlining is not used for feature flags. diff --git a/Dockerfile b/Dockerfile index 5719342..5b8a924 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,10 @@ FROM oven/bun:1 AS runtime WORKDIR /app ENV NODE_ENV=production ENV DB_PATH=/app/data/app.db +# Runtime feature flags (D-015) — override at `docker run -e` / compose time. +# AUTH_ENABLED=false disables login/register (the auth API 404s and the +# client hides the Sign in UI). Defaults to enabled. +ENV AUTH_ENABLED=true COPY package.json bun.lock bunfig.toml ./ RUN bun install --frozen-lockfile --production diff --git a/src/App.tsx b/src/App.tsx index 495c1b2..5fd6131 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -215,7 +215,9 @@ export function App() {

{/* Session UI (M4). Anonymous-first: logged out, this is only an - unobtrusive "Sign in" button — everything else is unchanged. */} + unobtrusive "Sign in" button — everything else is unchanged. + Renders nothing until the /me probe confirms the server has auth + enabled (runtime flag, D-015). */} diff --git a/src/components/AuthForms.tsx b/src/components/AuthForms.tsx index e7a50aa..07f9bab 100644 --- a/src/components/AuthForms.tsx +++ b/src/components/AuthForms.tsx @@ -14,7 +14,7 @@ type Mode = "login" | "register"; * stays authoritative: its `{ error }` messages are shown verbatim on 4xx. */ export function AuthForms() { - const { user, login, register, logout } = useAuth(); + const { enabled, user, login, register, logout } = useAuth(); const [open, setOpen] = useState(false); const [mode, setMode] = useState("login"); @@ -86,6 +86,10 @@ export function AuthForms() { if (event.key === "Escape") setOpen(false); } + // Runtime feature flag (D-015): until the /me probe confirms the server + // exposes /api/auth/*, this deployment has no login — render nothing. + if (!enabled) return null; + if (user) { return (
diff --git a/src/index.ts b/src/index.ts index dc4c8f3..2f8ded8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { join, normalize, sep } from "node:path"; import { ensureIcons, ICONS_DIR } from "../scripts/generate-icons"; import { injectPrecacheManifest } from "./pwa/precache"; import { auth } from "./server/auth"; +import { AUTH_ENABLED } from "./server/flags"; const ROOT = join(import.meta.dir, ".."); const IS_PRODUCTION = process.env.NODE_ENV === "production"; @@ -56,6 +57,9 @@ async function serveOrtFile(dir: string, name: string): Promise { async function handleApiRequest(request: Request): Promise { const { pathname } = new URL(request.url); + // Runtime feature flag (D-015): with auth disabled, the endpoints don't + // exist — the client's /me probe reads the 404 as "feature off". + if (!AUTH_ENABLED && pathname.startsWith("/api/auth/")) return NOT_FOUND(); switch (`${request.method} ${pathname}`) { case "POST /api/auth/register": return auth.register(request); diff --git a/src/lib/authClient.ts b/src/lib/authClient.ts index 1c3509f..49276ad 100644 --- a/src/lib/authClient.ts +++ b/src/lib/authClient.ts @@ -134,26 +134,41 @@ export function register( return requestUser(fetchFn, "/api/auth/register", { username, password }); } +/** Result of the startup /me probe (fetchAuthStatus). */ +export interface AuthStatus { + /** + * Whether this deployment offers login at all. False when the server 404s + * /api/auth/me (runtime feature flag AUTH_ENABLED=false, D-015) — and also + * while the server is unreachable, since signing in is impossible then + * anyway. The auth UI renders nothing until this is true. + */ + enabled: boolean; + /** The signed-in user, or null when anonymous. */ + user: AuthUser | null; +} + /** - * GET /api/auth/me → the current user, or null when anonymous. NEVER rejects: + * GET /api/auth/me → auth availability + the current user. NEVER rejects: * the app is offline-first and /api/* is network-only in the service worker, - * so 401s, network failures, and malformed bodies all resolve to anonymous - * without surfacing an error (Architecture.md §5 anonymous-first invariant). + * so 401s and malformed bodies resolve to anonymous, and 404 (feature flag + * off, D-015) and network failure resolve to `enabled: false` — none of them + * surface an error (Architecture.md §5 anonymous-first invariant). */ -export async function fetchCurrentUser( +export async function fetchAuthStatus( fetchFn: FetchLike = defaultFetch, -): Promise { +): Promise { try { const response = await fetchFn("/api/auth/me", { method: "GET", credentials: "same-origin", }); - if (!response.ok) return null; - const payload: unknown = await response.json(); + if (response.status === 404) return { enabled: false, user: null }; + if (!response.ok) return { enabled: true, user: null }; + const payload: unknown = await response.json().catch(() => null); const user = (payload as { user?: unknown } | null)?.user; - return isAuthUser(user) ? user : null; + return { enabled: true, user: isAuthUser(user) ? user : null }; } catch { - return null; + return { enabled: false, user: null }; } } diff --git a/src/lib/useAuth.ts b/src/lib/useAuth.ts index a3672ac..dd9b323 100644 --- a/src/lib/useAuth.ts +++ b/src/lib/useAuth.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { - fetchCurrentUser, + fetchAuthStatus, login as loginRequest, logout as logoutRequest, register as registerRequest, @@ -8,6 +8,12 @@ import { } from "./authClient"; export interface AuthState { + /** + * Whether this deployment offers login at all (runtime flag, D-015). + * Starts false — the auth UI stays hidden until the /me probe confirms the + * endpoints exist, so a disabled deployment never flashes a Sign in button. + */ + enabled: boolean; /** The signed-in user, or null while anonymous (the default). */ user: AuthUser | null; /** Rejects with a UI-safe Error message on failure (e.g. 401). */ @@ -21,18 +27,22 @@ export interface AuthState { /** * Session state for the M4 auth UI. The session cookie is HttpOnly, so the * only source of truth is GET /api/auth/me, probed once on mount. - * `fetchCurrentUser` never rejects — 401 and network failure (offline, server - * down) both resolve to anonymous, keeping the anonymous-first invariant: + * `fetchAuthStatus` never rejects — 401 resolves to anonymous, and 404 + * (AUTH_ENABLED=false, D-015) or network failure (offline, server down) + * resolve to `enabled: false`, keeping the anonymous-first invariant: * a failing /me must not surface any error. */ export function useAuth(): AuthState { + const [enabled, setEnabled] = useState(false); const [user, setUser] = useState(null); useEffect(() => { let cancelled = false; - fetchCurrentUser().then((current) => { + fetchAuthStatus().then((status) => { // Guard against StrictMode double-mounts / fast unmounts. - if (!cancelled && current) setUser(current); + if (cancelled) return; + setEnabled(status.enabled); + if (status.user) setUser(status.user); }); return () => { cancelled = true; @@ -53,5 +63,5 @@ export function useAuth(): AuthState { setUser(null); }, []); - return { user, login, register, logout }; + return { enabled, user, login, register, logout }; } diff --git a/tests/auth-client.test.ts b/tests/auth-client.test.ts index 8074f19..76bd902 100644 --- a/tests/auth-client.test.ts +++ b/tests/auth-client.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "bun:test"; import { - fetchCurrentUser, + fetchAuthStatus, login, logout, register, @@ -64,32 +64,38 @@ describe("validatePassword", () => { }); }); -describe("fetchCurrentUser (fail-silent /me)", () => { +describe("fetchAuthStatus (fail-silent /me)", () => { test("returns the user on 200", async () => { const { fetch, calls } = mockFetch(jsonResponse(200, { user: USER })); - expect(await fetchCurrentUser(fetch)).toEqual(USER); + expect(await fetchAuthStatus(fetch)).toEqual({ enabled: true, user: USER }); // The cookie is HttpOnly — it must ride along via same-origin credentials. expect(calls[0]?.input).toBe("/api/auth/me"); expect(calls[0]?.init?.credentials).toBe("same-origin"); }); - test("resolves to anonymous (null) on 401", async () => { + test("resolves to anonymous (null user) on 401", async () => { const { fetch } = mockFetch(jsonResponse(401, { error: "unauthorized" })); - expect(await fetchCurrentUser(fetch)).toBeNull(); + expect(await fetchAuthStatus(fetch)).toEqual({ enabled: true, user: null }); }); - test("resolves to anonymous on network failure (offline-first)", async () => { + test("resolves to disabled on 404 (AUTH_ENABLED=false, D-015)", async () => { + const { fetch } = mockFetch(jsonResponse(404, { error: "Not Found" })); + expect(await fetchAuthStatus(fetch)).toEqual({ enabled: false, user: null }); + }); + + test("resolves to disabled on network failure (offline-first)", async () => { // /api/* is network-only in the service worker, so offline /me rejects. - // That must NOT throw or surface an error — just stay anonymous. + // That must NOT throw or surface an error — the auth UI just stays + // hidden (signing in is impossible without the server anyway). const { fetch } = mockFetch(new TypeError("Failed to fetch")); - expect(await fetchCurrentUser(fetch)).toBeNull(); + expect(await fetchAuthStatus(fetch)).toEqual({ enabled: false, user: null }); }); test("resolves to anonymous on a malformed body", async () => { const { fetch } = mockFetch( new Response("proxy error", { status: 200 }), ); - expect(await fetchCurrentUser(fetch)).toBeNull(); + expect(await fetchAuthStatus(fetch)).toEqual({ enabled: true, user: null }); }); });