diff --git a/.env.example b/.env.example
index c1733ba8..c2f2b40c 100644
--- a/.env.example
+++ b/.env.example
@@ -78,10 +78,27 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Admin Dashboard Data
# =============================================================================
-# Directory for admin dashboard state: config overrides, admin password hash,
-# installed plugins/themes, and audit logs (default: ./data/admin).
-# For Docker, the default resolves to /app/data/admin - mount a persistent
-# volume there (see docker-compose.yml).
+# Admin data is split across two directories so the config volume can be
+# mounted read-only after the setup wizard completes (see issue #226).
+#
+# Config dir - operator-authored state. Holds config.json, policy.json,
+# admin.json (passwordHash only), plugin-config/, plugins/, themes/, and
+# branding uploads. Safe to mount read-only after setup.
+# Default: ./data/admin (or ADMIN_DATA_DIR if that legacy variable is set)
+# ADMIN_CONFIG_DIR=./data/admin
+#
+# State dir - runtime mutations. Holds admin-state.json (login timestamps),
+# audit.log, and the bootstrap setup token. Always read-write.
+# Default: ./data/admin-state (or ADMIN_DATA_DIR/state when ADMIN_DATA_DIR
+# is set, for back-compat with single-volume installs)
+# ADMIN_STATE_DIR=./data/admin-state
+#
+# Set to "true" to enforce read-only mode at the application layer (cleaner
+# error than a mid-request EROFS). Pair with `:ro` on the config-volume mount.
+# ADMIN_CONFIG_READONLY=true
+#
+# Legacy: a single dir containing both config and state. Honoured if neither
+# of the split variables is set. New installs should use the split vars.
# ADMIN_DATA_DIR=./data/admin
# =============================================================================
diff --git a/Dockerfile b/Dockerfile
index 20084be2..8e3a3e68 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -34,7 +34,7 @@ RUN apk upgrade --no-cache && \
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
-RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data
+RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
ENV PORT=3000
diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx
index c1e1e752..9f57f3b3 100644
--- a/app/admin/login/page.tsx
+++ b/app/admin/login/page.tsx
@@ -47,13 +47,13 @@ export default function AdminLoginPage() {
-
- {logoUrl ? (
-
- ) : (
+ {logoUrl ? (
+
+ ) : (
+
- )}
-
+
+ )}
Admin Dashboard
Enter your admin password to continue
diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts
index 766e2110..dca051d1 100644
--- a/app/api/auth/sso/start/route.ts
+++ b/app/api/auth/sso/start/route.ts
@@ -7,14 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
-import { readFileEnv } from '@/lib/read-file-env';
+import { hasSessionSecret } from '@/lib/auth/session-secret';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
export async function POST(request: NextRequest) {
try {
- if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
+ if (!hasSessionSecret()) {
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
}
diff --git a/app/api/config/route.ts b/app/api/config/route.ts
index c6f9f4f0..2640c2be 100644
--- a/app/api/config/route.ts
+++ b/app/api/config/route.ts
@@ -1,8 +1,8 @@
import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
-import { readFileEnv } from '@/lib/read-file-env';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
+import { hasSessionSecret } from '@/lib/auth/session-secret';
/**
* Runtime configuration endpoint
@@ -35,8 +35,8 @@ export async function GET() {
oauthOnly,
oauthClientId: configManager.get
('oauthClientId', ''),
oauthIssuerUrl: configManager.get('oauthIssuerUrl', ''),
- rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
- settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
+ rememberMeEnabled: hasSessionSecret(),
+ settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
devMode: configManager.get('devMode', false),
faviconUrl: configManager.get('faviconUrl', '/branding/Bulwark_Favicon.svg'),
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts
index a6f6da6b..8d853a74 100644
--- a/app/api/settings/route.ts
+++ b/app/api/settings/route.ts
@@ -6,7 +6,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager';
-import { readFileEnv } from '@/lib/read-file-env';
+import { hasSessionSecret } from '@/lib/auth/session-secret';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function classifyError(error: unknown): { message: string; status: number } {
@@ -50,7 +50,10 @@ function classifyError(error: unknown): { message: string; status: number } {
}
function isEnabled(): boolean {
- return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
+ const flagOn =
+ process.env.SETTINGS_SYNC_ENABLED === 'true' ||
+ configManager.get('settingsSyncEnabled', false);
+ return flagOn && hasSessionSecret();
}
/** Strip trailing slashes so differently-formatted URLs still match. */
diff --git a/app/api/setup/finish/route.ts b/app/api/setup/finish/route.ts
new file mode 100644
index 00000000..4c0d11df
--- /dev/null
+++ b/app/api/setup/finish/route.ts
@@ -0,0 +1,107 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { writeFile } from 'node:fs/promises';
+import { detectSetupState } from '@/lib/setup/state';
+import { authenticateWizardRequest, SETUP_COOKIE } from '@/lib/setup/session';
+import { configManager } from '@/lib/admin/config-manager';
+import { setInitialAdminPassword } from '@/lib/admin/password';
+import { clearSetupToken } from '@/lib/setup/token';
+import { ensureConfigDir, getConfigPath } from '@/lib/admin/paths';
+import { auditLog } from '@/lib/admin/audit';
+import { logger } from '@/lib/logger';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * POST /api/setup/finish
+ *
+ * Final wizard step. Validates that required config is in place, hashes the
+ * admin password, marks setup complete, deletes the setup token (which
+ * invalidates the wizard cookie), and optionally drops a `.config-locked`
+ * marker so the operator remembers they intended to mount :ro.
+ *
+ * Body: { adminPassword: string, lockConfig?: boolean }
+ */
+export async function POST(request: NextRequest) {
+ if (detectSetupState() !== 'bootstrap') {
+ return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
+ }
+ if (!(await authenticateWizardRequest())) {
+ return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
+ }
+
+ let body: { adminPassword?: unknown; lockConfig?: unknown };
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
+ }
+
+ const adminPassword =
+ typeof body?.adminPassword === 'string' ? body.adminPassword : '';
+ if (adminPassword.length < 8) {
+ return NextResponse.json(
+ { error: 'Admin password must be at least 8 characters' },
+ { status: 400 },
+ );
+ }
+
+ const lockConfig = body?.lockConfig === true;
+
+ // Validate required config is present.
+ await configManager.ensureLoaded();
+ const jmapUrl = configManager.get('jmapServerUrl', '');
+ if (!jmapUrl || typeof jmapUrl !== 'string') {
+ return NextResponse.json(
+ { error: 'JMAP server URL is required (run the Server step first)' },
+ { status: 400 },
+ );
+ }
+
+ try {
+ // 1. Provision the admin account. Aborts cleanly if one already exists
+ // (defence in depth - should be impossible in bootstrap state).
+ const created = await setInitialAdminPassword(adminPassword);
+ if (!created) {
+ return NextResponse.json(
+ { error: 'Admin account already exists; cannot finish setup again' },
+ { status: 409 },
+ );
+ }
+
+ // 2. Persist setupComplete flag. After this, detectSetupState() flips
+ // to 'configured' and middleware starts 404'ing /setup paths.
+ await configManager.markSetupComplete();
+
+ // 3. Optional advisory lock marker.
+ if (lockConfig) {
+ await ensureConfigDir();
+ await writeFile(
+ getConfigPath('.config-locked'),
+ new Date().toISOString(),
+ 'utf-8',
+ );
+ }
+
+ // 4. Destroy the setup token. Any other browser holding the cookie is
+ // now unauthenticated.
+ await clearSetupToken();
+
+ await auditLog(
+ 'setup.finish',
+ { lockConfig, jmapServerUrl: jmapUrl },
+ request.headers.get('x-forwarded-for') ?? 'unknown',
+ );
+
+ const response = NextResponse.json({ ok: true, lockConfig });
+ response.cookies.delete(SETUP_COOKIE);
+ return response;
+ } catch (error) {
+ logger.error('Wizard finish failed', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return NextResponse.json(
+ { error: 'Failed to finish setup', detail: error instanceof Error ? error.message : 'Unknown' },
+ { status: 500 },
+ );
+ }
+}
diff --git a/app/api/setup/status/route.ts b/app/api/setup/status/route.ts
new file mode 100644
index 00000000..aa2f77f9
--- /dev/null
+++ b/app/api/setup/status/route.ts
@@ -0,0 +1,52 @@
+import { NextResponse } from 'next/server';
+import { detectSetupState } from '@/lib/setup/state';
+import { authenticateWizardRequest } from '@/lib/setup/session';
+import { configManager } from '@/lib/admin/config-manager';
+import { isConfigReadOnly } from '@/lib/admin/paths';
+import { SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * GET /api/setup/status - public endpoint that returns the wizard state
+ * and (if authenticated) the partial config saved by previous steps. The
+ * wizard polls this on load so a refresh resumes with prior values.
+ *
+ * Sensitive values (OAuth client secret, session secret) are NEVER sent
+ * back to the client - only a `HasValue` boolean. Re-entering them
+ * after refresh is the price of not exposing them.
+ */
+export async function GET() {
+ await configManager.ensureLoaded();
+ const state = detectSetupState();
+ const authenticated = state === 'bootstrap' ? await authenticateWizardRequest() : false;
+
+ let partialConfig: Record | null = null;
+ if (state === 'bootstrap' && authenticated) {
+ // Only echo back values the operator has actually saved during the
+ // wizard (admin overrides). System defaults must not flow back here,
+ // because the wizard has its own opinionated defaults (e.g. settings
+ // sync on by default) that we'd otherwise stomp.
+ const sources = configManager.getAllWithSources();
+ const safe: Record = {};
+ for (const [key, info] of Object.entries(sources)) {
+ if (info.source !== 'admin') continue;
+ if (SENSITIVE_CONFIG_KEYS.has(key)) {
+ safe[`${key}HasValue`] = typeof info.value === 'string' && info.value.length > 0;
+ } else {
+ safe[key] = info.value;
+ }
+ }
+ partialConfig = safe;
+ }
+
+ return NextResponse.json(
+ {
+ state,
+ authenticated,
+ readOnly: isConfigReadOnly(),
+ partialConfig,
+ },
+ { headers: { 'Cache-Control': 'no-store' } },
+ );
+}
diff --git a/app/api/setup/step/route.ts b/app/api/setup/step/route.ts
new file mode 100644
index 00000000..2dc08f67
--- /dev/null
+++ b/app/api/setup/step/route.ts
@@ -0,0 +1,118 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { detectSetupState } from '@/lib/setup/state';
+import { authenticateWizardRequest } from '@/lib/setup/session';
+import { configManager } from '@/lib/admin/config-manager';
+import { CONFIG_ENV_MAP } from '@/lib/admin/types';
+import { parseJmapServers } from '@/lib/admin/jmap-servers';
+import { logger } from '@/lib/logger';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * Mapping of wizard-friendly step keys to the config keys they update. Each
+ * step's PATCH validates against this allowlist so a compromised wizard
+ * client can't slip in arbitrary config keys.
+ */
+const STEP_KEYS: Record = {
+ server: [
+ 'appName',
+ 'jmapServerUrl',
+ 'stalwartFeaturesEnabled',
+ 'jmapServers',
+ 'jmapServerAutoPickByDomain',
+ ],
+ auth: [
+ 'oauthEnabled',
+ 'oauthOnly',
+ 'oauthClientId',
+ 'oauthClientSecret',
+ 'oauthIssuerUrl',
+ ],
+ security: ['sessionSecret', 'settingsSyncEnabled'],
+ logging: ['logFormat', 'logLevel'],
+ branding: [
+ 'faviconUrl',
+ 'appLogoLightUrl',
+ 'appLogoDarkUrl',
+ 'loginLogoLightUrl',
+ 'loginLogoDarkUrl',
+ 'loginCompanyName',
+ 'loginImprintUrl',
+ 'loginPrivacyPolicyUrl',
+ 'loginWebsiteUrl',
+ ],
+};
+
+/**
+ * POST /api/setup/step
+ * Body: { step: 'server' | 'auth' | ..., values: Record }
+ *
+ * Persists partial config under the admin override (config.json). Each
+ * step's allowed keys are restricted by STEP_KEYS so the client can only
+ * touch what the corresponding screen owns.
+ */
+export async function POST(request: NextRequest) {
+ if (detectSetupState() !== 'bootstrap') {
+ return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
+ }
+ if (!(await authenticateWizardRequest())) {
+ return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
+ }
+
+ let body: { step?: unknown; values?: unknown };
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
+ }
+
+ const step = typeof body?.step === 'string' ? body.step : '';
+ const values = body?.values;
+ const allowedKeys = STEP_KEYS[step];
+ if (!allowedKeys) {
+ return NextResponse.json({ error: `Unknown step: ${step}` }, { status: 400 });
+ }
+ if (!values || typeof values !== 'object' || Array.isArray(values)) {
+ return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
+ }
+
+ const updates: Record = {};
+ for (const [key, value] of Object.entries(values as Record)) {
+ if (!allowedKeys.includes(key)) {
+ return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
+ }
+ if (!(key in CONFIG_ENV_MAP)) {
+ return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
+ }
+ if (key === 'jmapServers') {
+ // Sanitize: drop entries with bad ids, dup ids, or non-HTTP URLs
+ // before they're persisted. Mirrors the admin config PATCH route.
+ if (value != null && !Array.isArray(value)) {
+ return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
+ }
+ const sanitized = parseJmapServers(value);
+ const incomingCount = Array.isArray(value) ? value.length : 0;
+ if (sanitized.length !== incomingCount) {
+ return NextResponse.json(
+ { error: `One or more jmapServers entries were invalid (kept ${sanitized.length}/${incomingCount})` },
+ { status: 400 },
+ );
+ }
+ updates[key] = sanitized;
+ continue;
+ }
+ updates[key] = value;
+ }
+
+ try {
+ await configManager.ensureLoaded();
+ await configManager.setAdminConfig(updates);
+ return NextResponse.json({ ok: true });
+ } catch (error) {
+ logger.error('Wizard step save failed', {
+ step,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return NextResponse.json({ error: 'Failed to save step' }, { status: 500 });
+ }
+}
diff --git a/app/api/setup/test-jmap/route.ts b/app/api/setup/test-jmap/route.ts
new file mode 100644
index 00000000..76262c8f
--- /dev/null
+++ b/app/api/setup/test-jmap/route.ts
@@ -0,0 +1,104 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { detectSetupState } from '@/lib/setup/state';
+import { authenticateWizardRequest } from '@/lib/setup/session';
+
+export const dynamic = 'force-dynamic';
+
+const JMAP_ENDPOINTS = ['/.well-known/jmap', '/jmap/session', '/jmap'];
+const FETCH_TIMEOUT_MS = 5000;
+
+/**
+ * POST /api/setup/test-jmap - server-side probe of a JMAP server. Mirrors
+ * the check_jmap_server() helper in setup.sh: we hit a few common session
+ * endpoints and look for capability strings to confirm the URL is actually
+ * a JMAP server (vs. a generic HTTP 200 page).
+ *
+ * Body: { url: string }
+ */
+export async function POST(request: NextRequest) {
+ if (detectSetupState() !== 'bootstrap') {
+ return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
+ }
+ if (!(await authenticateWizardRequest())) {
+ return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
+ }
+
+ let body: { url?: unknown };
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
+ }
+
+ const raw = typeof body?.url === 'string' ? body.url.trim() : '';
+ if (!raw) {
+ return NextResponse.json({ error: 'url required' }, { status: 400 });
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(raw);
+ } catch {
+ return NextResponse.json({ status: 'invalid_url', message: 'URL is not well-formed' });
+ }
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ return NextResponse.json({ status: 'invalid_url', message: 'URL must use http or https' });
+ }
+
+ const base = raw.replace(/\/+$/, '');
+
+ for (const endpoint of JMAP_ENDPOINTS) {
+ const target = base + endpoint;
+ try {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
+ const res = await fetch(target, {
+ method: 'GET',
+ redirect: 'follow',
+ signal: controller.signal,
+ });
+ clearTimeout(timer);
+
+ if (!res.ok) continue;
+ const text = await res.text();
+ if (looksLikeJmapSession(text)) {
+ return NextResponse.json({
+ status: 'jmap_detected',
+ endpoint,
+ httpStatus: res.status,
+ });
+ }
+ } catch {
+ // Try the next endpoint; we'll fall through to a final reachability
+ // check below if none match.
+ }
+ }
+
+ // No JMAP session found. Was the server even reachable?
+ try {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
+ const res = await fetch(base, {
+ method: 'HEAD',
+ redirect: 'follow',
+ signal: controller.signal,
+ });
+ clearTimeout(timer);
+ return NextResponse.json({
+ status: 'reachable_no_jmap',
+ httpStatus: res.status,
+ message:
+ 'Server responded but no JMAP session was found at standard paths. ' +
+ 'This is OK if a reverse proxy routes JMAP separately.',
+ });
+ } catch (error) {
+ return NextResponse.json({
+ status: 'unreachable',
+ message: error instanceof Error ? error.message : 'Connection failed',
+ });
+ }
+}
+
+function looksLikeJmapSession(body: string): boolean {
+ return /"capabilities"|"apiUrl"|"downloadUrl"|"urn:ietf:params:jmap/i.test(body);
+}
diff --git a/app/api/setup/token/route.ts b/app/api/setup/token/route.ts
new file mode 100644
index 00000000..4b22b794
--- /dev/null
+++ b/app/api/setup/token/route.ts
@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { detectSetupState } from '@/lib/setup/state';
+import { verifySetupToken } from '@/lib/setup/token';
+import { buildSessionCookieAttributes } from '@/lib/setup/session';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * POST /api/setup/token - exchange the bootstrap token (printed to logs at
+ * startup) for a wizard session cookie. After this, subsequent step calls
+ * authenticate via the cookie instead of pasting the token every time.
+ *
+ * Body: { token: string }
+ */
+export async function POST(request: NextRequest) {
+ if (detectSetupState() !== 'bootstrap') {
+ return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
+ }
+
+ let body: { token?: unknown };
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
+ }
+
+ const submitted = typeof body?.token === 'string' ? body.token.trim() : '';
+ if (!submitted) {
+ return NextResponse.json({ error: 'Token required' }, { status: 400 });
+ }
+
+ const ok = await verifySetupToken(submitted);
+ if (!ok) {
+ // Don't differentiate between "wrong token" and "no token issued" - the
+ // operator either has it from the logs or they don't.
+ return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
+ }
+
+ const response = NextResponse.json({ ok: true });
+ const attrs = buildSessionCookieAttributes();
+ response.cookies.set(attrs.name, submitted, {
+ httpOnly: attrs.httpOnly,
+ sameSite: attrs.sameSite,
+ secure: attrs.secure,
+ path: attrs.path,
+ maxAge: attrs.maxAge,
+ });
+ return response;
+}
diff --git a/app/setup/layout.tsx b/app/setup/layout.tsx
new file mode 100644
index 00000000..61e6465b
--- /dev/null
+++ b/app/setup/layout.tsx
@@ -0,0 +1,5 @@
+import type { ReactNode } from 'react';
+
+export default function SetupLayout({ children }: { children: ReactNode }) {
+ return {children}
;
+}
diff --git a/app/setup/page.tsx b/app/setup/page.tsx
new file mode 100644
index 00000000..52952cab
--- /dev/null
+++ b/app/setup/page.tsx
@@ -0,0 +1,1288 @@
+'use client';
+
+import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { apiFetch } from '@/lib/browser-navigation';
+
+type State = 'bootstrap' | 'configured' | 'env-managed';
+
+interface StatusResponse {
+ state: State;
+ authenticated: boolean;
+ readOnly: boolean;
+ partialConfig: Record | null;
+}
+
+interface JmapServerRow {
+ id: string;
+ label: string;
+ url: string;
+ /** comma-separated, parsed before save */
+ domains: string;
+}
+
+interface WizardConfig {
+ // Server
+ appName: string;
+ jmapServerUrl: string;
+ stalwartFeaturesEnabled: boolean;
+ jmapServers: JmapServerRow[];
+ jmapServerAutoPickByDomain: boolean;
+ // Auth
+ oauthEnabled: boolean;
+ oauthOnly: boolean;
+ oauthClientId: string;
+ oauthClientSecret: string;
+ oauthIssuerUrl: string;
+ // Security
+ sessionSecret: string;
+ settingsSyncEnabled: boolean;
+ // Logging
+ logFormat: 'text' | 'json';
+ logLevel: 'error' | 'warn' | 'info' | 'debug';
+ // Branding
+ faviconUrl: string;
+ appLogoLightUrl: string;
+ appLogoDarkUrl: string;
+ loginLogoLightUrl: string;
+ loginLogoDarkUrl: string;
+ loginCompanyName: string;
+ loginImprintUrl: string;
+ loginPrivacyPolicyUrl: string;
+ loginWebsiteUrl: string;
+}
+
+const EMPTY_CONFIG: WizardConfig = {
+ appName: 'Bulwark Webmail',
+ jmapServerUrl: '',
+ stalwartFeaturesEnabled: true,
+ jmapServers: [],
+ jmapServerAutoPickByDomain: false,
+ oauthEnabled: false,
+ oauthOnly: false,
+ oauthClientId: '',
+ oauthClientSecret: '',
+ oauthIssuerUrl: '',
+ sessionSecret: '',
+ settingsSyncEnabled: true,
+ logFormat: 'text',
+ logLevel: 'info',
+ faviconUrl: '',
+ appLogoLightUrl: '',
+ appLogoDarkUrl: '',
+ loginLogoLightUrl: '',
+ loginLogoDarkUrl: '',
+ loginCompanyName: '',
+ loginImprintUrl: '',
+ loginPrivacyPolicyUrl: '',
+ loginWebsiteUrl: '',
+};
+
+const STEPS = [
+ { id: 'welcome', label: 'Welcome' },
+ { id: 'server', label: 'Server' },
+ { id: 'auth', label: 'Auth' },
+ { id: 'security', label: 'Security' },
+ { id: 'logging', label: 'Logging' },
+ { id: 'branding', label: 'Branding' },
+ { id: 'review', label: 'Review' },
+] as const;
+
+export default function SetupWizardPage() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const [bootstrapping, setBootstrapping] = useState(true);
+ const [error, setError] = useState(null);
+ const [state, setState] = useState('bootstrap');
+ const [authenticated, setAuthenticated] = useState(false);
+ const [readOnly, setReadOnly] = useState(false);
+ const [config, setConfig] = useState(EMPTY_CONFIG);
+ const [stepIndex, setStepIndex] = useState(0);
+ const [completed, setCompleted] = useState(false);
+
+ // ─── Initial status load ────────────────────────────────────────────────
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const res = await apiFetch('/api/setup/status', { cache: 'no-store' });
+ const data = (await res.json()) as StatusResponse;
+ if (cancelled) return;
+
+ setState(data.state);
+ setReadOnly(data.readOnly);
+
+ if (data.state === 'configured' || data.state === 'env-managed') {
+ // Wizard not active - bounce to login. Middleware will 404 us
+ // before we get here in practice, but defensive.
+ router.replace('/');
+ return;
+ }
+
+ setAuthenticated(data.authenticated);
+ if (data.partialConfig) {
+ setConfig((prev) => mergePartial(prev, data.partialConfig!));
+ // If auth is OK and we already have a JMAP URL persisted, jump
+ // ahead to the next unfilled step.
+ if (data.authenticated && data.partialConfig.jmapServerUrl) {
+ setStepIndex(2);
+ } else if (data.authenticated) {
+ setStepIndex(1);
+ }
+ }
+ } catch (e) {
+ if (!cancelled) setError(humanError(e));
+ } finally {
+ if (!cancelled) setBootstrapping(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [router]);
+
+ // ─── Token submit (welcome step) ────────────────────────────────────────
+ async function submitToken(token: string) {
+ setError(null);
+ const res = await apiFetch('/api/setup/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error ?? `Token rejected (HTTP ${res.status})`);
+ }
+ setAuthenticated(true);
+ setStepIndex(1);
+ }
+
+ // ─── Step persistence ───────────────────────────────────────────────────
+ async function saveStep(step: string, values: Record) {
+ const res = await apiFetch('/api/setup/step', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ step, values }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error ?? `Step save failed (HTTP ${res.status})`);
+ }
+ }
+
+ // ─── Render shell ───────────────────────────────────────────────────────
+ if (bootstrapping) {
+ return Loading…
;
+ }
+
+ if (completed) {
+ return ;
+ }
+
+ if (state !== 'bootstrap') {
+ return ;
+ }
+
+ if (readOnly) {
+ return (
+
+ Configuration is read-only
+
+ The config volume is mounted read-only or ADMIN_CONFIG_READONLY is set.
+ Remount it read-write or unset that variable, then restart the container.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {error && setError(null)} />}
+
+ {!authenticated ? (
+ {
+ try {
+ await submitToken(t);
+ } catch (e) {
+ setError(humanError(e));
+ }
+ }}
+ />
+ ) : (
+ {
+ try {
+ await saveStep(step, values);
+ setStepIndex((i) => Math.min(i + 1, STEPS.length - 1));
+ } catch (e) {
+ setError(humanError(e));
+ }
+ }}
+ onBack={() => setStepIndex((i) => Math.max(i - 1, 1))}
+ onFinish={() => {
+ setCompleted(true);
+ // Hard navigation after a beat — gives the user a moment
+ // to see the success screen and works around any router
+ // edge cases that swallow client-side replaces after the
+ // setupComplete flag flips.
+ setTimeout(() => {
+ window.location.assign('/admin/login');
+ }, 1500);
+ }}
+ />
+ )}
+
+
+
+ );
+}
+
+// ─── Layout helpers ───────────────────────────────────────────────────────
+
+function Header() {
+ return (
+
+
Bulwark Webmail Setup
+
+ Configure your webmail instance from the browser.
+
+
+ );
+}
+
+function ProgressBar({ stepIndex }: { stepIndex: number }) {
+ return (
+
+
+ {STEPS.map((step, i) => (
+
+ ))}
+
+
+ );
+}
+
+function CompletedScreen() {
+ return (
+
+
+
+
You're all set!
+
+ Bulwark Webmail is configured and ready to use.
+
+
+
+
+ Taking you to the admin dashboard…
+
+
+ );
+}
+
+function AlreadyConfiguredScreen() {
+ return (
+
+
+
+
Setup is already complete
+
+ Bulwark Webmail is configured. Sign in to continue.
+
+
+
+
+ );
+}
+
+function CenteredCard({ children }: { children: ReactNode }) {
+ return (
+
+ );
+}
+
+function ErrorBanner({ error, onDismiss }: { error: string; onDismiss: () => void }) {
+ return (
+
+ {error}
+
+ dismiss
+
+
+ );
+}
+
+// ─── Welcome / token step ────────────────────────────────────────────────
+
+function WelcomeStep({ tokenFromUrl, onSubmit }: { tokenFromUrl: string; onSubmit: (t: string) => Promise }) {
+ const [token, setToken] = useState(tokenFromUrl);
+ const [submitting, setSubmitting] = useState(false);
+
+ // Auto-submit if token came in via URL.
+ useEffect(() => {
+ if (tokenFromUrl && !submitting) {
+ setSubmitting(true);
+ onSubmit(tokenFromUrl).finally(() => setSubmitting(false));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [tokenFromUrl]);
+
+ async function handle(e: FormEvent) {
+ e.preventDefault();
+ setSubmitting(true);
+ try {
+ await onSubmit(token.trim());
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+// ─── Step router ─────────────────────────────────────────────────────────
+
+interface StepProps {
+ stepIndex: number;
+ config: WizardConfig;
+ setConfig: React.Dispatch>;
+ onNext: (step: string, values: Record) => Promise;
+ onBack: () => void;
+ onFinish: () => void;
+}
+
+function StepContent({ stepIndex, config, setConfig, onNext, onBack, onFinish }: StepProps) {
+ switch (stepIndex) {
+ case 1:
+ return ;
+ case 2:
+ return ;
+ case 3:
+ return ;
+ case 4:
+ return ;
+ case 5:
+ return ;
+ case 6:
+ return ;
+ default:
+ return Loading…
;
+ }
+}
+
+// ─── Server step ─────────────────────────────────────────────────────────
+
+function ServerStep({ config, setConfig, onNext }: Pick) {
+ const [submitting, setSubmitting] = useState(false);
+ const [probe, setProbe] = useState(null);
+ const [probing, setProbing] = useState(false);
+
+ async function testJmap() {
+ setProbe(null);
+ setProbing(true);
+ try {
+ const res = await apiFetch('/api/setup/test-jmap', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url: config.jmapServerUrl }),
+ });
+ const data = await res.json();
+ if (data.status === 'jmap_detected') {
+ setProbe(`JMAP server confirmed at ${data.endpoint}`);
+ } else if (data.status === 'reachable_no_jmap') {
+ setProbe(`Server reachable (HTTP ${data.httpStatus}) but no JMAP session found at standard paths.`);
+ } else {
+ setProbe(data.message ?? 'Could not reach server.');
+ }
+ } catch (e) {
+ setProbe(humanError(e));
+ } finally {
+ setProbing(false);
+ }
+ }
+
+ const [showAdditional, setShowAdditional] = useState(config.jmapServers.length > 0);
+
+ function updateRow(index: number, patch: Partial) {
+ setConfig({
+ ...config,
+ jmapServers: config.jmapServers.map((row, i) => (i === index ? { ...row, ...patch } : row)),
+ });
+ }
+
+ function addRow() {
+ setConfig({
+ ...config,
+ jmapServers: [...config.jmapServers, { id: '', label: '', url: '', domains: '' }],
+ });
+ setShowAdditional(true);
+ }
+
+ function removeRow(index: number) {
+ setConfig({
+ ...config,
+ jmapServers: config.jmapServers.filter((_, i) => i !== index),
+ });
+ }
+
+ // Validate the multi-server rows: each must have a unique id matching the
+ // schema, a usable URL, and no collision with the primary server.
+ const rowErrors: string[] = [];
+ const seenIds = new Set();
+ for (let i = 0; i < config.jmapServers.length; i++) {
+ const r = config.jmapServers[i];
+ const id = r.id.trim();
+ if (!id) {
+ rowErrors.push(`Server #${i + 1}: id is required`);
+ } else if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(id)) {
+ rowErrors.push(`Server #${i + 1}: id must be alphanumeric (with - or _), starting with a letter or digit`);
+ } else if (seenIds.has(id)) {
+ rowErrors.push(`Server #${i + 1}: id "${id}" is duplicated`);
+ } else {
+ seenIds.add(id);
+ }
+ const url = r.url.trim();
+ if (!url) {
+ rowErrors.push(`Server #${i + 1}: url is required`);
+ } else if (!/^https?:\/\//i.test(url)) {
+ rowErrors.push(`Server #${i + 1}: url must start with http:// or https://`);
+ }
+ }
+ const hasRowErrors = rowErrors.length > 0;
+
+ async function handle(e: FormEvent) {
+ e.preventDefault();
+ if (hasRowErrors) return;
+ setSubmitting(true);
+ try {
+ await onNext('server', {
+ appName: config.appName,
+ jmapServerUrl: config.jmapServerUrl,
+ stalwartFeaturesEnabled: config.stalwartFeaturesEnabled,
+ // The API route runs parseJmapServers on this; we pre-canonicalize
+ // here so the round-trip is clean.
+ jmapServers: rowsToCanonical(config.jmapServers),
+ jmapServerAutoPickByDomain: config.jmapServerAutoPickByDomain,
+ });
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+// ─── Auth step ───────────────────────────────────────────────────────────
+
+function AuthStep({ config, setConfig, onNext, onBack }: Pick) {
+ const [submitting, setSubmitting] = useState(false);
+
+ async function handle(e: FormEvent) {
+ e.preventDefault();
+ setSubmitting(true);
+ try {
+ const values: Partial = { oauthEnabled: config.oauthEnabled };
+ if (config.oauthEnabled) {
+ values.oauthOnly = config.oauthOnly;
+ values.oauthClientId = config.oauthClientId;
+ values.oauthIssuerUrl = config.oauthIssuerUrl;
+ if (config.oauthClientSecret) {
+ values.oauthClientSecret = config.oauthClientSecret;
+ }
+ }
+ await onNext('auth', values);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+// ─── Security step ───────────────────────────────────────────────────────
+
+function generateSessionSecret(): string {
+ // 32 random bytes, base64-encoded - same shape as `openssl rand -base64 32`.
+ const bytes = new Uint8Array(32);
+ crypto.getRandomValues(bytes);
+ let bin = '';
+ for (const b of bytes) bin += String.fromCharCode(b);
+ return btoa(bin);
+}
+
+function SecurityStep({ config, setConfig, onNext, onBack }: Pick) {
+ const [submitting, setSubmitting] = useState(false);
+ const [reveal, setReveal] = useState(false);
+ const [customize, setCustomize] = useState(false);
+
+ // Auto-generate on first render so the operator doesn't have to click a
+ // button for the recommended path. They can still regenerate or paste
+ // their own via the "Customize" toggle.
+ useEffect(() => {
+ if (!config.sessionSecret) {
+ setConfig((prev) => ({ ...prev, sessionSecret: generateSessionSecret() }));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ async function handle(e: FormEvent) {
+ e.preventDefault();
+ setSubmitting(true);
+ try {
+ const values: Partial = {
+ settingsSyncEnabled: config.settingsSyncEnabled,
+ };
+ if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
+ await onNext('security', values);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+// ─── Logging step ────────────────────────────────────────────────────────
+
+function LoggingStep({ config, setConfig, onNext, onBack }: Pick) {
+ const [submitting, setSubmitting] = useState(false);
+ async function handle(e: FormEvent) {
+ e.preventDefault();
+ setSubmitting(true);
+ try {
+ await onNext('logging', { logFormat: config.logFormat, logLevel: config.logLevel });
+ } finally {
+ setSubmitting(false);
+ }
+ }
+ return (
+
+ );
+}
+
+// ─── Branding step ───────────────────────────────────────────────────────
+
+function BrandingStep({ config, setConfig, onNext, onBack }: Pick) {
+ const [submitting, setSubmitting] = useState(false);
+ async function handle(e: FormEvent) {
+ e.preventDefault();
+ setSubmitting(true);
+ try {
+ // Only send fields the operator actually filled in. Saving an empty
+ // string would create an admin override that shadows the system
+ // default — a blank "Login logo" field would suppress the default
+ // Bulwark logo on the login page, which is never what we want from
+ // the wizard.
+ const allFields = {
+ faviconUrl: config.faviconUrl,
+ appLogoLightUrl: config.appLogoLightUrl,
+ appLogoDarkUrl: config.appLogoDarkUrl,
+ loginLogoLightUrl: config.loginLogoLightUrl,
+ loginLogoDarkUrl: config.loginLogoDarkUrl,
+ loginCompanyName: config.loginCompanyName,
+ loginImprintUrl: config.loginImprintUrl,
+ loginPrivacyPolicyUrl: config.loginPrivacyPolicyUrl,
+ loginWebsiteUrl: config.loginWebsiteUrl,
+ };
+ const values: Record = {};
+ for (const [k, v] of Object.entries(allFields)) {
+ if (v.trim() !== '') values[k] = v.trim();
+ }
+ await onNext('branding', values);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+ return (
+
+ );
+}
+
+// ─── Review / finish step ─────────────────────────────────────────────────
+
+function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack: () => void; onFinish: () => void }) {
+ const [adminPassword, setAdminPassword] = useState('');
+ const [adminConfirm, setAdminConfirm] = useState('');
+ const [lockConfig, setLockConfig] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [localError, setLocalError] = useState(null);
+
+ async function handle(e: FormEvent) {
+ e.preventDefault();
+ setLocalError(null);
+ if (adminPassword.length < 8) {
+ setLocalError('Admin password must be at least 8 characters.');
+ return;
+ }
+ if (adminPassword !== adminConfirm) {
+ setLocalError('Passwords do not match.');
+ return;
+ }
+ setSubmitting(true);
+ try {
+ const res = await apiFetch('/api/setup/finish', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ adminPassword, lockConfig }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ setLocalError(data.error ?? `Finish failed (HTTP ${res.status})`);
+ return;
+ }
+ onFinish();
+ } catch (e) {
+ setLocalError(humanError(e));
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+// ─── Atoms ────────────────────────────────────────────────────────────────
+
+function StepHeader({ title, subtitle }: { title: string; subtitle?: string }) {
+ return (
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ );
+}
+
+function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
+ return (
+
+
{label}
+ {children}
+ {hint &&
{hint}
}
+
+ );
+}
+
+function Input({
+ value,
+ onChange,
+ type = 'text',
+ placeholder,
+ required,
+ autoFocus,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ type?: string;
+ placeholder?: string;
+ required?: boolean;
+ autoFocus?: boolean;
+}) {
+ return (
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ required={required}
+ autoFocus={autoFocus}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ />
+ );
+}
+
+function Select({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) {
+ return (
+ onChange(e.target.value)}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ >
+ {options.map((o) => (
+
+ {o.label}
+
+ ))}
+
+ );
+}
+
+function Toggle({
+ checked,
+ onChange,
+ label,
+ hint,
+ disabled,
+}: {
+ checked: boolean;
+ onChange: (v: boolean) => void;
+ label: string;
+ hint?: string;
+ disabled?: boolean;
+}) {
+ return (
+
+ onChange(e.target.checked)}
+ className="mt-1 h-4 w-4"
+ />
+
+
{label}
+ {hint &&
{hint}
}
+
+
+ );
+}
+
+function Footer({ children }: { children: ReactNode }) {
+ return {children}
;
+}
+
+function PrimaryButton({ children, ...rest }: React.ButtonHTMLAttributes) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SecondaryButton({ children, onClick, disabled }: { children: ReactNode; onClick: () => void; disabled?: boolean }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Row({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────
+
+function mergePartial(prev: WizardConfig, partial: Record): WizardConfig {
+ const next: WizardConfig = { ...prev };
+ for (const key of Object.keys(prev) as (keyof WizardConfig)[]) {
+ const incoming = partial[key];
+ if (incoming === undefined) continue;
+ if (key === 'jmapServers') {
+ // Server stores canonical shape; wizard form uses csv domains string.
+ next.jmapServers = canonicalToRows(incoming);
+ continue;
+ }
+ if (typeof incoming === typeof prev[key] || prev[key] === '' || prev[key] === false) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (next as any)[key] = incoming;
+ }
+ }
+ return next;
+}
+
+function canonicalToRows(value: unknown): JmapServerRow[] {
+ if (!Array.isArray(value)) return [];
+ return value
+ .map((item): JmapServerRow | null => {
+ if (!item || typeof item !== 'object') return null;
+ const e = item as Record;
+ const id = typeof e.id === 'string' ? e.id : '';
+ const label = typeof e.label === 'string' ? e.label : '';
+ const url = typeof e.url === 'string' ? e.url : '';
+ const domains = Array.isArray(e.domains)
+ ? (e.domains as unknown[])
+ .filter((d): d is string => typeof d === 'string')
+ .join(', ')
+ : '';
+ if (!id || !url) return null;
+ return { id, label, url, domains };
+ })
+ .filter((r): r is JmapServerRow => r !== null);
+}
+
+function rowsToCanonical(rows: JmapServerRow[]) {
+ return rows
+ .map((r) => {
+ const id = r.id.trim();
+ const url = r.url.trim();
+ if (!id || !url) return null;
+ const domains = r.domains
+ .split(',')
+ .map((d) => d.trim())
+ .filter(Boolean);
+ return {
+ id,
+ label: r.label.trim() || id,
+ url,
+ ...(domains.length > 0 ? { domains } : {}),
+ };
+ })
+ .filter((e): e is { id: string; label: string; url: string; domains?: string[] } => e !== null);
+}
+
+function hasAnyBranding(c: WizardConfig): boolean {
+ return Boolean(
+ c.loginCompanyName ||
+ c.faviconUrl ||
+ c.appLogoLightUrl ||
+ c.appLogoDarkUrl ||
+ c.loginLogoLightUrl ||
+ c.loginLogoDarkUrl ||
+ c.loginWebsiteUrl ||
+ c.loginImprintUrl ||
+ c.loginPrivacyPolicyUrl,
+ );
+}
+
+function humanError(e: unknown): string {
+ if (e instanceof Error) return e.message;
+ if (typeof e === 'string') return e;
+ return 'Unknown error';
+}
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 24fda38b..be57fd10 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -281,7 +281,7 @@ export function EmailComposer({
const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: primaryIdentity;
- // Alias identities often lack a configured signature — fall back to the primary
+ // Alias identities often lack a configured signature - fall back to the primary
// identity's signature so replies (which auto-select a matching alias) still
// populate the user's signature.
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 1caac5ee..5f6fd253 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -2296,7 +2296,7 @@ export function EmailViewer({
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Per RFC 8621 § 4.1.4, when a message has only one alternative the server
// exposes the same part in both htmlBody and textBody. The shared part may
- // actually be text/plain (plain-text-only mail) — rendering that as HTML
+ // actually be text/plain (plain-text-only mail) - rendering that as HTML
// collapses newlines and skips linkification, so route by the part's type.
const htmlPart = email.htmlBody[0];
if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') {
@@ -2691,7 +2691,7 @@ export function EmailViewer({
// double re-inverting images nested inside those containers.
// Nested bgcolor containers must NOT add another invert layer: each filter
// toggles the inversion, so an odd number of stacked filters (e.g. body +
- // outer bgcolor table + inner bgcolor table) produces an inverted result —
+ // outer bgcolor table + inner bgcolor table) produces an inverted result -
// i.e. light-on-light. The second rule disables filter on bgcolor-like
// elements that are descendants of another bgcolor-like element.
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
@@ -2888,7 +2888,7 @@ export function EmailViewer({
// Re-invert emoji glyphs so they keep their original colors. The
// body's invert filter flips colored emoji (yellow smiley → blue,
// red heart → cyan, etc.). Wrap each emoji run in a span that
- // re-inverts. Only act when the ancestor invert depth is odd —
+ // re-inverts. Only act when the ancestor invert depth is odd -
// emojis inside a double-inverted bgcolor container already render
// at their original colors.
let emojiRe: RegExp;
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index 1d0843f0..61577091 100644
--- a/components/email/thread-conversation-view.tsx
+++ b/components/email/thread-conversation-view.tsx
@@ -331,7 +331,7 @@ function EmailCard({
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
// Server-generated HTML from text/plain emails often lacks tags, collapsing newlines.
- // Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody —
+ // Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody -
// in that case there is no real plain-text alternative, so always render the HTML.
const textPartId = email.textBody?.[0]?.partId;
const htmlPartId = email.htmlBody[0].partId;
diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx
index 48df8979..b0764e98 100644
--- a/components/layout/account-switcher.tsx
+++ b/components/layout/account-switcher.tsx
@@ -114,7 +114,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
setDefaultAccount(accountId);
};
- // Show the account's own identity, not the preferred sending identity —
+ // Show the account's own identity, not the preferred sending identity -
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
// the actually logged-in account (info@linusrath.de).
const displayName = activeAccount?.displayName || activeAccount?.label || "";
diff --git a/docker-compose.yml b/docker-compose.yml
index ddf1a798..928b41b1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,8 +11,13 @@ services:
volumes:
# Encrypted user settings (SETTINGS_DATA_DIR).
- bulwark-settings:/app/data/settings
- # Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR).
+ # Admin configuration: config.json, policy.json, admin.json (passwordHash),
+ # plugins, themes, branding uploads (ADMIN_CONFIG_DIR). Can be mounted
+ # read-only after running the setup wizard - append `:ro` to lock it.
- bulwark-admin:/app/data/admin
+ # Admin runtime state: admin-state.json (login timestamps), audit.log,
+ # setup token (ADMIN_STATE_DIR). Always read-write.
+ - bulwark-admin-state:/app/data/admin-state
# Anonymous telemetry: instance id, consent state, login HMACs (TELEMETRY_DATA_DIR).
# Persisting this preserves the admin's consent choice and stable instance id across upgrades.
- bulwark-telemetry:/app/data/telemetry
@@ -35,4 +40,5 @@ services:
volumes:
bulwark-settings:
bulwark-admin:
+ bulwark-admin-state:
bulwark-telemetry:
diff --git a/instrumentation.node.ts b/instrumentation.node.ts
index d4e1525e..13438a52 100644
--- a/instrumentation.node.ts
+++ b/instrumentation.node.ts
@@ -1,6 +1,9 @@
import { readFileSync } from "fs";
import { configManager } from "./lib/admin/config-manager";
import { initAdminPassword } from "./lib/admin/password";
+import { migrateLegacyAdminLayout } from "./lib/admin/migrate";
+import { detectSetupState } from "./lib/setup/state";
+import { ensureSetupToken } from "./lib/setup/token";
const pkg = JSON.parse(
readFileSync(`${process.cwd()}/package.json`, "utf-8")
@@ -8,11 +11,36 @@ const pkg = JSON.parse(
const current: string = pkg.version ?? "0.0.0";
console.info(`Bulwark Webmail v${current}`);
-// Initialize admin config and password bootstrap
-configManager.load()
+// Initialize admin config and password bootstrap. Migration runs first so
+// existing v1 layouts are split before anything reads admin.json.
+migrateLegacyAdminLayout()
+ .then(() => configManager.load())
.then(() => initAdminPassword())
- .then(() => {
+ .then(async () => {
console.info("Admin dashboard initialized");
+ // If we're in bootstrap state (no JMAP_SERVER_URL env and no
+ // setupComplete in config.json), generate/refresh the setup token and
+ // print it to the logs so the operator can complete the web wizard
+ // without execing into the container.
+ if (detectSetupState() === "bootstrap") {
+ try {
+ const token = await ensureSetupToken();
+ const port = process.env.PORT || "3000";
+ console.info("");
+ console.info("==============================================================");
+ console.info(" SETUP REQUIRED");
+ console.info(` Token: ${token}`);
+ console.info(` Open: http://:${port}/setup?token=${token}`);
+ console.info(" Token expires in 1 hour. Restart the container to reissue.");
+ console.info("==============================================================");
+ console.info("");
+ } catch (err) {
+ console.warn(
+ "Failed to issue setup token:",
+ err instanceof Error ? err.message : err,
+ );
+ }
+ }
})
.then(async () => {
// Anonymous telemetry - on by default. Admins can disable via the
diff --git a/lib/account-utils.ts b/lib/account-utils.ts
index b9e95cf6..ea63020f 100644
--- a/lib/account-utils.ts
+++ b/lib/account-utils.ts
@@ -65,7 +65,7 @@ export function getAccountScopedKey(baseKey: string, accountId: string): string
/**
* Hard upper bound on cookie slots. Each slot can hold up to ~3 cookies
* (session, refresh token, server id, auth context), so 50 slots ≈ 125
- * cookies on average — within Firefox's per-domain limit of 150.
+ * cookies on average - within Firefox's per-domain limit of 150.
*/
export const MAX_ACCOUNT_SLOTS = 50;
@@ -83,7 +83,7 @@ export const MAX_ACCOUNTS_HTTP1 = 5;
* We walk recent resource-timing entries and treat a single h2/h3 sighting
* as a positive signal. Cross-origin entries may report an empty
* `nextHopProtocol` without `Timing-Allow-Origin`, in which case we
- * under-detect and fall back to the conservative cap — that's safe.
+ * under-detect and fall back to the conservative cap - that's safe.
*/
export function isHttp2Available(): boolean {
if (typeof performance === 'undefined') return false;
diff --git a/lib/admin/audit.ts b/lib/admin/audit.ts
index 9b3a6ded..0c0bb73e 100644
--- a/lib/admin/audit.ts
+++ b/lib/admin/audit.ts
@@ -1,28 +1,23 @@
-import { appendFile, stat, rename, mkdir } from 'node:fs/promises';
+import { appendFile, stat, rename, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
-import path from 'node:path';
import { logger } from '@/lib/logger';
+import { ensureStateDir, getStatePath } from './paths';
import type { AuditEntry } from './types';
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_ROTATIONS = 3;
-
-function getAdminDir(): string {
- return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
-}
+const AUDIT_LOG_FILE = 'audit.log';
function getAuditLogPath(): string {
- return path.join(getAdminDir(), 'audit.log');
+ return getStatePath(AUDIT_LOG_FILE);
}
/**
- * Append an audit entry to the admin audit log.
+ * Append an audit entry to the admin audit log. Stored under the state dir
+ * so it remains writable when the config dir is mounted read-only.
*/
export async function auditLog(action: string, detail: Record, ip: string): Promise {
- const dir = getAdminDir();
- if (!existsSync(dir)) {
- await mkdir(dir, { recursive: true });
- }
+ await ensureStateDir();
const entry: AuditEntry = {
ts: new Date().toISOString(),
@@ -64,7 +59,6 @@ async function rotateIfNeeded(logPath: string): Promise {
export async function readAuditLog(page: number = 1, limit: number = 50, actionFilter?: string): Promise<{ entries: AuditEntry[]; total: number }> {
const logPath = getAuditLogPath();
try {
- const { readFile } = await import('node:fs/promises');
const content = await readFile(logPath, 'utf-8');
const lines = content.trim().split('\n').filter(Boolean);
@@ -77,7 +71,6 @@ export async function readAuditLog(page: number = 1, limit: number = 50, actionF
}
const total = entries.length;
- // Return newest first
entries.reverse();
const start = (page - 1) * limit;
return { entries: entries.slice(start, start + limit), total };
diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts
index fa7df72c..1e3e278d 100644
--- a/lib/admin/config-manager.ts
+++ b/lib/admin/config-manager.ts
@@ -1,13 +1,8 @@
-import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
-import { existsSync } from 'node:fs';
-import path from 'node:path';
+import { readFile, writeFile, rename } from 'node:fs/promises';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
-
-function getAdminDir(): string {
- return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
-}
+import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
function parseEnvValue(value: string, type: string): unknown {
switch (type) {
@@ -127,6 +122,7 @@ class ConfigManager {
* Update admin config overrides. Writes to disk.
*/
async setAdminConfig(updates: Record): Promise {
+ assertWritable('update admin config');
Object.assign(this.adminConfig, updates);
await this.writeJsonFile('config.json', this.adminConfig);
}
@@ -135,10 +131,29 @@ class ConfigManager {
* Remove an admin override, reverting to env/default.
*/
async removeAdminOverride(key: string): Promise {
+ assertWritable('remove admin override');
delete this.adminConfig[key];
await this.writeJsonFile('config.json', this.adminConfig);
}
+ /**
+ * Whether the setup wizard has completed. Used by middleware to gate the
+ * /setup routes and the rest of the app.
+ */
+ isSetupComplete(): boolean {
+ return this.adminConfig.setupComplete === true;
+ }
+
+ /**
+ * Mark setup wizard as complete. Called by the wizard's finish endpoint
+ * after all other config has been written. Refuses in read-only mode.
+ */
+ async markSetupComplete(): Promise {
+ assertWritable('mark setup complete');
+ this.adminConfig.setupComplete = true;
+ await this.writeJsonFile('config.json', this.adminConfig);
+ }
+
/**
* Get the current settings policy.
*/
@@ -150,6 +165,7 @@ class ConfigManager {
* Update the settings policy. Writes to disk.
*/
async setPolicy(policy: SettingsPolicy): Promise {
+ assertWritable('update settings policy');
this.policyCache = {
...DEFAULT_POLICY,
...policy,
@@ -167,7 +183,7 @@ class ConfigManager {
}
private async readJsonFile(filename: string): Promise | null> {
- const filePath = path.join(getAdminDir(), filename);
+ const filePath = getConfigPath(filename);
try {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw);
@@ -179,15 +195,21 @@ class ConfigManager {
}
private async writeJsonFile(filename: string, data: Record): Promise {
- const dir = getAdminDir();
- if (!existsSync(dir)) {
- await mkdir(dir, { recursive: true });
- }
- const targetPath = path.join(dir, filename);
+ await ensureConfigDir();
+ const targetPath = getConfigPath(filename);
const tmpPath = targetPath + '.tmp';
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
await rename(tmpPath, targetPath);
}
}
-export const configManager = new ConfigManager();
+// Stash the singleton on globalThis so HMR / multiple module-evaluation
+// boundaries (middleware vs route handlers in dev with turbopack) all share
+// the same in-memory state. Without this, marking setupComplete=true in a
+// route handler is invisible to the next middleware run, and the wizard
+// redirect after finish never fires.
+const SINGLETON_KEY = Symbol.for('bulwark.admin.configManager');
+type GlobalWithConfig = typeof globalThis & { [SINGLETON_KEY]?: ConfigManager };
+const g = globalThis as GlobalWithConfig;
+export const configManager: ConfigManager =
+ g[SINGLETON_KEY] ?? (g[SINGLETON_KEY] = new ConfigManager());
diff --git a/lib/admin/migrate.ts b/lib/admin/migrate.ts
new file mode 100644
index 00000000..39d7b2be
--- /dev/null
+++ b/lib/admin/migrate.ts
@@ -0,0 +1,196 @@
+import { readFile, writeFile, rename, stat, unlink } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { logger } from '@/lib/logger';
+import {
+ ensureConfigDir,
+ ensureStateDir,
+ getConfigPath,
+ getStatePath,
+ isConfigReadOnly,
+} from './paths';
+import type { AdminConfigData, AdminStateData } from './types';
+
+const MIGRATION_MARKER = '.migrated-v2';
+
+interface LegacyAdminData {
+ passwordHash: string;
+ createdAt?: string;
+ lastLogin?: string | null;
+ passwordChangedAt?: string;
+}
+
+/**
+ * One-shot migration from the v1 layout (everything mixed in `data/admin/`)
+ * to the v2 layout (config + state split, see lib/admin/paths.ts).
+ *
+ * Idempotent: writes a `.migrated-v2` marker into the config dir on success.
+ *
+ * Migrations performed:
+ * 1. admin.json with timestamps → admin.json (passwordHash only) +
+ * admin-state.json (createdAt, lastLogin, passwordChangedAt)
+ * 2. audit.log moved from config dir to state dir (by rename if same FS,
+ * else copy + delete).
+ *
+ * Skipped silently when the config dir is read-only - operators who already
+ * locked their config volume must do the migration manually before mounting
+ * :ro.
+ */
+export async function migrateLegacyAdminLayout(): Promise {
+ if (isConfigReadOnly()) return;
+
+ const markerPath = getConfigPath(MIGRATION_MARKER);
+ if (existsSync(markerPath)) return;
+
+ let didWork = false;
+
+ try {
+ didWork = (await migrateAdminJson()) || didWork;
+ didWork = (await migrateAuditLog()) || didWork;
+
+ await ensureConfigDir();
+ await writeFile(markerPath, new Date().toISOString(), 'utf-8');
+ if (didWork) {
+ logger.info('Admin layout migrated to v2 (config/state split)');
+ }
+ } catch (error) {
+ logger.warn('Admin layout migration failed; will retry on next boot', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+}
+
+/**
+ * If the existing admin.json carries timestamp fields (legacy mixed layout),
+ * split them into admin-state.json and rewrite admin.json without them.
+ * Returns true if a migration was performed.
+ */
+async function migrateAdminJson(): Promise {
+ const adminJsonPath = getConfigPath('admin.json');
+ if (!existsSync(adminJsonPath)) return false;
+
+ let raw: string;
+ try {
+ raw = await readFile(adminJsonPath, 'utf-8');
+ } catch {
+ return false;
+ }
+
+ let data: LegacyAdminData;
+ try {
+ data = JSON.parse(raw) as LegacyAdminData;
+ } catch {
+ logger.warn('admin.json is not valid JSON; skipping migration');
+ return false;
+ }
+
+ const hasLegacyFields =
+ 'createdAt' in data || 'lastLogin' in data || 'passwordChangedAt' in data;
+ if (!hasLegacyFields) return false; // already in v2 shape
+
+ if (!data.passwordHash || typeof data.passwordHash !== 'string') {
+ logger.warn('admin.json missing passwordHash; skipping migration');
+ return false;
+ }
+
+ const now = new Date().toISOString();
+ const stateData: AdminStateData = {
+ createdAt: data.createdAt ?? now,
+ lastLogin: data.lastLogin ?? null,
+ passwordChangedAt: data.passwordChangedAt ?? now,
+ };
+ const configData: AdminConfigData = { passwordHash: data.passwordHash };
+
+ await ensureStateDir();
+ const statePath = getStatePath('admin-state.json');
+
+ // If admin-state.json already exists, prefer its values: a previous
+ // migration may have succeeded and recorded fresh login timestamps that
+ // we'd otherwise stomp. The legacy admin.json data is older by definition.
+ if (!existsSync(statePath)) {
+ const stateTmp = statePath + '.tmp';
+ await writeFile(stateTmp, JSON.stringify(stateData, null, 2), 'utf-8');
+ await rename(stateTmp, statePath);
+ }
+
+ const configTmp = adminJsonPath + '.tmp';
+ await writeFile(configTmp, JSON.stringify(configData, null, 2), 'utf-8');
+ await rename(configTmp, adminJsonPath);
+
+ logger.info('Migrated admin.json: split timestamps into admin-state.json');
+ return true;
+}
+
+/**
+ * Move audit.log from the config dir to the state dir if present. Returns
+ * true if a migration was performed. Also moves rotated copies (audit.log.1
+ * through .3).
+ */
+async function migrateAuditLog(): Promise {
+ const sources = [
+ 'audit.log',
+ 'audit.log.1',
+ 'audit.log.2',
+ 'audit.log.3',
+ ];
+
+ let moved = false;
+ for (const name of sources) {
+ const src = getConfigPath(name);
+ if (!existsSync(src)) continue;
+
+ await ensureStateDir();
+ const dst = getStatePath(name);
+
+ try {
+ // Same-FS rename is atomic. Falls through to copy if cross-device.
+ await rename(src, dst);
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code;
+ if (code === 'EXDEV') {
+ // Cross-device: copy bytes, then delete source.
+ const data = await readFile(src);
+ await writeFile(dst, data);
+ await unlink(src);
+ } else {
+ throw error;
+ }
+ }
+ moved = true;
+ }
+
+ if (moved) {
+ logger.info('Migrated audit.log to state dir');
+ }
+ return moved;
+}
+
+/**
+ * Returns approximate size of legacy data still mixed in the config dir
+ * (for diagnostics / admin UI). Always returns 0 once migration has run.
+ */
+export async function getLegacyDataInfo(): Promise<{ adminJsonHasTimestamps: boolean; auditLogInConfigDir: boolean }> {
+ let adminJsonHasTimestamps = false;
+ const adminJsonPath = getConfigPath('admin.json');
+ if (existsSync(adminJsonPath)) {
+ try {
+ const raw = await readFile(adminJsonPath, 'utf-8');
+ const parsed = JSON.parse(raw);
+ adminJsonHasTimestamps =
+ 'createdAt' in parsed ||
+ 'lastLogin' in parsed ||
+ 'passwordChangedAt' in parsed;
+ } catch {
+ /* ignore */
+ }
+ }
+
+ let auditLogInConfigDir = false;
+ try {
+ await stat(getConfigPath('audit.log'));
+ auditLogInConfigDir = true;
+ } catch {
+ /* not present - good */
+ }
+
+ return { adminJsonHasTimestamps, auditLogInConfigDir };
+}
diff --git a/lib/admin/password.ts b/lib/admin/password.ts
index d1043df4..955d81fb 100644
--- a/lib/admin/password.ts
+++ b/lib/admin/password.ts
@@ -1,9 +1,14 @@
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
-import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
-import { existsSync } from 'node:fs';
-import path from 'node:path';
+import { readFile, writeFile, rename } from 'node:fs/promises';
import { logger } from '@/lib/logger';
-import type { AdminData } from './types';
+import {
+ ensureConfigDir,
+ ensureStateDir,
+ getConfigPath,
+ getStatePath,
+ assertWritable,
+} from './paths';
+import type { AdminConfigData, AdminStateData } from './types';
const SCRYPT_KEYLEN = 64;
const SCRYPT_COST = 16384; // 2^14
@@ -11,13 +16,8 @@ const SCRYPT_BLOCK_SIZE = 8;
const SCRYPT_PARALLELIZATION = 1;
const SALT_LENGTH = 32;
-function getAdminDir(): string {
- return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
-}
-
-function getAdminJsonPath(): string {
- return path.join(getAdminDir(), 'admin.json');
-}
+const ADMIN_CONFIG_FILE = 'admin.json';
+const ADMIN_STATE_FILE = 'admin-state.json';
function hashPassword(password: string): Promise {
return new Promise((resolve, reject) => {
@@ -33,10 +33,8 @@ function hashPassword(password: string): Promise {
function verifyPassword(password: string, stored: string): Promise {
return new Promise((resolve, reject) => {
- // Support both scrypt format and bcrypt-prefixed values
if (stored.startsWith('$scrypt$')) {
const parts = stored.split('$');
- // $scrypt$N=...,r=...,p=...$salt$hash
if (parts.length !== 5) return resolve(false);
const paramStr = parts[2];
const salt = Buffer.from(parts[3], 'base64');
@@ -53,7 +51,6 @@ function verifyPassword(password: string, stored: string): Promise {
resolve(timingSafeEqual(derivedKey, storedHash));
});
} else {
- // Unknown format
resolve(false);
}
});
@@ -63,50 +60,84 @@ function isHashed(value: string): boolean {
return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$');
}
-async function readAdminData(): Promise {
- const filePath = getAdminJsonPath();
+// ─── Disk I/O ───────────────────────────────────────────────────────────────
+
+async function readJson(filePath: string): Promise {
try {
const raw = await readFile(filePath, 'utf-8');
- return JSON.parse(raw) as AdminData;
+ return JSON.parse(raw) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
- logger.warn('Failed to read admin.json', { error: error instanceof Error ? error.message : 'Unknown error' });
+ logger.warn('Failed to read admin file', {
+ filePath,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
return null;
}
}
-async function writeAdminData(data: AdminData): Promise {
- const dir = getAdminDir();
- if (!existsSync(dir)) {
- await mkdir(dir, { recursive: true });
- }
- const targetPath = getAdminJsonPath();
- const tmpPath = targetPath + '.tmp';
- await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
- await rename(tmpPath, targetPath);
+async function readConfigData(): Promise {
+ return readJson(getConfigPath(ADMIN_CONFIG_FILE));
}
-let cachedAdminData: AdminData | null = null;
+async function readStateData(): Promise {
+ return readJson(getStatePath(ADMIN_STATE_FILE));
+}
+
+async function writeConfigData(data: AdminConfigData): Promise {
+ assertWritable('save admin password');
+ await ensureConfigDir();
+ const target = getConfigPath(ADMIN_CONFIG_FILE);
+ const tmp = target + '.tmp';
+ await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
+ await rename(tmp, target);
+}
+
+async function writeStateData(data: AdminStateData): Promise {
+ await ensureStateDir();
+ const target = getStatePath(ADMIN_STATE_FILE);
+ const tmp = target + '.tmp';
+ await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
+ await rename(tmp, target);
+}
+
+// ─── Cache & init ───────────────────────────────────────────────────────────
+
+let cachedConfig: AdminConfigData | null = null;
+let cachedState: AdminStateData | null = null;
let initialized = false;
+function freshState(): AdminStateData {
+ const now = new Date().toISOString();
+ return { createdAt: now, lastLogin: null, passwordChangedAt: now };
+}
+
/**
* Initialize admin password on startup.
- * If ADMIN_PASSWORD is cleartext, hash it and write to admin.json.
- * Returns true if admin is enabled.
+ * - If admin.json exists, use it (state file may or may not exist; created on first need).
+ * - Otherwise, if ADMIN_PASSWORD env var is set, hash and persist it.
+ * - Otherwise, admin dashboard stays disabled.
*/
export async function initAdminPassword(): Promise {
- if (initialized) return cachedAdminData !== null;
+ if (initialized) return cachedConfig !== null;
- // Check persistent file first
- const existing = await readAdminData();
- if (existing) {
- cachedAdminData = existing;
+ const existingConfig = await readConfigData();
+ if (existingConfig) {
+ cachedConfig = existingConfig;
+ cachedState = (await readStateData()) ?? freshState();
+ if (!(await readStateData())) {
+ // No state file yet (fresh install or migration); create it.
+ try {
+ await writeStateData(cachedState);
+ } catch {
+ /* state dir may not be writable yet during early boot probes */
+ }
+ }
initialized = true;
logger.info('Admin dashboard enabled (password loaded from admin.json)');
return true;
}
- // Check env var
const envPassword = process.env.ADMIN_PASSWORD;
if (!envPassword) {
initialized = true;
@@ -114,33 +145,17 @@ export async function initAdminPassword(): Promise {
return false;
}
- if (isHashed(envPassword)) {
- // Already hashed in env - save to file
- const data: AdminData = {
- passwordHash: envPassword,
- createdAt: new Date().toISOString(),
- lastLogin: null,
- passwordChangedAt: new Date().toISOString(),
- };
- await writeAdminData(data);
- cachedAdminData = data;
- initialized = true;
- logger.info('Admin password hash saved to admin.json from environment variable');
- return true;
- }
-
- // Cleartext - hash it
- const hash = await hashPassword(envPassword);
- const data: AdminData = {
- passwordHash: hash,
- createdAt: new Date().toISOString(),
- lastLogin: null,
- passwordChangedAt: new Date().toISOString(),
- };
- await writeAdminData(data);
- cachedAdminData = data;
+ const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
+ cachedConfig = { passwordHash: hash };
+ cachedState = freshState();
+ await writeConfigData(cachedConfig);
+ await writeStateData(cachedState);
initialized = true;
- logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env');
+ if (isHashed(envPassword)) {
+ logger.info('Admin password hash saved to admin.json from environment variable');
+ } else {
+ logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env');
+ }
return true;
}
@@ -148,11 +163,9 @@ export async function initAdminPassword(): Promise {
* Verify a password against the stored admin hash.
*/
export async function verifyAdminPassword(password: string): Promise {
- if (!cachedAdminData) {
- cachedAdminData = await readAdminData();
- }
- if (!cachedAdminData) return false;
- return verifyPassword(password, cachedAdminData.passwordHash);
+ if (!cachedConfig) cachedConfig = await readConfigData();
+ if (!cachedConfig) return false;
+ return verifyPassword(password, cachedConfig.passwordHash);
}
/**
@@ -163,14 +176,30 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
if (!valid) return false;
const hash = await hashPassword(newPassword);
- if (!cachedAdminData) return false;
+ cachedConfig = { passwordHash: hash };
+ await writeConfigData(cachedConfig);
- cachedAdminData = {
- ...cachedAdminData,
- passwordHash: hash,
+ cachedState = {
+ ...(cachedState ?? freshState()),
passwordChangedAt: new Date().toISOString(),
};
- await writeAdminData(cachedAdminData);
+ await writeStateData(cachedState);
+ return true;
+}
+
+/**
+ * Set the admin password without verifying a current one. Used by the setup
+ * wizard during initial bootstrap. Refuses to overwrite an existing password.
+ */
+export async function setInitialAdminPassword(newPassword: string): Promise {
+ const existing = await readConfigData();
+ if (existing) return false;
+ const hash = await hashPassword(newPassword);
+ cachedConfig = { passwordHash: hash };
+ cachedState = freshState();
+ await writeConfigData(cachedConfig);
+ await writeStateData(cachedState);
+ initialized = true;
return true;
}
@@ -178,29 +207,31 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
* Update the last login timestamp.
*/
export async function updateLastLogin(): Promise {
- if (!cachedAdminData) return;
- cachedAdminData = {
- ...cachedAdminData,
+ if (!cachedConfig) return;
+ cachedState = {
+ ...(cachedState ?? freshState()),
lastLogin: new Date().toISOString(),
};
- await writeAdminData(cachedAdminData);
+ try {
+ await writeStateData(cachedState);
+ } catch (error) {
+ logger.warn('Failed to update admin last-login state', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
}
/**
* Check if admin dashboard is enabled (has a password configured).
*/
export function isAdminEnabled(): boolean {
- return cachedAdminData !== null;
+ return cachedConfig !== null;
}
/**
* Get admin metadata (without the hash).
*/
-export function getAdminMeta(): { createdAt: string; lastLogin: string | null; passwordChangedAt: string } | null {
- if (!cachedAdminData) return null;
- return {
- createdAt: cachedAdminData.createdAt,
- lastLogin: cachedAdminData.lastLogin,
- passwordChangedAt: cachedAdminData.passwordChangedAt,
- };
+export function getAdminMeta(): AdminStateData | null {
+ if (!cachedConfig) return null;
+ return cachedState ?? freshState();
}
diff --git a/lib/admin/paths.ts b/lib/admin/paths.ts
new file mode 100644
index 00000000..b791ecaa
--- /dev/null
+++ b/lib/admin/paths.ts
@@ -0,0 +1,126 @@
+import { existsSync } from 'node:fs';
+import { mkdir, writeFile, unlink } from 'node:fs/promises';
+import path from 'node:path';
+import { logger } from '@/lib/logger';
+
+/**
+ * Admin data directories.
+ *
+ * Two dirs intentionally split (issue #226):
+ * - CONFIG: holds operator-authored state (config.json, policy.json,
+ * admin.json passwordHash, plugins, themes, branding uploads). Can be
+ * mounted read-only after initial setup.
+ * - STATE: holds runtime mutations (admin-state.json with login timestamps,
+ * audit.log, .setup-token). Always read-write.
+ *
+ * Resolution order:
+ * getConfigDir()
+ * 1. ADMIN_CONFIG_DIR
+ * 2. ADMIN_DATA_DIR (legacy)
+ * 3. /data/admin
+ *
+ * getStateDir()
+ * 1. ADMIN_STATE_DIR
+ * 2. /state - if config dir was set explicitly
+ * 3. /state - back-compat: stays on the legacy volume
+ * 4. /data/admin-state - fresh-install default; matches the
+ * sibling mount in docker-compose.yml
+ *
+ * The legacy ADMIN_DATA_DIR keeps existing single-volume mounts working
+ * unchanged: everything ends up under it, with state in a `state/` subdir.
+ * Fresh installs and the docker-compose default keep state in a separate
+ * sibling dir so the config dir can be mounted :ro after setup.
+ */
+
+export function getConfigDir(): string {
+ return (
+ process.env.ADMIN_CONFIG_DIR ||
+ process.env.ADMIN_DATA_DIR ||
+ path.join(process.cwd(), 'data', 'admin')
+ );
+}
+
+export function getStateDir(): string {
+ if (process.env.ADMIN_STATE_DIR) return process.env.ADMIN_STATE_DIR;
+ if (process.env.ADMIN_CONFIG_DIR) {
+ return path.join(process.env.ADMIN_CONFIG_DIR, 'state');
+ }
+ if (process.env.ADMIN_DATA_DIR) {
+ return path.join(process.env.ADMIN_DATA_DIR, 'state');
+ }
+ return path.join(process.cwd(), 'data', 'admin-state');
+}
+
+export function getConfigPath(filename: string): string {
+ return path.join(getConfigDir(), filename);
+}
+
+export function getStatePath(filename: string): string {
+ return path.join(getStateDir(), filename);
+}
+
+export async function ensureConfigDir(): Promise {
+ const dir = getConfigDir();
+ if (!existsSync(dir)) {
+ await mkdir(dir, { recursive: true });
+ }
+}
+
+export async function ensureStateDir(): Promise {
+ const dir = getStateDir();
+ if (!existsSync(dir)) {
+ await mkdir(dir, { recursive: true });
+ }
+}
+
+// ─── Read-only mode ─────────────────────────────────────────────────────────
+
+let cachedReadOnly: boolean | null = null;
+
+/**
+ * Whether the config dir is locked. Operators set ADMIN_CONFIG_READONLY=true
+ * after running the setup wizard and remounting the volume :ro.
+ *
+ * When true, all writes to the config dir are refused at the application
+ * layer (cleaner error than a mid-request EROFS).
+ */
+export function isConfigReadOnly(): boolean {
+ if (cachedReadOnly !== null) return cachedReadOnly;
+ const v = (process.env.ADMIN_CONFIG_READONLY || '').toLowerCase();
+ cachedReadOnly = v === 'true' || v === '1' || v === 'yes';
+ return cachedReadOnly;
+}
+
+/**
+ * Probe the config dir by writing a temp file. Used to auto-detect RO mounts
+ * when ADMIN_CONFIG_READONLY is not set explicitly. Run once at startup;
+ * cheap on local FS, can be slow on networked FS, hence opt-in.
+ */
+export async function probeConfigReadOnly(): Promise {
+ if (process.env.ADMIN_CONFIG_READONLY) return isConfigReadOnly();
+ try {
+ const probe = path.join(getConfigDir(), '.rw-probe');
+ await writeFile(probe, '');
+ await unlink(probe);
+ cachedReadOnly = false;
+ return false;
+ } catch {
+ cachedReadOnly = true;
+ logger.info('Config dir is read-only (auto-detected)');
+ return true;
+ }
+}
+
+export class ConfigReadOnlyError extends Error {
+ constructor(operation: string) {
+ super(
+ `Cannot ${operation}: configuration is read-only. ` +
+ `Remount the config volume read-write or unset ADMIN_CONFIG_READONLY.`
+ );
+ this.name = 'ConfigReadOnlyError';
+ }
+}
+
+export function assertWritable(operation: string): void {
+ if (isConfigReadOnly()) throw new ConfigReadOnlyError(operation);
+}
diff --git a/lib/admin/plugin-config.ts b/lib/admin/plugin-config.ts
index bd2d8cfc..68c7e68d 100644
--- a/lib/admin/plugin-config.ts
+++ b/lib/admin/plugin-config.ts
@@ -2,13 +2,10 @@ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
-
-function getAdminDir(): string {
- return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
-}
+import { getConfigDir, assertWritable } from './paths';
function getPluginConfigDir(): string {
- return path.join(getAdminDir(), 'plugin-config');
+ return path.join(getConfigDir(), 'plugin-config');
}
function configPath(pluginId: string): string {
@@ -41,6 +38,7 @@ export async function getPluginConfig(pluginId: string): Promise {
+ assertWritable('update plugin config');
const dir = getPluginConfigDir();
await ensureDir(dir);
@@ -57,6 +55,7 @@ export async function setPluginConfig(pluginId: string, key: string, value: unkn
* Delete a single config key for a plugin.
*/
export async function deletePluginConfigKey(pluginId: string, key: string): Promise {
+ assertWritable('delete plugin config key');
const config = await getPluginConfig(pluginId);
delete config[key];
@@ -77,5 +76,6 @@ export async function deletePluginConfigKey(pluginId: string, key: string): Prom
* Delete all config for a plugin (used when uninstalling).
*/
export async function deleteAllPluginConfig(pluginId: string): Promise {
+ assertWritable('delete plugin config');
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
}
diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts
index e700eece..e09ca97a 100644
--- a/lib/admin/plugin-registry.ts
+++ b/lib/admin/plugin-registry.ts
@@ -3,17 +3,14 @@ import { existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { logger } from '@/lib/logger';
-
-function getAdminDir(): string {
- return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
-}
+import { getConfigDir, assertWritable } from './paths';
function getPluginsDir(): string {
- return path.join(getAdminDir(), 'plugins');
+ return path.join(getConfigDir(), 'plugins');
}
function getThemesDir(): string {
- return path.join(getAdminDir(), 'themes');
+ return path.join(getConfigDir(), 'themes');
}
// ─── Types ───────────────────────────────────────────────────
@@ -141,6 +138,7 @@ export async function savePlugin(
plugin: ServerPlugin,
code: string,
): Promise {
+ assertWritable('install plugin');
const dir = getPluginsDir();
await ensureDir(dir);
@@ -171,6 +169,7 @@ export async function savePlugin(
}
export async function updatePluginMeta(id: string, updates: Partial>): Promise {
+ assertWritable('update plugin metadata');
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === id);
if (idx < 0) return null;
@@ -181,6 +180,7 @@ export async function updatePluginMeta(id: string, updates: Partial {
+ assertWritable('delete plugin');
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === id);
if (idx < 0) return false;
@@ -221,6 +221,7 @@ export async function saveTheme(
theme: ServerTheme,
css: string,
): Promise {
+ assertWritable('install theme');
const dir = getThemesDir();
await ensureDir(dir);
@@ -240,6 +241,7 @@ export async function saveTheme(
}
export async function updateThemeMeta(id: string, updates: Partial>): Promise {
+ assertWritable('update theme metadata');
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === id);
if (idx < 0) return null;
@@ -250,6 +252,7 @@ export async function updateThemeMeta(id: string, updates: Partial {
+ assertWritable('delete theme');
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === id);
if (idx < 0) return false;
diff --git a/lib/admin/session.ts b/lib/admin/session.ts
index ecde855e..fb08468f 100644
--- a/lib/admin/session.ts
+++ b/lib/admin/session.ts
@@ -1,7 +1,7 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
-import { readFileEnv } from '@/lib/read-file-env';
+import { getSessionSecret } from '@/lib/auth/session-secret';
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
import type { AdminSessionPayload } from './types';
@@ -12,7 +12,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
- const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
+ const secret = getSessionSecret();
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 76fd247e..66ecf08d 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -1,12 +1,30 @@
// Admin dashboard types
-export interface AdminData {
+/**
+ * Operator-authored admin record. Lives in admin.json under the config dir
+ * and can be mounted read-only after setup. Only the password hash itself
+ * is config; mutable timestamps live in AdminStateData.
+ */
+export interface AdminConfigData {
passwordHash: string;
+}
+
+/**
+ * Runtime-mutable admin record. Lives in admin-state.json under the state
+ * dir. Updated on every login and password change, so it must stay writable.
+ */
+export interface AdminStateData {
createdAt: string;
lastLogin: string | null;
passwordChangedAt: string;
}
+/**
+ * Combined view used by getAdminMeta() and tests. Constructed by merging
+ * admin.json + admin-state.json at read time.
+ */
+export interface AdminData extends AdminConfigData, AdminStateData {}
+
export interface AdminSessionPayload {
role: 'admin';
iat: number;
diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts
index 670bcc68..38eb24a4 100644
--- a/lib/auth/crypto.ts
+++ b/lib/auth/crypto.ts
@@ -1,6 +1,6 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { logger } from '@/lib/logger';
-import { readFileEnv } from '@/lib/read-file-env';
+import { getSessionSecret } from '@/lib/auth/session-secret';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
@@ -9,7 +9,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
- const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
+ const secret = getSessionSecret();
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
diff --git a/lib/auth/session-secret.ts b/lib/auth/session-secret.ts
new file mode 100644
index 00000000..aceff84f
--- /dev/null
+++ b/lib/auth/session-secret.ts
@@ -0,0 +1,31 @@
+import { configManager } from '@/lib/admin/config-manager';
+import { readFileEnv } from '@/lib/read-file-env';
+
+/**
+ * Resolve the session secret from any of the supported sources, in priority
+ * order:
+ * 1. SESSION_SECRET env var
+ * 2. SESSION_SECRET_FILE-pointed file
+ * 3. Admin override in config.json (set by the setup wizard)
+ *
+ * Returns an empty string when nothing is configured. Callers must treat
+ * empty as "feature disabled" rather than crashing.
+ *
+ * The configManager fallback exists so the web installer can persist the
+ * secret without touching .env files. It only takes effect if the env vars
+ * aren't set, so existing deployments aren't affected.
+ */
+export function getSessionSecret(): string {
+ const fromEnv = process.env.SESSION_SECRET;
+ if (fromEnv) return fromEnv;
+
+ const fromFile = readFileEnv(process.env.SESSION_SECRET_FILE);
+ if (fromFile) return fromFile;
+
+ const fromAdmin = configManager.get('sessionSecret', '');
+ return fromAdmin || '';
+}
+
+export function hasSessionSecret(): boolean {
+ return getSessionSecret().length > 0;
+}
diff --git a/lib/settings-sync.ts b/lib/settings-sync.ts
index 297baa29..6a5fb1be 100644
--- a/lib/settings-sync.ts
+++ b/lib/settings-sync.ts
@@ -3,14 +3,14 @@ import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
-import { readFileEnv } from '@/lib/read-file-env';
+import { getSessionSecret } from '@/lib/auth/session-secret';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
function getKey(): Buffer {
- const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
+ const secret = getSessionSecret();
if (!secret) throw new Error('SESSION_SECRET not configured');
return createHash('sha256').update(secret).digest();
}
diff --git a/lib/setup/session.ts b/lib/setup/session.ts
new file mode 100644
index 00000000..e4922e4e
--- /dev/null
+++ b/lib/setup/session.ts
@@ -0,0 +1,33 @@
+import { cookies } from 'next/headers';
+import { verifySetupToken } from './token';
+
+export const SETUP_COOKIE = 'bulwark_setup_token';
+const COOKIE_MAX_AGE = 60 * 60; // 1 hour, matches token TTL
+
+/**
+ * The wizard "session" is just the setup token itself, set as an HttpOnly
+ * cookie after the operator pastes it into step 1. Subsequent step calls
+ * re-verify the cookie value against the .setup-token file. When the wizard
+ * finishes, the token file is deleted and any cookies become useless.
+ *
+ * No JWT, no separate signing key, no rotating session id. The lifecycle of
+ * the wizard maps 1:1 to the lifecycle of the token file.
+ */
+
+export async function authenticateWizardRequest(): Promise {
+ const jar = await cookies();
+ const token = jar.get(SETUP_COOKIE)?.value;
+ if (!token) return false;
+ return verifySetupToken(token);
+}
+
+export function buildSessionCookieAttributes() {
+ return {
+ name: SETUP_COOKIE,
+ httpOnly: true,
+ sameSite: 'lax' as const,
+ secure: process.env.NODE_ENV === 'production',
+ path: '/',
+ maxAge: COOKIE_MAX_AGE,
+ };
+}
diff --git a/lib/setup/state.ts b/lib/setup/state.ts
new file mode 100644
index 00000000..ee23f9b2
--- /dev/null
+++ b/lib/setup/state.ts
@@ -0,0 +1,53 @@
+import { existsSync } from 'node:fs';
+import { configManager } from '@/lib/admin/config-manager';
+import { getConfigPath, isConfigReadOnly } from '@/lib/admin/paths';
+
+/**
+ * The three lifecycle states for the running container.
+ *
+ * bootstrap - no config persisted yet and no JMAP_SERVER_URL env. The
+ * setup wizard is served at /setup; everything else 302s
+ * there.
+ * configured - setup wizard finished (admin override config.json carries
+ * setupComplete=true). Normal app; /setup returns 404.
+ * env-managed - JMAP_SERVER_URL is set in the environment, so the
+ * operator is configuring via .env (legacy / CI path). The
+ * wizard stays disabled.
+ */
+export type SetupState = 'bootstrap' | 'configured' | 'env-managed';
+
+/**
+ * Cheap to call on every request. configManager keeps `setupComplete` in
+ * memory after the initial load, so this is just env reads + an in-memory
+ * boolean check.
+ */
+export function detectSetupState(): SetupState {
+ if (configManager.isSetupComplete()) return 'configured';
+ if (process.env.JMAP_SERVER_URL && process.env.JMAP_SERVER_URL.trim() !== '') {
+ return 'env-managed';
+ }
+ // Read-only config dir + no setupComplete flag means the volume was
+ // mounted :ro before the wizard ran. Fall through to bootstrap so the
+ // failure (write attempt during wizard) surfaces with a clear error
+ // rather than silently 404'ing /setup.
+ if (isConfigReadOnly()) return 'bootstrap';
+ return 'bootstrap';
+}
+
+/**
+ * Whether the wizard's UI and APIs should be reachable.
+ */
+export function isSetupActive(): boolean {
+ return detectSetupState() === 'bootstrap';
+}
+
+/**
+ * The persisted `.config-locked` marker the wizard drops when the operator
+ * checks "lock configuration after setup" on the review screen. Purely
+ * advisory - the actual locking is the operator's `:ro` mount or the
+ * ADMIN_CONFIG_READONLY env var. This file is what the admin UI uses to
+ * remind the operator that they intended to lock.
+ */
+export function lockMarkerExists(): boolean {
+ return existsSync(getConfigPath('.config-locked'));
+}
diff --git a/lib/setup/token.ts b/lib/setup/token.ts
new file mode 100644
index 00000000..58fd4de8
--- /dev/null
+++ b/lib/setup/token.ts
@@ -0,0 +1,111 @@
+import { randomBytes, timingSafeEqual } from 'node:crypto';
+import { readFile, writeFile, unlink, stat } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { logger } from '@/lib/logger';
+import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
+
+const TOKEN_FILE = '.setup-token';
+const TOKEN_BYTES = 32;
+const DEFAULT_TTL_SECONDS = 60 * 60; // 1 hour
+
+interface TokenPayload {
+ token: string;
+ issuedAt: number;
+ ttlSeconds: number;
+}
+
+/**
+ * Read the current token if one exists and hasn't expired. Stale tokens
+ * are deleted lazily - first stale read removes the file.
+ */
+async function readToken(): Promise {
+ const path = getStatePath(TOKEN_FILE);
+ if (!existsSync(path)) return null;
+ try {
+ const raw = await readFile(path, 'utf-8');
+ const payload = JSON.parse(raw) as TokenPayload;
+ if (Date.now() / 1000 - payload.issuedAt > payload.ttlSeconds) {
+ try { await unlink(path); } catch { /* ok */ }
+ return null;
+ }
+ return payload;
+ } catch (error) {
+ logger.warn('Failed to read setup token', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return null;
+ }
+}
+
+/**
+ * Generate (or refresh) the setup token. Called at startup when the app
+ * detects bootstrap state. Idempotent: returns the existing token if it's
+ * still valid, otherwise issues a fresh one.
+ *
+ * The token lands in a file in ADMIN_STATE_DIR (always writable, never
+ * read-only) and is also printed to the container logs so the operator
+ * can copy it without execing into the container.
+ */
+export async function ensureSetupToken(ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise {
+ const existing = await readToken();
+ if (existing) return existing.token;
+
+ await ensureStateDir();
+ const token = randomBytes(TOKEN_BYTES).toString('hex');
+ const payload: TokenPayload = {
+ token,
+ issuedAt: Math.floor(Date.now() / 1000),
+ ttlSeconds,
+ };
+ const path = getStatePath(TOKEN_FILE);
+ await writeFile(path, JSON.stringify(payload, null, 2), 'utf-8');
+ return token;
+}
+
+/**
+ * Verify a token submitted by the wizard. Constant-time comparison; never
+ * leak the stored token via timing.
+ */
+export async function verifySetupToken(submitted: string): Promise {
+ if (!submitted || typeof submitted !== 'string') return false;
+ const stored = await readToken();
+ if (!stored) return false;
+
+ const a = Buffer.from(submitted);
+ const b = Buffer.from(stored.token);
+ if (a.length !== b.length) return false;
+ return timingSafeEqual(a, b);
+}
+
+/**
+ * Delete the token file. Called by the wizard's finish endpoint after
+ * setupComplete=true is persisted.
+ */
+export async function clearSetupToken(): Promise {
+ const path = getStatePath(TOKEN_FILE);
+ try {
+ await unlink(path);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
+ logger.warn('Failed to clear setup token', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+}
+
+/**
+ * For diagnostics / startup logging.
+ */
+export async function getTokenInfo(): Promise<{ exists: boolean; expiresInSeconds: number | null }> {
+ const path = getStatePath(TOKEN_FILE);
+ if (!existsSync(path)) return { exists: false, expiresInSeconds: null };
+ try {
+ await stat(path);
+ const payload = await readToken();
+ if (!payload) return { exists: false, expiresInSeconds: null };
+ const elapsed = Date.now() / 1000 - payload.issuedAt;
+ return { exists: true, expiresInSeconds: Math.max(0, Math.floor(payload.ttlSeconds - elapsed)) };
+ } catch {
+ return { exists: false, expiresInSeconds: null };
+ }
+}
diff --git a/proxy.ts b/proxy.ts
index fed44af2..f4b69f68 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -2,6 +2,8 @@ import { type NextRequest, NextResponse } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
+import { configManager } from "./lib/admin/config-manager";
+import { detectSetupState } from "./lib/setup/state";
const intlMiddleware = createIntlMiddleware(routing);
@@ -11,8 +13,59 @@ const intlMiddleware = createIntlMiddleware(routing);
// requests for API routes, Next internals and static assets.
const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)|\.[^/]+$/;
+function isSetupPath(pathname: string): boolean {
+ return (
+ pathname === "/setup" ||
+ pathname.startsWith("/setup/") ||
+ pathname.startsWith("/api/setup")
+ );
+}
+
export async function proxy(request: NextRequest) {
- if (PROXY_SKIP_PATTERN.test(request.nextUrl.pathname)) {
+ // Resolve setup state before deciding what to skip. The first call after
+ // boot triggers the config load; subsequent calls are in-memory.
+ await configManager.ensureLoaded();
+ const setupState = detectSetupState();
+ const pathname = request.nextUrl.pathname;
+
+ if (setupState === "bootstrap") {
+ // Wizard active. Redirect HTML pages to /setup; let asset/internal
+ // requests through so the wizard UI can render. Block non-setup APIs
+ // with a 503 so cached SPA code doesn't silently call them.
+ const allowed =
+ isSetupPath(pathname) ||
+ pathname === "/api/health" ||
+ pathname.startsWith("/_next/") ||
+ pathname.startsWith("/branding/") ||
+ /\.[^/]+$/.test(pathname);
+
+ if (!allowed) {
+ if (pathname.startsWith("/api/")) {
+ return new NextResponse(
+ JSON.stringify({ error: "setup_required", message: "Initial setup has not completed." }),
+ { status: 503, headers: { "content-type": "application/json" } },
+ );
+ }
+ const url = request.nextUrl.clone();
+ url.pathname = "/setup";
+ url.search = request.nextUrl.search;
+ return NextResponse.redirect(url);
+ }
+ } else if (isSetupPath(pathname)) {
+ // Configured / env-managed: wizard is no longer reachable.
+ // - HTML /setup pages → redirect to admin login so users who reload
+ // the URL after setup don't see a dead "Not Found" page.
+ // - /api/setup/* → 404 (no reason to expose these endpoints).
+ if (pathname.startsWith("/api/setup")) {
+ return new NextResponse("Not Found", { status: 404 });
+ }
+ const url = request.nextUrl.clone();
+ url.pathname = "/admin/login";
+ url.search = "";
+ return NextResponse.redirect(url);
+ }
+
+ if (PROXY_SKIP_PATTERN.test(pathname)) {
return NextResponse.next();
}
@@ -50,9 +103,10 @@ export async function proxy(request: NextRequest) {
`media-src 'self' blob:`,
].join("; ");
- // Skip intl middleware for /admin routes - they have their own layout
- const pathname = request.nextUrl.pathname;
+ // Skip intl middleware for /admin and /setup routes - they have their
+ // own layout outside the [locale] tree.
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
+ const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/');
// When localePrefix is 'always', paths that already have a locale prefix
// (e.g. /en/settings) should not be re-processed by the intl middleware -
@@ -63,7 +117,7 @@ export async function proxy(request: NextRequest) {
);
let intlResponse: ReturnType | null = null;
- if (!isAdminRoute && !hasLocalePrefix) {
+ if (!isAdminRoute && !isSetupRoute && !hasLocalePrefix) {
try {
intlResponse = intlMiddleware(request);
} catch (error) {