import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { DEFAULT_ATTACHMENT_EXTENSIONS, VS_NFD_PROFILE, isVsNfdCompliant } from '@dorfteich/shared'; import { Prisma } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { z } from 'zod'; import { AuditService } from '../audit/audit.service'; import { AppConfig } from '../config/app-config.service'; 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'), // Instance-default quotas (ADR 0011); per-user/per-pond overrides live // in quota_overrides and win over these (QuotaService, issue #22). 'quota.editorsPerPond': z.number().int().min(0).default(5), 'quota.readersPerPond': z.number().int().min(0).default(50), 'quota.additionalPonds': z.number().int().min(0).default(0), 'quota.storageBytes': z .number() .int() .min(0) .default(1024 * 1024 * 1024), 'quota.maxFileBytes': z .number() .int() .min(0) .default(25 * 1024 * 1024), // Trash retention (ADR 0013, issue #31): days a soft-deleted page stays // restorable before the daily purge job removes it for good. 'trash.retentionDays': z.number().int().min(1).default(30), // Audit-trail retention (issue #196): days an `audit_log` entry is kept // before the daily retention job removes it; the deletion itself is // recorded (`audit.pruned`) so the gap is explainable. The read-access // trail (#224) is deliberately NOT covered — it gets its own period. 'audit.retentionDays': z.number().int().min(1).default(365), // Conversion-job payload retention (issue #233): days a finished // (succeeded/failed) import/export job keeps its raw input/result bytes // before the daily prune job nulls them. The row itself survives for // status/audit purposes; PENDING/RUNNING jobs are never touched. 'conversion.payloadRetentionDays': z.number().int().min(1).default(30), // Mail outbox retention (issue #234): days a SENT or permanently FAILED // outbox row is kept before the daily retention job deletes it. Digest // bodies carry page titles (content-adjacent data), so the copy must be // bounded. PENDING rows — including failed-but-retryable ones — are // never touched; the retry loop owns them. 'mail.outboxRetentionDays': z.number().int().min(1).default(30), // IdP claim mapping (issue #217, ADR 0021): declarative rules turning // ID-token claims into pond roles and the site-admin flag — instance // configuration, not code. Applied on every OIDC login through the same // grant service path as manual grants (cache + collab revocation stay // correct); the mapping only creates/revokes rows it owns (origin `idp`) // and only demotes a site admin it itself promoted. `site_admin` rules // take no pond; every other role requires one. 'idpMapping.rules': z .array( z.object({ claim: z.string().min(1), value: z.string().min(1), role: z.enum(['site_admin', 'pond_admin', 'editor', 'reader']), pondSlug: z.string().min(1).optional(), }), ) .superRefine((rules, ctx) => { rules.forEach((rule, index) => { if (rule.role === 'site_admin' && rule.pondSlug) { ctx.addIssue({ code: 'custom', path: [index], message: 'validation.invalid' }); } if (rule.role !== 'site_admin' && !rule.pondSlug) { ctx.addIssue({ code: 'custom', path: [index], message: 'validation.required' }); } }); }) .default([]), // Read-trail master switch (issue #225, ADR 0023). Default OFF: read // logging is employee monitoring in a works council's eyes — an ordinary // instance must not surveil reads. The VS-NfD reference configuration // (#227) turns it on together with the written purpose limitation // (60-sicherheitsdokumentation.md §7). Off means NO event is written // anywhere, including stdout; the api states the switch position once at // startup, so a gap in the evidence is never ambiguous. 'readTrail.enabled': z.boolean().default(false), // Read-trail dedup window (issue #223, ADR 0023): one event per // (session, page, channel) within an aligned window of this many minutes. // 5 minutes keeps a live Yjs session (collab tokens every 60 s) at a // bounded ~12 events/hour/page while still evidencing distinct visits. 'readTrail.dedupWindowMinutes': z.number().int().min(1).default(5), // Read-trail retention (issue #224): days a read event is kept before the // daily maintenance job removes it — deliberately independent of // `audit.retentionDays` (#196), because volume, purpose and legal basis // differ. The deletion itself is audited (`read_trail.pruned`), so a gap // is always explainable. One year mirrors the audit default; shortening // it is an operator decision under the purpose limitation (#225). 'readTrail.retentionDays': z.number().int().min(1).default(365), // Default VS-NfD classification for newly created pages (ADR 0022, // issue #204). An instance operated inside a classified environment sets // this to `vs_nfd` so nothing starts unmarked; inheritance from the // parent page (#205) wins over this default. The marking is not a // protection mechanism — permissions ignore it. 'classification.newPageDefault': z.enum(['unclassified', 'vs_nfd']).default('unclassified'), // Attaching files to a classified page (issue #213, ADR 0022): the UI // always warns (the file inherits a classification its content cannot // carry, #212); `block` hardens the warning into a server-side rejection. // Default `warn` — blocking is the reference-configuration choice (#227). 'classification.uploadPolicy': z.enum(['warn', 'block']).default('warn'), // Non-image upload allowlist (ADR 0011, issue #61): lowercase extensions // without the dot. Images are always allowed regardless; SVG is governed // by `upload.svgPolicy`. Normalized (lowercased, dot-stripped, deduped) so // an admin can paste `.PDF` or `pdf` interchangeably. 'upload.allowedExtensions': z .array(z.string()) .transform((exts) => [ ...new Set(exts.map((e) => e.trim().replace(/^\./, '').toLowerCase()).filter(Boolean)), ]) .pipe(z.array(z.string().regex(/^[a-z0-9]+$/))) .default([...DEFAULT_ATTACHMENT_EXTENSIONS]), // SVG upload handling (security.md §Uploads): sanitize strips scripts and // event handlers with a maintained library; reject refuses SVG outright. 'upload.svgPolicy': z.enum(['reject', 'sanitize']).default('sanitize'), // Public REST API master switch (issue #104, default off): every // /api/public/v1 route answers 404 while disabled. Individual ponds // additionally opt in through their pond settings (`apiEnabled`). 'api.enabled': z.boolean().default(false), // Built-in MCP endpoint master switch (issue #105, default off) — // independent of the REST switch; ponds opt in via `mcpEnabled`. 'mcp.enabled': z.boolean().default(false), // Plugin-architecture master switch (issue #200, ADR 0025). Default ON: // plugins predate the switch, so existing instances keep working; the // VS-NfD reference configuration (#227) turns it off. While off, every // plugin surface answers 404 (admin install/list, pond toggles, frame // and asset routes) — only the authenticated fallback-metadata route // stays, so existing blocks still render their declared text fallback // (an image fallback degrades to neutral text: its bytes live on the // disabled asset surface). 'plugins.enabled': z.boolean().default(true), // Plugin allowlist with SHA-256 hash pinning (issue #232, ADR 0025). // Empty (the default) = pinning is NOT enforced — plugins load as // before, which keeps existing instances working. Non-empty = only the // listed plugin ids load, and only while the installed bundle's // observed hash equals the pinned one; installs of unlisted or // mismatching bundles are rejected, loads fail closed (assets/frame // 404, audited `plugin.rejected`). A version bump changes the bundle // hash, so it requires an explicit re-pin — the intended friction. 'plugins.allowlist': z .array( z.object({ id: z.string().min(1), sha256: z .string() .trim() .toLowerCase() .regex(/^[a-f0-9]{64}$/), }), ) .default([]), // Atom feed master switch (issue #191). Default ON: feeds predate the // switch, so existing instances and their subscribed readers keep // working; the VS-NfD reference configuration (#227) turns it off. // While disabled, the feed routes AND the feed-token management answer // 404 (existence hidden, same semantics as the two switches above). 'feeds.enabled': z.boolean().default(true), // Backup targets (ADR 0015, issue #103). The backup sidecar reads these // rows directly (apps/backup settings.ts — keep the schemas in sync); the // Nextcloud app password is NOT here, it lives in the secret store // (security.md §Secrets). localRetentionDays `null` = no admin override, // the sidecar's BACKUP_RETENTION_DAYS env stays authoritative. 'backup.localRetentionDays': z.number().int().min(1).nullable().default(null), 'backup.remoteRetentionDays': z.number().int().min(1).default(30), 'backup.nextcloud.enabled': z.boolean().default(false), 'backup.nextcloud.baseUrl': z.string().trim().url().or(z.literal('')).default(''), 'backup.nextcloud.username': z.string().trim().max(200).default(''), 'backup.nextcloud.folder': z .string() .trim() .max(500) .refine((folder) => !folder.split('/').some((s) => s === '.' || s === '..'), { message: 'validation.invalid', }) .default('dorfteich-backups'), 'backup.nextcloud.uploadSchedule': z.enum(['off', 'daily', 'weekly']).default('daily'), // Instance legal pages (issue #82, security.md §Privacy): Markdown texts // for imprint and privacy policy, rendered publicly at /legal/. // Empty = not configured yet (the legal pages then show a notice and // Site Admins a warning banner instead of silently missing). 'legal.imprint': z.string().max(100_000).default(''), 'legal.privacyPolicy': z.string().max(100_000).default(''), // Landing-page body: the Site Admin's Markdown for the public home page // (`/`), rendered through the same sanitizing pipeline as the legal pages. // Empty = the built-in default welcome text is shown instead. 'home.content': z.string().max(100_000).default(''), // 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; export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS; export type InstanceSettingValue = z.infer< (typeof INSTANCE_SETTINGS)[K] >; export type InstanceSettings = { [K in InstanceSettingKey]: InstanceSettingValue }; /** * 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(); constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, private readonly logger: PinoLogger, private readonly config: AppConfig, ) { this.logger.setContext(InstanceSettingsService.name); } async get(key: K): Promise> { if (this.cache.has(key)) return this.cache.get(key) as InstanceSettingValue; 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; } async getAll(): Promise { 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( key: K, value: unknown, actorUserId: string, ): Promise> { 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) }, }); } // Mode `enforced` (#246, ADR 0027): a write that would set a // catalog-violating value is rejected at the ONE write path every // caller uses — hiding alone (#245) is UI cosmetics a scripted client // bypasses. Existing violating values are reported (startup log, // admin card), never auto-changed: the operator resolves them // consciously, and writes that DECREASE compliance are what this // blocks. 403, not 400: the request is well-formed, the policy says no. if (this.config.env.VS_NFD_MODE === 'enforced') { const entry = VS_NFD_PROFILE.find((e) => e.scope === 'instance' && e.key === key); if (entry && !isVsNfdCompliant(entry, parsed.data)) { throw new ForbiddenException({ code: 'vs_nfd_profile_violation', details: { [key]: ['vs_nfd_profile_violation'] }, }); } } // 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({ where: { key }, create: { key, value: stored }, update: { value: stored }, }); this.cache.set(key, parsed.data); // Values stay out of the trail: legal texts are long, and future keys // could be sensitive — the key names what changed, the log has the actor. await this.audit.record({ action: 'settings.changed', actorId: actorUserId, targetType: 'setting', targetId: key, }); return parsed.data as InstanceSettingValue; } }