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; +}