diff --git a/.env.example b/.env.example index 527edf8..729312c 100644 --- a/.env.example +++ b/.env.example @@ -3,15 +3,16 @@ # PORT=3000 # === Authentication === -# Admin user seeded on first run (only if no users exist in DB) -AUTH_USER=admin -AUTH_PASS=change-me-on-first-login -# If your password includes "#" and you do not want to quote AUTH_PASS, use base64: -# AUTH_PASS_B64=Y2hhbmdlLW1lLW9uLWZpcnN0LWxvZ2lu +# On first run, visit http://localhost:3000/setup to create your admin account. +# Alternatively, set AUTH_USER/AUTH_PASS to seed an admin from env (useful for CI/automation). +# AUTH_USER=admin +# AUTH_PASS=your-strong-password-here +# If your password includes "#", use base64: AUTH_PASS_B64= # Example: echo -n 'my#password' | base64 # API key for headless/external access (x-api-key header) -API_KEY=generate-a-random-key +# Auto-generated on first run if not set. Persisted to .data/.auto-generated. +# API_KEY= # Primary gateway defaults (used by /api/gateways seeding if DB is empty) MC_DEFAULT_GATEWAY_NAME=primary @@ -48,7 +49,8 @@ GOOGLE_CLIENT_ID= NEXT_PUBLIC_GOOGLE_CLIENT_ID= # Legacy cookie auth (backward compat, can be removed once all clients use session auth) -AUTH_SECRET=random-secret-for-legacy-cookies +# Auto-generated on first run if not set. Persisted to .data/.auto-generated. +# AUTH_SECRET= # Coordinator identity (used for coordinator chat status replies and comms UI) MC_COORDINATOR_AGENT=coordinator diff --git a/.gitignore b/.gitignore index 1e0d2b5..30e8f42 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,6 @@ playwright-report/ /e2e-debug-*.png /e2e-channels-*.png -# Claude Code context files -CLAUDE.md +# Claude Code context files (root CLAUDE.md is committed for AI agent discovery) **/CLAUDE.md +!/CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8a60cf6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# Mission Control + +Open-source dashboard for AI agent orchestration. Manage agent fleets, track tasks, monitor costs, and orchestrate workflows. + +**Stack**: Next.js 16, React 19, TypeScript 5, SQLite (better-sqlite3), Tailwind CSS 3, Zustand, pnpm + +## Prerequisites + +- Node.js >= 22 (LTS recommended; 24.x also supported) +- pnpm (`corepack enable` to auto-install) + +## Setup + +```bash +pnpm install +pnpm build +``` + +Secrets (AUTH_SECRET, API_KEY) auto-generate on first run if not set. +Visit `http://localhost:3000/setup` to create an admin account, or set `AUTH_USER`/`AUTH_PASS` in `.env` for headless/CI seeding. + +## Run + +```bash +pnpm dev # development (localhost:3000) +pnpm start # production +node .next/standalone/server.js # standalone mode (after build) +``` + +## Docker + +```bash +docker compose up # zero-config +bash install.sh --docker # full guided setup +``` + +Production hardening: `docker compose -f docker-compose.yml -f docker-compose.hardened.yml up -d` + +## Tests + +```bash +pnpm test # unit tests (vitest) +pnpm test:e2e # end-to-end (playwright) +pnpm typecheck # tsc --noEmit +pnpm lint # eslint +pnpm test:all # lint + typecheck + test + build + e2e +``` + +## Key Directories + +``` +src/app/ Next.js pages + API routes (App Router) +src/components/ UI panels and shared components +src/lib/ Core logic, database, utilities +.data/ SQLite database + runtime state (gitignored) +scripts/ Install, deploy, diagnostics scripts +docs/ Documentation and guides +``` + +Path alias: `@/*` maps to `./src/*` + +## Data Directory + +Set `MISSION_CONTROL_DATA_DIR` env var to change the data location (defaults to `.data/`). +Database path: `MISSION_CONTROL_DB_PATH` (defaults to `.data/mission-control.db`). + +## Conventions + +- **Commits**: Conventional Commits (`feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:`) +- **No AI attribution**: Never add `Co-Authored-By` or similar trailers to commits +- **Package manager**: pnpm only (no npm/yarn) +- **Icons**: No icon libraries -- use raw text/emoji in components +- **Standalone output**: `next.config.js` sets `output: 'standalone'` + +## Common Pitfalls + +- **Standalone mode**: Use `node .next/standalone/server.js`, not `pnpm start` (which requires full `node_modules`) +- **better-sqlite3**: Native addon -- needs rebuild when switching Node versions (`pnpm rebuild better-sqlite3`) +- **AUTH_PASS with `#`**: Quote it (`AUTH_PASS="my#pass"`) or use `AUTH_PASS_B64` (base64-encoded) +- **Gateway optional**: Set `NEXT_PUBLIC_GATEWAY_OPTIONAL=true` for standalone deployments without gateway connectivity diff --git a/Dockerfile b/Dockerfile index 3a48b7e..2443239 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,10 +38,12 @@ COPY --from=build /app/src/lib/schema.sql ./src/lib/schema.sql # Create data directory with correct ownership for SQLite RUN mkdir -p .data && chown nextjs:nodejs .data RUN echo 'const http=require("http");const r=http.get("http://localhost:"+(process.env.PORT||3000)+"/api/status?action=health",s=>{process.exit(s.statusCode===200?0:1)});r.on("error",()=>process.exit(1));r.setTimeout(4000,()=>{r.destroy();process.exit(1)})' > /app/healthcheck.js +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh USER nextjs ENV PORT=3000 EXPOSE 3000 ENV HOSTNAME=0.0.0.0 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD ["node", "/app/healthcheck.js"] -CMD ["node", "server.js"] +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/README.md b/README.md index b416977..38cef32 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ cd mission-control bash install.sh --docker ``` -The installer auto-generates secure credentials, starts the container, and runs an OpenClaw fleet health check. Open `http://localhost:3000` and log in with the printed credentials. +The installer auto-generates secure credentials, starts the container, and runs an OpenClaw fleet health check. Open `http://localhost:3000` to create your admin account. ### One-Command Install (Local) @@ -66,12 +66,12 @@ git clone https://github.com/builderz-labs/mission-control.git cd mission-control nvm use 22 # or: nvm use 24 pnpm install -cp .env.example .env # edit with your values -pnpm dev # http://localhost:3000 +pnpm dev # http://localhost:3000/setup ``` -Initial login is seeded from `AUTH_USER` / `AUTH_PASS` on first run. -If `AUTH_PASS` contains `#`, quote it (e.g. `AUTH_PASS="my#password"`) or use `AUTH_PASS_B64`. +On first run, visit `http://localhost:3000/setup` to create your admin account. Secrets (`AUTH_SECRET`, `API_KEY`) are auto-generated and persisted to `.data/`. + +For CI/automation, set `AUTH_USER` and `AUTH_PASS` env vars to seed the admin from environment instead. ## Gateway Optional Mode (Standalone Deployment) @@ -99,6 +99,14 @@ Requires active gateway: For production VPS setups, you can also proxy gateway WebSockets over 443. See `docs/deployment.md`. +### Docker Zero-Config + +```bash +docker compose up +``` + +No `.env` file needed. The container auto-generates `AUTH_SECRET` and `API_KEY` on first boot and persists them across restarts. Visit `http://localhost:3000` to create your admin account. + ### Docker Hardening (Production) For production deployments, use the hardened compose overlay: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..d3668a0 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -e + +# --- Source .env if present --- +if [ -f /app/.env ]; then + printf '[entrypoint] Loading .env\n' + set -a + . /app/.env + set +a +fi + +# --- Helper: generate a random hex secret --- +generate_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + else + head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' + fi +} + +SECRETS_FILE="/app/.data/.generated-secrets" + +# Load previously generated secrets if they exist +if [ -f "$SECRETS_FILE" ]; then + printf '[entrypoint] Loading persisted secrets from .data\n' + set -a + . "$SECRETS_FILE" + set +a +fi + +# --- AUTH_SECRET --- +if [ -z "$AUTH_SECRET" ] || [ "$AUTH_SECRET" = "random-secret-for-legacy-cookies" ]; then + AUTH_SECRET=$(generate_secret) + printf '[entrypoint] Generated new AUTH_SECRET\n' + printf 'AUTH_SECRET=%s\n' "$AUTH_SECRET" >> "$SECRETS_FILE" + export AUTH_SECRET +fi + +# --- API_KEY --- +if [ -z "$API_KEY" ] || [ "$API_KEY" = "generate-a-random-key" ]; then + API_KEY=$(generate_secret) + printf '[entrypoint] Generated new API_KEY\n' + printf 'API_KEY=%s\n' "$API_KEY" >> "$SECRETS_FILE" + export API_KEY +fi + +printf '[entrypoint] Starting server\n' +exec node server.js diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 22f4879..3fe7dd6 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server' import { authenticateUser, createSession } from '@/lib/auth' -import { logAuditEvent } from '@/lib/db' +import { logAuditEvent, needsFirstTimeSetup } from '@/lib/db' import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure } from '@/lib/session-cookie' import { loginLimiter } from '@/lib/rate-limit' import { logger } from '@/lib/logger' @@ -22,6 +22,19 @@ export async function POST(request: Request) { const user = authenticateUser(username, password) if (!user) { logAuditEvent({ action: 'login_failed', actor: username, ip_address: ipAddress, user_agent: userAgent }) + + // When no users exist at all, give actionable feedback instead of "Invalid credentials" + if (needsFirstTimeSetup()) { + return NextResponse.json( + { + error: 'No admin account has been created yet', + code: 'NO_USERS', + hint: 'Visit /setup to create your admin account', + }, + { status: 401 } + ) + } + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) } diff --git a/src/app/api/setup/route.ts b/src/app/api/setup/route.ts new file mode 100644 index 0000000..384390e --- /dev/null +++ b/src/app/api/setup/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from 'next/server' +import { needsFirstTimeSetup } from '@/lib/db' +import { createUser, createSession } from '@/lib/auth' +import { logAuditEvent } from '@/lib/db' +import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure } from '@/lib/session-cookie' +import { logger } from '@/lib/logger' + +const INSECURE_PASSWORDS = new Set([ + 'admin', + 'password', + 'change-me-on-first-login', + 'changeme', + 'testpass123', +]) + +export async function GET() { + return NextResponse.json({ needsSetup: needsFirstTimeSetup() }) +} + +export async function POST(request: Request) { + try { + // Only allow setup when no users exist + if (!needsFirstTimeSetup()) { + return NextResponse.json( + { error: 'Setup has already been completed' }, + { status: 403 } + ) + } + + const body = await request.json() + const { username, password, displayName } = body as { + username?: string + password?: string + displayName?: string + } + + // Validate username + if (!username || typeof username !== 'string') { + return NextResponse.json({ error: 'Username is required' }, { status: 400 }) + } + const trimmedUsername = username.trim().toLowerCase() + if (trimmedUsername.length < 2 || trimmedUsername.length > 64) { + return NextResponse.json({ error: 'Username must be 2-64 characters' }, { status: 400 }) + } + if (!/^[a-z0-9_.-]+$/.test(trimmedUsername)) { + return NextResponse.json( + { error: 'Username can only contain lowercase letters, numbers, dots, hyphens, and underscores' }, + { status: 400 } + ) + } + + // Validate password + if (!password || typeof password !== 'string') { + return NextResponse.json({ error: 'Password is required' }, { status: 400 }) + } + if (password.length < 12) { + return NextResponse.json({ error: 'Password must be at least 12 characters' }, { status: 400 }) + } + if (INSECURE_PASSWORDS.has(password)) { + return NextResponse.json({ error: 'That password is too common. Choose a stronger one.' }, { status: 400 }) + } + + // Double-check no users exist (race safety — createUser will also fail on duplicate username) + if (!needsFirstTimeSetup()) { + return NextResponse.json( + { error: 'Another admin was created while you were setting up' }, + { status: 409 } + ) + } + + const resolvedDisplayName = displayName?.trim() || + trimmedUsername.charAt(0).toUpperCase() + trimmedUsername.slice(1) + + const user = createUser(trimmedUsername, password, resolvedDisplayName, 'admin') + + const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown' + const userAgent = request.headers.get('user-agent') || undefined + + logAuditEvent({ + action: 'setup_admin_created', + actor: user.username, + actor_id: user.id, + ip_address: ipAddress, + user_agent: userAgent, + }) + + logger.info(`First-time setup: admin user "${user.username}" created`) + + // Auto-login: create session and set cookie + const { token, expiresAt } = createSession(user.id, ipAddress, userAgent, user.workspace_id) + + const response = NextResponse.json({ + user: { + id: user.id, + username: user.username, + display_name: user.display_name, + role: user.role, + }, + }) + + const isSecureRequest = isRequestSecure(request) + const cookieName = getMcSessionCookieName(isSecureRequest) + response.cookies.set(cookieName, token, { + ...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }), + }) + + return response + } catch (error) { + logger.error({ err: error }, 'Setup error') + return NextResponse.json({ error: 'Failed to create admin account' }, { status: 500 }) + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index dfdc321..b5bc603 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -29,6 +29,7 @@ type LoginRequestBody = type LoginErrorPayload = { code?: string error?: string + hint?: string } function readLoginErrorPayload(value: unknown): LoginErrorPayload { @@ -37,6 +38,7 @@ function readLoginErrorPayload(value: unknown): LoginErrorPayload { return { code: typeof record.code === 'string' ? record.code : undefined, error: typeof record.error === 'string' ? record.error : undefined, + hint: typeof record.hint === 'string' ? record.hint : undefined, } } @@ -62,6 +64,7 @@ export default function LoginPage() { const [password, setPassword] = useState('') const [error, setError] = useState('') const [pendingApproval, setPendingApproval] = useState(false) + const [needsSetup, setNeedsSetup] = useState(false) const [loading, setLoading] = useState(false) const [googleLoading, setGoogleLoading] = useState(false) const [googleReady, setGoogleReady] = useState(false) @@ -69,6 +72,20 @@ export default function LoginPage() { const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '' + // Check if first-time setup is needed on page load — auto-redirect to /setup + useEffect(() => { + fetch('/api/setup') + .then((res) => res.json()) + .then((data) => { + if (data.needsSetup) { + window.location.href = '/setup' + } + }) + .catch(() => { + // Ignore — setup check is best-effort + }) + }, []) + const completeLogin = useCallback(async (path: string, body: LoginRequestBody) => { const res = await fetch(path, { method: 'POST', @@ -80,6 +97,14 @@ export default function LoginPage() { const data = readLoginErrorPayload(await res.json().catch(() => null)) if (data.code === 'PENDING_APPROVAL') { setPendingApproval(true) + setNeedsSetup(false) + setError('') + setLoading(false) + setGoogleLoading(false) + return false + } + if (data.code === 'NO_USERS') { + setNeedsSetup(true) setError('') setLoading(false) setGoogleLoading(false) @@ -87,6 +112,7 @@ export default function LoginPage() { } setError(data.error || 'Login failed') setPendingApproval(false) + setNeedsSetup(false) setLoading(false) setGoogleLoading(false) return false @@ -202,6 +228,29 @@ export default function LoginPage() { )} + {needsSetup && ( +
+
+ + + + + +
+
No admin account created yet
+

