Add account/session endpoints and typed instance settings with admin API

Server halves of #17/#18/#19: PATCH /users/me and change-password
(verifies the current password, logs out every other session),
GET/DELETE /users/me/sessions with current-session flag and protection
against revoking oneself; InstanceSettingsService as a typed, cached,
Zod-validated registry over instance_settings (schema-default fallback
for invalid stored values, audit-logged writes) consumed by the signup
flow; /admin/settings behind the new SiteAdminGuard with strict
unknown-key rejection. SessionsService moves to its own module to keep
Auth/Users acyclic. Three new e2e suites bring the api to 42 tests.

Part of #17, #18, #19

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-05 05:26:45 +02:00
parent bed9fc9307
commit 1314096c94
16 changed files with 520 additions and 29 deletions

View File

@ -28,7 +28,8 @@
"pino-http": "^10.4.0",
"prisma": "^6.3.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0"
"rxjs": "^7.8.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",

View File

@ -0,0 +1,46 @@
import { Body, Controller, Get, Patch, Req, UseGuards } from '@nestjs/common';
import { z } from 'zod';
import type { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import {
INSTANCE_SETTINGS,
InstanceSettingKey,
InstanceSettings,
InstanceSettingsService,
} from '../settings/instance-settings.service';
import { SiteAdminGuard } from './site-admin.guard';
// Partial update: any subset of the known settings, each validated by
// its own schema inside the service (double validation is fine — this
// outer schema only gates unknown keys).
const patchSchema = z
.object(
Object.fromEntries(
Object.keys(INSTANCE_SETTINGS).map((key) => [key, z.unknown().optional()]),
) as Record<InstanceSettingKey, z.ZodOptional<z.ZodUnknown>>,
)
.strict();
@Controller('admin/settings')
@UseGuards(SiteAdminGuard)
export class AdminSettingsController {
constructor(private readonly settings: InstanceSettingsService) {}
@Get()
getSettings(): Promise<InstanceSettings> {
return this.settings.getAll();
}
@Patch()
async patchSettings(
@Body(new ZodValidationPipe(patchSchema)) input: Partial<Record<InstanceSettingKey, unknown>>,
@Req() request: AuthedRequest,
): Promise<InstanceSettings> {
for (const [key, value] of Object.entries(input)) {
if (value === undefined) continue;
await this.settings.set(key as InstanceSettingKey, value, request.user!.id);
}
return this.settings.getAll();
}
}

View File

@ -0,0 +1,107 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
describe.skipIf(!hasTestDb)('admin settings (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
let adminCookie: string;
let memberCookie: string;
const api = () => request(app.getHttpServer());
const password = 'ein ordentliches admin passwort';
async function makeActiveUser(name: string, siteAdmin: boolean): Promise<string> {
const users = app.get(UsersService);
const user = await users.createUser({
username: `${name}-${suffix}`,
email: `${name}-${suffix}@example.org`,
displayName: name,
password,
locale: 'en',
});
await users.markEmailVerified(user.id);
if (siteAdmin) {
await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
}
const res = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: user.username, password })
.expect(200);
return sessionCookieOf(res);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
await prisma.instanceSetting.deleteMany({ where: { key: 'auth.registrationMode' } });
app = await createTestApp();
adminCookie = await makeActiveUser('root', true);
memberCookie = await makeActiveUser('member', false);
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
await prisma.instanceSetting.deleteMany({ where: { key: 'auth.registrationMode' } });
await prisma.$disconnect();
await app.close();
});
it('denies the settings endpoints to non-admins and anonymous callers', async () => {
await api().get('/api/v1/admin/settings').expect(401);
await api().get('/api/v1/admin/settings').set('Cookie', memberCookie).expect(403);
});
it('returns typed defaults for unset settings', async () => {
const res = await api().get('/api/v1/admin/settings').set('Cookie', adminCookie).expect(200);
expect(res.body['auth.registrationMode']).toBe('open');
expect(res.body['instance.name']).toBe('Dorfteich');
});
it('rejects invalid values with field details', async () => {
const res = await api()
.patch('/api/v1/admin/settings')
.set('Cookie', adminCookie)
.send({ 'auth.registrationMode': 'sideways' })
.expect(400);
expect(res.body.details).toHaveProperty('auth.registrationMode');
});
it('closing registration takes effect immediately, reopening too', async () => {
await api()
.patch('/api/v1/admin/settings')
.set('Cookie', adminCookie)
.send({ 'auth.registrationMode': 'closed' })
.expect(200);
const newcomer = {
username: `late-${suffix}`,
email: `late-${suffix}@example.org`,
displayName: 'Late',
password: 'auch ein gutes passwort',
};
await api().post('/api/v1/auth/signup').send(newcomer).expect(403);
await api()
.patch('/api/v1/admin/settings')
.set('Cookie', adminCookie)
.send({ 'auth.registrationMode': 'open' })
.expect(200);
await api().post('/api/v1/auth/signup').send(newcomer).expect(201);
});
it('rejects unknown setting keys', async () => {
await api()
.patch('/api/v1/admin/settings')
.set('Cookie', adminCookie)
.send({ 'made.up': true })
.expect(400);
});
});

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AdminSettingsController } from './admin.controller';
@Module({
controllers: [AdminSettingsController],
})
export class AdminModule {}

