diff --git a/apps/api/src/admin/system-admin.controller.ts b/apps/api/src/admin/system-admin.controller.ts
index 362d92a..051030a 100644
--- a/apps/api/src/admin/system-admin.controller.ts
+++ b/apps/api/src/admin/system-admin.controller.ts
@@ -10,10 +10,12 @@ import {
type StorageOverviewView,
type SystemBackupView,
type SystemJobView,
+ type VsNfdProfileView,
} from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
+import { VsNfdProfileService } from '../settings/vs-nfd-profile.service';
import { SiteAdminGuard } from './site-admin.guard';
import { SystemAdminService } from './system-admin.service';
@@ -21,7 +23,17 @@ import { SystemAdminService } from './system-admin.service';
@Controller('admin/system')
@UseGuards(SiteAdminGuard)
export class SystemAdminController {
- constructor(private readonly system: SystemAdminService) {}
+ constructor(
+ private readonly system: SystemAdminService,
+ private readonly vsNfdProfile: VsNfdProfileService,
+ ) {}
+
+ /** Active VS-NfD mode + catalog verdict for the running configuration
+ * (issue #243, ADR 0027). Exposure only — the treatments are #244–#246. */
+ @Get('vs-nfd-profile')
+ vsNfd(): Promise {
+ return this.vsNfdProfile.evaluate();
+ }
@Get('jobs')
async jobs(): Promise {
diff --git a/apps/api/src/settings/settings.module.ts b/apps/api/src/settings/settings.module.ts
index 805938f..edb37b0 100644
--- a/apps/api/src/settings/settings.module.ts
+++ b/apps/api/src/settings/settings.module.ts
@@ -1,11 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { InstanceSettingsService } from './instance-settings.service';
+import { VsNfdProfileService } from './vs-nfd-profile.service';
/** Global: instance configuration is read across many feature modules. */
@Global()
@Module({
- providers: [InstanceSettingsService],
- exports: [InstanceSettingsService],
+ providers: [InstanceSettingsService, VsNfdProfileService],
+ exports: [InstanceSettingsService, VsNfdProfileService],
})
export class SettingsModule {}
diff --git a/apps/api/src/settings/vs-nfd-profile-catalogue.test.ts b/apps/api/src/settings/vs-nfd-profile-catalogue.test.ts
new file mode 100644
index 0000000..3f38a4a
--- /dev/null
+++ b/apps/api/src/settings/vs-nfd-profile-catalogue.test.ts
@@ -0,0 +1,54 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { VS_NFD_PROFILE, VS_NFD_PROFILE_ADVISORY } from '@dorfteich/shared';
+import { describe, expect, it } from 'vitest';
+
+/**
+ * The fence that keeps the machine-readable VS-NfD catalog (issue #243)
+ * and the hardening guide (issue #227) together — same pattern as the
+ * audit-catalogue fence (#201): every switch the guide's reference tables
+ * name must be triaged into the catalog (decidable compliant value) or
+ * the explicit advisory list (judgement call), and neither may name a
+ * switch the guide does not know. A new switch line in the guide without
+ * a triage decision fails this test.
+ */
+// __dirname, not import.meta: the api package compiles CJS.
+const doc = readFileSync(
+ join(__dirname, '../../../../docs/vs-nfd/50-haertungsleitfaden.md'),
+ 'utf8',
+);
+
+/** All code spans in the first cell of each table row between two headings. */
+function guideKeys(fromHeading: string, toHeading: string): string[] {
+ const from = doc.indexOf(fromHeading);
+ const to = doc.indexOf(toHeading);
+ expect(from).toBeGreaterThan(-1);
+ expect(to).toBeGreaterThan(from);
+ const keys: string[] = [];
+ for (const line of doc.slice(from, to).split('\n')) {
+ if (!line.startsWith('|')) continue;
+ const firstCell = line.split('|')[1] ?? '';
+ for (const match of firstCell.matchAll(/`([^`]+)`/g)) {
+ keys.push(match[1]!);
+ }
+ }
+ return keys.sort();
+}
+
+function triagedKeys(scope: 'instance' | 'deploy'): string[] {
+ return [
+ ...VS_NFD_PROFILE.filter((e) => e.scope === scope).map((e) => e.key),
+ ...VS_NFD_PROFILE_ADVISORY.filter((e) => e.scope === scope).map((e) => e.key),
+ ].sort();
+}
+
+describe('VS-NfD catalog fence (issue #243)', () => {
+ it('triages every instance setting of hardening-guide §1.1', () => {
+ expect(triagedKeys('instance')).toEqual(guideKeys('### 1.1', '### 1.2'));
+ });
+
+ it('triages every deploy variable of hardening-guide §1.2', () => {
+ expect(triagedKeys('deploy')).toEqual(guideKeys('### 1.2', '### 1.3'));
+ });
+});
diff --git a/apps/api/src/settings/vs-nfd-profile.e2e.db.test.ts b/apps/api/src/settings/vs-nfd-profile.e2e.db.test.ts
new file mode 100644
index 0000000..2ac9f89
--- /dev/null
+++ b/apps/api/src/settings/vs-nfd-profile.e2e.db.test.ts
@@ -0,0 +1,114 @@
+import { INestApplication } from '@nestjs/common';
+import { type VsNfdProfileView } from '@dorfteich/shared';
+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';
+
+import { InstanceSettingsService } from './instance-settings.service';
+
+/**
+ * The VS-NfD profile endpoint (issue #243): active mode from the env,
+ * catalog verdict from the running configuration — Site-Admin only. The
+ * mode is deploy-level, so it is fixed per app boot (env override BEFORE
+ * createTestApp, pattern local-auth-switch.e2e.db.test.ts).
+ */
+describe.skipIf(!hasTestDb)('VS-NfD profile endpoint (e2e, issue #243)', () => {
+ let app: INestApplication;
+ let prisma: PrismaClient;
+ const suffix = uniqueSuffix();
+ const password = 'profilkatalog ist wachsam 1';
+ const ids: Record = {};
+ const cookies: Record = {};
+
+ const api = () => request(app.getHttpServer());
+
+ async function makeUser(handle: string, siteAdmin: boolean): Promise {
+ const users = app.get(UsersService);
+ const username = `nfd-${handle}-${suffix}`;
+ const user = await users.createUser({
+ username,
+ email: `${username}@example.org`,
+ displayName: `Nfd ${handle}`,
+ password,
+ locale: 'en',
+ });
+ await users.markEmailVerified(user.id);
+ if (siteAdmin)
+ await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
+ ids[handle] = user.id;
+ cookies[handle] = sessionCookieOf(
+ await api()
+ .post('/api/v1/auth/login')
+ .send({ usernameOrEmail: username, password })
+ .expect(200),
+ );
+ }
+
+ beforeAll(async () => {
+ process.env.VS_NFD_MODE = 'marked';
+ prisma = createTestPrisma();
+ await prisma.rateLimit.deleteMany({});
+ app = await createTestApp();
+ await makeUser('admin', true);
+ await makeUser('user', false);
+ });
+
+ afterAll(async () => {
+ delete process.env.VS_NFD_MODE;
+ await prisma.instanceSetting.deleteMany({
+ where: { key: { in: ['auth.registrationMode', 'legal.imprint'] } },
+ });
+ await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
+ await prisma.$disconnect();
+ await app.close();
+ });
+
+ it('is Site-Admin only', async () => {
+ await api().get('/api/v1/admin/system/vs-nfd-profile').expect(401);
+ await api().get('/api/v1/admin/system/vs-nfd-profile').set('Cookie', cookies.user!).expect(403);
+ });
+
+ it('reports the env mode and a truthful per-entry verdict', async () => {
+ const res = await api()
+ .get('/api/v1/admin/system/vs-nfd-profile')
+ .set('Cookie', cookies.admin!)
+ .expect(200);
+ const view = res.body as VsNfdProfileView;
+ expect(view.mode).toBe('marked');
+ // Fresh instance: registration open, feeds/plugins on, read trail off,
+ // legal texts empty — all violations by design of the defaults.
+ const byKey = new Map(view.entries.map((e) => [e.key, e]));
+ expect(byKey.get('auth.registrationMode')!.compliant).toBe(false);
+ expect(byKey.get('api.enabled')!.compliant).toBe(true);
+ expect(byKey.get('feeds.enabled')!.compliant).toBe(false);
+ expect(byKey.get('readTrail.enabled')!.compliant).toBe(false);
+ expect(byKey.get('legal.imprint')!.compliant).toBe(false);
+ // Deploy scope: the test env keeps the permissive defaults.
+ expect(byKey.get('AUTH_LOCAL_ENABLED')!.compliant).toBe(false);
+ expect(view.violations).toBe(view.entries.filter((e) => !e.compliant).length);
+ expect(view.violations).toBeGreaterThan(0);
+ });
+
+ it('verdict follows a settings change', async () => {
+ const settings = app.get(InstanceSettingsService);
+ const before = (
+ await api()
+ .get('/api/v1/admin/system/vs-nfd-profile')
+ .set('Cookie', cookies.admin!)
+ .expect(200)
+ ).body as VsNfdProfileView;
+ await settings.set('auth.registrationMode', 'closed', ids.admin!);
+ const after = (
+ await api()
+ .get('/api/v1/admin/system/vs-nfd-profile')
+ .set('Cookie', cookies.admin!)
+ .expect(200)
+ ).body as VsNfdProfileView;
+ expect(after.entries.find((e) => e.key === 'auth.registrationMode')!.compliant).toBe(true);
+ expect(after.violations).toBe(before.violations - 1);
+ });
+});
diff --git a/apps/api/src/settings/vs-nfd-profile.service.ts b/apps/api/src/settings/vs-nfd-profile.service.ts
new file mode 100644
index 0000000..b6d6a91
--- /dev/null
+++ b/apps/api/src/settings/vs-nfd-profile.service.ts
@@ -0,0 +1,50 @@
+import { Injectable } from '@nestjs/common';
+import {
+ VS_NFD_PROFILE,
+ describeCompliance,
+ isVsNfdCompliant,
+ type ApiEnv,
+ type VsNfdProfileEntry,
+ type VsNfdProfileView,
+} from '@dorfteich/shared';
+
+import { AppConfig } from '../config/app-config.service';
+import { InstanceSettingsService, type InstanceSettings } from './instance-settings.service';
+
+/**
+ * Evaluates the running configuration against the VS-NfD reference
+ * profile (issue #243, ADR 0027). Instance entries read the settings
+ * registry, deploy entries the validated env — both through their normal
+ * typed paths, so the verdict always describes what the application
+ * actually does, not what a file claims. The three treatment modes
+ * (#244–#246) build on this evaluation; here it is exposure only.
+ */
+@Injectable()
+export class VsNfdProfileService {
+ constructor(
+ private readonly settings: InstanceSettingsService,
+ private readonly config: AppConfig,
+ ) {}
+
+ private valueOf(entry: VsNfdProfileEntry, settings: InstanceSettings): unknown {
+ return entry.scope === 'instance'
+ ? settings[entry.key as keyof InstanceSettings]
+ : this.config.env[entry.key as keyof ApiEnv];
+ }
+
+ async evaluate(): Promise {
+ const settings = await this.settings.getAll();
+ const entries = VS_NFD_PROFILE.map((entry) => ({
+ scope: entry.scope,
+ key: entry.key,
+ compliant: isVsNfdCompliant(entry, this.valueOf(entry, settings)),
+ compliantValue: describeCompliance(entry.compliance),
+ hardeningRef: entry.hardeningRef,
+ }));
+ return {
+ mode: this.config.env.VS_NFD_MODE,
+ entries,
+ violations: entries.filter((entry) => !entry.compliant).length,
+ };
+ }
+}
diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx
index 62a0eb0..ed7b893 100644
--- a/apps/web/src/pages/AdminSettingsPage.tsx
+++ b/apps/web/src/pages/AdminSettingsPage.tsx
@@ -1,5 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
-import { docToHtml, markdownToDoc } from '@dorfteich/shared';
+import { docToHtml, markdownToDoc, type VsNfdProfileView } from '@dorfteich/shared';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -71,6 +71,7 @@ export function AdminSettingsPage(): React.JSX.Element {
{t('system:settingsLink')} →
+
{t('settings:admin.general')}