+ Set up your admin account to get started with Mission Control. +

+ +
+ )} + {error && (
{error} diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx new file mode 100644 index 0000000..58b531e --- /dev/null +++ b/src/app/setup/page.tsx @@ -0,0 +1,313 @@ +'use client' + +import { useCallback, useEffect, useState, type FormEvent } from 'react' +import Image from 'next/image' +import { Button } from '@/components/ui/button' + +type SetupStep = 'form' | 'creating' + +interface ProgressStep { + label: string + status: 'pending' | 'active' | 'done' | 'error' +} + +const INITIAL_PROGRESS: ProgressStep[] = [ + { label: 'Validating credentials', status: 'pending' }, + { label: 'Creating admin account', status: 'pending' }, + { label: 'Configuring session', status: 'pending' }, + { label: 'Launching dashboard', status: 'pending' }, +] + +function ProgressIndicator({ steps }: { steps: ProgressStep[] }) { + return ( +
+ {steps.map((step, i) => ( +
+
+ {step.status === 'done' && ( + + + + )} + {step.status === 'active' && ( +
+ )} + {step.status === 'pending' && ( +
+ )} + {step.status === 'error' && ( + + + + + )} +
+ + {step.label} + +
+ ))} +
+ ) +} + +export default function SetupPage() { + const [username, setUsername] = useState('admin') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [displayName, setDisplayName] = useState('') + const [error, setError] = useState('') + const [step, setStep] = useState('form') + const [progress, setProgress] = useState(INITIAL_PROGRESS) + const [checking, setChecking] = useState(true) + const [setupAvailable, setSetupAvailable] = useState(false) + + useEffect(() => { + fetch('/api/setup') + .then((res) => res.json()) + .then((data) => { + if (!data.needsSetup) { + window.location.href = '/login' + return + } + setSetupAvailable(true) + setChecking(false) + }) + .catch(() => { + setError('Failed to check setup status') + setChecking(false) + }) + }, []) + + const updateProgress = useCallback((index: number, status: ProgressStep['status']) => { + setProgress((prev) => prev.map((s, i) => (i === index ? { ...s, status } : s))) + }, []) + + const handleSubmit = useCallback(async (e: FormEvent) => { + e.preventDefault() + setError('') + + if (password !== confirmPassword) { + setError('Passwords do not match') + return + } + if (password.length < 12) { + setError('Password must be at least 12 characters') + return + } + + setStep('creating') + setProgress(INITIAL_PROGRESS) + + // Step 1: Validating + updateProgress(0, 'active') + await new Promise((r) => setTimeout(r, 400)) + updateProgress(0, 'done') + + // Step 2: Creating account + updateProgress(1, 'active') + try { + const res = await fetch('/api/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + password, + displayName: displayName || undefined, + }), + }) + + if (!res.ok) { + const data = await res.json().catch(() => null) + updateProgress(1, 'error') + setError(data?.error || 'Setup failed') + // Allow retry after a brief pause + await new Promise((r) => setTimeout(r, 1500)) + setStep('form') + setProgress(INITIAL_PROGRESS) + return + } + + updateProgress(1, 'done') + + // Step 3: Configuring session + updateProgress(2, 'active') + await new Promise((r) => setTimeout(r, 500)) + updateProgress(2, 'done') + + // Step 4: Launching + updateProgress(3, 'active') + await new Promise((r) => setTimeout(r, 300)) + updateProgress(3, 'done') + + await new Promise((r) => setTimeout(r, 500)) + window.location.href = '/' + } catch { + updateProgress(1, 'error') + setError('Network error') + await new Promise((r) => setTimeout(r, 1500)) + setStep('form') + setProgress(INITIAL_PROGRESS) + } + }, [username, password, confirmPassword, displayName, updateProgress]) + + if (checking) { + return ( +
+
+
+ Checking setup status... +
+
+ ) + } + + if (!setupAvailable) { + return null + } + + return ( +
+
+
+
+ Mission Control logo +
+

+ {step === 'form' ? 'Welcome to Mission Control' : 'Setting up Mission Control'} +

+

+ {step === 'form' + ? 'Create your admin account to get started' + : 'Creating your admin account...'} +

+
+ + {step === 'creating' && ( +
+ + {error && ( +
+ {error} +
+ )} +
+ )} + + {step === 'form' && ( + <> + {error && ( +
+ {error} +
+ )} + +
+
+ + setUsername(e.target.value)} + className="w-full h-10 px-3 rounded-lg bg-secondary border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-smooth" + placeholder="admin" + autoComplete="username" + autoFocus + required + minLength={2} + maxLength={64} + pattern="[a-z0-9_.\-]+" + title="Lowercase letters, numbers, dots, hyphens, and underscores only" + /> +
+ +
+ + setDisplayName(e.target.value)} + className="w-full h-10 px-3 rounded-lg bg-secondary border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-smooth" + placeholder="Admin" + maxLength={100} + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full h-10 px-3 rounded-lg bg-secondary border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-smooth" + placeholder="At least 12 characters" + autoComplete="new-password" + required + minLength={12} + /> + {password.length > 0 && password.length < 12 && ( +

+ {12 - password.length} more character{12 - password.length !== 1 ? 's' : ''} needed +

+ )} +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full h-10 px-3 rounded-lg bg-secondary border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-smooth" + placeholder="Repeat your password" + autoComplete="new-password" + required + minLength={12} + /> + {confirmPassword.length > 0 && password !== confirmPassword && ( +

Passwords do not match

+ )} +
+ + +
+ +