View File

@ -0,0 +1,16 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import type { AuthedRequest } from '../auth/auth.guard';
/**
* Second-stage guard for /admin routes: AuthGuard has already attached
* the user; this one requires the Site Admin flag (permissions.md).
*/
@Injectable()
export class SiteAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<AuthedRequest>();
if (!request.user?.isSiteAdmin) throw new ForbiddenException();
return true;
}
}

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { LoggerModule } from 'nestjs-pino';
import { AdminModule } from './admin/admin.module';
import { AuthModule } from './auth/auth.module';
import { ApiExceptionFilter } from './common/api-exception.filter';
import { AppConfig } from './config/app-config.service';
@ -10,6 +11,7 @@ import { HealthModule } from './health/health.module';
import { MailModule } from './mail/mail.module';
import { PrismaModule } from './prisma/prisma.module';
import { RateLimitModule } from './rate-limit/rate-limit.module';
import { SettingsModule } from './settings/settings.module';
import { UsersModule } from './users/users.module';
@Module({
@ -18,8 +20,10 @@ import { UsersModule } from './users/users.module';
PrismaModule,
RateLimitModule,
MailModule,
SettingsModule,
UsersModule,
AuthModule,
AdminModule,
LoggerModule.forRootAsync({
inject: [AppConfig],
useFactory: (config: AppConfig) => ({

View File

@ -47,7 +47,6 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
afterAll(async () => {
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
await prisma.instanceSetting.deleteMany({ where: { key: 'auth.registrationMode' } });
await prisma.$disconnect();
await app.close();
});
@ -82,17 +81,6 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
expect(user?.status).toBe('ACTIVE');
});
it('refuses signup while registration is closed', async () => {
await prisma.instanceSetting.create({
data: { key: 'auth.registrationMode', value: 'closed' },
});
await api()
.post('/api/v1/auth/signup')
.send({ ...account, username: `late-${suffix}`, email: `late-${suffix}@example.org` })
.expect(403);
await prisma.instanceSetting.delete({ where: { key: 'auth.registrationMode' } });
});
// ---------------------------------------------------------------- #14
let cookie: string;

View File

@ -7,19 +7,18 @@ import { AuthController } from './auth.controller';
import { AuthGuard } from './auth.guard';
import { AuthService } from './auth.service';
import { AuthTokensService } from './auth-tokens.service';
import { SessionsService } from './sessions.service';
import { SessionsModule } from './sessions.module';
@Module({
imports: [UsersModule, MailModule],
imports: [UsersModule, MailModule, SessionsModule],
controllers: [AuthController],
providers: [
AuthService,
AuthTokensService,
SessionsService,
// Global default-protected: every route needs a session unless it
// opts out with @Public().
{ provide: APP_GUARD, useClass: AuthGuard },
],
exports: [SessionsService, AuthTokensService],
exports: [AuthTokensService],
})
export class AuthModule {}

View File

@ -12,6 +12,7 @@ import { AppConfig } from '../config/app-config.service';
import { MailService } from '../mail/mail.service';
import { PrismaService } from '../prisma/prisma.service';
import { RateLimitService } from '../rate-limit/rate-limit.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { UsersService } from '../users/users.service';
import { AuthTokensService } from './auth-tokens.service';
import { SessionsService } from './sessions.service';
@ -31,13 +32,14 @@ export class AuthService {
private readonly mail: MailService,
private readonly rateLimits: RateLimitService,
private readonly config: AppConfig,
private readonly settings: InstanceSettingsService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(AuthService.name);
}
async signup(input: SignupInput): Promise<void> {
if ((await this.registrationMode()) === 'closed') {
if ((await this.settings.get('auth.registrationMode')) === 'closed') {
throw new ForbiddenException({ code: 'registration_closed' });
}
const user = await this.users.createUser(input);
@ -142,17 +144,6 @@ export class AuthService {
asLocale(user.locale),
);
}
/**
* Registration mode straight from instance_settings; the typed
* InstanceSettingsService (issue #19) will replace this direct read.
*/
private async registrationMode(): Promise<'open' | 'closed'> {
const row = await this.prisma.instanceSetting.findUnique({
where: { key: 'auth.registrationMode' },
});
return row?.value === 'closed' ? 'closed' : 'open';
}
}
function asLocale(locale: string): 'de' | 'en' {

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { SessionsService } from './sessions.service';
/**
* Own module so both AuthModule (guard, login) and UsersModule (session
* management endpoints) can use sessions without importing each other.
*/
@Module({
providers: [SessionsService],
exports: [SessionsService],
})
export class SessionsModule {}

View File

@ -0,0 +1,82 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { z } from 'zod';
import { PrismaService } from '../prisma/prisma.service';
/**
* The typed registry of instance settings. Adding a setting = adding a
* line here; readers get parsed, defaulted values and writers get
* validation for free. Secrets never go through this table
* (security.md §Secrets).
*/
export const INSTANCE_SETTINGS = {
'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'),
'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
} as const;
export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS;
export type InstanceSettingValue<K extends InstanceSettingKey> = z.infer<
(typeof INSTANCE_SETTINGS)[K]
>;
export type InstanceSettings = { [K in InstanceSettingKey]: InstanceSettingValue<K> };
/**
* Typed, cached access to instance_settings. The in-process cache is
* invalidated on every write; with one api container per stage
* (ADR 0002) that is sufficient no cross-instance bus needed yet.
*/
@Injectable()
export class InstanceSettingsService {
private cache = new Map<InstanceSettingKey, unknown>();
constructor(
private readonly prisma: PrismaService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(InstanceSettingsService.name);
}
async get<K extends InstanceSettingKey>(key: K): Promise<InstanceSettingValue<K>> {
if (this.cache.has(key)) return this.cache.get(key) as InstanceSettingValue<K>;
const row = await this.prisma.instanceSetting.findUnique({ where: { key } });
const parsed = INSTANCE_SETTINGS[key].safeParse(row?.value);
// Unknown/invalid stored values fall back to the schema default
// instead of breaking the instance.
const value = parsed.success ? parsed.data : INSTANCE_SETTINGS[key].parse(undefined);
this.cache.set(key, value);
return value as InstanceSettingValue<K>;
}
async getAll(): Promise<InstanceSettings> {
const entries = await Promise.all(
(Object.keys(INSTANCE_SETTINGS) as InstanceSettingKey[]).map(
async (key) => [key, await this.get(key)] as const,
),
);
return Object.fromEntries(entries) as InstanceSettings;
}
async set<K extends InstanceSettingKey>(
key: K,
value: unknown,
actorUserId: string,
): Promise<InstanceSettingValue<K>> {
const parsed = INSTANCE_SETTINGS[key].safeParse(value);
if (!parsed.success) {
throw new BadRequestException({
code: 'bad_request',
details: { [key]: parsed.error.issues.map((i) => i.message) },
});
}
await this.prisma.instanceSetting.upsert({
where: { key },
create: { key, value: parsed.data },
update: { value: parsed.data },
});
this.cache.set(key, parsed.data);
this.logger.info({ key, actorUserId }, 'audit: instance setting changed');
return parsed.data as InstanceSettingValue<K>;
}
}

View File

@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { InstanceSettingsService } from './instance-settings.service';
/** Global: instance configuration is read across many feature modules. */
@Global()
@Module({
providers: [InstanceSettingsService],
exports: [InstanceSettingsService],
})
export class SettingsModule {}

View File

@ -0,0 +1,123 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from './users.service';
describe.skipIf(!hasTestDb)('account self-service (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const username = `nia-${suffix}`;
const password = 'nias ausgezeichnetes passwort';
let cookie: string;
const api = () => request(app.getHttpServer());
async function login(pw: string): Promise<string> {
const res = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password: pw })
.expect(200);
return sessionCookieOf(res);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
const users = app.get(UsersService);
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: 'Nia',
password,
locale: 'en',
});
await users.markEmailVerified(user.id);
cookie = await login(password);
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
// ---------------------------------------------------------------- #17
it('updates display name and locale, visible via /auth/me', async () => {
await api()
.patch('/api/v1/users/me')
.set('Cookie', cookie)
.send({ displayName: 'Nia Neu', locale: 'de' })
.expect(200);
const me = await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200);
expect(me.body.displayName).toBe('Nia Neu');
expect(me.body.locale).toBe('de');
});
it('validates profile input with field details', async () => {
const res = await api()
.patch('/api/v1/users/me')
.set('Cookie', cookie)
.send({ displayName: '' })
.expect(400);
expect(res.body.details).toHaveProperty('displayName');
});
it('changes the password only with the correct current password', async () => {
await api()
.post('/api/v1/users/me/change-password')
.set('Cookie', cookie)
.send({ currentPassword: 'geraten und falsch', newPassword: 'noch besseres passwort' })
.expect(403);
const otherSession = await login(password);
await api()
.post('/api/v1/users/me/change-password')
.set('Cookie', cookie)
.send({ currentPassword: password, newPassword: 'noch besseres passwort' })
.expect(204);
// The changing session survives, every other one is gone.
await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200);
await api().get('/api/v1/auth/me').set('Cookie', otherSession).expect(401);
cookie = await login('noch besseres passwort');
});
// ---------------------------------------------------------------- #18
it('lists active sessions with the current one flagged', async () => {
const second = await login('noch besseres passwort');
const res = await api().get('/api/v1/users/me/sessions').set('Cookie', cookie).expect(200);
expect(res.body.length).toBeGreaterThanOrEqual(2);
expect(res.body.filter((s: { current: boolean }) => s.current)).toHaveLength(1);
expect(second).toBeTruthy();
});
it('revokes another session; it is logged out on its next request', async () => {
const victim = await login('noch besseres passwort');
const list = await api().get('/api/v1/users/me/sessions').set('Cookie', victim).expect(200);
const current = list.body.find((s: { current: boolean }) => s.current);
await api().delete(`/api/v1/users/me/sessions/${current.id}`).set('Cookie', cookie).expect(204);
await api().get('/api/v1/auth/me').set('Cookie', victim).expect(401);
});
it('refuses to revoke the current session via the revoke endpoint', async () => {
const list = await api().get('/api/v1/users/me/sessions').set('Cookie', cookie).expect(200);
const current = list.body.find((s: { current: boolean }) => s.current);
await api().delete(`/api/v1/users/me/sessions/${current.id}`).set('Cookie', cookie).expect(403);
});
it('revokes all other sessions at once', async () => {
await login('noch besseres passwort');
await login('noch besseres passwort');
await api().delete('/api/v1/users/me/sessions').set('Cookie', cookie).expect(204);
const res = await api().get('/api/v1/users/me/sessions').set('Cookie', cookie).expect(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].current).toBe(true);
});
});

