feat: add PRIVATE_MODE site-wide auth gate (0.48.0)
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>
This commit is contained in:
101
frontend/e2e/private-mode.spec.ts
Normal file
101
frontend/e2e/private-mode.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// Network-level mocks for the private-mode UX. The backend integration
|
||||
// tests (api_private_mode.rs) cover the actual gate; here we only
|
||||
// verify that the SvelteKit universal load redirects anonymous
|
||||
// visitors to /login and then back to where they were going.
|
||||
|
||||
const userFixture = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
is_admin: false
|
||||
};
|
||||
const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } };
|
||||
|
||||
async function stubPrivateInstance(page: Page) {
|
||||
let loggedIn = false;
|
||||
|
||||
// The flag that flips the gate on. Frontend reads it in
|
||||
// `+layout.ts` to decide whether to redirect.
|
||||
await page.route('**/api/v1/auth/config', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
self_register_enabled: false,
|
||||
private_mode: true
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/auth/me', async (route) => {
|
||||
if (loggedIn) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: userFixture })
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: { code: 'unauthenticated', message: 'unauthenticated' }
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/auth/login', async (route) => {
|
||||
loggedIn = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: userFixture })
|
||||
});
|
||||
});
|
||||
|
||||
// The real backend would 401 these too in private mode; we stub
|
||||
// success so the post-login navigation can render the home page
|
||||
// without an additional redirect cycle.
|
||||
await page.route('**/api/v1/mangas*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(emptyPage)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('private mode: anonymous visit to / redirects to /login?next=%2F', async ({ page }) => {
|
||||
await stubPrivateInstance(page);
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/login\?next=%2F$/);
|
||||
await expect(page.getByTestId('login-username')).toBeVisible();
|
||||
});
|
||||
|
||||
test('private mode: register link is hidden', async ({ page }) => {
|
||||
await stubPrivateInstance(page);
|
||||
await page.goto('/login');
|
||||
await expect(page.getByTestId('nav-login')).toBeVisible();
|
||||
// self_register_enabled is the effective value (false in private
|
||||
// mode regardless of ALLOW_SELF_REGISTER), so the navbar must
|
||||
// never render the register affordance here.
|
||||
await expect(page.getByTestId('nav-register')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('private mode: after login the user lands back on the requested page', async ({ page }) => {
|
||||
await stubPrivateInstance(page);
|
||||
|
||||
// Visit a deep link → bounced to /login with next= preserving it.
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/login\?next=%2F$/);
|
||||
|
||||
await page.getByTestId('login-username').fill('alice');
|
||||
await page.getByTestId('login-password').fill('hunter2hunter2');
|
||||
await page.getByTestId('login-submit').click();
|
||||
|
||||
// Authenticated → can now reach the home page without bouncing.
|
||||
await expect(page.getByTestId('session-user')).toContainText('alice');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.47.0",
|
||||
"version": "0.48.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -102,10 +102,14 @@ export async function deleteToken(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export type AuthConfig = {
|
||||
/** When false, /v1/auth/register returns 403 and the UI should
|
||||
/** Effective value (`allow_self_register && !private_mode`).
|
||||
* When false, /v1/auth/register returns 403 and the UI should
|
||||
* hide its register affordance. Admins can still mint accounts
|
||||
* via POST /v1/admin/users. */
|
||||
self_register_enabled: boolean;
|
||||
/** When true, every read endpoint requires auth and anonymous
|
||||
* visitors are redirected to `/login` (see `+layout.ts`). */
|
||||
private_mode: boolean;
|
||||
};
|
||||
|
||||
/** Public — no auth, no cookie required. */
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getAuthConfig } from './api/auth';
|
||||
|
||||
class AuthConfigStore {
|
||||
self_register_enabled = $state(true);
|
||||
private_mode = $state(false);
|
||||
loaded = $state(false);
|
||||
private loading = false;
|
||||
|
||||
@@ -25,6 +26,7 @@ class AuthConfigStore {
|
||||
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.
|
||||
@@ -32,6 +34,16 @@ class AuthConfigStore {
|
||||
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();
|
||||
|
||||
@@ -14,15 +14,23 @@
|
||||
import Shield from '@lucide/svelte/icons/shield';
|
||||
import '$lib/styles/tokens.css';
|
||||
|
||||
let { children } = $props();
|
||||
let { children, data } = $props();
|
||||
let loggingOut = $state(false);
|
||||
let headerEl: HTMLElement | undefined = $state();
|
||||
|
||||
// Seed authConfig from the universal layout load. $effect keeps
|
||||
// the store in sync if `data` is replaced by a subsequent layout
|
||||
// load (client-side nav). The first run also covers initial
|
||||
// hydration so the navbar's register link reflects the real
|
||||
// server flag without a separate fetch.
|
||||
$effect(() => {
|
||||
authConfig.seed(data.authConfig);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
theme.init();
|
||||
preferences.init();
|
||||
if (!session.loaded) session.refresh();
|
||||
if (!authConfig.loaded) authConfig.load();
|
||||
|
||||
// Publish the header's measured height as a CSS custom
|
||||
// property so sticky descendants (e.g. the reader nav) can
|
||||
|
||||
41
frontend/src/routes/+layout.ts
Normal file
41
frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Universal root load. Surfaces /auth/config to every page so the
|
||||
// navbar + layout can render without an extra round-trip, and — when
|
||||
// the backend reports PRIVATE_MODE=true — bounces anonymous visitors
|
||||
// to /login before any page-specific load fires. The backend
|
||||
// middleware is still the source of truth for the gate; this just
|
||||
// matches the UX so users don't see a page full of failed fetches.
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getAuthConfig, me, type AuthConfig } from '$lib/api/auth';
|
||||
|
||||
// Paths reachable anonymously even when private_mode is on. /login is
|
||||
// the entry point of the auth flow; everything else (including
|
||||
// /register, which is force-blocked in private mode) bounces.
|
||||
const PRIVATE_MODE_BYPASS = new Set(['/login']);
|
||||
|
||||
const PUBLIC_DEFAULTS: AuthConfig = {
|
||||
self_register_enabled: true,
|
||||
private_mode: false
|
||||
};
|
||||
|
||||
export const load: LayoutLoad = async ({ url }) => {
|
||||
let authConfig: AuthConfig = PUBLIC_DEFAULTS;
|
||||
try {
|
||||
authConfig = await getAuthConfig();
|
||||
} catch {
|
||||
// Fail-soft: keep the optimistic public-mode defaults so a
|
||||
// backend hiccup doesn't lock anyone out of the login page.
|
||||
// No private data can leak through here — the backend
|
||||
// middleware is still authoritative for the gate.
|
||||
}
|
||||
|
||||
if (authConfig.private_mode && !PRIVATE_MODE_BYPASS.has(url.pathname)) {
|
||||
const user = await me().catch(() => null);
|
||||
if (!user) {
|
||||
const next = url.pathname + url.search;
|
||||
redirect(302, `/login?next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { authConfig };
|
||||
};
|
||||
113
frontend/src/routes/layout.test.ts
Normal file
113
frontend/src/routes/layout.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the API client *before* importing the load function so the
|
||||
// module under test picks up the mock when it resolves its imports.
|
||||
vi.mock('$lib/api/auth', () => ({
|
||||
getAuthConfig: vi.fn(),
|
||||
me: vi.fn()
|
||||
}));
|
||||
|
||||
import { load } from './+layout';
|
||||
import { getAuthConfig, me, type AuthConfig } from '$lib/api/auth';
|
||||
|
||||
type MinimalLoadEvent = { url: { pathname: string; search: string } };
|
||||
|
||||
function event(pathname: string, search = ''): MinimalLoadEvent {
|
||||
return { url: { pathname, search } };
|
||||
}
|
||||
|
||||
// `LayoutLoad`'s declared return type is `void | …`. Our `load`
|
||||
// always returns `{ authConfig }`, but TypeScript can't narrow on
|
||||
// that at the call site. Wrap to remove the `void` arm so the
|
||||
// assertions stay terse.
|
||||
async function callLoad(ev: MinimalLoadEvent): Promise<{ authConfig: AuthConfig }> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await load(ev as any);
|
||||
return result as { authConfig: AuthConfig };
|
||||
}
|
||||
|
||||
const PUBLIC_CFG = { self_register_enabled: true, private_mode: false };
|
||||
const PRIVATE_CFG = { self_register_enabled: false, private_mode: true };
|
||||
|
||||
const aliceUser = {
|
||||
id: 'u1',
|
||||
username: 'alice',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
is_admin: false
|
||||
};
|
||||
|
||||
describe('root +layout load', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getAuthConfig).mockReset();
|
||||
vi.mocked(me).mockReset();
|
||||
});
|
||||
|
||||
it('public mode: returns authConfig data, never calls me()', async () => {
|
||||
vi.mocked(getAuthConfig).mockResolvedValue(PUBLIC_CFG);
|
||||
const data = await callLoad(event('/'));
|
||||
expect(data.authConfig).toEqual(PUBLIC_CFG);
|
||||
expect(me).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('private mode + anonymous on `/`: throws redirect(302) to /login with next=', async () => {
|
||||
vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG);
|
||||
vi.mocked(me).mockResolvedValue(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await expect(load(event('/') as any)).rejects.toMatchObject({
|
||||
status: 302,
|
||||
location: '/login?next=%2F'
|
||||
});
|
||||
});
|
||||
|
||||
it('private mode + anonymous on `/login`: passes through without redirect', async () => {
|
||||
vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG);
|
||||
const data = await callLoad(event('/login'));
|
||||
expect(data.authConfig.private_mode).toBe(true);
|
||||
// me() must not run on the login page itself, otherwise anonymous
|
||||
// visits make an extra round-trip every page load.
|
||||
expect(me).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('private mode + logged-in user: no redirect, returns authConfig', async () => {
|
||||
vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG);
|
||||
vi.mocked(me).mockResolvedValue(aliceUser);
|
||||
const data = await callLoad(event('/'));
|
||||
expect(data.authConfig).toEqual(PRIVATE_CFG);
|
||||
});
|
||||
|
||||
it('private mode + anonymous: preserves pathname AND search in next=', async () => {
|
||||
vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG);
|
||||
vi.mocked(me).mockResolvedValue(null);
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
load(event('/manga/abc', '?page=3') as any)
|
||||
).rejects.toMatchObject({
|
||||
status: 302,
|
||||
location: '/login?next=%2Fmanga%2Fabc%3Fpage%3D3'
|
||||
});
|
||||
});
|
||||
|
||||
it('private mode + anonymous on /register: redirects to /login (register is never reachable in private mode)', async () => {
|
||||
vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG);
|
||||
vi.mocked(me).mockResolvedValue(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await expect(load(event('/register') as any)).rejects.toMatchObject({
|
||||
status: 302,
|
||||
location: '/login?next=%2Fregister'
|
||||
});
|
||||
});
|
||||
|
||||
it('getAuthConfig failure: falls back to public-mode defaults, no redirect', async () => {
|
||||
// The backend middleware is the source of truth for the gate;
|
||||
// if the config probe blips, fail soft so a brief outage doesn't
|
||||
// lock everyone out of even the login page. No private data
|
||||
// can leak because the backend still 401s every request.
|
||||
vi.mocked(getAuthConfig).mockRejectedValue(new Error('network'));
|
||||
const data = await callLoad(event('/'));
|
||||
expect(data.authConfig).toEqual({
|
||||
self_register_enabled: true,
|
||||
private_mode: false
|
||||
});
|
||||
expect(me).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user