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
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>
452 lines
16 KiB
TypeScript
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')) },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|