3.1.0
3.1 ships the self-update programme's Tier 4 — autonomous in a maintenance window for real (the v3.0.0 notes documented the design; this is the release the code actually lands in), adds first-class SMTP delivery so update failures email the admin, and bundles a defence-in-depth pass across the HTTP/API entry points. Two new admin-facing escape hatches arrive: a preflight check that aborts an update before it mutates the working tree when the target tag's engines.node doesn't match the running runtime, and email notifications for every auto-rollback / preflight outcome (not just the terminal rollback-failed state).
Notable enhancements
- Self-update — Tier 4 (autonomous in a maintenance window). Set
updates.tier: "autonomous"together withupdates.maintenanceWindow: {"start":"HH:MM","end":"HH:MM","tz":"local"|"utc"}to constrain autonomous updates to a nightly window. The scheduler snapsscheduledForforward to the next window opening when grace would otherwise land outside the window, and defers the fire when the window has closed by the timer callback. Cross-midnight windows (end < start) are supported; DST transitions are absorbed by host wall-clock arithmetic. A missing or malformed window degrades the policy to Tier 3 with an explicitpolicy.reasonofmaintenance-window-missing/maintenance-window-invalid; an admin banner surfaces the misconfiguration so autonomous behaviour is not silently disabled. The admin update page shows a "Maintenance window" section with the parsed window summary, the next opening, and a "deferred until " subtitle on the scheduled panel when the timer has been snapped forward. Closes #7607 (#7753). - Updater — real SMTP via nodemailer (new top-level
mail.*block). Replaces the "(would send email)" stub. New settings:mail.host,mail.port,mail.secure,mail.from,mail.auth.{user,pass}.mail.host=nullkeeps the legacy log-only behaviour. Thenodemailerdependency is lazy-imported on first send so installs that don't configure mail pay no runtime cost; the transport is cached on the full SMTP options tuple so areloadSettings()change to host/port/credentials invalidates the cache.settings.json.dockerreadsMAIL_HOST/MAIL_FROM/MAIL_PORT/MAIL_SECUREfrom env. Send errors are logged warn and swallowed so a transient SMTP failure can never poison the updater state machine. - Updater — preflight against the target tag's
engines.node. Before mutating the working tree,runPreflightnow runsgit show <tag>:package.jsonand verifiesprocess.versions.nodesatisfies the target'sengines.node. A mismatch fails cleanly atpreflight-failedwith the detailtarget requires Node >=X, running Y— no drain, no restart, no rollback. The check runs after signature verification so we only trust signedpackage.json. NewPreflightReason: 'node-engine-mismatch'. - Updater — email admin on rollback / preflight-failed (not just
rollback-failed). Before this release only the terminalrollback-failedstate emailed. Auto-recovered failures (rolled-back-install-failed,rolled-back-build-failed,rolled-back-health-check,rolled-back-crash-loop) andpreflight-failednow also fire one email per<outcome>:<targetTag>(dedupe key inEmailSendLog.lastFailureKey). A 3am autonomous update that rolls back because of, say, a Node engine bump now lands in the admin inbox at 3am instead of staying invisible until the next admin login. Boot-path catch-up covers cases where the failure preceded a clean process exit (timer-fired health-check rollback, crash-loop forced rollback, preflight-failed that didn't get to email before exit). - API —
listAuthorsOfPadfilters the synthetic system author.Pad.SYSTEM_AUTHOR_ID(a.etherpad-system) is the placeholder Etherpad attributes to when the HTTP API receives a call without anauthorId(setText, setHTML, appendText, server-side import). It was leaking throughlistAuthorsOfPad, making pads with only API-driven content appear to have one "real" author. The synthetic id is now filtered at that API surface only —getAllAuthors()and downstream callers (copy, anonymize, atext verification) still see it. Fixes #7785 / #7790 (#7793).
Notable fixes
- Export HTML — ordered-list counter no longer poisoned by a sibling unordered list. When an ordered-list level was the only consumer of
olItemCounts, closing any list at that depth (including a<ul>that happened to share the level) reset the counter to 0. A subsequent unrelated<ol>at the same depth then took the "counter exists but is 0" branch and emitted<ol class="...">without thestart=attribute. The reset is now gated online.listTypeName === 'number'so closing an unordered list never touches the ol bookkeeping. Fixes #7786 / #7787 (#7791). - Export — bad
:revreturns a meaningful 500 body, not Express's HTML error page. A non-numeric:rev(e.g./p/foo/test1/export/txt) reachedcheckValidRevwhich throwsCustomError('rev is not a number', 'apierror'); the message fell through.catch(next)and Express's default renderer returned an HTML 500 page. The route handler now catches the apierror and emitserr.messageas a deterministictext/plain500. As a follow-up,checkValidRevruns beforeres.attachment()so an invalid rev no longer leaves aContent-Dispositionheader in place (browsers were offering to save the error message as a file), and unrelated export failures (conversion, fs, soffice) are surfaced as text/plain rather than the HTML stack page. Fixes #7788 (#7792).
Security hardening
A bundle of defence-in-depth tightening picked up during an internal audit pass (#7784):
- HTTP API — OAuth JWT path. Verify the signature before reading any claim off the payload; require
admin: truestrictly (presence is no longer sufficient). The apikey comparison switches tocrypto.timingSafeEqual. - Import/Export temp-file path tokens. Derived from
crypto.randomBytes(16)instead ofMath.random(). - Token transfer. Records now have a 5-minute TTL and are single-use (removed from the store before responding). The author token is no longer in the redemption response body — the
HttpOnlycookie is the only delivery channel. x-proxy-pathheader sanitiser (newsrc/node/utils/sanitizeProxyPath.ts). Shared byadmin.tsandspecialpages.ts. Strips characters outside[A-Za-z0-9_./-], collapses leading//+to a single/, rejects..traversal.admin.tsalso emitsVary: x-proxy-pathandCache-Control: private, no-storeso a poisoned response can never be reused for another origin.Pad.appendRevisioninsert-op author invariant. Centralises the "every insert op carries anauthorattribute" rule the socket handler already enforced, so non-wire callers (setText,setHTML,restoreRevision, plugin paths) get the same check.Pad.initandsetPadHTMLsubstituteSYSTEM_AUTHOR_IDwhen no author is supplied — same patternsetText/spliceTextalready used.setPadRawlegacy-import rewrite. Bulk-import bypassesappendRevision, so a hand-crafted.etherpadfile could persist non-conforming records that any subsequentsetText/setHTMLwould refuse to extend. A pre-pass now walks revs in order, sanitises each changeset's+ops against the cumulative pad pool (substitutingSYSTEM_AUTHOR_IDwhere needed), and re-applies each changeset to a running atext so the head atext and key-revmeta.atext/meta.poolsnapshots stay in lock-step. Conforming payloads round-trip unchanged.
Internal / contributor-facing
- Backend tests —
tests/backend/specs/{api,admin}/*un-skipped. The pnpm test script's glob (tests/backend/specs/**.ts) only matched depth-1 files. Every spec underapi/(14 files) andadmin/(2 files) has been silently skipped by CI. Switched to--extension ts --recursiveso mocha walks the tree as documented. A new vitest regression check reads the pnpm script, hands mocha the same arguments under--dry-run --list-files, and asserts representative specs from both subdirectories appear in the discovered list (#7789). - CI — Windows
npx ENOENTin the glob-discovery regression check.execFileSync('npx', ...)doesn't pick upnpx.cmdon Windows runners. Resolved by runningmocha's JS entry directly viarequire.resolveunder the current node process. Path normalisation now goes throughpath.relative+replace([\\/])so mixed-separator / drive-letter casing on Windows mocha output still matches the POSIX-relative assertions (#7794). - CI —
anonymizeAuthorSocketsuite gated on admin-socket health whenep_hash_authis installed. Un-hiding the suite in #7789 surfaced a 14-minute stall on every with-plugins matrix run becauseep_hash_auth'shandleMessagehook fires for every socket message regardless of namespace and reads from the deprecatedclientcontext (undefined for non-pad namespaces). Until the root cause lands (tracked in #7795), the suite skips itself when an application-level probe shows the admin/settingsnamespace isn't responding — keeps the no-plugin matrix covered and stops burning ~14 minutes per with-plugins run (#7796).
Localisation
- Multiple updates from translatewiki.net.