+ This page is only available during first-time setup. +

+ + )} +
+
+ ) +} diff --git a/src/lib/auto-credentials.ts b/src/lib/auto-credentials.ts new file mode 100644 index 0000000..9e80346 --- /dev/null +++ b/src/lib/auto-credentials.ts @@ -0,0 +1,106 @@ +import { randomBytes } from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { config, ensureDirExists } from './config' +import { logger } from './logger' + +function getGeneratedFilePath(): string { + return path.join(config.dataDir, '.auto-generated') +} + +interface PersistedValues { + AUTH_SECRET?: string + API_KEY?: string +} + +function readPersisted(): PersistedValues { + try { + if (!fs.existsSync(getGeneratedFilePath())) return {} + const raw = fs.readFileSync(getGeneratedFilePath(), 'utf8') + const values: PersistedValues = {} + for (const line of raw.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIdx = trimmed.indexOf('=') + if (eqIdx < 0) continue + const key = trimmed.slice(0, eqIdx).trim() + const value = trimmed.slice(eqIdx + 1).trim() + if (key === 'AUTH_SECRET' || key === 'API_KEY') { + values[key] = value + } + } + return values + } catch { + return {} + } +} + +function writePersisted(values: PersistedValues): void { + try { + ensureDirExists(config.dataDir) + const lines = [ + '# Auto-generated values. Overridden by env vars when set.', + ] + if (values.AUTH_SECRET) lines.push(`AUTH_SECRET=${values.AUTH_SECRET}`) + if (values.API_KEY) lines.push(`API_KEY=${values.API_KEY}`) + fs.writeFileSync(getGeneratedFilePath(), lines.join('\n') + '\n', { mode: 0o600 }) + } catch (err) { + logger.warn({ err }, 'Failed to persist auto-generated values') + } +} + +function generate(): string { + return randomBytes(32).toString('hex') +} + +// Known placeholder values from .env.example that should be replaced +const PLACEHOLDER_AUTH_SECRETS = new Set([ + 'random-secret-for-legacy-cookies', +]) +const PLACEHOLDER_API_KEYS = new Set([ + 'generate-a-random-key', +]) + +/** + * Ensure AUTH_SECRET and API_KEY are available. + * Priority: env var > persisted file > auto-generate + persist. + * Sets process.env so downstream code picks them up. + */ +export function ensureAutoGeneratedCredentials(): void { + if (process.env.NEXT_PHASE === 'phase-production-build') return + + const persisted = readPersisted() + let dirty = false + + // AUTH_SECRET + const currentAuthSecret = (process.env.AUTH_SECRET || '').trim() + if (!currentAuthSecret || PLACEHOLDER_AUTH_SECRETS.has(currentAuthSecret)) { + if (persisted.AUTH_SECRET) { + process.env.AUTH_SECRET = persisted.AUTH_SECRET + } else { + const val = generate() + process.env.AUTH_SECRET = val + persisted.AUTH_SECRET = val + dirty = true + logger.info('Auto-generated AUTH_SECRET (persisted to .data/.auto-generated)') + } + } + + // API_KEY + const currentApiKey = (process.env.API_KEY || '').trim() + if (!currentApiKey || PLACEHOLDER_API_KEYS.has(currentApiKey)) { + if (persisted.API_KEY) { + process.env.API_KEY = persisted.API_KEY + } else { + const val = generate() + process.env.API_KEY = val + persisted.API_KEY = val + dirty = true + logger.info('Auto-generated API_KEY (persisted to .data/.auto-generated)') + } + } + + if (dirty) { + writePersisted(persisted) + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 9f8b4ca..54c13a3 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -6,6 +6,7 @@ import { eventBus } from './event-bus'; import { hashPassword } from './password'; import { logger } from './logger'; import { parseMentions as parseMentionTokens } from './mentions'; +import { ensureAutoGeneratedCredentials } from './auto-credentials'; // Database file location const DB_PATH = config.dbPath; @@ -20,6 +21,7 @@ const isTestMode = process.env.MISSION_CONTROL_TEST_MODE === '1' */ export function getDatabase(): Database.Database { if (!db) { + ensureAutoGeneratedCredentials(); ensureDirExists(dirname(DB_PATH)); db = new Database(DB_PATH); @@ -126,9 +128,10 @@ function seedAdminUserFromEnv(dbConn: Database.Database): void { const password = resolveSeedAuthPassword() if (!password) { - logger.warn( - 'AUTH_PASS is not set — skipping admin user seeding. ' + - 'Set AUTH_PASS (quote values containing #) or AUTH_PASS_B64 in your environment.' + // No AUTH_PASS set — admin will be created via /setup web wizard instead + logger.info( + 'AUTH_PASS is not set — admin account will be created via /setup. ' + + 'Set AUTH_PASS or AUTH_PASS_B64 to seed an admin from env (useful for CI/automation).' ) return } @@ -309,6 +312,20 @@ export interface ProvisionEvent { created_at: number } +/** + * Returns true when the database has zero users — i.e. first-time setup is needed. + * Safe to call during normal operation (fast single-row query). + */ +export function needsFirstTimeSetup(): boolean { + try { + const database = getDatabase() + const row = database.prepare('SELECT COUNT(*) as count FROM users').get() as CountRow + return row.count === 0 + } catch { + return false + } +} + // Database helper functions export const db_helpers = { /** diff --git a/src/proxy.ts b/src/proxy.ts index 0093d29..9d8ae15 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -171,9 +171,9 @@ export function proxy(request: NextRequest) { } } - // Allow login page, auth API, docs, and container health probe without session + // Allow login, setup, auth API, docs, and container health probe without session const isPublicHealthProbe = pathname === '/api/status' && request.nextUrl.searchParams.get('action') === 'health' - if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs' || isPublicHealthProbe) { + if (pathname === '/login' || pathname === '/setup' || pathname.startsWith('/api/auth/') || pathname === '/api/setup' || pathname === '/api/docs' || pathname === '/docs' || isPublicHealthProbe) { const { response, nonce } = nextResponseWithNonce(request) return addSecurityHeaders(response, request, nonce) }