etherpad-lite/docs/superpowers/specs/2026-05-15-issue-7524-swagger-ui-telemetry-design.md
John McLear 278acb10cb
Drop swagger-ui, document telemetry, add opt-outs (#7524) (#7757)
* docs: design spec for #7524 drop swagger-ui + privacy opt-outs

Three-deliverable plan: vendor RapiDoc to replace swagger-ui-express
(Scarf-injecting), add privacy.updateCheck and privacy.pluginCatalog
opt-outs for our two outbound calls, and ship PRIVACY.md as a public
stance doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: implementation plan for #7524 swagger-ui + privacy opt-outs

Twelve TDD-flavoured tasks: privacy settings shape, UpdateCheck +
installer opt-outs (each with a failing-test-first cycle), admin
backend/UI plumbing, dependency drop, vendored RapiDoc, PRIVACY.md,
final verification matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(privacy): add privacy block to settings shape

Adds privacy.updateCheck and privacy.pluginCatalog, both defaulting to
true so behavior is unchanged until operators opt out.

Refs #7524

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(privacy): honour privacy.updateCheck=false in UpdateCheck

check() and getLatestVersion() now early-return when the setting is
off. Logs once on first skip. The admin "update available" panel
already tolerates an undefined latestVersion.

Refs #7524

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(privacy): honour privacy.pluginCatalog=false in installer

Extracts the gate into pluginCatalogGuard.ts so it can be unit-tested
under vitest without dragging in the CJS require() chain from
installer.ts. getAvailablePlugins() now throws the tagged disabled
error before any fetch.

Refs #7524

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(privacy): emit results:catalogDisabled when pluginCatalog off

Short-circuits the four catalog-driven socket events. The install/
uninstall events are untouched so operators can still install by
plugin name even when the catalog is disabled.

Refs #7524

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bin): stalePlugins reads updateServer and honours privacy flag

Was hardcoding static.etherpad.org and ignoring opt-out. Now exits 0
cleanly when privacy.pluginCatalog=false.

Refs #7524

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(settings): document privacy block in settings template

Refs #7524

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api-docs): replace swagger-ui-express with RapiDoc shell

Drops the swagger-ui-express dep (third-party Scarf telemetry pixel,
see swagger-api/swagger-ui#10573) and serves /api-docs with a static
HTML shell that mounts <rapi-doc>. /api-docs.json is unchanged.

The vendored RapiDoc asset is added in the next commit so the tree is
broken for one diff hunk — pair this with the rapidoc-min.js commit
during review.

Refs #7524

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api-docs): vendor RapiDoc 9.3.4 (MIT) as static asset

Pinned bundle with checksum in VERSION. Replaces swagger-ui-dist which
shipped a Scarf telemetry pixel.

Disables RapiDoc's bundled Google Fonts request via load-fonts="false"
plus explicit regular-font/mono-font system stacks — RapiDoc's CSS
@font-face rules would otherwise fetch Open Sans from fonts.gstatic.com
at render time.

Also fixes the /api-docs route's res.sendFile to use an absolute path
resolved via settings.root (the previous {root: 'src/static'} was
resolved from CWD which is already src/, producing src/src/static).

Refs #7524

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(admin): banner when plugin catalog is disabled

Subscribes to results:catalogDisabled and renders a localized info
banner on the plugins page. install/uninstall still function via CLI.

Refs #7524

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: PRIVACY.md and README/CHANGELOG pointers

Publishes Etherpad's stance on telemetry: two documented, opt-out
outbound calls; no third-party analytics; no install-time phone-homes
in our deps.

Refs #7524

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): await checkPluginForUpdates and emit array on error

Qodo flagged that checkUpdates emitted the unresolved Promise (missing
await) and emitted {} for updatable on the error path, both breaking
the admin UI's expected string[] shape. Pre-existing bug surfaced when
the surrounding block was edited for the privacy.pluginCatalog gate.

Refs #7524

