Login feature flag added.

This commit is contained in:
Lucas Winther
2026-07-10 04:50:25 +02:00
parent 525e8e9718
commit 71d1730284
8 changed files with 91 additions and 26 deletions
+20
View File
@@ -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 `<AuthForms />` 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.
+4
View File
@@ -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
+3 -1
View File
@@ -215,7 +215,9 @@ export function App() {
</p>
</div>
{/* 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). */}
<AuthForms />
</header>
+5 -1
View File
@@ -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<Mode>("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 (
<div className="auth">
+4
View File
@@ -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<Response> {
async function handleApiRequest(request: Request): Promise<Response> {
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);
+25 -10
View File
@@ -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 {
/**
* GET /api/auth/me → the current user, or null when anonymous. 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).
* 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.
*/
export async function fetchCurrentUser(
enabled: boolean;
/** The signed-in user, or null when anonymous. */
user: AuthUser | null;
}
/**
* 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 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 fetchAuthStatus(
fetchFn: FetchLike = defaultFetch,
): Promise<AuthUser | null> {
): Promise<AuthStatus> {
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 };
}
}
+16 -6
View File
@@ -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<AuthUser | null>(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 };
}
+15 -9
View File
@@ -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("<html>proxy error</html>", { status: 200 }),
);
expect(await fetchCurrentUser(fetch)).toBeNull();
expect(await fetchAuthStatus(fetch)).toEqual({ enabled: true, user: null });
});
});