All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m21s
CI / Build container images (pull_request) Successful in 3m59s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m27s
CI / Import/export fidelity gate (push) Successful in 58s
CI / Lint, typecheck, test (push) Successful in 6m30s
The install path records the SHA-256 of the delivered bundle ZIP (plugins.bundle_hash; pre-#232 installs show it as unknown until reinstalled). plugins.allowlist in instance_settings names permitted ids with their pinned hashes: empty (default) = not enforced, existing instances unchanged; non-empty = installs of unlisted or deviating bundles are rejected (plugin_not_pinned / plugin_hash_mismatch, 403), and an installed plugin outside the list or with a deviating hash does not load — absent from pond mount lists, frame/assets 404. Every rejection is audited (plugin.rejected, catalogue v1.5). A version bump changes the hash and therefore requires an explicit re-pin — the intended friction (ADR 0025). Admin UI shows observed vs pinned hash per plugin with pin/re-pin/unpin. Scope stated honestly in plugin-architecture.md: the pin answers "is this the reviewed bundle"; post-install disk tampering is platform integrity (ADR 0019), sandbox containment stays the sandbox's job. Hardening guide row + catalog advisory triage; residual risk R-03 resolved. e2e: empty-allowlist compatibility, pinned load, unpinned and tampered installs rejected and audited, pin drift blocks loading while the admin still sees the mismatch, version bump needs re-pin. Full api suite 101 files / 561 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
298 lines
15 KiB
TypeScript
298 lines
15 KiB
TypeScript
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/<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,
|
|
private readonly config: AppConfig,
|
|
) {
|
|
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) },
|
|
});
|
|
}
|
|
// 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<K>;
|
|
}
|
|
}
|