#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
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:
parent
db4f517e44
commit
183faf7710
@ -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';
|
||||
@ -260,6 +260,16 @@ 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")
|
||||
@ -268,6 +278,7 @@ model Page {
|
||||
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")
|
||||
|
||||
164
apps/api/src/pages/classification.e2e.db.test.ts
Normal file
164
apps/api/src/pages/classification.e2e.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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) },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
<option value="closed">{t('settings:admin.registrationClosed')}</option>
|
||||
</select>
|
||||
</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}>
|
||||
{t('settings:admin.save')}
|
||||
</button>
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -93,13 +93,14 @@ 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) |
|
||||
| `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 |
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user