/** * 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')) }, }, }, }, }; }