Skip to content

.pr_agent_accepted_suggestions

qodo-merge-bot edited this page Apr 20, 2026 · 36 revisions
                     PR 7569 (2026-04-20)                    
[security] Overprivileged PR token
Overprivileged PR token The workflow runs on pull_request but now grants GITHUB_TOKEN `packages: write`, so untrusted PR-controlled code executed in the `Test` step can publish/overwrite GHCR packages despite the explicit GHCR login/push steps being gated to `push`. This increases CI blast radius and enables supply-chain compromise via CI token abuse.

Issue description

The workflow grants packages: write at the workflow level while still running on pull_request, which exposes package publish capability to PR-executed code.

Issue Context

Top-level permissions: apply to the entire workflow run (including pull_request runs), even when individual publish steps are gated by if: github.event_name == 'push'.

Fix Focus Areas

  • .github/workflows/docker.yml[2-17]
  • .github/workflows/docker.yml[64-122]

Proposed fix

  1. Keep the PR test job with minimal permissions (for example only contents: read).
  2. Create a separate publish job that runs only on push (use if: github.event_name == 'push') and set permissions: packages: write at the job level for that job only.
  3. Optionally make publish depend on the test job (needs:) so pushes only publish after tests pass.

[maintainability] Docs miss GHCR option
Docs miss GHCR option After adding `ghcr.io/ether/etherpad` as a publish target, Docker documentation still states the official image is only on Docker Hub and provides only Docker Hub pull commands. This makes the new GHCR mirror effectively undiscoverable for users relying on project docs.

Issue description

Documentation only references Docker Hub, but the workflow now publishes the same tags to GHCR.

Issue Context

Users following docs will not discover the GHCR mirror and may continue to hit Docker Hub rate limits.

Fix Focus Areas

  • doc/docker.md[1-13]
  • README.md[104-113]

Proposed fix

Update Docker docs (and optionally README) to mention GHCR as an alternative mirror and add example pull commands such as:

  • docker pull ghcr.io/ether/etherpad:latest
  • docker pull ghcr.io/ether/etherpad:<version> while keeping Docker Hub as canonical if desired.


                     PR 7552 (2026-04-19)                    
[correctness] Installer clones wrong repo
Installer clones wrong repo README.md now instructs users to run installers from the `ether/etherpad` repo, but both installer scripts still default to cloning `https://github.com/ether/etherpad-lite.git`, so the one-liner can install a different repo than the documentation implies.

Issue description

README.md now points users to run installers from https://raw.githubusercontent.com/ether/etherpad/..., but the installer scripts still default to cloning https://github.com/ether/etherpad-lite.git. This can cause the one-liner install path to clone an unexpected repository.

Issue Context

  • README is the primary entry point for users.
  • bin/installer.sh and bin/installer.ps1 embed their own default clone URL.

Fix Focus Areas

  • bin/installer.sh[5-12]
  • bin/installer.sh[33-38]
  • bin/installer.ps1[1-11]
  • bin/installer.ps1[37-41]
  • README.md[71-88]

Suggested fix

  • Change the default repo URL in both installer scripts to https://github.com/ether/etherpad.git.
  • Update the usage examples/comments in both scripts to match the new raw GitHub URLs.
  • Ensure README and installer scripts agree on the repo being installed (directory name can remain etherpad-lite if intentional).

[observability] Broken Docker badge link
Broken Docker badge link README.md links the Docker workflow badge to `actions/workflows/dockerfile.yml`, but this repo’s Docker workflow file is `docker.yml`, so the badge/link will 404.

Issue description

The README Docker CI badge points to actions/workflows/dockerfile.yml, but the repository workflow file is docker.yml. This breaks the badge and link.

Issue Context

The repo contains .github/workflows/docker.yml.

Fix Focus Areas

  • README.md[35-39]
  • .github/workflows/docker.yml[1-5]

Suggested fix

Update the Docker badge/link in README to reference actions/workflows/docker.yml (both the badge.svg URL and the clickable link).


[maintainability] Conflicting GitHub URLs
Conflicting GitHub URLs Startup logging now directs users to `https://github.com/ether/etherpad/issues`, but the default pad text still advertises `https://github.com/ether/etherpad-lite`, giving users conflicting guidance on where the project lives.

Issue description

User-facing text references two different GitHub repositories (etherpad vs etherpad-lite). This is confusing for users looking for source/issues.

Issue Context

  • Startup console message points to https://github.com/ether/etherpad/issues.
  • Default pad contents still include https://github.com/ether/etherpad-lite.

Fix Focus Areas

  • src/node/hooks/express.ts[67-72]
  • src/node/utils/Settings.ts[390-397]

Suggested fix

Update defaultPadText to point at the same GitHub repo as the startup message (and consider doing the same for other prominent user-facing URLs if they exist).



                     PR 7545 (2026-04-18)                    
[reliability] Pad settings missing feature flag
Pad settings missing feature flag The PR introduces creator-owned pad settings (including enforcement and chat suppression) and changes new-pad defaults, but the new behavior is enabled unconditionally with no feature flag or default-off gating. This violates the requirement to gate new features behind a disabled-by-default feature flag to preserve prior default behavior.

Issue description

Creator-owned pad settings and new-pad default seeding are enabled unconditionally. Compliance requires new features be behind a feature flag that is disabled by default, preserving the old behavior when the flag is off.

Issue Context

padSettingsDefaults is always sent in CLIENT_READY, and on first pad creation the server persists these defaults, changing behavior for all deployments.

Fix Focus Areas

  • src/static/js/pad.ts[263-271]
  • src/node/handler/PadMessageHandler.ts[901-907]
  • src/templates/pad.html[170-220]

[maintainability] Cookie documentation not updated
Cookie documentation not updated The PR changes how the `language` cookie is set (moved to new My View logic) and introduces new preference behavior, but repository documentation is not updated accordingly. This makes the documented cookie behavior inaccurate for users/admins.

Issue description

Documentation about cookies is now inaccurate due to the changes in where/how the language cookie is set and the new My View preference behavior.

Issue Context

doc/cookies.md currently points readers to the old implementation location for language. The PR changes the implementation but does not update docs.

Fix Focus Areas

  • doc/cookies.md[5-10]
  • src/static/js/pad.ts[546-550]
  • src/static/js/pad_editor.ts[58-82]

[security] Pad creator can be lost
Pad creator can be lost handlePadOptionsMessage() calls padManager.getPad(session.padId) without passing the session authorId, so if a padoptions message is processed before the normal pad creation path completes, the pad can be created with revision 0 author set to '' and isPadCreator() will fail for everyone (locking pad settings/delete).

Issue description

handlePadOptionsMessage() calls padManager.getPad(session.padId) without providing session.author. If the pad does not yet exist at that moment, PadManager.getPad() will create it with the default authorId = '', which makes revision 0’s author empty. Because creator checks rely on revision 0’s author, the pad can end up with no valid creator.

Issue Context

  • Creator is determined via pad.getRevisionAuthor(0).
  • PadManager.getPad() uses authorId = '' by default.

Fix Focus Areas

  • src/node/handler/PadMessageHandler.ts[267-295]
  • src/node/db/PadManager.ts[109-144]

What to change

  • In handlePadOptionsMessage(), avoid implicitly creating a pad with an empty author:
  • Option A (preferred): Check existence first (doesPadExist). If it does not exist, reject the message (or defer until after CLIENT_READY completes).
  • Option B: Call padManager.getPad(session.padId, null, session.author) so any accidental creation still assigns the correct creator.
  • Keep the creator authorization check, but ensure the pad is not created as a side effect of unauthorized/early messages.

[correctness] Defaults override server config
Defaults override server config getMyViewDefaults() hard-codes fallback defaults (e.g., showChat/showLineNumbers true) when cookies are absent, and these values are sent as padSettingsDefaults for new pads, overriding server-side defaults such as settings.padOptions.showChat/showLineNumbers.

Issue description

New pads are seeded from padSettingsDefaults, but the client computes these via getMyViewDefaults() which uses hard-coded fallbacks like showChat: true and showLineNumbers: true when cookies are unset. This makes newly created pad defaults override server/admin defaults (e.g., settings.padOptions.showChat), because the server only applies its defaults when the incoming value is null/undefined.

Issue Context

  • Server defaulting behavior is in Pad.normalizePadSettings().
  • Client sends padSettingsDefaults in CLIENT_READY.

Fix Focus Areas

  • src/static/js/pad.ts[187-271]
  • src/node/handler/PadMessageHandler.ts[898-907]
  • src/node/db/Pad.ts[87-103]

What to change

  • Replace padSettingsDefaults: getMyViewDefaults() with a payload that only includes explicit user overrides (no hard-coded fallbacks). For example:
  • Send getMyViewOverrides() instead of getMyViewDefaults().
  • Ensure any undefined/null values are omitted so the server can apply settings.padOptions.* defaults.
  • Optionally, add a server-side guard when applying padSettingsDefaults to treat missing values as “unset” (so server defaults win) rather than trusting client fallbacks.


                     PR 7542 (2026-04-18)                    
[performance] Unthrottled line-number recalcs
Unthrottled line-number recalcs broadcast.ts calls updateLineNumbers() synchronously on every applyChangeset(), forcing repeated full-document layout reads and DOM writes during timeslider scrubbing/playback and potentially making large pads very slow. scheduleLineNumberUpdate() also doesn’t coalesce repeated calls, so resize can queue many redundant recalculations.

Issue description

updateLineNumbers() is invoked in a hot path (per changeset) and resize scheduling is not coalesced. This causes excessive layout/reflow work, especially when scrubbing through many revisions or resizing the window.

Issue Context

The editor’s gutter update logic is run via an idle timer with a time budget; the timeslider should similarly throttle/coalesce updates.

Fix Focus Areas

  • src/static/js/broadcast.ts[189-193]
  • src/static/js/broadcast.ts[195-259]
  • src/static/js/broadcast.ts[520-535]

Proposed fix approach

  • Add a let lineNumberUpdatePending = false flag.
  • Change scheduleLineNumberUpdate() to no-op if lineNumberUpdatePending is true; otherwise set it true and run a single requestAnimationFrame(() => { lineNumberUpdatePending = false; updateLineNumbers(); }) (keep double-rAF only if you can justify it, but still coalesce).
  • Replace direct updateLineNumbers() calls in applyChangeset() with scheduleLineNumberUpdate() (or call direct only when needed and still coalesce).
  • Ensure initial render still triggers at least one update after DOM insertion.


                     PR 7539 (2026-04-17)                    
[reliability] `docx` export lacks flag
`docx` export lacks flag DOCX export is newly enabled in both the server allowlist and the UI without any feature flag or disabled-by-default toggle. This violates the requirement that new features be behind a feature flag with the old behavior preserved when disabled.

Issue description

DOCX export is introduced/enabled without a feature flag and changes existing behavior (the Word button now downloads DOCX).

Issue Context

Compliance requires new features to be behind a flag, disabled by default, and to preserve the old execution path when disabled.

Fix Focus Areas

  • src/static/js/pad_impexp.ts[147-164]
  • src/node/hooks/express/importexport.ts[31-46]


                     PR 7535 (2026-04-17)                    
[correctness] Autofix breaks static HTML
Autofix breaks static HTML In autofix mode, the new scan rewrites "/static/plugins/..." to "../static/plugins/..." in HTML files under a plugin’s `static/` directory, but those files are served from `/static/plugins//static/...` so the rewritten relative URL resolves to a non-existent path and will 404. The warning text also gives the same incorrect remediation guidance for `static/*.html` as for templates.

Issue description

checkPlugin currently applies the same autofix (/static/plugins/... -> ../static/plugins/...) to both templates/ and static/ HTML/EJS files. This is correct for templates rendered under pad pages, but incorrect for HTML served from a plugin’s static/ directory, because those pages are loaded from /static/plugins/<plugin>/static/... and need different relative paths.

Issue Context

Etherpad serves plugin static files through the /static/* handler (see Minify path rewriting). A one-size-fits-all rewrite to ../static/plugins/ will generate broken URLs for static HTML.

Fix Focus Areas

  • bin/plugins/checkPlugin.ts[377-413]

Suggested change

  • Keep scanning static/**/*.html for warnings, but either:
  • Do not autofix files under static/, and update the warning message to say “use a relative path appropriate for the file’s location (no leading '/')”, OR
  • Implement a separate autofix for static/**/*.html that computes the correct relative prefix to /static/plugins/ based on the file’s depth under the plugin’s static/ directory.


                     PR 7516 (2026-04-14)                    
