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>
This commit is contained in:
John McLear 2026-05-16 18:34:22 +01:00 committed by GitHub
parent 8c4f974122
commit 278acb10cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 4050 additions and 85 deletions

View file

@ -6,6 +6,14 @@
### Notable enhancements
- **Privacy — drop swagger-ui telemetry, document phone-homes, add opt-outs.**
- Dropped `swagger-ui-express` because upstream 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)). `/api-docs` now serves a vendored copy of [Scalar](https://github.com/scalar/scalar) (MIT) configured with `withDefaultFonts: false` and `telemetry: false` so no outbound calls are made.
- New `privacy.updateCheck` (default `true`) — set to `false` to disable the hourly `UpdateCheck.ts` request to `${updateServer}/info.json`.
- New `privacy.pluginCatalog` (default `true`) — set to `false` to disable the admin plugins page fetch of `${updateServer}/plugins.json`. CLI install-by-name still works.
- New [`PRIVACY.md`](PRIVACY.md) at repo root documenting both outbound calls, what they send, and how to turn each off.
- `bin/plugins/stalePlugins.ts` now reads `settings.updateServer` (was hardcoded to `static.etherpad.org`) and honours the new flag.
- Closes #7524.
- **Self-update subsystem — Tier 2 (manual click).**
- Admins on a git install can click "Apply update" at `/admin/update`. Etherpad runs a 60s session drain (with T-60 / T-30 / T-10 broadcasts to every pad), `git fetch / checkout / pnpm install --frozen-lockfile / pnpm run build:ui`, and exits with code 75 so a process supervisor restarts it on the new version. The next boot runs a 60s health check; if `/health` doesn't come up the previous SHA + lockfile are restored automatically.
- Crash-loop guard: if the new version reboots more than twice without the health check completing, RollbackHandler forces a rollback regardless of the timer.

68
PRIVACY.md Normal file
View file

@ -0,0 +1,68 @@
# 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. Plugins are out of
scope — audit any plugin you install.
## 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` (override via `updateServer`) |
| Frequency | hourly while the server runs |
| Payload | GET only; `User-Agent: Etherpad/<version>` |
| Purpose | surface an "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` (override via `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 via CLI still works) |
| Source | `src/static/js/pluginfw/installer.ts` |
## What we removed
`swagger-ui-express` was dropped because the upstream npm package
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)).
`/api-docs` is now served by a vendored copy of [Scalar](https://github.com/scalar/scalar)
(MIT) with no outbound calls. The shell explicitly opts out of Scalar's
default font fetch (`withDefaultFonts: false`) and analytics
(`telemetry: false`), and pins a system-font stack via CSS.
`@scarf/scarf` is listed under `ignoredBuiltDependencies` in
`pnpm-workspace.yaml`, so its postinstall pixel is suppressed even if a
future transitive dep pulls Scarf in.
## 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`.

View file

@ -10,7 +10,7 @@
Every keystroke is attributed to its author. Every revision is preserved. The timeslider lets you scrub through a document's entire history, character by character. Author colours make collaboration visible at a glance — not buried in a menu.
Etherpad runs on your server, under your governance. No telemetry. No upsells. AI is a plugin you install, pointed at the model you choose, running on infrastructure you control — not a feature decided for you in a boardroom you weren't in.
Etherpad runs on your server, under your governance. No telemetry. No upsells. AI is a plugin you install, pointed at the model you choose, running on infrastructure you control — not a feature decided for you in a boardroom you weren't in. See [PRIVACY.md](PRIVACY.md) for the two opt-out network calls Etherpad's own code makes and how to disable each.
The code is Apache 2.0. The data format is open. It [scales to thousands of simultaneous editors per pad](http://scale.etherpad.org/). Translated into 105 languages. Extended through hundreds of plugins. Used by Wikimedia, governments, public-sector institutions, and self-hosters worldwide since 2009.

View file

@ -1044,6 +1044,16 @@ input, button, select, optgroup, textarea {
padding: 8px 8px 40px;
}
.pm-banner {
margin: 8px 0 16px;
padding: 12px 16px;
border-radius: 6px;
border: 1px solid var(--ink-3, #cbd5e1);
background: var(--surface-2, #f8fafc);
font-size: .9rem;
}
.pm-banner-info { border-left: 4px solid var(--accent, #0ea5e9); }
/* Header */
.pm-header {
display: flex;

View file

@ -9,6 +9,7 @@ import {IconButton} from "../components/IconButton.tsx";
export const HomePage = () => {
const pluginsSocket = useStore(state => state.pluginsSocket)
const [plugins, setPlugins] = useState<PluginDef[]>([])
const [catalogDisabled, setCatalogDisabled] = useState(false)
const installedPlugins = useStore(state => state.installedPlugins)
const setInstalledPlugins = useStore(state => state.setInstalledPlugins)
// Default sort: name ascending. PR #7716 set this to "downloads desc" but
@ -89,11 +90,14 @@ export const HomePage = () => {
pluginsSocket.emit('search', searchParams)
}
const onCatalogDisabled = () => setCatalogDisabled(true)
pluginsSocket.on('results:installed', onInstalled)
pluginsSocket.on('results:updatable', onUpdatable)
pluginsSocket.on('finished:install', onFinishedInstall)
pluginsSocket.on('finished:uninstall', onFinishedUninstall)
pluginsSocket.on('connect', onConnect)
pluginsSocket.on('results:catalogDisabled', onCatalogDisabled)
pluginsSocket.emit('getInstalled')
@ -105,6 +109,7 @@ export const HomePage = () => {
pluginsSocket.off('finished:install', onFinishedInstall)
pluginsSocket.off('finished:uninstall', onFinishedUninstall)
pluginsSocket.off('connect', onConnect)
pluginsSocket.off('results:catalogDisabled', onCatalogDisabled)
}
}, [pluginsSocket])
@ -145,6 +150,12 @@ export const HomePage = () => {
return (
<div className="pm-page">
{catalogDisabled && (
<div className="pm-banner pm-banner-info" role="status">
<Trans i18nKey="admin_plugins.catalog_disabled"/>
</div>
)}
{/* ── Page header ────────────────────────────────────────────────── */}
<div className="pm-header">
<div>

View file

@ -3,10 +3,17 @@
// Returns a list of stale plugins and their authors email
import process from "node:process";
import settings from "../../src/node/utils/Settings";
const currentTime = new Date();
(async () => {
const resp = await fetch('https://static.etherpad.org/plugins.full.json');
if (!settings.privacy.pluginCatalog) {
console.info(
'stalePlugins: plugin catalog disabled by privacy.pluginCatalog=false; exiting'
);
process.exit(0);
}
const resp = await fetch(`${settings.updateServer}/plugins.full.json`);
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
const data: any = await resp.json();
for (const plugin of Object.keys(data)) {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,246 @@
# 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.

75
pnpm-lock.yaml generated
View file

@ -343,9 +343,6 @@ importers:
surrealdb:
specifier: ^2.0.3
version: 2.0.3(tslib@2.8.1)(typescript@6.0.3)
swagger-ui-express:
specifier: ^5.0.1
version: 5.0.1(express@5.2.1)
tinycon:
specifier: 0.6.8
version: 0.6.8
@ -434,9 +431,6 @@ importers:
'@types/supertest':
specifier: ^7.2.0
version: 7.2.0
'@types/swagger-ui-express':
specifier: ^4.1.8
version: 4.1.8
'@types/underscore':
specifier: ^1.13.0
version: 1.13.0
@ -1757,9 +1751,6 @@ packages:
'@rushstack/eslint-patch@1.16.1':
resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
'@shikijs/core@3.23.0':
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
@ -1969,9 +1960,6 @@ packages:
'@types/mime-types@3.0.1':
resolution: {integrity: sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==}
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/mocha@10.0.10':
resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==}
@ -2019,15 +2007,9 @@ packages:
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@types/send@0.17.4':
resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==}
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
'@types/serve-static@1.15.7':
resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==}
'@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
@ -2043,9 +2025,6 @@ packages:
'@types/supertest@7.2.0':
resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==}
'@types/swagger-ui-express@4.1.8':
resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==}
'@types/tar@6.1.13':
resolution: {integrity: sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==}
@ -5438,15 +5417,6 @@ packages:
swagger-schema-official@2.0.0-bab6bed:
resolution: {integrity: sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==}
swagger-ui-dist@5.20.6:
resolution: {integrity: sha512-q/1dwcCOQb+qsNkb+1VWRdGEEVdBtOTH4vv9rICjPwJXOwq/JSRkBbuEMjMe161Oxsp589+8Ff5nE4HTPLWIAw==}
swagger-ui-express@5.0.1:
resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==}
engines: {node: '>= v0.10.32'}
peerDependencies:
express: '>=4.0.0 || >=5.0.0-beta'
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@ -7204,8 +7174,6 @@ snapshots:
'@rushstack/eslint-patch@1.16.1': {}
'@scarf/scarf@1.4.0': {}
'@shikijs/core@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
@ -7447,8 +7415,6 @@ snapshots:
'@types/mime-types@3.0.1': {}
'@types/mime@1.3.5': {}
'@types/mocha@10.0.10': {}
'@types/ms@2.1.0': {}
@ -7502,21 +7468,10 @@ snapshots:
'@types/semver@7.7.1': {}
'@types/send@0.17.4':
dependencies:
'@types/mime': 1.3.5
'@types/node': 25.7.0
'@types/send@1.2.1':
dependencies:
'@types/node': 25.7.0
'@types/serve-static@1.15.7':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 25.7.0
'@types/send': 0.17.4
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
@ -7540,11 +7495,6 @@ snapshots:
'@types/methods': 1.1.4
'@types/superagent': 8.1.9
'@types/swagger-ui-express@4.1.8':
dependencies:
'@types/express': 5.0.6
'@types/serve-static': 1.15.7
'@types/tar@6.1.13':
dependencies:
'@types/node': 25.7.0
@ -8769,10 +8719,10 @@ snapshots:
'@rushstack/eslint-patch': 1.16.1
'@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0)(typescript@6.0.3)
'@typescript-eslint/parser': 7.18.0(eslint@10.4.0)(typescript@6.0.3)
eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0)
eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0)(eslint@10.4.0)
eslint-plugin-cypress: 2.15.2(eslint@10.4.0)
eslint-plugin-eslint-comments: 3.2.0(eslint@10.4.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0))(eslint@10.4.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1)(eslint@10.4.0)
eslint-plugin-mocha: 10.5.0(eslint@10.4.0)
eslint-plugin-n: 17.24.0(eslint@10.4.0)(typescript@6.0.3)
eslint-plugin-prefer-arrow: 1.2.3(eslint@10.4.0)
@ -8793,7 +8743,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0):
eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0)(eslint@10.4.0):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@8.1.1)
@ -8804,18 +8754,18 @@ snapshots:
stable-hash: 0.0.5
tinyglobby: 0.2.16
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0))(eslint@10.4.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1)(eslint@10.4.0)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0))(eslint@10.4.0):
eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1)(eslint@10.4.0):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 7.18.0(eslint@10.4.0)(typescript@6.0.3)
eslint: 10.4.0
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0)
eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.32.0)(eslint@10.4.0)
transitivePeerDependencies:
- supports-color
@ -8837,7 +8787,7 @@ snapshots:
eslint: 10.4.0
ignore: 5.3.2
eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0))(eslint@10.4.0):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.9.1)(eslint@10.4.0):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -8848,7 +8798,7 @@ snapshots:
doctrine: 2.1.0
eslint: 10.4.0
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0))(eslint@10.4.0)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.9.1)(eslint@10.4.0)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@ -11366,15 +11316,6 @@ snapshots:
swagger-schema-official@2.0.0-bab6bed: {}
swagger-ui-dist@5.20.6:
dependencies:
'@scarf/scarf': 1.4.0
swagger-ui-express@5.0.1(express@5.2.1):
dependencies:
express: 5.2.1
swagger-ui-dist: 5.20.6
symbol-tree@3.2.4: {}
tabbable@6.4.0: {}

