Compare commits

...

5 Commits

Author SHA1 Message Date
040f3fbeae #229: operations manual (install, update, backup/restore, deletion, roles)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m1s
CI / Build container images (pull_request) Successful in 1m21s
CI / Auth e2e pack (pull_request) Successful in 8m47s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
docs/vs-nfd/70-betriebshandbuch.md: installation as run on the real
stages (airgap variant explicitly pending #218-#221 with what already
exists as groundwork), update/rollback incl. the no-down-migrations
caveat, backup/restore with the ADR-0026 target allowlist and the
rehearsed monthly restore drill (evidence: logs on #98), the full
scheduler-job table (cadences verified against code), the deletion-and-
destruction chapter built on the #228 copy list (per content type:
what deletion reaches, what remains, immediate-destruction path,
decommissioning), and role separation incl. the deliberate limits of a
Site Admin and the honest note that Site Admin read-bypass makes the
content/platform split non-absolute app-side. Every procedure carries
its evidence level (erprobt / nicht geprobt / offen) — nothing claimed
above what was actually executed.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:35:26 +02:00
f0c6af4412 #228: security documentation (architecture, data flows, network plan)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m0s
CI / Build container images (pull_request) Successful in 1m22s
CI / Auth e2e pack (pull_request) Successful in 9m2s
CI / Import/export fidelity gate (pull_request) Successful in 1m2s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
docs/vs-nfd/60-sicherheitsdokumentation.md: component diagram with per-
service purpose and privileges, network plan digit-exact against the
deploy compose (127.0.0.1-only app bindings, internal-only data zone),
data-flow diagrams (auth, realtime editing incl. LISTEN/NOTIFY and the
60s collab token, export via the pinned sidecars, backup incl. the
ADR-0026 allowlist, and every read channel), named trust boundaries
(reverse proxy, plugin sandbox, outbound SMTP/mirror), and the complete
list of content copies — in-database, on-volume and outside the
instance — that the deletion concept in #229 builds on. Mermaid only,
German (assessor audience), with the maintained-in-same-PR rule stated.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:35:26 +02:00
868b79c8bc #213: warn on uploads to classified pages; instance policy can block
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m15s
CI / Build container images (pull_request) Successful in 4m27s
CI / Auth e2e pack (pull_request) Successful in 9m10s
CI / Import/export fidelity gate (pull_request) Successful in 53s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 20s
CI / Lint, typecheck, test (push) Successful in 5m47s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m26s
CI / Import/export fidelity gate (push) Successful in 1m0s
The attachments panel of a classified page shows a persistent notice
naming the consequence (de+en): the file inherits the page's
classification but its content carries no marking (#212). The new
instance setting classification.uploadPolicy (default warn, documented;
the VS-NfD reference configuration blocks, #227) hardens the warning
into a server-side rejection (403 classified_upload_blocked) — enforced
in the upload service, not only in the UI. Tests: warning visible in the
local attachments pack; block enforced server-side with warn/block both
ways and open pages unaffected.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:33:34 +02:00
e505fc74dc #212: mark attachment downloads by filename prefix and companion file
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m34s
CI / Build container images (pull_request) Successful in 14s
CI / Auth e2e pack (pull_request) Successful in 9m36s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
Downloads whose effective classification is vs_nfd carry the documented
VS-NfD_ filename prefix (single source classificationFilenamePrefix() in
shared; ADR 0022 records the short form for file names). Effective
classification: the linked page's level; an attachment with unset pageId
(paste-then-insert, pond-level) FAILS CLOSED to the highest level of any
live page in its pond. The pond export ZIP adds a sibling
<file>.classification.txt companion with the full marking for classified
media, next to the manifest entry (#210). Documented in operations.md,
incl. the deliberate residual risk: the file's own content carries no
marking (recorded on #231, not hidden). Tests: prefixed classified
download, unchanged open download, fail-closed orphan both ways, ZIP
companion + manifest level.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:29:16 +02:00
521ea514b4 #211: classification through feeds, public API, search and the no-JS shell
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m38s
CI / Build container images (pull_request) Successful in 4m14s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m6s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
Feeds: classified entries carry a standard Atom <category>
(term=level, scheme=urn:dorfteich:classification, label=the fixed
wording); the feed document states the highest contained level once;
all-open feeds carry none. Public API: page representations (list+get)
gain the classification field, OpenAPI + public-api.md documented.
Search: every hit carries the level and the palette renders the marking
with the snippet (compact form of the banner, text token only). No-JS
shell: banner above and below the content, own markup for the separate
render path; unclassified pages unchanged everywhere. One test per
channel (feed categories + count, public API list/get with the switch
on, search hit levels, shell top+bottom).

Also: fidelity CI sidecars get per-job container names — the fixed
names collided across parallel runs on the shared host (run 547's red
fidelity job; a fixed-name cleanup could even kill a sibling's live
sidecars).

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:23:53 +02:00
39 changed files with 1086 additions and 32 deletions

View File

@ -674,13 +674,16 @@ jobs:
# image has no iproute2). Sharing the netns means no published ports.
- name: Start pinned pandoc + Gotenberg sidecars
run: |
# Clear any leftovers from an earlier interrupted run so the named
# containers never collide, and nothing leaks on the shared host.
docker rm -f fidelity-pandoc fidelity-gotenberg 2>/dev/null || true
# Sidecar names carry THIS job container's id: parallel runs on the
# shared host must not collide on a fixed name (a fixed-name rm -f
# here even killed a sibling run's live sidecars — run 547).
JOB_ID=$(cat /etc/hostname)
docker run -d --name fidelity-pandoc \
echo "PANDOC_NAME=fidelity-pandoc-${JOB_ID}" >> "$GITHUB_ENV"
echo "GOTENBERG_NAME=fidelity-gotenberg-${JOB_ID}" >> "$GITHUB_ENV"
docker rm -f "fidelity-pandoc-${JOB_ID}" "fidelity-gotenberg-${JOB_ID}" 2>/dev/null || true
docker run -d --name "fidelity-pandoc-${JOB_ID}" \
--network "container:${JOB_ID}" pandoc/core:3.6 server
docker run -d --name fidelity-gotenberg \
docker run -d --name "fidelity-gotenberg-${JOB_ID}" \
--network "container:${JOB_ID}" gotenberg/gotenberg:8
for i in $(seq 1 30); do
curl -sf http://localhost:3030/version >/dev/null && break
@ -704,15 +707,15 @@ jobs:
- name: Dump sidecar logs on failure
if: failure()
run: |
echo '--- pandoc ---'; docker logs fidelity-pandoc 2>&1 | tail -30 || true
echo '--- gotenberg ---'; docker logs fidelity-gotenberg 2>&1 | tail -30 || true
echo '--- pandoc ---'; docker logs "$PANDOC_NAME" 2>&1 | tail -30 || true
echo '--- gotenberg ---'; docker logs "$GOTENBERG_NAME" 2>&1 | tail -30 || true
# Always tear the sidecars down — they run on the shared runner host, so a
# leaked (especially Chromium-backed Gotenberg) container would waste its
# memory until the next run and break re-runs on the container name.
# memory until the next run.
- name: Stop sidecars
if: always()
run: docker rm -f fidelity-pandoc fidelity-gotenberg 2>/dev/null || true
run: docker rm -f "$PANDOC_NAME" "$GOTENBERG_NAME" 2>/dev/null || true
images:
name: Build container images

View File

@ -90,14 +90,17 @@ export class FilesController {
@Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> {
const { attachment, stream, inline } = await this.files.download(request.user ?? null, fileId);
const { attachment, stream, inline, downloadName } = await this.files.download(
request.user ?? null,
fileId,
);
response.set('X-Content-Type-Options', 'nosniff');
// Attachments are immutable — a new upload always gets a new id.
response.set('Cache-Control', 'private, max-age=31536000, immutable');
const kind = inline ? 'inline' : 'attachment';
return new StreamableFile(stream, {
type: attachment.mimeType,
disposition: `${kind}; filename="${encodeURIComponent(attachment.fileName)}"`,
disposition: `${kind}; filename="${encodeURIComponent(downloadName)}"`,
});
}

View File

@ -327,6 +327,121 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => {
expect(item.pageTitle).toBe(`Page Files ${suffix}`);
});
it('prefixes downloads of classified attachments; unset pageId fails closed (issue #212)', async () => {
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Classified Files ${suffix}` })
.expect(201);
const uploaded = await api()
.post(`/api/v1/pages/${page.body.id}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('%PDF-1.4 classified content'), 'geheim.pdf')
.expect(201);
// Unclassified page: unchanged filename.
const openServed = await api()
.get(`/api/v1/media/${uploaded.body.id}`)
.set('Cookie', ownerCookie)
.buffer(true)
.parse(binaryParser as unknown as ParseCallback)
.expect(200);
expect(openServed.headers['content-disposition']).toContain('filename="geheim.pdf"');
// Classified page: the documented VS-NfD_ prefix.
await prisma.page.update({
where: { id: page.body.id as string },
data: { classification: 'VS_NFD' },
});
const served = await api()
.get(`/api/v1/media/${uploaded.body.id}`)
.set('Cookie', ownerCookie)
.buffer(true)
.parse(binaryParser as unknown as ParseCallback)
.expect(200);
expect(served.headers['content-disposition']).toContain('filename="VS-NfD_geheim.pdf"');
// pageId unset (paste-then-insert): fails closed to the pond's highest
// level — the pond now contains a classified page, so the orphan upload
// is served with the prefix too.
const orphan = await api()
.post(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('%PDF-1.4 orphan bytes'), 'lose-datei.pdf')
.expect(201);
const orphanServed = await api()
.get(`/api/v1/media/${orphan.body.id}`)
.set('Cookie', ownerCookie)
.buffer(true)
.parse(binaryParser as unknown as ParseCallback)
.expect(200);
expect(orphanServed.headers['content-disposition']).toContain(
'filename="VS-NfD_lose-datei.pdf"',
);
// Back to all-open: the orphan serves unprefixed again.
await prisma.page.update({
where: { id: page.body.id as string },
data: { classification: 'UNCLASSIFIED' },
});
const openOrphan = await api()
.get(`/api/v1/media/${orphan.body.id}`)
.set('Cookie', ownerCookie)
.buffer(true)
.parse(binaryParser as unknown as ParseCallback)
.expect(200);
expect(openOrphan.headers['content-disposition']).toContain('filename="lose-datei.pdf"');
});
it('blocks uploads to classified pages server-side when the policy says so (issue #213)', async () => {
const settings = app.get(InstanceSettingsService);
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Blocked Uploads ${suffix}` })
.expect(201);
await prisma.page.update({
where: { id: page.body.id as string },
data: { classification: 'VS_NFD' },
});
// Default policy `warn`: the upload is allowed (the UI shows the notice).
await api()
.post(`/api/v1/pages/${page.body.id}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('%PDF-1.4 warned upload'), 'warned.pdf')
.expect(201);
await settings.set('classification.uploadPolicy', 'block', 'test');
try {
// Enforced server-side, not only in the UI.
const blocked = await api()
.post(`/api/v1/pages/${page.body.id}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('%PDF-1.4 blocked upload'), 'blocked.pdf')
.expect(403);
expect((blocked.body as { code: string }).code).toBe('classified_upload_blocked');
// Unclassified pages stay uploadable under `block`.
const open = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Open Uploads ${suffix}` })
.expect(201);
await api()
.post(`/api/v1/pages/${open.body.id}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('%PDF-1.4 open upload'), 'open.pdf')
.expect(201);
} finally {
await settings.set('classification.uploadPolicy', 'warn', 'test');
await prisma.instanceSetting.deleteMany({
where: { key: 'classification.uploadPolicy' },
});
}
});
it('pond file manager reports usage, orphans, and page links (#61)', async () => {
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)

View File

@ -3,6 +3,7 @@ import { Readable } from 'node:stream';
import {
BadRequestException,
ForbiddenException,
Injectable,
InternalServerErrorException,
NotFoundException,
@ -13,7 +14,9 @@ import {
AttachmentListItemView,
AttachmentView,
PondFilesView,
PageClassification,
SVG_MIME_TYPE,
classificationFilenamePrefix,
fileExtension,
isImageMimeType,
} from '@dorfteich/shared';
@ -36,6 +39,11 @@ export interface FileDownload {
* else office files, PDFs, and SVG is always sent as a download so it
* can never execute inline (ADR 0011, security.md §Uploads). */
inline: boolean;
/** The filename for the Content-Disposition (issue #212, ADR 0022): the
* original name, prefixed `VS-NfD_` when the attachment's effective
* classification is vs_nfd the one marker an arbitrary binary can
* carry. The file's CONTENT stays unmarked (documented residual risk). */
downloadName: string;
}
/** What the upload bytes resolved to after allowlist + SVG handling. */
@ -198,6 +206,16 @@ export class FilesService {
): Promise<AttachmentView> {
const page = await this.prisma.page.findFirst({ where: { id: pageId } });
if (!page) throw new NotFoundException();
// Attaching to a classified page (issue #213, ADR 0022): the file will
// inherit a classification its content cannot carry (#212). The UI warns;
// the instance can harden the warning into a server-side block — enforced
// HERE, not only client-side.
if (page.classification === 'VS_NFD') {
const policy = await this.settings.get('classification.uploadPolicy');
if (policy === 'block') {
throw new ForbiddenException({ code: 'classified_upload_blocked' });
}
}
return this.upload(user, page.pondId, file, page.id);
}
@ -229,13 +247,40 @@ export class FilesService {
throw new InternalServerErrorException({ code: 'attachment_integrity_failure' });
}
}
const classification = await this.effectiveClassification(attachment);
return {
attachment,
stream: Readable.from(buffer),
inline: isImageMimeType(attachment.mimeType),
downloadName: `${classificationFilenamePrefix(classification)}${attachment.fileName}`,
};
}
/**
* The classification an attachment inherits (issue #212, ADR 0022): its
* page's level. An attachment whose `pageId` is still unset
* (paste-then-insert, pond-level files) FAILS CLOSED to the highest level
* of any live page in its pond it could belong to any of them, so it is
* treated as classified as the most classified candidate. In an all-open
* pond that is `unclassified`, so nothing gets marked noise.
*/
private async effectiveClassification(attachment: Attachment): Promise<PageClassification> {
if (attachment.pageId) {
const page = await this.prisma.page.findUnique({
where: { id: attachment.pageId },
select: { classification: true },
});
if (page) return page.classification.toLowerCase() as PageClassification;
// Page row gone but link set (race with purge): fall through to the
// pond-wide fail-closed answer below.
}
const classified = await this.prisma.page.findFirst({
where: { pondId: attachment.pondId, deletedAt: null, classification: 'VS_NFD' },
select: { id: true },
});
return classified ? 'vs_nfd' : 'unclassified';
}
/**
* Hash attachments that predate #199 (sha256 null), a bounded batch per
* nightly run until none remain idempotent by construction (hashed rows

View File

@ -263,6 +263,53 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
expect(cache.markdown).not.toContain('classification:');
});
it('adds a classification companion for classified media in the ZIP (issue #212)', async () => {
const image = await files.upload({ id: ownerId } as never, personalPondId, {
buffer: Buffer.from(PNG_BASE64, 'base64'),
size: 70,
originalname: 'secret-dot.png',
});
const slug = await seedPage(
personalPondId,
'Zip Media Classified',
`# Zip Media Classified\n\n![dot](${image.id})`,
);
await prisma.page.updateMany({
where: { pondId: personalPondId, slug },
data: { classification: 'VS_NFD' },
});
const res = await api()
.get(`/api/v1/ponds/${personalPondId}/export/markdown`)
.set('Cookie', ownerCookie)
.buffer(true)
.parse((r, cb) => {
const chunks: Buffer[] = [];
r.on('data', (c: Buffer) => chunks.push(c));
r.on('end', () => cb(null, Buffer.concat(chunks)));
})
.expect(200);
const entries = zipEntries(res.body as Buffer);
// Media inherits the highest referencing page's level: sibling companion
// carries the full marking; the manifest lists the media file's level.
const companion = entries[`media/${image.id}.png.classification.txt`];
expect(companion).toBeDefined();
expect(Buffer.from(companion!).toString('utf8')).toContain('VS NUR FÜR DEN DIENSTGEBRAUCH');
const manifest = JSON.parse(Buffer.from(entries['manifest.json']!).toString('utf8')) as {
files: { path: string; classification: string }[];
};
expect(manifest.files).toContainEqual({
path: `media/${image.id}.png`,
classification: 'vs_nfd',
});
await prisma.page.updateMany({
where: { pondId: personalPondId, slug },
data: { classification: 'UNCLASSIFIED' },
});
});
it('skips an attachment whose bytes are missing on disk instead of crashing', async () => {
// An attachment row with no file (data drift): upload then remove the bytes.
const image = await files.upload({ id: ownerId } as never, personalPondId, {

View File

@ -165,10 +165,20 @@ export class ExportService {
manifestFiles.push({ path: `${prefix}${page.slug}.md`, classification: level });
}
for (const attachment of attachments) {
const mediaLevel = mediaClassification.get(attachment.id) ?? 'unclassified';
manifestFiles.push({
path: `${prefix}media/${mediaNameById.get(attachment.id)!}`,
classification: mediaClassification.get(attachment.id) ?? 'unclassified',
classification: mediaLevel,
});
// Companion file for classified media (issue #212): the binary itself
// cannot carry the marking, so a sibling text file states it — it
// survives unpacking and copying, where the manifest may be dropped.
const mediaMarking = classificationMarking(mediaLevel);
if (mediaMarking) {
archive.append(`${mediaMarking}\n`, {
name: `${prefix}media/${mediaNameById.get(attachment.id)!}.classification.txt`,
});
}
}
// The archive-level manifest (#210): every file with its level, and the
// highest level contained stated once — the bulk-egress channel stays

View File

@ -101,6 +101,13 @@ export function buildOpenApiDocument(): object {
properties: {
slug: { type: 'string' },
title: { type: 'string' },
classification: {
type: 'string',
enum: ['unclassified', 'vs_nfd'],
description:
'VS-NfD marking level (ADR 0022). A marking, not access control; ' +
'consumers re-publishing content are expected to carry it onward.',
},
parent: {
type: ['string', 'null'],
description:
@ -118,6 +125,14 @@ export function buildOpenApiDocument(): object {
slug: { type: 'string' },
title: { type: 'string' },
pondSlug: { type: 'string' },
classification: {
type: 'string',
enum: ['unclassified', 'vs_nfd'],
description:
'VS-NfD marking level (ADR 0022). A marking, not access control; ' +
'consumers re-publishing content are expected to carry it onward.',
},
parent: {
type: ['string', 'null'],
description: 'Parent page slug; see PageListItem.parent.',

View File

@ -327,6 +327,35 @@ describe.skipIf(!hasTestDb)('public api v1 (e2e, issue #104)', () => {
.expect(404);
});
it('includes the classification in page representations (issue #211)', async () => {
const created = await pub()
.post(`/api/public/v1/ponds/${pondSlug}/pages`)
.set('Authorization', bearer('editor'))
.send({ title: `Classified Api Page ${suffix}`, markdown: 'classified body' })
.expect(201);
const slug = (created.body as { slug: string }).slug;
expect((created.body as { classification: string }).classification).toBe('unclassified');
await prisma.page.updateMany({
where: { slug, pond: { slug: pondSlug } },
data: { classification: 'VS_NFD' },
});
const fetched = await pub()
.get(`/api/public/v1/ponds/${pondSlug}/pages/${slug}`)
.set('Authorization', bearer('editor'))
.expect(200);
expect((fetched.body as { classification: string }).classification).toBe('vs_nfd');
const list = await pub()
.get(`/api/public/v1/ponds/${pondSlug}/pages`)
.set('Authorization', bearer('editor'))
.expect(200);
const listed = (list.body as { slug: string; classification: string }[]).find(
(p) => p.slug === slug,
);
expect(listed?.classification).toBe('vs_nfd');
});
it('round-trips a page through Markdown, replaces content via the collab path', async () => {
const markdown = '# Heading\n\nHello **world** from the API.\n';
const created = await pub()

View File

@ -7,6 +7,7 @@ import {
pondFeatureEnabled,
pondSettingsSchema,
type CommentListFilter,
type PageClassification,
type PageListQuery,
type CreateCommentInput,
type CreateLabelInput,
@ -110,6 +111,7 @@ export class PublicApiService {
return items.map((item) => ({
slug: item.slug,
title: item.title,
classification: item.classification,
parent: (item.parentId && slugById.get(item.parentId)) || null,
labels: item.labelIds.map((id) => labelNames.get(id) ?? id).sort(),
createdAt: item.createdAt,
@ -129,6 +131,9 @@ export class PublicApiService {
slug: page.slug,
title: page.title,
pondSlug,
// VS-NfD level (#211): part of the versioned representation so API
// consumers can carry the marking onward.
classification: page.classification.toLowerCase() as PageClassification,
parent,
markdown: cache?.markdown ?? '',
html: cache?.html ?? '',

View File

@ -145,6 +145,40 @@ describe.skipIf(!hasTestDb)('atom feeds (e2e, issue #149)', () => {
expect(res.text).toContain(`/api/v1/public/${pondSlug}/newer-${suffix}`);
});
it('marks classified entries and states the highest level at feed level (issue #211)', async () => {
// Unclassified feed: no category element at all (ADR 0022 — no noise).
const open = await api().get(`/api/v1/public/${pondSlug}/feed.xml`).expect(200);
expect(open.text).not.toContain('urn:dorfteich:classification');
await prisma.page.updateMany({
where: { pondId, slug: `newer-${suffix}` },
data: { classification: 'VS_NFD' },
});
try {
const res = await api().get(`/api/v1/public/${pondSlug}/feed.xml`).expect(200);
// The classified entry carries the documented category element…
expect(res.text).toContain(
'<category term="vs_nfd" scheme="urn:dorfteich:classification" ' +
'label="VS NUR FÜR DEN DIENSTGEBRAUCH"/>',
);
// …and the feed document states the highest contained level once:
// 1 feed-level + 1 entry-level = exactly two categories (the open
// entry carries none).
expect(res.text.split('urn:dorfteich:classification').length - 1).toBe(2);
// The page feed of a classified page marks its entries and itself too.
const pageFeed = await api()
.get(`/api/v1/public/${pondSlug}/newer-${suffix}/feed.xml`)
.expect(200);
expect(pageFeed.text).toContain('urn:dorfteich:classification');
} finally {
await prisma.page.updateMany({
where: { pondId, slug: `newer-${suffix}` },
data: { classification: 'UNCLASSIFIED' },
});
}
});
it('serves a page feed built from the version history', async () => {
const res = await api().get(`/api/v1/public/${pondSlug}/newer-${suffix}/feed.xml`).expect(200);
expect(res.text).toContain('<title>Newer Page — Feed Pond</title>');

View File

@ -1,4 +1,9 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
PageClassification,
classificationMarking,
highestClassification,
} from '@dorfteich/shared';
import { Pond, User } from '@prisma/client';
import { PagesService } from '../pages/pages.service';
@ -10,12 +15,18 @@ import { escapeHtml } from './html-shell';
/** How many entries a feed carries — plenty for readers polling regularly. */
const FEED_ENTRIES = 30;
/** The documented scheme URI of the classification `<category>` element
* (issue #211, ADR 0022; see docs/self-hosting/public-api.md §Feeds). */
const CLASSIFICATION_SCHEME = 'urn:dorfteich:classification';
interface FeedEntry {
id: string;
title: string;
link: string;
updated: Date;
summary?: string;
/** VS-NfD level (#211) — rendered as an Atom `<category>` when classified. */
classification?: PageClassification;
}
/**
@ -60,6 +71,7 @@ export class FeedService {
? `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}`
: `${baseUrl}/p/${pond.slug}/${page.slug}`,
updated: new Date(page.updatedAt),
classification: page.classification,
}));
return atomDocument({
id: `${baseUrl}/api/v1/public/${pond.slug}/feed.xml`,
@ -79,7 +91,7 @@ export class FeedService {
const pond = await this.requireVisiblePond(user, pondSlug);
const page = await this.prisma.page.findFirst({
where: { pondId: pond.id, slug: pageSlug, deletedAt: null },
select: { id: true, pondId: true, slug: true, title: true },
select: { id: true, pondId: true, slug: true, title: true, classification: true },
});
if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) {
throw new NotFoundException();
@ -94,11 +106,13 @@ export class FeedService {
user === null
? `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}`
: `${baseUrl}/p/${pond.slug}/${page.slug}`;
const pageLevel = page.classification.toLowerCase() as PageClassification;
const entries = versions.map((version) => ({
id: `urn:dorfteich:version:${version.id}`,
title: version.label ?? version.trigger.toLowerCase(),
link,
updated: version.createdAt,
classification: pageLevel,
}));
return atomDocument({
id: `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}/feed.xml`,
@ -118,6 +132,18 @@ export class FeedService {
}
}
/** The classification as a standard Atom `<category>` (issue #211): `term` =
* the machine-readable level, `label` = the fixed marking wording. Only
* classified content carries one (ADR 0022: unclassified shows no marking). */
function categoryTag(classification: PageClassification | undefined, indent: string): string {
const marking = classification ? classificationMarking(classification) : null;
if (!classification || !marking) return '';
return (
`${indent}<category term="${escapeHtml(classification)}" ` +
`scheme="${CLASSIFICATION_SCHEME}" label="${escapeHtml(marking)}"/>\n`
);
}
function atomDocument(feed: {
id: string;
title: string;
@ -125,6 +151,10 @@ function atomDocument(feed: {
entries: FeedEntry[];
}): string {
const updated = feed.entries[0]?.updated ?? new Date();
// The feed document states the highest level it contains (issue #211).
const highest = highestClassification(
feed.entries.map((entry) => entry.classification ?? 'unclassified'),
);
const entries = feed.entries
.map(
(entry) =>
@ -133,6 +163,7 @@ function atomDocument(feed: {
` <title>${escapeHtml(entry.title)}</title>\n` +
` <link href="${escapeHtml(entry.link)}"/>\n` +
` <updated>${entry.updated.toISOString()}</updated>\n` +
categoryTag(entry.classification, ' ') +
(entry.summary ? ` <summary>${escapeHtml(entry.summary)}</summary>\n` : '') +
` </entry>`,
)
@ -143,6 +174,7 @@ function atomDocument(feed: {
` <id>${escapeHtml(feed.id)}</id>\n` +
` <title>${escapeHtml(feed.title)}</title>\n` +
` <link rel="self" href="${escapeHtml(feed.selfLink)}"/>\n` +
categoryTag(highest === 'unclassified' ? undefined : highest, ' ') +
` <updated>${updated.toISOString()}</updated>\n` +
`${entries}\n` +
`</feed>\n`

View File

@ -42,6 +42,12 @@ export function htmlDocument({
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; line-height: 1.6; }
img { max-width: 100%; height: auto; }
.public-page__pond { color: #64748b; font-size: 0.9rem; }
/* VS-NfD marking (issue #211, ADR 0022): same convention as the SPA
bold, centered, ruled band above and below the content. currentColor
keeps full contrast in both color schemes. */
.classification-banner { margin: 0.75rem 0; padding: 0.25rem 0.5rem;
border-top: 2px solid currentColor; border-bottom: 2px solid currentColor;
font-weight: 700; letter-spacing: 0.08em; text-align: center; font-size: 0.9rem; }
pre { overflow-x: auto; }
.public-footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #64748b;
font-size: 0.9rem; }

View File

@ -109,6 +109,34 @@ describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => {
expect((json.body as { html: string }).html).toContain('Hello world');
});
it('renders the VS-NfD marking top and bottom in the no-JS shell (issue #211)', async () => {
const marking = 'VS NUR FÜR DEN DIENSTGEBRAUCH';
// Unclassified: the shell carries no marking at all.
const open = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(200);
expect(open.text).not.toContain(marking);
await prisma.page.updateMany({
where: { pondId, slug: pageSlug },
data: { classification: 'VS_NFD' },
});
try {
const html = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(200);
// Above AND below the content — the shell is its own render path.
expect(html.text.split(`<p class="classification-banner">${marking}</p>`).length - 1).toBe(2);
const [before, after] = html.text.split('Hello world from a public page.');
expect(before).toContain(marking);
expect(after).toContain(marking);
// The JSON the SPA renders carries the level too (#206).
const json = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(200);
expect((json.body as { classification: string }).classification).toBe('vs_nfd');
} finally {
await prisma.page.updateMany({
where: { pondId, slug: pageSlug },
data: { classification: 'UNCLASSIFIED' },
});
}
});
it('never resolves a non-public page for an anonymous visitor', async () => {
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}`).expect(404);
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/content`).expect(404);

View File

@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { classificationMarking } from '@dorfteich/shared';
import type { PageClassification, PageCommentsView } from '@dorfteich/shared';
import { Page, Pond, User } from '@prisma/client';
@ -194,6 +195,12 @@ export class PublicService {
canonical: string,
): Promise<string> {
const content = await this.content(user, pondSlug, pageSlug);
// The VS-NfD marking renders in the same places as the SPA — above and
// below the content (issue #211, ADR 0022). The no-JS shell is its own
// render path, so it carries its own banner markup; unclassified pages
// get none.
const marking = classificationMarking(content.classification);
const banner = marking ? `<p class="classification-banner">${escapeHtml(marking)}</p>\n` : '';
// No session-dependent content: this document is identical for every viewer
// who may read the page (crawler-safe, cacheable). The shared shell adds
// the legal footer links (issue #82) in the instance default locale.
@ -206,9 +213,10 @@ export class PublicService {
`/api/v1/public/${encodeURIComponent(pondSlug)}/feed.xml`,
canonical,
).toString(),
bodyHtml: `<p class="public-page__pond">${escapeHtml(content.pondName)}</p>
bodyHtml: `${banner}<p class="public-page__pond">${escapeHtml(content.pondName)}</p>
<h1>${escapeHtml(content.title)}</h1>
${content.html}`,
${content.html}
${banner}`,
});
}
}

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import {
PageClassification,
SEARCH_HIGHLIGHT_END,
SEARCH_HIGHLIGHT_START,
SEARCH_RESULT_LIMIT,
@ -34,6 +35,7 @@ interface SearchRow {
pondName: string;
labelIds: string[];
snippet: string;
classification: string;
}
/**
@ -159,6 +161,7 @@ export class PostgresSearchProvider extends SearchProvider {
const rows = await this.prisma.$queryRaw<SearchRow[]>(Prisma.sql`
SELECT p.id AS "pageId", p.title, p.slug, p.pond_id AS "pondId",
p.classification::text AS classification,
po.slug AS "pondSlug", po.name AS "pondName",
COALESCE(
ARRAY(SELECT pl.label_id FROM page_labels pl WHERE pl.page_id = p.id),
@ -210,6 +213,8 @@ export class PostgresSearchProvider extends SearchProvider {
pondName: row.pondName,
labelIds: row.labelIds,
snippet: row.snippet,
// A hit on a classified page is never shown unmarked (#211).
classification: row.classification.toLowerCase() as PageClassification,
}));
}
}

View File

@ -23,6 +23,7 @@ describe('SearchProvider DI seam (issue #49)', () => {
pondSlug: 'pond',
pondName: 'Pond',
labelIds: [],
classification: 'unclassified',
snippet: 'a snippet',
};
const fake: SearchProvider = {

View File

@ -90,6 +90,20 @@ describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
expect(results[0]!.pageId).toBe(titleHit);
});
it('carries the classification with every hit — a classified snippet is never unmarked (issue #211)', async () => {
const classifiedId = await makePage(`classified ${term} note`, `secret ${term} content`);
await prisma.page.update({
where: { id: classifiedId },
data: { classification: 'VS_NFD' },
});
const results = await search.search({ q: term }, owner);
const classified = results.find((r) => r.pageId === classifiedId);
expect(classified?.classification).toBe('vs_nfd');
// Every other hit carries the field too, as `unclassified`.
const other = results.find((r) => r.pageId !== classifiedId);
expect(other?.classification).toBe('unclassified');
});
it('highlights the match in the snippet', async () => {
const results = await search.search({ q: term }, owner);
const bodyHit = results.find((r) => r.snippet.includes(SEARCH_HIGHLIGHT_START));

View File

@ -57,6 +57,11 @@ export const INSTANCE_SETTINGS = {
// 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

View File

@ -109,3 +109,19 @@ test('pond file manager shows usage and flags an orphan (Pond Admin)', async ({
await context.close();
});
test('shows the classified-upload warning on a classified page (issue #213)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const page = await context.newPage();
await page.goto('/p/content-fixtures/classified-note');
await openAttachments(page);
// The persistent warning names the consequence; the wording is the fixed
// marking formula (ADR 0022), not localized.
await expect(page.locator('.attachments-panel__warning')).toBeVisible();
await expect(page.locator('.attachments-panel__warning')).toContainText(
'VS NUR FÜR DEN DIENSTGEBRAUCH',
);
await context.close();
});

View File

@ -1,4 +1,4 @@
import type { AttachmentListItemView } from '@dorfteich/shared';
import type { AttachmentListItemView, PageClassification } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { Editor } from '@tiptap/react';
import { useRef, useState } from 'react';
@ -19,11 +19,16 @@ export function AttachmentsPanel({
pageId,
editor,
canEdit,
classification,
onClose,
}: {
pageId: string;
editor: Editor | null;
canEdit: boolean;
/** VS-NfD level of the page (issue #213): a classified page shows the
* upload warning naming the consequence attachments inherit a level
* their content cannot carry (#212). */
classification?: PageClassification;
onClose: () => void;
}): React.JSX.Element {
const { t } = useTranslation('files');
@ -96,6 +101,12 @@ export function AttachmentsPanel({
<FormError error={error} />
{canEdit && classification === 'vs_nfd' && (
<p className="attachments-panel__warning" role="note">
{t('classifiedUploadWarning')}
</p>
)}
{canEdit && (
<div className="attachments-panel__upload">
<input

View File

@ -29,6 +29,7 @@ interface InstanceSettings {
'upload.allowedExtensions': string[];
'upload.svgPolicy': 'reject' | 'sanitize';
'classification.newPageDefault': 'unclassified' | 'vs_nfd';
'classification.uploadPolicy': 'warn' | 'block';
'legal.imprint': string;
'legal.privacyPolicy': string;
'home.content': string;
@ -101,6 +102,15 @@ export function AdminSettingsPage(): React.JSX.Element {
<option value="vs_nfd">{t('settings:admin.classificationVsNfd')}</option>
</select>
</Field>
<Field
label={t('settings:admin.uploadPolicy')}
hint={t('settings:admin.uploadPolicyHelp')}
>
<select {...form.register('classification.uploadPolicy')}>
<option value="warn">{t('settings:admin.uploadPolicyWarn')}</option>
<option value="block">{t('settings:admin.uploadPolicyBlock')}</option>
</select>
</Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('settings:admin.save')}
</button>

View File

@ -1,5 +1,10 @@
import { DEFAULT_FONTS, extractOutline } from '@dorfteich/shared';
import type { PageListItemView, PageStateView, PondView } from '@dorfteich/shared';
import type {
PageClassification,
PageListItemView,
PageStateView,
PondView,
} from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Collaboration } from '@tiptap/extension-collaboration';
import { EditorContent, useEditor } from '@tiptap/react';
@ -126,6 +131,8 @@ interface ResolvedPage {
pondId: string;
slug: string;
title: string;
/** VS-NfD level (issue #213); undefined for the offline-cache fallback. */
classification?: PageClassification;
}
function PageEditor({
@ -305,6 +312,7 @@ function PageEditor({
pageId={page.id}
editor={editor}
canEdit={canEdit}
classification={page.classification}
onClose={onCloseAttachments}
/>
)}
@ -421,7 +429,13 @@ export function PageEditorPage(): React.JSX.Element {
const offlineCached = !navigator.onLine && !page.data ? recallPage(pondSlug, pageSlug) : null;
const resolved: ResolvedPage | null = page.data
? { id: page.data.id, pondId: page.data.pondId, slug: page.data.slug, title: page.data.title }
? {
id: page.data.id,
pondId: page.data.pondId,
slug: page.data.slug,
title: page.data.title,
classification: page.data.classification,
}
: offlineCached
? {
id: offlineCached.pageId,

View File

@ -1,3 +1,4 @@
import { classificationMarking } from '@dorfteich/shared';
import type { PondView, SearchResultView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
@ -43,6 +44,7 @@ function saveRecent(query: string): string[] {
*/
export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.Element {
const { t } = useTranslation('search');
const { t: tCommon } = useTranslation('common');
const navigate = useNavigate();
const { pondSlug } = useCurrentPondRoute();
const inputRef = useRef<HTMLInputElement>(null);
@ -220,6 +222,14 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E
onClick={() => open(hit)}
>
<span className="search-result__title">{hit.title}</span>
{/* A hit on a classified page is never shown unmarked
(issue #211, ADR 0022) fixed wording, not localized. */}
{classificationMarking(hit.classification) && (
<span className="search-result__classification">
<span className="visually-hidden">{tCommon('classification.label')}: </span>
{classificationMarking(hit.classification)}
</span>
)}
<span className="search-result__pond">{t('inPond', { pond: hit.pondName })}</span>
<LabelChips labelIds={hit.labelIds} byId={byId} />
<span className="search-result__snippet">

View File

@ -2809,6 +2809,16 @@ ul[data-type='task_list'] li p:last-of-type {
font-size: 0.85rem;
}
/* VS-NfD marking on a search hit (issue #211, ADR 0022): compact form of the
banner text token only, full contrast in both themes. */
.search-result__classification {
display: block;
color: var(--color-text);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.search-result__snippet {
display: block;
margin-top: 2px;
@ -3061,6 +3071,17 @@ ul[data-type='task_list'] li p:last-of-type {
margin: 0;
}
/* Upload warning on classified pages (issue #213, ADR 0022) a persistent
note, not a dismissable toast: the consequence applies to every upload. */
.attachments-panel__warning {
margin: 0 0 var(--space-2);
padding: var(--space-2);
border: 1px solid var(--color-border);
border-left: 3px solid currentColor;
font-size: 0.85rem;
color: var(--color-text);
}
.attachments-panel__upload {
display: flex;
align-items: center;

View File

@ -62,7 +62,10 @@ guardrails). Separation is a platform property.
labels _around_ it (e.g. an accessibility label naming the element) are
i18n'd. The single source is `classificationMarking()` in
`@dorfteich/shared` (`packages/shared/src/pages.ts`); no output channel
hard-codes the string.
hard-codes the string. Where a full wording cannot live — file NAMES of
attachment downloads (#212) — the established short form `VS-NfD` is
used as the prefix `VS-NfD_`, single source
`classificationFilenamePrefix()` in the same module.
## Consequences

View File

@ -192,3 +192,25 @@ not a copy of the purged page.
| max upload size | 25 MiB (quota ladder, ADR 0011) |
| collab connections per instance | 500 concurrent |
| rate limits | login 10/min/IP, signup 5/h/IP, API 100/min/user |
## Classified attachment downloads (issue #212, ADR 0022)
An attachment is an opaque binary — the application cannot write the
VS-NfD marking into arbitrary file formats. The marking therefore lives
**around** the file:
- **Filename prefix `VS-NfD_`** on every download whose effective
classification is `vs_nfd` (single source:
`classificationFilenamePrefix()` in `@dorfteich/shared`).
- **Effective classification**: the linked page's level. An attachment
whose `pageId` is not (yet) set — paste-then-insert, pond-level files —
**fails closed** to the highest level of any live page in its pond.
- **Containing archive**: the pond export ZIP states each media file's
level in `manifest.json` and adds a sibling
`<file>.classification.txt` companion carrying the full marking for
classified media.
Residual risk, deliberately documented rather than hidden (recorded on
issue #231): the file's own **content** carries no marking — a user who
renames the file has an unmarked classified binary. Marking file contents
would require rewriting arbitrary formats, which ADR 0019 rules out.

View File

@ -52,6 +52,28 @@ curl -H "Authorization: Bearer dt_pat_..." \
Deliberately not in v1 (stage 2): attachment upload, version endpoints,
webhooks.
### Classification (VS-NfD marking, issue #211 / ADR 0022)
Every page representation (`GET …/pages`, `GET …/pages/{pageSlug}`)
carries a `classification` field: `"unclassified"` or `"vs_nfd"`. It is a
**marking, not access control** — permissions are unchanged by it. API
consumers that render or re-publish page content are expected to carry
the marking onward (the fixed wording is
`VS NUR FÜR DEN DIENSTGEBRAUCH`).
The Atom feeds mark classified content with a standard `<category>`
element on both levels:
```xml
<category term="vs_nfd" scheme="urn:dorfteich:classification"
label="VS NUR FÜR DEN DIENSTGEBRAUCH"/>
```
Each classified entry carries one, and the feed document itself carries
one stating the **highest** level it contains. Unclassified entries and
all-open feeds carry none (marking everything trains readers to ignore
markings, ADR 0022).
## Connect Claude Code / MCP clients
The instance ships its own MCP endpoint (Streamable HTTP) at `/api/mcp`

View File

@ -57,9 +57,9 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_
- [x] PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208
- [x] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 23 AT · #209
- [x] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210
- [ ] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 23 AT · #211
- [ ] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 12 AT · #212
- [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213
- [x] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 23 AT · #211
- [x] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 12 AT · #212
- [x] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213
### P1-3 Verifizierter Offline-/Airgap-Pfad · 810 AT ⟵ neu aus Roadmap
@ -187,10 +187,11 @@ nach sich zieht.
- [ ] **Härtungsleitfaden** mit Referenzkonfiguration „VS-NfD-Betrieb":
lokale Auth aus, Public-API aus, MCP aus, Feeds aus, Plugins aus,
Backup nur lokal · 3 AT · #227
- [ ] **Sicherheitsdokumentation**: Architektur, Datenflüsse, Netzplan,
Ports/Dienste, Vertrauensgrenzen · 4 AT · #228
- [ ] **Betriebshandbuch**: Installation (inkl. Airgap), Update, Backup/Restore,
Löschung und Vernichtung, Rollentrennung · 45 AT · #229
- [x] **Sicherheitsdokumentation**: Architektur, Datenflüsse, Netzplan,
Ports/Dienste, Vertrauensgrenzen · 4 AT · #228`60-sicherheitsdokumentation.md`
- [x] **Betriebshandbuch**: Installation (inkl. Airgap), Update, Backup/Restore,
Löschung und Vernichtung, Rollentrennung · 45 AT · #229`70-betriebshandbuch.md`
(Airgap-Kapitel verweist auf offene #218#221)
- [ ] **Zuarbeit IT-Grundschutz** APP.3.1 und CON.11.1, je Anforderung
„Produkt / Betreiber / nicht anwendbar" · 34 AT · #230
- [ ] **Restrisikoliste** mit bewusst offenen Punkten · 1 AT · #231

View File

@ -0,0 +1,277 @@
# Sicherheitsdokumentation (VS-NfD, Issue #228)
Zweck: das Dokument, das ein Prüfer zuerst liest — Architektur,
Datenflüsse, Netzplan, Vertrauensgrenzen und die vollständige Liste
aller Inhaltskopien. Es konsolidiert bestehende Referenzdokumente,
erfindet nichts neu und ist **auf die Ziffer genau** an
`deploy/compose/docker-compose.yml` ausgerichtet (die Compose-Datei ist
die maßgebliche Dienst- und Portliste; weicht dieses Dokument ab, ist
das ein Fehler in diesem Dokument).
Einordnung: Dorfteich erbringt **keine Sicherheitsgrundfunktion**
(§52 VSA, ADR 0019, Abgrenzungserklärung
`40-abgrenzungserklaerung.md`). Diese Dokumentation beschreibt, was die
Anwendung tut — Verschlüsselung, Netztrennung, Datenträgerschutz und
Beweissicherung auf Plattformebene fallen dem Betreiber zu.
Quell-Dokumente (englisch, maßgeblich für Details):
`docs/architecture/security.md`, `deployment.md`, `data-model.md`,
`permissions.md`, `realtime-collaboration.md`,
`plugin-architecture.md`, `operations.md`, `audit-events.md`;
Deploy-Sicht: `deploy/stages.md`, `deploy/compose/docker-compose.yml`.
Pflegeregel: Änderungen an Diensten, Ports, Datenflüssen oder
Speicherorten werden **im selben PR** hier nachgezogen (dieselbe Regel
wie beim Härtungsleitfaden `50-haertungsleitfaden.md`).
---
## 1 Komponenten
```mermaid
flowchart LR
subgraph client [Endgerät]
B[Browser SPA]
end
subgraph host [Betreiber-Host / Reverse-Proxy-Zone]
RP[Reverse Proxy TLS]
end
subgraph frontend [Docker-Netz frontend]
W[web nginx statisch]
A[api NestJS]
C[collab Hocuspocus]
end
subgraph internal [Docker-Netz internal]
DB[(PostgreSQL 17.5)]
P[pandoc 3.6]
G[gotenberg 8]
BK[backup Sidecar]
end
SMTP[(SMTP-Relay extern)]
MIR[(Backup-Spiegel rsync/WebDAV, Allowlist)]
B -->|HTTPS 443| RP
RP -->|/| W
RP -->|/api| A
RP -->|/collab WebSocket| C
A --> DB
C --> DB
A -->|HTTP pandoc:3030| P
A -->|HTTP gotenberg:3000| G
A -->|Mail| SMTP
BK --> DB
BK -.->|nur bei Konfiguration + Allowlist| MIR
BK -->|Fehler-Mail| SMTP
```
| Dienst | Image (gepinnt, #203/#236) | Zweck | Privilegien |
| ---------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `web` | eigenes Image (nginx-unprivileged 1.27, uid 101) | statische SPA-Auslieferung | kein Volume, nur Netz `frontend`, kein DB-Zugang |
| `api` | eigenes Image (Node 22.15.1-alpine, User `node`) | REST-API, Permissions, Scheduler-Jobs, Import/Export-Orchestrierung, Audit | Volumes `uploads`, `plugins`, `secrets`, `backups` (ro); Netze `frontend`+`internal`; DB-Vollzugriff |
| `collab` | eigenes Image (Node, User `node`) | Echtzeit-Editing (Yjs/Hocuspocus), Persistenz der Dokumente | Netze `frontend`+`internal`; DB-Vollzugriff; keine Datei-Volumes |
| `db` | `postgres:17.5-alpine@sha256:…` | einziger Datenbestand (außer Uploads) | nur Netz `internal`; Volume `db-data` |
| `pandoc` | `pandoc/core:3.6@sha256:…` | Dokumentkonvertierung (Import/Export DOCX/ODT) | nur `internal`; zustandslos, kein Volume, kein DB-Zugang |
| `gotenberg` | `gotenberg/gotenberg:8@sha256:…` | HTML→PDF-Rendering (Chromium) | nur `internal`; zustandslos, kein Volume, kein DB-Zugang |
| `backup` | eigenes Image | nächtlicher `pg_dump -Fc` + Volume-Tar, Retention, optionaler Spiegel | nur `internal`; Volumes `uploads`, `plugins`, `secrets` (ro), `backups` (rw); DB-Zugang |
| `caddy` (Compose-Profil, optional) | `caddy:2.10-alpine@sha256:…` | TLS-Terminierung, wenn kein Host-Proxy existiert | Ports 80/443 nach außen; Netz `frontend` |
Alle eigenen Images laufen als non-root (`USER node`); die Stages auf
ONE nutzen einen Host-Reverse-Proxy (Caddy des Hosts), das
`caddy`-Profil bleibt dort inaktiv.
## 2 Netzplan (Ports und Protokolle)
Maßgeblich: `deploy/compose/docker-compose.yml`. Alle
Anwendungs-Portbindungen sind **auf 127.0.0.1 beschränkt** — von außen
erreichbar ist ausschließlich der Reverse Proxy.
| Von | Nach | Port/Protokoll | Exposition |
| ----------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| Client | Reverse Proxy | 443/TCP HTTPS (80 nur Redirect) | **extern** |
| Reverse Proxy | `web` | `127.0.0.1:${WEB_PORT:-8100}` → Container 8080, HTTP | Host-lokal |
| Reverse Proxy | `api` | `127.0.0.1:${API_PORT:-8101}` → Container 3000, HTTP | Host-lokal |
| Reverse Proxy | `collab` | `127.0.0.1:${COLLAB_PORT:-8102}` → Container 3000, HTTP + WebSocket-Upgrade | Host-lokal |
| `api`/`collab`/`backup` | `db` | 5432/TCP (nur Docker-Netz `internal`) | intern |
| `api` | `pandoc` | `http://pandoc:3030` (nur `internal`) | intern |
| `api` | `gotenberg` | `http://gotenberg:3000` (nur `internal`) | intern |
| `api`, `backup` | SMTP-Relay | vom Betreiber konfiguriert (`SMTP_*`) | **ausgehend extern** |
| `backup` | Spiegel-Ziel | rsync über SSH (Port konfigurierbar) bzw. WebDAV/HTTPS — **nur** an Hosts der Deploy-Allowlist `BACKUP_ALLOWED_TARGETS` (ADR 0026, #192); leere Allowlist = alle Fernziele hart aus | **ausgehend extern, allowlist-beschränkt** |
| optional `caddy` | Client | 80/443 | extern (nur wenn Profil aktiv) |
Es gibt **keine eingehenden** Verbindungen außer über den Reverse
Proxy. `db`, `pandoc`, `gotenberg`, `backup` haben keinerlei
Portbindung an den Host.
## 3 Datenflüsse
### 3.1 Authentifizierung
```mermaid
sequenceDiagram
participant B as Browser
participant A as api
participant DB as PostgreSQL
B->>A: POST /api/v1/auth/login (Origin-Header Pflicht, fail-closed CSRF #189)
A->>DB: user_identities (Argon2id-Hash prüfen), rate_limits (10/min/IP)
A-->>B: Set-Cookie dt_session (HttpOnly, Secure, SameSite=Lax)
Note over A,DB: Session-Row: gehashte Id, expiresAt (SESSION_ABSOLUTE_HOURS),<br/>Idle-Grenze (SESSION_IDLE_HOURS, #190); Audit auth.login_*
```
Externe Authentisierung (OIDC/Proxy-Header) ist geplant (M27,
#214#217) und ändert diesen Fluss; bis dahin ist lokale
Passwort-Authentifizierung der einzige Weg.
### 3.2 Editieren (Echtzeit)
```mermaid
sequenceDiagram
participant B as Browser
participant A as api
participant C as collab
participant DB as PostgreSQL
B->>A: GET /pages/:id/collab-token (Guard: Read-Recht)
A-->>B: Kurzlebiges Token (60 s TTL, HKDF-Subkey aus COLLAB_TOKEN_SECRET, #188), mode rw/ro
B->>C: WebSocket /collab + Token
C->>C: Token-Verifikation (shared token-crypto), rw nur bei Write-Grant
B-->>C: Yjs-Updates (CRDT)
C->>DB: debounced Persist: pages.ydoc_state + page_updates-Log,<br/>abgeleiteter page_content_cache (plain/markdown/html/outline/tsvector)
A-->>C: Rechteänderungen via Postgres LISTEN/NOTIFY (Kanal-Konstanten shared) → close(4205)
```
### 3.3 Export (PDF/DOCX/ODT/ZIP)
```mermaid
sequenceDiagram
participant B as Browser
participant A as api
participant P as pandoc
participant G as gotenberg
B->>A: POST /pages/:id/export {format}
A->>A: conversion_jobs-Row (input = aufbereitetes Markdown/HTML,<br/>bei eingestufter Seite Option {marking}, #208/#209)
A->>G: html → pdf (Kennzeichnung im Header/Footer-Template je Seite)
A->>P: gfm → docx/odt (reference-doc mit Kennzeichnung, in-Request-Datei)
A-->>B: Poll GET /jobs/:id → Download /jobs/:id/result
Note over A: Payloads werden nach conversion.payloadRetentionDays genullt (#233)
```
Der Pond-ZIP-Export streamt direkt aus `page_content_cache` +
`uploads`-Volume (permissionsgefiltert) und enthält seit M26
Frontmatter/Aufdruck, `manifest.json` und Begleitdateien für
eingestufte Inhalte (#210/#212).
### 3.4 Backup
```mermaid
sequenceDiagram
participant BK as backup-Sidecar
participant DB as PostgreSQL
participant V as Volumes uploads/plugins
participant M as Spiegel (optional)
BK->>DB: nächtlich pg_dump -Fc
BK->>V: tar der Volumes (lesend)
BK->>BK: Restore-Set aufs backups-Volume, Retention (BACKUP_RETENTION_DAYS)
BK-->>M: rsync/WebDAV NUR an BACKUP_ALLOWED_TARGETS (ADR 0026); leer = aus
BK-->>BK: status.json; Fehler-Mail direkt via SMTP
```
Backups sind **unverschlüsselt by design** (ADR 0015/0019) —
Datenträger- und Transportschutz ist Plattformsache
(Abgrenzungserklärung §2).
### 3.5 Lesekanäle
Jeder Kanal, über den Seiteninhalt die Anwendung verlässt (zugleich die
Instrumentierungsliste für den Lesetrail, #222):
| Kanal | Pfad | Rechteprüfung | Kennzeichnung (M26) |
| -------------------------- | -------------------------------- | -------------------------------------- | ----------------------------------- |
| SPA-Seitenansicht | `GET /api/v1/pages/:id` u. a. | Guard (shared Resolver) | Banner oben+unten (#206) |
| Public-/No-JS-Ansicht | `GET /api/v1/public/:pond/:page` | `public`-Grant, sonst 404 | Banner oben+unten (#211) |
| Public REST API | `/api/public/v1/**` | `api.enabled` + Pond-Opt-in + PAT | `classification`-Feld (#211) |
| MCP-Endpoint | `/api/mcp` | `mcp.enabled` + Pond-Opt-in + PAT | wie Public API (gleiches Modell) |
| Atom-Feeds | `…/feed.xml` | `feeds.enabled`; Grant bzw. Feed-Token | `<category>`-Element (#211) |
| Suche | `GET /api/v1/search` | per-Treffer-Resolution | Level je Treffer (#211) |
| Attachment-Download | `GET /api/v1/media/:fileId` | Guard über Seite/Pond | Dateinamens-Präfix `VS-NfD_` (#212) |
| Export (PDF/Office/ZIP/MD) | s. 3.3 | Guard; ZIP permissionsgefiltert | je Kanal (#207#210) |
| Collab-WebSocket | `/collab` | Token aus 3.2 | Inhalt = Editor-Ansicht (#206) |
## 4 Vertrauensgrenzen
1. **Client ↔ Instanz** (Internet/Behördennetz): TLS am Reverse Proxy
terminiert (Betreiber-Zone). Die Anwendung setzt die
Security-Header selbst (eigene Middleware, #197: CSP `script-src
'self'`, `X-Frame-Options: SAMEORIGIN`, restriktives CORS);
Cookie-Mutationen sind Origin-pflichtig (fail-closed, #189).
2. **Reverse Proxy ↔ Anwendungscontainer**: nur 127.0.0.1-Bindungen;
der Proxy ist die einzige Eintrittsstelle. Der geplante
Proxy-Header-/mTLS-Authentisierungspfad (#215) verschiebt die
Authentisierungs-Vertrauensgrenze an genau diese Stelle — bis dahin
trägt der Proxy nur Transport.
3. **Anwendungs- ↔ Datenzone**: `db`, `pandoc`, `gotenberg`, `backup`
sind nur im Docker-Netz `internal` erreichbar; `web` hat keinerlei
Zugang dorthin.
4. **Plugin-Sandbox** (ADR 0008): Plugin-Code läuft ausschließlich im
sandboxed iframe (eigene Origin-lose Umgebung, Message-Protokoll,
deklarierte Capabilities); serverseitig wird beim Install ein
Static-Gate erzwungen. Instanzweiter Kill-Switch `plugins.enabled`
(#200); Hash-Pinning ist bewusst verschoben (#232,
Restrisikoliste). Der Sandbox-Escape-Regressionstest (bösartiges
Fixture-Plugin) läuft in CI.
5. **Ausgehende Kanäle**: SMTP (Betreiber-Relay) und Backup-Spiegel
(Allowlist, ADR 0026) sind die einzigen initiierten
Außenverbindungen. Es gibt keine Telemetrie, keine Update-Pings,
keine externen Font-/CDN-Ladungen (ADR 0016: Fonts self-hosted).
6. **Betreiber-Plattform**: Host, Docker-Daemon, Volumes, Netzwerk und
Backups liegen außerhalb der Anwendungsverantwortung
(Abgrenzungserklärung).
## 5 Vollständige Liste der Inhaltskopien
Grundlage des Löschkonzepts im Betriebshandbuch
(`70-betriebshandbuch.md` §5) — Löschen ist nur vollständig, wenn es
jede dieser Kopien erreicht oder ihren Verbleib begründet.
**In der Datenbank (Volume `db-data`):**
| Ort | Inhalt | Lebenszyklus |
| ------------------------------ | ------------------------------------------------- | ----------------------------------------------------- |
| `pages.ydoc_state` | aktuelles Dokument (Yjs-Binärzustand) | bis Purge der Seite |
| `page_updates` | inkrementelles Update-Log | Compaction; Purge löscht |
| `page_content_cache` | Klartext, Markdown, HTML, Outline, **Suchvektor** | bei jedem Persist ersetzt; Purge löscht |
| `page_versions` | eigenständige Snapshots (Historie) | bis Purge der Seite |
| `comments` | Kommentartexte | Purge der Seite kaskadiert |
| `conversion_jobs.input/result` | Export-/Import-Payloads (ganze Dokumente) | genullt nach `conversion.payloadRetentionDays` (#233) |
| `mail_outbox` | Mail-Bodies (Digest-Titel!) | gelöscht nach `mail.outboxRetentionDays` (#234) |
| `page_links.target_slug` | Slug-Text auch nach Ziel-Purge | bewusst behalten (#235, Restrisiko I-24) |
| `audit_log` | Metadaten (nie Inhalt) | `audit.retentionDays` (#196) |
| `attachments` (Row) | Metadaten + SHA-256 | Purge der Seite/des Ponds |
**Auf Volumes:**
| Ort | Inhalt | Lebenszyklus |
| --------- | -------------------------------------------- | --------------------------------------------------------------- |
| `uploads` | Attachment-Bytes | Purge löscht Datei + Quota; Orphan-Sweep (#194) räumt Verwaiste |
| `backups` | nächtliche Restore-Sets (Dump + Volume-Tars) | `BACKUP_RETENTION_DAYS` |
| `plugins` | Plugin-Pakete (kein Seiteninhalt) | Uninstall |
| `secrets` | Secret-Store (SMTP etc., kein Seiteninhalt) | Betreiber |
**Außerhalb der Instanz (nicht von Anwendungs-Löschung erreichbar —
Löschkonzept muss sie benennen):**
| Ort | Inhalt | Kontrolle |
| ----------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ |
| Backup-Spiegel (BASEL/WebDAV) | vollständige Restore-Sets | Betreiber; Retention auf Zielsystem |
| Export-Artefakte | PDF/DOCX/ODT/ZIP beim Nutzer | organisatorisch (VS-Handhabung, Kennzeichnung M26) |
| Browser-IndexedDB | Offline-Kopie zuletzt geöffneter Seiten | Endgeräteschutz (Restrisiko I-25) |
| Feed-Reader / API-Konsumenten | abonnierte Inhalte | organisatorisch; Referenzkonfiguration schaltet Feeds/API ab |
| Mails beim Empfänger | Digest-/Benachrichtigungstexte (Titel) | Restrisiko I-23; Referenzkonfiguration ohne SMTP |
| Container-stdout-Logs | Metadaten inkl. Audit-Zeilen (nie Seiteninhalt, security.md §Logging) | Docker json-file mit Rotation; Collector des Betreibers |
## 6 Kryptografie-Inventar (Bestand, kein Anspruch)
Zur Einordnung (vollständige Liste: Abgrenzungserklärung §3): Argon2id
für Passwort-Hashes, HMAC-signierte Kurzzeit-Tokens über `jose` mit
HKDF-Zweckableitung (#188), SHA-256-Integritätshashes für Attachments
(#199, fail-closed beim Download). Keine Inhalts- oder
Backup-Verschlüsselung, kein eigenes Schlüsselmanagement — bewusst
(§52 VSA).

View File

@ -0,0 +1,210 @@
# Betriebshandbuch (VS-NfD, Issue #229)
Zweck: der Betreiber führt die Instanz ohne uns — auch an dem Tag, an
dem etwas ausfällt. Dieses Handbuch bündelt die betrieblichen
Prozeduren und verweist für Schrittfolgen auf die maßgeblichen
Runbooks im Repository, statt sie zu duplizieren. Das Kapitel mit dem
größten VS-Gewicht ist **§5 Löschung und Vernichtung**; es baut auf der
vollständigen Kopienliste der Sicherheitsdokumentation auf
(`60-sicherheitsdokumentation.md` §5).
**Belegstufen.** Jede Prozedur nennt am Ende ihren Erprobungsstand:
- ✅ _erprobt_ — vom Autor bzw. automatisiert mindestens einmal real
ausgeführt, mit Beleg.
- ⚠️ _Mechanik vorhanden, nicht geprobt_ — implementiert und getestet,
aber noch nie im Ernstfall/als Übung durchgespielt.
- ⏳ _offen_ — kommt mit dem genannten Issue.
---
## 1 Installation
Referenz: `deploy/stages.md` (maßgebliche Schrittfolge),
`docs/self-hosting/README.md`.
1. Host-Voraussetzungen: Docker + Compose, ein Reverse Proxy mit TLS
(oder das mitgelieferte `caddy`-Compose-Profil).
2. `deploy/compose/docker-compose.yml` + `.env` (Vorlage
`.env.example`; **niemals** eine echte `.env` ins Repo — CI-Zaun
#198) auf den Host bringen; `COMPOSE_PROJECT_NAME`, Ports
(`WEB_PORT`/`API_PORT`/`COLLAB_PORT`), `POSTGRES_PASSWORD`, Secrets
setzen.
3. `docker compose pull && docker compose up -d` — die api wendet
Migrationen beim Start selbst an (`MIGRATE_ON_START`); es gibt
keinen separaten Migrationsschritt.
4. Erststart: der Setup-Wizard (Issue #80) legt das Admin-Konto an und
verriegelt sich danach dauerhaft; bis zum Abschluss ist nur
`/setup/*` erreichbar.
5. Reverse Proxy: `/` → web, `/api` → api, `/collab` → collab mit
WebSocket-Upgrade (Portliste: Sicherheitsdokumentation §2).
6. Für VS-NfD-Betrieb anschließend die Referenzkonfiguration aus dem
Härtungsleitfaden (`50-haertungsleitfaden.md`) anwenden und mit
dessen Checkliste verifizieren.
Belegstufe: ✅ erprobt — die Stages Test/Int/Prod auf ONE sind exakt
nach dieser Prozedur aufgesetzt und laufen produktiv (`deploy/stages.md`
dokumentiert die realen Instanzen).
**Airgap-/Offline-Variante:** ⏳ offen — Mirror-Verfahren (#218),
netzloser Build (#219), Testlauf in isolierter Umgebung (#220),
Offline-Update-Pfad (#221); Meilenstein M28. Bereits vorhanden als
Grundlage: alle Dritt-Images digest-gepinnt (#203), ein authoritativer
Node-Pin (#236), SBOMs je Release (#202).
## 2 Update und Rollback
Referenz: `docs/architecture/operations.md` §Update strategy,
`deploy/stages.md`.
- **Stages:** Merge auf `main` → CD baut Images, deployt Test, führt
Smoke-Tests aus, promotet Int. CD synct **keine** Compose-Dateien —
Compose-Änderungen werden von Hand auf den Host übernommen, sonst
räumt `--remove-orphans` manuell ergänzte Container ab.
Belegstufe: ✅ erprobt (läuft bei jedem Merge; zuletzt CD-Lauf 568).
- **Produktion:** ausschließlich getaggte Releases. `git tag vX.Y.Z`
baut die Versions-Images und führt die Update-Simulation als Gate
aus; `git tag prod-vX.Y.Z-initial` pinnt die Version in der
Prod-`.env` und rollt aus (readyz-Poll). Migrationen laufen beim
api-Start; **Rückwärtsmigrationen gibt es nicht** — Rollback setzt
deshalb ein DB-kompatibles Vorgängerimage voraus (Migrations-Kaveat;
im Zweifel Restore aus dem Backup-Set der Vorversion).
Belegstufe: ✅ erprobt (alle Prod-Versionen bis v0.12.0 so
ausgerollt).
- **Rollback:** `git tag prod-v<prev>-rollback1` pinnt die Vorversion
zurück. Belegstufe: ⚠️ Mechanik vorhanden, nicht geprobt — ein realer
Rollback war bisher nie nötig; die Übung steht aus.
- **Dritt-Image-Digest heben:** Prozedur `deploy/stages.md` §5a
(imagetools inspect → Compose-Referenz ändern → CI bestätigt → Stage-
Composes von Hand nachziehen → `docker inspect` verifiziert).
Belegstufe: ✅ erprobt (Rollout #203 am 31.07.2026).
- **Offline-Update:** ⏳ offen (#221, M28).
## 3 Backup und Restore
Referenz: `docs/operations/restore-runbook.md` (maßgeblich),
`deploy/backup-basel.md`, ADR 0015/0026.
- Nächtlich erzeugt der backup-Sidecar ein konsistentes Restore-Set
(`pg_dump -Fc` + Tar der Volumes `uploads`/`plugins`) auf dem
`backups`-Volume; Retention `BACKUP_RETENTION_DAYS` (Default 30,
Test/Int 7). Status in `status.json`, Fehler alarmieren per Mail
direkt via SMTP (bewusst nicht über die api — sie könnte das kaputte
Teil sein).
- **Zielbeschränkung (#192, ADR 0026):** Fernziele (rsync-Spiegel,
WebDAV) funktionieren nur gegen Hosts der **Deploy-Allowlist**
`BACKUP_ALLOWED_TARGETS`; eine leere Allowlist schaltet alle
Fernziele hart ab. Die Allowlist ist bewusst NICHT über die Admin-UI
änderbar (Rollentrennung, §6).
- **Restore:** `deploy/backup/restore.sh` fährt den Stack kontrolliert
herunter, spielt Dump + Volumes zurück und startet neu; Details und
Totalverlust-Szenario im Runbook.
- **Geprobter Restore:** ✅ erprobt — ein automatischer monatlicher
Restore-Drill (`drill-*`-Tag → `drill.yml`) stellt das jüngste
Prod-Set in einer Wegwerf-Umgebung wieder her und prüft Inhalte; das
Protokoll jedes Laufs steht als Kommentar an Issue #98.
Backups sind unverschlüsselt by design — Datenträgerschutz ist
Plattformsache (Abgrenzungserklärung).
## 4 Wartungsjobs (Scheduler)
Alle Jobs laufen in der api (in-app Scheduler, `jobs`-Tabelle), sind im
Site-Admin-Systempanel sichtbar und dort manuell auslösbar (auditiert
als `job.triggered`). Aktueller Bestand (9):
| Job | Rhythmus | Wirkung |
| -------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `trash-purge` | täglich | endgültiges Löschen abgelaufener Papierkorb-Seiten **und** -Teiche (`trash.retentionDays`, #31/#193) |
| `orphan-file-sweep` | täglich | verwaiste Uploads entfernen (#194); trägt nachts auch den SHA-256-Backfill (#199) |
| `page-compaction` | stündlich | `page_updates`-Log in den Zustand mergen |
| `version-thinning` | täglich | automatische Versions-Snapshots ausdünnen |
| `audit-retention` | täglich | `audit_log` nach `audit.retentionDays` beschneiden (#196), Lücke selbst auditiert (`audit.pruned`) |
| `conversion-payload-prune` | täglich | Import-/Export-Payloads fertiger Jobs nullen (#233) |
| `mail-outbox-retention` | täglich | SENT/endgültig FAILED Outbox-Zeilen löschen (#234) |
| `data-export-purge` | stündlich | abgelaufene DSGVO-Datenexporte entfernen (#68) |
| `notification-digest` | alle 15 min (Versand nach Nutzer-Präferenz) | Benachrichtigungs-Digests versenden |
Der e2e-Zaun `apps/web/e2e/system.spec.ts` pinnt diese Zahl — ein
neuer Job ohne Handbuch-/Zaun-Anpassung wird rot.
## 5 Löschung und Vernichtung
Grundsatz: „Gelöscht" heißt in Dorfteich erst dann gelöscht, wenn alle
Kopien aus `60-sicherheitsdokumentation.md` §5 erreicht sind. Die
Tabelle nennt je Inhaltstyp, was die Löschung tut, welche Kopien sie
erreicht und was **stehen bleibt**.
| Vorgang | Wirkung | erreichte Kopien | Rückstände / Fristen |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Seite in den Papierkorb | Soft-Delete (`deleted_at`), aus Suchindex entfernt (#195) | Suchvektor sofort | Inhalt vollständig vorhanden, wiederherstellbar bis Purge |
| Seiten-Purge (automatisch nach `trash.retentionDays`, Default 30, oder manuell) | löscht Zustand, Update-Log, Content-Cache, Versionen, Kommentare, Attachments (Bytes + Quota), Watches; Kinder rücken auf | DB-Zeilen + `uploads`-Bytes | `page_links.target_slug` bleibt bewusst (#235, Restrisiko I-24); Backups/Spiegel bis Ablauf ihrer Retention; Export-/Endgeräte-Kopien organisatorisch |
| Teich-Purge (#193, automatisch/manuell, Site-Admin) | löscht alle Seiten samt Kaskade, Labels, Grants, Nutzungszähler, Plugin-Opt-ins, Conversion-Jobs, Files | wie oben, teichweit | wie oben |
| Nutzer löschen / DSGVO | Konto löschen bzw. Autorschaft pseudonymisieren (`user.pseudonymized`) | Identitätsdaten | von ihm erstellte Inhalte gehören dem Teich |
| Export-Payloads | `conversion-payload-prune` nullt `input`/`result` fertiger Jobs (Default 30 d) | DB | Job-Zeile bleibt für Status/Audit |
| Mail-Kopien | `mail-outbox-retention` löscht SENT/endgültig FAILED (Default 30 d) | DB | zugestellte Mails beim Empfänger (Restrisiko I-23) |
| Backup-Sets | Retention des Sidecars bzw. des Spiegels | `backups`-Volume, Spiegel | ein gelöschter Inhalt lebt maximal bis zum Ablauf der längsten Backup-Retention weiter — bei sofortigem Vernichtungsbedarf Sets manuell löschen (Host-Zugriff) und Spiegel bereinigen |
| Audit-Trail | `audit-retention` (Default 365 d) | DB | Metadaten, nie Inhalt |
**Sofortige Vernichtung einzelner Inhalte** (über die Fristen hinaus):
Seite manuell purgen (Papierkorb → endgültig löschen), danach auf dem
Host die Backup-Sets der Aufbewahrungskette löschen bzw. den Spiegel
bereinigen und Endgeräte-/Exportkopien organisatorisch einsammeln.
Belegstufe Purge-Pfade: ✅ erprobt (laufen täglich produktiv; Purge-
Semantik durch Tests gepinnt). Belegstufe „Backup-Kette manuell
vernichten": ⚠️ nicht geprobt.
**Außerbetriebnahme einer Instanz:** `docker compose down -v` entfernt
Container und **alle benannten Volumes** (`db-data`, `uploads`,
`plugins`, `secrets`, `backups`, Caddy-Volumes); anschließend Spiegel-
Bestände löschen und Host-Datenträger nach Betreiber-Vorgabe
vernichten — die physische Vernichtung ist Plattformsache
(Abgrenzungserklärung). Belegstufe: ✅ erprobt für den Stack-Teil (der
stillgelegte Alt-VPS wurde so zurückgebaut; Datenträger dort noch als
Rollback-Reserve vorhanden).
## 6 Rollentrennung
| Aufgabe | braucht |
| --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| Instanz-Settings, Nutzer-/Quota-Verwaltung, Plugins, Wartungsjobs anstoßen, Teich-Purge, Audit-Panel | **Site-Admin** (App-Rolle) |
| Deploy, `.env`/Compose ändern, Backup-Allowlist, Secrets-Volume, Restore, Backup-Sets vernichten, DB-Direktzugriff, Log-Collector | **Plattformzugriff** (Host) |
| Release nach Prod | Plattformzugriff + Freigabeprozess (Tag) |
Was ein Site-Admin **nicht** kann (bewusste Grenzen):
- die Backup-Zielliste ändern oder Fernziele aktivieren
(`BACKUP_ALLOWED_TARGETS` ist Deploy-Konfiguration, ADR 0026);
- Secrets lesen oder setzen, die im Secret-Store/der `.env` liegen
(die Settings-Tabelle trägt nie Secrets, security.md §Secrets);
- Deploy-/Composeänderungen, Image-Versionen, Migrationen auslösen;
- den Audit-Trail editieren (nur lesen; Retention läuft als Job und
protokolliert sich selbst);
- Backups herunterladen ist möglich (In-App-Restore-Pfad, #103), aber
ihre Vernichtung auf Host/Spiegel nicht.
Zu beachten: ein Site-Admin **liest** per Rollen-Bypass jeden Inhalt
der Instanz (permissions.md) — die Trennung „wer darf Inhalte sehen"
von „wer betreibt die Plattform" ist damit App-seitig bewusst NICHT
absolut; wo das nicht tragbar ist, ist es organisatorisch zu regeln
(Vier-Augen-Prinzip bei Vergabe der Site-Admin-Rolle). Der Lesetrail
für eingestufte Inhalte (M29, #222#225) macht solche Zugriffe
nachvollziehbar.
## 7 Monitoring und Störung
Referenz: `deploy/monitoring.md`, `docs/architecture/operations.md`
§Health & monitoring.
- `GET /api/v1/readyz` prüft DB, Migrationen, Konverter, Renderer,
Backup-Status; `healthz` je Container. Externe Überwachung der
Referenzinstanzen: Uptime-Kuma mit Alarmierung.
- Logs: alle Dienste loggen JSON auf stdout (Docker json-file mit
Rotation); Audit-Ereignisse mit `msg`-Präfix `audit: ` und
`severity`-Feld — Weiterleitung an SIEM/Syslog übernimmt der
Collector des Betreibers (Katalog:
`docs/architecture/audit-events.md`, v1.1, mit
Kompatibilitätsversprechen).
- Integritätsalarm: `file.integrity_failed` (critical) = Download-Hash
≠ Upload-Hash → Objekt als manipuliert/korrupt behandeln, Datei aus
Backup-Set wiederherstellen (Runbook), erneut laden; der
Audit-Eintrag trägt beide Hashes.

View File

@ -110,5 +110,6 @@
"backup_set_not_found": "Das gewählte Backup-Set wurde nicht gefunden.",
"scope_required": "Dieses API-Token hat nicht den erforderlichen Scope.",
"pond_not_found": "Der Teich existiert nicht.",
"classification_lower_forbidden": "Zum Herabstufen der Einstufung fehlt die Berechtigung (Teich-Admin erforderlich)."
"classification_lower_forbidden": "Zum Herabstufen der Einstufung fehlt die Berechtigung (Teich-Admin erforderlich).",
"classified_upload_blocked": "Uploads auf eingestufte Seiten sind auf dieser Instanz blockiert."
}

View File

@ -19,5 +19,6 @@
"svgSanitize": "Bereinigen (Skripte entfernen)",
"svgReject": "Ablehnen",
"save": "Upload-Einstellungen speichern"
}
},
"classifiedUploadWarning": "Diese Seite ist als „VS NUR FÜR DEN DIENSTGEBRAUCH\" eingestuft. Angehängte Dateien erben die Einstufung; die Datei selbst trägt im Inhalt keine Kennzeichnung (nur Dateinamens-Präfix und Begleitdatei beim Download)."
}

View File

@ -52,7 +52,11 @@
"newPageClassification": "Einstufung neuer Seiten",
"newPageClassificationHelp": "Standard-Einstufung (VS-NfD-Kennzeichnung) für neu angelegte Seiten. Die Kennzeichnung ist keine Zugriffskontrolle; die Trennung von Einstufungsniveaus leistet die Umgebung (eine Instanz je Niveau).",
"classificationUnclassified": "Offen — keine Kennzeichnung",
"classificationVsNfd": "VS NUR FÜR DEN DIENSTGEBRAUCH"
"classificationVsNfd": "VS NUR FÜR DEN DIENSTGEBRAUCH",
"uploadPolicy": "Datei-Uploads auf eingestufte Seiten",
"uploadPolicyHelp": "Anhänge erben die Einstufung der Seite, tragen selbst aber keine Kennzeichnung im Inhalt. „Blockieren\" lehnt Uploads auf eingestufte Seiten serverseitig ab.",
"uploadPolicyWarn": "Warnen — Upload mit deutlichem Hinweis erlauben",
"uploadPolicyBlock": "Blockieren — Uploads auf eingestufte Seiten ablehnen"
},
"landing": {
"title": "Startseite",

View File

@ -110,5 +110,6 @@
"backup_set_not_found": "The selected backup set was not found.",
"scope_required": "This API token does not have the required scope.",
"pond_not_found": "The pond does not exist.",
"classification_lower_forbidden": "You lack the permission to lower the classification (Pond Admin required)."
"classification_lower_forbidden": "You lack the permission to lower the classification (Pond Admin required).",
"classified_upload_blocked": "Uploads to classified pages are blocked on this instance."
}

View File

@ -19,5 +19,6 @@
"svgSanitize": "Sanitize (strip scripts)",
"svgReject": "Reject",
"save": "Save upload settings"
}
},
"classifiedUploadWarning": "This page is classified “VS NUR FÜR DEN DIENSTGEBRAUCH”. Attached files inherit the classification; the file content itself carries no marking (only the filename prefix and companion file on download)."
}

View File

@ -52,7 +52,11 @@
"newPageClassification": "Classification of new pages",
"newPageClassificationHelp": "Default classification (VS-NfD marking) for newly created pages. The marking is not access control; separating classification levels is the environments job (one instance per level).",
"classificationUnclassified": "Open — no marking",
"classificationVsNfd": "VS NUR FÜR DEN DIENSTGEBRAUCH"
"classificationVsNfd": "VS NUR FÜR DEN DIENSTGEBRAUCH",
"uploadPolicy": "File uploads to classified pages",
"uploadPolicyHelp": "Attachments inherit the pages classification but carry no marking in their content. “Block” rejects uploads to classified pages server-side.",
"uploadPolicyWarn": "Warn — allow the upload with a clear notice",
"uploadPolicyBlock": "Block — reject uploads to classified pages"
},
"landing": {
"title": "Landing page",

View File

@ -26,6 +26,16 @@ export function classificationMarking(classification: PageClassification): strin
return classification === 'vs_nfd' ? 'VS NUR FÜR DEN DIENSTGEBRAUCH' : null;
}
/**
* File-name-safe short marker for downloads (issue #212, ADR 0022): an
* arbitrary binary cannot carry the marking inside, so its NAME does. The
* established short form of the German marking is `VS-NfD`; the full
* wording stays the on-screen/companion form. Empty for unclassified.
*/
export function classificationFilenamePrefix(classification: PageClassification): string {
return classification === 'vs_nfd' ? 'VS-NfD_' : '';
}
/** Ordering of levels: the index in {@link PAGE_CLASSIFICATIONS} (lowest
* first) the tree invariant (#205) and archive-level statements (#210)
* compare through this, never through string comparison. */

View File

@ -3,6 +3,7 @@ import { z } from 'zod';
import type { ApiTokenScope } from './api-tokens';
import type { CommentView } from './comments';
import type { LabelTreeNode, LabelView } from './labels';
import type { PageClassification } from './pages';
/**
* Wire types of the public REST API (`/api/public/v1`, issue #104). The
@ -29,6 +30,8 @@ export interface PublicPondView {
export interface PublicPageListItemView {
slug: string;
title: string;
/** VS-NfD level (issue #211, ADR 0022) — see `docs/self-hosting/public-api.md`. */
classification: PageClassification;
/** Parent page slug in the tree (issue #110), or null at the root nulled
* as well when the token's user may not read the parent (no existence leak). */
parent: string | null;
@ -41,6 +44,8 @@ export interface PublicPageView {
slug: string;
title: string;
pondSlug: string;
/** VS-NfD level (issue #211, ADR 0022) — see `docs/self-hosting/public-api.md`. */
classification: PageClassification;
/** Parent page slug (issue #110); see {@link PublicPageListItemView.parent}. */
parent: string | null;
markdown: string;

View File

@ -1,5 +1,7 @@
import { z } from 'zod';
import type { PageClassification } from './pages';
/**
* Search schemas and views (issue #49/#50, ADR 0010). Full-text search runs on
* PostgreSQL behind the `SearchProvider` interface. Diacritic-insensitive
@ -49,4 +51,7 @@ export interface SearchResultView {
labelIds: string[];
/** Snippet with matches wrapped in the highlight sentinels above. */
snippet: string;
/** VS-NfD level (issue #211, ADR 0022): a snippet of a classified page is
* never shown unmarked the UI renders the marking with every hit. */
classification: PageClassification;
}