Closes#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>
* fix: page sessionstorage cleanup to avoid OOM (#7830)
SessionStore._cleanup() previously called `findKeys('sessionstorage:*',
null)`, materialising every session key into a single array. On decade-
old MariaDB installs with millions of sessions this OOMs the node
process within ~15 minutes — see #7830.
Switch to ueberdb2 6.1.0's findKeysPaged with a 500-key page size, and
yield to the event loop between pages so the DB driver can release each
page's buffered rows and request handlers can interleave.
The break is now driven by `page.length === 0` rather than `page.length
< CLEANUP_PAGE_SIZE` so a stubbed/throttled paged source still iterates
the full keyspace.
Adds a regression test that seeds 50 sessionstorage rows, monkey-patches
`DB.findKeysPaged` to use a 4-key page, runs cleanup, and asserts every
expired row is removed plus every valid row preserved across page
boundaries.
Closes#7830
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Qodo review on #7831
Four follow-ups raised by Qodo on the session cleanup paging fix:
- DB.ts: fail-fast at init() if any required wrapper method (incl.
findKeysPaged) is missing, so a stale ueberdb2 pin surfaces at boot
rather than crashing the first cleanup run an hour later.
- SessionStore: bound a single _cleanup() run to 10 minutes. Under
sustained session creation the keyspace can grow faster than cleanup
drains it; without a budget the next scheduled run would never fire.
When the budget hits, log a warning and let the next run continue.
- SessionStore: log the defensive `page[0] <= after` cursor-stall break.
Previously the loop exited silently, leaving expired rows behind with
no operator-visible signal of the backend regression.
- Tests: the paged-cleanup regression test now removes both expiredSids
AND validSids in finally, so a failed assertion doesn't leak rows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: note paged session cleanup in CHANGELOG + settings template
CHANGELOG.md picks up an entry under 3.1.0 Notable fixes describing the
OOM cause, the paged iteration, the 10-minute per-run budget, the
cursor-stall logging, and the fail-fast init guard.
settings.json.template's sessionCleanup comment adds the page-size,
budget, and pointer to #7830 so admins can reason about the new
behaviour from the template alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: regenerate lockfile against ueberdb2 6.1.2
Now that ether/ueberDB#983 unblocked the publish workflow (OIDC trusted
publishing), ueberdb2 6.1.2 is live on npm and the `^6.1.0` pin in
src/package.json resolves cleanly. Resolves the ERR_PNPM_OUTDATED_LOCKFILE
that was blocking CI on this PR.
29 SessionStore backend tests still green against the published tarball.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(updater): plan tier 4 — autonomous update in maintenance window (#7607)
Maps PR 4 of the auto-update design spec (§"Tier 4 — autonomous") to concrete
files, tasks, and verification steps. Subsequent commits scaffold against this
plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(updater): MaintenanceWindow module — wall-clock window math for tier 4
Pure module: parseWindow, inWindow, nextWindowStart. Supports tz=local|utc
and cross-midnight ranges. Used by upcoming Scheduler + UpdatePolicy changes.
22 vitest unit tests cover format validation, same-day + cross-midnight
boundaries, and host-local vs UTC clock comparisons. DST handling is
absorbed by JS Date constructor's wall-clock normalization (documented in
the file header).
Refs #7607
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(updater): tier 4 backend — window-gated UpdatePolicy + Scheduler
Wires MaintenanceWindow into the existing tier 3 backend so autonomous
updates only fire while `now` is inside `updates.maintenanceWindow`.
UpdatePolicy
- new optional `maintenanceWindow` input
- canAutonomous flips on only for git+tier=autonomous+parse-valid window
- new reasons `maintenance-window-missing` / `maintenance-window-invalid`
- rollback-failed still wins over window denial
Scheduler
- decideSchedule snaps scheduledFor forward to nextWindowStart when
canAutonomous + grace lands outside the window
- decideTriggerApply returns a new `{action: 'defer'}` when canAutonomous
+ fire-time is outside the window; carries nextStart for the runner
- canAutonomous=false preserves Tier 3 behavior unchanged
index.ts wires settings.updates.maintenanceWindow through both passes and
re-arms the timer on defer. Status endpoint surface (nextWindowOpensAt) +
admin UI picker land in a follow-up commit.
Settings adds `maintenanceWindow: {start, end, tz} | null`, defaulting to
null. settings.json.template / settings.json.docker document the shape.
Tests
- 22 vitest cases for MaintenanceWindow already cover the math
- 4 new UpdatePolicy cases for the window outcomes
- 6 new Scheduler cases for tier-4 schedule/trigger paths
- Full backend-new suite: 629 passed (35 files)
Refs #7607
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(updater): tier 4 admin UI — window status, deferred subtitle, banner
GET /admin/update/status now returns:
- `maintenanceWindow`: the parsed window object (admin sessions only)
- `nextWindowOpensAt`: ISO of the next window opening when tier=autonomous
UpdatePage
- new "Maintenance window" section when tier=autonomous, shows current
window summary + next opens at, or "Not configured" when unset
- scheduled panel now appends a "deferred until <iso>" line when the
backend has snapped scheduledFor to the next window opening
UpdateBanner
- new variant when tier=autonomous and policy.reason is
`maintenance-window-missing` or `maintenance-window-invalid`, linking
to /admin/update
i18n
- 8 new keys under `update.banner.*`, `update.page.policy.*`,
`update.page.scheduled.*`, `update.window.*` (en.json only;
translations follow via the usual locale workflow)
Interactive picker is intentionally deferred — admins edit
`updates.maintenanceWindow` via the parsed JSONC settings editor (#7709).
A follow-up commit may add a thin write-through component if the JSONC
round-trip turns out to be too rough for typical operators.
Refs #7607
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(updater): tier 4 — window gate, DST notes, runbook §12 (#7607)
CHANGELOG: flip Tier 4 from "designed, not yet implemented" to current.
Document maintenanceWindow shape, snap-forward, defer-at-fire, and the
two missing/invalid policy reasons.
doc/admin/updates.md: new "Tier 4 — autonomous in a maintenance window"
section with config example, policy gating, DST/timezone notes, admin UI
behavior.
runbook: §12 walks a disposable VM through missing-window, malformed,
outside-window deferral, fire-at-opening, and window-closes-mid-grace.
Adds five sign-off checklist items.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(updater): tier 4 window-boundary integration (#7607)
Mocha integration covering the four scenarios called out in the spec
§"Tier 4 — autonomous":
- outside-window: decideSchedule snaps scheduledFor forward to the
next opening and the snapped value round-trips through saveState
- inside-window at fire-time: decideTriggerApply returns fire
- window-closes-mid-grace: decideTriggerApply returns defer with
nextStart at the next opening; persisted state moves forward
- cancel during deferred-grace: state returns to idle, and the next
decideSchedule pass re-emits a schedule snapped to the next opening
All 4 cases passing locally under tsx mocha.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(updater): real SMTP via nodemailer (mail.* settings) (#7607)
Replaces the (would send email) stub introduced in PR #7601 with a
nodemailer-backed transport. The dependency is lazy-imported so installs
that don't set mail.host pay no runtime cost.
Settings additions
- new top-level mail block: host, port, secure, from, auth (user/pass)
- mail.host=null keeps the legacy log-only behaviour; the Notifier
still updates dedupe state so we don't re-evaluate every tick
- settings.json.template documents the shape inline
- settings.json.docker reads MAIL_HOST / MAIL_FROM / MAIL_PORT /
MAIL_SECURE from env so operators can configure via container env
Transport
- lazy import('nodemailer') on first send
- transport cached by host; settings reload picks up new host without
needing a restart
- send errors are swallowed (logged warn) so a transient SMTP failure
can never poison the surrounding updater state machine
- successful sends log at info; legacy "(would send email)" path
remains the visible signal when mail is disabled
Refs #7607
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(updater): preflight checks target tag's engines.node (#7607)
Before mutating the working tree, runPreflight now reads the target tag's
package.json via `git show <tag>:package.json` and verifies that
process.versions.node satisfies its engines.node range. Failures land at
preflight-failed cleanly (no rollback needed — nothing has changed yet).
Motivation: a release that bumps the Node floor used to either fail
mid-`pnpm install` (which then rolls back successfully) or restart on the
new build and crash in the boot path (which then rolls back via the
health-check timer). Both paths recover, but they burn a drain + restart
cycle on a condition we can reject upfront.
Implementation
- new PreflightReason `node-engine-mismatch`
- new dep `readTargetEnginesNode(tag)` — runs the git-show as a child
process with stdio captured to a string; missing tag / missing file /
malformed JSON / missing engines.node all resolve to null (treated as
"no constraint, pass")
- uses existing semver dep with includePrerelease: true
- new PreflightInput field `currentNodeVersion`; threaded from
process.versions.node in both wirings (scheduler + manual apply)
- check runs *after* signature verification so we trust the package.json
- PreflightResult carries an optional `detail` string; applyPipeline
appends it to the lastResult.reason so the admin UI shows e.g.
"node-engine-mismatch: target requires Node >=26.0.0, running 25.0.0"
Tests: 6 new vitest cases (no engines.node, satisfies, fails below floor,
caret range, loose-spaced range, ordering after signature). Full
backend-new: 635 passed (was 629).
Refs #7607
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(updater): email admin on auto-rollback / preflight-failed (#7607)
Before this commit, only the terminal rollback-failed state emailed the
admin. Auto-recovered failures (rolled-back-install-failed, rolled-back-
build-failed, rolled-back-health-check, rolled-back-crash-loop) and pre-
flight-failed surfaced only via the /admin/update banner — so a 3am
autonomous update that failed because of, say, a Node engine bump would
roll back silently and stay invisible until the admin next logged in.
Notifier
- new EmailKinds: 'update-preflight-failed', 'update-rolled-back',
'update-rollback-failed'
- new pure decideOutcomeEmail(input) → {toSend, newState}
- dedupe key `<outcome>:<targetTag>` in EmailSendLog.lastFailureKey:
same outcome on same tag emits one email per cycle (kills retry-loop
spam); a different outcome or different tag resets the key
- rollback-failed always fires (terminal — overrides dedupe)
- state.ts validator + loadState backfill the new field for legacy
state files (Tier 1/2/3 installs upgrading in place)
Wiring
- new index.ts helper notifyApplyFailure() loads state, runs the pure
notifier, sends (via the nodemailer-backed sendEmailViaSmtp from the
previous commit), persists the new dedupe key — all best-effort
- schedulerTriggerApply: fires on applyUpdate returning preflight-failed
or rolled-back
- /admin/update/apply HTTP handler: same
- boot path in expressCreateServer: if state.lastResult is a failure
outcome we haven't already emailed about, fire then. Covers:
- health-check timeout rollback (timer expired between boots)
- crash-loop forced rollback caught on a later boot
- preflight-failed where the process didn't get to email before exit
- unacknowledged rollback-failed terminal
Tests
- 8 new vitest cases for decideOutcomeEmail (adminEmail=null, each
outcome's content, dedupe by tag, dedupe by outcome, rollback-failed
bypass)
- Full backend-new suite: 643 passed (was 635)
Refs #7607
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(updater): address Qodo review on tier 4
- UpdatePage: only show "deferred until" subtitle when scheduledFor
actually matches nextWindowOpensAt. The previous `scheduledFor >
now + 60s` heuristic misfired during a normal in-window 15-min
grace period.
- applyPipeline: return the enriched preflight reason (`reason:
detail`) instead of only `pf.reason`, so /admin/update/apply 409
bodies and failure-notify emails preserve diagnostics like the
Node engine mismatch detail.
- updater/index: key the cached nodemailer transport on the full
set of SMTP options (host + port + secure + auth) so runtime
changes to port/credentials via reloadSettings() invalidate
the 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>
* docs: design spec for #7524 drop swagger-ui + privacy opt-outs
Three-deliverable plan: vendor RapiDoc to replace swagger-ui-express
(Scarf-injecting), add privacy.updateCheck and privacy.pluginCatalog
opt-outs for our two outbound calls, and ship PRIVACY.md as a public
stance doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: implementation plan for #7524 swagger-ui + privacy opt-outs
Twelve TDD-flavoured tasks: privacy settings shape, UpdateCheck +
installer opt-outs (each with a failing-test-first cycle), admin
backend/UI plumbing, dependency drop, vendored RapiDoc, PRIVACY.md,
final verification matrix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(privacy): add privacy block to settings shape
Adds privacy.updateCheck and privacy.pluginCatalog, both defaulting to
true so behavior is unchanged until operators opt out.
Refs #7524
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(privacy): honour privacy.updateCheck=false in UpdateCheck
check() and getLatestVersion() now early-return when the setting is
off. Logs once on first skip. The admin "update available" panel
already tolerates an undefined latestVersion.
Refs #7524
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(privacy): honour privacy.pluginCatalog=false in installer
Extracts the gate into pluginCatalogGuard.ts so it can be unit-tested
under vitest without dragging in the CJS require() chain from
installer.ts. getAvailablePlugins() now throws the tagged disabled
error before any fetch.
Refs #7524
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(privacy): emit results:catalogDisabled when pluginCatalog off
Short-circuits the four catalog-driven socket events. The install/
uninstall events are untouched so operators can still install by
plugin name even when the catalog is disabled.
Refs #7524
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(bin): stalePlugins reads updateServer and honours privacy flag
Was hardcoding static.etherpad.org and ignoring opt-out. Now exits 0
cleanly when privacy.pluginCatalog=false.
Refs #7524
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(settings): document privacy block in settings template
Refs #7524
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api-docs): replace swagger-ui-express with RapiDoc shell
Drops the swagger-ui-express dep (third-party Scarf telemetry pixel,
see swagger-api/swagger-ui#10573) and serves /api-docs with a static
HTML shell that mounts <rapi-doc>. /api-docs.json is unchanged.
The vendored RapiDoc asset is added in the next commit so the tree is
broken for one diff hunk — pair this with the rapidoc-min.js commit
during review.
Refs #7524
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api-docs): vendor RapiDoc 9.3.4 (MIT) as static asset
Pinned bundle with checksum in VERSION. Replaces swagger-ui-dist which
shipped a Scarf telemetry pixel.
Disables RapiDoc's bundled Google Fonts request via load-fonts="false"
plus explicit regular-font/mono-font system stacks — RapiDoc's CSS
@font-face rules would otherwise fetch Open Sans from fonts.gstatic.com
at render time.
Also fixes the /api-docs route's res.sendFile to use an absolute path
resolved via settings.root (the previous {root: 'src/static'} was
resolved from CWD which is already src/, producing src/src/static).
Refs #7524
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): banner when plugin catalog is disabled
Subscribes to results:catalogDisabled and renders a localized info
banner on the plugins page. install/uninstall still function via CLI.
Refs #7524
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: PRIVACY.md and README/CHANGELOG pointers
Publishes Etherpad's stance on telemetry: two documented, opt-out
outbound calls; no third-party analytics; no install-time phone-homes
in our deps.
Refs #7524
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin): await checkPluginForUpdates and emit array on error
Qodo flagged that checkUpdates emitted the unresolved Promise (missing
await) and emitted {} for updatable on the error path, both breaking
the admin UI's expected string[] shape. Pre-existing bug surfaced when
the surrounding block was edited for the privacy.pluginCatalog gate.
Refs #7524
* feat(api-docs): swap RapiDoc for Scalar (actively maintained)
Per @SamTV12345's review on #7757: RapiDoc has been effectively
unmaintained for a while. Scalar (https://github.com/scalar/scalar)
is MIT-licensed, actively developed, and ships a self-contained
standalone bundle that works the same way for our purposes.
Privacy posture is preserved by configuring the embed:
- withDefaultFonts: false (no fonts.scalar.com woff2 fetch)
- telemetry: false (defensive)
- agent.disabled: true (no api.scalar.com/vector/* calls)
- mcp.disabled: true (no MCP integration)
- showDeveloperTools: 'never'
- hideClientButton: true
Verified with headless Chromium: page loads /api-docs, mounts Scalar,
renders the Etherpad OpenAPI document, and makes zero requests to
any host other than localhost.
Vendor:
- src/static/vendor/scalar/standalone.js (@scalar/api-reference 1.57.2)
- src/static/vendor/scalar/VERSION (sha256 pinned)
- src/static/vendor/scalar/LICENSE (MIT)
Removed:
- src/static/vendor/rapidoc/*
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* harden: reject USER_CHANGES inserts without an author attribute
Insert ops MUST carry the author attribute reference so that pad.atext.text
and pad.atext.attribs stay in lock-step. An accepted insert with empty
attribs would grow text without contributing matching attribute markers,
leaving the stored AText in a state where the two iterables disagree on
length when reconstructed. Downstream clients then fail reconciliation in
ace2_inner.ts:setDocAText with 'mismatch error setting raw text in
setDocAText' on every subsequent pad load — making the affected pad
effectively unloadable until manually repaired.
This commit adds a single defensive check inside the existing per-op
validation loop in handleUserChanges: when an op is a '+' (insert) and
its attribs string doesn't yield an 'author' entry via
AttributeMap.fromString, reject with badChangeset. The check piggybacks
on the wireApool that was already constructed for the prior author-match
validation, so no extra parsing.
Test fixtures in messages.ts were updated to send proper author-attributed
inserts plus the matching apool (mirroring what the JS web client always
does). A new regression test 'insert without author attribute is rejected'
locks in the new behaviour.
* harden: also close the HTTP API / plugin path via stable system author
The first commit closed the socket.io USER_CHANGES hole. This commit closes
the parallel path through Pad.spliceText (used by API.setText, API.appendText,
the import flow, and plugins like ep_post_data) where an unattributed insert
would otherwise produce a malformed AText.
Approach: instead of REJECTING (which would break ep_post_data and many
existing tests that call setText/appendText without an authorId), substitute
a stable system author when none is provided. The resulting changeset is
properly attributed, the AText stays well-formed, and existing callers
continue to work unchanged. Plugins that want named author attribution
should still pass an explicit authorId (e.g., one allocated via
authorManager.createAuthor).
Pad.SYSTEM_AUTHOR_ID = 'a.etherpad-system' — a stable identifier that
appears in the pad's attribute pool when internal callers (HTTP API,
plugins, server-side imports) write text without naming an author. The
existing 'attribute changes by another author' protections still apply
to socket.io USER_CHANGES paths — a remote client can't impersonate the
system author for inserts (their session author check fires first).
Test:
- Pad.ts spec adds 'spliceText with empty authorId attributes to the
system author' — verifies pad text lands AND the pool contains the
system-author binding. Existing tests that pass an authorId are
unaffected.
* harden: reject USER_CHANGES that would strand the trailing newline
Etherpad's pad text always ends with '\n'. _handleUserChanges previously
appended a separate `nlChangeset` correction revision whenever the
applied USER_CHANGES left the pad without a trailing '\n'. The stored
pad ended up well-formed, but the FIRST NEW_CHANGES broadcast (the
malformed user revision itself) reached browsers BEFORE the correction
did, and applyToAttribution's MergingOpAssembler aborts with
"line assembler not finished" on a non-'\n'-terminated doc — the
watching browser session then dropped the changeset and any subsequent
edits silently no-op'd until the user reloaded.
Replace the silent auto-correction with an explicit reject. Compute
`applyToText(rebasedChangeset, prevText)` before appendRevision; if the
result doesn't end with '\n', throw -> badChangeset disconnect. Clients
must emit USER_CHANGES whose application preserves the invariant —
this matches what the JS web client already does and forces non-JS
clients (etherpad-pad, third-party integrations) to surface their bugs
in their own logs instead of stranding the trailing newline in pad
revision history.
Also fixes a latent retransmission-detection bug surfaced by this PR's
author-attrib changes: moveOpsToNewPool renumbers `*N` references to
whatever slot the pad pool assigns, which can differ from the wire
form's slot. Comparing the raw client wire against the stored revision
form (`changeset === c`) then misses legitimate retransmissions and
the same edit gets duplicated. Snapshot the post-pool-mapping form
(`canonicalCs`) and compare that against `c` instead.
Backend test additions:
- 'changeset that would strand the trailing \\n is rejected' covers
the new rejection path with wire `Z:6>1|1=6*0+1$X` against
`hello\n`.
- handleMessageSecurity test now captures roSocket's own authorId and
uses it in the apool sent through roSocket, because the prior PR
commit made `*0` referencing the wrong author a hard reject.
All 1130 backend tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump etherpad-cli-client to ^4.0.3
4.0.3 sends author-attributed inserts and preserves the trailing
newline, complying with this PR's tightened USER_CHANGES validation.
The rate-limit CI workflow drives the test pad via this client, so
without the bump the new server-side rejects fire on the very first
\`pad.append()\` and the rate-limit disconnect never gets a chance to
arrive — testlimits.sh exits 0 instead of 1 and the rate-limit job
fails with "ratelimit was not triggered when sending every 99 ms".
Refs ether/etherpad-cli-client#131
* harden: reject USER_CHANGES that name the reserved system author
The session-author equality check already rejects wire `*N` that
names a different real user, but `a.etherpad-system` is server-
internal — it's only used when spliceText / setText is called with
an empty authorId from HTTP API or plugin paths. A wire op that
names it is either a confused client or an attempt to launder
edits through a reserved attribution slot. Refuse.
Backend test 'insert claiming the reserved system author is
rejected' locks in the new behavior with wire `Z:1>5*0+5$hello`
plus an apool that maps slot 0 to `a.etherpad-system`. All 1131
backend tests pass.
Inline literal `'a.etherpad-system'` rather than importing the
constant from `Pad.SYSTEM_AUTHOR_ID` — `require('../db/Pad')` at
PadMessageHandler module scope returned a partially-initialized
class via the padManager circular path, leaving the static-field
access undefined at runtime.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>