dorfteich/apps/api/src/settings/instance-settings.service.ts
Claude Fable 5 183faf7710
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m42s
CI / Build container images (pull_request) Successful in 3m56s
CI / Auth e2e pack (pull_request) Successful in 8m17s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 24s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 6m17s
CD / Smoke tests against Test (push) Successful in 3m32s
CI / Build container images (push) Has been skipped
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 8m20s
CI / Import/export fidelity gate (push) Successful in 55s
#204: classification as first-class page metadata (ADR 0022)
Enum field on Page (UNCLASSIFIED default, VS_NFD), migration backfills
existing pages. New pages take the instance-wide default from
classification.newPageDefault (admin-visible, de+en). The value rides in
every PageView, so no channel needs an extra request. The field is a
marking, not a protection mechanism: a test pins that permission
decisions are unchanged by it. The marking wording is fixed in ADR 0022
and sourced solely from classificationMarking() in @dorfteich/shared.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:01:50 +02:00

208 lines
10 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),
// 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'),
// 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>;
}
}