dorfteich/apps/api/src/settings/instance-settings.service.ts
Claude Fable 5 4af5e6e81f
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m2s
CI / Build container images (pull_request) Successful in 4m1s
CI / Auth e2e pack (pull_request) Successful in 8m26s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m23s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m6s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m25s
CI / Import/export fidelity gate (push) Successful in 1m0s
#225: read-trail master switch and written purpose limitation
New instance switch readTrail.enabled, default OFF: read logging is
employee monitoring in a works council's eyes — an ordinary instance
must not surveil reads. Off means no event is written ANYWHERE (no row,
no stdout line, verified by test); the api announces the switch position
once per boot, so an eventless trail is never ambiguous — a gap reads
as "was off", never "was lost".

The written purpose limitation ships as section 7 of the VS-NfD
security documentation (#228): what is recorded (no content, no titles,
no IPs, no fingerprinting), why (evidence for reads of marked content
only — variant A is the technical anchor of the promise), who may read
it (Site Admin, API-only), for how long (readTrail.retentionDays,
audited pruning), and what it may NOT be used for (no performance or
behaviour monitoring). The hardening guide's reference configuration
turns the trail on (reference value true) and points to that text; the
existing trail suites now enable the switch explicitly.

Refs #225.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:35:18 +02:00

233 lines
12 KiB
TypeScript

import { BadRequestException, Injectable } from '@nestjs/common';
import { DEFAULT_ATTACHMENT_EXTENSIONS } from '@dorfteich/shared';
import { Prisma } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { z } from 'zod';
import { AuditService } from '../audit/audit.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),
// 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),
// 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/<kind>.
// 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<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 audit: AuditService,
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) },
});
}
// 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<K>;
}
}