Back to Etherpad Lite

Issue 7693 — Admin OpenAPI Coverage Implementation Plan

docs/superpowers/plans/2026-05-08-issue-7693-admin-openapi.md

3.1.036.9 KB
Original Source

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

FileStatusResponsibility
src/node/hooks/express/openapi-admin.tsCreateHand-authored admin OpenAPI document. Exports generateAdminDefinition() and an expressPreSession hook serving /admin/openapi.json.
src/tests/backend/specs/openapi-admin.tsCreateMocha specs asserting document shape, sub-schema fidelity, and cross-collision against the public spec.
admin/scripts/merge-openapi.mjsCreatePure-JS deep-merge of two OpenAPI 3.0 documents with collision detection.
admin/scripts/__tests__/merge-openapi.test.mjsCreateNode --test unit specs for mergeOpenAPI.
admin/scripts/dump-spec.tsModifyAlso import generateAdminDefinition, merge with the public spec, write the merged JSON.
src/ep.jsonModifyRegister 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) <[email protected]>"

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 <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.'},
        },
      },
    },
  },
  • 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) <[email protected]>"

Task 3: Add GET /admin/update/statusgetUpdateStatus

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) <[email protected]>"

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) <[email protected]>"

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<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.

  • 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) <[email protected]>"

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) <[email protected]>"

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 <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');
  • 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) <[email protected]>"

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.