* feat(api-docs): swap RapiDoc for Scalar (actively maintained)

Per @SamTV12345's review on #7757: RapiDoc has been effectively
unmaintained for a while. Scalar (https://github.com/scalar/scalar)
is MIT-licensed, actively developed, and ships a self-contained
standalone bundle that works the same way for our purposes.

Privacy posture is preserved by configuring the embed:
  - withDefaultFonts: false   (no fonts.scalar.com woff2 fetch)
  - telemetry: false          (defensive)
  - agent.disabled: true      (no api.scalar.com/vector/* calls)
  - mcp.disabled:   true      (no MCP integration)
  - showDeveloperTools: 'never'
  - hideClientButton: true

Verified with headless Chromium: page loads /api-docs, mounts Scalar,
renders the Etherpad OpenAPI document, and makes zero requests to
any host other than localhost.

Vendor:
  - src/static/vendor/scalar/standalone.js     (@scalar/api-reference 1.57.2)
  - src/static/vendor/scalar/VERSION           (sha256 pinned)
  - src/static/vendor/scalar/LICENSE           (MIT)
Removed:
  - src/static/vendor/rapidoc/*

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:34:22 +01:00

246 lines
9.9 KiB
Markdown

# Issue #7524 — Drop swagger-ui, take a telemetry stance, opt out our phone-homes
**Status:** approved 2026-05-15
**Closes:** [ether/etherpad#7524](https://github.com/ether/etherpad/issues/7524)
**Author:** John McLear (design brainstormed with Claude)
**Branch:** `feature/7524-drop-swagger-ui-telemetry` off `develop`
## Goal
Eliminate the only known third-party telemetry vector in Etherpad's runtime
dependency tree (swagger-ui's Scarf pixel) and give operators explicit,
documented opt-outs for the two outbound calls Etherpad itself makes.
## Background
Etherpad currently ships `swagger-ui-express ^5.0.1` to render the OpenAPI
spec at `/api-docs`. Upstream's npm distribution injects a Scarf analytics
pixel that cannot be disabled at install or runtime (see
[swagger-api/swagger-ui#10573](https://github.com/swagger-api/swagger-ui/issues/10573)).
Etherpad itself makes two outbound calls:
1. `src/node/utils/UpdateCheck.ts` — hourly `GET ${updateServer}/info.json`
for the admin "update available" notice. No opt-out today.
2. `src/static/js/pluginfw/installer.ts:179``GET ${updateServer}/plugins.json`
on admin-plugins page load (10 min cache). No opt-out today.
Both share the `updateServer` setting (default `https://static.etherpad.org`).
There is no public document stating Etherpad's position on telemetry.
## Out of scope
- Replacing `static.etherpad.org` itself or hosting a mirror.
- Auditing telemetry beyond the two known endpoints.
- Changing `/api-docs.json` (spec endpoint unchanged).
- The admin OpenAPI editor effort (issue #7693, separate PR).
## Deliverables
Three deliverables ship together in one PR closing #7524:
### 1. Replace `swagger-ui-express` with vendored RapiDoc
**Removed:**
- `swagger-ui-express ^5.0.1` from `src/package.json` dependencies.
- `@types/swagger-ui-express ^4.1.8` from `src/package.json` devDependencies.
- `import {serve, setup} from 'swagger-ui-express'` at `src/node/handler/RestAPI.ts:8`.
- The three-line route block at `src/node/handler/RestAPI.ts:1440-1445`
(`app.use('/api-docs', serve)` + `app.get('/api-docs', setup(...))`).
**Added:**
- `src/static/vendor/rapidoc/rapidoc-min.js` — vendored from
`https://unpkg.com/rapidoc@9.3.x/dist/rapidoc-min.js` (MIT, ~370KB),
committed as a static asset. Pinned exact version recorded in
`src/static/vendor/rapidoc/VERSION`. No CDN fetch at runtime.
- `src/static/api-docs.html` — minimal HTML shell:
```html
<!doctype html><html><head><title>Etherpad API</title>
<script type="module" src="/static/vendor/rapidoc/rapidoc-min.js"></script>
</head><body>
<rapi-doc spec-url="/api-docs.json" theme="light" render-style="read"
show-header="false" allow-server-selection="false"></rapi-doc>
</body></html>
```
- Route registration that serves `/api-docs` → `api-docs.html` and the
static asset under `/static/vendor/rapidoc/`. The simplest path is to
drop the file under `src/static/` and let the existing static-file
middleware pick it up; if that needs an explicit route, add it next to
the `/api-docs.json` handler in `RestAPI.ts`.
**Kept (unchanged):**
- `/api-docs.json` route at `RestAPI.ts:1449-1453`.
- `src/node/types/SwaggerUIResource.ts` (TypeScript type used by `openapi.ts`,
no runtime swagger-ui dependency).
- The unrelated swagger comment at `openapi.ts:810`.
**Verification of clean vendoring:**
Before committing the vendored file, grep it for:
`fetch(`, `XMLHttpRequest`, `sendBeacon`, `scarf`, `googletag`, `analytics`,
`navigator.connection`. Any hit must be reviewed and confirmed as either
same-origin spec loading (legit) or removed. Outcome is documented in the
PR description.
### 2. Privacy opt-out config
**`src/node/utils/Settings.ts` — new block** alongside the existing
`privacyBanner`:
```ts
privacy: {
updateCheck: boolean, // default true
pluginCatalog: boolean, // default true
},
```
Defaults are `true` so behavior is identical to today; operators flip to
`false` to silence each call. Both default-true keeps the change
non-breaking for existing installs.
**`src/node/utils/UpdateCheck.ts`:**
- `check()` — early-return when `settings.privacy.updateCheck === false`,
logging once: `Update check disabled by privacy.updateCheck=false`.
No fetch, no scheduled retry.
- `getLatestVersion()` — returns `undefined` when disabled. The existing
caller at `src/node/hooks/express/adminsettings.ts:105` already tolerates
undefined; the admin panel simply omits the "update available" line.
**`src/static/js/pluginfw/installer.ts`:**
- `getAvailablePlugins()` — early-throw when
`settings.privacy.pluginCatalog === false` with a tagged error:
`Error('Plugin catalog disabled (privacy.pluginCatalog=false)')`.
- Admin consumer at `src/node/hooks/express/adminplugins.ts` catches this
specific error and renders a fallback panel: "Plugin catalog is disabled.
Enter a plugin name to install manually." with a free-text install input.
`install(pluginName)` itself remains functional — only browsing is gated.
**`bin/plugins/stalePlugins.ts`:**
- Currently hardcodes `https://static.etherpad.org/plugins.full.json`.
Rewrite to read `settings.updateServer` and respect
`settings.privacy.pluginCatalog`. When disabled, log and exit 0 (dev
tool; failing isn't useful).
**`settings.json.template`:**
Add the `privacy` block with comments pointing readers to `PRIVACY.md`.
### 3. `PRIVACY.md` + README link
New `PRIVACY.md` at the repo root, factual and short:
```
# Privacy in Etherpad
## What this document is
A complete, current list of every network call Etherpad's own code
makes to a third party, plus how to turn each one off.
## TL;DR
Etherpad ships with two outbound calls to etherpad.org. Both are
documented below. Both can be disabled with a single config value
each. No analytics, no usage pings, no third-party SDKs at runtime.
## Outbound calls
### 1. Version check
- URL: https://static.etherpad.org/info.json (or `updateServer`)
- Frequency: hourly while the server runs
- Payload: GET only; User-Agent header carries "Etherpad/<version>"
- Purpose: surface a "update available" notice in the admin panel
- Disable: set `privacy.updateCheck: false` in settings.json
- Source: src/node/utils/UpdateCheck.ts
### 2. Plugin catalog
- URL: https://static.etherpad.org/plugins.json (or `updateServer`)
- Frequency: on admin-plugins page load (cached 10 min)
- Payload: GET only; same User-Agent
- Purpose: list installable ep_* plugins in the admin UI
- Disable: set `privacy.pluginCatalog: false` in settings.json
(manual install-by-name still works)
- Source: src/static/js/pluginfw/installer.ts
## What we removed
swagger-ui-express was dropped in <PR #> because the upstream package
injects a Scarf analytics pixel that cannot be disabled at build or
runtime. /api-docs is now served by a vendored copy of RapiDoc (MIT)
with no outbound calls.
## What we will not add
- usage analytics or telemetry SDKs
- crash reporters that send data without explicit opt-in
- third-party CDN dependencies at runtime
- dependencies whose install or runtime phones home
## Plugins
Third-party plugins are out of this guarantee. Plugins run in your
Etherpad process with full access; audit any plugin you install.
## Reporting
Found an outbound call this doc doesn't list? Open an issue with the
label `privacy`.
```
**`README.md`** — one line near the top under the existing intro:
> Privacy: Etherpad makes two opt-out network calls and ships no third-party
> telemetry. See [PRIVACY.md](PRIVACY.md).
**`CHANGELOG.md`** (or release notes file) — single entry under the new
release section:
> **Privacy:** Dropped `swagger-ui-express` (third-party telemetry);
> `/api-docs` now served by vendored RapiDoc. Added `privacy.updateCheck`
> and `privacy.pluginCatalog` opt-outs. See `PRIVACY.md`.
## Testing
**Backend (vitest, required per project memory):**
- `Settings.test.ts` — `privacy.updateCheck` and `privacy.pluginCatalog`
default to `true` when not set in `settings.json`.
- New `UpdateCheck.test.ts` — `check()` makes no fetch when
`privacy.updateCheck === false` (fetch mocked, assert no call).
- New `installer.test.ts` — `getAvailablePlugins()` throws the tagged
disabled error when `privacy.pluginCatalog === false`.
**Manual smoke (pre-merge, on port 9003):**
1. Start dev server, open `/api-docs` — confirm RapiDoc renders the spec
and DevTools Network tab shows zero third-party hosts.
2. Set `privacy.updateCheck: false`, restart — confirm no request to
`static.etherpad.org/info.json` and admin "update available" line is
absent.
3. Set `privacy.pluginCatalog: false`, open admin plugins — confirm the
manual install-by-name fallback renders; `ep_align` installs by name.
**Existing e2e:**
Run admin-page Playwright suites; any test that depended on swagger-ui's
specific DOM needs updating to RapiDoc selectors or removing.
**Dependency hygiene:**
- `pnpm install` clean.
- `grep -ri "swagger" src/ --exclude-dir=node_modules` returns only the
unrelated comment at `openapi.ts:810` and the kept `SwaggerUIResource.ts`
type.
- `grep -E "fetch\(|XMLHttpRequest|sendBeacon|scarf|google" src/static/vendor/rapidoc/rapidoc-min.js`
reviewed; results documented in PR description.
## Rollout
- Branch: `feature/7524-drop-swagger-ui-telemetry` off `develop`.
- Single PR closing #7524.
- After push, wait ~20s, run `gh pr checks`. Fix CI failures inline before
moving on.
- Action all Qodo review comments inline.
## Rollback
All changes are either additive (the `privacy` block, both defaults `true`)
or one-for-one (`swagger-ui-express` → vendored RapiDoc, same URL surface).
Reverting the merge restores prior behavior cleanly.
## Risks
- Operators with proxies fronting `/api-docs` — URL unchanged, transparent.
- API consumers scraping `/api-docs.json` — untouched.
- Custom admin pages that depended on swagger-ui's specific DOM —
unlikely (core only); will surface in CI.
- RapiDoc upstream eventually adds telemetry — mitigated by vendoring a
pinned version and re-grepping on each bump.