#204: classification as first-class page metadata (ADR 0022)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m42s
CI / Build container images (pull_request) Successful in 3m56s
CI / Auth e2e pack (pull_request) Successful in 8m17s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 24s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 6m17s
CD / Smoke tests against Test (push) Successful in 3m32s
CI / Build container images (push) Has been skipped
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 8m20s
CI / Import/export fidelity gate (push) Successful in 55s

Enum field on Page (UNCLASSIFIED default, VS_NFD), migration backfills
existing pages. New pages take the instance-wide default from
classification.newPageDefault (admin-visible, de+en). The value rides in
every PageView, so no channel needs an extra request. The field is a
marking, not a protection mechanism: a test pins that permission
decisions are unchanged by it. The marking wording is fixed in ADR 0022
and sourced solely from classificationMarking() in @dorfteich/shared.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-31 06:01:50 +02:00
parent db4f517e44
commit 183faf7710
12 changed files with 275 additions and 25 deletions

View File

@ -0,0 +1,8 @@
-- #204 (ADR 0022): classification becomes first-class page metadata. The
-- column is a marking, not a protection mechanism — permissions are
-- untouched. NOT NULL with a default backfills every existing page to
-- UNCLASSIFIED in the same statement.
CREATE TYPE "PageClassification" AS ENUM ('UNCLASSIFIED', 'VS_NFD');
ALTER TABLE "pages"
ADD COLUMN "classification" "PageClassification" NOT NULL DEFAULT 'UNCLASSIFIED';

View File

