dorfteich/apps/api/src/public-api/openapi.ts
Claude Fable 5 521ea514b4
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m38s
CI / Build container images (pull_request) Successful in 4m14s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m6s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#211: classification through feeds, public API, search and the no-JS shell
Feeds: classified entries carry a standard Atom <category>
(term=level, scheme=urn:dorfteich:classification, label=the fixed
wording); the feed document states the highest contained level once;
all-open feeds carry none. Public API: page representations (list+get)
gain the classification field, OpenAPI + public-api.md documented.
Search: every hit carries the level and the palette renders the marking
with the snippet (compact form of the banner, text token only). No-JS
shell: banner above and below the content, own markup for the separate
render path; unclassified pages unchanged everywhere. One test per
channel (feed categories + count, public API list/get with the switch
on, search hit levels, shell top+bottom).

Also: fidelity CI sidecars get per-job container names — the fixed
names collided across parallel runs on the shared host (run 547's red
fidelity job; a fixed-name cleanup could even kill a sibling's live
sidecars).

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:23:53 +02:00

452 lines
16 KiB
TypeScript

/**
* The OpenAPI 3.1 description of the public REST API (issue #104),
* maintained by hand next to the controller — the api has no swagger
* tooling, and this surface is small and versioned. A test walks the
* controller's routes and asserts each one appears here, so the document
* cannot silently drift from the implementation.
*/
const bearerSecurity = [{ pat: [] }] as const;
const pondParam = {
name: 'pondSlug',
in: 'path',
required: true,
schema: { type: 'string' },
description: 'Pond slug (the pond must have opted into the API).',
} as const;
const pageParam = {
name: 'pageSlug',
in: 'path',
required: true,
schema: { type: 'string' },
} as const;
const labelParam = {
name: 'labelId',
in: 'path',
required: true,
schema: { type: 'string' },
} as const;
const commentParam = {
name: 'commentId',
in: 'path',
required: true,
schema: { type: 'string' },
} as const;
function jsonResponse(description: string, schema: object): object {
return { description, content: { 'application/json': { schema } } };
}
function jsonBody(schema: object): object {
return { required: true, content: { 'application/json': { schema } } };
}
const ref = (name: string): object => ({ $ref: `#/components/schemas/${name}` });
export function buildOpenApiDocument(): object {
return {
openapi: '3.1.0',
info: {
title: 'Dorfteich public API',
version: '1',
description:
'Token-authenticated access to a Dorfteich instance. A personal access token ' +
'(created under Settings) acts as its user: the normal permission model applies, ' +
'narrowed by the token scope (read/write) and an optional pond restriction. ' +
'Only ponds that opted into the API are visible; everything else answers 404. ' +
'Errors carry `{ code, message, details? }`; requests are rate-limited per token.',
},
servers: [{ url: '/api/public/v1' }],
security: [...bearerSecurity],
components: {
securitySchemes: {
pat: {
type: 'http',
scheme: 'bearer',
description: 'Personal access token (`dt_pat_…`) from Settings → API tokens.',
},
},
schemas: {
Me: {
type: 'object',
properties: {
user: {
type: 'object',
properties: {
id: { type: 'string' },
username: { type: 'string' },
displayName: { type: 'string' },
},
},
scope: { type: 'string', enum: ['read', 'write'] },
pondSlugs: { type: 'array', items: { type: 'string' } },
},
},
Pond: {
type: 'object',
properties: {
slug: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
type: { type: 'string', enum: ['personal', 'shared'] },
createdAt: { type: 'string', format: 'date-time' },
},
},
PageListItem: {
type: '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:
'Parent page slug in the page tree; null at the root or when the parent ' +
'is not readable for this token.',
},
labels: { type: 'array', items: { type: 'string' } },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
Page: {
type: 'object',
properties: {
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.',
},
markdown: { type: 'string' },
html: { type: 'string', description: 'Server-rendered, sanitized HTML.' },
labels: { type: 'array', items: { type: 'string' } },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
CreatePage: {
type: 'object',
required: ['title'],
properties: {
title: { type: 'string', maxLength: 200 },
markdown: { type: 'string', description: 'Initial content; empty allowed.' },
parent: {
type: 'string',
description: 'Parent page slug — nests the new page under it (max depth 6).',
},
},
},
UpdatePage: {
type: 'object',
minProperties: 1,
properties: {
title: { type: 'string', maxLength: 200 },
markdown: {
type: 'string',
description:
'Replaces the whole content. Applied through the collaborative document, ' +
'so open editors converge; the response may briefly lag the change.',
},
parent: {
type: ['string', 'null'],
description:
'Move the page in the tree: a page slug nests it (appended to the new ' +
'sibling group), null moves it to the top level. Moving a page into its ' +
'own subtree or past the depth limit is rejected (409).',
},
},
},
SearchResult: {
type: 'object',
properties: {
pondSlug: { type: 'string' },
pageSlug: { type: 'string' },
title: { type: 'string' },
snippet: { type: 'string', description: 'Matches wrapped in `**…**`.' },
},
},
Label: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
color: { type: 'string' },
parentId: { type: ['string', 'null'] },
},
},
LabelTreeNode: {
allOf: [
ref('Label'),
{
type: 'object',
properties: { children: { type: 'array', items: ref('LabelTreeNode') } },
},
],
},
CreateLabel: {
type: 'object',
required: ['name'],
properties: {
name: { type: 'string', maxLength: 60 },
color: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' },
parentId: { type: ['string', 'null'] },
},
},
UpdateLabel: {
type: 'object',
minProperties: 1,
description: 'Rename, recolour and/or move (parentId null = to the root).',
properties: {
name: { type: 'string', maxLength: 60 },
color: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' },
parentId: { type: ['string', 'null'] },
},
},
Comment: {
type: 'object',
properties: {
id: { type: 'string' },
parentId: { type: ['string', 'null'] },
body: { type: 'string', description: 'Raw Markdown.' },
html: { type: 'string', description: 'Rendered, sanitized HTML.' },
author: { type: ['object', 'null'] },
resolvedAt: { type: ['string', 'null'], format: 'date-time' },
createdAt: { type: 'string', format: 'date-time' },
},
},
CreateComment: {
type: 'object',
required: ['body'],
properties: {
body: { type: 'string', description: 'Markdown.' },
parentId: {
type: ['string', 'null'],
description: 'Reply target (a thread root id); absent = new thread.',
},
},
},
PageComments: {
type: 'object',
properties: {
threads: { type: 'array', items: { type: 'object' } },
openCount: { type: 'integer' },
resolvedCount: { type: 'integer' },
},
},
Error: {
type: 'object',
properties: {
code: { type: 'string' },
message: { type: 'string' },
details: { type: 'object' },
},
},
},
},
paths: {
'/me': {
get: {
summary:
"The token's user — id, username, display name — plus scope and pond " +
'restriction. The way to find your own user id (client smoke test).',
responses: { '200': jsonResponse('Token identity', ref('Me')) },
},
},
'/ponds': {
get: {
summary: 'API-enabled ponds visible to the token.',
responses: {
'200': jsonResponse('Ponds', { type: 'array', items: ref('Pond') }),
},
},
},
'/ponds/{pondSlug}': {
get: {
summary: 'Pond metadata.',
parameters: [pondParam],
responses: { '200': jsonResponse('Pond', ref('Pond')) },
},
},
'/ponds/{pondSlug}/pages': {
get: {
summary: 'Readable pages of the pond.',
parameters: [
pondParam,
{
name: 'createdSince',
in: 'query',
required: false,
schema: { type: 'string', format: 'date-time' },
description: 'Only pages created at or after this ISO 8601 instant (issue #148).',
},
{
name: 'updatedSince',
in: 'query',
required: false,
schema: { type: 'string', format: 'date-time' },
description: 'Only pages updated at or after this ISO 8601 instant (issue #148).',
},
],
responses: {
'200': jsonResponse('Pages', { type: 'array', items: ref('PageListItem') }),
},
},
post: {
summary: 'Create a page from title + Markdown (write scope).',
parameters: [pondParam],
requestBody: jsonBody(ref('CreatePage')),
responses: { '201': jsonResponse('Created page', ref('Page')) },
},
},
'/ponds/{pondSlug}/pages/{pageSlug}': {
get: {
summary: 'One page as Markdown + rendered HTML.',
parameters: [pondParam, pageParam],
responses: { '200': jsonResponse('Page', ref('Page')) },
},
patch: {
summary: 'Update title and/or replace content (write scope).',
parameters: [pondParam, pageParam],
requestBody: jsonBody(ref('UpdatePage')),
responses: { '200': jsonResponse('Updated page', ref('Page')) },
},
delete: {
summary: 'Move the page to the trash (write scope).',
parameters: [pondParam, pageParam],
responses: { '204': { description: 'Trashed' } },
},
},
'/search': {
get: {
summary: 'Permission-filtered full-text search across exposed ponds.',
parameters: [
{ name: 'q', in: 'query', required: true, schema: { type: 'string' } },
{
name: 'pond',
in: 'query',
schema: { type: 'string' },
description: 'Restrict to one pond (slug).',
},
{
name: 'label',
in: 'query',
schema: { type: 'string' },
description: 'Restrict to pages carrying this label id.',
},
],
responses: {
'200': jsonResponse('Hits', { type: 'array', items: ref('SearchResult') }),
},
},
},
'/ponds/{pondSlug}/export/markdown': {
get: {
summary: 'ZIP of the readable pages as Markdown files (plus media).',
parameters: [pondParam],
responses: {
'200': {
description: 'ZIP stream',
content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } },
},
},
},
},
'/ponds/{pondSlug}/labels': {
get: {
summary: 'The pond label tree.',
parameters: [pondParam],
responses: {
'200': jsonResponse('Labels', { type: 'array', items: ref('LabelTreeNode') }),
},
},
post: {
summary: 'Create a label (write scope, Pond Admin).',
parameters: [pondParam],
requestBody: jsonBody(ref('CreateLabel')),
responses: { '201': jsonResponse('Created label', ref('Label')) },
},
},
'/ponds/{pondSlug}/labels/{labelId}': {
patch: {
summary: 'Rename, recolour and/or move a label (write scope, Pond Admin).',
parameters: [pondParam, labelParam],
requestBody: jsonBody(ref('UpdateLabel')),
responses: { '200': jsonResponse('Updated label', ref('Label')) },
},
delete: {
summary: 'Delete an unused label (write scope, Pond Admin).',
parameters: [pondParam, labelParam],
responses: { '204': { description: 'Deleted' } },
},
},
'/ponds/{pondSlug}/pages/{pageSlug}/labels/{labelId}': {
put: {
summary: 'Assign a label to the page (write scope).',
parameters: [pondParam, pageParam, labelParam],
responses: {
'200': jsonResponse("The page's labels", { type: 'array', items: ref('Label') }),
},
},
delete: {
summary: 'Unassign a label from the page (write scope, idempotent).',
parameters: [pondParam, pageParam, labelParam],
responses: { '204': { description: 'Unassigned' } },
},
},
'/ponds/{pondSlug}/pages/{pageSlug}/comments': {
get: {
summary: 'Comment threads of the page.',
parameters: [
pondParam,
pageParam,
{
name: 'filter',
in: 'query',
schema: { type: 'string', enum: ['all', 'open', 'resolved'] },
},
],
responses: { '200': jsonResponse('Threads', ref('PageComments')) },
},
post: {
summary: "Comment on the page (write scope; the pond's comment policy applies).",
parameters: [pondParam, pageParam],
requestBody: jsonBody(ref('CreateComment')),
responses: { '201': jsonResponse('Created comment', ref('Comment')) },
},
},
'/ponds/{pondSlug}/pages/{pageSlug}/comments/{commentId}/resolve': {
post: {
summary: 'Resolve a comment thread (write scope).',
parameters: [pondParam, pageParam, commentParam],
responses: { '201': jsonResponse('Resolved comment', ref('Comment')) },
},
delete: {
summary: 'Reopen a resolved thread (write scope).',
parameters: [pondParam, pageParam, commentParam],
responses: { '200': jsonResponse('Reopened comment', ref('Comment')) },
},
},
},
};
}