diff --git a/.gitignore b/.gitignore index 098074ad290..c38ed72cc40 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,8 @@ stage/ prime/ .craft/ *.snap + +# Generated by `pnpm --filter admin gen:api` from src/node/hooks/express/openapi.ts. +# Regenerated by build/test/dev scripts; not committed. +/admin/src/api/schema.d.ts +/admin/src/api/version.ts diff --git a/admin/README.md b/admin/README.md index 0d6babeddbd..6d069bba4d6 100644 --- a/admin/README.md +++ b/admin/README.md @@ -1,30 +1,66 @@ -# React + TypeScript + Vite +# Admin UI -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +Vite + React 19 single-page app served at `/admin`. Talks to the backend over +socket.io for the existing settings / plugins / pads pages, and (when +endpoints are added to the OpenAPI spec) over a typed REST client. -Currently, two official plugins are available: +## Scripts -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +| Script | What it does | +| -------------------- | -------------------------------------------------------- | +| `pnpm dev` | `gen:api` + Vite dev server (expects backend on :9001). | +| `pnpm gen:api` | Regenerates `src/api/{schema.d.ts,version.ts}` from the OpenAPI spec. | +| `pnpm build` | `gen:api` + `tsc` + `vite build`. | +| `pnpm build-copy` | Same, but writes into `../src/templates/admin`. | +| `pnpm test` | `gen:api` + smoke tests for the API client wiring. | +| `pnpm lint` | ESLint. | -## Expanding the ESLint configuration +## Typed API client -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: +The admin uses [`openapi-typescript`] to generate types from +`src/node/hooks/express/openapi.ts`, [`openapi-fetch`] for typed requests, and +[`openapi-react-query`] for TanStack Query bindings. -- Configure the top-level `parserOptions` property like this: +[`openapi-typescript`]: https://github.com/openapi-ts/openapi-typescript +[`openapi-fetch`]: https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-fetch +[`openapi-react-query`]: https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-react-query -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, -} +### Generated files + +`admin/src/api/schema.d.ts` and `admin/src/api/version.ts` are generated by +`gen:api` and gitignored — never commit them. They are produced by: + +```sh +pnpm --filter admin gen:api +``` + +`admin/scripts/gen-api.mjs` loads `src/node/hooks/express/openapi.ts`, calls +`generateDefinitionForVersion` for the latest API version, pipes the JSON +through `openapi-typescript` to produce `schema.d.ts`, and emits a runtime +constant `LATEST_API_VERSION` (read from `info.version` in the spec) to +`version.ts` so `client.ts` can build the right `/api//` baseUrl. + +`gen:api` runs as the first step of `dev`, `build`, `build-copy`, and +`test`, so a fresh checkout produces the generated files automatically when +any of those scripts is invoked. After modifying any of the following, the +next `pnpm ` will refresh the generated files; you can also +run `gen:api` directly: + +- `src/node/hooks/express/openapi.ts` +- `src/node/handler/APIHandler.ts` (changes to `latestApiVersion`) +- the resource definitions referenced by `openapi.ts` + +### Using the client + +```tsx +import { $api } from './api/client'; + +const SettingsPanel = () => { + const { data } = $api.useQuery('get', '/admin/settings'); // example + return
{JSON.stringify(data, null, 2)}
; +}; ``` -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list +The admin endpoints are not yet present in the OpenAPI spec — this client is +in place to support upcoming work (see issue #7638 follow-up). For now, it is +exercised only by the smoke test. diff --git a/admin/package.json b/admin/package.json index 3084b5fbc2b..cd5cb440907 100644 --- a/admin/package.json +++ b/admin/package.json @@ -4,14 +4,20 @@ "version": "2.7.3", "type": "module", "scripts": { - "dev": "vite", - "build": "tsc && vite build", + "dev": "pnpm gen:api && vite", + "gen:api": "node scripts/gen-api.mjs", + "build": "pnpm gen:api && tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "build-copy": "tsc && vite build --outDir ../src/templates/admin --emptyOutDir", - "preview": "vite preview" + "build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir", + "preview": "vite preview", + "test": "pnpm gen:api && tsx --test src/api/__tests__/client.test.ts" }, "dependencies": { - "@radix-ui/react-switch": "^1.2.6" + "@radix-ui/react-switch": "^1.2.6", + "@tanstack/react-query": "^5.100.9", + "@tanstack/react-query-devtools": "^5.100.9", + "openapi-fetch": "^0.17.0", + "openapi-react-query": "^0.5.4" }, "devDependencies": { "@radix-ui/react-dialog": "^1.1.15", @@ -28,12 +34,14 @@ "i18next": "^26.0.10", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.14.0", + "openapi-typescript": "^7.13.0", "react": "^19.2.6", "react-dom": "^19.2.6", "react-hook-form": "^7.75.0", "react-i18next": "^17.0.7", "react-router-dom": "^7.15.0", "socket.io-client": "^4.8.3", + "tsx": "^4.21.0", "typescript": "^6.0.3", "vite": "^8.0.11", "vite-plugin-babel": "^1.6.0", 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 new file mode 100644 index 00000000000..6c229e80270 --- /dev/null +++ b/admin/scripts/dump-spec.ts @@ -0,0 +1,57 @@ +// 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'; +// @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) { + 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'); diff --git a/admin/scripts/gen-api.mjs b/admin/scripts/gen-api.mjs new file mode 100644 index 00000000000..d96383e2563 --- /dev/null +++ b/admin/scripts/gen-api.mjs @@ -0,0 +1,78 @@ +// admin/scripts/gen-api.mjs +// +// Regenerates admin/src/api/schema.d.ts from the live OpenAPI spec exported +// by src/node/hooks/express/openapi.ts. Run via `pnpm --filter admin gen:api`. + +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const adminRoot = path.resolve(here, '..'); +const outFile = path.join(adminRoot, 'src', 'api', 'schema.d.ts'); + +const tmpDir = mkdtempSync(path.join(tmpdir(), 'etherpad-openapi-')); +const specPath = path.join(tmpDir, 'spec.json'); + +// On Windows pnpm resolves to pnpm.cmd, which spawnSync can only find via a +// shell. Use shell on Windows only to avoid Node's DEP0190 warning elsewhere. +// Every argument here is fixed (no user input) so the shell:true variant is +// not an injection risk. +const spawnOpts = { + cwd: adminRoot, + stdio: 'inherit', + shell: process.platform === 'win32', +}; + +try { + const dump = spawnSync( + 'pnpm', + ['exec', 'tsx', 'scripts/dump-spec.ts', specPath], + spawnOpts, + ); + if (dump.status !== 0) { + console.error(`dump-spec.ts failed with exit code ${dump.status}`); + process.exit(dump.status ?? 1); + } + + const gen = spawnSync( + 'pnpm', + ['exec', 'openapi-typescript', specPath, '-o', outFile], + spawnOpts, + ); + if (gen.status !== 0) { + console.error(`openapi-typescript failed with exit code ${gen.status}`); + process.exit(gen.status ?? 1); + } + + const header = + `// GENERATED — do not edit. Run \`pnpm --filter admin gen:api\` to regenerate.\n` + + `// Source: src/node/hooks/express/openapi.ts (#7638)\n\n`; + const body = readFileSync(outFile, 'utf8'); + writeFileSync(outFile, header + body, 'utf8'); + + // Emit a runtime-side version constant so client.ts can build the right + // baseUrl. Generated paths are unprefixed (e.g. "/createGroup"), but the + // backend mounts the FLAT-style spec under /api//. + const spec = JSON.parse(readFileSync(specPath, 'utf8')); + const apiVersion = spec?.info?.version; + if (typeof apiVersion !== 'string' || apiVersion.length === 0) { + console.error('OpenAPI spec is missing info.version; cannot emit version.ts'); + process.exit(1); + } + const versionFile = path.join(adminRoot, 'src', 'api', 'version.ts'); + writeFileSync( + versionFile, + header + + `export const LATEST_API_VERSION = ${JSON.stringify(apiVersion)};\n` + + `export const API_BASE_URL = \`/api/\${LATEST_API_VERSION}\`;\n`, + 'utf8', + ); + + console.log(`Wrote ${path.relative(process.cwd(), outFile)}`); + console.log(`Wrote ${path.relative(process.cwd(), versionFile)}`); +} finally { + rmSync(tmpDir, { recursive: true, force: true }); +} 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/admin/src/api/QueryProvider.tsx b/admin/src/api/QueryProvider.tsx new file mode 100644 index 00000000000..54ee2d95cec --- /dev/null +++ b/admin/src/api/QueryProvider.tsx @@ -0,0 +1,40 @@ +// admin/src/api/QueryProvider.tsx +// +// TanStack Query provider for the admin UI. Devtools are loaded lazily and +// only in dev builds so they don't ship to production. + +import { lazy, Suspense, useState, type ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const Devtools = import.meta.env.DEV + ? lazy(() => + import('@tanstack/react-query-devtools').then((m) => ({ + default: m.ReactQueryDevtools, + })), + ) + : null; + +export const QueryProvider = ({ children }: { children: ReactNode }) => { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: true, + }, + }, + }), + ); + + return ( + + {children} + {Devtools && ( + + + + )} + + ); +}; diff --git a/admin/src/api/__tests__/client.test.ts b/admin/src/api/__tests__/client.test.ts new file mode 100644 index 00000000000..23230390bd5 --- /dev/null +++ b/admin/src/api/__tests__/client.test.ts @@ -0,0 +1,20 @@ +// admin/src/api/__tests__/client.test.ts +// +// Smoke test that the OpenAPI client module loads and exposes the expected +// surface. Catches toolchain wiring regressions (missing peer deps, +// generator output that doesn't export `paths`, etc.). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +test('client module exports public + admin clients and query hooks', async () => { + const mod = await import('../client.ts'); + assert.ok(mod.fetchClient, 'fetchClient export is present'); + assert.ok(mod.adminFetchClient, 'adminFetchClient export is present'); + assert.ok(mod.$api, '$api export is present'); + assert.ok(mod.$adminApi, '$adminApi export is present'); + assert.equal(typeof mod.fetchClient.GET, 'function', 'fetchClient.GET is a function'); + assert.equal(typeof mod.adminFetchClient.GET, 'function', 'adminFetchClient.GET is a function'); + assert.equal(typeof mod.$api.useQuery, 'function', '$api.useQuery is a function'); + assert.equal(typeof mod.$adminApi.useQuery, 'function', '$adminApi.useQuery is a function'); +}); diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts new file mode 100644 index 00000000000..39294c9769e --- /dev/null +++ b/admin/src/api/client.ts @@ -0,0 +1,30 @@ +// admin/src/api/client.ts +// +// Typed HTTP clients and TanStack Query hooks derived from the generated +// OpenAPI schema. Regenerate the schema with `pnpm --filter admin gen:api`. +// +// The merged spec covers two surfaces with different baseUrls: +// +// - Public versioned API at /api// (paths like /createGroup) +// - Admin endpoints at root (paths like /admin-auth/) +// +// We narrow the generated `paths` interface by URL prefix and create one +// typed client per surface. TypeScript then rejects calling an admin path on +// the public client (or vice versa) at compile time — there is no shared +// client whose runtime baseUrl would silently target the wrong surface. + +import createClient from 'openapi-fetch'; +import createQueryHooks from 'openapi-react-query'; +import type { paths } from './schema'; +import { API_BASE_URL } from './version'; + +type AdminPath = Extract; +type PublicPath = Exclude; +type PublicPaths = Pick; +type AdminPaths = Pick; + +export const fetchClient = createClient({ baseUrl: API_BASE_URL }); +export const adminFetchClient = createClient({ baseUrl: '/' }); + +export const $api = createQueryHooks(fetchClient); +export const $adminApi = createQueryHooks(adminFetchClient); diff --git a/admin/src/main.tsx b/admin/src/main.tsx index c7dcc456bf6..e5f6c8ab452 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -14,6 +14,7 @@ import {PadPage} from "./pages/PadPage.tsx"; import {ToastDialog} from "./utils/Toast.tsx"; import {ShoutPage} from "./pages/ShoutPage.tsx"; import {UpdatePage} from "./pages/UpdatePage.tsx"; +import {QueryProvider} from './api/QueryProvider.tsx'; const router = createBrowserRouter(createRoutesFromElements( <>}> @@ -34,11 +35,13 @@ const router = createBrowserRouter(createRoutesFromElements( ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - + + + + + + + + , ) diff --git a/admin/tsconfig.json b/admin/tsconfig.json index a7fc6fbf23d..ae96c41ebf8 100644 --- a/admin/tsconfig.json +++ b/admin/tsconfig.json @@ -21,5 +21,6 @@ "noFallthroughCasesInSwitch": true }, "include": ["src"], + "exclude": ["src/**/__tests__/**"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/docs/superpowers/plans/2026-05-01-issue-7638-admin-typesafe-api.md b/docs/superpowers/plans/2026-05-01-issue-7638-admin-typesafe-api.md new file mode 100644 index 00000000000..49623f4c6e8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-issue-7638-admin-typesafe-api.md @@ -0,0 +1,804 @@ +# Issue 7638 — Typesafe Admin API Client + TanStack Query Rails 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:** Lay down the codegen toolchain, runtime client, and TanStack Query provider for the admin UI. No call-site migrations. + +**Architecture:** A small Node script imports the OpenAPI spec builder from `src/node/hooks/express/openapi.ts`, writes the JSON to a temp file, and runs `openapi-typescript` to produce a checked-in `admin/src/api/schema.d.ts`. The runtime exposes a typed `openapi-fetch` client and `openapi-react-query` hooks via `admin/src/api/client.ts`, mounted under a `` at the admin root. CI re-runs codegen and fails if the working tree is dirty. + +**Tech Stack:** TypeScript, React 19, Vite (rolldown-vite), `openapi-typescript`, `openapi-fetch`, `openapi-react-query`, `@tanstack/react-query`, `@tanstack/react-query-devtools`, `tsx` (devDep, runs the codegen script against TS source). + +**Spec:** `docs/superpowers/specs/2026-05-01-issue-7638-admin-typesafe-api-design.md` + +**Branch:** `chore/admin-typesafe-api-7638` (already cut off `origin/develop`, design doc committed as `41d2babf4`). + +**Working directory for all commands:** `/home/jose/etherpad/etherpad-lite` unless otherwise stated. + +--- + +## File Structure + +**Create:** +- `admin/scripts/gen-api.mjs` — orchestrator script. Invokes `tsx` to run a small TS entry that prints the spec JSON, captures stdout to a temp file, then shells out to `openapi-typescript`. +- `admin/scripts/dump-spec.ts` — TS entry that imports `generateDefinitionForVersion` from the etherpad source and writes the JSON to stdout. +- `admin/src/api/schema.d.ts` — generated. Checked in. +- `admin/src/api/client.ts` — `openapi-fetch` + `openapi-react-query` instances. +- `admin/src/api/QueryProvider.tsx` — TanStack Query provider, dev-only devtools. +- `admin/src/api/__tests__/client.test.ts` — module-load smoke test. +- `admin/README.md` — codegen docs (file does not currently exist). + +**Modify:** +- `src/node/hooks/express/openapi.ts` — add `export { generateDefinitionForVersion }` at the end so external scripts can call the spec builder. Surgical change, no behavior delta. +- `admin/package.json` — add deps and `gen:api` script; amend `build` to run `gen:api` first. +- `admin/src/main.tsx` — wrap router subtree in ``. +- `.github/workflows/frontend-admin-tests.yml` — add a freshness-check step before the existing admin build step. + +**Conventions to honor:** +- Per project memory, the PR will go to `johnmclear/etherpad-lite`, not `ether/etherpad-lite`. +- Commit at the end of each task. +- Run `pnpm ts-check` and admin's lint at the end before declaring done. + +--- + +## Task 1: Export the spec builder from `openapi.ts` + +**Files:** +- Modify: `src/node/hooks/express/openapi.ts:422` (and end of file) + +The script needs to call `generateDefinitionForVersion` from outside the module. It is currently only used within the file. Adding a CommonJS-style export keeps the existing `exports.expressPreSession` style consistent. + +- [ ] **Step 1: Read the current export style at the bottom of the file** + +Run: `grep -n "^exports\." src/node/hooks/express/openapi.ts` +Expected output: a line like `578:exports.expressPreSession = async (hookName:string, {app}:any) => {` + +- [ ] **Step 2: Add the export** + +Append at the end of `src/node/hooks/express/openapi.ts` (after the existing hook export, after line 771): + +```ts +exports.generateDefinitionForVersion = generateDefinitionForVersion; +exports.APIPathStyle = APIPathStyle; +``` + +(Both are needed: the script will call `generateDefinitionForVersion(apiHandler.latestApiVersion, APIPathStyle.FLAT)` and we want a single import surface.) + +- [ ] **Step 3: Verify ts-check still passes** + +Run: `pnpm ts-check` +Expected: no new errors. (If pre-existing errors are present, confirm none are in `openapi.ts`.) + +- [ ] **Step 4: Commit** + +```bash +git add src/node/hooks/express/openapi.ts +git commit -m "$(cat <<'EOF' +feat(api): export generateDefinitionForVersion from openapi hook + +Required by the admin codegen script (#7638) to dump the OpenAPI spec +without booting Express. No behavior change for the request hook. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Add admin dependencies + +**Files:** +- Modify: `admin/package.json` + +- [ ] **Step 1: Read the current `admin/package.json`** + +Run: `cat admin/package.json` +Expected: confirm there is a `dependencies` block and a `devDependencies` block. + +- [ ] **Step 2: Install runtime deps** + +Run: +```bash +pnpm --filter admin add @tanstack/react-query @tanstack/react-query-devtools openapi-fetch openapi-react-query +``` +Expected: deps added under `dependencies`. `pnpm-lock.yaml` updated at repo root. + +- [ ] **Step 3: Install dev deps** + +Run: +```bash +pnpm --filter admin add -D openapi-typescript tsx +``` +Expected: deps added under `devDependencies`. + +- [ ] **Step 4: Sanity check the diff** + +Run: `git diff admin/package.json` +Expected: six new entries (4 deps, 2 devDeps), no other changes. + +- [ ] **Step 5: Commit** + +```bash +git add admin/package.json pnpm-lock.yaml +git commit -m "$(cat <<'EOF' +chore(admin): add OpenAPI codegen + TanStack Query deps (#7638) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Write the spec-dump entry + +**Files:** +- Create: `admin/scripts/dump-spec.ts` + +This file is intentionally tiny. It runs under `tsx` so it can resolve the etherpad-lite TypeScript source directly. + +- [ ] **Step 1: Create the file** + +```ts +// 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 stdout. Invoked by +// admin/scripts/gen-api.mjs via `tsx`. + +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const repoRoot = path.resolve(__dirname, '..', '..'); + +// `openapi.ts` uses CommonJS-style `exports.*` despite living in an ESM repo, +// so we go through createRequire to load it cleanly. +import { createRequire } from 'node:module'; +const require = createRequire(pathToFileURL(path.join(repoRoot, 'src', 'node', 'hooks', 'express', 'openapi.ts')).toString()); + +const apiHandler = require('../../src/node/handler/APIHandler'); +const { generateDefinitionForVersion, APIPathStyle } = + require('../../src/node/hooks/express/openapi') as { + generateDefinitionForVersion: (version: string, style?: string) => unknown; + APIPathStyle: { FLAT: string; REST: string }; + }; + +const spec = generateDefinitionForVersion(apiHandler.latestApiVersion, APIPathStyle.FLAT); +process.stdout.write(JSON.stringify(spec, null, 2)); +``` + +- [ ] **Step 2: Smoke-test the entry** + +Run: +```bash +cd admin && pnpm exec tsx scripts/dump-spec.ts > /tmp/etherpad-spec.json +echo "exit: $?" +head -c 200 /tmp/etherpad-spec.json +``` +Expected: exit 0; the head output starts with `{` and contains `"openapi"` and `"paths"`. + +If the script fails because importing `openapi.ts` triggers errors from `Settings`, debug by running `pnpm exec tsx -e "require('../src/node/hooks/express/openapi.ts')"` from `admin/` to isolate. The most likely fix is to set `EP_LOG_DESTINATION=stderr` or similar; do not refactor `Settings` from this PR — note the issue and ask before expanding scope. + +- [ ] **Step 3: Commit** + +```bash +git add admin/scripts/dump-spec.ts +git commit -m "$(cat <<'EOF' +chore(admin): add OpenAPI spec dump entry (#7638) + +Loaded via tsx by gen-api.mjs in the next commit. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Write the codegen orchestrator + +**Files:** +- Create: `admin/scripts/gen-api.mjs` + +- [ ] **Step 1: Create the file** + +```js +// admin/scripts/gen-api.mjs +// +// Regenerates admin/src/api/schema.d.ts from the live OpenAPI spec exported +// by src/node/hooks/express/openapi.ts. Run via `pnpm --filter admin gen:api`. + +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const adminRoot = path.resolve(here, '..'); +const outFile = path.join(adminRoot, 'src', 'api', 'schema.d.ts'); + +const tmpDir = mkdtempSync(path.join(tmpdir(), 'etherpad-openapi-')); +const specPath = path.join(tmpDir, 'spec.json'); + +try { + const dump = spawnSync('pnpm', ['exec', 'tsx', 'scripts/dump-spec.ts'], { + cwd: adminRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); + if (dump.status !== 0) { + console.error(`dump-spec.ts failed with exit code ${dump.status}`); + process.exit(dump.status ?? 1); + } + writeFileSync(specPath, dump.stdout, 'utf8'); + + const gen = spawnSync( + 'pnpm', + ['exec', 'openapi-typescript', specPath, '-o', outFile], + { cwd: adminRoot, stdio: 'inherit' }, + ); + if (gen.status !== 0) { + console.error(`openapi-typescript failed with exit code ${gen.status}`); + process.exit(gen.status ?? 1); + } + + const header = + `// GENERATED — do not edit. Run \`pnpm --filter admin gen:api\` to regenerate.\n` + + `// Source: src/node/hooks/express/openapi.ts (#7638)\n\n`; + const body = readFileSync(outFile, 'utf8'); + writeFileSync(outFile, header + body, 'utf8'); + + console.log(`Wrote ${path.relative(process.cwd(), outFile)}`); +} finally { + rmSync(tmpDir, { recursive: true, force: true }); +} +``` + +- [ ] **Step 2: Add the `gen:api` script and amend `build`** + +In `admin/package.json`, edit the `scripts` block. Before: + +```json +"scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "build-copy": "tsc && vite build --outDir ../src/templates/admin --emptyOutDir", + "preview": "vite preview" +} +``` + +After: + +```json +"scripts": { + "dev": "vite", + "gen:api": "node scripts/gen-api.mjs", + "build": "pnpm gen:api && tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir", + "preview": "vite preview" +} +``` + +- [ ] **Step 3: Run codegen and confirm output** + +Run: +```bash +mkdir -p admin/src/api +pnpm --filter admin gen:api +ls -la admin/src/api/schema.d.ts +head -10 admin/src/api/schema.d.ts +``` +Expected: +- exit 0 +- `schema.d.ts` exists, > 1 KB +- first two lines are the generated header +- subsequent lines contain `export interface paths` and entries like `"/api/{version}/createGroup"` + +- [ ] **Step 4: Commit script + package.json + generated schema** + +```bash +git add admin/scripts/gen-api.mjs admin/package.json admin/src/api/schema.d.ts +git commit -m "$(cat <<'EOF' +chore(admin): wire OpenAPI codegen into build (#7638) + +Adds `gen:api` script and amends `build`/`build-copy` to regenerate +admin/src/api/schema.d.ts before compiling. The generated file is +checked in so it shows up in PR review and so a fresh checkout doesn't +need codegen to typecheck. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Runtime client module + +**Files:** +- Create: `admin/src/api/client.ts` + +- [ ] **Step 1: Create the file** + +```ts +// admin/src/api/client.ts +// +// Typed HTTP client and TanStack Query hooks derived from the generated +// OpenAPI schema. Regenerate the schema with `pnpm --filter admin gen:api`. + +import createClient from 'openapi-fetch'; +import createQueryHooks from 'openapi-react-query'; +import type { paths } from './schema'; + +export const fetchClient = createClient({ baseUrl: '/' }); +export const $api = createQueryHooks(fetchClient); +``` + +- [ ] **Step 2: Confirm typecheck passes** + +Run: `pnpm --filter admin exec tsc --noEmit` +Expected: no errors. If `paths` is missing from `schema.d.ts`, rerun `pnpm --filter admin gen:api` (it should have produced an `export interface paths` already in Task 4). + +- [ ] **Step 3: Commit** + +```bash +git add admin/src/api/client.ts +git commit -m "$(cat <<'EOF' +feat(admin): typed openapi-fetch + react-query client (#7638) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Query provider with dev-only devtools + +**Files:** +- Create: `admin/src/api/QueryProvider.tsx` + +- [ ] **Step 1: Create the file** + +```tsx +// admin/src/api/QueryProvider.tsx +// +// TanStack Query provider for the admin UI. Devtools are loaded lazily and +// only in dev builds so they don't ship to production. + +import { lazy, Suspense, useState, type ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const Devtools = import.meta.env.DEV + ? lazy(() => + import('@tanstack/react-query-devtools').then((m) => ({ + default: m.ReactQueryDevtools, + })), + ) + : null; + +export const QueryProvider = ({ children }: { children: ReactNode }) => { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: true, + }, + }, + }), + ); + + return ( + + {children} + {Devtools && ( + + + + )} + + ); +}; +``` + +- [ ] **Step 2: Typecheck** + +Run: `pnpm --filter admin exec tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add admin/src/api/QueryProvider.tsx +git commit -m "$(cat <<'EOF' +feat(admin): TanStack Query provider, dev-only devtools (#7638) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Mount the provider at the admin root + +**Files:** +- Modify: `admin/src/main.tsx` + +- [ ] **Step 1: Read the file to confirm current shape** + +Run: `cat admin/src/main.tsx` +Expected: matches the structure where `` wraps `` wraps `` inside ``. + +- [ ] **Step 2: Edit `admin/src/main.tsx`** + +Add the import after the existing imports: + +```tsx +import { QueryProvider } from './api/QueryProvider.tsx'; +``` + +Wrap the existing `...` subtree in ``. The render block becomes: + +```tsx +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + + , +) +``` + +(Provider order matters only for context lookups; placing `QueryProvider` outside `I18nextProvider` is fine because it does not consume i18n.) + +- [ ] **Step 3: Typecheck** + +Run: `pnpm --filter admin exec tsc --noEmit` +Expected: no errors. + +- [ ] **Step 4: Build the admin bundle** + +Run: `pnpm --filter admin run build` +Expected: build succeeds. Output indicates one bundle (no extra chunk for devtools in production — confirm by grepping the `dist/` for `query-devtools` strings; should be absent). + +```bash +grep -rn "ReactQueryDevtools" admin/dist/ 2>/dev/null | head +``` +Expected: no matches (production bundle excludes devtools). + +- [ ] **Step 5: Commit** + +```bash +git add admin/src/main.tsx +git commit -m "$(cat <<'EOF' +feat(admin): mount TanStack Query provider at root (#7638) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Smoke test for the client module + +**Files:** +- Create: `admin/src/api/__tests__/client.test.ts` + +The admin package does not yet ship a unit test runner. Reuse whatever the rest of admin uses for tests if anything; otherwise, this test runs under `tsx --test` (Node's built-in test runner, no extra deps). Confirm at Step 1. + +- [ ] **Step 1: Detect the test runner** + +Run: +```bash +grep -E '"(test|vitest|jest)"' admin/package.json +ls admin/vitest.config.* admin/jest.config.* 2>/dev/null +``` + +If admin has no runner configured, use Node's built-in `node:test` (which `tsx` supports). + +- [ ] **Step 2: Create the test file** + +```ts +// admin/src/api/__tests__/client.test.ts +// +// Smoke test that the OpenAPI client module loads and exposes the expected +// surface. Catches toolchain wiring regressions (missing peer deps, +// generator output that doesn't export `paths`, etc.). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +test('client module exports fetchClient and $api', async () => { + const mod = await import('../client.ts'); + assert.ok(mod.fetchClient, 'fetchClient export is present'); + assert.ok(mod.$api, '$api export is present'); + assert.equal(typeof mod.fetchClient.GET, 'function', 'fetchClient.GET is a function'); + assert.equal(typeof mod.$api.useQuery, 'function', '$api.useQuery is a function'); +}); +``` + +- [ ] **Step 3: Add a `test` script to `admin/package.json`** (only if one does not already exist) + +If `admin/package.json` has no `"test"` script, add: + +```json +"test": "tsx --test src/api/__tests__/client.test.ts" +``` + +If admin already has a test runner (e.g. `vitest`), skip the script addition and instead place the test at the location the existing runner picks up (`*.test.ts` is conventional for both vitest and node:test). + +- [ ] **Step 4: Run the test** + +Run: `pnpm --filter admin test` +Expected: 1 test passing. + +- [ ] **Step 5: Commit** + +```bash +git add admin/src/api/__tests__/client.test.ts admin/package.json +git commit -m "$(cat <<'EOF' +test(admin): smoke test for typed openapi-fetch client (#7638) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: CI freshness check + +**Files:** +- Modify: `.github/workflows/frontend-admin-tests.yml` + +Add a step before the existing `Build admin frontend` step that runs codegen and fails if the working tree changed. + +- [ ] **Step 1: Read the current workflow** + +Run: `grep -n "Build admin frontend" .github/workflows/frontend-admin-tests.yml` +Expected: a single match around the build step that runs `pnpm run build` from `working-directory: admin`. + +- [ ] **Step 2: Insert the freshness check** + +Insert immediately before the `Build admin frontend` step: + +```yaml + - name: Verify admin OpenAPI schema is up to date + working-directory: admin + run: | + pnpm gen:api + if ! git diff --exit-code src/api/schema.d.ts; then + echo "" + echo "::error::admin/src/api/schema.d.ts is out of date." + echo "Run \`pnpm --filter admin gen:api\` and commit the result." + exit 1 + fi +``` + +- [ ] **Step 3: Lint the YAML** + +Run: `python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/frontend-admin-tests.yml'))" && echo OK` +Expected: `OK`. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/frontend-admin-tests.yml +git commit -m "$(cat <<'EOF' +ci(admin): verify generated OpenAPI schema is up to date (#7638) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: Documentation + +**Files:** +- Create: `admin/README.md` + +- [ ] **Step 1: Create the file** + +```markdown +# Admin UI + +Vite + React 19 single-page app served at `/admin`. Talks to the backend over +socket.io for the existing settings / plugins / pads pages, and (when +endpoints are added to the OpenAPI spec) over a typed REST client. + +## Scripts + +| Script | What it does | +| -------------------- | -------------------------------------------------------- | +| `pnpm dev` | Vite dev server. Expects an etherpad backend on :9001. | +| `pnpm gen:api` | Regenerates `src/api/schema.d.ts` from the OpenAPI spec. | +| `pnpm build` | `gen:api` + `tsc` + `vite build`. | +| `pnpm build-copy` | Same, but writes into `../src/templates/admin`. | +| `pnpm test` | Smoke tests for the API client wiring. | +| `pnpm lint` | ESLint. | + +## Typed API client + +The admin uses [`openapi-typescript`] to generate types from +`src/node/hooks/express/openapi.ts`, [`openapi-fetch`] for typed requests, and +[`openapi-react-query`] for TanStack Query bindings. + +[`openapi-typescript`]: https://github.com/openapi-ts/openapi-typescript +[`openapi-fetch`]: https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-fetch +[`openapi-react-query`]: https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-react-query + +### Regenerating the schema + +```sh +pnpm --filter admin gen:api +``` + +This runs `admin/scripts/gen-api.mjs`, which loads +`src/node/hooks/express/openapi.ts`, calls `generateDefinitionForVersion` for +the latest API version, pipes the JSON through `openapi-typescript`, and +writes the result to `admin/src/api/schema.d.ts`. The generated file is +checked in. + +Run `gen:api` after any change to: + +- `src/node/hooks/express/openapi.ts` +- `src/node/handler/APIHandler.ts` (changes to `latestApiVersion`) +- the resource definitions referenced by `openapi.ts` + +### CI freshness check + +`.github/workflows/frontend-admin-tests.yml` runs `pnpm gen:api` and fails the +build if `admin/src/api/schema.d.ts` is out of date. If you see the failure +locally, run `pnpm --filter admin gen:api` and commit the regenerated file. + +### Using the client + +```tsx +import { $api } from './api/client'; + +const SettingsPanel = () => { + const { data } = $api.useQuery('get', '/admin/settings'); // example + return
{JSON.stringify(data, null, 2)}
; +}; +``` + +The admin endpoints are not yet present in the OpenAPI spec — this client is +in place to support upcoming work (see issue #7638 follow-up). For now, it is +exercised only by the smoke test. +``` + +- [ ] **Step 2: Commit** + +```bash +git add admin/README.md +git commit -m "$(cat <<'EOF' +docs(admin): document OpenAPI codegen workflow (#7638) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 11: Full verification pass + +No new files — this task confirms the work is green end-to-end before pushing. + +- [ ] **Step 1: Clean rebuild** + +Run: +```bash +pnpm --filter admin gen:api +pnpm --filter admin run build +``` +Expected: both succeed. + +- [ ] **Step 2: Repo-wide typecheck** + +Run: `pnpm ts-check` +Expected: no new errors versus baseline. If there are pre-existing errors, confirm none are in files this PR touched. + +- [ ] **Step 3: Admin tests** + +Run: `pnpm --filter admin test` +Expected: 1 test passing. + +- [ ] **Step 4: Backend unit tests** (sanity — `openapi.ts` change) + +Run: `pnpm test` (or the narrowest available suite covering the API hook; if the full suite is slow, run specs that exercise `openapi.ts` only). +Expected: green. + +- [ ] **Step 5: Confirm devtools absent from production bundle** + +Run: `grep -rn "ReactQueryDevtools" admin/dist/ 2>/dev/null` +Expected: zero matches. + +- [ ] **Step 6: Manual smoke** + +Per project convention (memory: install plugin/branch for manual test), install this branch on a local etherpad and: +- Open `/admin/` in a dev build (`pnpm --filter admin dev`). Confirm the React Query devtools panel button appears in the bottom corner. +- Open `/admin/` in the production-built bundle. Confirm devtools panel is absent. +- Click through plugins / settings / pads / shout pages and confirm no regression versus pre-PR behavior (existing socket.io flows unchanged). + +Document the smoke results in the PR description. + +- [ ] **Step 7: Push** + +```bash +git push -u fork chore/admin-typesafe-api-7638 +``` + +- [ ] **Step 8: Open PR** + +```bash +gh pr create \ + --repo johnmclear/etherpad-lite \ + --title "chore(admin): typesafe API client + TanStack Query rails (#7638)" \ + --body "$(cat <<'EOF' +## Summary + +Lays down the rails for a typesafe, OpenAPI-derived admin API client backed by TanStack Query. Closes #7638. + +- Codegen toolchain (`pnpm --filter admin gen:api`) producing `admin/src/api/schema.d.ts` from `src/node/hooks/express/openapi.ts`. +- Runtime client (`openapi-fetch` + `openapi-react-query`). +- `` mounted at the admin root with dev-only devtools. +- CI freshness check on the generated schema. +- `admin/README.md` documenting the workflow. + +**No call sites migrated.** Admin endpoints aren't in the OpenAPI spec yet — that gap is filed as a follow-up issue and must land before any migration is useful. #7601 should rebase onto this branch. + +**Semver:** patch — build tooling + currently-unused runtime libs, no observable behavior change. + +## Test plan + +- [x] `pnpm --filter admin gen:api` runs clean +- [x] `pnpm --filter admin run build` succeeds +- [x] `pnpm --filter admin test` passes (smoke test) +- [x] `pnpm ts-check` clean +- [x] Production bundle does not contain devtools +- [x] Manual smoke: dev build shows devtools, prod build hides them, existing socket.io pages unaffected + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 9: Trigger Qodo review** (per project convention) + +```bash +gh pr comment --repo johnmclear/etherpad-lite --body "/review" +``` + +- [ ] **Step 10: File the spec-coverage follow-up issue** + +Create a new issue on `ether/etherpad` titled "Document admin endpoints in the OpenAPI spec" and link from the PR body. The issue should note that 7638 rails are unused until admin endpoints are added. + +--- + +## Risk register (carried from spec) + +- **`openapi.ts` not cleanly importable.** If `dump-spec.ts` fails to import the module due to side effects (Settings, log4js init), pause and ask before refactoring `Settings`. A common workaround is to set `EP_LOG_DESTINATION=stderr` or set `NODE_ENV=production`. Do not silently expand scope. +- **Generated schema differs by Node version.** `openapi-typescript` output is deterministic, but if a contributor sees a phantom diff, confirm Node major matches the CI matrix (22/24/25 today; CI uses 24 on PRs). +- **Bundle size.** ~12 KB gzipped added to the admin bundle even with no call sites. Acceptable; flagged in the PR body for transparency. + +## Out of scope (do not pull in) + +- Adding admin endpoints to the OpenAPI spec. +- Migrating any `fetch()` site in `admin/src/`. +- Backend handler changes. +- Pad-side frontend changes. 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-01-issue-7638-admin-typesafe-api-design.md b/docs/superpowers/specs/2026-05-01-issue-7638-admin-typesafe-api-design.md new file mode 100644 index 00000000000..35a0fa1a538 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-issue-7638-admin-typesafe-api-design.md @@ -0,0 +1,198 @@ +# Issue 7638 — Typesafe Admin API Client + TanStack Query Rails + +**Status:** design approved 2026-05-01 +**Issue:** https://github.com/ether/etherpad/issues/7638 +**Related:** #7601 (introduces new admin REST sites that will adopt these rails) + +## Goal + +Lay down the toolchain and runtime rails for a typesafe, OpenAPI-derived admin +API client backed by TanStack Query. Do not migrate any existing call sites. + +## Why rails-only + +The issue's framing ("migrate every `useEffect`+`fetch` site") overstates what is +actually present in `admin/src/` today. + +- The only REST `fetch()` sites are `App.tsx` and `LoginScreen.tsx` (both POST to + `/admin-auth/`) and `i18n.ts` (locale loading). +- All admin pages with real data flow (Settings, Plugins, Pads, Shout) run over + socket.io + zustand, not REST. +- The OpenAPI spec produced by `src/node/hooks/express/openapi.ts` only covers + the public Etherpad HTTP API under `/api/{version}/*`. It documents zero admin + endpoints — no `/admin-auth/`, no future `/admin/*` REST endpoints from #7601. + +So the generated client has nothing in `admin/src/` to type today. The value of +landing this PR now is to get the rails in place so #7601 (and any subsequent +admin REST work) can adopt them on day one. + +A separate issue will be filed to add admin endpoint coverage to the OpenAPI +spec; until that lands, no migrations are useful. + +## Out of scope + +- Admin endpoint coverage in the OpenAPI spec (separate issue). +- Migrating any existing `fetch()` call site. +- Backend changes. +- Pad-side frontend. + +## Toolchain + +| Package | Type | Purpose | +| -------------------------------- | -------------- | ---------------------------------------- | +| `openapi-typescript` | devDependency | Generates `.d.ts` from the OpenAPI spec | +| `openapi-fetch` | dependency | Typed `fetch` wrapper | +| `openapi-react-query` | dependency | TanStack Query bindings over the client | +| `@tanstack/react-query` | dependency | Query runtime | +| `@tanstack/react-query-devtools` | dependency | Dev-only devtools panel | + +All added to `admin/package.json`. No version pinning beyond standard caret +ranges; pick the latest stable at implementation time. + +## Codegen (option 3, hybrid) + +One checked-in artifact, CI-enforced freshness. + +### Script: `admin/scripts/gen-api.mjs` + +1. Imports the spec-building entry point from + `src/node/hooks/express/openapi.ts` (or a thin wrapper module that calls + the spec builder without booting Express). Writes the resulting spec JSON + to a temp file in `os.tmpdir()`. +2. Shells out: + `openapi-typescript -o admin/src/api/schema.d.ts`. +3. Prepends a generated header comment to the output: + `// GENERATED — do not edit. Run \`pnpm gen:api\` to regenerate.` +4. Removes the temp file. + +If `openapi.ts` cannot be loaded as an ES module without side effects (e.g. +because it imports settings or boots an Express app at import time), the +implementation must extract the pure spec-builder into a dedicated module so +the script can call it cleanly. That refactor is in scope; the touch should be +minimal. + +### Wiring + +- `admin/package.json`: + - `"scripts": { "gen:api": "node scripts/gen-api.mjs", ... }`. + - `"build"` is amended to run `gen:api` before `tsc && vite build` so a + fresh checkout builds without manual steps. +- Root `package.json`: existing admin build entry point invokes the same + script (or relies on `admin/package.json`'s amended `build`). + +### Generated output + +- Path: `admin/src/api/schema.d.ts`. +- Checked in. +- First line: generated-header comment. + +### CI freshness check + +A CI job (folded into the existing admin lint workflow if practical, otherwise +a new step) runs: + +```bash +pnpm --filter admin gen:api +git diff --exit-code admin/src/api/schema.d.ts +``` + +If the diff is non-empty, CI fails with a message instructing the contributor +to run `pnpm --filter admin gen:api` and commit the result. + +## Runtime client + +### `admin/src/api/client.ts` + +```ts +import createClient from "openapi-fetch"; +import createQueryHooks from "openapi-react-query"; +import type { paths } from "./schema"; + +export const fetchClient = createClient({ baseUrl: "/" }); +export const $api = createQueryHooks(fetchClient); +``` + +### `admin/src/api/QueryProvider.tsx` + +- Wraps children in `QueryClientProvider`. +- Single shared `QueryClient` constructed once (module-level or `useState` + initializer), with defaults: + - `staleTime: 30_000` + - `refetchOnWindowFocus: true` + - Other defaults left at library defaults. +- Mounts `ReactQueryDevtools` only when `import.meta.env.DEV` is true. Use a + dynamic `import()` so devtools do not ship in the production bundle. + +### `admin/src/main.tsx` + +Wrap `` in ``. No other changes. + +## Documentation + +`admin/README.md` (create or extend) documents: + +- How to regenerate: `pnpm --filter admin gen:api`. +- When to regenerate: after any change to `src/node/hooks/express/openapi.ts` + or anything that affects the spec it builds. +- What gets regenerated: `admin/src/api/schema.d.ts` only. +- The CI freshness check and how to recover from a failing check. +- A short "how to use the client" snippet showing + `$api.useQuery("get", "/some/path")` once admin endpoints are in the spec. + +## Tests + +- **Module-load smoke test** (`admin/src/api/__tests__/client.test.ts` or + similar, matching whatever test infra `admin/` already uses): imports + `$api` and `fetchClient`, asserts both are defined. This catches toolchain + wiring breakage (missing peer deps, bad export shape, etc.). +- **CI freshness check** (above) is the test for spec/schema sync. +- **Manual smoke after PR install:** install the branch on the local + Etherpad, open `/admin`, confirm: + - Existing socket.io flows (settings, plugins, pads) still work — no + regressions from the `` wrap. + - React Query devtools panel appears in a dev build (`pnpm --filter admin + dev`) and is absent from a production build. + +Note: per project convention, the user expects automated tests before manual +verification, but the manual smoke is unavoidable here because devtools +visibility and provider wrap are runtime concerns. The smoke check is a +secondary safety net, not the primary test strategy. + +## Branch / PR plan + +- Fork: `johnmclear/etherpad-lite` (per project convention; never commit + directly to `ether/etherpad-lite`). +- Branch: `chore/admin-typesafe-api-7638`. +- Base: latest `main` of the fork, after syncing from `ether/etherpad-lite`. +- PR title: `chore(admin): typesafe API client + TanStack Query rails`. +- PR body declares semver: **patch** (build tooling + unused runtime libs; + no observable behavior change). +- PR body links #7638 and notes: + - Rails-only — no call site migrations. + - Separate spec-coverage issue to follow. + - #7601 should rebase onto this branch once merged. + +## Risks + +- **`openapi.ts` not cleanly importable.** If pulling the spec builder out + requires touching production paths, that risk needs a small refactor PR + first. Mitigation: keep the extraction surgical; if it grows, split into + its own PR and rebase 7638 on top. +- **Bundle size.** TanStack Query + react-query bindings add ~12 KB gzipped + to the admin bundle even with no call sites using it. Acceptable for an + internal admin UI; flag in PR body for transparency. +- **Provider wrap regressions.** `` wrapping `` should + be inert for socket.io paths but the manual smoke confirms. + +## Definition of done + +- `pnpm --filter admin gen:api` runs cleanly on a fresh checkout. +- `pnpm --filter admin build` succeeds. +- `admin/src/api/schema.d.ts` is checked in with the generated header. +- `` wraps ``; devtools visible in dev, absent in + production build. +- CI freshness check is wired and passing. +- `admin/README.md` documents the codegen workflow. +- Manual smoke confirms no regression in existing socket.io-driven pages. +- PR opened against `johnmclear/etherpad-lite`, semver labelled patch, + Qodo `/review` triggered after push. 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..547aa478035 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-issue-7693-admin-openapi-design.md @@ -0,0 +1,304 @@ +# 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`. The merge therefore happens at JSON-dump time, +before `openapi-typescript` runs. + +### Two clients, one schema + +The merged schema covers two surfaces with different baseUrls (public API +under `/api//`, admin endpoints at root). A single runtime client +with one `baseUrl` cannot target both correctly. `admin/src/api/client.ts` +therefore narrows the generated `paths` interface by URL prefix and exports +two clients: + +```ts +type AdminPath = Extract; +type PublicPath = Exclude; +export const fetchClient = createClient>({ baseUrl: API_BASE_URL }); +export const adminFetchClient = createClient>({ baseUrl: '/' }); +export const $api = createQueryHooks(fetchClient); +export const $adminApi = createQueryHooks(adminFetchClient); +``` + +Narrowing at the type level means TypeScript rejects calling an admin path +on `fetchClient` (or vice versa) at compile time — the runtime baseUrl +mismatch is unrepresentable. + +## 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 **conditionally** +mounts: + +``` +GET /admin/openapi.json (CORS: *) +``` + +The route is gated by `settings.adminOpenAPI.enabled`, **default `false`**, +per the project's "new features behind a flag, off by default" policy +(CONTRIBUTING.md, AGENTS.MD, best_practices.md). When the flag is off, +`expressPreSession` returns early and the route is dormant. + +When enabled, the route registers 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) that operators choose to +expose. + +## 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/pnpm-lock.yaml b/pnpm-lock.yaml index 661fd42d5eb..d8dff4f8f77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,18 @@ importers: '@radix-ui/react-switch': specifier: ^1.2.6 version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-query': + specifier: ^5.100.9 + version: 5.100.9(react@19.2.6) + '@tanstack/react-query-devtools': + specifier: ^5.100.9 + version: 5.100.9(@tanstack/react-query@5.100.9(react@19.2.6))(react@19.2.6) + openapi-fetch: + specifier: ^0.17.0 + version: 0.17.0 + openapi-react-query: + specifier: ^0.5.4 + version: 0.5.4(@tanstack/react-query@5.100.9(react@19.2.6))(openapi-fetch@0.17.0) devDependencies: '@radix-ui/react-dialog': specifier: ^1.1.15 @@ -89,6 +101,9 @@ importers: lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.6) + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@6.0.3) react: specifier: ^19.2.6 version: 19.2.6 @@ -107,6 +122,9 @@ importers: socket.io-client: specifier: ^4.8.3 version: 4.8.3 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -159,7 +177,7 @@ importers: version: 0.129.0 vitepress: specifier: ^2.0.0-alpha.17 - version: 2.0.0-alpha.17(@types/node@25.6.2)(esbuild@0.28.0)(jwt-decode@4.0.0)(oxc-minify@0.129.0)(postcss@8.5.14)(tsx@4.21.0)(typescript@6.0.3) + version: 2.0.0-alpha.17(@types/node@25.6.2)(change-case@5.4.4)(esbuild@0.28.0)(jwt-decode@4.0.0)(oxc-minify@0.129.0)(postcss@8.5.14)(tsx@4.21.0)(typescript@6.0.3) src: dependencies: @@ -1674,6 +1692,16 @@ packages: peerDependencies: '@redis/client': ^5.12.1 + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.14': + resolution: {integrity: sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/binding-android-arm64@1.0.0-rc.18': resolution: {integrity: sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1839,6 +1867,23 @@ packages: '@swc/helpers@0.5.21': resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@tanstack/query-core@5.100.9': + resolution: {integrity: sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==} + + '@tanstack/query-devtools@5.100.9': + resolution: {integrity: sha512-gqiptrTIhbK2PuCaPRHmWXfJG1NGYVFpAr0HqogEqiSBNB5xDz6fmesQt7w4WgMOqOQPnPHJ3ZDMuhDaXvNO8g==} + + '@tanstack/react-query-devtools@5.100.9': + resolution: {integrity: sha512-mM3slaVGXJmz+pOLgXdANj75ikgQCyudyl3kmFvm6brI1JyVeY/+IeD17uDHIvZrD8hfoO2sdZ54RFsHdYAuhA==} + peerDependencies: + '@tanstack/react-query': ^5.100.9 + react: ^18 || ^19 + + '@tanstack/react-query@5.100.9': + resolution: {integrity: sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==} + peerDependencies: + react: ^18 || ^19 + '@tediousjs/connection-string@1.1.0': resolution: {integrity: sha512-z9ZBWEG+8pIB5V1zYzlRPXx0oRJ5H7coPnMQK8EZOw03UTPI9Umn6viL36f5w+CuqkKsnCM50RVStpjZmR0Bng==} @@ -2483,6 +2528,10 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2725,6 +2774,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2765,6 +2817,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -3597,9 +3652,6 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} - get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -3814,6 +3866,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + individual@3.0.0: resolution: {integrity: sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==} @@ -4007,6 +4063,10 @@ packages: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-md4@0.3.2: resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} @@ -4376,6 +4436,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -4603,6 +4667,15 @@ packages: resolution: {integrity: sha512-1tfLpC+7CajKv08vuFOLm4t8rJvJyqKuyau5IIIrGg3YuQYhmP7JqDL6p6WnbDCusmh3krCrKXoHB6hLF/iHcQ==} engines: {node: '>=20.0.0'} + openapi-fetch@0.17.0: + resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} + + openapi-react-query@0.5.4: + resolution: {integrity: sha512-V9lRiozjHot19/BYSgXYoyznDxDJQhEBSdi26+SJ0UqjMANLQhkni4XG+Z7e3Ag7X46ZLMrL9VxYkghU3QvbWg==} + peerDependencies: + '@tanstack/react-query': ^5.80.0 + openapi-fetch: ^0.17.0 + openapi-schema-validation@0.4.2: resolution: {integrity: sha512-K8LqLpkUf2S04p2Nphq9L+3bGFh/kJypxIG2NVGKX0ffzT4NQI9HirhiY6Iurfej9lCu7y4Ndm4tv+lm86Ck7w==} @@ -4612,6 +4685,15 @@ packages: openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + openapi-typescript-helpers@0.1.0: + resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + option@0.2.4: resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} @@ -4655,6 +4737,10 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -4755,6 +4841,10 @@ packages: engines: {node: '>=18'} hasBin: true + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + png-js@1.1.0: resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==} @@ -5374,6 +5464,10 @@ packages: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -5538,6 +5632,10 @@ packages: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -5647,6 +5745,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -5964,6 +6065,9 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -7046,6 +7150,29 @@ snapshots: dependencies: '@redis/client': 5.12.1(@opentelemetry/api@1.9.1) + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.14(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rolldown/binding-android-arm64@1.0.0-rc.18': optional: true @@ -7174,6 +7301,21 @@ snapshots: dependencies: tslib: 2.8.1 + '@tanstack/query-core@5.100.9': {} + + '@tanstack/query-devtools@5.100.9': {} + + '@tanstack/react-query-devtools@5.100.9(@tanstack/react-query@5.100.9(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/query-devtools': 5.100.9 + '@tanstack/react-query': 5.100.9(react@19.2.6) + react: 19.2.6 + + '@tanstack/react-query@5.100.9(react@19.2.6)': + dependencies: + '@tanstack/query-core': 5.100.9 + react: 19.2.6 + '@tediousjs/connection-string@1.1.0': {} '@tootallnate/quickjs-emscripten@0.23.0': {} @@ -7811,12 +7953,13 @@ snapshots: '@vueuse/shared': 14.2.1(vue@3.5.30(typescript@6.0.3)) vue: 3.5.30(typescript@6.0.3) - '@vueuse/integrations@14.2.1(focus-trap@8.0.0)(jwt-decode@4.0.0)(vue@3.5.30(typescript@6.0.3))': + '@vueuse/integrations@14.2.1(change-case@5.4.4)(focus-trap@8.0.0)(jwt-decode@4.0.0)(vue@3.5.30(typescript@6.0.3))': dependencies: '@vueuse/core': 14.2.1(vue@3.5.30(typescript@6.0.3)) '@vueuse/shared': 14.2.1(vue@3.5.30(typescript@6.0.3)) vue: 3.5.30(typescript@6.0.3) optionalDependencies: + change-case: 5.4.4 focus-trap: 8.0.0 jwt-decode: 4.0.0 @@ -7872,6 +8015,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-colors@4.1.3: {} + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -8131,6 +8276,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + change-case@5.4.4: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -8163,6 +8310,8 @@ snapshots: color-name@1.1.4: {} + colorette@1.4.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -8287,6 +8436,12 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -8671,10 +8826,10 @@ snapshots: '@rushstack/eslint-patch': 1.16.1 '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0)(typescript@6.0.3) '@typescript-eslint/parser': 7.18.0(eslint@10.3.0)(typescript@6.0.3) - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0)(eslint@10.3.0) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0))(eslint@10.3.0) eslint-plugin-cypress: 2.15.2(eslint@10.3.0) eslint-plugin-eslint-comments: 3.2.0(eslint@10.3.0) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1)(eslint@10.3.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0))(eslint@10.3.0))(eslint@10.3.0) eslint-plugin-mocha: 10.5.0(eslint@10.3.0) eslint-plugin-n: 17.24.0(eslint@10.3.0)(typescript@6.0.3) eslint-plugin-prefer-arrow: 1.2.3(eslint@10.3.0) @@ -8695,7 +8850,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0)(eslint@10.3.0): + eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0))(eslint@10.3.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@8.1.1) @@ -8706,18 +8861,18 @@ snapshots: stable-hash: 0.0.5 tinyglobby: 0.2.16 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1)(eslint@10.3.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0))(eslint@10.3.0))(eslint@10.3.0) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1)(eslint@10.3.0): + eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0))(eslint@10.3.0))(eslint@10.3.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@10.3.0)(typescript@6.0.3) eslint: 10.3.0 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0)(eslint@10.3.0) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0))(eslint@10.3.0) transitivePeerDependencies: - supports-color @@ -8739,7 +8894,7 @@ snapshots: eslint: 10.3.0 ignore: 5.3.2 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1)(eslint@10.3.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0))(eslint@10.3.0))(eslint@10.3.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -8750,7 +8905,7 @@ snapshots: doctrine: 2.1.0 eslint: 10.3.0 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1)(eslint@10.3.0) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0))(eslint@10.3.0))(eslint@10.3.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -9162,10 +9317,6 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.0: - dependencies: - resolve-pkg-maps: 1.0.0 - get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -9421,6 +9572,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + i18next-browser-languagedetector@8.2.1: dependencies: '@babel/runtime': 7.28.6 @@ -9453,6 +9611,8 @@ snapshots: imurmurhash@0.1.4: {} + index-to-position@1.2.0: {} + individual@3.0.0: {} inherits@2.0.4: {} @@ -9630,6 +9790,8 @@ snapshots: js-cookie@3.0.5: {} + js-levenshtein@1.1.6: {} + js-md4@0.3.2: {} js-md5@0.8.3: {} @@ -10024,6 +10186,10 @@ snapshots: dependencies: brace-expansion: 1.1.14 + minimatch@5.1.9: + dependencies: + brace-expansion: 5.0.5 + minimatch@9.0.9: dependencies: brace-expansion: 2.1.0 @@ -10277,6 +10443,16 @@ snapshots: openapi-types: 12.1.3 qs: 6.15.0 + openapi-fetch@0.17.0: + dependencies: + openapi-typescript-helpers: 0.1.0 + + openapi-react-query@0.5.4(@tanstack/react-query@5.100.9(react@19.2.6))(openapi-fetch@0.17.0): + dependencies: + '@tanstack/react-query': 5.100.9(react@19.2.6) + openapi-fetch: 0.17.0 + openapi-typescript-helpers: 0.1.0 + openapi-schema-validation@0.4.2: dependencies: jsonschema: 1.2.4 @@ -10292,6 +10468,18 @@ snapshots: openapi-types@12.1.3: {} + openapi-typescript-helpers@0.1.0: {} + + openapi-typescript@7.13.0(typescript@6.0.3): + dependencies: + '@redocly/openapi-core': 1.34.14(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 6.0.3 + yargs-parser: 21.1.1 + option@0.2.4: {} optional-js@2.3.0: {} @@ -10366,6 +10554,12 @@ snapshots: pako@1.0.11: {} + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -10455,6 +10649,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pluralize@8.0.0: {} + png-js@1.1.0: dependencies: browserify-zlib: 0.2.0 @@ -11183,6 +11379,8 @@ snapshots: transitivePeerDependencies: - supports-color + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -11324,7 +11522,7 @@ snapshots: tsx@4.21.0: dependencies: esbuild: 0.27.1 - get-tsconfig: 4.13.0 + get-tsconfig: 4.14.0 optionalDependencies: fsevents: 2.3.3 @@ -11338,6 +11536,8 @@ snapshots: type-fest@0.20.2: {} + type-fest@4.41.0: {} + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -11499,6 +11699,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js-replace@1.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -11574,7 +11776,7 @@ snapshots: fsevents: 2.3.3 tsx: 4.21.0 - vitepress@2.0.0-alpha.17(@types/node@25.6.2)(esbuild@0.28.0)(jwt-decode@4.0.0)(oxc-minify@0.129.0)(postcss@8.5.14)(tsx@4.21.0)(typescript@6.0.3): + vitepress@2.0.0-alpha.17(@types/node@25.6.2)(change-case@5.4.4)(esbuild@0.28.0)(jwt-decode@4.0.0)(oxc-minify@0.129.0)(postcss@8.5.14)(tsx@4.21.0)(typescript@6.0.3): dependencies: '@docsearch/css': 4.6.0 '@docsearch/js': 4.6.0 @@ -11588,7 +11790,7 @@ snapshots: '@vue/devtools-api': 8.1.0 '@vue/shared': 3.5.30 '@vueuse/core': 14.2.1(vue@3.5.30(typescript@6.0.3)) - '@vueuse/integrations': 14.2.1(focus-trap@8.0.0)(jwt-decode@4.0.0)(vue@3.5.30(typescript@6.0.3)) + '@vueuse/integrations': 14.2.1(change-case@5.4.4)(focus-trap@8.0.0)(jwt-decode@4.0.0)(vue@3.5.30(typescript@6.0.3)) focus-trap: 8.0.0 mark.js: 8.11.1 minisearch: 7.2.0 @@ -11803,6 +12005,8 @@ snapshots: yallist@5.0.0: {} + yaml-ast-parser@0.0.43: {} + yargs-parser@21.1.1: {} yargs-unparser@2.0.0: diff --git a/settings.json.template b/settings.json.template index 734592d23d1..3e477b032e9 100644 --- a/settings.json.template +++ b/settings.json.template @@ -230,6 +230,19 @@ "requireAdminForStatus": false }, + /* + * Admin OpenAPI document at /admin/openapi.json. + * + * Disabled by default per Etherpad's "new features behind a flag, off by + * default" policy. The admin UI's typed client embeds the spec at build + * time, so the runtime route is only useful for third-party tooling + * (Postman, swagger-ui, downstream clients). Set `enabled: true` to mount + * the route; the SPA at /admin/ continues to serve normally either way. + */ + "adminOpenAPI": { + "enabled": false + }, + /* * Contact address for admin notifications (updates, security advisories, future features). * Set to null to disable outbound mail from the updater. 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..53b9a77a02e --- /dev/null +++ b/src/node/hooks/express/openapi-admin.ts @@ -0,0 +1,182 @@ +'use strict'; + +import {ArgsExpressType} from '../../types/ArgsExpressType'; +import settings, {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 (when settings.adminOpenAPI.enabled) 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 => { + // Behind a feature flag, default off. Etherpad policy + // (CONTRIBUTING.md, AGENTS.MD) requires new features to ship disabled by + // default. The route is only useful for third-party tooling — codegen + // imports generateAdminDefinition() in-process and does not depend on it. + // + // The flag is checked per-request (not at registration time) so toggling + // settings.adminOpenAPI.enabled at runtime takes effect immediately and + // so test suites that share a long-lived Express agent can exercise both + // states without restarting the server. + app.get('/admin/openapi.json', (_req: any, res: any) => { + if (!settings.adminOpenAPI?.enabled) { + // Return JSON 404 (not the SPA's text/html catch-all) so callers get + // a clear "feature disabled" signal rather than an HTML page. + return res.status(404).type('application/json').send({error: 'Not Found'}); + } + res.header('Access-Control-Allow-Origin', '*'); + res.json(generateAdminDefinition()); + }); +}; + +exports.expressPreSession = expressPreSession; diff --git a/src/node/hooks/express/openapi.ts b/src/node/hooks/express/openapi.ts index e07daf6d86b..6eb420f2894 100644 --- a/src/node/hooks/express/openapi.ts +++ b/src/node/hooks/express/openapi.ts @@ -769,3 +769,6 @@ const generateServerForApiVersion = (apiRoot:string, req:any): { } => ({ url: `${settings.ssl ? 'https' : 'http'}://${req.headers.host}${apiRoot}`, }); + +exports.generateDefinitionForVersion = generateDefinitionForVersion; +exports.APIPathStyle = APIPathStyle; diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 3b5e9790f9c..28e237073df 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -332,6 +332,9 @@ export type SettingsType = { githubRepo: string, requireAdminForStatus: boolean, }, + adminOpenAPI: { + enabled: boolean, + }, adminEmail: string | null, getPublicSettings: () => Pick, } @@ -516,6 +519,18 @@ const settings: SettingsType = { // disabling the updater itself. requireAdminForStatus: false, }, + /** + * Admin OpenAPI document endpoint at /admin/openapi.json. + * + * Disabled by default per Etherpad's "new features behind a flag, off by + * default" policy (see CONTRIBUTING.md). The codegen pipeline imports + * generateAdminDefinition() in-process and does not depend on the route; + * enable this only if you want third-party tooling (Postman, swagger-ui, + * downstream clients) to consume the spec at runtime. + */ + adminOpenAPI: { + enabled: false, + }, /** * Contact address for admin notifications (updates, future security advisories). * Null disables outbound mail from the updater. diff --git a/src/tests/backend/specs/openapi-admin.ts b/src/tests/backend/specs/openapi-admin.ts new file mode 100644 index 00000000000..eb98f9a0ff9 --- /dev/null +++ b/src/tests/backend/specs/openapi-admin.ts @@ -0,0 +1,216 @@ +'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 (feature flag)', function () { + // The route is registered unconditionally; the handler reads + // settings.adminOpenAPI.enabled per-request. This lets a single Express + // agent (shared across the whole suite via common.init()) exercise both + // states by toggling the flag in-process — no server restart needed. + let agent: any; + let settingsModule: any; + + before(async function () { + const common = require('../common'); + agent = await common.init(); + settingsModule = require('../../../node/utils/Settings').default; + }); + + after(function () { + // Restore default-off so subsequent specs don't see leaked state. + if (settingsModule?.adminOpenAPI) settingsModule.adminOpenAPI.enabled = false; + }); + + it('returns 404 JSON when settings.adminOpenAPI.enabled is false (default)', async function () { + settingsModule.adminOpenAPI = settingsModule.adminOpenAPI || {enabled: false}; + settingsModule.adminOpenAPI.enabled = false; + const res = await agent.get('/admin/openapi.json').expect(404); + assert.match(res.headers['content-type'] || '', /application\/json/); + assert.deepEqual(res.body, {error: 'Not Found'}); + }); + + it('serves the admin OpenAPI document as JSON when the flag is on', async function () { + settingsModule.adminOpenAPI.enabled = true; + 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 when enabled (matches /api/openapi.json)', async function () { + settingsModule.adminOpenAPI.enabled = true; + const res = await agent.get('/admin/openapi.json').expect(200); + assert.equal(res.headers['access-control-allow-origin'], '*'); + }); + }); +});