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 });
});
});