diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index e974a1f..8b18654 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -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
diff --git a/apps/api/src/public-api/openapi.ts b/apps/api/src/public-api/openapi.ts
index cc0a467..0306f3d 100644
--- a/apps/api/src/public-api/openapi.ts
+++ b/apps/api/src/public-api/openapi.ts
@@ -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.',
diff --git a/apps/api/src/public-api/public-api.e2e.db.test.ts b/apps/api/src/public-api/public-api.e2e.db.test.ts
index 704c81b..c80c236 100644
--- a/apps/api/src/public-api/public-api.e2e.db.test.ts
+++ b/apps/api/src/public-api/public-api.e2e.db.test.ts
@@ -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()
diff --git a/apps/api/src/public-api/public-api.service.ts b/apps/api/src/public-api/public-api.service.ts
index d4a0fce..03dff78 100644
--- a/apps/api/src/public-api/public-api.service.ts
+++ b/apps/api/src/public-api/public-api.service.ts
@@ -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 ?? '',
diff --git a/apps/api/src/public/feed.e2e.db.test.ts b/apps/api/src/public/feed.e2e.db.test.ts
index cf563ae..5d6ed1a 100644
--- a/apps/api/src/public/feed.e2e.db.test.ts
+++ b/apps/api/src/public/feed.e2e.db.test.ts
@@ -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(
+ '',
+ );
+ // …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('
Newer Page — Feed Pond');
diff --git a/apps/api/src/public/feed.service.ts b/apps/api/src/public/feed.service.ts
index 6a82006..583d110 100644
--- a/apps/api/src/public/feed.service.ts
+++ b/apps/api/src/public/feed.service.ts
@@ -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 `` 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 `` 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 `` (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}\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: {
` ${escapeHtml(entry.title)}\n` +
` \n` +
` ${entry.updated.toISOString()}\n` +
+ categoryTag(entry.classification, ' ') +
(entry.summary ? ` ${escapeHtml(entry.summary)}\n` : '') +
` `,
)
@@ -143,6 +174,7 @@ function atomDocument(feed: {
` ${escapeHtml(feed.id)}\n` +
` ${escapeHtml(feed.title)}\n` +
` \n` +
+ categoryTag(highest === 'unclassified' ? undefined : highest, ' ') +
` ${updated.toISOString()}\n` +
`${entries}\n` +
`\n`
diff --git a/apps/api/src/public/html-shell.ts b/apps/api/src/public/html-shell.ts
index 84be3e7..25cf372 100644
--- a/apps/api/src/public/html-shell.ts
+++ b/apps/api/src/public/html-shell.ts
@@ -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; }
diff --git a/apps/api/src/public/public.e2e.db.test.ts b/apps/api/src/public/public.e2e.db.test.ts
index def1f8f..371d507 100644
--- a/apps/api/src/public/public.e2e.db.test.ts
+++ b/apps/api/src/public/public.e2e.db.test.ts
@@ -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(`
${marking}
`).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);
diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts
index e1aea15..ca1d35c 100644
--- a/apps/api/src/public/public.service.ts
+++ b/apps/api/src/public/public.service.ts
@@ -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 {
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 ? `
${escapeHtml(marking)}
\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: `
${escapeHtml(content.pondName)}
+ bodyHtml: `${banner}
${escapeHtml(content.pondName)}
${escapeHtml(content.title)}
-${content.html}`,
+${content.html}
+${banner}`,
});
}
}
diff --git a/apps/api/src/search/postgres-search.provider.ts b/apps/api/src/search/postgres-search.provider.ts
index b6a4de3..db54c90 100644
--- a/apps/api/src/search/postgres-search.provider.ts
+++ b/apps/api/src/search/postgres-search.provider.ts
@@ -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(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,
}));
}
}
diff --git a/apps/api/src/search/search.provider.test.ts b/apps/api/src/search/search.provider.test.ts
index dd55b23..cf19023 100644
--- a/apps/api/src/search/search.provider.test.ts
+++ b/apps/api/src/search/search.provider.test.ts
@@ -23,6 +23,7 @@ describe('SearchProvider DI seam (issue #49)', () => {
pondSlug: 'pond',
pondName: 'Pond',
labelIds: [],
+ classification: 'unclassified',
snippet: 'a snippet',
};
const fake: SearchProvider = {
diff --git a/apps/api/src/search/search.service.db.test.ts b/apps/api/src/search/search.service.db.test.ts
index 7e73ebd..1f45c5d 100644
--- a/apps/api/src/search/search.service.db.test.ts
+++ b/apps/api/src/search/search.service.db.test.ts
@@ -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));
diff --git a/apps/web/src/search/SearchPalette.tsx b/apps/web/src/search/SearchPalette.tsx
index c31e312..56f8b08 100644
--- a/apps/web/src/search/SearchPalette.tsx
+++ b/apps/web/src/search/SearchPalette.tsx
@@ -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(null);
@@ -220,6 +222,14 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E
onClick={() => open(hit)}
>
{hit.title}
+ {/* A hit on a classified page is never shown unmarked
+ (issue #211, ADR 0022) — fixed wording, not localized. */}
+ {classificationMarking(hit.classification) && (
+
+ {tCommon('classification.label')}:
+ {classificationMarking(hit.classification)}
+
+ )}
{t('inPond', { pond: hit.pondName })}
diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css
index 7ee68f2..1e5fadc 100644
--- a/apps/web/src/styles/base.css
+++ b/apps/web/src/styles/base.css
@@ -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;
diff --git a/docs/self-hosting/public-api.md b/docs/self-hosting/public-api.md
index 51c2087..191d753 100644
--- a/docs/self-hosting/public-api.md
+++ b/docs/self-hosting/public-api.md
@@ -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 ``
+element on both levels:
+
+```xml
+
+```
+
+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` —
diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md
index 9b36183..bd929b7 100644
--- a/docs/vs-nfd/20-massnahmenplan.md
+++ b/docs/vs-nfd/20-massnahmenplan.md
@@ -57,7 +57,7 @@ _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) · 2–3 AT · #209
- [x] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210
- - [ ] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211
+ - [x] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211
- [ ] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212
- [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213
diff --git a/packages/shared/src/public-api.ts b/packages/shared/src/public-api.ts
index ffea41d..493ac53 100644
--- a/packages/shared/src/public-api.ts
+++ b/packages/shared/src/public-api.ts
@@ -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;
diff --git a/packages/shared/src/search.ts b/packages/shared/src/search.ts
index 60845ed..0fca7e5 100644
--- a/packages/shared/src/search.ts
+++ b/packages/shared/src/search.ts
@@ -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;
}