diff --git a/admin/scripts/__tests__/merge-openapi.test.mjs b/admin/scripts/__tests__/merge-openapi.test.mjs new file mode 100644 index 00000000000..7fb454c1150 --- /dev/null +++ b/admin/scripts/__tests__/merge-openapi.test.mjs @@ -0,0 +1,74 @@ +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'); +}); diff --git a/admin/scripts/dump-spec.ts b/admin/scripts/dump-spec.ts index 2f2c388a56e..6c229e80270 100644 --- a/admin/scripts/dump-spec.ts +++ b/admin/scripts/dump-spec.ts @@ -1,16 +1,18 @@ // admin/scripts/dump-spec.ts // -// Imports the OpenAPI spec builder from the etherpad source and writes the -// flat-style spec for the latest API version as JSON to the file path passed -// as argv[2]. Invoked by admin/scripts/gen-api.mjs via `tsx`. +// 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 +// 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 {writeFileSync} from 'node:fs'; import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import {fileURLToPath, pathToFileURL} from 'node:url'; +// @ts-expect-error — sibling .mjs has no .d.ts; tsx resolves it at runtime. +import {mergeOpenAPI} from './merge-openapi.mjs'; const outFile = process.argv[2]; if (!outFile) { @@ -23,24 +25,33 @@ 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', +); -// `openapi.ts` and `APIHandler.ts` use CommonJS-style `exports.*`. Under tsx's -// ESM dynamic import, the whole `module.exports` is exposed as `default`. -type ApiHandlerModule = { latestApiVersion: string }; +type ApiHandlerModule = {latestApiVersion: string}; type OpenApiModule = { generateDefinitionForVersion: (version: string, style?: string) => unknown; - APIPathStyle: { FLAT: string; REST: string }; + 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 spec = openapi.generateDefinitionForVersion( +const publicSpec = openapi.generateDefinitionForVersion( apiHandler.latestApiVersion, openapi.APIPathStyle.FLAT, ); +const adminSpec = openapiAdmin.generateAdminDefinition(); + +const merged = mergeOpenAPI(publicSpec, adminSpec); -writeFileSync(path.resolve(outFile), JSON.stringify(spec, null, 2), 'utf8'); +writeFileSync(path.resolve(outFile), JSON.stringify(merged, null, 2), 'utf8'); diff --git a/admin/scripts/merge-openapi.mjs b/admin/scripts/merge-openapi.mjs new file mode 100644 index 00000000000..ff78576c7ce --- /dev/null +++ b/admin/scripts/merge-openapi.mjs @@ -0,0 +1,56 @@ +// 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} 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, + ), + }, + }; +}; diff --git a/docs/superpowers/plans/2026-05-08-issue-7693-admin-openapi.md b/docs/superpowers/plans/2026-05-08-issue-7693-admin-openapi.md new file mode 100644 index 00000000000..b1a545b6dc4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-issue-7693-admin-openapi.md @@ -0,0 +1,1058 @@ +# Issue 7693 — Admin OpenAPI Coverage Implementation Plan + +> **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 Structure + +| 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. | + +--- + +## Task 1: Stub `openapi-admin.ts` with empty paths + +**Files:** +- 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`: + +```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'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm run test -- --grep "admin OpenAPI document"` +Expected: FAIL — module `../../../node/hooks/express/openapi-admin` not found. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/node/hooks/express/openapi-admin.ts`: + +```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; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm run test -- --grep "admin OpenAPI document"` +Expected: PASS — 3 tests passing. + +- [ ] **Step 5: Commit** + +```bash +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) " +``` + +--- + +## Task 2: Add `POST /admin-auth/` — `verifyAdminAccess` + +**Files:** +- 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`): + +```ts + 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()); + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm run test -- --grep "admin OpenAPI document"` +Expected: FAIL — three new tests fail because `paths['/admin-auth/']` is undefined. + +- [ ] **Step 3: Implement the path** + +Edit `src/node/hooks/express/openapi-admin.ts`. Replace `paths: {}` with: + +```ts + paths: { + '/admin-auth/': { + post: { + operationId: 'verifyAdminAccess', + summary: 'Verify or establish an admin session', + description: + 'POST with `Authorization: Basic ` 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.'}, + }, + }, + }, + }, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm run test -- --grep "admin OpenAPI document"` +Expected: PASS — all 6 tests passing (3 from Task 1 + 3 new). + +- [ ] **Step 5: Commit** + +```bash +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) " +``` + +--- + +## Task 3: Add `GET /admin/update/status` — `getUpdateStatus` + +**Files:** +- 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`): + +```ts + 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']); + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm run test -- --grep "admin OpenAPI document"` +Expected: FAIL — schema and path entries undefined. + +- [ ] **Step 3: Implement the schemas and path** + +Edit `src/node/hooks/express/openapi-admin.ts`. Replace the empty `schemas: {}` with: + +```ts + 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/`: + +```ts + '/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.', + }, + }, + }, + }, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm run test -- --grep "admin OpenAPI document"` +Expected: PASS — all tests passing. + +- [ ] **Step 5: Cross-check schema parity with handler** + +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.) + +- [ ] **Step 6: Commit** + +```bash +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) " +``` + +--- + +## Task 4: Cross-collision regression test against the public spec + +**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`: + +```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(', ')}`); + }); + }); +``` + +- [ ] **Step 2: Run tests** + +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. + +- [ ] **Step 3: Commit** + +```bash +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) " +``` + +--- + +## Task 5: Mount `/admin/openapi.json` via `expressPreSession` hook + +**Files:** +- 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`: + +```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'], '*'); + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm run test -- --grep "GET /admin/openapi.json"` +Expected: FAIL — 404 (route not registered). + +- [ ] **Step 3: Add the express hook** + +Append to `src/node/hooks/express/openapi-admin.ts`: + +```ts +import {ArgsExpressType} from '../../types/ArgsExpressType'; + +export const expressPreSession = async ( + _hookName: string, + {app}: ArgsExpressType, +): Promise => { + 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`. + +- [ ] **Step 4: Register the part in ep.json** + +Edit `src/ep.json`. Find the existing `openapi` part: + +```json +{ + "name": "openapi", + "hooks": { + "expressPreSession": "ep_etherpad-lite/node/hooks/express/openapi" + } +} +``` + +Add a new entry directly after it: + +```json +{ + "name": "openapi-admin", + "hooks": { + "expressPreSession": "ep_etherpad-lite/node/hooks/express/openapi-admin" + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pnpm run test -- --grep "GET /admin/openapi.json"` +Expected: PASS. + +- [ ] **Step 6: Verify no regression in the existing admin SPA route** + +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. + +- [ ] **Step 7: Commit** + +```bash +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) " +``` + +--- + +## Task 6: Implement `merge-openapi.mjs` + +**Files:** +- 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`: + +```js +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'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd admin && pnpm exec node --test scripts/__tests__/merge-openapi.test.mjs` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the merge function** + +Create `admin/scripts/merge-openapi.mjs`: + +```js +// 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, + ), + }, + }; +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd admin && pnpm exec node --test scripts/__tests__/merge-openapi.test.mjs` +Expected: PASS — 7 tests passing. + +- [ ] **Step 5: Commit** + +```bash +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) " +``` + +--- + +## Task 7: Wire `merge-openapi` into `dump-spec.ts` + +**Files:** +- 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`. + +- [ ] **Step 2: Modify the script** + +Replace `admin/scripts/dump-spec.ts` with: + +```ts +// 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 \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'); +``` + +- [ ] **Step 3: Regenerate the typed client** + +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. + +- [ ] **Step 4: Verify schema.d.ts contains admin paths** + +Run: `grep -E '"/admin-auth/"|"/admin/update/status"' admin/src/api/schema.d.ts | head` +Expected: both path strings appear at least once each. + +- [ ] **Step 5: Run admin client tests** + +Run: `pnpm --filter admin test` +Expected: existing client tests still pass (`pnpm gen:api` chains in front). + +- [ ] **Step 6: Run TypeScript build** + +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). + +- [ ] **Step 7: Commit** + +```bash +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) " +``` + +--- + +## Task 8: Full backend test suite + ts-check + +**Files:** none + +- [ ] **Step 1: Run backend tests** + +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. + +- [ ] **Step 2: Run TypeScript check** + +Run: `pnpm run ts-check 2>&1 | tail -20` +Expected: 0 errors. + +- [ ] **Step 3: Run admin merge tests** + +Run: `cd admin && pnpm exec node --test scripts/__tests__/merge-openapi.test.mjs` +Expected: PASS — 7 tests. + +- [ ] **Step 4: Smoke the route in a live server** + +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 +``` + +- [ ] **Step 5: Confirm no broken admin SPA** + +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. + +- [ ] **Step 6: No commit; this task is verification-only.** + +--- + +## Task 9: Open the PR + +**Files:** none + +- [ ] **Step 1: Push the branch** + +```bash +git push -u fork feat/7693-admin-openapi +``` + +- [ ] **Step 2: Open the draft PR against the PR #7695 branch** + +```bash +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 +)" +``` + +- [ ] **Step 3: Echo the PR URL** + +The `gh pr create` command prints the URL. Capture and surface it to the user. + +--- + +## Self-Review Notes + +- Spec coverage: each spec section maps to a task — Task 1 covers info+security schemes, Task 2 `/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. +- Placeholder scan: every code block is concrete; no "TBD" or "etc.". +- Type consistency: `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). +- Out-of-scope drift: the plan does NOT modify any existing fetch() call site, does NOT add `execution`/`lastResult`/`lockHeld` (those are Tier 2's job), and does NOT touch the public openapi.ts. diff --git a/docs/superpowers/specs/2026-05-08-issue-7693-admin-openapi-design.md b/docs/superpowers/specs/2026-05-08-issue-7693-admin-openapi-design.md new file mode 100644 index 00000000000..3e7868742d0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-issue-7693-admin-openapi-design.md @@ -0,0 +1,278 @@ +# Issue 7693 — Document admin endpoints in the OpenAPI spec + +**Status:** design approved 2026-05-08 +**Issue:** https://github.com/ether/etherpad/issues/7693 +**Stacks on:** PR #7695 (`chore/admin-typesafe-api-7638-upstream`) — codegen rails +**Related:** #7601 (introduced `/admin/update/status`); #7607 (Tier 2 update endpoints, in-flight) + +## Goal + +Add OpenAPI definitions for the admin endpoints currently consumed by the admin +UI so the typed client generated by PR #7695 (`admin/src/api/schema.d.ts`) +gains admin call-sites the day it lands. + +This PR adds the schema only. **No call-sites migrate** — that is the explicit +follow-up named in #7693. + +## Scope + +In: + +- `POST /admin-auth/` — login + session check (consumed by `LoginScreen.tsx` + and `App.tsx`). +- `GET /admin/update/status` — Tier 1 update banner data (consumed by + `UpdateBanner.tsx` and `UpdatePage.tsx`; introduced by #7601, merged on + develop). + +Out: + +- `/admin/update/{apply,cancel,acknowledge,log}` — Tier 2 endpoints from the + in-flight `feat/7607-auto-update-tier2-manual-click` branch. That PR amends + `openapi-admin.ts` when it lands. +- The admin SPA static-file route (`/admin/{*filename}`) — not an API. +- `/admin/socket.io/*` — websocket; out of OpenAPI scope. +- `/api/version-status` — already public, belongs in the public spec, not the + admin spec. +- Migrating any of the four admin `fetch()` call-sites to `$api`. + +## Architecture + +### File layout (new files marked NEW) + +``` +src/node/hooks/express/ +├── openapi.ts unchanged — APIHandler-driven public spec +└── openapi-admin.ts NEW — hand-authored OpenAPI 3.0 doc for admin routes + +src/tests/backend/specs/ +└── openapi-admin.ts NEW — Mocha specs asserting document shape + +admin/scripts/ +├── dump-spec.ts MODIFIED — also import generateAdminDefinition, +│ deep-merge into one document, write merged JSON +├── merge-openapi.mjs NEW — focused deep-merge with collision detection +├── __tests__/ +│ └── merge-openapi.test.mjs NEW — node --test unit specs for the merge +└── gen-api.mjs unchanged — still calls dump-spec.ts then + openapi-typescript on the resulting JSON +``` + +`openapi-admin.ts` is a **static OpenAPI document** (no APIHandler reflection). +Hand-authored because admin routes aren't registered through APIHandler — they +are plain Express handlers. This keeps `openapi.ts`'s 771-line generator +untouched and avoids tangling two different generation strategies in one +module. + +### Why merge in `dump-spec.ts` rather than at `openapi-typescript` time + +`openapi-typescript` only accepts one input. We could run it twice and emit +two `.d.ts` files, but the chosen design (see "Codegen merge" below) is a single +merged `schema.d.ts` so the admin UI's `$api` instance has one `paths` +interface covering both surfaces. The merge therefore happens at JSON-dump +time, before `openapi-typescript` runs. + +## OpenAPI document contents + +### Info & security schemes + +```yaml +openapi: 3.0.2 +info: + title: Etherpad Admin API + version: + description: | + Authenticated administrative endpoints consumed by the Etherpad admin UI. + Distinct from the public /api/{version}/* surface served by openapi.json. + +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + sessionCookie: + type: apiKey + in: cookie + name: express_sid +``` + +`basicAuth` covers the login POST to `/admin-auth/`. `sessionCookie` covers +post-login admin sessions established by `express-session` (cookie name +`express_sid` is the Etherpad default; if a deployment overrides it the spec +remains structurally correct — only the documented cookie name shifts). + +The two schemes coexist on `/admin-auth/`; only `sessionCookie` applies on +`/admin/update/status`. + +### Paths + +#### `POST /admin-auth/` — `verifyAdminAccess` + +- **Security:** `[{ basicAuth: [] }, { sessionCookie: [] }, {}]` — Basic *or* + session cookie *or* none. The empty object documents that the server + accepts the request without auth and replies `401`. +- **Responses:** + - `200` — admin verified (Basic logged in, or session cookie was valid for + an admin user). Empty body. + - `401` — no auth presented and no session. Empty body. + - `403` — auth presented or session present, but the user is not an admin. + Empty body. +- **Description:** notes that POST with `Authorization: Basic …` establishes + an admin session; POST with no auth header verifies an existing one. + +This single-operation modeling matches reality: the route is one +middleware-terminated path that branches on what the client sends. Two +operations on the same path would imply different server behavior the +admin UI does not actually depend on. + +#### `GET /admin/update/status` — `getUpdateStatus` + +- **Security:** `[{ sessionCookie: [] }, {}]` — cookie when + `updates.requireAdminForStatus=true`, otherwise anonymous OK. The + conditional is documented in the description; clients that depend on + receiving the full diagnostic payload should send the session cookie. +- **Responses:** + - `200` — JSON body matching the `UpdateStatus` schema below. + - `401` / `403` — only emitted when `updates.requireAdminForStatus=true`. + +Response schema `UpdateStatus` mirrors the runtime shape returned by +`src/node/hooks/express/updateStatus.ts:res.json({...})` on the base branch +(`chore/admin-typesafe-api-7638-upstream`, which mirrors develop's Tier 1): + +```yaml +UpdateStatus: + type: object + required: [currentVersion, installMethod, tier, vulnerableBelow] + properties: + currentVersion: { type: string } + latest: { $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: { $ref: '#/components/schemas/PolicyResult', nullable: true } + vulnerableBelow: + type: array + items: { $ref: '#/components/schemas/VulnerableBelowDirective' } +``` + +Sub-schemas (`ReleaseInfo`, `PolicyResult`, `VulnerableBelowDirective`) +mirror the exported interfaces in `src/node/updater/types.ts` exactly: + +- `ReleaseInfo`: `version`, `tag`, `body`, `publishedAt`, `prerelease`, `htmlUrl`. +- `PolicyResult`: `canNotify`, `canManual`, `canAuto`, `canAutonomous`, `reason`. +- `VulnerableBelowDirective`: `announcedBy`, `threshold`. + +The Tier 2 PR (#7607) will amend `UpdateStatus` to add `execution`, +`lastResult`, and `lockHeld` (with their corresponding sub-schemas) when it +ships its own changes to `updateStatus.ts`. Those fields are out of scope +here. + +### Public exposure (runtime) + +`openapi-admin.ts` exports an `expressPreSession` hook that mounts: + +``` +GET /admin/openapi.json (CORS: *) +``` + +for parity with `/api/openapi.json` and `/rest/openapi.json`. The hook +registers the route in `expressPreSession`, which runs before +`expressCreateServer` (where `admin.ts` registers the SPA wildcard +`/admin/{*filename}`). The earlier registration ensures +`/admin/openapi.json` resolves before the wildcard catches it. + +Codegen does not depend on this route — `dump-spec.ts` calls +`generateAdminDefinition()` in-process. The route exists for downstream +tooling (Postman, swagger-ui, third-party clients). + +## Codegen merge + +`merge-openapi.mjs` exports one function: + +```js +mergeOpenAPI(publicDoc, adminDoc) -> mergedDoc +``` + +Rules: + +| Section | Rule | +| ------------------------------ | ----------------------------------------------------------------- | +| `paths` | Union by path key. Collision throws. | +| `components.schemas` | Union by name. Collision throws. | +| `components.parameters` | Union by name. Collision throws. | +| `components.responses` | Union by name. Collision throws. | +| `components.securitySchemes` | Union by name. Collision throws. | +| `security` (root) | Public spec's root `security` is preserved; admin paths declare their own per-operation security so admin requirements never apply to public paths. | +| `info`, `servers` | Public spec wins. | + +Throwing on collision is intentional: silent overwrite is a footgun, and the +backend test below catches collisions before merge runs in CI. + +## Tests + +### Backend — `src/tests/backend/specs/openapi-admin.ts` + +Mocha specs against `generateAdminDefinition()`. No live HTTP. + +- Document is valid OpenAPI 3.0 (smoke check via `openapi-schema-validation`, + already in `node_modules`). +- `paths['/admin-auth/'].post.operationId === 'verifyAdminAccess'` and + declares responses `200`, `401`, `403`. +- `paths['/admin/update/status'].get.operationId === 'getUpdateStatus'` and + references `#/components/schemas/UpdateStatus`. +- `components.securitySchemes` contains `basicAuth` and `sessionCookie`. +- `components.schemas.UpdateStatus.properties` contains every property name + emitted by `updateStatus.ts:res.json({...})`. Cross-checked by importing + the same handler and asserting key parity. This is the regression net for + spec/handler drift. +- Admin operationIds and admin path keys do not collide with the public + spec (cross-loaded via `generateDefinitionForVersion`). Cross-collision + is impossible today (admin paths start with `/admin`, public paths are + flat or `/createGroup`-style), but the test fails loudly if a future + rename breaks the assumption. + +### Codegen merge — `admin/scripts/__tests__/merge-openapi.test.mjs` + +Node `--test` runner (already used by #7695 for `client.test.ts`). + +- Two minimal docs merge into the expected union. +- Path collision throws. +- Schema-name collision throws. +- Public root `security` is preserved when admin doc declares no root + security. +- Per-operation security on admin paths survives the merge unchanged. + +### No frontend tests this PR + +No call-sites migrate, so there is nothing UI-observable to assert. +Migration PRs add Playwright coverage when they touch each fetch. + +## Risks & mitigations + +| Risk | Mitigation | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `UpdateStatus` schema drifts from `updateStatus.ts` over time | Backend spec cross-checks property names against the handler. Tier 2 PR amends both spec and handler in one change. | +| Tier 2 (#7607) rebase conflicts with the new `openapi-admin.ts` | This PR adds only `/admin/update/status`. Tier 2 appends new entries — no conflict on the existing one. | +| `merge-openapi.mjs` silently overwrites a duplicate | Throws on collision. Backend spec cross-checks against the public spec. | +| `/admin/openapi.json` collides with `/admin/{*filename}` SPA wildcard | `openapi-admin.ts` registers in `expressPreSession`; `admin.ts` registers in `expressCreateServer`. Earlier hook wins. Backend smoke test confirms 200 + JSON content-type. | +| #7695 changes shape before it merges, breaking our base | This PR is stacked on #7695's branch. Rebase when #7695 rebases. PR description documents the dependency. | +| `express_sid` is not the actual cookie name in some deployments | Documented; spec is structurally correct; deployments that override it can still consume a typed client. | + +## Rollout + +1. Branch `feat/7693-admin-openapi` from `chore/admin-typesafe-api-7638-upstream`. +2. Add `openapi-admin.ts`, `merge-openapi.mjs`; modify `dump-spec.ts`. +3. Add backend spec and merge unit tests. +4. Open PR #7693 as **draft**, base set to `chore/admin-typesafe-api-7638-upstream`. +5. When PR #7695 merges to develop, change base to `develop`, rebase, mark + ready for review. +6. Follow-up PR (separately tracked) migrates the four admin `fetch()` + sites: `LoginScreen.tsx`, `App.tsx`, `UpdateBanner.tsx`, `UpdatePage.tsx`. + +## Open question deferred to implementation + +The `express_sid` cookie name is the documented default but Etherpad +deployments can override it via settings. Implementation will read the +configured name at spec-generation time (or document the override path) so +the spec reflects the running configuration. If reading the configured name +is awkward at codegen time (it requires booting Settings), the spec keeps +the default and notes the override in the description. diff --git a/src/ep.json b/src/ep.json index bf90c52d43b..319f8f792e1 100644 --- a/src/ep.json +++ b/src/ep.json @@ -139,6 +139,12 @@ "hooks": { "expressPreSession": "ep_etherpad-lite/node/hooks/express/openapi" } + }, + { + "name": "openapi-admin", + "hooks": { + "expressPreSession": "ep_etherpad-lite/node/hooks/express/openapi-admin" + } } ] } diff --git a/src/node/hooks/express/openapi-admin.ts b/src/node/hooks/express/openapi-admin.ts new file mode 100644 index 00000000000..c01cec34ab2 --- /dev/null +++ b/src/node/hooks/express/openapi-admin.ts @@ -0,0 +1,168 @@ +'use strict'; + +import {ArgsExpressType} from '../../types/ArgsExpressType'; +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: { + '/admin-auth/': { + post: { + operationId: 'verifyAdminAccess', + summary: 'Verify or establish an admin session', + description: + 'POST with `Authorization: Basic ` 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.'}, + }, + }, + }, + '/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.', + }, + }, + }, + }, + }, + components: { + 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'}, + }, + }, + }, + }, + securitySchemes: { + basicAuth: { + type: 'http', + scheme: 'basic', + }, + sessionCookie: { + type: 'apiKey', + in: 'cookie', + name: 'express_sid', + }, + }, + }, +}); + +exports.generateAdminDefinition = generateAdminDefinition; + +export const expressPreSession = async ( + _hookName: string, + {app}: ArgsExpressType, +): Promise => { + app.get('/admin/openapi.json', (_req: any, res: any) => { + res.header('Access-Control-Allow-Origin', '*'); + res.json(generateAdminDefinition()); + }); +}; + +exports.expressPreSession = expressPreSession; diff --git a/src/tests/backend/specs/openapi-admin.ts b/src/tests/backend/specs/openapi-admin.ts new file mode 100644 index 00000000000..c48afca7cfe --- /dev/null +++ b/src/tests/backend/specs/openapi-admin.ts @@ -0,0 +1,194 @@ +'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'); + }); + + 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)); + const keys = security.map((s: any) => Object.keys(s)[0] ?? '__anon__'); + assert.deepEqual(keys.sort(), ['__anon__', 'basicAuth', 'sessionCookie'].sort()); + }); + }); + + 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.slice().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.slice().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']); + }); + }); + + describe('cross-collision with public spec', function () { + let publicDoc: any; + before(function () { + const apiHandler = require('../../../node/handler/APIHandler'); + const openapi = require('../../../node/hooks/express/openapi'); + publicDoc = openapi.generateDefinitionForVersion( + apiHandler.latestApiVersion, + openapi.APIPathStyle.FLAT, + ); + }); + + it('admin paths and operationIds do not collide with the latest public spec', function () { + 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 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(', ')}`); + }); + }); + + 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/']); + assert.ok(res.body.paths['/admin/update/status']); + }); + + 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'], '*'); + }); + }); +});