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

9.9 KiB

Issue #7524 — Drop swagger-ui, take a telemetry stance, opt out our phone-homes

Status: approved 2026-05-15 Closes: ether/etherpad#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).

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:179GET ${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:
    <!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-docsapi-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:

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.

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.

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.tsprivacy.updateCheck and privacy.pluginCatalog default to true when not set in settings.json.
  • New UpdateCheck.test.tscheck() makes no fetch when privacy.updateCheck === false (fetch mocked, assert no call).
  • New installer.test.tsgetAvailablePlugins() 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.