All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m14s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m45s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m20s
CI / Import/export fidelity gate (push) Successful in 45s
New /admin/system panel (operations.md §Maintenance jobs): the maintenance job list shows every registered job with truthful last-run data (new Job.lastDurationMs recorded by the scheduler) and a manual trigger that respects the run-mutex and is itself audit-logged; a backup card mirrors the sidecar's status.json including the freshness verdict; an audit-log viewer filters by actor, action, and time range with pagination; and a storage overview lists the largest ponds. Auth events and admin actions (grants, members, user/quota admin, plugins, settings, setup) now land in a new audit_log table through a central AuditService — which keeps emitting the established stdout log line — while content activity stays log-only by design. All endpoints are Site-Admin-only; covered by API DB tests and a Playwright pack in CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
226 lines
8.5 KiB
TypeScript
226 lines
8.5 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 { AuditService } from '../audit/audit.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 audit: AuditService,
|
|
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);
|
|
await this.audit.record({ action: 'setup.preseeded', actorId: admin.id });
|
|
}
|
|
|
|
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);
|
|
await this.audit.record({
|
|
action: 'setup.admin_created',
|
|
actorId: admin.id,
|
|
targetType: 'user',
|
|
targetId: admin.id,
|
|
});
|
|
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();
|
|
await this.audit.record({ action: 'setup.smtp_stored', actorId: actor.id });
|
|
}
|
|
|
|
/** 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);
|
|
await this.audit.record({ action: 'setup.completed', actorId: actor.id });
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|