[reliability] `ueberdb2` requires Node >=22
`ueberdb2` requires Node >=22 The updated `ueberdb2@5.0.42` dependency declares `node >=22.22.0`, but this repo declares `node >=20.0.0` with `engineStrict: true`, which will break installs/runs on Node 20. This is an incompatible change that must be avoided or explicitly documented with corresponding engine/version updates.

Issue description

ueberdb2@5.0.42 requires Node >=22.22.0 but this repo declares Node >=20.0.0 (and engineStrict: true), so the dependency bump can break installs/runs on Node 20.

Issue Context

The PR updates ueberdb2 to ^5.0.42. The lockfile indicates this version has a stricter Node engine requirement than the project currently declares.

Fix Focus Areas

  • pnpm-lock.yaml[5038-5041]
  • src/package.json[76-76]
  • src/package.json[125-129]
  • package.json[44-52]


                     PR 7512 (2026-04-14)                    
[maintainability] Wrong hook source path
Wrong hook source path The new aceRegisterLineAttributes docs say the hook is called from `src/static/js/ace2_inner.js`, but the implementation is in `src/static/js/ace2_inner.ts`. This misleads readers trying to locate or verify the hook behavior in the source tree.

Issue description

The aceRegisterLineAttributes documentation claims the hook is called from src/static/js/ace2_inner.js, but the codebase uses src/static/js/ace2_inner.ts for the implementation. This makes it harder for readers to find the hook.

Issue Context

The hook is invoked in the Enter key handling logic via hooks.callAll('aceRegisterLineAttributes').

Fix Focus Areas

  • doc/api/hooks_client-side.md[210-213]
  • doc/api/hooks_client-side.adoc[214-217]

Suggested change

Replace src/static/js/ace2_inner.js with src/static/js/ace2_inner.ts in the new aceRegisterLineAttributes section (and keep the two doc formats consistent).



                     PR 7491 (2026-04-07)                    
[maintainability] Docs omit --otp flag
Docs omit --otp flag `bin/setup-trusted-publishers.sh` now supports `--otp`, but `doc/npm-trusted-publishing.md` doesn’t document it in the script usage examples or explain the 2FA/EOTP flow. This can lead maintainers to follow the docs and hit EOTP without knowing the intended invocation.

Issue description

The repo documentation for npm trusted publishing doesn’t mention the new --otp option or the fact that npm trust github requires OTP on 2FA-enabled accounts.

Issue Context

The script itself now documents --otp and explains TOTP expiry, but maintainers are likely to follow doc/npm-trusted-publishing.md first.

Fix Focus Areas

  • doc/npm-trusted-publishing.md[24-55]
  • bin/setup-trusted-publishers.sh[17-22]

Suggested fix

Update doc/npm-trusted-publishing.md to include:

  • An example invocation with --otp <code>
  • A short note that npm trust github requires OTP for 2FA accounts and TOTPs expire quickly (suggest chunking with --packages).


                     PR 7485 (2026-04-07)                    
[correctness] Node version requirement mismatch
Node version requirement mismatch bin/installer.sh accepts Node.js >=18 (and README advertises >=18), but the repo declares engines.node >=20, so the installer can proceed on Node 18/19 and then fail during dependency install/build.

Issue description

The installer and README currently state/enforce Node.js >=18, but the project’s package.json engines require Node.js >=20. This mismatch will cause the quick install flow to accept unsupported Node versions and then fail later.

Issue Context

  • bin/installer.sh uses REQUIRED_NODE_MAJOR=18 and checks NODE_MAJOR against it.
  • The repo declares engines.node: >=20.0.0 (root and src).

Fix Focus Areas

  • bin/installer.sh[34-51]
  • README.md[46-54]
  • README.md[126-138]

Suggested fix

  • Update the installer to require Node.js 20+ (and adjust messaging).
  • Update README quick install and (optional but recommended) the Requirements section to explicitly mention Node.js >=20.

[correctness] Branch ignored for existing dir
Branch ignored for existing dir If ETHERPAD_DIR already exists and is a git repo, the installer runs `git pull --ff-only` without checking out ETHERPAD_BRANCH or validating ETHERPAD_REPO, so it can silently install/build a different version than requested.

Issue description

When $ETHERPAD_DIR already exists, the installer does a plain git pull --ff-only and does not ensure the checkout matches $ETHERPAD_BRANCH (or that the remote matches $ETHERPAD_REPO). This can lead to silently building the wrong revision.

Issue Context

The fresh clone path uses git clone --branch "$ETHERPAD_BRANCH" "$ETHERPAD_REPO", but the existing repo path does not.

Fix Focus Areas

  • bin/installer.sh[67-78]

Suggested fix

  • In the existing repo path:
  • Validate the remote (e.g., compare git remote get-url origin to $ETHERPAD_REPO, or at least warn/fatal on mismatch).
  • Ensure a clean working tree (fatal if dirty).
  • Fetch updates (git fetch --tags --prune) and then checkout/switch to $ETHERPAD_BRANCH (or detach at tag) before pulling/fast-forwarding.
  • Optionally print which commit/branch was installed for clarity.


                     PR 7480 (2026-04-06)                    
[reliability] No loadTesting reproduction test
No loadTesting reproduction test The added regression test verifies `CLIENT_VARS` consistency but does not enable `loadTesting` or simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply" failure mode under load. This fails the requirement to add automated load-test coverage for the specific edge case.

Issue description

The PR adds a regression test, but it does not reproduce the reported production failure mode under loadTesting (very active pads with concurrent edits while a client loads), as required.

Issue Context

The compliance requirement expects an automated scenario that can trigger the mismatched apply failure before the fix and demonstrate non-failure after the fix, specifically when loadTesting is enabled and edits occur at high frequency during pad load.

Fix Focus Areas

  • src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

[reliability] No CLIENT_VARS delay test
No CLIENT_VARS delay test The new test does not simulate or introduce any timing/delay or race between `CLIENT_VARS` delivery and subsequent `NEW_CHANGES` reception. This leaves the suspected race condition scenario untested.

Issue description

There is no test coverage that introduces a delay/race between receiving CLIENT_VARS and receiving/applying NEW_CHANGES, which is part of the reported failure mechanism.

Issue Context

The bug manifests when CLIENT_VARS state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no mismatched apply occurs.

Fix Focus Areas

  • src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

[correctness] Connect can miss revisions
Connect can miss revisions In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions created during that await are broadcast to existing room members but not to the connecting socket. Because there is no explicit catch-up after joining, the new client can remain stuck on an older revision until some later edit triggers updatePadClients().

Issue description

During handleClientReady() (first connect), the socket does not join the pad room until after await hooks.aCallAll('clientVars', ...). Any revisions appended while awaiting the hook are sent to existing room members via updatePadClients(), but the connecting socket is not yet in _getRoomSockets(pad.id) and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.

Issue Context

updatePadClients() is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.

Fix Focus Areas

  • src/node/handler/PadMessageHandler.ts[1077-1093]

Proposed fix

After socket.join(sessionInfo.padId) and after sending CLIENT_VARS (so the client is initialized), explicitly send any missing revisions:

  1. Initialize sessionInfo.time consistently for the snapshot revision (for correct timeDelta computation), ideally using await pad.getRevisionDate(headRev) (and consider also using this timestamp in clientVars.collab_client_vars.time).
  2. Call await exports.updatePadClients(pad) once after sessionInfo.rev = headRev (and after sessionInfo.time is set) to flush any revisions that occurred between snapshot capture and the join. This ensures that if the pad advanced during the hook await window, the new client receives NEW_CHANGES immediately, even if the pad becomes quiet afterward.

[reliability] No loadTesting reproduction test
No loadTesting reproduction test The added regression test verifies `CLIENT_VARS` consistency but does not enable `loadTesting` or simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply" failure mode under load. This fails the requirement to add automated load-test coverage for the specific edge case.

Issue description

The PR adds a regression test, but it does not reproduce the reported production failure mode under loadTesting (very active pads with concurrent edits while a client loads), as required.

Issue Context

The compliance requirement expects an automated scenario that can trigger the mismatched apply failure before the fix and demonstrate non-failure after the fix, specifically when loadTesting is enabled and edits occur at high frequency during pad load.

Fix Focus Areas

  • src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

[reliability] No CLIENT_VARS delay test
No CLIENT_VARS delay test The new test does not simulate or introduce any timing/delay or race between `CLIENT_VARS` delivery and subsequent `NEW_CHANGES` reception. This leaves the suspected race condition scenario untested.

Issue description

There is no test coverage that introduces a delay/race between receiving CLIENT_VARS and receiving/applying NEW_CHANGES, which is part of the reported failure mechanism.

Issue Context

The bug manifests when CLIENT_VARS state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no mismatched apply occurs.

Fix Focus Areas

  • src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

[correctness] Connect can miss revisions
Connect can miss revisions In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions created during that await are broadcast to existing room members but not to the connecting socket. Because there is no explicit catch-up after joining, the new client can remain stuck on an older revision until some later edit triggers updatePadClients().

Issue description

During handleClientReady() (first connect), the socket does not join the pad room until after await hooks.aCallAll('clientVars', ...). Any revisions appended while awaiting the hook are sent to existing room members via updatePadClients(), but the connecting socket is not yet in _getRoomSockets(pad.id) and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.

Issue Context

updatePadClients() is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.

Fix Focus Areas

  • src/node/handler/PadMessageHandler.ts[1077-1093]

Proposed fix

After socket.join(sessionInfo.padId) and after sending CLIENT_VARS (so the client is initialized), explicitly send any missing revisions:

  1. Initialize sessionInfo.time consistently for the snapshot revision (for correct timeDelta computation), ideally using await pad.getRevisionDate(headRev) (and consider also using this timestamp in clientVars.collab_client_vars.time).
  2. Call await exports.updatePadClients(pad) once after sessionInfo.rev = headRev (and after sessionInfo.time is set) to flush any revisions that occurred between snapshot capture and the join. This ensures that if the pad advanced during the hook await window, the new client receives NEW_CHANGES immediately, even if the pad becomes quiet afterward.

[reliability] No loadTesting reproduction test
No loadTesting reproduction test The added regression test verifies `CLIENT_VARS` consistency but does not enable `loadTesting` or simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply" failure mode under load. This fails the requirement to add automated load-test coverage for the specific edge case.

Issue description

The PR adds a regression test, but it does not reproduce the reported production failure mode under loadTesting (very active pads with concurrent edits while a client loads), as required.

Issue Context

The compliance requirement expects an automated scenario that can trigger the mismatched apply failure before the fix and demonstrate non-failure after the fix, specifically when loadTesting is enabled and edits occur at high frequency during pad load.

Fix Focus Areas

  • src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

[reliability] No CLIENT_VARS delay test
No CLIENT_VARS delay test The new test does not simulate or introduce any timing/delay or race between `CLIENT_VARS` delivery and subsequent `NEW_CHANGES` reception. This leaves the suspected race condition scenario untested.

Issue description

There is no test coverage that introduces a delay/race between receiving CLIENT_VARS and receiving/applying NEW_CHANGES, which is part of the reported failure mechanism.

Issue Context

The bug manifests when CLIENT_VARS state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no mismatched apply occurs.

Fix Focus Areas

  • src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

[correctness] Connect can miss revisions
Connect can miss revisions In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions created during that await are broadcast to existing room members but not to the connecting socket. Because there is no explicit catch-up after joining, the new client can remain stuck on an older revision until some later edit triggers updatePadClients().

Issue description

During handleClientReady() (first connect), the socket does not join the pad room until after await hooks.aCallAll('clientVars', ...). Any revisions appended while awaiting the hook are sent to existing room members via updatePadClients(), but the connecting socket is not yet in _getRoomSockets(pad.id) and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.

Issue Context

updatePadClients() is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.

Fix Focus Areas

  • src/node/handler/PadMessageHandler.ts[1077-1093]

Proposed fix

After socket.join(sessionInfo.padId) and after sending CLIENT_VARS (so the client is initialized), explicitly send any missing revisions:

  1. Initialize sessionInfo.time consistently for the snapshot revision (for correct timeDelta computation), ideally using await pad.getRevisionDate(headRev) (and consider also using this timestamp in clientVars.collab_client_vars.time).
  2. Call await exports.updatePadClients(pad) once after sessionInfo.rev = headRev (and after sessionInfo.time is set) to flush any revisions that occurred between snapshot capture and the join. This ensures that if the pad advanced during the hook await window, the new client receives NEW_CHANGES immediately, even if the pad becomes quiet afterward.

