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; }