Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b97baa3
chore(admin): typesafe API client + TanStack Query rails (#7638) (#2)
JohnMcLear May 7, 2026
40fe8a4
chore(admin): gitignore generated schema/version, regen on every scri…
JohnMcLear May 7, 2026
d8f16c4
docs(admin): design for admin OpenAPI coverage (#7693)
JohnMcLear May 8, 2026
5dc662a
docs(admin): correct UpdateStatus schema to base-branch shape (#7693)
JohnMcLear May 8, 2026
038a211
docs(admin): implementation plan for admin OpenAPI coverage (#7693)
JohnMcLear May 8, 2026
633d0a4
feat(admin): stub OpenAPI document for admin endpoints (#7693)
JohnMcLear May 8, 2026
0e8548e
feat(admin): document POST /admin-auth/ in OpenAPI (#7693)
JohnMcLear May 8, 2026
71a5a13
feat(admin): document GET /admin/update/status in OpenAPI (#7693)
JohnMcLear May 8, 2026
5e10f30
test(admin): regression net for admin/public OpenAPI collisions (#7693)
JohnMcLear May 8, 2026
b793d6c
feat(admin): expose admin OpenAPI doc at /admin/openapi.json (#7693)
JohnMcLear May 8, 2026
9a9a27f
feat(admin): mergeOpenAPI helper for codegen pipeline (#7693)
JohnMcLear May 8, 2026
fe345a9
docs(admin): fix stale "Section 3" reference in spec (#7693)
JohnMcLear May 8, 2026
2c91b75
feat(admin): include admin OpenAPI in generated client (#7693)
JohnMcLear May 8, 2026
dbcfb39
fix(admin): gate /admin/openapi.json behind a feature flag (#7693)
JohnMcLear May 8, 2026
b76a263
fix(admin): split fetchClient by surface; admin client baseUrl='/' (#…
JohnMcLear May 8, 2026
0d5d2d5
docs(admin): spec — feature flag + dual-client design (#7693)
JohnMcLear May 8, 2026
3256a72
fix(admin): check adminOpenAPI flag per-request, not at hook setup (#…
JohnMcLear May 8, 2026
4786601
Merge remote-tracking branch 'origin/develop' into feat/7693-admin-op…
JohnMcLear May 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
78 changes: 57 additions & 21 deletions admin/README.md
Original file line number Diff line number Diff line change
@@ -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/<version>/` 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 <dev|build|test>` 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 <pre>{JSON.stringify(data, null, 2)}</pre>;
};
```

- 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.
18 changes: 13 additions & 5 deletions admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
74 changes: 74 additions & 0 deletions admin/scripts/__tests__/merge-openapi.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
57 changes: 57 additions & 0 deletions admin/scripts/dump-spec.ts
Original file line number Diff line number Diff line change
@@ -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 <output-path>\n');
process.exit(2);
}

const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, '..', '..');

const apiHandlerPath = path.join(repoRoot, 'src', 'node', 'handler', 'APIHandler.ts');
const openapiPath = path.join(repoRoot, 'src', 'node', 'hooks', 'express', 'openapi.ts');
const openapiAdminPath = path.join(
repoRoot, 'src', 'node', 'hooks', 'express', 'openapi-admin.ts',
);

type ApiHandlerModule = {latestApiVersion: string};
type OpenApiModule = {
generateDefinitionForVersion: (version: string, style?: string) => unknown;
APIPathStyle: {FLAT: string; REST: string};
};
type OpenApiAdminModule = {
generateAdminDefinition: () => unknown;
};

const apiHandlerMod = await import(pathToFileURL(apiHandlerPath).href);
const openapiMod = await import(pathToFileURL(openapiPath).href);
const openapiAdminMod = await import(pathToFileURL(openapiAdminPath).href);

const apiHandler = (apiHandlerMod.default ?? apiHandlerMod) as ApiHandlerModule;
const openapi = (openapiMod.default ?? openapiMod) as OpenApiModule;
const openapiAdmin = (openapiAdminMod.default ?? openapiAdminMod) as OpenApiAdminModule;

const publicSpec = openapi.generateDefinitionForVersion(
apiHandler.latestApiVersion,
openapi.APIPathStyle.FLAT,
);
const adminSpec = openapiAdmin.generateAdminDefinition();

const merged = mergeOpenAPI(publicSpec, adminSpec);

writeFileSync(path.resolve(outFile), JSON.stringify(merged, null, 2), 'utf8');
78 changes: 78 additions & 0 deletions admin/scripts/gen-api.mjs
Original file line number Diff line number Diff line change
@@ -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/<version>/.
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 });
}
Loading
Loading