[reliability] No loadTesting reproduction test
No loadTesting reproduction test The added regression test verifies `CLIENT_VARS` consistency but does not enable `loadTesting` or simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply" failure mode under load. This fails the requirement to add automated load-test coverage for the specific edge case.

Issue description

The PR adds a regression test, but it does not reproduce the reported production failure mode under loadTesting (very active pads with concurrent edits while a client loads), as required.

Issue Context

The compliance requirement expects an automated scenario that can trigger the mismatched apply failure before the fix and demonstrate non-failure after the fix, specifically when loadTesting is enabled and edits occur at high frequency during pad load.

Fix Focus Areas

  • src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

[reliability] No CLIENT_VARS delay test
No CLIENT_VARS delay test The new test does not simulate or introduce any timing/delay or race between `CLIENT_VARS` delivery and subsequent `NEW_CHANGES` reception. This leaves the suspected race condition scenario untested.

Issue description

There is no test coverage that introduces a delay/race between receiving CLIENT_VARS and receiving/applying NEW_CHANGES, which is part of the reported failure mechanism.

Issue Context

The bug manifests when CLIENT_VARS state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no mismatched apply occurs.

Fix Focus Areas

  • src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

[correctness] Connect can miss revisions
Connect can miss revisions In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions created during that await are broadcast to existing room members but not to the connecting socket. Because there is no explicit catch-up after joining, the new client can remain stuck on an older revision until some later edit triggers updatePadClients().

Issue description

During handleClientReady() (first connect), the socket does not join the pad room until after await hooks.aCallAll('clientVars', ...). Any revisions appended while awaiting the hook are sent to existing room members via updatePadClients(), but the connecting socket is not yet in _getRoomSockets(pad.id) and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.

Issue Context

updatePadClients() is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.

Fix Focus Areas

  • src/node/handler/PadMessageHandler.ts[1077-1093]

Proposed fix

After socket.join(sessionInfo.padId) and after sending CLIENT_VARS (so the client is initialized), explicitly send any missing revisions:

  1. Initialize sessionInfo.time consistently for the snapshot revision (for correct timeDelta computation), ideally using await pad.getRevisionDate(headRev) (and consider also using this timestamp in clientVars.collab_client_vars.time).
  2. Call await exports.updatePadClients(pad) once after sessionInfo.rev = headRev (and after sessionInfo.time is set) to flush any revisions that occurred between snapshot capture and the join. This ensures that if the pad advanced during the hook await window, the new client receives NEW_CHANGES immediately, even if the pad becomes quiet afterward.

[maintainability] Test doesn't validate rev
Test doesn't validate rev The new regression test only compares initialAttributedText.text to pad.text() and never verifies that initialAttributedText corresponds to collabVars.rev. This can pass even if CLIENT_VARS advertises an incorrect revision number (the invariant that matters for preventing mismatched apply).

Issue description

The regression test claims to verify that collabVars.rev matches initialAttributedText, but it only checks initialAttributedText.text === pad.text(). This does not assert the core invariant (text corresponds to the advertised revision) and can miss the exact class of bug being guarded.

Issue Context

Pad.getInternalRevisionAText(rev) exists and can compute the AText for an arbitrary revision.

Fix Focus Areas

  • src/tests/backend/specs/clientvar_rev_consistency.ts[23-55]
  • src/node/db/Pad.ts[209-222]

Proposed fix

In the test, replace (or supplement) the pad.text() comparison with a revision-specific comparison:

  • After receiving CLIENT_VARS, fetch const atextAtRev = await pad.getInternalRevisionAText(collabVars.rev);
  • Assert atextAtRev.text === collabVars.initialAttributedText.text Optionally, to better exercise the original race, introduce a concurrent edit while the handshake is in progress (e.g., start handshake() but do not await it immediately, perform a pad.setText() or append revision, then await the handshake) and still assert consistency using getInternalRevisionAText(collabVars.rev) (do not assert it equals the latest head text in the presence of concurrency).

[correctness] NaN timeDelta in catch-up
NaN timeDelta in catch-up handleClientReady now calls updatePadClients() right after sending CLIENT_VARS, so a newly connected socket can immediately receive NEW_CHANGES catch-up messages. updatePadClients computes timeDelta as currentTime - sessioninfo.time, but sessioninfo.time is never initialized for a new session, resulting in timeDelta=NaN which breaks timeslider/broadcast code that does currentTime += timeDelta.

Issue description

updatePadClients() computes timeDelta as currentTime - sessioninfo.time. In the new initial-connect catch-up path (handleClientReady now calls await updatePadClients(pad)), sessioninfo.time can be undefined, producing timeDelta: NaN in NEW_CHANGES. Timeslider/broadcast code uses timeDelta to advance currentTime, so NaN will break time rendering.

Issue Context

A new socket’s sessioninfos[socket.id] is initialized as {} and handleClientReady sets sessionInfo.rev but does not set sessionInfo.time before calling updatePadClients().

Fix Focus Areas

  • src/node/handler/PadMessageHandler.ts[1088-1097]
  • src/node/handler/PadMessageHandler.ts[738-795]

Suggested fix

Pick one (or both):

  1. Initialize sessionInfo.time during CLIENT_READY handling (normal connect and reconnect) to the timestamp that corresponds to sessionInfo.rev (often currentTime already computed for CLIENT_VARS).
  2. Make updatePadClients() robust:
  • If sessioninfo.time is nullish, set timeDelta to 0 (or compute based on the revision timestamp) and/or initialize sessioninfo.time before computing timeDelta.


                     PR 7474 (2026-04-06)                    
[security] 10MB socket payload exposure
10MB socket payload exposure Raising the default `socketIo.maxHttpBufferSize` to 10MB increases the amount of data an (often unauthenticated) client can force the server to parse and route per socket.io message. The existing production rate limiter is per-message (not per-byte), so large 10MB messages are treated the same as tiny messages, increasing DoS/performance risk.

Issue description

With maxHttpBufferSize defaulting to 10MB, the current per-message socket rate limiting allows very large payloads at the same event cost as small ones, increasing resource exhaustion risk.

Issue Context

  • Socket.io transport accepts up to settings.socketIo.maxHttpBufferSize bytes per message.
  • PadMessageHandler.handleMessage() applies rate limiting only in production and always consumes 1 point per event.

Fix Focus Areas

  • src/node/handler/PadMessageHandler.ts[273-286]
  • src/node/utils/Settings.ts[619-632]
  • src/node/utils/Settings.ts[360-370]

Suggested change

Adjust the rate limiting consumption to scale with payload size for message types that can be large (notably COLLABROOM/USER_CHANGES). For example:

  • Estimate bytes using a cheap field (e.g., message.data?.changeset?.length when present).
  • Consume Math.max(1, Math.ceil(bytes / (1024 * 1024))) points so ~1MiB costs 1 point; a 10MB paste costs ~10 points. This keeps single large pastes possible while reducing the ability to flood the server with repeated max-sized messages.


                     PR 7472 (2026-04-06)                    
[reliability] Dev proxy test is conditional
Dev proxy test is conditional The new `x-proxy-path` test only asserts when it detects a dev-mode `/watch/` script URL, but the backend test runner sets `NODE_ENV=production`, so the assertion is skipped and the test can pass even if the fix is reverted. This violates the requirement to include a regression test that deterministically fails without the fix.

Issue description

The new x-proxy-path regression test can be a no-op because it only asserts in dev mode, while backend tests are executed with NODE_ENV=production.

Issue Context

src/package.json runs backend tests with cross-env NODE_ENV=production ..., so specialpages.ts uses the production branch that emits relative entrypoints (no /watch/), causing the new test's assertion to be skipped.

Fix Focus Areas

  • src/tests/backend/specs/specialpages.ts[35-62]
  • src/package.json[134-140]
  • src/node/hooks/express/specialpages.ts[312-339]

[security] Unvalidated proxy path header
Unvalidated proxy path header In dev mode, `handleLiveReload()` concatenates the request-supplied `x-proxy-path` header into the JavaScript entrypoint URL, so malformed values (e.g., `'/'` → `//watch/...`) or scheme/protocol-relative prefixes can produce an off-origin `` or broken asset path. Because `entrypoint` is rendered into `">`, this directly controls what JavaScript the browser attempts to load.

Issue description

