All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m6s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m13s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m6s
CI / Import/export fidelity gate (push) Successful in 45s
The SPA now probes GET /setup on boot: while setup is pending it renders only the wizard at /setup, the login page (to resume a started wizard as the Site Admin), and a "setup pending" notice on every other route — without the regular chrome, whose pond/search queries would all 503. If the probe itself fails (offline reload), the app falls through to the normal routes. Once completed, /setup just goes home. The wizard walks six steps against the #80 api: welcome with language choice (drives i18n immediately and pre-fills the admin/instance locales), Site Admin account (signs in via the step-1 session cookie), instance basics, SMTP with live-test-before-save plus an explicit skip, registration mode, and a summary whose finish unlocks the app logged-in-ready. Every step validates through the shared Zod schemas before advancing; entered values live in the parent component, so Back preserves them, and re-submitting a step on a second forward pass just overwrites the same settings. A wizard someone else started shows a sign-in hand-off instead of dead admin-gated steps. New `setup` i18n namespace in de and en. Two #80 touch-ups fell out of verifying this end to end: the SMTP port's NaN case now maps to the translated required-message, and GET /setup's smtpConfigured uses `||` instead of `??` so the empty strings compose passes for unset vars fall through to the secret store. The e2e pack (setup.spec.ts) needs an instance where setup is still pending, so the CI job provisions a second api + static web against a virgin database on their own ports and runs the full wizard journey there, including the failing-relay path and both languages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
219 lines
8.4 KiB
TypeScript
219 lines
8.4 KiB
TypeScript
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<void> {
|
|
if (this.config.env.NODE_ENV === 'test') return;
|
|
await this.preseedFromEnv();
|
|
}
|
|
|
|
async preseedFromEnv(): Promise<void> {
|
|
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<SetupStatusView> {
|
|
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<User> {
|
|
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<string> {
|
|
return this.sessions.create(user.id, userAgent);
|
|
}
|
|
|
|
/** Step 2 — instance name and default locale (instance_settings). */
|
|
async applyInstance(input: SetupInstanceInput, actor: User): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
if (!(await this.state.isPending())) {
|
|
throw new GoneException({ code: 'setup_locked' });
|
|
}
|
|
}
|
|
|
|
private async siteAdminExists(): Promise<boolean> {
|
|
return (await this.prisma.user.count({ where: { isSiteAdmin: true } })) > 0;
|
|
}
|
|
}
|