View file

@ -417,6 +417,17 @@
"updateServer": "https://etherpad.org/ep_infos",
/*
* Outbound network calls. See PRIVACY.md for what each one sends.
* - updateCheck=false : disables hourly version check (UpdateCheck.ts)
* - pluginCatalog=false: disables admin plugin browser
* (manual install-by-name via CLI still works)
*/
"privacy": {
"updateCheck": true,
"pluginCatalog": true
},
/*
* Should we suppress errors from being visible in the default Pad Text?
*/

View file

@ -61,6 +61,7 @@
"admin_plugins.available_search.placeholder": "Search for plugins to install",
"admin_plugins.check_updates": "Check for updates",
"admin_plugins.core_count": "{{count}} core",
"admin_plugins.catalog_disabled": "Plugin catalog is disabled by your operator (privacy.pluginCatalog=false). To install a plugin, run `pnpm run plugins i ep_<name>` from the server.",
"admin_plugins.crumbs": "Plugins",
"admin_plugins.description": "Description",
"admin_plugins.disables.label": "Disables:",

View file

@ -5,8 +5,8 @@ import {ErrorCaused} from "../types/ErrorCaused";
import createHTTPError from "http-errors";
const apiHandler = require('./APIHandler')
import {serve, setup} from 'swagger-ui-express'
import express from "express";
import path from "path";
import settings from '../utils/Settings';
@ -1437,12 +1437,9 @@ export const expressCreateServer = async (hookName: string, {app}: ArgsExpressTy
}
app.use('/api-docs', serve);
app.get('/api-docs', setup(undefined, {
swaggerOptions: {
url: '/api-docs.json',
},
}));
app.get('/api-docs', (_req, res) => {
res.sendFile(path.join(settings.root, 'src', 'static', 'api-docs.html'));
});
app.use(express.json());

