Skip to content

feat: Backend esm vitest#5

Open
deepshekhardas wants to merge 435 commits into
developfrom
fix/pr-7605-esm-vitest
Open

feat: Backend esm vitest#5
deepshekhardas wants to merge 435 commits into
developfrom
fix/pr-7605-esm-vitest

Conversation

@deepshekhardas

@deepshekhardas deepshekhardas commented May 21, 2026

Copy link
Copy Markdown
Owner

Port of upstream ether#7605

Converts backend tests from mocha to vitest and migrates to ESM.


Summary by cubic

Migrates backend tests to ESM and vitest, then modernizes CI and packaging while shipping major admin, privacy, and editor upgrades. Runs on Node ≥22.12 with pnpm, adds Debian/Snap builds, and publishes a signed APT repo.

  • New Features

    • Admin UI: update banner + /update page, JSONC settings editor, Authors page with search and GDPR erasure, i18n loader fix, plugin disables surfaced, visual refresh.
    • Privacy/GDPR: deletion tokens (with recovery flow), tri‑state IP logging, HttpOnly author token, configurable privacy banner, new Privacy document.
    • Editor: line duplicate/delete shortcuts, preserved non‑breaking spaces, Page Up/Down and undo/redo scrolling fixes, readonly toolbar toggle (showMenuRight), improved author‑color contrast, theme‑color meta, Open Graph/Twitter metadata.
    • Maintenance: pad compaction via API + CLIs (bin/compactPad, bin/compactAllPads, bin/compactStalePads).
    • Accessibility/Intl: server‑rendered html[lang|dir], proper dialog semantics and focus management, accessible toolbar/export controls.
  • CI & Packaging

    • Tests: backend on vitest under ESM; Playwright discovers plugin specs; “with‑plugins” matrix; feature tags + declared‑disables contract; flaky ep_cursortrace excluded from plugin matrix.
    • Tooling: minimum Node ≥22.12 (workflows use 24); pnpm store caching; .npmrc hardlink installs; Dockerfile updated.
    • Packaging: Debian .deb via nfpm with systemd unit; Snap build/publish; signed APT repo at https://etherpad.org/apt; hardened release workflows.
    • Installers: one‑line POSIX/PowerShell installers with end‑to‑end CI.
    • Docs: VitePress build fixed (oxc-minify), build on PRs, content updates.

Written for commit d5732a8. Summary will update on new commits.

Review in cubic

Gared and others added 30 commits April 22, 2026 10:52
Bumps [axios](https://github.com/axios/axios) from 1.15.1 to 1.15.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](axios/axios@v1.15.1...v1.15.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…ith 9 updates (ether#7579)

Bumps the dev-dependencies group with 9 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [eslint](https://github.com/eslint/eslint) | `10.2.0` | `10.2.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.4` | `4.1.5` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.58.2` | `8.59.0` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.58.2` | `8.59.0` |
| [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) | `7.1.0` | `7.1.1` |
| [i18next](https://github.com/i18next/i18next) | `26.0.5` | `26.0.6` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.72.1` | `7.73.1` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.14.1` | `7.14.2` |
| [vite-plugin-static-copy](https://github.com/sapphi-red/vite-plugin-static-copy) | `4.0.1` | `4.1.0` |



Updates `eslint` from 10.2.0 to 10.2.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](eslint/eslint@v10.2.0...v10.2.1)

Updates `vitest` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest)

Updates `@typescript-eslint/eslint-plugin` from 8.58.2 to 8.59.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.58.2 to 8.59.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.0/packages/parser)

Updates `eslint-plugin-react-hooks` from 7.1.0 to 7.1.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/eslint-plugin-react-hooks@7.1.1/packages/eslint-plugin-react-hooks)

Updates `i18next` from 26.0.5 to 26.0.6
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](i18next/i18next@v26.0.5...v26.0.6)

Updates `react-hook-form` from 7.72.1 to 7.73.1
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](react-hook-form/react-hook-form@v7.72.1...v7.73.1)

Updates `react-router-dom` from 7.14.1 to 7.14.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.14.2/packages/react-router-dom)

