docs/superpowers/plans/2026-05-08-issue-7693-admin-openapi.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add OpenAPI 3.0 coverage for /admin-auth/ and /admin/update/status so the typed client generated by PR #7695 includes admin call-sites.
Architecture: New hand-authored OpenAPI document src/node/hooks/express/openapi-admin.ts (no APIHandler reflection — admin routes aren't APIHandler-driven). Codegen-side merge in admin/scripts/dump-spec.ts unions the public and admin docs into one JSON before openapi-typescript runs, producing one admin/src/api/schema.d.ts covering both surfaces.
Tech Stack: TypeScript (server hook), Node ESM (admin scripts), openapi-schema-validation (already in repo), Mocha (backend specs), Node --test runner (admin script tests).
Branch: feat/7693-admin-openapi, stacked on chore/admin-typesafe-api-7638-upstream (PR #7695). Already created.
Spec: docs/superpowers/specs/2026-05-08-issue-7693-admin-openapi-design.md
| File | Status | Responsibility |
|---|---|---|
src/node/hooks/express/openapi-admin.ts | Create | Hand-authored admin OpenAPI document. Exports generateAdminDefinition() and an expressPreSession hook serving /admin/openapi.json. |
src/tests/backend/specs/openapi-admin.ts | Create | Mocha specs asserting document shape, sub-schema fidelity, and cross-collision against the public spec. |
admin/scripts/merge-openapi.mjs | Create | Pure-JS deep-merge of two OpenAPI 3.0 documents with collision detection. |
admin/scripts/__tests__/merge-openapi.test.mjs | Create | Node --test unit specs for mergeOpenAPI. |
admin/scripts/dump-spec.ts | Modify | Also import generateAdminDefinition, merge with the public spec, write the merged JSON. |
src/ep.json | Modify | Register openapi-admin as a part with expressPreSession hook so /admin/openapi.json mounts. |
openapi-admin.ts with empty pathsFiles:
Create: src/node/hooks/express/openapi-admin.ts
Create: src/tests/backend/specs/openapi-admin.ts
Step 1: Write the failing test
Create src/tests/backend/specs/openapi-admin.ts:
'use strict';
import {strict as assert} from 'assert';
const validateOpenAPI = require('openapi-schema-validation').validate;
const openapiAdmin = require('../../../node/hooks/express/openapi-admin');
describe('admin OpenAPI document', function () {
let doc: any;
before(function () {
doc = openapiAdmin.generateAdminDefinition();
});
it('returns a valid OpenAPI 3.0 document', function () {
const {valid, errors} = validateOpenAPI(doc, 3);
if (!valid) {
throw new Error(
`admin OpenAPI doc is invalid: ${JSON.stringify(errors, null, 2)}`,
);
}
});
it('declares info.title as "Etherpad Admin API"', function () {
assert.equal(doc.info.title, 'Etherpad Admin API');
});
it('exposes basicAuth and sessionCookie security schemes', function () {
assert.ok(doc.components.securitySchemes.basicAuth);
assert.equal(doc.components.securitySchemes.basicAuth.type, 'http');
assert.equal(doc.components.securitySchemes.basicAuth.scheme, 'basic');
assert.ok(doc.components.securitySchemes.sessionCookie);
assert.equal(doc.components.securitySchemes.sessionCookie.type, 'apiKey');
assert.equal(doc.components.securitySchemes.sessionCookie.in, 'cookie');
});
});
Run: pnpm run test -- --grep "admin OpenAPI document"
Expected: FAIL — module ../../../node/hooks/express/openapi-admin not found.
Create src/node/hooks/express/openapi-admin.ts:
'use strict';
import {getEpVersion} from '../../utils/Settings';
const OPENAPI_VERSION = '3.0.2';
/**
* Build the OpenAPI 3.0 document for Etherpad's admin endpoints.
*
* Distinct from the public versioned API document built by openapi.ts —
* admin routes are plain Express handlers (not APIHandler-driven), so this
* spec is hand-authored. The shape is consumed by admin/scripts/dump-spec.ts
* for client-side codegen and exposed at GET /admin/openapi.json for
* downstream tooling.
*/
export const generateAdminDefinition = (): any => ({
openapi: OPENAPI_VERSION,
info: {
title: 'Etherpad Admin API',
description:
'Authenticated administrative endpoints consumed by the Etherpad admin UI. ' +
'Distinct from the public /api/{version}/* surface served by /api/openapi.json.',
version: getEpVersion(),
},
paths: {},
components: {
schemas: {},
securitySchemes: {
basicAuth: {
type: 'http',
scheme: 'basic',
},
sessionCookie: {
type: 'apiKey',
in: 'cookie',
name: 'express_sid',
},
},
},
});
exports.generateAdminDefinition = generateAdminDefinition;
Run: pnpm run test -- --grep "admin OpenAPI document"
Expected: PASS — 3 tests passing.
git add src/node/hooks/express/openapi-admin.ts src/tests/backend/specs/openapi-admin.ts
git commit -m "feat(admin): stub OpenAPI document for admin endpoints (#7693)
Adds generateAdminDefinition() returning a minimal valid OpenAPI 3.0
document with no paths yet, plus security schemes for the two auth
modes (Basic + session cookie). Subsequent tasks fill in the actual
admin paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>"
POST /admin-auth/ — verifyAdminAccessFiles:
Modify: src/node/hooks/express/openapi-admin.ts
Modify: src/tests/backend/specs/openapi-admin.ts
Step 1: Add failing tests
Append to src/tests/backend/specs/openapi-admin.ts (inside the existing describe):
describe('/admin-auth/', function () {
it('declares POST with operationId verifyAdminAccess', function () {
const op = doc.paths['/admin-auth/']?.post;
assert.ok(op, 'POST /admin-auth/ is missing');
assert.equal(op.operationId, 'verifyAdminAccess');
});
it('documents responses 200, 401, 403', function () {
const responses = doc.paths['/admin-auth/'].post.responses;
assert.ok(responses['200'], 'missing 200 response');
assert.ok(responses['401'], 'missing 401 response');
assert.ok(responses['403'], 'missing 403 response');
});
it('declares security: basicAuth, sessionCookie, anonymous', function () {
const security = doc.paths['/admin-auth/'].post.security;
assert.ok(Array.isArray(security));
// Each entry is an object: empty {} = anonymous OK.
const keys = security.map((s: any) => Object.keys(s)[0] ?? '__anon__');
assert.deepEqual(keys.sort(), ['__anon__', 'basicAuth', 'sessionCookie'].sort());
});
});
Run: pnpm run test -- --grep "admin OpenAPI document"
Expected: FAIL — three new tests fail because paths['/admin-auth/'] is undefined.
Edit src/node/hooks/express/openapi-admin.ts. Replace paths: {} with:
paths: {
'/admin-auth/': {
post: {
operationId: 'verifyAdminAccess',
summary: 'Verify or establish an admin session',
description:
'POST with `Authorization: Basic <user:pass>` to log in as an admin ' +
'(server sets a session cookie on success). POST with no auth header ' +
'to verify an existing admin session cookie. The response body is ' +
'always empty; the status code conveys the outcome.',
security: [
{basicAuth: []},
{sessionCookie: []},
{},
],
responses: {
'200': {description: 'Caller is an authenticated admin.'},
'401': {description: 'No authentication presented and no admin session exists.'},
'403': {description: 'Authenticated, but the user is not an admin.'},
},
},
},
},
Run: pnpm run test -- --grep "admin OpenAPI document"
Expected: PASS — all 6 tests passing (3 from Task 1 + 3 new).
git add src/node/hooks/express/openapi-admin.ts src/tests/backend/specs/openapi-admin.ts
git commit -m "feat(admin): document POST /admin-auth/ in OpenAPI (#7693)
Adds verifyAdminAccess as the operation that the admin UI's LoginScreen
and App session check both call. Documents Basic auth, session cookie,
and anonymous request modes plus their 200/401/403 responses.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>"
GET /admin/update/status — getUpdateStatusFiles:
Modify: src/node/hooks/express/openapi-admin.ts
Modify: src/tests/backend/specs/openapi-admin.ts
Step 1: Add failing tests
Append to src/tests/backend/specs/openapi-admin.ts (inside the existing top-level describe):
describe('/admin/update/status', function () {
it('declares GET with operationId getUpdateStatus', function () {
const op = doc.paths['/admin/update/status']?.get;
assert.ok(op, 'GET /admin/update/status is missing');
assert.equal(op.operationId, 'getUpdateStatus');
});
it('200 response references components.schemas.UpdateStatus', function () {
const ok = doc.paths['/admin/update/status'].get.responses['200'];
assert.equal(
ok.content['application/json'].schema.$ref,
'#/components/schemas/UpdateStatus',
);
});
it('declares security: sessionCookie OR anonymous', function () {
const security = doc.paths['/admin/update/status'].get.security;
const keys = security.map((s: any) => Object.keys(s)[0] ?? '__anon__');
assert.deepEqual(keys.sort(), ['__anon__', 'sessionCookie'].sort());
});
});
describe('UpdateStatus schema', function () {
it('declares all properties emitted by the handler', function () {
const schema = doc.components.schemas.UpdateStatus;
assert.equal(schema.type, 'object');
const props = Object.keys(schema.properties).sort();
assert.deepEqual(props, [
'currentVersion',
'installMethod',
'lastCheckAt',
'latest',
'policy',
'tier',
'vulnerableBelow',
]);
});
it('installMethod enum matches updater/types.ts InstallMethod', function () {
const enums = doc.components.schemas.UpdateStatus.properties.installMethod.enum;
assert.deepEqual(enums.sort(), ['auto', 'docker', 'git', 'managed', 'npm']);
});
it('tier enum matches updater/types.ts Tier', function () {
const enums = doc.components.schemas.UpdateStatus.properties.tier.enum;
assert.deepEqual(enums.sort(), ['auto', 'autonomous', 'manual', 'notify', 'off']);
});
it('declares ReleaseInfo, PolicyResult, VulnerableBelowDirective sub-schemas', function () {
assert.ok(doc.components.schemas.ReleaseInfo);
assert.ok(doc.components.schemas.PolicyResult);
assert.ok(doc.components.schemas.VulnerableBelowDirective);
});
it('ReleaseInfo properties mirror updater/types.ts', function () {
const props = Object.keys(doc.components.schemas.ReleaseInfo.properties).sort();
assert.deepEqual(props, [
'body', 'htmlUrl', 'prerelease', 'publishedAt', 'tag', 'version',
]);
});
it('PolicyResult properties mirror updater/types.ts', function () {
const props = Object.keys(doc.components.schemas.PolicyResult.properties).sort();
assert.deepEqual(props, [
'canAuto', 'canAutonomous', 'canManual', 'canNotify', 'reason',
]);
});
it('VulnerableBelowDirective properties mirror updater/types.ts', function () {
const props = Object.keys(doc.components.schemas.VulnerableBelowDirective.properties).sort();
assert.deepEqual(props, ['announcedBy', 'threshold']);
});
});
Run: pnpm run test -- --grep "admin OpenAPI document"
Expected: FAIL — schema and path entries undefined.
Edit src/node/hooks/express/openapi-admin.ts. Replace the empty schemas: {} with:
schemas: {
ReleaseInfo: {
type: 'object',
required: ['version', 'tag', 'body', 'publishedAt', 'prerelease', 'htmlUrl'],
properties: {
version: {type: 'string', description: 'Semver string without leading "v".'},
tag: {type: 'string', description: 'Original GitHub tag_name (e.g. "v2.7.2").'},
body: {type: 'string', description: 'Markdown body of the release.'},
publishedAt: {type: 'string', format: 'date-time'},
prerelease: {type: 'boolean'},
htmlUrl: {type: 'string', format: 'uri'},
},
},
PolicyResult: {
type: 'object',
required: ['canNotify', 'canManual', 'canAuto', 'canAutonomous', 'reason'],
properties: {
canNotify: {type: 'boolean'},
canManual: {type: 'boolean'},
canAuto: {type: 'boolean'},
canAutonomous: {type: 'boolean'},
reason: {type: 'string'},
},
},
VulnerableBelowDirective: {
type: 'object',
required: ['announcedBy', 'threshold'],
properties: {
announcedBy: {type: 'string'},
threshold: {type: 'string'},
},
},
UpdateStatus: {
type: 'object',
required: ['currentVersion', 'installMethod', 'tier', 'vulnerableBelow'],
properties: {
currentVersion: {type: 'string'},
latest: {
allOf: [{$ref: '#/components/schemas/ReleaseInfo'}],
nullable: true,
},
lastCheckAt: {type: 'string', format: 'date-time', nullable: true},
installMethod: {
type: 'string',
enum: ['auto', 'git', 'docker', 'npm', 'managed'],
},
tier: {
type: 'string',
enum: ['off', 'notify', 'manual', 'auto', 'autonomous'],
},
policy: {
allOf: [{$ref: '#/components/schemas/PolicyResult'}],
nullable: true,
},
vulnerableBelow: {
type: 'array',
items: {$ref: '#/components/schemas/VulnerableBelowDirective'},
},
},
},
},
Then add the new path entry alongside /admin-auth/:
'/admin/update/status': {
get: {
operationId: 'getUpdateStatus',
summary: 'Fetch updater status for the admin UI banner and update page',
description:
'Returns the cached update state (current version, latest known release, ' +
'install method, tier, policy verdict, and vulnerability directives). ' +
'Open by default; gated to authenticated admin sessions when ' +
'updates.requireAdminForStatus=true in settings.',
security: [
{sessionCookie: []},
{},
],
responses: {
'200': {
description: 'Update status payload.',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/UpdateStatus'},
},
},
},
'401': {
description: 'requireAdminForStatus is set and no admin session exists.',
},
'403': {
description: 'requireAdminForStatus is set and the session user is not an admin.',
},
},
},
},
Run: pnpm run test -- --grep "admin OpenAPI document"
Expected: PASS — all tests passing.
Run: grep -A20 "res.json({" src/node/hooks/express/updateStatus.ts
Confirm every key in the handler's response object appears in the
UpdateStatus.properties declared above. (The test from Step 1 already
asserts this, but the manual eyeball is cheap insurance against typos.)
git add src/node/hooks/express/openapi-admin.ts src/tests/backend/specs/openapi-admin.ts
git commit -m "feat(admin): document GET /admin/update/status in OpenAPI (#7693)
Adds getUpdateStatus operation plus UpdateStatus, ReleaseInfo,
PolicyResult, and VulnerableBelowDirective sub-schemas. Property names
and enums mirror src/node/updater/types.ts and the response object
emitted by updateStatus.ts. Tier 2 (#7607) will amend UpdateStatus when
it ships execution/lastResult/lockHeld.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>"
Files:
Modify: src/tests/backend/specs/openapi-admin.ts
Step 1: Add failing test
Append to the top-level describe in src/tests/backend/specs/openapi-admin.ts:
describe('cross-collision with public spec', function () {
it('admin paths and operationIds do not collide with the latest public spec', function () {
const apiHandler = require('../../../node/handler/APIHandler');
const openapi = require('../../../node/hooks/express/openapi');
const publicDoc = openapi.generateDefinitionForVersion(
apiHandler.latestApiVersion,
openapi.APIPathStyle.FLAT,
);
const adminPaths = Object.keys(doc.paths);
const publicPaths = Object.keys(publicDoc.paths);
const pathCollisions = adminPaths.filter((p) => publicPaths.includes(p));
assert.deepEqual(pathCollisions, [], `path collisions: ${pathCollisions.join(', ')}`);
const collectOpIds = (d: any): string[] => {
const ids: string[] = [];
for (const item of Object.values(d.paths) as any[]) {
for (const op of Object.values(item) as any[]) {
if (op && typeof op.operationId === 'string') ids.push(op.operationId);
}
}
return ids;
};
const adminIds = collectOpIds(doc);
const publicIds = collectOpIds(publicDoc);
const idCollisions = adminIds.filter((id) => publicIds.includes(id));
assert.deepEqual(idCollisions, [], `operationId collisions: ${idCollisions.join(', ')}`);
});
it('schema names do not collide with the latest public spec', function () {
const apiHandler = require('../../../node/handler/APIHandler');
const openapi = require('../../../node/hooks/express/openapi');
const publicDoc = openapi.generateDefinitionForVersion(
apiHandler.latestApiVersion,
openapi.APIPathStyle.FLAT,
);
const adminSchemas = Object.keys(doc.components.schemas);
const publicSchemas = Object.keys(publicDoc.components.schemas || {});
const collisions = adminSchemas.filter((n) => publicSchemas.includes(n));
assert.deepEqual(collisions, [], `schema name collisions: ${collisions.join(', ')}`);
});
});
Run: pnpm run test -- --grep "admin OpenAPI document"
Expected: PASS — current admin paths (/admin-auth/, /admin/update/status)
and schemas (UpdateStatus, ReleaseInfo, PolicyResult,
VulnerableBelowDirective) do not collide with public spec entries.
If a collision IS detected (e.g. someone renames a public schema to
PolicyResult later), this test fails loudly before codegen breaks.
git add src/tests/backend/specs/openapi-admin.ts
git commit -m "test(admin): regression net for admin/public OpenAPI collisions (#7693)
Cross-checks admin paths, operationIds, and schema names against the
latest public spec. Today there are no overlaps; the test exists to
catch future renames before they break the merged client codegen.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>"
/admin/openapi.json via expressPreSession hookFiles:
Modify: src/node/hooks/express/openapi-admin.ts
Modify: src/ep.json
Modify: src/tests/backend/specs/openapi-admin.ts
Step 1: Add failing live-route test
Append to src/tests/backend/specs/openapi-admin.ts:
describe('GET /admin/openapi.json', function () {
let agent: any;
before(async function () {
const common = require('../../common');
agent = await common.init();
});
it('serves the admin OpenAPI document as JSON', async function () {
const res = await agent.get('/admin/openapi.json').expect(200);
assert.match(res.headers['content-type'] || '', /application\/json/);
assert.equal(res.body.openapi, '3.0.2');
assert.equal(res.body.info.title, 'Etherpad Admin API');
assert.ok(res.body.paths['/admin-auth/']);
});
it('sets a permissive CORS header (matches /api/openapi.json)', async function () {
const res = await agent.get('/admin/openapi.json').expect(200);
assert.equal(res.headers['access-control-allow-origin'], '*');
});
});
Run: pnpm run test -- --grep "GET /admin/openapi.json"
Expected: FAIL — 404 (route not registered).
Append to src/node/hooks/express/openapi-admin.ts:
import {ArgsExpressType} from '../../types/ArgsExpressType';
export const expressPreSession = async (
_hookName: string,
{app}: ArgsExpressType,
): Promise<void> => {
app.get('/admin/openapi.json', (_req: any, res: any) => {
res.header('Access-Control-Allow-Origin', '*');
res.json(generateAdminDefinition());
});
};
exports.expressPreSession = expressPreSession;
The route registers in expressPreSession, which runs before
expressCreateServer (where admin.ts registers the SPA wildcard
/admin/{*filename}). Earlier registration wins — see the same pattern
in openapi.ts.
Edit src/ep.json. Find the existing openapi part:
{
"name": "openapi",
"hooks": {
"expressPreSession": "ep_etherpad-lite/node/hooks/express/openapi"
}
}
Add a new entry directly after it:
{
"name": "openapi-admin",
"hooks": {
"expressPreSession": "ep_etherpad-lite/node/hooks/express/openapi-admin"
}
}
Run: pnpm run test -- --grep "GET /admin/openapi.json"
Expected: PASS.
Run: pnpm run test -- --grep "admin"
Expected: PASS — every admin-related backend test still passes.
The wildcard at admin.ts:24 (/admin/{*filename}) registers in
expressCreateServer, which fires after expressPreSession, so our
/admin/openapi.json resolves first. If this test fails because the SPA
wildcard is hit, the bug is hook-order — verify by adding a logger to
both hooks.
git add src/node/hooks/express/openapi-admin.ts src/ep.json src/tests/backend/specs/openapi-admin.ts
git commit -m "feat(admin): expose admin OpenAPI doc at /admin/openapi.json (#7693)
Mounts the admin OpenAPI document at /admin/openapi.json (CORS: *) via an
expressPreSession hook, matching the /api/openapi.json convention. The
admin SPA wildcard at /admin/{*filename} registers later in
expressCreateServer, so the JSON route wins.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>"
merge-openapi.mjsFiles:
Create: admin/scripts/merge-openapi.mjs
Create: admin/scripts/__tests__/merge-openapi.test.mjs
Step 1: Write failing tests
Create admin/scripts/__tests__/merge-openapi.test.mjs:
import {test} from 'node:test';
import {strict as assert} from 'node:assert';
import {mergeOpenAPI} from '../merge-openapi.mjs';
const minimal = (overrides = {}) => ({
openapi: '3.0.2',
info: {title: 'X', version: '0.0.0'},
paths: {},
components: {schemas: {}, securitySchemes: {}},
...overrides,
});
test('unions paths from both docs', () => {
const pub = minimal({paths: {'/createGroup': {post: {operationId: 'createGroup'}}}});
const adm = minimal({paths: {'/admin-auth/': {post: {operationId: 'verifyAdminAccess'}}}});
const out = mergeOpenAPI(pub, adm);
assert.deepEqual(Object.keys(out.paths).sort(), ['/admin-auth/', '/createGroup']);
});
test('throws on path collision', () => {
const pub = minimal({paths: {'/x': {get: {}}}});
const adm = minimal({paths: {'/x': {post: {}}}});
assert.throws(() => mergeOpenAPI(pub, adm), /path collision/i);
});
test('unions components.schemas', () => {
const pub = minimal({components: {schemas: {A: {}}, securitySchemes: {}}});
const adm = minimal({components: {schemas: {B: {}}, securitySchemes: {}}});
const out = mergeOpenAPI(pub, adm);
assert.deepEqual(Object.keys(out.components.schemas).sort(), ['A', 'B']);
});
test('throws on schema name collision', () => {
const pub = minimal({components: {schemas: {Dup: {}}, securitySchemes: {}}});
const adm = minimal({components: {schemas: {Dup: {}}, securitySchemes: {}}});
assert.throws(() => mergeOpenAPI(pub, adm), /schema collision/i);
});
test('unions securitySchemes', () => {
const pub = minimal({components: {schemas: {}, securitySchemes: {apiKey: {}}}});
const adm = minimal({components: {schemas: {}, securitySchemes: {basicAuth: {}}}});
const out = mergeOpenAPI(pub, adm);
assert.deepEqual(
Object.keys(out.components.securitySchemes).sort(),
['apiKey', 'basicAuth'],
);
});
test('preserves public root security; admin per-operation security survives', () => {
const pub = minimal({security: [{apiKey: []}]});
const adm = minimal({
paths: {
'/admin-auth/': {
post: {
security: [{basicAuth: []}, {}],
},
},
},
});
const out = mergeOpenAPI(pub, adm);
assert.deepEqual(out.security, [{apiKey: []}]);
assert.deepEqual(
out.paths['/admin-auth/'].post.security,
[{basicAuth: []}, {}],
);
});
test('public info wins on conflict', () => {
const pub = minimal({info: {title: 'Public', version: '1.0'}});
const adm = minimal({info: {title: 'Admin', version: '2.0'}});
const out = mergeOpenAPI(pub, adm);
assert.equal(out.info.title, 'Public');
assert.equal(out.info.version, '1.0');
});
Run: cd admin && pnpm exec node --test scripts/__tests__/merge-openapi.test.mjs
Expected: FAIL — module not found.
Create admin/scripts/merge-openapi.mjs:
// admin/scripts/merge-openapi.mjs
//
// Deep-merges the public-API OpenAPI document with the admin OpenAPI
// document into a single document for openapi-typescript to consume.
//
// Rules:
// - paths: union by key; collision throws
// - components.{schemas,parameters,responses,securitySchemes}: union by name; collision throws
// - root info, servers, security: public wins (admin's are ignored at the root)
// - per-operation security on admin paths is preserved untouched
const unionMap = (label, a = {}, b = {}) => {
const out = {...a};
for (const [k, v] of Object.entries(b)) {
if (k in out) {
throw new Error(`${label} collision on key "${k}"`);
}
out[k] = v;
}
return out;
};
export const mergeOpenAPI = (publicDoc, adminDoc) => {
if (!publicDoc || !adminDoc) {
throw new Error('mergeOpenAPI requires both publicDoc and adminDoc');
}
return {
openapi: publicDoc.openapi || adminDoc.openapi,
info: publicDoc.info,
...(publicDoc.servers ? {servers: publicDoc.servers} : {}),
...(publicDoc.security ? {security: publicDoc.security} : {}),
paths: unionMap('path collision', publicDoc.paths, adminDoc.paths),
components: {
schemas: unionMap(
'schema collision',
publicDoc.components?.schemas,
adminDoc.components?.schemas,
),
parameters: unionMap(
'parameter collision',
publicDoc.components?.parameters,
adminDoc.components?.parameters,
),
responses: unionMap(
'response collision',
publicDoc.components?.responses,
adminDoc.components?.responses,
),
securitySchemes: unionMap(
'securityScheme collision',
publicDoc.components?.securitySchemes,
adminDoc.components?.securitySchemes,
),
},
};
};
Run: cd admin && pnpm exec node --test scripts/__tests__/merge-openapi.test.mjs
Expected: PASS — 7 tests passing.
git add admin/scripts/merge-openapi.mjs admin/scripts/__tests__/merge-openapi.test.mjs
git commit -m "feat(admin): mergeOpenAPI helper for codegen pipeline (#7693)
Pure-JS deep-merge of two OpenAPI 3.0 documents. Unions paths and
components by key; throws on collisions. Public document's info,
servers, and root security win over the admin document's. Used by
dump-spec.ts to produce a single merged JSON for openapi-typescript.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>"
merge-openapi into dump-spec.tsFiles:
Modify: admin/scripts/dump-spec.ts
Step 1: Read the current file
Run: cat admin/scripts/dump-spec.ts
Confirm it currently imports only openapi.ts's generateDefinitionForVersion.
Replace admin/scripts/dump-spec.ts with:
// admin/scripts/dump-spec.ts
//
// Imports the public + admin OpenAPI spec builders from the etherpad
// source, merges them into one document, and writes JSON to argv[2].
// Invoked by admin/scripts/gen-api.mjs via `tsx`.
//
// Why a file argument instead of stdout: importing openapi*.ts triggers
// Settings init, which configures log4js to write INFO/WARN lines to
// stdout. Capturing stdout would mix logs with JSON.
import {writeFileSync} from 'node:fs';
import path from 'node:path';
import {fileURLToPath, pathToFileURL} from 'node:url';
import {mergeOpenAPI} from './merge-openapi.mjs';
const outFile = process.argv[2];
if (!outFile) {
process.stderr.write('Usage: tsx scripts/dump-spec.ts <output-path>\n');
process.exit(2);
}
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, '..', '..');
const apiHandlerPath = path.join(repoRoot, 'src', 'node', 'handler', 'APIHandler.ts');
const openapiPath = path.join(repoRoot, 'src', 'node', 'hooks', 'express', 'openapi.ts');
const openapiAdminPath = path.join(
repoRoot, 'src', 'node', 'hooks', 'express', 'openapi-admin.ts',
);
type ApiHandlerModule = {latestApiVersion: string};
type OpenApiModule = {
generateDefinitionForVersion: (version: string, style?: string) => unknown;
APIPathStyle: {FLAT: string; REST: string};
};
type OpenApiAdminModule = {
generateAdminDefinition: () => unknown;
};
const apiHandlerMod = await import(pathToFileURL(apiHandlerPath).href);
const openapiMod = await import(pathToFileURL(openapiPath).href);
const openapiAdminMod = await import(pathToFileURL(openapiAdminPath).href);
const apiHandler = (apiHandlerMod.default ?? apiHandlerMod) as ApiHandlerModule;
const openapi = (openapiMod.default ?? openapiMod) as OpenApiModule;
const openapiAdmin = (openapiAdminMod.default ?? openapiAdminMod) as OpenApiAdminModule;
const publicSpec = openapi.generateDefinitionForVersion(
apiHandler.latestApiVersion,
openapi.APIPathStyle.FLAT,
);
const adminSpec = openapiAdmin.generateAdminDefinition();
const merged = mergeOpenAPI(publicSpec, adminSpec);
writeFileSync(path.resolve(outFile), JSON.stringify(merged, null, 2), 'utf8');
Run: pnpm --filter admin gen:api
Expected: stdout reports Wrote admin/src/api/schema.d.ts and Wrote admin/src/api/version.ts. No errors.
Run: grep -E '"/admin-auth/"|"/admin/update/status"' admin/src/api/schema.d.ts | head
Expected: both path strings appear at least once each.
Run: pnpm --filter admin test
Expected: existing client tests still pass (pnpm gen:api chains in front).
Run: pnpm --filter admin build
Expected: tsc and vite build complete with no errors. This proves the
generated types are syntactically valid and admin source still compiles
(no call-site changes are made — the existing fetch() sites compile
exactly as before; the new types are simply available for future use).
git add admin/scripts/dump-spec.ts
git commit -m "feat(admin): include admin OpenAPI in generated client (#7693)
Modifies dump-spec.ts to import generateAdminDefinition alongside the
public generator and feed both through mergeOpenAPI before writing the
JSON consumed by openapi-typescript. The resulting admin/src/api/
schema.d.ts paths interface now exposes /admin-auth/ and
/admin/update/status, ready for typed call-site adoption in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>"
Files: none
Run: pnpm run test 2>&1 | tail -30
Expected: All Mocha specs pass. If anything unrelated fails, the failure
is preexisting on the base branch — capture the output and confirm via
git stash && pnpm run test against the unmodified base before
declaring victory.
Run: pnpm run ts-check 2>&1 | tail -20
Expected: 0 errors.
Run: cd admin && pnpm exec node --test scripts/__tests__/merge-openapi.test.mjs
Expected: PASS — 7 tests.
Start the dev server in one terminal: pnpm run dev
In another: curl -s http://localhost:9001/admin/openapi.json | jq '.info.title, (.paths | keys | length)'
Expected output:
"Etherpad Admin API"
2
In a browser, open http://localhost:9001/admin/. Expected: admin
LoginScreen renders (the wildcard /admin/{*filename} still serves the
SPA). The /admin/openapi.json route did not break the wildcard
because the JSON route is registered earlier in the hook chain.
Files: none
git push -u fork feat/7693-admin-openapi
gh pr create \
--repo ether/etherpad \
--base chore/admin-typesafe-api-7638-upstream \
--head JohnMcLear:feat/7693-admin-openapi \
--draft \
--title "feat(admin): document admin endpoints in OpenAPI (#7693)" \
--body "$(cat <<'EOF'
## Summary
- Adds hand-authored `openapi-admin.ts` covering `POST /admin-auth/` (verifyAdminAccess) and `GET /admin/update/status` (getUpdateStatus).
- Merges admin spec into the codegen pipeline so `admin/src/api/schema.d.ts` exposes the admin paths.
- Mounts `/admin/openapi.json` (CORS: *) for downstream tooling.
- No call-site migrations — explicit follow-up named in #7693.
Stacks on #7695. Will be re-targeted at `develop` and rebased once #7695 merges.
Closes #7693.
## Test plan
- [ ] `pnpm run test` — admin OpenAPI Mocha specs pass, full suite green.
- [ ] `pnpm run ts-check` — 0 errors.
- [ ] `cd admin && pnpm exec node --test scripts/__tests__/merge-openapi.test.mjs` — 7 unit tests pass.
- [ ] `pnpm --filter admin build` — tsc + vite build clean.
- [ ] `curl /admin/openapi.json` returns the expected JSON in a live dev server.
- [ ] Admin SPA at `/admin/` still loads; the wildcard route is not broken.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
The gh pr create command prints the URL. Capture and surface it to the user.
/admin-auth/, Task 3 /admin/update/status + sub-schemas, Task 4 collision regression, Task 5 the runtime route, Task 6+7 the codegen merge, Task 8 verification, Task 9 ships.generateAdminDefinition is named identically across Task 1 (creation), Task 5 (used inside the hook), Task 7 (imported by dump-spec.ts), and Task 8 (used by tests). Same for mergeOpenAPI. Schema names (UpdateStatus, ReleaseInfo, PolicyResult, VulnerableBelowDirective) are consistent across Task 3 (creation) and Task 4 (collision check).execution/lastResult/lockHeld (those are Tier 2's job), and does NOT touch the public openapi.ts.