View File

@ -0,0 +1,95 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
NotFoundException,
Param,
Patch,
Post,
Req,
} from '@nestjs/common';
import {
CurrentUser as CurrentUserShape,
changePasswordInputSchema,
updateProfileInputSchema,
} from '@dorfteich/shared';
import { AuthedRequest, toCurrentUser } from '../auth/auth.guard';
import { SessionsService } from '../auth/sessions.service';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { UsersService } from './users.service';
export interface SessionView {
id: string;
createdAt: string;
lastSeenAt: string;
userAgent: string | null;
current: boolean;
}
/** Self-service endpoints for the signed-in account (issues #17, #18). */
@Controller('users/me')
export class UsersController {
constructor(
private readonly users: UsersService,
private readonly sessions: SessionsService,
) {}
@Patch()
async updateProfile(
@Body(new ZodValidationPipe(updateProfileInputSchema))
input: { displayName?: string; locale?: 'de' | 'en' },
@Req() request: AuthedRequest,
): Promise<CurrentUserShape> {
const updated = await this.users.updateProfile(request.user!.id, input);
return toCurrentUser(updated);
}
@Post('change-password')
@HttpCode(204)
async changePassword(
@Body(new ZodValidationPipe(changePasswordInputSchema))
input: { currentPassword: string; newPassword: string },
@Req() request: AuthedRequest,
): Promise<void> {
const userId = request.user!.id;
if (!(await this.users.checkPassword(userId, input.currentPassword))) {
throw new ForbiddenException({ code: 'password_incorrect' });
}
await this.users.setPassword(userId, input.newPassword);
// Every other device gets logged out; the current session stays.
await this.sessions.destroyAllForUser(userId, request.sessionId);
}
@Get('sessions')
async listSessions(@Req() request: AuthedRequest): Promise<SessionView[]> {
const sessions = await this.sessions.listForUser(request.user!.id);
return sessions.map((s) => ({
id: s.id,
createdAt: s.createdAt.toISOString(),
lastSeenAt: s.lastSeenAt.toISOString(),
userAgent: s.userAgent,
current: s.id === request.sessionId,
}));
}
@Delete('sessions/:id')
@HttpCode(204)
async revokeSession(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
if (id === request.sessionId) {
// The current session is ended via logout, not revocation.
throw new ForbiddenException({ code: 'cannot_revoke_current_session' });
}
const removed = await this.sessions.destroyById(id, request.user!.id);
if (!removed) throw new NotFoundException();
}
@Delete('sessions')
@HttpCode(204)
async revokeOtherSessions(@Req() request: AuthedRequest): Promise<void> {
await this.sessions.destroyAllForUser(request.user!.id, request.sessionId);
}
}

View File

@ -1,8 +1,12 @@
import { Module } from '@nestjs/common';
import { SessionsModule } from '../auth/sessions.module';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [SessionsModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})

3
pnpm-lock.yaml generated
View File

@ -74,6 +74,9 @@ importers:
rxjs:
specifier: ^7.8.0
version: 7.8.2
zod:
specifier: ^3.25.76
version: 3.25.76
devDependencies:
'@nestjs/cli':
specifier: ^11.0.0