#204: classification as first-class page metadata (ADR 0022) #263
@ -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,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)
|
||||||
|
|||||||
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,
|
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) },
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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`
|
||||||
|
|
||||||
|
|||||||
@ -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 · 8–12 AT · #206–#212
|
- [ ] Durchreichen in alle Ausgabekanäle · 8–12 AT · #206–#212
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 environment’s job (one instance per level).",
|
||||||
|
"classificationUnclassified": "Open — no marking",
|
||||||
|
"classificationVsNfd": "VS – NUR FÜR DEN DIENSTGEBRAUCH"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"title": "Landing page",
|
"title": "Landing page",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user