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. // `||`, not `??`: compose passes unset vars as empty strings, which // must fall through to the store (the loadApiEnv convention, #80). 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; } }