Add the first-run setup wizard API with env-backed secret store (#80)
Some checks failed
CD / Build and push images (push) Successful in 3m16s
CI / Lint, typecheck, test (push) Successful in 3m5s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Failing after 3m35s
CD / Promote to Int (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m6s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Build container images (push) Has been skipped
Some checks failed
CD / Build and push images (push) Successful in 3m16s
CI / Lint, typecheck, test (push) Successful in 3m5s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Failing after 3m35s
CD / Promote to Int (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m6s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Build container images (push) Has been skipped
When the api runs against a database without the setup.completedAt marker, a global SetupGuard answers every non-exempt route with 503 setup_required; only /setup/*, health probes, and the session routes stay reachable. The wizard steps (POST /setup/admin|instance|smtp| registration|complete) write straight to their production homes; the Site Admin step signs its creator in, later steps require that session. Completing sets the marker and locks every step permanently (410, also across restarts, and not reopenable via PATCH /admin/settings). SMTP entered in the wizard is verified with a live delivery test first (failure blocks the step with the transport error as detail) and then persisted to the new env-backed secret store: a mode-600 dotenv file on the new `secrets` volume (SECRETS_FILE). Explicit container env always wins over the store; empty compose-passed strings count as unset. The mail transport now resolves lazily through SmtpConfigService so wizard changes apply without a restart. SETUP_ADMIN_* env pre-seeds the whole wizard at boot for automated deploys; a backfill migration marks instances that already have a Site Admin as completed, and seed/vitest global-setup do the same for fixture databases. The setup e2e suite provisions its own fresh database (CREATE DATABASE + migrate deploy) per run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
9e8ebfe49c
commit
f0a82bad20
@ -28,7 +28,7 @@ ARG APP_VERSION=0.0.0-dev
|
|||||||
# Default the data dirs to the writable, node-owned locations created below, so
|
# 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
|
# 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).
|
# 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
|
WORKDIR /app
|
||||||
COPY --from=build --chown=node:node /out /app
|
COPY --from=build --chown=node:node /out /app
|
||||||
# Generate the Prisma client for this image's platform.
|
# 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
|
# 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
|
# ownership into a new volume on first mount) lets the non-root `node` user
|
||||||
# write to them.
|
# 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
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||||
|
|||||||
@ -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');
|
||||||
@ -380,6 +380,17 @@ async function main(): Promise<void> {
|
|||||||
if (fixture.username === 'fixture-user') contentOwnerId = userId;
|
if (fixture.username === 'fixture-user') contentOwnerId = userId;
|
||||||
}
|
}
|
||||||
if (contentOwnerId) await seedContentFixtures(contentOwnerId);
|
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({
|
await prisma.instanceSetting.upsert({
|
||||||
where: { key: 'seed.marker' },
|
where: { key: 'seed.marker' },
|
||||||
create: { key: 'seed.marker', value: { seededAt: new Date().toISOString() } },
|
create: { key: 'seed.marker', value: { seededAt: new Date().toISOString() } },
|
||||||
|
|||||||
@ -11,13 +11,19 @@ import {
|
|||||||
} from '../settings/instance-settings.service';
|
} from '../settings/instance-settings.service';
|
||||||
import { SiteAdminGuard } from './site-admin.guard';
|
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<InstanceSettingKey> = new Set(['setup.completedAt']);
|
||||||
|
|
||||||
// Partial update: any subset of the known settings, each validated by
|
// Partial update: any subset of the known settings, each validated by
|
||||||
// its own schema inside the service (double validation is fine — this
|
// 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
|
const patchSchema = z
|
||||||
.object(
|
.object(
|
||||||
Object.fromEntries(
|
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<InstanceSettingKey, z.ZodOptional<z.ZodUnknown>>,
|
) as Record<InstanceSettingKey, z.ZodOptional<z.ZodUnknown>>,
|
||||||
)
|
)
|
||||||
.strict();
|
.strict();
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import { PublicModule } from './public/public.module';
|
|||||||
import { RateLimitModule } from './rate-limit/rate-limit.module';
|
import { RateLimitModule } from './rate-limit/rate-limit.module';
|
||||||
import { SearchModule } from './search/search.module';
|
import { SearchModule } from './search/search.module';
|
||||||
import { SettingsModule } from './settings/settings.module';
|
import { SettingsModule } from './settings/settings.module';
|
||||||
|
import { SetupModule } from './setup/setup.module';
|
||||||
import { TrashModule } from './trash/trash.module';
|
import { TrashModule } from './trash/trash.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { VersionsModule } from './versions/versions.module';
|
import { VersionsModule } from './versions/versions.module';
|
||||||
@ -36,6 +37,9 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
RateLimitModule,
|
RateLimitModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
SettingsModule,
|
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,
|
UsersModule,
|
||||||
PermissionsModule,
|
PermissionsModule,
|
||||||
PondsModule,
|
PondsModule,
|
||||||
|
|||||||
@ -17,7 +17,14 @@ import { AppConfig } from '../config/app-config.service';
|
|||||||
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
||||||
import { RateLimit } from '../rate-limit/rate-limit.guard';
|
import { RateLimit } from '../rate-limit/rate-limit.guard';
|
||||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
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 { AuthService } from './auth.service';
|
||||||
import { SessionsService } from './sessions.service';
|
import { SessionsService } from './sessions.service';
|
||||||
|
|
||||||
@ -66,6 +73,9 @@ export class AuthController {
|
|||||||
await this.auth.resendVerification(input.email);
|
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()
|
@Public()
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ -80,10 +90,11 @@ export class AuthController {
|
|||||||
input.password,
|
input.password,
|
||||||
request.headers['user-agent'],
|
request.headers['user-agent'],
|
||||||
);
|
);
|
||||||
this.setSessionCookie(response, sessionToken);
|
setSessionCookie(response, sessionToken, this.config.env.NODE_ENV === 'production');
|
||||||
return toCurrentUser(user);
|
return toCurrentUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SetupExempt()
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
async logout(
|
async logout(
|
||||||
@ -96,6 +107,7 @@ export class AuthController {
|
|||||||
response.clearCookie(SESSION_COOKIE, { path: '/' });
|
response.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SetupExempt()
|
||||||
@Get('me')
|
@Get('me')
|
||||||
me(@Req() request: AuthedRequest): CurrentUserShape {
|
me(@Req() request: AuthedRequest): CurrentUserShape {
|
||||||
// AuthGuard guarantees request.user for non-@Public routes.
|
// AuthGuard guarantees request.user for non-@Public routes.
|
||||||
@ -125,14 +137,4 @@ export class AuthController {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.auth.resetPassword(input.token, input.password);
|
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: '/',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import type { CurrentUser as CurrentUserShape } from '@dorfteich/shared';
|
import type { CurrentUser as CurrentUserShape } from '@dorfteich/shared';
|
||||||
import type { User } from '@prisma/client';
|
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 { AppConfig } from '../config/app-config.service';
|
||||||
import { SessionsService } from './sessions.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']);
|
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ApiEnv, apiEnvSchema, parseEnv } from '@dorfteich/shared';
|
import { ApiEnv, apiEnvSchema, parseEnv } from '@dorfteich/shared';
|
||||||
|
|
||||||
|
import { overlayEnv, readSecretsFile } from './secret-store';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppConfig {
|
export class AppConfig {
|
||||||
readonly env: ApiEnv;
|
readonly env: ApiEnv;
|
||||||
|
|
||||||
constructor() {
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AppConfig } from './app-config.service';
|
import { AppConfig } from './app-config.service';
|
||||||
|
import { SecretStoreService } from './secret-store.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global so every module can inject AppConfig without importing this module.
|
* Global so every module can inject AppConfig without importing this module.
|
||||||
@ -9,7 +10,7 @@ import { AppConfig } from './app-config.service';
|
|||||||
*/
|
*/
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [AppConfig],
|
providers: [AppConfig, SecretStoreService],
|
||||||
exports: [AppConfig],
|
exports: [AppConfig, SecretStoreService],
|
||||||
})
|
})
|
||||||
export class ConfigModule {}
|
export class ConfigModule {}
|
||||||
|
|||||||
29
apps/api/src/config/secret-store.service.ts
Normal file
29
apps/api/src/config/secret-store.service.ts
Normal file
@ -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<string, string> {
|
||||||
|
return readSecretsFile(this.config.env.SECRETS_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(entries: Record<string, string>): Promise<void> {
|
||||||
|
await writeSecretsFile(this.config.env.SECRETS_FILE, entries);
|
||||||
|
this.logger.info({ keys: Object.keys(entries) }, 'audit: secret store updated');
|
||||||
|
}
|
||||||
|
}
|
||||||
58
apps/api/src/config/secret-store.test.ts
Normal file
58
apps/api/src/config/secret-store.test.ts
Normal file
@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
87
apps/api/src/config/secret-store.ts
Normal file
87
apps/api/src/config/secret-store.ts
Normal file
@ -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<string, string> {
|
||||||
|
const secrets: Record<string, string> = {};
|
||||||
|
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, string>): 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<string, string> {
|
||||||
|
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<string, string | undefined>,
|
||||||
|
secrets: Record<string, string>,
|
||||||
|
): Record<string, string | undefined> {
|
||||||
|
const merged: Record<string, string | undefined> = {};
|
||||||
|
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<string, string>,
|
||||||
|
): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@ -4,9 +4,11 @@ import type { Response } from 'express';
|
|||||||
|
|
||||||
import { Public } from '../auth/auth.guard';
|
import { Public } from '../auth/auth.guard';
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
import { SetupExempt } from '../setup/setup.guard';
|
||||||
import { ReadinessService } from './readiness.service';
|
import { ReadinessService } from './readiness.service';
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
|
@SetupExempt() // deploys and monitors must see health during first-run setup
|
||||||
@Controller()
|
@Controller()
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { apiI18n } from '../i18n/api-i18n';
|
import { apiI18n } from '../i18n/api-i18n';
|
||||||
|
|
||||||
export type MailTemplate = 'verifyEmail' | 'resetPassword';
|
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest';
|
||||||
|
|
||||||
export interface RenderedMail {
|
export interface RenderedMail {
|
||||||
subject: string;
|
subject: string;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { PinoLogger } from 'nestjs-pino';
|
|||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SmtpConfigService } from './smtp-config.service';
|
||||||
|
|
||||||
/** Minimal transport contract — nodemailer in production, a fake in tests. */
|
/** Minimal transport contract — nodemailer in production, a fake in tests. */
|
||||||
export interface MailTransport {
|
export interface MailTransport {
|
||||||
@ -36,6 +37,7 @@ export class MailWorker implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
@Inject(MAIL_TRANSPORT) private readonly transport: MailTransport,
|
@Inject(MAIL_TRANSPORT) private readonly transport: MailTransport,
|
||||||
|
private readonly smtpConfig: SmtpConfigService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(MailWorker.name);
|
this.logger.setContext(MailWorker.name);
|
||||||
@ -72,7 +74,7 @@ export class MailWorker implements OnModuleInit, OnModuleDestroy {
|
|||||||
private async deliverOne(mail: MailOutbox): Promise<void> {
|
private async deliverOne(mail: MailOutbox): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.transport.sendMail({
|
await this.transport.sendMail({
|
||||||
from: this.config.env.SMTP_FROM,
|
from: this.smtpConfig.effective().from,
|
||||||
to: mail.toAddress,
|
to: mail.toAddress,
|
||||||
subject: mail.subject,
|
subject: mail.subject,
|
||||||
text: mail.textBody,
|
text: mail.textBody,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|||||||
import { MailTransport, MailWorker } from './mail-worker.service';
|
import { MailTransport, MailWorker } from './mail-worker.service';
|
||||||
import { renderMail } from './mail-templates';
|
import { renderMail } from './mail-templates';
|
||||||
import { MailService } from './mail.service';
|
import { MailService } from './mail.service';
|
||||||
|
import { SmtpConfigService } from './smtp-config.service';
|
||||||
|
|
||||||
describe('mail templates', () => {
|
describe('mail templates', () => {
|
||||||
it('renders both languages with link, action, and greeting', () => {
|
it('renders both languages with link, action, and greeting', () => {
|
||||||
@ -52,7 +53,10 @@ describe.skipIf(!hasTestDb)('MailWorker (database)', () => {
|
|||||||
|
|
||||||
function makeWorker(transport: MailTransport): MailWorker {
|
function makeWorker(transport: MailTransport): MailWorker {
|
||||||
const logger = { setContext: vi.fn(), warn: vi.fn() } as unknown as PinoLogger;
|
const logger = { setContext: vi.fn(), warn: vi.fn() } as unknown as PinoLogger;
|
||||||
return new MailWorker(prisma, config, transport, logger);
|
const smtpConfig = {
|
||||||
|
effective: () => ({ from: 'Test <no-reply@test>' }),
|
||||||
|
} as unknown as SmtpConfigService;
|
||||||
|
return new MailWorker(prisma, config, transport, smtpConfig, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('delivers queued mail and marks it sent', async () => {
|
it('delivers queued mail and marks it sent', async () => {
|
||||||
|
|||||||
@ -1,28 +1,24 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { createTransport } from 'nodemailer';
|
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { MAIL_TRANSPORT, MailTransport, MailWorker } from './mail-worker.service';
|
||||||
import { MAIL_TRANSPORT, MailWorker } from './mail-worker.service';
|
|
||||||
import { MailService } from './mail.service';
|
import { MailService } from './mail.service';
|
||||||
|
import { SmtpConfigService } from './smtp-config.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
MailService,
|
MailService,
|
||||||
MailWorker,
|
MailWorker,
|
||||||
|
SmtpConfigService,
|
||||||
{
|
{
|
||||||
|
// Delegates per send so the wizard's SMTP changes (SmtpConfigService.
|
||||||
|
// refresh) take effect without restarting the worker.
|
||||||
provide: MAIL_TRANSPORT,
|
provide: MAIL_TRANSPORT,
|
||||||
inject: [AppConfig],
|
inject: [SmtpConfigService],
|
||||||
useFactory: (config: AppConfig) =>
|
useFactory: (smtp: SmtpConfigService): MailTransport => ({
|
||||||
createTransport({
|
sendMail: (mail) => smtp.transport().sendMail(mail),
|
||||||
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,
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [MailService],
|
exports: [MailService, SmtpConfigService],
|
||||||
})
|
})
|
||||||
export class MailModule {}
|
export class MailModule {}
|
||||||
|
|||||||
81
apps/api/src/mail/smtp-config.service.ts
Normal file
81
apps/api/src/mail/smtp-config.service.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { DEFAULT_ATTACHMENT_EXTENSIONS } from '@dorfteich/shared';
|
import { DEFAULT_ATTACHMENT_EXTENSIONS } from '@dorfteich/shared';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@ -47,6 +48,12 @@ export const INSTANCE_SETTINGS = {
|
|||||||
// SVG upload handling (security.md §Uploads): sanitize strips scripts and
|
// SVG upload handling (security.md §Uploads): sanitize strips scripts and
|
||||||
// event handlers with a maintained library; reject refuses SVG outright.
|
// event handlers with a maintained library; reject refuses SVG outright.
|
||||||
'upload.svgPolicy': z.enum(['reject', 'sanitize']).default('sanitize'),
|
'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;
|
} as const;
|
||||||
|
|
||||||
export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS;
|
export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS;
|
||||||
@ -103,10 +110,13 @@ export class InstanceSettingsService {
|
|||||||
details: { [key]: parsed.error.issues.map((i) => i.message) },
|
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({
|
await this.prisma.instanceSetting.upsert({
|
||||||
where: { key },
|
where: { key },
|
||||||
create: { key, value: parsed.data },
|
create: { key, value: stored },
|
||||||
update: { value: parsed.data },
|
update: { value: stored },
|
||||||
});
|
});
|
||||||
this.cache.set(key, parsed.data);
|
this.cache.set(key, parsed.data);
|
||||||
this.logger.info({ key, actorUserId }, 'audit: instance setting changed');
|
this.logger.info({ key, actorUserId }, 'audit: instance setting changed');
|
||||||
|
|||||||
36
apps/api/src/setup/setup-state.service.ts
Normal file
36
apps/api/src/setup/setup-state.service.ts
Normal file
@ -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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
apps/api/src/setup/setup.controller.ts
Normal file
96
apps/api/src/setup/setup.controller.ts
Normal file
@ -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<SetupStatusView> {
|
||||||
|
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<CurrentUserShape> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.setup.applyRegistration(input, request.user!);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(SiteAdminGuard)
|
||||||
|
@Post('complete')
|
||||||
|
@HttpCode(204)
|
||||||
|
async complete(@Req() request: AuthedRequest): Promise<void> {
|
||||||
|
await this.setup.complete(request.user!);
|
||||||
|
}
|
||||||
|
}
|
||||||
385
apps/api/src/setup/setup.e2e.db.test.ts
Normal file
385
apps/api/src/setup/setup.e2e.db.test.ts
Normal file
@ -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<string> {
|
||||||
|
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<void> {
|
||||||
|
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 <wiki@example.org>',
|
||||||
|
})
|
||||||
|
.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 <wiki@example.org>',
|
||||||
|
})
|
||||||
|
.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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<FakeSmtpServer> {
|
||||||
|
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<void>((done) => {
|
||||||
|
server.close(() => done());
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
46
apps/api/src/setup/setup.guard.ts
Normal file
46
apps/api/src/setup/setup.guard.ts
Normal file
@ -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<boolean> {
|
||||||
|
const exempt = this.reflector.getAllAndOverride<boolean>(SETUP_EXEMPT_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (exempt) return true;
|
||||||
|
if (await this.state.isPending()) {
|
||||||
|
throw new ServiceUnavailableException({ code: 'setup_required' });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/api/src/setup/setup.module.ts
Normal file
26
apps/api/src/setup/setup.module.ts
Normal file
@ -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 {}
|
||||||
216
apps/api/src/setup/setup.service.ts
Normal file
216
apps/api/src/setup/setup.service.ts
Normal file
@ -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<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.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,14 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database-backed tests run only when TEST_DATABASE_URL is set (locally:
|
* 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).
|
* 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
|
* This setup pushes the current Prisma schema into that database once per
|
||||||
* test run; tests skip themselves when the variable is absent.
|
* test run; tests skip themselves when the variable is absent.
|
||||||
*/
|
*/
|
||||||
export default function globalSetup(): void {
|
export default async function globalSetup(): Promise<void> {
|
||||||
const url = process.env.TEST_DATABASE_URL;
|
const url = process.env.TEST_DATABASE_URL;
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
execFileSync(
|
execFileSync(
|
||||||
@ -14,4 +16,17 @@ export default function globalSetup(): void {
|
|||||||
[require.resolve('prisma/build/index.js'), 'db', 'push', '--skip-generate'],
|
[require.resolve('prisma/build/index.js'), 'db', 'push', '--skip-generate'],
|
||||||
{ env: { ...process.env, DATABASE_URL: url }, stdio: 'inherit', cwd: __dirname },
|
{ 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,11 +37,26 @@ COMPOSE_PROJECT_NAME=dorfteich
|
|||||||
# origin check are derived from it — it must match what browsers use.
|
# origin check are derived from it — it must match what browsers use.
|
||||||
APP_BASE_URL=https://test.dorfteich.cloud
|
APP_BASE_URL=https://test.dorfteich.cloud
|
||||||
|
|
||||||
# SMTP relay for outgoing mail (verification, password reset). Leave unset
|
# SMTP relay for outgoing mail (verification, password reset). Optional:
|
||||||
# to keep the Mailpit dev defaults; real stages need a real relay.
|
# 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_HOST=mail.example.com
|
||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
SMTP_SECURE=true
|
SMTP_SECURE=true
|
||||||
SMTP_USER=wiki@example.com
|
SMTP_USER=wiki@example.com
|
||||||
SMTP_PASS=change-me
|
SMTP_PASS=change-me
|
||||||
SMTP_FROM=Dorfteich <wiki@example.com>
|
SMTP_FROM=Dorfteich <wiki@example.com>
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
|||||||
@ -51,13 +51,27 @@ services:
|
|||||||
# Public URL of this stage — e-mail links and the CSRF origin check
|
# Public URL of this stage — e-mail links and the CSRF origin check
|
||||||
# depend on it matching what browsers actually use.
|
# depend on it matching what browsers actually use.
|
||||||
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5173}
|
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5173}
|
||||||
# SMTP relay; defaults are only useful with the dev Mailpit overlay.
|
# SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes
|
||||||
SMTP_HOST: ${SMTP_HOST:-localhost}
|
# the relay to the secret store on the `secrets` volume (issue #80);
|
||||||
SMTP_PORT: ${SMTP_PORT:-1025}
|
# values set here in the stage .env always win over the store.
|
||||||
SMTP_SECURE: ${SMTP_SECURE:-false}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-}
|
||||||
|
SMTP_SECURE: ${SMTP_SECURE:-}
|
||||||
SMTP_USER: ${SMTP_USER:-}
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
SMTP_PASS: ${SMTP_PASS:-}
|
SMTP_PASS: ${SMTP_PASS:-}
|
||||||
SMTP_FROM: ${SMTP_FROM:-Dorfteich <no-reply@localhost>}
|
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).
|
# Matches the `uploads` volume mount below (ADR 0011).
|
||||||
UPLOADS_DIR: /data/uploads
|
UPLOADS_DIR: /data/uploads
|
||||||
# Matches the `plugins` volume mount below (ADR 0008, issue #71). A Site
|
# Matches the `plugins` volume mount below (ADR 0008, issue #71). A Site
|
||||||
@ -73,6 +87,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- uploads:/data/uploads
|
- uploads:/data/uploads
|
||||||
- plugins:/data/plugins
|
- plugins:/data/plugins
|
||||||
|
- secrets:/data/secrets
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@ -166,3 +181,4 @@ volumes:
|
|||||||
db-data:
|
db-data:
|
||||||
uploads:
|
uploads:
|
||||||
plugins:
|
plugins:
|
||||||
|
secrets:
|
||||||
|
|||||||
@ -68,11 +68,18 @@ restore drills (ADR 0015) keep it honest.
|
|||||||
- One `.env` per stage (never in git; `.env.example` in the repo documents
|
- One `.env` per stage (never in git; `.env.example` in the repo documents
|
||||||
every variable): database credentials, `APP_BASE_URL`, collab token
|
every variable): database credentials, `APP_BASE_URL`, collab token
|
||||||
signing key, SMTP settings, stage name shown in the UI for non-Prod.
|
signing key, SMTP settings, stage name shown in the UI for non-Prod.
|
||||||
- First-run **setup wizard** (kickoff decision): when the API starts against
|
- First-run **setup wizard** (kickoff decision, issue #80): when the API
|
||||||
an empty database it exposes only `/setup` (create Site Admin account,
|
starts against an empty database it exposes only `/setup` (create Site
|
||||||
SMTP, instance name/locale, registration mode); the wizard locks itself
|
Admin account, SMTP, instance name/locale, registration mode); the wizard
|
||||||
after completion. `.env` can pre-seed these for automated deploys
|
locks itself permanently after completion (steps answer 410, also across
|
||||||
(Test/Int use exactly that).
|
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)
|
## Pipeline (ADR 0014, concrete)
|
||||||
|
|
||||||
|
|||||||
@ -64,6 +64,11 @@
|
|||||||
"member_is_owner": "Die Mitgliedschaft des Teich-Eigentümers kann hier nicht geändert werden.",
|
"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.",
|
"cannot_modify_self": "Du kannst diese Aktion nicht auf dein eigenes Konto anwenden.",
|
||||||
"last_site_admin": "Der letzte Site-Admin kann nicht entfernt werden.",
|
"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": {
|
"validation": {
|
||||||
"required": "Dieses Feld ist erforderlich.",
|
"required": "Dieses Feld ist erforderlich.",
|
||||||
"taken": "Dieser Wert ist bereits vergeben.",
|
"taken": "Dieser Wert ist bereits vergeben.",
|
||||||
|
|||||||
@ -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:",
|
"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",
|
"action": "Neues Passwort setzen",
|
||||||
"expiry": "Der Link ist eine Stunde gültig. Dein aktuelles Passwort bleibt gültig, bis du ein neues gesetzt hast."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,6 +64,11 @@
|
|||||||
"member_is_owner": "The pond owner's membership cannot be changed here.",
|
"member_is_owner": "The pond owner's membership cannot be changed here.",
|
||||||
"cannot_modify_self": "You cannot perform this action on your own account.",
|
"cannot_modify_self": "You cannot perform this action on your own account.",
|
||||||
"last_site_admin": "The last Site Admin cannot be removed.",
|
"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": {
|
"validation": {
|
||||||
"required": "This field is required.",
|
"required": "This field is required.",
|
||||||
"taken": "This value is already taken.",
|
"taken": "This value is already taken.",
|
||||||
|
|||||||
@ -15,5 +15,11 @@
|
|||||||
"body": "someone (hopefully you) requested a password reset for your account. Use this link to set a new password:",
|
"body": "someone (hopefully you) requested a password reset for your account. Use this link to set a new password:",
|
||||||
"action": "Set 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."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,6 +90,28 @@ export const apiEnvSchema = z.object({
|
|||||||
* here; the relative default serves native dev/test runs.
|
* here; the relative default serves native dev/test runs.
|
||||||
*/
|
*/
|
||||||
PLUGINS_DIR: z.string().min(1).default('./data/plugins'),
|
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<typeof apiEnvSchema>;
|
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export * from './pages';
|
|||||||
export * from './permissions';
|
export * from './permissions';
|
||||||
export * from './plugins';
|
export * from './plugins';
|
||||||
export * from './search';
|
export * from './search';
|
||||||
|
export * from './setup';
|
||||||
export * from './ponds';
|
export * from './ponds';
|
||||||
export * from './quotas';
|
export * from './quotas';
|
||||||
export * from './text-diff';
|
export * from './text-diff';
|
||||||
|
|||||||
48
packages/shared/src/setup.ts
Normal file
48
packages/shared/src/setup.ts
Normal file
@ -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<typeof setupAdminInputSchema>;
|
||||||
|
|
||||||
|
/** 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<typeof setupInstanceInputSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<typeof setupSmtpInputSchema>;
|
||||||
|
|
||||||
|
/** Step 4 — who may self-register (ADR 0007). */
|
||||||
|
export const setupRegistrationInputSchema = z.object({
|
||||||
|
mode: z.enum(['open', 'closed']),
|
||||||
|
});
|
||||||
|
export type SetupRegistrationInput = z.infer<typeof setupRegistrationInputSchema>;
|
||||||
|
|
||||||
|
/** What `GET /setup` reports; the wizard UI (#81) renders its steps from this. */
|
||||||
|
export interface SetupStatusView {
|
||||||
|
status: 'required' | 'completed';
|
||||||
|
adminCreated: boolean;
|
||||||
|
smtpConfigured: boolean;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user