* feat(padOptions): pass plugin-namespaced ep_* keys through applyPadSettings
Native pad-wide settings ride a single padOptions object: the server seeds
clientVars.initialOptions, the client mutates via pad.changePadOption(), and
the existing padoptions COLLABROOM message broadcasts changes. Plugins can't
use the same rail today because applyPadSettings (client) and
normalizePadSettings (server) silently drop any key not in their hardcoded
whitelist.
Add a passthrough loop that preserves keys matching /^ep_[a-z0-9_]+$/ on both
sides. Plugins can now stash their pad-wide values under their own namespace
(e.g. pad.padOptions.ep_table_of_contents = {enabled: true}) and inherit the
existing broadcast, persistence, creator-only-write enforcement, and
enforceSettings semantics for free.
A new src/node/utils/PluginCapabilities module exposes
padOptionsPluginPassthrough = true so plugins can feature-detect via
require() and fall back to per-user behavior on older cores.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address Qodo review on PR #7698
Four concerns raised by Qodo (qodo-free-for-open-source-projects):
1. Feature flag — AGENTS.MD §52 requires new features behind a flag,
disabled by default. Add `enablePluginPadOptions` (default false) gating
the passthrough on both server (normalizePadSettings) and client
(applyPadSettings, via clientVars). Plugins detect the runtime state
through clientVars.enablePluginPadOptions; the static
PluginCapabilities flag stays as the "core can do this" signal.
2. Documentation — add a "Plugin-namespaced pad-wide options" section to
doc/plugins.md covering capability detection, the runtime flag, the
key namespace pattern, and the validation rules. Mirror the flag
description in settings.json.template.
3. Unbounded payload — values for ep_* keys are persisted with the pad and
broadcast to every connected client, so an unvalidated path was a
reliability hazard. Validate every ep_* value:
- Must round-trip through JSON.stringify (rejects functions, symbols,
BigInt, circular refs).
- Per-key serialized size capped at 64 KB.
- Combined ep_* size capped at 256 KB per pad.
Rejects drop the value with a console.warn line; the rest of the pad
settings round-trip cleanly.
4. PadOption type — add `[k: \`ep_${string}\`]: unknown` index signature
so the SocketIO message type matches runtime behavior; TS callers no
longer need unsafe casts to read plugin-namespaced keys.
Also extends the backend test suite with cases covering the runtime flag
(off/on), JSON-serializability rejection, per-key cap, and total cap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(snap-tests): assert_grep — use here-string to dodge pipefail SIGPIPE
`assert_grep` ran `printf '%s' "$out" | grep -q -F -- "$needle"` under
`set -o pipefail`. When grep matched early it closed its stdin, printf
got SIGPIPE on its next write (exit 141), and pipefail propagated the
broken-pipe failure to the pipeline — making `if` see non-zero and
falling into the FAIL branch even though grep itself succeeded.
Failure was timing-dependent: it only fired when `$out` was large enough
that printf hadn't flushed before grep exited. CI ubuntu-latest tipped
into the racy path on PR #7698 once `settings.json.template` grew by 11
lines (the new `enablePluginPadOptions` flag); the symptom was the
`Wrapper unit tests` step reporting `dbType rewritten to sqlite ✗` with
"got: /*…" output even though the seeded file did contain the needle.
Replace the pipe with a here-string so grep gets its input in one shot
with no pipe between processes — no SIGPIPE possible. The fail-message
`head -3` is converted to a here-string for the same reason.
Repro on a runner whose pipe-buffer flush is slower than grep's first
match would have hit the same flake on any PR; the bug isn't about
this particular template change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|---|---|---|
| .. | ||
| hooks | ||
| local/bin | ||
| tests | ||
| README.md | ||
| snapcraft.yaml | ||
Etherpad snap
Packages Etherpad as a Snap for publishing to the Snap Store.
User-facing usage
Install from the store
sudo snap install etherpad
The default listen port is 9001. Pad data lives in
/var/snap/etherpad/common/ and survives snap refresh.
Configure
The snap seeds $SNAP_COMMON/etc/settings.json from the upstream
template on first run. Edit that file directly to customise Etherpad,
then:
sudo snap restart etherpad
A few values are exposed as snap config so users don't have to edit the file by hand:
| Key | Default | Notes |
|---|---|---|
snap set etherpad port=9001 |
9001 |
Listen port |
snap set etherpad ip=0.0.0.0 |
0.0.0.0 |
Bind address |
The configure hook validates these (port must be 1–65535 integer,
ip must be a valid v4/v6 address) and restarts the daemon on change.
Build locally
sudo snap install --classic snapcraft
sudo snap install lxd && sudo lxd init --auto
snapcraft # from repo root; uses LXD by default
Output: etherpad_<version>_<arch>.snap.
Install a local build
sudo snap install --dangerous ./etherpad_*.snap
sudo snap start etherpad
curl http://127.0.0.1:9001/health # → {"status":"pass","releaseId":"X.Y.Z"}
Logs: sudo snap logs etherpad -f.
Architecture
File layout inside the snap
$SNAP/ # = /snap/etherpad/current (read-only squashfs)
├── opt/
│ ├── node/bin/node # pinned Node.js 22.12.0
│ └── etherpad/
│ ├── src/ # ep_etherpad-lite workspace package (with node_modules incl. tsx)
│ ├── admin/, ui/, doc/ # other workspace packages (built artefacts)
│ ├── settings.json.template # template, copied to $SNAP_COMMON on first run
│ └── var → /var/snap/etherpad/common/etherpad-app-var/ # symlink (see below)
├── bin/
│ ├── etherpad-service # daemon launch wrapper
│ ├── etherpad-cli # passthrough to bin/ scripts
│ └── etherpad-healthcheck-wrapper # HTTP /health probe
└── meta/snap.yaml
$SNAP_COMMON/ # = /var/snap/etherpad/common (read-write, persists across refreshes)
├── etc/settings.json # seeded from template on first run, never overwritten
├── var/etherpad.db # sqlite database
├── etherpad-app-var/installed_plugins.json # plugin registry, written by Etherpad core
└── logs/ # reserved for future use
Why the var/ symlink
Etherpad's plugin installer
(src/static/js/pluginfw/installer.ts) writes
installed_plugins.json via __dirname-relative paths, which resolve
to absolute paths inside $SNAP — read-only squashfs. Snap layouts
can't intercept paths inside $SNAP, so we replace the shipped var/
directory with a symlink at build time pointing to
/var/snap/etherpad/common/etherpad-app-var/ (created by the wrapper
on first run). The kernel transparently follows the symlink to writable
storage that survives snap refresh.
Why the seeded settings.json is rewritten
The upstream settings.json.template defaults to dbType: "dirty" —
the template itself warns this is dev-only. The launch wrapper rewrites
the seeded copy on first run to:
dbType: "sqlite"with file at$SNAP_COMMON/var/etherpad.dbip: "${IP:0.0.0.0}"— Etherpad's own env-substitution syntaxport: "${PORT:9001}"— same
The wrapper then exports IP and PORT from the snap config (via
snapctl get), so snap set etherpad port=N actually moves the
listener.
Why pnpm runs twice
pnpm install --frozen-lockfile --prod=false first (need devDeps to
build admin/ui/docs), then rm -rf node_modules && pnpm install --prod --frozen-lockfile --ignore-scripts after the build. This is faster
than pnpm prune --prod, which is interactive on workspace projects
(prompts "Proceed? (Y/n)" to stdin) and deadlocks under the
non-interactive build environment. See
nodejs/corepack#612
for the corepack-keyring refresh in step 2.
Why the daemon shares the snap name
apps.etherpad matches the snap name etherpad, so users invoke the
daemon via snap install etherpad → bare etherpad command. The CLI
passthrough is exposed as etherpad.cli (e.g.
etherpad.cli importSqlFile something.sql).
Testing
Three layers, each independently runnable:
1. Wrapper unit tests (~5 s, no snapd/sudo)
bash snap/tests/run-all.sh
Runs bash -n syntax checks on every wrapper + hook, then sources
each test-*.sh and reports pass/fail counts. Coverage:
test-snapcraft-yaml.sh— required keys, name validity, daemon-app matches snap name, noetherpad-literegression, environment vars whitelist.test-cli.sh— path-traversal rejection (../, subdir, empty),.ts/.shdispatch, default-case rejection, no-args usage.test-configure.sh— port (1–65535 integer) and ip (v4/v6) validation via mockedsnapctl.test-service-bootstrap.sh— first-run seeding fromsettings.json.template, sed rewrite of dbType/filename/ip/port, writable-dir creation, snapctl override propagation to node env, idempotency on second run, default fallbacks.
All tests use port 9003 for any binding (per project convention, since 9001 is reserved for ad-hoc local Etherpad work).
2. CI build verification
.github/workflows/snap-build.yml runs on every PR that touches
snap/, settings.json.template, or the workflow itself. Two jobs:
wrapper-tests— runssnap/tests/run-all.sh(~5 s).snap-pack— runssnapcraft pack --destructive-modeand uploads the resulting.snapas an artifact (downloadable from the run summary so reviewers can sideload).
This is intentionally separate from snap-publish.yml (tag-triggered,
LXD-based, pushes to the store).
3. End-to-end smoke test (~3 min, requires sudo + snapd)
bash snap/tests/smoke.sh
Rebuilds via destructive-mode, installs the resulting .snap,
configures port=9003, restarts, waits for plugin migration to
finish, asserts a listener on 9003, hits /health, and tails the
last 20 log lines. Useful when changing the wrappers or the build
recipe before pushing.
Development workflow
# 1. Make a change to snap/snapcraft.yaml or one of the wrappers.
# 2. Fast feedback loop — only the unit tests:
bash snap/tests/run-all.sh
# 3. Full local verification — actually build and install:
bash snap/tests/smoke.sh
# 4. Push. CI will run wrapper-tests + snap-pack on the PR.
git push
If snapcraft pack complains about the LXD provider,
--destructive-mode lets you build directly on the host (used by both
the smoke script and CI). It pollutes the host with build deps and
puts parts/, stage/, prime/ in the worktree (gitignored). Wipe
with sudo rm -rf parts stage prime.
Publishing
Maintainers only. See:
- Register a snap — claims the name on the store
snapcraft export-login— generates the credential we put inSNAPCRAFT_STORE_CREDENTIALS- Snapcraft publishing how-to index
One-time setup:
snapcraft register etherpad
snapcraft export-login --snaps etherpad \
--channels edge,stable \
--acls package_access,package_push,package_release -
Store the printed credential in the repo secret
SNAPCRAFT_STORE_CREDENTIALS. Create a GitHub Environment named
snap-store-stable with required reviewers so stable promotion is
gated.
.github/workflows/snap-publish.yml then handles the rest on every
vX.Y.Z (or X.Y.Z) tag: build → publish to edge → manual approval
gate → publish to stable.
Troubleshooting
Daemon flapping with EROFS: read-only file system — Etherpad is
trying to write somewhere inside $SNAP. Check whether the path is
covered by the var/ symlink (architecture section above). New write
targets need either an additional symlink at build time
(snap/snapcraft.yaml step 4) or a config knob to redirect into
$SNAP_COMMON.
Cannot find package 'tsx' — the wrapper must cd "${APP_DIR}/src"
before node, since tsx lives in the workspace's node_modules and
not at the install root under pnpm hoisting.
ERR_REQUIRE_CYCLE_MODULE — use bare --import tsx, not
--import tsx/esm. The ESM-only loader trips on Etherpad's mixed
CJS/ESM source.
snap install fails with unable to contact snap store — almost
always a Canonical-side outage. Check
snapcraft.statuspage.io. For
local development you can sidestep the store dependency entirely by
building with snapcraft pack --destructive-mode (no LXD container
provisioning, so no in-container snap install).
pnpm prune --prod hangs forever — never use it directly here. It
has an interactive "Proceed? (Y/n)" prompt for workspaces that
deadlocks under sudo/tee. The build recipe uses
rm -rf node_modules && pnpm install --prod --frozen-lockfile --ignore-scripts instead.
snap refresh blew away my data — it didn't. Pad data is in
/var/snap/etherpad/common/, which is preserved across refreshes.
Check /var/snap/etherpad/common/var/etherpad.db exists.