Updates `vite-plugin-static-copy` from 4.0.1 to 4.1.0
- [Release notes](https://github.com/sapphi-red/vite-plugin-static-copy/releases)
- [Changelog](https://github.com/sapphi-red/vite-plugin-static-copy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sapphi-red/vite-plugin-static-copy/compare/vite-plugin-static-copy@4.0.1...vite-plugin-static-copy@4.1.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: vitest
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.59.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.59.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: eslint-plugin-react-hooks
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: i18next
  dependency-version: 26.0.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.73.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react-router-dom
  dependency-version: 7.14.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: vite-plugin-static-copy
  dependency-version: 4.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [mysql2](https://github.com/sidorares/node-mysql2) from 3.22.1 to 3.22.2.
- [Release notes](https://github.com/sidorares/node-mysql2/releases)
- [Changelog](https://github.com/sidorares/node-mysql2/blob/master/Changelog.md)
- [Commits](sidorares/node-mysql2@v3.22.1...v3.22.2)

---
updated-dependencies:
- dependency-name: mysql2
  dependency-version: 3.22.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…ther#7559)

* feat(packaging): add Debian (.deb) build via nfpm with systemd unit

First-class Debian packaging for Etherpad, producing signed-ready
etherpad-lite_<version>_<arch>.deb artefacts for amd64 and arm64 from a
single nfpm manifest. Installing the package gives users:

- /opt/etherpad-lite with a prebuilt, self-contained node_modules/ — no
  pnpm required at runtime, just `nodejs (>= 20)`.
- etherpad system user/group, created via `adduser` in preinst.
- /etc/etherpad-lite/settings.json seeded from the template on first
  install, preserved across upgrades, removed on `purge`.
- /var/lib/etherpad-lite owned by etherpad:etherpad, with the default
  dirty-DB retargeted there so ProtectSystem=strict works.
- /lib/systemd/system/etherpad-lite.service — hardened unit
  (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp,
  RestrictAddressFamilies) with Restart=on-failure.
- /usr/bin/etherpad-lite CLI wrapper running `node --import tsx/esm`.

CI (.github/workflows/deb-package.yml) triggers on v* tags, builds both
arches via native runners (ubuntu-latest + ubuntu-24.04-arm), smoke-tests
the amd64 package end-to-end (install → systemctl start → curl /health
→ purge → confirm user removed), and attaches the artefacts to the
GitHub Release.

Publishing to an APT repo (Cloudsmith, Launchpad PPA, self-hosted
reprepro) is intentionally out of scope — needs a governance decision on
who holds the signing key. Recipes are documented in packaging/README.md.

Refs ether#7529

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(deb): fail smoke test on /health timeout, tighten default-file perms, 2-space indent

Addresses Qodo review feedback on ether#7559:

1. Smoke test false-positive: the `for` loop polling /health never failed
   the job if the endpoint stayed down — `curl && break || sleep 2`
   keeps returning 0 from the trailing `sleep`, so `set -e` never
   trips. CI could attach a broken .deb to a release. Fix: track
   success explicitly and exit 1 (plus dump journald logs for
   diagnostics) when the service never becomes healthy.

2. /etc/default/etherpad-lite was world-readable (0644). systemd loads
   it via `EnvironmentFile=…`, and Etherpad supports
   ${ENV_VAR}-substitution for secrets (DB_PASSWORD etc.), so any
   local user could read anything admins drop there. Fix: install the
   conffile as root:etherpad 0640 — only root and the service user can
   read it.

3. Indentation: reflow maintainer scripts from 4-space to 2-space to
   match the repo style rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
)

* fix(editor): preserve U+00A0 non-breaking space (ether#3037)

Non-breaking spaces were silently normalized to regular spaces at every
ingestion point, so typed/pasted/imported nbsps never reached the
changeset and users could not glue words against line-wrap in French or
other languages that require nbsp typography.

Removed the four strip sites that replaced U+00A0 with U+0020:
  - src/node/db/Pad.ts cleanText
  - src/static/js/contentcollector.ts textify
  - src/static/js/ace2_inner.ts textify
  - src/static/js/ace2_inner.ts importText raw-text guard

Updated both processSpaces functions (domline and ExportHtml) to tokenize
U+00A0 as a separate unit, emit it verbatim as &nbsp;, and treat it as
content (not whitespace) for the run-collapse bookkeeping so adjacent
regular-space runs aren't miscounted.

Added backend round-trip tests for spliceText and setText, and extended
the cleanText case table. Updated the existing contentcollector and
importexport specs whose expectations encoded the previous buggy
behavior; they now assert genuine nbsp preservation.

Verified manually in Firefox: clipboard U+00A0 → paste → pad → getText
returns c2 a0; getHTML emits `100&nbsp;km`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(contentcollector): collapse display-artifact nbsp runs on DOM read-back

processSpaces is a lossy one-way display transform: leading/trailing
spaces and all-but-the-last of a run get rendered as &nbsp; so HTML
doesn't collapse them. When incorporateUserChanges reads text back from
the DOM, those display-artifact nbsps were being stored in the changeset
model instead of being normalized back to plain spaces.

This broke handleReturnIndentation, whose /^ *(?:)/ regex only matches
ASCII spaces: auto-indent after `foo:\n` produced 4 spaces instead of
the expected prev-indent (2) + THE_TAB (4) = 6, because the previous
line's model had nbsps where it used to have spaces.

Fix: in contentcollector.textify, collapse any [  ]+ run back to
plain spaces UNLESS the run is pure U+00A0 AND strictly interior to
word chars. That preserves user-intended typographic nbsps like
"100 km" while undoing the one-way display transform.

Updated 7 contentcollector tests and 7 importexport tests whose
assertions needed to reflect the new rule (boundary/mixed runs collapse;
pure-interior nbsp runs preserve).

Fixes the Playwright regression in indentation.spec.ts:117 that the
previous commit introduced.

* fix(contentcollector): canonicalize nbsp runs at line assembly, not per text node

Addresses Qodo code review feedback on PR ether#7585.

## Bug fix — nbsp lost at DOM text-node boundary

The previous approach ran the "collapse display-artifact nbsp" rule inside
textify(), which is called per individual DOM TEXT_NODE. A user-intended
nbsp sitting at a text-node boundary (e.g., <span>100</span><span>&nbsp;km
</span>) was incorrectly seen as non-interior (before === '' for the second
text node) and normalized back to a regular space.

Fix: move the canonicalization out of textify() and run it on each
fully assembled line string inside cc.finish(). The rule remains:

    [  ]+ run  ->  plain spaces
                   UNLESS pure U+00A0 AND strictly interior to non-ws chars

It is length-preserving, so attribute offsets and line lengths are
unaffected.

Added a regression test (contentcollector.spec.ts) for the cross-span
case.

## Docs concern

Reverted the type-only addition of spliceText to PadType. spliceText
is an existing Pad runtime method; the backend test now uses a cast
(`(pad as any).spliceText`) so the PR does not expand the declared
public type surface, avoiding a separate documentation requirement.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tization (ether#7587)

PadMessageHandler built the `pluginsSanitized` payload for clientVars by
aliasing `plugins.plugins` and then mutating each entry's `package` field
in place:

    let pluginsSanitized: any = plugins.plugins;
    Object.keys(plugins.plugins).forEach(function(element) {
      const p: any = plugins.plugins[element].package;
      pluginsSanitized[element].package = {name: p.name, version: p.version};
    });

Because `pluginsSanitized` is a reference to `plugins.plugins`, the
assignment clobbered the server-side plugin registry. After the first
pad connection, every plugin's `package` object held only `{name,
version}` — `realPath`, `path`, and `location` were gone.

Minify.ts resolves `/static/plugins/ep_*/...` URLs via
`plugin.package.realPath`. Once the field disappeared, every subsequent
static asset request for a bundled plugin 500'd with:

    TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of
    type string. Received undefined
        at Object.join (node:path:1354:7)
        at _minify (src/node/utils/Minify.ts:181:23)

Symptoms on Chromium: plugin CSS/JS assets fail to load (e.g.
/static/plugins/ep_font_size/static/css/size.css returns 500), so
plugins partially render or don't work at all. Firefox swallows the
resulting console errors quietly.

Fix: extract the sanitization into a pure helper `sanitizePluginsForWire`
that returns a fresh object graph and never touches the input. The
helper is covered by a new backend spec that:
  * verifies the sanitized output has only {name, version} in `package`
  * asserts the input registry's realPath/path/location survive the call
  * runs the call repeatedly and confirms non-destructiveness
  * mutates the returned copy and asserts the input is independent

Verified live with the dev server: before the fix, `/static/plugins/
ep_font_size/static/css/size.css` 500'd after visiting any pad; after
the fix it returns 200 both before and after pad connections.
ether#7586) (ether#7588)

`Pad.normalizePadSettings()` was defaulting `lang` to the literal string
'en' when `rawPadSettings.lang` was not a string. That value flowed into
`clientVars.padOptions.lang` and then into `getParams()` in pad.ts,
which calls `html10n.localize([serverValue, 'en'])` as a callback for
the `lang` setting. The result: every pad forced English on load,
overriding the browser's Accept-Language and the existing auto-detect
chain in l10n.ts (cookie -> navigator.language -> 'en').

The regression was introduced in ether#7545 ("Add creator-owned pad settings
defaults", commit e0ccdb4). 2.6.1 did not have this default, so
auto-detect worked there. 2.7.0 broke it.

Fix: default `lang` to null. The client's existing flow already handles
null correctly — getParams() at pad.ts:172 has
`if (serverValue == null) continue;`, so the forced-localize callback
simply does not fire, and l10n.ts's browser-language auto-detect runs.
Pad-settings dropdown consumer at pad.ts:489 already uses
`padOptions.lang || 'en'` so null renders fine there too.

`PadSettings.lang` is now typed `string | null` to match.

Added three backend regression tests under `normalizePadSettings lang`:
  * defaults to null when lang is absent (so client auto-detects)
  * preserves an explicit string lang (creator override still works)
  * drops non-string lang values to null rather than coercing to 'en'

Manual verification: with Firefox set to German, loading a fresh pad
now renders the UI in German. Index and timeslider continued to work
as before. Setting `?lang=de` or a language cookie continues to
override browser detection, as intended.

Fixes ether#7586
…ether#7584)

* fix(a11y): negotiate lang/dir per request and set on <html>

Server-renders the html element with `lang` and `dir` matching the
client's Accept-Language header (negotiated against availableLangs from
i18n hooks). Falls back to `en`/`ltr` if no match.

This gives screen readers a correct document language during the brief
window before client-side html10n refines it (l10n.ts already sets both
attributes after locale data loads).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): dialog semantics on popups; fix aria-role typo on userlist

Adds role=dialog, aria-modal=true, and either aria-labelledby (when an
h1 is present) or aria-label (for popups without an h1) to:

  - #settings, #import_export, #embed, #skin-variants (labelledby)
  - #connectivity, #users, #mycolorpicker (aria-label)

Fixes the invalid aria-role="document" attribute on #otherusers; it's
now role=region with aria-live=polite so screen readers announce
collaborator joins/leaves.

Container aria-label values are English-only for now — Etherpad's
html10n implementation only supports localizing specific attributes
(title, alt, placeholder, etc), not aria-label on container nodes.
Localization can follow once html10n grows that affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): focus management and Escape-to-close for popups

Three additions to toggleDropDown / _bodyKeyEvent:

  - Remember the trigger element (document.activeElement) when opening
    a popup, so we can restore focus when it closes.
  - On open, focus the first focusable element inside the popup so
    keyboard users land inside the dialog instead of staying on the
    trigger button.
  - Escape pressed while focus is inside a popup closes it, then the
    restore-focus path runs and the trigger button is refocused.

Replaces the previous behavior where Escape from inside a popup did
nothing; users had to click outside to dismiss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): make chaticon and chat header controls real buttons

- #chaticon: <div onclick> → <button type=button> with aria-label
- #titlecross / #titlesticky: <a onClick> → <button type=button>
  with aria-label (Close chat / Pin chat to screen)
- Decorative chat-bubble glyph gets aria-hidden=true so it isn't
  read alongside the button label
- #chatcounter labelled "Unread messages"
- Inline onclick attributes moved to chat.init() handlers
- CSS reset on the new buttons (transparent bg, no border, inherit
  font/color) so they match the prior visual design
- :focus-visible outlines for keyboard users

Existing test selectors (#chaticon, #titlecross, #titlesticky) are
unchanged and continue to work — they never relied on element type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): accessible names for icon-only toolbar/export controls

- Export links (#exportetherpada, #exporthtmla, #exportplaina,
  #exportworda, #exportpdfa, #exportopena): added aria-label so the
  link is announced as e.g. "Export as PDF". The inner icon span
  gets aria-hidden=true so screen readers don't read both the icon
  text and the link label.

- Show-more toolbar toggle (.show-more-icon-btn): converted from
  <span> to <button type=button> with aria-label and aria-expanded.
  The click handler now toggles aria-expanded alongside the
  full-icons class so assistive tech reflects the open/closed state.

- Theme switcher knob: aria-label changed from "theme-switcher-knob"
  (a class-style identifier, not human text) to "Toggle theme".

Aria-label values are English-only for now. Etherpad's html10n
implementation only localizes a fixed attribute list (title, alt,
placeholder, value, innerHTML, textContent); aria-label is not
included, so a clean l10n path requires a follow-up to either
extend html10n or set aria-label client-side after locale loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(a11y): cover dialog semantics, html lang, icon button labels

New Playwright spec verifies the a11y guarantees added by this branch:

  - <html> has a non-empty lang attribute
  - settings/import_export/embed/users popups expose role=dialog,
    aria-modal=true, and either aria-labelledby (when an h1 exists)
    or aria-label (when none does)
  - Escape from inside the settings popup closes it AND restores
    focus to the trigger button
  - Export links each carry a descriptive aria-label
  - #chaticon is a real <button> with aria-label
  - #titlecross / #titlesticky are real <button>s with aria-label
  - #otherusers uses role=region + aria-live=polite + aria-label
    (and the previous aria-role typo is gone)
  - .show-more-icon-btn is a <button> with aria-label and
    aria-expanded

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): address Qodo review feedback from PR ether#7584

1. Users Escape close broken - toggleDropDown('none') intentionally
   skips the users module so switching between other popups doesn't
   hide the user list. That meant Escape couldn't dismiss the Users
   popup either. The Escape branch now checks for #users as the
   focused popup and closes it explicitly (respecting stickyUsers)
   before falling through to the normal close-all path.

2. Embed focus overridden - the rAF auto-focus in toggleDropDown
   grabbed the first focusable descendant, which stole focus from
   command handlers that target a specific control (notably the Embed
   command's #linkinput). rAF now bails out if focus is already
   inside the newly-opened popup.

3. Button click blurs :focus before toggleDropDown captures trigger -
   discovered while investigating the Firefox Playwright failure for
   "settings popup Escape restores focus". Button.bind() calls
   $(':focus').trigger('blur') before invoking the callback, so by
   the time toggleDropDown() captured document.activeElement as the
   restore target it was already <body>. The click handler now
   stashes padeditbar._lastTrigger to the clicked <button> before
   blur runs; toggleDropDown only falls back to activeElement when
   the pre-stash didn't happen (keyboard shortcut path).

4. html10n overwrites aria-label - html10n unconditionally set
   aria-label to the translated string, clobbering explicit aria-label
   on elements that also carry data-l10n-id. setAttribute now only
   fires when the element has no aria-label; explicit author labels
   win, unlabelled translated elements still get a name.

5. Button visual reset - the show-more-icon-btn and #chaticon
   conversions inherited UA default button border/background/padding,
   shifting icon glyphs visibly off-centre. Added appearance /
   background / border / padding resets.

6. Export links test assumes soffice is installed - #exportworda,
   #exportpdfa, #exportopena are removed client-side by pad_impexp.ts
   when clientVars.exportAvailable === 'no'. The test now skips links
   absent at runtime.

Verified locally: all 10 a11y_dialogs specs pass on both Chromium and
Firefox; backend suite remains 799/799 passing; ts-check clean.

* fix(a11y): close popups with no focusable content; unbreak chat-icon layout

Round 2 of ether#7584 review follow-ups.

1. Users popup Escape still didn't close the dialog (user-confirmed).
   Root cause: _bodyKeyEvent is bound to the OUTER document's body.
   When #users opens, the command handler tries to focus
   #myusernameedit but that input is `disabled`, so focus stays in the
   ace editor iframe. Keydown from inside the iframe does not bubble
   to the outer document, so Esc never reaches _bodyKeyEvent.
   Fix: in the open-popup rAF, if no command handler placed focus
   inside the dialog, focus the popup div itself (with tabindex=-1).
   That keeps subsequent keydown events on the outer document so
   Esc can dismiss the popup. Also broadened the Esc branch to fire
   whenever any popup is `.popup-show`, regardless of where :focus
   lives — some popups legitimately have no focusable content at
   open.
   Added a regression test that opens #users and asserts Esc closes
   it. Passes on both Chromium and Firefox.

2. Chat icon (#chaticon) visual still wrong after the first CSS fix.
   - My previous `border: 0` reset was overriding the intended
     `border: 1px solid #ccc; border-bottom: none` from the earlier
     rule. Removed `border: 0`; the earlier explicit border suffices
     to suppress UA defaults.
   - The `<span class="buttonicon">` inside `#chaticon` was picking
     up the global `.buttonicon { display: flex; }` rule meant for
     toolbar button instances, which broke the inline layout of the
     label + glyph + counter row. Added a scoped
     `#chaticon .buttonicon { display: inline; }` override.

All 11 a11y_dialogs specs pass on Chromium and Firefox. Backend
suite and ts-check remain clean.

* fix(a11y): only stash _lastTrigger for dropdown-opening buttons

Round 3 follow-up. The previous Button.bind() change stashed every
clicked toolbar button as padeditbar._lastTrigger before blurring :focus.
That was necessary for popup-opening buttons (settings, import_export,
etc.) so Escape could return focus to them — but it also fired for
non-popup toolbar buttons (list toggles, bold/italic, indent/outdent,
clearauthorship). For those, the stash held a stale reference that
interfered with subsequent editor interactions and regressed Playwright
tests: ordered_list, unordered_list, undo_clear_authorship.

Fix: only stash when the clicked command is a registered dropdown
(settings, import_export, embed, showusers, savedrevision,
connectivity). Other commands return focus to the ace editor as before
and leave _lastTrigger alone.

Verified locally on Chromium:
  - ordered_list.spec.ts: 6/6 pass (was 4/6)
  - unordered_list.spec.ts: 6/6 pass (was 4/6)
  - undo_clear_authorship.spec.ts: 2/2 pass (was 0/2)
  - a11y_dialogs.spec.ts: 11/11 pass (unchanged)

* fix(a11y): address Qodo review round 4 for PR ether#7584

#1 Stale aria-label after relocalize
  html10n.translateNode() refused to overwrite any existing aria-label,
  which also skipped updates on language change (pad.applyLanguage()
  re-runs localize). Use a `data-l10n-aria-label="true"` marker: set
  aria-label + marker when html10n populates it, overwrite only if the
  marker is present. Explicit template-supplied aria-labels stay as-is;
  html10n-generated ones refresh on relocalize.

#2 Escape won't close colorpicker
  _bodyKeyEvent caught Escape on any `.popup.popup-show` but only
  closed dropdown popups via toggleDropDown('none'). Popups opened
  outside the editbar framework (#mycolorpicker, toggled directly by
  pad_userlist.ts) stayed open while preventDefault() swallowed the
  key. Now the Escape branch manually closes any popup that
  toggleDropDown('none') cannot reach (non-dropdown ids, plus #users
  unless pinned) and leaves registered dropdowns for toggleDropDown to
  close so its focus-restore sees the transition.

#3 Stale focus restoration
  toggleDropDown('none') restored focus to _lastTrigger even when no
  popup was open on entry, which meant background callers
  (connectivity setup, periodic state handling) could yank focus out
  of the editor to a stale toolbar button. Gated the restore on
  `wasAnyOpen === true` so it only fires when there was a popup to
  close.

ether#11 English aria-label overrides i18n (export links, chat icon)
  Removed the hard-coded English aria-label from export anchors and
  removed aria-hidden from their inner localized spans. Screen readers
  now get the localized child text as the accessible name (Etherpad,
  HTML, PDF, etc.), matching the visible UI language.
  Removed the English aria-label from #chaticon and #titlesticky as
  well — both have data-l10n-id, so html10n populates a localized
  aria-label via the marker mechanism in #1. #titlecross keeps its
  static aria-label because it has no data-l10n-id yet.

#4 4-space indent in a11y spec
  Two tests had continuation lines at 4-space indent violating the
  repo's 2-space rule. Folded the signatures onto one line.

Updated a11y_dialogs.spec.ts to assert accessible-name presence rather
than hard-coded English for elements whose names now come from the
localized text. Still asserts static English for #titlecross (not
localized yet).

Verified locally (dev server restarted for each round):
  - a11y_dialogs.spec.ts: 11/11 on Chromium, 11/11 on Firefox
  - ordered_list + unordered_list + undo_clear_authorship: 13/13 on Chromium
  - Full backend suite: 799 passing, 0 failing
  - tsc --noEmit clean in our code

ether#9 Popup behavior documentation: deferred to a follow-up doc PR so
this PR stays focused on the a11y code changes. The new keyboard
behavior (Escape-to-close, focus-restore-to-trigger) is small enough
to summarize in a short doc/ addition.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) from 8.3.2 to 8.4.0.
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](express-rate-limit/express-rate-limit@v8.3.2...v8.4.0)

---
updated-dependencies:
- dependency-name: express-rate-limit
  dependency-version: 8.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps the dev-dependencies group with 1 update: [i18next](https://github.com/i18next/i18next).


Updates `i18next` from 26.0.6 to 26.0.7
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](i18next/i18next@v26.0.6...v26.0.7)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [mssql](https://github.com/tediousjs/node-mssql) from 12.3.1 to 12.5.0.
- [Release notes](https://github.com/tediousjs/node-mssql/releases)
- [Changelog](https://github.com/tediousjs/node-mssql/blob/master/CHANGELOG.txt)
- [Commits](tediousjs/node-mssql@v12.3.1...v12.5.0)

---
updated-dependencies:
- dependency-name: mssql
  dependency-version: 12.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…er#7600)

Bumps the dev-dependencies group with 1 update: [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react).


Updates `lucide-react` from 1.8.0 to 1.11.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.11.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix(admin): restore i18n on /admin by copying locales to the right path

The admin SPA fetches `/admin/locales/<lang>.json`. Building with
vite-plugin-static-copy and `src: '../src/locales'` was placing the
115 core locale files at `src/templates/admin/src/locales/` (the
plugin's `dirClean` strips a leading `../` but keeps the remaining
parent path). The express admin handler 404'd those fetches, fell
back to serving `index.html`, JSON.parse silently failed, and every
`<Trans>` rendered its raw key — see ether#7586.

Replace the plugin with a small inline build/dev plugin: at build
time copy `src/locales/*.json` to `<outDir>/locales/`; in dev serve
the same files via middleware so `vite dev` also works. Drop the
now-unused `vite-plugin-static-copy` dependency.

Add regression coverage that none of the existing admin specs had:
- backend HTTP test for GET /admin/locales/{en,de}.json
- Playwright admin i18n spec asserting translated <h1> renders for
  the default locale and for ?lng=de, plus a request-level check
  that the response is JSON, not the SPA fallback.

Closes ether#7586

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(admin): bundle locales via import.meta.glob, drop copy plugin

The first pass at ether#7586 replaced vite-plugin-static-copy with a
custom build/dev plugin that copied src/locales/*.json into the
admin output and served them in dev. That works, but the
vite-plugin-static-copy README explicitly recommends the public
directory or a JS import for this case, and the import path is
strictly cleaner: no copy step, no /admin/locales/* express route,
no SPA-fallback-shaped failure mode.

Use import.meta.glob in admin/src/localization/i18n.ts so each
language ships as its own hashed JSON chunk and is lazy-loaded on
demand. The vite config goes back to just react + base + outDir.
The plugin namespaces (e.g. ep_admin_pads) keep their existing
admin/public/<ns>/<lang>.json layout.

Tests:
- Drop tests/backend/specs/adminLocales.ts — it asserted on a
  /admin/locales/<lang>.json route that this approach no longer
  uses; the regression mechanism it pinned doesn't exist anymore
  and the test required the admin frontend to be built before the
  backend test runs (which CI doesn't do).
- Keep tests/frontend-new/admin-spec/admini18n.spec.ts (rendered
  <h1> in default and ?lng=de). Verified red→green: reverting just
  the loader to the pre-fix /admin/locales fetch makes both specs
  fail; restoring makes them pass.

Also update pnpm-lock.yaml to drop the now-unused
vite-plugin-static-copy entries — fixes ERR_PNPM_OUTDATED_LOCKFILE
that was failing every CI install upfront.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#7589) (ether#7598)

After picking a value from a toolbar <select> (ep_headings style
picker is the canonical case), keyboard focus was left on the
nice-select wrapper rather than returned to the pad editor. Users
had to click back into the pad before typing resumed.

The ToolbarItem.bind() class already calls padeditor.ace.focus() at
the end of triggerCommand, but that only runs for selects wired via
data-key on the wrapping <li>. Plugin-provided selects (e.g.
ep_headings2's #heading-selection inside <li id="headings">, no
data-key) don't go through that path — they bind their own change
handler and never return focus.

Fix: add a delegated change handler on `#editbar select` that calls
padeditor.ace.focus() after any toolbar select change. Deferred via
setTimeout(0) so plugin change handlers (bound on the same event)
complete their ace.callWithAce work before focus moves. Redundant but
harmless for data-key-wired selects that are already refocused by
triggerCommand.

Added a Playwright regression test that simulates the nice-select
option-click (val + change, which is what the wrapper dispatches
internally) and verifies typing after the change lands in the pad.
Skips when ep_headings2 isn't installed.

Closes ether#7589.
)

* fix: page down/up now scrolls by viewport height, not line count

The previous implementation counted logical lines in the viewport,
which failed when long wrapped lines consumed the entire viewport.
Now scrolls by actual pixel height for correct behavior.

Fixes ether#4562

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use outerDoc instead of outerWin.document for viewport height in PageDown/Up

outerWin is an HTMLIFrameElement (returned by getElementsByName), not a
Window object, so it has no .document property. The existing getInnerHeight()
helper already uses outerDoc.documentElement.clientHeight correctly; align
the PageDown/PageUp handler with that pattern.

Adds a Playwright regression test that verifies PageDown scrolls the
viewport when the pad contains long wrapping lines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: rewrite page down/up to use pixel-based line counting

The previous approach tried to scroll the outerWin iframe element
directly which didn't work. Reverted to the original cursor-movement
approach but calculates lines-to-skip using viewport pixel height
divided by actual rendered line heights. This correctly handles long
wrapped lines that consume multiple visual rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore getInnerHeight + inclusive range fixes lost in rebase

Recover the PageDown/Up fixes that got dropped when this branch was
rebased onto develop:

- Use getInnerHeight() instead of outerDoc.documentElement.clientHeight
  so hidden-iframe and Opera edge cases are handled the same as the rest
  of the editor.
- scroll.getVisibleLineRange() returns an inclusive end index, so count
  (end - start + 1) logical lines to match the pixel-sum loop bounds.
- Replace the flaky 'PageDown scrolls viewport' test with the robust
  ether#4562 regression that builds long wrapped lines via direct DOM and
  asserts the caret advances on successive PageDown presses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…r#7562)

* fix(editor): undo/redo scrolls the viewport to follow the caret

Before: on a large pad, pressing Ctrl+Z (or Ctrl+Y, or the toolbar undo
button) updated the caret in the rep model and the DOM, but the viewport
did not follow when the caret landed below the visible area. The user
was left looking at the same scroll position while their change had
been undone somewhere they couldn't see.

Root cause: scroll.ts's `caretIsBelowOfViewport` branch ran
`outer.scrollTo(0, outer[0].innerHeight)` — a fixed offset equal to the
inner iframe's height, NOT the caret position. That was a special-case
added in PR ether#4639 to keep the caret visible when the user pressed Enter
at the very end of the pad. It worked for that one scenario because the
newly-appended `<div>` happened to be at the bottom of the pad too; for
any other way of putting the caret below the viewport (undo, redo,
programmatic selection change, deletion that collapsed a long block) it
scrolled to an arbitrary spot.

Fix: mirror the `caretIsAboveOfViewport` branch. After the deferred
render settles, recompute the caret's position relative to the viewport
and scroll by exactly the delta needed to bring the caret back in — plus
the configured margin. The Enter-at-last-line case still works because
the caret genuinely is near the bottom of the pad and the delta resolves
to "scroll down by a screen".

Closes ether#7007

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(7007): use real typing so undo has changesets to replay

The first iteration of the Playwright spec built the pad by writing
directly to #innerdocbody.innerHTML. That bypasses Etherpad's text
layer, so the undo module had no changeset to revert — Ctrl+Z became a
no-op and the scroll assertion saw no movement (CI failure output:
`Expected: < 2302, Received: 2302`).

Replace with real keyboard typing of 45 lines via the existing
writeToPad-style pattern, then make the edit + scroll + Ctrl+Z under
that real content. Slower (~5s per test) but faithful to how undo
interacts with the pad.

Also drop the `test.beforeEach(clearCookies)` scaffolding — it wasn't
doing anything useful here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(7007): scroll caret into view directly in doUndoRedo

Revert the scroll.ts rewrite from the previous commits and move the
fix to the right abstraction layer: the undo/redo entry point itself.

`scrollNodeVerticallyIntoView`'s caret-below-viewport branch has a
well-documented special case (PR ether#4639) that scrolls to the inner
iframe's innerHeight so Enter-on-last-line stays smooth. Changing
that function for the undo case risked regressing the Enter case or
racing with the existing scrollY bookkeeping. The CI run showed the
rewrite wasn't actually producing viewport movement.

Do the simpler thing instead: in `doUndoRedo`, after the selection is
updated, call `Element.scrollIntoView({block: "center"})` on the
caret's line node. That's browser-native, works inside the
ace_inner / ace_outer iframe chain, doesn't need setTimeout, and matches
what gedit/libreoffice do.

Closes ether#7007

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes ether#5071. `/p/:pad/:rev/export/etherpad` has always ignored the rev
parameter and returned the full pad history, unlike the txt/html
export endpoints which use the same route but do respect rev. Users
wanting to back up or inspect a snapshot of a pad at a specific rev
got every later revision in the payload instead — both wasteful and
a surprise when the downloaded .etherpad blob contained content that
had supposedly been reverted.

Change:
  - `exportEtherpad.getPadRaw(padId, readOnlyId, revNum?)` now takes an
    optional revNum. When supplied, it clamps to `min(revNum, pad.head)`,
    iterates only revs 0..effectiveHead, and ships a shallow-cloned pad
    object whose `head` and `atext` reflect the requested snapshot. The
    original live Pad is still passed to the `exportEtherpad` hook so
    plugin callbacks see the real document.
  - `ExportHandler` passes `req.params.rev` through on the `etherpad`
    type, matching the existing behavior of `txt` and `html`.
  - Chat history is intentionally left full (it is not rev-anchored).

Adds three backend regression tests under `ExportEtherpad.ts`:
  - default (no revNum) still exports the full history
  - explicit revNum limits exported revs and rewrites the serialized
    head so re-import reconstructs the pad at that rev
  - revNum above head is treated as full history, preventing accidental
    truncation of short pads

Out of scope: `getHTML(padID, rev)` on the API side is already honoring
rev in current code (exportHtml.getPadHTML threads the parameter
through), so the earlier report on that API call appears to be
resolved. This PR does not touch it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ther#7592, ether#7593) (ether#7597)

* fix(userlist): stop username input from overlapping the Log out button

Fixes ether#7593. In the pad's Users popup, #myusernameform had no width
set and the <input id="myusernameedit"> inside it took its natural
content width, pushing past the Log out button and making the button
overflow the popup at common widths.

Constrain #myusernameform to 75px and make the input fill its
container with box-sizing: border-box so the text field stays inside
the form and the Log out button sits visibly next to it rather than
getting covered or clipped off-screen.

Low-risk, CSS-only change. No test plan beyond visual verification
because the affected control is in the users popup UI.

* fix(chat): bottom-align titlebar controls; restore chat icon click (ether#7590)

Two regressions from the ether#7584 a11y refactor of the chat widget,
both pure-CSS fixes scoped to the chat panel.

1. Title bar — `<a>` → `<button>` for #titlecross/#titlesticky kept the
   `float: right` layout, but a `<button>`'s box is only as tall as its
   glyph, so the small `−` and `█` controls floated at the *top* of the
   44px title bar instead of sitting on the title's baseline as the
   anchors did. Switch #titlebar to a flex row with `align-items:
   flex-end`, give #titlelabel `flex: 1` to push the controls to the
   right edge, and use `order: 1/2` to keep the historical visual order
   `[█] [−]` (which `float: right` previously produced from reverse
   source order).

2. Chat-icon corner widget — `<div>` → `<button id="chaticon">` exposes
   the inner `<span class="buttonicon">` to the global `.buttonicon`
   rule's `display: flex; position: relative; align-items/justify-content:
   center;`. The existing override only reset `display`, leaving the
   span as a positioned flex item that, in some layouts, sat over the
   button's hit surface and swallowed clicks. Reset the remaining flex
   properties and add `pointer-events: none` so clicks always reach the
   `<button>`'s own click handler — preferred over weakening the global
   .buttonicon rule, which the toolbar relies on for icon centring.

Visual-only / behaviour-fix, no markup or JS changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): grey disabled chat option labels (ether#7592)

When "Disable chat" is ticked in the Settings dialog, refreshMyViewControls()
already sets `disabled` on `#options-stickychat` and `#options-chatandusers`,
but the browser only greys the checkbox itself — the adjacent `<label>`
keeps its normal colour, so the row still looks interactive even though
clicks are no-ops.

Add a popup-scoped rule that follows the existing convention used for
disabled `.nice-select` controls (`color: ether#999; cursor: not-allowed`) so
any disabled checkbox or radio in a settings popup matches its label to
the disabled state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(userlist): drop username input width cap (ether#7593 review)

The width:75px on #myusernameform and width:100%/box-sizing on
#myusernameedit from a55436c were guarding against an overlap with
a "Log out" button — but no Log out button exists in vanilla
etherpad-lite (the original report came from a setup with a plugin
that adds one). Without that button visible, the cap just makes the
default username field unnecessarily narrow.

Restore #myusernameform to just `margin-left: 10px` and drop the
forced width on the input. If the overlap reappears in a real plugin
setup it should be re-fixed there (or with a more targeted rule that
only kicks in when a logout button is actually present).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): keep titlesticky at top of title bar (ether#7590 review)

The previous pass bottom-aligned both corner controls via
align-items: flex-end on #titlebar. That correctly placed the close
button (#titlecross) on the title's baseline, but it also dragged the
much smaller "stick to screen" button (#titlesticky) down to the same
baseline — visibly far below where it sat in the original layout.

Switch to per-control align-self so each lands where it should:
  - #titlesticky → align-self: flex-start  (top, where it always was)
  - #titlecross  → align-self: flex-end    (bottom, on the title's baseline)
  - #titlelabel  → align-self: center      (don't stretch the heading)

Drop align-items from #titlebar so the defaults don't override these.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(chat): restore original #titlebar layout (ether#7590 review)

Both attempted CSS layouts for the title bar (full flex with
align-items: flex-end, then per-control align-self) ended up looking
worse than the original in review. Drop all the #titlebar / #titlelabel
/ #titlecross / #titlesticky changes from 905294d and f37da9a and
restore the pre-existing float-based layout. The chat panel ships with
its original visuals; we'll revisit ether#7590 separately if needed.

Keeps the chat-icon click fix from 905294d (#chaticon .buttonicon
flex/pointer-events reset) and the focus-visible additions for the
title-bar buttons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): clear inline display:none in chat.show()

When the user disables chat in settings, applyShowChat(false) calls
\`$('#chatbox').hide()\` which sets the chatbox's inline display to
\`none\`. Re-enabling chat doesn't undo that — it only re-shows the
icon. Then clicking the icon runs chat.show(), which adds the
\`.visible\` class but only flips visibility, not display, so the
chatbox stays hidden by the lingering inline style and the chat
appears not to open.

Clear the inline display in chat.show() before adding the .visible
class so the box becomes visible regardless of how it got hidden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(colibris): align username gap; grey unchecked-disabled toggles

users.css: change #myusernameform margin-left from 35px to 10px to
match the base popup_users.css. The 35px value was chosen for the
sticky chatAndUsers layout, but for the standalone Users popup it
opens an unnecessarily wide gap between the colour swatch and the
username field. (ether#7593 review)

form.css: drop the \`:checked\` qualifier from the disabled toggle
visual rule so unchecked-but-disabled toggles also dim. Without this,
"Chat always on screen" / "Show Chat and Users" stayed fully bright
when "Disable chat" was ticked even though the underlying inputs were
disabled. Fixes ether#7592 in the colibris skin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): simple flex titlebar — CHAT      _  []

Single flex row, vertically centred via align-items: center. Title
takes the remaining width with flex: 1; the two corner controls fall
in at the right edge in source order (titlecross then titlesticky),
giving the intended visual: minus on the left, sticky on the right.

Drops `float: right` from the controls, `display: inline` from the
heading, and the prior `padding-top: 2px` hack on titlesticky (flex
alignment handles the vertical position now).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): titlebar uses underscore for minimize; symmetric padding

- Replace \`&minus;\` with \`_\` in #titlecross. The minus glyph sits at
  the centre of its em-box and read as a hyphen mid-row when the row
  was vertically centred; \`_\` sits at the bottom of its em-box and
  reads as a proper minimize indicator.
- Even out #titlebar horizontal padding to 9px and drop the asymmetric
  \`margin-left: 4px\` on #titlelabel so CHAT on the left and the
  sticky button on the right are the same distance from the bar's
  edges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): lift #titlecross underscore 5px

The \`_\` glyph renders at the bottom of its em-box, so even with the
title bar's flex \`align-items: center\` it sits noticeably below the
CHAT baseline. Lift it with \`transform: translateY(-5px)\` (doesn't
affect flex layout calculations) so the underscore reads at roughly
the same vertical line as the title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(chat): cover ether#7590 / ether#7592 / ether#7593 fixes

Adds Playwright frontend specs for the changes in this PR:

chat.spec.ts
  - chat icon click reveals chatbox after disable→enable cycle
    (regression: chat.show() must clear inline display:none)
  - title bar lays out as a centred flex row with underscore minimize
    (covers display, align-items, label flex:1, no float, translateY
    lift, and visual padding symmetry via rendered geometry)
  - chat icon click reliably opens the chat box (#chaticon .buttonicon
    pointer/flex reset)

pad_settings.spec.ts
  - disabling chat disables and visually greys the dependent chat
    toggles (ether#7592 — checks input :disabled state and label opacity)

change_user_name.spec.ts
  - #myusernameform has 10px left margin and is not width-capped
    (ether#7593 review — colibris margin alignment, no input width cap)

Padding symmetry asserted via rendered rect deltas rather than the
CSS literal, since colibris ships its own #titlebar padding override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: added release notes for 2.7.1

* chore: don't cache node_modules due to cas
…r#7563)

* fix(settings): derive randomVersionString from release identity

Fixes ether#7213.

Etherpad appends a `?v=<token>` cache-buster to static assets and
embeds the same token as `clientVars.randomVersionString` in the
padbootstrap JS bundle produced by specialpages.ts. Because esbuild's
content-hash feeds back into the generated bundle filename
(`padbootstrap-<hash>.min.js`), the token's value determines the file
that clients are told to load.

Historically the token was `randomString(4)`, regenerated on every
boot. In a horizontally-scaled deployment (ingress → etherpad
service → multiple pods) that meant every pod produced a different
filename for the same built artifact. A client that loaded the HTML
from pod A would request `padbootstrap-ABCD.min.js` from pod B and
hit a 404 when the upstream balancer placed the follow-up request
elsewhere.

Derive the token deterministically so pods of the same build emit
identical filenames, while still rotating on release so clients
invalidate their cache correctly:

  ETHERPAD_VERSION_STRING env  →  verbatim (integrator override)
  else                         →  sha256(version + "|" + gitVersion)[:8]

Backwards-compatible: single-pod deployments see the same effective
behavior (token rotates each release). Integrators who want to pin
the token explicitly — e.g. tying it to their own deploy ID — can set
`ETHERPAD_VERSION_STRING` in the environment.

Test coverage added in src/tests/backend/specs/settings.ts:
- Default shape is an 8-hex-char sha256 prefix.
- ETHERPAD_VERSION_STRING override is respected verbatim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(7213): call reloadSettings() to exercise ETHERPAD_VERSION_STRING

The token is assigned inside reloadSettings, not parseSettings, so a
parseSettings-only call never sees the env var. Drive reloadSettings
directly, restoring the file paths and the prior token afterwards so
other tests see a clean module state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dependabot Bot and others added 29 commits May 22, 2026 18:08
Bumps [semver](https://github.com/npm/node-semver) from 7.8.0 to 7.8.1.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](npm/node-semver@v7.8.0...v7.8.1)

---
updated-dependencies:
- dependency-name: semver
  dependency-version: 7.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
… semantics (ether#7819) (ether#7827)

Two related operator-facing docs gaps, both surfaced by ether#7819:

1. settings.json on disk is a *template*; env-var substitution happens
   at load time in memory only. Operators repeatedly mistake the
   templated file for a stale config because the docs never spell out
   that the on-disk file is intentionally unchanged by env vars.

2. The default docker-compose.yml puts settings.json in the container's
   writable layer with no host mount, which means admin /settings edits
   are silently lost on `docker compose down && up`, `pull`, or
   watchtower — but preserved across plain `restart`. Operators don't
   reliably know which compose verbs recreate the container.

Adds two prose sections to doc/docker.md (explaining both gotchas, with
a recreate-vs-restart table) and a commented-out `./settings.json:…`
bind mount in both docker-compose.yml and the README compose example.
Bind mount is opt-in so existing setups behave identically.

No runtime change.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps [mssql](https://github.com/tediousjs/node-mssql) from 12.5.3 to 12.5.4.
- [Release notes](https://github.com/tediousjs/node-mssql/releases)
- [Changelog](https://github.com/tediousjs/node-mssql/blob/master/CHANGELOG.txt)
- [Commits](tediousjs/node-mssql@v12.5.3...v12.5.4)

---
updated-dependencies:
- dependency-name: mssql
  dependency-version: 12.5.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…ther#7836)

Closes ether#7835.

- src/locales/en.json: add `index.code` (referenced by src/templates/index.html
  for the session-receive code input but never defined, producing a
  "Couldn't find translation key" console error on the landing page).
- admin/src/utils/LoadingScreen.tsx, admin/src/pages/PadPage.tsx,
  admin/src/pages/AuthorPage.tsx: every @radix-ui/react-dialog `Dialog.Content`
  now has a `Dialog.Title` and `Dialog.Description` (visually hidden via
  `@radix-ui/react-visually-hidden` where there is no visible heading),
  silencing Radix's a11y console warnings on every admin page load.
- src/tests/backend-new/specs/template-l10n-keys.test.ts: regression
  coverage — fails CI if any `data-l10n-id` in `src/templates/*.html` is
  missing from `src/locales/en.json`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…errors (ether#7819) (ether#7826)

* feat(admin): explain env-var substitution in /settings, surface auth errors (ether#7819)

Three small, env-var-only UX improvements driven by issue ether#7819, where a
Docker operator saved an ep_oauth block in the admin /settings raw view
and reported it "disappeared" — but the underlying confusion was that
settings.json on disk is a *template*, not the effective config. None of
these changes is visible to installs that don't use ${VAR} placeholders.

* Banner above the editor explaining the template/env-substitution model,
  only rendered when the loaded file contains a ${VAR} placeholder. Tells
  the operator that the file is not env-substituted in place and that the
  Effective tab shows the live values.

* Effective tab in the mode toggle, read-only, also gated on ${VAR}. The
  backend was already emitting redacted runtime settings as `resolved`
  alongside every `load`; the SPA now exposes them so an operator can
  verify what Etherpad is actually using.

* admin_auth_error event from the /settings socket handler. The handler
  previously silently returned when the connecting session wasn't admin,
  which made misrouted Traefik+SSO auth look like "save did nothing" with
  no error path in the UI. Emit a dedicated event before dropping the
  socket so the SPA can show a clear toast.

Tests:
- src/tests/backend/specs/admin/adminSettingsAuthError.ts — new spec for
  the auth_error/disconnect contract.
- src/tests/frontend-new/admin-spec/adminsettings.spec.ts — new Playwright
  test asserting the banner + Effective tab only appear after a ${VAR}
  is added to settings.json, and that the Effective view is read-only +
  shows [REDACTED] for secrets.

No behaviour change for installs without ${VAR} placeholders — banner,
Effective tab, and auth-error contract are all the same as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): drop fragile pre-condition + add reconnect-loop guard (ether#7819)

CI's admin-UI workflow seeds settings.json by copying settings.json.template
verbatim, which contains ~30 \${VAR} placeholders. The new Playwright
test asserted "banner not present before adding placeholder" — true on a
fresh dev machine, false in CI. Drop that assertion: the negative path
is covered by the SettingsPage ENV_VAR_PATTERN regex itself; what
matters at the UI level is the positive path (banner + Effective tab
render correctly when placeholders are present), which this test still
exercises.

Also: the server's admin_auth_error path calls socket.disconnect(),
which the SPA's existing disconnect handler interprets as "io server
disconnect" and immediately reconnects — creating a reject/reconnect
loop. Track an authErrored flag and suppress the reconnect once an
auth_error has been received. Reset on successful connect, so a
legitimate re-auth path still works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d-test ELIFECYCLE (ether#7838)

* test(ci): heartbeat + running-test pointer in backend test diagnostics

Backend tests silently die with code 255 mid-suite ~22% of the time on
develop (most often Windows-with-plugins, Node 24). Each kill lands
300±50 ms after the previous test's clean ✔ teardown line and produces
no failing-test marker, no error, no Mocha summary, and — despite the
unconditional handlers in `diagnostics.ts` — none of the JS-level death
events fire either. Recent example: run 26311025244 (`Windows with
Plugins (24)`); both attempts crashed at completely different "last
test" locations, so the dying test itself isn't to blame.

The existing diagnostics only set lastSeenTest in afterEach, so if the
kill lands during the NEXT test's setup or body — which is exactly the
~300ms gap we observe — the pointer reads as the previous (passing)
test. That hides whether we're between tests or inside one, and which
one.

Two changes:

1. Track currentTest in beforeEach as well as lastFinishedTest in
   afterEach. Every diag line now carries both, so the death point is
   bracketable regardless of which lifecycle phase the kill interrupts.

2. Add a 1Hz heartbeat that writeSyncs the running-test name plus
   `process.memoryUsage()` (rss, heap) and the active-handle and
   active-request counts. The interval is unref'd so it never holds the
   event loop open by itself. Cost is roughly one extra log line per
   second of mocha runtime (~60-120 lines per CI run).

When the next failure fires, the last heartbeat narrows the kill window
to ≤1s, the running pointer names the test on the rails at that moment,
and the handle/memory trace gives a sparkline that exposes sudden
spikes — a leaked socket, an unref'd timer, a runaway map — that
would otherwise be invisible at the runner-log level.

No behavior change on successful runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: heartbeat _getActiveHandles optional chain bug (Qodo #2)

Qodo correctly flagged `_getActiveHandles?.().length` as a latent
TypeError: `?.()` guards the call but the call's `undefined` return
on a missing method still hits `.length`, which throws. Since the
heartbeat fires on a setInterval inside the mocha bootstrap, a Node
build without the underscore-prefixed internals would take down the
whole backend test run.

Capture the array first, then read `.length` only when it actually
exists. -1 stays as the "API missing" sentinel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ci): per-test start diag + drop stray console.log noise

Follow-up to the heartbeat PR after run 26397693748 confirmed the
diagnostic works (the kill landed at importexportGetPost.ts
'Import authorization checks > authn anonymous !exist -> fail',
~300 ms after the previous test's ✔). Two cleanups so the next
failure pinpoints faster and reads cleaner:

1. diagnostics.ts: emit a `test start: <name>` diag line in the
   mocha beforeEach hook, after setting the currentTest pointer.
   The 1Hz heartbeat misses tests that take less than a second,
   and the silent kills land ~300 ms after a test boundary —
   precisely the gap where heartbeat resolution fails. A start
   line per test gives sub-millisecond resolution on which test
   was on the rails when the process died.

2. specs/api/importexportGetPost.ts: drop a stray
   `console.log(importedPads)` debug leftover (and the duplicate
   `await importEtherpad(records)` only present to feed it) in
   the `malformed .etherpad files are rejected` block. The leftover
   dumped a ~600-line reflection of a supertest Response object
   to the CI log on every successful run, drowning the surrounding
   test output and making the silent-kill window much harder to
   read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ci): write node-report on every heartbeat tick

Run 26398054688 narrowed the kill to a specific test
(pad.ts > Gets text on a pad Id and doesn't have an excess newline)
but the test body is a trivial supertest GET — the kill bypasses
all JS handlers, so we can't capture stack state at death.
Two failures across two runs share the shape: an agent.{get,post}
+ common.generateJWTToken() call dies ~300-600 ms after test start,
with no JS-visible cause. The next step is V8 + native stack.

Hook into the existing 1Hz heartbeat to call
process.report.writeReport(path) whenever a report directory is set.
The Windows backend-tests workflow already wires up
`--report-directory=${{ github.workspace }}/node-report` via
NODE_OPTIONS and uploads that directory as an artifact on failure,
so the rolling snapshots ride for free on the existing upload step.

Each report (~50 KB) contains:
  - V8 + native call stacks for all threads
  - libuv active handles (open TCP, timers, file handles)
  - JS heap statistics
  - resourceUsage + system info
  - shared-object list

On the next reproduction the latest report before ELIFECYCLE will
sit ~0-1 s before the kill — enough to see whether the V8 stack
is inside jose's WebCrypto sign path, inside supertest's TCP
roundtrip, or somewhere unexpected entirely.

NODE_REPORT_DIR is also honored as an explicit override for local
repro / non-workflow runs.

Cost: ~6 files (~300 KB) per Windows backend-test failure, plus
~50 ms event-loop pause per heartbeat. No-op when neither env var
is set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: writeReport with bare filename, not mixed-slash absolute path

Run 26398830249 exposed the path-separator bug in the previous commit:
every heartbeat tick on the Windows runner logged

  Failed to open Node.js report file:
  D:\a\etherpad\etherpad/node-report/hb-NNNN-...json
  directory: D:\a\etherpad\etherpad/node-report (errno: 22)

— EINVAL. The workflow sets --report-directory with forward-slash
separators on Windows, then this code concatenated another `/` plus
the filename, producing a path Node's report writer rejects.

writeReport(fileName) takes a BARE filename and resolves it against
the configured report directory using the platform-correct separator
internally. Switch to that. For local repro overrides via
NODE_REPORT_DIR, push the path into process.report.directory (the
documented config knob) instead of joining it into the call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ci): write node-report on test boundaries, throttled to 4Hz

Run 26398985832 proved the heartbeat-only report cadence isn't tight
enough: the last report before the kill was hb-0013 at +16201ms,
~1.5 s before ELIFECYCLE at +17701ms — during which ~30 tests fired,
including the dying one (`authn anonymous !exist -> fail`). The
captured V8 stack is just our heartbeat code, not the dying test.

Move the writeReport call to a shared tryWriteReport() helper and
invoke it from BOTH the heartbeat AND mocha's beforeEach hook,
throttled to one report per 250 ms. That gives ≤250 ms resolution
on the kill window — close enough that the latest report captures
state from inside the dying test rather than from the test ~30
slots earlier. The heartbeat always writes (so we don't lose the
no-test-running ticks during setup); beforeEach only writes when
the throttle window has elapsed.

Cost ceiling: ~4 reports/sec × ~12 s test phase ≈ 48 reports
(~2.5 MB) per failing run. Each writeReport adds ~50 ms of
event-loop pause — at 4Hz that's 20% of wall time spent in
diagnostics, which is acceptable for a temporary debug-only
bootstrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ci): drop beforeEach report throttle from 250ms to 100ms

Run 26399285213's rerun captured a sixth death point on the new 4Hz
cadence (`socketio.ts > Duplicate-author handling > cookie identity:
same-author second socket kicks the first`, kill at +45953ms, 271ms
after test start). The throttle suppressed the dying test's own
beforeEach: previous boundary write landed 128 ms earlier and the
next 31 ms after that, both inside the 250 ms window. Last captured
report (be-0100) is from the previous test.

100 ms is still well above the inter-test cadence in fast burst
suites (tests fire 2-5 ms apart, so 20-50 of them get throttled to a
single write, ceiling ~10 writes/sec). But it's tight enough that
any death-window neighbour ≥100 ms after the previous report — the
shape we keep observing — gets its own boundary snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ther#7842)

* test(ci): schedule a mid-test snapshot 150ms after every beforeEach

Run 26401801404 (PR ether#7841 after merging develop) captured the dying
test's beforeEach node-report — be-0258, written 75ms after
socketio.ts > "Pad-wide settings creator gate different browsers"
entered — but no further state. The kill landed 321ms into the test
body, between 1 Hz heartbeat ticks, and the 100ms boundary throttle
prevented further beforeEach writes inside the same test. The report
we have shows only the listening server socket; the connections that
the test body creates (and that presumably precede the kill) never
get snapshotted.

Schedule an unref'd setTimeout from beforeEach that fires 150ms after
the test entered. If it's still the running test at fire time (i.e.
slow enough that the death window applies), capture a node-report
from INSIDE the test body — the moment when the real TCP / socket.io
activity is in flight. Fast tests (<150ms) skip the write because
afterEach has already cleared currentTest by the time the timer
fires.

Result on the next reproduction of the death pattern:
  - be-NNNN report at +75ms (beforeEach, body not yet started)
  - mt-MMMM report at +150ms (mid-test, body in flight, before kill
    at +320ms)
  - kill, no further reports

Cost: only slow tests (>150ms) generate an mt report, so the
artifact size growth is bounded by the count of tests that take
longer than 150ms — typically a small minority. Locally verified
against a 3-test probe: 2 fast tests skipped, 1 300ms test produced
the expected mt-NNNN snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ci): bump heartbeat from 1Hz to 5Hz

Run 26402211271 produced be-0207 (the dying test's beforeEach
snapshot) but no mid-test snapshot, even though setTimeout(150ms)
was scheduled and the test body lived another 280 ms after that
deadline. setTimeout under Windows-runner load is being starved
past the deadline — we already saw the previous test's mt fire at
+252 ms (102 ms late) when the deadline was 150 ms, so the dying
test's timer was likely scheduled to fire well after the kill at
+425 ms.

setInterval has fired reliably throughout the investigation
(every heartbeat in every run lands within ~1 s of schedule, even
when setTimeout misses). Bump heartbeat to 200 ms (5 Hz) so any
death window ≥200 ms is sampled inside the test body, independent
of how starved setTimeout is.

Cost on the Windows runner: the existing log shows writeReport
completes in <1 ms (from "Writing Node.js report" to "Node.js
report completed" timestamps), so 5 Hz adds ~5 ms/s of overhead.
Artifact growth: ~500 reports for a 100 s test phase (~25 MB raw,
~5 MB compressed). The setTimeout mid-test snapshot stays — it's
belt-and-suspenders cheap and fires for slow tests where the
heartbeat alone might not align with the death window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: isolate mt/hb writes from `be` throttle + gate timer on canWriteReport

Qodo flagged two real issues on ether#7842:

1. The single shared `lastReportT` made `mt` writes poison the `be`
   throttle window. Slow tests trigger an `mt` write at +150 ms, then
   the test ends a few ms later, and the NEXT test's `beforeEach`
   landed within the 100 ms throttle from the `mt` write — so its
   own `be` snapshot was suppressed. That's the exact boundary
   coverage the throttle is supposed to PROTECT. Local repro with a
   180 ms slow test followed by a fast one confirmed: the fast
   test's `be-0004` is now captured instead of swallowed.

   Fix: split into `lastBoundaryT` used and updated only by `be`
   writes. `hb` and `mt` pass `updateThrottle=false` and never
   advance the boundary timestamp.

2. `setTimeout` was being scheduled in `beforeEach` for every test
   even when `canWriteReport` is false (Linux backend matrix, local
   dev). That's a wasted timer per test for no possible diagnostic
   output. Gate the schedule itself on `canWriteReport`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r#7841)

This flag gates the ep_* passthrough on padoptions that shipped in 3.0.0
(PR ether#7698). It was introduced as opt-in, but the intent in shipping it
was to let plugins like ep_plugin_helpers' padToggle / padSelect ride
the existing broadcast/persist rail out of the box — flipping the
default closes the gap.

Why now
- ep_comments_page#422 (and sibling per-plugin reports discussed on
  Discord): stock 3.x deployments console.warn on every pad load because
  the helper detects clientVars.enablePluginPadOptions === false and
  tells the admin to flip it. With the flag default-true, the warning
  stops firing on fresh installs while still surfacing for operators
  who have explicitly opted out.
- Plugins that already depend on ep_plugin_helpers >= 0.6 expect the
  pad-wide path to work; the default-false gate silently no-op'd
  pad.changePadOption('ep_*', …) and made the helper UI inert.

Scope
- Settings.ts default flipped to true; comment rewritten to describe
  the new "operator opt-out" model rather than the old AGENTS.MD §52
  opt-in framing (that policy still applies to *new* features; this
  one has shipped and proven safe).
- settings.json.template env-var substitution default flipped to true
  so docker / supervisor configs without an explicit value get the
  new behavior.
- doc/plugins.md updated to match (default true, opt-out via
  settings.json) and the PluginCapabilities source comment.
- Backend test describe-blocks relabeled — "true" is now "(default)",
  "false" is now "(operator opt-out)". Both branches still cover the
  same matrix so the size-cap / namespace-validation paths stay
  exercised.

Compat
- Existing deployments with an explicit `"enablePluginPadOptions":
  false` in settings.json keep that value — no migration needed.
- Older clients only read clientVars.enablePluginPadOptions; the
  protocol shape is unchanged.

Closes ep_comments_page#422 (helper warning suppression for stock
deployments).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) (ether#7843)

* fix(pad): URL view-option params lost to padeditor.init race (ether#7840)

`?showLineNumbers=false` and `?useMonospaceFont=true` were being
silently clobbered shortly after pad load. Same race that affected
`?rtl=false` before ether#7464:

  1. _afterHandshake → getParams() sets settings.LineNumbersDisabled
     (or useMonospaceFontGlobal, noColors).
  2. _afterHandshake calls padeditor.init(view).then(postAceInit) —
     async; ace iframes still loading.
  3. Sync tail of _afterHandshake hits the URL-param overrides and
     calls changeViewOption('showLineNumbers', false) etc. These
     queue setProperty('showslinenumbers', false) in Ace2Editor's
     actionsPendingInit queue (loaded=false).
  4. ace.init resolves → loaded=true → queue flushes → URL-driven
     value applied.
  5. padeditor.init resumes past its own await and calls
     setViewOptions(initialViewOptions) — initialViewOptions is
     built from clientVars.initialOptions.view (server defaults
     ∨ cookie), which does NOT carry the URL preference. The
     resulting setProperty('showslinenumbers', true) runs against
     loaded=true ace and immediately re-shows the gutter.

ether#7464 noticed this race for RTL and moved the override into
postAceInit. The neighbouring blocks for showLineNumbers / noColors
/ useMonospaceFontGlobal were left at the synchronous-tail site —
generalise the same fix to all three.

Direct-browser users typically had a `prefs` cookie with
showLineNumbers=false from a prior in-pad toggle, so the
initialViewOptions value happened to match the URL param and the
race was unobservable. Cross-context iframe embeds (the reporter's
configuration) start with no cookie, so the server default true
fights the URL false and the race becomes visible.

Adds src/tests/frontend-new/specs/url_view_options.spec.ts covering
the showLineNumbers=false / =true / useMonospaceFont=true cases on
initial-load navigation (the path where the race actually fires).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(types): annotate navigateWithParam helper params

Fixes CI ts-check: parameters page/padId/param implicitly had `any`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(root): drop redundant top-level files

Three small clean-ups to make the project root easier to scan for new
contributors:

- best_practices.md was a near-duplicate of CONTRIBUTING.md. Merge its
  two unique bullets (PRs MUST include a description / flag empty
  descriptions as incomplete) into CONTRIBUTING.md and remove the file.
- .pr_agent.toml is a two-line Qodo PR-bot config. It has no functional
  references in the repo and Qodo currently doesn't review this repo.
- tests/ -> src/tests was an unused root symlink. Verified no workflow,
  script, eslint config, or playwright config relies on the root path
  (all callers use src/tests/... or the ep_etherpad-lite package path).

No CI, build, or docs updates needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(root): fix dead tests/frontend + best_practices.md refs

Follow-up on Qodo review feedback for ether#7839:

- CONTRIBUTING.md: front-end-tests path now reads src/tests/frontend/
  instead of the dead tests/frontend/ (the root tests symlink is gone).
  The browser URL <yourdomainhere>/tests/frontend stays — that's an
  Express route served by the test runner, not a filesystem path.
- docs/superpowers/specs/...openapi-design.md: drop best_practices.md
  from the parenthetical list of policy sources (now CONTRIBUTING.md
  and AGENTS.MD), since best_practices.md has been removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(oidc): fix OIDCAdapter broken flows

* fix(oidc): fix storage type to include string for userCode index

* test(oidc): add regression tests for OIDCAdapter broken flows
The 'enter is always visible after event' test asserted that the last
line was within the browser viewport using boundingBox().y + height vs
window.innerHeight. Those values live in different coordinate spaces
(boundingBox is outer-page; window is per-frame), and the comparison
is fundamentally unable to model what the editor's auto-scroll actually
guarantees: visibility inside the ace_outer iframe, not within the
outer browser viewport.

Any plugin that adds chrome above or below the editor (toolbar rows,
sidebars, etc.) pushes the iframe's bottom below the browser viewport
while auto-scroll has correctly placed the cursor at the iframe's
bottom — failures look like 'Expected: > 731, Received: 720'. An
earlier attempt to switch to toBeInViewport({ratio: 1}) traded the
false positives for false negatives under chromium + plugins because
the inner iframe's contents can report ratio 0 against the outer
viewport even when the line is visible inside the editor.

Drop the visibility assertion entirely. The test's real value — that
Enter keystrokes produce new lines and the editor's input pipeline
keeps up — is exercised by the per-iteration toHaveCount value-wait
in the loop above. Visibility under plugin chrome is a separate,
plugin-aware concern.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ther#7846)

In-process diagnostics (diagnostics.ts heartbeat at 5 Hz + node-report
snapshots on every beforeEach and heartbeat tick) merged in ether#7838 and
ether#7842 reach a hard ceiling: during every captured death window the V8
main isolate is event-loop-starved for 200-400 ms before the process
is externally terminated, so any timer-driven probe (heartbeat,
setTimeout, --report-on-signal handler) never gets serviced and we
have zero JS-visible state from the actual moment of death.

To capture state during the starvation window we need a probe whose
own scheduling does not depend on the dying process's libuv event
loop. This commit adds a tiny bash background loop to the Windows
backend-test steps (both with- and without-plugins). Every 500 ms it
appends:
  - netstat.log: localhost TCP socket state — surfaces TIME_WAIT /
    CLOSE_WAIT accumulation or ephemeral-port exhaustion that the
    in-process libuv handle list can't see (libuv only shows handles
    Node currently knows about; the kernel may hold many more sockets
    in disposal states).
  - tasklist.log: node.exe process state from the Windows OS view
    (handle count, working set, CPU time), independent of whether V8
    is responsive.

Both files land in $GITHUB_WORKSPACE/node-report/ which is already
the artifact-upload target on failure, so they ride for free on
existing infrastructure. The watcher is killed cleanly after `pnpm
test` returns so it never holds the runner open.

On the next captured silent ELIFECYCLE we'll have, for the first
time, a 500 ms-resolution external observation of TCP and process
state across the death window.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps [lru-cache](https://github.com/isaacs/node-lru-cache) from 11.5.0 to 11.5.1.
- [Changelog](https://github.com/isaacs/node-lru-cache/blob/main/CHANGELOG.md)
- [Commits](isaacs/node-lru-cache@v11.5.0...v11.5.1)

---
updated-dependencies:
- dependency-name: lru-cache
  dependency-version: 11.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [mysql2](https://github.com/sidorares/node-mysql2) from 3.22.3 to 3.22.4.
- [Release notes](https://github.com/sidorares/node-mysql2/releases)
- [Changelog](https://github.com/sidorares/node-mysql2/blob/master/Changelog.md)
- [Commits](sidorares/node-mysql2@v3.22.3...v3.22.4)

---
updated-dependencies:
- dependency-name: mysql2
  dependency-version: 3.22.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 8.0.7 to 8.0.9.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](nodemailer/nodemailer@v8.0.7...v8.0.9)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 8.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…ate + Node 24.16.0) (ether#7866)

* fix(test): stop a single leaked promise rejection from killing the whole backend suite

Root cause of the long-standing Windows backend-test "silent ELIFECYCLE"
flake (~22% of runs, rotating across random spec files, no mocha summary,
no JS-handler trace, bypassing --report-on-fatalerror / Defender / Windows
event log / AeDebug). Found by capturing a full-memory dump of the dying
node.exe with Sysinternals ProcDump (-t dump-on-terminate) and symbolizing
it against Node 24.15.0's node.pdb. The dying thread's stack:

    exit_or_terminate_process / common_exit        (CRT exit)
    node::Exit
    node::DefaultProcessExitHandlerInternal
    node::Environment::Exit
    node::ReallyExit                                (process.exit binding)
    ... v8 MicrotaskQueue::RunMicrotasks ...
    node::InternalCallbackScope::Close

No exception stream — a *clean* ExitProcess, not a crash. The job log
pinned the trigger:

    [INFO] server - Exiting...
    AssertionError at tests/backend/specs/SessionStore.ts:235
      at process.processTicksAndRejections

Mechanism: a timing-fragile test (SessionStore touch/expiry specs use real
setTimeout against a 200ms-expiry session; socket.io delay-race specs are
similar) gets timed out and abandoned by mocha, but its async body keeps
running. When its trailing assertion later throws, it surfaces as an ORPHAN
unhandled rejection belonging to no awaited test. Three handlers then
escalated that into a whole-process exit:
  - server.ts installed process-global uncaughtException/unhandledRejection
    handlers that call exports.exit() → process.reallyExit() (production
    graceful-shutdown behaviour, catastrophic in-process under mocha)
  - common.ts (PR ether#7663) and diagnostics.ts (PR ether#7838) rethrew the rejection
    and process.exit(1)

Because it's a deliberate, clean exit it bypassed every forensic layer; it
rotated across files because the orphan rejection lands during whatever test
is running; it's Windows-mostly because event-loop timing makes the abandoned
test's assertion fire in a *later* test's window more often there.

Fix (two halves):
  1. server.ts: gate the process-global uncaughtException / unhandledRejection
     / signal handlers behind `require.main === module`. They are correct for
     a real Etherpad process but must not fire when server.start() is called
     in-process by a test runner — mocha owns process-level error handling
     there. Mirrors the existing `if (require.main === module) exports.start()`
     idiom; production (node server.js) is unchanged.
  2. common.ts + diagnostics.ts: the backend-test bootstraps now LOG unhandled
     rejections instead of rethrowing / exiting. Orphan rejections cannot be
     cleanly attributed to a test, so rethrowing only yields an
     ERR_MOCHA_MULTIPLE_DONE abort. Real failures are unaffected — an assertion
     in a test's own awaited path rejects that test's promise and mocha fails
     it normally, never reaching this global handler.

Verified locally: a spec that leaks a delayed rejection during a later test
now reports `3 passing` / exit 0 with the rejection logged, instead of
aborting the run.

Follow-ups (separate PRs): harden the SessionStore / socket.io timing specs
to not leak (fake timers); remove the now-unneeded diagnostic scaffolding
(diagnostics.ts heartbeat/node-report, the ether#7846 OS sidecar) now that the
cause is known.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): run Windows backend tests on Node 25 to dodge the libuv connect overrun

Node 24.x's bundled libuv has a stack buffer overrun in the Windows TCP-connect
path (uv__tcp_connect / uv__tcp_try_connect), proven by a SilentProcessExit
full-memory dump of the dying mocha process: the main thread executes
__fastfail(FAST_FAIL_STACK_COOKIE_CHECK_FAILURE) from __report_gsfailure with
TCPWrap::Connect -> uv_tcp_connect on the stack. It fires under the backend
suite's heavy localhost connection churn, is address-family independent (occurs
on both sockaddr_in and sockaddr_in6, so an IPv4 pin does NOT help), and -- being
memory corruption -- bypasses all JS/Node observability, rotating across tests
as the "silent ELIFECYCLE" flake (~22% of Windows runs).

Empirically: Node 25 = 16/16 green; Node 24 (even with an IPv4 pin) = ~39% fail.
Node 25's newer bundled libuv does not overrun. Linux stays on Node 24 LTS (the
bug is Windows-specific). Revisit once the libuv fix is backported to 24.x.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): pin Windows backend to Node 24.16.0 (libuv fix) instead of 25

Bisect (standalone repro) pinpointed the fix to Node 24.16.0 (libuv 1.52.1):
24.15.0 (libuv 1.51.0) crashes the connect overrun 4/4 on 127.0.0.1, while
24.16.0 is clean 0/8. 24.16.0 stays on the Node 24 "Krypton" LTS line, so prefer
it over Node 25 (non-LTS). Pinned explicitly because setup-node's default
check-latest:false reuses the runner's pre-cached 24.15.0 for a bare "24".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(ci): reference upstream nodejs/node#63620 in the Windows Node-pin comment

Links the explicit 24.16.0 pin to the filed upstream issue so the pin can be
dropped back to plain "24" once the libuv connect-overrun fix is across the
supported 24.x baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ether#7868)

* test(ci): remove the silent-ELIFECYCLE investigation scaffolding

The Windows backend flake is root-caused (server.ts handler gate for the
in-process process.exit path; Windows pinned to Node 24.16.0 for the libuv
TCP-connect overrun, tracked upstream as nodejs/node#63620). Remove the
temporary diagnostics added while hunting it:

- delete src/tests/backend/diagnostics.ts (per-test heartbeat + node-report
  snapshots) and its `--require` from the backend `test` script;
- drop the `--report-on-fatalerror`/`-on-signal`/`-uncaught-exception`
  `NODE_OPTIONS` and the "Upload Node diagnostic reports" steps;
- drop the Windows OS-level netstat/tasklist sidecar watcher.

Kept: the real fixes — the log-only unhandledRejection guard in
tests/backend/common.ts, the lowerCasePadIds socket-teardown tracking (comment
de-referenced from the deleted file), and `pnpm test -- --exit` on Windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: drop the obsolete report-on-fatalerror NODE_OPTIONS guard assertion

The backend-tests-flake-mitigation source-lint guard required every backend
step to keep the --report-on-fatalerror NODE_OPTIONS + node-report upload. Those
diagnostics are removed in this PR now that the flake is root-caused, so drop
that assertion. Retain the Windows-only `--exit` checks (still a live invariant)
and reframe the file around the resolved root cause.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ith 8 updates (ether#7867)

Bumps the dev-dependencies group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-visually-hidden](https://github.com/radix-ui/primitives) | `1.2.3` | `1.2.4` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.59.4` | `8.60.0` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.59.4` | `8.60.0` |
| [i18next](https://github.com/i18next/i18next) | `26.2.0` | `26.3.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.16.0` | `1.17.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.0` | `7.76.1` |
| [zustand](https://github.com/pmndrs/zustand) | `5.0.13` | `5.0.14` |
| [oxc-minify](https://github.com/oxc-project/oxc/tree/HEAD/napi/minify) | `0.132.0` | `0.133.0` |



Updates `@radix-ui/react-visually-hidden` from 1.2.3 to 1.2.4
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@typescript-eslint/eslint-plugin` from 8.59.4 to 8.60.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.59.4 to 8.60.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.0/packages/parser)

Updates `i18next` from 26.2.0 to 26.3.0
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](i18next/i18next@v26.2.0...v26.3.0)

Updates `lucide-react` from 1.16.0 to 1.17.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.17.0/packages/lucide-react)

Updates `react-hook-form` from 7.76.0 to 7.76.1
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](react-hook-form/react-hook-form@v7.76.0...v7.76.1)

Updates `zustand` from 5.0.13 to 5.0.14
- [Release notes](https://github.com/pmndrs/zustand/releases)
- [Commits](pmndrs/zustand@v5.0.13...v5.0.14)

Updates `oxc-minify` from 0.132.0 to 0.133.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/napi/minify/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/crates_v0.133.0/napi/minify)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-visually-hidden"
  dependency-version: 1.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: i18next
  dependency-version: 26.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: lucide-react
  dependency-version: 1.17.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: oxc-minify
  dependency-version: 0.133.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.76.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: zustand
  dependency-version: 5.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [ueberdb2](https://github.com/ether/ueberDB) from 6.1.2 to 6.1.3.
- [Changelog](https://github.com/ether/ueberDB/blob/main/CHANGELOG.md)
- [Commits](ether/ueberDB@v6.1.2...v6.1.3)

---
updated-dependencies:
- dependency-name: ueberdb2
  dependency-version: 6.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Port of upstream ether#7605

Converts backend tests from mocha to vitest and migrates to ESM.
@deepshekhardas deepshekhardas force-pushed the fix/pr-7605-esm-vitest branch from ef58b53 to d5732a8 Compare June 2, 2026 02:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants