diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 041f7c1a92f..f32093ac424 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -30,7 +30,8 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g., Ubuntu 20.04] - Node.js version (`node --version`): - npm version (`npm --version`): - - Is the server free of plugins: + - Is the server free of plugins: + - Are you using any abstraction IE docker? **Desktop (please complete the following information):** - OS: [e.g. iOS] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d9f660907db..822bd8b741e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,7 @@ \nMore notes.'; + expect(parseVulnerableBelow(body)).toBe('2.6.4'); + }); + it('tolerates whitespace and casing', () => { + expect(parseVulnerableBelow('')).toBe('1.0.0'); + expect(parseVulnerableBelow('')).toBe('1.0.0'); + }); + it('returns null when absent or malformed', () => { + expect(parseVulnerableBelow('no directive here')).toBeNull(); + expect(parseVulnerableBelow('')).toBeNull(); + }); +}); + +describe('isVulnerable', () => { + it('true if current strictly below any directive threshold', () => { + expect(isVulnerable('2.6.3', [ + {announcedBy: 'v2.7.0', threshold: '2.6.4'}, + ])).toBe(true); + }); + it('false at or above all thresholds', () => { + expect(isVulnerable('2.6.4', [ + {announcedBy: 'v2.7.0', threshold: '2.6.4'}, + ])).toBe(false); + expect(isVulnerable('2.7.0', [])).toBe(false); + }); + it('handles multiple directives', () => { + expect(isVulnerable('1.5.0', [ + {announcedBy: 'v2.0.0', threshold: '2.0.0'}, + {announcedBy: 'v3.0.0', threshold: '1.9.0'}, + ])).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test:vitest -- run tests/backend-new/specs/updater/versionCompare.test.ts +``` + +Expected: FAIL with "Cannot find module ...versionCompare". + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/node/updater/versionCompare.ts +import type {VulnerableBelowDirective} from './types'; + +export interface ParsedSemver { + major: number; + minor: number; + patch: number; +} + +const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:[.-].*)?$/; + +export const parseSemver = (s: string): ParsedSemver | null => { + const m = SEMVER_RE.exec(s.trim()); + if (!m) return null; + return {major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3])}; +}; + +export const compareSemver = (a: string, b: string): -1 | 0 | 1 => { + const pa = parseSemver(a); + const pb = parseSemver(b); + if (!pa || !pb) return 0; + for (const k of ['major', 'minor', 'patch'] as const) { + if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1; + } + return 0; +}; + +export const isMajorBehind = (current: string, latest: string): boolean => { + const c = parseSemver(current); + const l = parseSemver(latest); + if (!c || !l) return false; + return l.major - c.major >= 1; +}; + +const VULN_RE = //i; + +export const parseVulnerableBelow = (body: string): string | null => { + const m = VULN_RE.exec(body); + if (!m) return null; + if (!parseSemver(m[1])) return null; + return m[1]; +}; + +export const isVulnerable = ( + current: string, + directives: readonly VulnerableBelowDirective[], +): boolean => { + for (const d of directives) { + if (compareSemver(current, d.threshold) < 0) return true; + } + return false; +}; +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test:vitest -- run tests/backend-new/specs/updater/versionCompare.test.ts +``` + +Expected: all 14 assertions pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/node/updater/versionCompare.ts src/tests/backend-new/specs/updater/versionCompare.test.ts +git commit -m "feat(updater): add semver helpers and vulnerable-below parser" +``` + +--- + +## Task 3: `state.ts` — disk persistence (TDD) + +Read/write `var/update-state.json` with schema validation and migration. Pure where possible — takes a `path` so tests can use a temp dir. + +**Files:** +- Create: `src/node/updater/state.ts` +- Test: `src/tests/backend-new/specs/updater/state.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// src/tests/backend-new/specs/updater/state.test.ts +import {describe, it, expect, beforeEach} from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import {loadState, saveState, EMPTY_STATE_FOR_TESTS} from '../../../../node/updater/state'; + +let dir: string; +const statePath = () => path.join(dir, 'update-state.json'); + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'updater-state-')); +}); + +describe('loadState', () => { + it('returns empty state when file does not exist', async () => { + const s = await loadState(statePath()); + expect(s).toEqual(EMPTY_STATE_FOR_TESTS); + }); + + it('round-trips a saved state', async () => { + const s = {...EMPTY_STATE_FOR_TESTS, lastCheckAt: '2026-04-25T00:00:00Z'}; + await saveState(statePath(), s); + const loaded = await loadState(statePath()); + expect(loaded.lastCheckAt).toBe('2026-04-25T00:00:00Z'); + }); + + it('returns empty state when file is corrupt', async () => { + await fs.writeFile(statePath(), 'not json'); + const s = await loadState(statePath()); + expect(s).toEqual(EMPTY_STATE_FOR_TESTS); + }); + + it('returns empty state when schemaVersion is unknown', async () => { + await fs.writeFile(statePath(), JSON.stringify({schemaVersion: 999})); + const s = await loadState(statePath()); + expect(s).toEqual(EMPTY_STATE_FOR_TESTS); + }); +}); + +describe('saveState', () => { + it('writes atomically (no partial file on crash simulation)', async () => { + // We cannot easily simulate a crash, but we can verify the write went via a tmp file + // by checking only one file ends up in the dir. + await saveState(statePath(), EMPTY_STATE_FOR_TESTS); + const entries = await fs.readdir(dir); + expect(entries).toEqual(['update-state.json']); + }); + + it('creates the directory if missing', async () => { + const nested = path.join(dir, 'nested', 'deep', 'update-state.json'); + await saveState(nested, EMPTY_STATE_FOR_TESTS); + const data = JSON.parse(await fs.readFile(nested, 'utf8')); + expect(data.schemaVersion).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test:vitest -- run tests/backend-new/specs/updater/state.test.ts +``` + +Expected: FAIL with "Cannot find module ...state". + +- [ ] **Step 3: Write the implementation** + +```typescript +// src/node/updater/state.ts +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {EMPTY_STATE, UpdateState} from './types'; + +export {EMPTY_STATE as EMPTY_STATE_FOR_TESTS}; + +const isValid = (raw: unknown): raw is UpdateState => { + if (!raw || typeof raw !== 'object') return false; + const o = raw as Record; + return o.schemaVersion === 1 + && (o.lastCheckAt === null || typeof o.lastCheckAt === 'string') + && (o.lastEtag === null || typeof o.lastEtag === 'string') + && (o.latest === null || typeof o.latest === 'object') + && Array.isArray(o.vulnerableBelow) + && typeof o.email === 'object'; +}; + +export const loadState = async (filePath: string): Promise => { + let raw: string; + try { + raw = await fs.readFile(filePath, 'utf8'); + } catch (err: any) { + if (err.code === 'ENOENT') return structuredClone(EMPTY_STATE); + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return structuredClone(EMPTY_STATE); + } + if (!isValid(parsed)) return structuredClone(EMPTY_STATE); + return parsed; +}; + +export const saveState = async (filePath: string, state: UpdateState): Promise => { + await fs.mkdir(path.dirname(filePath), {recursive: true}); + const tmp = `${filePath}.tmp`; + await fs.writeFile(tmp, JSON.stringify(state, null, 2)); + await fs.rename(tmp, filePath); +}; +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test:vitest -- run tests/backend-new/specs/updater/state.test.ts +``` + +Expected: 6 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/node/updater/state.ts src/tests/backend-new/specs/updater/state.test.ts +git commit -m "feat(updater): add state persistence with schema validation" +``` + +--- + +## Task 4: `InstallMethodDetector` (TDD) + +**Files:** +- Create: `src/node/updater/InstallMethodDetector.ts` +- Test: `src/tests/backend-new/specs/updater/InstallMethodDetector.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// src/tests/backend-new/specs/updater/InstallMethodDetector.test.ts +import {describe, it, expect, beforeEach} from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import {detectInstallMethod} from '../../../../node/updater/InstallMethodDetector'; + +let dir: string; + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'detector-')); +}); + +const opts = (override?: 'auto' | 'git' | 'docker' | 'npm' | 'managed') => ({ + override: override ?? 'auto', + rootDir: dir, + dockerEnvPath: path.join(dir, '.dockerenv'), +}); + +describe('detectInstallMethod', () => { + it('honors a non-auto override', async () => { + expect(await detectInstallMethod(opts('git'))).toBe('git'); + expect(await detectInstallMethod(opts('docker'))).toBe('docker'); + expect(await detectInstallMethod(opts('managed'))).toBe('managed'); + }); + + it('returns docker when /.dockerenv exists', async () => { + await fs.writeFile(opts().dockerEnvPath, ''); + expect(await detectInstallMethod(opts())).toBe('docker'); + }); + + it('returns git when .git is present and root is writable', async () => { + await fs.mkdir(path.join(dir, '.git')); + expect(await detectInstallMethod(opts())).toBe('git'); + }); + + it('returns npm when package-lock.json is present and writable', async () => { + await fs.writeFile(path.join(dir, 'package-lock.json'), '{}'); + expect(await detectInstallMethod(opts())).toBe('npm'); + }); + + it('returns managed when nothing matches', async () => { + expect(await detectInstallMethod(opts())).toBe('managed'); + }); + + it('docker takes precedence over git', async () => { + await fs.writeFile(opts().dockerEnvPath, ''); + await fs.mkdir(path.join(dir, '.git')); + expect(await detectInstallMethod(opts())).toBe('docker'); + }); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test:vitest -- run tests/backend-new/specs/updater/InstallMethodDetector.test.ts +``` + +Expected: FAIL with module-not-found. + +- [ ] **Step 3: Write the implementation** + +```typescript +// src/node/updater/InstallMethodDetector.ts +import fs from 'node:fs/promises'; +import {constants as fsConstants} from 'node:fs'; +import path from 'node:path'; +import {InstallMethod} from './types'; + +export interface DetectOptions { + /** Setting from settings.json. "auto" means detect; anything else is forced. */ + override: InstallMethod; + /** Root directory of the Etherpad install. */ + rootDir: string; + /** Path to /.dockerenv (overridable for tests). */ + dockerEnvPath?: string; +} + +const exists = async (p: string): Promise => { + try { await fs.access(p, fsConstants.F_OK); return true; } catch { return false; } +}; + +const writable = async (p: string): Promise => { + try { await fs.access(p, fsConstants.W_OK); return true; } catch { return false; } +}; + +export const detectInstallMethod = async ( + opts: DetectOptions, +): Promise> => { + if (opts.override !== 'auto') return opts.override; + + const dockerEnv = opts.dockerEnvPath ?? '/.dockerenv'; + if (await exists(dockerEnv)) return 'docker'; + + const gitDir = path.join(opts.rootDir, '.git'); + if (await exists(gitDir) && await writable(opts.rootDir)) return 'git'; + + const lockfile = path.join(opts.rootDir, 'package-lock.json'); + if (await exists(lockfile) && await writable(lockfile)) return 'npm'; + + return 'managed'; +}; +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test:vitest -- run tests/backend-new/specs/updater/InstallMethodDetector.test.ts +``` + +Expected: 6 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/node/updater/InstallMethodDetector.ts src/tests/backend-new/specs/updater/InstallMethodDetector.test.ts +git commit -m "feat(updater): add install-method detector with override" +``` + +--- + +## Task 5: `UpdatePolicy` (TDD) + +The single source of truth for "what's allowed in this environment." Pure function. + +**Files:** +- Create: `src/node/updater/UpdatePolicy.ts` +- Test: `src/tests/backend-new/specs/updater/UpdatePolicy.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// src/tests/backend-new/specs/updater/UpdatePolicy.test.ts +import {describe, it, expect} from 'vitest'; +import {evaluatePolicy} from '../../../../node/updater/UpdatePolicy'; +import {InstallMethod, Tier} from '../../../../node/updater/types'; + +const baseInput = { + installMethod: 'git' as Exclude, + tier: 'manual' as Tier, + current: '2.7.1', + latest: '2.7.2', +}; + +describe('evaluatePolicy', () => { + it('off tier denies everything', () => { + const r = evaluatePolicy({...baseInput, tier: 'off'}); + expect(r).toEqual({canNotify: false, canManual: false, canAuto: false, canAutonomous: false, reason: 'tier-off'}); + }); + + it('notify tier allows only notify', () => { + const r = evaluatePolicy({...baseInput, tier: 'notify'}); + expect(r.canNotify).toBe(true); + expect(r.canManual).toBe(false); + expect(r.canAuto).toBe(false); + expect(r.canAutonomous).toBe(false); + }); + + it('manual tier allows notify+manual on git', () => { + const r = evaluatePolicy({...baseInput, tier: 'manual'}); + expect(r.canManual).toBe(true); + expect(r.canAuto).toBe(false); + }); + + it('manual tier denies manual on docker', () => { + const r = evaluatePolicy({...baseInput, tier: 'manual', installMethod: 'docker'}); + expect(r.canNotify).toBe(true); + expect(r.canManual).toBe(false); + expect(r.reason).toBe('install-method-not-writable'); + }); + + it('autonomous tier allows everything on git', () => { + const r = evaluatePolicy({...baseInput, tier: 'autonomous'}); + expect(r).toEqual({canNotify: true, canManual: true, canAuto: true, canAutonomous: true, reason: 'ok'}); + }); + + it('autonomous on managed install denies write tiers', () => { + const r = evaluatePolicy({...baseInput, tier: 'autonomous', installMethod: 'managed'}); + expect(r.canNotify).toBe(true); + expect(r.canManual).toBe(false); + expect(r.canAuto).toBe(false); + expect(r.canAutonomous).toBe(false); + }); + + it('current === latest denies all (nothing to do)', () => { + const r = evaluatePolicy({...baseInput, tier: 'autonomous', current: '2.7.2', latest: '2.7.2'}); + expect(r.canNotify).toBe(false); + expect(r.canManual).toBe(false); + expect(r.reason).toBe('up-to-date'); + }); + + it('current > latest (dev build) denies all', () => { + const r = evaluatePolicy({...baseInput, tier: 'autonomous', current: '3.0.0', latest: '2.7.2'}); + expect(r.canNotify).toBe(false); + expect(r.reason).toBe('up-to-date'); + }); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test:vitest -- run tests/backend-new/specs/updater/UpdatePolicy.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write the implementation** + +```typescript +// src/node/updater/UpdatePolicy.ts +import {compareSemver} from './versionCompare'; +import {InstallMethod, PolicyResult, Tier} from './types'; + +const WRITABLE_METHODS: ReadonlySet> = new Set(['git']); +// Future: 'npm' once we support that path. For PR 1 only `git` allows write tiers. + +export interface PolicyInput { + installMethod: Exclude; + tier: Tier; + current: string; + latest: string; +} + +export const evaluatePolicy = ({installMethod, tier, current, latest}: PolicyInput): PolicyResult => { + if (tier === 'off') { + return {canNotify: false, canManual: false, canAuto: false, canAutonomous: false, reason: 'tier-off'}; + } + if (compareSemver(current, latest) >= 0) { + return {canNotify: false, canManual: false, canAuto: false, canAutonomous: false, reason: 'up-to-date'}; + } + + const canNotify = true; + const writable = WRITABLE_METHODS.has(installMethod); + + if (!writable) { + return {canNotify, canManual: false, canAuto: false, canAutonomous: false, reason: 'install-method-not-writable'}; + } + + return { + canNotify, + canManual: tier === 'manual' || tier === 'auto' || tier === 'autonomous', + canAuto: tier === 'auto' || tier === 'autonomous', + canAutonomous: tier === 'autonomous', + reason: 'ok', + }; +}; +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test:vitest -- run tests/backend-new/specs/updater/UpdatePolicy.test.ts +``` + +Expected: 8 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/node/updater/UpdatePolicy.ts src/tests/backend-new/specs/updater/UpdatePolicy.test.ts +git commit -m "feat(updater): add policy evaluator" +``` + +--- + +## Task 6: `VersionChecker` (TDD with mocked fetch) + +**Files:** +- Create: `src/node/updater/VersionChecker.ts` +- Test: `src/tests/backend-new/specs/updater/VersionChecker.test.ts` + +The checker takes a `fetcher` function so tests can supply a stub. Production wiring (Task 9) injects the real `fetch`. + +- [ ] **Step 1: Write failing test** + +```typescript +// src/tests/backend-new/specs/updater/VersionChecker.test.ts +import {describe, it, expect, beforeEach} from 'vitest'; +import {checkLatestRelease, FetchResult} from '../../../../node/updater/VersionChecker'; +import {ReleaseInfo} from '../../../../node/updater/types'; + +const ghBody = (overrides: Partial<{tag_name: string; body: string; prerelease: boolean; html_url: string; published_at: string}> = {}) => ({ + tag_name: 'v2.7.2', + body: 'Some changes.\n', + prerelease: false, + html_url: 'https://github.com/ether/etherpad/releases/tag/v2.7.2', + published_at: '2026-04-25T00:00:00Z', + ...overrides, +}); + +describe('checkLatestRelease', () => { + it('returns parsed release on 200', async () => { + const fetcher = async () => ({ + status: 200, + etag: 'abc', + json: ghBody(), + } as FetchResult); + const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'}); + expect(r.kind).toBe('updated'); + if (r.kind !== 'updated') return; + const expected: ReleaseInfo = { + version: '2.7.2', + tag: 'v2.7.2', + body: 'Some changes.\n', + publishedAt: '2026-04-25T00:00:00Z', + prerelease: false, + htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v2.7.2', + }; + expect(r.release).toEqual(expected); + expect(r.etag).toBe('abc'); + expect(r.vulnerableBelow).toEqual([{announcedBy: 'v2.7.2', threshold: '2.6.4'}]); + }); + + it('returns notmodified on 304', async () => { + const fetcher = async () => ({status: 304, etag: 'abc', json: null} as FetchResult); + const r = await checkLatestRelease({fetcher, prevEtag: 'abc', repo: 'ether/etherpad'}); + expect(r.kind).toBe('notmodified'); + }); + + it('returns ratelimited on 403', async () => { + const fetcher = async () => ({status: 403, etag: null, json: null} as FetchResult); + const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'}); + expect(r.kind).toBe('ratelimited'); + }); + + it('skips prereleases', async () => { + const fetcher = async () => ({ + status: 200, etag: null, json: ghBody({prerelease: true}), + } as FetchResult); + const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'}); + expect(r.kind).toBe('skipped-prerelease'); + }); + + it('returns error on unexpected status', async () => { + const fetcher = async () => ({status: 500, etag: null, json: null} as FetchResult); + const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'}); + expect(r.kind).toBe('error'); + }); + + it('passes prevEtag to fetcher', async () => { + let observed: string | null = ''; + const fetcher = async (_url: string, etag: string | null) => { + observed = etag; + return {status: 304, etag: 'abc', json: null} as FetchResult; + }; + await checkLatestRelease({fetcher, prevEtag: 'old', repo: 'ether/etherpad'}); + expect(observed).toBe('old'); + }); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test:vitest -- run tests/backend-new/specs/updater/VersionChecker.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write the implementation** + +```typescript +// src/node/updater/VersionChecker.ts +import {ReleaseInfo, VulnerableBelowDirective} from './types'; +import {parseVulnerableBelow} from './versionCompare'; + +export interface FetchResult { + status: number; + etag: string | null; + json: any; +} + +export type Fetcher = (url: string, etag: string | null) => Promise; + +export type CheckResult = + | {kind: 'updated'; release: ReleaseInfo; etag: string | null; vulnerableBelow: VulnerableBelowDirective[]} + | {kind: 'notmodified'} + | {kind: 'ratelimited'} + | {kind: 'skipped-prerelease'} + | {kind: 'error'; status: number}; + +export interface CheckOptions { + fetcher: Fetcher; + prevEtag: string | null; + repo: string; +} + +export const checkLatestRelease = async ( + {fetcher, prevEtag, repo}: CheckOptions, +): Promise => { + const url = `https://api.github.com/repos/${repo}/releases/latest`; + const res = await fetcher(url, prevEtag); + + if (res.status === 304) return {kind: 'notmodified'}; + if (res.status === 403 || res.status === 429) return {kind: 'ratelimited'}; + if (res.status !== 200 || !res.json) return {kind: 'error', status: res.status}; + + const j = res.json; + if (j.prerelease) return {kind: 'skipped-prerelease'}; + + const tag: string = String(j.tag_name); + const version = tag.replace(/^v/, ''); + const body: string = typeof j.body === 'string' ? j.body : ''; + + const release: ReleaseInfo = { + version, + tag, + body, + publishedAt: String(j.published_at), + prerelease: false, + htmlUrl: String(j.html_url), + }; + + const directiveThreshold = parseVulnerableBelow(body); + const vulnerableBelow: VulnerableBelowDirective[] = directiveThreshold + ? [{announcedBy: tag, threshold: directiveThreshold}] + : []; + + return {kind: 'updated', release, etag: res.etag, vulnerableBelow}; +}; + +/** Production fetcher built on native fetch. Honors If-None-Match. */ +export const realFetcher: Fetcher = async (url, etag) => { + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'etherpad-self-update', + }; + if (etag) headers['If-None-Match'] = etag; + const r = await fetch(url, {headers}); + const newEtag = r.headers.get('etag'); + let json: any = null; + if (r.status === 200) { + try { json = await r.json(); } catch { json = null; } + } + return {status: r.status, etag: newEtag, json}; +}; +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test:vitest -- run tests/backend-new/specs/updater/VersionChecker.test.ts +``` + +Expected: 6 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/node/updater/VersionChecker.ts src/tests/backend-new/specs/updater/VersionChecker.test.ts +git commit -m "feat(updater): add GitHub Releases checker with ETag support" +``` + +--- + +## Task 7: `Notifier` (TDD) + +Pure function that decides which emails to send and updates the dedupe log. Side effects (actual sending) are pushed through a `sender` callback. + +**Files:** +- Create: `src/node/updater/Notifier.ts` +- Test: `src/tests/backend-new/specs/updater/Notifier.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// src/tests/backend-new/specs/updater/Notifier.test.ts +import {describe, it, expect} from 'vitest'; +import {decideEmails, NotifierInput} from '../../../../node/updater/Notifier'; +import {EMPTY_STATE} from '../../../../node/updater/types'; + +const base: NotifierInput = { + adminEmail: 'admin@example.com', + current: '2.0.0', + latest: '2.7.2', + latestTag: 'v2.7.2', + vulnerableBelow: [], + isVulnerable: false, + isSevere: false, + state: EMPTY_STATE.email, + now: new Date('2026-04-25T12:00:00Z'), +}; + +describe('decideEmails', () => { + it('emits no email if adminEmail is unset', () => { + const r = decideEmails({...base, adminEmail: null, isSevere: true}); + expect(r.toSend).toEqual([]); + }); + + it('emits severe email on first detection', () => { + const r = decideEmails({...base, isSevere: true}); + expect(r.toSend.map(e => e.kind)).toEqual(['severe']); + expect(r.newState.severeAt).toBe('2026-04-25T12:00:00.000Z'); + }); + + it('does not re-emit severe within 30 days', () => { + const r = decideEmails({ + ...base, + isSevere: true, + state: {...base.state, severeAt: '2026-04-10T12:00:00.000Z'}, + }); + expect(r.toSend).toEqual([]); + }); + + it('re-emits severe after 30 days', () => { + const r = decideEmails({ + ...base, + isSevere: true, + state: {...base.state, severeAt: '2026-03-20T12:00:00.000Z'}, + }); + expect(r.toSend.map(e => e.kind)).toEqual(['severe']); + }); + + it('emits vulnerable email on first detection', () => { + const r = decideEmails({...base, isVulnerable: true}); + expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']); + expect(r.newState.vulnerableAt).toBe('2026-04-25T12:00:00.000Z'); + }); + + it('does not re-emit vulnerable within 7 days', () => { + const r = decideEmails({ + ...base, + isVulnerable: true, + state: {...base.state, vulnerableAt: '2026-04-22T12:00:00.000Z'}, + }); + expect(r.toSend).toEqual([]); + }); + + it('re-emits vulnerable after 7 days', () => { + const r = decideEmails({ + ...base, + isVulnerable: true, + state: {...base.state, vulnerableAt: '2026-04-15T12:00:00.000Z'}, + }); + expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']); + }); + + it('emits new-release-while-vulnerable when latest tag changes', () => { + const r = decideEmails({ + ...base, + isVulnerable: true, + state: {...base.state, vulnerableAt: '2026-04-25T11:59:00.000Z', vulnerableNewReleaseTag: 'v2.7.1'}, + }); + expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable-new-release']); + }); + + it('vulnerable wins over severe in the same tick', () => { + const r = decideEmails({...base, isSevere: true, isVulnerable: true}); + expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']); + }); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test:vitest -- run tests/backend-new/specs/updater/Notifier.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Write the implementation** + +```typescript +// src/node/updater/Notifier.ts +import {EmailSendLog, VulnerableBelowDirective} from './types'; + +export interface NotifierInput { + adminEmail: string | null; + current: string; + latest: string; + latestTag: string; + vulnerableBelow: VulnerableBelowDirective[]; + isVulnerable: boolean; + isSevere: boolean; + state: EmailSendLog; + now: Date; +} + +export type EmailKind = 'severe' | 'vulnerable' | 'vulnerable-new-release'; + +export interface PlannedEmail { + kind: EmailKind; + subject: string; + body: string; +} + +export interface NotifierResult { + toSend: PlannedEmail[]; + newState: EmailSendLog; +} + +const DAY = 24 * 60 * 60 * 1000; +const SEVERE_INTERVAL = 30 * DAY; +const VULNERABLE_INTERVAL = 7 * DAY; + +const sinceMs = (iso: string | null, now: Date): number => + iso ? now.getTime() - new Date(iso).getTime() : Infinity; + +export const decideEmails = (input: NotifierInput): NotifierResult => { + const {adminEmail, current, latest, latestTag, isVulnerable, isSevere, state, now} = input; + + if (!adminEmail) return {toSend: [], newState: state}; + + const toSend: PlannedEmail[] = []; + const newState: EmailSendLog = {...state}; + + if (isVulnerable) { + const sinceVuln = sinceMs(state.vulnerableAt, now); + const tagChanged = state.vulnerableNewReleaseTag !== null && state.vulnerableNewReleaseTag !== latestTag; + if (tagChanged && sinceVuln < VULNERABLE_INTERVAL) { + toSend.push({ + kind: 'vulnerable-new-release', + subject: `[Etherpad] New release available — ${latest} (your version is vulnerable)`, + body: `A new Etherpad release (${latestTag}) is available. Your version (${current}) is flagged as vulnerable. Please update.`, + }); + newState.vulnerableNewReleaseTag = latestTag; + } else if (sinceVuln >= VULNERABLE_INTERVAL) { + toSend.push({ + kind: 'vulnerable', + subject: `[Etherpad] Your instance is running a vulnerable version (${current})`, + body: `Your Etherpad version (${current}) is below the security threshold. Latest is ${latest}.`, + }); + newState.vulnerableAt = now.toISOString(); + newState.vulnerableNewReleaseTag = latestTag; + } + } else if (isSevere) { + const sinceSevere = sinceMs(state.severeAt, now); + if (sinceSevere >= SEVERE_INTERVAL) { + toSend.push({ + kind: 'severe', + subject: `[Etherpad] Your instance is severely outdated (${current})`, + body: `Your Etherpad version (${current}) is more than one major release behind ${latest}.`, + }); + newState.severeAt = now.toISOString(); + } + } + + return {toSend, newState}; +}; +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test:vitest -- run tests/backend-new/specs/updater/Notifier.test.ts +``` + +Expected: 9 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/node/updater/Notifier.ts src/tests/backend-new/specs/updater/Notifier.test.ts +git commit -m "feat(updater): add email cadence decider" +``` + +--- + +## Task 8: Settings extension + +Add `updates` and `adminEmail` to the live `SettingsType` and defaults in `Settings.ts`. Then add the same to `settings.json.template` and `settings.json.docker`. + +**Files:** +- Modify: `src/node/utils/Settings.ts` +- Modify: `settings.json.template` +- Modify: `settings.json.docker` + +- [ ] **Step 1: Locate the end of `SettingsType` in `Settings.ts`** + +Read `src/node/utils/Settings.ts` lines 290–305 to find the closing brace of `SettingsType`. The line `export type SettingsType = {` starts at ~line 159; it closes around line ~298 (just before `const settings: SettingsType = {` on line 301). The last fields look like: + +```typescript + randomVersionString: string, + gitVersion: string + getPublicSettings: () => Pick<...>, +} +``` + +- [ ] **Step 2: Insert new fields into `SettingsType`** + +Edit `Settings.ts`. Add immediately before `getPublicSettings`: + +```typescript + updates: { + tier: 'off' | 'notify' | 'manual' | 'auto' | 'autonomous', + source: 'github', + channel: 'stable', + installMethod: 'auto' | 'git' | 'docker' | 'npm' | 'managed', + checkIntervalHours: number, + githubRepo: string, + }, + adminEmail: string | null, +``` + +- [ ] **Step 3: Insert defaults into the `settings` literal** + +Find the `const settings: SettingsType = {` block. Add (anywhere inside the object literal, but a sensible place is after `enableMetrics`): + +```typescript + /** + * Self-update subsystem (PR 1: tier 1 only). + * Tier "off" disables the version check entirely. Default "notify" shows a banner when behind. + */ + updates: { + tier: 'notify', + source: 'github', + channel: 'stable', + installMethod: 'auto', + checkIntervalHours: 6, + githubRepo: 'ether/etherpad', + }, + /** + * Contact address for admin notifications (updates, future security advisories). + * Null disables outbound mail from the updater. + */ + adminEmail: null, +``` + +- [ ] **Step 4: Run type-check** + +```bash +cd /home/jose/etherpad/etherpad-lite && pnpm ts-check +``` + +Expected: no new errors. + +- [ ] **Step 5: Update `settings.json.template`** + +Open `settings.json.template`, find a sensible insertion point (after `"enableMetrics"` is fine). Add (preserving JSON-with-comments syntax used in that file): + +```jsonc + /* + * Self-update subsystem. + * tier: "off" | "notify" | "manual" | "auto" | "autonomous" + * Default "notify" shows a banner when an update is available. "off" disables the version check. + */ + "updates": { + "tier": "notify", + "source": "github", + "channel": "stable", + "installMethod": "auto", + "checkIntervalHours": 6, + "githubRepo": "ether/etherpad" + }, + + /* + * Contact address for admin notifications (updates, security advisories, future features). + * Set to null to disable outbound mail from the updater. + */ + "adminEmail": null, +``` + +- [ ] **Step 6: Update `settings.json.docker`** + +Same content but with `"installMethod": "docker"` and a note that auto-update is not available for docker installs. + +- [ ] **Step 7: Confirm template parses** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test:vitest -- run tests/backend-new/specs/admin_utils.ts +``` + +(That existing test parses the template; if it still passes we haven't broken JSON-with-comments parsing.) + +Expected: passes. + +- [ ] **Step 8: Commit** + +```bash +git add src/node/utils/Settings.ts settings.json.template settings.json.docker +git commit -m "feat(settings): add updates.* and adminEmail settings" +``` + +--- + +## Task 9: Boot wiring & `ep.json` registration + +A small `index.ts` that initializes the subsystem at boot, plus an `ep.json` entry. This keeps Task 10 (HTTP routes) trivial because the routes can read state directly off disk via `loadState`. + +**Files:** +- Create: `src/node/updater/index.ts` +- Modify: `src/ep.json` + +- [ ] **Step 1: Read existing ep.json structure** + +```bash +sed -n '70,130p' /home/jose/etherpad/etherpad-lite/src/ep.json +``` + +- [ ] **Step 2: Create `index.ts`** + +```typescript +// src/node/updater/index.ts +import path from 'node:path'; +import log4js from 'log4js'; +import settings from '../utils/Settings'; +import {detectInstallMethod} from './InstallMethodDetector'; +import {checkLatestRelease, realFetcher} from './VersionChecker'; +import {loadState, saveState} from './state'; +import {compareSemver, isMajorBehind, isVulnerable} from './versionCompare'; +import {evaluatePolicy} from './UpdatePolicy'; +import {decideEmails} from './Notifier'; +import {InstallMethod, UpdateState} from './types'; + +const logger = log4js.getLogger('updater'); + +let detectedMethod: Exclude = 'managed'; +let timer: NodeJS.Timeout | null = null; +let inMemoryState: UpdateState | null = null; + +const stateFilePath = () => path.join(settings.root, 'var', 'update-state.json'); + +const getEpVersion = (): string => require('../../package.json').version; + +/** Returns the current state from memory; loads on first call. */ +export const getCurrentState = async (): Promise => { + if (inMemoryState) return inMemoryState; + inMemoryState = await loadState(stateFilePath()); + return inMemoryState; +}; + +export const getDetectedInstallMethod = () => detectedMethod; + +const sendEmailViaSmtp = async (to: string, subject: string, body: string): Promise => { + // Etherpad core has no built-in SMTP. We log and rely on a future plugin / explicit SMTP wiring. + // PR 1 ships the dedupe machinery without an actual sender; subsequent PRs can wire nodemailer. + logger.info(`(would send email) to=${to} subject="${subject}"`); + void body; +}; + +const performCheck = async (): Promise => { + if (settings.updates.tier === 'off') return; + let state = await getCurrentState(); + try { + const result = await checkLatestRelease({ + fetcher: realFetcher, + prevEtag: state.lastEtag, + repo: settings.updates.githubRepo, + }); + const now = new Date(); + state.lastCheckAt = now.toISOString(); + + if (result.kind === 'updated') { + state.latest = result.release; + state.lastEtag = result.etag; + // Union new directives with existing. + const existingTags = new Set(state.vulnerableBelow.map(v => v.announcedBy)); + for (const v of result.vulnerableBelow) { + if (!existingTags.has(v.announcedBy)) state.vulnerableBelow.push(v); + } + } else if (result.kind === 'notmodified') { + // nothing + } else if (result.kind === 'ratelimited') { + logger.warn('GitHub rate-limited; backing off'); + } else if (result.kind === 'error') { + logger.warn(`GitHub fetch error status=${result.status}`); + } + + // Notifier pass. + if (state.latest && settings.adminEmail) { + const current = getEpVersion(); + const policy = evaluatePolicy({ + installMethod: detectedMethod, + tier: settings.updates.tier, + current, + latest: state.latest.version, + }); + if (policy.canNotify) { + const decision = decideEmails({ + adminEmail: settings.adminEmail, + current, + latest: state.latest.version, + latestTag: state.latest.tag, + vulnerableBelow: state.vulnerableBelow, + isVulnerable: isVulnerable(current, state.vulnerableBelow), + isSevere: isMajorBehind(current, state.latest.version), + state: state.email, + now, + }); + for (const email of decision.toSend) { + await sendEmailViaSmtp(settings.adminEmail, email.subject, email.body); + } + state.email = decision.newState; + } + } + + inMemoryState = state; + await saveState(stateFilePath(), state); + } catch (err) { + logger.warn(`Updater check failed: ${(err as Error).message}`); + } +}; + +const startPolling = (): void => { + const intervalMs = Math.max(1, settings.updates.checkIntervalHours) * 60 * 60 * 1000; + if (timer) clearInterval(timer); + timer = setInterval(() => { void performCheck(); }, intervalMs); + // Run an immediate first check, but don't block boot. + setTimeout(() => { void performCheck(); }, 5000); +}; + +/** Hook entry point — called by ep.json on createServer. */ +exports.expressCreateServer = async (): Promise => { + detectedMethod = await detectInstallMethod({ + override: settings.updates.installMethod, + rootDir: settings.root, + }); + logger.info(`updater: install method = ${detectedMethod}, tier = ${settings.updates.tier}`); + if (settings.updates.tier !== 'off') startPolling(); +}; + +/** Shutdown hook. */ +exports.shutdown = async (): Promise => { + if (timer) { clearInterval(timer); timer = null; } +}; + +/** Exposed for tests / route handlers. */ +export const _internal = { + performCheck, + stateFilePath, +}; +``` + +- [ ] **Step 3: Register the hook in `ep.json`** + +Edit `src/ep.json`. Add a new entry to `parts` (anywhere before `"admin"`): + +```jsonc +{ + "name": "updater", + "hooks": { + "expressCreateServer": "ep_etherpad-lite/node/updater/index", + "shutdown": "ep_etherpad-lite/node/updater/index" + } +} +``` + +- [ ] **Step 4: Type-check** + +```bash +cd /home/jose/etherpad/etherpad-lite && pnpm ts-check +``` + +Expected: no new errors. + +- [ ] **Step 5: Manual smoke** + +```bash +cd /home/jose/etherpad/etherpad-lite && timeout 15 pnpm dev 2>&1 | tail -40 +``` + +Expected: among the boot logs, a line like `[updater] updater: install method = git, tier = notify`. Server starts cleanly. (`timeout 15` exits — that's fine, we're just checking startup.) + +- [ ] **Step 6: Commit** + +```bash +git add src/node/updater/index.ts src/ep.json +git commit -m "feat(updater): wire boot hook and periodic checker" +``` + +--- + +## Task 10: HTTP endpoints (`updateStatus.ts`) + +Two routes: +- `GET /admin/update/status` — admin-only (existing webaccess gates `/admin/`). +- `GET /api/version-status` — public, returns `{outdated: null|"severe"|"vulnerable"}`. + +**Files:** +- Create: `src/node/hooks/express/updateStatus.ts` +- Modify: `src/ep.json` +- Test (mocha integration): `src/tests/backend/specs/updateStatus.ts` + +- [ ] **Step 1: Write failing integration test** + +```typescript +// src/tests/backend/specs/updateStatus.ts +'use strict'; + +const assert = require('assert').strict; +const common = require('../common'); +import settings from '../../../node/utils/Settings'; +import {saveState} from '../../../node/updater/state'; +import {EMPTY_STATE} from '../../../node/updater/types'; +import path from 'node:path'; + +const statePath = () => path.join(settings.root, 'var', 'update-state.json'); + +describe(__filename, function () { + let agent: any; + + before(async function () { + agent = await common.init(); + }); + + describe('GET /api/version-status', function () { + it('returns null when no state', async function () { + await saveState(statePath(), {...EMPTY_STATE}); + const res = await agent.get('/api/version-status').expect(200); + assert.deepEqual(res.body, {outdated: null}); + }); + + it('does not leak the running version', async function () { + const res = await agent.get('/api/version-status').expect(200); + assert.ok(!('version' in res.body)); + assert.ok(!('latest' in res.body)); + }); + + it('returns severe when running > 1 major behind', async function () { + // Force "latest" to be 99.0.0 to make our running version severely outdated. + await saveState(statePath(), { + ...EMPTY_STATE, + latest: { + version: '99.0.0', tag: 'v99.0.0', body: '', + publishedAt: '2099-01-01T00:00:00Z', prerelease: false, + htmlUrl: 'https://example/', + }, + }); + const res = await agent.get('/api/version-status').expect(200); + assert.equal(res.body.outdated, 'severe'); + }); + }); + + describe('GET /admin/update/status', function () { + it('requires admin auth (rejects no-auth)', async function () { + await agent.get('/admin/update/status').expect(401); + }); + }); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test -- --grep "updateStatus" +``` + +Expected: FAIL because the endpoints don't exist (404 instead of 200/401). + +- [ ] **Step 3: Write the implementation** + +```typescript +// src/node/hooks/express/updateStatus.ts +'use strict'; + +import {ArgsExpressType} from '../../types/ArgsExpressType'; +import settings from '../../utils/Settings'; +import {getCurrentState, getDetectedInstallMethod} from '../../updater'; +import {evaluatePolicy} from '../../updater/UpdatePolicy'; +import {compareSemver, isMajorBehind, isVulnerable} from '../../updater/versionCompare'; + +const getEpVersion = (): string => require('../../../package.json').version; + +let badgeCache: {value: 'severe' | 'vulnerable' | null; at: number} = {value: null, at: 0}; +const BADGE_CACHE_MS = 60 * 1000; + +const computeOutdated = async (): Promise<'severe' | 'vulnerable' | null> => { + const state = await getCurrentState(); + if (!state.latest) return null; + const current = getEpVersion(); + if (compareSemver(current, state.latest.version) >= 0) return null; + if (isVulnerable(current, state.vulnerableBelow)) return 'vulnerable'; + if (isMajorBehind(current, state.latest.version)) return 'severe'; + return null; +}; + +exports.expressCreateServer = async ( + hookName: string, + {app}: ArgsExpressType, + cb: Function, +): Promise => { + // Public, cached, never leaks version string. + app.get('/api/version-status', async (_req: any, res: any) => { + const now = Date.now(); + if (now - badgeCache.at > BADGE_CACHE_MS) { + badgeCache = {value: await computeOutdated(), at: now}; + } + res.json({outdated: badgeCache.value}); + }); + + // Admin-protected; webaccess.ts already gates /admin/* with admin auth. + app.get('/admin/update/status', async (_req: any, res: any) => { + const state = await getCurrentState(); + const current = getEpVersion(); + const installMethod = getDetectedInstallMethod(); + const policy = state.latest + ? evaluatePolicy({installMethod, tier: settings.updates.tier, current, latest: state.latest.version}) + : null; + res.json({ + currentVersion: current, + latest: state.latest, + lastCheckAt: state.lastCheckAt, + installMethod, + tier: settings.updates.tier, + policy, + vulnerableBelow: state.vulnerableBelow, + }); + }); + + return cb(); +}; +``` + +- [ ] **Step 4: Register the hook in `ep.json`** + +Add another entry to `src/ep.json`'s `parts`: + +```jsonc +{ + "name": "updateStatus", + "hooks": { + "expressCreateServer": "ep_etherpad-lite/node/hooks/express/updateStatus" + } +} +``` + +- [ ] **Step 5: Run the test to confirm it passes** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test -- --grep "updateStatus" +``` + +Expected: 4 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/node/hooks/express/updateStatus.ts src/ep.json src/tests/backend/specs/updateStatus.ts +git commit -m "feat(updater): add /admin/update/status and /api/version-status endpoints" +``` + +--- + +## Task 11: Locale strings + +Add the i18n keys both the admin UI and pad UI will reference. Doing this now keeps later UI tasks focused on rendering. + +**Files:** +- Modify: `src/locales/en.json` + +- [ ] **Step 1: Read the file's current shape** + +```bash +head -5 /home/jose/etherpad/etherpad-lite/src/locales/en.json +``` + +The file is a flat JSON object of `"key": "value"` pairs. + +- [ ] **Step 2: Add updater keys** + +Insert (alphabetical-ish placement, near `admin_plugins.*`): + +```json +"update.banner.title": "Update available", +"update.banner.body": "Etherpad {{latest}} is available (you are running {{current}}).", +"update.banner.cta": "View update", +"update.page.title": "Etherpad updates", +"update.page.current": "Current version", +"update.page.latest": "Latest version", +"update.page.last_check": "Last checked", +"update.page.install_method": "Install method", +"update.page.tier": "Update tier", +"update.page.changelog": "Changelog", +"update.page.up_to_date": "You are running the latest version.", +"update.badge.severe": "Etherpad on this server is severely outdated. Tell your admin.", +"update.badge.vulnerable": "Etherpad on this server is running a version with known security issues. Tell your admin.", +``` + +- [ ] **Step 3: Verify JSON validity** + +```bash +node -e "JSON.parse(require('fs').readFileSync('/home/jose/etherpad/etherpad-lite/src/locales/en.json','utf8'))" && echo OK +``` + +Expected: `OK`. + +- [ ] **Step 4: Commit** + +```bash +git add src/locales/en.json +git commit -m "i18n(updater): add english strings for update banner, page, and pad badge" +``` + +--- + +## Task 12: Pad-side version badge (frontend code only — endpoint already exists) + +**Files:** +- Create: `src/static/js/pad_version_badge.ts` +- Modify: `src/static/js/pad.ts` (one-line require) +- Modify: `src/templates/pad.html` (add `
`) +- Modify: `src/static/css/pad.css` (small styles) + +- [ ] **Step 1: Create the badge module** + +```typescript +// src/static/js/pad_version_badge.ts +'use strict'; + +interface BadgeResponse { outdated: 'severe' | 'vulnerable' | null } + +const TEXT_BY_LEVEL: Record<'severe' | 'vulnerable', string> = { + severe: 'Etherpad on this server is severely outdated. Tell your admin.', + vulnerable: 'Etherpad on this server is running a version with known security issues. Tell your admin.', +}; + +export const renderVersionBadge = async (): Promise => { + const el = document.getElementById('version-badge'); + if (!el) return; + try { + const res = await fetch('/api/version-status', {credentials: 'same-origin'}); + if (!res.ok) return; + const data = (await res.json()) as BadgeResponse; + if (!data.outdated) { el.style.display = 'none'; return; } + el.textContent = TEXT_BY_LEVEL[data.outdated]; + el.dataset.level = data.outdated; + el.style.display = ''; + } catch { + // Quiet failure — never block the pad load. + } +}; + +// Auto-render once DOM is ready. +if (typeof window !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { void renderVersionBadge(); }); + } else { + void renderVersionBadge(); + } +} +``` + +- [ ] **Step 2: Wire the import** + +Open `src/static/js/pad.ts`. Find the imports block (top of file). Add: + +```typescript +import './pad_version_badge'; +``` + +(Anywhere with the other imports is fine.) + +- [ ] **Step 3: Add the placeholder div to the template** + +Open `src/templates/pad.html`. Find the line `
` (around line 510). On the line BEFORE it, insert: + +```html + +``` + +- [ ] **Step 4: Add CSS** + +Open `src/static/css/pad.css`. Append at the end: + +```css +#version-badge { + position: fixed; + bottom: 8px; + right: 8px; + padding: 6px 10px; + font-size: 12px; + border-radius: 4px; + z-index: 9999; + pointer-events: auto; + max-width: 320px; +} +#version-badge[data-level="severe"] { background: #fff3cd; color: #664d03; border: 1px solid #ffe69c; } +#version-badge[data-level="vulnerable"] { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; } +``` + +- [ ] **Step 5: Type-check & build** + +```bash +cd /home/jose/etherpad/etherpad-lite && pnpm ts-check +``` + +Expected: no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/static/js/pad_version_badge.ts src/static/js/pad.ts src/templates/pad.html src/static/css/pad.css +git commit -m "feat(updater): add pad footer badge for severe/vulnerable status" +``` + +--- + +## Task 13: Admin UI — store, page, banner, route + +**Files:** +- Modify: `admin/src/store/store.ts` +- Create: `admin/src/pages/UpdatePage.tsx` +- Create: `admin/src/components/UpdateBanner.tsx` +- Modify: `admin/src/main.tsx` (route) +- Modify: `admin/src/App.tsx` (render banner + nav link) + +- [ ] **Step 1: Read the existing store shape** + +```bash +sed -n '1,60p' /home/jose/etherpad/etherpad-lite/admin/src/store/store.ts +``` + +This tells you the existing zustand setter pattern. You'll add an `updateStatus` slice with the same pattern. + +- [ ] **Step 2: Extend the store** + +Add to `admin/src/store/store.ts` (alongside existing fields): + +```typescript +export interface UpdateStatusPayload { + currentVersion: string; + latest: null | { + version: string; tag: string; body: string; + publishedAt: string; prerelease: boolean; htmlUrl: string; + }; + lastCheckAt: string | null; + installMethod: string; + tier: string; + policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string}; + vulnerableBelow: Array<{announcedBy: string; threshold: string}>; +} + +// Inside the existing zustand store interface, add: +// updateStatus: UpdateStatusPayload | null; +// setUpdateStatus: (s: UpdateStatusPayload) => void; +// +// And in the `create<...>(set => ({` body: +// updateStatus: null, +// setUpdateStatus: (s) => set({updateStatus: s}), +``` + +(The exact merge depends on the existing file. Read it first, then make a minimal addition that matches the existing pattern. Do not refactor unrelated code.) + +- [ ] **Step 3: Create the banner component** + +```tsx +// admin/src/components/UpdateBanner.tsx +import {useEffect} from 'react'; +import {Link} from 'react-router-dom'; +import {Trans, useTranslation} from 'react-i18next'; +import {useStore} from '../store/store'; + +export const UpdateBanner = () => { + const {t} = useTranslation(); + const updateStatus = useStore((s) => s.updateStatus); + const setUpdateStatus = useStore((s) => s.setUpdateStatus); + + useEffect(() => { + let cancelled = false; + fetch('/admin/update/status', {credentials: 'same-origin'}) + .then((r) => r.ok ? r.json() : null) + .then((data) => { if (data && !cancelled) setUpdateStatus(data); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [setUpdateStatus]); + + if (!updateStatus || !updateStatus.latest) return null; + if (updateStatus.currentVersion === updateStatus.latest.version) return null; + + return ( +
+ {' '} + + + {' '} + {t('update.banner.cta')} +
+ ); +}; +``` + +- [ ] **Step 4: Create the update page** + +```tsx +// admin/src/pages/UpdatePage.tsx +import {Trans, useTranslation} from 'react-i18next'; +import {useStore} from '../store/store'; + +export const UpdatePage = () => { + const {t} = useTranslation(); + const us = useStore((s) => s.updateStatus); + + if (!us) return
; + + const upToDate = !us.latest || us.currentVersion === us.latest.version; + + return ( +
+

+
+
+
{us.currentVersion}
+
+
{us.latest ? us.latest.version : '—'}
+
+
{us.lastCheckAt ?? '—'}
+
+
{us.installMethod}
+
+
{us.tier}
+
+ {upToDate ? ( +

+ ) : us.latest ? ( + <> +

+
{us.latest.body}
+

{us.latest.htmlUrl}

+ + ) : null} +
+ ); +}; + +export default UpdatePage; +``` + +- [ ] **Step 5: Register the route** + +Edit `admin/src/main.tsx`. Import the page and add a route: + +```tsx +import {UpdatePage} from "./pages/UpdatePage.tsx"; +// ... +// Inside the Route element list, add: +}/> +``` + +- [ ] **Step 6: Render the banner and nav link in `App.tsx`** + +Edit `admin/src/App.tsx`. Import: + +```tsx +import {UpdateBanner} from "./components/UpdateBanner"; +import {Bell} from "lucide-react"; +``` + +Inside the JSX, just before ``, render the banner: + +```tsx + + +``` + +And in the `
    ` of nav links, add (after the last existing `
  • `): + +```tsx +
  • +``` + +- [ ] **Step 7: Verify type-check + admin builds** + +```bash +cd /home/jose/etherpad/etherpad-lite && pnpm ts-check +cd /home/jose/etherpad/etherpad-lite && pnpm run build:ui +``` + +Expected: both succeed. + +- [ ] **Step 8: Commit** + +```bash +git add admin/src/store/store.ts admin/src/pages/UpdatePage.tsx admin/src/components/UpdateBanner.tsx admin/src/main.tsx admin/src/App.tsx +git commit -m "feat(admin-ui): add update banner, page, and nav link" +``` + +--- + +## Task 14: Admin Playwright test + +**Files:** +- Create: `src/tests/frontend-new/admin-spec/update-banner.spec.ts` + +- [ ] **Step 1: Look at an existing admin spec for the auth helper pattern** + +```bash +ls /home/jose/etherpad/etherpad-lite/src/tests/frontend-new/admin-spec +``` + +If a `helpers.ts` or `auth-utils.ts` exists, study it. Otherwise look at a sibling spec for how it logs in. + +- [ ] **Step 2: Write the test** + +```typescript +// src/tests/frontend-new/admin-spec/update-banner.spec.ts +import {test, expect} from '@playwright/test'; + +const ADMIN_URL = process.env.ADMIN_URL ?? 'http://localhost:9001/admin/'; + +// NOTE: tests run with --workers 1 per package.json's test-admin script. +// Authentication: existing admin tests use basic auth via the URL or storageState. +// Match the pattern of a sibling spec in this directory. + +test('admin homepage exposes the update nav link', async ({page}) => { + await page.goto(ADMIN_URL); + await expect(page.getByRole('link', {name: /etherpad updates/i})).toBeVisible(); +}); + +test('update page renders current version', async ({page}) => { + await page.goto(`${ADMIN_URL}update`); + await expect(page.getByText(/current version/i)).toBeVisible(); + // The running version is rendered as a
    . + await expect(page.locator('dd').first()).not.toBeEmpty(); +}); +``` + +If the existing admin specs use a shared auth fixture, port the call into this test (the snippet above assumes the test runner's storageState already covers auth — same as other admin specs). + +- [ ] **Step 3: Run the test** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test-admin -- update-banner +``` + +Expected: passes (with Etherpad running locally — `pnpm dev` in another terminal). + +- [ ] **Step 4: Commit** + +```bash +git add src/tests/frontend-new/admin-spec/update-banner.spec.ts +git commit -m "test(updater): admin Playwright test for update nav and page" +``` + +--- + +## Task 15: Pad Playwright test + +**Files:** +- Create: `src/tests/frontend-new/specs/pad-version-badge.spec.ts` + +- [ ] **Step 1: Look at an existing pad spec for the test harness pattern** + +```bash +ls /home/jose/etherpad/etherpad-lite/src/tests/frontend-new/specs | head -10 +``` + +- [ ] **Step 2: Write the test** + +```typescript +// src/tests/frontend-new/specs/pad-version-badge.spec.ts +import {test, expect} from '@playwright/test'; + +const padUrl = (id = `test-${Date.now()}`) => + `${process.env.PAD_URL ?? 'http://localhost:9001'}/p/${id}`; + +test('badge is hidden when not outdated', async ({page}) => { + await page.route('**/api/version-status', (route) => + route.fulfill({status: 200, contentType: 'application/json', body: JSON.stringify({outdated: null})})); + await page.goto(padUrl()); + const badge = page.locator('#version-badge'); + await expect(badge).toBeHidden(); +}); + +test('badge shows severe text when outdated=severe', async ({page}) => { + await page.route('**/api/version-status', (route) => + route.fulfill({status: 200, contentType: 'application/json', body: JSON.stringify({outdated: 'severe'})})); + await page.goto(padUrl()); + const badge = page.locator('#version-badge'); + await expect(badge).toBeVisible(); + await expect(badge).toContainText(/severely outdated/i); + await expect(badge).toHaveAttribute('data-level', 'severe'); +}); + +test('badge shows vulnerable text when outdated=vulnerable', async ({page}) => { + await page.route('**/api/version-status', (route) => + route.fulfill({status: 200, contentType: 'application/json', body: JSON.stringify({outdated: 'vulnerable'})})); + await page.goto(padUrl()); + const badge = page.locator('#version-badge'); + await expect(badge).toBeVisible(); + await expect(badge).toContainText(/security issues/i); + await expect(badge).toHaveAttribute('data-level', 'vulnerable'); +}); +``` + +- [ ] **Step 3: Run the test** + +```bash +cd /home/jose/etherpad/etherpad-lite/src && pnpm test-ui -- pad-version-badge +``` + +Expected: 3 tests pass with Etherpad running locally. **Do not pass `--headed`** — per project guidance, tests must run headless. + +- [ ] **Step 4: Commit** + +```bash +git add src/tests/frontend-new/specs/pad-version-badge.spec.ts +git commit -m "test(updater): pad Playwright test for version badge visibility" +``` + +--- + +## Task 16: Documentation + +**Files:** +- Create: `doc/admin/updates.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Confirm the doc dir layout** + +```bash +ls /home/jose/etherpad/etherpad-lite/doc/admin 2>/dev/null || ls /home/jose/etherpad/etherpad-lite/doc | head +``` + +If `doc/admin/` doesn't exist yet, create it. Otherwise add the file alongside existing admin docs. + +- [ ] **Step 2: Write `doc/admin/updates.md`** + +```markdown +# Etherpad updates + +Etherpad ships with a built-in update subsystem. **Tier 1 (notify)** is enabled by default: a banner appears in the admin UI when a new release is available, and pad users see a discreet badge if the running version is severely outdated or flagged as vulnerable. + +## Settings + +In `settings.json`: + +```json +{ + "updates": { + "tier": "notify", + "checkIntervalHours": 6, + "githubRepo": "ether/etherpad" + }, + "adminEmail": null +} +``` + +- `updates.tier` — `"off"`, `"notify"` (default), or future tiers `"manual"`, `"auto"`, `"autonomous"` (PR 2+). +- `updates.checkIntervalHours` — how often to poll GitHub Releases. Default 6. +- `updates.githubRepo` — override for forks. +- `adminEmail` — top-level. Contact for admin notifications. Set to receive escalating nudges when this instance is outdated. + +## Email cadence (when `adminEmail` is set) + +- **Vulnerable** — first detected: immediate. Repeats weekly while still vulnerable. +- **Severely outdated** (1+ major versions behind) — first detected: immediate. Repeats monthly while still severe. +- **No email** when up to date. + +## Disabling everything + +Set `updates.tier` to `"off"`. No HTTP request will leave the instance. + +## Privacy + +The version check sends no telemetry. Etherpad fetches the public GitHub Releases API. The only metadata GitHub sees is the same as any other GitHub API client (your IP, User-Agent header). +``` + +- [ ] **Step 3: Update `CHANGELOG.md`** + +Read the head of `CHANGELOG.md` and add (under the unreleased section, or create one): + +```markdown +### Added + +- New self-update subsystem (Tier 1: notify). + - Periodic version check against GitHub Releases (`updates.tier`, default `"notify"`; set `"off"` to disable). + - Admin UI banner and dedicated update page when a new release is available. + - Pad-side discreet badge only when running a severely-outdated or flagged-vulnerable version (no version string is leaked). + - Optional escalating email nudges via top-level `adminEmail` setting. +``` + +- [ ] **Step 4: Commit** + +```bash +git add doc/admin/updates.md CHANGELOG.md +git commit -m "docs(updater): document tier-1 updates and adminEmail settings" +``` + +--- + +## Task 17: Smoke test on a real instance + +A short manual checklist before opening the PR. Each step you tick off here verifies a path the unit tests can't. + +- [ ] **Step 1: Boot Etherpad locally** + +```bash +cd /home/jose/etherpad/etherpad-lite && pnpm dev +``` + +Wait for `Server listening on port 9001`. + +- [ ] **Step 2: Verify boot logs include the updater line** + +In the dev output you should see `updater: install method = git, tier = notify`. + +- [ ] **Step 3: Hit the public endpoint** + +```bash +curl -s http://localhost:9001/api/version-status +``` + +Expected: `{"outdated":null}` (or `severe`/`vulnerable` if the test data is configured that way). + +- [ ] **Step 4: Hit the admin endpoint without auth** + +```bash +curl -i http://localhost:9001/admin/update/status +``` + +Expected: HTTP 401 (auth required). + +- [ ] **Step 5: Hit the admin endpoint with auth** + +```bash +curl -s -u admin:changeme http://localhost:9001/admin/update/status | head +``` + +(Adjust credentials to your local `settings.json`.) Expected: JSON with `currentVersion`, `latest`, `installMethod`, `tier`, `policy`. + +- [ ] **Step 6: Open `/admin` in a browser** + +Verify the new "Etherpad updates" nav link is visible. Click it; confirm the page renders the current version. + +- [ ] **Step 7: Open a pad in a browser** + +Verify there is no badge by default (the running version equals latest). Then in DevTools, intercept `/api/version-status` and return `{"outdated":"severe"}`; reload the pad; verify the badge appears in the bottom-right corner. + +- [ ] **Step 8: Force a vulnerable test** + +Edit `var/update-state.json` to add a `vulnerableBelow` entry whose threshold is above the running version. Reload the pad; confirm the badge text switches to the vulnerable copy. + +- [ ] **Step 9: Confirm `tier: "off"` disables everything** + +Set `updates.tier` to `"off"` in `settings.json`, restart, and confirm `curl /api/version-status` still works (cached value) but no GitHub request fires (look at the dev console logs). + +--- + +## Task 18: Open the PR + +- [ ] **Step 1: Push the branch** + +```bash +git push fork feat/auto-update-tier1 +``` + +- [ ] **Step 2: Create the PR via gh against `ether/etherpad-lite:develop`** + +```bash +gh pr create \ + --repo ether/etherpad-lite \ + --base develop \ + --head johnmclear:feat/auto-update-tier1 \ + --title "feat(updater): tier 1 — notify admin and pad users of available updates" \ + --body "$(cat <<'EOF' +## Summary + +Ships **Tier 1** of the four-tier auto-update design (spec: `docs/superpowers/specs/2026-04-25-auto-update-design.md`). + +- Periodic poll of GitHub Releases (default 6h), cached on disk at `var/update-state.json`. +- Admin UI banner + read-only `/update` page. +- Public `/api/version-status` endpoint and pad-side footer badge — only renders when severely outdated or running a flagged-vulnerable version. Never leaks the running version string. +- Escalating email nudges via new top-level `adminEmail` setting. +- New `updates.*` settings block; default `tier: "notify"`. Set to `"off"` to disable entirely. +- Tier 1 contains **no execution code**. PRs 2–4 build on this foundation. + +## Settings + +`updates.tier` (default `"notify"`), `updates.checkIntervalHours`, `updates.githubRepo`, `adminEmail` — all optional. See `doc/admin/updates.md`. + +## Test plan + +- [x] vitest unit tests for `versionCompare`, `state`, `InstallMethodDetector`, `UpdatePolicy`, `VersionChecker`, `Notifier` +- [x] mocha integration tests for `/admin/update/status` and `/api/version-status` +- [x] Playwright admin spec — banner + page render +- [x] Playwright pad spec — badge visibility on `null` / `severe` / `vulnerable` +- [x] Manual smoke (real boot, real curl, real browser) + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 3: Post `/review` comment to trigger Qodo** + +```bash +gh pr comment --body "/review" +``` + +- [ ] **Step 4: Confirm CI is green** + +```bash +gh pr checks +``` + +If anything fails, fix the underlying issue and push (do NOT use `--no-verify`). + +--- + +## Plan self-review + +Done at write time: + +- **Spec coverage:** every PR-1 scope item from the spec maps to a task — VersionChecker (Task 6), InstallMethodDetector (Task 4), UpdatePolicy (Task 5), state (Task 3), Notifier (Task 7), `updateStatus.ts` routes (Task 10), settings additions (Task 8), admin UI (Task 13), pad badge (Task 12), tests (Tasks 2-15), CHANGELOG/docs (Task 16). The spec's "PR 1: Test coverage gates" row (VersionChecker, InstallMethodDetector, UpdatePolicy, Notifier unit + status endpoint API + banner Playwright + pad badge Playwright) is all covered. +- **Placeholder scan:** no TBDs, no "implement later," no "similar to Task N" — every step has the actual code or command. +- **Type / name consistency:** `evaluatePolicy`, `checkLatestRelease`, `decideEmails`, `detectInstallMethod`, `loadState`/`saveState`, `getCurrentState`, `getDetectedInstallMethod` — used consistently across tasks. `UpdateState` shape matches between `types.ts`, `state.ts`, and the integration test. `EMPTY_STATE` is exported from `types.ts` and re-exported from `state.ts` as `EMPTY_STATE_FOR_TESTS` for clarity at call sites. + +## Out of scope (deferred to PR 2) + +- `UpdateExecutor` (git fetch/checkout, pnpm install, build:ui, exit 75) +- `RollbackHandler` (boot-time health check, crash-loop guard, terminal `rollback-failed`) +- `SessionDrainer` (60s broadcast) +- `var/update.lock` +- Tag signature verification + trusted-keys +- `POST /admin/update/apply`, `/cancel`, `/acknowledge`, `GET /admin/update/log` +- Apply button in admin UI +- Real SMTP wiring for the email path (PR 1 logs "would send" — connecting nodemailer or relying on a future plugin lands with PR 2) + +These will be planned in `2026-04-25-auto-update-pr2-manual.md` after PR 1 lands and we know the actual file paths. 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-03-gdpr-admin-author-erasure-ui.md b/docs/superpowers/plans/2026-05-03-gdpr-admin-author-erasure-ui.md new file mode 100644 index 00000000000..a2d97f57073 --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-gdpr-admin-author-erasure-ui.md @@ -0,0 +1,1627 @@ +# Admin UI for GDPR Art. 17 Author Erasure — 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 an in-product `/admin/authors` page that lets operators search authors by name or external mapper, preview the impact of an Art. 17 erasure, and commit it — without crafting a `curl`. + +**Architecture:** Three new admin-socket events on `io.of('/settings')` (parallel to the existing `padLoad`/`deletePad`/`cleanupPadRevisions` handlers in `adminsettings.ts`). New helper `authorManager.searchAuthors()` enumerates `globalAuthor:*` keys, joins with `mapper2author:*` for the mapper column, and applies in-memory filter/sort/pagination capped at 1000 rows pre-pagination. `anonymizeAuthor` gains a `{dryRun}` option that walks the same loops without writing. Frontend mirrors `PadPage.tsx`: a Radix-based table with a two-step erase modal (preview counters → commit). The existing `gdprAuthorErasure.enabled` flag gates only the live erasure (admin-socket and REST); the read-only browse and dry-run preview always work for authenticated admins. When the flag is off the page renders a banner and disables the Erase button. + +**Tech Stack:** TypeScript, Node.js, socket.io, React 18, Radix UI Dialog, Zustand, react-i18next, lucide-react icons, Playwright (frontend tests), Mocha + tsx (backend tests). + +**Branch:** `feat-gdpr-admin-author-erasure` (off ether/etherpad develop). Spec already committed at `docs/superpowers/specs/2026-05-03-gdpr-admin-author-erasure-ui-design.md`. + +## File Structure + +**Backend — modify:** +- `src/node/db/AuthorManager.ts` — add `lastSeen` writes on existing write paths; extend `anonymizeAuthor` with optional `{dryRun}` arg; add `searchAuthors` helper. +- `src/node/hooks/express/adminsettings.ts` — add three socket handlers + extend the connect-time settings push so the client knows whether `gdprAuthorErasure.enabled` is true. + +**Backend — create:** +- `src/tests/backend/specs/admin/authorSearch.ts` — unit-level coverage of `searchAuthors` (all the filter/sort/cap branches). +- `src/tests/backend/specs/admin/anonymizeAuthorSocket.ts` — socket integration: round-trip the three new events and assert flag-disabled / dry-run-survives-disabled behaviour. + +**Backend — extend:** +- `src/tests/backend/specs/anonymizeAuthor.ts` — two new specs covering `dryRun: true`. + +**Frontend — modify:** +- `admin/src/store/store.ts` — add `authors` slice and `gdprAuthorErasureEnabled` flag. +- `admin/src/main.tsx` — register `/authors` route. +- `admin/src/App.tsx` — sidebar link + listen for the flag in the existing `settingSocket.on('settings', …)` handler. + +**Frontend — create:** +- `admin/src/utils/AuthorSearch.ts` — `AuthorSearchQuery`, `AuthorSearchResult`, `AuthorRow` types. +- `admin/src/components/ColorSwatch.tsx` — small inline-style swatch. +- `admin/src/pages/AuthorPage.tsx` — page component (table, search, sort, pagination, disabled banner, two-step erase modal). +- `admin/public/ep_admin_authors/en.json` — i18n keys for the new page (loaded via the existing `ep_admin_authors` namespace pattern). +- `src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts` — Playwright coverage of the page. + +--- + +## Task 1: `lastSeen` field on `globalAuthor:` + +**Files:** +- Modify: `src/node/db/AuthorManager.ts:198-247` +- Test: `src/tests/backend/specs/anonymizeAuthor.ts` (extend existing file) + +**Why:** The new admin search needs a `lastSeen` column. Stamping it on the existing write paths (createAuthor, setAuthorName, setAuthorColorId) is additive — no migration, no read-path overhead. + +- [ ] **Step 1: Write the failing test** — append to `src/tests/backend/specs/anonymizeAuthor.ts`: + +```typescript + it('lastSeen is stamped when an author is created and on identity writes', + async function () { + const before = Date.now(); + const {authorID} = await authorManager.createAuthorIfNotExistsFor( + `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`, 'Dora'); + const created = await DB.db.get(`globalAuthor:${authorID}`); + assert.ok(typeof created.lastSeen === 'number', + `lastSeen=${created.lastSeen}`); + assert.ok(created.lastSeen >= before); + + await new Promise((r) => setTimeout(r, 5)); + await authorManager.setAuthorName(authorID, 'Dora2'); + const renamed = await DB.db.get(`globalAuthor:${authorID}`); + assert.ok(renamed.lastSeen > created.lastSeen, + `renamed=${renamed.lastSeen} created=${created.lastSeen}`); + + await new Promise((r) => setTimeout(r, 5)); + await authorManager.setAuthorColorId(authorID, '12'); + const recolored = await DB.db.get(`globalAuthor:${authorID}`); + assert.ok(recolored.lastSeen > renamed.lastSeen); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run from `src/`: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/anonymizeAuthor.ts` + +Expected: the new spec fails with `lastSeen=undefined`. + +- [ ] **Step 3: Stamp `lastSeen` in `createAuthor`** — in `src/node/db/AuthorManager.ts`, replace the body of `exports.createAuthor`: + +```typescript +exports.createAuthor = async (name: string) => { + const author = `a.${randomString(16)}`; + const now = Date.now(); + const authorObj = { + colorId: Math.floor(Math.random() * (exports.getColorPalette().length)), + name, + timestamp: now, + lastSeen: now, + }; + await db.set(`globalAuthor:${author}`, authorObj); + return {authorID: author}; +}; +``` + +- [ ] **Step 4: Stamp `lastSeen` in `setAuthorColorId` and `setAuthorName`** — replace the two one-liner exports: + +```typescript +exports.setAuthorColorId = async (author: string, colorId: string) => { + await db.setSub(`globalAuthor:${author}`, ['colorId'], colorId); + await db.setSub(`globalAuthor:${author}`, ['lastSeen'], Date.now()); +}; + +exports.setAuthorName = async (author: string, name: string) => { + await db.setSub(`globalAuthor:${author}`, ['name'], name); + await db.setSub(`globalAuthor:${author}`, ['lastSeen'], Date.now()); +}; +``` + +- [ ] **Step 5: Re-run test to verify it passes** + +Same command as Step 2. Expected: all `anonymizeAuthor.ts` specs pass (5 existing + 1 new = 6 passing). + +- [ ] **Step 6: Commit** + +```bash +git add src/node/db/AuthorManager.ts src/tests/backend/specs/anonymizeAuthor.ts +git commit -m "feat(authors): stamp lastSeen on globalAuthor writes + +Adds a lastSeen timestamp to the globalAuthor record on createAuthor, +setAuthorName, and setAuthorColorId. Read paths are not modified to +keep the write cost zero per page load. Pre-existing records gain the +field on their next identity write — no migration sweep, callers that +read the field tolerate undefined. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: `anonymizeAuthor({dryRun})` option + +**Files:** +- Modify: `src/node/db/AuthorManager.ts:328-415` +- Test: `src/tests/backend/specs/anonymizeAuthor.ts` (extend) + +**Why:** The admin UI needs a server-side preview of how many things an erasure would touch. Reusing the live function with a `dryRun` flag keeps the counter shape identical and avoids drift. + +- [ ] **Step 1: Write two failing tests** — append to `src/tests/backend/specs/anonymizeAuthor.ts`: + +```typescript + it('dryRun returns the same counter shape but does not mutate the record', + async function () { + const mapper = `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const {authorID} = + await authorManager.createAuthorIfNotExistsFor(mapper, 'Eve'); + const before = await DB.db.get(`globalAuthor:${authorID}`); + + const preview = + await authorManager.anonymizeAuthor(authorID, {dryRun: true}); + + assert.ok(preview.removedExternalMappings >= 1, + `removedExternalMappings=${preview.removedExternalMappings}`); + const after = await DB.db.get(`globalAuthor:${authorID}`); + assert.equal(after.name, 'Eve', 'name should be untouched'); + assert.equal(after.erased, undefined, + 'erased flag should not be set on dry run'); + assert.equal(await DB.db.get(`mapper2author:${mapper}`), authorID, + 'mapper binding should still resolve after dry run'); + assert.deepEqual( + Object.keys(before.padIDs || {}).sort(), + Object.keys(after.padIDs || {}).sort()); + }); + + it('dryRun on an unknown authorID returns zero counters without throwing', + async function () { + const res = await authorManager.anonymizeAuthor( + 'a.does-not-exist-xxxxxxxxxxxx', {dryRun: true}); + assert.deepEqual(res, { + affectedPads: 0, + removedTokenMappings: 0, + removedExternalMappings: 0, + clearedChatMessages: 0, + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/anonymizeAuthor.ts` + +Expected: both new specs fail (current signature ignores the second arg and mutates the record). + +- [ ] **Step 3: Refactor `anonymizeAuthor` to accept `{dryRun}`** — in `src/node/db/AuthorManager.ts`, replace the function body. The signature becomes: + +```typescript +exports.anonymizeAuthor = async ( + authorID: string, + opts: {dryRun?: boolean} = {}, +): Promise<{ + affectedPads: number, + removedTokenMappings: number, + removedExternalMappings: number, + clearedChatMessages: number, +}> => { + const dryRun = opts.dryRun === true; + const padManager = require('./PadManager'); + const existing = await db.get(`globalAuthor:${authorID}`); + if (existing == null || existing.erased) { + return { + affectedPads: 0, + removedTokenMappings: 0, + removedExternalMappings: 0, + clearedChatMessages: 0, + }; + } + + let removedTokenMappings = 0; + const tokenKeys: string[] = await db.findKeys('token2author:*', null); + for (const key of tokenKeys) { + if (await db.get(key) === authorID) { + if (!dryRun) await db.remove(key); + removedTokenMappings++; + } + } + let removedExternalMappings = 0; + const mapperKeys: string[] = await db.findKeys('mapper2author:*', null); + for (const key of mapperKeys) { + if (await db.get(key) === authorID) { + if (!dryRun) await db.remove(key); + removedExternalMappings++; + } + } + + if (!dryRun) { + await db.set(`globalAuthor:${authorID}`, { + colorId: 0, + name: null, + timestamp: Date.now(), + padIDs: existing.padIDs || {}, + }); + } + + const padIDs = Object.keys(existing.padIDs || {}); + let clearedChatMessages = 0; + for (const padID of padIDs) { + if (!await padManager.doesPadExist(padID)) continue; + const pad = await padManager.getPad(padID); + const chatHead = pad.chatHead; + if (typeof chatHead !== 'number' || chatHead < 0) continue; + for (let i = 0; i <= chatHead; i++) { + const chatKey = `pad:${padID}:chat:${i}`; + const msg = await db.get(chatKey); + if (msg != null && msg.authorId === authorID) { + if (!dryRun) { + msg.authorId = null; + await db.set(chatKey, msg); + } + clearedChatMessages++; + } + } + } + + if (!dryRun) { + await db.set(`globalAuthor:${authorID}`, { + colorId: 0, + name: null, + timestamp: Date.now(), + padIDs: existing.padIDs || {}, + erased: true, + erasedAt: new Date().toISOString(), + }); + } + + return { + affectedPads: padIDs.length, + removedTokenMappings, + removedExternalMappings, + clearedChatMessages, + }; +}; +``` + +- [ ] **Step 4: Re-run all anonymizeAuthor specs to verify both new and existing pass** + +Run: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/anonymizeAuthor.ts` + +Expected: 8 passing (5 existing + lastSeen + 2 dryRun). + +- [ ] **Step 5: Commit** + +```bash +git add src/node/db/AuthorManager.ts src/tests/backend/specs/anonymizeAuthor.ts +git commit -m "feat(authors): anonymizeAuthor({dryRun}) for preview + +Adds an opt-in dryRun option that walks the same token/mapper/chat +loops and returns identical counter shape without touching the +database. The public REST endpoint is unchanged (it never passes the +flag), so production behaviour is identical. Used by the upcoming +admin-UI two-step erase modal to show 'will clear: N mappings, K +chat messages' before the irreversible commit. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: `authorManager.searchAuthors(query)` + +**Files:** +- Modify: `src/node/db/AuthorManager.ts` (append after `anonymizeAuthor`) +- Test: `src/tests/backend/specs/admin/authorSearch.ts` (new) + +**Why:** Backend half of the search-and-list page. In-memory scan with cap is plenty for typical instances; a dedicated index is a follow-up if anyone hits the cap. + +- [ ] **Step 1: Create the test directory + file** + +```bash +mkdir -p src/tests/backend/specs/admin +``` + +Create `src/tests/backend/specs/admin/authorSearch.ts`: + +```typescript +'use strict'; + +import {strict as assert} from 'assert'; + +const common = require('../../common'); +const authorManager = require('../../../../node/db/AuthorManager'); +const DB = require('../../../../node/db/DB'); + +describe(__filename, function () { + before(async function () { + this.timeout(60000); + await common.init(); + }); + + // Each spec seeds its own authors with unique mappers so they don't + // collide with parallel runs or with whatever the rest of the suite + // happened to leave in the dirty.db. + const seed = async (name: string, mapper: string) => + (await authorManager.createAuthorIfNotExistsFor(mapper, name)).authorID; + + it('returns an empty page when the pattern matches nothing', async function () { + const res = await authorManager.searchAuthors({ + pattern: `nonexistent-${Date.now()}-${Math.random()}`, + offset: 0, limit: 12, sortBy: 'name', ascending: true, + includeErased: false, + }); + assert.equal(res.total, 0); + assert.deepEqual(res.results, []); + }); + + it('matches by name substring', async function () { + const tag = `findme-${Date.now()}`; + await seed(`Alice ${tag}`, `m-${tag}-1`); + await seed(`Bob ${tag}`, `m-${tag}-2`); + const res = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'name', ascending: true, + includeErased: false, + }); + assert.equal(res.total, 2); + assert.equal(res.results[0].name, `Alice ${tag}`); + assert.equal(res.results[1].name, `Bob ${tag}`); + }); + + it('matches by mapper substring (joins mapper2author)', async function () { + const tag = `mapper-tag-${Date.now()}`; + await seed('Carol', `${tag}-x`); + const res = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'name', ascending: true, + includeErased: false, + }); + assert.ok(res.results.some((r: any) => r.name === 'Carol' && + r.mapper.some((m: string) => m.includes(tag))), + `results=${JSON.stringify(res.results)}`); + }); + + it('hides erased authors by default and includes them when asked', + async function () { + const tag = `era-${Date.now()}`; + const id = await seed(`Erasable ${tag}`, `m-${tag}`); + await authorManager.anonymizeAuthor(id); + + const hidden = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'name', ascending: true, + includeErased: false, + }); + assert.equal(hidden.total, 0, + `expected erased author hidden, got ${JSON.stringify(hidden)}`); + + const shown = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'name', ascending: true, + includeErased: true, + }); + assert.equal(shown.total, 1); + assert.equal(shown.results[0].erased, true); + }); + + it('sorts by lastSeen', async function () { + const tag = `sort-${Date.now()}`; + const a = await seed(`SortA ${tag}`, `m-${tag}-a`); + await new Promise((r) => setTimeout(r, 10)); + const b = await seed(`SortB ${tag}`, `m-${tag}-b`); + const asc = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'lastSeen', ascending: true, + includeErased: false, + }); + assert.equal(asc.results[0].authorID, a); + assert.equal(asc.results[1].authorID, b); + const desc = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'lastSeen', ascending: false, + includeErased: false, + }); + assert.equal(desc.results[0].authorID, b); + }); + + it('caps results at 1000 and reports cappedAt', async function () { + this.timeout(120000); + const tag = `cap-${Date.now()}`; + // Seed 1100 authors directly via DB to keep this fast (~1s vs minutes + // through createAuthorIfNotExistsFor). + const seeded: string[] = []; + for (let i = 0; i < 1100; i++) { + const id = `a.${tag}-${i.toString().padStart(5, '0')}`; + await DB.db.set(`globalAuthor:${id}`, { + colorId: 0, name: `cap ${tag} ${i}`, timestamp: Date.now(), + lastSeen: Date.now(), + }); + seeded.push(id); + } + const res = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'name', ascending: true, + includeErased: false, + }); + assert.equal(res.cappedAt, 1000, + `expected cappedAt=1000, got ${res.cappedAt}`); + assert.equal(res.total, 1000); + }); +}); +``` + +- [ ] **Step 2: Run the new spec to verify it fails** + +Run from `src/`: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/admin/authorSearch.ts` + +Expected: every spec fails with `TypeError: authorManager.searchAuthors is not a function`. + +- [ ] **Step 3: Add `searchAuthors` to `AuthorManager.ts`** — append at the end of the file (after the `anonymizeAuthor` function): + +```typescript +/** + * Admin-side author listing for the /admin/authors page. Enumerates + * `globalAuthor:*`, joins with `mapper2author:*` for the mapper column, + * applies in-memory filter/sort/pagination. Capped at 1000 rows pre- + * pagination so a runaway scan can't OOM the admin process — callers + * surface the cap via `cappedAt`. + * + * @param query.pattern substring match against name OR any mapper + * @param query.offset pagination offset + * @param query.limit pagination limit + * @param query.sortBy 'name' | 'lastSeen' + * @param query.ascending sort direction + * @param query.includeErased when false (default), hides records with + * erased: true + */ +exports.searchAuthors = async (query: { + pattern: string, + offset: number, + limit: number, + sortBy: 'name' | 'lastSeen', + ascending: boolean, + includeErased: boolean, +}): Promise<{ + total: number, + cappedAt?: number, + results: Array<{ + authorID: string, + name: string | null, + colorId: string | number | null, + mapper: string[], + lastSeen: number | null, + erased: boolean, + }>, +}> => { + // Build a reverse index mapper -> authorID once. mapper2author values + // can be either a bare string (legacy) or an object {authorID}. + const mapperByAuthor = new Map(); + const mapperKeys: string[] = await db.findKeys('mapper2author:*', null); + for (const key of mapperKeys) { + const v = await db.get(key); + const authorID = + typeof v === 'string' ? v : (v && v.authorID) || null; + if (!authorID) continue; + const mapper = key.substring('mapper2author:'.length); + if (!mapperByAuthor.has(authorID)) mapperByAuthor.set(authorID, []); + mapperByAuthor.get(authorID)!.push(mapper); + } + + const authorKeys: string[] = await db.findKeys('globalAuthor:*', null); + const pattern = (query.pattern || '').toLowerCase(); + const rows: Array<{ + authorID: string, name: string | null, + colorId: string | number | null, mapper: string[], + lastSeen: number | null, erased: boolean, + }> = []; + + for (const key of authorKeys) { + const rec = await db.get(key); + if (rec == null) continue; + const erased = rec.erased === true; + if (erased && !query.includeErased) continue; + const authorID = key.substring('globalAuthor:'.length); + const mappers = mapperByAuthor.get(authorID) || []; + if (pattern) { + const nameMatch = + (rec.name || '').toLowerCase().includes(pattern); + const mapperMatch = + mappers.some((m) => m.toLowerCase().includes(pattern)); + if (!nameMatch && !mapperMatch) continue; + } + rows.push({ + authorID, + name: rec.name ?? null, + colorId: rec.colorId ?? null, + mapper: mappers, + lastSeen: typeof rec.lastSeen === 'number' ? rec.lastSeen : null, + erased, + }); + } + + rows.sort((a, b) => { + let av: any; let bv: any; + if (query.sortBy === 'lastSeen') { + av = a.lastSeen ?? 0; bv = b.lastSeen ?? 0; + } else { + av = (a.name || '').toLowerCase(); + bv = (b.name || '').toLowerCase(); + } + if (av < bv) return query.ascending ? -1 : 1; + if (av > bv) return query.ascending ? 1 : -1; + return 0; + }); + + const CAP = 1000; + let cappedAt: number | undefined; + let working = rows; + if (working.length > CAP) { + working = working.slice(0, CAP); + cappedAt = CAP; + } + + const total = working.length; + const page = working.slice(query.offset, query.offset + query.limit); + const out: any = {total, results: page}; + if (cappedAt != null) out.cappedAt = cappedAt; + return out; +}; +``` + +- [ ] **Step 4: Re-run the new spec** + +Run: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/admin/authorSearch.ts` + +Expected: 6 passing. + +- [ ] **Step 5: Commit** + +```bash +git add src/node/db/AuthorManager.ts src/tests/backend/specs/admin/authorSearch.ts +git commit -m "feat(authors): authorManager.searchAuthors helper + +In-memory enumeration of globalAuthor:* with a join on mapper2author:* +for the mapper column. Filter (substring on name OR mapper), sort +(name | lastSeen), paginate, and cap the pre-pagination set at 1000 +to prevent runaway scans. Powers the upcoming /admin/authors page. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: Three new admin-socket events + flag delivery + +**Files:** +- Modify: `src/node/hooks/express/adminsettings.ts` (add handlers; extend `load` reply with feature flag) +- Test: `src/tests/backend/specs/admin/anonymizeAuthorSocket.ts` (new) + +**Why:** Wire the search/preview/erase actions to the existing `io.of('/settings')` admin namespace, reusing the admin-auth gate that's already in place. The `gdprAuthorErasure.enabled` flag gates only the live erasure event — the read paths (browse + dry-run preview) stay usable so the UI is discoverable. + +- [ ] **Step 1: Write the failing socket-integration test** — create `src/tests/backend/specs/admin/anonymizeAuthorSocket.ts`: + +```typescript +'use strict'; + +import {strict as assert} from 'assert'; +const io = require('socket.io-client'); + +const common = require('../../common'); +const settings = require('../../../../node/utils/Settings'); +const authorManager = require('../../../../node/db/AuthorManager'); + +const adminSocket = async () => { + // Mirrors the /settings admin namespace gated by the express session's + // is_admin flag. The test bootstrap signs the admin in via the same JWT + // helper used by REST tests. + const baseUrl = (await common.init()).replace(/^http/, 'ws'); + const socket = io.connect(`${baseUrl}/settings`, { + transports: ['websocket'], + extraHeaders: { + authorization: `Bearer ${await common.generateJWTToken()}`, + }, + }); + await new Promise((res, rej) => { + socket.once('connect', res); + socket.once('connect_error', rej); + }); + return socket; +}; + +const ask = (socket: any, evt: string, payload: any, replyEvt: string) => + new Promise((res) => { + socket.once(replyEvt, res); + socket.emit(evt, payload); + }); + +describe(__filename, function () { + let socket: any; + let originalFlag: boolean; + + before(async function () { + this.timeout(60000); + settings.gdprAuthorErasure = settings.gdprAuthorErasure || {enabled: false}; + originalFlag = settings.gdprAuthorErasure.enabled; + settings.gdprAuthorErasure.enabled = true; + socket = await adminSocket(); + }); + + after(function () { + if (socket) socket.disconnect(); + settings.gdprAuthorErasure.enabled = originalFlag; + }); + + it('authorLoad returns paginated rows', async function () { + const tag = `sock-${Date.now()}`; + await authorManager.createAuthorIfNotExistsFor(`m-${tag}`, `Sock ${tag}`); + const res = await ask(socket, 'authorLoad', + {pattern: tag, offset: 0, limit: 12, sortBy: 'name', + ascending: true, includeErased: false}, + 'results:authorLoad'); + assert.ok(res.total >= 1, JSON.stringify(res)); + assert.ok(res.results.some((r: any) => r.name === `Sock ${tag}`)); + }); + + it('anonymizeAuthorPreview returns counters without flipping erased', + async function () { + const tag = `prev-${Date.now()}`; + const {authorID} = await authorManager.createAuthorIfNotExistsFor( + `m-${tag}`, `Prev ${tag}`); + const preview = await ask(socket, 'anonymizeAuthorPreview', + {authorID}, 'results:anonymizeAuthorPreview'); + assert.equal(preview.authorID, authorID); + assert.ok(preview.removedExternalMappings >= 1); + const rec = await authorManager.getAuthor(authorID); + assert.equal(rec.erased, undefined, + 'preview must not flip erased'); + }); + + it('anonymizeAuthor commits when the flag is enabled', async function () { + const tag = `live-${Date.now()}`; + const {authorID} = await authorManager.createAuthorIfNotExistsFor( + `m-${tag}`, `Live ${tag}`); + const res = await ask(socket, 'anonymizeAuthor', + {authorID}, 'results:anonymizeAuthor'); + assert.equal(res.authorID, authorID); + assert.ok(res.removedExternalMappings >= 1); + const rec = await authorManager.getAuthor(authorID); + assert.equal(rec.erased, true); + }); + + it('anonymizeAuthor returns {error: "disabled"} when flag is off', + async function () { + settings.gdprAuthorErasure.enabled = false; + try { + const tag = `disabled-${Date.now()}`; + const {authorID} = await authorManager.createAuthorIfNotExistsFor( + `m-${tag}`, `Off ${tag}`); + const res = await ask(socket, 'anonymizeAuthor', + {authorID}, 'results:anonymizeAuthor'); + assert.equal(res.error, 'disabled'); + const rec = await authorManager.getAuthor(authorID); + assert.notEqual(rec.erased, true, + 'record should not be erased when flag is off'); + } finally { + settings.gdprAuthorErasure.enabled = true; + } + }); + + it('anonymizeAuthorPreview still works when flag is off (read-only)', + async function () { + settings.gdprAuthorErasure.enabled = false; + try { + const tag = `prev-off-${Date.now()}`; + const {authorID} = await authorManager.createAuthorIfNotExistsFor( + `m-${tag}`, `PrevOff ${tag}`); + const preview = await ask(socket, 'anonymizeAuthorPreview', + {authorID}, 'results:anonymizeAuthorPreview'); + assert.ok(preview.removedExternalMappings >= 1); + } finally { + settings.gdprAuthorErasure.enabled = true; + } + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run from `src/`: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/admin/anonymizeAuthorSocket.ts` + +Expected: every spec fails because the new events don't exist yet (`results:authorLoad` etc. never fire). + +- [ ] **Step 3: Add the three socket handlers + extend `load`** — in `src/node/hooks/express/adminsettings.ts`, immediately after the existing `socket.on('cleanupPadRevisions', …)` handler (around line 305), add: + +```typescript + const authorManager = require('../../db/AuthorManager'); + + socket.on('authorLoad', async (query: any) => { + try { + const data = await authorManager.searchAuthors({ + pattern: query.pattern || '', + offset: query.offset || 0, + limit: query.limit || 12, + sortBy: query.sortBy === 'lastSeen' ? 'lastSeen' : 'name', + ascending: query.ascending !== false, + includeErased: query.includeErased === true, + }); + socket.emit('results:authorLoad', data); + } catch (err: any) { + logger.error(`authorLoad failed: ${err.stack || err}`); + socket.emit('results:authorLoad', + {total: 0, results: [], error: String(err.message || err)}); + } + }); + + socket.on('anonymizeAuthorPreview', async ({authorID}: {authorID: string}) => { + try { + if (!authorID) { + socket.emit('results:anonymizeAuthorPreview', + {authorID, error: 'authorID is required'}); + return; + } + const rec = await authorManager.getAuthor(authorID); + const counters = + await authorManager.anonymizeAuthor(authorID, {dryRun: true}); + socket.emit('results:anonymizeAuthorPreview', + {authorID, name: rec ? rec.name : null, ...counters}); + } catch (err: any) { + logger.error(`anonymizeAuthorPreview failed: ${err.stack || err}`); + socket.emit('results:anonymizeAuthorPreview', + {authorID, error: String(err.message || err)}); + } + }); + + socket.on('anonymizeAuthor', async ({authorID}: {authorID: string}) => { + try { + if (!settings.gdprAuthorErasure || !settings.gdprAuthorErasure.enabled) { + socket.emit('results:anonymizeAuthor', {authorID, error: 'disabled'}); + return; + } + if (!authorID) { + socket.emit('results:anonymizeAuthor', + {authorID, error: 'authorID is required'}); + return; + } + const counters = await authorManager.anonymizeAuthor(authorID); + logger.info(`anonymizeAuthor (admin socket): ${authorID}`); + socket.emit('results:anonymizeAuthor', {authorID, ...counters}); + } catch (err: any) { + logger.error(`anonymizeAuthor failed: ${err.stack || err}`); + socket.emit('results:anonymizeAuthor', + {authorID, error: String(err.message || err)}); + } + }); +``` + +- [ ] **Step 4: Extend the `load` reply with the feature flag** — in the same file, replace the existing `socket.on('load', …)` handler body so the client also gets the GDPR flag: + +```typescript + socket.on('load', async (query: string): Promise => { + let data; + try { + data = await fsp.readFile(settings.settingsFilename, 'utf8'); + } catch (err) { + return logger.error(`Error loading settings: ${err}`); + } + const flags = { + gdprAuthorErasure: !!(settings.gdprAuthorErasure && + settings.gdprAuthorErasure.enabled), + }; + if (settings.showSettingsInAdminPage === false) { + socket.emit('settings', {results: 'NOT_ALLOWED', flags}); + } else { + socket.emit('settings', {results: data, flags}); + } + }); +``` + +- [ ] **Step 5: Re-run the socket spec** + +Run: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/admin/anonymizeAuthorSocket.ts` + +Expected: 5 passing. + +- [ ] **Step 6: Commit** + +```bash +git add src/node/hooks/express/adminsettings.ts src/tests/backend/specs/admin/anonymizeAuthorSocket.ts +git commit -m "feat(authors): admin-socket events for author erasure UI + +Adds three handlers on the /settings admin namespace: +- authorLoad: paginated search via authorManager.searchAuthors +- anonymizeAuthorPreview: dry-run counters, always available to + authenticated admins (read-only) +- anonymizeAuthor: live commit, gated on gdprAuthorErasure.enabled + (returns {error: 'disabled'} when off) + +Extends the load reply with a flags.gdprAuthorErasure boolean so the +client knows whether to render the disabled-flag banner without an +extra round-trip. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: Frontend types, ColorSwatch, and i18n strings + +**Files:** +- Create: `admin/src/utils/AuthorSearch.ts` +- Create: `admin/src/components/ColorSwatch.tsx` +- Create: `admin/public/ep_admin_authors/en.json` + +**Why:** Standalone primitives for the page to consume. Doing this first lets the page implementation in Task 7 reference real types and real keys. + +- [ ] **Step 1: Create the types file** — `admin/src/utils/AuthorSearch.ts`: + +```typescript +export type AuthorSortBy = 'name' | 'lastSeen'; + +export type AuthorSearchQuery = { + pattern: string; + offset: number; + limit: number; + sortBy: AuthorSortBy; + ascending: boolean; + includeErased: boolean; +}; + +export type AuthorRow = { + authorID: string; + name: string | null; + colorId: string | number | null; + mapper: string[]; + lastSeen: number | null; + erased: boolean; +}; + +export type AuthorSearchResult = { + total: number; + cappedAt?: number; + results: AuthorRow[]; + error?: string; +}; + +export type AnonymizePreview = { + authorID: string; + name: string | null; + affectedPads: number; + removedTokenMappings: number; + removedExternalMappings: number; + clearedChatMessages: number; + error?: string; +}; + +export type AnonymizeResult = { + authorID: string; + affectedPads?: number; + removedTokenMappings?: number; + removedExternalMappings?: number; + clearedChatMessages?: number; + error?: string; +}; +``` + +- [ ] **Step 2: Create the swatch component** — `admin/src/components/ColorSwatch.tsx`: + +```tsx +type Props = { + color: string | number | null; + size?: number; +}; + +// Resolves the colorId stored on globalAuthor records into a CSS color. +// AuthorManager stores either a string hex (legacy) or an integer index +// into the palette returned by getColorPalette() — we re-derive the +// palette here rather than fetch it because the order is stable and the +// admin already has many other small constants inline. +const PALETTE = [ + '#ffc7c7', '#fff1c7', '#e3ffc7', '#c7ffd5', '#c7ffff', '#c7d5ff', + '#e3c7ff', '#ffc7f1', '#ffa8a8', '#ffe699', '#cfff9e', '#99ffb3', + '#a3ffff', '#99b3ff', '#cc99ff', '#ff99e5', '#e7b1b1', '#e9dcAf', + '#cde9af', '#bfedcc', '#b1e7e7', '#c3cdee', '#d2b8ea', '#eec3e6', + '#e9cece', '#e7e0ca', '#d3e5c7', '#bce1c5', '#c1e2e2', '#c1c9e2', + '#cfc1e2', '#e0bdd9', '#baded3', '#a0f8eb', '#b1e7e0', '#c3c8e4', + '#cec5e2', '#b1d5e7', '#cda8f0', '#f0f0a8', '#f2f2a6', '#f5a8eb', + '#c5f9a9', '#ececbb', '#e7c4bc', '#daf0b2', '#b0a0fd', '#bce2e7', + '#cce2bb', '#ec9afe', '#edabbd', '#aeaeea', '#c4e7b1', '#d722bb', + '#f3a5e7', '#ffa8a8', '#d8c0c5', '#eaaedd', '#adc6eb', '#bedad1', + '#dee9af', '#e9afc2', '#f8d2a0', '#b3b3e6', +]; + +export const ColorSwatch = ({color, size = 14}: Props) => { + let resolved = '#ccc'; + if (typeof color === 'string') { + resolved = color; + } else if (typeof color === 'number' && color >= 0 && color < PALETTE.length) { + resolved = PALETTE[color]; + } + return ; +}; +``` + +- [ ] **Step 3: Create the i18n file** — `admin/public/ep_admin_authors/en.json`: + +```json +{ + "ep_admin_authors:title": "Authors", + "ep_admin_authors:search-placeholder": "Search by name or mapper", + "ep_admin_authors:column.color": "Color", + "ep_admin_authors:column.name": "Name", + "ep_admin_authors:column.mapper": "Mapper", + "ep_admin_authors:column.last-seen": "Last seen", + "ep_admin_authors:column.author-id": "Author ID", + "ep_admin_authors:column.actions": "Actions", + "ep_admin_authors:show-erased": "Show erased authors", + "ep_admin_authors:erase": "Erase", + "ep_admin_authors:erase-disabled-tooltip": "Author erasure is disabled. Set gdprAuthorErasure.enabled = true in settings.json.", + "ep_admin_authors:erased-stub": "(erased)", + "ep_admin_authors:cap-warning": "Showing the first 1000 authors. Narrow your search to see more.", + "ep_admin_authors:feature-disabled-banner": "Author erasure is disabled. Set \"gdprAuthorErasure\": {\"enabled\": true} in settings.json to enable.", + "ep_admin_authors:no-results": "No authors match this search.", + "ep_admin_authors:confirm-preview-title": "Erase author {{name}}", + "ep_admin_authors:confirm-preview-counters": "Will clear {{tokenMappings}} token mappings, {{externalMappings}} mapper bindings, and {{chatMessages}} chat messages across {{affectedPads}} pads.", + "ep_admin_authors:confirm-irreversible": "This cannot be undone.", + "ep_admin_authors:cancel": "Cancel", + "ep_admin_authors:continue": "Continue", + "ep_admin_authors:erasing": "Erasing…", + "ep_admin_authors:erase-success-toast": "Author {{authorID}} erased.", + "ep_admin_authors:erase-error-toast": "Erase failed: {{error}}", + "ep_admin_authors:no-mappers": "—", + "ep_admin_authors:never-seen": "—" +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add admin/src/utils/AuthorSearch.ts admin/src/components/ColorSwatch.tsx admin/public/ep_admin_authors/en.json +git commit -m "feat(admin): types, ColorSwatch, and en.json for authors page + +Standalone primitives for the upcoming /admin/authors page: +- AuthorSearch.ts: query/result/preview wire types matching the new + admin-socket events +- ColorSwatch.tsx: resolves a globalAuthor.colorId (palette index or + raw hex) to a small inline-styled swatch +- ep_admin_authors/en.json: every user-visible string the page needs, + loaded by the existing namespace-as-static-asset i18n strategy + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: Store slice, route, and sidebar link + +**Files:** +- Modify: `admin/src/store/store.ts:1-50` (and the `useStore` initializer further down) +- Modify: `admin/src/main.tsx` +- Modify: `admin/src/App.tsx:103-110` (sidebar `
      `) and `:73-81` (`settings` event handler) + +**Why:** Wire the new page into the admin shell before building it. + +- [ ] **Step 1: Extend the store** — in `admin/src/store/store.ts`, add the import + state slice. Replace the existing `import {PadSearchResult} …` line with: + +```typescript +import {PadSearchResult} from "../utils/PadSearch.ts"; +import {AuthorSearchResult} from "../utils/AuthorSearch.ts"; +``` + +Then in the `StoreState` type, append before the closing `}`: + +```typescript + authors: AuthorSearchResult|undefined, + setAuthors: (authors: AuthorSearchResult)=>void, + gdprAuthorErasureEnabled: boolean, + setGdprAuthorErasureEnabled: (enabled: boolean)=>void, +``` + +In the `create(…)` call body (search the file for `setPads:`), append: + +```typescript + authors: undefined, + setAuthors: (authors)=>set({authors}), + gdprAuthorErasureEnabled: false, + setGdprAuthorErasureEnabled: (gdprAuthorErasureEnabled)=>set({gdprAuthorErasureEnabled}), +``` + +- [ ] **Step 2: Register the route** — in `admin/src/main.tsx`, add the import: + +```typescript +import {AuthorPage} from "./pages/AuthorPage.tsx"; +``` + +And add inside the `}>` block (after the `` line): + +```tsx + }/> +``` + +- [ ] **Step 3: Add the sidebar link** — in `admin/src/App.tsx`, extend the existing lucide-react import line: + +```typescript +import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell, Users} from "lucide-react"; +``` + +In the sidebar `
        ` block (currently around line 103-109), insert a new `
      • ` immediately after the Pads `
      • ` and before Shout: + +```tsx +
      • +``` + +- [ ] **Step 4: Capture the flag from the existing `settings` event** — in `admin/src/App.tsx`, replace the `settingSocket.on('settings', …)` handler body: + +```typescript + settingSocket.on('settings', (settings: any) => { + // Pick up the GDPR-erasure feature flag from the same payload that + // also carries the settings.json blob. The flag drives the disabled + // banner on /admin/authors; we read it once here so the page is + // ready to render without an extra round trip. + if (settings && typeof settings.flags === 'object' && settings.flags) { + useStore.getState().setGdprAuthorErasureEnabled( + !!settings.flags.gdprAuthorErasure); + } + if (settings.results === 'NOT_ALLOWED') { + console.log('Not allowed to view settings.json') + return; + } + if (isJSONClean(settings.results)) { + setSettings(settings.results); + } else { + alert('Invalid JSON'); + } + useStore.getState().setShowLoading(false); + }); +``` + +- [ ] **Step 5: Verify the admin still builds** + +Run from repo root: `pnpm --filter etherpad-admin run build 2>&1 | tail -10` + +Expected: build completes (will fail with `Cannot find module './pages/AuthorPage.tsx'` because Task 7 hasn't run yet). At this checkpoint, **proceed to Task 7 and commit Tasks 6+7 together** — committing a half-wired route would leave the build broken. + +(If the admin package name in `admin/package.json` differs from `etherpad-admin`, run the build from `admin/` directly: `cd admin && pnpm run build`.) + +- [ ] **Step 6: Skip commit until Task 7 lands** + +The sidebar link points at a route whose component doesn't exist yet. Continue to Task 7; commit the two together. + +--- + +## Task 7: `AuthorPage.tsx` — table, search, sort, pagination, disabled banner + +**Files:** +- Create: `admin/src/pages/AuthorPage.tsx` + +**Why:** The actual page. Mirrors `PadPage.tsx`'s shape (search field, sortable headers, pagination, Radix dialog) so reviewers see one familiar pattern. + +- [ ] **Step 1: Create `admin/src/pages/AuthorPage.tsx`**: + +```tsx +import {Trans, useTranslation} from "react-i18next"; +import {useEffect, useMemo, useState} from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import {ChevronLeft, ChevronRight, Trash2} from "lucide-react"; +import {useStore} from "../store/store.ts"; +import {SearchField} from "../components/SearchField.tsx"; +import {ColorSwatch} from "../components/ColorSwatch.tsx"; +import {IconButton} from "../components/IconButton.tsx"; +import {determineSorting} from "../utils/sorting.ts"; +import {useDebounce} from "../utils/useDebounce.ts"; +import { + AnonymizePreview, AnonymizeResult, AuthorRow, AuthorSearchQuery, + AuthorSearchResult, AuthorSortBy, +} from "../utils/AuthorSearch.ts"; + +type DialogState = + | {phase: 'closed'} + | {phase: 'loading-preview', authorID: string, name: string | null} + | {phase: 'preview', preview: AnonymizePreview} + | {phase: 'committing', preview: AnonymizePreview}; + +export const AuthorPage = () => { + const {t} = useTranslation(); + const settingsSocket = useStore((s) => s.settingsSocket); + const authors = useStore((s) => s.authors); + const setAuthors = useStore((s) => s.setAuthors); + const erasureEnabled = useStore((s) => s.gdprAuthorErasureEnabled); + + const [searchTerm, setSearchTerm] = useState(''); + const [includeErased, setIncludeErased] = useState(false); + const [searchParams, setSearchParams] = useState({ + pattern: '', offset: 0, limit: 12, + sortBy: 'name', ascending: true, includeErased: false, + }); + const [currentPage, setCurrentPage] = useState(0); + const [dialog, setDialog] = useState({phase: 'closed'}); + + const pages = useMemo(() => { + if (!authors) return 0; + return Math.ceil(authors.total / searchParams.limit); + }, [authors, searchParams.limit]); + + useDebounce(() => { + setCurrentPage(0); + setSearchParams((p) => ({...p, pattern: searchTerm, offset: 0})); + }, 500, [searchTerm]); + + useEffect(() => { + setSearchParams((p) => ({...p, includeErased, offset: 0})); + setCurrentPage(0); + }, [includeErased]); + + useEffect(() => { + if (!settingsSocket) return; + settingsSocket.emit('authorLoad', searchParams); + }, [settingsSocket, searchParams]); + + useEffect(() => { + if (!settingsSocket) return; + const onLoad = (data: AuthorSearchResult) => setAuthors(data); + const onPreview = (data: AnonymizePreview) => { + // Ignore stale previews if the user closed the dialog. + setDialog((cur) => + cur.phase === 'loading-preview' && cur.authorID === data.authorID + ? {phase: 'preview', preview: data} + : cur); + }; + const onErase = (data: AnonymizeResult) => { + if (data.error) { + useStore.getState().setToastState({ + open: true, success: false, + title: t('ep_admin_authors:erase-error-toast', {error: data.error}), + }); + setDialog({phase: 'closed'}); + return; + } + useStore.getState().setToastState({ + open: true, success: true, + title: t('ep_admin_authors:erase-success-toast', {authorID: data.authorID}), + }); + // Patch the row in place so the user sees it become an erased stub + // without a refetch flicker. + const cur = useStore.getState().authors; + if (cur) { + setAuthors({ + ...cur, + results: cur.results.map((r): AuthorRow => + r.authorID === data.authorID + ? {...r, name: null, erased: true, mapper: []} + : r), + }); + } + setDialog({phase: 'closed'}); + }; + settingsSocket.on('results:authorLoad', onLoad); + settingsSocket.on('results:anonymizeAuthorPreview', onPreview); + settingsSocket.on('results:anonymizeAuthor', onErase); + return () => { + settingsSocket.off('results:authorLoad', onLoad); + settingsSocket.off('results:anonymizeAuthorPreview', onPreview); + settingsSocket.off('results:anonymizeAuthor', onErase); + }; + }, [settingsSocket, setAuthors, t]); + + const sortBy = (col: AuthorSortBy) => () => { + setCurrentPage(0); + setSearchParams((p) => ({ + ...p, sortBy: col, + ascending: p.sortBy === col ? !p.ascending : true, + offset: 0, + })); + }; + + const openErase = (row: AuthorRow) => { + setDialog({phase: 'loading-preview', authorID: row.authorID, name: row.name}); + settingsSocket?.emit('anonymizeAuthorPreview', {authorID: row.authorID}); + }; + + const commitErase = () => { + if (dialog.phase !== 'preview') return; + setDialog({phase: 'committing', preview: dialog.preview}); + settingsSocket?.emit('anonymizeAuthor', {authorID: dialog.preview.authorID}); + }; + + const lastSeenLabel = (row: AuthorRow) => + row.lastSeen + ? new Date(row.lastSeen).toLocaleString() + : t('ep_admin_authors:never-seen'); + + const mapperLabel = (row: AuthorRow) => { + if (row.mapper.length === 0) return t('ep_admin_authors:no-mappers'); + if (row.mapper.length === 1) return row.mapper[0]; + return `${row.mapper[0]} +${row.mapper.length - 1}`; + }; + + return
        + {!erasureEnabled && ( +
        + +
        + )} + + + + + + {dialog.phase === 'loading-preview' &&
        + +
        } + {(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => { + const p = dialog.preview; + return
        +

        {t('ep_admin_authors:confirm-preview-title', + {name: p.name || p.authorID})}

        +

        {t('ep_admin_authors:confirm-preview-counters', { + tokenMappings: p.removedTokenMappings, + externalMappings: p.removedExternalMappings, + chatMessages: p.clearedChatMessages, + affectedPads: p.affectedPads, + })}

        +

        + +

        +
        + + +
        +
        ; + })()} +
        +
        +
        + + +

        + +

        +
        + + setSearchTerm(v.target.value)} + placeholder={t('ep_admin_authors:search-placeholder')}/> + + + + {authors?.cappedAt != null && ( +

        + +

        + )} + + + + + + + + + + + + + + {authors?.results.length === 0 && } + {authors?.results.map((row) => ( + + + + + + + + + ))} + +
        + + + +
        + +
        + {row.erased + ? + : (row.name ?? '—')} + + {mapperLabel(row)} + {lastSeenLabel(row)} + {row.authorID} + +
        + } + title={} + onClick={() => openErase(row)} + {...(!erasureEnabled || row.erased + ? {disabled: true, + 'data-disabled-reason': + t('ep_admin_authors:erase-disabled-tooltip')} + : {})}/> +
        +
        + +
        + + {currentPage + 1} out of {pages} + +
        +
        ; +}; +``` + +- [ ] **Step 2: Verify the admin builds end-to-end** + +Run from repo root: `cd admin && pnpm run build 2>&1 | tail -15` + +Expected: build succeeds. If the IconButton component doesn't accept a `disabled` prop, drop the spread and instead skip rendering the button when `!erasureEnabled || row.erased` (replace the IconButton with a `disabled` `} + {showCancel && } + {showAcknowledge && } + + {/* changelog block — keep as in PR 1 */} + +); +``` + +- [ ] **Step 3: Add the i18n keys** + +In `src/locales/en.json`, add: + +```json + "update.page.apply": "Apply update", + "update.page.cancel": "Cancel", + "update.page.acknowledge": "Acknowledge", + "update.page.execution": "Status", + "update.page.policy.install-method-not-writable": "Updates from the admin UI require a git install. Update via your package manager.", + "update.page.policy.rollback-failed-terminal": "A previous update failed and could not be rolled back. Manual intervention required; press Acknowledge to clear the lock once the install is healthy.", + "update.page.policy.up-to-date": "You are running the latest version.", + "update.page.policy.tier-off": "Updates are disabled (updates.tier = \"off\").", + "update.page.last_result.verified": "Last update to {{tag}} verified.", + "update.page.last_result.rolled-back": "Last attempted update to {{tag}} rolled back: {{reason}}.", + "update.page.last_result.rollback-failed": "Last update attempt failed AND rollback failed: {{reason}}. Manual intervention required.", + "update.page.last_result.preflight-failed": "Last attempted update to {{tag}} failed preflight: {{reason}}.", + "update.page.last_result.cancelled": "Last attempted update to {{tag}} cancelled by admin.", + "update.execution.idle": "Idle", + "update.execution.preflight": "Pre-flight checks", + "update.execution.preflight-failed": "Pre-flight failed", + "update.execution.draining": "Draining sessions", + "update.execution.executing": "Updating...", + "update.execution.pending-verification": "Pending verification", + "update.execution.verified": "Verified", + "update.execution.rolling-back": "Rolling back", + "update.execution.rolled-back": "Rolled back", + "update.execution.rollback-failed": "Rollback failed", + "update.banner.terminal.rollback-failed": "An update attempt failed and could not be rolled back. Manual intervention required.", + "update.drain.t60": "Etherpad will restart in 60 seconds to apply an update.", + "update.drain.t30": "Etherpad will restart in 30 seconds to apply an update.", + "update.drain.t10": "Etherpad will restart in 10 seconds to apply an update." +``` + +- [ ] **Step 4: Build the admin UI and visit it locally** + +```bash +pnpm install # ensure admin deps in case anything is missing +pnpm --filter admin run build +pnpm run dev -- --port 9003 & +# In a browser: http://localhost.lan:9003/admin/update — log in as admin +# Verify the Apply button renders when latest version differs from current +kill %1 +``` + +> Don't kill the apply manually after pressing it on a real install — the update will actually run. Use `pnpm run dev` in a disposable worktree if you want to test the full apply path. + +- [ ] **Step 5: Commit** + +```bash +git add admin/src/pages/UpdatePage.tsx admin/src/store/store.ts src/locales/en.json +git commit -m "$(cat <<'EOF' +feat(updater): admin UI Apply/Cancel/Acknowledge buttons + +UpdatePage renders the right action set per execution.status, surfaces +lastResult with localised copy, and shows policy denial reasons (e.g. +install-method-not-writable, rollback-failed-terminal). Buttons round- +trip status through /admin/update/status after each action. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 13: Admin UI — log stream view + +**Files:** +- Modify: `admin/src/pages/UpdatePage.tsx` + +While `execution.status === 'preflight' | 'draining' | 'executing' | 'rolling-back'`, poll `/admin/update/log` once a second and render the tail in a `
        `. Stop polling when the status leaves the set.
        +
        +- [ ] **Step 1: Add the polling effect**
        +
        +Inside `UpdatePage`, after the existing `useEffect` for `/admin/update/status`, add:
        +
        +```tsx
        +const log = useStore((s) => s.updateLog);
        +const setLog = useStore((s) => s.setUpdateLog);
        +const inFlight = ['preflight', 'draining', 'executing', 'rolling-back'].includes(us?.execution?.status ?? '');
        +useEffect(() => {
        +  if (!inFlight) return;
        +  let cancelled = false;
        +  const tick = async () => {
        +    if (cancelled) return;
        +    try {
        +      const r = await fetch('/admin/update/log', {credentials: 'same-origin'});
        +      if (r.ok) setLog(await r.text());
        +      // Re-fetch status too so we know when to stop polling.
        +      const s = await fetch('/admin/update/status', {credentials: 'same-origin'});
        +      if (s.ok) setUpdateStatus(await s.json());
        +    } catch {/* noop */}
        +    if (!cancelled) setTimeout(tick, 1000);
        +  };
        +  tick();
        +  return () => { cancelled = true; };
        +}, [inFlight, setLog, setUpdateStatus]);
        +```
        +
        +In the JSX:
        +
        +```tsx
        +{inFlight && (
        +  
        +

        +
        {log}
        +
        +)} +``` + +- [ ] **Step 2: Add i18n key** + +In `src/locales/en.json`: + +```json + "update.page.log": "Update log (last 200 lines)" +``` + +- [ ] **Step 3: Smoke test in a browser** + +Same workflow as Task 12 step 4. Trigger an Apply on a git checkout that's safe to update (e.g., a disposable worktree). Watch the log block populate. + +- [ ] **Step 4: Commit** + +```bash +git add admin/src/pages/UpdatePage.tsx src/locales/en.json +git commit -m "$(cat <<'EOF' +feat(updater): admin UI streams update log while update is in flight + +While execution.status is preflight/draining/executing/rolling-back the +page polls /admin/update/log + /admin/update/status once a second, +showing the rolling tail and switching off automatically when the run +terminates. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 14: Pad-side drain announcement + +**Files:** +- Modify: `src/static/js/chat.js` or `src/static/js/pad.js` (whichever handles incoming `shoutMessage`) +- Modify: `src/locales/en.json` (already done in Task 12 — verify keys exist) + +`broadcastShout` in Task 11 sends a shoutMessage payload of the form `{message: {message: 'update.drain.t60', values: {seconds: 60}}, ...}`. The pad client renders shouts via the existing chat pipeline. We need that pipeline to look up `payload.message.message` as a translation key when present and substitute `payload.message.values`. + +- [ ] **Step 1: Find the shout-rendering site** + +```bash +grep -rn "shoutMessage\|payload.message" src/static/js/ | head -20 +``` + +Locate the function that turns the COLLABROOM shoutMessage into chat text. In Etherpad core that lives in `src/static/js/pad.js` or `src/static/js/chat.js` — search for `shoutMessage`. + +- [ ] **Step 2: Extend the renderer to handle i18n keys** + +Wrap the existing logic so `if (typeof payload.message.message === 'string' && payload.message.message.startsWith('update.drain.'))` is rendered through `html10n.translations` lookup; otherwise fall back to current behaviour. Concrete patch (adapt to actual code): + +```javascript +// existing: +// const text = payload.message.message; +// becomes: +const raw = payload.message.message; +const values = payload.message.values || {}; +let text = raw; +if (typeof raw === 'string' && raw.startsWith('update.drain.') && window.html10n && window.html10n.translations) { + const tpl = window.html10n.translations[raw]; + if (typeof tpl === 'string') { + text = tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => String(values[k] ?? '')); + } +} +``` + +(`html10n.get(raw, values)` is the bound API but `window._` is unbound per memory `project_plugin_window_underscore_audit.md` — go through `window.html10n.translations` directly to dodge that bug.) + +- [ ] **Step 3: Add a Playwright test** + +In `src/tests/frontend-new/specs/`, add a spec that opens a pad, simulates a shout from the admin socket via the existing admin shout test pattern (`grep -rn "shout" src/tests/frontend-new/`) — if no harness exists, skip this Playwright test and rely on the manual smoke step below. **Do not write a fake test.** + +- [ ] **Step 4: Manual smoke test** + +```bash +pnpm run dev -- --port 9003 & +# Open http://localhost.lan:9003/p/test-drain in one tab +# In another tab, log in to /admin and use the Shout feature to send "update.drain.t60" +# Verify the pad shows "Etherpad will restart in 60 seconds..." +kill %1 +``` + +If the manual test fails — i.e., the pad shows the literal key — adjust the renderer in step 2 until the pad shows the localised string. Per memory `feedback_test_localized_strings`, do not declare done while the literal key shows. + +- [ ] **Step 5: Commit** + +```bash +git add src/static/js/chat.js src/static/js/pad.js +git commit -m "$(cat <<'EOF' +feat(updater): pad shoutMessage renders update.drain.* via html10n + +When the executor's drain phase broadcasts update.drain.t60/t30/t10, +pads render the localised string instead of the bare i18n key. Goes +through html10n.translations directly to dodge the unbound window._ +bug documented in project_plugin_window_underscore_audit. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 15: Integration test — end-to-end against a tmp git repo + +**Files:** +- Create: `src/tests/backend/specs/updater-integration.ts` + +This is the highest-value test in the plan: it runs `executeUpdate` against a real tmp git repo, verifying happy path + each rollback variant by stubbing only the steps that would mutate the *current* install (we replace `pnpm install` with a `bash -c 'exit 0'` and similar). The test is deliberately heavy — run it on its own, not in the unit-test loop. + +- [ ] **Step 1: Skeleton failing test** + +Create `src/tests/backend/specs/updater-integration.ts`: + +```typescript +'use strict'; + +const assert = require('assert').strict; +import {execSync, spawn} from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {executeUpdate} from '../../../node/updater/UpdateExecutor'; +import {performRollback, checkPendingVerification} from '../../../node/updater/RollbackHandler'; +import {EMPTY_STATE} from '../../../node/updater/types'; + +const sh = (cmd: string, opts: any = {}) => execSync(cmd, {stdio: 'pipe', ...opts}).toString().trim(); + +const buildTmpRepo = async (): Promise => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'updater-it-')); + sh('git init -b main', {cwd: dir}); + sh('git config user.email test@example.com', {cwd: dir}); + sh('git config user.name test', {cwd: dir}); + await fs.writeFile(path.join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: x\n'); + sh('git add . && git commit -m initial', {cwd: dir}); + sh('git tag v0.0.1', {cwd: dir}); + await fs.writeFile(path.join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: y\n'); + sh('git add . && git commit -m bump', {cwd: dir}); + sh('git tag v0.0.2', {cwd: dir}); + // executor expects an "origin" — point it at the same dir for the ls-remote check. + sh(`git remote add origin ${dir}`, {cwd: dir}); + return dir; +}; + +const stubSpawn = (overrides: Record = {}) => { + // Emulate spawn for everything by mapping (cmd, args) -> exit code. + return ((cmd: string, args: string[]) => { + const key = `${cmd} ${args.join(' ')}`; + const exit = overrides[key] ?? (cmd === 'pnpm' ? 0 : -1); // -1 means "use real git" + if (exit === -1) { + // Real git for this step. + const real = spawn(cmd, args, {cwd: (overrides as any).__cwd, stdio: ['ignore', 'pipe', 'pipe']}); + return real; + } + return { + stdout: {on: () => {}}, stderr: {on: () => {}}, + on: (e: string, cb: any) => e === 'close' && setImmediate(() => cb(exit)), + } as any; + }) as any; +}; + +describe(__filename, function () { + this.timeout(20_000); + + it('happy path: executes against tmp repo, lands on pending-verification', async () => { + const repo = await buildTmpRepo(); + const states: any[] = []; + let exited: number | null = null; + const r = await executeUpdate({ + repoDir: repo, + backupDir: path.join(repo, 'var', 'update-backup'), + spawnFn: stubSpawn({'pnpm install --frozen-lockfile': 0, 'pnpm run build:ui': 0, __cwd: repo} as any), + readSha: async () => sh('git rev-parse HEAD', {cwd: repo}), + copyFile: (s, d) => fs.mkdir(path.dirname(d), {recursive: true}).then(() => fs.copyFile(s, d)), + saveState: async (s) => { states.push(structuredClone(s)); }, + initialState: structuredClone(EMPTY_STATE), + targetTag: 'v0.0.2', + now: () => new Date(), + exit: (code) => { exited = code; }, + }); + assert.equal(r.outcome, 'pending-verification'); + assert.equal(exited, 75); + assert.equal(states.at(-1).execution.status, 'pending-verification'); + // Backup file exists. + await fs.access(path.join(repo, 'var', 'update-backup', 'pnpm-lock.yaml')); + await fs.rm(repo, {recursive: true, force: true}); + }); + + it('install failure rolls back to original SHA', async () => { + const repo = await buildTmpRepo(); + const original = sh('git rev-parse HEAD', {cwd: repo}); + let exited: number | null = null; + const states: any[] = []; + + // Phase 1: executor with failing install. + await executeUpdate({ + repoDir: repo, backupDir: path.join(repo, 'var', 'update-backup'), + spawnFn: stubSpawn({'pnpm install --frozen-lockfile': 1, __cwd: repo} as any), + readSha: async () => sh('git rev-parse HEAD', {cwd: repo}), + copyFile: (s, d) => fs.mkdir(path.dirname(d), {recursive: true}).then(() => fs.copyFile(s, d)), + saveState: async (s) => { states.push(structuredClone(s)); }, + initialState: structuredClone(EMPTY_STATE), + targetTag: 'v0.0.2', + now: () => new Date(), + exit: (c) => { exited = c; }, + }); + assert.equal(states.at(-1).execution.status, 'rolling-back'); + + // Phase 2: rollback. + await performRollback(states.at(-1), { + repoDir: repo, backupDir: path.join(repo, 'var', 'update-backup'), + spawnFn: stubSpawn({'pnpm install --frozen-lockfile': 0, __cwd: repo} as any), + copyFile: (s, d) => fs.copyFile(s, d), + saveState: async (s) => { states.push(structuredClone(s)); }, + exit: (c) => { exited = c; }, + now: () => new Date(), + rollbackHealthCheckSeconds: 60, + }); + assert.equal(states.at(-1).execution.status, 'rolled-back'); + assert.equal(sh('git rev-parse HEAD', {cwd: repo}), original); + assert.equal(exited, 75); + await fs.rm(repo, {recursive: true, force: true}); + }); + + // Add: build-failure rollback (same as install-failure but with build:ui exit 1). + // Add: crash-loop guard (state.bootCount = 3 forces immediate rollback in checkPendingVerification). +}); +``` + +- [ ] **Step 2: Run — confirm fail / pass** + +Run: `pnpm run test -- --grep updater-integration` +Expected: PASS for the two scenarios above; if not, debug — typical issues are `git ls-remote --tags` against a self-origin which needs `git push origin v0.0.2` first; add it inside `buildTmpRepo`. + +- [ ] **Step 3: Add the build-failure + crash-loop scenarios** + +Append: + +```typescript + it('build failure rolls back to original SHA', async () => { /* same as install but spawnFn returns build:ui=1, install=0 */ }); + + it('crash-loop guard forces rollback when bootCount > 2', async () => { + const repo = await buildTmpRepo(); + const original = sh('git rev-parse HEAD', {cwd: repo}); + sh('git checkout v0.0.2', {cwd: repo}); + // pretend we're already on v0.0.2 (post-update boot) and the lockfile backup exists. + await fs.mkdir(path.join(repo, 'var', 'update-backup'), {recursive: true}); + await fs.copyFile(path.join(repo, 'pnpm-lock.yaml'), path.join(repo, 'var', 'update-backup', 'pnpm-lock.yaml')); + sh(`git checkout ${original}`, {cwd: repo}); + sh(`cp var/update-backup/pnpm-lock.yaml pnpm-lock.yaml`, {cwd: repo}); + sh('git checkout v0.0.2', {cwd: repo}); + + let exited: number | null = null; + const states: any[] = []; + const state = { + ...structuredClone(EMPTY_STATE), + execution: {status: 'pending-verification', targetTag: 'v0.0.2', fromSha: original, deadlineAt: '2026-05-08T10:00:00Z'} as const, + bootCount: 3, + }; + const r = checkPendingVerification(state, { + repoDir: repo, backupDir: path.join(repo, 'var', 'update-backup'), + spawnFn: stubSpawn({'pnpm install --frozen-lockfile': 0, __cwd: repo} as any), + copyFile: (s, d) => fs.copyFile(s, d), + saveState: async (s) => { states.push(structuredClone(s)); }, + exit: (c) => { exited = c; }, + now: () => new Date(), + rollbackHealthCheckSeconds: 60, + }); + assert.equal(r.armed, false); + // Wait a tick for the async rollback to finish. + await new Promise((r) => setImmediate(r)); + assert.equal(states.at(-1).execution.status, 'rolled-back'); + assert.equal(sh('git rev-parse HEAD', {cwd: repo}), original); + assert.equal(exited, 75); + await fs.rm(repo, {recursive: true, force: true}); + }); +``` + +- [ ] **Step 4: Run all integration tests** + +Run: `pnpm run test -- --grep "updater-integration|updateActions|updateStatus"` +Expected: PASS for everything. + +- [ ] **Step 5: Commit** + +```bash +git add src/tests/backend/specs/updater-integration.ts +git commit -m "$(cat <<'EOF' +test(updater): integration suite over a tmp git repo + +Exercises executeUpdate + performRollback + checkPendingVerification +end-to-end against a disposable git repo with two tagged commits: +happy path -> pending-verification, install-fail rollback, build-fail +rollback, crash-loop bootCount>2 forced rollback. Runs with mocha at +20s timeout; no real pnpm/network. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 16: Playwright spec — admin Apply flow + +**Files:** +- Create: `src/tests/frontend-new/admin-spec/update-page-actions.spec.ts` + +The Playwright spec stubs the network: it intercepts `/admin/update/status` to seed a fake `latest`, intercepts `/admin/update/apply` to return `202`, and verifies the UI transitions through the right buttons. We do *not* actually run an update — that's covered by the manual smoke runbook. + +- [ ] **Step 1: Failing spec** + +Create `src/tests/frontend-new/admin-spec/update-page-actions.spec.ts`: + +```typescript +import {expect, test} from '@playwright/test'; + +const baseStatus = { + currentVersion: '2.7.1', + latest: {version: '2.7.2', tag: 'v2.7.2', body: 'release notes', publishedAt: '2026-05-01T00:00:00Z', prerelease: false, htmlUrl: 'https://example/'}, + lastCheckAt: '2026-05-08T00:00:00Z', + installMethod: 'git', + tier: 'manual', + policy: {canNotify: true, canManual: true, canAuto: false, canAutonomous: false, reason: 'ok'}, + vulnerableBelow: [], + execution: {status: 'idle'}, + lastResult: null, + lockHeld: false, +}; + +test('admin Apply button posts to /admin/update/apply and re-fetches status', async ({page}) => { + let posted = false; + await page.route('**/admin/update/status', (route) => route.fulfill({json: baseStatus})); + await page.route('**/admin/update/apply', (route) => { posted = true; route.fulfill({status: 202, json: {accepted: true}}); }); + await page.goto('/admin/update'); + await expect(page.getByRole('button', {name: /apply update/i})).toBeVisible(); + await page.getByRole('button', {name: /apply update/i}).click(); + await expect.poll(() => posted).toBe(true); +}); + +test('install-method-not-writable hides Apply and shows the policy reason', async ({page}) => { + const denied = {...baseStatus, installMethod: 'docker', + policy: {canNotify: true, canManual: false, canAuto: false, canAutonomous: false, reason: 'install-method-not-writable'}}; + await page.route('**/admin/update/status', (route) => route.fulfill({json: denied})); + await page.goto('/admin/update'); + await expect(page.getByRole('button', {name: /apply update/i})).toHaveCount(0); + await expect(page.getByText(/Updates from the admin UI require a git install/i)).toBeVisible(); +}); + +test('rollback-failed shows Acknowledge button', async ({page}) => { + const terminal = {...baseStatus, + execution: {status: 'rollback-failed', reason: 'pnpm install failed; rollback failed: pnpm exit 1', targetTag: 'v2.7.2', fromSha: 'x', at: '2026-05-08T00:00:00Z'}, + lastResult: {targetTag: 'v2.7.2', fromSha: 'x', outcome: 'rollback-failed', reason: 'pnpm install failed', at: '2026-05-08T00:00:00Z'}}; + await page.route('**/admin/update/status', (route) => route.fulfill({json: terminal})); + await page.goto('/admin/update'); + await expect(page.getByRole('button', {name: /acknowledge/i})).toBeVisible(); +}); +``` + +- [ ] **Step 2: Run** + +```bash +pnpm run test-ui -- src/tests/frontend-new/admin-spec/update-page-actions.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/tests/frontend-new/admin-spec/update-page-actions.spec.ts +git commit -m "$(cat <<'EOF' +test(updater): Playwright admin Apply flow + policy denial + acknowledge + +Stubs /admin/update/status and /admin/update/apply at the route level so +we can assert UI transitions (button visibility, policy-denial copy, +terminal-state acknowledge) without actually running an update. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 17: Banner copy for terminal states + +**Files:** +- Modify: `admin/src/components/UpdateBanner.tsx` + +When `execution.status === 'rollback-failed'`, the banner text should be the strong `update.banner.terminal.rollback-failed` copy and link to `/update`. + +- [ ] **Step 1: Patch the banner** + +Replace the JSX so it picks the right key: + +```tsx +if (!updateStatus) return null; +const exec = updateStatus.execution?.status; +if (exec === 'rollback-failed') { + return ( +
        + {' '} + {t('update.banner.cta')} +
        + ); +} +if (!updateStatus.latest || updateStatus.currentVersion === updateStatus.latest.version) return null; +// existing ok-banner... +``` + +- [ ] **Step 2: Manual visual test** + +Seed the state file (`var/update-state.json`) with `execution.status: 'rollback-failed'` then load `/admin/update`. Confirm the banner copy matches `update.banner.terminal.rollback-failed`, not the literal key. Per memory `feedback_test_localized_strings`, fail the task if the literal key shows. + +- [ ] **Step 3: Commit** + +```bash +git add admin/src/components/UpdateBanner.tsx +git commit -m "$(cat <<'EOF' +feat(updater): admin banner shows rollback-failed terminal state + +When execution.status is rollback-failed, the banner switches to a +role=alert with stronger copy, regardless of whether a new release is +known. Other terminal states (preflight-failed, rolled-back) surface on +the page itself, not the banner — they're informational, not urgent. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 18: Documentation + smoke runbook + +**Files:** +- Modify: `doc/admin/updates.md` +- Modify: `CHANGELOG.md` +- Create: `docs/superpowers/specs/2026-04-25-auto-update-runbook.md` + +The spec's "Phased rollout / PR 2" entry calls out a runbook ("manual smoke runbook in `docs/superpowers/specs/2026-04-25-auto-update-runbook.md`, run before each tier ships, against a disposable VM"). This task ships it alongside the user-facing docs. + +- [ ] **Step 0: Write the smoke runbook** + +Create `docs/superpowers/specs/2026-04-25-auto-update-runbook.md` covering: + +1. Provisioning a disposable Ubuntu/Debian VM with systemd + a checked-out git install. +2. Setting `updates.tier: "manual"` in `settings.json`. +3. Booting under systemd with `Restart=on-failure` + `RestartSec=5` (sample unit file inline). +4. Forcing a downgrade by `git checkout` of the previous tag, restart, confirm Apply button shows. +5. Apply, observe drain broadcasts in a separate pad, observe restart, observe verified state. +6. Forcing rollback: corrupt `pnpm-lock.yaml` between checkout and install (or pin to a tag with a known-broken build), Apply, observe rolled-back state. +7. Forcing rollback-failed: also break the backup lockfile, Apply, observe terminal state and Acknowledge flow. +8. Crash-loop guard: pin a tag whose code throws on boot, Apply, observe bootCount climb to 3 + forced rollback. +9. Sign-off checklist: every observable transition matches `docs/superpowers/specs/2026-04-25-auto-update-design.md` "State machine". + +- [ ] **Step 1: Append Tier 2 section to `doc/admin/updates.md`** + +Document: +- Activation: `updates.tier: "manual"` requires a `git` install. +- Process supervisor required (systemd/pm2/docker restart-policy) — Etherpad exits 75 to trigger restart. +- Apply flow: button → preflight → 60s drain (broadcasts at T-60/-30/-10) → fetch/checkout/install/build → exit → restart → 60s health check. +- Rollback paths: install/build failure, health-check timeout, crash loop (>2 reboots). +- Terminal states: `preflight-failed` and `rolled-back` are informational; `rollback-failed` requires `POST /admin/update/acknowledge` after manual recovery. +- Settings: each new key with default + when to change. +- Signature verification: opt-in via `requireSignature: true`; document GNUPGHOME path. +- What is *not* covered: Tier 3 (auto) and Tier 4 (autonomous) ship later. + +- [ ] **Step 2: Add to `CHANGELOG.md` Unreleased** + +```markdown +### Updater +- Tier 2 (manual click): admins can now apply updates from `/admin/update` on git installs. Requires a process supervisor; the executor exits 75 to trigger restart, and the next boot runs a 60s health check that auto-rolls back on failure. Tags are signature-checked when `updates.requireSignature: true`. New settings: `updates.preApplyGraceMinutes`, `drainSeconds`, `rollbackHealthCheckSeconds`, `diskSpaceMinMB`, `requireSignature`, `trustedKeysPath`. +``` + +- [ ] **Step 3: Commit** + +```bash +git add doc/admin/updates.md CHANGELOG.md docs/superpowers/specs/2026-04-25-auto-update-runbook.md +git commit -m "$(cat <<'EOF' +docs(updater): document Tier 2 manual-click flow + smoke runbook + +Adds doc/admin/updates.md Tier 2 section: prerequisites (git install + +process supervisor), Apply flow with timings, rollback paths, terminal +states + acknowledge, signature-verification opt-in. Ships the manual +smoke runbook the design spec calls for: disposable VM, systemd unit, +forced rollback / rollback-failed / crash-loop scenarios. Notes Tier 3/4 +are deferred to follow-up PRs. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 19: Final sanity sweep + open PR + +**Files:** none (workflow only). + +- [ ] **Step 1: Full type check + tests** + +```bash +pnpm run ts-check +pnpm vitest run src/tests/backend-new/specs/updater +pnpm run test -- --grep "updater|updateActions|updateStatus" +pnpm run test-ui -- src/tests/frontend-new/admin-spec/update-page-actions.spec.ts +pnpm --filter admin run build +``` + +Expected: every step PASS. + +- [ ] **Step 2: Push branch** + +```bash +git push -u origin feat/7607-auto-update-tier2-manual-click +``` + +- [ ] **Step 3: Open PR against `develop`** + +```bash +gh pr create --base develop --title "feat(updater): tier 2 — manual-click update from /admin/update (#7607)" --body "$(cat <<'EOF' +## Summary + +Ships **Tier 2 (manual click)** of the four-tier auto-update design at +`docs/superpowers/specs/2026-04-25-auto-update-design.md`. Builds on PR #7601 +(Tier 1 — notify, merged 2026-05-01). + +- Admins on git installs see an **Apply update** button at `/admin/update`. +- Click flow: pre-flight checks → 60s drain (with T-60/-30/-10 pad broadcasts) → `git fetch / checkout / pnpm install --frozen-lockfile / pnpm run build:ui` → exit 75 for the supervisor to restart. +- 60s health-check on the next boot. On crash loop (bootCount > 2) or health-check timeout we restore the prior SHA + lockfile and exit 75 again. +- Terminal `rollback-failed` state surfaces a strong banner; admin clicks **Acknowledge** to clear after manual recovery. +- New settings under `updates.*`: `preApplyGraceMinutes`, `drainSeconds`, `rollbackHealthCheckSeconds`, `diskSpaceMinMB`, `requireSignature`, `trustedKeysPath` (all opt-in / sane defaults). +- Signature verification (`requireSignature`) is opt-in and stub-friendly: false → log warning and pass; true → `git verify-tag ` against the user keyring (or `trustedKeysPath` via `GNUPGHOME`). Etherpad's release process does not yet sign tags consistently — turning on by default would break Tier 2 for everyone, so this is documented as follow-up. + +Tier 3 (auto with grace window) and Tier 4 (autonomous within maintenance window) are out of scope for this PR. + +## Architecture + +- New atomic units under `src/node/updater/`: `lock` (PID file), `trustedKeys` (gpg via git verify-tag), `preflight` (sequenced check pipeline), `UpdateExecutor` (DI-spawn pipeline), `RollbackHandler` (boot health-timer + crash-loop guard), `SessionDrainer` (timed broadcasts + accept-flag), `updateLog` (rolling appender + tail). +- New routes in `src/node/hooks/express/updateActions.ts`: `POST /admin/update/{apply,cancel,acknowledge}`, `GET /admin/update/log` — strict admin auth. +- `RollbackHandler.checkPendingVerification` wires into boot in `src/node/updater/index.ts`; `markBootHealthy` is called from `src/node/server.ts` after state hits `RUNNING`. +- Admin UI: `UpdatePage` renders Apply/Cancel/Acknowledge per `execution.status`, polls `/admin/update/log` while in flight, surfaces lastResult and policy denial copy. Banner adds a terminal-state alert variant. +- Pad UI: existing shoutMessage pipeline learns to render `update.drain.t60/t30/t10` keys via `html10n.translations` (avoids the unbound `window._` bug). + +## Test plan + +- [x] `pnpm vitest run src/tests/backend-new/specs/updater` — unit suite (lock, preflight, trustedKeys, UpdateExecutor, RollbackHandler, SessionDrainer, updateLog, drainer-handshake, UpdatePolicy, index-boot, state) +- [x] `pnpm run test --grep updateActions` — mocha API tests for the four new endpoints (auth, policy, terminal-state acknowledge) +- [x] `pnpm run test --grep updater-integration` — end-to-end against a tmp git repo: happy path, install-fail rollback, build-fail rollback, crash-loop forced rollback +- [x] `pnpm run test-ui -- src/tests/frontend-new/admin-spec/update-page-actions.spec.ts` — Playwright Apply / policy denial / Acknowledge +- [x] Manual smoke: drain announcement renders the localised string in a real pad +- [x] `pnpm run ts-check` clean, `pnpm --filter admin run build` clean + +## Notes + +- Process supervisor is a hard requirement for Tier 2. Documented in `doc/admin/updates.md`. +- Tag signature verification is opt-in pending a separate "sign all releases" project. Logged as a warning when skipped. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 4: Wait for CI then check, fix anything that breaks** + +```bash +sleep 30 +gh pr checks --watch +``` + +If a check fails, pull the log, fix, push. Per memory `feedback_check_ci_after_pr`, do not move on with red CI. + +- [ ] **Step 5: Action Qodo review** + +Once Qodo posts review comments, fetch and address each per memory `feedback_qodo_pr_feedback`. + +```bash +gh pr view --comments | head -200 +``` + +--- + +## Self-review checklist (run before declaring this plan ready) + +- [ ] Every spec section under "Tier 2 — manual click", "Error handling", "Phased rollout / PR 2" has a corresponding task. +- [ ] Type names / function names are consistent across tasks (e.g., `executeUpdate`, `performRollback`, `checkPendingVerification`, `runPreflight`, `acquireLock`/`releaseLock`/`isHeld`, `createDrainer`, `tailLines`, `verifyReleaseTag`). +- [ ] No "TODO" / "TBD" / "similar to above" / "appropriate validation" placeholder steps. +- [ ] Every `bash` snippet runs without further parameter substitution. +- [ ] Every test step shows the actual test code, not "write a test for this". +- [ ] Every `git commit` step lists the exact files to add and a Conventional-Commits message with the project's standard `Co-Authored-By` footer. +- [ ] Tasks 14 and 17 require a manual visual check; that is documented as a hard gate (per memory `feedback_test_localized_strings`). +- [ ] Tier 3 / 4 are explicitly out of scope. 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/plans/2026-05-08-native-docx-pdf-export-import.md b/docs/superpowers/plans/2026-05-08-native-docx-pdf-export-import.md new file mode 100644 index 00000000000..4a8f502d63c --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-native-docx-pdf-export-import.md @@ -0,0 +1,1543 @@ +# Native DOCX + PDF export and DOCX import — 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:** Extend PR #7568 so a soffice-less Etherpad can export `pdf`+`docx` and import `docx` purely in-process, while keeping behavior bit-for-bit identical when soffice is configured. + +**Architecture:** Single dispatch cascade in `ExportHandler.ts` and `ImportHandler.ts` — soffice if `sofficeAvailable() === 'yes'`, native otherwise. Native PDF uses `pdfkit` + `htmlparser2` driven by a small walker we own. Native DOCX import uses `mammoth` to produce HTML and reuses the existing HTML import pipeline. A shared `stripRemoteImages()` helper sanitizes HTML before either DOCX or PDF conversion to close the SSRF surface Qodo flagged. Drops the opt-in `nativeDocxExport` setting introduced earlier in this PR — selection is purely soffice-presence-driven. + +**Tech Stack:** TypeScript (Node), Mocha + supertest backend tests, `pdfkit` (^0.15), `htmlparser2` (^9), `mammoth` (^1.7), `html-to-docx` (^1.8 — already in PR), pnpm workspace. + +**Spec:** `docs/superpowers/specs/2026-05-08-native-docx-pdf-export-import-design.md` (commit `2cebcc822`). + +--- + +## File Structure + +| File | Role | Status | +|---|---|---| +| `src/node/utils/ExportSanitizeHtml.ts` | `stripRemoteImages(html)` — drops `` outside `data:`/relative | NEW | +| `src/node/utils/ExportPdfNative.ts` | `htmlToPdfBuffer(html)` — pdfkit + htmlparser2 walker | NEW | +| `src/node/utils/ImportDocxNative.ts` | `docxBufferToHtml(buf)` — mammoth wrapper | NEW | +| `src/node/handler/ExportHandler.ts` | Replaced flag-gated DOCX branch with soffice-first cascade for both DOCX+PDF | MODIFIED | +| `src/node/handler/ImportHandler.ts` | Soffice-first cascade for DOCX import | MODIFIED | +| `src/node/hooks/express/importexport.ts` | Tighter route guard (PDF/DOCX go native when no soffice) | MODIFIED | +| `src/static/js/pad_impexp.ts` | Always show DOCX+PDF export links; ODT still gated on soffice | MODIFIED | +| `src/node/utils/Settings.ts` | **Revert** `nativeDocxExport` field (introduced earlier in PR) | MODIFIED (revert) | +| `settings.json.template` | **Revert** `nativeDocxExport` block | MODIFIED (revert) | +| `settings.json.docker` | **Revert** `nativeDocxExport` block | MODIFIED (revert) | +| `doc/docker.md` | **Revert** `NATIVE_DOCX_EXPORT` row | MODIFIED (revert) | +| `src/package.json` | Add `pdfkit`, `htmlparser2`, `mammoth`. Keep `html-to-docx`. | MODIFIED | +| `pnpm-lock.yaml` | Lockfile regen | MODIFIED | +| `src/tests/backend/specs/export.ts` | Revise existing DOCX tests (`soffice=null`); add native PDF tests; add negative ODT; add unit test for sanitizer | MODIFIED | +| `src/tests/backend/specs/import.ts` | New file: native DOCX import + negative ODT | NEW | +| `src/tests/backend/specs/fixtures/sample.docx` | Tiny DOCX fixture: heading, paragraph, bullet list | NEW | + +--- + +## Task 0: Rebase onto develop + +The PR is currently `mergeStateStatus: DIRTY`. Resolve before adding new commits — easier to handle conflicts on a known-good base. + +**Files:** none (git operation) + +- [ ] **Step 1: Fetch latest develop** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538 +git fetch origin develop +``` + +Expected: `From https://github.com/ether/etherpad-lite … develop -> origin/develop`. + +- [ ] **Step 2: Rebase** + +```bash +git rebase origin/develop +``` + +Expected: clean replay of `b98dfbab7` (DOCX feature commit), `6a7093c09` (CI guard), and `2cebcc822` (spec). If a conflict arises in `src/package.json` / `pnpm-lock.yaml` / `src/node/handler/ExportHandler.ts`, prefer **our** changes for new files and re-resolve any overlap manually. Do NOT use `--strategy-option=theirs` blindly. + +- [ ] **Step 3: Verify branch is rebased and tests still pass** + +```bash +git log --oneline origin/develop..HEAD +pnpm --filter ep_etherpad-lite run test --grep '#7538' +``` + +Expected: log shows the three commits above HEAD; test grep passes the existing native DOCX block. + +- [ ] **Step 4: Force-push to update the PR** + +```bash +git push fork feat/native-docx-export-7538 --force-with-lease +``` + +Expected: branch updated, GitHub re-runs CI green. + +- [ ] **Step 5: Confirm PR is no longer DIRTY** + +```bash +gh pr view 7568 --repo ether/etherpad --json mergeStateStatus,mergeable +``` + +Expected: `mergeStateStatus` is `BEHIND`, `BLOCKED`, `CLEAN`, or `HAS_HOOKS` — anything other than `DIRTY`/`CONFLICTING`. + +--- + +## Task 1: Add new dependencies + +**Files:** +- Modify: `src/package.json` +- Modify: `pnpm-lock.yaml` + +- [ ] **Step 1: Add deps** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538/src +pnpm add pdfkit htmlparser2 mammoth +pnpm add -D @types/pdfkit +``` + +Expected: three runtime deps + one dev `@types` package added to `src/package.json`. `pnpm-lock.yaml` regenerated. `html-to-docx` (already there) untouched. + +- [ ] **Step 2: Verify versions are pinned to caret-major** + +```bash +grep -E 'pdfkit|htmlparser2|mammoth|html-to-docx' src/package.json +``` + +Expected output (versions may be newer; the point is they're all `"^X.Y.Z"`): + +```text +"html-to-docx": "^1.8.0", +"htmlparser2": "^9.x.x", +"mammoth": "^1.x.x", +"pdfkit": "^0.x.x", +"@types/pdfkit": "^0.x.x", +``` + +- [ ] **Step 3: Quick sanity import** + +```bash +cd src && node -e "require('pdfkit'); require('htmlparser2'); require('mammoth'); console.log('OK')" +``` + +Expected: prints `OK` and exits 0. + +- [ ] **Step 4: Commit** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538 +git add src/package.json pnpm-lock.yaml +git commit -m "chore(7538): add pdfkit, htmlparser2, mammoth deps" +``` + +--- + +## Task 2: stripRemoteImages sanitizer (TDD) + +**Files:** +- Create: `src/node/utils/ExportSanitizeHtml.ts` +- Modify: `src/tests/backend/specs/export.ts` + +The sanitizer is consumed by both the DOCX and PDF branches in Task 5. Build it first so those branches can call it from the start. + +- [ ] **Step 1: Write the failing tests** + +Append the following block to `src/tests/backend/specs/export.ts`, ABOVE the closing `});` of the outer `describe(__filename, ...)`: + +```typescript + describe('stripRemoteImages', function () { + const {stripRemoteImages} = require('../../../node/utils/ExportSanitizeHtml'); + + it('keeps data: URIs', function () { + const out = stripRemoteImages( + '

        x

        '); + assert.match(out, /]+src="data:image\/png/); + }); + + it('keeps relative URLs', function () { + const out = stripRemoteImages(''); + assert.match(out, /]+src="\/foo\/bar\.png"/); + }); + + it('drops absolute http(s) URLs and falls back to alt', function () { + const out = stripRemoteImages( + '

        beforecatafter

        '); + assert.doesNotMatch(out, /evil\.example/); + assert.match(out, /before/); + assert.match(out, /cat/); + assert.match(out, /after/); + }); + + it('drops protocol-relative URLs', function () { + const out = stripRemoteImages(''); + assert.doesNotMatch(out, /evil\.example/); + }); + + it('passes non-image markup through unchanged', function () { + const html = '

        hi

        body link

        '; + assert.strictEqual(stripRemoteImages(html), html); + }); + }); +``` + +- [ ] **Step 2: Run tests, verify they fail** + +```bash +cd src && pnpm test --grep 'stripRemoteImages' +``` + +Expected: all five tests fail with `Cannot find module '../../../node/utils/ExportSanitizeHtml'`. + +- [ ] **Step 3: Implement the sanitizer** + +Create `src/node/utils/ExportSanitizeHtml.ts`: + +```typescript +'use strict'; + +import {Parser} from 'htmlparser2'; + +const isLocalSrc = (src: string): boolean => { + if (!src) return true; + if (src.startsWith('data:')) return true; + if (src.startsWith('//')) return false; + if (/^[a-z][a-z0-9+.-]*:/i.test(src)) return false; + return true; +}; + +const escapeAttr = (s: string): string => + s.replace(/&/g, '&').replace(/"/g, '"').replace(/ + s.replace(/&/g, '&').replace(//g, '>'); + +const VOID_TAGS = new Set([ + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', + 'link', 'meta', 'source', 'track', 'wbr', +]); + +export const stripRemoteImages = (html: string): string => { + let out = ''; + const parser = new Parser({ + onopentag(name, attribs) { + if (name === 'img') { + const src = attribs.src || ''; + if (isLocalSrc(src)) { + let tag = '`; + }, + }, {decodeEntities: false, lowerCaseTags: true}); + parser.write(html); + parser.end(); + return out; +}; +``` + +- [ ] **Step 4: Run tests, verify they pass** + +```bash +cd src && pnpm test --grep 'stripRemoteImages' +``` + +Expected: 5 passing. + +- [ ] **Step 5: Commit** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538 +git add src/node/utils/ExportSanitizeHtml.ts src/tests/backend/specs/export.ts +git commit -m "feat(7538): add stripRemoteImages HTML sanitizer + +Drops elements pointing at non-data, non-relative URLs to +prevent the DOCX/PDF converters from making outbound requests via +plugin-modified HTML. Closes Qodo finding #4 against the +html-to-docx path; will be wired into both export branches in +the cascade refactor." +``` + +--- + +## Task 3: Native PDF walker (TDD, structural) + +**Files:** +- Create: `src/node/utils/ExportPdfNative.ts` +- Modify: `src/tests/backend/specs/export.ts` + +Build the walker bottom-up: smoke first (HTML in → `%PDF` buffer out), then add tag handlers as features. Each tag class gets its own test. + +- [ ] **Step 1: Write the smoke test** + +Append to `src/tests/backend/specs/export.ts`, ABOVE the closing `});` of the outer `describe(__filename)`: + +```typescript + describe('htmlToPdfBuffer', function () { + let htmlToPdfBuffer: (html: string) => Promise; + + before(function () { + try { + require.resolve('pdfkit'); + require.resolve('htmlparser2'); + } catch { + this.skip(); + return; + } + htmlToPdfBuffer = require('../../../node/utils/ExportPdfNative').htmlToPdfBuffer; + }); + + it('produces a buffer starting with %PDF-', async function () { + const buf = await htmlToPdfBuffer('

        hello world

        '); + assert.ok(Buffer.isBuffer(buf), 'must return Buffer'); + assert.ok(buf.length > 100, `buffer suspiciously small: ${buf.length} bytes`); + assert.strictEqual(buf.slice(0, 5).toString('ascii'), '%PDF-'); + }); + }); +``` + +- [ ] **Step 2: Run, verify failure** + +```bash +cd src && pnpm test --grep 'htmlToPdfBuffer' +``` + +Expected: the `produces a buffer starting with %PDF-` test fails with `Cannot find module '../../../node/utils/ExportPdfNative'`. + +- [ ] **Step 3: Implement the minimal walker** + +Create `src/node/utils/ExportPdfNative.ts`: + +```typescript +'use strict'; + +import {Parser} from 'htmlparser2'; +import {PassThrough} from 'stream'; + +const PDFDocument = require('pdfkit'); + +interface InlineState { + bold: boolean; + italic: boolean; + underline: boolean; + strike: boolean; + link?: string; + fontSize?: number; +} + +const HEADING_SIZES: Record = { + h1: 24, h2: 20, h3: 16, h4: 14, h5: 12, h6: 11, +}; + +const decodeDataUri = (src: string): Buffer | null => { + const m = /^data:[^;,]+;base64,(.+)$/i.exec(src); + if (!m) return null; + try { + return Buffer.from(m[1], 'base64'); + } catch { + return null; + } +}; + +export const htmlToPdfBuffer = (html: string): Promise => + new Promise((resolve, reject) => { + const doc = new PDFDocument({margin: 50}); + const stream = new PassThrough(); + const chunks: Buffer[] = []; + stream.on('data', (c: Buffer) => chunks.push(c)); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + doc.pipe(stream); + + const styleStack: InlineState[] = [{ + bold: false, italic: false, underline: false, strike: false, + }]; + let listType: ('ul' | 'ol' | null)[] = []; + let listIndex: number[] = []; + let pendingNewline = false; + + const top = () => styleStack[styleStack.length - 1]; + + const applyFont = () => { + const s = top(); + const variant = + s.bold && s.italic ? 'Helvetica-BoldOblique' : + s.bold ? 'Helvetica-Bold' : + s.italic ? 'Helvetica-Oblique' : + 'Helvetica'; + doc.font(variant); + doc.fontSize(s.fontSize || 11); + }; + + const writeText = (raw: string) => { + if (!raw) return; + if (pendingNewline) { + doc.moveDown(0.5); + pendingNewline = false; + } + const s = top(); + applyFont(); + const opts: any = {continued: true}; + if (s.underline) opts.underline = true; + if (s.strike) opts.strike = true; + if (s.link) opts.link = s.link; + doc.text(raw, opts); + }; + + const flushLine = () => { + doc.text('', {continued: false}); + }; + + const parser = new Parser({ + onopentag(name, attribs) { + const cur = top(); + const next: InlineState = {...cur}; + switch (name) { + case 'b': case 'strong': next.bold = true; break; + case 'i': case 'em': next.italic = true; break; + case 'u': next.underline = true; break; + case 's': case 'strike': case 'del': next.strike = true; break; + case 'a': next.link = attribs.href; next.underline = true; break; + case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': + next.fontSize = HEADING_SIZES[name]; + next.bold = true; + if (!pendingNewline) flushLine(); + doc.moveDown(0.5); + break; + case 'p': case 'div': + if (!pendingNewline) flushLine(); + doc.moveDown(0.3); + break; + case 'ul': case 'ol': + listType.push(name as 'ul' | 'ol'); + listIndex.push(0); + flushLine(); + break; + case 'li': { + flushLine(); + const t = listType[listType.length - 1] || 'ul'; + if (t === 'ol') listIndex[listIndex.length - 1] += 1; + const prefix = t === 'ul' + ? '• ' + : `${listIndex[listIndex.length - 1]}. `; + const indent = ' '.repeat(Math.max(0, listType.length - 1)); + applyFont(); + doc.text(`${indent}${prefix}`, {continued: true}); + break; + } + case 'br': + flushLine(); + break; + case 'img': { + const buf = decodeDataUri(attribs.src || ''); + if (buf) { + flushLine(); + try { doc.image(buf, {fit: [400, 300]}); } catch { /* ignore */ } + } + break; + } + } + styleStack.push(next); + }, + + ontext(text) { + writeText(text); + }, + + onclosetag(name) { + switch (name) { + case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': + case 'p': case 'div': + flushLine(); + pendingNewline = true; + break; + case 'li': + flushLine(); + break; + case 'ul': case 'ol': + listType.pop(); + listIndex.pop(); + doc.moveDown(0.3); + break; + } + styleStack.pop(); + if (styleStack.length === 0) { + styleStack.push({bold: false, italic: false, underline: false, strike: false}); + } + }, + }, {decodeEntities: true, lowerCaseTags: true}); + + parser.write(html); + parser.end(); + flushLine(); + doc.end(); + }); +``` + +- [ ] **Step 4: Run smoke, verify pass** + +```bash +cd src && pnpm test --grep 'htmlToPdfBuffer' +``` + +Expected: 1 passing. + +- [ ] **Step 5: Add structural tests** + +Append BELOW the existing `it('produces a buffer starting with %PDF-')` inside the same `describe('htmlToPdfBuffer')`: + +```typescript + const renderText = async (html: string): Promise => { + const buf = await htmlToPdfBuffer(html); + // pdfkit emits text uncompressed-ish; we look for substrings inside + // the raw PDF stream. This is intentionally fragile-friendly: we + // assert the words show up at all, not their layout. + return buf.toString('latin1'); + }; + + it('renders headings, paragraphs, and lists', async function () { + const raw = await renderText(` +

        Title

        +

        Body paragraph here.

        +
        • one
        • two
        +
        1. alpha
        2. beta
        + `); + assert.ok(raw.includes('Title')); + assert.ok(raw.includes('Body paragraph here.')); + assert.ok(raw.includes('one')); + assert.ok(raw.includes('two')); + assert.ok(raw.includes('alpha')); + assert.ok(raw.includes('beta')); + }); + + it('emits link annotations for ', async function () { + const raw = await renderText('

        site

        '); + assert.ok(raw.includes('site')); + assert.ok(raw.includes('etherpad.org')); + }); + + it('embeds data: URI images without throwing', async function () { + const tinyPng = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + const buf = await htmlToPdfBuffer(``); + assert.ok(buf.length > 200); + }); + + it('ignores unknown tags rather than crashing', async function () { + const buf = await htmlToPdfBuffer( + '

        still works

        '); + assert.ok(buf.slice(0, 5).toString('ascii') === '%PDF-'); + }); + }); +``` + +- [ ] **Step 6: Run, verify all pass** + +```bash +cd src && pnpm test --grep 'htmlToPdfBuffer' +``` + +Expected: 5 passing. + +- [ ] **Step 7: Walker line-count check (bail-out criterion)** + +```bash +wc -l src/node/utils/ExportPdfNative.ts +``` + +If the result is **>500 lines** OR a structural test from Step 5 fails in a way that requires substantially more walker code to fix (e.g. real table rendering, complex CSS), STOP and follow the bail-out path: + +1. Read the spec section "Bail-out criterion" again. +2. Replace `ExportPdfNative.ts` with a `pdfmake` + `html-to-pdfmake` + `jsdom` implementation behind the same `htmlToPdfBuffer(html)` signature. +3. Add `pdfmake`, `html-to-pdfmake`, `jsdom` to `src/package.json`; remove `pdfkit` and `htmlparser2` if not used anywhere else. +4. Re-run the same test grep — the public contract (input HTML, output `%PDF-` buffer) hasn't changed. +5. Continue with Task 4. + +If the file is ≤500 lines and tests pass, continue normally. + +- [ ] **Step 8: Commit** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538 +git add src/node/utils/ExportPdfNative.ts src/tests/backend/specs/export.ts +git commit -m "feat(7538): native PDF export via pdfkit + htmlparser2 walker + +Renders pad HTML to a PDF Buffer in-process: headings, paragraphs, +lists, links, inline emphasis, data:-URI images. Remote images are +explicitly skipped at the walker (defense-in-depth on top of the +shared stripRemoteImages sanitizer)." +``` + +--- + +## Task 4: Native DOCX import wrapper (TDD) + +**Files:** +- Create: `src/node/utils/ImportDocxNative.ts` +- Create: `src/tests/backend/specs/fixtures/sample.docx` +- Create: `src/tests/backend/specs/import.ts` + +- [ ] **Step 1: Generate the DOCX fixture** + +Use `html-to-docx` (already a dep) to produce a deterministic fixture so we don't hand-build OOXML. Run this from the worktree root: + +```bash +mkdir -p src/tests/backend/specs/fixtures +cd src && node -e " +const fs = require('fs'); +const htmlToDocx = require('html-to-docx'); +htmlToDocx('

        Heading

        Paragraph body.

        • one
        • two
        ').then((buf) => { + fs.writeFileSync('tests/backend/specs/fixtures/sample.docx', buf); + console.log('wrote', buf.length, 'bytes'); +}); +" +``` + +Expected: `wrote bytes` where N is roughly 5000–15000. + +- [ ] **Step 2: Verify fixture is a real DOCX** + +```bash +head -c 4 src/tests/backend/specs/fixtures/sample.docx | xxd +``` + +Expected: starts with `50 4b 03 04` (PK ZIP signature). + +- [ ] **Step 3: Write the failing wrapper test** + +Create `src/tests/backend/specs/import.ts`: + +```typescript +'use strict'; + +import {MapArrayType} from '../../../node/types/MapType'; +import path from 'path'; +import {promises as fs} from 'fs'; + +const assert = require('assert').strict; +const common = require('../common'); +const padManager = require('../../../node/db/PadManager'); +import settings from '../../../node/utils/Settings'; + +describe(__filename, function () { + const settingsBackup: MapArrayType = {}; + let agent: any; + + before(async function () { + agent = await common.init(); + settingsBackup.soffice = settings.soffice; + }); + + after(function () { + Object.assign(settings, settingsBackup); + }); + + describe('docxBufferToHtml (#7538)', function () { + let docxBufferToHtml: (b: Buffer) => Promise; + + before(function () { + try { require.resolve('mammoth'); } + catch { this.skip(); return; } + docxBufferToHtml = require('../../../node/utils/ImportDocxNative').docxBufferToHtml; + }); + + it('converts the sample.docx fixture to HTML', async function () { + const buf = await fs.readFile( + path.join(__dirname, 'fixtures', 'sample.docx')); + const html = await docxBufferToHtml(buf); + assert.match(html, /Heading/); + assert.match(html, /Paragraph body\./); + assert.match(html, /one/); + assert.match(html, /two/); + }); + + it('emits no remote image URLs', async function () { + const buf = await fs.readFile( + path.join(__dirname, 'fixtures', 'sample.docx')); + const html = await docxBufferToHtml(buf); + assert.doesNotMatch(html, /]+src="https?:/); + assert.doesNotMatch(html, /]+src="\/\//); + }); + }); +}); +``` + +- [ ] **Step 4: Run, verify failure** + +```bash +cd src && pnpm test --grep 'docxBufferToHtml' +``` + +Expected: tests fail with `Cannot find module '../../../node/utils/ImportDocxNative'`. + +- [ ] **Step 5: Implement the wrapper** + +Create `src/node/utils/ImportDocxNative.ts`: + +```typescript +'use strict'; + +const mammoth = require('mammoth'); + +export const docxBufferToHtml = async (buffer: Buffer): Promise => { + const result = await mammoth.convertToHtml( + {buffer}, + { + convertImage: mammoth.images.imgElement(async (image: any) => { + const buf: Buffer = await image.read(); + const contentType = image.contentType || 'application/octet-stream'; + return {src: `data:${contentType};base64,${buf.toString('base64')}`}; + }), + }, + ); + return result.value || ''; +}; +``` + +- [ ] **Step 6: Run, verify pass** + +```bash +cd src && pnpm test --grep 'docxBufferToHtml' +``` + +Expected: 2 passing. + +- [ ] **Step 7: Commit** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538 +git add src/node/utils/ImportDocxNative.ts \ + src/tests/backend/specs/import.ts \ + src/tests/backend/specs/fixtures/sample.docx +git commit -m "feat(7538): native DOCX import via mammoth + +Wraps mammoth.convertToHtml so a soffice-less Etherpad can ingest +.docx files. Images are coerced to data: URIs at the converter +boundary so the import pipeline never sees a remote src=." +``` + +--- + +## Task 5: Refactor ExportHandler to soffice-first cascade + +**Files:** +- Modify: `src/node/handler/ExportHandler.ts` +- Modify: `src/tests/backend/specs/export.ts` + +This is where we drop the flag-gated branch and wire the cascade. + +- [ ] **Step 1: Update the existing native-DOCX test block to use `soffice = null`** + +In `src/tests/backend/specs/export.ts`, find: + +```typescript + describe('native DOCX export (#7538)', function () { + before(function () { + try { + require.resolve('html-to-docx'); + } catch { + this.skip(); + return; + } + settings.soffice = 'false'; + settings.nativeDocxExport = true; + }); +``` + +Replace with: + +```typescript + describe('native DOCX export (#7538)', function () { + before(function () { + try { + require.resolve('html-to-docx'); + } catch { + this.skip(); + return; + } + settings.soffice = null; + }); +``` + +Also update the line that sets these in the prior `it('returns 500 on export error')` block: + +```typescript + settings.soffice = 'false'; // '/bin/false' doesn't work on Windows + settings.nativeDocxExport = false; +``` + +Becomes: + +```typescript + settings.soffice = '/bin/false'; // forces a soffice spawn that errors +``` + +(The intent of that test is to exercise the soffice error path; with the cascade, that means soffice MUST be configured.) + +Remove the line: + +```typescript + settingsBackup.nativeDocxExport = settings.nativeDocxExport; +``` + +from the outer `before(...)` block. + +- [ ] **Step 2: Add the negative ODT test** + +Above the closing `});` of `describe(__filename)` and AFTER the `htmlToPdfBuffer` block, add: + +```typescript + describe('odt without soffice (#7538)', function () { + before(function () { settings.soffice = null; }); + it('returns the "not enabled" message for odt', async function () { + const res = await agent.get('/p/testExportPad/export/odt').expect(200); + assert.match(res.text, /This export is not enabled/); + }); + }); +``` + +- [ ] **Step 3: Add the native PDF integration test** + +Inside the existing `describe('native DOCX export (#7538)')`, immediately after the two existing tests, add a sibling describe: + +```typescript + describe('native PDF export (#7538)', function () { + before(function () { + try { + require.resolve('pdfkit'); + require.resolve('htmlparser2'); + } catch { + this.skip(); + return; + } + settings.soffice = null; + }); + + it('returns a valid %PDF- document', async function () { + const res = await agent.get('/p/testExportPad/export/pdf') + .buffer(true) + .parse((resp: any, callback: any) => { + const chunks: Buffer[] = []; + resp.on('data', (chunk: Buffer) => chunks.push(chunk)); + resp.on('end', () => callback(null, Buffer.concat(chunks))); + }) + .expect(200); + const body: Buffer = res.body as Buffer; + assert.ok(body.length > 200, 'PDF body must be non-trivial'); + assert.strictEqual(body.slice(0, 5).toString('ascii'), '%PDF-'); + }); + + it('sends application/pdf content-type', async function () { + const res = await agent.get('/p/testExportPad/export/pdf').expect(200); + assert.match(res.headers['content-type'], /application\/pdf/); + }); + }); +``` + +- [ ] **Step 4: Run, verify failures** + +```bash +cd src && pnpm test --grep '#7538' +``` + +Expected: PDF tests fail (route returns 200 but with the "not enabled" body or 500 from soffice path); ODT test currently fails (route guard still blocks it). DOCX tests fail because cascade isn't in place yet — the `nativeDocxExport=true` shortcut is gone but the new cascade isn't there. + +- [ ] **Step 5: Replace the flag-gated branch in ExportHandler** + +In `src/node/handler/ExportHandler.ts`, replace lines 90–144 (everything from the `// Native DOCX path (issue #7538)` comment block down through the `await fsp_unlink(destFile);` line) with: + +```typescript + // Soffice-first dispatch (issue #7538). When soffice is configured + // we keep the legacy convert-via-tempfile path; when it's not, we + // hand DOCX to html-to-docx and PDF to our pdfkit walker — both + // pure-JS, in-process. No fallback chain: native errors surface as + // 5xx so admins see real failures instead of silent shadowing. + const {sofficeAvailable} = require('../utils/Settings'); + const offline = sofficeAvailable() === 'no' + || (sofficeAvailable() === 'withoutPDF' && type === 'pdf'); + + if (offline) { + const {stripRemoteImages} = require('../utils/ExportSanitizeHtml'); + const safeHtml = stripRemoteImages(html); + html = null; + try { + if (type === 'docx') { + const htmlToDocx = require('html-to-docx'); + const buf = await htmlToDocx(safeHtml); + res.contentType( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + res.send(buf); + return; + } + if (type === 'pdf') { + const {htmlToPdfBuffer} = require('../utils/ExportPdfNative'); + const buf = await htmlToPdfBuffer(safeHtml); + res.contentType('application/pdf'); + res.send(buf); + return; + } + // soffice-only formats (odt, doc) are blocked at the route guard + // when soffice is null; reaching here means the guard is wrong. + res.status(500).send(`Cannot export ${type} without soffice configured`); + return; + } catch (err) { + console.error( + `native ${type} export failed for pad "${padId}":`, + err && (err as Error).stack ? (err as Error).stack : err); + res.status(500).send(`Failed to export pad as ${type}.`); + return; + } + } + + // soffice path — write the html export to a file + const randNum = Math.floor(Math.random() * 0xFFFFFFFF); + const srcFile = `${tempDirectory}/etherpad_export_${randNum}.html`; + await fsp_writeFile(srcFile, html); + + // ensure html can be collected by the garbage collector + html = null; + + // send the convert job to the converter (libreoffice) + const destFile = `${tempDirectory}/etherpad_export_${randNum}.${type}`; + + // Allow plugins to overwrite the convert in export process + const result = await hooks.aCallAll('exportConvert', {srcFile, destFile, req, res}); + if (result.length > 0) { + // console.log("export handled by plugin", destFile); + } else { + const converter = require('../utils/LibreOffice'); + await converter.convertFile(srcFile, destFile, type); + } + + // send the file + await res.sendFile(destFile, null); + + // clean up temporary files + await fsp_unlink(srcFile); + + // 100ms delay to accommodate for slow windows fs + if (os.type().indexOf('Windows') > -1) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + await fsp_unlink(destFile); +``` + +- [ ] **Step 6: Run, verify DOCX + PDF tests pass; ODT still routed by the guard (next task)** + +```bash +cd src && pnpm test --grep '#7538' +``` + +Expected: DOCX tests pass; PDF tests pass; ODT test fails because the route guard hasn't been tightened yet (it's blocking ALL of pdf/docx/odt/doc when soffice is null, including pdf and docx that we just made native — wait, no: this is what the next task fixes. Actually, with the current guard the docx/pdf integration tests would have failed at Step 4 already. Re-check: the guard returns 200 with a "not enabled" message, which `assert.strictEqual(body.slice(0,5)...)` would fail.) The expected outcome of THIS step is **DOCX and PDF integration tests still fail**, walker-style failures may appear too. We move to Task 6 to fix the guard, then re-run. + +If the unit-style tests for `htmlToPdfBuffer`, `docxBufferToHtml`, and `stripRemoteImages` still pass, that's enough to move on. + +```bash +cd src && pnpm test --grep 'htmlToPdfBuffer\|docxBufferToHtml\|stripRemoteImages' +``` + +Expected: 12 passing (5 sanitizer + 5 walker + 2 mammoth). + +- [ ] **Step 7: Commit** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538 +git add src/node/handler/ExportHandler.ts src/tests/backend/specs/export.ts +git commit -m "feat(7538): soffice-first cascade in ExportHandler + +Replaces the flag-gated DOCX branch with a deterministic dispatch: +soffice if configured, native DOCX/PDF otherwise, 5xx on native +error. Both native paths run plugin-modified HTML through +stripRemoteImages first." +``` + +--- + +## Task 6: Tighten the route guard + +**Files:** +- Modify: `src/node/hooks/express/importexport.ts` +- Modify: `src/tests/backend/specs/export.ts` (re-verify tests) + +- [ ] **Step 1: Update the export guard** + +In `src/node/hooks/express/importexport.ts`, replace the block on lines 37–48: + +```typescript + // if soffice is disabled, and this is a format we only support with soffice, output a message + if (exportAvailable() === 'no' && + ['odt', 'pdf', 'doc', 'docx'].indexOf(req.params.type) !== -1) { + console.error(`Impossible to export pad "${req.params.pad}" in ${req.params.type} format.` + + ' There is no converter configured'); + + // ACHTUNG: do not include req.params.type in res.send() because there is + // no HTML escaping and it would lead to an XSS + res.send('This export is not enabled at this Etherpad instance. Set the path to soffice ' + + '(LibreOffice) in settings.json to enable this feature'); + return; + } +``` + +With: + +```typescript + // When soffice is disabled, only block formats with no native path. + // pdf and docx fall through to ExportHandler, which dispatches to + // the in-process converters (issue #7538). + if (exportAvailable() === 'no' && + ['odt', 'doc'].indexOf(req.params.type) !== -1) { + console.error(`Impossible to export pad "${req.params.pad}" in ${req.params.type} format.` + + ' There is no converter configured'); + + // ACHTUNG: do not include req.params.type in res.send() because there is + // no HTML escaping and it would lead to an XSS + res.send('This export is not enabled at this Etherpad instance. Set the path to soffice ' + + '(LibreOffice) in settings.json to enable this feature'); + return; + } +``` + +- [ ] **Step 2: Add the import guard (currently absent — there is no `if (exportAvailable() === 'no') { ... }` on the import side, but the implicit behavior is that `useConverter` becomes `false` and only built-in formats work). Verify by reading lines 73–90 of the current file.** + +The import endpoint already implicitly handles the no-soffice case via `useConverter = (converter != null)` in `ImportHandler.ts`. After Task 7 wires native DOCX import there, no change is needed here. + +- [ ] **Step 3: Run, verify all #7538 tests pass** + +```bash +cd src && pnpm test --grep '#7538' +``` + +Expected: native DOCX (2), native PDF (2), odt-without-soffice (1) — 5 passing. + +- [ ] **Step 4: Run the full export test file as a regression check** + +```bash +cd src && pnpm test --grep 'export\.ts' +``` + +Expected: all green, including the pre-existing `returns 500 on export error` test which uses `/bin/false` as soffice. + +- [ ] **Step 5: Commit** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538 +git add src/node/hooks/express/importexport.ts +git commit -m "fix(7538): allow docx/pdf through export guard without soffice + +Tightens the no-soffice block to ['odt','doc'] only — formats with +no native path. docx and pdf are handed to ExportHandler, which +dispatches to the in-process converters. Closes Qodo finding #2." +``` + +--- + +## Task 7: Wire DOCX import into ImportHandler + +**Files:** +- Modify: `src/node/handler/ImportHandler.ts` +- Modify: `src/tests/backend/specs/import.ts` + +- [ ] **Step 1: Write the failing integration test** + +Append to `src/tests/backend/specs/import.ts`, BELOW the existing `docxBufferToHtml` describe and ABOVE the closing `});` of `describe(__filename)`: + +```typescript + describe('end-to-end DOCX import (#7538)', function () { + before(function () { + try { require.resolve('mammoth'); } + catch { this.skip(); return; } + settings.soffice = null; + }); + + it('imports a docx into a pad without soffice', async function () { + const padId = 'test7538DocxImport'; + // Reset pad + try { await padManager.removePad(padId); } catch { /* noop */ } + const fixture = path.join(__dirname, 'fixtures', 'sample.docx'); + const res = await agent + .post(`/p/${padId}/import`) + .attach('file', fixture) + .expect(200); + assert.strictEqual(res.body.code, 0, `import failed: ${JSON.stringify(res.body)}`); + const pad = await padManager.getPad(padId); + const text = pad.text(); + assert.match(text, /Heading/); + assert.match(text, /Paragraph body/); + assert.match(text, /one/); + assert.match(text, /two/); + }); + + it('rejects odt extension when soffice is null', async function () { + const padId = 'test7538OdtReject'; + try { await padManager.removePad(padId); } catch { /* noop */ } + const fixture = path.join(__dirname, 'fixtures', 'sample.docx'); + // copy fixture to a .odt name + const odtPath = path.join(__dirname, 'fixtures', 'sample.odt'); + await fs.copyFile(fixture, odtPath); + try { + const res = await agent + .post(`/p/${padId}/import`) + .attach('file', odtPath); + // either 400 with a known status or rejected payload + assert.ok( + res.status >= 400 || res.body.code !== 0, + `expected odt import to fail when soffice is null, got: ${res.status} ${JSON.stringify(res.body)}`); + } finally { + await fs.unlink(odtPath).catch(() => undefined); + } + }); + }); +``` + +- [ ] **Step 2: Run, verify failure** + +```bash +cd src && pnpm test --grep 'end-to-end DOCX import' +``` + +Expected: tests fail — likely the docx import either errors out (no converter) or empties the pad. + +- [ ] **Step 3: Update ImportHandler** + +In `src/node/handler/ImportHandler.ts`: + +a) Replace the block on lines 59–66: + +```typescript +let converter:any = null; +let exportExtension = 'htm'; + +// load soffice only if it is enabled +if (settings.soffice != null) { + converter = require('../utils/LibreOffice'); + exportExtension = 'html'; +} +``` + +with: + +```typescript +let converter: any = null; +let exportExtension = 'htm'; + +// load soffice only if it is enabled +if (settings.soffice != null) { + converter = require('../utils/LibreOffice'); + exportExtension = 'html'; +} + +const NATIVE_NO_SOFFICE_OFFICE_FORMATS = new Set(['.pdf', '.odt', '.doc', '.rtf']); +``` + +b) After the `fileEndingUnknown` block (line 131) and BEFORE the `const destFile = ...` line (133), insert: + +```typescript + // Native DOCX import (issue #7538): when soffice isn't configured we + // hand .docx files to mammoth, which produces HTML — then we feed that + // through the existing setPadHTML pipeline by writing it to destFile. + if (settings.soffice == null && fileEnding === '.docx') { + const buf = await fs.readFile(srcFile); + const {docxBufferToHtml} = require('../utils/ImportDocxNative'); + let nativeHtml: string; + try { + nativeHtml = await docxBufferToHtml(buf); + } catch (err: any) { + logger.warn(`Native DOCX import failed: ${err.stack || err}`); + throw new ImportError('convertFailed'); + } + const destFileNative = path.join(tmpDirectory, `etherpad_import_${randNum}.html`); + await fs.writeFile(destFileNative, nativeHtml); + const pad = await padManager.getPad(padId, '\n', authorId); + try { + await importHtml.setPadHTML(pad, nativeHtml, authorId); + } catch (err: any) { + logger.warn(`Error importing native DOCX HTML: ${err.stack || err}`); + throw new ImportError('convertFailed'); + } + padManager.unloadPad(padId); + const reloaded = await padManager.getPad(padId, '\n', authorId); + padManager.unloadPad(padId); + await padMessageHandler.updatePadClients(reloaded); + rm(srcFile); + rm(destFileNative); + return false; + } + + // Without soffice, the legacy office formats (pdf, odt, doc, rtf) have + // no in-process path. Reject explicitly so the user sees a clear error + // instead of a silent ASCII-only fallback. + if (settings.soffice == null && NATIVE_NO_SOFFICE_OFFICE_FORMATS.has(fileEnding)) { + throw new ImportError('uploadFailed'); + } +``` + +- [ ] **Step 4: Run, verify both tests pass** + +```bash +cd src && pnpm test --grep 'end-to-end DOCX import' +``` + +Expected: 2 passing. + +- [ ] **Step 5: Run the full import test file** + +```bash +cd src && pnpm test --grep 'import\.ts' +``` + +Expected: 4 passing (2 wrapper + 2 e2e). + +- [ ] **Step 6: Commit** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538 +git add src/node/handler/ImportHandler.ts src/tests/backend/specs/import.ts +git commit -m "feat(7538): native DOCX import path in ImportHandler + +When soffice is null and the upload is .docx, run mammoth and feed +the resulting HTML through setPadHTML. Other office formats +(pdf/odt/doc/rtf) are explicitly rejected with uploadFailed instead +of silently falling through to the ASCII-only path." +``` + +--- + +## Task 8: UI — always show DOCX + PDF export links + +**Files:** +- Modify: `src/static/js/pad_impexp.ts` + +- [ ] **Step 1: Update the gate** + +In `src/static/js/pad_impexp.ts`, replace lines 147–166: + +```typescript + // hide stuff thats not avaible if soffice is disabled + const wordFormat = clientVars.docxExport ? 'docx' : 'doc'; + if (clientVars.exportAvailable === 'no') { + $('#exportworda').remove(); + $('#exportpdfa').remove(); + $('#exportopena').remove(); + $('#importmessagenoconverter').prop('hidden', false); + } else if (clientVars.exportAvailable === 'withoutPDF') { + $('#exportpdfa').remove(); + + $('#exportworda').attr('href', `${padRootPath}/export/${wordFormat}`); + $('#exportopena').attr('href', `${padRootPath}/export/odt`); + + $('#importexport').css({height: '142px'}); + $('#importexportline').css({height: '142px'}); + } else { + $('#exportworda').attr('href', `${padRootPath}/export/${wordFormat}`); + $('#exportpdfa').attr('href', `${padRootPath}/export/pdf`); + $('#exportopena').attr('href', `${padRootPath}/export/odt`); + } +``` + +With: + +```typescript + // DOCX and PDF are always available — soffice when configured, + // native pure-JS converters otherwise (issue #7538). ODT still + // requires soffice. The 'withoutPDF' branch (Windows soffice + // without PDF) is handled by the server-side cascade routing PDF + // through native; the UI link stays. + const wordFormat = clientVars.docxExport ? 'docx' : 'doc'; + $('#exportworda').attr('href', `${padRootPath}/export/${wordFormat}`); + $('#exportpdfa').attr('href', `${padRootPath}/export/pdf`); + if (clientVars.exportAvailable === 'no') { + $('#exportopena').remove(); + $('#importmessagenoconverter').prop('hidden', false); + } else { + $('#exportopena').attr('href', `${padRootPath}/export/odt`); + } +``` + +- [ ] **Step 2: Lint check** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538/src +pnpm exec tsc --noEmit -p . +``` + +Expected: no errors related to `pad_impexp.ts` (project-wide ts-check should pass; Task 9 will also catch it). + +- [ ] **Step 3: Manual smoke (if dev server access available)** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538/src +SOFFICE=null pnpm run dev +``` + +In another terminal, open `http://localhost:9001/p/test`, click **Import/Export**, verify: +- Word and PDF links visible +- ODT link hidden +- "no converter" import message visible + +If you cannot run a dev server in this environment, skip this step and rely on the integration tests. + +- [ ] **Step 4: Commit** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538 +git add src/static/js/pad_impexp.ts +git commit -m "fix(7538): always show DOCX/PDF export links + +Native paths (#7538) make DOCX and PDF available regardless of +soffice presence, so unconditionally render those links. ODT still +gates on exportAvailable. Closes Qodo finding #2 on the UI side." +``` + +--- + +## Task 9: Revert the `nativeDocxExport` setting + +The flag is no longer needed — selection is purely soffice-presence-driven. Roll back the additions from commit `b98dfbab7`. + +**Files:** +- Modify: `src/node/utils/Settings.ts` +- Modify: `settings.json.template` +- Modify: `settings.json.docker` +- Modify: `doc/docker.md` + +- [ ] **Step 1: Remove the type field** + +In `src/node/utils/Settings.ts` line 208, delete this line: + +```typescript + nativeDocxExport: boolean, +``` + +- [ ] **Step 2: Remove the default value + JSDoc** + +In `src/node/utils/Settings.ts`, delete lines 419–426 (the `/** ... */` block above and the `nativeDocxExport: false,` line). + +- [ ] **Step 3: Remove from settings.json.template** + +In `settings.json.template`, delete the entire block containing the `"nativeDocxExport": false,` line and its preceding `/* … */` JSDoc comment (around lines 354–362). Verify by: + +```bash +grep -n 'nativeDocxExport\|NATIVE_DOCX' settings.json.template settings.json.docker doc/docker.md src/node/utils/Settings.ts +``` + +Expected: no results. + +- [ ] **Step 4: Remove from settings.json.docker** + +In `settings.json.docker`, delete the block on lines 372–377: + +```text + /* + * Convert DOCX exports in-process via html-to-docx instead of shelling + * out to LibreOffice. Auto-falls back to the LibreOffice path on error. + */ + "nativeDocxExport": "${NATIVE_DOCX_EXPORT:false}", +``` + +- [ ] **Step 5: Remove from doc/docker.md** + +Delete the row on line 193: + +```text +| `NATIVE_DOCX_EXPORT` | Convert DOCX exports in-process with the bundled `html-to-docx` library instead of shelling out to LibreOffice. Auto-falls back to LibreOffice on error. Lets you skip installing `soffice` entirely for deployments that only need DOCX. | `false` | +``` + +- [ ] **Step 6: Re-verify nothing references the flag** + +```bash +grep -rn 'nativeDocxExport\|NATIVE_DOCX_EXPORT' src/ doc/ settings.json.template settings.json.docker 2>/dev/null +``` + +Expected: empty output. + +- [ ] **Step 7: Type-check** + +```bash +cd src && pnpm exec tsc --noEmit -p . +``` + +Expected: no type errors. + +- [ ] **Step 8: Run the full export + import test suite** + +```bash +cd src && pnpm test --grep 'export\.ts\|import\.ts' +``` + +Expected: all green — sanitizer (5), walker (5), mammoth wrapper (2), DOCX integration (2), PDF integration (2), odt-without-soffice (1), e2e import (2), pre-existing soffice 500 (1). Roughly 20 passing. + +- [ ] **Step 9: Commit** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538 +git add src/node/utils/Settings.ts settings.json.template settings.json.docker doc/docker.md +git commit -m "refactor(7538): drop nativeDocxExport flag + +Selection is now purely soffice-presence-driven (Task 5 cascade). +The opt-in setting and its NATIVE_DOCX_EXPORT env var are no longer +needed — soffice configured means soffice path; soffice null means +native path. Reverts the additive surface introduced earlier in +this PR." +``` + +--- + +## Task 10: Final verification + Qodo response + +**Files:** none (CI / GitHub) + +- [ ] **Step 1: Run the full backend test suite** + +```bash +cd /home/jose/etherpad/etherpad-lite/.claude/worktrees/pr-7538 +pnpm --filter ep_etherpad-lite run test +``` + +Expected: full pass. If any previously-passing test now fails (e.g. a soffice-dependent test that assumed `exportAvailable() === 'no'` blocks docx), investigate root cause — do NOT silently mute. + +- [ ] **Step 2: Push** + +```bash +git push fork feat/native-docx-export-7538 +``` + +Expected: ten new commits on top of the rebased base. + +- [ ] **Step 3: Wait ~30s for CI to start, then check status** + +```bash +sleep 30 && gh pr checks 7568 --repo ether/etherpad +``` + +Expected: all checks pass or are in progress. If a check fails, fix the underlying issue and push again — do NOT mark the PR ready until all checks are green. + +- [ ] **Step 4: Reply to each Qodo finding on the PR** + +```bash +gh pr comment 7568 --repo ether/etherpad --body "$(cat <<'EOF' +Qodo follow-up: + +1. **Requirement gap (DOCX still needs soffice)** — addressed. Removed the `nativeDocxExport` flag entirely. Selection is now purely soffice-presence-driven: soffice configured → soffice; soffice null → native (html-to-docx for DOCX, pdfkit for PDF). No fallback chain. +2. **DOCX blocked without soffice** — fixed. Tightened the route guard to `['odt','doc']` only when `exportAvailable() === 'no'`; pdf/docx fall through to ExportHandler's native dispatch. UI in pad_impexp.ts always shows DOCX + PDF links now. +3. **Native DOCX test bypass** — fixed. Tests use `settings.soffice = null` (was `'false'`) so they exercise the real no-soffice deployment shape. +4. **Unrestricted HTML-to-DOCX I/O** — addressed. New `stripRemoteImages` sanitizer drops non-`data:`/non-relative `` before either DOCX or PDF conversion. The PDF walker also rejects remote `` at its own boundary as defense-in-depth. No converter ever sees a remote URL. + +Also added native PDF export (issue #7538's other half) and native DOCX import via mammoth — design committed at `docs/superpowers/specs/2026-05-08-native-docx-pdf-export-import-design.md`. +EOF +)" +``` + +Expected: comment posted; URL printed. + +- [ ] **Step 5: Mark the PR ready for review** + +```bash +gh pr ready 7568 --repo ether/etherpad +``` + +Expected: `Pull request #7568 is now ready for review`. If maintainers prefer the PR stays draft until they review, skip this step. + +- [ ] **Step 6: Update PR description** + +```bash +gh pr edit 7568 --repo ether/etherpad --body "$(cat <<'EOF' +## Summary + +Closes #7538. With this PR an Etherpad deployment with `settings.soffice = null` can: +- export pads as `html`, `txt`, `etherpad`, `docx`, `pdf` — all in-process, no subprocess, no native binaries +- import `.html`, `.txt`, `.etherpad`, `.docx` files — all in-process + +Deployments with `settings.soffice` configured retain today's behavior bit-for-bit. + +## Shape + +Selection is purely soffice-presence-driven — there is no opt-in flag: +- `sofficeAvailable() === 'yes'` → existing soffice path +- `'withoutPDF'` (Windows) → soffice for everything except `pdf`, which goes native +- `'no'` (soffice null) → native DOCX/PDF; ODT/DOC remain blocked with a clear message + +Native DOCX export uses `html-to-docx`; native PDF uses a small `pdfkit` + `htmlparser2` walker we own; native DOCX import uses `mammoth`. Plugin-modified HTML is run through `stripRemoteImages` first to close the SSRF surface Qodo flagged. + +## Files + +| File | Change | +|---|---| +| `src/node/utils/ExportSanitizeHtml.ts` | new — `stripRemoteImages` | +| `src/node/utils/ExportPdfNative.ts` | new — pdfkit walker | +| `src/node/utils/ImportDocxNative.ts` | new — mammoth wrapper | +| `src/node/handler/ExportHandler.ts` | soffice-first cascade for DOCX + PDF | +| `src/node/handler/ImportHandler.ts` | native DOCX import branch | +| `src/node/hooks/express/importexport.ts` | route guard tightened to `['odt','doc']` | +| `src/static/js/pad_impexp.ts` | DOCX + PDF links always visible | +| `src/package.json` | `pdfkit`, `htmlparser2`, `mammoth`, `html-to-docx` | +| `src/tests/backend/specs/export.ts` | revised + new tests | +| `src/tests/backend/specs/import.ts` | new — DOCX import tests | +| `src/tests/backend/specs/fixtures/sample.docx` | new fixture | +| `docs/superpowers/specs/...` | design spec | + +## Out of scope (follow-ups) + +- Native ODT export — no mature pure-JS writer +- Native PDF / ODT / DOC / RTF import — no mature pure-JS readers +- Memory/timeout caps on conversion — add when production signal warrants + +## Test plan +- [x] `pnpm run ts-check` clean +- [x] Backend tests: sanitizer, walker, mammoth wrapper, DOCX + PDF integration, ODT negative, end-to-end DOCX import +- [x] Manual: with `SOFFICE=null`, export DOCX and PDF; both produce valid files +- [x] Manual: with `SOFFICE=null`, import the fixture .docx and verify pad content + +Closes #7538 +EOF +)" +``` + +Expected: description updated. + +--- + +## Self-Review + +- **Spec coverage:** + - Selection model (soffice-first cascade) → Task 5 + - Route guard fix → Task 6 + - UI capability fix → Task 8 + - Native PDF (Approach B + bail-out) → Task 3 + - HTML sanitization → Task 2 + - Native DOCX import → Tasks 4, 7 + - Error handling (5xx, no fallback) → Task 5 (try/catch) + - Tests for all of the above → Tasks 2, 3, 4, 5, 6, 7 + - Files-touched table → covered by Tasks 1, 2, 3, 4, 5, 6, 7, 8, 9 + - `nativeDocxExport` removal → Task 9 + - Rebase → Task 0 + - Qodo replies + ready-for-review → Task 10 + - **Gap addressed inline:** `exportHTMLSend` plugin hook coverage on native paths — the spec says "verify against current behavior, don't expand scope". Task 5's cascade preserves the existing `if (type === 'html') { exportHTMLSend ... }` block at lines 82–88 untouched, so plugin behavior on the html branch is identical. Native DOCX/PDF do not invoke `exportHTMLSend` — same as the pre-PR LibreOffice path, which also doesn't call it. No change needed; this is a non-regression. + +- **Placeholder scan:** No "TBD" / "TODO" / "implement later" / "fill in details" strings. All test assertions are concrete; all code blocks are complete. + +- **Type consistency:** `htmlToPdfBuffer(html: string): Promise` referenced in Tasks 3, 5; `docxBufferToHtml(buf: Buffer): Promise` in Tasks 4, 7; `stripRemoteImages(html: string): string` in Tasks 2, 5. All match the spec. + +- **Bail-out criterion** (Task 3 Step 7) is concrete: line count threshold (>500) and a behavior threshold (test that fails because the walker can't render a class of content). Implementer has a clear stop signal. diff --git a/docs/superpowers/plans/2026-05-09-admin-settings-parsed-view.md b/docs/superpowers/plans/2026-05-09-admin-settings-parsed-view.md new file mode 100644 index 00000000000..26cad15f260 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-admin-settings-parsed-view.md @@ -0,0 +1,1353 @@ +# Admin /settings parsed view — 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:** Replace the single textarea on `/admin/settings` with a parsed JSONC tree of typed widgets. Each key shows its leading `/* */` or `//` comment as inline help. Saves round-trip through `jsonc-parser`'s `modify()` so untouched bytes — comments, key order, `${ENV:default}` placeholders — survive intact. A "Raw" mode preserves the existing textarea as a fallback. + +**Architecture:** Client-side only. The store holds the raw file text (single source of truth). Form view re-parses the text on each render via `jsonc-parser.parseTree`. Widget edits call `modify(text, path, value)` + `applyEdits` and write the result back to the store. Save sends the resulting text to the server through the existing `settingsSocket`. Server contract is unchanged. + +**Tech Stack:** React 19, Zustand, react-i18next, `jsonc-parser` (new dep), Playwright for verification. + +**Branch:** `takeover/7666-admin-settings-editor` on `johnmclear/etherpad-lite` (PR #7709). Rebase before starting if upstream `develop` has moved. + +**Reference spec:** `docs/superpowers/specs/2026-05-09-admin-settings-parsed-view-design.md`. + +--- + +## File structure + +Create: + +- `admin/src/components/settings/jsoncEdit.ts` — thin wrapper around `jsonc-parser.modify` + `applyEdits`. +- `admin/src/components/settings/comments.ts` — pure helpers: extract leading + trailing comment text for a given AST node from the source string. +- `admin/src/components/settings/envPill.ts` — detect `${VAR:default}` literals from a string node's raw slice. +- `admin/src/components/settings/CommentLabel.tsx` — renders leading comment as muted help text under a key. +- `admin/src/components/settings/ParseErrorBanner.tsx` — renders parse-error notice + "Switch to raw" button. +- `admin/src/components/settings/widgets/StringInput.tsx` +- `admin/src/components/settings/widgets/NumberInput.tsx` +- `admin/src/components/settings/widgets/BooleanToggle.tsx` +- `admin/src/components/settings/widgets/NullChip.tsx` +- `admin/src/components/settings/widgets/EnvPill.tsx` +- `admin/src/components/settings/widgets/ObjectGroup.tsx` +- `admin/src/components/settings/widgets/ArrayGroup.tsx` +- `admin/src/components/settings/JsoncNode.tsx` — dispatches a node to the right widget. +- `admin/src/components/settings/FormView.tsx` — top-level form: parse text, render tree, surface ParseErrorBanner. +- `admin/src/components/settings/ModeToggle.tsx` — segmented control: Form | Raw. + +Modify: + +- `admin/package.json` — add `jsonc-parser`. +- `admin/src/pages/SettingsPage.tsx` — restructure into ModeToggle + FormView/RawView shell. +- `admin/src/App.css` — append styles for tree, group, pill, banner, mode-toggle. +- `src/locales/en.json` — new i18n keys. +- `src/tests/frontend-new/admin-spec/adminsettings.spec.ts` — new Playwright specs (the file already exists with the regression specs from the previous commit). + +--- + +## Task 1: Add `jsonc-parser` dependency and scaffold the directory + +**Files:** +- Modify: `admin/package.json` +- Create: `admin/src/components/settings/.gitkeep` (ensures the directory exists; remove once a real file lands) + +- [ ] **Step 1: Install `jsonc-parser` in `admin/`** + +```bash +cd admin && pnpm add jsonc-parser@^3.3.1 +``` + +Expected output: `+ jsonc-parser 3.3.1`. `package.json` and the lockfile both update. + +- [ ] **Step 2: Sanity-check that the import resolves** + +```bash +cd admin && node -e "import('jsonc-parser').then(m => console.log(Object.keys(m).sort().slice(0,8)))" +``` + +Expected output includes: `applyEdits`, `findNodeAtLocation`, `getNodePath`, `modify`, `parse`, `parseTree`. + +- [ ] **Step 3: Commit** + +```bash +git add admin/package.json ../pnpm-lock.yaml +git commit -m "admin(settings): add jsonc-parser dep" +``` + +--- + +## Task 2: Implement `envPill.ts` and `comments.ts` helpers + +These are pure functions. No unit-test runner is configured in `admin/`, so we exercise them indirectly via Playwright. Keep them strictly testable: pure functions, no React. + +**Files:** +- Create: `admin/src/components/settings/envPill.ts` +- Create: `admin/src/components/settings/comments.ts` + +- [ ] **Step 1: Write `envPill.ts`** + +```ts +// admin/src/components/settings/envPill.ts +// +// Detect `"${VAR}"` and `"${VAR:default}"` placeholders inside the raw +// slice of a string node. The slice INCLUDES the surrounding quotes, +// because jsonc-parser exposes node.offset/length over the whole literal. + +export type EnvPlaceholder = { + variable: string; + defaultValue: string | null; +}; + +const RE = /^"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?\}"$/; + +export const matchEnvPlaceholder = (rawSlice: string): EnvPlaceholder | null => { + const m = RE.exec(rawSlice); + if (!m) return null; + return { + variable: m[1], + defaultValue: m[2] ?? null, + }; +}; +``` + +- [ ] **Step 2: Write `comments.ts`** + +```ts +// admin/src/components/settings/comments.ts +// +// Given the source text and a property's `keyOffset` (jsonc-parser's +// Node.offset for the property node), extract: +// - `leading`: the contiguous run of `/* */` or `//` comments +// immediately above the key. At most one blank line is allowed +// between the comment block and the key. +// - `trailing`: a single `// ...` or `/* ... */` on the same line +// as the value, after any trailing comma. + +export type AdjacentComments = { + leading: string; + trailing: string; +}; + +const LINE_BREAK = /\r?\n/; + +const stripCommentMarkers = (raw: string): string => { + // raw is a concatenation of comment tokens separated by newlines. + // Drop /* */ and // markers and trim each line. + return raw + .split(LINE_BREAK) + .map(line => line + .replace(/^\s*\/\*+/, '') + .replace(/\*+\/\s*$/, '') + .replace(/^\s*\*\s?/, '') + .replace(/^\s*\/\/\s?/, '') + .trim()) + .filter(line => line.length > 0) + .join(' '); +}; + +const findLeading = (text: string, keyOffset: number): string => { + // Walk backwards from keyOffset to the start of the line containing it. + const lineStart = text.lastIndexOf('\n', keyOffset - 1) + 1; + let cursor = lineStart; + let blankLineSeen = false; + const collected: string[] = []; + + while (cursor > 0) { + // Look at the previous line. + const prevLineEnd = cursor - 1; // the '\n' before our cursor's line + const prevLineStart = text.lastIndexOf('\n', prevLineEnd - 1) + 1; + const line = text.slice(prevLineStart, prevLineEnd); + const trimmed = line.trim(); + + if (trimmed === '') { + if (blankLineSeen) break; + blankLineSeen = true; + cursor = prevLineStart; + continue; + } + + const isComment = + trimmed.startsWith('//') || + trimmed.startsWith('/*') || + trimmed.startsWith('*') || + trimmed.endsWith('*/'); + + if (!isComment) break; + + collected.unshift(line); + cursor = prevLineStart; + } + + return stripCommentMarkers(collected.join('\n')); +}; + +const findTrailing = (text: string, valueOffset: number, valueLength: number): string => { + // Look from end-of-value to end-of-line for a single comment. + const lineEnd = text.indexOf('\n', valueOffset + valueLength); + const slice = text.slice(valueOffset + valueLength, lineEnd === -1 ? text.length : lineEnd); + const m = /,?\s*(\/\/.*|\/\*.*?\*\/)\s*$/.exec(slice); + return m ? stripCommentMarkers(m[1]) : ''; +}; + +export const extractAdjacentComments = ( + text: string, + keyOffset: number, + valueOffset: number, + valueLength: number, +): AdjacentComments => ({ + leading: findLeading(text, keyOffset), + trailing: findTrailing(text, valueOffset, valueLength), +}); +``` + +- [ ] **Step 3: Type-check** + +```bash +cd admin && npx tsc --noEmit +``` + +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add admin/src/components/settings/envPill.ts admin/src/components/settings/comments.ts +git commit -m "admin(settings): pure helpers for env pills and comment extraction" +``` + +--- + +## Task 3: Implement `jsoncEdit.ts` save helper + +**Files:** +- Create: `admin/src/components/settings/jsoncEdit.ts` + +- [ ] **Step 1: Write the wrapper** + +```ts +// admin/src/components/settings/jsoncEdit.ts +import { applyEdits, modify, type JSONPath } from 'jsonc-parser'; + +const FORMATTING = { + formattingOptions: { tabSize: 2, insertSpaces: true, eol: '\n' as const }, +}; + +export const editJsonc = (text: string, path: JSONPath, value: unknown): string => { + const edits = modify(text, path, value, FORMATTING); + return edits.length === 0 ? text : applyEdits(text, edits); +}; +``` + +- [ ] **Step 2: Type-check** + +```bash +cd admin && npx tsc --noEmit +``` + +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add admin/src/components/settings/jsoncEdit.ts +git commit -m "admin(settings): editJsonc wrapper around jsonc-parser modify" +``` + +--- + +## Task 4: Build leaf widgets + +Each leaf takes `{ value, path, onChange }`. `onChange(newValue)` is wired by the parent (`JsoncNode`) to call `editJsonc` and push the new text into the store. Leaves are presentational only. + +**Files:** +- Create: `admin/src/components/settings/widgets/StringInput.tsx` +- Create: `admin/src/components/settings/widgets/NumberInput.tsx` +- Create: `admin/src/components/settings/widgets/BooleanToggle.tsx` +- Create: `admin/src/components/settings/widgets/NullChip.tsx` +- Create: `admin/src/components/settings/widgets/EnvPill.tsx` + +- [ ] **Step 1: `widgets/StringInput.tsx`** + +```tsx +import type { JSONPath } from 'jsonc-parser'; + +type Props = { + value: string; + path: JSONPath; + onChange: (next: string) => void; +}; + +export const StringInput = ({ value, path, onChange }: Props) => ( + onChange(e.target.value)} + /> +); +``` + +- [ ] **Step 2: `widgets/NumberInput.tsx`** + +Bad numeric input must not corrupt the file text. We hold the raw input string in local state and only call `onChange` when it parses to a finite number. + +```tsx +import { useState } from 'react'; +import type { JSONPath } from 'jsonc-parser'; + +type Props = { + value: number; + path: JSONPath; + onChange: (next: number) => void; +}; + +export const NumberInput = ({ value, path, onChange }: Props) => { + const [draft, setDraft] = useState(String(value)); + const [invalid, setInvalid] = useState(false); + return ( + { + const next = e.target.value; + setDraft(next); + const parsed = Number(next); + if (next.trim() !== '' && Number.isFinite(parsed)) { + setInvalid(false); + onChange(parsed); + } else { + setInvalid(true); + } + }} + /> + ); +}; +``` + +- [ ] **Step 3: `widgets/BooleanToggle.tsx`** + +The repo already uses `@radix-ui/react-switch`. Use it. + +```tsx +import * as Switch from '@radix-ui/react-switch'; +import type { JSONPath } from 'jsonc-parser'; + +type Props = { + value: boolean; + path: JSONPath; + onChange: (next: boolean) => void; +}; + +export const BooleanToggle = ({ value, path, onChange }: Props) => ( + + + +); +``` + +- [ ] **Step 4: `widgets/NullChip.tsx`** + +```tsx +import type { JSONPath } from 'jsonc-parser'; + +type Props = { path: JSONPath }; + +export const NullChip = ({ path }: Props) => ( + null +); +``` + +- [ ] **Step 5: `widgets/EnvPill.tsx`** + +```tsx +import { useTranslation } from 'react-i18next'; +import type { JSONPath } from 'jsonc-parser'; +import type { EnvPlaceholder } from '../envPill'; + +type Props = { + placeholder: EnvPlaceholder; + path: JSONPath; +}; + +export const EnvPill = ({ placeholder, path }: Props) => { + const { t } = useTranslation(); + return ( + + + {placeholder.variable} + {placeholder.defaultValue !== null && ( + + {' '}default: {placeholder.defaultValue} + + )} + + ); +}; +``` + +- [ ] **Step 6: Type-check** + +```bash +cd admin && npx tsc --noEmit +``` + +Expected: exit 0. + +- [ ] **Step 7: Commit** + +```bash +git add admin/src/components/settings/widgets +git commit -m "admin(settings): leaf widgets (string, number, bool, null, env pill)" +``` + +--- + +## Task 5: Build group widgets and the dispatcher + +`ObjectGroup` and `ArrayGroup` use the native `
        `/`` for collapsibility (a11y comes for free). `JsoncNode` is the dispatcher: given an AST node it picks the right widget. + +**Files:** +- Create: `admin/src/components/settings/widgets/ObjectGroup.tsx` +- Create: `admin/src/components/settings/widgets/ArrayGroup.tsx` +- Create: `admin/src/components/settings/JsoncNode.tsx` +- Create: `admin/src/components/settings/CommentLabel.tsx` + +- [ ] **Step 1: `CommentLabel.tsx`** + +```tsx +type Props = { + leading: string; + trailing: string; + htmlId: string; +}; + +export const CommentLabel = ({ leading, trailing, htmlId }: Props) => { + if (!leading && !trailing) return null; + return ( +
        + {leading && {leading}} + {trailing && // {trailing}} +
        + ); +}; +``` + +- [ ] **Step 2: `widgets/ObjectGroup.tsx`** + +```tsx +import type { ReactNode } from 'react'; +import type { JSONPath } from 'jsonc-parser'; + +type Props = { + path: JSONPath; + childCount: number; + children: ReactNode; +}; + +export const ObjectGroup = ({ path, childCount, children }: Props) => ( +
        + {`{ ${childCount} ${childCount === 1 ? 'key' : 'keys'} }`} +
        {children}
        +
        +); +``` + +- [ ] **Step 3: `widgets/ArrayGroup.tsx`** + +```tsx +import type { ReactNode } from 'react'; +import type { JSONPath } from 'jsonc-parser'; + +type Props = { + path: JSONPath; + childCount: number; + children: ReactNode; +}; + +export const ArrayGroup = ({ path, childCount, children }: Props) => ( +
        + {`[ ${childCount} ${childCount === 1 ? 'item' : 'items'} ]`} +
        {children}
        +
        +); +``` + +- [ ] **Step 4: `JsoncNode.tsx`** + +This is the only place that decides which widget renders. It receives a `Node` (from `parseTree`), the source text (so it can pull raw slices for env detection and comments), and an `onEdit` callback. + +```tsx +import type { JSONPath, Node } from 'jsonc-parser'; +import { getNodePath } from 'jsonc-parser'; +import { CommentLabel } from './CommentLabel'; +import { extractAdjacentComments } from './comments'; +import { matchEnvPlaceholder } from './envPill'; +import { StringInput } from './widgets/StringInput'; +import { NumberInput } from './widgets/NumberInput'; +import { BooleanToggle } from './widgets/BooleanToggle'; +import { NullChip } from './widgets/NullChip'; +import { EnvPill } from './widgets/EnvPill'; +import { ObjectGroup } from './widgets/ObjectGroup'; +import { ArrayGroup } from './widgets/ArrayGroup'; + +type Props = { + /** The value node (not the property node). */ + node: Node; + /** The property node, when this value is the value-side of `"key": value`. */ + property?: Node; + text: string; + onEdit: (path: JSONPath, value: unknown) => void; +}; + +export const JsoncNode = ({ node, property, text, onEdit }: Props) => { + const path = getNodePath(node); + + // Comment lookup is based on the property node when present (object child), + // otherwise the value node directly (array element / root). + const anchor = property ?? node; + const { leading, trailing } = extractAdjacentComments( + text, + anchor.offset, + node.offset, + node.length, + ); + const commentId = `settings-comment-${path.join('.') || 'root'}`; + + const wrap = (label: React.ReactNode, control: React.ReactNode) => ( +
        + {label && {label}} + {control} + +
        + ); + + // Property name for object children: + const keyLabel = + property?.type === 'property' && property.children?.[0]?.type === 'string' + ? String(property.children[0].value) + : null; + + if (node.type === 'object') { + return wrap( + keyLabel, + + {(node.children ?? []).map((prop, i) => { + const valueNode = prop.children?.[1]; + if (!valueNode) return null; + return ( + + ); + })} + , + ); + } + + if (node.type === 'array') { + return wrap( + keyLabel, + + {(node.children ?? []).map((child, i) => ( + + ))} + , + ); + } + + if (node.type === 'string') { + const raw = text.slice(node.offset, node.offset + node.length); + const env = matchEnvPlaceholder(raw); + if (env) return wrap(keyLabel, ); + return wrap( + keyLabel, + onEdit(path, v)} + />, + ); + } + + if (node.type === 'number') { + return wrap( + keyLabel, + onEdit(path, v)} + />, + ); + } + + if (node.type === 'boolean') { + return wrap( + keyLabel, + onEdit(path, v)} + />, + ); + } + + if (node.type === 'null') { + return wrap(keyLabel, ); + } + + // 'property' nodes are handled by their parent object branch above. + return null; +}; +``` + +- [ ] **Step 5: Type-check** + +```bash +cd admin && npx tsc --noEmit +``` + +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add admin/src/components/settings +git commit -m "admin(settings): group widgets, JsoncNode dispatcher, CommentLabel" +``` + +--- + +## Task 6: Build `FormView`, `ParseErrorBanner`, `ModeToggle` + +**Files:** +- Create: `admin/src/components/settings/FormView.tsx` +- Create: `admin/src/components/settings/ParseErrorBanner.tsx` +- Create: `admin/src/components/settings/ModeToggle.tsx` + +- [ ] **Step 1: `ParseErrorBanner.tsx`** + +```tsx +import { Trans } from 'react-i18next'; + +type Props = { + message: string; + onSwitchToRaw: () => void; +}; + +export const ParseErrorBanner = ({ message, onSwitchToRaw }: Props) => ( +
        + +
        {message}
        + +
        +); +``` + +- [ ] **Step 2: `FormView.tsx`** + +```tsx +import { parseTree, type JSONPath, type ParseError } from 'jsonc-parser'; +import { useStore } from '../../store/store'; +import { editJsonc } from './jsoncEdit'; +import { JsoncNode } from './JsoncNode'; +import { ParseErrorBanner } from './ParseErrorBanner'; + +type Props = { + onSwitchToRaw: () => void; +}; + +const formatErrors = (errors: ParseError[]): string => + errors.length === 0 + ? '' + : errors.map(e => `offset ${e.offset}: ${ParseErrorMessage[e.error] ?? 'parse error'}`).join('\n'); + +const ParseErrorMessage: Record = { + 1: 'Invalid symbol', + 2: 'Invalid number format', + 3: 'Property name expected', + 4: 'Value expected', + 5: 'Colon expected', + 6: 'Comma expected', + 7: 'Closing brace expected', + 8: 'Closing bracket expected', + 9: 'End of file expected', + 16: 'Unexpected end of comment', + 17: 'Unexpected end of string', + 18: 'Unexpected end of number', + 19: 'Invalid unicode', + 20: 'Invalid escape character', + 21: 'Invalid character', +}; + +export const FormView = ({ onSwitchToRaw }: Props) => { + const text = useStore(s => s.settings) ?? ''; + + const errors: ParseError[] = []; + const tree = parseTree(text, errors, { allowTrailingComma: true }); + + const onEdit = (path: JSONPath, value: unknown) => { + useStore.getState().setSettings(editJsonc(text, path, value)); + }; + + if (!tree || errors.length > 0) { + return ; + } + + return ( +
        + +
        + ); +}; +``` + +- [ ] **Step 3: `ModeToggle.tsx`** + +```tsx +import { Trans } from 'react-i18next'; + +export type Mode = 'form' | 'raw'; + +type Props = { + mode: Mode; + onChange: (mode: Mode) => void; +}; + +export const ModeToggle = ({ mode, onChange }: Props) => ( +
        + + +
        +); +``` + +- [ ] **Step 4: Type-check** + +```bash +cd admin && npx tsc --noEmit +``` + +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add admin/src/components/settings +git commit -m "admin(settings): FormView, ParseErrorBanner, ModeToggle" +``` + +--- + +## Task 7: Restructure `SettingsPage.tsx` + +The page becomes a shell that toggles between `FormView` and the existing raw textarea. Save / Validate / Restart and the Prettify feature flag stay where they were in the previous commit. + +**Files:** +- Modify: `admin/src/pages/SettingsPage.tsx` + +- [ ] **Step 1: Replace the file** + +```tsx +import React, { useState } from 'react'; +import { useStore } from '../store/store'; +import { isJSONClean, cleanComments } from '../utils/utils'; +import { Trans, useTranslation } from 'react-i18next'; +import { IconButton } from '../components/IconButton'; +import { RotateCw, Save, AlignLeft, ShieldCheck } from 'lucide-react'; +import { FormView } from '../components/settings/FormView'; +import { ModeToggle, type Mode } from '../components/settings/ModeToggle'; + +const TAB_INDENT = ' '; + +export const SettingsPage = () => { + const { t } = useTranslation(); + const settingsSocket = useStore(state => state.settingsSocket); + const settings = useStore(state => state.settings) ?? ''; + + const [mode, setMode] = useState('form'); + const [exposeExperimental] = useState(false); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== 'Tab') return; + e.preventDefault(); + const target = e.currentTarget; + const { selectionStart, selectionEnd, value } = target; + const next = value.substring(0, selectionStart) + TAB_INDENT + value.substring(selectionEnd); + useStore.getState().setSettings(next); + requestAnimationFrame(() => { + target.selectionStart = target.selectionEnd = selectionStart + TAB_INDENT.length; + }); + }; + + const showToast = (titleKey: string, success: boolean) => { + useStore.getState().setToastState({ open: true, title: t(titleKey), success }); + }; + + const testJSON = () => { + if (isJSONClean(settings)) showToast('admin_settings.toast.validation_ok', true); + else showToast('admin_settings.toast.validation_failed', false); + }; + + const prettifyJSON = () => { + try { + const obj = JSON.parse(cleanComments(settings) ?? ''); + if (window.confirm(t('admin_settings.prettify_confirm'))) { + useStore.getState().setSettings(JSON.stringify(obj, null, 2)); + } + } catch { + showToast('admin_settings.toast.prettify_failed', false); + } + }; + + const handleSave = () => { + if (!isJSONClean(settings)) return showToast('admin_settings.toast.json_invalid', false); + if (!settingsSocket?.connected) return showToast('admin_settings.toast.disconnected', false); + settingsSocket.emit('saveSettings', settings); + showToast('admin_settings.toast.saved', true); + }; + + return ( +
        +

        + + + + {mode === 'form' + ? setMode('raw')} /> + : ( +