From f0a82bad20382868e501da9af5e53e3aa3c2d7ce Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 11 Jul 2026 15:10:28 +0200 Subject: [PATCH] Add the first-run setup wizard API with env-backed secret store (#80) When the api runs against a database without the setup.completedAt marker, a global SetupGuard answers every non-exempt route with 503 setup_required; only /setup/*, health probes, and the session routes stay reachable. The wizard steps (POST /setup/admin|instance|smtp| registration|complete) write straight to their production homes; the Site Admin step signs its creator in, later steps require that session. Completing sets the marker and locks every step permanently (410, also across restarts, and not reopenable via PATCH /admin/settings). SMTP entered in the wizard is verified with a live delivery test first (failure blocks the step with the transport error as detail) and then persisted to the new env-backed secret store: a mode-600 dotenv file on the new `secrets` volume (SECRETS_FILE). Explicit container env always wins over the store; empty compose-passed strings count as unset. The mail transport now resolves lazily through SmtpConfigService so wizard changes apply without a restart. SETUP_ADMIN_* env pre-seeds the whole wizard at boot for automated deploys; a backfill migration marks instances that already have a Site Admin as completed, and seed/vitest global-setup do the same for fixture databases. The setup e2e suite provisions its own fresh database (CREATE DATABASE + migrate deploy) per run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- apps/api/Dockerfile | 4 +- .../migration.sql | 11 + apps/api/prisma/seed.ts | 11 + apps/api/src/admin/admin.controller.ts | 10 +- apps/api/src/app.module.ts | 4 + apps/api/src/auth/auth.controller.ts | 26 +- apps/api/src/auth/auth.guard.ts | 13 +- apps/api/src/config/app-config.service.ts | 8 +- apps/api/src/config/config.module.ts | 5 +- apps/api/src/config/secret-store.service.ts | 29 ++ apps/api/src/config/secret-store.test.ts | 58 +++ apps/api/src/config/secret-store.ts | 87 ++++ apps/api/src/health/health.controller.ts | 2 + apps/api/src/mail/mail-templates.ts | 2 +- apps/api/src/mail/mail-worker.service.ts | 4 +- apps/api/src/mail/mail.db.test.ts | 6 +- apps/api/src/mail/mail.module.ts | 24 +- apps/api/src/mail/smtp-config.service.ts | 81 ++++ .../src/settings/instance-settings.service.ts | 14 +- apps/api/src/setup/setup-state.service.ts | 36 ++ apps/api/src/setup/setup.controller.ts | 96 +++++ apps/api/src/setup/setup.e2e.db.test.ts | 385 ++++++++++++++++++ apps/api/src/setup/setup.guard.ts | 46 +++ apps/api/src/setup/setup.module.ts | 26 ++ apps/api/src/setup/setup.service.ts | 216 ++++++++++ apps/api/vitest.global-setup.ts | 17 +- deploy/compose/.env.example | 19 +- deploy/compose/docker-compose.yml | 26 +- docs/architecture/deployment.md | 17 +- packages/shared/i18n/de/errors.json | 5 + packages/shared/i18n/de/mails.json | 6 + packages/shared/i18n/en/errors.json | 5 + packages/shared/i18n/en/mails.json | 6 + packages/shared/src/env.ts | 22 + packages/shared/src/index.ts | 1 + packages/shared/src/setup.ts | 48 +++ 36 files changed, 1324 insertions(+), 52 deletions(-) create mode 100644 apps/api/prisma/migrations/20260711100000_setup_completed_backfill/migration.sql create mode 100644 apps/api/src/config/secret-store.service.ts create mode 100644 apps/api/src/config/secret-store.test.ts create mode 100644 apps/api/src/config/secret-store.ts create mode 100644 apps/api/src/mail/smtp-config.service.ts create mode 100644 apps/api/src/setup/setup-state.service.ts create mode 100644 apps/api/src/setup/setup.controller.ts create mode 100644 apps/api/src/setup/setup.e2e.db.test.ts create mode 100644 apps/api/src/setup/setup.guard.ts create mode 100644 apps/api/src/setup/setup.module.ts create mode 100644 apps/api/src/setup/setup.service.ts create mode 100644 packages/shared/src/setup.ts diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index c5291dc..a8f0a22 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -28,7 +28,7 @@ ARG APP_VERSION=0.0.0-dev # Default the data dirs to the writable, node-owned locations created below, so # the image works out of the box even where compose does not set them; compose # still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR). -ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins +ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins SECRETS_FILE=/data/secrets/secrets.env WORKDIR /app COPY --from=build --chown=node:node /out /app # Generate the Prisma client for this image's platform. @@ -37,7 +37,7 @@ RUN node node_modules/prisma/build/index.js generate # root-owned; pre-creating them here (Docker copies an image directory's # ownership into a new volume on first mount) lets the non-root `node` user # write to them. -RUN mkdir -p /data/uploads /data/plugins && chown -R node:node /data/uploads /data/plugins +RUN mkdir -p /data/uploads /data/plugins /data/secrets && chown -R node:node /data/uploads /data/plugins /data/secrets USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ diff --git a/apps/api/prisma/migrations/20260711100000_setup_completed_backfill/migration.sql b/apps/api/prisma/migrations/20260711100000_setup_completed_backfill/migration.sql new file mode 100644 index 0000000..bce18ea --- /dev/null +++ b/apps/api/prisma/migrations/20260711100000_setup_completed_backfill/migration.sql @@ -0,0 +1,11 @@ +-- First-run setup wizard (issue #80): instances that predate the wizard are +-- already configured — a Site Admin exists. Backfill the completion marker so +-- they never see the wizard (and its lock, 410, applies immediately). A truly +-- fresh database has no Site Admin, gets no marker, and requires setup. +INSERT INTO "instance_settings" ("key", "value", "updatedAt") +SELECT + 'setup.completedAt', + to_jsonb(to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + now() +WHERE EXISTS (SELECT 1 FROM "users" WHERE "is_site_admin") + AND NOT EXISTS (SELECT 1 FROM "instance_settings" WHERE "key" = 'setup.completedAt'); diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index bf928ee..0aabe27 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -380,6 +380,17 @@ async function main(): Promise { if (fixture.username === 'fixture-user') contentOwnerId = userId; } if (contentOwnerId) await seedContentFixtures(contentOwnerId); + // Seeded environments are configured by definition: mark the first-run + // setup wizard (issue #80) as completed so e2e stacks and stages never + // hit the setup gate. Never overwrite an existing (real) completion. + const setupMarker = await prisma.instanceSetting.findUnique({ + where: { key: 'setup.completedAt' }, + }); + if (!setupMarker) { + await prisma.instanceSetting.create({ + data: { key: 'setup.completedAt', value: new Date().toISOString() }, + }); + } await prisma.instanceSetting.upsert({ where: { key: 'seed.marker' }, create: { key: 'seed.marker', value: { seededAt: new Date().toISOString() } }, diff --git a/apps/api/src/admin/admin.controller.ts b/apps/api/src/admin/admin.controller.ts index 7c35e19..2a76b4d 100644 --- a/apps/api/src/admin/admin.controller.ts +++ b/apps/api/src/admin/admin.controller.ts @@ -11,13 +11,19 @@ import { } from '../settings/instance-settings.service'; import { SiteAdminGuard } from './site-admin.guard'; +// Lifecycle markers, not configuration: never editable through this +// endpoint (the setup lock must be irreversible, issue #80). +const INTERNAL_KEYS: ReadonlySet = new Set(['setup.completedAt']); + // Partial update: any subset of the known settings, each validated by // its own schema inside the service (double validation is fine — this -// outer schema only gates unknown keys). +// outer schema only gates unknown and internal keys). const patchSchema = z .object( Object.fromEntries( - Object.keys(INSTANCE_SETTINGS).map((key) => [key, z.unknown().optional()]), + Object.keys(INSTANCE_SETTINGS) + .filter((key) => !INTERNAL_KEYS.has(key as InstanceSettingKey)) + .map((key) => [key, z.unknown().optional()]), ) as Record>, ) .strict(); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 253bf3a..7e6f81c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -25,6 +25,7 @@ import { PublicModule } from './public/public.module'; import { RateLimitModule } from './rate-limit/rate-limit.module'; import { SearchModule } from './search/search.module'; import { SettingsModule } from './settings/settings.module'; +import { SetupModule } from './setup/setup.module'; import { TrashModule } from './trash/trash.module'; import { UsersModule } from './users/users.module'; import { VersionsModule } from './versions/versions.module'; @@ -36,6 +37,9 @@ import { VersionsModule } from './versions/versions.module'; RateLimitModule, MailModule, SettingsModule, + // Before AuthModule: global guards run in registration order, and the + // setup gate must win over AuthGuard's 401 while setup is pending. + SetupModule, UsersModule, PermissionsModule, PondsModule, diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 8d9af56..2ffba52 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -17,7 +17,14 @@ import { AppConfig } from '../config/app-config.service'; import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { RateLimit } from '../rate-limit/rate-limit.guard'; import { InstanceSettingsService } from '../settings/instance-settings.service'; -import { AuthedRequest, Public, SESSION_COOKIE, toCurrentUser } from './auth.guard'; +import { SetupExempt } from '../setup/setup.guard'; +import { + AuthedRequest, + Public, + SESSION_COOKIE, + setSessionCookie, + toCurrentUser, +} from './auth.guard'; import { AuthService } from './auth.service'; import { SessionsService } from './sessions.service'; @@ -66,6 +73,9 @@ export class AuthController { await this.auth.resendVerification(input.email); } + // Exempt from the setup gate: a mid-wizard Site Admin who lost the + // session cookie must be able to sign back in and finish setup. + @SetupExempt() @Public() @Post('login') @HttpCode(200) @@ -80,10 +90,11 @@ export class AuthController { input.password, request.headers['user-agent'], ); - this.setSessionCookie(response, sessionToken); + setSessionCookie(response, sessionToken, this.config.env.NODE_ENV === 'production'); return toCurrentUser(user); } + @SetupExempt() @Post('logout') @HttpCode(204) async logout( @@ -96,6 +107,7 @@ export class AuthController { response.clearCookie(SESSION_COOKIE, { path: '/' }); } + @SetupExempt() @Get('me') me(@Req() request: AuthedRequest): CurrentUserShape { // AuthGuard guarantees request.user for non-@Public routes. @@ -125,14 +137,4 @@ export class AuthController { ): Promise { await this.auth.resetPassword(input.token, input.password); } - - private setSessionCookie(response: Response, token: string): void { - response.cookie(SESSION_COOKIE, token, { - httpOnly: true, - sameSite: 'lax', - secure: this.config.env.NODE_ENV === 'production', - maxAge: 30 * 24 * 60 * 60 * 1000, - path: '/', - }); - } } diff --git a/apps/api/src/auth/auth.guard.ts b/apps/api/src/auth/auth.guard.ts index 088d2bc..80ba2e5 100644 --- a/apps/api/src/auth/auth.guard.ts +++ b/apps/api/src/auth/auth.guard.ts @@ -10,7 +10,7 @@ import { import { Reflector } from '@nestjs/core'; import type { CurrentUser as CurrentUserShape } from '@dorfteich/shared'; import type { User } from '@prisma/client'; -import type { Request } from 'express'; +import type { Request, Response } from 'express'; import { AppConfig } from '../config/app-config.service'; import { SessionsService } from './sessions.service'; @@ -43,6 +43,17 @@ export function toCurrentUser(user: User): CurrentUserShape { }; } +/** Session cookie contract shared by login and the setup wizard (issue #80). */ +export function setSessionCookie(response: Response, token: string, production: boolean): void { + response.cookie(SESSION_COOKIE, token, { + httpOnly: true, + sameSite: 'lax', + secure: production, + maxAge: 30 * 24 * 60 * 60 * 1000, + path: '/', + }); +} + const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); /** diff --git a/apps/api/src/config/app-config.service.ts b/apps/api/src/config/app-config.service.ts index c0abd78..0e88a2f 100644 --- a/apps/api/src/config/app-config.service.ts +++ b/apps/api/src/config/app-config.service.ts @@ -1,11 +1,17 @@ import { Injectable } from '@nestjs/common'; import { ApiEnv, apiEnvSchema, parseEnv } from '@dorfteich/shared'; +import { overlayEnv, readSecretsFile } from './secret-store'; + @Injectable() export class AppConfig { readonly env: ApiEnv; constructor() { - this.env = parseEnv(apiEnvSchema, process.env); + // Secrets the setup wizard persisted (SMTP credentials) extend the + // environment; explicit process env always wins (secret-store.ts). + const secretsFile = apiEnvSchema.shape.SECRETS_FILE.parse(process.env.SECRETS_FILE); + const secrets = readSecretsFile(secretsFile); + this.env = parseEnv(apiEnvSchema, overlayEnv(process.env, secrets)); } } diff --git a/apps/api/src/config/config.module.ts b/apps/api/src/config/config.module.ts index afd236e..3d540f0 100644 --- a/apps/api/src/config/config.module.ts +++ b/apps/api/src/config/config.module.ts @@ -1,6 +1,7 @@ import { Global, Module } from '@nestjs/common'; import { AppConfig } from './app-config.service'; +import { SecretStoreService } from './secret-store.service'; /** * Global so every module can inject AppConfig without importing this module. @@ -9,7 +10,7 @@ import { AppConfig } from './app-config.service'; */ @Global() @Module({ - providers: [AppConfig], - exports: [AppConfig], + providers: [AppConfig, SecretStoreService], + exports: [AppConfig, SecretStoreService], }) export class ConfigModule {} diff --git a/apps/api/src/config/secret-store.service.ts b/apps/api/src/config/secret-store.service.ts new file mode 100644 index 0000000..28c92df --- /dev/null +++ b/apps/api/src/config/secret-store.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { PinoLogger } from 'nestjs-pino'; + +import { AppConfig } from './app-config.service'; +import { readSecretsFile, writeSecretsFile } from './secret-store'; + +/** + * Injectable facade over the env-backed secret store file (secret-store.ts). + * Reading goes to disk every time — writes are rare (setup wizard) and the + * only frequent reader (SmtpConfigService) caches on its own terms. + */ +@Injectable() +export class SecretStoreService { + constructor( + private readonly config: AppConfig, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(SecretStoreService.name); + } + + read(): Record { + return readSecretsFile(this.config.env.SECRETS_FILE); + } + + async set(entries: Record): Promise { + await writeSecretsFile(this.config.env.SECRETS_FILE, entries); + this.logger.info({ keys: Object.keys(entries) }, 'audit: secret store updated'); + } +} diff --git a/apps/api/src/config/secret-store.test.ts b/apps/api/src/config/secret-store.test.ts new file mode 100644 index 0000000..99bbe5b --- /dev/null +++ b/apps/api/src/config/secret-store.test.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, readFileSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + overlayEnv, + parseSecretsFile, + readSecretsFile, + serializeSecrets, + writeSecretsFile, +} from './secret-store'; + +describe('secret store (env-backed, issue #80)', () => { + it('roundtrips values through serialize and parse, including escapes', () => { + const secrets = { + SMTP_HOST: 'mail.example.org', + SMTP_PASS: 'with "quotes", back\\slash and\nnewline', + SMTP_USER: '', + }; + expect(parseSecretsFile(serializeSecrets(secrets))).toEqual(secrets); + }); + + it('parses bare (unquoted) values and skips comments and noise', () => { + const parsed = parseSecretsFile( + ['# comment', '', 'SMTP_HOST=plain.example.org', 'not a pair', 'SMTP_PORT=587'].join('\n'), + ); + expect(parsed).toEqual({ SMTP_HOST: 'plain.example.org', SMTP_PORT: '587' }); + }); + + it('lets explicit env win over the store and treats empty strings as unset', () => { + const merged = overlayEnv( + { SMTP_HOST: 'from-env.example.org', SMTP_PORT: '', UNRELATED: undefined }, + { SMTP_HOST: 'from-store.example.org', SMTP_PORT: '2525', SMTP_USER: '' }, + ); + expect(merged.SMTP_HOST).toBe('from-env.example.org'); // env wins + expect(merged.SMTP_PORT).toBe('2525'); // empty env falls back to store + expect('SMTP_USER' in merged).toBe(false); // empty store value = unset + expect('UNRELATED' in merged).toBe(false); + }); + + it('writes atomically with owner-only permissions and merges entries', async () => { + const file = join(mkdtempSync(join(tmpdir(), 'dorfteich-secrets-')), 'nested', 'secrets.env'); + await writeSecretsFile(file, { SMTP_HOST: 'first.example.org', SMTP_PASS: 'geheim' }); + await writeSecretsFile(file, { SMTP_HOST: 'second.example.org' }); + expect(readSecretsFile(file)).toEqual({ + SMTP_HOST: 'second.example.org', // updated + SMTP_PASS: 'geheim', // preserved from the first write + }); + expect(statSync(file).mode & 0o777).toBe(0o600); + expect(readFileSync(file, 'utf8')).toContain('SMTP_HOST="second.example.org"'); + }); + + it('treats a missing file as an empty store', () => { + expect(readSecretsFile(join(tmpdir(), 'does-not-exist.env'))).toEqual({}); + }); +}); diff --git a/apps/api/src/config/secret-store.ts b/apps/api/src/config/secret-store.ts new file mode 100644 index 0000000..88da4f3 --- /dev/null +++ b/apps/api/src/config/secret-store.ts @@ -0,0 +1,87 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { chmod, mkdir, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +/** + * The env-backed secret store (security.md §Secrets, issue #80): secrets the + * setup wizard collects in the browser (SMTP credentials) are persisted as a + * mode-600 dotenv-style file on a volume — never as database rows. The file + * extends the environment: `overlayEnv` fills only variables the process + * environment does not set, so the stage `.env` always stays authoritative. + */ + +/** Parses the dotenv-style store content. Ignores blank lines and comments. */ +export function parseSecretsFile(content: string): Record { + const secrets: Record = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) { + value = value.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } + secrets[key] = value; + } + return secrets; +} + +/** Serializes secrets with double-quoted, escaped values (dotenv-compatible). */ +export function serializeSecrets(secrets: Record): string { + const lines = [ + '# Managed by Dorfteich (setup wizard). Values here fill environment', + '# variables that the container environment does not set explicitly.', + ]; + for (const [key, value] of Object.entries(secrets)) { + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + lines.push(`${key}="${escaped}"`); + } + return lines.join('\n') + '\n'; +} + +/** Reads the store file; a missing file is an empty store, not an error. */ +export function readSecretsFile(path: string): Record { + if (!existsSync(path)) return {}; + return parseSecretsFile(readFileSync(path, 'utf8')); +} + +/** + * Merges the store under the real environment: explicit env vars win, store + * values fill the gaps (and Zod defaults fill whatever remains at parse + * time). Empty strings count as unset on both sides — compose passes + * `${SMTP_HOST:-}` as `""` for variables the stage `.env` does not define, + * and those must not shadow wizard-written store values or schema defaults. + */ +export function overlayEnv( + env: Record, + secrets: Record, +): Record { + const merged: Record = {}; + for (const [key, value] of Object.entries(secrets)) { + if (value !== '') merged[key] = value; + } + for (const [key, value] of Object.entries(env)) { + if (value !== undefined && value !== '') merged[key] = value; + } + return merged; +} + +/** + * Merges entries into the store file atomically (staging file + rename, so a + * crash mid-write never leaves a torn file) and keeps it owner-only readable. + */ +export async function writeSecretsFile( + path: string, + entries: Record, +): Promise { + const merged = { ...readSecretsFile(path), ...entries }; + await mkdir(dirname(path), { recursive: true }); + const staging = `${path}.tmp-${process.pid}`; + await writeFile(staging, serializeSecrets(merged), { mode: 0o600 }); + await rename(staging, path); + // rename preserves the staging file's mode, but be explicit in case a + // pre-existing file with looser permissions was replaced on some platforms. + await chmod(path, 0o600); +} diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index 2319010..5fe1363 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -4,9 +4,11 @@ import type { Response } from 'express'; import { Public } from '../auth/auth.guard'; import { AppConfig } from '../config/app-config.service'; +import { SetupExempt } from '../setup/setup.guard'; import { ReadinessService } from './readiness.service'; @Public() +@SetupExempt() // deploys and monitors must see health during first-run setup @Controller() export class HealthController { constructor( diff --git a/apps/api/src/mail/mail-templates.ts b/apps/api/src/mail/mail-templates.ts index bb1e0a5..d40176c 100644 --- a/apps/api/src/mail/mail-templates.ts +++ b/apps/api/src/mail/mail-templates.ts @@ -1,6 +1,6 @@ import { apiI18n } from '../i18n/api-i18n'; -export type MailTemplate = 'verifyEmail' | 'resetPassword'; +export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest'; export interface RenderedMail { subject: string; diff --git a/apps/api/src/mail/mail-worker.service.ts b/apps/api/src/mail/mail-worker.service.ts index 01363ff..94289f6 100644 --- a/apps/api/src/mail/mail-worker.service.ts +++ b/apps/api/src/mail/mail-worker.service.ts @@ -4,6 +4,7 @@ import { PinoLogger } from 'nestjs-pino'; import { AppConfig } from '../config/app-config.service'; import { PrismaService } from '../prisma/prisma.service'; +import { SmtpConfigService } from './smtp-config.service'; /** Minimal transport contract — nodemailer in production, a fake in tests. */ export interface MailTransport { @@ -36,6 +37,7 @@ export class MailWorker implements OnModuleInit, OnModuleDestroy { private readonly prisma: PrismaService, private readonly config: AppConfig, @Inject(MAIL_TRANSPORT) private readonly transport: MailTransport, + private readonly smtpConfig: SmtpConfigService, private readonly logger: PinoLogger, ) { this.logger.setContext(MailWorker.name); @@ -72,7 +74,7 @@ export class MailWorker implements OnModuleInit, OnModuleDestroy { private async deliverOne(mail: MailOutbox): Promise { try { await this.transport.sendMail({ - from: this.config.env.SMTP_FROM, + from: this.smtpConfig.effective().from, to: mail.toAddress, subject: mail.subject, text: mail.textBody, diff --git a/apps/api/src/mail/mail.db.test.ts b/apps/api/src/mail/mail.db.test.ts index 3835264..5c63cca 100644 --- a/apps/api/src/mail/mail.db.test.ts +++ b/apps/api/src/mail/mail.db.test.ts @@ -7,6 +7,7 @@ import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { MailTransport, MailWorker } from './mail-worker.service'; import { renderMail } from './mail-templates'; import { MailService } from './mail.service'; +import { SmtpConfigService } from './smtp-config.service'; describe('mail templates', () => { it('renders both languages with link, action, and greeting', () => { @@ -52,7 +53,10 @@ describe.skipIf(!hasTestDb)('MailWorker (database)', () => { function makeWorker(transport: MailTransport): MailWorker { const logger = { setContext: vi.fn(), warn: vi.fn() } as unknown as PinoLogger; - return new MailWorker(prisma, config, transport, logger); + const smtpConfig = { + effective: () => ({ from: 'Test ' }), + } as unknown as SmtpConfigService; + return new MailWorker(prisma, config, transport, smtpConfig, logger); } it('delivers queued mail and marks it sent', async () => { diff --git a/apps/api/src/mail/mail.module.ts b/apps/api/src/mail/mail.module.ts index cba06d1..cb63c54 100644 --- a/apps/api/src/mail/mail.module.ts +++ b/apps/api/src/mail/mail.module.ts @@ -1,28 +1,24 @@ import { Module } from '@nestjs/common'; -import { createTransport } from 'nodemailer'; -import { AppConfig } from '../config/app-config.service'; -import { MAIL_TRANSPORT, MailWorker } from './mail-worker.service'; +import { MAIL_TRANSPORT, MailTransport, MailWorker } from './mail-worker.service'; import { MailService } from './mail.service'; +import { SmtpConfigService } from './smtp-config.service'; @Module({ providers: [ MailService, MailWorker, + SmtpConfigService, { + // Delegates per send so the wizard's SMTP changes (SmtpConfigService. + // refresh) take effect without restarting the worker. provide: MAIL_TRANSPORT, - inject: [AppConfig], - useFactory: (config: AppConfig) => - createTransport({ - host: config.env.SMTP_HOST, - port: config.env.SMTP_PORT, - secure: config.env.SMTP_SECURE, - auth: config.env.SMTP_USER - ? { user: config.env.SMTP_USER, pass: config.env.SMTP_PASS } - : undefined, - }), + inject: [SmtpConfigService], + useFactory: (smtp: SmtpConfigService): MailTransport => ({ + sendMail: (mail) => smtp.transport().sendMail(mail), + }), }, ], - exports: [MailService], + exports: [MailService, SmtpConfigService], }) export class MailModule {} diff --git a/apps/api/src/mail/smtp-config.service.ts b/apps/api/src/mail/smtp-config.service.ts new file mode 100644 index 0000000..77f4ae5 --- /dev/null +++ b/apps/api/src/mail/smtp-config.service.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { apiEnvSchema, parseEnv } from '@dorfteich/shared'; +import { createTransport, type Transporter } from 'nodemailer'; + +import { overlayEnv } from '../config/secret-store'; +import { SecretStoreService } from '../config/secret-store.service'; +import type { MailTransport } from './mail-worker.service'; + +export interface SmtpSettings { + host: string; + port: number; + secure: boolean; + user?: string; + pass?: string; + from: string; +} + +const smtpEnvSchema = apiEnvSchema.pick({ + SMTP_HOST: true, + SMTP_PORT: true, + SMTP_SECURE: true, + SMTP_USER: true, + SMTP_PASS: true, + SMTP_FROM: true, +}); + +/** + * Effective SMTP configuration at call time: explicit process env wins, the + * secret store (written by the setup wizard, issue #80) fills the gaps, Zod + * defaults cover the rest. Cached until `refresh()` — the wizard calls that + * after saving, so mail flows with the new relay without a restart. + */ +@Injectable() +export class SmtpConfigService { + private cache: { settings: SmtpSettings; transporter: Transporter } | undefined; + + constructor(private readonly secretStore: SecretStoreService) {} + + effective(): SmtpSettings { + return this.resolve().settings; + } + + /** Shared lazy transport for the mail outbox worker. */ + transport(): MailTransport { + return this.resolve().transporter; + } + + refresh(): void { + this.cache = undefined; + } + + /** Builds a transporter for arbitrary candidate settings (setup SMTP test). */ + buildTransport(settings: SmtpSettings): Transporter { + return createTransport({ + host: settings.host, + port: settings.port, + secure: settings.secure, + auth: settings.user ? { user: settings.user, pass: settings.pass } : undefined, + // Bounded waits so a wrong host fails the wizard step with a clear + // error instead of hanging the request. + connectionTimeout: 10_000, + greetingTimeout: 10_000, + socketTimeout: 20_000, + }); + } + + private resolve(): { settings: SmtpSettings; transporter: Transporter } { + if (this.cache) return this.cache; + const env = parseEnv(smtpEnvSchema, overlayEnv(process.env, this.secretStore.read())); + const settings: SmtpSettings = { + host: env.SMTP_HOST, + port: env.SMTP_PORT, + secure: env.SMTP_SECURE, + user: env.SMTP_USER, + pass: env.SMTP_PASS, + from: env.SMTP_FROM, + }; + this.cache = { settings, transporter: this.buildTransport(settings) }; + return this.cache; + } +} diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index a51376f..7dc4ce6 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { DEFAULT_ATTACHMENT_EXTENSIONS } from '@dorfteich/shared'; +import { Prisma } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { z } from 'zod'; @@ -47,6 +48,12 @@ export const INSTANCE_SETTINGS = { // SVG upload handling (security.md §Uploads): sanitize strips scripts and // event handlers with a maintained library; reject refuses SVG outright. 'upload.svgPolicy': z.enum(['reject', 'sanitize']).default('sanitize'), + // When the first-run setup wizard completed (issue #80). Null = the + // instance still requires setup and only /setup/* is reachable; once set + // the wizard is locked for good (SetupStateService). Written by the wizard, + // env pre-seeding, the fixture seed, and a backfill migration for + // instances that predate the wizard. + 'setup.completedAt': z.string().nullable().default(null), } as const; export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS; @@ -103,10 +110,13 @@ export class InstanceSettingsService { details: { [key]: parsed.error.issues.map((i) => i.message) }, }); } + // Nullable settings (setup.completedAt) store JSON null explicitly — + // Prisma requires the sentinel for that. + const stored = parsed.data === null ? Prisma.JsonNull : parsed.data; await this.prisma.instanceSetting.upsert({ where: { key }, - create: { key, value: parsed.data }, - update: { value: parsed.data }, + create: { key, value: stored }, + update: { value: stored }, }); this.cache.set(key, parsed.data); this.logger.info({ key, actorUserId }, 'audit: instance setting changed'); diff --git a/apps/api/src/setup/setup-state.service.ts b/apps/api/src/setup/setup-state.service.ts new file mode 100644 index 0000000..6ec8aae --- /dev/null +++ b/apps/api/src/setup/setup-state.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Whether the instance still requires the first-run setup wizard + * (issue #80). Setup is pending until `setup.completedAt` is written — + * by the wizard's complete step, by env pre-seeding, by the fixture seed, + * or by the backfill migration for instances that predate the wizard. + * + * Reads the row directly instead of going through InstanceSettingsService: + * that service caches misses, and a request hitting a still-pending + * instance must not freeze the pending state past an external seed (the + * e2e stacks seed a running api). Completion is permanent, so a completed + * answer is remembered for the process lifetime and costs nothing per + * request; while pending, the instance serves almost no traffic anyway. + */ +@Injectable() +export class SetupStateService { + private completed = false; + + constructor(private readonly prisma: PrismaService) {} + + async isPending(): Promise { + if (this.completed) return false; + const row = await this.prisma.instanceSetting.findUnique({ + where: { key: 'setup.completedAt' }, + select: { value: true }, + }); + if (typeof row?.value === 'string' && row.value) { + this.completed = true; + return false; + } + return true; + } +} diff --git a/apps/api/src/setup/setup.controller.ts b/apps/api/src/setup/setup.controller.ts new file mode 100644 index 0000000..ba5e756 --- /dev/null +++ b/apps/api/src/setup/setup.controller.ts @@ -0,0 +1,96 @@ +import { Body, Controller, Get, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common'; +import { + CurrentUser as CurrentUserShape, + SetupAdminInput, + SetupInstanceInput, + SetupRegistrationInput, + SetupSmtpInput, + SetupStatusView, + setupAdminInputSchema, + setupInstanceInputSchema, + setupRegistrationInputSchema, + setupSmtpInputSchema, +} from '@dorfteich/shared'; +import type { Response } from 'express'; + +import { SiteAdminGuard } from '../admin/site-admin.guard'; +import { AuthedRequest, Public, setSessionCookie, toCurrentUser } from '../auth/auth.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { AppConfig } from '../config/app-config.service'; +import { RateLimit } from '../rate-limit/rate-limit.guard'; +import { SetupExempt } from './setup.guard'; +import { SetupService } from './setup.service'; + +/** + * The first-run wizard endpoints (issue #80). Reachable while setup is + * pending; every step answers 410 `setup_locked` once the wizard completed + * (only the status stays readable — the SPA routes on it). Step 1 signs the + * created Site Admin in; the remaining steps require that session, so a + * second visitor cannot hijack a wizard someone else already started. + */ +@SetupExempt() +@Controller('setup') +export class SetupController { + constructor( + private readonly setup: SetupService, + private readonly config: AppConfig, + ) {} + + @Public() + @Get() + status(): Promise { + return this.setup.status(); + } + + @Public() + @Post('admin') + @HttpCode(201) + @RateLimit({ scope: 'setup', limit: 10, windowSeconds: 60 * 60 }) + async createAdmin( + @Body(new ZodValidationPipe(setupAdminInputSchema)) input: SetupAdminInput, + @Req() request: AuthedRequest, + @Res({ passthrough: true }) response: Response, + ): Promise { + const admin = await this.setup.createAdmin(input); + const sessionToken = await this.setup.startSession(admin, request.headers['user-agent']); + setSessionCookie(response, sessionToken, this.config.env.NODE_ENV === 'production'); + return toCurrentUser(admin); + } + + @UseGuards(SiteAdminGuard) + @Post('instance') + @HttpCode(204) + async applyInstance( + @Body(new ZodValidationPipe(setupInstanceInputSchema)) input: SetupInstanceInput, + @Req() request: AuthedRequest, + ): Promise { + await this.setup.applyInstance(input, request.user!); + } + + @UseGuards(SiteAdminGuard) + @Post('smtp') + @HttpCode(204) + async applySmtp( + @Body(new ZodValidationPipe(setupSmtpInputSchema)) input: SetupSmtpInput, + @Req() request: AuthedRequest, + ): Promise { + await this.setup.applySmtp(input, request.user!); + } + + @UseGuards(SiteAdminGuard) + @Post('registration') + @HttpCode(204) + async applyRegistration( + @Body(new ZodValidationPipe(setupRegistrationInputSchema)) input: SetupRegistrationInput, + @Req() request: AuthedRequest, + ): Promise { + await this.setup.applyRegistration(input, request.user!); + } + + @UseGuards(SiteAdminGuard) + @Post('complete') + @HttpCode(204) + async complete(@Req() request: AuthedRequest): Promise { + await this.setup.complete(request.user!); + } +} diff --git a/apps/api/src/setup/setup.e2e.db.test.ts b/apps/api/src/setup/setup.e2e.db.test.ts new file mode 100644 index 0000000..d2d719f --- /dev/null +++ b/apps/api/src/setup/setup.e2e.db.test.ts @@ -0,0 +1,385 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, statSync } from 'node:fs'; +import * as net from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { readSecretsFile } from '../config/secret-store'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { SetupService } from './setup.service'; + +/** apps/api — the Prisma schema and the workspace-linked prisma CLI live here. */ +const API_ROOT = join(__dirname, '..', '..'); + +/** + * The wizard runs exactly once against an EMPTY database — the shared test + * database is seeded/marked as completed, so this suite provisions its own + * fresh database per run (CREATE DATABASE + `prisma migrate deploy`, which + * also exercises the backfill migration on a virgin schema) and drops it + * afterwards. Runs sequentially with the other files (fileParallelism off). + */ +describe.skipIf(!hasTestDb)('first-run setup wizard (fresh database, issue #80)', () => { + const baseUrl = process.env.TEST_DATABASE_URL!; + const baseSecretsFile = process.env.SECRETS_FILE; + const suffix = uniqueSuffix(); + + function freshDatabaseUrl(name: string): string { + const url = new URL(baseUrl); + url.pathname = `/${name}`; + return url.toString(); + } + + async function createFreshDatabase(name: string): Promise { + const admin = new PrismaClient({ datasourceUrl: baseUrl }); + try { + await admin.$executeRawUnsafe(`CREATE DATABASE "${name}"`); + } finally { + await admin.$disconnect(); + } + const url = freshDatabaseUrl(name); + execFileSync( + process.execPath, + [join(API_ROOT, 'node_modules', 'prisma', 'build', 'index.js'), 'migrate', 'deploy'], + { env: { ...process.env, DATABASE_URL: url }, stdio: 'pipe', cwd: API_ROOT }, + ); + return url; + } + + async function dropDatabase(name: string): Promise { + const admin = new PrismaClient({ datasourceUrl: baseUrl }); + try { + await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`); + } finally { + await admin.$disconnect(); + } + } + + afterAll(() => { + // Leave the worker env as found — later suites in this process must + // keep hitting the shared test database. + process.env.TEST_DATABASE_URL = baseUrl; + process.env.DATABASE_URL = baseUrl; + if (baseSecretsFile === undefined) delete process.env.SECRETS_FILE; + else process.env.SECRETS_FILE = baseSecretsFile; + }); + + describe('interactive wizard flow', () => { + const dbName = `dorfteich_setup_${suffix}`; + let app: INestApplication; + let prisma: PrismaClient; + let cookie: string; + let secretsFile: string; + + const admin = { + username: `setup-admin-${suffix}`, + email: `setup-admin-${suffix}@example.org`, + displayName: 'Setup Admin', + password: 'ein wirklich gutes passwort', + locale: 'de' as const, + }; + + const api = () => request(app.getHttpServer()); + + beforeAll(async () => { + const url = await createFreshDatabase(dbName); + process.env.TEST_DATABASE_URL = url; + secretsFile = join(mkdtempSync(join(tmpdir(), 'dorfteich-setup-')), 'secrets.env'); + process.env.SECRETS_FILE = secretsFile; + prisma = new PrismaClient({ datasourceUrl: url }); + app = await createTestApp(); + }, 60_000); + + afterAll(async () => { + await prisma.$disconnect(); + await app.close(); + await dropDatabase(dbName); + }); + + it('requires setup on a fresh database and gates every non-exempt route', async () => { + const status = await api().get('/api/v1/setup').expect(200); + expect(status.body).toMatchObject({ + status: 'required', + adminCreated: false, + smtpConfigured: false, + }); + + // Protected and public routes alike answer with the setup state … + const ponds = await api().get('/api/v1/ponds').expect(503); + expect(ponds.body.code).toBe('setup_required'); + const signup = await api().post('/api/v1/auth/signup').send(admin).expect(503); + expect(signup.body.code).toBe('setup_required'); + + // … while health stays reachable for deploys and monitors. + await api().get('/api/v1/healthz').expect(200); + }); + + it('creates the site admin verified, signed in, and with a personal pond', async () => { + const res = await api().post('/api/v1/setup/admin').send(admin).expect(201); + expect(res.body).toMatchObject({ username: admin.username, isSiteAdmin: true }); + cookie = sessionCookieOf(res); + + const me = await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200); + expect(me.body.isSiteAdmin).toBe(true); + + const user = await prisma.user.findUnique({ where: { username: admin.username } }); + expect(user?.status).toBe('ACTIVE'); + expect(user?.emailVerifiedAt).not.toBeNull(); + const personal = await prisma.pond.count({ + where: { ownerId: user!.id, type: 'PERSONAL' }, + }); + expect(personal).toBe(1); + }); + + it('rejects a second admin and unauthenticated steps', async () => { + const dup = await api() + .post('/api/v1/setup/admin') + .send({ ...admin, username: `other-${suffix}`, email: `other-${suffix}@example.org` }) + .expect(409); + expect(dup.body.code).toBe('setup_admin_exists'); + + await api() + .post('/api/v1/setup/instance') + .send({ name: 'Testteich', defaultLocale: 'de' }) + .expect(401); + }); + + it('applies instance name, locale, and registration mode', async () => { + await api() + .post('/api/v1/setup/instance') + .set('Cookie', cookie) + .send({ name: 'Testteich', defaultLocale: 'de' }) + .expect(204); + await api() + .post('/api/v1/setup/registration') + .set('Cookie', cookie) + .send({ mode: 'closed' }) + .expect(204); + }); + + it('blocks the SMTP step with actionable detail when the live test fails', async () => { + const res = await api() + .post('/api/v1/setup/smtp') + .set('Cookie', cookie) + .send({ + // Nothing listens on port 9 — the connection is refused fast. + host: '127.0.0.1', + port: 9, + secure: false, + from: 'Testteich ', + }) + .expect(400); + expect(res.body.code).toBe('smtp_test_failed'); + expect(res.body.details?.smtp?.[0]).toBeTruthy(); + // Nothing was persisted for the failed attempt. + expect(existsSync(secretsFile)).toBe(false); + }); + + it('persists SMTP to the secret store after a successful live test', async () => { + const smtp = await startFakeSmtpServer(); + try { + await api() + .post('/api/v1/setup/smtp') + .set('Cookie', cookie) + .send({ + host: '127.0.0.1', + port: smtp.port, + secure: false, + from: 'Testteich ', + }) + .expect(204); + } finally { + await smtp.close(); + } + // The live test really delivered a message to the admin address. + expect(smtp.messages.length).toBe(1); + expect(smtp.messages[0]).toContain(admin.email); + + const stored = readSecretsFile(secretsFile); + expect(stored.SMTP_HOST).toBe('127.0.0.1'); + expect(stored.SMTP_PORT).toBe(String(smtp.port)); + expect(statSync(secretsFile).mode & 0o777).toBe(0o600); + }); + + it('completes the wizard, unlocking the app and locking every step (410)', async () => { + await api().post('/api/v1/setup/complete').set('Cookie', cookie).expect(204); + + // The instance works: gate lifted, settings took effect. + await api().get('/api/v1/ponds').set('Cookie', cookie).expect(200); + const registration = await api().get('/api/v1/auth/registration').expect(200); + expect(registration.body.mode).toBe('closed'); + const settings = await api().get('/api/v1/admin/settings').set('Cookie', cookie).expect(200); + expect(settings.body['instance.name']).toBe('Testteich'); + expect(settings.body['instance.defaultLocale']).toBe('de'); + expect(settings.body['setup.completedAt']).toBeTruthy(); + + // Every wizard step is gone for good; the status stays readable. + for (const [path, body] of [ + ['admin', admin], + ['instance', { name: 'X', defaultLocale: 'en' }], + ['registration', { mode: 'open' }], + ['complete', {}], + ] as const) { + const res = await api() + .post(`/api/v1/setup/${path}`) + .set('Cookie', cookie) + .send(body) + .expect(410); + expect(res.body.code).toBe('setup_locked'); + } + const status = await api().get('/api/v1/setup').expect(200); + expect(status.body.status).toBe('completed'); + }); + + it('keeps the lock after a restart (fresh application instance)', async () => { + const restarted = await createTestApp(); + try { + const res = await request(restarted.getHttpServer()) + .post('/api/v1/setup/admin') + .send({ ...admin, username: `late-${suffix}`, email: `late-${suffix}@example.org` }) + .expect(410); + expect(res.body.code).toBe('setup_locked'); + await request(restarted.getHttpServer()).get('/api/v1/setup').expect(200); + } finally { + await restarted.close(); + } + }); + + it('refuses to reopen the lock through the admin settings endpoint', async () => { + await api() + .patch('/api/v1/admin/settings') + .set('Cookie', cookie) + .send({ 'setup.completedAt': null }) + .expect(400); + }); + }); + + describe('env pre-seeding (automated deploys)', () => { + const dbName = `dorfteich_preseed_${suffix}`; + let app: INestApplication; + const preseedEnv = { + SETUP_ADMIN_USERNAME: `preseed-admin-${suffix}`, + SETUP_ADMIN_EMAIL: `preseed-admin-${suffix}@example.org`, + SETUP_ADMIN_PASSWORD: 'ein wirklich gutes passwort', + SETUP_INSTANCE_NAME: 'Vorbefüllter Teich', + SETUP_DEFAULT_LOCALE: 'de', + SETUP_REGISTRATION_MODE: 'closed', + } as const; + + beforeAll(async () => { + const url = await createFreshDatabase(dbName); + process.env.TEST_DATABASE_URL = url; + process.env.SECRETS_FILE = join( + mkdtempSync(join(tmpdir(), 'dorfteich-preseed-')), + 'secrets.env', + ); + Object.assign(process.env, preseedEnv); + app = await createTestApp(); + }, 60_000); + + afterAll(async () => { + for (const key of Object.keys(preseedEnv)) delete process.env[key]; + await app.close(); + await dropDatabase(dbName); + }); + + it('completes and locks the wizard at boot without any interaction', async () => { + // Boot hook is inert under NODE_ENV=test (like the other workers) — + // drive the same method the hook runs. + await app.get(SetupService).preseedFromEnv(); + + const api = () => request(app.getHttpServer()); + const status = await api().get('/api/v1/setup').expect(200); + expect(status.body.status).toBe('completed'); + + // The pre-seeded admin can sign in and use the instance right away. + const login = await api() + .post('/api/v1/auth/login') + .send({ + usernameOrEmail: preseedEnv.SETUP_ADMIN_USERNAME, + password: preseedEnv.SETUP_ADMIN_PASSWORD, + }) + .expect(200); + expect(login.body.isSiteAdmin).toBe(true); + const cookie = sessionCookieOf(login); + const settings = await api().get('/api/v1/admin/settings').set('Cookie', cookie).expect(200); + expect(settings.body['instance.name']).toBe('Vorbefüllter Teich'); + expect(settings.body['auth.registrationMode']).toBe('closed'); + + // A second boot-time pre-seed run is a no-op, and the wizard is locked. + await app.get(SetupService).preseedFromEnv(); + const locked = await api().post('/api/v1/setup/complete').set('Cookie', cookie).expect(410); + expect(locked.body.code).toBe('setup_locked'); + }); + }); +}); + +interface FakeSmtpServer { + port: number; + messages: string[]; + close(): Promise; +} + +/** + * Minimal SMTP endpoint — just enough protocol for nodemailer's verify() + * (connect + EHLO) and a plain unauthenticated send, so the wizard's live + * delivery test runs against a real socket. + */ +function startFakeSmtpServer(): Promise { + const messages: string[] = []; + const server = net.createServer((socket) => { + let buffer = ''; + let inData = false; + let current = ''; + socket.write('220 fake.test ESMTP\r\n'); + socket.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + let newline: number; + while ((newline = buffer.indexOf('\r\n')) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 2); + if (inData) { + if (line === '.') { + messages.push(current); + current = ''; + inData = false; + socket.write('250 OK\r\n'); + } else { + current += line + '\n'; + } + continue; + } + const command = line.toUpperCase(); + if (command.startsWith('EHLO') || command.startsWith('HELO')) { + socket.write('250-fake.test\r\n250 8BITMIME\r\n'); + } else if (command.startsWith('DATA')) { + inData = true; + socket.write('354 go ahead\r\n'); + } else if (command.startsWith('QUIT')) { + socket.write('221 bye\r\n'); + socket.end(); + } else { + socket.write('250 OK\r\n'); + } + } + }); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as net.AddressInfo).port; + resolve({ + port, + messages, + close: () => + new Promise((done) => { + server.close(() => done()); + }), + }); + }); + }); +} diff --git a/apps/api/src/setup/setup.guard.ts b/apps/api/src/setup/setup.guard.ts new file mode 100644 index 0000000..6e19d20 --- /dev/null +++ b/apps/api/src/setup/setup.guard.ts @@ -0,0 +1,46 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + ServiceUnavailableException, + SetMetadata, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { SetupStateService } from './setup-state.service'; + +const SETUP_EXEMPT_KEY = 'setupExempt'; + +/** + * Marks routes that stay reachable while the instance still requires the + * first-run setup: the wizard itself, health probes, and the session + * routes (so a mid-wizard admin who lost the cookie can sign back in). + */ +export const SetupExempt = (): MethodDecorator & ClassDecorator => + SetMetadata(SETUP_EXEMPT_KEY, true); + +/** + * Global first-line guard (registered before AuthGuard via module order): + * while setup is pending every non-exempt route answers 503 + * `setup_required`, so clients — including anonymous ones — always learn + * the instance state instead of a misleading 401 (issue #80). + */ +@Injectable() +export class SetupGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly state: SetupStateService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const exempt = this.reflector.getAllAndOverride(SETUP_EXEMPT_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (exempt) return true; + if (await this.state.isPending()) { + throw new ServiceUnavailableException({ code: 'setup_required' }); + } + return true; + } +} diff --git a/apps/api/src/setup/setup.module.ts b/apps/api/src/setup/setup.module.ts new file mode 100644 index 0000000..6371304 --- /dev/null +++ b/apps/api/src/setup/setup.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; + +import { SessionsModule } from '../auth/sessions.module'; +import { MailModule } from '../mail/mail.module'; +import { PondsModule } from '../ponds/ponds.module'; +import { SettingsModule } from '../settings/settings.module'; +import { UsersModule } from '../users/users.module'; +import { SetupController } from './setup.controller'; +import { SetupGuard } from './setup.guard'; +import { SetupService } from './setup.service'; +import { SetupStateService } from './setup-state.service'; + +/** + * First-run setup wizard (issue #80). Imported in AppModule BEFORE + * AuthModule on purpose: global guards run in registration order, and the + * setup gate must answer 503 `setup_required` before AuthGuard could turn + * the same request into a misleading 401. + */ +@Module({ + imports: [SettingsModule, UsersModule, PondsModule, SessionsModule, MailModule], + controllers: [SetupController], + providers: [SetupService, SetupStateService, { provide: APP_GUARD, useClass: SetupGuard }], + exports: [SetupService, SetupStateService], +}) +export class SetupModule {} diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts new file mode 100644 index 0000000..70f9a68 --- /dev/null +++ b/apps/api/src/setup/setup.service.ts @@ -0,0 +1,216 @@ +import { + BadRequestException, + ConflictException, + GoneException, + Injectable, + OnModuleInit, +} from '@nestjs/common'; +import { + SetupAdminInput, + SetupInstanceInput, + SetupRegistrationInput, + SetupSmtpInput, + SetupStatusView, + setupAdminInputSchema, +} from '@dorfteich/shared'; +import { User } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; + +import { SessionsService } from '../auth/sessions.service'; +import { AppConfig } from '../config/app-config.service'; +import { SecretStoreService } from '../config/secret-store.service'; +import { renderMail } from '../mail/mail-templates'; +import { SmtpConfigService, SmtpSettings } from '../mail/smtp-config.service'; +import { PondsService } from '../ponds/ponds.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; +import { UsersService } from '../users/users.service'; +import { SetupStateService } from './setup-state.service'; + +/** + * First-run setup wizard (issue #80, deployment.md §Configuration): runs + * exactly once against an empty database. Steps write to their production + * homes right away (users table, instance_settings, secret store) — there + * is no separate wizard state; completing sets `setup.completedAt`, which + * locks every step permanently (410, also after restarts). + */ +@Injectable() +export class SetupService implements OnModuleInit { + constructor( + private readonly prisma: PrismaService, + private readonly state: SetupStateService, + private readonly settings: InstanceSettingsService, + private readonly users: UsersService, + private readonly ponds: PondsService, + private readonly sessions: SessionsService, + private readonly secretStore: SecretStoreService, + private readonly smtpConfig: SmtpConfigService, + private readonly config: AppConfig, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(SetupService.name); + } + + /** + * Env pre-seeding for automated deploys: a fresh database plus + * SETUP_ADMIN_* env completes the whole wizard at boot, so pipelines + * never have to click through it. Inert in tests (they call + * preseedFromEnv directly, like the other boot-time workers). + */ + async onModuleInit(): Promise { + if (this.config.env.NODE_ENV === 'test') return; + await this.preseedFromEnv(); + } + + async preseedFromEnv(): Promise { + const env = this.config.env; + if (!env.SETUP_ADMIN_USERNAME || !env.SETUP_ADMIN_EMAIL || !env.SETUP_ADMIN_PASSWORD) return; + if (!(await this.state.isPending())) return; + + // Fails the boot loudly on invalid values — a half-seeded instance + // would be much harder to diagnose than a startup error. + const input = setupAdminInputSchema.parse({ + username: env.SETUP_ADMIN_USERNAME, + email: env.SETUP_ADMIN_EMAIL, + password: env.SETUP_ADMIN_PASSWORD, + displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME, + locale: env.SETUP_DEFAULT_LOCALE, + }); + const admin = await this.createAdmin(input); + if (env.SETUP_INSTANCE_NAME) { + await this.settings.set('instance.name', env.SETUP_INSTANCE_NAME, admin.id); + } + if (env.SETUP_DEFAULT_LOCALE) { + await this.settings.set('instance.defaultLocale', env.SETUP_DEFAULT_LOCALE, admin.id); + } + if (env.SETUP_REGISTRATION_MODE) { + await this.settings.set('auth.registrationMode', env.SETUP_REGISTRATION_MODE, admin.id); + } + await this.complete(admin); + this.logger.info({ userId: admin.id }, 'audit: setup pre-seeded from environment'); + } + + async status(): Promise { + const pending = await this.state.isPending(); + return { + status: pending ? 'required' : 'completed', + adminCreated: await this.siteAdminExists(), + // Configured means: some source (stage env or the wizard via the + // secret store) sets a relay host — the Zod default alone does not. + smtpConfigured: Boolean(process.env.SMTP_HOST ?? this.secretStore.read().SMTP_HOST), + }; + } + + /** Step 1 — creates the Site Admin, verified and with a personal pond. */ + async createAdmin(input: SetupAdminInput): Promise { + await this.assertPending(); + if (await this.siteAdminExists()) { + throw new ConflictException({ code: 'setup_admin_exists' }); + } + const created = await this.users.createUser(input); + // The wizard admin verifies nothing by mail — SMTP may not even be + // configured yet. Activate directly, like a completed double opt-in. + const admin = await this.prisma.user.update({ + where: { id: created.id }, + data: { isSiteAdmin: true, status: 'ACTIVE', emailVerifiedAt: new Date() }, + }); + await this.ponds.ensurePersonalPond(admin); + this.logger.info({ userId: admin.id }, 'audit: setup created site admin'); + return admin; + } + + async startSession(user: User, userAgent: string | undefined): Promise { + return this.sessions.create(user.id, userAgent); + } + + /** Step 2 — instance name and default locale (instance_settings). */ + async applyInstance(input: SetupInstanceInput, actor: User): Promise { + await this.assertPending(); + await this.settings.set('instance.name', input.name, actor.id); + await this.settings.set('instance.defaultLocale', input.defaultLocale, actor.id); + } + + /** + * Step 3 — SMTP relay. Runs a live delivery test (connect + send a test + * mail to the admin) before anything is persisted; failures block the + * step with the transport error as actionable detail. On success the + * values go to the env-backed secret store (security.md §Secrets), never + * into the database. Skipping the step entirely is allowed — the + * instance then sends no signup/reset mail until SMTP is configured. + */ + async applySmtp(input: SetupSmtpInput, actor: User): Promise { + await this.assertPending(); + const candidate: SmtpSettings = { + host: input.host, + port: input.port, + secure: input.secure, + user: input.user || undefined, + pass: input.pass, + from: input.from, + }; + await this.sendTestMail(candidate, actor); + await this.secretStore.set({ + SMTP_HOST: candidate.host, + SMTP_PORT: String(candidate.port), + SMTP_SECURE: String(candidate.secure), + SMTP_USER: candidate.user ?? '', + SMTP_PASS: candidate.pass ?? '', + SMTP_FROM: candidate.from, + }); + this.smtpConfig.refresh(); + this.logger.info({ userId: actor.id }, 'audit: setup stored SMTP configuration'); + } + + /** Step 4 — registration mode (ADR 0007). */ + async applyRegistration(input: SetupRegistrationInput, actor: User): Promise { + await this.assertPending(); + await this.settings.set('auth.registrationMode', input.mode, actor.id); + } + + /** Final step — locks the wizard for good (410 from here on). */ + async complete(actor: User): Promise { + await this.assertPending(); + if (!(await this.siteAdminExists())) { + throw new BadRequestException({ code: 'setup_admin_missing' }); + } + await this.settings.set('setup.completedAt', new Date().toISOString(), actor.id); + this.logger.info({ userId: actor.id }, 'audit: setup completed and locked'); + } + + private async sendTestMail(candidate: SmtpSettings, actor: User): Promise { + const transport = this.smtpConfig.buildTransport(candidate); + try { + await transport.verify(); + const rendered = renderMail( + 'smtpTest', + { displayName: actor.displayName, link: this.config.env.APP_BASE_URL }, + actor.locale === 'de' ? 'de' : 'en', + ); + await transport.sendMail({ + from: candidate.from, + to: actor.email, + subject: rendered.subject, + text: rendered.text, + html: rendered.html, + }); + } catch (error) { + const detail = error instanceof Error ? error.message.slice(0, 500) : String(error); + throw new BadRequestException({ + code: 'smtp_test_failed', + details: { smtp: [detail] }, + }); + } finally { + transport.close(); + } + } + + private async assertPending(): Promise { + if (!(await this.state.isPending())) { + throw new GoneException({ code: 'setup_locked' }); + } + } + + private async siteAdminExists(): Promise { + return (await this.prisma.user.count({ where: { isSiteAdmin: true } })) > 0; + } +} diff --git a/apps/api/vitest.global-setup.ts b/apps/api/vitest.global-setup.ts index 14d3562..39426d6 100644 --- a/apps/api/vitest.global-setup.ts +++ b/apps/api/vitest.global-setup.ts @@ -1,12 +1,14 @@ import { execFileSync } from 'node:child_process'; +import { PrismaClient } from '@prisma/client'; + /** * Database-backed tests run only when TEST_DATABASE_URL is set (locally: * the compose dev db on port 5434; in CI: the postgres service container). * This setup pushes the current Prisma schema into that database once per * test run; tests skip themselves when the variable is absent. */ -export default function globalSetup(): void { +export default async function globalSetup(): Promise { const url = process.env.TEST_DATABASE_URL; if (!url) return; execFileSync( @@ -14,4 +16,17 @@ export default function globalSetup(): void { [require.resolve('prisma/build/index.js'), 'db', 'push', '--skip-generate'], { env: { ...process.env, DATABASE_URL: url }, stdio: 'inherit', cwd: __dirname }, ); + // The shared test database counts as a configured instance — without the + // completion marker every suite would hit the first-run setup gate + // (issue #80). The setup suite provisions its own fresh database instead. + const prisma = new PrismaClient({ datasourceUrl: url }); + try { + await prisma.instanceSetting.upsert({ + where: { key: 'setup.completedAt' }, + create: { key: 'setup.completedAt', value: new Date().toISOString() }, + update: {}, + }); + } finally { + await prisma.$disconnect(); + } } diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 4b49077..7b4a9ed 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -37,11 +37,26 @@ COMPOSE_PROJECT_NAME=dorfteich # origin check are derived from it — it must match what browsers use. APP_BASE_URL=https://test.dorfteich.cloud -# SMTP relay for outgoing mail (verification, password reset). Leave unset -# to keep the Mailpit dev defaults; real stages need a real relay. +# SMTP relay for outgoing mail (verification, password reset). Optional: +# leave everything unset and configure the relay in the browser during the +# first-run setup wizard instead (stored on the `secrets` volume, issue #80). +# Values set here always win over wizard-stored ones. SMTP_HOST=mail.example.com SMTP_PORT=465 SMTP_SECURE=true SMTP_USER=wiki@example.com SMTP_PASS=change-me SMTP_FROM=Dorfteich + +# --- first-run setup (optional pre-seeding, issue #80) ------------------------ +# A fresh (empty) database makes the instance require the browser setup +# wizard. Automated deploys can skip it entirely by pre-seeding the Site +# Admin here; the wizard then completes and locks itself at first boot. +# All three SETUP_ADMIN_* values are required for pre-seeding to trigger. +#SETUP_ADMIN_USERNAME=admin +#SETUP_ADMIN_EMAIL=admin@example.com +#SETUP_ADMIN_PASSWORD=change-me-please +#SETUP_ADMIN_DISPLAY_NAME=Admin +#SETUP_INSTANCE_NAME=Dorfteich +#SETUP_DEFAULT_LOCALE=en +#SETUP_REGISTRATION_MODE=open diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index 90bd371..8a34757 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -51,13 +51,27 @@ services: # Public URL of this stage — e-mail links and the CSRF origin check # depend on it matching what browsers actually use. APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5173} - # SMTP relay; defaults are only useful with the dev Mailpit overlay. - SMTP_HOST: ${SMTP_HOST:-localhost} - SMTP_PORT: ${SMTP_PORT:-1025} - SMTP_SECURE: ${SMTP_SECURE:-false} + # SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes + # the relay to the secret store on the `secrets` volume (issue #80); + # values set here in the stage .env always win over the store. + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-} + SMTP_SECURE: ${SMTP_SECURE:-} SMTP_USER: ${SMTP_USER:-} SMTP_PASS: ${SMTP_PASS:-} - SMTP_FROM: ${SMTP_FROM:-Dorfteich } + SMTP_FROM: ${SMTP_FROM:-} + # Env-backed secret store on the `secrets` volume mount below + # (security.md §Secrets, issue #80). + SECRETS_FILE: /data/secrets/secrets.env + # Optional first-run pre-seeding (issue #80): with all three + # SETUP_ADMIN_* values set, a fresh database skips the browser wizard. + SETUP_ADMIN_USERNAME: ${SETUP_ADMIN_USERNAME:-} + SETUP_ADMIN_EMAIL: ${SETUP_ADMIN_EMAIL:-} + SETUP_ADMIN_PASSWORD: ${SETUP_ADMIN_PASSWORD:-} + SETUP_ADMIN_DISPLAY_NAME: ${SETUP_ADMIN_DISPLAY_NAME:-} + SETUP_INSTANCE_NAME: ${SETUP_INSTANCE_NAME:-} + SETUP_DEFAULT_LOCALE: ${SETUP_DEFAULT_LOCALE:-} + SETUP_REGISTRATION_MODE: ${SETUP_REGISTRATION_MODE:-} # Matches the `uploads` volume mount below (ADR 0011). UPLOADS_DIR: /data/uploads # Matches the `plugins` volume mount below (ADR 0008, issue #71). A Site @@ -73,6 +87,7 @@ services: volumes: - uploads:/data/uploads - plugins:/data/plugins + - secrets:/data/secrets depends_on: db: condition: service_healthy @@ -166,3 +181,4 @@ volumes: db-data: uploads: plugins: + secrets: diff --git a/docs/architecture/deployment.md b/docs/architecture/deployment.md index c8bb1ef..3c9fbca 100644 --- a/docs/architecture/deployment.md +++ b/docs/architecture/deployment.md @@ -68,11 +68,18 @@ restore drills (ADR 0015) keep it honest. - One `.env` per stage (never in git; `.env.example` in the repo documents every variable): database credentials, `APP_BASE_URL`, collab token signing key, SMTP settings, stage name shown in the UI for non-Prod. -- First-run **setup wizard** (kickoff decision): when the API starts against - an empty database it exposes only `/setup` (create Site Admin account, - SMTP, instance name/locale, registration mode); the wizard locks itself - after completion. `.env` can pre-seed these for automated deploys - (Test/Int use exactly that). +- First-run **setup wizard** (kickoff decision, issue #80): when the API + starts against an empty database it exposes only `/setup` (create Site + Admin account, SMTP, instance name/locale, registration mode); the wizard + locks itself permanently after completion (steps answer 410, also across + restarts). `SETUP_ADMIN_*` in `.env` pre-seeds the whole wizard for + automated deploys; instances that predate the wizard are locked by a + backfill migration. +- Secrets entered in the wizard (the SMTP password) go to the **env-backed + secret store** — a mode-600 dotenv file on the `secrets` volume + (`SECRETS_FILE`, security.md §Secrets), never into the database. Explicit + container env always wins over the store, so operators can override a + broken wizard entry from the stage `.env`. ## Pipeline (ADR 0014, concrete) diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index f9a6510..8f30529 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -64,6 +64,11 @@ "member_is_owner": "Die Mitgliedschaft des Teich-Eigentümers kann hier nicht geändert werden.", "cannot_modify_self": "Du kannst diese Aktion nicht auf dein eigenes Konto anwenden.", "last_site_admin": "Der letzte Site-Admin kann nicht entfernt werden.", + "setup_required": "Diese Instanz ist noch nicht eingerichtet. Bitte führe zuerst die Ersteinrichtung aus.", + "setup_locked": "Die Ersteinrichtung ist bereits abgeschlossen.", + "setup_admin_exists": "Es existiert bereits ein Site-Admin-Konto.", + "setup_admin_missing": "Lege zuerst das Site-Admin-Konto an.", + "smtp_test_failed": "Der SMTP-Test ist fehlgeschlagen. Bitte prüfe die Verbindungsdaten.", "validation": { "required": "Dieses Feld ist erforderlich.", "taken": "Dieser Wert ist bereits vergeben.", diff --git a/packages/shared/i18n/de/mails.json b/packages/shared/i18n/de/mails.json index 756b4b0..7a7ffbd 100644 --- a/packages/shared/i18n/de/mails.json +++ b/packages/shared/i18n/de/mails.json @@ -15,5 +15,11 @@ "body": "jemand (hoffentlich du) hat das Zurücksetzen des Passworts für dein Konto angefordert. Über diesen Link kannst du ein neues Passwort setzen:", "action": "Neues Passwort setzen", "expiry": "Der Link ist eine Stunde gültig. Dein aktuelles Passwort bleibt gültig, bis du ein neues gesetzt hast." + }, + "smtpTest": { + "subject": "SMTP-Testnachricht", + "body": "diese Testnachricht bestätigt, dass dein Dorfteich E-Mails über den konfigurierten SMTP-Server versenden kann. Deine Instanz erreichst du hier:", + "action": "Dorfteich öffnen", + "expiry": "Du kannst diese E-Mail einfach löschen." } } diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index 17be79d..80540c2 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -64,6 +64,11 @@ "member_is_owner": "The pond owner's membership cannot be changed here.", "cannot_modify_self": "You cannot perform this action on your own account.", "last_site_admin": "The last Site Admin cannot be removed.", + "setup_required": "This instance is not set up yet. Please run the first-run setup first.", + "setup_locked": "First-run setup has already been completed.", + "setup_admin_exists": "A Site Admin account already exists.", + "setup_admin_missing": "Create the Site Admin account first.", + "smtp_test_failed": "The SMTP test failed. Please check the connection details.", "validation": { "required": "This field is required.", "taken": "This value is already taken.", diff --git a/packages/shared/i18n/en/mails.json b/packages/shared/i18n/en/mails.json index f25d633..fa6fdde 100644 --- a/packages/shared/i18n/en/mails.json +++ b/packages/shared/i18n/en/mails.json @@ -15,5 +15,11 @@ "body": "someone (hopefully you) requested a password reset for your account. Use this link to set a new password:", "action": "Set new password", "expiry": "The link is valid for one hour. Your current password stays valid until you set a new one." + }, + "smtpTest": { + "subject": "SMTP test message", + "body": "this test message confirms that your Dorfteich can send e-mail through the configured SMTP server. You can reach your instance here:", + "action": "Open Dorfteich", + "expiry": "You can simply delete this e-mail." } } diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index e01458c..84de051 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -90,6 +90,28 @@ export const apiEnvSchema = z.object({ * here; the relative default serves native dev/test runs. */ PLUGINS_DIR: z.string().min(1).default('./data/plugins'), + /** + * Env-backed secret store (security.md §Secrets, issue #80): a mode-600 + * dotenv-style file on a persistent volume where the setup wizard writes + * secrets entered in the browser (currently the SMTP configuration). + * Values from this file fill environment variables that are NOT set on + * the process — explicit container env always wins, so operators can + * override a broken wizard entry from the stage `.env`. + */ + SECRETS_FILE: z.string().min(1).default('./data/secrets.env'), + /** + * First-run pre-seeding (issue #80): when the api boots against a database + * that still requires setup and all three SETUP_ADMIN_* values are set, it + * creates the Site Admin, applies the optional instance values below, and + * completes (locks) the wizard — automated deploys never see it. + */ + SETUP_ADMIN_USERNAME: z.string().optional(), + SETUP_ADMIN_EMAIL: z.string().optional(), + SETUP_ADMIN_PASSWORD: z.string().optional(), + SETUP_ADMIN_DISPLAY_NAME: z.string().optional(), + SETUP_INSTANCE_NAME: z.string().optional(), + SETUP_DEFAULT_LOCALE: z.enum(['de', 'en']).optional(), + SETUP_REGISTRATION_MODE: z.enum(['open', 'closed']).optional(), }); export type ApiEnv = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2f8fb7e..52e0805 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,6 +16,7 @@ export * from './pages'; export * from './permissions'; export * from './plugins'; export * from './search'; +export * from './setup'; export * from './ponds'; export * from './quotas'; export * from './text-diff'; diff --git a/packages/shared/src/setup.ts b/packages/shared/src/setup.ts new file mode 100644 index 0000000..e6f8059 --- /dev/null +++ b/packages/shared/src/setup.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +import { signupInputSchema } from './auth'; + +/** + * First-run setup wizard (issue #80, deployment.md §Configuration): a fresh + * instance exposes only `/setup/*` until the wizard completes; completing it + * locks the wizard permanently (steps answer 410 afterwards). + */ + +/** Step 1 — the Site Admin account; same field rules as regular signup. */ +export const setupAdminInputSchema = signupInputSchema; +export type SetupAdminInput = z.infer; + +/** Step 2 — instance identity. Mirrors the instance_settings validations. */ +export const setupInstanceInputSchema = z.object({ + name: z.string().trim().min(1, 'validation.required').max(60), + defaultLocale: z.enum(['de', 'en']), +}); +export type SetupInstanceInput = z.infer; + +/** + * Step 3 — SMTP relay. Optional: skipping it is allowed, the instance then + * sends no mail (signup verification, password reset) until configured. + * Saving runs a live delivery test first; failures block the step. + */ +export const setupSmtpInputSchema = z.object({ + host: z.string().trim().min(1, 'validation.required').max(255), + port: z.number().int().min(1).max(65535), + secure: z.boolean(), + user: z.string().max(255).optional(), + pass: z.string().max(1024).optional(), + from: z.string().trim().min(3, 'validation.required').max(255), +}); +export type SetupSmtpInput = z.infer; + +/** Step 4 — who may self-register (ADR 0007). */ +export const setupRegistrationInputSchema = z.object({ + mode: z.enum(['open', 'closed']), +}); +export type SetupRegistrationInput = z.infer; + +/** What `GET /setup` reports; the wizard UI (#81) renders its steps from this. */ +export interface SetupStatusView { + status: 'required' | 'completed'; + adminCreated: boolean; + smtpConfigured: boolean; +}