dorfteich/apps/api/src/auth/auth.controller.ts
Claude Fable 5 f0a82bad20
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
Add the first-run setup wizard API with env-backed secret store (#80)
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
2026-07-11 15:10:28 +02:00

141 lines
4.3 KiB
TypeScript

import { Body, Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common';
import {
CurrentUser as CurrentUserShape,
LoginInput,
SignupInput,
forgotPasswordInputSchema,
loginInputSchema,
resendVerificationInputSchema,
resetPasswordInputSchema,
signupInputSchema,
verifyEmailInputSchema,
} from '@dorfteich/shared';
import type { Response } from 'express';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AppConfig } from '../config/app-config.service';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { RateLimit } from '../rate-limit/rate-limit.guard';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { SetupExempt } from '../setup/setup.guard';
import {
AuthedRequest,
Public,
SESSION_COOKIE,
setSessionCookie,
toCurrentUser,
} from './auth.guard';
import { AuthService } from './auth.service';
import { SessionsService } from './sessions.service';
@AuthenticatedOnly() // routes reachable without a session opt out via @Public
@Controller('auth')
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly sessions: SessionsService,
private readonly config: AppConfig,
private readonly settings: InstanceSettingsService,
) {}
/** Public: the SPA hides the signup route while registration is closed. */
@Public()
@Get('registration')
async registration(): Promise<{ mode: 'open' | 'closed' }> {
return { mode: await this.settings.get('auth.registrationMode') };
}
@Public()
@Post('signup')
@HttpCode(201)
@RateLimit({ scope: 'signup', limit: 5, windowSeconds: 60 * 60 })
async signup(@Body(new ZodValidationPipe(signupInputSchema)) input: SignupInput): Promise<void> {
await this.auth.signup(input);
}
@Public()
@Post('verify-email')
@HttpCode(204)
@RateLimit({ scope: 'verify-email', limit: 20, windowSeconds: 60 * 60 })
async verifyEmail(
@Body(new ZodValidationPipe(verifyEmailInputSchema)) input: { token: string },
): Promise<void> {
await this.auth.verifyEmail(input.token);
}
@Public()
@Post('resend-verification')
@HttpCode(204)
@RateLimit({ scope: 'resend-verification', limit: 5, windowSeconds: 60 * 60 })
async resendVerification(
@Body(new ZodValidationPipe(resendVerificationInputSchema)) input: { email: string },
): Promise<void> {
await this.auth.resendVerification(input.email);
}
// Exempt from the setup gate: a mid-wizard Site Admin who lost the
// session cookie must be able to sign back in and finish setup.
@SetupExempt()
@Public()
@Post('login')
@HttpCode(200)
@RateLimit({ scope: 'login', limit: 10, windowSeconds: 60 })
async login(
@Body(new ZodValidationPipe(loginInputSchema)) input: LoginInput,
@Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response,
): Promise<CurrentUserShape> {
const { sessionToken, user } = await this.auth.login(
input.usernameOrEmail,
input.password,
request.headers['user-agent'],
);
setSessionCookie(response, sessionToken, this.config.env.NODE_ENV === 'production');
return toCurrentUser(user);
}
@SetupExempt()
@Post('logout')
@HttpCode(204)
async logout(
@Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response,
): Promise<void> {
if (request.sessionToken) {
await this.sessions.destroyByRawToken(request.sessionToken);
}
response.clearCookie(SESSION_COOKIE, { path: '/' });
}
@SetupExempt()
@Get('me')
me(@Req() request: AuthedRequest): CurrentUserShape {
// AuthGuard guarantees request.user for non-@Public routes.
return toCurrentUser(request.user!);
}
@Public()
@Post('forgot-password')
@HttpCode(204)
@RateLimit({ scope: 'forgot-password', limit: 5, windowSeconds: 60 * 60 })
async forgotPassword(
@Body(new ZodValidationPipe(forgotPasswordInputSchema)) input: { email: string },
): Promise<void> {
await this.auth.forgotPassword(input.email);
}
@Public()
@Post('reset-password')
@HttpCode(204)
@RateLimit({ scope: 'reset-password', limit: 10, windowSeconds: 60 * 60 })
async resetPassword(
@Body(new ZodValidationPipe(resetPasswordInputSchema))
input: {
token: string;
password: string;
},
): Promise<void> {
await this.auth.resetPassword(input.token, input.password);
}
}