From 183faf7710ea027e9c103bb2eb95f1c7b0165e25 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 06:01:50 +0200 Subject: [PATCH] #204: classification as first-class page metadata (ADR 0022) Enum field on Page (UNCLASSIFIED default, VS_NFD), migration backfills existing pages. New pages take the instance-wide default from classification.newPageDefault (admin-visible, de+en). The value rides in every PageView, so no channel needs an extra request. The field is a marking, not a protection mechanism: a test pins that permission decisions are unchanged by it. The marking wording is fixed in ADR 0022 and sourced solely from classificationMarking() in @dorfteich/shared. Co-Authored-By: Claude Fable 5 (1M context) --- .../migration.sql | 8 + apps/api/prisma/schema.prisma | 35 ++-- .../src/pages/classification.e2e.db.test.ts | 164 ++++++++++++++++++ apps/api/src/pages/pages.service.ts | 8 + .../src/settings/instance-settings.service.ts | 6 + apps/web/src/pages/AdminSettingsPage.tsx | 12 ++ .../adr/0022-page-classification.md | 8 + docs/architecture/data-model.md | 21 +-- docs/vs-nfd/20-massnahmenplan.md | 2 +- packages/shared/i18n/de/settings.json | 6 +- packages/shared/i18n/en/settings.json | 6 +- packages/shared/src/pages.ts | 24 +++ 12 files changed, 275 insertions(+), 25 deletions(-) create mode 100644 apps/api/prisma/migrations/20260731120000_page_classification/migration.sql create mode 100644 apps/api/src/pages/classification.e2e.db.test.ts diff --git a/apps/api/prisma/migrations/20260731120000_page_classification/migration.sql b/apps/api/prisma/migrations/20260731120000_page_classification/migration.sql new file mode 100644 index 0000000..2c55f57 --- /dev/null +++ b/apps/api/prisma/migrations/20260731120000_page_classification/migration.sql @@ -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'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b8043e6..2762fbc 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -260,19 +260,30 @@ model RoleGrant { /// page never changes its URL or breaks wikilinks. Trashed pages keep their /// `parentId` (restore re-attaches to the nearest live ancestor, issue #107); /// `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 { - id String @id @default(uuid()) - pondId String @map("pond_id") - parentId String? @map("parent_id") - title String - slug String - ydocState Bytes @map("ydoc_state") - sortKey String @map("sort_key") - createdBy String @map("created_by") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") - deletedBy String? @map("deleted_by") + id String @id @default(uuid()) + pondId String @map("pond_id") + parentId String? @map("parent_id") + title String + slug String + ydocState Bytes @map("ydoc_state") + sortKey String @map("sort_key") + classification PageClassification @default(UNCLASSIFIED) + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + deletedBy String? @map("deleted_by") pond Pond @relation(fields: [pondId], references: [id]) parent Page? @relation("PageHierarchy", fields: [parentId], references: [id], onDelete: SetNull) diff --git a/apps/api/src/pages/classification.e2e.db.test.ts b/apps/api/src/pages/classification.e2e.db.test.ts new file mode 100644 index 0000000..d33975f --- /dev/null +++ b/apps/api/src/pages/classification.e2e.db.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 24d2901..5317390 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -9,6 +9,7 @@ import { CreatePageInput, MAX_PAGE_DEPTH, OutlineEntry, + PageClassification, PageDeleteMode, PageListItemView, PageListQuery, @@ -35,6 +36,7 @@ import { PinoLogger } from 'nestjs-pino'; import { AppConfig } from '../config/app-config.service'; import { PermissionService } from '../permissions/permission.service'; import { PrismaService } from '../prisma/prisma.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; import { WatchesService } from '../watches/watches.service'; import { SearchProvider } from '../search/search.provider'; import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key'; @@ -65,6 +67,7 @@ export class PagesService { private readonly config: AppConfig, private readonly search: SearchProvider, private readonly watches: WatchesService, + private readonly settings: InstanceSettingsService, ) { this.logger.setContext(PagesService.name); } @@ -77,6 +80,7 @@ export class PagesService { title: page.title, slug: page.slug, sortKey: page.sortKey, + classification: page.classification.toLowerCase() as PageClassification, createdAt: page.createdAt.toISOString(), updatedAt: page.updatedAt.toISOString(), deletedAt: page.deletedAt?.toISOString() ?? null, @@ -299,6 +303,9 @@ export class PagesService { }); const sortKey = generateKeyBetween(last?.sortKey ?? null, null); 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({ data: { @@ -307,6 +314,7 @@ export class PagesService { title, slug, sortKey, + classification: defaultClassification === 'vs_nfd' ? 'VS_NFD' : 'UNCLASSIFIED', ydocState: state, createdBy: user.id, contentCache: { create: contentCacheData(content) }, diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index 4b42156..67f2571 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -51,6 +51,12 @@ export const INSTANCE_SETTINGS = { // bounded. PENDING rows — including failed-but-retryable ones — are // never touched; the retry loop owns them. 'mail.outboxRetentionDays': z.number().int().min(1).default(30), + // Default VS-NfD classification for newly created pages (ADR 0022, + // issue #204). An instance operated inside a classified environment sets + // this to `vs_nfd` so nothing starts unmarked; inheritance from the + // parent page (#205) wins over this default. The marking is not a + // protection mechanism — permissions ignore it. + 'classification.newPageDefault': z.enum(['unclassified', 'vs_nfd']).default('unclassified'), // Non-image upload allowlist (ADR 0011, issue #61): lowercase extensions // without the dot. Images are always allowed regardless; SVG is governed // by `upload.svgPolicy`. Normalized (lowercased, dot-stripped, deduped) so diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index c0e26c6..978c295 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -28,6 +28,7 @@ interface InstanceSettings { 'plugins.enabled': boolean; 'upload.allowedExtensions': string[]; 'upload.svgPolicy': 'reject' | 'sanitize'; + 'classification.newPageDefault': 'unclassified' | 'vs_nfd'; 'legal.imprint': string; 'legal.privacyPolicy': string; 'home.content': string; @@ -89,6 +90,17 @@ export function AdminSettingsPage(): React.JSX.Element { + + + diff --git a/docs/architecture/adr/0022-page-classification.md b/docs/architecture/adr/0022-page-classification.md index 1eb2174..5ba8a03 100644 --- a/docs/architecture/adr/0022-page-classification.md +++ b/docs/architecture/adr/0022-page-classification.md @@ -55,6 +55,14 @@ guardrails). Separation is a platform property. risk, not a silent one. 6. **Unclassified content shows no marking.** Marking everything trains 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 diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 80337aa..7ad29d3 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -92,16 +92,17 @@ and `subject_type = user`. ### `pages` -| Column | Notes | -| ---------------------------------------- | ----------------------------------------------------------------------------------- | -| `id`, `pond_id` | | -| `title` | also indexed for search weight A | -| `slug` | unique per pond, for stable URLs and wikilink resolution | -| `ydoc_state` (bytea) | current merged Yjs state (ADR 0003) | -| `ydoc_updates` | append log table `page_updates(page_id, seq, update bytea)`, compacted periodically | -| `sort_key` | manual sidebar ordering (fractional indexing) | -| `created_by`, `created_at`, `updated_at` | | -| `deleted_at`, `deleted_by` | trash | +| Column | Notes | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id`, `pond_id` | | +| `title` | also indexed for search weight A | +| `slug` | unique per pond, for stable URLs and wikilink resolution | +| `ydoc_state` (bytea) | current merged Yjs state (ADR 0003) | +| `ydoc_updates` | append log table `page_updates(page_id, seq, update bytea)`, compacted periodically | +| `sort_key` | manual sidebar ordering (fractional indexing) | +| `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`). | +| `created_by`, `created_at`, `updated_at` | | +| `deleted_at`, `deleted_by` | trash | ### `page_content_cache` diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index cf4ef74..4323ff1 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -48,7 +48,7 @@ _Meilenstein: `M27 — VS-NfD: external authentication`_ _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 - [ ] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205 - [ ] Durchreichen in alle Ausgabekanäle · 8–12 AT · #206–#212 diff --git a/packages/shared/i18n/de/settings.json b/packages/shared/i18n/de/settings.json index 5c854d1..37ec6b6 100644 --- a/packages/shared/i18n/de/settings.json +++ b/packages/shared/i18n/de/settings.json @@ -48,7 +48,11 @@ "registrationOpen": "Offen — alle können sich registrieren", "registrationClosed": "Geschlossen — keine neuen Registrierungen", "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": { "title": "Startseite", diff --git a/packages/shared/i18n/en/settings.json b/packages/shared/i18n/en/settings.json index 8c69e9d..ec53831 100644 --- a/packages/shared/i18n/en/settings.json +++ b/packages/shared/i18n/en/settings.json @@ -48,7 +48,11 @@ "registrationOpen": "Open — anyone can register", "registrationClosed": "Closed — no new registrations", "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 environment’s job (one instance per level).", + "classificationUnclassified": "Open — no marking", + "classificationVsNfd": "VS – NUR FÜR DEN DIENSTGEBRAUCH" }, "landing": { "title": "Landing page", diff --git a/packages/shared/src/pages.ts b/packages/shared/src/pages.ts index cc6e2e8..9ece5c5 100644 --- a/packages/shared/src/pages.ts +++ b/packages/shared/src/pages.ts @@ -6,6 +6,26 @@ import { z } from 'zod'; * 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 .string() .trim() @@ -101,6 +121,10 @@ export interface PageView { title: string; slug: 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; updatedAt: string; deletedAt: string | null;