# 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) <noreply@anthropic.com>"
```

---

## 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) <noreply@anthropic.com>"
```

---

## 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) <noreply@anthropic.com>"
```

---

## 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) <noreply@anthropic.com>"
```

---

## 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) <noreply@anthropic.com>"
```

---

## 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) <noreply@anthropic.com>"
```

---

## 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) <noreply@anthropic.com>"
```

---

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