x-proxy-path is used directly to build <script src> URLs in dev mode. Certain header values can generate protocol-relative URLs (e.g., '/' + '/watch/..'//watch/..) or other unexpected paths; additionally, accepting arbitrary prefixes allows off-origin script URLs if the prefix contains // or a scheme.

Issue Context

This is in the dev-mode live reload path (handleLiveReload). Production mode uses relative entrypoints and is unaffected.

Fix Focus Areas

  • Add a small helper to normalize/validate x-proxy-path (e.g., allow only '' or a path that starts with a single /, strip trailing /, reject values starting with // or containing :).
  • Use the helper for all three dev-mode entrypoints (index/pad/timeslider).
  • src/node/hooks/express/specialpages.ts[161-170]
  • src/node/hooks/express/specialpages.ts[182-199]
  • src/node/hooks/express/specialpages.ts[212-230]


                     PR 7470 (2026-04-05)                    
[correctness] Explicit start overridden
Explicit start overridden When opening a new ordered list, the counter-based continuation takes precedence over `line.start`, so an explicitly stored start value can be ignored and replaced with a computed value. This can export incorrect numbering for lists that intentionally restart or that were imported with explicit `` semantics.

Issue description

The HTML exporter opens a new <ol> using olItemCounts before honoring line.start, which means explicit list starts (from stored attributes or HTML import) can be overwritten.

Issue Context

line.start comes from the start line attribute in the pad’s attribution stream. The editor renumbering logic sets this attribute intentionally, so export should treat it as authoritative when present.

Fix Focus Areas

  • src/node/utils/ExportHtml.ts[393-402]
  • src/node/utils/ExportHtml.ts[414-423]

Suggested fix approach

  • When deciding the <ol ...> opener:
  • If line.start is present and is a finite number, emit that value.
  • Else fall back to the continuation counter.
  • Ensure the counter is seeded/aligned when line.start is used (so later continuations at that level don’t assume the list started at 1).

[correctness] No per-level counter reset
No per-level counter reset `olItemCounts` is only cleared when leaving all lists, not when a list level is closed (listLevel decreases). This can incorrectly continue numbering when re-entering the same indentation level later (common with nested ordered lists).

Issue description

The new olItemCounts map is cleared only when line.listLevel becomes falsy (outside any list). When the exporter closes list levels due to indentation decreasing, counters for the closed levels remain and can incorrectly affect later <ol start="..."> values.

Issue Context

Nested lists commonly decrease listLevel back to a parent and later re-enter the same nested level under a different parent item. Counters for that nested level should not carry across these separate nested list instances unless the document’s stored start attributes explicitly indicate continuation.

Fix Focus Areas

  • src/node/utils/ExportHtml.ts[441-466]
  • src/node/utils/ExportHtml.ts[468-472]

Suggested fix approach

  • When closing list elements (the branch that emits </ol> / </ul> when nextLine.listLevel < line.listLevel or list type changes):
  • Delete olItemCounts[level] entries for any ordered-list levels being closed.
  • Keep counts only for still-open levels.
  • Keep the existing full reset when leaving all lists as a safety net.

[maintainability] Brittle start assertions
Brittle start assertions The new tests use `html.includes('start="N"')`, which is less specific than the previous regex-based checks and can accidentally pass if an unrelated list segment contains the same start value. This weakens regression protection as list export grows more complex (e.g., nested lists).

Issue description

The tests match start="2" / start="3" anywhere in the HTML, which is not tied to the specific ordered-list segment that should continue numbering.

Issue Context

As export output evolves (nested lists, multiple ordered lists in one pad), a global substring match can yield false positives.

Fix Focus Areas

  • src/tests/backend/specs/export_list.ts[36-104]

Suggested fix approach

  • Extract ordered-list open tags in order (e.g., regex for <ol[^>]*class="number"[^>]*>), then assert on the second segment’s attributes.
  • Or parse the HTML with jsdom and query for ol.number nodes, asserting the start attribute on the expected index.


                     PR 7469 (2026-04-05)                    
[reliability] `.git` copy still mandatory
`.git` copy still mandatory The new `ONBUILD COPY --chown=etherpad:etherpad ./.git ./.git` still hard-requires a `.git` entry to exist in the build context, so builds from contexts without git metadata (e.g., source tarballs) will still fail at the COPY step. This conflicts with the requirement to support deterministic builds when `.git` is unavailable by providing a bypass/override mechanism.

Issue description

The Docker build still unconditionally requires .git to be present in the build context due to ONBUILD COPY ./.git ./.git, which breaks builds where git metadata is not included (and provides no explicit deterministic override path).

Issue Context

COPY cannot be conditional, so a missing .git causes an immediate build failure. The compliance requirement expects a documented bypass (build-arg/env) for version/commit discovery when .git is unavailable.

Fix Focus Areas

  • Dockerfile[6-6]
  • Dockerfile[119-134]
  • .dockerignore[6-22]


                     PR 7468 (2026-04-05)                    
[correctness] Wrong pnpm project scope
Wrong pnpm project scope `bin/updatePlugins.sh` runs `pnpm outdated` / `pnpm update` from the workspace root, but the root `package.json` only depends on the linked `ep_etherpad-lite` package, so this script will not detect/update plugins that are dependencies of the main `src` package. As a result it can incorrectly report “All plugins are up-to-date” even when plugins used by Etherpad are outdated.

Issue description

updatePlugins.sh runs pnpm at the workspace root, which (in this repo) does not list plugin dependencies, so pnpm outdated won’t surface outdated plugins used by the main app.

Issue Context

  • Workspace root package.json only depends on ep_etherpad-lite (link).
  • Other repo scripts scope pnpm operations via --filter or --recursive.

Fix Focus Areas

  • bin/updatePlugins.sh[3-11]

Suggested change

Update the script to run against the ep_etherpad-lite workspace project (or cd src before running pnpm), e.g.:

  • pnpm --filter ep_etherpad-lite outdated --depth=0 ...
  • pnpm --filter ep_etherpad-lite update "$@" (keeping the rest of the logic the same).

[correctness] Updates core package
Updates core package The `grep '^ep_'` filter can include `ep_etherpad-lite`, which other plugin tooling explicitly excludes; attempting to update the core workspace-linked package is not a plugin update and can fail or block updating real plugins. This makes the update list incorrect and can cause the script to behave unexpectedly.

Issue description

updatePlugins.sh collects package names matching ^ep_ but doesn’t exclude ep_etherpad-lite, which is not a plugin and is explicitly skipped elsewhere.

Issue Context

Other plugin management/update code filters out ep_etherpad-lite.

Fix Focus Areas

  • bin/updatePlugins.sh[5-5]

Suggested change

Add an exclusion after the grep '^ep_' step, for example:

  • ... | grep '^ep_' | grep -v '^ep_etherpad-lite$' (If you also implement workspace scoping, keep this exclusion as a safety guard.)

[reliability] Errors reported as up-to-date
Errors reported as up-to-date Any failure in `pnpm outdated` (for example, pnpm execution errors) is treated the same as “no outdated plugins” because the `|| { echo "All plugins are up-to-date"; exit 0; }` handler triggers on any non-zero pipeline exit. This can silently hide real problems and cause false success.

Issue description

The script currently prints "All plugins are up-to-date" for any non-zero exit status from the pnpm outdated | awk | grep pipeline, which includes actual command failures.

Issue Context

POSIX /bin/sh does not reliably support set -o pipefail, so the fix should avoid relying on pipefail.

Fix Focus Areas

  • bin/updatePlugins.sh[5-8]

Suggested change (POSIX sh compatible)

Restructure to:

  1. Run pnpm ... outdated and fail hard if it fails.
  2. Then parse the output; treat grep no-match as an empty list. Example structure:


                     PR 7467 (2026-04-05)                    
[correctness] Map updated after failure
Map updated after failure addSubDependency() updates dependenciesMap even when linking or package.json read/parse fails, marking the dependency as tracked despite an incomplete/failed setup. This can block later cleanup because removeSubDependency() returns early when dependenciesMap.has(dependency) is true.

Issue description

addSubDependency() calls this.dependenciesMap.set(dependency, ...) even if the try block fails (linking or reading/parsing package.json). This can leave the dependency marked as “in use” and prevent removal later.

Issue Context

removeSubDependency() returns early if dependenciesMap.has(dependency) is true, so an entry created after a failed add can block cleanup.

Fix Focus Areas

  • src/static/js/pluginfw/LinkInstaller.ts[180-200]
  • src/static/js/pluginfw/LinkInstaller.ts[94-110]

Suggested change

  • Move this.dependenciesMap.set(dependency, new Set([plugin])) into the try block after successful link + successful package.json read/parse.
  • If package.json read/parse fails, do not set the map entry; optionally rethrow so plugin install fails loudly rather than leaving partial state.
  • (Optional hardening) After linkDependency(), verify the symlink/dir exists before marking success.


                     PR 7465 (2026-04-05)                    
[maintainability] Docker docs mismatch
Docker docs mismatch The PR updates doc/docker.md to recommend pulling image tag 2.6.1, but the parallel AsciiDoc doc/docker.adoc still recommends 1.8.0, resulting in conflicting installation instructions. Users following the AsciiDoc guide will likely deploy an outdated version.

Issue description

doc/docker.md was updated to reference Docker image tag 2.6.1, but the AsciiDoc equivalent (doc/docker.adoc) still references 1.8.0, creating conflicting guidance.

Issue Context

The repository's current version is 2.6.1, and the Markdown Docker docs already reflect that.

Fix Focus Areas

  • doc/docker.adoc[10-16]


                     PR 7464 (2026-04-05)                    
[correctness] RTL preference forced to false
RTL preference forced to false `pad._afterHandshake()` now always calls `changeViewOption('rtlIsTrue', false)` for the common case where `settings.rtlIsTrue` is false, which overwrites any existing `rtlIsTrue` cookie and forces LTR even when RTL should come from cookie or language direction. This can break persisted RTL mode and prevent RTL-by-language defaults from taking effect unless the URL explicitly enables RTL.

Issue description

pad._afterHandshake() now unconditionally calls changeViewOption('rtlIsTrue', settings.rtlIsTrue === true). Because settings.rtlIsTrue is typically false (server defaults include padOptions.rtl: false), this writes rtlIsTrue=false into view options and persists it to the prefs cookie, overwriting an existing rtlIsTrue=true cookie and suppressing RTL-by-language defaults.

Issue Context

  • changeViewOption() flows into handleOptionsChange(), which persists view options to the prefs cookie via padcookie.setPref().
  • Cookie restore logic for RTL only re-applies when the cookie is true, so overwriting it to false prevents restore.

Fix focus areas

  • Only apply rtlIsTrue view option when there is an explicit override (e.g., URL contains rtl, or a server config explicitly enables RTL). Avoid calling changeViewOption('rtlIsTrue', false) as a default.
  • Consider using presence checks (URLSearchParams.has('rtl')) or a tri-state/flag to distinguish “unset” from “explicit false”.

Suggested approach

  1. In _afterHandshake(), replace the unconditional call with logic like:
  • If getUrlVars().has('rtl'), set rtlIsTrue based on the URL value (params.get('rtl') === 'true').
  • Else if server config explicitly sets RTL on (e.g., clientVars.padOptions.rtl === true), set it to true.
  • Else do nothing so cookie and html10n.getDirection() defaults can apply.

Fix Focus Areas (exact locations)

  • src/static/js/pad.ts[153-173]
  • src/static/js/pad.ts[429-556]
  • src/static/js/pad.ts[598-617]

[correctness] RTL preference forced to false
RTL preference forced to false `pad._afterHandshake()` now always calls `changeViewOption('rtlIsTrue', false)` for the common case where `settings.rtlIsTrue` is false, which overwrites any existing `rtlIsTrue` cookie and forces LTR even when RTL should come from cookie or language direction. This can break persisted RTL mode and prevent RTL-by-language defaults from taking effect unless the URL explicitly enables RTL.

Issue description

pad._afterHandshake() now unconditionally calls changeViewOption('rtlIsTrue', settings.rtlIsTrue === true). Because settings.rtlIsTrue is typically false (server defaults include padOptions.rtl: false), this writes rtlIsTrue=false into view options and persists it to the prefs cookie, overwriting an existing rtlIsTrue=true cookie and suppressing RTL-by-language defaults.

Issue Context

  • changeViewOption() flows into handleOptionsChange(), which persists view options to the prefs cookie via padcookie.setPref().
  • Cookie restore logic for RTL only re-applies when the cookie is true, so overwriting it to false prevents restore.

Fix focus areas

  • Only apply rtlIsTrue view option when there is an explicit override (e.g., URL contains rtl, or a server config explicitly enables RTL). Avoid calling changeViewOption('rtlIsTrue', false) as a default.
  • Consider using presence checks (URLSearchParams.has('rtl')) or a tri-state/flag to distinguish “unset” from “explicit false”.

Suggested approach

  1. In _afterHandshake(), replace the unconditional call with logic like:
  • If getUrlVars().has('rtl'), set rtlIsTrue based on the URL value (params.get('rtl') === 'true').
  • Else if server config explicitly sets RTL on (e.g., clientVars.padOptions.rtl === true), set it to true.
  • Else do nothing so cookie and html10n.getDirection() defaults can apply.

Fix Focus Areas (exact locations)

  • src/static/js/pad.ts[153-173]
  • src/static/js/pad.ts[429-556]
  • src/static/js/pad.ts[598-617]

[correctness] RTL preference forced to false
RTL preference forced to false `pad._afterHandshake()` now always calls `changeViewOption('rtlIsTrue', false)` for the common case where `settings.rtlIsTrue` is false, which overwrites any existing `rtlIsTrue` cookie and forces LTR even when RTL should come from cookie or language direction. This can break persisted RTL mode and prevent RTL-by-language defaults from taking effect unless the URL explicitly enables RTL.

Issue description

pad._afterHandshake() now unconditionally calls changeViewOption('rtlIsTrue', settings.rtlIsTrue === true). Because settings.rtlIsTrue is typically false (server defaults include padOptions.rtl: false), this writes rtlIsTrue=false into view options and persists it to the prefs cookie, overwriting an existing rtlIsTrue=true cookie and suppressing RTL-by-language defaults.

Issue Context

  • changeViewOption() flows into handleOptionsChange(), which persists view options to the prefs cookie via padcookie.setPref().
  • Cookie restore logic for RTL only re-applies when the cookie is true, so overwriting it to false prevents restore.

Fix focus areas

  • Only apply rtlIsTrue view option when there is an explicit override (e.g., URL contains rtl, or a server config explicitly enables RTL). Avoid calling changeViewOption('rtlIsTrue', false) as a default.
  • Consider using presence checks (URLSearchParams.has('rtl')) or a tri-state/flag to distinguish “unset” from “explicit false”.

Suggested approach

  1. In _afterHandshake(), replace the unconditional call with logic like:
  • If getUrlVars().has('rtl'), set rtlIsTrue based on the URL value (params.get('rtl') === 'true').
  • Else if server config explicitly sets RTL on (e.g., clientVars.padOptions.rtl === true), set it to true.
  • Else do nothing so cookie and html10n.getDirection() defaults can apply.

Fix Focus Areas (exact locations)

  • src/static/js/pad.ts[153-173]
  • src/static/js/pad.ts[429-556]
  • src/static/js/pad.ts[598-617]

[correctness] RTL preference forced to false
RTL preference forced to false `pad._afterHandshake()` now always calls `changeViewOption('rtlIsTrue', false)` for the common case where `settings.rtlIsTrue` is false, which overwrites any existing `rtlIsTrue` cookie and forces LTR even when RTL should come from cookie or language direction. This can break persisted RTL mode and prevent RTL-by-language defaults from taking effect unless the URL explicitly enables RTL.

Issue description

pad._afterHandshake() now unconditionally calls changeViewOption('rtlIsTrue', settings.rtlIsTrue === true). Because settings.rtlIsTrue is typically false (server defaults include padOptions.rtl: false), this writes rtlIsTrue=false into view options and persists it to the prefs cookie, overwriting an existing rtlIsTrue=true cookie and suppressing RTL-by-language defaults.

Issue Context

  • changeViewOption() flows into handleOptionsChange(), which persists view options to the prefs cookie via padcookie.setPref().
  • Cookie restore logic for RTL only re-applies when the cookie is true, so overwriting it to false prevents restore.

Fix focus areas

  • Only apply rtlIsTrue view option when there is an explicit override (e.g., URL contains rtl, or a server config explicitly enables RTL). Avoid calling changeViewOption('rtlIsTrue', false) as a default.
  • Consider using presence checks (URLSearchParams.has('rtl')) or a tri-state/flag to distinguish “unset” from “explicit false”.

Suggested approach

  1. In _afterHandshake(), replace the unconditional call with logic like:
  • If getUrlVars().has('rtl'), set rtlIsTrue based on the URL value (params.get('rtl') === 'true').
  • Else if server config explicitly sets RTL on (e.g., clientVars.padOptions.rtl === true), set it to true.
  • Else do nothing so cookie and html10n.getDirection() defaults can apply.

Fix Focus Areas (exact locations)

  • src/static/js/pad.ts[153-173]
  • src/static/js/pad.ts[429-556]
  • src/static/js/pad.ts[598-617]


                     PR 7461 (2026-04-04)                    
[correctness] Doesn't capture no-list
Doesn't capture no-list Neighbor list state is only saved if getLineListType() returns a truthy string, so lines with no list attribute are ignored. If corruption adds a list attribute to a previously non-list neighbor line, there is no saved baseline to detect and revert the corruption.

Issue description

The pre-drop snapshot only records neighbor list types when they are truthy, which drops the case where the neighbor line has no list attribute. If the browser corruption adds a list type, the code cannot restore back to “no list”.

Issue Context

setLineListType(lineNum, '') is the supported way to clear list state (and related metadata).

Fix Focus Areas

  • src/static/js/ace2_inner.ts[3269-3286]
  • src/static/js/ace2_inner.ts[2353-2359]

Suggested change

  • Always push a snapshot entry for the neighbor lines, even when getLineListType(...) is null/undefined.
  • Use a type like listType: string | null (or undefined) in the snapshot.
  • During restore: if saved is null/undefined and current is non-null, clear via setLineListType(resolvedLineNum, '') (or removeAttributeOnLine).

[correctness] Skips list normalization
Skips list normalization dropRestore restores list state via documentAttributeManager.setAttributeOnLine('list', ...) instead of setLineListType(), bypassing ordered-list renumbering and 'start' cleanup. This can leave ordered list numbering or related line metadata inconsistent after restoration.

Issue description

The restoration logic writes the 'list' attribute directly with setAttributeOnLine(), which bypasses list-specific invariants (clearing 'start', renumbering ordered lists).

Issue Context

setLineListType() is the established helper that maintains list-related attributes and calls renumberList().

Fix Focus Areas

  • src/static/js/ace2_inner.ts[3308-3313]
  • src/static/js/ace2_inner.ts[2353-2367]

Suggested change

  • Replace documentAttributeManager.setAttributeOnLine(lineNum, 'list', listType) with setLineListType(resolvedLineNum, listType ?? '').
  • Ensure the snapshot/restoration logic can represent the cleared state (no list) so setLineListType(..., '') is used when appropriate.

[reliability] atKey may throw
atKey may throw The new drop handler calls rep.lines.atKey(firstLineSelected.id) / atKey(lastLineSelected.id) without checking that those IDs exist in rep.lines. SkipList.atKey() dereferences with a non-null assertion and will throw if the key is missing, breaking drag-and-drop with an uncaught exception.

Issue description

rep.lines.atKey(...) can throw at runtime if the DOM node id is missing or not present in rep.lines. This can crash the drop handler.

Issue Context

SkipList.atKey() uses a non-null assertion on the backing map lookup, so missing keys are fatal.

Fix Focus Areas

  • src/static/js/ace2_inner.ts[3269-3272]
  • src/static/js/skiplist.ts[332-335]

Proposed fix

  1. Before calling rep.lines.atKey(firstLineSelected.id) / rep.lines.atKey(lastLineSelected.id), validate:
  • firstLineSelected != null && typeof firstLineSelected.id === 'string' && firstLineSelected.id !== ''
  • rep.lines.containsKey(firstLineSelected.id) (same for last).
  1. If validation fails, set savedLineAttrs = null (or leave it null) and skip the restore path.
  2. (Optional) If you switch to saving neighbor keys (recommended), you can avoid atKey() here entirely and only use containsKey/indexOfKey later.

[correctness] Doesn't capture no-list
Doesn't capture no-list Neighbor list state is only saved if getLineListType() returns a truthy string, so lines with no list attribute are ignored. If corruption adds a list attribute to a previously non-list neighbor line, there is no saved baseline to detect and revert the corruption.

Issue description

The pre-drop snapshot only records neighbor list types when they are truthy, which drops the case where the neighbor line has no list attribute. If the browser corruption adds a list type, the code cannot restore back to “no list”.

Issue Context

setLineListType(lineNum, '') is the supported way to clear list state (and related metadata).

Fix Focus Areas

  • src/static/js/ace2_inner.ts[3269-3286]
  • src/static/js/ace2_inner.ts[2353-2359]

Suggested change

  • Always push a snapshot entry for the neighbor lines, even when getLineListType(...) is null/undefined.
  • Use a type like listType: string | null (or undefined) in the snapshot.
  • During restore: if saved is null/undefined and current is non-null, clear via setLineListType(resolvedLineNum, '') (or removeAttributeOnLine).

[correctness] Skips list normalization
Skips list normalization dropRestore restores list state via documentAttributeManager.setAttributeOnLine('list', ...) instead of setLineListType(), bypassing ordered-list renumbering and 'start' cleanup. This can leave ordered list numbering or related line metadata inconsistent after restoration.

Issue description

The restoration logic writes the 'list' attribute directly with setAttributeOnLine(), which bypasses list-specific invariants (clearing 'start', renumbering ordered lists).

Issue Context

setLineListType() is the established helper that maintains list-related attributes and calls renumberList().

Fix Focus Areas

  • src/static/js/ace2_inner.ts[3308-3313]
  • src/static/js/ace2_inner.ts[2353-2367]

Suggested change

  • Replace documentAttributeManager.setAttributeOnLine(lineNum, 'list', listType) with setLineListType(resolvedLineNum, listType ?? '').
  • Ensure the snapshot/restoration logic can represent the cleared state (no list) so setLineListType(..., '') is used when appropriate.


                     PR 7460 (2026-04-04)                    
[security] Unsanitized HTML paste insertion
Unsanitized HTML paste insertion The new paste handler parses `text/html` from the clipboard and directly imports/inserts the resulting DOM nodes into the editor document without sanitization, allowing scriptable elements/attributes from untrusted clipboard HTML to enter the live DOM. Because incorporation is deferred via `setTimeout(…, 0)`, malicious nodes can execute (or trigger loads) before Etherpad’s collector later ignores unsupported tags.

Issue description

The paste handler inserts clipboard-provided HTML nodes into the live editor DOM without sanitizing tags/attributes, enabling XSS via event handlers/scriptable elements and unwanted resource loads.

Issue Context

The intent is to preserve inline formatting tags (b/i/u/s/etc.) that the browser otherwise normalizes away. The fix should preserve those formatting tags but must reject/strip dangerous markup and attributes before insertion.

Fix Focus Areas

  • src/static/js/ace2_inner.ts[3244-3275]

Concrete fix direction

  • Build a strict allowlist sanitizer for the parsed clipboard DOM before importNode():
  • Allow only a minimal set of elements needed for formatting/structure (for example: b, strong, i, em, u, s, del, ins, br, and optionally div/p/span).
  • Strip all attributes (especially on*, style, src, href, etc.).
  • Drop disallowed elements entirely (or unwrap them, keeping safe child text/formatting).
  • Insert only the sanitized fragment into the range.
  • (Optional hardening) If sanitization results in empty content, fall back to default paste behavior.

[security] Unsanitized HTML paste insertion
Unsanitized HTML paste insertion The new paste handler parses `text/html` from the clipboard and directly imports/inserts the resulting DOM nodes into the editor document without sanitization, allowing scriptable elements/attributes from untrusted clipboard HTML to enter the live DOM. Because incorporation is deferred via `setTimeout(…, 0)`, malicious nodes can execute (or trigger loads) before Etherpad’s collector later ignores unsupported tags.

Issue description

The paste handler inserts clipboard-provided HTML nodes into the live editor DOM without sanitizing tags/attributes, enabling XSS via event handlers/scriptable elements and unwanted resource loads.

Issue Context

The intent is to preserve inline formatting tags (b/i/u/s/etc.) that the browser otherwise normalizes away. The fix should preserve those formatting tags but must reject/strip dangerous markup and attributes before insertion.

Fix Focus Areas

  • src/static/js/ace2_inner.ts[3244-3275]

Concrete fix direction

  • Build a strict allowlist sanitizer for the parsed clipboard DOM before importNode():
  • Allow only a minimal set of elements needed for formatting/structure (for example: b, strong, i, em, u, s, del, ins, br, and optionally div/p/span).
  • Strip all attributes (especially on*, style, src, href, etc.).
  • Drop disallowed elements entirely (or unwrap them, keeping safe child text/formatting).
  • Insert only the sanitized fragment into the range.
  • (Optional hardening) If sanitization results in empty content, fall back to default paste behavior.


                     PR 7455 (2026-04-04)                    
[security] JSON primitive POST hangs
JSON primitive POST hangs In `openapi.ts`, POST requests fall back to `formidable.parse(req)` unless `req.body` is an object, so a valid `application/json` body that parses to a primitive (e.g., `"x"`, `123`, `true`) will still hit formidable after `express.json()` has consumed the stream, reintroducing the hang/timeout. Because parsing happens before auth checks, an unauthenticated request can trigger this and tie up server resources.

Issue description

src/node/hooks/express/openapi.ts falls back to formidable.parse(req) unless req.body is an object. For Content-Type: application/json requests where Express parses the body to a primitive (string/number/boolean), the code will still call formidable on an already-consumed stream and hang.

Issue Context

express.json() is installed globally, so JSON requests will have their streams consumed before this handler runs.

Fix Focus Areas

  • src/node/hooks/express/openapi.ts[613-630]

What to change

  • Detect JSON requests via req.headers['content-type'] / req.is('application/json') (or equivalently: if req.body is not undefined for POST) and never invoke formidable for those.
  • For JSON requests where req.body is not a plain object (primitive/array), set formData to {} (or otherwise map it safely) rather than passing the primitive/array into Object.assign().
  • Keep formidable parsing only for multipart/form-data and application/x-www-form-urlencoded (or when the body stream has not been consumed).

[reliability] No POST JSON regression test
No POST JSON regression test The PR changes POST body parsing behavior for legacy API OpenAPI handling but does not add an automated regression test that would fail without this fix. This increases the risk that POST+JSON timeouts (or incorrect method handling) reappear unnoticed in future changes.

Issue description

The OpenAPI legacy API POST parsing fix is not protected by a regression test that exercises a legacy v1.2.14 endpoint via POST with Content-Type: application/json.

Issue Context

The PR modifies openapi.ts to avoid hangs/timeouts when express.json() has already consumed the request stream, and to make method handling case-insensitive. Without a targeted test, future refactors could reintroduce the timeout/incorrect parsing.

Fix Focus Areas

  • src/tests/backend/specs/api/sessionsAndGroups.ts[257-266]
  • src/node/hooks/express/openapi.ts[613-630]

[reliability] Empty JSON still times out
Empty JSON still times out In src/node/hooks/express/openapi.ts, JSON POST requests with an empty parsed body (e.g., '{}' or '[]') still fall back to formidable.parse(req), which can hang because express.json() has already consumed the request stream. This leaves a reliable timeout/DoS edge case where requests remain stuck until the server times them out.

Issue description

src/node/hooks/express/openapi.ts decides whether to use req.body by checking Object.keys(req.body).length > 0. For an empty but valid JSON body ({} or []), this check fails and the code falls back to formidable.parse(req), which can hang because the JSON stream was already consumed by express.json().

Issue Context

There is already a working pattern for this in src/node/handler/RestAPI.ts: determine JSON vs form parsing based on Content-Type, not on whether req.body happens to be non-empty.

Fix Focus Areas

  • src/node/hooks/express/openapi.ts[615-630]

Suggested change

  • Branch on req.headers['content-type'] (or req.is('application/json')) similar to RestAPI.ts:
  • If no content-type or it starts with application/json, use formData = req.body even if empty.
  • Otherwise, use formidable.
  • This ensures empty JSON bodies do not trigger formidable and prevents the stream-hang timeout.

[correctness] Empty JSON still hangs
Empty JSON still hangs In `src/node/hooks/express/openapi.ts`, POST requests with an already-parsed JSON body will still fall back to formidable when `req.body` is `{}`, re-triggering the original timeout/hang because formidable tries to parse an already-consumed request stream. This breaks POST endpoints that legitimately send an empty JSON object or otherwise result in an empty parsed body.

Issue description

openapi.ts falls back to formidable whenever Object.keys(req.body).length === 0. If express.json() already consumed the stream and produced {}, formidable will attempt to parse an already-consumed stream, risking the original hang/timeout.

Issue Context

express.json() is registered globally, and RestAPI.ts already uses a safer Content-Type based decision.

Fix Focus Areas

  • src/node/hooks/express/openapi.ts[615-630]

Suggested change

  • Decide whether to use req.body vs formidable based on req.headers['content-type'] (similar to src/node/handler/RestAPI.ts).
  • For application/json (or missing content-type if you want to preserve existing behavior), always use req.body even if it is {}.
  • Only invoke formidable for multipart/form-data / application/x-www-form-urlencoded (or other non-JSON content types).

[reliability] Empty JSON still times out
Empty JSON still times out In src/node/hooks/express/openapi.ts, JSON POST requests with an empty parsed body (e.g., '{}' or '[]') still fall back to formidable.parse(req), which can hang because express.json() has already consumed the request stream. This leaves a reliable timeout/DoS edge case where requests remain stuck until the server times them out.

Issue description

src/node/hooks/express/openapi.ts decides whether to use req.body by checking Object.keys(req.body).length > 0. For an empty but valid JSON body ({} or []), this check fails and the code falls back to formidable.parse(req), which can hang because the JSON stream was already consumed by express.json().

Issue Context

There is already a working pattern for this in src/node/handler/RestAPI.ts: determine JSON vs form parsing based on Content-Type, not on whether req.body happens to be non-empty.

Fix Focus Areas

  • src/node/hooks/express/openapi.ts[615-630]

Suggested change

  • Branch on req.headers['content-type'] (or req.is('application/json')) similar to RestAPI.ts:
  • If no content-type or it starts with application/json, use formData = req.body even if empty.
  • Otherwise, use formidable.
  • This ensures empty JSON bodies do not trigger formidable and prevents the stream-hang timeout.

[reliability] Method lowercasing can throw
Method lowercasing can throw `c.request.method.toLowerCase()` will throw a TypeError if `c.request.method` is missing or not a string, turning requests into 500s. Previously the strict equality check would not crash in that scenario.

Issue description

c.request.method.toLowerCase() can throw if method is undefined/null/non-string.

Issue Context

The handler signature uses c: any, so runtime type safety matters.

Fix Focus Areas

  • src/node/hooks/express/openapi.ts[615-616]

Suggested change

  • Replace with a guarded normalization:
  • const method = (c.request.method ?? '').toString().toLowerCase();
  • or if ((c.request.method ?? '').toLowerCase() === 'post') { ... }
  • Keep the case-insensitive behavior without risking a throw.


                     PR 7454 (2026-04-04)                    
[correctness] Overwrites global window._
Overwrites global window._ `src/static/js/vendors/html10n.ts` now unconditionally assigns `window._ = html10n.get`, overwriting any existing global `_` (commonly underscore/lodash). This is a breaking global side effect that can cause runtime failures in plugins/tests/inline scripts that rely on `window._` being underscore rather than a gettext-style function.

Issue description

The PR now always overwrites window._, which can break any code expecting underscore/lodash on window._.

Issue Context

Etherpad has historically shipped underscore and some environments/tests explicitly load it as a global _. At the same time, Etherpad docs recommend window._('pad.chat') for localization in plugins.

Fix Focus Areas

  • src/static/js/vendors/html10n.ts[995-1001]
  • src/node/utils/Minify.ts[39-47]
  • doc/localization.md[38-63]

Suggested fix options

  1. Back-compat shim:
  • If window._ is already set and is not html10n.get, store it somewhere stable first (e.g. window.__underscore = window._) before overwriting.
  • Then set window._ = html10n.get.
  1. Safer assignment policy:
  • Only overwrite window._ if it appears to be underscore/lodash AND you preserve it to another global.
  • Or introduce a dedicated alias (e.g. window.gettext = html10n.get) and update docs + provide deprecation period. Include a short migration note for plugin authors if behavior changes.

[reliability] Language selection still races
Language selection still races Even with the getParams() de-dupe, the UI language can still end up nondeterministic because `html10n.localize()` is called asynchronously from both `src/static/js/l10n.ts` (on `indexed`) and `src/static/js/pad.ts` (lang callback), and `html10n.localize()` has no cancellation/serialization. Whichever async localization finishes last wins, so slow networks can still cause the wrong final language (cookie/browser vs URL/server).

Issue description

Multiple async calls to html10n.localize() can be in flight simultaneously (from l10n.ts and from the pad lang setting). Because html10n applies translations when each request completes, the final language is whichever request finishes last.

Issue Context

The PR reduces one race (server vs URL within getParams), but the broader concurrency issue remains.

Fix Focus Areas

  • src/static/js/l10n.ts[4-16]
  • src/static/js/pad.ts[141-173]
  • src/static/js/vendors/html10n.ts[474-490]

Suggested fix

Implement a last-request-wins guard in html10n:

  • Add an incrementing request token/sequence (e.g. this._localizeSeq++).
  • Capture the seq in localize() and in the build() callback only apply this.translations = ... if the seq matches the latest. Alternative (less invasive):
  • Avoid the second localize call by incorporating URL lang into the initial l10n.ts language priority list, and have pad.ts only update the cookie when lang is provided.


                     PR 7451 (2026-04-04)                    
[correctness] Hint not announced
Hint not announced The new sr-only keyboard hint is connected to #editorcontainer, but focus is moved into the nested editor iframe’s body, so screen readers won’t announce the hint when entering the editor. The hint text will instead appear as standalone screen-reader content in the page reading order, not as contextual help for the editor.

Issue description

The keyboard hint is currently attached to #editorcontainer, but the focused element for editing is inside nested iframes. As a result, screen readers will not announce the hint when the user enters the editor.

Issue Context

The editor iframes are created dynamically in Ace2Editor.init() and appended into #editorcontainer, and focus is moved to the inner iframe body.

Fix Focus Areas

  • src/templates/pad.html[87-88]
  • src/static/js/ace.ts[185-200]

Suggested fix

Move the accessible name/description from #editorcontainer to the actual focusable element the user tabs into (the outer editor iframe). Concretely:

  1. Remove aria-label/aria-describedby from #editorcontainer.
  2. In Ace2Editor.init(), after creating/appending outerFrame, set:
  • outerFrame.setAttribute('aria-label', 'Document editor') (or better: reuse existing title semantics)
  • outerFrame.setAttribute('aria-describedby', 'editor-keyboard-hint') This keeps the hint element in the same document as the iframe attributes and makes the hint announce when the iframe receives focus.

[correctness] Readonly state not conveyed
Readonly state not conveyed The editor body is always given role="textbox" and aria-multiline="true", but readonly mode only flips contentEditable without setting aria-readonly. Screen readers can announce an editable textbox even when the pad is read-only, misrepresenting the editor state.

Issue description

role="textbox" is added to the editor body, but readonly pads do not set aria-readonly, causing ARIA semantics to disagree with actual editability.

Issue Context

Readonly mode calls ace_setEditable(!clientVars.readonly), which toggles contentEditable.

Fix Focus Areas

  • src/static/js/ace.ts[284-290]
  • src/static/js/ace2_inner.ts[464-468]
  • src/static/js/pad.ts[524-537]

Suggested fix

Update setEditable() in ace2_inner.ts to also set aria-readonly on targetBody:

  • When isEditable is false: targetBody.setAttribute('aria-readonly', 'true')
  • When isEditable is true: remove it or set to 'false' This keeps screen reader announcements aligned with actual editor permissions.


                     PR 7450 (2026-04-04)                    
[correctness] Unescaped prefix in RegExp
Unescaped prefix in RegExp src/static/js/l10n.ts interpolates window.clientVars.cookiePrefix directly into a RegExp constructor, so a configured prefix containing regex metacharacters can throw at runtime or match the wrong cookie, breaking localization selection.

Issue description

src/static/js/l10n.ts constructs a RegExp using the configured cookie prefix without escaping. If the prefix contains any regex metacharacters (for example ., +, [, \), new RegExp(...) can throw or match incorrectly, breaking language detection.

Issue Context

The prefix is a user setting (cookie.prefix) and is not constrained to regex-safe characters.

Fix Focus Areas

  • src/static/js/l10n.ts[6-8]

Suggested fix

  • Escape the prefix before inserting it into the regex, e.g.:
  • const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  • new RegExp(${esc(cp)}language=((\w{2,3})(-\w+)?))
  • Keep the unprefixed fallback match as-is for migration.

[reliability] `cookie.prefix` enabled by default
`cookie.prefix` enabled by default The new cookie-prefixing behavior is enabled by default via `prefix: 'ep_'`, which changes cookie names even if an admin does not opt in. This violates the requirement that new features be behind a feature flag and disabled by default.

Issue description

Cookie name prefixing is enabled by default (prefix: 'ep_'), which changes behavior without an explicit opt-in.

Issue Context

Compliance requires new features to be behind a feature flag and disabled by default.

Fix Focus Areas

  • src/node/utils/Settings.ts[532-535]
  • settings.json.template[385-395]

[reliability] `express_sid` cookie rename breaks
`express_sid` cookie rename breaks The express-session cookie name is changed to `${settings.cookie.prefix}express_sid` with no migration fallback, which will invalidate existing login sessions on upgrade. This is a backward-compatibility break introduced by a config/default change without a safe migration path.

Issue description

Changing the express-session cookie name to a prefixed value invalidates existing sessions because the old express_sid cookie will no longer be read.

Issue Context

This PR introduces settings.cookie.prefix and applies it to the express-session cookie name. Backward compatibility should be preserved where feasible (or the default must not break existing behavior).

Fix Focus Areas

  • src/node/hooks/express.ts[203-235]
  • src/node/utils/Settings.ts[532-535]
  • doc/cookies.md[5-10]

[correctness] Language cookie not read
Language cookie not read The UI now persists language to `${cookiePrefix}language`, but the l10n bootstrap still only looks for an unprefixed `language` cookie, so language selection will not persist across reloads when `cookie.prefix` is set. Additionally, `padBootstrap.js` loads l10n before `window.clientVars.cookiePrefix` is set, preventing l10n from using the configured prefix at initialization time.

Issue description

Language is now stored in a prefixed cookie (e.g., ep_language), but the l10n bootstrap reads only the unprefixed language cookie, so language preference will not persist across reloads.

Issue Context

  • padBootstrap.js currently require()s the l10n module before setting window.clientVars.cookiePrefix, so l10n cannot depend on clientVars unless the bootstrap order is changed.

Fix Focus Areas

  • src/templates/padBootstrap.js[4-12]
  • src/static/js/l10n.ts[4-11]
  • src/static/js/pad.ts[142-149]

Expected fix

  1. Ensure window.clientVars.cookiePrefix is set before loading l10n on pad pages (swap the order in padBootstrap.js).
  2. Update src/static/js/l10n.ts to read ${cookiePrefix}language with fallback to language (for migration/back-compat).

[correctness] Token transfer ignores prefix
Token transfer ignores prefix `welcome.ts` posts `token` and `prefsHttp` from unprefixed cookies, but the token transfer endpoint now sets only prefixed cookies (`${prefix}token`, `${prefix}prefsHttp`), so token transfer breaks when only prefixed cookies exist. This will fail on fresh installs (default `ep_`) or once unprefixed cookies are cleared.

Issue description

The welcome/tokenTransfer client code reads unprefixed cookies (token, prefsHttp) but the server and other client code now use prefixed cookie names, causing token transfer to fail when only prefixed cookies exist.

Issue Context

  • Index page bootstrap (indexBootstrap.js) does not currently expose cookiePrefix, unlike pad/timeslider bootstraps.

Fix Focus Areas

  • src/static/js/welcome.ts[22-31]
  • src/templates/indexBootstrap.js[1-7]
  • src/node/hooks/express/tokenTransfer.ts[15-46]

Expected fix

  1. Expose cookiePrefix to the welcome/index page scripts (similar to padBootstrap.js / timeSliderBootstrap.js).
  2. Update welcome.ts to read ${cookiePrefix}token / ${cookiePrefix}prefsHttp with fallback to unprefixed names for migration.
  3. (Optional) Consider accepting both prefixed and unprefixed values server-side if the body is missing fields, to ease migration.

[correctness] Timeslider drops sessionID
Timeslider drops sessionID `timeslider.ts` now uses `cookiePrefix` for the token cookie but still sends `sessionID: Cookies.get('sessionID')` unprefixed in its socket messages, so HTTP API sessions stop working when `cookie.prefix` is non-empty. This can deny access to private/group pads when `settings.requireSession` is enabled because the server never receives the session cookie value.

Issue description

Timeslider socket messages still send sessionID from the unprefixed cookie name, which breaks HTTP API session authorization when cookie.prefix is non-empty.

Issue Context

  • SecurityManager.checkAccess() depends on the session cookie to authorize when settings.requireSession is enabled (and for certain group/private pad flows).

Fix Focus Areas

  • src/static/js/timeslider.ts[49-56]
  • src/static/js/timeslider.ts[97-107]

Expected fix

  • Compute cp = window.clientVars?.cookiePrefix || '' in a scope accessible to sendSocketMsg.
  • Send sessionID: Cookies.get(${cp}sessionID) || Cookies.get('sessionID') (matching the pad page behavior).

[correctness] Vite prefix hardcoded empty
Vite prefix hardcoded empty src/templates/padViteBootstrap.js hardcodes cookiePrefix to "" and overwrites window.clientVars, so Vite-based pad loads (ui/pad.html) cannot honor cookie.prefix and will continue to read/write unprefixed token/sessionID cookies during CLIENT_READY.

Issue description

The Vite bootstrap sets window.clientVars.cookiePrefix to "" unconditionally, which prevents the Vite pad flow from using the configured cookie prefix.

Issue Context

  • ui/pad.html imports padViteBootstrap.js directly.
  • pad.ts reads window.clientVars.cookiePrefix before sending CLIENT_READY to decide which cookie names to use.

Fix Focus Areas

  • src/templates/padViteBootstrap.js[4-9]
  • ui/pad.html[681-685]

Suggested fix

  • In padViteBootstrap.js, do not overwrite an existing window.clientVars.cookiePrefix. For example:
  • Initialize with merge/optional assignment:
  • window.clientVars = window.clientVars || {};
  • window.clientVars.randomVersionString ||= '...';
  • window.clientVars.cookiePrefix ||= '';
  • Optionally (to make this actually configurable in the Vite UI), set window.clientVars.cookiePrefix in ui/pad.html before the module import (e.g., from a build-time env substitution or a small inline script), so developers can test cookie.prefix with the Vite UI.

[reliability] Prefs cookie loses migration
Prefs cookie loses migration pad_cookie.ts now unconditionally switches to prefixed prefs/prefsHttp cookie names, so existing user prefs cookies will be ignored when enabling `cookie.prefix`. This will reset user preferences (theme/view options/etc.) after prefix is turned on.

Issue description

When cookie.prefix is enabled, existing prefs/prefsHttp cookies are no longer read because pad_cookie now uses only the prefixed cookie name.

Issue Context

Other cookies (token/sessionID) implement a prefixed-or-unprefixed fallback to support migration when enabling a prefix.

Fix Focus Areas

  • src/static/js/pad_cookie.ts[22-57]

Suggested fix

In pad_cookie initialization:

  • If cookiePrefix is non-empty and the prefixed prefs cookie is missing, try reading the legacy unprefixed cookie.
  • If legacy prefs exist, write them to the prefixed cookie (optionally deleting the old cookie), then proceed using the prefixed name.

[maintainability] `doc/cookies.md` missing cookie prefix
`doc/cookies.md` missing cookie prefix The cookie documentation still lists unprefixed cookie names (for example, `express_sid`, `token`, `language`, `sessionID`) and does not mention `cookie.prefix` or how it affects cookie names. This is a documentation gap for a configuration-facing behavior change.

Issue description

doc/cookies.md does not mention cookie.prefix and lists cookie names without considering the prefix behavior.

Issue Context

The PR adds a new configuration setting that changes externally visible cookie names; docs should explain the setting, defaults, and how names change.

Fix Focus Areas

  • doc/cookies.md[1-18]
  • settings.json.template[385-395]
  • src/node/utils/Settings.ts[532-538]

[maintainability] cookie.prefix not validated
cookie.prefix not validated `cookie.prefix` is concatenated into cookie names across server and client without validation, so an invalid prefix can generate cookie names that break cookie parsing/setting and lead to hard-to-debug auth/session issues. This is especially risky because the feature is explicitly user-configurable.

Issue description

An invalid cookie.prefix value can silently break cookie handling because it is concatenated directly into cookie names.

Issue Context

  • reloadSettings() already validates certain settings (e.g., skinName, socketTransportProtocols) but does not validate cookie-related naming.

Fix Focus Areas

  • src/node/utils/Settings.ts[529-538]
  • src/node/utils/Settings.ts[956-1104]

Expected fix

  • Add validation for settings.cookie.prefix during settings reload (e.g., restrict to a safe subset such as [A-Za-z0-9_]* or RFC cookie-name token characters).
  • If invalid, log an error/warn and fall back to a safe default (or reject startup with a clear error).


                     PR 7449 (2026-04-04)                    
[correctness] Batch renumber misses lists
Batch renumber misses lists doIndentOutdent() and doInsertList() now suppress per-line renumbering and only call renumberList() once from firstLine, so additional ordered-list segments within the selection can keep stale `start` attributes. This can produce incorrect ordered-list numbering in the editor and in HTML exports for multi-list selections.

Issue description

doIndentOutdent() and doInsertList() batch setLineListType() changes and then call renumberList() only once using firstLine. This can miss renumbering when the selection spans multiple separate ordered lists (or when the first selected line is outside the impacted ordered list), leaving stale start attributes.

Issue Context

  • renumberList(lineNum) only renumbers the contiguous list that contains lineNum and returns null for non-list lines and for indent lists.
  • Selection-based operations can include multiple disjoint list segments.

Fix Focus Areas

  • src/static/js/ace2_inner.ts[2407-2446]
  • src/static/js/ace2_inner.ts[3378-3437]

Implementation notes

After applying the batched mods, renumber each distinct ordered-list segment that might have been affected (not just the one near firstLine). For example:

  • Scan from firstLine to lastLine (and possibly ±1 line for boundary effects) to find lines whose list attribute is numberN and that start a new contiguous list segment (previous line not number* at same/any level, or previous is null/indent).
  • Call renumberList() once per discovered segment start.
  • Deduplicate calls (e.g., track segment starts) to keep complexity O(selection).

[correctness] Indent style breaks getHTML test
Indent style breaks getHTML test ExportHtml now emits ``, but the backend API import/export golden test still expects `` exactly for indented lists. This will cause the `indentedListsAreNotBullets` getHTML test to fail due to strict string equality.

Issue description

The getHTML output for indent lists now includes style="list-style-type: none;", but an existing golden-string API test still expects the old markup. This will break backend CI.

Issue Context

The API import/export tests compare res.body.data.html to wantHTML with strict equality.

Fix Focus Areas

  • src/tests/backend/specs/api/importexport.ts[66-71]
  • src/tests/backend/specs/api/importexport.ts[261-267]

Implementation notes

Update the indentedListsAreNotBullets wantHTML string to match the new ExportHtml output, e.g.:

  • from: <ul class="indent">...
  • to: <ul class="indent" style="list-style-type: none;">... Also consider grepping for other hard-coded occurrences of <ul class="indent"> expectations that are generated via getHTML()/ExportHtml.

[maintainability] `indent` export test ineffective
`indent` export test ineffective The new `indent lines export without bullet markers` test does not actually create any `indent` list lines (it only adds attribs to the pool) and it conditionally asserts only if `class="indent"` appears, so it can pass even if the regression returns. This violates the requirement that bug fixes include a regression test that would fail without the fix.

Issue description

The indent lines export without bullet markers test is not a true regression test because it does not reliably generate indent-type lines and it conditionally skips its assertion.

Issue Context

The test currently calls pad.setText(...) and only adds ['list','indent1'] via pool.putAttrib(...), which does not apply the attribute to any line. It then only asserts if class="indent" appears in exported HTML.

Fix Focus Areas

  • src/tests/backend/specs/export_list.ts[15-42]


                     PR 7448 (2026-04-04)                    
[maintainability] Cleanup missing regression test
Cleanup missing regression test This PR adds new session cleanup behavior (`_cleanup()` / `startCleanup()`), but the diff does not include any automated regression test to prove the bug stays fixed. Without a regression test, the cleanup logic can be unintentionally reverted or broken without detection.

Issue description

The PR introduces new session cleanup logic but does not include an automated regression test that would fail without the fix and pass with it.

Issue Context

This change is intended to prevent unbounded growth of sessionstorage:* records (issue #5010). A regression test should validate that expired sessions and “empty cookie-only” non-expiring sessions are removed by the new cleanup mechanism.

Fix Focus Areas

  • src/node/db/SessionStore.ts[45-102]
  • src/tests/backend/specs/SessionStore.ts[1-220]

[performance] Cleanup overlaps and overloads DB
Cleanup overlaps and overloads DB _cleanup() calls DB.findKeys('sessionstorage:*') to load all session keys into memory and then performs per-key DB.get()/DB.remove() operations; on large datasets this can take longer than CLEANUP_INTERVAL_MS, causing overlapping cleanups because startCleanup() uses setInterval(). This can lead to sustained DB load, duplicated work, and potential memory exhaustion when the key list is very large.

Issue description

The periodic cleanup can overload the DB and the Node process:

  1. It loads all sessionstorage:* keys into memory.
  2. It can run overlapping executions because it is scheduled via setInterval() with no mutual exclusion.

Issue Context

On instances with millions of session rows, a full scan plus per-key get/remove can exceed the 1-hour interval, causing multiple concurrent cleanups and compounding DB load.

Fix Focus Areas

  • Add a guard to prevent concurrent _cleanup() runs (e.g., this._cleanupRunning or storing the in-flight promise).
  • Prefer setTimeout re-scheduling after completion instead of setInterval to avoid overlap by construction.
  • Reduce peak memory/DB pressure by batching:
  • If findKeys cannot paginate, consider processing and removing keys in chunks with a concurrency limit, and/or limiting the number of keys processed per run to bound runtime.
  • src/node/db/SessionStore.ts[45-52]
  • src/node/db/SessionStore.ts[67-98]

[reliability] Startup cleanup timeout leaks
Startup cleanup timeout leaks SessionStore.startCleanup() schedules a one-off setTimeout() but does not store/clear it in shutdown(), so it can still fire after closeServer() has shut down the store (and after restartServer() has created a new one). This can trigger extra concurrent cleanups and log noise, and the startup timeout is also not unref()'d so it can delay process exit for up to 5 seconds.

Issue description

startCleanup() schedules a one-shot startup setTimeout() but does not store its timer ID, so shutdown() cannot cancel it. This allows _cleanup() to run after shutdown/restart and creates avoidable concurrent cleanup work and log noise.

Issue Context

  • closeServer() calls sessionStore.shutdown() on restart/shutdown.
  • shutdown() currently only clears the interval, not the startup timeout.

Fix Focus Areas

  • Add a this._cleanupTimeout field and assign the result of setTimeout().
  • In shutdown(), clearTimeout(this._cleanupTimeout) and set it to null.
  • Consider calling .unref() on the startup timeout handle as well.
  • src/node/db/SessionStore.ts[45-61]

[maintainability] Misleading stale-age documentation
Misleading stale-age documentation The comment/docstring claim stale sessions are determined by “haven't been touched in STALE_SESSION_MAX_AGE_MS”, but _cleanup() never uses STALE_SESSION_MAX_AGE_MS or any age/touch time when deciding to remove non-expiring sessions. This mismatch is misleading for maintenance and suggests incorrect behavior to future readers.

Issue description

The code comments/docstring describe age-based stale cleanup using STALE_SESSION_MAX_AGE_MS, but the implementation does not use that constant or any age/touch timestamp.

Issue Context

This is primarily a maintainability/documentation correctness issue that can mislead future changes.

Fix Focus Areas

  • Either remove STALE_SESSION_MAX_AGE_MS and update docstrings/comments to describe the actual behavior (cookie-only sessions), OR implement the intended age-based logic if a reliable timestamp can be derived/stored.
  • src/node/db/SessionStore.ts[12-16]
  • src/node/db/SessionStore.ts[63-66]
  • src/node/db/SessionStore.ts[86-97]


                     PR 7447 (2026-04-04)                    
[correctness] Indent precondition unverified
Indent precondition unverified The new regression test presses Tab/Shift+Tab but never asserts that the line actually became an indented sub-bullet, so the test can pass even if indentation keystrokes are ignored and thus not exercise the reported bug scenario. This reduces the test’s effectiveness at preventing regressions for #5718.

Issue description

The regression test for #5718 does not verify that the Tab keypress actually created an indented sub-bullet, so the test may pass without reproducing the failing precondition.

Issue Context

Other list tests in this suite validate indentation by asserting presence of .list-bullet2 after Tab.

Fix Focus Areas

  • src/tests/frontend-new/specs/ordered_list.spec.ts[106-113]
  • src/tests/frontend-new/specs/unordered_list.spec.ts[77-98]

Suggested change

After the Tab + text entry (and/or after the subsequent Enter), add an assertion such as:

  • await expect(padBody.locator('div').nth(1).locator('.list-bullet2')).toHaveCount(1); (If the actual DOM uses list-indent2 for this scenario, use a combined selector like .list-bullet2, .list-indent2.)

[reliability] Hard-coded sleep in test
Hard-coded sleep in test The new regression test adds `waitForTimeout(500)`, which slows the suite and can still be flaky on slower CI nodes; the subsequent `toHaveAttribute(..., {timeout: 5000})` expectations already provide waiting. This sleep is therefore likely redundant and should be removed or replaced with a condition-based wait.

Issue description

The test uses a fixed waitForTimeout(500) before assertions that already auto-wait, adding unnecessary latency and potential flakiness.

Issue Context

expect(locator).toHaveAttribute(..., {timeout: 5000}) already retries until the attribute matches or times out.

Fix Focus Areas

  • src/tests/frontend-new/specs/ordered_list.spec.ts[123-134]

Suggested change

Remove the await page.waitForTimeout(500); line and rely on the existing toHaveAttribute assertions. (If an explicit wait is still needed, replace with a condition-based wait, e.g., wait for the first OL to have start='1' rather than sleeping a fixed duration.)



                     PR 7445 (2026-04-04)                    
[reliability] Unasserted pad cleanup
Unasserted pad cleanup The new createDiffHTML API spec deletes the test pad in an `after()` hook without asserting the HTTP status or response body, so a failing `deletePad` call can silently pass and leave test data behind. This reduces test suite reliability and can mask regressions in the deletePad endpoint.

Issue description

The after() cleanup in createDiffHTML.ts does not assert the deletePad response, so cleanup failures can be missed.

Issue Context

Other backend API tests validate deletePad by asserting HTTP 200, JSON content-type, and res.body.code === 0.

Fix Focus Areas

  • src/tests/backend/specs/api/createDiffHTML.ts[68-71]

Suggested change

Capture the response from the delete request and add expectations similar to other tests:

  • .expect(200)
  • .expect('Content-Type', /json/)
  • assert.equal(res.body.code, 0)

[reliability] Flaky pad ID generation
Flaky pad ID generation The new test uses `Date.now()` alone for `testPadId`, which can collide in parallel or near-simultaneous test runs and cause nondeterministic failures. Other backend API specs use a random ID helper to avoid this collision risk.

Issue description

const testPadId = createDiffHTML_${Date.now()}; can collide if tests execute in parallel or two processes start within the same millisecond.

Issue Context

Multiple existing backend API specs define and use a small makeid() helper (random alphanumeric) for pad IDs.

Fix Focus Areas

  • src/tests/backend/specs/api/createDiffHTML.ts[8-8]

Suggested change

Replace the Date.now()-only ID with a random helper (either copy the makeid() pattern used in other specs, or combine timestamp + random suffix, e.g. ${Date.now()}_${Math.random().toString(36).slice(2)}) to reduce collision risk.



                     PR 7442 (2026-04-03)                    
[correctness] Wrong install-deps working directory
Wrong install-deps working directory AGENTS.MD tells users to run `sudo npx playwright install-deps` without `cd src`, but Playwright is a devDependency of the `src` workspace (ep_etherpad-lite) rather than the repo root. Running it from the root can resolve a different Playwright version via `npx`, causing mismatched system/browser dependencies relative to the version actually used by `test-ui`/`test-admin`.

Issue description

AGENTS.MD instructs sudo npx playwright install-deps without changing into the src workspace, where Playwright is actually installed and where the Playwright test scripts run from.

Issue Context

This repo is a workspace/monorepo. @playwright/test is a devDependency in src/package.json, not the root. Running npx playwright from the root may fetch/execute a different Playwright version than the one used by pnpm --filter ep_etherpad-lite run test-ui.

Fix Focus Areas

  • AGENTS.MD[92-99]

[correctness] Wrong plugin tests location
Wrong plugin tests location AGENTS.MD claims plugin backend tests live under `src/node_modules/...`, but the backend `test` script actually runs plugin specs from the workspace root `node_modules` (referenced as `../node_modules/...` from within `src`). Following the guide will send contributors to a non-existent/wrong directory and make it harder to confirm plugin test discovery.

Issue description

AGENTS.MD documents plugin backend tests as living under src/node_modules/..., but the repo’s backend test script runs plugin specs from the workspace root node_modules (../node_modules/... from src). This mismatch misleads contributors trying to locate plugin test files.

Issue Context

The ep_etherpad-lite package lives in src/, and its test script references plugin tests via a path that resolves to <repo-root>/node_modules/ep_*/static/tests/backend/specs/**.

Fix Focus Areas

  • AGENTS.MD[81-85]
  • src/package.json[134-138]

Expected change

Update the AGENTS.MD bullet to point to the correct location (for example: node_modules/ep_*/static/tests/backend/specs/ at repo root, or explicitly explain it’s ../node_modules/... relative to src/).


[correctness] Incorrect Playwright browsers
Incorrect Playwright browsers AGENTS.MD states frontend E2E tests run on chromium, firefox, and webkit by default, but the Playwright config only defines chromium and firefox projects (webkit is commented out). This creates incorrect expectations about browser coverage when running `test-ui`.

Issue description

AGENTS.MD claims Playwright E2E runs on chromium, firefox, and webkit by default, but the Playwright config only includes chromium and firefox.

Issue Context

The projects list in src/playwright.config.ts defines which browsers run by default.

Fix Focus Areas

  • AGENTS.MD[107-110]
  • src/playwright.config.ts[31-44]

Expected change

Either:

  1. Update AGENTS.MD to say it runs on chromium and firefox by default (and mention webkit is disabled), or
  2. Re-enable a webkit project in src/playwright.config.ts if webkit coverage is intended.


                     PR 7438 (2026-04-02)                    
[correctness] Identity skip stalls revision
Identity skip stalls revision broadcast.ts now skips applyChangeset() for identity changesets, but applyChangeset() is also what advances padContents.currentRevision/currentTime and (when following latest) moves the slider; skipping it can leave the timeslider desynced/stuck on an older revision. This can break live-following after an identity revision arrives and can break scrubbing because goToRevision() continues computing paths from a stale padContents.currentRevision.

Issue description

The new identity-changeset guards skip applyChangeset(), but that function also advances padContents.currentRevision/currentTime and (in live-follow mode) moves the slider. For identity revisions, you still need to advance revision/time (and potentially slider) without calling mutateAttributionLines()/mutateTextLines().

Issue Context

  • applyChangeset() is responsible for updating padContents.currentRevision, padContents.currentTime, and (when preventSliderMovement is false) BroadcastSlider.setSliderPosition(revision).
  • The PR added !isIdentity(...) checks at multiple call sites, so identity revisions currently do not advance state.

Fix Focus Areas

  • src/static/js/broadcast.ts[135-196]
  • src/static/js/broadcast.ts[198-207]
  • src/static/js/broadcast.ts[265-315]

Implementation direction (one of)

  1. Move the identity handling into applyChangeset():
  • At the top of applyChangeset(), if isIdentity(changeset) then:
  • If !preventSliderMovement, do the slider movement bookkeeping (goToRevisionIfEnabledCount++ + BroadcastSlider.setSliderPosition(revision)).
  • Update padContents.currentRevision = revision and padContents.currentTime += timeDelta.
  • Call updateTimer() and update authors UI as needed.
  • Return early (do not call the mutators).
  • Then remove (or relax) the !isIdentity(...) guards at the call sites.
  1. Alternatively, add a small helper like advanceRevision(revision, timeDelta, preventSliderMovement) and call it in the isIdentity branches at each call site. Either way, ensure live-following continues across identity revisions and goToRevision() ends with padContents.currentRevision === path.rev even if the composed changeset is identity.

[maintainability] Unused identity import
Unused identity import broadcast.ts imports `identity` from Changeset.ts but never uses it, which is likely to be flagged by linting and fail the repo’s eslint-based lint script. This also increases confusion because the fix only uses `isIdentity()`.

Issue description

identity is imported from ./Changeset in broadcast.ts but never used.

Issue Context

The project runs eslint . via the lint script, and unused imports are typically reported.

Fix Focus Areas

  • src/static/js/broadcast.ts[26-33]

Suggested fix

Remove identity from the import list unless you end up using it as part of the identity-changeset handling refactor.


[maintainability] Identity case not exercised
Identity case not exercised The new regression spec never verifies that an identity changeset scenario is actually reached (nor that playback advanced), so it can pass without executing the `isIdentity()` branch that this PR adds. This reduces the test’s ability to prevent regressions of the identity-changeset crash path.

Issue description

The new Playwright test does not prove that the identity-changeset handling added to timeslider playback is actually exercised. It only checks for absence of error popups after a fixed delay.

Issue Context

The fix in the PR is gated on isIdentity(changeset) (identity is unpack(cs).ops === '' && oldLen === newLen). A regression test should ensure it hits that condition and that playback/scrubbing actually advances.

Fix Focus Areas

  • src/tests/frontend-new/specs/timeslider_identity_changeset.spec.ts[13-52]
  • src/static/js/broadcast.ts[135-203]

Suggested change

  • Adjust the revision setup to reliably create a net-zero span (e.g., rev0 empty → rev1 type "X" → rev2 delete-all back to empty), then jump across that span in timeslider (a jump forces composition and is where identity changesets can appear).
  • Add an assertion that playback/scrubbing progressed (examples: #ui-slider-handle style/position changes, or #timer text changes between two waits).
  • Keep the existing “no gritter errors” assertion as a secondary signal.

[maintainability] Scrub coverage incomplete
Scrub coverage incomplete The “scrub through all revisions” test only navigates to `#0` once and then checks slider visibility, so it does not actually scrub across revisions and won’t catch failures that occur when moving through multiple revisions. This leaves the intended scrubbing regression scenario largely untested.

Issue description

The current scrub test does not scrub across revisions; it only jumps once to #0 and asserts the slider is visible.

Issue Context

The bug being fixed is in the timeslider changeset application path during playback/scrubbing. To validate scrubbing, the test should move across multiple revisions (preferably including a span that can compose to an identity changeset) and assert no errors.

Fix Focus Areas

  • src/tests/frontend-new/specs/timeslider_identity_changeset.spec.ts[54-92]

Suggested change

  • Determine the latest revision number (for example via a page.evaluate reading the slider max if available, or by tracking how many edits you made).
  • Loop through multiple revision targets (e.g., 0 → latest in steps, or every revision) using hash navigation or simulated slider dragging.
  • After each move, wait for a reliable signal of revision application (e.g., #timer change) and assert no .gritter-item.error is present.

[maintainability] Misleading identity-skip comment
Misleading identity-skip comment In goToRevision(), the comment says identity changesets are skipped, but the code still calls applyChangeset() for any truthy changeset string (identity changesets are truthy). This comment/code mismatch is likely to confuse future maintainers and risks an incorrect refactor that reintroduces timeslider desync bugs.

Issue description

A comment in goToRevision() says identity changesets are skipped, but the code still calls applyChangeset() for identity changesets (they are truthy strings). This mismatch can mislead future refactors.

Issue Context

Identity changesets are correctly handled inside applyChangeset() (mutations are skipped, but revision/time state still advances). The call site should reflect that.

Fix Focus Areas

  • src/static/js/broadcast.ts[288-292]


General

Resources

For Developers

How to's

Set up

Advanced steps

Integrating Etherpad in your web app

for Developers

Clone this wiki locally