Thanks to every contributor: PRs, issues, reviews, translations, plugins, answered questions. This project runs on volunteer effort, not one person or one company. That's by design and it's why it's lasted.
Thanks to everyone running an instance: schools, newsrooms, gov departments, or just yourself. You trusted us with your documents. We don't take that lightly.
Thanks to the wider FOSS community. We've relied on other people's open source work more than we can credit individually, and tried to give some back. If you've never contributed to a FOSS project, this is your sign. Code, docs, translations, bug reports, or just running an instance and telling people about it, all of it counts.
The migration to modern JS tooling has been one of the best things to happen to this codebase. Easier to contribute to, easier to maintain, easier to build on, which is what matters for a 15+ year old project that intends to keep going.
Going forward: keep Etherpad boring, stable, self-hostable, no enshittification, while staying open to the plugins and integrations that make it useful. We need more maintainers and more instances. If that's you, open an issue.
Here's to the next 10,000. John McLear
* chore(deps): vendor the unmaintained `security` escaper into core
The `security` npm package (escapeHTML / escapeHTMLAttribute and the JS/CSS
encoders) has had no release since 2012, yet it sits directly in Etherpad's
client-side XSS-defense path (pad_utils, domline) and the server-side HTML
export. Rather than keep a 14-year-old, single-maintainer dependency guarding
output encoding, vendor its implementation into core.
- static/js/security.ts now contains the escaping logic directly (reproduced
verbatim from security@1.0.0, MIT, Chad Weider — byte-identical output) and
no longer does `require('security')`. The full public API is preserved, so
plugins that `require('ep_etherpad-lite/static/js/security')` keep working
unchanged.
- pad_utils.ts requires the local './security' module instead of the bare
'security' specifier (domline.ts and ExportHtml.ts already did).
- Drop `security` from src/package.json dependencies and from Minify's
LIBRARY_WHITELIST (no bare specifier is served to the browser anymore).
Added tests/backend/specs/security.ts locking the byte-for-byte escaping
output so the vendored copy can never silently drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: use ESM named exports so vitest can resolve the security module
CI "Run the new vitest tests" failed with `Cannot find module './security'`
from pad_utils.ts. vitest/vite's CJS require() shim doesn't add a `.ts`
extension when resolving a relative specifier, so `require('./security')`
couldn't locate security.ts. (The old bare `require('security')` resolved to
a real .js in node_modules, which is why this only surfaced after vendoring.)
- security.ts now uses ESM `export const` for the seven helpers instead of a
`module.exports = {...}` block.
- pad_utils.ts imports it as `import * as Security from './security'`, which
goes through vite's resolver (knows .ts) and is also properly typed.
CJS consumers (domline.ts, ExportHtml.ts, the backend spec) keep working via
tsx/esbuild ESM->CJS interop. Verified: tsc clean, full vitest suite 721
passing, and the mocha security/export/import specs 27 passing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: force fresh run (prior run used a stale merge ref after reopen)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: remove ReDoS in vendored JSON-string-literal regex
CodeQL flagged a high-severity exponential-backtracking alert on the
JSON-string-literal regex vendored from the `security` package:
`/"(?:\\.|[^"])*"/`. The `[^"]` class also matches a backslash, so it overlaps
with the `\\.` alternative and backtracks exponentially on adversarial input
like `"\!\!\!...` (no closing quote). The original lived inside node_modules so
it was never scanned; vendoring it surfaced the alert.
Fix to the canonical linear form `/"(?:[^"\\]|\\.)*"/`, where the backslash is
excluded from the character class so the two alternatives are mutually
exclusive. It matches exactly the same well-formed JSON string literals (and
encodeJavaScriptData only ever runs it over JSON.stringify output), so behaviour
is unchanged for valid input.
Added tests: encodeJavaScriptData output + a ReDoS guard that runs the regex
over 50k adversarial chars and asserts it returns in well under a second.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps): drop three unmaintained dependencies from core
Remove dependencies whose upstreams are effectively abandoned, replacing
each with a maintained alternative or native API. No behaviour change for
users; reduces the production dependency surface.
- unorm (last publish 2019): replace `UNorm.nfc(s)` in contentcollector
with native `String.prototype.normalize('NFC')`, available in every
supported Node and browser. Also drop it from Minify's LIBRARY_WHITELIST.
- find-root (last publish 2017): inline a ~10-line equivalent in
AbsolutePaths.findEtherpadRoot(), mirroring find-root's semantics
(closest ancestor containing package.json, throw if none).
- jsonminify (last publish 2021): swap settings parsing to jsonc-parser
(already used by the admin workspace, actively maintained). The old
`jsonminify(str).replace(',]', ']').replace(',}', '}')` had two bugs that
jsonc-parser's allowTrailingComma fixes: String#replace only swapped the
FIRST trailing comma of each kind, and the blind replace corrupted ',]' /
',}' byte sequences inside string values (e.g. URLs).
Added a regression test in settings.ts covering multiple trailing commas
and ',]'/',}' inside string values.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: drop stale $unorm bundle entry from tar.json
The unorm removal left `$unorm/lib/unorm.js` listed in the ace2_inner.js
client bundle manifest. With unorm uninstalled, getTar() would point at a
node_modules asset that no longer exists, producing 404s when loading that
bundle. Nothing imports unorm anymore (contentcollector now uses native
String.prototype.normalize), so the entry is dead and removed.
Caught by Qodo review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: update container loadSettings helper to jsonc-parser
tests/container/loadSettings.js is a standalone helper (separate from
node/utils/Settings.ts) that parsed settings.json.docker with jsonminify
directly. Removing jsonminify from dependencies broke the container test
suite with MODULE_NOT_FOUND. Switch it to jsonc-parser to match Settings.ts.
Verified loadSettings() parses settings.json.docker and applies the
container ip/port overrides.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: migrate useful wiki content into the VitePress manual (#7990)
The GitHub wiki is being retired; documentation should ship with the
software. This migrates the still-accurate, non-duplicate wiki pages into
the published VitePress site (doc/**/*.md + the sidebar in
doc/.vitepress/config.mts) so they are versioned, searchable and portable:
- deployment.md: reverse-proxy configs (Nginx/Apache/Caddy/Traefik/
HAProxy) with the WebSocket-upgrade rules, subdirectory hosting via
X-Proxy-Path, native HTTPS via the ssl block, a systemd unit, and the
Istio manifest (with the Redis-adapter multi-replica caveat).
- accessibility.md: editor keyboard shortcuts (verified against
ace2_inner.ts / broadcast_slider.ts / pad_editbar.ts), toolbar
navigation, NVDA notes.
- faq.md: install methods, URL-path reference, listing/deleting pads
(API-first), backup/restore, and history pruning.
- development.md: source-tree tour, the pad<->format conversion pipeline,
the internal DB API, and the Fontello toolbar-icon workflow.
- database.md: the key/value schema plus connecting MySQL/PostgreSQL/Redis
backends and a pgloader MySQL->PostgreSQL migration (database docs were
previously absent from the VitePress site).
Every page was checked against the current source before inclusion:
corrected the apt instructions to the live signed repo (stable/main,
signed-by key), dropped the unpublished snap, fixed the Redis dbSettings
(flat host/port/password or url, not the obsolete client_options),
dropped charset from the PostgreSQL example, and removed a phantom
getEtherpad API reference. The VitePress site builds cleanly
(pnpm run docs:build) with the dead-link checker enabled.
Closes#7990
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: add verified hands-on changeset/atext walkthrough (#7990)
Migrate the practical Changeset-library tutorial from the wiki into
changeset_library.md, rewritten against the current API: unpack(),
deserializeOps() (replacing the deprecated opIterator) and
new AttributePool() (replacing the removed AttributePoolFactory). Every
example output was produced by running the code against the current
Changeset.ts / AttributePool.ts, not copied from the wiki. Also fixes a
stale ether/etherpad-lite source link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(import): correct outdated import-format help message (#7988)
The import dialog's "no converter" notice claimed only plain text and
HTML could be imported and linked users to the legacy AbiWord wiki page,
prompting them to install LibreOffice for formats that already work
natively.
Etherpad imports .txt, .html, .docx (via mammoth) and .etherpad files
without LibreOffice; only .pdf/.odt/.doc/.rtf still need it. Update the
message to say so and move the help link to the ether/etherpad org.
Closes#7988
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(import): point help link to new LibreOffice wiki page (Qodo #7989)
The AbiWord wiki pages were vandalized/empty. Added a clean LibreOffice
setup page on the wiki and point the import dialog there instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(import): reference Etherpad docs instead of wiki for LibreOffice
The wiki is being retired, so don't link to it. Point users at the
documentation site for installing LibreOffice for extra import formats.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both scripts still called the pre-v6 callback-style ueberdb2 API, producing
type errors (masked in places by `// @ts-ignore`) against the current
promise-based signatures (`set(key, value)`, `init()`, `close()` — no
callback/extra args):
importSqlFile.ts(73) initDb(null) Expected 0 arguments, but got 1
migrateDirtyDBtoRealDB.ts(51) db.set(k,v,bcb,wcb) Expected 2 arguments, but got 4
migrateDirtyDBtoRealDB.ts(56) db.close(null) Expected 0 arguments, but got 1
migrateDirtyDBtoRealDB.ts(57) dirty.close(null) Expected 0 arguments, but got 1
- importSqlFile: drop the unused `util` import and the `util.promisify`
wrappers; `await db.init()`, `await db.set(...)`, `await db.close()`
directly. Removes two `// @ts-ignore` that were hiding the broken calls.
- migrateDirtyDBtoRealDB: replace the bcb/wcb callback machinery with
`await db.set(key, value)` in the loop and call `close()` with no args.
Also fixes the progress log which referenced an undefined `length`
instead of `keys.length`.
Pure type/correctness cleanup; behaviour is unchanged (writes are now
awaited, which is equivalent or safer). `tsc --noEmit` on the bin package
is now clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`bin/migrateDB.ts` opens a source and target ueberdb2 Database, copies all
keys, then resolves without closing either connection or calling
process.exit(). Two problems with ueberdb2 6.1.x:
- 6.1.x keeps an internal keep-alive timer running until close() is called,
so the migration process hangs forever after "Done syncing dbs" instead
of exiting. (Pre-6.1.x it exited on its own once the work was done.)
- Target writes are buffered and only guaranteed flushed to disk on close()
/flush(), so an operator who Ctrl-Cs the apparently-finished process could
end up with an incomplete migration.
Close the target then the source on both the success and error paths (which
flushes buffered writes and clears the keep-alive timer) and exit with an
explicit status code, matching the pattern already used in
migrateDirtyDBtoRealDB.ts.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Installer test" workflow has hung for 6 hours (until GitHub's job
ceiling cancels it) on every ubuntu/macos run since v3.2.0. The smoke
test starts `pnpm run prod` in the background, confirms /api responds,
then tears it down with:
kill "$PID"
wait "$PID"
`pnpm run prod` is a nested launcher (pnpm -> pnpm --filter -> node), so
$PID is only the outer pnpm. SIGTERM is forwarded down the chain and the
script then `wait`s on it, but if the node server doesn't exit (e.g. a
live flush timer keeping the event loop alive) the wait blocks forever
and the step never releases its output pipe -> 6h hang. Windows passed
because it uses `Stop-Process -Force`.
Fix the teardown to be robust regardless of server shutdown behaviour:
- `set -m` so the launcher gets its own process group
- kill the whole group (SIGTERM, then SIGKILL fallback) via a trap
- drop the blocking `wait`
- add `timeout-minutes: 8` to both smoke steps as a hard backstop so a
future hang fails in minutes, not 6 hours
This unblocks CI on PRs that touch the installer workflow. The
underlying clean-shutdown regression (server not exiting on SIGTERM,
likely the ueberDB flush-timer setInterval) is tracked separately.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The releaseEtherpad workflow renames ep_etherpad-lite -> ep_etherpad and
publishes ./src to npm, but that publish is not load-bearing:
- `ep_etherpad` has 0 dependents on npm; nothing in this repo depends on it.
- Plugins import `ep_etherpad-lite` resolved from the LOCAL core install, and
plugin CI clones `ether/etherpad` rather than `npm install`-ing core.
- Etherpad is run via git clone / Docker / zip / snap, never `npm install`.
It has been failing with E404 (the ep_etherpad package has no OIDC trusted
publisher configured on npmjs.com), which is why npm is stuck at 2.5.0 while
3.0/3.1/3.2/3.3 shipped fine without it.
Rather than chase a trusted-publisher setup for a publish nobody consumes,
park the workflow: gate the job behind an explicit `confirm: true` dispatch
input so a stray run fails fast with a clear message instead of a confusing
404, and document the status in the header + the AGENTS.MD Releasing section.
This is the package owner's (samtv12345) call: either finish the trusted-
publisher config to revive it, or remove the workflow. Parked pending that
decision; nothing about the release depends on it.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pad): keep token-less Delete pad reachable without pad-wide settings (#7959)
The token-less "Delete pad" button (#delete-pad) was nested inside the
enablePadWideSettings-gated pad-settings section, so disabling pad-wide
settings removed the only way to delete a pad without a recovery token.
Combined with #7926 hiding the token disclosure when deletion needs no
token (e.g. allowPadDeletionByAllUsers), a user who was allowed to delete
could be left with no deletion UI at all.
Pad deletion is unrelated to pad-wide settings, so:
- Move #delete-pad out of the enablePadWideSettings block in pad.html; it
is now always rendered and hidden by default.
- Add a canDeletePad clientVar (isCreator || allowPadDeletionByAllUsers)
and drive the button's visibility from it in pad_editor.ts, mirroring the
existing canDeleteWithoutToken handling for the token disclosure.
The two controls are now mutually coherent and neither depends on
enablePadWideSettings: the plain button shows when this session can delete
without a token, the recovery-token disclosure shows otherwise.
Tests:
- backend padDeletionUiPlacement.ts: #delete-pad is rendered with
enablePadWideSettings both on and off (fails without the template move).
- backend socketio.ts: canDeletePad reflects the creator/allow-all matrix,
including a non-creator who only gains it under allowPadDeletionByAllUsers.
- frontend pad_settings.spec.ts: asserts #delete-pad is no longer a
descendant of #pad-settings-section.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pad): never let readonly sessions delete via token-less paths (#7959)
Qodo review of #7960: `canDeletePad` was `isCreator || allowPadDeletionByAllUsers`,
so under allowPadDeletionByAllUsers a readonly viewer received
canDeletePad=true and the relocated #delete-pad button unhid for them.
Worse, the server-side handlePadDelete `flagOk`/`creatorOk` branches never
checked session.readonly either, so a readonly-link holder could actually
delete the pad without a token — a data-loss hole that the new always-rendered
button would expose.
Exclude readonly sessions from both the clientVar and the server's token-less
authorization paths. A valid recovery token (tokenOk) stays a sufficient
credential regardless of session mode.
Test: socketio.ts asserts a readonly viewer gets canDeletePad=false and that a
token-less PAD_DELETE from a readonly session leaves the pad intact (red before
this change on the clientVar assertion).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pin the transitive @opentelemetry/core dep (pulled in via
@elastic/elasticsearch -> @elastic/transport) to >=2.8.0 to clear
GHSA-8988-4f7v-96qf / CVE-2026-54285: W3CBaggagePropagator.extract()
did not enforce W3C size limits on inbound baggage headers, allowing
unbounded memory allocation. @elastic/transport declares the dep as
"2.x" so 2.8.0 satisfies the existing range with no parent bump, and
2.8.0's @opentelemetry/api peer range (>=1.0.0 <1.10.0) is satisfied
by the 1.9.1 already in the tree.
Override added to pnpm-workspace.yaml alongside the other CVE
force-bumps (pnpm 11 ignores root package.json pnpm.overrides).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ueberdb2 6.1.10 rewrote the cache/buffer layer (CacheAndBufferLayer.ts),
replacing the constructor's always-on, *referenced* `setInterval` flush timer
with a lazily-armed `setTimeout` that is `.unref()`'d and only created when
there are dirty keys. On a fresh, empty dirty DB there are no dirty keys, so no
flush timer is ever armed and ueberdb2 no longer anchors Node's event loop. In
the packaged (.deb/systemd) production boot this exposes a startup window where
the loop has no referenced handle and the process exits cleanly (code 0) before
`server.listen()` binds the port — so the server never serves /health.
Symptom on develop: the Debian-package amd64 smoke test failed on three
consecutive pushes (#7966 ueberdb2 6.1.9->6.1.12, then #7965, #7967), with the
service logging the version banner then "Deactivated successfully" and the
health check on :9001 never connecting. Backend/Docker/downstream-smoke stayed
green because they keep the loop alive by other means; only the bare
fresh-empty-dirty-DB packaged boot hits the gap. ueberdb2 6.1.12 is the latest
published release, so there is no fixed version to roll forward to yet — pin
back to the last green release (6.1.9) on both src/ and bin/.
Also add a `pull_request` trigger to the Debian-package workflow (scoped to the
same production-footprint paths). The smoke step is the only check that catches
this "boots then exits before binding" class of regression, but it previously
ran only on push to develop — i.e. *after* merge — which is exactly why the
Dependabot bump turned develop red instead of being blocked at PR time. The
release/apt-publish jobs are tag-guarded, so PRs run the build+smoke job only.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* PadManager: reject unreachable '.' and '..' pad ids
isValidPadId accepted pad ids that consist only of URL dot-segments
('.' and '..'). Per the WHATWG URL standard a browser normalises
"/p/." to "/p/" and "/p/.." to "/", so such a pad can never be opened
or exported: the request arrives at "/p/" and Etherpad answers
"Cannot GET /p/". The pad is created in the database but is forever
unreachable.
Reject these ids in isValidPadId so the broken pads can no longer be
created, and add a regression test that fails without the fix.
* adminsettings: allow deleting legacy '.'/'..' pads
The isValidPadId tightening makes getPad() reject the pad ids '.' and
'..', which also blocks their deletion: a pad with such an id created
before the change still exists (doesPadExists is true), so the admin
deletePad handler takes the "healthy" branch where getPad() now throws.
The outer catch swallowed that error without emitting a terminal
results:deletePad, leaving an undeletable orphan in the admin UI.
Fall back to the existing raw key purge when getPad() throws, so these
pads can still be removed. Adds a regression test.
* tests: run the isValidPadId regression under the mocha suite
The original unit test lived in the vitest backend-new suite, but PadManager
loads DB, Pad and customError with CommonJS require() at import time. Under
vitest those require() calls are resolved by Node natively and fail on the .ts
sources ("Cannot find module '../utils/customError'"); vi.mock could not
intercept them, so the suite errored before any test ran. It also used a
top-level `await import`, which tripped tsc TS1378 under the project tsconfig.
Move the test to the mocha backend suite, which runs with --import=tsx and
resolves the .ts requires natively, so PadManager can be required directly with
no mocking. isValidPadId is a pure function and DB only connects lazily in
DB.init(), so loading the module has no side effects and no database is needed.
* deps: resolve open Dependabot security alerts
Bump transitive dependencies flagged by Dependabot via pnpm-workspace
overrides. Refreshes stale override floors that the advisory ranges have
since grown past, and adds overrides for newly-flagged packages:
- form-data -> >=4.0.6 (GHSA-hmw2-7cc7-3qxx, high)
- ws -> >=8.21.0 (GHSA-96hv-2xvq-fx4p / GHSA-58qx-3vcg-4xpx, high/med)
- esbuild -> >=0.28.1 (GHSA-gv7w-rqvm-qjhr / GHSA-g7r4-m6w7-qqqr, high/low)
- basic-ftp -> >=5.3.1 (GHSA-rpmf-866q-6p89, high) [stale floor: 5.3.0 still vuln]
- tar -> >=7.5.16 (GHSA-vmf3-w455-68vh, med) [stale floor: 7.5.11]
- js-yaml -> >=4.2.0 (GHSA-h67p-54hq-rp68, med) [stale floor: 4.1.1 now vuln]
- qs -> >=6.15.2 (GHSA-q8mj-m7cp-5q26, med) [stale floor: 6.14.2 still vuln]
- ip-address -> >=10.1.1 (GHSA-v2v4-37r5-5v8g, med)
- @babel/core-> >=7.29.6 (GHSA-4x5r-pxfx-6jf8, low)
ts-check, backend test-utils, and the vite/esbuild production build all
pass locally on Node 24.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* deps: cap basic-ftp override to 5.x to avoid major bump
Qodo flagged the open-ended `basic-ftp@<5.3.1: '>=5.3.1'` override as
resolving to 6.0.1 (a surprise major) on the runtime plugin-install path
(live-plugin-manager -> proxy-agent -> get-uri -> basic-ftp). The CVE fix
(5.3.1) exists on the 5.x line, so bound the override to '>=5.3.1 <6.0.0'
for the minimal, lowest-risk patch. Now resolves to basic-ftp@5.3.1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(pad): suppress deletion token for durable identities; relabel recovery action (#7926)
Builds on the allowPadDeletionByAllUsers suppression with the rest of the
ideas discussed on the issue.
Server (handleClientReady):
- A creator's deletion token is now also withheld when they have a *durable*
identity: authenticated (req.session.user with a username) AND the deployment
pins that identity to a stable authorID via a getAuthorId hook. Only then does
the creator path (author === revision-0 author) survive a cookie clear or a
different device, making the recovery token redundant.
- This deliberately tightens the previous `requireAuthentication => always
suppress` rule: without a getAuthorId hook the authorID still comes from the
per-browser token cookie, so an authenticated user on a second device is NOT
the creator. Withholding the token there would strand them, so they now keep
getting one. SSO deployments using the documented getAuthorId pattern get the
clean no-modal experience.
- New `canDeleteWithoutToken` clientVar (allowPadDeletionByAllUsers OR durable
identity) drives the client label.
Client (pad_editor.ts):
- When canDeleteWithoutToken, the recovery-token disclosure summary is labelled
plainly "Delete Pad" (reusing the already-translated pad.settings.deletePad
key) instead of the jargon "Delete with token".
Tests (socketio.ts): anonymous -> token; allowPadDeletionByAllUsers -> none;
authenticated without a getAuthorId hook -> token; authenticated with one ->
none. Verified in a browser for both label/modal outcomes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pad): render recovery disclosure for all sessions; hide it (not relabel) when no token is needed
Addresses Qodo review on #7930:
1. Token UI fully suppressed when not needed (was: only the summary relabelled).
When canDeleteWithoutToken (allowPadDeletionByAllUsers or a durable identity),
pad_editor.ts now hides the whole #delete-pad-with-token disclosure — label,
token field and submit — so no deletion-token wording remains. With the plain
"Delete Pad" button present this is also the cleaner UX.
2. Recovery disclosure no longer gated on !requireAuthentication. Because a token
can now be issued under requireAuthentication when the deployment lacks a
durable getAuthorId mapping, the template must render the recovery form there
too — otherwise an authenticated creator gets a token with no UI to enter it
on another device. It is rendered hidden by default and shown by the client
only when canDeleteWithoutToken is false.
3. API.createPad aligned: also returns null deletionToken under
allowPadDeletionByAllUsers, matching the socket/UI path.
Tests: deletePad.ts gains a createPad-under-allowPadDeletionByAllUsers case.
Verified live in Chromium: allowAll -> disclosure hidden, no modal; default
anonymous -> disclosure visible, modal shown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(settings): env-var overrides for update-check/plugin-catalog/updater (offline installs)
Air-gapped and firewalled deployments could not disable Etherpad's outbound
calls (the hourly version check, the admin plugin catalogue, and the
self-updater) without editing settings.json inside the container image — the
shipped settings.json.docker hardcoded updates.tier and omitted the privacy
block entirely, so there was no env-var to flip.
Wire the relevant keys through the existing ${ENV:default} substitution in both
settings.json.docker and settings.json.template:
- PRIVACY_UPDATE_CHECK (privacy.updateCheck, default true)
- PRIVACY_PLUGIN_CATALOG (privacy.pluginCatalog, default true)
- UPDATES_TIER (updates.tier, default notify; "off" = no calls)
- UPDATES_SOURCE / UPDATES_CHANNEL / UPDATES_CHECK_INTERVAL_HOURS /
UPDATES_GITHUB_REPO / UPDATES_REQUIRE_ADMIN_FOR_STATUS (docker)
- UPDATE_SERVER (updateServer endpoint)
Document the full set in doc/docker.md (new "Updates & privacy" section) and
cross-link from doc/admin/updates.md. Add backend regression tests that parse
the shipped settings.json.docker and settings.json.template and assert the
overrides apply with correct boolean/numeric coercion, so a future edit that
drops the ${ENV} placeholders fails loudly.
Addresses #7911 (item 2). No code changes — config + docs + tests only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: address Qodo review — point to PRIVACY.md, clarify tier=off scope
- Outbound-call docs/comments referenced doc/privacy.md (the storage/logging
doc); the canonical outbound-call inventory is repo-root PRIVACY.md, which the
runtime messages in UpdateCheck.ts / Settings.ts also reference. Re-point
settings.json.{template,docker} and doc/docker.md there.
- doc/admin/updates.md said updates.tier="off" means "no HTTP request will leave
the instance", but the legacy UpdateCheck.ts call to ${updateServer}/info.json
is gated by privacy.updateCheck, not updates.tier. Clarify that air-gapped
installs must set PRIVACY_UPDATE_CHECK=false too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: link PRIVACY.md via absolute URL to satisfy VitePress dead-link check
PRIVACY.md lives at the repo root, outside the doc/ tree VitePress builds, so a
relative link to it (../PRIVACY.md / ../../PRIVACY.md) is flagged as a dead link
and fails `docs:build`. Use the absolute GitHub URL instead, matching how
doc/configuration.md already links settings.json.template.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(timeslider): port legacy mocha specs to Playwright, retire orphaned suite
The legacy src/tests/frontend/specs/ mocha suite is run by no CI workflow,
so its timeslider coverage was dead — a regression in the in-pad history UI
(#7659/#7946) sailed through CI. Port the still-meaningful cases to
frontend-new Playwright specs, re-targeted at the real in-pad UI (outer
banner / slider / export links that pad_mode.ts drives) rather than the
isolated ?embed=1 iframe the existing specs use:
- timeslider_revision_labels.spec.ts (from timeslider_labels.js): the
#history-banner shows 'Version N' + a valid (non-NaN) date and timer, and
both update when scrubbing to revision 0.
- timeslider_export_links.spec.ts (from timeslider_numeric_padID.js and the
'checks the export url' case of timeslider_revisions.js): the outer export
hrefs target /p/<pad>/<rev>/export/<type> for the viewed revision, including
a numeric pad id, and follow the slider to revision 0.
- timeslider_deeplink.spec.ts (from the 'jumps to a revision given in the url'
case): a #rev/N hash — and the legacy #N shortlink form — boots straight
into history mode at that revision.
Delete the three now-ported legacy specs. Verified passing on Chromium and
Firefox. The star-marker case of timeslider_revisions.js is already covered by
timeslider_saved_revisions.spec.ts (#7948).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(timeslider): make deep-link spec robust on Firefox
Assert the canonical #rev/0 URL and the slider landing on revision 0 rather
than the #history-banner-rev label text. The banner label is populated via a
MutationObserver bridge that races the iframe load on the bootstrap path in
Firefox (it is already covered for the normal button-entry flow by
timeslider_revision_labels.spec.ts); the URL + slider value are the
deterministic signals that the deep link entered history at the right revision.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(timeslider): address Qodo review on #7949
- Pin locale to en-US in the revision-labels spec so the localized 'Version N'
label and 'Saved <Month> <day>, <year>' date assertions are deterministic and
the date stays Date-parseable, instead of depending on the runner's locale.
- Use a high-entropy numeric pad id (timestamp + random) in the export-links
spec so reruns against a persistent DB can't collide on the same pad.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pad): show saved-revision markers in in-pad history mode (#7946)
When #7659 moved the timeslider into the pad as an embedded iframe, the
user-facing control became the outer #history-slider-input range input.
The saved-revision stars are still drawn by broadcast_slider.ts into the
iframe's #ui-slider-bar, but that DOM is hidden in embed mode, and
pad_mode.ts bridged rev/max/value/timer/authors to the outer slider but
never the saved revisions. Result: clicking "Save Revision" appeared to
work but no markers showed in the timeslider (regression in 3.3.x).
Bridge the embedded timeslider's clientVars.savedRevisions onto the outer
slider as percentage-positioned star markers, rendered on first sync and
re-rendered when the slider max changes. Markers are a purely visual,
aria-hidden overlay (keyboard/SR users already reach any revision via the
slider + step buttons) with a click-to-seek convenience for mouse users.
Adds a frontend-new Playwright spec exercising the real user flow (save a
revision in the pad, enter in-pad history mode, assert a visible marker on
the outer slider). The only prior coverage lived in the legacy mocha
suite (src/tests/frontend/specs/timeslider_revisions.js) which no CI
workflow runs, and the modern timeslider specs drive the ?embed=1 iframe
directly and never exercise the outer history UI — so this regression was
invisible to CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pad): live saved-revision markers + Qodo review fixes (#7946)
Address Qodo review on #7948:
- Live updates (bug 1): the server's SAVE_REVISION handler never broadcast
NEW_SAVEDREV, so the client handler that adds a star was dead and no open
timeslider ever updated live. Wire it up: Pad.addSavedRevision returns the
new revision (undefined on duplicate); handleSaveRevisionMessage broadcasts
NEW_SAVEDREV to the pad room. pad_mode.ts now sources outer markers from the
embedded slider's live #ui-slider-bar .star DOM (labels from clientVars) and
observes that bar so a revision saved by a collaborator appears on an
already-open history slider. Live editors ignore the unknown message type.
- Single-revision pad (bug 2): allow max === 0 so a revision saved at rev 0
still renders a marker instead of being cleared by the old max <= 0 guard.
- Test rigor (bug 3): assert the marker's inline left percentage directly
instead of falling back to a layout coordinate, which let left:0% pass.
Adds a two-client Playwright test for the live path (verified it fails with
the server broadcast removed). Backend Pad + pad API specs (88) still pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps ueberdb2 6.1.8 -> 6.1.9 (postgres pool-error handling + TCP
keep-alive for #7878; redis/rethink connection-error handlers so a
dropped DB connection no longer crashes the process) and adds the
3.3.1 CHANGELOG entry required by bin/release.ts before the release
workflow can run.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(admin): don't let one unreadable pad empty the Manage-pads list (#7935)
The admin /settings `padLoad` handler hydrates every pad via getPad() to
build the listing (the default lastEdited sort forces a full scan).
findKeys('pad:*','*:*:*') returns every key under the `pad:` prefix,
including legacy/foreign or migration-corrupted records — e.g. a value
stored as a JSON string rather than a pad object, which a botched
dirty.db -> PostgreSQL migration produces. Loading such a record makes
Pad.init throw `Cannot use 'in' operator to search for 'pool'`.
The handler had no try/catch, so one bad record rejected the whole
request: no `results:padLoad` was emitted (the SPA showed an empty
"No results" state forever — the reported symptom) and the unhandled
rejection could exit the server.
Make loadMeta resilient — a failed getPad() logs a warning and returns
zeroed metadata so the bad pad still surfaces for deletion instead of
hiding every other pad — and wrap the handler so it always emits a
terminal reply and never bubbles to the process-level handler.
Tests:
- backend: tests/backend/specs/admin/padLoadResilience.ts asserts a
corrupt pad:* record no longer hides the readable pads (fails without
the fix: no results:padLoad reply + server exit).
- e2e: tests/frontend-new/admin-spec/admin_pads_page.spec.ts covers
create pad -> welcome-page recent-pads -> /admin Manage-pads UI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(admin): sanitize padLoad error logs to avoid leaking pad content
Qodo review: the error logs (and the error field returned to the SPA)
used `${err}` / err.message verbatim. For the corruption case the
TypeError message embeds the raw stored value ("...'pool' in <value>"),
which for a real pad can be document text — so logging it verbatim could
leak content, bloat logs, and let embedded newlines forge log lines.
Add a safeErr() helper (error name + single-line, 120-char-capped
message, control chars stripped) and use it in both the per-pad warning
and the outer handler error log, and for the error field emitted to the
client.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(admin): make surfaced corrupt pads deletable from the admin UI
Qodo review: padLoad now surfaces unreadable pad:* records (zeroed
metadata) so admins can delete them, but deletePad's normal path
(doesPadExists -> getPad -> Pad.remove) can't touch such a record:
doesPadExists() returns false for a non-object value and getPad() throws,
so the surfaced pad was effectively undeletable and the handler emitted
nothing.
When doesPadExists() is false but a raw `pad:${id}` value is present,
fall back to a raw key purge: sweep sub-keys (revs/chat/deletionToken/…)
via findKeys, drop the readonly mapping, then padManager.removePad()
(which removes the main key + pad-list + cache entry). Always emit a
terminal results:deletePad reply (including for an already-absent id) so
the UI clears the row instead of silently doing nothing, and wrap the
handler so failures are logged (sanitized) rather than swallowed.
Adds a backend test asserting a surfaced corrupt pad can be deleted and
disappears from the listing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(admin): assert corrupt-pad delete via listing, not raw db.get
The delete test probed `db.get('pad:<id>')` for null right after deletePad.
That passed on postgres but failed on the dirty backend (Windows CI):
ueberdb2's per-backend read/write buffering can return the just-removed
value immediately after remove(), so it asserted storage internals rather
than behaviour. The deletion is still durable (same db.remove() every pad
uses; the in-memory pad-list entry is dropped synchronously). Assert the
behavioural outcome instead — the corrupt pad disappears from the padLoad
listing while the good pad remains.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When `allowPadDeletionByAllUsers` is true, anyone can delete a pad with no
token at all (handlePadDelete's flagOk branch), so the one-time deletion
token is pointless and the "Save your pad deletion token" modal only
overwhelms users who will never need it.
Gate token issuance on `!settings.allowPadDeletionByAllUsers` so no token
reaches clientVars; the client's showDeletionTokenModalIfPresent() then
returns early and the modal never appears. No new setting — it derives
automatically from the existing one.
Closes#7926.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The numbered-list branch of `domline.appendSpan` interpolated the line's
`start` attribute value into an `<ol start=...>` tag unquoted and unescaped,
then assigned the result to `node.innerHTML`. The value comes verbatim from
the attribute pool, which an attacker can populate via a crafted `.etherpad`
import (`ImportEtherpad.setPadRaw` validates attribute keys but not values).
A space-free value such as `1><svg/onload=...>` therefore broke out of the
tag and produced a live element, executing attacker JavaScript in the pad's
origin for every subsequent viewer of the pad or timeslider.
An `<ol>` start is only ever an integer, so coerce it with `Number.parseInt`,
omit the attribute entirely when the value is not a number, and HTML-escape +
quote it (the same `Security.escapeHTMLAttribute` treatment the sibling
`listType` class already gets, and that #7905 applied to the export sink).
Adds a backend regression test that drives the real domline render path
through jsdom and asserts the malicious value cannot produce a live element.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pad): show detected language in settings dropdown (#7925)
When the UI language was auto-detected from the browser (no language
cookie and no pad-wide lang set), refreshMyViewControls() and
refreshPadSettingsControls() set the language dropdown to
`<option>.lang || 'en'`. Since the detected language lives only in
html10n (not in padOptions/effectiveOptions), the value was undefined
and the dropdown fell back to hardcoded English — even though the pad
UI itself rendered correctly in the detected language.
Fall back to html10n.getLanguage() before 'en' so the dropdown reflects
the language actually rendered. getLanguage() returns the matched
lowercase locale key, which matches the <option value> keys.
Adds a regression test that loads a pad under a de-DE browser locale and
asserts the dropdown reads "Deutsch" without any manual selection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(language): assert German toolbar tooltip (Qodo #7928)
The detection-works precondition computed a boolean via locator.evaluate()
but never asserted it, so it could not fail. Assert the bold button's
parent title is the German "Fett (Strg-B)" with toHaveAttribute instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The OP still saw a white strip above the dark pad on iOS Safari even with the
theme-color metas in place. theme-color only tints the address-bar chrome;
the status-bar safe area at the very top is painted by iOS from the ROOT
canvas background. The colibris background variants only colour inner
containers (#editorcontainerbox, body descendants), leaving <html> and <body>
transparent (computed rgba(0,0,0,0)) with color-scheme: normal — so the canvas
fell back to the UA light default (white). Android tints its chrome from
theme-color, which is why it looked fine there but iOS did not.
Paint the <html> root per toolbar variant with the toolbar colour (matching
theme-color and the OP's request that the bar match the toolbar) and set
color-scheme so the safe area, toolbar, and address bar are one seamless
colour. Verified in a mobile viewport: dark-OS root background is now
rgb(72,83,101) (#485365, == --super-dark-color, the toolbar colour); light-OS
stays white. Colours mirror skin_toolbar_colors.ts.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(downstream): robust per-client error handling in run-clients.sh (Qodo)
- Move clone/fetch/checkout inside the per-client guarded block with explicit
'|| exit 1' on every step. set -e is suspended inside a subshell used as an
'||' operand, so relying on it silently swallowed clone/checkout failures
(and continued from the wrong cwd); explicit guards make one client's failure
a per-client fail=1 while the loop continues, and the run exits non-zero.
- Stop suppressing fetch errors; fetch only when the pinned commit isn't already
reachable, and surface the real error.
- Run manifest commands via 'bash -c' instead of 'eval' (trusted in-repo
allowlist; avoids double-parsing / leaking into this script's shell).
Verified: two bogus clients -> both reported, loop continues, exit 1; happy
path (cli, no server) -> vectors pass, smoke skips, exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(downstream): pin clients to their merged-commit SHAs
The three client Phase-2 PRs merged; bump each manifest ref from its
pre-merge PR-branch tip (now deleted / unfetchable) to its squash-merge
commit on main:
etherpad-pad -> 91620c6 (ether/pad#1)
etherpad-cli-client -> ebc516e (ether/etherpad-cli-client#136)
etherpad-desktop -> ab83da6 (ether/etherpad-desktop#78)
Verified each merged main carries the test entry points
(vectors.rs/smoke.rs; test:vectors/test:smoke scripts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(downstream): run manifest commands under bash strict mode (Qodo)
bash -c spawns a fresh shell without -e/-u/-o pipefail, so a pipeline-stage
failure inside a client's vectorTest/smokeCmd could be masked. Use
'bash -euo pipefail -c' so those failures surface as a non-zero exit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the per-kind orchestration (clone @ pinned ref, inject core's
freshly-generated wire-vectors fixture via ETHERPAD_WIRE_VECTORS, run each
client's vectorTest + smokeCmd against the booted server) in a testable
run-clients.sh, and flips the three manifest entries to enabled, pinned to the
commits that carry their Phase 2 tests:
ether/pad#1, ether/etherpad-cli-client#136, ether/etherpad-desktop#78.
Validated end-to-end locally against a real core: all three clients' vectors +
live smoke pass. Refs should be bumped to the squash-merge commits once those
client PRs land.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: design for downstream client compatibility tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: Phase 1 implementation plan for downstream compat tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(downstream): add golden wire-vector generator
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(downstream): add committed golden wire-vectors fixture
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(downstream): assert wire-vectors fixture stability + consistency
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(downstream): pin socket.io handshake + USER_CHANGES sequence
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(downstream): snapshot client-facing HTTP API shapes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(downstream): add client manifest (entries disabled pending Phase 2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(downstream): add downstream-smoke workflow (boot/self-check/teardown + manifest scaffold)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(downstream): validate settings rewrite + ignore docs/** (Qodo)
Fail fast if the template's port/auth literals drift so a no-op sed can't
silently boot the smoke server on the wrong port/auth. Also ignore docs/**
(not just doc/**) so docs-only PRs don't trigger the boot job.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `# 3.3.0` changelog section was written before ~8 commits landed on
develop. #7909 (dark mode) and #7918/#7911 (offline Docker boot) were already
captured; this adds the remaining user-facing change and dependency bumps so
the 3.3.0 release notes are complete:
- Notable fixes: #7910 — Firefox authorship-colour flake (early keystrokes
tagged author='' before the async userAuthor propagated, producing an
unattributed insert the pad-corruption guard rejected).
- Dependencies: mysql2 →3.22.5 (#7915), undici →8.4.1 (#7914), pdfkit
→0.19.0 (#7916), @radix-ui/react-switch →1.3.0 (#7913), and the extra
dev-dependency group bump (#7912).
Localisation (translatewiki) is already covered by the existing entry.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(release): remove deprecated bin/createRelease.sh
This script has carried a "DEPRECATED since Etherpad 1.7.0 (2018-08-17),
left here just for documentation" banner for years and is dead code:
- It authenticates to the GitHub API with the `?access_token=` query
parameter, which GitHub removed in 2021 — every API call (token check,
branch merge, release publish) now fails outright.
- It targets the old `ether/etherpad-lite` repo paths and calls
`bin/buildForWindows.sh` / `make docs`, neither of which is how releases
are built anymore.
- Nothing references it (no workflow, script, or doc).
The current release flow is the "Release etherpad" workflow
(.github/workflows/release.yml) driving bin/release.ts, then the tag-push
triggers handleRelease.yml + releaseEtherpad.yml. createRelease.sh only adds
confusion, so remove it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agents): document the release procedure and docs publishing
Add a "Releasing" section to AGENTS.MD so maintainers have a single
reference for cutting a release, instead of reverse-engineering it from
bin/release.ts and the workflow files.
Covers:
- Prerequisites: the CHANGELOG `# X.Y.Z` guard, and the requirement that all
four package.json files agree (release.ts reads the current version from
src/package.json) — the desync that blocked the 3.3.0 release.
- The one-dispatch flow: "Release etherpad" -> bin/release.ts -> tag push,
and what the vX.Y.Z tag auto-triggers (handleRelease GitHub Release,
docker, snap-publish), plus the separate manual npm publish dispatch.
- Documentation: the two distinct kinds of doc work — per-PR doc/ updates in
behaviour-change PRs, and the automated release-time versioned-docs publish
into the ether.github.com sibling repo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(docker): don't let pnpm self-provision a pinned version on offline boot (#7911)
The official Docker image installs pnpm directly via npm (corepack was dropped
for Node 25+). Standalone pnpm still honours the "packageManager" pin in
package.json: the image's pnpm intentionally lags that pin (pnpm 11.1.x enforces
a minimum-release-age policy the frozen-lockfile build can't satisfy), so pnpm
treats every invocation — including the informational `pnpm --version` probe
Etherpad runs at startup — as a request to download and run the pinned build.
Behind a corporate firewall / in an air-gapped install that download fails:
[WARN] plugins - Failed to get pnpm version: Error: Command exited with
code 1: pnpm --version
which is what #7911 reported.
Fix — neutralise the gap instead of closing it (closing it would break the
frozen-lockfile build on 11.1.x):
- Dockerfile build stage sets `pnpm_config_pm_on_fail=ignore` (the pnpm 11
successor to managePackageManagerVersions), inherited by the development and
production runtime stages. pnpm then uses the installed pnpm instead of
fetching the pinned one. It does not change which pnpm runs the build-time
install, so the frozen-lockfile build is unaffected.
- plugins.ts startup probe and the updater's pnpm-on-PATH checks run with the
same flag, so the fix also covers non-Docker offline installs and the probe
can never fail-loud.
Add a backend spec that fails CI if the offline guard is dropped while the image
pnpm differs from the package.json pin.
Verified with a standalone (non-corepack) pnpm: a "packageManager" mismatch
makes `pnpm --version` exit 1 by default (tries to fetch the pinned build), and
exit 0 reading the local version with pm_on_fail=ignore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: scope pnpm offline-guard check to the runtime-inherited build stage
Address Qodo review: the regression spec matched ENV pnpm_config_pm_on_fail
anywhere in the Dockerfile, so it would still pass if the guard were removed
from the `build` stage (which the runtime stages inherit) but left in the
throwaway `adminbuild` stage — reintroducing the offline failure. Extract the
`build` stage block and assert the ENV is present there specifically.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Release workflow has failed on the last two dispatches with:
Error: No changelog record for 3.4.0, please create changelog record
at bin/release.ts:146
3.3.0 was never released (latest tag/release is v3.2.0). The repo already
carries a complete `# 3.3.0` changelog section, and the intent is to ship
3.3.0 next. A prior commit reverted the root and admin/ package.json back to
3.2.0 to set up a clean 3.2.0 -> 3.3.0 minor bump, but missed src/package.json
and bin/package.json, which were left at 3.3.0.
bin/release.ts reads the current version from src/package.json, so it computed
minor(3.3.0) = 3.4.0 and aborted on the missing 3.4.0 changelog. Syncing src
and bin back to 3.2.0 (matching root + admin) makes the next `minor` dispatch
compute 3.2.0 -> 3.3.0, which satisfies the existing changelog check.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(theme-color): emit media-scoped dark variant for iOS Safari (#7606)
The theme-color meta only had a single light value rendered server-side;
dark mode was applied purely by JS (skin_variants.ts) after page load.
iOS Safari colors the address bar at parse time and does not reliably
repaint when JS mutates the meta later, so dark-mode iPhone users kept a
white address bar above a dark toolbar (the green Chromium Playwright test
masked this because Chrome does honor the dynamic update).
Emit a prefers-color-scheme media-scoped pair server-side so the correct
color is chosen at first paint without JS:
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#485365" media="(prefers-color-scheme: dark)">
- Add SkinColors.darkToolbarColor() (reuses toolbarColorForTokens).
- Expose enableDarkMode via getPublicSettings so the templates can gate the
dark variant on it (no dark variant when dark mode can't be reached).
- Apply to both pad.html and timeslider.html.
- updateThemeColorMeta now updates every theme-color meta so a manual
#options-darkmode toggle still wins over the media scoping on
desktop/Android.
- Backend + frontend tests updated to assert the media-scoped pair and the
enableDarkMode-off case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(theme-color): prevent the light-mode flash on dark-OS load (#7606)
The theme-color meta fix corrects the address-bar tint, but dark-OS users
still saw the whole page painted light before the JS bundle ran and applied
the dark skin classes in postAceInit — a visible flash on every browser,
not just the mobile address bar.
Add a tiny blocking inline script in <head>, before the stylesheet, that
applies the dark skin classes to <html> synchronously during parse when the
client is in dark mode (matchMedia + no localStorage white-mode override).
The condition mirrors pad.ts's auto-switch, which still runs on init to wire
up the #options-darkmode toggle and theme the editor iframes (those don't
exist yet at parse time). Gated on the same enableDarkMode + colibris check
as the dark theme-color variant. Applied to pad.html and timeslider.html.
Verified in Chromium: at domcontentloaded a dark-OS client's <html> already
carries super-dark-editor/dark-background/super-dark-toolbar (no flash), and
a light-OS client is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(changelog): note the dark-mode address-bar + flash fix (#7606)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(theme-color): guard pre-paint script on #skinvariantsbuilder; ignore updater state
Address PR review:
- Copilot: the inline pre-paint dark-mode script must skip the auto-dark
switch on the #skinvariantsbuilder hash, matching pad.ts — otherwise it
forces super-dark classes on a dark-OS client and fights the variants
builder UI. Added the guard to pad.html and timeslider.html and a backend
assertion so it can't regress.
- Qodo: ignore var/update-state.json (runtime updater cache) so the server
run that regenerates it can't dirty the tree or be committed accidentally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): tag inserts with clientVars author until userAuthor propagates
Fixes the intermittent Firefox `clear_authorship_color` flake ("clear authorship
colors can be undone to restore author colors").
Root cause: the inner editor's `thisAuthor` starts '' and is only populated when
collab_client's `setProperty('userAuthor', userId)` reaches the inner frame —
that call is queued by the outer ace wrapper via pendingInit until the iframe
loads, so it is applied asynchronously. Under Firefox timing the first keystrokes
can beat it, so freshly typed text is tagged author=''. An empty author
canonicalizes to no-author, producing an unattributed insert (`Z:1>5+5$Hello`)
that the server's pad-corruption guard rejects ("submitted an insert without an
author attribute"), dropping the whole USER_CHANGES and losing authorship. Undo
then cannot restore an author color and the test sees `<span class="">`.
Fix: a `getLocalAuthor()` helper that falls back to `clientVars.userId` (the same
author id, available synchronously in the inner frame — already used as `myId`
elsewhere) whenever `thisAuthor` is still empty, applied at the three new-text
insert sites. The intentional clear-authorship path (`['author','']`) and the
server-side guard are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): also seed line-attribute author to close the same race
The first commit fixed text inserts, but line-attribute changes (lists, headings,
alignment) emit a line-marker insert tagged with AttributeManager.author, which
likewise starts '' and is only set when the async setProperty('userAuthor')
lands. An early list/heading could therefore emit an unattributed line-marker
insert and hit the same server-side rejection.
Seed documentAttributeManager.author from getLocalAuthor() (clientVars fallback)
right after it is constructed; the userauthor handler keeps it in sync after.
Audited the other author-tagging paths: all ace2_inner text-insert sites go
through getLocalAuthor()/authorizer now, and contentcollector (paste/import)
derives authorship from the pasted DOM's author- classes via className2Author —
a different mechanism, not this async race — and self-guards on `if (state.author)`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): read the local author from the TOP pad window, not the inner frame
v1 of this fix fell back to `window.clientVars?.userId`, but that did NOT resolve
the flake — CI reproduced the identical failure and the server log still showed
the unattributed-insert rejection.
Ground truth via direct DOM measurement of a running pad: the inner editor
iframe's `window.clientVars` is NEVER populated (undefined at t=0/1/3/6s for the
life of the pad), so the v1 fallback always returned ''. The author id lives on
the TOP pad window (`window.top.clientVars.userId`), set by pad.ts when the
CLIENT_VARS message arrives — which necessarily precedes editor creation, so it
is reliably available from the inner frame the moment the editor can accept
input.
getLocalAuthor() now reads window.top.clientVars.userId (guarded with try/catch
for cross-origin embedded pads). thisAuthor still takes precedence once the
queued setProperty('userAuthor') lands. All text-insert sites and the
line-attribute seed already route through getLocalAuthor(), so both paths are
covered.
Verification: ts-check passes; fix source confirmed by direct measurement (the
top window reliably holds the author at editor-init time). Local end-to-end
repro was not possible — the dev server's require-kernel bundle did not reflect
working-tree edits — so this is validated against the measured source + CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): walk ancestor frames for the author instead of window.top
Addresses a correctness gap flagged in review: reading window.top.clientVars
fails for same-origin embeds, where window.top is the host page (accessible, so
no exception is thrown) and has no Etherpad clientVars — getLocalAuthor() then
returns '' and the unattributed-insert bug recurs.
Walk up the ancestor chain (inner -> ace_outer -> pad window) and return the
first frame that has clientVars.userId. This stops at the pad window and never
depends on window.top, so it is correct for normal pads, same-origin embeds, and
cross-origin embeds (the try/catch ends the walk if an ancestor is cross-origin,
though the pad window — always same-origin with the editor frames — is reached
first). Behavior in the non-embed case is unchanged (pad window is 2 hops up).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(collab): stamp the author onto outgoing inserts in collab_client
Replaces the earlier getLocalAuthor attempts (v1-v3, reverted from ace2_inner.ts),
which all failed because they tried to *read* an author that genuinely does not
exist yet: the local author id (clientVars.userId) only arrives with the
CLIENT_VARS socket message, and under load (Firefox + plugins) the editor can
become editable and the user can type before that message lands. At that instant
there is no author anywhere client-side, so the editor emits an unattributed
insert that the server's pad-corruption guard rejects — dropping the change and
losing authorship (the clear_authorship_color flake).
Fix where the author IS reliably available: collab_client only exists after
CLIENT_VARS has arrived, so its `userId` is always populated. A new pure helper
stampAuthorOnInserts() rewrites the outgoing wire changeset so every '+' op
carries an author, stamping userId onto any that lack one, just before it goes on
the wire (both the USER_CHANGES send and the reconnect/further-changeset path).
Independent of editor-init timing.
Verification:
- Unit test (stampAuthorOnInserts.test.ts, 5 cases): stamps the exact flake
changeset `Z:1>5+5$Hello` -> authored + checkRep-valid + text preserved; leaves
already-attributed inserts and keep/remove-only changesets unchanged; never
invents an author when userId is empty; preserves other attributes.
- ace2_inner.ts reverted to develop (the editor is unchanged; the fix is fully
localized to the send path).
- Local run of the clear_authorship_color spec with the fix: all pass, no
server-side unattributed-insert rejections; full backend-new vitest green
(the unrelated backend-tests-glob env-only failure aside).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Require ADMIN_PASSWORD and the database password to be provided explicitly
(no implicit fallback).
- Default TRUST_PROXY to false.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Hardening: API request handling, token generation, and plugin loading
- pad_utils.randomString: generate the random IDs via crypto.getRandomValues
(CSPRNG) instead of Math.random.
- OAuth2Provider: constant-time password comparison and a uniform failure delay
on the OIDC interaction login; own-property user lookup.
- API.appendChatMessage: require the pad to already exist (getPadSafe),
consistent with the other content API methods.
- RestAPI /api/2: forward only the authorization header rather than merging all
request headers into the API field set.
- LinkInstaller: validate plugin dependency names before building filesystem
paths from them.
- admin file server: return a generic error message and log details server-side.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Address review: timingSafeEqual on raw bytes; align /api/2 auth fallback
- OAuth2Provider.constantTimeEquals: compare raw UTF-8 bytes with
crypto.timingSafeEqual instead of hashing them first. Resolves the CodeQL
"password hash with insufficient computational effort" alert while keeping a
content-independent comparison (length difference is covered by the uniform
failure delay).
- RestAPI /api/2: fall back to the authorization header whenever the field is
falsy (not only null), matching the openapi.ts handler so the two routers
authenticate identically (Qodo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* appendChatMessage: throw explicit error instead of getPadSafe (review)
Per review: replace the getPadSafe(padID, true) existence check with an
explicit `throw new CustomError('padID does not exist', 'apierror')` so chat
messages can't create pads, without fetching the pad.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Escape exported data-* attributes; require explicit deploy credentials
- ExportHtml: escape the name and value of attributes emitted by the
exportHtmlAdditionalTagsWithData hook, consistent with the URL/text
escaping already applied when generating exported HTML.
- docker-compose: require ADMIN_PASSWORD and the database password to be set
explicitly (no default fallback); default TRUST_PROXY to false.
- Settings: log a warning (error level in production) when an account uses a
default/placeholder password from the shipped config.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Settings: also warn on default OIDC client secrets
Extend the placeholder-credential check to cover sso.clients[].client_secret,
so a deployment that enables SSO without setting ADMIN_SECRET / USER_SECRET is
flagged (error level under NODE_ENV=production) the same way default account
passwords are.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Move docker-compose changes to a separate PR
The deployment-default changes (required credentials, TRUST_PROXY) are being
discussed separately; this PR keeps only the non-breaking export escaping and
credential-warning changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In-pad history mode positioned the timeslider iframe (#history-frame-mount)
as an absolute, inset:0 overlay over #editorcontainerbox. That filled the
editor area but took the iframe out of flow, so any in-flow side panel in
#editorcontainerbox — e.g. ep_webrtc's video column (#rtcbox) — ended up
hidden beneath the overlay. In live mode the same panel sits beside the
editor, so the two modes disagreed (ether/ep_webrtc rendered nothing in the
timeslider).
Make the iframe occupy the same in-flow flex slot the live editor uses
(flex: 1 1 auto in the row-flex #editorcontainerbox) so side panels lay out
identically in both modes.
This surfaced a latent bug: `body.history-mode #editorcontainer { display:
none }` (specificity 0-1-1) was being outranked by the two-id layout rule
`#editorcontainerbox #editorcontainer { display: flex }` (0-2-0), so the live
editor was never actually removed from flow — the absolute overlay merely
painted over it. With the iframe now in normal flow that no longer hides the
editor, so the hide rule is given matching specificity
(body.history-mode #editorcontainerbox #editorcontainer) to win the cascade.
Added a padmode.spec.ts regression test that injects a left-pinned in-flow
side panel, enters history mode, and asserts the live editor is display:none,
the iframe is not absolutely positioned, and the iframe lays out beside the
panel filling the editor's remaining width (no overlap).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: URL-encode pad names in admin 'Open' button and recent pads (#7865)
- encodeURIComponent in admin PadPage 'Open' button href
- decodeURIComponent when reading pad name from URL pathname
in pad_userlist.ts and colibris/pad.js (recent pads storage)
- encodeURIComponent in colibris/index.js recent pads href;
display text uses stored name directly (no double-decode)
- add recent_pads spec asserting encoded URLs
- add share dialog spec asserting URL encoding of special chars
* fix: address Qodo review on recent-pads encoding and admin Open button
- Normalize legacy URL-encoded recentPads names before re-encoding the
href in colibris/index.js, preventing double-encoding (%2F -> %252F)
of entries stored by older versions.
- Add noopener,noreferrer to the admin 'Open' window.open call to
prevent reverse tabnabbing, matching the pattern used elsewhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pad): attribute default welcome text to the system author (#7885)
When a user opens a brand-new pad, CLIENT_READY calls
getPad(padId, null, session.author), and Pad.init attributed the
auto-generated default content (settings.defaultPadText or a
padDefaultContent hook substitution) to that author. The welcome text —
which the user never wrote — therefore carried the creator's `author`
attribute and rendered in their authorship colour.
Track whether the initial text came from the default-content path and,
if so, attribute the initial changeset to the stable system author
(Pad.SYSTEM_AUTHOR_ID) instead of the creating user. Explicitly provided
text (e.g. HTTP API createPad with text + author) keeps the real author.
The creating user becomes a listed author only once they actually type;
their "ownership" of the pad (pad-wide settings defaults, the author
token) does not depend on owning the default text, so it is unaffected.
listAuthorsOfPad already filters out the system author, so the public
API reports zero contributors for an untouched default pad.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pad): keep creator as revision-0 author, only de-colour the text
CI (socketio "Pad-wide settings creator gate") and Qodo both caught that
attributing revision 0 to the system author stripped the creating user's
ownership: isPadCreator() / the pad-wide settings gate and the deletion
token all key off getRevisionAuthor(0).
Decouple the two: the initial revision's meta.author stays the real
creator (ownership preserved), while only the welcome text's `author`
*attribute* — the thing that colours it — becomes Pad.SYSTEM_AUTHOR_ID.
The system fallback for the revision author now applies solely when no
author was supplied at all.
Tests assert both halves: default text is system-coloured (not the
creator's colour) AND the creator remains the revision-0 author; explicit
text stays coloured with the creator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(wcag): target the user-authored span, not the system welcome text
The wcag_author_color spec picked the first `span[class*="author-"]` on
the page to measure the user's colour contrast. With #7885 the default
welcome text is now owned by the system author and renders with no
background colour, so that first span is no longer the current user's —
the contrast read came back as transparent rgba(0,0,0,0) and the three
assertions failed.
Match the author span by the text we just typed ("contrast smoke")
instead, so the test measures the actual user-authored content it
intends to. Verified locally: 3/3 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: refresh docs for 3.2.0 — correct stale content, document recent features
The hand-maintained VitePress docs under doc/ had drifted behind a lot of
recent work. They are authored prose (not generated from the OpenAPI spec),
so they need manual upkeep. This pass corrects content that was actively
wrong and documents features shipped since they were last touched.
Corrections (was wrong / misleading):
- cli.md: every command used `node bin/foo.js`, but the scripts are
TypeScript run via pnpm — copy-paste failed. Rewrote to
`pnpm run --filter bin <script>`, documented ~13 previously-undocumented
operator tools, and split running-vs-stopped requirements. Registered the
missing `compactStalePads` script in bin/package.json so the documented
invocation actually works.
- stats.md: described a pre-Prometheus world. Rewrote for the gated
`/stats` (JSON) and `/stats/prometheus` endpoints, the live metric set,
the opt-in `scalingDiveMetrics` instruments (#7756), and `measured-core`.
- admin/updates.md: removed three false "SMTP not yet wired" claims (it is,
via nodemailer + the `mail.*` block), documented the `node-engine-mismatch`
preflight check and the rollback/preflight failure emails, and stripped
obsolete "PR 1 / PR 2" staging language now that all tiers ship.
- api/http_api.md: added the undocumented `anonymizeAuthor` (GDPR Art. 17)
call, fixed copyPad/movePad version annotations (1.2.8 → 1.2.9), corrected
getPadID's param name (readOnlyID → roID), and dropped a reference to a
non-existent `getEtherpad` API call.
- skins.md: colibris is the current default, not an "experimental" skin for
a future 2.0.
- localization.md: bare `window._('key')` is unbound and returns undefined;
recommend `window.html10n.get(...)` / data-l10n-id instead.
- README.md: bumped the v2.2.5 upgrade example to v3.2.0; fixed a
docker.adoc link to docker.md.
- docker.md: added MAIL_*, ENABLE_METRICS, GDPR_AUTHOR_ERASURE_ENABLED,
PRIVACY_BANNER_*, PUBLIC_URL, AUTHENTICATION_METHOD, ENABLE_DARK_MODE,
ENABLE_PAD_WIDE_SETTINGS; fixed the SOCKETIO_MAX_HTTP_BUFFER_SIZE default
(50000 → 1000000).
New documentation:
- configuration.md (new): how settings + `${VAR:default}` substitution work,
trustProxy, and — the previously-undocumented feature — running under a
subpath/ingress via x-proxy-path / X-Forwarded-Prefix / X-Ingress-Path,
with the sanitizer rules and Traefik/NGINX examples. Wired into the
VitePress sidebar and the index hero.
- hooks_server-side.md: ccRegisterBlockElements (the server-side companion
plugin authors miss), exportConvert, exportHTMLSend, createServer,
restartServer, and clientReady (marked deprecated).
- hooks_client-side.md: aceDrop, acePaste, handleClientTimesliderMessage_<name>.
VitePress build passes. The legacy .adoc set was intentionally left in place
— it still feeds the per-version doc archives published to ether.github.com
at release time (bin/release.ts), so it is not dead and is out of scope here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: address Qodo review — drop .js invocations from configuration.md and CLI help
- configuration.md: the settings-override example referenced a nonexistent
`node src/node/server.js`. Use the supported launcher instead
(`bin/run.sh -s <file>`), and note the runtime is server.ts via tsx.
- compactStalePads.ts / compactPad.ts / compactAllPads.ts: their header
comments and runtime usage output still printed `node bin/*.js`, which
points at files that don't exist. Switched to the documented
`pnpm run --filter bin <script>` form so the --help text matches the docs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* css: restore URL wrapping in pad editor (#7894)
Long URLs in the pad editor overflow instead of wrapping because the
global a { white-space: nowrap } rule in pad.css overrides the wrapping
properties set on #innerdocbody. Add explicit white-space, word-wrap,
and overflow-wrap to #innerdocbody a so URLs wrap inside the editor
while preserving no-wrap behavior for links elsewhere in the UI.
Fixes#7894
* test: add regression test for long URL wrapping in pad editor (#7894)
* test: add regression test for long URL wrapping in pad editor (#7894)
* fix: pad-wide view settings apply to the creator's own view (#7900)
The pad creator is never "enforced upon themselves" (isPadSettingsEnforcedForMe
is false whenever canEditPadSettings is true), so getEffectivePadOptions always
merges their personal view overrides (cookies) on top of the pad-wide options.
As a result, a creator who had at some point toggled e.g. their personal "Read
content from right to left" carried a stale rtlIsTrue=false cookie that silently
masked the pad-wide value they later set — toggling the pad-wide control (and
then enforcing it) appeared to do nothing on the creator's own screen.
Fix: when the creator changes a pad-wide view option, sync their personal pref
to the chosen value so their own view adopts the pad-wide setting immediately.
This deliberately does NOT change the precedence model — the creator can still
override the setting afterwards via the "My view" controls, so the existing
behaviour where a creator keeps personal overrides while enforcing settings for
other users (pad_settings.spec.ts) is preserved.
Adds a frontend test reproducing the stale-personal-cookie scenario.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: address Qodo review — clear cookies on the page's own context (#7900)
browser.newContext()+clearCookies() cleared cookies on a throwaway context
(not the page fixture under test) and leaked the context. Use
page.context().clearCookies() so the regression test reliably starts without a
stale rtlIsTrue pref.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* timeslider: respect author colors, font, line numbers from pad editor
- nice-select.ts: dispatch native change event after jQuery trigger so
addEventListener-based bridges (pad_mode.ts) fire (jQuery 3.7.1
trigger() does not dispatch native DOM events)
- timeslider.ts: fix font-family reset (jQuery 3 ignores null css value);
add showAuthorColors + showLineNumbers + padFontFamily support;
wire cookie read/write for all three settings
- broadcast.ts: gate author color CSS on .authorColors class
- pad_mode.ts: bridge author colors, font family, line numbers checkboxes
from pad settings into embedded timeslider iframe
- add Playwright test for showAuthorshipColors
* timeslider: tidy settings-bridge wiring
Refactor only — no behaviour change.
pad_mode.ts: collapse the five ad-hoc listener stores (playbackChange-
Listener, followChangeListener, authorColorsListeners, fontFamily-
Listeners, lineNumbersListeners) into the single outerControlListeners
list via a shared bindOuter() helper, so every outer-control listener is
registered and torn down through one uniform path. The three view-setting
bridges (author colours, font family, line numbers) become one
data-driven bridgeView() over their element ids instead of three
near-identical closures.
timeslider.ts: hoist applyPadFontFamily to a top-level helper next to
applyShowAuthorColors (keeping the '' reset for jQuery 3), and co-locate
the three BroadcastSlider view-setting methods that were split across the
function.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: RTL content option no longer flips the whole page (#7900)
The pad's RTL content option (rtlIsTrue) is a per-pad setting that should
only change the direction of the editor's text contents. The setter in
ace2_inner.ts wrote the direction to the top-level `document.documentElement`
(`document` there resolves to the main pad page, since the editor iframes are
derived from it), which flipped the entire page — toolbar and chrome included.
The page direction is owned solely by the UI language (l10n.ts sets it from
html10n.getDirection()). Apply the content direction to the inner editor
document (`targetDoc.documentElement`) instead, so enabling RTL content leaves
the surrounding interface untouched.
Adds a frontend test asserting the inner editor flips to RTL while the
top-level <html> dir stays ltr.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: address Qodo review — robust frame access and locale-independent page-dir assertion (#7900)
- Use a frameLocator chain (Playwright auto-waits) instead of
page.frame('ace_inner')! which can race with inner-editor readiness.
- Don't hardcode dir='ltr' on the top-level <html> (the UI language can
legitimately pick rtl). Capture the initial page dir and assert it is
unchanged after toggling the pad RTL option — that is the real invariant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ci): remove the silent-ELIFECYCLE investigation scaffolding
The Windows backend flake is root-caused (server.ts handler gate for the
in-process process.exit path; Windows pinned to Node 24.16.0 for the libuv
TCP-connect overrun, tracked upstream as nodejs/node#63620). Remove the
temporary diagnostics added while hunting it:
- delete src/tests/backend/diagnostics.ts (per-test heartbeat + node-report
snapshots) and its `--require` from the backend `test` script;
- drop the `--report-on-fatalerror`/`-on-signal`/`-uncaught-exception`
`NODE_OPTIONS` and the "Upload Node diagnostic reports" steps;
- drop the Windows OS-level netstat/tasklist sidecar watcher.
Kept: the real fixes — the log-only unhandledRejection guard in
tests/backend/common.ts, the lowerCasePadIds socket-teardown tracking (comment
de-referenced from the deleted file), and `pnpm test -- --exit` on Windows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: drop the obsolete report-on-fatalerror NODE_OPTIONS guard assertion
The backend-tests-flake-mitigation source-lint guard required every backend
step to keep the --report-on-fatalerror NODE_OPTIONS + node-report upload. Those
diagnostics are removed in this PR now that the flake is root-caused, so drop
that assertion. Retain the Windows-only `--exit` checks (still a live invariant)
and reframe the file around the resolved root cause.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): stop a single leaked promise rejection from killing the whole backend suite
Root cause of the long-standing Windows backend-test "silent ELIFECYCLE"
flake (~22% of runs, rotating across random spec files, no mocha summary,
no JS-handler trace, bypassing --report-on-fatalerror / Defender / Windows
event log / AeDebug). Found by capturing a full-memory dump of the dying
node.exe with Sysinternals ProcDump (-t dump-on-terminate) and symbolizing
it against Node 24.15.0's node.pdb. The dying thread's stack:
exit_or_terminate_process / common_exit (CRT exit)
node::Exit
node::DefaultProcessExitHandlerInternal
node::Environment::Exit
node::ReallyExit (process.exit binding)
... v8 MicrotaskQueue::RunMicrotasks ...
node::InternalCallbackScope::Close
No exception stream — a *clean* ExitProcess, not a crash. The job log
pinned the trigger:
[INFO] server - Exiting...
AssertionError at tests/backend/specs/SessionStore.ts:235
at process.processTicksAndRejections
Mechanism: a timing-fragile test (SessionStore touch/expiry specs use real
setTimeout against a 200ms-expiry session; socket.io delay-race specs are
similar) gets timed out and abandoned by mocha, but its async body keeps
running. When its trailing assertion later throws, it surfaces as an ORPHAN
unhandled rejection belonging to no awaited test. Three handlers then
escalated that into a whole-process exit:
- server.ts installed process-global uncaughtException/unhandledRejection
handlers that call exports.exit() → process.reallyExit() (production
graceful-shutdown behaviour, catastrophic in-process under mocha)
- common.ts (PR #7663) and diagnostics.ts (PR #7838) rethrew the rejection
and process.exit(1)
Because it's a deliberate, clean exit it bypassed every forensic layer; it
rotated across files because the orphan rejection lands during whatever test
is running; it's Windows-mostly because event-loop timing makes the abandoned
test's assertion fire in a *later* test's window more often there.
Fix (two halves):
1. server.ts: gate the process-global uncaughtException / unhandledRejection
/ signal handlers behind `require.main === module`. They are correct for
a real Etherpad process but must not fire when server.start() is called
in-process by a test runner — mocha owns process-level error handling
there. Mirrors the existing `if (require.main === module) exports.start()`
idiom; production (node server.js) is unchanged.
2. common.ts + diagnostics.ts: the backend-test bootstraps now LOG unhandled
rejections instead of rethrowing / exiting. Orphan rejections cannot be
cleanly attributed to a test, so rethrowing only yields an
ERR_MOCHA_MULTIPLE_DONE abort. Real failures are unaffected — an assertion
in a test's own awaited path rejects that test's promise and mocha fails
it normally, never reaching this global handler.
Verified locally: a spec that leaks a delayed rejection during a later test
now reports `3 passing` / exit 0 with the rejection logged, instead of
aborting the run.
Follow-ups (separate PRs): harden the SessionStore / socket.io timing specs
to not leak (fake timers); remove the now-unneeded diagnostic scaffolding
(diagnostics.ts heartbeat/node-report, the #7846 OS sidecar) now that the
cause is known.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): run Windows backend tests on Node 25 to dodge the libuv connect overrun
Node 24.x's bundled libuv has a stack buffer overrun in the Windows TCP-connect
path (uv__tcp_connect / uv__tcp_try_connect), proven by a SilentProcessExit
full-memory dump of the dying mocha process: the main thread executes
__fastfail(FAST_FAIL_STACK_COOKIE_CHECK_FAILURE) from __report_gsfailure with
TCPWrap::Connect -> uv_tcp_connect on the stack. It fires under the backend
suite's heavy localhost connection churn, is address-family independent (occurs
on both sockaddr_in and sockaddr_in6, so an IPv4 pin does NOT help), and -- being
memory corruption -- bypasses all JS/Node observability, rotating across tests
as the "silent ELIFECYCLE" flake (~22% of Windows runs).
Empirically: Node 25 = 16/16 green; Node 24 (even with an IPv4 pin) = ~39% fail.
Node 25's newer bundled libuv does not overrun. Linux stays on Node 24 LTS (the
bug is Windows-specific). Revisit once the libuv fix is backported to 24.x.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): pin Windows backend to Node 24.16.0 (libuv fix) instead of 25
Bisect (standalone repro) pinpointed the fix to Node 24.16.0 (libuv 1.52.1):
24.15.0 (libuv 1.51.0) crashes the connect overrun 4/4 on 127.0.0.1, while
24.16.0 is clean 0/8. 24.16.0 stays on the Node 24 "Krypton" LTS line, so prefer
it over Node 25 (non-LTS). Pinned explicitly because setup-node's default
check-latest:false reuses the runner's pre-cached 24.15.0 for a bare "24".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(ci): reference upstream nodejs/node#63620 in the Windows Node-pin comment
Links the explicit 24.16.0 pin to the filed upstream issue so the pin can be
dropped back to plain "24" once the libuv connect-overrun fix is across the
supported 24.x baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In-process diagnostics (diagnostics.ts heartbeat at 5 Hz + node-report
snapshots on every beforeEach and heartbeat tick) merged in #7838 and
#7842 reach a hard ceiling: during every captured death window the V8
main isolate is event-loop-starved for 200-400 ms before the process
is externally terminated, so any timer-driven probe (heartbeat,
setTimeout, --report-on-signal handler) never gets serviced and we
have zero JS-visible state from the actual moment of death.
To capture state during the starvation window we need a probe whose
own scheduling does not depend on the dying process's libuv event
loop. This commit adds a tiny bash background loop to the Windows
backend-test steps (both with- and without-plugins). Every 500 ms it
appends:
- netstat.log: localhost TCP socket state — surfaces TIME_WAIT /
CLOSE_WAIT accumulation or ephemeral-port exhaustion that the
in-process libuv handle list can't see (libuv only shows handles
Node currently knows about; the kernel may hold many more sockets
in disposal states).
- tasklist.log: node.exe process state from the Windows OS view
(handle count, working set, CPU time), independent of whether V8
is responsive.
Both files land in $GITHUB_WORKSPACE/node-report/ which is already
the artifact-upload target on failure, so they ride for free on
existing infrastructure. The watcher is killed cleanly after `pnpm
test` returns so it never holds the runner open.
On the next captured silent ELIFECYCLE we'll have, for the first
time, a 500 ms-resolution external observation of TCP and process
state across the death window.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'enter is always visible after event' test asserted that the last
line was within the browser viewport using boundingBox().y + height vs
window.innerHeight. Those values live in different coordinate spaces
(boundingBox is outer-page; window is per-frame), and the comparison
is fundamentally unable to model what the editor's auto-scroll actually
guarantees: visibility inside the ace_outer iframe, not within the
outer browser viewport.
Any plugin that adds chrome above or below the editor (toolbar rows,
sidebars, etc.) pushes the iframe's bottom below the browser viewport
while auto-scroll has correctly placed the cursor at the iframe's
bottom — failures look like 'Expected: > 731, Received: 720'. An
earlier attempt to switch to toBeInViewport({ratio: 1}) traded the
false positives for false negatives under chromium + plugins because
the inner iframe's contents can report ratio 0 against the outer
viewport even when the line is visible inside the editor.
Drop the visibility assertion entirely. The test's real value — that
Enter keystrokes produce new lines and the editor's input pipeline
keeps up — is exercised by the per-iteration toHaveCount value-wait
in the loop above. Visibility under plugin chrome is a separate,
plugin-aware concern.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(oidc): fix OIDCAdapter broken flows
* fix(oidc): fix storage type to include string for userCode index
* test(oidc): add regression tests for OIDCAdapter broken flows
* chore(root): drop redundant top-level files
Three small clean-ups to make the project root easier to scan for new
contributors:
- best_practices.md was a near-duplicate of CONTRIBUTING.md. Merge its
two unique bullets (PRs MUST include a description / flag empty
descriptions as incomplete) into CONTRIBUTING.md and remove the file.
- .pr_agent.toml is a two-line Qodo PR-bot config. It has no functional
references in the repo and Qodo currently doesn't review this repo.
- tests/ -> src/tests was an unused root symlink. Verified no workflow,
script, eslint config, or playwright config relies on the root path
(all callers use src/tests/... or the ep_etherpad-lite package path).
No CI, build, or docs updates needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(root): fix dead tests/frontend + best_practices.md refs
Follow-up on Qodo review feedback for #7839:
- CONTRIBUTING.md: front-end-tests path now reads src/tests/frontend/
instead of the dead tests/frontend/ (the root tests symlink is gone).
The browser URL <yourdomainhere>/tests/frontend stays — that's an
Express route served by the test runner, not a filesystem path.
- docs/superpowers/specs/...openapi-design.md: drop best_practices.md
from the parenthetical list of policy sources (now CONTRIBUTING.md
and AGENTS.MD), since best_practices.md has been removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(pad): URL view-option params lost to padeditor.init race (#7840)
`?showLineNumbers=false` and `?useMonospaceFont=true` were being
silently clobbered shortly after pad load. Same race that affected
`?rtl=false` before #7464:
1. _afterHandshake → getParams() sets settings.LineNumbersDisabled
(or useMonospaceFontGlobal, noColors).
2. _afterHandshake calls padeditor.init(view).then(postAceInit) —
async; ace iframes still loading.
3. Sync tail of _afterHandshake hits the URL-param overrides and
calls changeViewOption('showLineNumbers', false) etc. These
queue setProperty('showslinenumbers', false) in Ace2Editor's
actionsPendingInit queue (loaded=false).
4. ace.init resolves → loaded=true → queue flushes → URL-driven
value applied.
5. padeditor.init resumes past its own await and calls
setViewOptions(initialViewOptions) — initialViewOptions is
built from clientVars.initialOptions.view (server defaults
∨ cookie), which does NOT carry the URL preference. The
resulting setProperty('showslinenumbers', true) runs against
loaded=true ace and immediately re-shows the gutter.
#7464 noticed this race for RTL and moved the override into
postAceInit. The neighbouring blocks for showLineNumbers / noColors
/ useMonospaceFontGlobal were left at the synchronous-tail site —
generalise the same fix to all three.
Direct-browser users typically had a `prefs` cookie with
showLineNumbers=false from a prior in-pad toggle, so the
initialViewOptions value happened to match the URL param and the
race was unobservable. Cross-context iframe embeds (the reporter's
configuration) start with no cookie, so the server default true
fights the URL false and the race becomes visible.
Adds src/tests/frontend-new/specs/url_view_options.spec.ts covering
the showLineNumbers=false / =true / useMonospaceFont=true cases on
initial-load navigation (the path where the race actually fires).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(types): annotate navigateWithParam helper params
Fixes CI ts-check: parameters page/padId/param implicitly had `any`.
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 flag gates the ep_* passthrough on padoptions that shipped in 3.0.0
(PR #7698). It was introduced as opt-in, but the intent in shipping it
was to let plugins like ep_plugin_helpers' padToggle / padSelect ride
the existing broadcast/persist rail out of the box — flipping the
default closes the gap.
Why now
- ep_comments_page#422 (and sibling per-plugin reports discussed on
Discord): stock 3.x deployments console.warn on every pad load because
the helper detects clientVars.enablePluginPadOptions === false and
tells the admin to flip it. With the flag default-true, the warning
stops firing on fresh installs while still surfacing for operators
who have explicitly opted out.
- Plugins that already depend on ep_plugin_helpers >= 0.6 expect the
pad-wide path to work; the default-false gate silently no-op'd
pad.changePadOption('ep_*', …) and made the helper UI inert.
Scope
- Settings.ts default flipped to true; comment rewritten to describe
the new "operator opt-out" model rather than the old AGENTS.MD §52
opt-in framing (that policy still applies to *new* features; this
one has shipped and proven safe).
- settings.json.template env-var substitution default flipped to true
so docker / supervisor configs without an explicit value get the
new behavior.
- doc/plugins.md updated to match (default true, opt-out via
settings.json) and the PluginCapabilities source comment.
- Backend test describe-blocks relabeled — "true" is now "(default)",
"false" is now "(operator opt-out)". Both branches still cover the
same matrix so the size-cap / namespace-validation paths stay
exercised.
Compat
- Existing deployments with an explicit `"enablePluginPadOptions":
false` in settings.json keep that value — no migration needed.
- Older clients only read clientVars.enablePluginPadOptions; the
protocol shape is unchanged.
Closes ep_comments_page#422 (helper warning suppression for stock
deployments).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): schedule a mid-test snapshot 150ms after every beforeEach
Run 26401801404 (PR #7841 after merging develop) captured the dying
test's beforeEach node-report — be-0258, written 75ms after
socketio.ts > "Pad-wide settings creator gate different browsers"
entered — but no further state. The kill landed 321ms into the test
body, between 1 Hz heartbeat ticks, and the 100ms boundary throttle
prevented further beforeEach writes inside the same test. The report
we have shows only the listening server socket; the connections that
the test body creates (and that presumably precede the kill) never
get snapshotted.
Schedule an unref'd setTimeout from beforeEach that fires 150ms after
the test entered. If it's still the running test at fire time (i.e.
slow enough that the death window applies), capture a node-report
from INSIDE the test body — the moment when the real TCP / socket.io
activity is in flight. Fast tests (<150ms) skip the write because
afterEach has already cleared currentTest by the time the timer
fires.
Result on the next reproduction of the death pattern:
- be-NNNN report at +75ms (beforeEach, body not yet started)
- mt-MMMM report at +150ms (mid-test, body in flight, before kill
at +320ms)
- kill, no further reports
Cost: only slow tests (>150ms) generate an mt report, so the
artifact size growth is bounded by the count of tests that take
longer than 150ms — typically a small minority. Locally verified
against a 3-test probe: 2 fast tests skipped, 1 300ms test produced
the expected mt-NNNN snapshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): bump heartbeat from 1Hz to 5Hz
Run 26402211271 produced be-0207 (the dying test's beforeEach
snapshot) but no mid-test snapshot, even though setTimeout(150ms)
was scheduled and the test body lived another 280 ms after that
deadline. setTimeout under Windows-runner load is being starved
past the deadline — we already saw the previous test's mt fire at
+252 ms (102 ms late) when the deadline was 150 ms, so the dying
test's timer was likely scheduled to fire well after the kill at
+425 ms.
setInterval has fired reliably throughout the investigation
(every heartbeat in every run lands within ~1 s of schedule, even
when setTimeout misses). Bump heartbeat to 200 ms (5 Hz) so any
death window ≥200 ms is sampled inside the test body, independent
of how starved setTimeout is.
Cost on the Windows runner: the existing log shows writeReport
completes in <1 ms (from "Writing Node.js report" to "Node.js
report completed" timestamps), so 5 Hz adds ~5 ms/s of overhead.
Artifact growth: ~500 reports for a 100 s test phase (~25 MB raw,
~5 MB compressed). The setTimeout mid-test snapshot stays — it's
belt-and-suspenders cheap and fires for slow tests where the
heartbeat alone might not align with the death window.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: isolate mt/hb writes from `be` throttle + gate timer on canWriteReport
Qodo flagged two real issues on #7842:
1. The single shared `lastReportT` made `mt` writes poison the `be`
throttle window. Slow tests trigger an `mt` write at +150 ms, then
the test ends a few ms later, and the NEXT test's `beforeEach`
landed within the 100 ms throttle from the `mt` write — so its
own `be` snapshot was suppressed. That's the exact boundary
coverage the throttle is supposed to PROTECT. Local repro with a
180 ms slow test followed by a fast one confirmed: the fast
test's `be-0004` is now captured instead of swallowed.
Fix: split into `lastBoundaryT` used and updated only by `be`
writes. `hb` and `mt` pass `updateThrottle=false` and never
advance the boundary timestamp.
2. `setTimeout` was being scheduled in `beforeEach` for every test
even when `canWriteReport` is false (Linux backend matrix, local
dev). That's a wasted timer per test for no possible diagnostic
output. Gate the schedule itself on `canWriteReport`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): heartbeat + running-test pointer in backend test diagnostics
Backend tests silently die with code 255 mid-suite ~22% of the time on
develop (most often Windows-with-plugins, Node 24). Each kill lands
300±50 ms after the previous test's clean ✔ teardown line and produces
no failing-test marker, no error, no Mocha summary, and — despite the
unconditional handlers in `diagnostics.ts` — none of the JS-level death
events fire either. Recent example: run 26311025244 (`Windows with
Plugins (24)`); both attempts crashed at completely different "last
test" locations, so the dying test itself isn't to blame.
The existing diagnostics only set lastSeenTest in afterEach, so if the
kill lands during the NEXT test's setup or body — which is exactly the
~300ms gap we observe — the pointer reads as the previous (passing)
test. That hides whether we're between tests or inside one, and which
one.
Two changes:
1. Track currentTest in beforeEach as well as lastFinishedTest in
afterEach. Every diag line now carries both, so the death point is
bracketable regardless of which lifecycle phase the kill interrupts.
2. Add a 1Hz heartbeat that writeSyncs the running-test name plus
`process.memoryUsage()` (rss, heap) and the active-handle and
active-request counts. The interval is unref'd so it never holds the
event loop open by itself. Cost is roughly one extra log line per
second of mocha runtime (~60-120 lines per CI run).
When the next failure fires, the last heartbeat narrows the kill window
to ≤1s, the running pointer names the test on the rails at that moment,
and the handle/memory trace gives a sparkline that exposes sudden
spikes — a leaked socket, an unref'd timer, a runaway map — that
would otherwise be invisible at the runner-log level.
No behavior change on successful runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: heartbeat _getActiveHandles optional chain bug (Qodo #2)
Qodo correctly flagged `_getActiveHandles?.().length` as a latent
TypeError: `?.()` guards the call but the call's `undefined` return
on a missing method still hits `.length`, which throws. Since the
heartbeat fires on a setInterval inside the mocha bootstrap, a Node
build without the underscore-prefixed internals would take down the
whole backend test run.
Capture the array first, then read `.length` only when it actually
exists. -1 stays as the "API missing" sentinel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): per-test start diag + drop stray console.log noise
Follow-up to the heartbeat PR after run 26397693748 confirmed the
diagnostic works (the kill landed at importexportGetPost.ts
'Import authorization checks > authn anonymous !exist -> fail',
~300 ms after the previous test's ✔). Two cleanups so the next
failure pinpoints faster and reads cleaner:
1. diagnostics.ts: emit a `test start: <name>` diag line in the
mocha beforeEach hook, after setting the currentTest pointer.
The 1Hz heartbeat misses tests that take less than a second,
and the silent kills land ~300 ms after a test boundary —
precisely the gap where heartbeat resolution fails. A start
line per test gives sub-millisecond resolution on which test
was on the rails when the process died.
2. specs/api/importexportGetPost.ts: drop a stray
`console.log(importedPads)` debug leftover (and the duplicate
`await importEtherpad(records)` only present to feed it) in
the `malformed .etherpad files are rejected` block. The leftover
dumped a ~600-line reflection of a supertest Response object
to the CI log on every successful run, drowning the surrounding
test output and making the silent-kill window much harder to
read.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): write node-report on every heartbeat tick
Run 26398054688 narrowed the kill to a specific test
(pad.ts > Gets text on a pad Id and doesn't have an excess newline)
but the test body is a trivial supertest GET — the kill bypasses
all JS handlers, so we can't capture stack state at death.
Two failures across two runs share the shape: an agent.{get,post}
+ common.generateJWTToken() call dies ~300-600 ms after test start,
with no JS-visible cause. The next step is V8 + native stack.
Hook into the existing 1Hz heartbeat to call
process.report.writeReport(path) whenever a report directory is set.
The Windows backend-tests workflow already wires up
`--report-directory=${{ github.workspace }}/node-report` via
NODE_OPTIONS and uploads that directory as an artifact on failure,
so the rolling snapshots ride for free on the existing upload step.
Each report (~50 KB) contains:
- V8 + native call stacks for all threads
- libuv active handles (open TCP, timers, file handles)
- JS heap statistics
- resourceUsage + system info
- shared-object list
On the next reproduction the latest report before ELIFECYCLE will
sit ~0-1 s before the kill — enough to see whether the V8 stack
is inside jose's WebCrypto sign path, inside supertest's TCP
roundtrip, or somewhere unexpected entirely.
NODE_REPORT_DIR is also honored as an explicit override for local
repro / non-workflow runs.
Cost: ~6 files (~300 KB) per Windows backend-test failure, plus
~50 ms event-loop pause per heartbeat. No-op when neither env var
is set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: writeReport with bare filename, not mixed-slash absolute path
Run 26398830249 exposed the path-separator bug in the previous commit:
every heartbeat tick on the Windows runner logged
Failed to open Node.js report file:
D:\a\etherpad\etherpad/node-report/hb-NNNN-...json
directory: D:\a\etherpad\etherpad/node-report (errno: 22)
— EINVAL. The workflow sets --report-directory with forward-slash
separators on Windows, then this code concatenated another `/` plus
the filename, producing a path Node's report writer rejects.
writeReport(fileName) takes a BARE filename and resolves it against
the configured report directory using the platform-correct separator
internally. Switch to that. For local repro overrides via
NODE_REPORT_DIR, push the path into process.report.directory (the
documented config knob) instead of joining it into the call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): write node-report on test boundaries, throttled to 4Hz
Run 26398985832 proved the heartbeat-only report cadence isn't tight
enough: the last report before the kill was hb-0013 at +16201ms,
~1.5 s before ELIFECYCLE at +17701ms — during which ~30 tests fired,
including the dying one (`authn anonymous !exist -> fail`). The
captured V8 stack is just our heartbeat code, not the dying test.
Move the writeReport call to a shared tryWriteReport() helper and
invoke it from BOTH the heartbeat AND mocha's beforeEach hook,
throttled to one report per 250 ms. That gives ≤250 ms resolution
on the kill window — close enough that the latest report captures
state from inside the dying test rather than from the test ~30
slots earlier. The heartbeat always writes (so we don't lose the
no-test-running ticks during setup); beforeEach only writes when
the throttle window has elapsed.
Cost ceiling: ~4 reports/sec × ~12 s test phase ≈ 48 reports
(~2.5 MB) per failing run. Each writeReport adds ~50 ms of
event-loop pause — at 4Hz that's 20% of wall time spent in
diagnostics, which is acceptable for a temporary debug-only
bootstrap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): drop beforeEach report throttle from 250ms to 100ms
Run 26399285213's rerun captured a sixth death point on the new 4Hz
cadence (`socketio.ts > Duplicate-author handling > cookie identity:
same-author second socket kicks the first`, kill at +45953ms, 271ms
after test start). The throttle suppressed the dying test's own
beforeEach: previous boundary write landed 128 ms earlier and the
next 31 ms after that, both inside the 250 ms window. Last captured
report (be-0100) is from the previous test.
100 ms is still well above the inter-test cadence in fast burst
suites (tests fire 2-5 ms apart, so 20-50 of them get throttled to a
single write, ceiling ~10 writes/sec). But it's tight enough that
any death-window neighbour ≥100 ms after the previous report — the
shape we keep observing — gets its own boundary snapshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): explain env-var substitution in /settings, surface auth errors (#7819)
Three small, env-var-only UX improvements driven by issue #7819, where a
Docker operator saved an ep_oauth block in the admin /settings raw view
and reported it "disappeared" — but the underlying confusion was that
settings.json on disk is a *template*, not the effective config. None of
these changes is visible to installs that don't use ${VAR} placeholders.
* Banner above the editor explaining the template/env-substitution model,
only rendered when the loaded file contains a ${VAR} placeholder. Tells
the operator that the file is not env-substituted in place and that the
Effective tab shows the live values.
* Effective tab in the mode toggle, read-only, also gated on ${VAR}. The
backend was already emitting redacted runtime settings as `resolved`
alongside every `load`; the SPA now exposes them so an operator can
verify what Etherpad is actually using.
* admin_auth_error event from the /settings socket handler. The handler
previously silently returned when the connecting session wasn't admin,
which made misrouted Traefik+SSO auth look like "save did nothing" with
no error path in the UI. Emit a dedicated event before dropping the
socket so the SPA can show a clear toast.
Tests:
- src/tests/backend/specs/admin/adminSettingsAuthError.ts — new spec for
the auth_error/disconnect contract.
- src/tests/frontend-new/admin-spec/adminsettings.spec.ts — new Playwright
test asserting the banner + Effective tab only appear after a ${VAR}
is added to settings.json, and that the Effective view is read-only +
shows [REDACTED] for secrets.
No behaviour change for installs without ${VAR} placeholders — banner,
Effective tab, and auth-error contract are all the same as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin): drop fragile pre-condition + add reconnect-loop guard (#7819)
CI's admin-UI workflow seeds settings.json by copying settings.json.template
verbatim, which contains ~30 \${VAR} placeholders. The new Playwright
test asserted "banner not present before adding placeholder" — true on a
fresh dev machine, false in CI. Drop that assertion: the negative path
is covered by the SettingsPage ENV_VAR_PATTERN regex itself; what
matters at the UI level is the positive path (banner + Effective tab
render correctly when placeholders are present), which this test still
exercises.
Also: the server's admin_auth_error path calls socket.disconnect(),
which the SPA's existing disconnect handler interprets as "io server
disconnect" and immediately reconnects — creating a reject/reconnect
loop. Track an authErrored flag and suppress the reconnect once an
auth_error has been received. Reset on successful connect, so a
legitimate re-auth path still works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#7835.
- src/locales/en.json: add `index.code` (referenced by src/templates/index.html
for the session-receive code input but never defined, producing a
"Couldn't find translation key" console error on the landing page).
- admin/src/utils/LoadingScreen.tsx, admin/src/pages/PadPage.tsx,
admin/src/pages/AuthorPage.tsx: every @radix-ui/react-dialog `Dialog.Content`
now has a `Dialog.Title` and `Dialog.Description` (visually hidden via
`@radix-ui/react-visually-hidden` where there is no visible heading),
silencing Radix's a11y console warnings on every admin page load.
- src/tests/backend-new/specs/template-l10n-keys.test.ts: regression
coverage — fails CI if any `data-l10n-id` in `src/templates/*.html` is
missing from `src/locales/en.json`.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related operator-facing docs gaps, both surfaced by #7819:
1. settings.json on disk is a *template*; env-var substitution happens
at load time in memory only. Operators repeatedly mistake the
templated file for a stale config because the docs never spell out
that the on-disk file is intentionally unchanged by env vars.
2. The default docker-compose.yml puts settings.json in the container's
writable layer with no host mount, which means admin /settings edits
are silently lost on `docker compose down && up`, `pull`, or
watchtower — but preserved across plain `restart`. Operators don't
reliably know which compose verbs recreate the container.
Adds two prose sections to doc/docker.md (explaining both gotchas, with
a recreate-vs-restart table) and a commented-out `./settings.json:…`
bind mount in both docker-compose.yml and the README compose example.
Bind mount is opt-in so existing setups behave identically.
No runtime change.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: page sessionstorage cleanup to avoid OOM (#7830)
SessionStore._cleanup() previously called `findKeys('sessionstorage:*',
null)`, materialising every session key into a single array. On decade-
old MariaDB installs with millions of sessions this OOMs the node
process within ~15 minutes — see #7830.
Switch to ueberdb2 6.1.0's findKeysPaged with a 500-key page size, and
yield to the event loop between pages so the DB driver can release each
page's buffered rows and request handlers can interleave.
The break is now driven by `page.length === 0` rather than `page.length
< CLEANUP_PAGE_SIZE` so a stubbed/throttled paged source still iterates
the full keyspace.
Adds a regression test that seeds 50 sessionstorage rows, monkey-patches
`DB.findKeysPaged` to use a 4-key page, runs cleanup, and asserts every
expired row is removed plus every valid row preserved across page
boundaries.
Closes#7830
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Qodo review on #7831
Four follow-ups raised by Qodo on the session cleanup paging fix:
- DB.ts: fail-fast at init() if any required wrapper method (incl.
findKeysPaged) is missing, so a stale ueberdb2 pin surfaces at boot
rather than crashing the first cleanup run an hour later.
- SessionStore: bound a single _cleanup() run to 10 minutes. Under
sustained session creation the keyspace can grow faster than cleanup
drains it; without a budget the next scheduled run would never fire.
When the budget hits, log a warning and let the next run continue.
- SessionStore: log the defensive `page[0] <= after` cursor-stall break.
Previously the loop exited silently, leaving expired rows behind with
no operator-visible signal of the backend regression.
- Tests: the paged-cleanup regression test now removes both expiredSids
AND validSids in finally, so a failed assertion doesn't leak rows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: note paged session cleanup in CHANGELOG + settings template
CHANGELOG.md picks up an entry under 3.1.0 Notable fixes describing the
OOM cause, the paged iteration, the 10-minute per-run budget, the
cursor-stall logging, and the fail-fast init guard.
settings.json.template's sessionCleanup comment adds the page-size,
budget, and pointer to #7830 so admins can reason about the new
behaviour from the template alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: regenerate lockfile against ueberdb2 6.1.2
Now that ether/ueberDB#983 unblocked the publish workflow (OIDC trusted
publishing), ueberdb2 6.1.2 is live on npm and the `^6.1.0` pin in
src/package.json resolves cleanly. Resolves the ERR_PNPM_OUTDATED_LOCKFILE
that was blocking CI on this PR.
29 SessionStore backend tests still green against the published tarball.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(docker): admin save persists across container restart (#7819)
The OP reports the symptom on the official Docker image specifically.
Adds two layers of coverage to docker.yml's build-test job, driven from
inside a container started against the same TEST_TAG the existing
test-container step uses:
1. New mocha spec adminSettings_7819.ts under tests/container/specs/api
— authenticates against /admin, opens the /settings socket, saves an
augmented JSON with an ep_oauth-shaped top-level block, and asserts
the next load reply contains the marker. Intentionally leaves the
marker on disk so the workflow can inspect it.
2. docker.yml now `docker exec test grep`s for the marker after
test-container, then `docker restart`s the container, waits for the
health probe, and re-greps. Both checks must pass — the first proves
the socket-driven save actually touched the file inside the
container layer; the second proves an in-place restart doesn't reset
it. A recreate (docker rm + docker run) would wipe the file, but
that's expected (image layer) and out of scope.
Container is started with `-e ADMIN_PASSWORD=changeme1` so the existing
settings.json.docker provisions the admin user; pad.js doesn't touch
/admin so the existing API specs are unaffected. test-container timeout
bumped 5s → 30s to cover socket connect + save round-trip, and the
mocha discovery extension list now includes `ts` so the new spec is
picked up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(docker): authenticate via /admin-auth/ POST, surface auth/load failures fast (#7819)
CI failed on #7821 with a generic 20s mocha timeout because the spec
hit GET /admin/ to grab a session cookie. webaccess.ts only treats
paths starting with /admin-auth as requireAdmin — and the container
runs with REQUIRE_AUTHENTICATION=false (default), so GET /admin/ never
issued a Basic challenge and Set-Cookie was empty. The socket then
connected unauthenticated, adminsettings.ts's connection handler
returned early without binding any listeners, and the load() promise
hung until mocha killed the test with no useful diagnostic.
Switch to POST /admin-auth/ (always-requireAdmin, regardless of
settings.requireAuthentication). Assert a 2xx with at least one
Set-Cookie before proceeding. Add an 8s timeout + meaningful error
message to load() so the "session was not admin" failure mode reports
immediately instead of burning the suite budget.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(docker): replace splice with hand-built payload (#7819)
Last CI failed because the splice-after-last-} approach landed a comma
between an existing trailing-comma-before-comment and the close brace
of settings.json.docker, producing `, /* … */, "ep_oauth"` — invalid
JSON.
settings.json.docker uses jsonc `/* */` and `//` comments and a
trailing-comma-before-comment-before-close shape that's annoying to
patch from the test side, and the existing isJSONClean has zero
backend coverage so the splice is going through Etherpad's lenient
write path anyway.
Switch to a hand-built minimal-but-viable settings document containing
the ep_oauth block. Three properties hold:
- We're testing the WRITE path, not the synthesis path. Whatever
bytes we send, the next `load` must return verbatim.
- The post-save document must survive `docker restart` (the next
step in docker.yml) — minimal-but-viable means port/users/dbType
are present so Etherpad boots back up and HEALTHCHECK passes.
- The next `load` reply must equal the bytes we saved
(`reply.results === augmented`) — stronger than `.includes()`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The admin saveSettings socket had zero direct backend coverage and the
e2e 'restart works' test only checked the page renders after restart —
neither catches a deployment that resets settings.json on restart, nor
the user-visible workflow that triggered #7819 (add a top-level plugin
block via Raw, save, watch it disappear).
Adds three backend specs for the saveSettings socket:
- payload is written byte-for-byte to settings.settingsFilename
- augmenting existing JSON with a new top-level block round-trips
through the next load reply
- /* */ comments survive the write path
Adds one e2e spec mirroring the #7819 workflow: open Raw, prepend an
ep_oauth-shaped top-level block, save, restartEtherpad(), re-login,
confirm the block is still in Raw and surfaces as its own Form-view
section ('Ep oauth' from humanize()).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ep_readonly_guest is archived (read-only on GitHub) and its
authenticate hook unconditionally swaps req.session.user with a
read-only guest, even when the request carries an HTTP Authorization
header. That silently demoted admin login attempts and stalled the
anonymizeAuthorSocket tests for 14 min/run on every with-plugins CI
matrix (#7795). The pre-fix theory blamed ep_hash_auth.handleMessage;
the actual hook trace is a red herring — handleMessage only fires on
the /pad namespace and never on /settings.
ep_guest is the maintained successor (same authors, same purpose).
1.0.72 on npm already includes the "defer to basic auth / admin
paths" fix backported to ep_readonly_guest by intent here. Swapping
the matrix unblocks the anonymizeAuthorSocket suite on Linux,
Windows, and the upgrade-from-latest-release workflow.
The runtime probe added in #7796 stays — it still catches any other
authenticate-hook plugin that rejects the test's plain-text
credentials (e.g. a future ep_hash_auth-style hashed-only plugin).
Reattribute its comment so future readers don't chase ep_hash_auth.
Closes#7795.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: design for URL base-path support (#7802)
Spec covers the architecture, header handling rules, components touched,
backwards-compatibility story, risks, and test plan for honoring
X-Forwarded-Prefix / X-Ingress-Path under trustProxy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: amend #7802 spec with discovery of pre-existing proxy-path helpers
After exploring the codebase, much of the proposed architecture is
already in place (sanitizeProxyPath, padBootstrap.js basePath derivation,
admin SPA rewrite). Spec now reflects the actual delta: header source
expansion, /manifest.json prefix-awareness, socialMeta proxyPath honoring,
and template URL touch-ups for index/timeslider/pad/export.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(plan): implementation plan for URL base-path support (#7802)
Adds the bite-sized TDD task list to ship X-Forwarded-Prefix /
X-Ingress-Path support: extends sanitizeProxyPath, makes /manifest.json
and socialMeta prefix-aware, touches up the remaining leading-slash
URLs in index/pad/timeslider/export templates, fixes a pre-existing
manifest .. count bug. Drops the originally-proposed <base href>
belt-and-braces after discovering it'd break the existing relative
URLs in pad.html/timeslider.html and wouldn't help plugin DOM
injection anyway (path-absolute URLs ignore <base>'s path component).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(proxy): accept X-Forwarded-Prefix and X-Ingress-Path under trustProxy (#7802)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(pwa): make /manifest.json honor sanitised proxy-path (#7802)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(social-meta): honor proxyPath in from-request og:url and og:image (#7802)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(templates): index.html manifest + jslicense links honor proxyPath (#7802)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(templates): pad.html reconnect/jslicense honor proxyPath; fix manifest .. count (#7802)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(templates): timeslider.html reconnect/jslicense honor proxyPath; fix manifest .. count (#7802)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(templates): export_html.html manifest honors proxyPath when available (#7802)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: end-to-end coverage for X-Forwarded-Prefix / X-Ingress-Path (#7802)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(settings): trustProxy also enables X-Forwarded-Prefix / X-Ingress-Path (#7802)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: spec for admin/settings resolved runtime values (#7803)
Side-channel resolved+redacted settings alongside raw file blob.
Form view dropdowns and env pill chips reflect actual runtime values
instead of falling back to template defaults. Save round-trip is
unchanged so ${VAR:default} literals stay intact on disk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: implementation plan for admin/settings resolved runtime (#7803)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): add redactor for resolved settings payload (#7803)
Pure helper that walks the live settings module and replaces known
sensitive paths (users.*.password, dbSettings.password,
sso.clients[*].client_secret, sessionKey, …) with [REDACTED] sentinel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): emit redacted runtime settings on /settings socket load (#7803)
Existing 'results' raw-file blob is unchanged so the textarea editor
and saveSettings round-trip continue to preserve \${VAR:default}
literals on disk. New 'resolved' field carries the in-memory settings
module run through the redactor — admin SPA can use it to show actual
runtime values next to env-var placeholders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): show resolved runtime value on EnvPill (#7803)
Admin SPA now stores the resolved field from the /settings socket
payload and exposes useResolvedAt(path) to walk it. EnvPill renders a
"→ active value" chip when the path is resolved, or "→ ••••••" with a
redacted tooltip when the server returned the [REDACTED] sentinel.
Old-server fallback (undefined resolved) keeps current behaviour.
The admin test script glob now picks up .test.tsx alongside .test.ts
so the new EnvPill tests run under tsx --test.
Closes#7803.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(updater): resolve pad author from token cookie, not session.user
Etherpad does not populate an authorID into the express-session user
object for pad visitors, so resolveRequestAuthor() always returned null
in production, causing computeOutdated() to return EMPTY and the
pad-side gritter to never fire.
Replace the session-based lookup with a cookie-based path that mirrors
how the socket.io handshake resolves pad-visitor identity: read the
HttpOnly `token` (or `<prefix>token`) cookie and call
authorManager.getAuthorId(token, user) via dynamic import (same
circular-init guard pattern as the PadManager import).
Update the test harness to mock AuthorManager instead of injecting a
fake req.session.user.author, and to set req.cookies.token directly.
All 9 cases continue to pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(openapi): clarify admin spec scope includes pad-side endpoints
/api/version-status is a public pad-side endpoint but lives in the
admin OpenAPI document because it shares the same internal route
registration. Add a note to info.description so downstream tooling
consumers are not misled into treating it as an admin-only route.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: design spec for #7799 outdated-notice redesign
Per-pad first-author gating, dismissable gritter, minor-or-more rule, drop vulnerable UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: implementation plan for #7799 outdated-notice redesign
12 bite-sized tasks, TDD-first where applicable; closes the spec end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(updater): add isMinorOrMoreBehind, drop major/vulnerable helpers
Adds isMinorOrMoreBehind(current, latest) which returns true only when
the latest release is at least one minor version ahead (patch-only deltas
return false). Removes isMajorBehind, parseVulnerableBelow, and
isVulnerable from versionCompare.ts — callers in updateStatus.ts,
VersionChecker.ts, and index.ts will be updated in subsequent tasks.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(updater): drop vulnerable-below directive and state field
Remove VulnerableBelowDirective type, UpdateState.vulnerableBelow field, and
all related scraping/checking logic (parseVulnerableBelow, isVulnerable imports).
Clean up Notifier, OpenAPI schema, and all test fixtures to match.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(updater): drop residual EmailSendLog vulnerable fields
Remove `vulnerableAt` and `vulnerableNewReleaseTag` from the
`EmailSendLog` interface, `EMPTY_STATE`, and the `isValidEmail`
validator — these backed the removed `vulnerable`/`vulnerable-new-release`
email kinds and are now dead code.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): add firstAuthorOf helper
Export firstAuthorOf() from updateStatus.ts — finds the lowest-numbered
author attrib in a pad's pool, skipping empty-string placeholders.
Covered by 6 vitest cases in tests/backend-new/specs/hooks/express/.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): add resolveRequestAuthor helper for HTTP GET
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): pad-aware /api/version-status with first-author gating
Replace global badge cache with a per-(padId, authorId) LRU cache. The
new response shape is {outdated: 'minor' | null, isFirstAuthor: boolean};
the old 'severe'/'vulnerable' enum is dropped entirely. computeOutdated
now resolves the pad's first author and compares it against the session
author before returning outdated:'minor', so the notice is only shown to
the person who created the pad.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): switch isSevere signal from major-only to minor-or-more behind
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(updater): end-to-end coverage for /api/version-status
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(openapi): /api/version-status pad-aware shape and gating
Add the /api/version-status GET operation to the admin OpenAPI spec with
the new pad-aware response shape: outdated enum reduced to [minor]|null,
isFirstAuthor boolean, and an optional padId query param.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(pad): remove unused #version-badge template and CSS
* feat(pad): replace persistent badge with first-author outdated gritter
Renames pad_version_badge.ts → pad_outdated_notice.ts and rewrites it
as a fire-and-forget gritter notice that only shows when the API reports
outdated=minor AND the current user is the pad's first author. Wires
the new maybeShowOutdatedNotice() call into pad.ts immediately after
showPrivacyBannerIfEnabled().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(pad): playwright coverage for outdated notice gritter
Six Playwright specs exercise maybeShowOutdatedNotice: null response,
isFirstAuthor:false guard, positive appearance + text, X-dismiss,
500 server error tolerance, and 8 s auto-fade.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(pad): outdated-notice redesign + drop vulnerable-below docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(test): remove stale specs for deleted #version-badge surface
Delete the GET /api/version-status describe block from the legacy mocha
spec (asserted outdated:null and outdated:'severe' — both no longer match
the new response shape). The new vitest spec at
tests/backend-new/specs/hooks/express/updateStatus.test.ts covers this
surface comprehensively.
Delete src/tests/frontend-new/specs/pad-version-badge.spec.ts entirely:
all three tests reference the #version-badge DOM element removed in Task 8
and stub 'severe'/'vulnerable' enum values that no longer exist.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: clean stale references to vulnerable/severe in types, emails, docs
- Remove OutdatedLevel type (null|'severe') from types.ts — no consumers
remain after the badge redesign removed the severe tier.
- Fix Notifier severe-email body: was "more than one major release behind"
but isSevere now fires on minor-or-more, so update to "at least one
minor release behind the latest published version".
- Drop "vulnerability directives" from the /admin/update/status OpenAPI
description; replace with the actual response fields.
- Remove stale vulnerableBelow field from UpdateStatusPayload in
admin/src/store/store.ts — server no longer sends it.
- Fix docs/admin/updates.md: "pad-side badge" → "pad-side notice".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(l10n): short-circuit form controls in translateNode (no spurious warning)
`<select data-l10n-id="...">` with `<option>` element children — the
pattern used by ep_headings2, ep_align, ep_font_size, ep_font_family, …
— used to drop into the textContent branch of html10n.translateNode and
hunt for a text-node child to overwrite. There is none, so the loop
exited with `found = false` and emitted:
Unexpected error: could not translate element content for key ep_headings.style
The SELECT/INPUT/TEXTAREA aria-label fallback already lived inside the
same else-branch, *after* the warning, so the accessible name landed
correctly but the noisy console line still fired on every pad load.
Move the form-control case into its own `else if`, before the text-node
hunt: aria-label is the only sensible localization target for these
elements (a <select>'s text is its <option> labels, not its own name).
Closes the console warning reported on Etherpad 3.1.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): actually exercise the form-control short-circuit branch
Qodo's review on #7797 caught two real test bugs:
1. `pad.toolbar.bold.title` ends in `.title`, which is in html10n's
attribute allowlist. translateNode picks `prop = 'title'`, takes
the first branch (node[prop] = str.str), and never reaches the
textContent path where the warning lives. The test would pass
without my fix.
Switched the key to `pad.loading` — same stable pad-bundle
translation, but the suffix isn't in the allowlist, so `prop`
defaults to `textContent` and the test actually exercises the
regression path.
2. `page.waitForTimeout(50)` is a fixed sleep, but `html10n.localize`
runs its work inside a `build()` callback, so completion timing
depends on the loader. Replaced with a deterministic `html10n.mt
.bind('localized', …)` await.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin/pads): apply filter chip server-side, before pagination
Before: PadPage's filter chip (`active`/`recent`/`empty`/`stale`) ran
on the client AFTER the 12-row page slice was already on screen. On a
deployment with hundreds of pads it produced obviously wrong results
— click "empty pads" on page 1 with 100 empties and only the 0–12
empties within the current page passed the filter. thm reported this
on a 3.1.0 deployment.
Move the filter into `PadSearchQuery` so the `/settings` socket can
apply it before slicing:
1. pattern filter on names (cheap)
2. hydrate metadata for the matching pad universe iff a non-`all`
filter is set or a non-`padName` sort is requested
3. apply filter chip on the hydrated set
4. sort + slice → `total` reflects the filtered universe so the
pagination footer makes sense
The original handler also had a 4-way `if/else if` that duplicated the
hydrate-and-sort loop per `sortBy`. Folded those into one pipeline
with a single comparator switch.
Client side, `PadPage.tsx`:
- drop the client-side `filteredResults` filter (server already filters)
- chip click writes `filter` into searchParams (debounced refetch) and
resets `currentPage` to 0
- older clients that don't send `filter` keep working — server defaults
to `all`
Stats cards (totalUsers/activeCount/emptyCount) still count the visible
page only — that's a pre-existing UI limitation tracked separately.
Closes the regression thm reported.
Test plan
- `tsc --noEmit` clean (server + admin)
- New backend spec `padLoadFilter.ts` exercises filter:empty with
small `limit` to lock in the bug-fix, plus all/active/omitted cases
- `5 passing` locally on Node 25
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* address Qodo review on #7798
1. Functional setState updaters for every searchParams mutation
(Qodo bug 1). The debounced pattern handler captured a render-time
snapshot of searchParams; a faster chip click or sort change in
between would be silently reverted when the debounce fired. Now
every mutation merges against the latest state.
2. Concurrency-limited hydration (Qodo bug 3). The earlier draft
issued Promise.all over the full candidate set, fanning out to
thousands of in-flight padManager.getPad() reads on busy
deployments. New mapWithConcurrency() caps concurrent loads at 16
— empirically enough to saturate a single ueberDB driver without
pushing the event loop into back-pressure.
3. Test cleanup deletes the injected test-admin (Qodo bug 4). The
original snapshot/restore pattern saved `settings.users` by
reference; reassigning the same reference in after() left the
inserted key in place and could leak into later backend specs.
4. Document the new `filter` field on the `padLoad` socket query in
admin/README.md (Qodo rule violation 2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Decision pending: finish that config, or remove this workflow. See AGENTS.MD.
name:releaseEtherpad.yaml
permissions:
contents:read
id-token:write # for npm OIDC trusted publishing
on:
workflow_dispatch:
inputs:
confirm:
description:'PARKED — publish ep_etherpad to npm? Requires a trusted publisher configured on npmjs.com first (see workflow header). Set true only if that is done.'
required:true
default:false
type:boolean
env:
PNPM_HOME:~/.pnpm-store
@ -12,8 +36,16 @@ jobs:
release:
runs-on:ubuntu-latest
steps:
- name:Guard — refuse unless explicitly confirmed
if:${{ inputs.confirm != true }}
run:|
echo "::error::releaseEtherpad is PARKED. The ep_etherpad npm publish is non-functional"
echo "::error::(no trusted publisher configured on npmjs.com; 0 dependents on npm)."
echo "::error::Re-run with confirm=true only after the owner configures a trusted"
echo "::error::publisher. See the workflow header / AGENTS.MD 'Releasing' section."
exit 1
- name:Checkout repository
uses:actions/checkout@v6
uses:actions/checkout@v7
- uses:actions/setup-node@v6
with:
# OIDC trusted publishing needs npm >= 11.5.1, which requires
@ -22,7 +54,7 @@ jobs:
registry-url:https://registry.npmjs.org/
- name:Upgrade npm to >=11.5.1 (required for trusted publishing)
@ -211,6 +211,38 @@ pnpm run plugins ls # List installed
### Settings
Configured via `settings.json`. A template is available at `settings.json.template`. Environment variables can override any setting using `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`.
## Releasing
Releases are driven almost entirely by GitHub Actions. A maintainer dispatches **one** workflow; the version bump, tagging, GitHub Release, Docker images, and snap all cascade off the pushed tag. The npm publish is a separate manual dispatch.
### Prerequisites (check these before dispatching)
- **A `# X.Y.Z` changelog section for the _target_ version must already be at the top of `CHANGELOG.md`.**`bin/release.ts` aborts with `No changelog record for X.Y.Z, please create changelog record` if it's missing. Write the section before dispatching.
- **All four `package.json` files must agree on the current version:** root `package.json`, `src/package.json`, `admin/package.json`, `bin/package.json`. `release.ts` reads the *current* version from **`src/package.json`** and computes the next with `semver.inc(current, type)`. If the files are out of sync (e.g. one was hand-edited), the computed target is wrong and the changelog guard fails. *(This exact desync — `src`/`bin` left at 3.3.0 while root/admin were 3.2.0 — blocked the 3.3.0 release in June 2026.)*
### Cutting a release
1. **Actions → "Release etherpad"** → Run workflow (`workflow_dispatch`), choose `patch` / `minor` / `major`. Cadence is monthly **minors** (3.0.0 → 3.1.0 → 3.2.0 → …); use `major` only for breaking changes.
2. The **Prepare release** step runs `bin/release.ts`, which:
- sanity-checks the tree (clean working dir, on `develop`, `develop`/`master` upstreams in sync, `../ether.github.com` cloned & clean on `master`);
- bumps the version in all four `package.json` files;
- commits `bump version`, merges `develop` → `master`, creates both `X.Y.Z`**and**`vX.Y.Z` tags, merges `master` back to `develop`;
- builds and stages the versioned docs into the website repo (see **Documentation** below).
3. The **Push after release** step (`bin/push-after-release.sh`) pushes `master`, `develop`, the tag, `--tags`, and the `ether.github.com` docs commit.
4. The pushed **`vX.Y.Z` tag auto-triggers** three workflows:
- `handleRelease.yml` → builds Etherpad, extracts the matching changelog section via `generateChangelog` (`bin/generateReleaseNotes.ts`), and publishes the **GitHub Release** (`make_latest: true`);
- `docker.yml` → builds & pushes the Docker images;
- `snap-publish.yml` → publishes the snap.
5. **npm publish — PARKED, not part of the release.**`releaseEtherpad.yaml` publishes the core as `ep_etherpad` to npm, but that package is **not load-bearing**: it has 0 dependents, nothing depends on it (plugins import `ep_etherpad-lite` from the *local* core install; plugin CI clones the repo), and Etherpad is run via clone/Docker/zip/snap — never `npm install`. The publish currently fails with `E404` because `ep_etherpad` has no OIDC trusted publisher configured on npmjs.com, which is why npm sits at 2.5.0 while 3.x shipped fine without it. The workflow is gated behind a `confirm: true` input so it can't run by accident. **Skip it for a normal release.** To revive it, the npm owner of `ep_etherpad` (`samtv12345`) configures a trusted publisher (npmjs.com → ep_etherpad → Settings → Trusted Publisher → repo `ether/etherpad`, workflow `releaseEtherpad.yml`); otherwise the workflow can be removed. Decision pending.
### Documentation
Two distinct things, both important:
**1. Per-PR doc updates — your responsibility in every behaviour-change PR.**
The `doc/` workspace holds the user/admin/API docs (Markdown + AsciiDoc). Whenever a PR changes API responses, CLI flags, settings keys, hooks, or error formats, update the relevant files in `doc/`**in the same PR** — don't defer it to release time. The HTTP API reference is `doc/api/http_api.{md,adoc}` (keep both in sync). Preview locally with `pnpm run makeDocs`.
**2. Release-time versioned docs publishing — automated, no manual step.**
During a release, `release.ts` runs `pnpm run makeDocs` (→ `bin/make_docs.ts`) to render `doc/` into `out/doc/`, then copies it into the sibling website repo at `../ether.github.com/public/doc/vX.Y.Z`, bumps that repo's version, and commits `X.Y.Z docs`; `push-after-release.sh` pushes it, publishing the versioned docs on etherpad.org. This requires `ether.github.com` checked out as a **sibling directory** — the **Release etherpad** workflow checks it out automatically. If you ever run `release.ts` by hand, clone it first: `cd .. && git clone git@github.com:ether/ether.github.com.git`.
## Monorepo Structure
This project uses pnpm workspaces. The workspaces are:
3.3.2 is a bug-fix and dependency-hardening follow-up to 3.3.1. It rounds out the pad-deletion UX rework (suppressing the recovery token for durable identities, keeping the token-less Delete button reachable, and closing a read-only deletion hole), restores the saved-revision markers that went missing from in-pad history mode in 3.3.x, and adds env-var overrides so air-gapped installs can switch off Etherpad's outbound calls without editing the image. It also fixes the `migrateDB` / `importSqlFile` / `migrateDirtyDBtoRealDB` CLI scripts against the promise-based ueberdb2 API, rejects unreachable `.`/`..` pad ids, and clears a batch of dependency security advisories (including CVE-2026-54285). On the CI side it unblocks the installer smoke test (which had been hanging the full 6-hour job ceiling since 3.2.0) and pins ueberdb2 past a startup-exit regression in the packaged boot.
### Security
- **Force `@opentelemetry/core` ≥ 2.8.0 (GHSA-8988-4f7v-96qf / CVE-2026-54285, #7975).** The transitive dep (pulled in via `@elastic/elasticsearch` → `@elastic/transport`) had a `W3CBaggagePropagator.extract()` that did not enforce W3C size limits on inbound baggage headers, allowing unbounded memory allocation. Pinned via a `pnpm-workspace.yaml` override; satisfies the existing `2.x` range with no parent bump.
- **Resolve open Dependabot security alerts (#7967).** Refreshes stale override floors and adds new ones via `pnpm-workspace` overrides: `form-data` ≥ 4.0.6, `ws` ≥ 8.21.0, `esbuild` ≥ 0.28.1, `basic-ftp` ≥ 5.3.1 (capped `<6.0.0` to avoid a surprise major on the plugin-install path), `tar` ≥ 7.5.16, `js-yaml` ≥ 4.2.0, `qs` ≥ 6.15.2, `ip-address` ≥ 10.1.1, and `@babel/core` ≥ 7.29.6.
- **Reject read-only deletion via token-less paths (part of #7959 / #7960).** Under `allowPadDeletionByAllUsers` a read-only viewer was granted `canDeletePad=true`, and the server's `flagOk`/`creatorOk` branches never checked `session.readonly` — so a read-only link holder could delete a pad without a token. Read-only sessions are now excluded from both the client var and the server's token-less authorization paths; a valid recovery token stays sufficient regardless of session mode.
### Notable enhancements
- **Pad deletion — suppress the recovery token for durable identities and relabel the action (#7926 / #7930).** Building on the `allowPadDeletionByAllUsers` suppression, a creator's deletion token is now also withheld when they have a *durable* identity — authenticated (`req.session.user` with a username) **and** the deployment pins that identity to a stable `authorID` via a `getAuthorId` hook — since only then does the creator survive a cookie clear or a different device, making the token redundant. This tightens the previous "require authentication ⇒ always suppress" rule: without `getAuthorId` the authorID still comes from the per-browser cookie, so an authenticated user on a second device is *not* the creator and keeps getting a token. A new `canDeleteWithoutToken` client var hides the whole recovery-token disclosure (label, field, submit) when no token is needed, and the recovery form now renders for all sessions (hidden by default) so an authenticated creator without a durable mapping still has UI to enter their token. `API.createPad` returns a `null``deletionToken` under `allowPadDeletionByAllUsers`, matching the socket/UI path.
- **Offline/air-gapped installs — env-var overrides for the update check, plugin catalog, and updater (#7917, addresses #7911).** Firewalled deployments could not disable Etherpad's outbound calls without editing `settings.json` inside the image. The relevant keys are now wired through the `${ENV:default}` substitution in `settings.json.docker` and `settings.json.template`: `PRIVACY_UPDATE_CHECK`, `PRIVACY_PLUGIN_CATALOG`, `UPDATES_TIER` (`off` = no calls), `UPDATE_SERVER`, plus the docker-only `UPDATES_SOURCE` / `UPDATES_CHANNEL` / `UPDATES_CHECK_INTERVAL_HOURS` / `UPDATES_GITHUB_REPO` / `UPDATES_REQUIRE_ADMIN_FOR_STATUS`. A new "Updates & privacy" section in `doc/docker.md` documents the set; backend tests parse the shipped configs and fail if the `${ENV}` placeholders are dropped. Config, docs, and tests only — no runtime code change.
### Notable fixes
- **Pad — keep the token-less Delete button reachable without pad-wide settings (#7959 / #7960).** The token-less `#delete-pad` button was nested inside the `enablePadWideSettings`-gated section, so disabling pad-wide settings removed the only no-token deletion path — and combined with #7926 hiding the token disclosure when no token is needed, a user allowed to delete could be left with no deletion UI at all. The button is now always rendered (hidden by default) and driven by a `canDeletePad` client var (creator or `allowPadDeletionByAllUsers`, excluding read-only sessions), so the plain button and the recovery-token disclosure are mutually coherent and neither depends on pad-wide settings.
- **History mode — restore the saved-revision markers (#7946 / #7948).** When #7659 moved the timeslider into the pad as an embedded iframe, the user-facing control became the outer `#history-slider-input`, but the saved-revision stars were still drawn into the now-hidden iframe `#ui-slider-bar`, so "Save Revision" appeared to do nothing in in-pad history mode (a 3.3.x regression). `pad_mode.ts` now bridges the embedded slider's saved revisions onto the outer slider as percentage-positioned, aria-hidden star markers (with click-to-seek for mouse users), and the server's `SAVE_REVISION` handler broadcasts `NEW_SAVEDREV` to the pad room so a revision saved by a collaborator appears live on an already-open history slider. A single revision saved at rev 0 now renders too. Adds Playwright coverage for both the single-client and two-client live paths.
- **Import dialog — correct the outdated "no converter" help message (#7988 / #7989).** The notice claimed only plain text and HTML could be imported and linked to the legacy AbiWord wiki, prompting LibreOffice installs for formats that already work natively. Etherpad imports `.txt`, `.html`, `.docx` (via mammoth) and `.etherpad` without LibreOffice; only `.pdf`/`.odt`/`.doc`/`.rtf` still need it. The message now says so and points at the documentation site.
- **PadManager — reject unreachable `.` and `..` pad ids (#7962).**`isValidPadId` accepted ids consisting only of URL dot-segments, but per the WHATWG URL standard a browser normalises `/p/.` to `/p/` and `/p/..` to `/`, so such a pad could be created in the database yet never opened or exported. These ids are now rejected, and the admin `deletePad` handler falls back to a raw key purge when `getPad()` throws so any legacy `.`/`..` pad can still be removed.
### Internal / contributor-facing
- **CLI — fix the database migration/import scripts against the ueberdb2 promise API (#7982 / #7983).**`migrateDB.ts` opened source and target databases, copied all keys, then resolved without closing either — so under ueberdb2 6.1.x the keep-alive timer kept the process hanging after "Done syncing dbs", and buffered target writes were only guaranteed flushed on `close()`. It now closes both databases (flushing writes, clearing the timer) on success and error paths and exits with an explicit status. `importSqlFile.ts` and `migrateDirtyDBtoRealDB.ts` were ported off the pre-v6 callback API to `await db.init()` / `db.set(k, v)` / `db.close()`, removing two `@ts-ignore`s that hid broken calls and fixing an undefined `length` in a progress log; `tsc --noEmit` on the bin package is now clean.
- **CI — stop the installer smoke test hanging the 6-hour job ceiling (#7981).** The "Installer test" had hung on every ubuntu/macOS run since 3.2.0: `pnpm run prod` is a nested launcher, so `kill "$PID"; wait "$PID"` only signalled the outer pnpm and blocked forever if the node server didn't exit on SIGTERM. Teardown now runs the launcher in its own process group, kills the whole group (SIGTERM then SIGKILL), drops the blocking `wait`, and adds an 8-minute `timeout-minutes` backstop to both smoke steps.
- **CI — run the Debian-package smoke test on PRs (#7969).** The packaged-boot smoke test previously ran only on push to `develop` — i.e. after merge — which is why the ueberdb2 startup-exit regression turned `develop` red instead of being blocked at PR time. A `pull_request` trigger (scoped to production-footprint paths) now runs the build+smoke job on PRs; the release/apt-publish jobs stay tag-guarded.
- **Release — park the non-functional `ep_etherpad` npm publish (#7922).** The `releaseEtherpad` workflow republished `./src` as `ep_etherpad`, a package with zero dependents that nothing in the repo or any deployment path consumes, and it had been failing with E404 (no OIDC trusted publisher configured). The job is now gated behind an explicit `confirm: true` dispatch input so a stray run fails fast with a clear message, with the status documented in the workflow header and `AGENTS.MD`.
- **Tests — port the orphaned legacy timeslider specs to Playwright (#7949).** The `src/tests/frontend/specs/` mocha suite is run by no CI workflow, so its timeslider coverage was dead — which is how the #7946 history-mode regression reached a release. The still-meaningful cases (revision labels, export links, deep-link entry) were ported to `frontend-new` Playwright specs re-targeted at the real in-pad UI, and the three now-ported legacy specs were deleted.
### Dependencies
- `ueberdb2` pinned to `6.1.13`. 6.1.10 rewrote the cache/buffer layer to lazily arm an `.unref()`'d flush timer only when there are dirty keys, so on a fresh empty dirty DB nothing anchored Node's event loop and the packaged (.deb/systemd) boot could exit cleanly (code 0) before `server.listen()` bound the port — failing the Debian-package health check. The dep was pinned back to the last green release (6.1.9, #7969) and then rolled forward to the now-fixed `6.1.13` (#7979), pinned exactly rather than with a caret.
3.3.1 is a small bug-fix and hardening follow-up to 3.3.0. It closes a stored-XSS vector in the numbered-list `start` attribute, hardens the database layer so a dropped connection to PostgreSQL / Redis / RethinkDB no longer crashes the process (via ueberdb2 6.1.9), and fixes a handful of pad and admin regressions — the iOS dark-mode status bar, the settings language dropdown, the pad-deletion modal under `allowPadDeletionByAllUsers`, and a single unreadable pad blanking the admin Manage-pads list.
### Security
- **Pad editor — escape and integer-coerce the numbered-list `start` attribute (GHSA-f7h5-v9hm-548j, #7937).** A crafted `<ol start>` value flowed unescaped into `domline.ts`, a distinct client-side sink from the export-path fix in 3.3.0's #7905. The value is now integer-coerced and HTML-escaped before it reaches the DOM. A jsdom regression test covers the sink.
### Notable fixes
- **Skin — paint the root canvas so iOS dark mode has no white status bar (#7606 / #7931).** iOS Safari paints the top safe area from the `html` root background, which `theme-color` (an Android address-bar hint) does not affect, so dark-mode pads showed a white status-bar strip on iOS. Colibris now sets the root background and `color-scheme` so the safe area matches the editor.
- **Settings — show the detected language in the dropdown (#7925 / #7928).** The settings language `<select>` did not reflect the language Etherpad had actually auto-detected; it now shows the active selection.
- **Pad — don't issue a deletion token (or show its modal) when `allowPadDeletionByAllUsers` is on (#7929).** With pad deletion open to all users the client still minted a deletion token and surfaced the confirm modal; both are now suppressed in that configuration.
- **Admin — one unreadable pad no longer empties the Manage-pads list (#7935 / #7938).** A single pad that failed to read could throw out of the list-hydration path and blank the entire admin Manage-pads view; the read is now guarded per-pad so the rest of the list still renders.
### Internal / contributor-facing
- **CI — downstream client compatibility gate (#7923 / #7924 / #7927).** A new gate smoke-tests the published `etherpad-pad`, `etherpad-cli`, and `etherpad-desktop` clients against the server build (Phase 1 + Phase 2), with robust per-client error handling in `run-clients.sh` so one client's failure is reported rather than masking the others.
- **CI — verify Etherpad boots offline (#7936).** Adds a test step that confirms a built Etherpad starts with no network access.
### Dependencies
- `ueberdb2` 6.1.8 → 6.1.9 — PostgreSQL pool errors are now handled and TCP keep-alive is enabled (fixes #7878), and the Redis and RethinkDB drivers attach connection-error handlers so a dropped database connection no longer crashes the Etherpad process.
- `semver` 7.8.2 → 7.8.3 (#7933), `rate-limiter-flexible` 11.1.1 → 11.2.0 (#7934), plus a dev-dependencies group update (#7932).
# 3.3.0
3.3 is primarily a security-hardening release. A defence-in-depth pass tightens the HTTP API entry points, switches random-id generation to a CSPRNG, escapes exported `data-*` attributes, and flips the shipped Docker deployment defaults so a fresh install no longer boots with implicit credentials or a trusting proxy. Alongside that, the `ep_*` pad-options passthrough that shipped opt-in in 3.0.0 is now on by default, the in-pad timeslider learns to honour the editor's view settings (authorship colours, font family, line numbers), and a long tail of pad-editor layout, RTL, and URL-encoding fixes lands. The release also carries the root-cause fix for the long-standing Windows backend-test "silent ELIFECYCLE" flake.
### Notable enhancements
- **Plugin pad options on by default — `settings.enablePluginPadOptions` now defaults to `true` (#7841).** The flag that gates the `ep_*` passthrough on pad options (shipped opt-in in 3.0.0, #7698) is flipped to default-on, so plugins such as `ep_plugin_helpers`' `padToggle` / `padSelect` ride the existing broadcast/persist rail out of the box. This closes `ep_comments_page#422` — stock 3.x deployments `console.warn`ed on every pad load because the helper detected `enablePluginPadOptions === false`. The `settings.json.template` env-var default is flipped to match, so Docker/supervisor configs without an explicit value get the new behaviour. Existing deployments with an explicit `"enablePluginPadOptions": false` keep that value — no migration needed — and the protocol shape is unchanged for older clients.
- **Timeslider — honour the editor's view settings (#7899).** The in-pad timeslider now respects `showAuthorshipColors`, `padFontFamily`, and line-numbers, bridged from the pad-settings checkboxes into the embedded timeslider iframe so the two views agree. `nice-select.ts` dispatches a native `change` event after the jQuery trigger so the `addEventListener`-based bridge in `pad_mode.ts` fires (jQuery 3.7.1's `trigger()` does not dispatch native DOM events), and the font-family reset is fixed for jQuery 3 (which ignores a `null` css value). The five ad-hoc listener stores in `pad_mode.ts` are consolidated into one `bindOuter()` path and the three view-setting bridges into a single data-driven `bridgeView()` (refactor only).
- **Admin settings — explain env-var substitution and surface auth errors (#7819 / #7826).** Three env-var-only UX improvements driven by #7819 (a Docker operator saved an `ep_oauth` block in the Raw view and reported it "disappeared", not realising `settings.json` on disk is a *template*, not the effective config): a banner above the editor explaining the template/substitution model (rendered only when the loaded file contains a `${VAR}` placeholder); a read-only **Effective** tab exposing the redacted runtime settings the backend already emitted as `resolved` (also gated on `${VAR}`); and an `admin_auth_error` event so a misrouted Traefik+SSO session that isn't admin gets a clear toast instead of a silent "save did nothing". A reconnect-loop guard suppresses the SPA's auto-reconnect once an auth error has been received. No behaviour change for installs without `${VAR}` placeholders.
### Security hardening
A defence-in-depth pass across the API, token, export, and deployment surfaces:
- **HTTP API request handling, random IDs, and plugin loading (#7906).**`pad_utils.randomString` now generates random IDs via `crypto.getRandomValues` (CSPRNG) instead of `Math.random`. `OAuth2Provider` compares passwords with `crypto.timingSafeEqual` on the raw UTF-8 bytes (resolving the CodeQL "insufficient computational effort" alert) behind a uniform failure delay, and looks users up via own-property access only. `API.appendChatMessage` throws `padID does not exist` rather than creating the pad, consistent with the other content API methods. The `/api/2` REST router forwards only the `authorization` header (not the full request header set) and falls back to it whenever the field is falsy, matching the `openapi.ts` handler so both routers authenticate identically. `LinkInstaller` validates plugin dependency names before building filesystem paths from them, and the admin file server returns a generic error while logging details server-side.
- **Escape exported `data-*` attributes; warn on default/placeholder credentials (#7905).**`ExportHtml` now escapes the name and value of attributes emitted by the `exportHtmlAdditionalTagsWithData` hook, consistent with the URL/text escaping already applied to exported HTML. `Settings` logs a warning (error level under `NODE_ENV=production`) when an account uses a default/placeholder password from the shipped config, and the check is extended to cover `sso.clients[].client_secret` so enabling SSO without setting `ADMIN_SECRET` / `USER_SECRET` is flagged the same way.
- **Docker deployment defaults — require explicit credentials, default `TRUST_PROXY` off (#7907).** The shipped `docker-compose` now requires `ADMIN_PASSWORD` and the database password to be provided explicitly (no implicit fallback) and defaults `TRUST_PROXY` to `false`. Operators relying on the previous implicit defaults must now set these values explicitly.
### Notable fixes
- **History mode — lay the timeslider iframe in the editor's flex slot (#7903).** In-pad history mode positioned `#history-frame-mount` as an `inset:0` absolute overlay over `#editorcontainerbox`, which took the iframe out of flow and hid any in-flow side panel (e.g. `ep_webrtc`'s `#rtcbox` video column) beneath it — so history mode and live mode disagreed. The iframe now occupies the same in-flow flex slot the live editor uses, and a latent specificity bug (the `body.history-mode #editorcontainer { display: none }` hide rule was outranked by the two-id layout rule, so the live editor was only ever painted over) is fixed by giving the hide rule matching specificity. Adds a `padmode.spec.ts` regression test.
- **Pad editor — restore URL wrapping (#7894 / #7896).** Long URLs in the pad editor overflowed instead of wrapping because the global `a { white-space: nowrap }` rule overrode the wrapping properties on `#innerdocbody`. Explicit `white-space` / `word-wrap` / `overflow-wrap` on `#innerdocbody a` restores wrapping inside the editor while preserving no-wrap for links elsewhere in the UI.
- **RTL content option no longer flips the whole page (#7900 / #7901).** The per-pad RTL content option (`rtlIsTrue`) wrote the direction to the top-level `document.documentElement`, flipping the entire page — toolbar and chrome included. The content direction is now applied to the inner editor document (`targetDoc.documentElement`); page direction stays owned by the UI language (`l10n.ts`). Adds a frontend test asserting the inner editor flips while the top-level `<html>` dir is unchanged.
- **Pad-wide view settings apply to the creator's own view (#7900 / #7902).** Because a creator is never "enforced upon themselves", a stale personal view-override cookie (e.g. `rtlIsTrue=false` from an earlier toggle) silently masked the pad-wide value they later set, so the control appeared to do nothing on their own screen. Changing a pad-wide view option now syncs the creator's personal pref to the chosen value; the precedence model is unchanged (the creator can still override afterwards via "My view").
- **URL view-option params lost to a `padeditor.init` race (#7840 / #7843).**`?showLineNumbers=false` and `?useMonospaceFont=true` were silently clobbered shortly after load — the same race #7464 fixed for `?rtl=false`, but the neighbouring `showLineNumbers` / `noColors` / `useMonospaceFontGlobal` blocks were left at the synchronous-tail site. The fix is generalised to all three (moved into `postAceInit`). Mostly observable in cross-context iframe embeds that start with no `prefs` cookie. Adds `url_view_options.spec.ts`.
- **Default welcome text attributed to the system author (#7885 / #7887).** Auto-generated default pad content (`settings.defaultPadText` / `padDefaultContent` hook) carried the creating user's `author` attribute and rendered in their authorship colour, even though they never wrote it. The welcome text's `author`*attribute* is now `Pad.SYSTEM_AUTHOR_ID`, while revision 0's `meta.author` stays the real creator so ownership (pad-wide settings gate, deletion token) is preserved. Explicitly provided text (e.g. HTTP API `createPad` with text + author) keeps the real author.
- **URL-encode pad names in the admin 'Open' button and recent pads (#7865 / #7895).** Pad names are `encodeURIComponent`-d in the admin `PadPage` Open href and the colibris recent-pads href, and `decodeURIComponent`-d when read back from the URL pathname; legacy URL-encoded recent-pads names are normalised before re-encoding to prevent double-encoding (`%2F` → `%252F`). The admin Open `window.open` gains `noopener,noreferrer`.
- **OIDC — fix broken `OIDCAdapter` flows (#7837).** Repairs the adapter flows and widens the storage type to include `string` for the `userCode` index; adds regression tests.
- **Accessibility — dialog titles/descriptions and a missing l10n key (#7835 / #7836).** Adds the `index.code` key referenced by `index.html` but never defined (which produced a "Couldn't find translation key" console error on the landing page), and gives every admin `@radix-ui/react-dialog``Dialog.Content` a `Dialog.Title` and `Dialog.Description` (visually hidden where there's no visible heading), silencing Radix's a11y warnings. A new backend spec fails CI if any `data-l10n-id` in `src/templates/*.html` is missing from `en.json`.
- **Offline/air-gapped Docker boot — stop pnpm self-provisioning a pinned version (issue #7911).** The official image installs pnpm directly (corepack was dropped for Node 25+). Because the image's pnpm intentionally lags the `packageManager` pin in `package.json` (pnpm 11.1.x enforces a minimum-release-age policy the frozen-lockfile build can't satisfy), pnpm treated every call — including the informational `pnpm --version` probe Etherpad runs at startup — as a request to download the pinned build. Behind a firewall that download failed (`Failed to get pnpm version: … Command exited with code 1`), breaking startup. The Dockerfile now sets `pnpm_config_pm_on_fail=ignore`, and the startup probe plus the updater's pnpm-on-PATH checks run with the same flag, so pnpm uses the installed version instead of reaching for the network (without changing which pnpm runs the build-time install). A backend spec fails CI if that guard is dropped while a version gap exists.
- **Firefox authorship colours — tag early keystrokes with the right author (#7910).** The inner editor's `thisAuthor` starts empty and is only populated when collab_client's queued `setProperty('userAuthor', userId)` reaches the iframe (applied asynchronously via `pendingInit`). Under Firefox timing the first keystrokes could beat it, so freshly typed text — and early line-attribute changes (lists, headings, alignment) — were tagged `author=''`, which canonicalises to an unattributed insert that the server's pad-corruption guard rejects, dropping the whole change and losing authorship (the intermittent `clear_authorship_color` flake, where undo couldn't restore the author colour). A `getLocalAuthor()` helper now falls back to `clientVars.userId` (the same id, available synchronously) whenever `thisAuthor` is still empty, applied at the text-insert sites and to seed `documentAttributeManager.author`; the intentional clear-authorship path and the server-side guard are unchanged.
- **Dark mode — fix the white address bar and the light-flash on load (#7909, issue #7606).** Dark-mode users still saw a white mobile address bar above the dark toolbar, and the whole page flashed light before going dark. Both came from rendering the light state server-side and switching to dark only after the JS bundle ran: iOS Safari reads `theme-color` at parse time and doesn't reliably repaint on a later JS mutation, and the page painted light before the bundle applied the dark skin classes. The server now emits a `prefers-color-scheme`-scoped `theme-color` pair so the address bar is correct at first paint, plus a small blocking `<head>` script that applies the dark skin classes before the stylesheet paints. Both are gated on `enableDarkMode` (default on) and the colibris skin; `pad.ts` still runs on init to wire up the `#options-darkmode` toggle (which now updates every `theme-color` meta) and theme the editor iframes. Applies to the pad and timeslider views.
### Internal / contributor-facing
- **Root-caused and fixed the Windows backend-test "silent ELIFECYCLE" flake (#7866).** The ~22% Windows flake — rotating across random spec files, no mocha summary, no JS trace — was diagnosed from a full-memory dump as two distinct causes. (1) A timing-fragile test abandoned by mocha keeps running and later throws an *orphan* unhandled rejection; `server.ts`'s process-global `uncaughtException`/`unhandledRejection` handlers (correct for a real Etherpad process) escalated that into a clean `process.exit`. They are now gated behind `require.main === module`, and the backend-test bootstraps (`common.ts`, `diagnostics.ts`) log orphan rejections instead of rethrowing. (2) A stack-buffer overrun in Node 24.x's bundled libuv Windows TCP-connect path (`uv__tcp_connect`) corrupts memory under the suite's localhost-connection churn; CI pins the Windows backend job to Node **24.16.0** (libuv 1.52.1, the bisected fix), referencing upstream `nodejs/node#63620`. Linux stays on Node 24 LTS.
- **Removed the now-unneeded ELIFECYCLE diagnostic scaffolding (#7846 / #7838 / #7842 / #7868).** The OS-level sidecar watcher, the diagnostics heartbeat/running-test pointer, and the mid-test snapshot — added to chase the flake above — are removed now that the cause is known.
- **Docs — document the Docker `settings.json` writable-layer and env-var-vs-file semantics (#7819 / #7827).** Two operator-facing gaps surfaced by #7819: that the on-disk `settings.json` is a template (env substitution happens in memory at load time), and that the default compose puts `settings.json` in the container's writable layer with no host mount, so admin edits are lost on `down`/`pull`/watchtower but survive a plain `restart`. Adds prose + a recreate-vs-restart table to `doc/docker.md` and a commented-out opt-in bind mount to the compose files.
- **Docs refresh for 3.2.0 (#7888)**, **dropped three redundant top-level files (#7839)**, **dropped a fragile viewport assertion in the enter test (#7845)**, and a backend-test fix-up.
### Dependencies
- Two major bumps: `redis` 5.12.1 → 6.0.0 (#7869) and `ejs` 5.0.2 → 6.0.1 (#7860).
3.2 adds first-class reverse-proxy / ingress support — `X-Forwarded-Prefix` and `X-Ingress-Path` are now honoured under `trustProxy`, so Etherpad can live under a subpath (Traefik, Nginx, Kubernetes Ingress) without breaking the PWA manifest, social-meta URLs, or any of the bootstrap asset links. The admin settings page learns to show *resolved* runtime values next to `${VAR:default}` placeholders, the v3.1.0 admin pad-list filter chips now apply server-side (so "show empty pads" no longer returns 0–12 of hundreds), and the v3.1.0 redesigned outdated-version gritter actually fires in production now (the session-based author lookup it shipped with always returned null for pad visitors).
### Notable enhancements
- **HTTP — accept `X-Forwarded-Prefix` and `X-Ingress-Path` under `trustProxy` (#7802 / #7806).** With `trustProxy: true`, Etherpad now honours `X-Forwarded-Prefix` (de-facto Traefik / Spring) and `X-Ingress-Path` (Kubernetes Ingress) in addition to the prefix it already inferred from the request path. The shared `sanitizeProxyPath` helper added in 3.1.0 (defence-in-depth: `[A-Za-z0-9_./-]` only, `//+` collapsed, `..` traversal rejected) is extended to the new headers and applied consistently across `/manifest.json`, `socialMeta``og:url` / `og:image`, and the `index.html` / `pad.html` / `timeslider.html` / `export_html.html` templates (manifest links, jslicense links, reconnect URLs). A pre-existing `..` segment-count miscalculation in `pad.html` / `timeslider.html` that broke the manifest link when served from a deep subpath is also fixed in passing. New end-to-end suite covers the prefix-applied / prefix-ignored matrix under `trustProxy=true|false` for both header names. `settings.json.template` documents the new headers alongside the existing `trustProxy` notes.
- **Admin settings — resolved runtime values surface on env-pill chips (#7803 / #7807).** The `/admin/settings` socket payload now carries a new `resolved` field alongside the existing raw-file `results` blob, carrying the actual in-memory settings module run through a new redactor (`AdminSettingsRedact`) that replaces known-sensitive paths (`users.*.password`, `dbSettings.password`, `sso.clients[*].client_secret`, `sessionKey`, …) with `[REDACTED]`. The admin SPA's `EnvPill` renders a `→ active value` chip when the path is resolved, or `→ ••••••` with a redacted tooltip when the server returned the sentinel — so `port: ${PORT:9001}` now shows `→ 9001` (or whatever the live value is) instead of silently falling back to the template default. Old admin SPAs that don't read `resolved` continue to work; the save round-trip is unchanged so `${VAR:default}` literals are still preserved verbatim on disk. The admin test script glob picks up `.test.tsx` alongside `.test.ts` so the new `EnvPill` and `resolveByPath` tests run under `tsx --test`.
### Notable fixes
- **Admin pads — filter chip now applies server-side, before pagination (#7798).** The 3.1.0 admin pad-list filter chips (`active` / `recent` / `empty` / `stale`) ran on the client *after* the 12-row page slice had already arrived. On a deployment with hundreds of pads, clicking "empty pads" on page 1 only matched the 0–12 empties that happened to land in the current page, with the pagination footer reporting nonsense totals (reported on a 3.1.0 deployment). The filter is now part of the `padLoad` socket query — pattern filter on names runs first (cheap), metadata hydration for the matching pad universe is gated on a non-`all` filter or a non-`padName` sort and runs under a 16-way concurrency cap (was unbounded `Promise.all`, which fanned out to thousands of in-flight `padManager.getPad()` reads on busy deployments), then the filter chip, then sort + slice. `total` reflects the filtered universe so the footer makes sense. Older admin clients that don't send `filter` keep working — the server defaults to `all`. The `if/else if` ladder that duplicated the hydrate-and-sort loop per `sortBy` is folded into one pipeline with a single comparator switch.
- **Pad outdated notice — author now resolved from token cookie, not session (Qodo #7804 / #7805).** The 3.1.0 redesigned outdated-version gritter never fired in production. `resolveRequestAuthor()` looked for an `authorID` on `req.session.user`, which Etherpad does not populate for pad visitors (express-session only carries the admin-login user), so `computeOutdated()` always returned EMPTY. The lookup now mirrors how the socket.io handshake resolves pad-visitor identity — read the HttpOnly `token` (or `<prefix>token`) cookie and call `authorManager.getAuthorId(token, user)` via a dynamic import (same circular-init guard pattern the file already uses for `PadManager`). The admin OpenAPI document gains a `description` note clarifying that `/api/version-status` is a public pad-side endpoint that lives in the admin doc only because it shares the same internal route registration.
- **Localisation — silence spurious "could not translate element content" warning (#7797).**`<select data-l10n-id="…">` with `<option>` element children — the pattern used by `ep_headings2`, `ep_align`, `ep_font_size`, `ep_font_family`, … — used to drop into the textContent branch of `html10n.translateNode`, hunt for a text-node child to overwrite, find none, and emit `Unexpected error: could not translate element content for key …` on every pad load. The `SELECT` / `INPUT` / `TEXTAREA` aria-label fallback already lived inside the same else-branch *after* the warning, so the accessible name landed correctly but the noisy console line still fired. Form-control elements now short-circuit into the aria-label path *before* the text-node hunt — aria-label is the only sensible localization target for these elements (a `<select>`'s text is its `<option>` labels, not its own name). Closes the console warning reported on Etherpad 3.1.0.
### Internal / contributor-facing
- **CI — swap archived `ep_readonly_guest` for `ep_guest` in the plugin matrix (#7795 / #7808).**`ep_readonly_guest` is archived (read-only on GitHub) and its `authenticate` hook unconditionally swapped `req.session.user` with a read-only guest, *even when the request carried an HTTP Authorization header*. That silently demoted admin login attempts and stalled the `anonymizeAuthorSocket` tests for 14 min/run on every with-plugins CI matrix. The pre-fix theory from 3.1.0 (#7796) blamed `ep_hash_auth.handleMessage`; that was a red herring — `handleMessage` only fires on the `/pad` namespace, never on `/settings`. `ep_guest` is the maintained successor (same authors, same purpose); 1.0.72 on npm already defers to basic auth / admin paths. Swapping the matrix unblocks the `anonymizeAuthorSocket` suite on Linux, Windows, and the upgrade-from-latest-release workflow. The runtime probe added in #7796 stays — it still catches any other authenticate-hook plugin that rejects the test's plain-text credentials (e.g. a future hashed-only plugin).
- **Tests — admin `saveSettings` round-trip + cross-restart persistence (#7819 / #7820 / #7821).** The admin `saveSettings` socket had zero direct backend coverage and the existing e2e "restart works" test only checked that the page renders after a restart, neither of which catches a deployment that resets `settings.json` on restart, nor the user-visible workflow that triggered #7819 (add a top-level plugin block via Raw, save, watch it disappear). Three new backend specs (`adminSettingsSave.ts`) verify byte-for-byte write, top-level-block augmentation round-tripping through the next `load`, and `/* */` comments surviving the write path. A new e2e spec mirrors the #7819 user workflow — open Raw, prepend an `ep_oauth`-shaped top-level block, save, `restartEtherpad()`, re-login, confirm the block is still in Raw and surfaces as its own Form-view section (`Ep oauth` from `humanize()`). A separate `docker.yml` job (`adminSettings_7819.ts`) authenticates via `POST /admin-auth/` (always-requireAdmin, regardless of `settings.requireAuthentication`), saves a hand-built minimal-but-viable settings document containing a marker block, `docker exec test grep`s for it, `docker restart`s the container, waits for the health probe, and re-greps. Both checks must pass.
- **Bug report template** now asks contributors whether the abstraction in their proposed fix matches the rest of the codebase, to head off premature-generalisation fixes earlier in review.
### Dependencies
- `ueberdb2` 6.0.3 → 6.1.2 (two patch releases of cleanup on top of the 6.1.0 `findKeysPaged` API that the 3.1.0 sessionstorage OOM fix relies on).
3.1 ships the self-update programme's **Tier 4 — autonomous in a maintenance window** for real (the v3.0.0 notes documented the design; this is the release the code actually lands in), adds first-class SMTP delivery so update failures email the admin, and bundles a defence-in-depth pass across the HTTP/API entry points. Two new admin-facing escape hatches arrive: a preflight check that aborts an update *before* it mutates the working tree when the target tag's `engines.node` doesn't match the running runtime, and email notifications for every auto-rollback / preflight outcome (not just the terminal `rollback-failed` state).
### Notable enhancements
- **pad: Outdated-version notice redesigned (#7799).** The persistent "severely outdated" banner is replaced by a dismissable gritter notification (auto-fades after 8 seconds), shown only to a pad's first author and only when the server is at least one minor version behind the latest released version. Patch-only deltas no longer fire the notice. The `vulnerable-below` directive scraping, the `severe` and `vulnerable` enum values, and the `vulnerableBelow` state field have been removed.
- **API: `GET /api/version-status` updated (#7799).** Now accepts an optional `?padId=<id>` query parameter and returns `{outdated: "minor" | null, isFirstAuthor: boolean}`. The `severe` and `vulnerable` enum values are gone. Results are cached per `(padId, authorId)` for 60 seconds.
- **Self-update — Tier 4 (autonomous in a maintenance window).** Set `updates.tier: "autonomous"` together with `updates.maintenanceWindow: {"start":"HH:MM","end":"HH:MM","tz":"local"|"utc"}` to constrain autonomous updates to a nightly window. The scheduler snaps `scheduledFor` forward to the next window opening when grace would otherwise land outside the window, and defers the fire when the window has closed by the timer callback. Cross-midnight windows (`end < start`) are supported; DST transitions are absorbed by host wall-clock arithmetic. A missing or malformed window degrades the policy to Tier 3 with an explicit `policy.reason` of `maintenance-window-missing` / `maintenance-window-invalid`; an admin banner surfaces the misconfiguration so autonomous behaviour is not silently disabled. The admin update page shows a "Maintenance window" section with the parsed window summary, the next opening, and a "deferred until <iso>" subtitle on the scheduled panel when the timer has been snapped forward. Closes #7607 (#7753).
- **Updater — real SMTP via nodemailer (new top-level `mail.*` block).** Replaces the "(would send email)" stub. New settings: `mail.host`, `mail.port`, `mail.secure`, `mail.from`, `mail.auth.{user,pass}`. `mail.host=null` keeps the legacy log-only behaviour. The `nodemailer` dependency is lazy-imported on first send so installs that don't configure mail pay no runtime cost; the transport is cached on the full SMTP options tuple so a `reloadSettings()` change to host/port/credentials invalidates the cache. `settings.json.docker` reads `MAIL_HOST` / `MAIL_FROM` / `MAIL_PORT` / `MAIL_SECURE` from env. Send errors are logged warn and swallowed so a transient SMTP failure can never poison the updater state machine.
- **Updater — preflight against the target tag's `engines.node`.** Before mutating the working tree, `runPreflight` now runs `git show <tag>:package.json` and verifies `process.versions.node` satisfies the target's `engines.node`. A mismatch fails cleanly at `preflight-failed` with the detail `target requires Node >=X, running Y` — no drain, no restart, no rollback. The check runs *after* signature verification so we only trust signed `package.json`. New `PreflightReason: 'node-engine-mismatch'`.
@ -14,6 +157,7 @@
- **Export HTML — ordered-list counter no longer poisoned by a sibling unordered list.** When an ordered-list level was the only consumer of `olItemCounts`, closing *any* list at that depth (including a `<ul>` that happened to share the level) reset the counter to 0. A subsequent unrelated `<ol>` at the same depth then took the "counter exists but is 0" branch and emitted `<ol class="...">` without the `start=` attribute. The reset is now gated on `line.listTypeName === 'number'` so closing an unordered list never touches the ol bookkeeping. Fixes #7786 / #7787 (#7791).
- **Export — bad `:rev` returns a meaningful 500 body, not Express's HTML error page.** A non-numeric `:rev` (e.g. `/p/foo/test1/export/txt`) reached `checkValidRev` which throws `CustomError('rev is not a number', 'apierror')`; the message fell through `.catch(next)` and Express's default renderer returned an HTML 500 page. The route handler now catches the apierror and emits `err.message` as a deterministic `text/plain` 500. As a follow-up, `checkValidRev` runs *before*`res.attachment()` so an invalid rev no longer leaves a `Content-Disposition` header in place (browsers were offering to save the error message as a file), and unrelated export failures (conversion, fs, soffice) are surfaced as text/plain rather than the HTML stack page. Fixes #7788 (#7792).
- **Session cleanup no longer OOMs on huge sessionstorage tables.** Pre-2.7.3 `SessionStore._cleanup()` issued a single unbounded `findKeys('sessionstorage:*', null)` that materialised every key into one JS array; on a decade-old MariaDB install with millions of stale sessions the mysql2 driver retained the rows on the pool connection while the JS array dominated heap, OOMing the process within ~15 minutes of boot. Cleanup now pages the keyspace in 500-key batches via the new `findKeysPaged` API on ueberdb2 6.1.0 (DB-side ranged query on mysql/postgres, JS-side fallback elsewhere), yielding to the event loop between pages. A single run is capped at 10 minutes; the next scheduled run continues. The defensive cursor-stall guard now logs an error rather than silently aborting, and `DB.init()` fails fast if any required wrapper method is missing (a misconfigured ueberdb2 pin surfaces at boot instead of an hour later). Fixes #7830 (#7831).
* PRs MUST include a non-empty description explaining what the change does and why
* PRs without a description should be flagged as incomplete
* the commit series in the PR should be _linear_ (it **should not contain merge commits**). This is necessary because we want to be able to [bisect](https://en.wikipedia.org/wiki/Bisection_(software_engineering)) bugs easily. Rewrite history/perform a rebase if necessary
* PRs should be issued against the **develop** branch: we never pull directly into **master**
* PRs **should not have conflicts** with develop. If there are, please resolve them rebasing and force-pushing
@ -123,7 +125,7 @@ Documentation should be kept up-to-date. This means, whenever you add a new API
You can build the docs e.g. produce html, using `make docs`. At some point in the future we will provide an online documentation. The current documentation in the github wiki should always reflect the state of `master` (!), since there are no docs in master, yet.
## Testing
Front-end tests are found in the `tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/tests/frontend`.
Front-end tests are found in the `src/tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/tests/frontend`.
Back-end tests can be run from the `src` directory, via `npm test`.
You can use `npm test -- --inspect-brk` and navigate to `edge://inspect` or `chrome://inspect` to debug the tests.
| `filter` | `"all" \| "active" \| "recent" \| "empty" \| "stale"`*(opt.)* | no | Filter chip; defaults to `"all"`. Applied **before** pagination so `total` and the page slice both reflect the filtered universe. Older clients that omit the field get the unchanged `"all"` behaviour. |
Filter semantics — applied after pattern matching, before sort + slice:
(Please talk to people on the mailing list before you change this page, see our section on [how to get in touch](https://github.com/ether/etherpad#get-in-touch))
**We have decided that LLM/Agent/AI contributions are fine as long as they are within the instructions set out by this document.**
## Pull requests
* PRs MUST include a non-empty description explaining what the change does and why
* PRs without a description should be flagged as incomplete
* the commit series in the PR should be _linear_ (it **should not contain merge commits**). This is necessary because we want to be able to [bisect](https://en.wikipedia.org/wiki/Bisection_(software_engineering)) bugs easily. Rewrite history/perform a rebase if necessary
* PRs should be issued against the **develop** branch: we never pull directly into **master**
* PRs **should not have conflicts** with develop. If there are, please resolve them rebasing and force-pushing
* when preparing your PR, please make sure that you have included the relevant **changes to the documentation** (preferably with usage examples)
* contain meaningful and detailed **commit messages** in the form:
```
submodule: description
longer description of the change you have made, eventually mentioning the
number of the issue that is being fixed, in the form: Fixes #someIssueNumber
```
* if the PR is a **bug fix**:
* The commit that fixes the bug should **include a regression test** that
would fail if the bug fix was reverted. Adding the regression test in the
same commit as the bug fix makes it easier for a reviewer to verify that the
test is appropriate for the bug fix.
* If there is a bug report, **the pull request description should include the
text "`Fixes #xxx`"** so that the bug report is auto-closed when the PR is
merged. It is less useful to say the same thing in a commit message because
GitHub will spam the bug report every time the commit is rebased, and
because a bug number alone becomes meaningless in forks. (A full URL would
be better, but ideally each commit is readable on its own without the need
to examine an external reference to understand motivation or context.)
* think about stability: code has to be backwards compatible as much as possible. Always **assume your code will be run with an older version of the DB/config file**
* if you want to remove a feature, **deprecate it instead**:
* write an issue with your deprecation plan
* output a `WARN` in the log informing that the feature is going to be removed
* remove the feature in the next version
* if you want to add a new feature, put it under a **feature flag**:
* once the new feature has reached a minimal level of stability, do a PR for it, so it can be integrated early
* expose a mechanism for enabling/disabling the feature
* the new feature should be **disabled** by default. With the feature disabled, the code path should be exactly the same as before your contribution. This is a __necessary condition__ for early integration
* think of the PR not as something that __you wrote__, but as something that __someone else is going to read__. The commit series in the PR should tell a novice developer the story of your thoughts when developing it
## How to write a bug report
* Please be polite, we all are humans and problems can occur.
* Please add as much information as possible, for example
* client os(s) and version(s)
* browser(s) and version(s), is the problem reproducible on different clients
* special environments like firewalls or antivirus
* host os and version
* npm and nodejs version
* Logfiles if available
* steps to reproduce
* what you expected to happen
* what actually happened
* Please format logfiles and code examples with markdown see github Markdown help below the issue textarea for more information.
If you send logfiles, please set the loglevel switch DEBUG in your settings.json file:
```
/* The log level we are using, can be: DEBUG, INFO, WARN, ERROR */
"loglevel": "DEBUG",
```
The logfile location is defined in startup script or the log is directly shown in the commandline after you have started etherpad.
## General goals of Etherpad
To make sure everybody is going in the same direction:
* easy to install for admins and easy to use for people
* easy to integrate into other apps, but also usable as standalone
* lightweight and scalable
* extensible, as much functionality should be extendable with plugins so changes don't have to be done in core.
Also, keep it maintainable. We don't wanna end up as the monster Etherpad was!
## How to work with git?
* Don't work in your master branch.
* Make a new branch for every feature you're working on. (This ensures that you can work you can do lots of small, independent pull requests instead of one big one with complete different features)
* Don't use the online edit function of github (this only creates ugly and not working commits!)
* Try to make clean commits that are easy readable (including descriptive commit messages!)
* Test before you push. Sounds easy, it isn't!
* Don't check in stuff that gets generated during build or runtime
* Make small pull requests that are easy to review but make sure they do add value by themselves / individually
## Coding style
* Do write comments. (You don't have to comment every line, but if you come up with something that's a bit complex/weird, just leave a comment. Bear in mind that you will probably leave the project at some point and that other people will read your code. Undocumented huge amounts of code are worthless!)
* Never ever use tabs
* Indentation: 2 spaces
* Don't overengineer. Don't try to solve any possible problem in one step, but try to solve problems as easy as possible and improve the solution over time!
* Do generalize sooner or later! (if an old solution, quickly hacked together, poses more problems than it solves today, refactor it!)
* Keep it compatible. Do not introduce changes to the public API, db schema or configurations too lightly. Don't make incompatible changes without good reasons!
* If you do make changes, document them! (see below)
* Use protocol independent urls "//"
## Branching model / git workflow
see git flow http://nvie.com/posts/a-successful-git-branching-model/
### `master` branch
* the stable
* This is the branch everyone should use for production stuff
### `develop`branch
* everything that is READY to go into master at some point in time
* This stuff is tested and ready to go out
### release branches
* stuff that should go into master very soon
* only bugfixes go into these (see http://nvie.com/posts/a-successful-git-branching-model/ for why)
* we should not be blocking new features to develop, just because we feel that we should be releasing it to master soon. This is the situation that release branches solve/handle.
### hotfix branches
* fixes for bugs in master
### feature branches (in your own repos)
* these are the branches where you develop your features in
* If it's ready to go out, it will be merged into develop
Over the time we pull features from feature branches into the develop branch. Every month we pull from develop into master. Bugs in master get fixed in hotfix branches. These branches will get merged into master AND develop. There should never be commits in master that aren't in develop
## Documentation
The docs are in the `doc/` folder in the git repository, so people can easily find the suitable docs for the current git revision.
Documentation should be kept up-to-date. This means, whenever you add a new API method, add a new hook or change the database model, pack the relevant changes to the docs in the same pull request.
You can build the docs e.g. produce html, using `make docs`. At some point in the future we will provide an online documentation. The current documentation in the github wiki should always reflect the state of `master` (!), since there are no docs in master, yet.
## Testing
Front-end tests are found in the `tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/tests/frontend`.
Back-end tests can be run from the `src` directory, via `npm test`.
You can use `npm test -- --inspect-brk` and navigate to `edge://inspect` or `chrome://inspect` to debug the tests.
echo"WARNING: You can only run this script if your github api token is allowed to create and merge branches on $ETHER_REPO and $ETHER_WEB_REPO."
echo"This script automatically changes the version number in package.json and adds a text to CHANGELOG.md."
echo"When you use this script you should be in the branch that you want to release (develop probably) on latest version. Any changes that are currently not committed will be committed."
echo"-----"
# Get the latest version
LATEST_GIT_TAG=$(git tag | tail -n 1)
# Current environment
echo"Current environment: "
echo"- branch: $(git branch | grep '* ')"
echo"- last commit date: $(git show --quiet --pretty=format:%ad)"
echo"- current version: $LATEST_GIT_TAG"
echo"- temp dir: $TMP_DIR"
# Get new version number
# format: x.x.x
echo -n "Enter new version (x.x.x): "
read VERSION
# Get the message for the changelogs
read -p "Enter new changelog entries (press enter): "
- **Tier 1 (notify)** — 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. No execution.
- **Tier 1 (notify)** — default. A banner appears in the admin UI when a new release is available, and pad users see a dismissable gritter notification if the running version is at least one minor version behind the latest release. No execution.
- **Tier 2 (manual click)** — admins on a git install can click "Apply update" at `/admin/update`. Etherpad drains active sessions, runs `git fetch / checkout / pnpm install / pnpm run build:ui`, and exits with code 75 so a process supervisor restarts it on the new version. Auto-rolls back on failure.
- **Tier 3 (auto with grace window)** — opt-in. On a git install, a newly detected release transitions execution state to `scheduled` and is applied after `preApplyGraceMinutes`. During the grace window, `/admin/update` shows a live countdown plus Cancel and Apply now buttons; an admin email (if `adminEmail` is set) fires once per scheduled tag.
- **Tier 4 (autonomous in maintenance window)** — opt-in. Tier 3 + `updates.maintenanceWindow` is required; the scheduler only fires while the wall clock is inside the configured window. Updates detected outside the window queue for the next opening.
@ -29,13 +29,22 @@ In `settings.json`:
"requireSignature": false,
"trustedKeysPath": null
},
"adminEmail": null
"adminEmail": null,
// SMTP transport for the admin notification emails. host=null keeps
// log-only behaviour ("(would send email)"); set host+from to deliver.
"mail": {
"host": null,
"port": 587,
"secure": false,
"from": null,
"auth": null
}
}
```
| Setting | Default | Notes |
| --- | --- | --- |
| `updates.tier` | `"notify"` | One of `"off"`, `"notify"`, `"manual"`, `"auto"`, `"autonomous"`. Higher tiers are silently downgraded if the install method does not allow them. PR 1 only honors `"notify"` and `"off"`. |
| `updates.tier` | `"notify"` | One of `"off"`, `"notify"`, `"manual"`, `"auto"`, `"autonomous"`. All tiers are implemented. Higher tiers are silently downgraded if the install method does not allow them (only `"git"` installs can run the write tiers `manual` / `auto` / `autonomous`). |
| `updates.source` | `"github"` | Reserved for future alternative sources. Only `"github"` is implemented. |
| `updates.installMethod` | `"auto"` | One of `"auto"`, `"git"`, `"docker"`, `"npm"`, `"managed"`. Auto-detects via filesystem heuristics. Set explicitly to override. |
@ -49,37 +58,53 @@ In `settings.json`:
| `updates.requireSignature` | `false` | When `true`, refuse updates whose tag is not signed by a trusted key. Verification is done via `git verify-tag <tag>` against the user's GPG keyring. Default `false` because Etherpad's release process does not yet sign tags consistently — turning the check on by default would block every Tier 2 update. Set `true` if you run your own builds or have imported a fork's keys. |
| `updates.trustedKeysPath` | `null` | Override the keyring location passed to `git verify-tag` via the `$GNUPGHOME` env var. Useful when the trusted keys live in a dedicated keyring outside the Etherpad user's home. Only meaningful when `requireSignature: true`. |
| `adminEmail` | `null` | Top-level. Contact for admin notifications. Setting it enables the email nudges below. |
| `mail.host` | `null` | Top-level SMTP host. **`null` keeps log-only behaviour** — notifications are logged as `(would send email)` and never delivered. Set a host (and `mail.from`) to deliver over SMTP via nodemailer. The `nodemailer` dependency is lazy-loaded, so installs that leave `mail.host` unset pay no runtime cost. |
| `mail.port` | `587` | SMTP port. |
| `mail.secure` | `false` | `true` for an implicit-TLS connection (typically port 465); `false` uses STARTTLS upgrade when offered. |
| `mail.from` | `null` | Envelope/From address. **Required for delivery** — if `mail.from` is unset (even with a host) the updater falls back to log-only `(would send email)`. |
| `mail.auth` | `null` | SMTP credentials object `{ "user": "...", "pass": "..." }`, passed through to nodemailer. Leave `null` for unauthenticated relays. |
## What "outdated" means
- **`severe`** — running at least one major version behind the latest release.
- **`vulnerable`** — the running version is below a `vulnerable-below` threshold announced in a recent release. Releases declare these via a `<!-- updater: vulnerable-below X.Y.Z -->` HTML comment in their body. The newest such directive wins.
- **`minor`** — the running server is at least one minor version behind the latest published release. Patch-only deltas (same major and minor, higher patch) do not fire the notice.
## Email cadence (when `adminEmail` is set)
These are the "nudge" emails sent by the periodic checker when the instance is behind:
| Trigger | First send | Repeat |
| --- | --- | --- |
| Vulnerable status detected | Immediate | Weekly while still vulnerable |
| New release announced while still vulnerable | Immediate | n/a (one event per tag change) |
| Severely outdated detected | Immediate | Monthly while still severely outdated |
| Outdated (minor or more behind) detected | Immediate | Every 30 days while still outdated (`SEVERE_INTERVAL`) |
| Up to date | No email | — |
If `adminEmail` is unset, the updater never sends mail. The admin UI banner and the pad-side badge still work without it.
The write tiers also email about **apply outcomes** so admins learn about failures without watching the UI:
PR 1 ships the cadence machinery but does not yet wire a real SMTP transport — emails are logged with `(would send email)` until a future PR adds the transport. The dedupe state still advances correctly so admins are not bombarded once SMTP is wired.
| Outcome | When | Dedupe |
| --- | --- | --- |
| `update-preflight-failed` | An auto/autonomous apply was blocked at preflight (e.g. `node-engine-mismatch`, dirty tree, low disk). Subject: *Auto-update to `<tag>` blocked at preflight*. | Deduped on `<outcome>:<targetTag>` — one email per outcome per target tag. |
| `update-rolled-back` | An apply failed mid-flow and Etherpad auto-recovered to the previous version. Subject: *Auto-update to `<tag>` rolled back*. | Deduped on `<outcome>:<targetTag>`. |
| `update-rollback-failed` | **Terminal.** The apply failed *and* the rollback failed — manual intervention required. Subject: *Auto-update FAILED and could not be rolled back — manual intervention required*. | **Always sends**, bypassing dedupe, because the admin must learn about it even if a transient failure shared the same key. |
## Pad-side badge
A different outcome or a different target tag resets the dedupe key and fires a fresh email. Manual (Tier 2) failures surface in the admin UI banner; the outcome emails are tied to the auto/autonomous flows.
Pad users see no version information by default. A small badge appears in the bottom-right corner only when:
If `adminEmail` is unset, the updater never sends mail. The admin UI banner and the pad-side notice still work without it.
- The instance is `severe` (one or more major versions behind), or
- The instance is `vulnerable` (running below an announced threshold).
SMTP delivery is wired via [nodemailer](https://nodemailer.com/) (lazy-loaded). When `mail.host` and `mail.from` are both set, emails are delivered over SMTP. When either is unset the updater falls back to logging each message as `(would send email)` — the dedupe state still advances correctly, so admins are not bombarded once SMTP is configured. An SMTP send failure is caught and logged (`email send failed: …`) and never disrupts the updater state machine.
The public endpoint `/api/version-status` returns only `{outdated: null|"severe"|"vulnerable"}` — it never leaks the running version, so attackers do not gain a fingerprint vector.
## Pad-side notice
Pad users see no version information by default. A dismissable gritter notification appears only when:
- The running server is at least one minor version behind the latest published release (patch-only deltas do not fire), **and**
- The requesting user is the first author of the pad.
The notice auto-fades after 8 seconds and can be dismissed immediately. The public endpoint `/api/version-status` accepts an optional `?padId=<id>` query parameter and returns `{outdated: "minor" | null, isFirstAuthor: boolean}` — it never leaks the running version, so attackers do not gain a fingerprint vector. Results are cached per `(padId, authorId)` for 60 seconds.
## Disabling everything
Set `updates.tier` to `"off"`. No HTTP request will leave the instance and no banner or badge will render.
Set `updates.tier` to `"off"`. The self-updater goes silent — no request to the GitHub Releases API leaves the instance and no banner or badge renders. Note this does **not** cover the separate legacy version check in `UpdateCheck.ts`, which still fetches `${updateServer}/info.json` until you also set `privacy.updateCheck` to `false` (see [PRIVACY.md](https://github.com/ether/etherpad/blob/develop/PRIVACY.md)).
On Docker / air-gapped installs you can do both without editing `settings.json` inside the image by setting `UPDATES_TIER=off`**and**`PRIVACY_UPDATE_CHECK=false` (add `PRIVACY_PLUGIN_CATALOG=false` to also disable the admin plugin browser's catalogue fetch). See the [Updates & privacy](../docker.md#updates--privacy-offline--air-gapped) table in the Docker docs for the full set of environment variables.
## Privacy
@ -96,7 +121,7 @@ The version check sends no telemetry. Etherpad fetches the public GitHub Release
Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout).
In PR 1 (notify only) the install method does not change behavior — every install method gets the banner. From PR 2 onward the install method gates whether the manual-click and automatic tiers can run; only `"git"` is initially supported for write tiers.
Every install method gets the Tier 1 banner. The install method gates whether the write tiers (manual click, auto, autonomous) can run: only `"git"` installs are supported for the write tiers — other methods are silently downgraded to notify.
## Tier 2 — manual click
@ -113,7 +138,7 @@ Etherpad applies an update by **exiting with code 75** so a process supervisor r
### What clicking "Apply update" does
1. **Lock acquire** — `var/update.lock` (PID-based, stale locks reaped automatically).
2. **Pre-flight checks** — install method writable, working tree clean, free disk ≥ `diskSpaceMinMB`, `pnpm` on `PATH`, target tag exists at the configured remote, signature verifies (if `requireSignature: true`). On failure, state goes to `preflight-failed` with a typed reason; the admin sees a banner and clicks **Acknowledge** to clear it. No filesystem mutation has happened — nothing to roll back.
2. **Pre-flight checks** — install method writable, working tree clean, free disk ≥ `diskSpaceMinMB`, `pnpm` on `PATH`, no lock held, target tag exists at the configured remote, signature verifies (if `requireSignature: true`), and the target's Node engine matches the running Node. The Node-engine check runs *after* signature verification (so the `engines.node` range comes from a trusted tag): Etherpad reads `engines.node` from the target tag's `package.json` via `git show <tag>:package.json` and refuses the update via `semver.satisfies` if the running Node does not satisfy it. On failure, state goes to `preflight-failed` with a typed reason; the admin sees a banner and clicks **Acknowledge** to clear it. No filesystem mutation has happened — nothing to roll back.
3. **Drain** — `drainSeconds` window during which T-60 / T-30 / T-10 announcements broadcast to every connected pad and new socket connections are refused. Click **Cancel** during this window to abort cleanly.
5. **Exit 75** — the supervisor restarts on the new version.
@ -124,6 +149,7 @@ Etherpad applies an update by **exiting with code 75** so a process supervisor r
| What went wrong | Resulting state | Admin action |
| --- | --- | --- |
| Pre-flight check fails | `preflight-failed` | Click **Acknowledge** after fixing the underlying issue (free up disk, clean working tree, etc.). |
| Target tag requires a newer (or different) Node than the one running | `preflight-failed` (reason `node-engine-mismatch`) | Fails cleanly at preflight with a detail like *"target requires Node >=X, running Y"*. No drain, no `git checkout`, no restart, nothing to roll back — the install is untouched. Upgrade Node to a version that satisfies the target's `engines.node`, then **Acknowledge** and retry. |
| `git fetch` / `git checkout` fails mid-flow | `rolled-back` | Informational. The working tree is back where it started; click **Acknowledge** to clear. |
| `pnpm install` or `pnpm run build:ui` fails | `rolled-back` | Same as above. The lockfile and SHA are restored. |
| `/health` doesn't come up within `rollbackHealthCheckSeconds` | `rolled-back` | Same — RollbackHandler restores the previous SHA + lockfile and exits 75 again. |
@ -184,7 +210,7 @@ A single `grace-start` notification fires per scheduled tag:
> [Etherpad] Auto-update scheduled for 2.7.2
with the `scheduledFor` timestamp. Etherpad core does not yet wire SMTP; the message logs as `(would send email)` until a future PR adds a transport. Cadence and dedupe still update correctly.
with the `scheduledFor` timestamp. Delivery follows the same SMTP path as every other notification: when `mail.host` and `mail.from` are set the message is sent via nodemailer, otherwise it logs as `(would send email)`. Cadence and dedupe update correctly either way.
The right way to give docker admins an in-product Apply button is to delegate to the orchestrator rather than mutate the container. Two patterns to consider in a follow-up PR:
Returns an outdated-version signal intended for the pad-side gritter.
*Query parameters:*
[cols="1,1,1,3"]
|===
| name | type | required | description
| `padId`
| string
| no
| Pad whose first-author membership is being checked.
|===
*Response 200 (`application/json`):*
[source,json]
----
{
"outdated": "minor",
"isFirstAuthor": true
}
----
`outdated` is `"minor"` only when the running server is at least one minor version behind the latest published release AND the request resolves to the pad's first author. Otherwise it is `null`. Result is cached per `(padId, authorId)` for 60s. The endpoint is disabled entirely when `updates.tier = 'off'`.
@ -306,6 +306,18 @@ Returns the Author Name of the author
-> can't be deleted cause this would involve scanning all the pads where this author was
#### anonymizeAuthor(authorID)
* API >= 1.3.1
Erases an author's identity across all pads they contributed to (GDPR Article 17, the "right to erasure"). The author's name and external/token mappings are removed and their chat messages are cleared, so the author can no longer be re-identified from pad data.
This endpoint is **disabled by default** and is gated on `gdprAuthorErasure.enabled = true` in `settings.json`. If GDPR author erasure is not enabled, the call returns an error and performs no changes. See [doc/privacy.md](../privacy.md) for the privacy/data-retention context.
* `{code: 1, message:"anonymizeAuthor is disabled — set gdprAuthorErasure.enabled = true in settings.json to enable GDPR Art. 17 erasure", data: null}`
* `{code: 1, message:"authorID is required", data: null}`
### Session
Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. Only users with a valid session for this group, can access group pads. You can create a session after you authenticated the user at your web application, to give them access to the pads. You should save the sessionID of this session and delete it after the user logged out.
moves a pad. If force is true and the destination pad exists, it will be overwritten.
@ -646,7 +658,7 @@ collapses the pad's revision history to reclaim database space (issue #6194). Wr
When `keepRevisions` is omitted (or null), all history is collapsed into a single base revision that reproduces the current pad text — equivalent to a freshly-imported pad. When set to a positive integer N, the pad keeps only its last N revisions.
Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. **This operation is destructive — export the pad first via `getEtherpad` if you need a backup.**
Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. **This operation is destructive — export the pad first (for example via the `/p/:padID/export/etherpad` export endpoint) if you need a full-history backup.**
| `padId` | string | no | Pad whose first-author membership is being checked. |
**Response 200 (`application/json`):**
```json
{
"outdated": "minor",
"isFirstAuthor": true
}
```
`outdated` is `"minor"` only when the running server is at least one minor version behind the latest published release AND the request resolves to the pad's first author. Otherwise it is `null`. Result is cached per `(padId, authorId)` for 60s. The endpoint is disabled entirely when `updates.tier = 'off'`.
You can find different tools for migrating things, checking your Etherpad health in the bin directory.
One of these is the migrateDB command. It takes two settings.json files and copies data from one source to another one.
In this example we migrate from the old dirty db to the new rustydb engine. So we copy these files to the root of the etherpad-directory.
Etherpad ships a set of operator tools in the `bin/` directory for migrating
data, reclaiming database space, repairing damaged pads, managing sessions and
managing plugins. They are TypeScript scripts run through `tsx`, and each one
is registered as a pnpm script in `bin/package.json`. Invoke them from the
Etherpad root with:
```
pnpm run --filter bin <script>[args]
```
For example `pnpm run --filter bin checkPad my-pad`. Running the `.ts` files
directly with `node bin/foo.js` will **not** work — there are no compiled `.js`
files and the scripts need the `tsx` loader.
## Running vs. stopped
Some tools talk to a **running** Etherpad over its HTTP API (they read the API
key from `APIKEY.txt` and connect to `ip`/`port` from `settings.json`). Others
open the database **directly** and must be run while Etherpad is **stopped**,
otherwise you risk a database lock or a corrupt write. Each tool below is
labelled accordingly.
## Database migration (`migrateDB`)
`migrateDB` copies every record from one database to another. It takes two
settings files — `--file1` is the **source**, `--file2` is the **target** —
each describing a database with a `dbType` and `dbSettings`. Both paths are
resolved relative to the Etherpad root.
In this example we migrate from the old `dirty` db to the new `rustydb` engine.
Create a source descriptor `source.json` in the Etherpad root:
````json
{
"dbType": "dirty",
"dbSettings": {
"filename": "./var/dirty.db"
}
}
````
and a target descriptor `target.json`:
````json
{
"dbType": "rustydb",
"dbSettings": {
"filename": "./var/rusty.db"
}
}
````
Then run:
```
pnpm run --filter bin migrateDB --file1 source.json --file2 target.json
```
````json
{
"dbType": "rustydb",
"dbSettings": {
"filename": "./var/rusty2.db"
}
}
````
After that we need to move the data from dirty to rustydb.
Therefore, we call `pnpm run --filter bin migrateDB --file1 test1.json --file2 test2.json` with these two files in our root directories. After some time the data should be copied over to the new database.
After some time the data is copied over to the new database. Run this with
Etherpad **stopped**.
## Pad compaction
Long-lived pads with heavy edit history accumulate revisions in the database. Three CLIs reclaim that space, in increasing scope:
Long-lived pads with heavy edit history accumulate revisions in the database.
Three CLIs reclaim that space, in increasing scope. All of them drive a
**running** Etherpad over the `compactPad` HTTP API.
| Tool | Targets | When to use |
| --- | --- | --- |
| `bin/compactPad.js <padID>` | one pad | you know which pad is fat |
| `bin/compactAllPads.js` | every pad | bulk reclaim across the whole instance |
| `bin/compactStalePads.js --older-than N` | pads not edited in N days | reclaim the cold tail without touching pads still in active use |
| `pnpm run --filter bin compactPad <padID>` | one pad | you know which pad is fat |
| `pnpm run --filter bin compactAllPads` | every pad | bulk reclaim across the whole instance |
| `pnpm run --filter bin compactStalePads --older-than N` | pads not edited in N days | reclaim the cold tail without touching pads still in active use |
All three are gated on `cleanup.enabled = true` in `settings.json` and are **destructive**: history is collapsed (or trimmed). Export anything you can't afford to lose with `getEtherpad` first.
All three are gated on `cleanup.enabled = true` in `settings.json` and are
**destructive**: history is collapsed (or trimmed). Export anything you can't
afford to lose with the `getEtherpad` API first.
Common flags:
- `--keep N` — retain the last N revisions instead of collapsing all history.
- `--dry-run` — list pads and revision counts without writing.
- `--dry-run` — list pads and revision counts without writing (`compactAllPads`
and `compactStalePads` only).
- `--older-than N` — (`compactStalePads` only, **required**) only consider pads
not edited in the last N days.
### Examples
````
# Compact a specific pad, collapsing all history.
node bin/compactPad.js my-pad
pnpm run --filter bin compactPad my-pad
# Keep only the last 50 revisions of one pad.
node bin/compactPad.js my-pad --keep 50
pnpm run --filter bin compactPad my-pad --keep 50
# Compact every pad on the instance (per-pad failures don't stop the run).
node bin/compactAllPads.js
node bin/compactAllPads.js --dry-run
pnpm run --filter bin compactAllPads
pnpm run --filter bin compactAllPads --dry-run
# Compact only pads not edited in the last 90 days, keeping the last 50 revisions.
pnpm run --filter bin compactStalePads --older-than 90 --keep 50
pnpm run --filter bin compactStalePads --older-than 90 --dry-run
````
`bin/compactStalePads.js` is the right tool for periodic operator runs on long-lived instances — hot pads that users are still navigating in timeslider stay untouched, and only the cold tail is rewritten. Per-pad failures (including a `getLastEdited` fault) are counted but do not abort the bulk run; the exit code reflects whether anything failed.
`compactStalePads` is the right tool for periodic operator runs on long-lived
instances — hot pads that users are still navigating in timeslider stay
untouched (staleness is even re-checked right before each compaction), and only
the cold tail is rewritten. Per-pad failures (including a `getLastEdited` fault)
are counted but do not abort the bulk run; the exit code reflects whether
anything failed.
See the `compactPad` HTTP API in `doc/api/http_api.md` for the same primitive over the wire (issues #6194, #7642).
See the `compactPad` HTTP API in `doc/api/http_api.md` for the same primitive
over the wire (issues #6194, #7642).
## Pad maintenance and debugging
| Tool | Purpose | Args | Etherpad |
| --- | --- | --- | --- |
| `pnpm run --filter bin checkPad <padID>` | Check one pad's revisions for data corruption. | `<padID>` | stopped |
| `pnpm run --filter bin checkAllPads` | Check every pad on the instance for data corruption. | none | stopped |
| `pnpm run --filter bin repairPad <padID>` | Repair a pad by extracting all of its data, deleting it and re-inserting it. | `<padID>` | stopped (the script refuses to be useful otherwise) |
| `pnpm run --filter bin rebuildPad <padID> <rev> [newPadID]` | Rebuild a damaged pad into a new pad at a known-good revision. The new pad defaults to `<padID>-rebuilt` and must not already exist. | `<padID> <rev> [newPadID]` | stopped |
| `node --import tsx extractPadData.ts <padID>` | Export one pad's data to a `<padID>.db` dirtyDB file so a bug can be reproduced in a dev environment. (No pnpm alias — run with the `tsx` loader directly from `bin/`.) | `<padID>` | stopped |
`checkPad`/`checkAllPads` report corruption but do not modify anything;
`repairPad` and `rebuildPad` write. As always, back up first.
## Database tools
| Tool | Purpose | Args | Etherpad |
| --- | --- | --- | --- |
| `pnpm run --filter bin migrateDB --file1 <src.json> --file2 <dst.json>` | Copy all records from a source database to a target database (see above). | `--file1 <src.json> --file2 <dst.json>` | stopped |
| `pnpm run --filter bin migrateDirtyDBtoRealDB` | One-shot migration of `var/dirty.db` into the real database configured in `settings.json`. Back up `dirty.db` first; may need more memory (e.g. `node --max-old-space-size=4096`). | none (reads target db from `settings.json`) | stopped |
| `pnpm run --filter bin importSqlFile <sqlFile>` | Import a SQL dump (rows of `REPLACE INTO store VALUES (...)`) into the configured database. | `<sqlFile>` | stopped |
## Session and pad management
| Tool | Purpose | Args | Etherpad |
| --- | --- | --- | --- |
| `pnpm run --filter bin deletePad <padID>` | Delete a single pad. | `<padID>` | running |
| `pnpm run --filter bin deleteAllGroupSessions` | Delete all group sessions (useful when a misconfiguration has wedged group access). | none | running |
| `pnpm run --filter bin createUserSession` | Create a throwaway group, pad, author and session, printing a `sessionID` you can set as a cookie — handy for debugging session-based configs. | none | running |
## Plugin tools
| Tool | Purpose | Args | Etherpad |
| --- | --- | --- | --- |
| `pnpm run --filter bin plugins <action> [names…]` | Manage installed plugins. Actions: `i`/`install`, `rm`/`remove`, `ls`/`list`, `up`/`update`. Install also accepts `--path <dir>` and `--github <repo>` sources. | `<action> [names…]` | stopped |
| `pnpm run --filter bin checkPlugin <ep_name> [autofix\|autocommit\|autopush]` | Lint a plugin checkout (a sibling `../ep_name` directory) against the plugin conventions; optional modes auto-fix, commit, or commit+push+publish (the last is dangerous). | `<ep_name> [mode]` | n/a |
| `pnpm run --filter bin stalePlugins` | List plugins in the registry not updated in over two years, with maintainer email. Requires `privacy.pluginCatalog` enabled. | none | n/a |
This page collects working configurations for deploying Etherpad in production:
running it behind a reverse proxy, hosting it under a subdirectory, terminating
HTTPS natively, running it as a system service, and deploying it on Kubernetes.
Etherpad listens on port `9001` by default. Throughout this page the upstream
Etherpad server is assumed to be reachable at `http://127.0.0.1:9001`.
## Running behind a reverse proxy
The recommended production setup is to run Etherpad on `127.0.0.1:9001` and put a
reverse proxy in front of it to terminate TLS, serve a virtual host, and forward
requests.
Etherpad uses WebSockets (via socket.io). The load-bearing part of every proxy
config below is the WebSocket upgrade: the proxy **must** forward the `Upgrade`
and `Connection` headers, or real-time editing will silently fail back to slow
long-polling (or break entirely).
When Etherpad runs behind a proxy you should also set `trustProxy: true` in your
settings so that Etherpad honours the `X-Forwarded-*` headers (correct client IP,
secure-cookie flag, etc.). See the `trustProxy` section in the [Configuration documentation](./configuration.md) for the full details of which headers are trusted.
### Nginx
```nginx
# Map the Upgrade header so WebSockets work. Place this in the http context.
@ -29,6 +29,37 @@ Edit `<BASEDIR>/settings.json.docker` at your will. When rebuilding the image, t
**Each configuration parameter can also be set via an environment variable**, using the syntax `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`. For details, refer to `settings.json.template`.
### How `settings.json` and environment variables interact
This trips people up often enough that it's worth calling out explicitly (see [#7819](https://github.com/ether/etherpad/issues/7819)):
* `settings.json` inside the container is a **template** containing `${VAR:default}` placeholders.
* Environment variable substitution happens at **load time, in memory only** — env vars never overwrite `settings.json` on disk.
* `docker exec <container> cat /opt/etherpad-lite/settings.json` will therefore always show the *templated* file (e.g. `"port": "${PORT:9001}"`), regardless of what `PORT` is set to in your environment. The resolved value is what Etherpad uses at runtime; the file is unchanged.
* The admin /settings page also reads this file directly, so the raw view shows placeholders too. The page now surfaces a banner and an "Effective" tab that displays the in-memory resolved values when placeholders are present.
### Persisting admin /settings edits across container recreates
`settings.json` lives in the container's writable layer by default. That means:
| `docker restart` | Preserved (writable layer is reused) |
| `docker compose restart` | Preserved |
| `docker compose down && docker compose up` | **Reset** to the image template |
| `docker compose pull && docker compose up` | **Reset** to the new image template |
| Watchtower / image auto-update | **Reset** to the new image template |
| `docker rm` + `docker run` | **Reset** to the image template |
If you intend to edit `settings.json` through the admin UI (rather than relying solely on env vars), mount the file from the host so edits survive container recreate:
(Bootstrap by copying `settings.json.docker` to `./settings.json` on the host before the first `up`.) The default compose example below ships this line commented out — uncomment it if you need persistent on-disk edits.
### Rebuilding including some plugins
If you want to install some plugins in your container, it is sufficient to list them in the ETHERPAD_PLUGINS build variable.
The variable value has to be a space separated, double quoted list of plugin names (see examples).
@ -81,10 +112,31 @@ The `settings.json.docker` available by default allows to control almost every s
| `DEFAULT_PAD_TEXT` | The default text of a pad | `Welcome to Etherpad! This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents! Get involved with Etherpad at https://etherpad.org` |
| `IP` | IP which etherpad should bind at. Change to `::` for IPv6 | `0.0.0.0` |
| `PORT` | port which etherpad should bind at | `9001` |
| `PUBLIC_URL` | Canonical public origin of this instance, e.g. `https://pad.example.com` (no trailing slash, must include scheme). Used to build absolute URLs in link-preview meta tags. When `null`, falls back to the incoming request's protocol+Host. | `null` |
| `ENABLE_DARK_MODE` | Respect the end user's browser dark-mode preference. When enabled this overrides the admin-configured skin variants and skin name for that user. | `true` |
| `ADMIN_PASSWORD` | the password for the `admin` user (leave unspecified if you do not want to create it) | |
| `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | |
### Updates & privacy (offline / air-gapped)
Etherpad makes a small number of outbound calls (a periodic version check and the admin plugin catalogue). In an air-gapped or firewalled deployment these can be disabled entirely without editing `settings.json` inside the image — set the variables below. See [PRIVACY.md](https://github.com/ether/etherpad/blob/develop/PRIVACY.md) and [doc/admin/updates.md](admin/updates.md) for what each call sends.
| `PRIVACY_UPDATE_CHECK` | Set to `false` to disable the hourly version check (`UpdateCheck.ts`). | `true` |
| `PRIVACY_PLUGIN_CATALOG` | Set to `false` to disable the admin plugin browser (manual install-by-name via CLI still works). | `true` |
| `UPDATES_TIER` | Self-updater tier: `off` \| `notify` \| `manual` \| `auto` \| `autonomous`. Set to `off` to suppress the GitHub Releases check entirely. | `notify` |
| `UPDATES_SOURCE` | Where update metadata is fetched from. | `github` |
| `UPDATES_CHANNEL` | Release channel to track. | `stable` |
| `UPDATES_CHECK_INTERVAL_HOURS` | How often (hours) the updater polls when not `off`. | `6` |
| `UPDATES_GITHUB_REPO` | Repository the updater checks for releases. | `ether/etherpad` |
| `UPDATES_REQUIRE_ADMIN_FOR_STATUS`| Lock `/admin/update/status` to authenticated admins. | `false` |
| `UPDATE_SERVER` | Endpoint backing the version check. Point elsewhere (or disable the check above) for offline installs. | `https://etherpad.org/ep_infos` |
> **Fully offline:** set `UPDATES_TIER=off`, `PRIVACY_UPDATE_CHECK=false`, and `PRIVACY_PLUGIN_CATALOG=false`. The version check is fire-and-forget and already fails closed (a blocked endpoint is caught and logged, it does not prevent startup), but disabling it removes the outbound attempt and the log noise.
### Database
| Variable | Description | Default |
@ -179,6 +231,31 @@ For the editor container, you can also make it full width by adding `full-width-
| `DISABLE_IP_LOGGING` | Privacy: disable IP logging | `false` |
### Email (SMTP)
SMTP transport used by the self-updater and admin notifications. When `MAIL_HOST` is `null` (the default) Etherpad keeps log-only behaviour and sends no real mail; set `MAIL_HOST` and `MAIL_FROM` to send via nodemailer.
| `PRIVACY_BANNER_BODY` | Banner body text. | `This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.` |
| `PRIVACY_BANNER_LEARN_MORE_URL` | Optional URL for a "learn more" link in the banner. | `null` |
@ -187,6 +264,10 @@ For the editor container, you can also make it full width by adding `full-width-
| `COOKIE_SESSION_LIFETIME` | How long (ms) a user can be away before they must log in again. | `864000000` (10 days) |
| `COOKIE_SESSION_REFRESH_INTERVAL` | How often (ms) to write the latest cookie expiration time. | `86400000` (1 day) |
| `SHOW_SETTINGS_IN_ADMIN_PAGE` | hide/show the settings.json in admin page | `true` |
| `AUTHENTICATION_METHOD` | Authentication method used by the server. Use `sso` for the built-in OpenID Connect provider, or `apikey` for the legacy API-key authentication system. | `sso` |
| `ENABLE_METRICS` | Enable the Prometheus metrics endpoint used by monitoring plugins to collect metrics about Etherpad. Disable if you do not use any monitoring plugins. | `true` |
| `ENABLE_PAD_WIDE_SETTINGS` | Enable creator-owned pad-wide settings and new-pad default seeding from My View. The pad creator gets a "Pad-wide Settings" section to set/enforce defaults; other users see only their own view options. Set to `false` for the legacy single-settings behavior. | `true` |
| `GDPR_AUTHOR_ERASURE_ENABLED` | Enable the GDPR Art. 17 author anonymize/erasure REST endpoint and admin UI. Enable only when an operator process exists to authorise erasure requests. | `false` |
| `TRUST_PROXY` | set to `true` if you are using a reverse proxy in front of Etherpad (for example: Traefik for SSL termination via Let's Encrypt). This will affect security and correctness of the logs if not done | `false` |
| `IMPORT_MAX_FILE_SIZE` | maximum allowed file size when importing a pad, in bytes. | `52428800` (50 MB) |
| `IMPORT_EXPORT_MAX_REQ_PER_IP` | maximum number of import/export calls per IP. | `10` |
@ -208,7 +289,7 @@ For the editor container, you can also make it full width by adding `full-width-
| `FOCUS_LINE_PERCENTAGE_ARROW_UP` | Percentage of viewport height to be additionally scrolled when user presses arrow up in the line of the top of the viewport. Set to 0 to let the scroll to be handled as default by Etherpad | `0` |
| `FOCUS_LINE_DURATION` | Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation | `0` |
| `FOCUS_LINE_CARET_SCROLL` | Flag to control if it should scroll when user places the caret in the last line of the viewport | `false` |
| `SOCKETIO_MAX_HTTP_BUFFER_SIZE` | The maximum size (in bytes) of a single message accepted via Socket.IO. If a client sends a larger message, its connection gets closed to prevent DoS (memory exhaustion) attacks. | `50000` |
| `SOCKETIO_MAX_HTTP_BUFFER_SIZE` | The maximum size (in bytes) of a single message accepted via Socket.IO. If a client sends a larger message, its connection gets closed to prevent DoS (memory exhaustion) attacks. Larger values allow bigger pastes. | `1000000` (1 MB) |
| `LOAD_TEST` | Allow Load Testing tools to hit the Etherpad Instance. WARNING: this will disable security on the instance. | `false` |
| `DUMP_ON_UNCLEAN_EXIT` | Enable dumping objects preventing a clean exit of Node.js. WARNING: this has a significant performance impact. | `false` |
| `EXPOSE_VERSION` | Expose Etherpad version in the web interface and in the Server http header. Do not enable on production machines. | `false` |
@ -282,6 +363,13 @@ services:
volumes:
- plugins:/opt/etherpad-lite/src/plugin_packages
- etherpad-var:/opt/etherpad-lite/var
# OPTIONAL: persist admin /settings edits across container recreates.
# Without this mount, settings.json lives in the image's writable
> **Note:** Etherpad core sets up a `window._` gettext-like shortcut as
> `window._ = html10n.get`. Because this assignment does **not** bind `this`,
> calling the shortcut bare as `window._('pad.chat')` loses its `this` context
> and returns `undefined` (the `get` implementation reads `this.translations`).
> Prefer one of the patterns that actually works:
>
> * Call the method on the object directly: `window.html10n.get('pad.chat')`.
> * Bind it once, then reuse: `const _ = window.html10n.get.bind(window.html10n);`
> followed by `_('pad.chat')`.
>
> Wherever possible, prefer marking up your templates with `data-l10n-id`
> attributes (see above) instead of translating strings in JavaScript at all —
> html10n applies those automatically.
### 2. Create translate files in the locales directory of your plugin
* The name of the file must be the language code of the language it contains translations for (see [supported lang codes](https://joker-x.github.com/languages4translatewiki/test/); e.g. en ? English, es ? Spanish...)
@ -20,7 +20,7 @@ and the URL to use if you embed the timeslider in your own page.
You can choose a skin changing the parameter `skinName` in `settings.json`.
Since Etherpad **1.7.5**, two skins are included:
Two skins are included:
* `no-skin`: an empty skin, leaving the default Etherpad appearance unchanged, that you can use as guidance to develop your own.
* `colibris`: a new, experimental skin, that will become the default in Etherpad 2.0.
* `colibris`: the current default skin, used by Etherpad out of the box. This is what you see in a standard installation.
* `no-skin`: an unstyled base skin that leaves the default Etherpad appearance unchanged. Use it as a starting point and guidance to develop your own skin.
Etherpad keeps track of the goings-on inside the edit machinery. If you'd like to have a look at this, just point your browser to `/stats`.
# Statistics and metrics
We currently measure:
Etherpad tracks runtime statistics about the edit machinery, the database
layer, and the Node.js process, and can expose them over HTTP for monitoring.
- totalUsers (counter)
- connects (meter)
- disconnects (meter)
- pendingEdits (counter)
- edits (timer)
- failedChangesets (meter)
- httpRequests (timer)
- http500 (meter)
- memoryUsage (gauge)
There are two endpoints:
Under the hood, we are happy to rely on [measured](https://github.com/felixge/node-measured) for all our metrics needs.
- `GET /stats` — a JSON dump of the internal `measured-core` collection.
- `GET /stats/prometheus` — the same kind of data (plus process/runtime
metrics) in the [Prometheus](https://prometheus.io/) text exposition format.
To modify or simply access our stats in your plugin, simply `require('ep_etherpad-lite/stats')` which is a [`measured.Collection`](https://yaorg.github.io/node-measured/packages/measured-core/Collection.html).
## Enabling the endpoints
Both endpoints are gated behind the `enableMetrics` setting, which defaults to
`true`:
```json
{
"enableMetrics": true
}
```
When `enableMetrics` is `false` the routes are **not registered at all** — a
request to `/stats` or `/stats/prometheus` returns the normal 404 handling, not
an empty response. The admin-panel statistics view is unaffected by this
setting.
## `GET /stats` (JSON)
Returns the current snapshot of the `measured-core` collection as JSON. The
following metrics are collected:
| Metric | Type | Meaning |
| --- | --- | --- |
| `totalUsers` | gauge | Number of users currently connected across all pads. |
| `activePads` | gauge | Number of pads with at least one connected user. |
| `connects` | meter | Rate of new client connections. |
| `disconnects` | meter | Rate of client disconnections. |
| `rateLimited` | meter | Rate of messages dropped by the per-connection rate limiter. |
| `pendingEdits` | counter | Edits received but not yet fully processed. |
| `edits` | timer | Time taken to process an incoming `USER_CHANGES` edit (full handler span). |
| `failedChangesets` | meter | Rate of changesets that failed to apply. |
| `httpRequests` | timer | Duration of HTTP requests served by Express. |
| `http500` | meter | Rate of HTTP 500 responses. |
| `memoryUsage` | gauge | Process resident set size (`process.memoryUsage().rss`). |
| `memoryUsageHeap` | gauge | Process heap usage (`process.memoryUsage().heapUsed`). |
| `lastDisconnect` | gauge | Timestamp (ms) of the most recent socket disconnect. |
| `ueberdb_*` | gauge | One gauge per [ueberDB](https://github.com/ether/ueberDB) database statistic, e.g. read/write counts and timings (`ueberdb_reads`, `ueberdb_writes`, …). The exact set depends on the configured database driver. |
# Downstream Client Compatibility Tests — Phase 1 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 a core-side compatibility gate (golden-vector + socket-sequence + HTTP-API-shape contract tests, plus a downstream-smoke workflow scaffold) so a PR against `develop` detects changes that would break the separate downstream clients.
**Architecture:** Layer A — hermetic contract tests run inside core's existing mocha backend suite, anchored to a committed `wire-vectors.json` fixture generated from core's own `Changeset`/`AttributePool`. Layer B — a `downstream-smoke.yml` workflow that boots a real Etherpad on :9003, proves the boot→healthcheck→teardown cycle with a self-check, and is ready to matrix over a `clients.json` manifest as clients register in Phase 2.
**Scope note:** This plan is **Phase 1 only** (all changes in `ether/etherpad`). Phase 2 (wiring each client repo's `test:vectors` + smoke and registering it in the manifest) is one separate plan/PR per client repo and is out of scope here.
---
## File Structure
- Create `src/tests/backend/specs/downstream/generate-vectors.ts` — pure module exporting `generateVectors(): WireVector[]`; the single source of truth for the canonical wire fixtures. Also runnable as a CLI to (re)write the fixture.
- Create `src/tests/fixtures/wire-vectors.json` — committed canonical fixture (generated, never hand-edited).
- Create `src/tests/backend/specs/downstream/wire-vectors.ts` — mocha spec asserting the committed fixture is stable and self-consistent.
All backend specs live under `specs/downstream/` so the existing `mocha ... --recursive tests/backend/specs` glob picks them up with zero config change.
- [ ] **Step 3: Sanity-run the generator (no fixture committed yet)**
Run from `src/`:
```bash
cd src && pnpm run vectors:gen
```
Expected: prints `wrote .../src/tests/fixtures/wire-vectors.json` and the file exists with 5 vectors. Verify each has non-empty `changeset` and a `resultText` that differs from `initialText`.
Open `src/tests/fixtures/wire-vectors.json`. Confirm it is a JSON array of 5 objects, each with keys `name, initialText, changeset, pool, resultText`. The `pool` for `plain-insert`/`plain-delete`/`multiline-insert` has empty `numToAttrib`; `formatted-insert` and `attrib-reuse` contain a `bold,true` entry.
- [ ] **Step 3: Commit**
```bash
git add src/tests/fixtures/wire-vectors.json
git commit -m "test(downstream): add committed golden wire-vectors fixture"
Expected: 2 passing. If `waitForSocketEvent`'s default 1s timeout is too tight on the ACCEPT_COMMIT, pass a larger `timeoutMs` (its 3rd arg) — e.g. `common.waitForSocketEvent(socket, 'message', 5000)`.
cd src && grep -n "apiKey\|apikey" tests/backend/common.ts | head
```
Expected: a `common.apiKey` export (or similar). If the export is named differently, adjust the `apiKey` reference in the spec to match before running.
Reference an existing workflow (`.github/workflows/backend-tests.yml`) for the checkout/pnpm/node setup block this repo uses, and copy that setup verbatim into the job below.
- [ ] **Step 1: Write the workflow**
Create `.github/workflows/downstream-smoke.yml`:
```yaml
name: Downstream smoke
on:
pull_request:
schedule:
- cron: '0 4 * * *' # nightly against develop
permissions:
contents: read
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout core (PR)
uses: actions/checkout@v4
# --- Reuse core's standard node+pnpm setup (copy from backend-tests.yml) ---
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Boot Etherpad on :9003
env:
APIKEY: downstream-smoke-key
run: |
mkdir -p var
echo -n "$APIKEY" > APIKEY.txt
PORT=9003 pnpm run prod &
echo $! > /tmp/ep.pid
- name: Wait for healthcheck
run: |
for i in $(seq 1 60); do
if curl -fsS http://localhost:9003/api/ >/dev/null; then
echo "up"; exit 0
fi
sleep 2
done
echo "server did not come up"; exit 1
- name: Self-check (boot + API roundtrip proves the harness)
# iterating /tmp/clients.json. Until a client is enabled this is a no-op.
- name: Teardown (by PID, never pkill)
if: always()
run: |
if [ -f /tmp/ep.pid ]; then kill "$(cat /tmp/ep.pid)" 2>/dev/null || true; fi
```
- [ ] **Step 2: Confirm the boot command + port env**
Run:
```bash
cd /home/jose/etherpad/etherpad-core-fresh && grep -nE '"prod"|"dev"|"start"' src/package.json
grep -rn "process.env.PORT\|settings.port" src/node/utils/Settings.ts | head
```
Expected: confirm the script that starts a production server and that `PORT`/`APIKEY` are honored (Settings reads `process.env.PORT`). If the runnable script is named differently (e.g. `prod` vs `dev`), update the "Boot Etherpad" step to match. If APIKEY is read from a file rather than env, the `echo ... > APIKEY.txt` line already covers it.
--body "Adds core-side contract tests (golden wire-vectors, socket-sequence, HTTP API shapes) and a downstream-smoke workflow scaffold so PRs detect changes that would break the separate CLI / terminal / desktop clients. Phase 2 wires each client repo's vector+smoke tests and flips its manifest entry to enabled. Spec + plan under docs/superpowers/. Closes nothing; tracks the downstream-compat initiative."
```
- [ ] **Step 4: Watch CI**
Per the "check CI after PRs" rule, wait ~20s then:
```bash
gh pr checks --watch
```
Expected: backend tests green (now including the downstream specs); `Downstream smoke` green (self-check passes, no clients enabled yet). Fix any red before moving on.
- Flakiness mitigations: healthcheck-poll (Task 7 step), PID teardown (Task 7), :9003 (Task 7), no external clients on the gate yet so no flake surface (Task 6 `enabled:false`). ✅
- Phasing: Phase 1 self-contained and green; Phase 2 explicitly out of scope. ✅
**Verification-required tasks** (Task 5 step 2, Task 7 step 2) ask the engineer to confirm the exact `common.apiKey` export name and the production boot script/port handling against the live repo before running — these are real lookups, not placeholders, because those names are repo-version-specific.
**Type consistency:** `WireVector` fields (`name/initialText/changeset/pool/resultText`) are defined in Task 1 and used identically in Tasks 2–3. `generateVectors()` signature is stable across Tasks 1/3. Manifest keys (`enabled`, `vectorTest`, `smokeCmd`) in Task 6 match the workflow's `.filter(c=>c.enabled)` consumer in Task 7.
authors emitting leading-slash URLs need to use `clientVars`-derived or
relative paths — documented separately as a plugin guidance issue, not
resolved here.
6. **Settings documentation** — `settings.json.template` and `Settings.ts`
doc comment for `trustProxy` need to mention the new header sources.
## Components
Reflects the discovery above — extends existing helpers; smaller surface than the original draft.
| File | Change |
|---|---|
| `src/node/utils/sanitizeProxyPath.ts` | Extend header source list to also read `x-forwarded-prefix` and `x-ingress-path`. Standard headers (everything other than the existing `x-proxy-path`) gated on `settings.trustProxy === true`. First non-empty wins, after sanitization. |
| `src/node/hooks/express/pwa.ts` | `/manifest.json` handler reads `sanitizeProxyPath(req)` and emits `${proxyPath}/favicon.ico`, `${proxyPath}/static/skins/...`, `${proxyPath}/` for `start_url`. Mark response `Vary: x-proxy-path, x-ingress-path, x-forwarded-prefix` + `Cache-Control: private, no-store` when proxyPath is non-empty (mirrors the admin handler's pattern). |
| `src/node/utils/socialMeta.ts` | `buildAbsoluteUrl` honors `proxyPath` when falling back to from-request origin: `${origin}${proxyPath}${pathname}`. `publicURL` still wins when set. |
| `src/templates/index.html` | Replace `<link rel="manifest" href="/manifest.json">` and the jslicense `<a href="/javascript">` with `proxyPath`-prefixed values. Route handler in `specialpages.ts` passes `proxyPath` as an explicit template variable. |
| `src/templates/timeslider.html`, `pad.html` | jslicense `<a href>` and `<form action="/ep/pad/reconnect">` use `proxyPath`. Fix pre-existing `..`-count bug in the manifest `<link>` (`../../manifest.json` in pad.html resolves under a prefix as `/manifest.json` instead of `/sub/manifest.json`; ditto `../../../manifest.json` in timeslider). Reduce by one to make the path correct in both root-mount and prefix-mount cases. |
| `src/templates/export_html.html` | `<link rel="manifest">` uses proxyPath via the export route's render context. |
| `src/node/hooks/express/specialpages.ts` + export route | Pass `proxyPath` into every `eejs.require` call that renders the affected templates. |
| `settings.json.template` + `Settings.ts` doc comment | Document the three honored header names and the trustProxy gate. No new field. |
Out: no new `basePath.ts`, no Express middleware, no EJS-context-wide helper, no admin Vite rebuild, no `clientVars.basePath`, no edits to `pad.ts` / `timeslider.ts` / `padBootstrap.js`. Pre-existing code already covers those surfaces (`pad.baseURL` and `window.plugins.baseURL` are derived client-side from `window.location` in `padBootstrap.js` / `timeSliderBootstrap.js`).
2. `sanitizeProxyPath(req)` → `'/api/hassio_ingress/abc123'` (read from `X-Ingress-Path` because trustProxy is on; would also accept `X-Forwarded-Prefix`).
3. `specialpages.ts` route handler for `/p/:pad`: renders `pad.html` with `proxyPath` in the template context. Output includes:
- jslicense `<a href>` and reconnect form `action` prefixed via the EJS variable.
- `<link rel="manifest" href="../manifest.json">` (fixed from `../../manifest.json` — see Risks): resolves to `/api/hassio_ingress/abc123/manifest.json`.
- `<link href="../static/css/pad.css...">` is already relative — resolves under the prefix to `/api/hassio_ingress/abc123/static/css/pad.css`.
4. Browser fetches `/api/hassio_ingress/abc123/manifest.json` → `pwa.ts` route emits manifest with all icon `src` values prefixed; `start_url` prefixed.
5. Browser establishes socket.io: client-side `padBootstrap.js` computes `basePath = new URL('..', window.location).pathname` → `/api/hassio_ingress/abc123/` → `pad.baseURL` set → `socketio.connect('/api/hassio_ingress/abc123/', ...)` → socket.io path is `/api/hassio_ingress/abc123/socket.io/`. No code change here — pre-existing logic.
7. Inner ace iframe loads `../static/empty.html` (relative) → resolves to `/api/hassio_ingress/abc123/static/empty.html` naturally. No code change in the inner iframe; plugin CSS injected via `aceEditorCSS` is also relative-prefixed (`../static/plugins/...`).
Direct (non-proxied) request — same code path:
1. No `x-proxy-path`, no `X-Forwarded-Prefix`, no `X-Ingress-Path` → `sanitizeProxyPath(req) === ''`.
2. Templates render today's output. The reduced-`..`-count manifest link still resolves to `/manifest.json` from a root-mount pad URL (`/p/test`), so no observable change for non-proxied users.
- GET `/admin/`: assert prefix is present in `<link>`/`<script>` paths and `<base href>` present. The exact mechanism (Vite `base: './'` rebuild vs. server-side templating of the admin HTML) is chosen during implementation; the test only asserts the post-render output.
- GET `/` with same headers but trustProxy OFF: no prefix anywhere, output
matches the trustProxy-on-no-headers output.
- GET `/` with `X-Forwarded-Prefix: /a/../b`: no prefix (rejected),
The pad-side "Etherpad on this server is severely outdated. Tell your admin." banner is shown to every visitor of every pad, persistently, whenever the running server is at least one major version behind the latest published release. The reporter (a server admin) says:
> "it's inappropriate to inform users of a site about maintenance tasks that they don't understand or have context to resolve. It wastes users' time by having them try and contact me, and it wastes my time by having to respond."
In addition to the social problem, the current implementation triggers on develop checkouts and on minor-only deltas in some upstream-version states, and has been observed intercepting chat-icon clicks (z-index 9999, bottom-right) in plugin test matrices pinned to older cores.
## Goals
1. The notice is shown **only to the pad's first author** (the author whose ID occupies position 0 in the pad's attribute pool — i.e. whoever made the first edit).
2. The notice is **non-persistent**: a dismissable `$.gritter` toast, auto-fading after 8s, rather than an always-visible badge.
3. The notice fires **only on minor-or-more behind** (e.g. 3.1.0 → 3.2.0, 2.7.3 → 3.0.0). Patch-only deltas (3.0.1 → 3.0.2) never fire.
4. The notice never fires when `current >= latest` (covers the develop-after-bump case).
5. The `vulnerable-below` UI is **dropped entirely**, along with the directive parser and state field. The vulnerable enum is gone from the API.
## Non-goals
- No new settings flag. `updates.tier = 'off'` remains the kill-switch.
- No translations of new strings in this PR. A `TODO(i18n)` placeholder is carried forward — strings are hard-coded English, mirroring the current state of the badge code. A follow-up adds `pad.outdatedNotice.*` keys once the html10n key set is set up to be shared with the pad-side bundle.
## Architecture
```
Browser (pad load, after CLIENT_VARS) Server
───────────────────────────────────── ──────
pad.ts → maybeShowOutdatedNotice()
│
├─ GET /api/version-status?padId=<id> (cookies: express_sid)
│
│ loadState(stateFilePath())
│ │
│ ├─ no latest → {outdated:null,isFirstAuthor:false}
│ ├─ current >= latest → {outdated:null,isFirstAuthor:false}
The endpoint shape collapses to a single enum (`'minor' | null`) plus a per-request `isFirstAuthor` boolean. The server never returns a positive `outdated` value to a non-first-author requester — there is no client-side "the answer is minor, but show it conditionally" path. Operational signal does not leak to ordinary pad visitors.
## Server changes
### `src/node/updater/versionCompare.ts`
- **Add**`isMinorOrMoreBehind(current: string, latest: string): boolean` — `true` iff `parseSemver(current).major < parseSemver(latest).major`, or majors equal and `current.minor < latest.minor`. Patch-only delta returns `false`. Returns `false` on parse failure of either side.
- **Delete**`isMajorBehind`, `isVulnerable`, `parseVulnerableBelow`, the `VULN_RE` regex, and the `VulnerableBelowDirective` import.
### `src/node/updater/types.ts`
- **Delete**`VulnerableBelowDirective`.
- **Delete**`UpdaterState.vulnerableBelow` field.
### `src/node/updater/state.ts`
- Stop reading and stop writing `vulnerableBelow`. Existing state files with the field still parse — the loader ignores unknown keys. No migration needed; the field naturally drops on next write.
### `src/node/updater/VersionChecker.ts`
- Remove the release-notes scraping that called `parseVulnerableBelow`. The rest of the check (current vs latest tag) is unchanged.
- `resolveRequestAuthor(req)` is a small helper that reads `req.cookies.express_sid`, calls `sessionStore.get(sid)` (the same store used by the express-session middleware), and returns `session?.user?.author ?? null`. On any failure path it returns `null` — the request is then treated as anonymous and gets `EMPTY`.
- `padId` is validated through `padutils.validateRequest({padID: padId})` before being passed to `padManager`. Validation failures map to `EMPTY`, not 400 — keeping the endpoint quiet about whether the pad exists.
- LRU cap of 1000 entries bounds memory on busy servers; entries expire by TTL anyway.
- Single-flight per cache key collapses bursts at expiry into one disk read.
- `_resetBadgeCacheForTests()` clears both `cache` and `inFlight`.
### `src/node/hooks/express/openapi-admin.ts`
- Update the OpenAPI doc for `/api/version-status`:
- Add `padId` query parameter (string, optional, must match Etherpad's pad-id format).
- `pnpm exec playwright test outdated_notice` clean under `xvfb-run` (frontend).
- Manual: load a pad on the dev server (`http://localhost.lan:9003/p/test`) with `var/update.state.json` pinned to a higher `latest.version` — gritter appears once for first-author in incognito-A, absent in incognito-B (second visitor).
## Docs / settings / build
- `doc/api/http_api.md` (and `.adoc` if present) — update `/api/version-status` entry: new shape, new `padId` query param, note that positive results are scoped to first-author.
- `doc/api/updater.md` (or the relevant `updates.tier` section in `doc/settings.md`) — drop the paragraph(s) on the vulnerable-below directive and the persistent banner UI.
- `CHANGELOG.md` (Unreleased) — one entry: "Outdated-version notice redesigned per #7799 — transient gritter, first-author only, minor-or-major behind only. The persistent banner, `severe` enum, and `vulnerable-below` directive scraping are removed."
- No settings-schema changes. `updates.tier = 'off'` remains the full kill-switch.
- `vite.config.ts` (and any other bundle config) — rename `pad_version_badge` entries to `pad_outdated_notice`. Grep to confirm no admin-bundle reference exists (shouldn't; pad-only).
## Risk / open questions
- **Develop-on-stale-package.json.** Today develop's `package.json` reads `2.7.3` while the latest npm release is newer. Under this design, the notice still triggers on develop because `current < latest`. The expected operational practice is for the post-release bump of develop's `package.json` to a higher pre-release identifier to short-circuit this naturally. Documented in the CHANGELOG entry. If maintainers want belt-and-braces, a follow-up can add a `.git`-presence short-circuit, but that is explicitly out-of-scope here per the design decision.
- **First-author churn on imported pads.** If a pad was created via `setText`/API by an admin script using a service-account author, the first-author signal points at that service account. Operationally fine — the notice just won't fire for anyone. Acceptable.
- **Anonymous browsers without express_sid.** First load of a pad with no prior session has no `express_sid` cookie until `socket.io` connects. The version-status request fires after `CLIENT_VARS`, which is after the socket handshake, so by then the cookie exists. If for any reason it doesn't, `resolveRequestAuthor` returns `null` and the response is `EMPTY` — fail-quiet.
"index.copyLinkDescription":"انقر على الزر أدناه لنسخ الرابط إلى الحافظة الخاصة بك.",
"index.copyLinkButton":"نسخ الرابط إلى الحافظة",
"index.copyLinkButton":"نُسخت الوصلة إلى الحافظة",
"index.transferToSystem":"3. نسخ الجلسة إلى النظام الجديد",
"index.transferToSystemDescription":"افتح الرابط المنسوخ في المتصفح أو الجهاز المستهدف لنقل جلستك.",
"index.transferSessionDescription":"انقل جلستك الحالية إلى المتصفح أو الجهاز بالنقر على الزر أدناه. سيؤدي هذا إلى نسخ رابط لصفحة ستنقل جلستك عند فتحها في المتصفح أو الجهاز المستهدف.",
"pad.importExport.noConverter.innerHTML":"لا يمكنك الاستيراد إلا من تنسيقات النصوص العادية أو تنسيقات HTML. لمزيد من ميزات الاستيراد المتقدمة، يرجى <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">تثبيت LibreOffice</a> .",
"pad.importExport.noConverter.innerHTML":"يمكنك استيراد النصوص العادية، وملفات HTML، وملفات Microsoft Word (.docx)، وملفات Etherpad مباشرةً. لاستيراد تنسيقات أخرى مثل PDF، وODT، وDOC، وRTF، يحتاج مسؤول الخادم إلى تثبيت LibreOffice - راجع <a href=\"https://docs.etherpad.org/\">وثائق Etherpad</a> .",
"pad.modals.connected":"متصل.",
"pad.modals.reconnecting":"إعادة الاتصال ببادك..",
"admin_plugins.catalog_disabled":"Katalog pluginů je zakázán vaším operátorem (privacy.pluginCatalog=false). Chcete-li plugin nainstalovat, spusťte příkaz `pnpm run plugins i ep_<name> ` ze serveru.",
"admin_plugins.crumbs":"Pluginy",
"admin_plugins.description":"Popis",
"admin_plugins.disables.label":"Vypnuto:",
"admin_plugins.disables.warning_title":"Tento plugin záměrně odstraňuje uvedené funkce Etherpadu.",
"admin_plugins.error_retrieving":"Chyba při načítání pluginů",
"admin_plugins.install_error":"Instalace pluginu {{plugin}} se nezdařila: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad":"Nelze nainstalovat {{plugin}}: vyžaduje novější verzi Etherpadu. Prosím, aktualizujte Etherpad a zkuste to znovu.",
"admin_settings.toast.saved":"Nastavení bylo úspěšně uloženo.",
"admin_settings.toast.save_failed":"Uložení se nezdařilo: soubor settings.json se nepodařilo zapsat.",
"admin_settings.toast.json_invalid":"Syntaktická chyba: zkontrolujte čárky, závorky a uvozovky.",
"admin_settings.toast.disconnected":"Nelze uložit: nejsem připojen k serveru.",
"admin_settings.toast.validation_ok":"JSON je platný.",
"admin_settings.toast.validation_failed":"JSON je neplatný: opravte syntaktické chyby.",
"admin_settings.toast.prettify_failed":"Nelze upravovat: nejprve opravte syntaktické chyby.",
"admin_settings.prettify_confirm":"Zkrášlováním odstraníte všechny komentáře. Pokračovat?",
"admin_settings.mode.form":"Formulář",
"admin_settings.mode.effective":"Efektivní",
"admin_settings.mode.effective_tooltip":"Zobrazení hodnot, které Etherpad aktuálně používá, pouze pro čtení, po substituci proměnných prostředí. Tajné kódy jsou redigovány.",
"admin_settings.mode.aria_label":"Režim editoru",
"admin_settings.envvar_banner.title":"Tento soubor je šablona, nikoli živá konfigurace.",
"admin_plugins.catalog_disabled":"Plugin-Katalog wurde vom Betreiber deaktiviert (privacy.pluginCatalog=false). Um ein Plugin zu installieren, führen Sie `pnpm run plugins i ep_<name>` auf dem Server aus.",
"admin_plugins.crumbs":"Plugins",
"admin_plugins.description":"Beschreibung",
"admin_plugins.disables.label":"Deaktiviert:",
"admin_plugins.disables.warning_title":"Dieses Plugin entfernt absichtlich die aufgeführten Etherpad-Funktionen.",
"admin_plugins.error_retrieving":"Fehler beim Abrufen von Plugins",
"admin_plugins.install_error":"Installation von {{plugin}} fehlgeschlagen: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad":"Installation von {{plugin}} nicht möglich: Hierfür ist neuere Version von Etherpad notwendig. Bitte führe ein Upgrade von Etherpad durch und versuche es erneut.",
"admin_settings.toast.save_failed":"Speichern fehlgeschlagen: settings.json konnte nicht geschrieben werden.",
"admin_settings.toast.json_invalid":"Syntaxfehler: Überprüfen Sie Kommas, Klammern und Anführungszeichen.",
"admin_settings.toast.disconnected":"Nicht gespeichert: Nicht mit dem Server verbunden.",
"admin_settings.toast.validation_ok":"JSON ist gültig.",
"admin_settings.toast.validation_failed":"JSON ist ungültig: Bitte beheben Sie Syntaxfehler.",
"admin_settings.toast.prettify_failed":"Prettify nicht möglich: Bitte beheben Sie zunächst Syntaxfehler.",
"admin_settings.prettify_confirm":"Prettify entfernt alle Kommentare. Weiter?",
"admin_settings.mode.form":"Formular",
"admin_settings.mode.effective_tooltip":"Read-only-Ansicht der Werte, die Etherpad tatsächlich verwendet, nach Umgebungsvariablen-Ersetzung. Secrets sind geschwärzt.",
"admin_settings.mode.aria_label":"Editor-Modus",
"admin_settings.envvar_banner.title":"Diese Datei ist eine Vorlage, nicht die Live-Konfiguration.",
"admin_settings.envvar_banner.body":"Platzhalter wie ${VAR:default} werden beim Start in den Speicher eingesetzt; sie werden nie in diese Datei zurückgeschrieben. Bearbeiten Sie env vars in Ihrer Umgebung (Docker compose, systemd, .env), um den aufgelösten Wert zu ändern, oder ersetzen Sie den Platzhalter hier durch ein Literal. Wechseln Sie auf die Registerkarte \"Effective\", um zu sehen, was Etherpad gerade verwendet.",
"admin_settings.toast.auth_error":"Sie sind nicht als Administrator authentifiziert. Bitte melden Sie sich erneut an.",
"admin_settings.section.general":"Allgemein",
"admin_settings.parse_error.title":"Parsen von settings.json nicht möglich",
"index.placeholderPadEnter":"Gib den Namen des Pads ein...",
"index.createAndShareDocuments":"Erstelle und teile Dokumente in Echtzeit",
"index.createAndShareDocumentsDescription":"Etherpad ermöglicht die gemeinsame Bearbeitung von Dokumenten in Echtzeit, ähnlich wie ein Live-Multiplayer-Editor, der in Ihrem Browser läuft.",
"index.createAndShareDocumentsDescription":"Etherpad ermöglicht die gemeinsame Bearbeitung von Dokumenten in Echtzeit, ähnlich wie ein Live-Multiplayer-Editor, der in Deinem Browser läuft.",
"pad.noCookie":"Das Cookie konnte nicht gefunden werden. Bitte erlaube Cookies in deinem Browser! Deine Sitzung und Einstellungen werden zwischen den Besuchen nicht gespeichert. Dies kann darauf zurückzuführen sein, dass Etherpad in einigen Browsern in einem iFrame enthalten ist. Bitte stelle sicher, dass sich Etherpad auf der gleichen Subdomain/Domain wie der übergeordnete iFrame befindet.",
"pad.permissionDenied":"Du hast keine Berechtigung, um auf dieses Pad zuzugreifen.",
"pad.settings.rtlcheck":"Inhalt von rechts nach links lesen?",
"pad.settings.enforcedNotice":"Diese Einstellungen wurden vom Ersteller des Pads gesperrt. Wenden Sie sich an den Ersteller, wenn Sie diese ändern möchten.",
"pad.settings.fontType":"Schriftart:",
"pad.settings.fontType.normal":"Normal",
"pad.settings.language":"Sprache:",
"pad.settings.deletePad":"Pad löschen",
"pad.delete.confirm":"Möchtest du dieses Pad wirklich löschen?",
"pad.deletionToken.modalBody":"Dieses Token ist die einzige Möglichkeit, dieses Pad zu löschen, falls Deine Browsersitzung unterbrochen wird oder Du das Gerät wechselst. Speichere es an einem sicheren Ort – es wird hier nur einmal angezeigt.",
"pad.deletionToken.deleteWithToken":"Pad mit Token löschen",
"pad.importExport.exportetherpada.title":"Export im Etherpad-Format",
"pad.importExport.exporthtmla.title":"Exportieren als HTML",
"pad.importExport.exportplaina.title":"Export als einfacher Text",
"pad.importExport.exportworda.title":"Exportieren als Microsoft Word",
"pad.importExport.exportpdfa.title":"Als PDF exportieren",
"pad.importExport.exportopena.title":"Export als ODF (Open Document Format)",
"pad.importExport.noConverter.innerHTML":"Du kannst nur aus reinen Text- oder HTML-Formaten importieren. Für umfangreichere Importfunktionen <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">muss AbiWord oder LibreOffice auf dem Server installiert werden</a>.",
"pad.modals.connected":"Verbunden.",
"pad.modals.reconnecting":"Dein Pad wird neu verbunden...",
"pad.modals.rateLimited.explanation":"Sie haben zu viele Nachrichten an dieses Pad gesendet, so dass die Verbindung unterbrochen wurde.",
"pad.modals.rejected.explanation":"Der Server hat eine Nachricht abgelehnt, die von deinem Browser gesendet wurde.",
"pad.modals.rejected.cause":"Möglicherweise wurde der Server aktualisiert, während du das Pad angesehen hast, oder es existiert ein Fehler in Etherpad. Versuche, die Seite neu zu laden.",
"pad.modals.disconnected":"Ihre Verbindung wurde getrennt.",
"pad.modals.disconnected":"Deine Verbindung wurde getrennt.",
"pad.modals.disconnected.explanation":"Die Verbindung zum Server wurde unterbrochen.",
"pad.modals.disconnected.cause":"Möglicherweise ist der Server nicht erreichbar. Bitte benachrichtige den Dienstadministrator, falls dies weiterhin passiert.",
"admin_pads.empty_never_edited":"empty · never edited",
"admin_pads.error_dialog_description":"An error has occurred.",
"admin_pads.error_prefix":"Error",
"admin_pads.filter.active":"Active",
"admin_pads.filter.all":"All",
@ -132,13 +137,21 @@
"admin_settings.prettify_confirm":"Prettifying will remove all comments. Continue?",
"admin_settings.mode.form":"Form",
"admin_settings.mode.raw":"Raw",
"admin_settings.mode.effective":"Effective",
"admin_settings.mode.effective_tooltip":"Read-only view of the values Etherpad is actually using right now, after environment-variable substitution. Secrets are redacted.",
"admin_settings.mode.aria_label":"Editor mode",
"admin_settings.envvar_banner.title":"This file is a template, not the live config.",
"admin_settings.envvar_banner.body":"Placeholders like ${VAR:default} are substituted into memory at startup; they are never written back to this file. Edit env vars in your environment (Docker compose, systemd, .env) to change the resolved value, or replace the placeholder here with a literal. Switch to the Effective tab to see what Etherpad is using right now.",
"admin_settings.toast.auth_error":"You are not authenticated as admin. Please log in again.",
"index.transferToSystem":"3. Copy session to new system",
"index.transferToSystemDescription":"Open the copied link in the target browser or device to transfer your session.",
"index.code":"Code",
"index.transferSessionDescription":"Transfer your current session to browser or device by clicking the button below. This will copy a link to a page that will transfer your session when opened in the target browser or device.",
"index.createOpenPad":"Open pad by name",
"index.openPad":"open an existing Pad with the name:",
@ -300,7 +314,7 @@
"pad.importExport.exportworda.title":"Export as Microsoft Word",
"pad.importExport.exportpdfa.title":"Export as PDF",
"pad.importExport.exportopena.title":"Export as ODF (Open Document Format)",
"pad.importExport.noConverter.innerHTML":"You can only import from plain text or HTML formats. For more advanced import features, please <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">install LibreOffice</a>.",
"pad.importExport.noConverter.innerHTML":"You can import plain text, HTML, Microsoft Word (.docx) and Etherpad files directly. To import other formats such as PDF, ODT, DOC or RTF, the server administrator needs to install LibreOffice — see the <a href=\"https://docs.etherpad.org/\">Etherpad documentation</a>.",
"pad.modals.connected":"Connected.",
"pad.modals.reconnecting":"Reconnecting to your pad…",
"admin_plugins.available_search.placeholder":"Rechercher des greffons à installer",
"admin_plugins.check_updates":"Vérifier les mises à jour",
"admin_plugins.catalog_disabled":"Le catalogue de greffons est désactivé par votre administrateur (privacy.pluginCatalog=false). Pour installer un greffon, exécutez `pnpm run plugins i ep_<name> ` sur le serveur.",
"admin_plugins.crumbs":"Greffons",
"admin_plugins.description":"Description",
"admin_plugins.disables.label":"Désactive:",
"admin_plugins.disables.warning_title":"Ce greffon supprime intentionnellement les fonctionnalités Etherpad listées.",
"admin_plugins.error_retrieving":"Erreur lors de la récupération des greffons",
"admin_plugins.install_error":"Échec de l'installation de {{plugin}}: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad":"Impossible d'installer {{plugin}}: une version plus récente d'Etherpad est requise. Veuillez mettre à jour Etherpad et réessayer.",
"admin_plugins.installed":"Greffons installés",
"admin_plugins.installed_fetching":"Récupération des greffons installés en cours...",
"admin_plugins.installed_nothing":"Vous n’avez encore installé aucun greffon.",
@ -50,8 +113,25 @@
"admin_plugins.last-update":"Dernière mise à jour",
"admin_plugins.name":"Nom",
"admin_plugins.page-title":"Gestionnaire de greffons — Etherpad",
"admin_plugins.reload_catalog":"Recharger le catalogue",
"pad.settings.deletePad":"Supprimer le bloc-notes",
"pad.delete.confirm":"Voulez-vous vraiment supprimer ce bloc-notes ?",
"pad.deletionToken.modalTitle":"Enregistrez votre jeton de suppression de bloc-notes",
"pad.deletionToken.modalBody":"Ce jeton est le seul moyen de supprimer ce bloc-notes si vous perdez votre session de navigateur ou si vous changez d'appareil. Conservez-le en lieu sûr — il n'apparaît ici qu'une seule fois.",
"pad.importExport.noConverter.innerHTML":"Vous pouvez uniquement importer du texte brut ou du HTML. Pour des fonctionnalités d'importation plus avancées, veuillez <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">installer LibreOffice</a>.",
"pad.importExport.exportetherpada.title":"Exporter au format Etherpad",
"pad.importExport.exporthtmla.title":"Exporter au format HTML",
"pad.importExport.exportplaina.title":"Exporter en texte brut",
"pad.importExport.noConverter.innerHTML":"Vous pouvez importer directement du texte brut, du HTML,Microsoft Word (.docx) et des fichiers Etherpad. Pour importer d'autres formats tels que PDF, ODT, DOC ou RTF, l'administrateur du serveur doit installer LibreOffice; consultez la <a href=\"https://docs.etherpad.org/\">documentation Etherpad</a> .",
"pad.modals.connected":"Connecté.",
"pad.modals.reconnecting":"Reconnexion à votre bloc-notes en cours...",
"pad.modals.forcereconnect":"Forcer la reconnexion",
"admin_plugins.catalog_disabled":"Tá catalóg breiseán díchumasaithe ag d'oibreoir (privacy.pluginCatalog=false). Chun breiseán a shuiteáil, rith `pnpm run plugins i ep_<ainm>` ón bhfreastalaí.",
"admin_plugins.crumbs":"Breiseáin",
"admin_plugins.description":"Cur síos",
"admin_plugins.disables.label":"Díchumasaíonn:",
"admin_plugins.disables.warning_title":"Baintear na gnéithe Etherpad atá liostaithe d'aon ghnó leis an mbreiseán seo.",
"admin_plugins.error_retrieving":"Earráid ag aisghabháil breiseán",
"admin_plugins.install_error":"Theip ar {{plugin}} a shuiteáil: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad":"Ní féidir {{plugin}} a shuiteáil: teastaíonn leagan níos nuaí de Etherpad. Uasghrádaigh Etherpad agus déan iarracht arís.",
"admin_plugins.installed":"Breiseáin suiteáilte",
"admin_plugins.installed_fetching":"Ag fáil breiseáin suiteáilte…",
"admin_plugins.installed_nothing":"Níl aon bhreiseáin suiteáilte agat fós.",
"admin_settings.toast.validation_failed":"Tá JSON neamhbhailí: ceartaigh earráidí comhréire le do thoil.",
"admin_settings.toast.prettify_failed":"Ní féidir áilleacht a dhéanamh: ceartaigh earráidí comhréire ar dtús le do thoil.",
"admin_settings.prettify_confirm":"Bainfear na tráchtanna go léir má dhéantar áilleachtú. Leanfaidh tú ar aghaidh?",
"admin_settings.mode.form":"Foirm",
"admin_settings.mode.raw":"Amh",
"admin_settings.mode.effective":"Éifeachtach",
"admin_settings.mode.effective_tooltip":"Radharc inléite amháin ar na luachanna atá á n-úsáid ag Etherpad faoi láthair, tar éis athsholáthar athróg timpeallachta. Tá rúin curtha in eagar.",
"admin_settings.envvar_banner.title":"Is teimpléad an comhad seo, ní an chumraíocht bheo.",
"admin_settings.envvar_banner.body":"Cuirtear áitchoimeádaithe cosúil le ${VAR:default} isteach sa chuimhne ag an am tosaithe; ní scríobhtar ar ais chuig an gcomhad seo iad choíche. Cuir na cineálacha timpeallachta in eagar i do thimpeallacht (Docker compose, systemd, .env) chun an luach réitithe a athrú, nó cuir litriúil in ionad an áitchoimeádaitheora anseo. Téigh go dtí an cluaisín Éifeachtach chun a fheiceáil cad atá á úsáid ag Etherpad faoi láthair.",
"admin_settings.toast.auth_error":"Níl tú fíordheimhnithe mar riarthóir. Logáil isteach arís le do thoil.",
"admin_settings.section.general":"Ginearálta",
"admin_settings.parse_error.title":"Ní féidir settings.json a pharsáil",
"admin_settings.parse_error.cta":"Athraigh go amh le heagarthóireacht",
"admin_settings.env_pill.tooltip":"Léann sé ón athróg timpeallachta {{variable}}. Úsáidtear an luach thíos nuair nach bhfuil {{variable}} socraithe.",
"admin_settings.save_error":"Earráid ag sábháil socruithe",
"admin_settings.saved_success":"Socruithe sábháilte go rathúil",
@ -150,12 +185,15 @@
"update.page.policy.rollback-failed-terminal":"Theip ar nuashonrú roimhe seo agus níorbh fhéidir é a chur ar ais. Brúigh Admhaigh tar éis don tsuiteáil a bheith sláintiúil chun an glas a ghlanadh.",
"update.page.policy.up-to-date":"Tá an leagan is déanaí á rith agat.",
"update.page.policy.maintenance-window-missing":"Éilíonn Sraith 4 (uathrialach) fuinneog chothabhála. Socraigh updates.maintenanceWindow i settings.json chun nuashonruithe uathrialacha a chumasú.",
"update.page.policy.maintenance-window-invalid":"Tá Sraith 4 (uathrialach) díchumasaithe mar gheall ar mhífhoirmiú updates.maintenanceWindow. Bhíothas ag súil le {start, end, tz} le hamanna HH:MM agus tz de \"local\" nó \"utc\".",
"update.page.last_result.verified":"Nuashonrú deireanach ar {{tag}} fíoraithe.",
"update.page.last_result.rolled-back":"Cuireadh an iarracht dheireanach ar {{tag}} a nuashonrú ar ceal: {{reason}}.",
"update.page.last_result.rollback-failed":"Theip ar an iarracht nuashonraithe dheireanach AGUS theip ar an rolladh ar ais: {{reason}}. Idirghabháil láimhe ag teastáil.",
"update.page.last_result.preflight-failed":"Theip ar an iarracht dheireanach ar {{tag}} a nuashonrú roimh ré: {{reason}}.",
"update.page.last_result.cancelled":"Cealaíodh an iarracht dheireanach ar {{tag}} a nuashonrú ag an riarthóir.",
"update.window.next_opens_at":"Osclaítear an chéad fhuinneog eile ag {{at}}.",
"update.drain.t60":"Atosóidh Etherpad i gceann 60 soicind chun nuashonrú a chur i bhfeidhm.",
"update.drain.t30":"Atosóidh Etherpad i gceann 30 soicind chun nuashonrú a chur i bhfeidhm.",
"update.drain.t10":"Atosóidh Etherpad i gceann 10 soicind chun nuashonrú a chur i bhfeidhm.",
@ -181,6 +230,7 @@
"index.copyLinkButton":"Cóipeáil nasc chuig an ghearrthaisce",
"index.transferToSystem":"3. Cóipeáil an seisiún chuig an gcóras nua",
"index.transferToSystemDescription":"Oscail an nasc cóipeáilte sa bhrabhsálaí nó ar an ngléas sprice chun do sheisiún a aistriú.",
"index.code":"Cód",
"index.transferSessionDescription":"Aistrigh do sheisiún reatha chuig brabhsálaí nó gléas trí chliceáil ar an gcnaipe thíos. Cóipeálfaidh sé seo nasc chuig leathanach a aistreoidh do sheisiún nuair a osclófar é sa bhrabhsálaí nó sa ghléas sprice.",
"index.createOpenPad":"Oscail ceap de réir ainm",
"index.openPad":"oscail Pad atá ann cheana féin leis an ainm:",
"admin_plugins.catalog_disabled":"O catálogo de complementos está desactivado polo operador (privacy.pluginCatalog=false). Para instalar un complemento, executa `pnpm run plugins i ep_<nome>` no servidor.",
"admin_plugins.crumbs":"Complementos",
"admin_plugins.description":"Descrición",
"admin_plugins.disables.label":"Desactiva:",
"admin_plugins.disables.warning_title":"Este complemento elimina intencionadamente as funcionalidades de Etherpad listadas.",
"admin_plugins.error_retrieving":"Erro ao recuperar os complementos",
"admin_plugins.install_error":"Erro ao instalar «{{plugin}}»: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad":"Non se pode instalar «{{plugin}}»: cómpre unha versión máis recente de Etherpad. Actualiza Etherpad e inténtao de novo.",
"admin_settings.toast.save_failed":"Fallou o gardado: non se puido escribir no ficheiro «settings.json».",
"admin_settings.toast.json_invalid":"Erro de sintaxe: comproba as comas, as chaves e as comiñas.",
"admin_settings.toast.disconnected":"Non se pode gardar: non tes conexión co servidor.",
"admin_settings.toast.validation_ok":"O JSON é válido.",
"admin_settings.toast.validation_failed":"O JSON non é válido: corrixe os erros de sintaxe.",
"admin_settings.toast.prettify_failed":"Non se pode aquelar: primeiro corrixe os erros de sintaxe.",
"admin_settings.prettify_confirm":"Ao aquelar, eliminaranse todos os comentarios. Queres continuar?",
"admin_settings.mode.form":"Formulario",
"admin_settings.mode.raw":"En bruto",
"admin_settings.mode.effective":"Efectivo",
"admin_settings.mode.effective_tooltip":"Vista de só lectura dos valores que Etherpad está a usar agora mesmo, despois da substitución das variables de contorno. Os segredos están agochados.",
"admin_settings.mode.aria_label":"Modo de edición",
"admin_settings.envvar_banner.title":"Este ficheiro é un modelo, non a configuración real.",
"admin_settings.envvar_banner.body":"Os marcadores de posición como «${VAR:default}» substitúense na memoria durante o inicio da aplicación; nunca se escribir neste ficheiro. Edita as variables no teu contorno (Docker compose, systemd, .env) para cambiar o valor resolto ou substitúe o marcador de posición aquí por unha cadea de texto literal. Cambia á lapela «Efectivo» para ver o que está a usar Etherpad agora mesmo.",
"admin_settings.toast.auth_error":"Non te conectaches cunha conta administrativa. Inicia sesión de novo.",
"admin_settings.section.general":"Xeral",
"admin_settings.parse_error.title":"Non se pode analizar o ficheiro «settings.json»",
"admin_settings.parse_error.cta":"Cambiar ao editor en bruto para editar",
"admin_settings.env_pill.tooltip":"Le a variable de contorno «{{variable}}». O valor que aparece a continuación úsase cando a variable «{{variable}}» non está definida.",
"admin_settings.env_pill.runtime_tooltip":"Etherpad está a usar este valor actualmente, resolto a partir de «{{variable}}» ou o seu valor predeterminado.",
"admin_settings.env_pill.redacted_tooltip":"Etherpad está a usar un valor para «{{variable}}», pero está oculto porque é un segredo.",
"admin_settings.page-title":"Axustes - Etherpad",
"admin_settings.save_error":"Produciuse un erro ao gardar os axustes",
"update.page.policy.rollback-failed-terminal":"Fallou unha actualización anterior e non se puido reverter. Preme en «Son consciente» despois de que a instalación estea correcta para levantar o bloqueo.",
"update.page.policy.up-to-date":"Estás a executar a última versión.",
"update.page.policy.tier-off":"As actualizacións están desactivadas (updates.tier = \"off\").",
"update.page.policy.maintenance-window-missing":"O nivel 4 (autónomo) necesita un período de mantemento. Define «updates.maintenanceWindow» en «settings.json» para activar as actualizacións autónomas.",
"update.page.policy.maintenance-window-invalid":"O nivel 4 (autónomo) está desactivado porque «updates.maintenanceWindow» ten un formato incorrecto. Esperábase «{inicio, fin, zona horaria}», con horas HH:MM e zona horaria «local» ou «utc».",
"update.page.last_result.verified":"Verificouse a última actualización a {{tag}}.",
"update.page.last_result.rolled-back":"Reverteuse o último intento de actualización a {{tag}}: {{reason}}.",
"update.page.last_result.rollback-failed":"Fallou o último intento de actualización E fallou a reversión: {{reason}}. Cómpre unha intervención manual.",
@ -170,9 +207,16 @@
"update.execution.rollback-failed":"Fallou a reversión",
"update.banner.terminal.rollback-failed":"Fallou un intento de actualización e non se puido reverter. Cómpre unha intervención manual.",
"update.banner.scheduled":"Actualización automática programada a {{tag}}: aplicarase en {{remaining}}.",
"update.banner.maintenance-window-missing":"As actualizacións autónomas están desactivadas ata que se configure un período de mantemento.",
"update.banner.maintenance-window-invalid":"As actualizacións autónomas están desactivadas porque o período de mantemento ten un formato incorrecto.",
"update.window.next_opens_at":"O seguinte período comeza ás {{at}}.",
"update.drain.t60":"Etherpad reiniciarase en 60 segundos para aplicar unha actualización.",
"update.drain.t30":"Etherpad reiniciarase en 30 segundos para aplicar unha actualización.",
"update.drain.t10":"Etherpad reiniciarase en 10 segundos para aplicar unha actualización.",
@ -188,6 +232,7 @@
"index.copyLinkButton":"Copiar a ligazón no portapapeis",
"index.transferToSystem":"3. Copia a sesión no novo sistema",
"index.transferToSystemDescription":"Abre a ligazón copiada no navegador ou dispositivo de destino para transferir a túa sesión.",
"index.code":"Código",
"index.transferSessionDescription":"Transfire a túa sesión actual ao navegador ou dispositivo facendo clic no botón de embaixo. Isto copiará unha ligazón cara a unha páxina que transferirá a túa sesión cando se abra no navegador ou dispositivo de destino.",
"index.createOpenPad":"Abrir un documento por nome",
"index.openPad":"abrir un documento existente co nome:",
@ -278,27 +323,27 @@
"pad.modals.userdup.explanation":"Semella que este documento está aberto en varias ventás do navegador neste ordenador.",
"pad.modals.userdup.advice":"Reconectar para usar esta ventá.",
"pad.modals.unauth":"Non autorizado",
"pad.modals.unauth.explanation":"Os seus permisos cambiaron mentres estaba nesta páxina. Intente a reconexión.",
"pad.modals.unauth.explanation":"Os geus permisos cambiaron mentres estabas nesta páxina. Intenta reconectarte.",
"pad.modals.looping.explanation":"Hai un problema de comunicación co servidor de sincronización.",
"pad.modals.looping.cause":"Seica a súa conexión pasa a través dun firewall ou proxy incompatible.",
"pad.modals.looping.cause":"Seica a túa conexión pasa a través dun firewall ou proxy incompatible.",
"pad.modals.initsocketfail":"Non se pode alcanzar o servidor.",
"pad.modals.initsocketfail.explanation":"Non se pode conectar co servidor de sincronización.",
"pad.modals.initsocketfail.cause":"Isto acontece probablemente debido a un problema co navegador ou coa conexión á internet.",
"pad.modals.initsocketfail.cause":"Isto acontece probablemente debido a un problema co navegador ou coa conexión a Internet.",
"pad.modals.slowcommit.explanation":"O servidor non responde.",
"pad.modals.slowcommit.cause":"Isto pode deberse a un problema de conexión á rede.",
"pad.modals.badChangeset.explanation":"O servidor de sincronización clasificou como ilegal unha das súas edicións.",
"pad.modals.badChangeset.cause":"Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Póñase en contacto co administrador do servizo, se pensa que isto é un erro. Intente reconectar para continuar editando.",
"pad.modals.corruptPad.explanation":"O documento ao que intenta acceder está corrompido.",
"pad.modals.corruptPad.cause":"Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Póñase en contacto co administrador do servizo.",
"pad.modals.badChangeset.explanation":"O servidor de sincronización clasificou como ilegal unha das túas edicións.",
"pad.modals.badChangeset.cause":"Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Ponte en contacto coa administración do servizo se pensas que isto é un erro. Intenta reconectar para continuar editando.",
"pad.modals.corruptPad.explanation":"O documento ao que intentas acceder está corrompido.",
"pad.modals.corruptPad.cause":"Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Ponte en contacto coa administración do servizo.",
"pad.modals.deleted":"Borrado.",
"pad.modals.deleted.explanation":"Este documento foi eliminado.",
"pad.modals.rateLimited":"Taxa limitada.",
"pad.modals.rateLimited.explanation":"Enviaches demasiadas mensaxes a este documento polo que te desconectamos.",
"pad.modals.rejected.explanation":"O servidor rexeitou unha mensaxe que o teu navegador enviou.",
"pad.modals.rejected.cause":"O servidor podería ter sido actualizado mentras ollabas o documento, ou pode que sexa un fallo de Etherpad. Intenta recargar a páxina.",
"pad.modals.disconnected":"Foi desconectado.",
"pad.modals.disconnected":"Desconectácheste.",
"pad.modals.disconnected.explanation":"Perdeuse a conexión co servidor",
"pad.modals.disconnected.cause":"O servidor non está dispoñible. Póñase en contacto co administrador do servizo se o problema continúa.",
"pad.modals.disconnected.cause":"O servidor non está dispoñible. Ponte en contacto coa administración do servizo se o problema continúa.",
"pad.gritter.unacceptedCommit.title":"Edición sen gardar",
"pad.gritter.unacceptedCommit.text":"A túa edición recente aínda non está gardada. Conéctate e inténtao de novo.",
"pad.share":"Compartir este documento",
@ -326,8 +371,13 @@
"pad.historyMode.settings.playbackSpeed":"Velocidade de reprodución:",
"pad.historyMode.chat.replayHeader":"Conversa o {{time}}",
"pad.historyMode.users.authorsHeader":"Autores nesta revisión",
"pad.editor.skipToContent":"Ir ata o editor",
"pad.editor.keyboardHint":"Preme a tecla Esc para saír do editor. Preme Alt+F9 para acceder á barra de ferramentas.",
"pad.editor.toolbar.formatting":"Barras de ferramentas de formato",
"pad.editor.toolbar.actions":"Barras de ferramentas de acción",
"pad.editor.toolbar.showMore":"Mostrar máis botóns da barra de ferramentas",
"timeslider.toolbar.authors":"Editoras:",
"timeslider.toolbar.authorsList":"Sen Editoras",
"timeslider.toolbar.authorsList":"Sen editoras",
"timeslider.toolbar.exportlink.title":"Exportar",
"timeslider.exportCurrent":"Exportar a versión actual en formato:",
"pad.editbar.clearcolors":"Eliminar as cores relativas ás participantes en todo o documento? Non se poderán recuperar.",
"pad.impexp.importbutton":"Importar agora",
"pad.impexp.importing":"Importando...",
"pad.impexp.confirmimport":"A importación dun ficheiro ha sobrescribir o texto actual do documento. Está seguro de querer continuar?",
"pad.impexp.convertFailed":"Non somos capaces de importar o ficheiro. Utilice un formato de documento diferente ou copie e pegue manualmente",
"pad.impexp.padHasData":"Non puidemos importar este ficheiro porque este documento xa sufriu cambios; importe a un novo documento.",
"pad.impexp.uploadFailed":"Houbo un erro ao cargar o ficheiro; inténteo de novo",
"pad.impexp.confirmimport":"A importación dun ficheiro ha sobrescribir o texto actual do documento. Queres continuar?",
"pad.impexp.convertFailed":"Non somos capaces de importar o ficheiro. Utiliza un formato de documento diferente ou copia e pega manualmente.",
"pad.impexp.padHasData":"Non puidemos importar este ficheiro porque este documento xa sufriu cambios; importa a un novo documento.",
"pad.impexp.uploadFailed":"Houbo un erro ao subir o ficheiro; inténtao de novo",
"pad.impexp.importfailed":"Fallou a importación",
"pad.impexp.copypaste":"Copie e pegue",
"pad.impexp.exportdisabled":"A exportación en formato {{type}} está desactivada. Póñase en contacto co administrador do sistema se quere máis detalles.",
"pad.impexp.maxFileSize":"Ficheiro demasiado granda. Contacta coa administración para aumentar o tamaño permitido para importacións",
"pad.impexp.copypaste":"Copia e pega",
"pad.impexp.exportdisabled":"A exportación en formato {{type}} está desactivada. Ponte en contacto coa administración do sistema se queres máis detalles.",
"pad.impexp.maxFileSize":"O ficheiro é demasiado grande. Contacta coa administración para aumentar o tamaño permitido para as importacións.",
"pad.social.description":"Un documento colaborativo que calquera pode editar en tempo real."
}
Some files were not shown because too many files have changed in this diff
Show more