Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions docs/superpowers/specs/2026-05-10-openapi-cleanup-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# OpenAPI cleanup for downstream tooling — Design

**Date:** 2026-05-10
**Owner:** John McLear
**Scope:** `src/node/hooks/express/openapi.ts` + type tweak + tests
**Driven by:** integrating Etherpad with [printingpress.dev](https://printingpress.dev). Generating a Go CLI / Claude Code skill from `/api/openapi.json` revealed structural problems in the served spec that hurt every downstream consumer (printing-press, Postman, Swagger UI, openapi-generator, etc.). This PR fixes Etherpad's spec; generating and publishing a CLI is a follow-up that depends on this landing.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. https://printingpress.dev protocol-specific url 📘 Rule violation ⛨ Security

The new design doc introduces a protocol-specific external URL (https://...) instead of a
protocol-independent URL (//...). This can cause mixed-content/protocol-coupling issues and
violates the URL formatting guideline.
Agent Prompt
## Issue description
A new external link was added with a protocol-specific URL (`https://...`) but the compliance checklist requires protocol-independent URLs (`//...`) where applicable.

## Issue Context
This is in a markdown design doc and should be updated to avoid protocol coupling.

## Fix Focus Areas
- docs/superpowers/specs/2026-05-10-openapi-cleanup-design.md[6-6]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Respectfully disagreeing here. Protocol-relative URLs (//host) are an HTML/CSS pattern that resolves against the ambient page protocol — they don't work in markdown the way they do in HTML.

In CommonMark/GFM, [text](//host) does parse, but the resolved URL depends on the rendering surface's <base> (which on GitHub is https, so the result is identical to writing https://). For non-browser contexts (cat docs/*.md, IDE preview, plain-text email of the doc, mirror sites without HTTPS), //host doesn't resolve as a URL at all.

The repo's existing convention also uses https:// throughout — etherpad.org, github.com, apache.org license URLs, oauth.pstmn.io redirect, etc. Switching one external link in one design doc to // would be inconsistent without buying any portability or security benefit (CSP rules don't apply to markdown source).

Leaving as https://printingpress.dev.


## Problems in the current spec

A live capture of `/api/openapi.json` (Etherpad 1.3.0, 48 paths) showed:

1. **Top-level `tags` array is empty/null.** Per-operation `tags: ["pad"]`, `["group"]`, etc. are populated for ops in the `resources` map, but consumers that group by tag (printing-press, Swagger UI sidebar, openapi-generator's resource modules) need the top-level array to discover and order them.

2. **Every operation duplicated as GET and POST.** Lines 562–573 of `openapi.ts` deliberately emit `paths[path] = { get: {...UsingGET}, post: {...UsingPOST} }`. The original comment ("It may be confusing that every operation can be called with both GET and POST") acknowledges this. A 48-path API generates a 96-operation CLI with `check-token using-get` + `check-token using-post`, etc.

3. **14 operations missing from the `resources` map.** As new API versions were added (1.2.8 → 1.3.1), `APIHandler.ts` got new functions but the `resources` map in `openapi.ts` was never updated. Affected ops have no `tags`, no `summary`, no `description`:
- `getAttributePool`, `getRevisionChangeset`, `copyPad`, `movePad`, `getPadID`, `getSavedRevisionsCount`, `listSavedRevisions`, `saveRevision`, `restoreRevision`, `appendText`, `getStats`, `copyPadWithoutHistory`, `compactPad`, `anonymizeAuthor`

4. **Empty summaries on tracked ops.** `listSessionsOfGroup`, `listAllGroups`, `createDiffHTML` had `summary: ''`. `createPad` had no summary at all.

## Non-goals

- **Deprecating GET routes at runtime.** Existing third-party clients use `GET /api/1.x.x/foo?apikey=...`. Removing GET would be a breaking change for them — out of scope. This PR only changes what the spec *advertises*.
- **Fixing printing-press's operationId derivation bug** (`get-html_get-htmlusing-get`). Generator-side issue.
- **Admin API spec** (`/admin/openapi.json`). Different surface, separate cleanup if needed later.

## Design

### Per-op tag overrides

The simplest fix for Problem 3-and-friends is to allow per-operation `tags` overrides in the `resources` map. Existing chat ops (`getChatHistory`, `getChatHead`, `appendChatMessage`) and `checkToken` are nested under `pad` for routing reasons; tagging them as `chat` / `server` without restructuring the map preserves all REST URLs.

The operations builder destructures `tags` from each spec entry and falls back to `[resource]` when absent:

```ts
const {operationId, responseSchema, tags: customTags, ...operation} = spec as any;
// ...
operations[operationId] = {
operationId,
...operation,
responses,
tags: customTags || [resource],
_restPath: `/${resource}/${action}`,
};
```

The `SwaggerUIResource` type gains an optional `tags?: string[]` field.

### Top-level tags array

Added inside `generateDefinitionForVersion`'s returned `definition` object:

```ts
tags: [
{name: 'pad', description: 'Pad lifecycle, content, revisions, attributes'},
{name: 'author', description: 'Authors and authorship'},
{name: 'session', description: 'Group sessions'},
{name: 'group', description: 'Groups (multi-tenant pads)'},
{name: 'chat', description: 'In-pad chat history'},
{name: 'server', description: 'Server-level operations (stats, token check)'},
],
```

### Backfill missing entries

14 operations added to `resources` with proper summaries. Most go under `pad`; `anonymizeAuthor` under `author`; `getStats` under a new `server` resource group.

| Tag | Operation | Summary |
|--------|----------------------------|---------------------------------------------------------------|
| pad | `getAttributePool` | returns the attribute pool of a pad |
| pad | `getRevisionChangeset` | returns the changeset at a given revision of a pad |
| pad | `copyPad` | copies a pad with full history and chat |
| pad | `movePad` | moves a pad — copy then delete the original |
| pad | `getPadID` | returns the read-write pad ID for a given read-only pad ID |
| pad | `getSavedRevisionsCount` | returns the number of saved revisions of a pad |
| pad | `listSavedRevisions` | returns the list of saved revisions of a pad |
| pad | `saveRevision` | saves a revision of a pad |
| pad | `restoreRevision` | restores a pad to a specific revision |
| pad | `appendText` | appends text to a pad |
| pad | `copyPadWithoutHistory` | copies a pad without history or chat |
| pad | `compactPad` | compacts a pad's revision history, keeping recent ones |
| author | `anonymizeAuthor` | anonymizes an author across all their edits |
| server | `getStats` | returns server-wide statistics |

`checkToken` stays under `pad` in the resources map (preserves `/rest/<ver>/pad/checkToken`) but gains an explicit `tags: ['server']` override so it groups correctly in OpenAPI without changing its REST URL.

### Runtime vs published spec split

`generateDefinitionForVersion` gains a `{public}` flag:

```ts
const generateDefinitionForVersion = (
version: string,
style: string = APIPathStyle.FLAT,
{public: isPublic = false}: {public?: boolean} = {},
) => { ... }
```

When `isPublic`, paths emit only `post:`. Otherwise both `get:` and `post:` (current behavior).

- The `definition` passed to `new OpenAPIBackend({...})` stays as-is (no flag) → both verbs routed at runtime → backward compat preserved.
- The handlers serving `/api/openapi.json`, `/rest/openapi.json`, `/api/{version}/openapi.json` call with `{public: true}` → clients see clean POST-only API.

operationIds in the public spec are unchanged (`${name}UsingPOST`), so any tooling already generated from the previous spec still finds its operations — strict subset, not rename.

## Test plan

Two new describe blocks in `src/tests/backend/specs/api/api.ts` (existing home for `/api/openapi.json` tests):

1. **public OpenAPI spec shape** — fetches `/api/openapi.json` once, asserts:
- Top-level `tags` array contains `{pad, author, session, group, chat, server}`
- Every operation has `tags: [...]` with ≥1 non-empty entry
- Every operation has a non-empty `summary` (≥3 chars)
- Every path advertises only `post:`

2. **runtime backward compatibility** — drives the live API:
- `GET /api/{v}/checkToken?apikey=...` returns code 0
- `POST /api/{v}/checkToken` returns code 0

These assert both halves of the design: the published spec is clean, and the runtime hasn't lost backward-compat routing.

## Blast radius

- **Runtime callers** (third-party scripts, ep_ai_mcp's HTTP fallback paths if any, dashboards, CI hooks): zero impact. Both GET and POST routes still resolve.
- **Tooling regenerators** (Postman collections, Swagger UI, openapi-generator clients): strict improvement. Smaller, better-named, properly-grouped surface. operationIds stable.
- **REST-style URLs** (`/rest/...`): unchanged for every existing op. No restructuring of `resources` was needed because per-op tag overrides do the work. New backfilled ops (`getAttributePool` etc.) gain a `/rest/X/pad/getAttributePool` path; their previous fallback `/rest/X/getAttributePool` is no longer the canonical REST route, but FLAT (`/api/...`) is unchanged.

## Out-of-band note

The spec already serves at three URLs (`/api/openapi.json`, `/rest/openapi.json`, `/api/{version}/openapi.json`); the cleanup applies to all three because the same builder backs them.

A separate admin spec exists at `/admin/openapi.json` (added in #7693/#7705) — out of scope here, worth a similar audit later.

## Follow-up phases (not part of this PR)

- **Phase B:** point printing-press at the cleaned spec, generate `etherpad` Go CLI + Claude Code skill, push to a new `ether/etherpad-cli` repo, submit to printingpress.dev community library.
- **Phase C:** submit `ep_ai_mcp` as the canonical MCP entry in printingpress.dev's library — generated MCP from OpenAPI would be strictly worse (no changeset/authorship reach-through).
147 changes: 124 additions & 23 deletions src/node/hooks/express/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,14 @@ const resources:SwaggerUIResource = {
},
listSessions: {
operationId: 'listSessionsOfGroup',
summary: '',
summary: 'returns all sessions of a group',
responseSchema: {
sessions: {type: 'array', items: {$ref: '#/components/schemas/SessionInfo'}},
},
},
list: {
operationId: 'listAllGroups',
summary: '',
summary: 'returns the IDs of all groups on this server',
responseSchema: {groupIDs: {type: 'array', items: {type: 'string'}}},
},
},
Expand Down Expand Up @@ -128,6 +128,10 @@ const resources:SwaggerUIResource = {
summary: 'Returns the Author Name of the author',
responseSchema: {info: {$ref: '#/components/schemas/UserInfo'}},
},
anonymize: {
operationId: 'anonymizeAuthor',
summary: 'anonymizes an author across all their edits',
},
},

// Session
Expand Down Expand Up @@ -158,11 +162,12 @@ const resources:SwaggerUIResource = {
},
createDiffHTML: {
operationId: 'createDiffHTML',
summary: '',
summary: 'returns an HTML diff between two revisions of a pad',
responseSchema: {},
},
create: {
operationId: 'createPad',
summary: 'creates a new (non-group) pad',
description:
'creates a new (non-group) pad. Note that if you need to create a group Pad, ' +
'you should call createGroupPad',
Expand Down Expand Up @@ -234,22 +239,82 @@ const resources:SwaggerUIResource = {
},
checkToken: {
operationId: 'checkToken',
summary: 'returns ok when the current api token is valid',
summary: 'returns ok when the current API token is valid',
tags: ['server'],
},
getChatHistory: {
operationId: 'getChatHistory',
summary: 'returns the chat history',
tags: ['chat'],
responseSchema: {messages: {type: 'array', items: {$ref: '#/components/schemas/Message'}}},
},
// We need an operation that returns a Message so it can be picked up by the codegen :(
getChatHead: {
operationId: 'getChatHead',
summary: 'returns the chatHead (chat-message) of the pad',
tags: ['chat'],
responseSchema: {chatHead: {$ref: '#/components/schemas/Message'}},
},
appendChatMessage: {
operationId: 'appendChatMessage',
summary: 'appends a chat message',
tags: ['chat'],
},
getAttributePool: {
operationId: 'getAttributePool',
summary: 'returns the attribute pool of a pad',
},
getRevisionChangeset: {
operationId: 'getRevisionChangeset',
summary: 'returns the changeset at a given revision of a pad',
},
copyPad: {
operationId: 'copyPad',
summary: 'copies a pad with full history and chat',
},
movePad: {
operationId: 'movePad',
summary: 'moves a pad — copy then delete the original',
},
getPadID: {
operationId: 'getPadID',
summary: 'returns the read-write pad ID for a given read-only pad ID',
},
getSavedRevisionsCount: {
operationId: 'getSavedRevisionsCount',
summary: 'returns the number of saved revisions of a pad',
},
listSavedRevisions: {
operationId: 'listSavedRevisions',
summary: 'returns the list of saved revisions of a pad',
},
saveRevision: {
operationId: 'saveRevision',
summary: 'saves a revision of a pad',
},
restoreRevision: {
operationId: 'restoreRevision',
summary: 'restores a pad to a specific revision',
},
appendText: {
operationId: 'appendText',
summary: 'appends text to a pad',
},
copyPadWithoutHistory: {
operationId: 'copyPadWithoutHistory',
summary: 'copies a pad without history or chat',
},
compactPad: {
operationId: 'compactPad',
summary: 'compacts a pad\'s revision history, keeping recent revisions only',
},
},

// Server
server: {
getStats: {
operationId: 'getStats',
summary: 'returns server-wide statistics',
},
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
},
};
Expand Down Expand Up @@ -396,7 +461,7 @@ const defaultResponseRefs:OpenAPISuccessResponse = {
const operations: OpenAPIOperations = {};
for (const [resource, actions] of Object.entries(resources)) {
for (const [action, spec] of Object.entries(actions)) {
const {operationId,responseSchema, ...operation} = spec;
const {operationId, responseSchema, tags: customTags, ...operation} = spec as any;

// add response objects
const responses:OpenAPISuccessResponse = {...defaultResponseRefs};
Expand All @@ -409,20 +474,43 @@ for (const [resource, actions] of Object.entries(resources)) {
}

// add final operation object to dictionary
// tags default to [resource] but can be overridden per-op via spec.tags
// (e.g. chat ops nested under pad use tags: ['chat']).
operations[operationId] = {
operationId,
...operation,
responses,
tags: [resource],
tags: customTags || [resource],
_restPath: `/${resource}/${action}`,
};
}
}

const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT) => {
const definition = {
/**
* Generate the OpenAPI definition for a given API version + path style.
*
* The `public` flag controls whether the spec is the *runtime* definition (used
* to dispatch requests via openapi-backend, which still routes both GET and
* POST for backward compatibility with older clients) or the *published* spec
* (served at /api/openapi.json etc., advertising only POST so generated tooling
* — printingpress.dev, openapi-generator, Postman — sees a clean surface).
*/
const generateDefinitionForVersion = (
version: string,
style: string = APIPathStyle.FLAT,
{public: isPublic = false}: {public?: boolean} = {},
) => {
const definition: any = {
openapi: OPENAPI_VERSION,
info,
tags: [
{name: 'pad', description: 'Pad lifecycle, content, revisions, attributes'},
{name: 'author', description: 'Authors and authorship'},
{name: 'session', description: 'Group sessions'},
{name: 'group', description: 'Groups (multi-tenant pads)'},
{name: 'chat', description: 'In-pad chat history'},
{name: 'server', description: 'Server-level operations (stats, token check)'},
],
paths: {},
components: {
parameters: {},
Expand Down Expand Up @@ -559,18 +647,29 @@ const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT)
delete operation._restPath;

// add to definition
// NOTE: It may be confusing that every operation can be called with both GET and POST
// @ts-ignore
definition.paths[path] = {
get: {
...operation,
operationId: `${operation.operationId}UsingGET`,
},
post: {
...operation,
operationId: `${operation.operationId}UsingPOST`,
},
};
// The runtime spec advertises both GET and POST so existing clients (some of
// which still pass apikey/params via query string) keep working. The public
// spec served at /api/openapi.json advertises only POST — that is the
// recommended call style and what downstream codegens should target.
if (isPublic) {
definition.paths[path] = {
post: {
...operation,
operationId: `${operation.operationId}UsingPOST`,
},
};
} else {
definition.paths[path] = {
get: {
...operation,
operationId: `${operation.operationId}UsingGET`,
},
post: {
...operation,
operationId: `${operation.operationId}UsingPOST`,
},
};
}
}
return definition;
};
Expand All @@ -588,11 +687,13 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
const definition = generateDefinitionForVersion(version, style);

// serve version specific openapi definition; regenerate per request so runtime
// settings (e.g. authenticationMethod) are reflected
// settings (e.g. authenticationMethod) are reflected. The served spec uses
// {public: true} to advertise only POST per path (the runtime backend below
// still routes both GET and POST for backward compatibility).
app.get(`${apiRoot}/openapi.json`, (req:any, res:any) => {
// For openapi definitions, wide CORS is probably fine
res.header('Access-Control-Allow-Origin', '*');
const liveDefinition = generateDefinitionForVersion(version, style);
const liveDefinition = generateDefinitionForVersion(version, style, {public: true});
res.json({...liveDefinition, servers: [generateServerForApiVersion(apiRoot, req)]});
});

Expand All @@ -601,7 +702,7 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
if (isLatestAPIVersion) {
app.get(`/${style}/openapi.json`, (req:any, res:any) => {
res.header('Access-Control-Allow-Origin', '*');
const liveDefinition = generateDefinitionForVersion(version, style);
const liveDefinition = generateDefinitionForVersion(version, style, {public: true});
res.json({...liveDefinition, servers: [generateServerForApiVersion(apiRoot, req)]});
});
}
Expand Down
5 changes: 3 additions & 2 deletions src/node/types/SwaggerUIResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ export type SwaggerUIResource = {
[secondKey: string]: {
operationId: string,
summary?: string,
description?:string
responseSchema?: object
description?: string,
responseSchema?: object,
tags?: string[],
}
}
}
Expand Down
Loading
Loading