* fix(docker): share corepack cache so etherpad user can resolve pnpm (#7687)
PR #7674 switched the Dockerfile from `npm install -g pnpm` to corepack
and `corepack prepare pnpm@${PnpmVersion} --activate`. The activate step
runs as root and writes its lastKnownGood pin into `$COREPACK_HOME`,
which defaults to `~/.cache/node/corepack` — i.e. a per-user path. The
Dockerfile then drops to `USER etherpad` and later runs
`bin/installLocalPlugins.sh`, which invokes `pnpm` as etherpad. With an
empty per-user corepack cache and no shared activation file, corepack
re-resolves pnpm and (for forks/configs without a `packageManager` pin
matching the activated version) can fall back to "latest" from the
registry — pulling `pnpm@10.33.4` instead of the requested 11.x and
failing the workspace's `engines.pnpm` check.
Pin `COREPACK_HOME=/opt/corepack` and chown it to etherpad after the
prepare step. Both root and etherpad now share the same lastKnownGood
file and tarball cache, so etherpad inherits the activated pnpm without
hitting the registry again.
Verified end-to-end:
- `docker build --target development --build-arg ETHERPAD_LOCAL_PLUGINS=ep_test`
with a stub local plugin runs `installLocalPlugins.sh` cleanly:
`Done in 16.6s using pnpm v11.0.6`.
- `docker run ... pnpm --version` as etherpad reports 11.0.6 from the
shared cache — no "Unsupported environment" error.
Note: corepack still emits a one-time "about to download" line at
runtime because `corepack prepare pnpm@11.0.6` resolves to the highest
matching patch (11.0.8) at build time while the project's
`packageManager` field pins exactly 11.0.6. That's a follow-up — the
download succeeds non-interactively and the engine check passes.
Fixes#7687.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(docker): action Qodo PR review (#7687 follow-up)
- Replace hard-coded /opt/corepack with ${COREPACK_HOME} in mkdir/chown
so the env var stays the single source of truth (Qodo: "COREPACK_HOME
path duplication").
- Add a build-test-local-plugin job to .github/workflows/docker.yml that
builds the development target with a stub ETHERPAD_LOCAL_PLUGINS so
the original failure mode (corepack/pnpm cache invisible across the
USER switch) cannot silently regress (Qodo: "COREPACK_HOME fix lacks
test"). The job is small — `docker build` only, no run — and uses the
shared GHA buildx cache.
Verified: same docker build + `docker run pnpm --version` flow on the
variable form gives identical output (pnpm 11.0.6 from the etherpad-owned
cache).
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(settings): enable Pad-wide Settings by default; fix misleading modal title
The creator-owned Pad-wide Settings feature (#7545) shipped behind a flag that
defaulted to false. With the flag off the modal still rendered an H1 of
`pad.settings.padSettings` ("Pad-wide Settings") for *every* user, even though
no pad-wide controls were ever shown. Two readers in different browsers both
saw "Pad-wide Settings" as the modal title, which looked like a creator-gate
regression but was just a copy bug.
Two changes:
1. Flip the default of `enablePadWideSettings` to `true` (Settings.ts plus
both settings templates). With the feature on, the creator (revision-0
author) gets a real "Pad-wide Settings" section gated by
`clientVars.canEditPadSettings`, while every other user sees only "User
Settings" — matching the design intent of #7545. This is a behavior change,
so the settings comments are expanded to describe what the toggle now does.
2. Drop the conditional H1 in `src/templates/pad.html` and always use
`pad.settings.title` ("Settings"). Operators who explicitly disable the
feature shouldn't see a label that lies about a section that isn't
rendered.
Adds backend regression coverage in `tests/backend/specs/socketio.ts`:
- Different browsers (different cookie jars => different authorIDs): only the
first joiner gets `canEditPadSettings: true`.
- Same browser, two tabs (shared HttpOnly token cookie => same authorID):
both connections are the same identity, both correctly land on the creator
path.
* test(settings): regression coverage for the settings modal H1
Asserts the rendered `/p/<id>` HTML always uses
`data-l10n-id="pad.settings.title"` for the modal heading, regardless of
`enablePadWideSettings`. Catches a re-introduction of the old conditional
that printed "Pad-wide Settings" for every user when the feature was off.
Action of Qodo review feedback on PR #7679.
* fix(7686): legacy padOptions.userName/userColor=false breaks pad
Settings.json files generated before December 2021 used `false` as the
default for these two string options (commit 8c857a85a switched the
template default to `null` and noted "this change has no effect due to
a bug in how pad options are processed; that bug will be fixed in a
future commit" — the follow-up never landed). pad.ts:getParams() then
runs `false.toString()`, the resulting string "false" passes the
`!== false` sentinel check at _afterHandshake, and notifyChangeName
ships USERINFO_UPDATE with name="false" and colorId="false" (clobbered
via clientVars.userColor). The server's hex regex rejects the colour
and throws `malformed color: false`; the user sees their name as
"false" and a white swatch.
Defense in depth:
- Server: Settings.ts::reloadSettings() coerces legacy boolean false
to null for padOptions.userName / padOptions.userColor and warns the
operator, matching the existing disableIPlogging shim pattern.
- Client: the pad.ts userName / userColor callbacks reject the
literal "false" string so URL params (?userName=false) and any
other path that surfaces the sentinel as a string are also no-ops.
- Backend regression test mirrors the shim and asserts it normalizes
legacy false, leaves explicit values intact, leaves null untouched,
does not coerce other padOptions keys, and does not coerce the
string "false" (that path is the client guard's responsibility).
Closes#7686
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7686): guard padOptions shim against non-object config (Qodo)
Qodo flagged that storeSettings() will overwrite settings.padOptions
raw with whatever settings.json supplies — including null, primitives,
or arrays — which would make the new userName/userColor shim crash on
property access. Add a shape guard so the shim is a no-op for malformed
padOptions, and extend the regression test to cover null / primitive /
array shapes.
This doesn't change which configs work (Pad.ts also assumes padOptions
is an object and would already crash on a null padOptions when a pad
is opened) but it stops the shim from being the loud thing in the
stack trace if someone hits it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(docker): clear most CVEs in published image — npm/pnpm/uuid + drop curl
Cuts published-image vulnerabilities from 18 (4H/13M/1L) across 8 packages
to 12 (2H/9M/1L) across 3 packages. The remaining three (curl/libcurl,
git, busybox) are all upstream Alpine 3.23 packages with "not fixed"
status — libcurl is pulled in transitively by git and cannot be
removed independently.
Changes:
- Provision pnpm via corepack instead of `npm install -g pnpm`, then
remove the bundled npm. The base image's npm@10.9.7 ships old
transitives (picomatch 4.0.3 → CVE-2026-33671/33672, brace-expansion
2.0.2 → CVE-2026-33750) that we don't otherwise need at runtime;
corepack handles pnpm directly without npm. Fixes 1H + 1M.
- Bump PnpmVersion 10.28.2 → 10.33.2 to align with the rest of the
workflow and pull in pnpm's patched bundled brace-expansion (5.0.5
vs 5.0.4). Fixes 1M.
- Add `uuid@<14.0.0` → `>=14.0.0` to pnpm.overrides
(GHSA-w5hq-g745-h8pq). Fixes 1M.
- Drop `curl` from the runtime apk add list and switch HEALTHCHECK to
wget (busybox built-in). curl was only invoked by the healthcheck and
by dev/CI scripts that don't run in the container. Removes the curl
CLI binary; libcurl remains as a git transitive dep, so the
`apk/alpine/curl` advisories scout reports against libcurl persist
but aren't reachable from any code we ship. As a side-effect this
also clears nghttp2 (CVE-2026-27135) which was a curl-CLI dep.
- Switch HEALTHCHECK URL from `localhost` to `127.0.0.1` — alpine/musl
resolves localhost to ::1 first and Etherpad only binds IPv4.
Verified locally: docker build → docker run → healthy → docker scout
cves shows 12 CVEs / 3 packages.
* fix(docker): refresh corepack before preparing pnpm (Qodo)
Node 22's bundled corepack ships a stale signing-key list and can reject
newer pnpm releases (nodejs/corepack#612), which would fail the image
build at `corepack prepare`. Mirror the snap/snapcraft.yaml workaround:
`npm install -g corepack@latest` before activating pnpm, in both
adminbuild and build stages. npm is still removed afterwards.
* docs(changelog): note docker image dropping curl/npm/npx (Qodo)
Address Qodo's "backwards-incompatible change without mitigation" rule
violations by documenting the removal in the 2.7.3 breaking-changes
section. Operators who exec into the container can apk add curl on
demand or use the busybox wget / pnpm already present.
* chore: pnpm
* chore: pnpm
* chore: pnpm
* chore: pnpm
* chore: pnpm
* chore: pnpm
* chore: pnpm
* chore: pnpm
* chore: pnpm
---------
Co-authored-by: SamTV12345 <40429738+samtv12345@users.noreply.github.com>
* fix(socketio): don't kick authenticated duplicate-author sessions (#7656)
The CLIENT_READY handler kicks any prior socket whose authorID matches the
joining socket's, originally as a workaround for stale tabs in the same
browser (cookie-derived authorIDs were per-browser, so "same authorID, same
pad" reliably meant "page refresh / second tab in this browser").
With stable identities (basic auth, SSO, apikey, getAuthorId hook) the same
authorID can legitimately appear across windows or devices, so the kick
disconnects real concurrent sessions. Skip the kick when the joining socket
has req.session.user set; cookie-only sessions keep the existing behavior so
the userdup modal and the xxauto_reconnect path still work.
* fix(socketio): suppress USER_LEAVE when other same-author sockets remain
With the duplicate-author kick disabled for authenticated sessions, a single
authorID can legitimately span multiple sockets in one pad. handleDisconnect
was emitting USER_LEAVE on every socket close, which made clients (whose
presence is keyed by authorID) drop the author entirely even when another
socket of theirs was still online.
Only broadcast USER_LEAVE — and only run the userLeave hook — when the
disconnecting socket is the last one in the pad for that author.
Adds two backend tests:
- authenticated identity: closing one of two same-author sockets does NOT
emit USER_LEAVE on the other.
- different authors (regression): closing socket A still emits USER_LEAVE
for socket B.
Action of Qodo review feedback on PR #7678.
* test(ci): stronger diagnostics for silent backend-test exit
PR #7663 added unhandledRejection / uncaughtException handlers in
common.ts. The next failure after merge (run 25279692065 - Windows
without plugins, Node 24) showed mocha exiting with code 1 mid-suite
261ms after the last passing test, with NEITHER handler firing. So
something more drastic is killing the process - SIGKILL, OOM, fatal
native error - or mocha itself called process.exit before the JS
handlers in common.ts could run.
Two issues with the previous attempt:
1. Handlers in common.ts only register when a spec imports common.ts.
Only 27 of 47 specs do. If a non-common spec triggers the death,
handlers may never have been registered.
2. process.stderr.write is asynchronous on Windows when stderr is
piped (which it is under GitHub Actions). On a hard kill the buffered
line never reaches the runner log.
This patch:
- Moves diagnostic handlers to a dedicated tests/backend/diagnostics.ts
loaded via mocha --require, so they register at startup before any
spec runs.
- Uses fs.writeSync(2, ...) for synchronous stderr writes that the
kernel completes before returning - the line lands in the log even
if the process is killed milliseconds later.
- Adds beforeExit / exit / signal handlers so we can discriminate the
exit mechanism: clean drain vs process.exit vs SIGKILL vs signal.
- Tracks last-seen test via mocha root afterEach hook so the death
point is visible in the log.
The next CI failure should print enough context to identify the cause,
after which we can fix the real bug and drop this file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(diagnostics): exit(1) on uncaughtException so fatal errors fail fast
Qodo flagged on PR #7665: the uncaughtException handler in
tests/backend/diagnostics.ts only logged and returned. Once a handler is
registered, Node no longer exits on its own. Specs that don't import
tests/backend/common.ts (20 of 47) have only this handler — so a fatal
error would have been swallowed and tests would limp along instead of
failing fast.
Mirror common.ts and call process.exit(1) after logging.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every test in this describe block depends on each user having a
settable, displayable username — clicking a userlist row reads the
user's display name, prefills `@<name>` in the chat input, etc.
ep_disable_change_author_name disables exactly that machinery and
its CI fails the three userlist_click_to_chat cases (#86) for
exactly this reason.
Add the second tag so plugins declaring
`disables: ["@feature:username"]` opt out via the disables contract.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: PR5 GDPR author erasure design spec
* docs: PR5 GDPR author erasure implementation plan
* feat(gdpr): AuthorManager.anonymizeAuthor — Art. 17 erasure
* test(gdpr): AuthorManager.anonymizeAuthor unit tests
* feat(gdpr): REST anonymizeAuthor on API version 1.3.1
* test(gdpr): REST anonymizeAuthor end-to-end
* docs(gdpr): right-to-erasure section + anonymizeAuthor example
* fix(gdpr): make anonymizeAuthor resumable on partial failure
Qodo review: the `erased: true` sentinel was written before the chat
scrub loop, so a throw during scrub left chat messages untouched
while subsequent calls short-circuited on `existing.erased` and never
finished. Split the write: zero the display identity first (still
hides the name), run the chat scrub, and only then stamp
`erased: true` so a retry resumes the sweep. Regression test
covers the partial-run → retry path.
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: PR4 GDPR privacy banner design spec
* docs: PR4 GDPR privacy banner implementation plan
* feat(gdpr): typed privacyBanner setting block + public getter exposure
* feat(gdpr): send privacyBanner config to the browser via clientVars
* feat(gdpr): privacy banner DOM (hidden by default)
* feat(gdpr): render privacy banner on pad load when enabled
* style(gdpr): privacy banner layout
* test+fix(gdpr): privacy banner Playwright + hidden-attr CSS override
* docs(gdpr): privacyBanner configuration section
* fix(gdpr): reject unsafe learnMoreUrl schemes
Qodo review: showPrivacyBannerIfEnabled assigned config.learnMoreUrl
directly to <a href>, so a misconfigured settings.privacyBanner.
learnMoreUrl of `javascript:alert(1)` or `data:…<script>…` would run
script on click. Validate via URL parsing and allow only http(s) /
mailto; everything else yields no link. Playwright regression guards
the four cases (javascript, data, https, mailto).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(privacy-banner): drop unneeded !important on [hidden] rule
Class+attribute selector already outranks `.privacy-banner { display: flex }`
on specificity (0,2,0 vs 0,1,0), so `!important` was redundant. Adds a
comment explaining why so a future reader doesn't put it back.
Per Sam's review on #7549.
* refactor(privacy-banner): render as a persistent gritter, not custom DOM
Drops the bespoke #privacy-banner template + ~50 lines of popup.css and
delegates to $.gritter.add({sticky: true, position: 'bottom'}). The
notice now matches every other gritter on the pad (theme variables,
shadow, animation, (X) close), sits in the bottom corner instead of
above the editor, and inherits dark-mode handling for free.
The two dismissal modes survive intact:
- dismissible: gritter closes on (X); before_close persists a flag
in localStorage so the notice is suppressed on subsequent loads.
- sticky: closes for the current session only; never persists; the
next pad load shows it again.
learnMoreUrl still goes through the same safeUrl() filter so a
javascript:/data:/vbscript: URL can't smuggle a script handler into the
anchor (Qodo's review concern remains addressed).
Tests: src/tests/frontend-new/specs/privacy_banner.spec.ts now drives
the real showPrivacyBannerIfEnabled via a __etherpad_privacyBanner__
test hook and asserts against the rendered gritter, instead of the
previous tests that mutated DOM by hand and never exercised the
function under test. Coverage adds: enabled=false short-circuit,
dismissible-flag-respected on subsequent show, sticky-ignores-flag,
sticky-close-does-not-persist, javascript: rejection, data: rejection,
and mailto: allow-list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(privacy-banner): noreferrer + validate dismissal (Qodo)
Two follow-ups from Qodo's review on #7549:
1. The Learn-more link now sets `rel="noreferrer noopener"` (was just
`noopener`). Without `noreferrer` the browser sends the pad URL as a
Referer to the operator-configured external policy site, which leaks
pad identifiers to a third party. Matches the rel pattern already
used by pad_utils.ts.
2. `privacyBanner.dismissal` is now validated in reloadSettings(): an
unknown value falls back to 'dismissible' with a `logger.warn`, in
the same shape as the existing ipLogging validation a few lines up.
The client also guards defensively (treats anything other than the
exact string 'sticky' as 'dismissible') so that hot-reload paths
that skip the server validator can't silently degrade a typo'd
'sticky' into "no close button persisted, no localStorage suppression".
Test added: spec asserts the rel attribute, and a new test exercises
the dismissal fallback (sets dismissal:'wat', asserts the gritter is
shown, the (X) closes it, and the dismissal flag is persisted — i.e.
the unknown value is treated like 'dismissible').
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(privacy-banner): gate test hook on webdriver, align doc with sticky behavior
Two follow-ups from Qodo's second review on #7549.
Rule violation: __etherpad_privacyBanner__ was published on every pad
load even when privacyBanner.enabled was false, so the disabled-by-
default feature still added an observable global. Gate the assignment
on `navigator.webdriver` — Playwright/ChromeDriver/Selenium set this
to true; production browsers do not — so the hook is only present for
tests and the disabled path is genuinely zero-side-effect.
Bug 3 (sticky still closable): doc/privacy.md previously claimed
`dismissal: "sticky"` removes the close button, but the gritter
implementation always renders (X). Aligning the doc with reality —
sticky now means "shows on every load, but closable for the session"
— rather than adding bespoke CSS to a vanilla gritter (matches the
"don't style it differently than other gritter messages" preference
that drove the gritter migration in 906e145).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(privacy-banner): allow-list keys before sending to clientVars (Qodo)
storeSettings() merges nested objects with _.defaults() and preserves
unknown nested keys, and TypeScript's Pick<> doesn't strip at runtime.
The previous wire path forwarded settings.privacyBanner by reference
into both clientVars and getPublicSettings(), so any extra keys an
operator typed (or pasted) under privacyBanner — credentials, internal
notes, anything — would have shipped to every browser on every pad
load.
Adds getPublicPrivacyBanner() in Settings.ts that returns a literal
with only {enabled, title, body, learnMoreUrl, dismissal}, and uses it
from both leak sites (PadMessageHandler.ts clientVars and
getPublicSettings()). Single source of truth for the wire shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend tests on develop have a ~22% silent failure rate (mostly Windows,
sometimes Linux) where mocha exits with code 1 mid-suite, producing no
test failure marker, no error, and no Mocha summary. Different exit
points each run.
Root cause discovery is blocked by src/tests/backend/common.ts:33, which
rethrows unhandled Promise rejections as uncaught exceptions but never
logs the reason first. When the rethrow happens between specs, mocha
exits with code 1 and the original rejection is lost - especially on
Windows, where stderr is not always flushed before abrupt exit.
This patch is purely diagnostic: it writes the reason (or stack) to
stderr before rethrowing, and adds a matching uncaughtException handler
for the same purpose. Behavior on success is unchanged. The next CI
failure will surface what is actually rejecting (DirtyDB write?
plugin lifecycle? socket cleanup?), so we can fix the real cause.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every assertion in this describe block measures the author span's
background-color against its computed text colour. Plugins that
disable author background colouring entirely — e.g. ep_author_neat2,
which renders authorship as coloured underlines instead — can't
satisfy the WCAG bg/text contrast invariant because there's no
background to measure (transparent vs. transparent yields no ratio).
Tag the describe block so plugins declaring
`disables: ["@feature:authorship-bg-color"]` opt out of pass 1
through the disables contract, the same way change_user_color.spec.ts
already does.
Unblocks ep_author_neat2 CI (its current main run fails on three
wcag_author_color tests).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: tag rtl_url_param toggle-off specs with @feature:rtl-toggle
The two cases that require RTL to be flippable away from the
plugin-forced default — `?rtl=false` overriding a prior `?rtl=true`
and a no-param reload falling back to the cookie — are exactly what
ep_right_to_left intentionally disables. Tag them so the plugin can
declare `disables: ["@feature:rtl-toggle"]` and pass the disables
contract's honesty check.
Also list the new tag (and the previously omitted @feature:line-numbers)
in doc/PLUGIN_FEATURE_DISABLES.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: also tag chat title-bar layout spec with @feature:rtl-toggle
The leftGap/rightGap symmetry assertion in this test is LTR-only:
colibris ships a one-sided #titlebar padding rule (the existing
asymmetric pad is fine in LTR because the buttons are on the right
where the larger pad sits) that throws gaps apart by ~170px when
body[dir=rtl] reverses the flex item order.
Without a fix to colibris's chat header padding (out of scope here),
plugins that force RTL on can't pass this assertion. Add the second
tag so they can declare the disable instead of false-failing it.
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: PR3 GDPR anonymous identity hardening design spec
* docs: PR3 GDPR anon identity implementation plan
* feat(gdpr): ensureAuthorTokenCookie helper — HttpOnly server-set author token
* feat(gdpr): set HttpOnly author-token cookie from the pad routes
* feat(gdpr): read author token from cookie first, keep message.token fallback
* feat(gdpr): stop generating the author token client-side
* test(gdpr): server sets + reuses the HttpOnly author-token cookie
* fix+test(gdpr): parse token cookie from handshake Cookie header
socket.io handshake doesn't run cookie-parser, so socket.request.cookies
is undefined. Parse the Cookie header directly in handleClientReady so
the HttpOnly token actually resolves. Playwright spec covers HttpOnly
attribute, reload-stability, and context-isolation.
* docs(gdpr): token cookie is now HttpOnly + server-set
* fix(gdpr): close two HttpOnly token bypasses
Qodo review:
- Timeslider still ran the pre-PR3 JS-cookie path: it read
Cookies.get('${cp}token') (which HttpOnly hides), then generated a
fresh plaintext token and overwrote the server's HttpOnly cookie with
it, and sent token in every socket message. Strip the token read/
write entirely from timeslider.ts and from the outgoing message
shape; the server reads the cookie off the socket.io handshake just
like on /p/:pad.
- tokenTransfer re-issued the author cookie without HttpOnly, undoing
the hardening the first time a user transferred a session. Re-set
it as HttpOnly + Secure (on HTTPS) + SameSite=Lax. Also stop
trusting the body-supplied token on POST: read it off req.cookies
server-side so the client never needs JS access to the token.
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(colors): clamp author backgrounds to WCAG 2.1 AA on render
Fixes#7377.
Authors can pick any color via the color picker, so a user who chooses
a dark red ends up with black text rendered on a background that fails
WCAG 2.1 AA (4.5:1) — unreadable, but there is no way for *viewers* to
remediate since they cannot change another author's color. Screenshot
in the issue shows exactly this.
This PR lands a viewer-side clamp. For each author background, if
neither black nor white text would satisfy the target contrast ratio,
the bg is iteratively blended toward white until black text does. The
author's stored color is untouched — turning off the new
padOptions.enforceReadableAuthorColors flag restores the raw colors
immediately.
New helpers in src/static/js/colorutils.ts:
- relativeLuminance(triple) — WCAG 2.1 relative-luminance formula
- contrastRatio(c1, c2) — in [1, 21]; >=4.5 = AA, >=7.0 = AAA
- ensureReadableBackground(hex, minContrast = 4.5)
— returns a hex that meets minContrast
against black text, preserving hue
Wire-up:
- src/static/js/ace2_inner.ts (setAuthorStyle): pass bgcolor through
ensureReadableBackground before picking text color. Gated on
padOptions.enforceReadableAuthorColors (default true). Guarded by
colorutils.isCssHex so the few non-hex values (CSS vars, etc.) skip
the clamp and pass through unchanged.
- Settings.ts / settings.json.template / settings.json.docker: new
padOptions.enforceReadableAuthorColors flag, default true, with a
matching PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS env var in the
docker template.
- doc/docker.md: env-var row.
- src/tests/backend/specs/colorutils.ts: new unit coverage for the
three new helpers, including the exact #cc0000 failure case from
the issue screenshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(7377): simplify — just pick higher-contrast text, drop bg clamp
First iteration added an iterative bg-lightening helper
(ensureReadableBackground) gated by a new padOptions flag. CI caught the
correct simpler framing: because WCAG contrast is symmetric in [1, 21],
at least one of black/white always clears AA (4.5:1) for any sRGB
colour. The real bug was that the pre-fix textColorFromBackgroundColor
used a plain-luminosity cutoff (< 0.5 → white), which produced
sub-AA combinations like white-on-red (#ff0000) at 4.0:1.
Reduce the PR to the minimal surface:
- colorutils.textColorFromBackgroundColor now picks whichever of
black/white has the higher WCAG contrast ratio against the bg.
- colorutils.relativeLuminance and colorutils.contrastRatio are kept
as reusable building blocks; ensureReadableBackground is dropped
(no caller needed it once text selection was fixed).
- ace2_inner.ts setAuthorStyle no longer needs the opt-in flag or the
isCssHex guard — the helper handles every input its caller already
passes.
- padOptions.enforceReadableAuthorColors setting reverted along with
settings.json.template, settings.json.docker, and doc/docker.md.
- Tests replaced: instead of asserting the bg gets lightened, assert
that the chosen text colour clears AA for every primary. Covers the
exact #ff0000 failure case from the issue screenshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(7377): assert relative-contrast invariant, not absolute AA
Pure primaries like #ff0000 cannot clear WCAG AA (4.5:1) against either
#222 or #fff — the best either can do is ~4.0:1. No text-colour choice
alone fixes that; bg clamping would be a separate concern. The test
should therefore verify the *real* invariant: the chosen text colour
must produce the higher contrast of the two options, regardless of
whether that contrast clears any absolute threshold.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7377): compare against rendered #222/#fff, not pure black/white
First cut of textColorFromBackgroundColor computed contrast against
pure black (L=0) and pure white (L=1), then returned the concrete
#222/#fff the pad actually renders with. For some mid-saturation
backgrounds the two comparisons disagreed — e.g. #ff0000:
vs pure black = 5.25 → pick black → render #222 → actual 3.98
vs pure white = 4.00 → would-render #fff → actual 4.00
The helper picked the wrong option because it compared against the
wrong target. Compare against the actual rendered colours so the
returned text colour is genuinely the higher-contrast choice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(7377): pick unambiguous colibris test bgs
#ff0000 lives right at the boundary for the two text choices (4.00 vs
3.98), so the test for colibris-skin mapping was entangled with the
border-case selector pick. Use #ffeedd (clearly light → dark text
wins) and #111111 (clearly dark → light text wins) so the test
isolates the skin mapping from the tie-breaking logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7377): use rendered text colour + clamp bg to actually meet AA
Local repro of the issue exposed two real bugs in the previous fix:
1. textColorFromBackgroundColor compared bg against a hardcoded #222 —
but in the colibris skin --super-dark-color resolves to #485365.
For the issue's exact case (#9AB3FA author bg) the selector returned
var(--super-dark-color) thinking it was getting a 7.7:1 ratio, while
the browser actually rendered 3.78:1 — identical to what the issue
screenshot reported. This PR's previous behaviour on the issue's
inputs was unchanged from the pre-fix.
2. For mid-saturation pastels (#9AB3FA) and pure primaries (#ff0000)
neither rendered dark nor white text can clear AA. Text-colour
selection alone genuinely cannot fix this band; the ensureReadable
bg clamp dropped in ce0c5c283 was load-bearing.
Changes:
- colorutils.ts: per-skin SKIN_TEXT_COLORS table with darkRef/lightRef
matching what the browser actually paints (colibris #485365,
default #222). Re-introduces ensureReadableBackground, but skin-aware
and symmetric — blends bg toward white or black depending on which
text colour wins, so it works for both light and dark backgrounds.
- ace2_inner.ts: setAuthorStyle runs the bg through the clamp before
picking text colour. Gated on padOptions.enforceReadableAuthorColors
(default true).
- Settings.ts / settings.json.template / settings.json.docker /
doc/docker.md: padOption + PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS
env var.
- tests: failing-then-green coverage for the issue's exact case
(#9AB3FA + colibris), the previously-impossible #ff0000, the
no-mutation case, non-hex pass-through, and a sweep over primaries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(7377): add e2e DOM-contrast spec + extra unit cases
The previous coverage was unit-only, which is what let the original wrong-
reference-colour bug ship — the algorithm tests were green but nothing
exercised what the browser actually paints. New coverage:
Playwright (src/tests/frontend-new/specs/wcag_author_color.spec.ts):
- Sets the user's colour to the issue's exact #9AB3FA, types text, reads
the rendered author span's computed bg + colour from the inner frame,
and asserts the WCAG ratio between the two is >= 4.5. Repeated for
#ff0000 (the other historically-failing case).
- Asserts #ffeedd (already AA-friendly) is rendered unchanged — guards
against the clamp mutating colours that don't need it.
Backend additions (src/tests/backend/specs/colorutils.ts):
- Symmetric-clamp test: dark mid-saturation bg where light text wins, the
clamp must darken (not lighten). Direction check via relativeLuminance.
- minContrast parameter: AAA (7.0) must produce more clamping than AA.
- Output shape: result must be a parseable hex string (round-trip safe).
- Short-hex (#abc) input is accepted and normalised.
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(userlist): click a user to open chat with @<name> prefilled
Newcomers to a multi-user pad regularly fail to discover the chat
panel and the @-mention convention. Make the user list itself the
discovery affordance: clicking another user's row opens chat (if
hidden) and prefills the input with "@<their_name> ", ready to send.
The skin gets a small visual cue — pointer cursor on .usertdname and
an underline on hover — so the affordance is visible without
requiring a redesign. The color swatch keeps its own click semantics
(color picker), so the swatch cell is excluded from the new handler.
To let bot/AI plugins substitute their trigger string for an
otherwise-useless @-mention of the bot's display name (e.g.
"@AI Assistant" → "@ai"), this adds a new client-side hook,
chatPrefillFromUser, that takes {authorId, name, prefill} and lets
the first plugin to return a non-empty string override the default
prefill. Documented in doc/api/hooks_client-side.md alongside
chatSendMessage.
Plugin errors in the hook are caught — a misbehaving plugin can't
break the click. If chat is hidden by pad settings, chat.show() is
a no-op and the click effectively does nothing, which matches the
existing behavior of "no chat means no chat-related affordances".
The new prefill never clobbers a real partial message in the input;
if the user was mid-typing something, the @-mention is appended
rather than replacing.
* fix(userlist): don't steal rename focus + add Playwright coverage
Two follow-ups on review of the click-to-chat handler:
1. Bug (Qodo, correctness): clicking the rename <input> on an unnamed
user's row triggered the new row handler, which then focused
#chatinput and made it impossible to name unnamed users from the
user list. Add an early-return that skips form controls inside
the row (input/textarea/select/button/a/[contenteditable=true]).
The swatch was already excluded; this widens the same idea to
anything that's interactive on its own merits.
2. Test coverage: add a frontend Playwright spec
(userlist_click_to_chat.spec.ts) covering the supported flows
and the new regression:
- clicking another named user opens chat and prefills "@<name> "
- clicking the swatch opens color picker, not chat
- clicking the rename <input> on an unnamed user keeps focus
on the input (regression test for the bug above)
- partial chat message is preserved when prefilling
* test: stabilise the partial-message preservation case
The 'partial message in chat input is preserved when prefilling'
case was flaking on CI. Three small changes:
- Seed the chat input with fill() rather than click() + keyboard.type().
Earlier the test was racing chat.focus()'s own setTimeout(100) — when
the keyboard.type started before that timer fired, the typing landed
in whatever element had focus at the time, which wasn't always the
chat input. fill() bypasses focus state entirely.
- Wait for the chat box to be visible before filling, so we don't race
the chaticon click handler.
- Replace the two sequential expect/wait pairs after the daveRow click
with one waitForFunction that asserts both 'hi there' and '@Dave' are
in the input together. The prefill is async (setTimeout(50) inside
the click handler), so a combined wait is more reliable than checking
one piece, then snapshotting and asserting the other.
The other three cases in this file passed unchanged on CI; only this
fourth one was racy.
* fix: don't commit local .claude worktrees / var state
These were accidentally added in ffe947706 by an over-broad git add -A.
Both paths are workspace-local and unrelated to this PR.
Adds a new pad option `fadeInactiveAuthorColors` (default `true`) that controls whether each author's caret/background fades toward white as they go inactive. Configurable server-side (`settings.json` / `PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS`), per-pad in the Pad Settings panel, per-user in the My View panel, or via `?fadeInactiveAuthorColors=false`.
Disabling the fade is useful on busy pads where every faded author visually counts as a second on-screen color (a 30-author pad becomes a 60-color pad), or when inactivity tracking is undesirable for whatever reason.
Closes#7138.
Surfaced by ep_hide_line_numbers' red main. Two pad_settings specs
use `expect(...).not.toHaveClass(/line-numbers-hidden/)` to verify
the line-number gutter is visible by default before settings flip
it on, but plugins that hide line numbers (ep_hide_line_numbers
sets `pad.changeViewOption('showLineNumbers', false)` in postAceInit)
keep that class on the body for the entire pad lifetime.
Tag the two affected specs with @feature:line-numbers so plugins
that hide line numbers can declare `disables: ["@feature:line-numbers"]`
in ep.json and have these excluded from pass-1 regression while
still failing pass-2 honesty (plugin must actually keep them hidden).
Companion to the existing tag set (@feature:chat, @feature:username,
@feature:clear-authorship, @feature:error-gritter,
@feature:authorship-bg-color). See doc/PLUGIN_FEATURE_DISABLES.md.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plugins like ep_author_neat2 swap Etherpad's coloured-background
authorship indicator for an underline. Their README is explicit
about this; their main is red on the disables-aware test runner
because change_user_color.spec.ts:59 hard-asserts the chat <p>'s
background-color matches the user's colour, which a non-background
rendering legitimately won't satisfy.
Add a second tag (@feature:authorship-bg-color) alongside the
existing @feature:chat so plugins that swap rendering can declare
"disables": ["@feature:authorship-bg-color"] and have this single
spec excluded from pass-1 regression while still running pass-2
honesty (the bg-color assertion fails under the plugin → contract
honoured).
Multi-tag: ep_disable_chat keeps excluding it via @feature:chat;
ep_author_neat2 excludes it via @feature:authorship-bg-color.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(packaging): publish Etherpad as a Snap
Adds first-class Snap packaging so Ubuntu / snapd users can install via
`sudo snap install etherpad-lite`.
- snap/snapcraft.yaml — core24, strict confinement, builds with pnpm
against a pinned Node.js 22 runtime. Version is auto-derived from
src/package.json so `snap info` tracks upstream release numbering.
- snap/local/bin/etherpad-service — launch wrapper that seeds
$SNAP_COMMON/etc/settings.json on first run (rewriting the default
dirty-DB path to a writable $SNAP_COMMON location) and execs Etherpad
via `node --import tsx/esm`.
- snap/local/bin/etherpad-healthcheck-wrapper — HTTP probe for external
supervisors, falling back to Node if curl isn't staged.
- snap/local/bin/etherpad-cli — thin passthrough to Etherpad's bin/
scripts (importSqlFile, checkPad, etc.).
- snap/hooks/configure — exposes `snap set etherpad-lite port=<n>` and
`ip=<addr>` with validation, restarts the service when running.
- snap/README.md — build / install / configure / publish instructions.
- .github/workflows/snap-publish.yml — builds on every v* tag, uploads
a short-lived artifact, publishes to `edge`, and then promotes to
`stable` through a manually-approved GitHub Environment. Requires a
one-time `snapcraft register etherpad-lite` plus provisioning of the
`SNAPCRAFT_STORE_CREDENTIALS` repo secret (instructions inline).
Pad data (dirty DB, logs) lives in /var/snap/etherpad-lite/common/ and
survives snap refreshes. The read-only $SNAP squashfs is never written
to at runtime.
Refs #7529
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(snap): pass --settings flag, env-subst ip/port, 2-space indent
Addresses Qodo review feedback on #7558:
1. Settings file ignored: Etherpad's Settings loader reads `argv.settings`,
not the `EP_SETTINGS` env var. Without `--settings`, the launcher's
seeded $SNAP_COMMON/etc/settings.json is never loaded; Etherpad falls
back to <install-root>/settings.json, which lives on the read-only
squashfs — so the default dirty-DB path ends up unwritable and the
daemon fails to persist pads. Fix: pass `--settings "${SETTINGS}"` to
node; drop the EP_SETTINGS export.
2. `snap set` overrides were no-ops: the seeded settings.json carries the
template's literal `"ip": "0.0.0.0"` / `"port": 9001` values, which
override the env-based defaults Etherpad exposes via ${…}
substitution. Users following the README saw the listener stay put
after `snap set etherpad-lite port=…`. Fix: after copying the
template on first run, rewrite the top-level `ip` and `port` lines
to `"${IP:0.0.0.0}"` / `"${PORT:9001}"`. Use `0,/…/` anchors so the
`dbSettings.port` entry further down stays literal.
3. Indentation: reflow the new shell scripts from 4-space to 2-space to
match the repo style rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(snap): default seeded settings to sqlite, not dirty
settings.json.template's own comment says dirty is for testing only.
A Snap install is the "not testing" case — shipping it by default
means every `sudo snap install etherpad-lite` starts on a DB the
project explicitly recommends against.
Rewrite the postinstall sed to switch dbType: "dirty" → "sqlite" and
point filename at $SNAP_COMMON/var/etherpad.db. sqlite is already
shipped in-tree via ueberdb2 → rusty-store-kv (prebuilt napi-rs
binary, no build deps), so this works under strict confinement with
zero snap.yaml changes.
Only affects first-run seeding; existing $SNAP_COMMON/etc/settings.json
is never touched on refresh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(snap): rename to "etherpad", glob tag filter, harden cli
- Snap is registered as `etherpad` (the project's only name) — drops the
legacy `etherpad-lite` from the name, app, paths, install dir, configure
hook, README and workflow artifact. The daemon app shares the snap name,
so `snap install etherpad` exposes a bare `etherpad` command; the bin/
passthrough is now `etherpad.cli`.
- snap-publish.yml: GitHub Actions tag filters use globs, not regex. The
prior `v?[0-9]+.[0-9]+.[0-9]+` pattern would never match a real release
tag (Qodo review). Replace with two glob entries covering `vX.Y.Z` and
`X.Y.Z`.
- etherpad-cli: reject path-traversal in the `<bin-script>` arg (anything
containing `/`, `..`, or empty) and add a default `*)` case so files
with unsupported extensions fail loud instead of silently exiting 0
(Qodo review).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(snap): unbreak build — refresh corepack, drop pnpm prune
Two issues hit on the first real `snapcraft pack` of this recipe:
- `corepack prepare pnpm@10.33.0 --activate` failed with
`Cannot find matching keyid` because Node 22.12's bundled corepack
ships a stale signing-key list and rejects newer pnpm releases
(nodejs/corepack#612). Refresh corepack itself via npm before
preparing pnpm.
- `pnpm prune --prod` is interactive on workspace projects: it asks
"The modules directories will be removed and reinstalled from
scratch. Proceed? (Y/n)" and deadlocks on stdin under sudo + tee.
Replace it with the explicit "wipe node_modules + prod reinstall"
pattern, which is non-interactive, faster (pnpm resolves the prod
graph from its CAS cache), and byte-identical in result.
Verified locally: `snapcraft pack --destructive-mode` produces
`etherpad_2.6.1_amd64.snap` end-to-end in ~3 min.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(snap): unbreak runtime — tsx resolution, var/ writability, env
Three runtime crashes surfaced when actually installing the built snap
under strict confinement. Fixed each, plus a smoke-test script.
- `tsx` is in the `src` workspace's node_modules under pnpm hoisting,
not at the snap install root. The wrapper now `cd "${APP_DIR}/src"`
and uses bare `--import tsx` (matching `bin/cleanRun.sh`); the prior
`--import tsx/esm` triggered ERR_REQUIRE_CYCLE on Etherpad's mixed
CJS/ESM source tree.
- Etherpad's plugin installer writes `var/installed_plugins.json` via
__dirname-relative paths, which resolve to absolute paths inside the
read-only snap squashfs (EROFS). snap layouts can't intercept paths
inside `$SNAP`, so replace the shipped `var/` dir with a symlink to
`/var/snap/etherpad/common/etherpad-app-var/` (auto-created by the
wrapper on first run). Persistent state survives `snap refresh`.
- Drop the unused `EP_SETTINGS` and `EP_DATA_DIR` env vars from the
app's `environment:` block. Etherpad's settings loader doesn't read
them — it reads `argv.settings`, which the wrapper already passes via
`--settings`. They were producing `[WARN] settings - Unknown Setting`
noise on every start.
Add `snap/tests/smoke.sh`: rebuild + install + configure test port 9003
+ assert listener + curl /health + tail logs. Local verified output:
HTTP 200, body {"status":"pass","releaseId":"2.6.1"}, server logs
`Etherpad is running` on `http://0.0.0.0:9003/`.
.gitignore now excludes destructive-mode build outputs (parts/, stage/,
prime/, .craft/, *.snap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(snap): wrapper unit tests, PR CI build, expanded docs
Coverage in snap/tests/ (47 assertions, ~5s, no snapd/sudo/network):
- test-snapcraft-yaml.sh: required keys, name validity, daemon-app
matches snap name, no etherpad-lite regression, env-var whitelist.
- test-cli.sh: path-traversal rejection, .ts/.sh dispatch, default-case
rejection, no-args usage.
- test-configure.sh: port (1-65535) and ip (v4/v6) validation via
mocked snapctl.
- test-service-bootstrap.sh: first-run seeding from
settings.json.template, sed rewrite of dbType/filename/ip/port,
writable-dir creation, snapctl override propagation to node env,
idempotency on second run, default fallbacks.
- run-all.sh: bash -n syntax check on every wrapper + hook, then
sources each test file and reports totals. All assertions use port
9003 (project test convention).
CI in .github/workflows/snap-build.yml:
- Triggers on PR / push-to-develop touching snap/, settings.json.template,
or the workflow itself.
- Job 1 wrapper-tests: runs run-all.sh.
- Job 2 snap-pack: snapcraft pack --destructive-mode, uploads .snap as
PR artifact for sideload.
- Stays separate from snap-publish.yml (tag-triggered, store-bound).
snap/README.md fully rewritten:
- User-facing usage, install, configure
- Architecture: file layout, var/-symlink rationale, settings.json
rewrite rationale, double-pnpm-install rationale, daemon-name-shares-
snap-name rationale
- Three test layers with exactly when/why to run each
- Dev workflow loop
- Publishing maintainer setup
- Troubleshooting for every failure mode hit during this PR (EROFS,
tsx not found, ERR_REQUIRE_CYCLE, snap-store-down, pnpm prune hang)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(snap): replace dead snapcraft.io/docs/releasing-to-the-snap-store link
That URL now 404s. Point at the canonical documentation.ubuntu.com
locations instead, broken out into the specific pages a maintainer
actually needs:
- Register a snap (to claim the name)
- snapcraft export-login (to generate the SNAPCRAFT_STORE_CREDENTIALS
secret)
- Publishing how-to index (root index for everything else)
Same fix in the snap-publish.yml header comment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaced by ep_disable_chat#75 — the disables-aware test runner ran
pass 1 (regression) and these two tests failed because they click
label[for="options-disablechat"], which ep_disable_chat hides as
intended. Tagging them brings them into pass 2 (honesty) where
they're expected to fail under the plugin, instead of pass 1 where
they shouldn't run.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the hardcoded "Disables:" label and the inline title attribute
with proper i18n keys (admin_plugins.disables.label,
admin_plugins.disables.warning_title) and add role="alert" so screen
readers announce the warning instead of treating it as visual noise.
Per user review on #7649: "we should display it as a warning only if
a plugin disables a test... Also i18n!!! Always remember to do i18n."
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ep_disable_chat#75 ran with `disables: ["@feature:chat"]` declared in
ep.json but the helper printed "No 'disables' declared — running
standard test suite" and exec'd a vanilla `playwright test`, with
@feature:chat-tagged tests running anyway and timing out one by one.
Root cause: the auto-detect used `find -maxdepth 3 plugin_packages/
-name ep.json -not -path '*/.versions/*'`. Live-plugin-manager
installs plugins under `plugin_packages/.versions/<name>@<ver>/`
and exposes them as symlinks at `plugin_packages/ep_<name>`. find(1)
doesn't follow symlinks by default, so:
- the .versions/ ep.json was excluded by -not -path
- the symlink at plugin_packages/ep_<name> was visited but find
didn't recurse into it because it's a symlink, not a real dir
=> 0 candidates found, helper falls through to standard mode.
Switch to a shell glob with `-f` membership tests, which resolve
symlinks correctly. Verified against a synthetic install: the helper
now finds the disables list and prints "Plugin disables: @feature:chat"
before running pass 1.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass 2 was running every test tagged with a disabled feature to
completion. For a busy tag like @feature:chat (12 tests) with the
default 90s per-test timeout, that's 10+ minutes of expected failures
piling up — ep_disable_chat's first PR #75 ran 14+ min before being
cancelled.
Pass 2 only needs *evidence* the feature is gone — one failing tagged
test is enough. Add:
--max-failures=1 stop on the first failure (~30s vs ~10min)
--timeout=30000 cap any single test at 30s
--retries=0 already there, but flagged: CI retry default
(up to 5 with WITH_PLUGINS=1) would retry an
"expected failure" multiple times.
Worst case (first tagged test happens to pass): we wait for the
second one to fail, ~60s. Still bounded.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass 2 was running every test tagged with a disabled feature to
completion. For a busy tag like @feature:chat (12 tests) with the
default 90s per-test timeout, that's 10+ minutes of expected failures
piling up — ep_disable_chat's first PR #75 ran 14+ min before being
cancelled.
Pass 2 only needs *evidence* the feature is gone — one failing tagged
test is enough. Add:
--max-failures=1 stop on the first failure (~30s vs ~10min)
--timeout=30000 cap any single test at 30s
--retries=0 already there, but flagged: CI retry default
(up to 5 with WITH_PLUGINS=1) would retry an
"expected failure" multiple times.
Worst case (first tagged test happens to pass): we wait for the
second one to fail, ~60s. Still bounded.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to ether/ether.github.com#395 — the admin UI's "available
plugins" listing now also renders the plugin's declared `disables`
(see doc/PLUGIN_FEATURE_DISABLES.md) so an operator about to click
Install sees the same warning as a user browsing etherpad.org/plugins:
"Disables: chat".
- src/node/types/PackageInfo.ts: optional `disables?: string[]` on
the registry payload type.
- admin/src/pages/Plugin.ts: same on the admin-side PluginDef.
- admin/src/pages/HomePage.tsx: render an amber callout under the
description when `disables` is present and non-empty. Plugins
without a disables field render unchanged.
The plugin-registry build pipeline still has to start surfacing
`disables` from ep.json into plugins.json/plugins.viewer.json — until
that lands, the new callout no-ops everywhere, which is fine.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plugins that intentionally remove a baseline Etherpad feature
(ep_disable_chat, ep_disable_change_author_name, ep_disable_error_messages,
ep_disable_reset_authorship_colours) currently break core tests for the
removed feature. Their main branches are red, their auto-publish gates
never fire, and Dependabot PRs pile up.
The temptation is to give these plugins an "opt-out of these tests"
flag — but that's a self-serving attestation: a plugin can claim "I
just disable chat, ignore those tests" and quietly break unrelated
functionality on the user's install. etherpad.org/plugins would still
show it green.
This commit introduces a small declared-disables contract that closes
that gap:
1. Core specs grow @feature:* Playwright tags. Initial set:
@feature:chat, @feature:username, @feature:clear-authorship,
@feature:error-gritter. Tags are added test-by-test where the
test exercises a single feature, so the contract stays precise.
2. Plugins declare which feature tags they disable in their ep.json:
{ "name": "ep_disable_chat", "disables": ["@feature:chat"], ... }
3. bin/run-frontend-tests-with-disables.sh enforces the contract via
two passes:
- Pass 1 (regression): every test NOT in the disabled list must
pass. Catches plugins that break things they don't claim to.
- Pass 2 (honesty): every test that IS in the disabled list
must FAIL. Catches plugins that lie about disabling features
they don't actually disable, and stops them from grep-inverting
arbitrary unrelated tests.
4. doc/PLUGIN_FEATURE_DISABLES.md walks the design and migration.
The disables list is in ep.json (publicly visible), so etherpad.org/plugins
can surface "this plugin disables: chat" alongside the green CI badge —
users see what they're losing before they install.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(plugins): updatePlugins.sh actually updates installed plugins (#6670)
bin/updatePlugins.sh detected outdated plugins by running
`pnpm --filter ep_etherpad-lite outdated --depth=0`, but installed
plugins are not registered in src/package.json — bin/plugins.ts adds
them via linkInstaller.installPlugin which writes to
src/plugin_packages/.versions/<name>@<version>/ and tracks the result
in var/installed_plugins.json. pnpm has no view of them, so `outdated`
returns empty and the script always reported "All plugins are
up-to-date" even when newer versions existed on the registry. PR #7468
fixed npm→pnpm and install→update but kept the same broken detection
mechanism, which is why the issue stayed open after that PR landed.
Read the plugin list from var/installed_plugins.json instead, then
re-invoke linkInstaller.installPlugin(name) for each entry. Calling
the installer without a version pin resolves the registry-latest and
overwrites the existing pinned copy, so an outdated plugin is brought
to head while plugins already at latest are no-ops apart from the
pnpm cache hit.
Add an `update`/`up` action to bin/plugins.ts so users can also run
`pnpm run plugins update` directly, mirroring the existing
install/remove/list actions. updatePlugins.sh becomes a one-line
wrapper for backwards compatibility.
Reproduction (verified):
pnpm run install-plugins ep_markdown@11.0.5 # latest is 11.0.18
./bin/updatePlugins.sh # → 11.0.18
Edge cases tested: no plugins installed, missing installed_plugins.json,
already-at-latest re-run.
Closes#6670.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(plugins): validate ep_ prefix and dedupe + add regression test
Qodo flagged two issues on the original update() addition:
1. Security — update() trusted every name in var/installed_plugins.json,
so a corrupted or hand-edited manifest could coerce the script into
installing arbitrary npm packages. pluginfw/plugins.getPackages
already gates on the ep_ prefix; mirror that gate here.
2. Reliability — no automated regression test, so a future refactor
could silently bring back the broken behaviour.
Extract the safe-name filter to filterUpdatablePluginNames in
bin/commonPlugins.ts (pure, side-effect-free, prefix configurable, also
de-duplicates repeats so a duplicated entry installs once). Use it from
plugins.ts update().
Add src/tests/backend/specs/filterUpdatablePluginNames.ts covering: keep
prefixed names, drop ep_etherpad-lite, reject non-prefixed entries,
de-dupe repeats, tolerate missing/null/non-string name fields, empty
input, custom prefix.
Manually verified end-to-end on a live install: an
installed_plugins.json containing ep_markdown@11.0.5, a duplicate
ep_markdown, and a "malicious-package" entry runs `Updating plugins to
latest from registry: ep_markdown` (only) and ep_markdown ends up at
11.0.18 — the bad entries are silently filtered out.
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: widen timing windows for flaky CI tests on slow runners
SessionStore.ts and socketio.ts dominate develop CI failures (~25–40% of
recent runs). Both fail due to race conditions on slow Windows / loaded
Linux runners — not logic bugs.
SessionStore tests configure session expiry windows of 100ms and then
sleep 110ms before asserting. On a slow runner, the wall-clock between
`set()` and the assertion can exceed 100ms, the timeout in
`SessionStore._updateExpirations()` then sees `exp.real <= now` and
calls `_destroy()`, deleting the DB record before the assertion runs.
The test then reads `null` / `undefined` instead of the expected JSON.
Tripling each affected window (100→300, 110→330, 200→600) keeps the
relative timing semantics identical while leaving enough headroom for
a slow CI runner. Local run is +3s on this spec; cheap insurance for
the global flake rate.
`waitForSocketEvent` in tests/backend/common.ts uses a 1s timeout for
socket.io message round-trips. The socket.io handshake + auth +
CLIENT_READY can exceed 1s on a slow runner; bumped to 5s.
The most-failing tests this addresses:
- SessionStore: get of record from previous run (not yet expired)
- SessionStore: external expiration update is picked up
- SessionStore: shutdown cancels timeouts
- socketio: !authn anonymous cookie /p/pad -> 200, ok
- socketio: authn user /p/pad -> 200, ok
- clientvar_rev_consistency: CLIENT_VARS stays consistent under
concurrent edits during handshake
All 28 SessionStore + 33 socketio tests pass locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: address Qodo PR feedback — configurable socket timeout, polling for cleanup
Both items raised in Qodo's review of #7647.
1) Hardcoded 5s socket wait
waitForSocketEvent() now takes an optional timeoutMs (default 1000ms,
matching pre-PR behaviour). Only the known-slow connect() and
handshake() paths pass 5000ms — they're the ones blowing the 1s budget
on loaded CI runners. Per-message waits (waitForAcceptCommit and
ad-hoc callers in messages.ts etc.) keep the 1s default so failures
surface fast with the descriptive helper error rather than the generic
Mocha timeout.
2) SessionStore waits still tight
Replaced fixed sleeps with a small `eventually()` poller for the three
"record should be gone after expiry" assertions:
- set of session that expires
- switch from non-expiring to expiring
- get of record from previous run (not yet expired)
These now poll every 25ms up to 2000ms so they pass immediately when
cleanup completes on a fast runner, and tolerate hundreds of ms of
event-loop delay on a slow one. No fixed coupling between sleep
duration and expiry duration.
For the inverse "record should still be there" case in
`shutdown cancels timeouts`, polling doesn't apply (we're verifying
that a cancelled timer did NOT fire, which requires a real wait past
the original expiry). Bumped expires 300→500ms and wait 330→700ms so
setup (set+get+shutdown) has 500ms before the timer would fire (vs.
30ms previously) and the 700ms wait still passes the original expiry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(docs): build on PRs and pin Node 22 (Qodo follow-up to #7640)
Qodo flagged two reliability gaps on the oxc-minify fix that landed in
#7640:
1. The Deploy Docs to GitHub Pages workflow only ran on push to
develop, so a PR that broke `pnpm run docs:build` was not caught
until after merge — exactly how the dead-link regression in #7546
escaped. Add a pull_request trigger that runs the same build but
skips the deploy/upload steps via `if: github.event_name ==
'push'`. Also include the workflow file itself in the path filter
so changes to it are exercised on PR.
2. oxc-minify@0.128.0 requires Node ^20.19.0 || >=22.12.0, but the
workflow did not pin Node and the repo declared engines.node
>=22.0.0 with engineStrict: true — a runner image (or local dev)
on Node 22.0–22.11 would refuse to install. Pin Node 22 in the
docs workflow with actions/setup-node@v6 (matching the rest of
CI), and bump engines.node to >=22.12.0 so the project's
engineStrict gate matches the actual minimum.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(docs): split build and deploy so PR runs do not hit pages env protection
The previous attempt put `if: github.event_name == 'push'` on individual
deploy steps but kept the single job's `environment: github-pages`
binding. Environment protection rules reject any non-develop ref
(including `refs/pull/N/merge`), so the runner failed the entire job
at creation time before any step could execute:
Branch "refs/pull/7645/merge" is not allowed to deploy to
github-pages due to environment protection rules.
Split into two jobs: `build` runs on every trigger (PR + push) and
uploads the artifact only on push, `deploy` depends on `build`,
runs only on push, and is the only job bound to the github-pages
environment. Standard GHA pages-deploy pattern; PR builds never
attempt to enter the protected environment.
* docs: align Node minimum references with bumped engines.node (Qodo round 2 on #7645)
Qodo flagged that engines.node moved from >=22.0.0 to >=22.12.0 in
this PR but documentation still claimed the old requirement. Sync the
three places that pinned a specific minimum:
- README.md installation requirements (>= 22 → >= 22.12)
- doc/npm-trusted-publishing.md publish prerequisites
(>=22.0.0 → >=22.12.0, with oxc-minify cited as the driver)
- CHANGELOG.md 2.7.3 breaking-changes entry (22 → 22.12, with the
same oxc-minify justification)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>