View file

@ -9,6 +9,7 @@ import {PackageData, PackageInfo} from "../../types/PackageInfo";
import semver from 'semver';
import log4js from 'log4js';
import {MapArrayType} from "../../types/MapType";
import settings from "../../utils/Settings";
const pluginDefs = require('../../../static/js/pluginfw/plugin_defs');
const logger = log4js.getLogger('adminPlugins');
@ -49,31 +50,43 @@ exports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => {
const installed =
Object.keys(pluginDefs.plugins).map((plugin) => pluginDefs.plugins[plugin].package);
const updatable = await checkPluginForUpdates();
installed.forEach((plugin) => {
plugin.updatable = updatable.includes(plugin.name);
})
if (settings.privacy.pluginCatalog) {
const updatable = await checkPluginForUpdates();
installed.forEach((plugin) => {
plugin.updatable = updatable.includes(plugin.name);
})
}
// When the catalog is disabled, `updatable` simply stays unset on
// each installed plugin — the admin UI renders no "update available"
// badge, which is correct.
socket.emit('results:installed', {installed});
});
socket.on('checkUpdates', async () => {
if (!settings.privacy.pluginCatalog) {
socket.emit('results:catalogDisabled');
return;
}
// Check plugins for updates
try {
const updatable = checkPluginForUpdates();
const updatable = await checkPluginForUpdates();
socket.emit('results:updatable', {updatable});
} catch (err) {
const errc = err as ErrorCaused
console.warn(errc.stack || errc.toString());
socket.emit('results:updatable', {updatable: {}});
socket.emit('results:updatable', {updatable: []});
}
});
socket.on('getAvailable', async (query:string) => {
if (!settings.privacy.pluginCatalog) {
socket.emit('results:catalogDisabled');
return;
}
try {
const results = await getAvailablePlugins(/* maxCacheAge:*/ false);
socket.emit('results:available', results);
@ -84,6 +97,10 @@ exports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => {
});
socket.on('search', async (query: QueryType) => {
if (!settings.privacy.pluginCatalog) {
socket.emit('results:catalogDisabled');
return;
}
try {
if (query.searchTerm) logger.info(`Plugin search: ${query.searchTerm}'`);
const results = await search(query.searchTerm, /* maxCacheAge:*/ 60 * 10);

View file

@ -193,6 +193,10 @@ export type SettingsType = {
learnMoreUrl: string | null,
dismissal: 'dismissible' | 'sticky',
},
privacy: {
updateCheck: boolean,
pluginCatalog: boolean,
},
skinName: string | null,
skinVariants: string,
ip: string,
@ -425,6 +429,14 @@ const settings: SettingsType = {
learnMoreUrl: null,
dismissal: 'dismissible',
},
privacy: {
// Outbound calls. See PRIVACY.md.
// Set to false to disable hourly version check (UpdateCheck.ts).
updateCheck: true,
// Set to false to disable plugin-catalog fetch from updateServer
// (installer.ts). Manual install via CLI still works.
pluginCatalog: true,
},
/*
* Skin name.
*

View file

@ -13,6 +13,7 @@ type Infos = {
const updateInterval = 60 * 60 * 1000; // 1 hour
let infos: Infos;
let lastLoadingTime: number | null = null;
let loggedDisabled = false;
const loadEtherpadInformations = () => {
if (lastLoadingTime !== null && Date.now() - lastLoadingTime < updateInterval) {
@ -38,6 +39,7 @@ const loadEtherpadInformations = () => {
export const getLatestVersion = () => {
if (!settings.privacy.updateCheck) return undefined;
needsUpdate().catch();
return infos?.latestVersion;
};
@ -55,6 +57,13 @@ const needsUpdate = async (cb?: Function) => {
};
export const check = () => {
if (!settings.privacy.updateCheck) {
if (!loggedDisabled) {
console.info('Update check disabled by privacy.updateCheck=false (see PRIVACY.md)');
loggedDisabled = true;
}
return;
}
needsUpdate((needsUpdate: boolean) => {
if (needsUpdate) {
console.warn(`Update available: Download the actual version ${infos.latestVersion}`);

View file

@ -83,7 +83,6 @@
"socket.io-client": "^4.8.3",
"superagent": "10.3.0",
"surrealdb": "^2.0.3",
"swagger-ui-express": "^5.0.1",
"tinycon": "0.6.8",
"tsx": "4.22.0",
"ueberdb2": "^5.0.48",
@ -119,7 +118,6 @@
"@types/semver": "^7.7.1",
"@types/sinon": "^21.0.1",
"@types/supertest": "^7.2.0",
"@types/swagger-ui-express": "^4.1.8",
"@types/underscore": "^1.13.0",
"@types/whatwg-mimetype": "^5.0.0",
"chokidar": "^5.0.0",

33
src/static/api-docs.html Normal file
View file

@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Etherpad API</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<style>
html, body { margin: 0; padding: 0; height: 100%; }
/* Scalar ships @font-face rules pointing at fonts.scalar.com.
withDefaultFonts:false disables that import; the rule below
forces a system-font stack so nothing fetches over the wire. */
:root, .scalar-app, body { font-family: system-ui, -apple-system, sans-serif !important; }
</style>
</head>
<body>
<div id="app"></div>
<script src="/static/vendor/scalar/standalone.js"></script>
<script>
Scalar.createApiReference('#app', {
url: '/api-docs.json',
theme: 'default',
layout: 'modern',
hideClientButton: true,
showDeveloperTools: 'never',
withDefaultFonts: false,
telemetry: false,
agent: { disabled: true },
mcp: { disabled: true }
});
</script>
</body>
</html>

View file

@ -17,6 +17,7 @@ import settings, {
reloadSettings
} from '../../../node/utils/Settings';
import {LinkInstaller} from "./LinkInstaller";
import {assertPluginCatalogEnabled} from "./pluginCatalogGuard";
import {
checkEngineCompatibility,
EngineIncompatibleError,
@ -199,6 +200,7 @@ export let availablePlugins:MapArrayType<PackageInfo>|null = null;
let cacheTimestamp = 0;
export const getAvailablePlugins = async (maxCacheAge: number | false) => {
assertPluginCatalogEnabled();
const nowTimestamp = Math.round(Date.now() / 1000);
// check cache age before making any request

View file

@ -0,0 +1,11 @@
'use strict';
import settings from '../../../node/utils/Settings';
export const assertPluginCatalogEnabled = () => {
if (!settings.privacy.pluginCatalog) {
throw new Error(
'Plugin catalog disabled by privacy.pluginCatalog=false (see PRIVACY.md)'
);
}
};

21
src/static/vendor/scalar/LICENSE vendored Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023-present Scalar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
src/static/vendor/scalar/VERSION vendored Normal file
View file

@ -0,0 +1,3 @@
@scalar/api-reference 1.57.2 (standalone bundle) - vendored 2026-05-16
source: https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.57.2/dist/browser/standalone.js
sha256: 538bea0c451ae72ca26242244491a35b3bee4e1ba282f0076f454a68b83788e5

2364
src/static/vendor/scalar/standalone.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,21 @@
import {describe, it, expect, beforeEach} from 'vitest';
import settings from '../../../../node/utils/Settings';
import {assertPluginCatalogEnabled} from '../../../../static/js/pluginfw/pluginCatalogGuard';
describe('Plugin catalog opt-out guard', () => {
beforeEach(() => {
settings.privacy.pluginCatalog = true;
});
it('throws tagged error when privacy.pluginCatalog is false', () => {
settings.privacy.pluginCatalog = false;
expect(() => assertPluginCatalogEnabled()).toThrow(
/privacy\.pluginCatalog=false/
);
});
it('does not throw when privacy.pluginCatalog is true', () => {
settings.privacy.pluginCatalog = true;
expect(() => assertPluginCatalogEnabled()).not.toThrow();
});
});

View file

@ -0,0 +1,12 @@
import {describe, it, expect} from 'vitest';
import settings from '../../../../node/utils/Settings';
describe('privacy settings defaults', () => {
it('privacy.updateCheck defaults to true', () => {
expect(settings.privacy.updateCheck).toBe(true);
});
it('privacy.pluginCatalog defaults to true', () => {
expect(settings.privacy.pluginCatalog).toBe(true);
});
});

View file

@ -0,0 +1,31 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import settings from '../../../../node/utils/Settings';
import {check} from '../../../../node/utils/UpdateCheck';
describe('UpdateCheck opt-out', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('check() does not call fetch when privacy.updateCheck is false', async () => {
settings.privacy.updateCheck = false;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('{}', {status: 200})
);
check();
// Allow any internal microtasks to settle.
await new Promise((r) => setImmediate(r));
expect(fetchSpy).not.toHaveBeenCalled();
settings.privacy.updateCheck = true;
});
it('check() calls fetch when privacy.updateCheck is true', async () => {
settings.privacy.updateCheck = true;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({latestVersion: '99.0.0'}), {status: 200})
);
check();
await new Promise((r) => setImmediate(r));
expect(fetchSpy).toHaveBeenCalled();
});
});