Commit graph

9721 commits

Author SHA1 Message Date
John McLear
0e8548e530 feat(admin): document POST /admin-auth/ in OpenAPI (#7693)
Adds verifyAdminAccess as the operation that the admin UI's LoginScreen
and App session check both call. Documents Basic auth, session cookie,
and anonymous request modes plus their 200/401/403 responses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:59:24 +01:00
John McLear
633d0a493d feat(admin): stub OpenAPI document for admin endpoints (#7693)
Adds generateAdminDefinition() returning a minimal valid OpenAPI 3.0
document with no paths yet, plus security schemes for the two auth
modes (Basic + session cookie). Subsequent tasks fill in the actual
admin paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:58:37 +01:00
John McLear
038a211e5c docs(admin): implementation plan for admin OpenAPI coverage (#7693)
Step-by-step task breakdown for the schema-only PR: stub the document,
add /admin-auth/ + /admin/update/status paths and sub-schemas,
collision regression, /admin/openapi.json route, codegen merge, and CI
verification. No call-site migrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:57:04 +01:00
John McLear
5dc662a728 docs(admin): correct UpdateStatus schema to base-branch shape (#7693)
The first draft mirrored the Tier 2 (#7607) response shape, but this PR
stacks on #7695 whose updateStatus.ts only emits the Tier 1 fields. Fix
the schema, install-method enum, and tier enum to match types.ts and
the actual handler. Tier 2 amends UpdateStatus when it lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:53:04 +01:00
John McLear
d8f16c4f79 docs(admin): design for admin OpenAPI coverage (#7693)
Adds the design doc for documenting `/admin-auth/*` and `/admin/update/status`
in the OpenAPI spec so the typed client generated by #7695 (`admin/src/api/
schema.d.ts`) gains admin call-sites the day it lands.

This PR is stacked on #7695 — codegen rails must merge first. Schema-only,
no call-site migrations (those are an explicit follow-up named in #7693).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:52:02 +01:00
John McLear
40fe8a4c23 chore(admin): gitignore generated schema/version, regen on every script (#7638)
Qodo flagged the committed admin/src/api/schema.d.ts as a build artifact
that violates the rule against committing generated files (rule 467291,
"Exclude build artifacts and runtime-generated files from version control").

This commit:
- Adds admin/src/api/{schema.d.ts,version.ts} to .gitignore.
- Removes both from VCS (the prior squash had committed them).
- Chains \`gen:api\` into the dev and test scripts so a fresh checkout
  lands a working dev server / test run without an extra step. build
  and build-copy already chained gen:api.
- Drops the now-redundant CI freshness diff step from
  frontend-admin-tests.yml — with the files no longer committed, the
  build step's gen:api invocation is the only check needed.
- Updates admin/README.md to describe the new generated-file workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:56:48 +01:00
John McLear
b97baa3ef5 chore(admin): typesafe API client + TanStack Query rails (#7638) (#2)
* docs(admin): design for typesafe API client + TanStack Query rails (#7638)

Rails-only scope: codegen toolchain, runtime client, and provider — no
call-site migrations. Admin endpoints are not yet covered by the OpenAPI
spec, so a separate issue will follow before any migration is useful.

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

* docs(admin): implementation plan for typesafe API client rails (#7638)

Step-by-step task breakdown for the rails-only PR: codegen toolchain,
runtime client, TanStack Query provider, CI freshness check, docs. No
call-site migrations until admin endpoints are added to the OpenAPI
spec (separate follow-up).

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

* feat(api): export generateDefinitionForVersion from openapi hook

Required by the admin codegen script (#7638) to dump the OpenAPI spec
without booting Express. No behavior change for the request hook.

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

* chore(admin): add OpenAPI codegen + TanStack Query deps (#7638)

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

* chore(admin): add OpenAPI spec dump entry (#7638)

Loaded via tsx by gen-api.mjs in the next commit. Writes JSON to a file
path argument so log4js stdout output (from Settings init) does not
pollute the spec output.

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

* chore(admin): wire OpenAPI codegen into build (#7638)

Adds gen:api script and amends build/build-copy to regenerate
admin/src/api/schema.d.ts before compiling. The generated file is
checked in so it shows up in PR review and so a fresh checkout doesn't
need codegen to typecheck.

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

* feat(admin): typed openapi-fetch + react-query client (#7638)

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

* feat(admin): TanStack Query provider, dev-only devtools (#7638)

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

* feat(admin): mount TanStack Query provider at root (#7638)

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

* test(admin): smoke test for typed openapi-fetch client (#7638)

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

* ci(admin): verify generated OpenAPI schema is up to date (#7638)

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

* docs(admin): document OpenAPI codegen workflow (#7638)

Replaces the default Vite scaffold README with admin-specific scripts
table and codegen workflow notes.

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

* build(admin): exclude __tests__ from tsc include (#7638)

The smoke test imports node:test/node:assert which need @types/node.
Admin source is browser-only, so excluding __tests__ from the production
typecheck is cleaner than adding Node types to the bundle config. The
test still runs under tsx, which doesn't share this constraint.

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

* chore: regenerate lockfile with pnpm 10 to restore overrides block (#7638)

Adding admin deps with pnpm 11 stripped the top-level \`overrides:\`
section from pnpm-lock.yaml, which CI uses pnpm 10 to verify. Result:
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH on every job. Re-running pnpm 10
\`install --lockfile-only\` restores the overrides block; the new admin
package entries land in the same commit. Two stale lockfile entries
not present in package.json (\`serialize-javascript\` version pin and
\`uuid@<14.0.0\`) were normalized by the regen — package.json is the
source of truth for those.

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

* build(docker): preserve strictDepBuilds=false in trimmed workspace yaml (#7638)

Adding tsx as an admin devDep brings esbuild@0.27.x into the resolved
subgraph, and the Dockerfile's runtime stage was overwriting
pnpm-workspace.yaml with a stripped-down version that lost the
strictDepBuilds=false setting from the source repo. With pnpm 10's
default of strictDepBuilds=true, the install then errors on
ERR_PNPM_IGNORED_BUILDS for esbuild + scarf rather than warning.

Restore the strictDepBuilds=false and the @scarf/scarf ignore in the
trimmed yaml so the production install matches develop's behavior.

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

* fix(admin): point client baseUrl at /api/<version> via codegen (#7638)

Qodo flagged: with baseUrl='/' and schema paths like '/createGroup',
calls landed at /createGroup, but the backend mounts the FLAT-style
spec under /api/<version>/. So once a call site lands, every request
404s.

gen:api now also emits admin/src/api/version.ts containing
LATEST_API_VERSION (read from info.version in the spec) and a derived
API_BASE_URL = `/api/<version>`. client.ts imports API_BASE_URL.
Workflow freshness check covers both generated files.

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

* build(admin): cross-platform spawn in gen-api.mjs (#7638)

Windows CI failed because spawnSync('pnpm', ...) cannot resolve
pnpm.cmd without a shell. Set shell:true on win32 only so Linux/macOS
runs avoid Node's DEP0190 warning. All spawn args are literal strings,
so the shell variant is not an injection risk.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:33:59 +01:00
translatewiki.net
bcf5c91c15
Localisation updates from https://translatewiki.net. 2026-05-07 14:03:09 +02:00
John McLear
a356414254
fix(socialMeta): coerced numeric/boolean override silently dropped (#7692)
* fix(socialMeta): coerced numeric/boolean override silently dropped

Qodo flagged on PR #7691: Settings.coerceValue() turns numeric-looking env
vars into numbers and "true"/"false" into booleans, so e.g.
SOCIAL_META_DESCRIPTION="2026" arrives at the resolver as the number 2026.
The previous resolver gated on `typeof override === 'string'`, so it
silently fell back to the i18n catalog with no warning — the operator's
docker config would appear broken.

Accept string|number|boolean and stringify before the empty-check; null /
undefined / unsupported types still fall through to the catalog. Two new
unit specs cover the numeric and boolean coercion paths.

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

* fix(socialMeta): widen description type to match coerced runtime

Action Qodo bug-find: SettingsType.socialMeta.description and
SocialMetaSettings.description still claimed `string | null`, but
Settings.coerceValue() can produce number|boolean from env-var-driven
config — the previous resolver fix was correct at runtime but the type
mismatch forced `as unknown as string` casts in the new tests.

Widen both declared types to `string | number | boolean | null` to match
runtime reality, drop the typeof string|number|boolean guards in the
resolver (the union now narrows automatically) and remove the test casts.
Behaviour is unchanged.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:38:17 +08:00
John McLear
90aafb115e
feat(socialMeta): settings.socialMeta.description override (#7599 follow-up) (#7691)
Issue #7599 follow-up from @stffen: the OG description has no obvious
settings.json knob and the i18n catalog default is English, but most
preview crawlers (WhatsApp, Signal, Slack, Telegram, Facebook) don't
send Accept-Language and so always hit the English fallback regardless
of how many locale files translate `pad.social.description`.

Keep the i18n catalog as the default source — translatable strings
belong in locale files, per the original Qodo review on PR #7635 — but
add an explicit `socialMeta.description` setting that wins when set as
a non-empty string, regardless of negotiated language. This is the
lever that fixes the crawler case for non-English instances without
re-introducing per-language config in settings.json (operators who
want that still use customLocaleStrings).

- Empty/whitespace overrides are treated as unset (would otherwise
  silently blank the preview).
- Override is HTML-escaped via the same path as every other value.
- og:locale stays language-negotiated; only the description is forced.
- Documented next to publicURL in settings.json.template and
  settings.json.docker (env var SOCIAL_META_DESCRIPTION). The
  customLocaleStrings example now spells out pad.social.description so
  operators discover both routes.

5 new unit specs + 4 new integration specs cover override-wins,
null/missing fallback, blank-treated-as-unset, and HTML-escaping.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:12:23 +08:00
John McLear
ab0cff4d95
fix(7606): sync theme-color meta with client-side dark-mode switch (#7690)
* fix(7606): sync theme-color meta with client-side dark-mode switch

PR #7636 emits <meta name="theme-color"> server-side from
settings.skinVariants. That covers operators who hard-code a dark
toolbar in settings.json, but not the runtime path: pad.ts auto-flips
the toolbar to super-dark when enableDarkMode is on, the browser
reports prefers-color-scheme: dark, and no localStorage white-mode
override is set, plus the user can flip it via #options-darkmode.
Both paths run skinVariants.updateSkinVariantsClasses(), which until
now never touched the meta — so dark-mode users kept the light
#ffffff baseline and saw a white address bar above a dark toolbar
(stffen on #7606 after 2.7.3).

Push the toolbar-color lookup into updateSkinVariantsClasses so the
meta tracks every class change: the auto-switch on init, the user
toggle, and the skinVariants builder. Mirrors the CSS-source-order
table from src/node/utils/SkinColors.ts (last matching *-toolbar
token wins). When no meta is present (non-colibris skin, server
omits it) the helper is a no-op.

Adds Playwright coverage for both paths under
colorScheme: 'light' (manual toggle) and 'dark' (auto-switch on
dark-OS clients — the case stffen reported).

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

* fix(theme-color): action Qodo PR review

(1) Bug — duplicated toolbar→color table: extract the CSS-source-order
mapping and the default-color constant into src/static/js/skin_toolbar_colors,
re-imported by both src/node/utils/SkinColors.ts (server, EJS template
helper) and src/static/js/skin_variants.ts (client, runtime updates). Lives
under static/js so the browser bundle can resolve it; server-side imports
of static/js modules already exist (Changeset, AttributeMap, ImportHtml,
hooks). One source of truth means a future palette change can no longer
silently desync the server-rendered baseline meta from the client updates,
which was the exact regression that brought us here.

(2) Rule violation — 4-space continuation indentation in the new
Playwright spec: re-indent the themeColor helper and the multiline
test(...) call to the repo's 2-space rule (.editorconfig).

Existing backend coverage (configuredToolbarColor unit tests + the
specialpages server-render checks for both pad and timeslider) still
passes against the refactored helper, so it's regression-locked end to
end.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:43:29 +01:00
John McLear
fe9727b31e
fix(docker): share corepack cache so etherpad user can resolve pnpm (#7689)
* fix(docker): share corepack cache so etherpad user can resolve pnpm (#7687)

PR #7674 switched the Dockerfile from `npm install -g pnpm` to corepack
and `corepack prepare pnpm@${PnpmVersion} --activate`. The activate step
runs as root and writes its lastKnownGood pin into `$COREPACK_HOME`,
which defaults to `~/.cache/node/corepack` — i.e. a per-user path. The
Dockerfile then drops to `USER etherpad` and later runs
`bin/installLocalPlugins.sh`, which invokes `pnpm` as etherpad. With an
empty per-user corepack cache and no shared activation file, corepack
re-resolves pnpm and (for forks/configs without a `packageManager` pin
matching the activated version) can fall back to "latest" from the
registry — pulling `pnpm@10.33.4` instead of the requested 11.x and
failing the workspace's `engines.pnpm` check.

Pin `COREPACK_HOME=/opt/corepack` and chown it to etherpad after the
prepare step. Both root and etherpad now share the same lastKnownGood
file and tarball cache, so etherpad inherits the activated pnpm without
hitting the registry again.

Verified end-to-end:

- `docker build --target development --build-arg ETHERPAD_LOCAL_PLUGINS=ep_test`
  with a stub local plugin runs `installLocalPlugins.sh` cleanly:
  `Done in 16.6s using pnpm v11.0.6`.
- `docker run ... pnpm --version` as etherpad reports 11.0.6 from the
  shared cache — no "Unsupported environment" error.

Note: corepack still emits a one-time "about to download" line at
runtime because `corepack prepare pnpm@11.0.6` resolves to the highest
matching patch (11.0.8) at build time while the project's
`packageManager` field pins exactly 11.0.6. That's a follow-up — the
download succeeds non-interactively and the engine check passes.

Fixes #7687.

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

* chore(docker): action Qodo PR review (#7687 follow-up)

- Replace hard-coded /opt/corepack with ${COREPACK_HOME} in mkdir/chown
  so the env var stays the single source of truth (Qodo: "COREPACK_HOME
  path duplication").

- Add a build-test-local-plugin job to .github/workflows/docker.yml that
  builds the development target with a stub ETHERPAD_LOCAL_PLUGINS so
  the original failure mode (corepack/pnpm cache invisible across the
  USER switch) cannot silently regress (Qodo: "COREPACK_HOME fix lacks
  test"). The job is small — `docker build` only, no run — and uses the
  shared GHA buildx cache.

Verified: same docker build + `docker run pnpm --version` flow on the
variable form gives identical output (pnpm 11.0.6 from the etherpad-owned
cache).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:09:27 +01:00
John McLear
b751fd7b9e
feat(settings): enable Pad-wide Settings by default; fix misleading modal title (#7679)
* feat(settings): enable Pad-wide Settings by default; fix misleading modal title

The creator-owned Pad-wide Settings feature (#7545) shipped behind a flag that
defaulted to false. With the flag off the modal still rendered an H1 of
`pad.settings.padSettings` ("Pad-wide Settings") for *every* user, even though
no pad-wide controls were ever shown. Two readers in different browsers both
saw "Pad-wide Settings" as the modal title, which looked like a creator-gate
regression but was just a copy bug.

Two changes:

1. Flip the default of `enablePadWideSettings` to `true` (Settings.ts plus
   both settings templates). With the feature on, the creator (revision-0
   author) gets a real "Pad-wide Settings" section gated by
   `clientVars.canEditPadSettings`, while every other user sees only "User
   Settings" — matching the design intent of #7545. This is a behavior change,
   so the settings comments are expanded to describe what the toggle now does.

2. Drop the conditional H1 in `src/templates/pad.html` and always use
   `pad.settings.title` ("Settings"). Operators who explicitly disable the
   feature shouldn't see a label that lies about a section that isn't
   rendered.

Adds backend regression coverage in `tests/backend/specs/socketio.ts`:
- Different browsers (different cookie jars => different authorIDs): only the
  first joiner gets `canEditPadSettings: true`.
- Same browser, two tabs (shared HttpOnly token cookie => same authorID):
  both connections are the same identity, both correctly land on the creator
  path.

* test(settings): regression coverage for the settings modal H1

Asserts the rendered `/p/<id>` HTML always uses
`data-l10n-id="pad.settings.title"` for the modal heading, regardless of
`enablePadWideSettings`. Catches a re-introduction of the old conditional
that printed "Pad-wide Settings" for every user when the feature was off.

Action of Qodo review feedback on PR #7679.
2026-05-07 10:01:04 +01:00
John McLear
da87a4fca2
fix(7686): username 'false' / 'malformed color: false' for legacy settings.json (#7688)
* fix(7686): legacy padOptions.userName/userColor=false breaks pad

Settings.json files generated before December 2021 used `false` as the
default for these two string options (commit 8c857a85a switched the
template default to `null` and noted "this change has no effect due to
a bug in how pad options are processed; that bug will be fixed in a
future commit" — the follow-up never landed). pad.ts:getParams() then
runs `false.toString()`, the resulting string "false" passes the
`!== false` sentinel check at _afterHandshake, and notifyChangeName
ships USERINFO_UPDATE with name="false" and colorId="false" (clobbered
via clientVars.userColor). The server's hex regex rejects the colour
and throws `malformed color: false`; the user sees their name as
"false" and a white swatch.

Defense in depth:
- Server: Settings.ts::reloadSettings() coerces legacy boolean false
  to null for padOptions.userName / padOptions.userColor and warns the
  operator, matching the existing disableIPlogging shim pattern.
- Client: the pad.ts userName / userColor callbacks reject the
  literal "false" string so URL params (?userName=false) and any
  other path that surfaces the sentinel as a string are also no-ops.
- Backend regression test mirrors the shim and asserts it normalizes
  legacy false, leaves explicit values intact, leaves null untouched,
  does not coerce other padOptions keys, and does not coerce the
  string "false" (that path is the client guard's responsibility).

Closes #7686

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

* fix(7686): guard padOptions shim against non-object config (Qodo)

Qodo flagged that storeSettings() will overwrite settings.padOptions
raw with whatever settings.json supplies — including null, primitives,
or arrays — which would make the new userName/userColor shim crash on
property access. Add a shape guard so the shim is a no-op for malformed
padOptions, and extend the regression test to cover null / primitive /
array shapes.

This doesn't change which configs work (Pad.ts also assumes padOptions
is an object and would already crash on a null padOptions when a pad
is opened) but it stops the shim from being the loud thing in the
stack trace if someone hits it.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:32:17 +01:00
Etherpad Release Bot
45b000d533 Merge branch 'master' into develop 2026-05-06 21:41:47 +00:00
Etherpad Release Bot
07902d3da1 Merge branch 'develop' 2026-05-06 21:41:47 +00:00
Etherpad Release Bot
db602bcb70 bump version 2026-05-06 21:41:46 +00:00
SamTV12345
5e74928317 chore: use jq for version replacement [skip ci] 2026-05-06 23:40:23 +02:00
SamTV12345
ad9f424eef chore: use --no-git-tag-version for pnpm version to support dirty trees during release 2026-05-06 23:28:33 +02:00
SamTV12345
0b0883c02b chore: allow installs on release 2026-05-06 23:19:07 +02:00
SamTV12345
484d48ad46 chore: update changelog for 2.7.3 release 2026-05-06 22:16:35 +02:00
SamTV12345
bfdbd2bb91
chore: removed axios (#7685)
* chore: removed axios

* chore: pnpm
2026-05-06 22:06:04 +02:00
SamTV12345
6c7598d88b chore: fixed deb package 2026-05-06 22:04:39 +02:00
John McLear
958590d1c8
chore(docker): clear most CVEs in published image (npm/pnpm/uuid + drop curl) (#7674)
* chore(docker): clear most CVEs in published image — npm/pnpm/uuid + drop curl

Cuts published-image vulnerabilities from 18 (4H/13M/1L) across 8 packages
to 12 (2H/9M/1L) across 3 packages. The remaining three (curl/libcurl,
git, busybox) are all upstream Alpine 3.23 packages with "not fixed"
status — libcurl is pulled in transitively by git and cannot be
removed independently.

Changes:

- Provision pnpm via corepack instead of `npm install -g pnpm`, then
  remove the bundled npm. The base image's npm@10.9.7 ships old
  transitives (picomatch 4.0.3 → CVE-2026-33671/33672, brace-expansion
  2.0.2 → CVE-2026-33750) that we don't otherwise need at runtime;
  corepack handles pnpm directly without npm. Fixes 1H + 1M.

- Bump PnpmVersion 10.28.2 → 10.33.2 to align with the rest of the
  workflow and pull in pnpm's patched bundled brace-expansion (5.0.5
  vs 5.0.4). Fixes 1M.

- Add `uuid@<14.0.0` → `>=14.0.0` to pnpm.overrides
  (GHSA-w5hq-g745-h8pq). Fixes 1M.

- Drop `curl` from the runtime apk add list and switch HEALTHCHECK to
  wget (busybox built-in). curl was only invoked by the healthcheck and
  by dev/CI scripts that don't run in the container. Removes the curl
  CLI binary; libcurl remains as a git transitive dep, so the
  `apk/alpine/curl` advisories scout reports against libcurl persist
  but aren't reachable from any code we ship. As a side-effect this
  also clears nghttp2 (CVE-2026-27135) which was a curl-CLI dep.

- Switch HEALTHCHECK URL from `localhost` to `127.0.0.1` — alpine/musl
  resolves localhost to ::1 first and Etherpad only binds IPv4.

Verified locally: docker build → docker run → healthy → docker scout
cves shows 12 CVEs / 3 packages.

* fix(docker): refresh corepack before preparing pnpm (Qodo)

Node 22's bundled corepack ships a stale signing-key list and can reject
newer pnpm releases (nodejs/corepack#612), which would fail the image
build at `corepack prepare`. Mirror the snap/snapcraft.yaml workaround:
`npm install -g corepack@latest` before activating pnpm, in both
adminbuild and build stages. npm is still removed afterwards.

* docs(changelog): note docker image dropping curl/npm/npx (Qodo)

Address Qodo's "backwards-incompatible change without mitigation" rule
violations by documenting the removal in the 2.7.3 breaking-changes
section. Operators who exec into the container can apk add curl on
demand or use the busybox wget / pnpm already present.

* chore: pnpm

* chore: pnpm

* chore: pnpm

* chore: pnpm

* chore: pnpm

* chore: pnpm

* chore: pnpm

* chore: pnpm

* chore: pnpm

---------

Co-authored-by: SamTV12345 <40429738+samtv12345@users.noreply.github.com>
2026-05-06 22:00:13 +02:00
dependabot[bot]
fb09b11ff5
build(deps): bump lru-cache from 11.3.5 to 11.3.6 (#7671)
Bumps [lru-cache](https://github.com/isaacs/node-lru-cache) from 11.3.5 to 11.3.6.
- [Changelog](https://github.com/isaacs/node-lru-cache/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-lru-cache/compare/v11.3.5...v11.3.6)

---
updated-dependencies:
- dependency-name: lru-cache
  dependency-version: 11.3.6
  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>
2026-05-06 20:16:16 +02:00
dependabot[bot]
045505b0e9
build(deps): bump axios from 1.15.2 to 1.16.0 (#7672)
Bumps [axios](https://github.com/axios/axios) from 1.15.2 to 1.16.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.2...v1.16.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.16.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>
2026-05-06 20:16:12 +02:00
dependabot[bot]
4accea429b
build(deps): bump actions/download-artifact from 4 to 8 (#7668)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 20:12:20 +02:00
dependabot[bot]
25dd8e492e
build(deps): bump actions/upload-artifact from 4 to 7 (#7669)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 20:12:12 +02:00
John McLear
70415714e6
fix(socketio): don't kick authenticated duplicate-author sessions (#7656) (#7678)
* fix(socketio): don't kick authenticated duplicate-author sessions (#7656)

The CLIENT_READY handler kicks any prior socket whose authorID matches the
joining socket's, originally as a workaround for stale tabs in the same
browser (cookie-derived authorIDs were per-browser, so "same authorID, same
pad" reliably meant "page refresh / second tab in this browser").

With stable identities (basic auth, SSO, apikey, getAuthorId hook) the same
authorID can legitimately appear across windows or devices, so the kick
disconnects real concurrent sessions. Skip the kick when the joining socket
has req.session.user set; cookie-only sessions keep the existing behavior so
the userdup modal and the xxauto_reconnect path still work.

* fix(socketio): suppress USER_LEAVE when other same-author sockets remain

With the duplicate-author kick disabled for authenticated sessions, a single
authorID can legitimately span multiple sockets in one pad. handleDisconnect
was emitting USER_LEAVE on every socket close, which made clients (whose
presence is keyed by authorID) drop the author entirely even when another
socket of theirs was still online.

Only broadcast USER_LEAVE — and only run the userLeave hook — when the
disconnecting socket is the last one in the pad for that author.

Adds two backend tests:
- authenticated identity: closing one of two same-author sockets does NOT
  emit USER_LEAVE on the other.
- different authors (regression): closing socket A still emits USER_LEAVE
  for socket B.

Action of Qodo review feedback on PR #7678.
2026-05-06 20:08:36 +02:00
dependabot[bot]
d9e1be04f4
build(deps): bump mssql from 12.5.0 to 12.5.2 (#7675)
Bumps [mssql](https://github.com/tediousjs/node-mssql) from 12.5.0 to 12.5.2.
- [Release notes](https://github.com/tediousjs/node-mssql/releases)
- [Changelog](https://github.com/tediousjs/node-mssql/blob/master/CHANGELOG.txt)
- [Commits](https://github.com/tediousjs/node-mssql/compare/v12.5.0...v12.5.2)

---
updated-dependencies:
- dependency-name: mssql
  dependency-version: 12.5.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>
2026-05-06 20:07:53 +02:00
dependabot[bot]
96c032ae3e
build(deps): bump rate-limiter-flexible from 11.0.1 to 11.1.0 (#7683)
Bumps [rate-limiter-flexible](https://github.com/animir/node-rate-limiter-flexible) from 11.0.1 to 11.1.0.
- [Release notes](https://github.com/animir/node-rate-limiter-flexible/releases)
- [Commits](https://github.com/animir/node-rate-limiter-flexible/compare/v11.0.1...v11.1.0)

---
updated-dependencies:
- dependency-name: rate-limiter-flexible
  dependency-version: 11.1.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>
2026-05-06 20:07:45 +02:00
dependabot[bot]
1e156660e9
build(deps): bump @elastic/elasticsearch from 9.3.4 to 9.4.0 (#7682)
Bumps [@elastic/elasticsearch](https://github.com/elastic/elasticsearch-js) from 9.3.4 to 9.4.0.
- [Release notes](https://github.com/elastic/elasticsearch-js/releases)
- [Changelog](https://github.com/elastic/elasticsearch-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/elastic/elasticsearch-js/compare/v9.3.4...v9.4.0)

---
updated-dependencies:
- dependency-name: "@elastic/elasticsearch"
  dependency-version: 9.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>
2026-05-06 20:07:36 +02:00
dependabot[bot]
74e769a182
build(deps): bump express-rate-limit from 8.4.1 to 8.5.1 (#7681)
Bumps [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) from 8.4.1 to 8.5.1.
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.4.1...v8.5.1)

---
updated-dependencies:
- dependency-name: express-rate-limit
  dependency-version: 8.5.1
  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>
2026-05-06 20:07:26 +02:00
dependabot[bot]
629c7541cb
build(deps-dev): bump the dev-dependencies group across 1 directory with 7 updates (#7684)
Bumps the dev-dependencies group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [sinon](https://github.com/sinonjs/sinon) | `21.1.2` | `22.0.0` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.59.1` | `8.59.2` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.59.1` | `8.59.2` |
| [i18next](https://github.com/i18next/i18next) | `26.0.8` | `26.0.9` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.14.2` | `7.15.0` |
| [zustand](https://github.com/pmndrs/zustand) | `5.0.12` | `5.0.13` |
| [oxc-minify](https://github.com/oxc-project/oxc/tree/HEAD/napi/minify) | `0.128.0` | `0.129.0` |



Updates `sinon` from 21.1.2 to 22.0.0
- [Release notes](https://github.com/sinonjs/sinon/releases)
- [Changelog](https://github.com/sinonjs/sinon/blob/main/docs/changelog.md)
- [Commits](https://github.com/sinonjs/sinon/compare/v21.1.2...v22.0.0)

Updates `@typescript-eslint/eslint-plugin` from 8.59.1 to 8.59.2
- [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.2/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.59.1 to 8.59.2
- [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.2/packages/parser)

Updates `i18next` from 26.0.8 to 26.0.9
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.8...v26.0.9)

Updates `react-router-dom` from 7.14.2 to 7.15.0
- [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.15.0/packages/react-router-dom)

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

Updates `oxc-minify` from 0.128.0 to 0.129.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.129.0/napi/minify)

---
updated-dependencies:
- dependency-name: sinon
  dependency-version: 22.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.59.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.59.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: i18next
  dependency-version: 26.0.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: react-router-dom
  dependency-version: 7.15.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: zustand
  dependency-version: 5.0.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: oxc-minify
  dependency-version: 0.129.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>
2026-05-06 20:07:14 +02:00
translatewiki.net
796968e517
Localisation updates from https://translatewiki.net. 2026-05-04 14:02:42 +02:00
John McLear
57758f4aaa
test(ci): stronger diagnostics for silent backend-test exit (follow-up to #7663) (#7665)
* test(ci): stronger diagnostics for silent backend-test exit

PR #7663 added unhandledRejection / uncaughtException handlers in
common.ts. The next failure after merge (run 25279692065 - Windows
without plugins, Node 24) showed mocha exiting with code 1 mid-suite
261ms after the last passing test, with NEITHER handler firing. So
something more drastic is killing the process - SIGKILL, OOM, fatal
native error - or mocha itself called process.exit before the JS
handlers in common.ts could run.

Two issues with the previous attempt:

1. Handlers in common.ts only register when a spec imports common.ts.
   Only 27 of 47 specs do. If a non-common spec triggers the death,
   handlers may never have been registered.

2. process.stderr.write is asynchronous on Windows when stderr is
   piped (which it is under GitHub Actions). On a hard kill the buffered
   line never reaches the runner log.

This patch:

- Moves diagnostic handlers to a dedicated tests/backend/diagnostics.ts
  loaded via mocha --require, so they register at startup before any
  spec runs.
- Uses fs.writeSync(2, ...) for synchronous stderr writes that the
  kernel completes before returning - the line lands in the log even
  if the process is killed milliseconds later.
- Adds beforeExit / exit / signal handlers so we can discriminate the
  exit mechanism: clean drain vs process.exit vs SIGKILL vs signal.
- Tracks last-seen test via mocha root afterEach hook so the death
  point is visible in the log.

The next CI failure should print enough context to identify the cause,
after which we can fix the real bug and drop this file.

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

* fix(diagnostics): exit(1) on uncaughtException so fatal errors fail fast

Qodo flagged on PR #7665: the uncaughtException handler in
tests/backend/diagnostics.ts only logged and returned. Once a handler is
registered, Node no longer exits on its own. Specs that don't import
tests/backend/common.ts (20 of 47) have only this handler — so a fatal
error would have been swallowed and tests would limp along instead of
failing fast.

Mirror common.ts and call process.exit(1) after logging.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:56:55 +01:00
John McLear
a0011f286d
test: also tag userlist_click_to_chat describe with @feature:username (#7664)
Every test in this describe block depends on each user having a
settable, displayable username — clicking a userlist row reads the
user's display name, prefills `@<name>` in the chat input, etc.
ep_disable_change_author_name disables exactly that machinery and
its CI fails the three userlist_click_to_chat cases (#86) for
exactly this reason.

Add the second tag so plugins declaring
`disables: ["@feature:username"]` opt out via the disables contract.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:52:15 +08:00
John McLear
69bb1e19c5
feat(gdpr): author erasure (PR5 of #6701) (#7550)
* docs: PR5 GDPR author erasure design spec

* docs: PR5 GDPR author erasure implementation plan

* feat(gdpr): AuthorManager.anonymizeAuthor — Art. 17 erasure

* test(gdpr): AuthorManager.anonymizeAuthor unit tests

* feat(gdpr): REST anonymizeAuthor on API version 1.3.1

* test(gdpr): REST anonymizeAuthor end-to-end

* docs(gdpr): right-to-erasure section + anonymizeAuthor example

* fix(gdpr): make anonymizeAuthor resumable on partial failure

Qodo review: the `erased: true` sentinel was written before the chat
scrub loop, so a throw during scrub left chat messages untouched
while subsequent calls short-circuited on `existing.erased` and never
finished. Split the write: zero the display identity first (still
hides the name), run the chat scrub, and only then stamp
`erased: true` so a retry resumes the sweep. Regression test
covers the partial-run → retry path.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:30:49 +01:00
John McLear
487842006c
feat(gdpr): configurable privacy banner (PR4 of #6701) (#7549)
* docs: PR4 GDPR privacy banner design spec

* docs: PR4 GDPR privacy banner implementation plan

* feat(gdpr): typed privacyBanner setting block + public getter exposure

* feat(gdpr): send privacyBanner config to the browser via clientVars

* feat(gdpr): privacy banner DOM (hidden by default)

* feat(gdpr): render privacy banner on pad load when enabled

* style(gdpr): privacy banner layout

* test+fix(gdpr): privacy banner Playwright + hidden-attr CSS override

* docs(gdpr): privacyBanner configuration section

* fix(gdpr): reject unsafe learnMoreUrl schemes

Qodo review: showPrivacyBannerIfEnabled assigned config.learnMoreUrl
directly to <a href>, so a misconfigured settings.privacyBanner.
learnMoreUrl of `javascript:alert(1)` or `data:…<script>…` would run
script on click. Validate via URL parsing and allow only http(s) /
mailto; everything else yields no link. Playwright regression guards
the four cases (javascript, data, https, mailto).

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

* fix(privacy-banner): drop unneeded !important on [hidden] rule

Class+attribute selector already outranks `.privacy-banner { display: flex }`
on specificity (0,2,0 vs 0,1,0), so `!important` was redundant. Adds a
comment explaining why so a future reader doesn't put it back.

Per Sam's review on #7549.

* refactor(privacy-banner): render as a persistent gritter, not custom DOM

Drops the bespoke #privacy-banner template + ~50 lines of popup.css and
delegates to $.gritter.add({sticky: true, position: 'bottom'}). The
notice now matches every other gritter on the pad (theme variables,
shadow, animation, (X) close), sits in the bottom corner instead of
above the editor, and inherits dark-mode handling for free.

The two dismissal modes survive intact:
  - dismissible: gritter closes on (X); before_close persists a flag
    in localStorage so the notice is suppressed on subsequent loads.
  - sticky: closes for the current session only; never persists; the
    next pad load shows it again.

learnMoreUrl still goes through the same safeUrl() filter so a
javascript:/data:/vbscript: URL can't smuggle a script handler into the
anchor (Qodo's review concern remains addressed).

Tests: src/tests/frontend-new/specs/privacy_banner.spec.ts now drives
the real showPrivacyBannerIfEnabled via a __etherpad_privacyBanner__
test hook and asserts against the rendered gritter, instead of the
previous tests that mutated DOM by hand and never exercised the
function under test. Coverage adds: enabled=false short-circuit,
dismissible-flag-respected on subsequent show, sticky-ignores-flag,
sticky-close-does-not-persist, javascript: rejection, data: rejection,
and mailto: allow-list.

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

* fix(privacy-banner): noreferrer + validate dismissal (Qodo)

Two follow-ups from Qodo's review on #7549:

1. The Learn-more link now sets `rel="noreferrer noopener"` (was just
   `noopener`). Without `noreferrer` the browser sends the pad URL as a
   Referer to the operator-configured external policy site, which leaks
   pad identifiers to a third party.  Matches the rel pattern already
   used by pad_utils.ts.

2. `privacyBanner.dismissal` is now validated in reloadSettings(): an
   unknown value falls back to 'dismissible' with a `logger.warn`, in
   the same shape as the existing ipLogging validation a few lines up.
   The client also guards defensively (treats anything other than the
   exact string 'sticky' as 'dismissible') so that hot-reload paths
   that skip the server validator can't silently degrade a typo'd
   'sticky' into "no close button persisted, no localStorage suppression".

Test added: spec asserts the rel attribute, and a new test exercises
the dismissal fallback (sets dismissal:'wat', asserts the gritter is
shown, the (X) closes it, and the dismissal flag is persisted — i.e.
the unknown value is treated like 'dismissible').

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

* fix(privacy-banner): gate test hook on webdriver, align doc with sticky behavior

Two follow-ups from Qodo's second review on #7549.

Rule violation: __etherpad_privacyBanner__ was published on every pad
load even when privacyBanner.enabled was false, so the disabled-by-
default feature still added an observable global. Gate the assignment
on `navigator.webdriver` — Playwright/ChromeDriver/Selenium set this
to true; production browsers do not — so the hook is only present for
tests and the disabled path is genuinely zero-side-effect.

Bug 3 (sticky still closable): doc/privacy.md previously claimed
`dismissal: "sticky"` removes the close button, but the gritter
implementation always renders (X). Aligning the doc with reality —
sticky now means "shows on every load, but closable for the session"
— rather than adding bespoke CSS to a vanilla gritter (matches the
"don't style it differently than other gritter messages" preference
that drove the gritter migration in 906e145).

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

* fix(privacy-banner): allow-list keys before sending to clientVars (Qodo)

storeSettings() merges nested objects with _.defaults() and preserves
unknown nested keys, and TypeScript's Pick<> doesn't strip at runtime.
The previous wire path forwarded settings.privacyBanner by reference
into both clientVars and getPublicSettings(), so any extra keys an
operator typed (or pasted) under privacyBanner — credentials, internal
notes, anything — would have shipped to every browser on every pad
load.

Adds getPublicPrivacyBanner() in Settings.ts that returns a literal
with only {enabled, title, body, learnMoreUrl, dismissal}, and uses it
from both leak sites (PadMessageHandler.ts clientVars and
getPublicSettings()). Single source of truth for the wire shape.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:59:38 +08:00
John McLear
a0b85dd8b3
test(ci): log unhandledRejection / uncaughtException in backend test bootstrap (#7663)
Backend tests on develop have a ~22% silent failure rate (mostly Windows,
sometimes Linux) where mocha exits with code 1 mid-suite, producing no
test failure marker, no error, and no Mocha summary. Different exit
points each run.

Root cause discovery is blocked by src/tests/backend/common.ts:33, which
rethrows unhandled Promise rejections as uncaught exceptions but never
logs the reason first. When the rethrow happens between specs, mocha
exits with code 1 and the original rejection is lost - especially on
Windows, where stderr is not always flushed before abrupt exit.

This patch is purely diagnostic: it writes the reason (or stack) to
stderr before rethrowing, and adds a matching uncaughtException handler
for the same purpose. Behavior on success is unchanged. The next CI
failure will surface what is actually rejecting (DirtyDB write?
plugin lifecycle? socket cleanup?), so we can fix the real cause.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 06:42:59 +01:00
John McLear
ba28241179
test: tag wcag_author_color describe with @feature:authorship-bg-color (#7662)
Every assertion in this describe block measures the author span's
background-color against its computed text colour. Plugins that
disable author background colouring entirely — e.g. ep_author_neat2,
which renders authorship as coloured underlines instead — can't
satisfy the WCAG bg/text contrast invariant because there's no
background to measure (transparent vs. transparent yields no ratio).

Tag the describe block so plugins declaring
`disables: ["@feature:authorship-bg-color"]` opt out of pass 1
through the disables contract, the same way change_user_color.spec.ts
already does.

Unblocks ep_author_neat2 CI (its current main run fails on three
wcag_author_color tests).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 06:40:31 +01:00
John McLear
aee2b79b6a
test: tag rtl_url_param toggle-off specs with @feature:rtl-toggle (#7661)
* test: tag rtl_url_param toggle-off specs with @feature:rtl-toggle

The two cases that require RTL to be flippable away from the
plugin-forced default — `?rtl=false` overriding a prior `?rtl=true`
and a no-param reload falling back to the cookie — are exactly what
ep_right_to_left intentionally disables. Tag them so the plugin can
declare `disables: ["@feature:rtl-toggle"]` and pass the disables
contract's honesty check.

Also list the new tag (and the previously omitted @feature:line-numbers)
in doc/PLUGIN_FEATURE_DISABLES.md.

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

* test: also tag chat title-bar layout spec with @feature:rtl-toggle

The leftGap/rightGap symmetry assertion in this test is LTR-only:
colibris ships a one-sided #titlebar padding rule (the existing
asymmetric pad is fine in LTR because the buttons are on the right
where the larger pad sits) that throws gaps apart by ~170px when
body[dir=rtl] reverses the flex item order.

Without a fix to colibris's chat header padding (out of scope here),
plugins that force RTL on can't pass this assertion. Add the second
tag so they can declare the disable instead of false-failing it.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:26:50 +08:00
dependabot[bot]
0803791acc
build(deps-dev): bump the dev-dependencies group across 1 directory with 2 updates (#7653)
Bumps the dev-dependencies group with 2 updates in the / directory: [eslint](https://github.com/eslint/eslint) and [react-hook-form](https://github.com/react-hook-form/react-hook-form).


Updates `eslint` from 10.2.1 to 10.3.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.1...v10.3.0)

Updates `react-hook-form` from 7.74.0 to 7.75.0
- [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](https://github.com/react-hook-form/react-hook-form/compare/v7.74.0...v7.75.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.75.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>
2026-05-03 06:00:36 +01:00
John McLear
49bc33f019
feat(gdpr): HttpOnly author-token cookie (PR3 of #6701) (#7548)
* docs: PR3 GDPR anonymous identity hardening design spec

* docs: PR3 GDPR anon identity implementation plan

* feat(gdpr): ensureAuthorTokenCookie helper — HttpOnly server-set author token

* feat(gdpr): set HttpOnly author-token cookie from the pad routes

* feat(gdpr): read author token from cookie first, keep message.token fallback

* feat(gdpr): stop generating the author token client-side

* test(gdpr): server sets + reuses the HttpOnly author-token cookie

* fix+test(gdpr): parse token cookie from handshake Cookie header

socket.io handshake doesn't run cookie-parser, so socket.request.cookies
is undefined. Parse the Cookie header directly in handleClientReady so
the HttpOnly token actually resolves. Playwright spec covers HttpOnly
attribute, reload-stability, and context-isolation.

* docs(gdpr): token cookie is now HttpOnly + server-set

* fix(gdpr): close two HttpOnly token bypasses

Qodo review:
- Timeslider still ran the pre-PR3 JS-cookie path: it read
  Cookies.get('${cp}token') (which HttpOnly hides), then generated a
  fresh plaintext token and overwrote the server's HttpOnly cookie with
  it, and sent token in every socket message. Strip the token read/
  write entirely from timeslider.ts and from the outgoing message
  shape; the server reads the cookie off the socket.io handshake just
  like on /p/:pad.
- tokenTransfer re-issued the author cookie without HttpOnly, undoing
  the hardening the first time a user transferred a session. Re-set
  it as HttpOnly + Secure (on HTTPS) + SameSite=Lax. Also stop
  trusting the body-supplied token on POST: read it off req.cookies
  server-side so the client never needs JS access to the token.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:56:56 +01:00
John McLear
9014d3a7c4
fix(colors): pick WCAG-higher-contrast text for author colors (#7565)
* feat(colors): clamp author backgrounds to WCAG 2.1 AA on render

Fixes #7377.

Authors can pick any color via the color picker, so a user who chooses
a dark red ends up with black text rendered on a background that fails
WCAG 2.1 AA (4.5:1) — unreadable, but there is no way for *viewers* to
remediate since they cannot change another author's color. Screenshot
in the issue shows exactly this.

This PR lands a viewer-side clamp. For each author background, if
neither black nor white text would satisfy the target contrast ratio,
the bg is iteratively blended toward white until black text does. The
author's stored color is untouched — turning off the new
padOptions.enforceReadableAuthorColors flag restores the raw colors
immediately.

New helpers in src/static/js/colorutils.ts:

- relativeLuminance(triple)  — WCAG 2.1 relative-luminance formula
- contrastRatio(c1, c2)      — in [1, 21]; >=4.5 = AA, >=7.0 = AAA
- ensureReadableBackground(hex, minContrast = 4.5)
                             — returns a hex that meets minContrast
                               against black text, preserving hue

Wire-up:

- src/static/js/ace2_inner.ts (setAuthorStyle): pass bgcolor through
  ensureReadableBackground before picking text color. Gated on
  padOptions.enforceReadableAuthorColors (default true). Guarded by
  colorutils.isCssHex so the few non-hex values (CSS vars, etc.) skip
  the clamp and pass through unchanged.
- Settings.ts / settings.json.template / settings.json.docker: new
  padOptions.enforceReadableAuthorColors flag, default true, with a
  matching PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS env var in the
  docker template.
- doc/docker.md: env-var row.
- src/tests/backend/specs/colorutils.ts: new unit coverage for the
  three new helpers, including the exact #cc0000 failure case from
  the issue screenshot.

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

* refactor(7377): simplify — just pick higher-contrast text, drop bg clamp

First iteration added an iterative bg-lightening helper
(ensureReadableBackground) gated by a new padOptions flag. CI caught the
correct simpler framing: because WCAG contrast is symmetric in [1, 21],
at least one of black/white always clears AA (4.5:1) for any sRGB
colour. The real bug was that the pre-fix textColorFromBackgroundColor
used a plain-luminosity cutoff (< 0.5 → white), which produced
sub-AA combinations like white-on-red (#ff0000) at 4.0:1.

Reduce the PR to the minimal surface:

- colorutils.textColorFromBackgroundColor now picks whichever of
  black/white has the higher WCAG contrast ratio against the bg.
- colorutils.relativeLuminance and colorutils.contrastRatio are kept
  as reusable building blocks; ensureReadableBackground is dropped
  (no caller needed it once text selection was fixed).
- ace2_inner.ts setAuthorStyle no longer needs the opt-in flag or the
  isCssHex guard — the helper handles every input its caller already
  passes.
- padOptions.enforceReadableAuthorColors setting reverted along with
  settings.json.template, settings.json.docker, and doc/docker.md.
- Tests replaced: instead of asserting the bg gets lightened, assert
  that the chosen text colour clears AA for every primary. Covers the
  exact #ff0000 failure case from the issue screenshot.

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

* test(7377): assert relative-contrast invariant, not absolute AA

Pure primaries like #ff0000 cannot clear WCAG AA (4.5:1) against either
#222 or #fff — the best either can do is ~4.0:1. No text-colour choice
alone fixes that; bg clamping would be a separate concern. The test
should therefore verify the *real* invariant: the chosen text colour
must produce the higher contrast of the two options, regardless of
whether that contrast clears any absolute threshold.

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

* fix(7377): compare against rendered #222/#fff, not pure black/white

First cut of textColorFromBackgroundColor computed contrast against
pure black (L=0) and pure white (L=1), then returned the concrete
#222/#fff the pad actually renders with. For some mid-saturation
backgrounds the two comparisons disagreed — e.g. #ff0000:
  vs pure black = 5.25 → pick black → render #222 → actual 3.98
  vs pure white = 4.00 → would-render #fff → actual 4.00
The helper picked the wrong option because it compared against the
wrong target. Compare against the actual rendered colours so the
returned text colour is genuinely the higher-contrast choice.

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

* test(7377): pick unambiguous colibris test bgs

#ff0000 lives right at the boundary for the two text choices (4.00 vs
3.98), so the test for colibris-skin mapping was entangled with the
border-case selector pick. Use #ffeedd (clearly light → dark text
wins) and #111111 (clearly dark → light text wins) so the test
isolates the skin mapping from the tie-breaking logic.

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

* fix(7377): use rendered text colour + clamp bg to actually meet AA

Local repro of the issue exposed two real bugs in the previous fix:

1. textColorFromBackgroundColor compared bg against a hardcoded #222 —
   but in the colibris skin --super-dark-color resolves to #485365.
   For the issue's exact case (#9AB3FA author bg) the selector returned
   var(--super-dark-color) thinking it was getting a 7.7:1 ratio, while
   the browser actually rendered 3.78:1 — identical to what the issue
   screenshot reported. This PR's previous behaviour on the issue's
   inputs was unchanged from the pre-fix.

2. For mid-saturation pastels (#9AB3FA) and pure primaries (#ff0000)
   neither rendered dark nor white text can clear AA. Text-colour
   selection alone genuinely cannot fix this band; the ensureReadable
   bg clamp dropped in ce0c5c283 was load-bearing.

Changes:

- colorutils.ts: per-skin SKIN_TEXT_COLORS table with darkRef/lightRef
  matching what the browser actually paints (colibris #485365,
  default #222). Re-introduces ensureReadableBackground, but skin-aware
  and symmetric — blends bg toward white or black depending on which
  text colour wins, so it works for both light and dark backgrounds.
- ace2_inner.ts: setAuthorStyle runs the bg through the clamp before
  picking text colour. Gated on padOptions.enforceReadableAuthorColors
  (default true).
- Settings.ts / settings.json.template / settings.json.docker /
  doc/docker.md: padOption + PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS
  env var.
- tests: failing-then-green coverage for the issue's exact case
  (#9AB3FA + colibris), the previously-impossible #ff0000, the
  no-mutation case, non-hex pass-through, and a sweep over primaries.

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

* test(7377): add e2e DOM-contrast spec + extra unit cases

The previous coverage was unit-only, which is what let the original wrong-
reference-colour bug ship — the algorithm tests were green but nothing
exercised what the browser actually paints. New coverage:

Playwright (src/tests/frontend-new/specs/wcag_author_color.spec.ts):
- Sets the user's colour to the issue's exact #9AB3FA, types text, reads
  the rendered author span's computed bg + colour from the inner frame,
  and asserts the WCAG ratio between the two is >= 4.5. Repeated for
  #ff0000 (the other historically-failing case).
- Asserts #ffeedd (already AA-friendly) is rendered unchanged — guards
  against the clamp mutating colours that don't need it.

Backend additions (src/tests/backend/specs/colorutils.ts):
- Symmetric-clamp test: dark mid-saturation bg where light text wins, the
  clamp must darken (not lighten). Direction check via relativeLuminance.
- minContrast parameter: AAA (7.0) must produce more clamping than AA.
- Output shape: result must be a parseable hex string (round-trip safe).
- Short-hex (#abc) input is accepted and normalised.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:56:30 +08:00
John McLear
25c43140bc
feat(userlist): click a user to open chat with @<name> prefilled (#7660)
* feat(userlist): click a user to open chat with @<name> prefilled

Newcomers to a multi-user pad regularly fail to discover the chat
panel and the @-mention convention. Make the user list itself the
discovery affordance: clicking another user's row opens chat (if
hidden) and prefills the input with "@<their_name> ", ready to send.

The skin gets a small visual cue — pointer cursor on .usertdname and
an underline on hover — so the affordance is visible without
requiring a redesign. The color swatch keeps its own click semantics
(color picker), so the swatch cell is excluded from the new handler.

To let bot/AI plugins substitute their trigger string for an
otherwise-useless @-mention of the bot's display name (e.g.
"@AI Assistant" → "@ai"), this adds a new client-side hook,
chatPrefillFromUser, that takes {authorId, name, prefill} and lets
the first plugin to return a non-empty string override the default
prefill. Documented in doc/api/hooks_client-side.md alongside
chatSendMessage.

Plugin errors in the hook are caught — a misbehaving plugin can't
break the click. If chat is hidden by pad settings, chat.show() is
a no-op and the click effectively does nothing, which matches the
existing behavior of "no chat means no chat-related affordances".

The new prefill never clobbers a real partial message in the input;
if the user was mid-typing something, the @-mention is appended
rather than replacing.

* fix(userlist): don't steal rename focus + add Playwright coverage

Two follow-ups on review of the click-to-chat handler:

1. Bug (Qodo, correctness): clicking the rename <input> on an unnamed
   user's row triggered the new row handler, which then focused
   #chatinput and made it impossible to name unnamed users from the
   user list. Add an early-return that skips form controls inside
   the row (input/textarea/select/button/a/[contenteditable=true]).
   The swatch was already excluded; this widens the same idea to
   anything that's interactive on its own merits.

2. Test coverage: add a frontend Playwright spec
   (userlist_click_to_chat.spec.ts) covering the supported flows
   and the new regression:
   - clicking another named user opens chat and prefills "@<name> "
   - clicking the swatch opens color picker, not chat
   - clicking the rename <input> on an unnamed user keeps focus
     on the input (regression test for the bug above)
   - partial chat message is preserved when prefilling

* test: stabilise the partial-message preservation case

The 'partial message in chat input is preserved when prefilling'
case was flaking on CI. Three small changes:

- Seed the chat input with fill() rather than click() + keyboard.type().
  Earlier the test was racing chat.focus()'s own setTimeout(100) — when
  the keyboard.type started before that timer fired, the typing landed
  in whatever element had focus at the time, which wasn't always the
  chat input. fill() bypasses focus state entirely.
- Wait for the chat box to be visible before filling, so we don't race
  the chaticon click handler.
- Replace the two sequential expect/wait pairs after the daveRow click
  with one waitForFunction that asserts both 'hi there' and '@Dave' are
  in the input together. The prefill is async (setTimeout(50) inside
  the click handler), so a combined wait is more reliable than checking
  one piece, then snapshotting and asserting the other.

The other three cases in this file passed unchanged on CI; only this
fourth one was racy.

* fix: don't commit local .claude worktrees / var state

These were accidentally added in ffe947706 by an over-broad git add -A.
Both paths are workspace-local and unrelated to this PR.
2026-05-03 05:40:44 +01:00
John McLear
e0a989094d
feat(colors): add padOptions.fadeInactiveAuthorColors with toolbar UI (#7554)
Adds a new pad option `fadeInactiveAuthorColors` (default `true`) that controls whether each author's caret/background fades toward white as they go inactive. Configurable server-side (`settings.json` / `PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS`), per-pad in the Pad Settings panel, per-user in the My View panel, or via `?fadeInactiveAuthorColors=false`.

Disabling the fade is useful on busy pads where every faded author visually counts as a second on-screen color (a 30-author pad becomes a 60-color pad), or when inactivity tracking is undesirable for whatever reason.

Closes #7138.
2026-05-03 04:01:13 +08:00
John McLear
6e3f929896
test: tag two line-numbers-touching pad_settings specs with @feature:line-numbers (#7658)
Surfaced by ep_hide_line_numbers' red main. Two pad_settings specs
use `expect(...).not.toHaveClass(/line-numbers-hidden/)` to verify
the line-number gutter is visible by default before settings flip
it on, but plugins that hide line numbers (ep_hide_line_numbers
sets `pad.changeViewOption('showLineNumbers', false)` in postAceInit)
keep that class on the body for the entire pad lifetime.

Tag the two affected specs with @feature:line-numbers so plugins
that hide line numbers can declare `disables: ["@feature:line-numbers"]`
in ep.json and have these excluded from pass-1 regression while
still failing pass-2 honesty (plugin must actually keep them hidden).

Companion to the existing tag set (@feature:chat, @feature:username,
@feature:clear-authorship, @feature:error-gritter,
@feature:authorship-bg-color). See doc/PLUGIN_FEATURE_DISABLES.md.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:43:10 +01:00
John McLear
0be8afc1ac
test: add @feature:authorship-bg-color tag for plugins that re-render authorship (#7657)
Plugins like ep_author_neat2 swap Etherpad's coloured-background
authorship indicator for an underline. Their README is explicit
about this; their main is red on the disables-aware test runner
because change_user_color.spec.ts:59 hard-asserts the chat <p>'s
background-color matches the user's colour, which a non-background
rendering legitimately won't satisfy.

Add a second tag (@feature:authorship-bg-color) alongside the
existing @feature:chat so plugins that swap rendering can declare
"disables": ["@feature:authorship-bg-color"] and have this single
spec excluded from pass-1 regression while still running pass-2
honesty (the bg-color assertion fails under the plugin → contract
honoured).

Multi-tag: ep_disable_chat keeps excluding it via @feature:chat;
ep_author_neat2 excludes it via @feature:authorship-bg-color.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:35:13 +08:00
John McLear
02e37e0112
feat(packaging): publish Etherpad as a Snap (#7558)
* feat(packaging): publish Etherpad as a Snap

Adds first-class Snap packaging so Ubuntu / snapd users can install via
`sudo snap install etherpad-lite`.

- snap/snapcraft.yaml — core24, strict confinement, builds with pnpm
  against a pinned Node.js 22 runtime. Version is auto-derived from
  src/package.json so `snap info` tracks upstream release numbering.
- snap/local/bin/etherpad-service — launch wrapper that seeds
  $SNAP_COMMON/etc/settings.json on first run (rewriting the default
  dirty-DB path to a writable $SNAP_COMMON location) and execs Etherpad
  via `node --import tsx/esm`.
- snap/local/bin/etherpad-healthcheck-wrapper — HTTP probe for external
  supervisors, falling back to Node if curl isn't staged.
- snap/local/bin/etherpad-cli — thin passthrough to Etherpad's bin/
  scripts (importSqlFile, checkPad, etc.).
- snap/hooks/configure — exposes `snap set etherpad-lite port=<n>` and
  `ip=<addr>` with validation, restarts the service when running.
- snap/README.md — build / install / configure / publish instructions.
- .github/workflows/snap-publish.yml — builds on every v* tag, uploads
  a short-lived artifact, publishes to `edge`, and then promotes to
  `stable` through a manually-approved GitHub Environment. Requires a
  one-time `snapcraft register etherpad-lite` plus provisioning of the
  `SNAPCRAFT_STORE_CREDENTIALS` repo secret (instructions inline).

Pad data (dirty DB, logs) lives in /var/snap/etherpad-lite/common/ and
survives snap refreshes. The read-only $SNAP squashfs is never written
to at runtime.

Refs #7529

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

* fix(snap): pass --settings flag, env-subst ip/port, 2-space indent

Addresses Qodo review feedback on #7558:

1. Settings file ignored: Etherpad's Settings loader reads `argv.settings`,
   not the `EP_SETTINGS` env var. Without `--settings`, the launcher's
   seeded $SNAP_COMMON/etc/settings.json is never loaded; Etherpad falls
   back to <install-root>/settings.json, which lives on the read-only
   squashfs — so the default dirty-DB path ends up unwritable and the
   daemon fails to persist pads. Fix: pass `--settings "${SETTINGS}"` to
   node; drop the EP_SETTINGS export.

2. `snap set` overrides were no-ops: the seeded settings.json carries the
   template's literal `"ip": "0.0.0.0"` / `"port": 9001` values, which
   override the env-based defaults Etherpad exposes via ${…}
   substitution. Users following the README saw the listener stay put
   after `snap set etherpad-lite port=…`. Fix: after copying the
   template on first run, rewrite the top-level `ip` and `port` lines
   to `"${IP:0.0.0.0}"` / `"${PORT:9001}"`. Use `0,/…/` anchors so the
   `dbSettings.port` entry further down stays literal.

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

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

* fix(snap): default seeded settings to sqlite, not dirty

settings.json.template's own comment says dirty is for testing only.
A Snap install is the "not testing" case — shipping it by default
means every `sudo snap install etherpad-lite` starts on a DB the
project explicitly recommends against.

Rewrite the postinstall sed to switch dbType: "dirty" → "sqlite" and
point filename at $SNAP_COMMON/var/etherpad.db. sqlite is already
shipped in-tree via ueberdb2 → rusty-store-kv (prebuilt napi-rs
binary, no build deps), so this works under strict confinement with
zero snap.yaml changes.

Only affects first-run seeding; existing $SNAP_COMMON/etc/settings.json
is never touched on refresh.

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

* fix(snap): rename to "etherpad", glob tag filter, harden cli

- Snap is registered as `etherpad` (the project's only name) — drops the
  legacy `etherpad-lite` from the name, app, paths, install dir, configure
  hook, README and workflow artifact. The daemon app shares the snap name,
  so `snap install etherpad` exposes a bare `etherpad` command; the bin/
  passthrough is now `etherpad.cli`.
- snap-publish.yml: GitHub Actions tag filters use globs, not regex. The
  prior `v?[0-9]+.[0-9]+.[0-9]+` pattern would never match a real release
  tag (Qodo review). Replace with two glob entries covering `vX.Y.Z` and
  `X.Y.Z`.
- etherpad-cli: reject path-traversal in the `<bin-script>` arg (anything
  containing `/`, `..`, or empty) and add a default `*)` case so files
  with unsupported extensions fail loud instead of silently exiting 0
  (Qodo review).

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

* fix(snap): unbreak build — refresh corepack, drop pnpm prune

Two issues hit on the first real `snapcraft pack` of this recipe:

- `corepack prepare pnpm@10.33.0 --activate` failed with
  `Cannot find matching keyid` because Node 22.12's bundled corepack
  ships a stale signing-key list and rejects newer pnpm releases
  (nodejs/corepack#612). Refresh corepack itself via npm before
  preparing pnpm.
- `pnpm prune --prod` is interactive on workspace projects: it asks
  "The modules directories will be removed and reinstalled from
  scratch. Proceed? (Y/n)" and deadlocks on stdin under sudo + tee.
  Replace it with the explicit "wipe node_modules + prod reinstall"
  pattern, which is non-interactive, faster (pnpm resolves the prod
  graph from its CAS cache), and byte-identical in result.

Verified locally: `snapcraft pack --destructive-mode` produces
`etherpad_2.6.1_amd64.snap` end-to-end in ~3 min.

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

* fix(snap): unbreak runtime — tsx resolution, var/ writability, env

Three runtime crashes surfaced when actually installing the built snap
under strict confinement. Fixed each, plus a smoke-test script.

- `tsx` is in the `src` workspace's node_modules under pnpm hoisting,
  not at the snap install root. The wrapper now `cd "${APP_DIR}/src"`
  and uses bare `--import tsx` (matching `bin/cleanRun.sh`); the prior
  `--import tsx/esm` triggered ERR_REQUIRE_CYCLE on Etherpad's mixed
  CJS/ESM source tree.
- Etherpad's plugin installer writes `var/installed_plugins.json` via
  __dirname-relative paths, which resolve to absolute paths inside the
  read-only snap squashfs (EROFS). snap layouts can't intercept paths
  inside `$SNAP`, so replace the shipped `var/` dir with a symlink to
  `/var/snap/etherpad/common/etherpad-app-var/` (auto-created by the
  wrapper on first run). Persistent state survives `snap refresh`.
- Drop the unused `EP_SETTINGS` and `EP_DATA_DIR` env vars from the
  app's `environment:` block. Etherpad's settings loader doesn't read
  them — it reads `argv.settings`, which the wrapper already passes via
  `--settings`. They were producing `[WARN] settings - Unknown Setting`
  noise on every start.

Add `snap/tests/smoke.sh`: rebuild + install + configure test port 9003
+ assert listener + curl /health + tail logs. Local verified output:
  HTTP 200, body {"status":"pass","releaseId":"2.6.1"}, server logs
  `Etherpad is running` on `http://0.0.0.0:9003/`.

.gitignore now excludes destructive-mode build outputs (parts/, stage/,
prime/, .craft/, *.snap).

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

* test(snap): wrapper unit tests, PR CI build, expanded docs

Coverage in snap/tests/ (47 assertions, ~5s, no snapd/sudo/network):
- test-snapcraft-yaml.sh: required keys, name validity, daemon-app
  matches snap name, no etherpad-lite regression, env-var whitelist.
- test-cli.sh: path-traversal rejection, .ts/.sh dispatch, default-case
  rejection, no-args usage.
- test-configure.sh: port (1-65535) and ip (v4/v6) validation via
  mocked snapctl.
- test-service-bootstrap.sh: first-run seeding from
  settings.json.template, sed rewrite of dbType/filename/ip/port,
  writable-dir creation, snapctl override propagation to node env,
  idempotency on second run, default fallbacks.
- run-all.sh: bash -n syntax check on every wrapper + hook, then
  sources each test file and reports totals. All assertions use port
  9003 (project test convention).

CI in .github/workflows/snap-build.yml:
- Triggers on PR / push-to-develop touching snap/, settings.json.template,
  or the workflow itself.
- Job 1 wrapper-tests: runs run-all.sh.
- Job 2 snap-pack: snapcraft pack --destructive-mode, uploads .snap as
  PR artifact for sideload.
- Stays separate from snap-publish.yml (tag-triggered, store-bound).

snap/README.md fully rewritten:
- User-facing usage, install, configure
- Architecture: file layout, var/-symlink rationale, settings.json
  rewrite rationale, double-pnpm-install rationale, daemon-name-shares-
  snap-name rationale
- Three test layers with exactly when/why to run each
- Dev workflow loop
- Publishing maintainer setup
- Troubleshooting for every failure mode hit during this PR (EROFS,
  tsx not found, ERR_REQUIRE_CYCLE, snap-store-down, pnpm prune hang)

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

* docs(snap): replace dead snapcraft.io/docs/releasing-to-the-snap-store link

That URL now 404s. Point at the canonical documentation.ubuntu.com
locations instead, broken out into the specific pages a maintainer
actually needs:

- Register a snap (to claim the name)
- snapcraft export-login (to generate the SNAPCRAFT_STORE_CREDENTIALS
  secret)
- Publishing how-to index (root index for everything else)

Same fix in the snap-publish.yml header comment.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:19:10 +01:00