@ -260,19 +260,30 @@ model RoleGrant {
/// page never changes its URL or breaks wikilinks. Trashed pages keep their /// page never changes its URL or breaks wikilinks. Trashed pages keep their
/// `parentId` (restore re-attaches to the nearest live ancestor, issue #107); /// `parentId` (restore re-attaches to the nearest live ancestor, issue #107);
/// `SetNull` is only the FK backstop — purge promotes children explicitly. /// `SetNull` is only the FK backstop — purge promotes children explicitly.
/// VS-NfD marking level of a page (ADR 0022). Deliberately an enum on Page,
/// not a label: instance-wide meaning, not user-deletable in routine content
/// work, inherits down the tree (#205), reaches every output channel
/// (#206#212). It is a MARKING, not a protection mechanism — separation of
/// levels happens outside the application (one instance per level).
enum PageClassification {
UNCLASSIFIED
VS_NFD
}
model Page { model Page {
id String @id @default(uuid()) id String @id @default(uuid())
pondId String @map("pond_id") pondId String @map("pond_id")
parentId String? @map("parent_id") parentId String? @map("parent_id")
title String title String
slug String slug String
ydocState Bytes @map("ydoc_state") ydocState Bytes @map("ydoc_state")
sortKey String @map("sort_key") sortKey String @map("sort_key")
createdBy String @map("created_by") classification PageClassification @default(UNCLASSIFIED)
createdAt DateTime @default(now()) @map("created_at") createdBy String @map("created_by")
updatedAt DateTime @updatedAt @map("updated_at") createdAt DateTime @default(now()) @map("created_at")
deletedAt DateTime? @map("deleted_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedBy String? @map("deleted_by") deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by")
pond Pond @relation(fields: [pondId], references: [id]) pond Pond @relation(fields: [pondId], references: [id])
parent Page? @relation("PageHierarchy", fields: [parentId], references: [id], onDelete: SetNull) parent Page? @relation("PageHierarchy", fields: [parentId], references: [id], onDelete: SetNull)

View File

@ -0,0 +1,164 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* Classification as first-class page metadata (issue #204, ADR 0022): the
* enum field, the instance default for new pages, its presence in the page
* representations the frontend already loads and the ADR's explicit claim
* that the field is a marking, NOT a protection mechanism (permissions are
* unchanged by it).
*/
describe.skipIf(!hasTestDb)('page classification (e2e, issue #204)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'eingestufte seiten 123';
const admin = { username: `carla-class-${suffix}`, displayName: `Carla Class ${suffix}` };
const outsider = { username: `oskar-class-${suffix}`, displayName: `Oskar Out ${suffix}` };
let adminCookie: string;
let outsiderCookie: string;
let pondId: string;
const api = () => request(app.getHttpServer());
async function loginOf(username: string): Promise<string> {
const res = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200);
return sessionCookieOf(res);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
const adminUser = await users.createUser({
username: admin.username,
email: `${admin.username}@example.org`,
displayName: admin.displayName,
password,
locale: 'en',
});
const verifyToken = await tokens.issue(adminUser.id, 'EMAIL_VERIFICATION', 600);
await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204);
// Site admin so the test can flip the instance default over the real
// admin API (which also invalidates the settings cache).
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
adminCookie = await loginOf(admin.username);
const outsiderUser = await users.createUser({
username: outsider.username,
email: `${outsider.username}@example.org`,
displayName: outsider.displayName,
password,
locale: 'en',
});
await users.markEmailVerified(outsiderUser.id);
outsiderCookie = await loginOf(outsider.username);
const ponds = await api().get('/api/v1/ponds').set('Cookie', adminCookie).expect(200);
pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id;
});
afterAll(async () => {
// Shared DB: leave no settings key behind for other suites.
await prisma.instanceSetting.deleteMany({ where: { key: 'classification.newPageDefault' } });
await prisma.page.deleteMany({
where: { pond: { owner: { username: { contains: suffix } } } },
});
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('new pages default to unclassified and carry the field in every page representation', async () => {
const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', adminCookie)
.send({ title: `Open Notes ${suffix}` })
.expect(201);
expect(created.body.classification).toBe('unclassified');
const fetched = await api()
.get(`/api/v1/pages/${created.body.id}`)
.set('Cookie', adminCookie)
.expect(200);
expect(fetched.body.classification).toBe('unclassified');
const list = await api()
.get(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', adminCookie)
.expect(200);
const listed = list.body.find((p: { id: string }) => p.id === created.body.id);
expect(listed.classification).toBe('unclassified');
});
it('the instance setting supplies the default for newly created pages', async () => {
await api()
.patch('/api/v1/admin/settings')
.set('Cookie', adminCookie)
.send({ 'classification.newPageDefault': 'vs_nfd' })
.expect(200);
try {
const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', adminCookie)
.send({ title: `Classified Notes ${suffix}` })
.expect(201);
expect(created.body.classification).toBe('vs_nfd');
} finally {
await api()
.patch('/api/v1/admin/settings')
.set('Cookie', adminCookie)
.send({ 'classification.newPageDefault': 'unclassified' })
.expect(200);
}
});
it('classification changes no permission decision (ADR 0022: marking, not protection)', async () => {
const open = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', adminCookie)
.send({ title: `Perm Open ${suffix}` })
.expect(201);
const classified = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', adminCookie)
.send({ title: `Perm Classified ${suffix}` })
.expect(201);
await prisma.page.update({
where: { id: classified.body.id },
data: { classification: 'VS_NFD' },
});
// The owner reads and writes a classified page exactly like an open one —
// no extra capability appears on the read/write path.
await api().get(`/api/v1/pages/${classified.body.id}`).set('Cookie', adminCookie).expect(200);
await api()
.patch(`/api/v1/pages/${classified.body.id}`)
.set('Cookie', adminCookie)
.send({ title: `Perm Classified 2 ${suffix}` })
.expect(200);
// And a stranger is denied identically for both (404, existence hidden) —
// the marking neither grants nor removes access.
await api().get(`/api/v1/pages/${open.body.id}`).set('Cookie', outsiderCookie).expect(404);
await api()
.get(`/api/v1/pages/${classified.body.id}`)
.set('Cookie', outsiderCookie)
.expect(404);
});
});

View File

@ -9,6 +9,7 @@ import {
CreatePageInput, CreatePageInput,
MAX_PAGE_DEPTH, MAX_PAGE_DEPTH,
OutlineEntry, OutlineEntry,
PageClassification,
PageDeleteMode, PageDeleteMode,
PageListItemView, PageListItemView,
PageListQuery, PageListQuery,
@ -35,6 +36,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { WatchesService } from '../watches/watches.service'; import { WatchesService } from '../watches/watches.service';
import { SearchProvider } from '../search/search.provider'; import { SearchProvider } from '../search/search.provider';
import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key'; import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
@ -65,6 +67,7 @@ export class PagesService {
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly search: SearchProvider, private readonly search: SearchProvider,
private readonly watches: WatchesService, private readonly watches: WatchesService,
private readonly settings: InstanceSettingsService,
) { ) {
this.logger.setContext(PagesService.name); this.logger.setContext(PagesService.name);
} }
@ -77,6 +80,7 @@ export class PagesService {
title: page.title, title: page.title,
slug: page.slug, slug: page.slug,
sortKey: page.sortKey, sortKey: page.sortKey,
classification: page.classification.toLowerCase() as PageClassification,
createdAt: page.createdAt.toISOString(), createdAt: page.createdAt.toISOString(),
updatedAt: page.updatedAt.toISOString(), updatedAt: page.updatedAt.toISOString(),
deletedAt: page.deletedAt?.toISOString() ?? null, deletedAt: page.deletedAt?.toISOString() ?? null,
@ -299,6 +303,9 @@ export class PagesService {
}); });
const sortKey = generateKeyBetween(last?.sortKey ?? null, null); const sortKey = generateKeyBetween(last?.sortKey ?? null, null);
const content = deriveContent(state); const content = deriveContent(state);
// New pages start at the instance-wide default level (ADR 0022, #204);
// inheritance of the parent's level arrives with #205 and wins over this.
const defaultClassification = await this.settings.get('classification.newPageDefault');
const page = await this.prisma.page.create({ const page = await this.prisma.page.create({
data: { data: {
@ -307,6 +314,7 @@ export class PagesService {
title, title,
slug, slug,
sortKey, sortKey,
classification: defaultClassification === 'vs_nfd' ? 'VS_NFD' : 'UNCLASSIFIED',
ydocState: state, ydocState: state,
createdBy: user.id, createdBy: user.id,
contentCache: { create: contentCacheData(content) }, contentCache: { create: contentCacheData(content) },

View File

@ -51,6 +51,12 @@ export const INSTANCE_SETTINGS = {
// bounded. PENDING rows — including failed-but-retryable ones — are // bounded. PENDING rows — including failed-but-retryable ones — are
// never touched; the retry loop owns them. // never touched; the retry loop owns them.
'mail.outboxRetentionDays': z.number().int().min(1).default(30), 'mail.outboxRetentionDays': z.number().int().min(1).default(30),
// Default VS-NfD classification for newly created pages (ADR 0022,
// issue #204). An instance operated inside a classified environment sets
// this to `vs_nfd` so nothing starts unmarked; inheritance from the
// parent page (#205) wins over this default. The marking is not a
// protection mechanism — permissions ignore it.
'classification.newPageDefault': z.enum(['unclassified', 'vs_nfd']).default('unclassified'),
// Non-image upload allowlist (ADR 0011, issue #61): lowercase extensions // Non-image upload allowlist (ADR 0011, issue #61): lowercase extensions
// without the dot. Images are always allowed regardless; SVG is governed // without the dot. Images are always allowed regardless; SVG is governed
// by `upload.svgPolicy`. Normalized (lowercased, dot-stripped, deduped) so // by `upload.svgPolicy`. Normalized (lowercased, dot-stripped, deduped) so

View File

@ -28,6 +28,7 @@ interface InstanceSettings {
'plugins.enabled': boolean; 'plugins.enabled': boolean;
'upload.allowedExtensions': string[]; 'upload.allowedExtensions': string[];
'upload.svgPolicy': 'reject' | 'sanitize'; 'upload.svgPolicy': 'reject' | 'sanitize';
'classification.newPageDefault': 'unclassified' | 'vs_nfd';
'legal.imprint': string; 'legal.imprint': string;
'legal.privacyPolicy': string; 'legal.privacyPolicy': string;
'home.content': string; 'home.content': string;
@ -89,6 +90,17 @@ export function AdminSettingsPage(): React.JSX.Element {
<option value="closed">{t('settings:admin.registrationClosed')}</option> <option value="closed">{t('settings:admin.registrationClosed')}</option>
</select> </select>
</Field> </Field>
<Field
label={t('settings:admin.newPageClassification')}
hint={t('settings:admin.newPageClassificationHelp')}
>
<select {...form.register('classification.newPageDefault')}>
<option value="unclassified">
{t('settings:admin.classificationUnclassified')}
</option>
<option value="vs_nfd">{t('settings:admin.classificationVsNfd')}</option>
</select>
</Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}> <button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('settings:admin.save')} {t('settings:admin.save')}
</button> </button>

View File

@ -55,6 +55,14 @@ guardrails). Separation is a platform property.
risk, not a silent one. risk, not a silent one.
6. **Unclassified content shows no marking.** Marking everything trains 6. **Unclassified content shows no marking.** Marking everything trains
users to ignore markings. users to ignore markings.
7. **The marking wording is fixed and locale-independent:**
`VS NUR FÜR DEN DIENSTGEBRAUCH` — the official formula of the German
VSA. It is deliberately **not** translated: a marking is a fixed legal
formula, and a localized variant would not be the marking. Only the UI
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.
## Consequences ## Consequences

View File

@ -92,16 +92,17 @@ and `subject_type = user`.
### `pages` ### `pages`
| Column | Notes | | Column | Notes |
| ---------------------------------------- | ----------------------------------------------------------------------------------- | | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`, `pond_id` | | | `id`, `pond_id` | |
| `title` | also indexed for search weight A | | `title` | also indexed for search weight A |
| `slug` | unique per pond, for stable URLs and wikilink resolution | | `slug` | unique per pond, for stable URLs and wikilink resolution |
| `ydoc_state` (bytea) | current merged Yjs state (ADR 0003) | | `ydoc_state` (bytea) | current merged Yjs state (ADR 0003) |
| `ydoc_updates` | append log table `page_updates(page_id, seq, update bytea)`, compacted periodically | | `ydoc_updates` | append log table `page_updates(page_id, seq, update bytea)`, compacted periodically |
| `sort_key` | manual sidebar ordering (fractional indexing) | | `sort_key` | manual sidebar ordering (fractional indexing) |
| `created_by`, `created_at`, `updated_at` | | | `classification` | VS-NfD marking level, enum `UNCLASSIFIED` / `VS_NFD` (ADR 0022, issue #204). A marking, not a protection mechanism: permissions ignore it; separation of levels is one instance per level. Default for new pages from `instance_settings` (`classification.newPageDefault`). |
| `deleted_at`, `deleted_by` | trash | | `created_by`, `created_at`, `updated_at` | |
| `deleted_at`, `deleted_by` | trash |
### `page_content_cache` ### `page_content_cache`

View File

@ -48,7 +48,7 @@ _Meilenstein: `M27 — VS-NfD: external authentication`_
_Meilenstein: `M26 — VS-NfD: classification metadata`_ _Meilenstein: `M26 — VS-NfD: classification metadata`_
- [ ] Enum-Feld `classification` an `Page`, Migration, Default aus - [x] Enum-Feld `classification` an `Page`, Migration, Default aus
Instance-Setting · 2 AT · #204 Instance-Setting · 2 AT · #204
- [ ] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205 - [ ] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205
- [ ] Durchreichen in alle Ausgabekanäle · 812 AT · #206#212 - [ ] Durchreichen in alle Ausgabekanäle · 812 AT · #206#212

View File

@ -48,7 +48,11 @@
"registrationOpen": "Offen — alle können sich registrieren", "registrationOpen": "Offen — alle können sich registrieren",
"registrationClosed": "Geschlossen — keine neuen Registrierungen", "registrationClosed": "Geschlossen — keine neuen Registrierungen",
"save": "Speichern", "save": "Speichern",
"saved": "Gespeichert." "saved": "Gespeichert.",
"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"
}, },
"landing": { "landing": {
"title": "Startseite", "title": "Startseite",

View File

@ -48,7 +48,11 @@
"registrationOpen": "Open — anyone can register", "registrationOpen": "Open — anyone can register",
"registrationClosed": "Closed — no new registrations", "registrationClosed": "Closed — no new registrations",
"save": "Save", "save": "Save",
"saved": "Saved." "saved": "Saved.",
"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"
}, },
"landing": { "landing": {
"title": "Landing page", "title": "Landing page",

View File

@ -6,6 +6,26 @@ import { z } from 'zod';
* saved wholesale over REST as a base64 string. * saved wholesale over REST as a base64 string.
*/ */
/**
* VS-NfD classification levels of a page (ADR 0022), lowest first. The
* field is a marking, not a protection mechanism: permissions ignore it,
* and separating levels is the platform's job (one instance per level).
* API representations carry the lowercase value; the Prisma enum stores
* the uppercase spelling.
*/
export const PAGE_CLASSIFICATIONS = ['unclassified', 'vs_nfd'] as const;
export type PageClassification = (typeof PAGE_CLASSIFICATIONS)[number];
/**
* The official marking wording (ADR 0022). Deliberately NOT translated:
* a marking is a fixed formula, so it stays identical in every locale
* only the surrounding UI labels are i18n'd. `null` = no marking at all
* (unclassified content shows nothing, per ADR 0022).
*/
export function classificationMarking(classification: PageClassification): string | null {
return classification === 'vs_nfd' ? 'VS NUR FÜR DEN DIENSTGEBRAUCH' : null;
}
export const pageTitleSchema = z export const pageTitleSchema = z
.string() .string()
.trim() .trim()
@ -101,6 +121,10 @@ export interface PageView {
title: string; title: string;
slug: string; slug: string;
sortKey: string; sortKey: string;
/** VS-NfD marking level (ADR 0022, issue #204) part of the metadata
* every page response already carries, so no channel needs an extra
* request to render the marking. */
classification: PageClassification;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;