openvibers/openvibe.blog Commits

API: publish OpenAPI 3.1 at /openapi.json (#118)

OP OpenVibers committed 15 days ago · c841c92ac5 · 4 files, +401 −0

The document is built from the route table in server/http/openapi.js, which lists
every method on every /api/v1 path the router mounts (blogs, posts, revisions,
members, media, the JSON Feed and the network changelog);
test/openapi.test.js walks the mounted router stack and fails when the table and
the router differ in either direction, so the document cannot advertise an address
the service does not answer.

Served as application/json; charset=utf-8, Cache-Control public, max-age=300, no
auth; /api/v1/openapi.json answers 308 to it. llms.txt gains an API section
pointing at it. Request fields are the handlers' own reads (domain/posts.js
validates them), response properties the DTOs' output.

Co-authored-by: CommandCodeBot <[email protected]>

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 6c9b154..9c5d7fd 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -209,6 +209,9 @@ async function createApp(opts = {}) {
}));
// ── API ─────────────────────────────────────────────────
++ // GET /openapi.json: the API document, generated from the table the /api/v1 router mounts (http/openapi.js).
++ // Mounted before the API router, whose last layer answers 404 for anything it does not serve.
++ require('./http/openapi').mount(app);
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
// ── Discovery, editor, public pages ─────────────────────
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 47ce9b0..0b31321 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -90,6 +90,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
{ title: 'Member blogs', url: abs('/sitemaps/blogs.xml'), note: 'each member blog has /@handle/feed.xml, /@handle/atom.xml and /@handle/feed.json' },
] },
{ title: 'Data', links: [{ title: 'Post JSON', url: abs('/'), note: 'append .json to any post URL (/@handle/slug.json)' }] },
++ { title: 'API', links: [{ title: 'OpenAPI 3.1 document', url: abs('/openapi.json'), note: 'every /api/v1 route, with its fields' }] },
{ title: 'AI desks', links: [{ title: 'AI desks', url: abs('/desks'), note: 'digests OpenVibe.AI writes from OpenVibe.Sources items (news, food recalls, deals, hosting plans, developer releases) and a satire column; labelled as AI, every digest section cited, never indexed; each desk has /desks/<desk>/feed.xml and each post a .json twin' }] },
],
}));
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
nnew file mode 100644
iindex 0000000..cf9c352
---- /dev/null
++++ b/server/http/openapi.js
@@@ -0,0 +1,308 @@
++'use strict';
++/**
++ * OpenAPI 3.1 for OpenVibe.Blog's JSON API, from the route table below.
++ *
++ * ROUTES is the table: every entry is one method on one path under /api/v1, with the operation the
++ * router serves. server/http/api.js mounts exactly these, and test/openapi.test.js walks the mounted
++ * router and fails when the two differ in either direction — so the document can never advertise an
++ * address this service does not answer.
++ *
++ * GET /openapi.json this document (public, Cache-Control 5 minutes)
++ * GET /api/v1/openapi.json 308 to /openapi.json
++ *
++ * Request fields come from what the handlers read out of the body (http/api.js) and what the domain
++ * validates (domain/posts.js, domain/blogs.js); response properties are the DTOs' output.
++ */
++const pkg = require('../../package.json');
++
++const MOUNT = '/api/v1';
++const SITE = 'https://openvibe.blog';
++
++const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
++const str = (extra = {}) => ({ type: 'string', ...extra });
++const nullable = (type, extra = {}) => ({ type: [type, 'null'], ...extra });
++const arrayOf = (items) => ({ type: 'array', items });
++
++const problem = {
++ description: 'A problem (RFC 9457, application/problem+json): type, title, status and a stable code',
++ content: {
++ 'application/problem+json': {
++ schema: {
++ type: 'object',
++ required: ['type', 'title', 'status'],
++ properties: { type: str(), title: str(), status: { type: 'integer' }, code: str(), detail: str(), request_id: str() },
++ },
++ },
++ },
++};
++const errors = (codes = [400, 401, 403, 404, 409, 413, 422, 428, 429]) => ({
++ ...Object.fromEntries(codes.map((code) => [code, problem])),
++ default: problem,
++});
++
++const path = (name, schema, description) => ({ name, in: 'path', required: true, schema, ...(description && { description }) });
++const query = (name, schema, description) => ({ name, in: 'query', required: false, schema, ...(description && { description }) });
++
++const HANDLE = path('handle', str({ pattern: '^[a-z0-9-]{2,40}$' }), 'The blog handle');
++const POST = path('id', { type: 'string', pattern: '^pst_[0-9A-HJKMNP-TV-Z]{26}$' }, 'The post id');
++const REVISION = path('n', { type: 'integer', minimum: 1 }, 'The revision number');
++const SUBJECT = path('subject', { type: 'string', pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }, 'The person (usr_ id)');
++const ATTACHMENT = path('aid', str(), 'The attachment id');
++
++const ANONYMOUS = [{}];
++const PERSON = [{ bearer: [] }, { cookie: [] }];
++
++const json = (description, schema) => ({ description, ...(schema ? { content: { 'application/json': { schema } } } : {}) });
++const body = (schema, required = true) => ({ required, content: { 'application/json': { schema } } });
++
++const citation = { type: 'object', properties: {
++ url: nullable('string'), title: nullable('string'), retrieved_at: nullable('string', { format: 'date-time' }),
++ quote: nullable('string'), license_note: nullable('string'),
++} };
++const visibility = { type: 'string', enum: ['public', 'unlisted', 'members', 'private'], default: 'public' };
++
++const PATHS = {};
++function route(method, p, operation) {
++ (PATHS[p] = PATHS[p] || {})[method] = operation;
++}
++
++route('get', '/blogs/{handle}', {
++ operationId: 'getBlog', summary: 'One blog', tags: ['blogs'], security: ANONYMOUS, parameters: [HANDLE],
++ description: 'A member of the blog (or Network staff) also sees its status, owner and feed settings.',
++ responses: { 200: json('The blog', { type: 'object', required: ['blog'], properties: { blog: ref('Blog') } }), ...errors([400, 404, 429]) },
++});
++route('post', '/blogs', {
++ operationId: 'ensureBlog', summary: 'Get or create the acting member\'s blog', tags: ['blogs'], security: PERSON,
++ description: 'blog.blog.create. Idempotent: 201 when it created the blog, 200 when it already existed.',
++ requestBody: body({ type: 'object', properties: { handle: str({ pattern: '^[a-z0-9-]{2,40}$' }), title: str({ maxLength: 200 }), description: str({ maxLength: 1000 }) } }, false),
++ responses: { 200: json('The existing blog', { type: 'object', properties: { blog: ref('Blog'), created: { const: false } } }),
++ 201: json('The new blog', { type: 'object', properties: { blog: ref('Blog'), created: { const: true } } }), ...errors() },
++});
++route('patch', '/blogs/{handle}', {
++ operationId: 'configureBlog', summary: 'Change a blog\'s title, description, language or feeds', tags: ['blogs'], security: PERSON, parameters: [HANDLE],
++ description: 'blog.blog.configure. Owners only.',
++ requestBody: body({ type: 'object', properties: {
++ title: str({ maxLength: 200 }), description: str({ maxLength: 1000 }), language: str(),
++ feeds: { type: 'object', properties: { rss: { type: 'boolean' }, atom: { type: 'boolean' }, json: { type: 'boolean' }, item_count: { type: 'integer', minimum: 1 }, full_content: { type: 'boolean' } } },
++ } }),
++ responses: { 200: json('The blog', { type: 'object', properties: { blog: ref('Blog') } }), ...errors() },
++});
++route('put', '/blogs/{handle}/theme', {
++ operationId: 'setBlogTheme', summary: 'Set the blog\'s theme preset', tags: ['blogs'], security: PERSON, parameters: [HANDLE],
++ description: 'blog.theme.set.',
++ requestBody: body({ type: 'object', required: ['theme'], properties: { theme: { type: 'string', enum: ['vibe', 'paper', 'slate', 'sand', 'nord', 'high-contrast'] } } }),
++ responses: { 200: json('The blog and every preset', { type: 'object', properties: { blog: ref('Blog'), themes: arrayOf(str()) } }), ...errors() },
++});
++route('get', '/blogs/{handle}/members', {
++ operationId: 'listBlogMembers', summary: 'A blog\'s members', tags: ['members'], security: PERSON, parameters: [HANDLE],
++ description: 'blog.member.manage. Owners only.',
++ responses: { 200: json('The members', { type: 'object', properties: { members: arrayOf(ref('Member')) } }), ...errors([400, 401, 403, 404, 429]) },
++});
++route('put', '/blogs/{handle}/members/{subject}', {
++ operationId: 'setBlogMember', summary: 'Set a person\'s role on a blog', tags: ['members'], security: PERSON, parameters: [HANDLE, SUBJECT],
++ description: 'blog.member.manage. Owners only.',
++ requestBody: body({ type: 'object', required: ['role'], properties: { role: { type: ['string', 'null'], enum: ['owner', 'editor', 'author', null] } } }),
++ responses: { 200: json('The member', { type: 'object', properties: { member: ref('Member') } }), ...errors() },
++});
++route('delete', '/blogs/{handle}/members/{subject}', {
++ operationId: 'removeBlogMember', summary: 'Remove a person from a blog', tags: ['members'], security: PERSON, parameters: [HANDLE, SUBJECT],
++ description: 'blog.member.manage. Owners only.',
++ responses: { 200: json('Whether a role was removed', { type: 'object', properties: { removed: { type: 'boolean' } } }), ...errors() },
++});
++route('get', '/blogs/{handle}/posts', {
++ operationId: 'listBlogPosts', summary: 'A blog\'s posts (published, or every draft with ?all=1 for a member)', tags: ['posts'], security: ANONYMOUS, parameters: [HANDLE,
++ query('limit', { type: 'integer', minimum: 1, maximum: 100, default: 20 }), query('offset', { type: 'integer', minimum: 0, maximum: 100000 }),
++ query('all', { type: 'string', enum: ['1'] }, 'Every state: members only, and a service token needs blog.post.read')],
++ responses: { 200: json('Posts, newest first', { type: 'object', properties: { total: { type: 'integer' }, posts: arrayOf(ref('Post')) } }), ...errors([400, 403, 404, 429]) },
++});
++route('get', '/changelog', {
++ operationId: 'getChangelog', summary: 'What shipped on every OpenVibe site, newest first', tags: ['changelog'], security: ANONYMOUS, parameters: [
++ query('service', str({ pattern: '^[a-z][a-z0-9-]{1,39}$' }), 'Only this service\'s entries (default: every site)'),
++ query('limit', { type: 'integer', minimum: 1 }), query('before', str(), 'The cursor from the previous page')],
++ description: 'Public and CORS *, cached 60 seconds: each site\'s "recently shipped" reads it.',
++ responses: { 200: json('Entries, the cursor for the next page and the latest patch notes post', { type: 'object', properties: {
++ service: nullable('string'), entries: arrayOf({ type: 'object' }), next: nullable('string'),
++ latest_post: nullable('object'), posts: arrayOf({ type: 'object' }), sites: arrayOf({ type: 'object' }),
++ } }), ...errors([400, 429]) },
++});
++route('post', '/blogs/{handle}/posts/ai-draft', {
++ operationId: 'draftPostWithAi', summary: 'Have OpenVibe.AI write a draft', tags: ['posts'], security: PERSON, parameters: [HANDLE],
++ description: 'blog.post.create. The draft is AI-authored and cannot publish until a person reviews it; 503 when AI drafting is not configured here.',
++ requestBody: body({ type: 'object', required: ['topic'], properties: {
++ topic: str({ maxLength: 300 }), brief: str({ maxLength: 4000 }), tone: str({ maxLength: 200 }), audience: str({ maxLength: 300 }),
++ } }),
++ responses: { 201: json('The draft and its revision number', { type: 'object', properties: { post: ref('Post'), revision: { type: 'integer' } } }), 503: problem, ...errors() },
++});
++route('post', '/blogs/{handle}/posts', {
++ operationId: 'createPost', summary: 'Create a post (a draft)', tags: ['posts'], security: PERSON, parameters: [HANDLE],
++ description: 'blog.post.create. X-OV-Origin: ai marks the revision AI-authored (it then needs a review before publishing).',
++ requestBody: body({ type: 'object', required: ['title'], properties: {
++ title: str({ maxLength: 200 }), body: str({ maxLength: 200000, description: 'Markdown' }), summary: str({ maxLength: 500 }),
++ slug: str({ pattern: '^[a-z0-9][a-z0-9-]{0,79}$' }), visibility,
++ entitlement_key: str({ pattern: '^[a-z][a-z0-9_.:-]{0,127}$', description: 'Only for visibility members' }),
++ tags: arrayOf(str({ maxLength: 50 })), categories: arrayOf(str()), series: nullable('string'), series_position: nullable('integer', { minimum: 1 }),
++ allow_comments: { type: 'boolean' }, noindex: { type: 'boolean' }, citations: arrayOf(citation), message: str(),
++ } }),
++ responses: { 201: json('The post and its revision number', { type: 'object', properties: { post: ref('Post'), revision: { type: 'integer' } } }), ...errors() },
++});
++route('get', '/blogs/{handle}/feed', {
++ operationId: 'getBlogFeed', summary: 'The blog\'s JSON Feed', tags: ['feeds'], security: ANONYMOUS, parameters: [HANDLE],
++ description: 'blog.feed.read. Public posts only, per the blog\'s feed settings; JSON Feed 1.1.',
++ responses: { 200: { description: 'The feed (application/feed+json)', content: { 'application/feed+json': { schema: { type: 'object' } } } }, ...errors([404, 429]) },
++});
++route('get', '/posts/{id}', {
++ operationId: 'getPost', summary: 'One post', tags: ['posts'], security: ANONYMOUS, parameters: [POST],
++ description: 'blog.post.read. A member sees the full post; a reader of a members-only post gets 403 with a teaser.',
++ responses: { 200: json('The post', { type: 'object', properties: { post: ref('Post') } }), 403: problem, ...errors([400, 404, 429]) },
++});
++route('patch', '/posts/{id}', {
++ operationId: 'updatePost', summary: 'Edit a post: a new revision, or the row (slug, visibility, terms, flags)', tags: ['posts'], security: PERSON, parameters: [POST],
++ description: 'blog.post.update. Any content change needs expected_revision (428 without it; 412 when it is stale).',
++ requestBody: body({ type: 'object', properties: {
++ expected_revision: { type: 'integer', minimum: 0 }, title: str({ maxLength: 200 }), body: str({ maxLength: 200000 }), summary: str({ maxLength: 500 }),
++ slug: str({ pattern: '^[a-z0-9][a-z0-9-]{0,79}$' }), visibility, entitlement_key: str(),
++ tags: arrayOf(str()), categories: arrayOf(str()), series: nullable('string'), series_position: nullable('integer'),
++ allow_comments: { type: 'boolean' }, noindex: { type: 'boolean' }, citations: arrayOf(citation), message: str(),
++ } }),
++ responses: { 200: json('The post, the revision and whether one was created', { type: 'object', properties: { post: ref('Post'), revision: { type: 'integer' }, created: { type: 'boolean' } } }), ...errors([412]) },
++});
++route('post', '/posts/{id}/publish', {
++ operationId: 'publishPost', summary: 'Publish a post (its head, or a named revision)', tags: ['posts'], security: PERSON, parameters: [POST],
++ description: 'blog.post.publish. An AI-authored revision needs a person\'s review first (409 post.review_required).',
++ requestBody: body({ type: 'object', properties: { revision: { type: 'integer', minimum: 1 } } }, false),
++ responses: { 200: json('The post and whether this call changed it', { type: 'object', properties: { post: ref('Post'), changed: { type: 'boolean' } } }), ...errors() },
++});
++route('post', '/posts/{id}/schedule', {
++ operationId: 'schedulePost', summary: 'Schedule a publish or an unpublish', tags: ['posts'], security: PERSON, parameters: [POST],
++ description: 'blog.post.schedule. Idempotent: the same (post, action, time, revision) is one job.',
++ requestBody: body({ type: 'object', required: ['at'], properties: {
++ at: str({ format: 'date-time' }), revision: { type: 'integer', minimum: 1 }, action: { type: 'string', enum: ['publish', 'unpublish'], default: 'publish' },
++ } }),
++ responses: { 201: json('The job and the post', { type: 'object', properties: { job: { type: 'object' }, created: { type: 'boolean' }, post: ref('Post') } }), ...errors() },
++});
++route('delete', '/posts/{id}/schedule', {
++ operationId: 'cancelScheduledPost', summary: 'Cancel a post\'s pending schedule', tags: ['posts'], security: PERSON, parameters: [POST],
++ description: 'blog.post.schedule.',
++ responses: { 200: json('Whether a job was cancelled', { type: 'object', properties: { cancelled: { type: 'boolean' }, post: ref('Post') } }), ...errors() },
++});
++route('post', '/posts/{id}/unpublish', {
++ operationId: 'unpublishPost', summary: 'Unpublish a post', tags: ['posts'], security: PERSON, parameters: [POST],
++ description: 'blog.post.unpublish.',
++ responses: { 200: json('The post and whether this call changed it', { type: 'object', properties: { post: ref('Post'), changed: { type: 'boolean' } } }), ...errors() },
++});
++route('delete', '/posts/{id}', {
++ operationId: 'deletePost', summary: 'Delete a post', tags: ['posts'], security: PERSON, parameters: [POST],
++ description: 'blog.post.delete.',
++ responses: { 200: json('What was deleted', { type: 'object', properties: { deleted: { const: true }, id: str() } }), ...errors() },
++});
++route('post', '/posts/{id}/revert', {
++ operationId: 'revertPost', summary: 'Revert a post to an earlier revision', tags: ['revisions'], security: PERSON, parameters: [POST],
++ description: 'blog.post.update. The revert is a new revision, not a rewrite of history.',
++ requestBody: body({ type: 'object', required: ['to_revision', 'expected_revision'], properties: {
++ to_revision: { type: 'integer', minimum: 1 }, expected_revision: { type: 'integer', minimum: 0 },
++ } }),
++ responses: { 201: json('The revert revision and the post', { type: 'object', properties: { revision: { type: 'integer' }, post: ref('Post') } }), ...errors() },
++});
++route('post', '/posts/{id}/reviews', {
++ operationId: 'reviewPostRevision', summary: 'Approve or reject an AI-authored revision', tags: ['revisions'], security: PERSON, parameters: [POST],
++ description: 'People only. An approved revision may then publish.',
++ requestBody: body({ type: 'object', required: ['revision', 'decision'], properties: {
++ revision: { type: 'integer', minimum: 1 }, decision: { type: 'string', enum: ['approved', 'rejected'] }, note: nullable('string'),
++ } }),
++ responses: { 201: json('The review', { type: 'object', properties: { review: { type: 'object' } } }), ...errors() },
++});
++route('post', '/posts/{id}/attachments', {
++ operationId: 'attachMedia', summary: 'Attach an OpenVibe.Media object to a post', tags: ['media'], security: PERSON, parameters: [POST],
++ description: 'blog.post.update. Attaching the same object twice updates its role, alt, caption and position instead.',
++ requestBody: body({ type: 'object', required: ['media_id'], properties: {
++ media_id: str(), role: { type: 'string', enum: ['cover', 'inline', 'gallery'], default: 'inline' },
++ alt: nullable('string'), caption: nullable('string'), position: { type: 'integer', minimum: 0, default: 0 },
++ } }),
++ responses: { 201: json('The attachment', { type: 'object', properties: { attachment: { type: 'object' } } }), ...errors() },
++});
++route('delete', '/posts/{id}/attachments/{aid}', {
++ operationId: 'detachMedia', summary: 'Remove an attachment from a post', tags: ['media'], security: PERSON, parameters: [POST, ATTACHMENT],
++ description: 'blog.post.update.',
++ responses: { 200: json('Whether an attachment was removed', { type: 'object', properties: { removed: { type: 'boolean' } } }), ...errors() },
++});
++route('get', '/posts/{id}/revisions', {
++ operationId: 'listRevisions', summary: 'A post\'s revision history (no bodies)', tags: ['revisions'], security: PERSON, parameters: [POST,
++ query('limit', { type: 'integer', minimum: 1 }), query('before', { type: 'integer', minimum: 1 }, 'The cursor: the newest revision to return')],
++ description: 'blog.post.read, and writers or staff only.',
++ responses: { 200: json('Revisions with their content length', { type: 'object', properties: { revisions: arrayOf({ type: 'object' }) } }), ...errors([400, 401, 403, 404, 429]) },
++});
++route('get', '/posts/{id}/revisions/{n}', {
++ operationId: 'getRevision', summary: 'One revision of a post, with its citations', tags: ['revisions'], security: PERSON, parameters: [POST, REVISION],
++ description: 'blog.post.read, and writers or staff only.',
++ responses: { 200: json('The revision and its citations', { type: 'object', properties: { revision: { type: 'object' }, citations: arrayOf(citation) } }), ...errors([400, 401, 403, 404, 429]) },
++});
++route('get', '/posts/{id}/diff', {
++ operationId: 'diffRevisions', summary: 'The diff between two revisions', tags: ['revisions'], security: PERSON, parameters: [POST,
++ query('from', { type: 'integer', minimum: 1 }, 'Required'), query('to', { type: 'integer', minimum: 1 }, 'Required'),
++ query('mode', { type: 'string', enum: ['word', 'line'] })],
++ description: 'blog.post.read, and writers or staff only.',
++ responses: { 200: json('{ diff }', { type: 'object', properties: { diff: { type: 'object' } } }), ...errors([400, 401, 403, 404, 429]) },
++});
++
++function openapi() {
++ return {
++ openapi: '3.1.0',
++ info: {
++ title: 'OpenVibe.Blog API',
++ version: pkg.version,
++ description: pkg.description,
++ license: { name: 'AGPL-3.0-only', identifier: 'AGPL-3.0-only' },
++ contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.Blog', email: '[email protected]' },
++ },
++ servers: [{ url: SITE }],
++ tags: ['blogs', 'posts', 'revisions', 'members', 'media', 'feeds', 'changelog'].map((name) => ({ name })),
++ components: {
++ securitySchemes: {
++ bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network user JWT (people) or a service token for audience openvibe.blog carrying the route\'s capability, acting for the person named in X-OV-Subject' },
++ cookie: { type: 'apiKey', in: 'cookie', name: 'ov_token', description: 'The browser session: the same Network user JWT as a host-only cookie' },
++ },
++ schemas: {
++ Blog: { type: 'object', required: ['id', 'handle', 'title'], properties: {
++ id: str({ pattern: '^blg_' }), kind: str(), handle: str(), title: str(), description: nullable('string'), url: str(),
++ theme: str(), language: str(), feeds: arrayOf({ type: 'object', properties: { type: str(), url: str() } }),
++ status: str({ description: 'Active blogs only are readable; members also see it' }),
++ owner_subject: nullable('string'), feed_settings: { type: 'object' },
++ } },
++ Post: { type: 'object', required: ['id', 'blog', 'slug', 'state'], properties: {
++ id: str({ pattern: '^pst_' }), blog: { type: 'object', properties: { id: str(), handle: str() } }, slug: str(), url: str(),
++ state: { type: 'string', enum: ['draft', 'scheduled', 'published', 'unpublished', 'deleted'] },
++ visibility: { type: 'string', enum: ['public', 'unlisted', 'members', 'private'] },
++ author_subject: nullable('string'), title: nullable('string'), summary: nullable('string'),
++ revision: nullable('integer'), published_revision: nullable('integer'),
++ first_published_at: nullable('string', { format: 'date-time' }), published_at: nullable('string', { format: 'date-time' }),
++ tags: arrayOf(str()), categories: arrayOf(str()),
++ series: nullable('object', { properties: { id: str(), slug: str(), title: str(), position: nullable('integer') } }),
++ allow_comments: { type: 'boolean' }, noindex: { type: 'boolean' },
++ indexability: nullable('object', { properties: { indexable: { type: 'boolean' }, robots: nullable('string'), reasons: arrayOf(str()) } }),
++ entitlement_key: nullable('string'), body: nullable('string'), authorship: nullable('object'),
++ review: nullable('object'), scheduled: arrayOf({ type: 'object' }), attachments: arrayOf({ type: 'object' }),
++ citations: arrayOf(citation),
++ } },
++ Member: { type: 'object', properties: { subject: str(), role: str(), name: nullable('string'), username: nullable('string') } },
++ },
++ },
++ paths: { ...Object.fromEntries(Object.entries(PATHS).map(([p, item]) => [MOUNT + p, item])) },
++ };
++}
++
++/** The documented routes as "METHOD /api/v1/path": what the router must mount. */
++function routes() {
++ return Object.entries(PATHS).flatMap(([p, item]) => Object.keys(item).map((method) => `${method.toUpperCase()} ${MOUNT}${p}`)).sort();
++}
++
++/** Serve GET /openapi.json here; GET /api/v1/openapi.json redirects to it (308). */
++function mount(app, document = openapi()) {
++ const send = (_req, res) => {
++ res.set('Cache-Control', 'public, max-age=300');
++ res.type('application/json; charset=utf-8').send(JSON.stringify(document));
++ };
++ app.get('/openapi.json', send);
++ app.get('/api/v1/openapi.json', (_req, res) => res.redirect(308, '/openapi.json'));
++}
++
++module.exports = { openapi, routes, mount, MOUNT };
ddiff --git a/test/openapi.test.js b/test/openapi.test.js
nnew file mode 100644
iindex 0000000..0890d6f
---- /dev/null
++++ b/test/openapi.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++/**
++ * /openapi.json documents this service's API, and the document is the API: every route it lists is a
++ * route the app mounts, and every /api/v1 route the app mounts is listed. The test walks the real
++ * router stack (the same arrays server/http/api.js mounts) rather than trusting the table in
++ * server/http/openapi.js, so a route added, renamed or removed without the document fails here.
++ *
++ * node test/openapi.test.js
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { routes, MOUNT } = require('../server/http/openapi');
++
++/** The path an app.use(...) layer mounts its router at, decoded from Express's compiled regexp. */
++function mountOf(layer) {
++ const source = String((layer.regexp && layer.regexp.source) || '');
++ if (layer.regexp && layer.regexp.fast_slash) return '';
++ const m = /^\^(.*?)\\\/\?\(\?=\\\/\|\$\)$/.exec(source);
++ assert.ok(m, `cannot read the mount path of a router layer (${source})`);
++ return m[1].replace(/\\\//g, '/').replace(/\\\./g, '.');
++}
++
++/** ":param" as OpenAPI writes it, "{param}". */
++const templated = (p) => String(p).replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
++
++/** Every "METHOD /path" the app answers, routers included. */
++function mounted(app) {
++ const out = new Set();
++ const walk = (stack, prefix) => {
++ for (const layer of stack) {
++ if (layer.route) {
++ for (const [method, on] of Object.entries(layer.route.methods)) {
++ if (!on || method === '_all') continue;
++ for (const p of [].concat(layer.route.path)) out.add(`${method.toUpperCase()} ${prefix}${templated(p)}`);
++ }
++ } else if (layer.name === 'router' && layer.handle && layer.handle.stack) {
++ walk(layer.handle.stack, prefix + mountOf(layer));
++ }
++ }
++ };
++ assert.ok(app._router && app._router.stack, 'the app has a router');
++ walk(app._router.stack, '');
++ return out;
++}
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('/openapi.json is served as a cached JSON document', async () => {
++ const r = await t.get('/openapi.json');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('content-type'), 'application/json; charset=utf-8');
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');
++ const doc = r.json();
++ assert.strictEqual(doc.openapi, '3.1.0');
++ assert.ok(doc.paths && typeof doc.paths === 'object');
++ assert.ok(Object.keys(doc.paths).length > 0, 'paths is not empty');
++ assert.strictEqual(doc.info.title, 'OpenVibe.Blog API');
++ assert.deepStrictEqual(doc.servers, [{ url: 'https://openvibe.blog' }]);
++ });
++
++ await check('every documented route is mounted', async () => {
++ const doc = (await t.get('/openapi.json')).json();
++ const live = mounted(t.app);
++ for (const key of Object.keys(doc.paths)) assert.ok(key.startsWith('/api/'), `${key} is outside /api`);
++ for (const [p, item] of Object.entries(doc.paths)) {
++ for (const [method, op] of Object.entries(item)) {
++ assert.ok(op.operationId, `${method.toUpperCase()} ${p} has an operationId`);
++ assert.ok(op.responses && Object.keys(op.responses).length, `${method.toUpperCase()} ${p} describes its responses`);
++ assert.ok(live.has(`${method.toUpperCase()} ${p}`), `${method.toUpperCase()} ${p} is documented but not mounted`);
++ }
++ }
++ });
++
++ await check('every mounted /api/v1 route is documented', async () => {
++ const live = [...mounted(t.app)].filter((key) => key.split(' ')[1].startsWith(`${MOUNT}/`) && !key.endsWith(`${MOUNT}/openapi.json`)).sort();
++ assert.deepStrictEqual(live, routes(), 'the document lists exactly the mounted /api/v1 routes');
++ });
++
++ await check('/api/v1/openapi.json redirects to it', async () => {
++ const r = await t.get(`/api/v1/openapi.json`);
++ assert.strictEqual(r.status, 308);
++ assert.strictEqual(r.headers.get('location'), '/openapi.json');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exitCode = 1; });