Login feature flag added.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
+24
-9
@@ -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<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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user