* 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>