When `PRIVATE_MODE=true`, every API path except a small allowlist
(`/health`, `/auth/{config,login,logout,register}`) requires a valid
session cookie or bearer token — anonymous reads are rejected with
401. Self-registration is force-disabled in private mode regardless
of `ALLOW_SELF_REGISTER`, so a locked-down instance flips with a
single switch (admins still mint accounts via `POST /admin/users`).
The backend gate is a tower middleware that reuses the existing
`CurrentUser` extractor, so the cookie + bearer paths cannot drift
from per-handler auth. `/auth/config` now exposes the flag plus the
effective `self_register_enabled` value so the frontend can render
the navbar correctly on the first paint.
On the frontend, a new universal root `+layout.ts` fetches the
config and redirects anonymous visitors to `/login?next=<path>`
before page-specific loads fire. The redirect is UX only — the
backend middleware is the source of truth, so crafted requests
still 401.
Defaults stay public (`PRIVATE_MODE=false`); existing deployments
need no env change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
50 lines
1.9 KiB
TypeScript
50 lines
1.9 KiB
TypeScript
// Anonymous-relevant auth policy (currently just whether self-
|
|
// registration is enabled). Loaded once per browser session on root-
|
|
// layout mount, then read reactively from `authConfig.self_register_enabled`.
|
|
//
|
|
// Defaults to `self_register_enabled = true` while loading so the
|
|
// register link doesn't flash off-and-on for the default-open case.
|
|
// If the fetch fails (network blip, backend restart), the stale value
|
|
// is kept — there's no per-request retry. A new tab will retry on its
|
|
// own mount.
|
|
//
|
|
// Same browser-only contract as `session.svelte.ts` — see that file's
|
|
// SSR comment.
|
|
|
|
import { browser } from '$app/environment';
|
|
import { getAuthConfig } from './api/auth';
|
|
|
|
class AuthConfigStore {
|
|
self_register_enabled = $state(true);
|
|
private_mode = $state(false);
|
|
loaded = $state(false);
|
|
private loading = false;
|
|
|
|
async load(): Promise<void> {
|
|
if (this.loaded || this.loading || !browser) return;
|
|
this.loading = true;
|
|
try {
|
|
const cfg = await getAuthConfig();
|
|
this.self_register_enabled = cfg.self_register_enabled;
|
|
this.private_mode = cfg.private_mode;
|
|
this.loaded = true;
|
|
} catch {
|
|
// Keep optimistic default; next page mount will retry.
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
|
|
/** Seed from server-rendered layout data so the very first paint
|
|
* doesn't flash the loading state. Used by `+layout.ts` /
|
|
* `+layout.svelte` on the universal-load path. Safe to call from
|
|
* SSR (no `browser` guard) since it touches only reactive state. */
|
|
seed(cfg: { self_register_enabled: boolean; private_mode: boolean }): void {
|
|
this.self_register_enabled = cfg.self_register_enabled;
|
|
this.private_mode = cfg.private_mode;
|
|
this.loaded = true;
|
|
}
|
|
}
|
|
|
|
export const authConfig = new AuthConfigStore();
|