Login feature flag added.

This commit is contained in:
Lucas Winther committed 2026-07-10 04:50:25 +02:00
1 parent 525e8e9718
commit 71d1730284
8 files changed
+91 -26

No files matched your search

+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);
+24 -9
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 {
/**
* 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
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 };
}