diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 0d1c51a7c..2f4893e5e 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -151,6 +151,32 @@ jobs:
docker volume rm ep7718-plugins >/dev/null 2>&1 || true
[ "$ok" = "1" ] || exit 1
+ -
+ # Regression test: Etherpad container must boot without external
+ # network access (e.g., air-gapped host or restrictive runtime policy).
+ name: Regression — boot with disabled network (--network none)
+ working-directory: etherpad
+ run: |
+ docker run --rm -d \
+ --network none \
+ --name test-offline ${{ env.TEST_TAG }}
+ docker logs -f test-offline &
+ ok=0
+ for i in $(seq 1 60); do
+ status=$(docker container inspect -f '{{.State.Health.Status}}' test-offline 2>/dev/null) || {
+ echo "container exited prematurely while offline"
+ docker logs test-offline || true
+ break
+ }
+ case ${status} in
+ healthy) ok=1; echo "offline boot healthy after $((i*2))s"; break;;
+ starting) sleep 2;;
+ *) echo "unexpected status: ${status}"; docker logs test-offline || true; break;;
+ esac
+ done
+ docker rm -f test-offline >/dev/null 2>&1 || true
+ [ "$ok" = "1" ] || exit 1
+
build-test-local-plugin:
# Regression coverage for #7687: the Docker image's
# `bin/installLocalPlugins.sh` step runs as the `etherpad` user and
diff --git a/.github/workflows/downstream-smoke.yml b/.github/workflows/downstream-smoke.yml
new file mode 100644
index 000000000..c565a22c5
--- /dev/null
+++ b/.github/workflows/downstream-smoke.yml
@@ -0,0 +1,108 @@
+name: Downstream smoke
+
+# Boots a real Etherpad from the PR and verifies the separate downstream clients
+# (Rust terminal editor, Node CLI, desktop/mobile) still round-trip against it.
+# Phase 1 lands the boot/healthcheck/self-check/teardown harness + manifest; the
+# per-client matrix activates as each client flips `enabled:true` in clients.json.
+
+on:
+ pull_request:
+ paths-ignore:
+ - "doc/**"
+ - "docs/**"
+ schedule:
+ - cron: '0 4 * * *' # nightly against the default branch
+
+permissions:
+ contents: read
+
+jobs:
+ smoke:
+ name: Boot + downstream clients
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ env:
+ PNPM_HOME: ~/.pnpm-store
+ APIKEY: downstream-smoke-key
+ steps:
+ - name: Checkout core (PR)
+ uses: actions/checkout@v6
+
+ - uses: actions/cache@v5
+ name: Cache pnpm store
+ with:
+ path: ${{ env.PNPM_HOME }}
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-store-
+
+ - uses: pnpm/action-setup@v6
+ name: Install pnpm
+ with:
+ run_install: false
+
+ - name: Use Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm i --frozen-lockfile
+
+ - name: Boot Etherpad on :9003 (apikey auth)
+ run: |
+ # The template ships a literal "port": 9001 and sso auth; rewrite both.
+ # (PORT env is ignored once the settings file specifies a port.)
+ sed -e 's#"port": 9001,#"port": 9003,#' \
+ -e 's#${AUTHENTICATION_METHOD:sso}#apikey#' \
+ settings.json.template > settings.json
+ # Fail fast if the template format drifted and sed silently no-op'd.
+ grep -q '"port": 9003,' settings.json \
+ || { echo "::error::port rewrite failed — settings.json.template format changed"; exit 1; }
+ grep -q '"authenticationMethod": "apikey"' settings.json \
+ || { echo "::error::auth rewrite failed — settings.json.template format changed"; exit 1; }
+ printf '%s' "$APIKEY" > APIKEY.txt
+ pnpm run prod > /tmp/ep.log 2>&1 &
+ echo $! > /tmp/ep.pid
+ echo "booted pid $(cat /tmp/ep.pid)"
+
+ - name: Wait for healthcheck
+ run: |
+ for i in $(seq 1 60); do
+ if curl -fsS "http://localhost:9003/api/" >/dev/null 2>&1; then
+ echo "server up after ${i} tries"; exit 0
+ fi
+ sleep 2
+ done
+ echo "::error::server did not come up"; tail -50 /tmp/ep.log; exit 1
+
+ - name: Self-check — authenticated create + read roundtrip
+ run: |
+ K="$APIKEY"
+ curl -fsS "http://localhost:9003/api/1/createPad?apikey=${K}&padID=smoke&text=hi%0A" | tee /tmp/create.json
+ grep -q '"code":0' /tmp/create.json
+ curl -fsS "http://localhost:9003/api/1/getText?apikey=${K}&padID=smoke" | tee /tmp/get.json
+ grep -q '"text":"hi' /tmp/get.json
+
+ - name: Generate canonical wire-vectors
+ run: cd src && pnpm run vectors:gen
+
+ # Rust toolchain for the `rust`-kind client (etherpad-pad). Other kinds
+ # use the node+pnpm already set up above.
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Run enabled downstream clients
+ env:
+ SMOKE_URL: http://localhost:9003
+ SMOKE_APIKEY: ${{ env.APIKEY }}
+ run: bash src/tests/downstream/run-clients.sh
+
+ - name: Teardown (by PID, never pkill)
+ if: always()
+ run: |
+ if [ -f /tmp/ep.pid ]; then
+ kill "$(cat /tmp/ep.pid)" 2>/dev/null || true
+ echo "killed $(cat /tmp/ep.pid)"
+ fi
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae4622048..de9c171ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,28 @@
+# 3.3.1
+
+3.3.1 is a small bug-fix and hardening follow-up to 3.3.0. It closes a stored-XSS vector in the numbered-list `start` attribute, hardens the database layer so a dropped connection to PostgreSQL / Redis / RethinkDB no longer crashes the process (via ueberdb2 6.1.9), and fixes a handful of pad and admin regressions — the iOS dark-mode status bar, the settings language dropdown, the pad-deletion modal under `allowPadDeletionByAllUsers`, and a single unreadable pad blanking the admin Manage-pads list.
+
+### Security
+
+- **Pad editor — escape and integer-coerce the numbered-list `start` attribute (GHSA-f7h5-v9hm-548j, #7937).** A crafted `
` value flowed unescaped into `domline.ts`, a distinct client-side sink from the export-path fix in 3.3.0's #7905. The value is now integer-coerced and HTML-escaped before it reaches the DOM. A jsdom regression test covers the sink.
+
+### Notable fixes
+
+- **Skin — paint the root canvas so iOS dark mode has no white status bar (#7606 / #7931).** iOS Safari paints the top safe area from the `html` root background, which `theme-color` (an Android address-bar hint) does not affect, so dark-mode pads showed a white status-bar strip on iOS. Colibris now sets the root background and `color-scheme` so the safe area matches the editor.
+- **Settings — show the detected language in the dropdown (#7925 / #7928).** The settings language `` did not reflect the language Etherpad had actually auto-detected; it now shows the active selection.
+- **Pad — don't issue a deletion token (or show its modal) when `allowPadDeletionByAllUsers` is on (#7929).** With pad deletion open to all users the client still minted a deletion token and surfaced the confirm modal; both are now suppressed in that configuration.
+- **Admin — one unreadable pad no longer empties the Manage-pads list (#7935 / #7938).** A single pad that failed to read could throw out of the list-hydration path and blank the entire admin Manage-pads view; the read is now guarded per-pad so the rest of the list still renders.
+
+### Internal / contributor-facing
+
+- **CI — downstream client compatibility gate (#7923 / #7924 / #7927).** A new gate smoke-tests the published `etherpad-pad`, `etherpad-cli`, and `etherpad-desktop` clients against the server build (Phase 1 + Phase 2), with robust per-client error handling in `run-clients.sh` so one client's failure is reported rather than masking the others.
+- **CI — verify Etherpad boots offline (#7936).** Adds a test step that confirms a built Etherpad starts with no network access.
+
+### Dependencies
+
+- `ueberdb2` 6.1.8 → 6.1.9 — PostgreSQL pool errors are now handled and TCP keep-alive is enabled (fixes #7878), and the Redis and RethinkDB drivers attach connection-error handlers so a dropped database connection no longer crashes the Etherpad process.
+- `semver` 7.8.2 → 7.8.3 (#7933), `rate-limiter-flexible` 11.1.1 → 11.2.0 (#7934), plus a dev-dependencies group update (#7932).
+
# 3.3.0
3.3 is primarily a security-hardening release. A defence-in-depth pass tightens the HTTP API entry points, switches random-id generation to a CSPRNG, escapes exported `data-*` attributes, and flips the shipped Docker deployment defaults so a fresh install no longer boots with implicit credentials or a trusting proxy. Alongside that, the `ep_*` pad-options passthrough that shipped opt-in in 3.0.0 is now on by default, the in-pad timeslider learns to honour the editor's view settings (authorship colours, font family, line numbers), and a long tail of pad-editor layout, RTL, and URL-encoding fixes lands. The release also carries the root-cause fix for the long-standing Windows backend-test "silent ELIFECYCLE" flake.
diff --git a/admin/package.json b/admin/package.json
index b2bb60bea..e5edb3568 100644
--- a/admin/package.json
+++ b/admin/package.json
@@ -1,7 +1,7 @@
{
"name": "admin",
"private": true,
- "version": "3.3.0",
+ "version": "3.3.1",
"type": "module",
"scripts": {
"dev": "pnpm gen:api && vite",
@@ -27,8 +27,8 @@
"@radix-ui/react-visually-hidden": "^1.2.5",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
- "@typescript-eslint/eslint-plugin": "^8.60.1",
- "@typescript-eslint/parser": "^8.60.1",
+ "@typescript-eslint/eslint-plugin": "^8.61.0",
+ "@typescript-eslint/parser": "^8.61.0",
"@vitejs/plugin-react": "^6.0.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"eslint": "^10.4.1",
diff --git a/bin/package.json b/bin/package.json
index 63a9286ab..f7f8556b1 100644
--- a/bin/package.json
+++ b/bin/package.json
@@ -1,6 +1,6 @@
{
"name": "bin",
- "version": "3.3.0",
+ "version": "3.3.1",
"description": "",
"main": "checkAllPads.js",
"directories": {
@@ -9,9 +9,9 @@
"dependencies": {
"ep_etherpad-lite": "workspace:../src",
"log4js": "^6.9.1",
- "semver": "^7.8.2",
+ "semver": "^7.8.3",
"tsx": "^4.22.4",
- "ueberdb2": "^6.1.8"
+ "ueberdb2": "^6.1.9"
},
"devDependencies": {
"@types/node": "^25.9.2",
diff --git a/docs/superpowers/plans/2026-06-09-downstream-client-compat-tests-phase1.md b/docs/superpowers/plans/2026-06-09-downstream-client-compat-tests-phase1.md
new file mode 100644
index 000000000..778c9a3d0
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-09-downstream-client-compat-tests-phase1.md
@@ -0,0 +1,633 @@
+# Downstream Client Compatibility Tests — Phase 1 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a core-side compatibility gate (golden-vector + socket-sequence + HTTP-API-shape contract tests, plus a downstream-smoke workflow scaffold) so a PR against `develop` detects changes that would break the separate downstream clients.
+
+**Architecture:** Layer A — hermetic contract tests run inside core's existing mocha backend suite, anchored to a committed `wire-vectors.json` fixture generated from core's own `Changeset`/`AttributePool`. Layer B — a `downstream-smoke.yml` workflow that boots a real Etherpad on :9003, proves the boot→healthcheck→teardown cycle with a self-check, and is ready to matrix over a `clients.json` manifest as clients register in Phase 2.
+
+**Tech Stack:** TypeScript, mocha (`--import=tsx`), `assert/strict`, supertest + socket.io-client via `common.ts` helpers, GitHub Actions.
+
+**Spec:** `docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md`
+
+**Scope note:** This plan is **Phase 1 only** (all changes in `ether/etherpad`). Phase 2 (wiring each client repo's `test:vectors` + smoke and registering it in the manifest) is one separate plan/PR per client repo and is out of scope here.
+
+---
+
+## File Structure
+
+- Create `src/tests/backend/specs/downstream/generate-vectors.ts` — pure module exporting `generateVectors(): WireVector[]`; the single source of truth for the canonical wire fixtures. Also runnable as a CLI to (re)write the fixture.
+- Create `src/tests/fixtures/wire-vectors.json` — committed canonical fixture (generated, never hand-edited).
+- Create `src/tests/backend/specs/downstream/wire-vectors.ts` — mocha spec asserting the committed fixture is stable and self-consistent.
+- Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts` — mocha spec asserting the socket.io handshake + USER_CHANGES→ACCEPT_COMMIT sequence/shapes.
+- Create `src/tests/backend/specs/downstream/wire-http-api.ts` — mocha spec snapshotting client-facing HTTP API response shapes.
+- Create `src/tests/downstream/clients.json` — manifest of downstream clients (data; entries `enabled:false` until their Phase-2 smoke lands).
+- Create `.github/workflows/downstream-smoke.yml` — boot/healthcheck/self-check/teardown + manifest matrix scaffold.
+- Modify `src/package.json` — add `vectors:gen` script.
+
+All backend specs live under `specs/downstream/` so the existing `mocha ... --recursive tests/backend/specs` glob picks them up with zero config change.
+
+---
+
+## Task 1: Golden-vector generator module
+
+**Files:**
+- Create: `src/tests/backend/specs/downstream/generate-vectors.ts`
+- Test: `src/tests/backend/specs/downstream/wire-vectors.ts` (created in Task 3; this task is tested via Task 2's run)
+
+- [ ] **Step 1: Write the generator module**
+
+Create `src/tests/backend/specs/downstream/generate-vectors.ts`:
+
+```typescript
+'use strict';
+
+/**
+ * Single source of truth for the downstream wire-compatibility fixtures.
+ *
+ * Each vector is a self-contained changeset application: given `initialAText`
+ * and `pool`, applying `changeset` yields `resultAText`. Downstream clients
+ * (which reimplement Etherpad's changeset/attribpool decoders) consume the
+ * exact same JSON and must reproduce `resultAText`. See the Phase 1 plan.
+ *
+ * Runnable as a CLI to (re)write src/tests/fixtures/wire-vectors.json:
+ * pnpm run vectors:gen
+ */
+
+import Changeset from '../../../../static/js/Changeset';
+import AttributePool from '../../../../static/js/AttributePool';
+
+export type WireVector = {
+ name: string;
+ initialText: string;
+ changeset: string;
+ pool: ReturnType;
+ resultText: string;
+};
+
+const vector = (
+ name: string,
+ initialText: string,
+ build: (pool: AttributePool) => string,
+): WireVector => {
+ const pool = new AttributePool();
+ const changeset = build(pool);
+ Changeset.checkRep(changeset);
+ return {
+ name,
+ initialText,
+ changeset,
+ pool: pool.toJsonable(),
+ resultText: Changeset.applyToText(changeset, initialText),
+ };
+};
+
+export const generateVectors = (): WireVector[] => [
+ vector('plain-insert', 'abc\n', () =>
+ Changeset.makeSplice('abc\n', 3, 0, 'XYZ')),
+
+ vector('plain-delete', 'abcdef\n', () =>
+ Changeset.makeSplice('abcdef\n', 1, 3, '')),
+
+ vector('formatted-insert', 'abc\n', (pool) =>
+ Changeset.makeSplice('abc\n', 3, 0, 'bold', [['bold', 'true']], pool)),
+
+ vector('multiline-insert', 'abc\n', () =>
+ Changeset.makeSplice('abc\n', 3, 0, 'one\ntwo\n')),
+
+ vector('attrib-reuse', 'abc\n', (pool) => {
+ // Two formatted inserts sharing one pool entry exercises pool index reuse.
+ const cs1 = Changeset.makeSplice('abc\n', 0, 0, 'A', [['bold', 'true']], pool);
+ const mid = Changeset.applyToText(cs1, 'abc\n');
+ const cs2 = Changeset.makeSplice(mid, mid.length - 1, 0, 'B', [['bold', 'true']], pool);
+ return Changeset.compose(cs1, cs2, pool);
+ }),
+];
+
+// CLI entry: write the canonical fixture to disk.
+if (require.main === module) {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const fs = require('fs');
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const path = require('path');
+ const out = path.join(__dirname, '../../../fixtures/wire-vectors.json');
+ fs.writeFileSync(out, `${JSON.stringify(generateVectors(), null, 2)}\n`);
+ // eslint-disable-next-line no-console
+ console.log(`wrote ${out}`);
+}
+```
+
+- [ ] **Step 2: Add the `vectors:gen` script**
+
+In `src/package.json`, add to `"scripts"` (alongside the existing `test` entry):
+
+```json
+"vectors:gen": "tsx tests/backend/specs/downstream/generate-vectors.ts",
+```
+
+- [ ] **Step 3: Sanity-run the generator (no fixture committed yet)**
+
+Run from `src/`:
+```bash
+cd src && pnpm run vectors:gen
+```
+Expected: prints `wrote .../src/tests/fixtures/wire-vectors.json` and the file exists with 5 vectors. Verify each has non-empty `changeset` and a `resultText` that differs from `initialText`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/tests/backend/specs/downstream/generate-vectors.ts src/package.json
+git commit -m "test(downstream): add golden wire-vector generator"
+```
+
+---
+
+## Task 2: Commit the generated fixture
+
+**Files:**
+- Create: `src/tests/fixtures/wire-vectors.json`
+
+- [ ] **Step 1: Generate the fixture**
+
+Run from `src/`:
+```bash
+cd src && pnpm run vectors:gen
+```
+Expected: `src/tests/fixtures/wire-vectors.json` written.
+
+- [ ] **Step 2: Eyeball the fixture**
+
+Open `src/tests/fixtures/wire-vectors.json`. Confirm it is a JSON array of 5 objects, each with keys `name, initialText, changeset, pool, resultText`. The `pool` for `plain-insert`/`plain-delete`/`multiline-insert` has empty `numToAttrib`; `formatted-insert` and `attrib-reuse` contain a `bold,true` entry.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/tests/fixtures/wire-vectors.json
+git commit -m "test(downstream): add committed golden wire-vectors fixture"
+```
+
+---
+
+## Task 3: Fixture stability + self-consistency spec
+
+**Files:**
+- Create: `src/tests/backend/specs/downstream/wire-vectors.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/tests/backend/specs/downstream/wire-vectors.ts`:
+
+```typescript
+'use strict';
+
+/**
+ * Guards the downstream wire-format contract:
+ * - the committed fixture exactly matches a fresh regeneration (any drift is a
+ * deliberate wire change and must be re-generated + reviewed in the same PR), and
+ * - every vector is internally consistent under core's own Changeset engine.
+ */
+
+const assert = require('assert').strict;
+import fs from 'fs';
+import path from 'path';
+import Changeset from '../../../../static/js/Changeset';
+import {generateVectors} from './generate-vectors';
+
+const fixturePath = path.join(__dirname, '../../../fixtures/wire-vectors.json');
+
+describe(__filename, function () {
+ it('committed fixture matches a fresh regeneration', function () {
+ const committed = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
+ const fresh = generateVectors();
+ assert.deepEqual(committed, fresh,
+ 'wire-vectors.json is stale — run `pnpm run vectors:gen` and commit the result');
+ });
+
+ it('every vector applies to its result under core Changeset', function () {
+ for (const v of generateVectors()) {
+ Changeset.checkRep(v.changeset);
+ assert.equal(Changeset.applyToText(v.changeset, v.initialText), v.resultText,
+ `vector ${v.name} result mismatch`);
+ }
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it passes (fixture already committed)**
+
+Run from `src/`:
+```bash
+cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-vectors.ts
+```
+Expected: 2 passing.
+
+- [ ] **Step 3: Prove the guard bites (temporary edit)**
+
+Hand-edit one `resultText` in `src/tests/fixtures/wire-vectors.json`, re-run the command above.
+Expected: the "committed fixture matches a fresh regeneration" test FAILS. Then `git checkout src/tests/fixtures/wire-vectors.json` to restore.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/tests/backend/specs/downstream/wire-vectors.ts
+git commit -m "test(downstream): assert wire-vectors fixture stability + consistency"
+```
+
+---
+
+## Task 4: Socket message-sequence spec
+
+**Files:**
+- Create: `src/tests/backend/specs/downstream/wire-socket-sequence.ts`
+
+Reference for helpers: `src/tests/backend/specs/messages.ts` (`common.connect`, `common.handshake`) and `src/tests/backend/common.ts`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts`:
+
+```typescript
+'use strict';
+
+/**
+ * Pins the socket.io message sequence + shapes that every realtime client
+ * depends on: handshake -> CLIENT_VARS, then USER_CHANGES -> ACCEPT_COMMIT.
+ * A change here is a wire-protocol change that will break downstream clients.
+ */
+
+const assert = require('assert').strict;
+const common = require('../../common');
+const padManager = require('../../../node/db/PadManager');
+import AttributePool from '../../../../static/js/AttributePool';
+import Changeset from '../../../../static/js/Changeset';
+
+describe(__filename, function () {
+ let agent: any;
+ let socket: any;
+ let padId: string;
+
+ before(async function () { agent = await common.init(); });
+
+ beforeEach(async function () {
+ padId = common.randomString();
+ const pad = await padManager.getPad(padId, 'init\n');
+ await pad.setText('init\n');
+ const res = await agent.get(`/p/${padId}`).expect(200);
+ socket = await common.connect(res);
+ });
+
+ afterEach(async function () {
+ if (socket != null) socket.close();
+ socket = null;
+ });
+
+ it('handshake returns CLIENT_VARS with the client-facing shape', async function () {
+ const {type, data} = await common.handshake(socket, padId);
+ assert.equal(type, 'CLIENT_VARS');
+ assert.ok(data.userId, 'CLIENT_VARS.userId missing');
+ assert.ok(data.collab_client_vars, 'collab_client_vars missing');
+ assert.equal(typeof data.collab_client_vars.rev, 'number');
+ assert.ok(data.collab_client_vars.initialAttributedText, 'initialAttributedText missing');
+ });
+
+ it('USER_CHANGES is acknowledged with ACCEPT_COMMIT and a bumped rev', async function () {
+ const {data: clientVars} = await common.handshake(socket, padId);
+ const rev = clientVars.collab_client_vars.rev;
+ const pool = new AttributePool();
+ const cs = Changeset.makeSplice('init\n', 4, 0, '-typed', [], pool);
+
+ const accepted = common.waitForSocketEvent(socket, 'message');
+ socket.emit('message', {
+ type: 'COLLABROOM',
+ component: 'pad',
+ data: {type: 'USER_CHANGES', baseRev: rev, changeset: cs, apool: pool.toJsonable()},
+ });
+ const msg: any = await accepted;
+ assert.equal(msg.type, 'COLLABROOM');
+ assert.equal(msg.data.type, 'ACCEPT_COMMIT');
+ assert.equal(msg.data.newRev, rev + 1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it passes**
+
+Run from `src/`:
+```bash
+cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-socket-sequence.ts
+```
+Expected: 2 passing. If `waitForSocketEvent`'s default 1s timeout is too tight on the ACCEPT_COMMIT, pass a larger `timeoutMs` (its 3rd arg) — e.g. `common.waitForSocketEvent(socket, 'message', 5000)`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/tests/backend/specs/downstream/wire-socket-sequence.ts
+git commit -m "test(downstream): pin socket.io handshake + USER_CHANGES sequence"
+```
+
+---
+
+## Task 5: HTTP API shape spec
+
+**Files:**
+- Create: `src/tests/backend/specs/downstream/wire-http-api.ts`
+
+Reference: `src/tests/backend/specs/api/api.ts` for the `common.init()` agent + API-version pattern.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/tests/backend/specs/downstream/wire-http-api.ts`:
+
+```typescript
+'use strict';
+
+/**
+ * Snapshots the *shapes* (keys/types, not volatile values) of the HTTP API
+ * endpoints downstream clients call to create pads and round-trip text.
+ */
+
+const assert = require('assert').strict;
+const common = require('../../common');
+
+describe(__filename, function () {
+ let agent: any;
+ let apiVersion = 1;
+ const apiKey = common.apiKey;
+ const padId = common.randomString();
+ const ep = (point: string, qs: string) =>
+ `/api/${apiVersion}/${point}?apikey=${apiKey}&${qs}`;
+
+ before(async function () {
+ agent = await common.init();
+ const res = await agent.get('/api/').expect(200);
+ apiVersion = res.body.currentVersion;
+ });
+
+ it('createPad returns the standard {code,message,data} envelope', async function () {
+ const res = await agent.get(ep('createPad', `padID=${padId}&text=hello%0A`)).expect(200);
+ assert.deepEqual(Object.keys(res.body).sort(), ['code', 'data', 'message']);
+ assert.equal(res.body.code, 0);
+ });
+
+ it('setText + getText round-trips text through the documented shape', async function () {
+ await agent.get(ep('setText', `padID=${padId}&text=world%0A`)).expect(200);
+ const res = await agent.get(ep('getText', `padID=${padId}`)).expect(200);
+ assert.equal(res.body.code, 0);
+ assert.equal(typeof res.body.data.text, 'string');
+ assert.equal(res.body.data.text, 'world\n');
+ });
+
+ it('getRevisionsCount exposes a numeric revisions field', async function () {
+ const res = await agent.get(ep('getRevisionsCount', `padID=${padId}`)).expect(200);
+ assert.equal(res.body.code, 0);
+ assert.equal(typeof res.body.data.revisions, 'number');
+ });
+});
+```
+
+- [ ] **Step 2: Confirm the apiKey helper name**
+
+Run from `src/`:
+```bash
+cd src && grep -n "apiKey\|apikey" tests/backend/common.ts | head
+```
+Expected: a `common.apiKey` export (or similar). If the export is named differently, adjust the `apiKey` reference in the spec to match before running.
+
+- [ ] **Step 3: Run test to verify it passes**
+
+Run from `src/`:
+```bash
+cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-http-api.ts
+```
+Expected: 3 passing.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/tests/backend/specs/downstream/wire-http-api.ts
+git commit -m "test(downstream): snapshot client-facing HTTP API shapes"
+```
+
+---
+
+## Task 6: Client manifest
+
+**Files:**
+- Create: `src/tests/downstream/clients.json`
+
+- [ ] **Step 1: Write the manifest**
+
+Create `src/tests/downstream/clients.json` (SHAs are current `main` HEADs at authoring; `enabled:false` until each client's Phase-2 smoke lands):
+
+```json
+[
+ {
+ "name": "etherpad-pad",
+ "repo": "https://github.com/ether/pad.git",
+ "ref": "31176d64ce746d45349e58ee6c0bb043052c6e66",
+ "kind": "rust",
+ "enabled": false,
+ "vectorTest": "cargo test --test vectors",
+ "smokeCmd": "cargo test --test smoke -- --ignored"
+ },
+ {
+ "name": "etherpad-cli-client",
+ "repo": "https://github.com/ether/etherpad-cli-client.git",
+ "ref": "edbe0bb70971e54514ebea672e4ad9b51fc55bff",
+ "kind": "node",
+ "enabled": false,
+ "vectorTest": "pnpm run test:vectors",
+ "smokeCmd": "pnpm run test:smoke"
+ },
+ {
+ "name": "etherpad-desktop",
+ "repo": "https://github.com/ether/etherpad-desktop.git",
+ "ref": "ad273c119f1926a8390c9908fc91f62fa2cf740f",
+ "kind": "desktop",
+ "enabled": false,
+ "vectorTest": "pnpm run test:vectors",
+ "smokeCmd": "pnpm run test:smoke"
+ }
+]
+```
+
+- [ ] **Step 2: Validate it is well-formed JSON**
+
+Run:
+```bash
+node -e "const c=require('./src/tests/downstream/clients.json'); console.log(c.length, c.map(x=>x.name).join(','))"
+```
+Expected: `3 etherpad-pad,etherpad-cli-client,etherpad-desktop`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/tests/downstream/clients.json
+git commit -m "test(downstream): add client manifest (entries disabled pending Phase 2)"
+```
+
+---
+
+## Task 7: Downstream-smoke workflow
+
+**Files:**
+- Create: `.github/workflows/downstream-smoke.yml`
+
+Reference an existing workflow (`.github/workflows/backend-tests.yml`) for the checkout/pnpm/node setup block this repo uses, and copy that setup verbatim into the job below.
+
+- [ ] **Step 1: Write the workflow**
+
+Create `.github/workflows/downstream-smoke.yml`:
+
+```yaml
+name: Downstream smoke
+
+on:
+ pull_request:
+ schedule:
+ - cron: '0 4 * * *' # nightly against develop
+
+permissions:
+ contents: read
+
+jobs:
+ smoke:
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ steps:
+ - name: Checkout core (PR)
+ uses: actions/checkout@v4
+
+ # --- Reuse core's standard node+pnpm setup (copy from backend-tests.yml) ---
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ - name: Install deps
+ run: pnpm install --frozen-lockfile
+
+ - name: Boot Etherpad on :9003
+ env:
+ APIKEY: downstream-smoke-key
+ run: |
+ mkdir -p var
+ echo -n "$APIKEY" > APIKEY.txt
+ PORT=9003 pnpm run prod &
+ echo $! > /tmp/ep.pid
+
+ - name: Wait for healthcheck
+ run: |
+ for i in $(seq 1 60); do
+ if curl -fsS http://localhost:9003/api/ >/dev/null; then
+ echo "up"; exit 0
+ fi
+ sleep 2
+ done
+ echo "server did not come up"; exit 1
+
+ - name: Self-check (boot + API roundtrip proves the harness)
+ run: |
+ K=downstream-smoke-key
+ curl -fsS "http://localhost:9003/api/1/createPad?apikey=$K&padID=smoke&text=hi%0A"
+ curl -fsS "http://localhost:9003/api/1/getText?apikey=$K&padID=smoke" | grep -q '"text":"hi'
+
+ - name: Generate canonical wire-vectors
+ run: cd src && pnpm run vectors:gen
+
+ - name: Run enabled downstream clients
+ run: |
+ node -e '
+ const fs=require("fs");
+ const clients=require("./src/tests/downstream/clients.json").filter(c=>c.enabled);
+ if(!clients.length){console.log("No clients enabled yet (Phase 1).");process.exit(0);}
+ fs.writeFileSync("/tmp/clients.json",JSON.stringify(clients));
+ '
+ # Phase 2 wires per-kind clone + toolchain + vector injection + smoke here,
+ # iterating /tmp/clients.json. Until a client is enabled this is a no-op.
+
+ - name: Teardown (by PID, never pkill)
+ if: always()
+ run: |
+ if [ -f /tmp/ep.pid ]; then kill "$(cat /tmp/ep.pid)" 2>/dev/null || true; fi
+```
+
+- [ ] **Step 2: Confirm the boot command + port env**
+
+Run:
+```bash
+cd /home/jose/etherpad/etherpad-core-fresh && grep -nE '"prod"|"dev"|"start"' src/package.json
+grep -rn "process.env.PORT\|settings.port" src/node/utils/Settings.ts | head
+```
+Expected: confirm the script that starts a production server and that `PORT`/`APIKEY` are honored (Settings reads `process.env.PORT`). If the runnable script is named differently (e.g. `prod` vs `dev`), update the "Boot Etherpad" step to match. If APIKEY is read from a file rather than env, the `echo ... > APIKEY.txt` line already covers it.
+
+- [ ] **Step 3: Lint the workflow YAML**
+
+Run:
+```bash
+node -e "require('js-yaml')" 2>/dev/null && npx --yes js-yaml .github/workflows/downstream-smoke.yml >/dev/null && echo "valid yaml" || python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/downstream-smoke.yml')); print('valid yaml')"
+```
+Expected: `valid yaml`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add .github/workflows/downstream-smoke.yml
+git commit -m "ci(downstream): add downstream-smoke workflow (boot/self-check/teardown + manifest scaffold)"
+```
+
+---
+
+## Task 8: Full backend-suite run + push
+
+**Files:** none (verification + integration)
+
+- [ ] **Step 1: Run the whole downstream spec group**
+
+Per the "always run backend tests" rule, run the new specs through the real mocha invocation the suite uses, from `src/`:
+```bash
+cd src && cross-env NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 --extension ts --recursive tests/backend/specs/downstream
+```
+Expected: all specs in `tests/backend/specs/downstream/` pass (7 tests total across 3 files).
+
+- [ ] **Step 2: Confirm no regression in the fixture guard**
+
+Run from `src/`:
+```bash
+cd src && pnpm run vectors:gen && git diff --exit-code src/tests/fixtures/wire-vectors.json
+```
+Expected: exit 0 (regeneration is byte-identical to the committed fixture).
+
+- [ ] **Step 3: Push the branch and open the PR**
+
+```bash
+git push -u origin feat/downstream-client-compat-tests
+gh pr create --base develop \
+ --title "test: downstream client compatibility gate (Phase 1)" \
+ --body "Adds core-side contract tests (golden wire-vectors, socket-sequence, HTTP API shapes) and a downstream-smoke workflow scaffold so PRs detect changes that would break the separate CLI / terminal / desktop clients. Phase 2 wires each client repo's vector+smoke tests and flips its manifest entry to enabled. Spec + plan under docs/superpowers/. Closes nothing; tracks the downstream-compat initiative."
+```
+
+- [ ] **Step 4: Watch CI**
+
+Per the "check CI after PRs" rule, wait ~20s then:
+```bash
+gh pr checks --watch
+```
+Expected: backend tests green (now including the downstream specs); `Downstream smoke` green (self-check passes, no clients enabled yet). Fix any red before moving on.
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Layer A golden vectors → Tasks 1–3. ✅
+- Layer A socket sequence → Task 4. ✅
+- Layer A HTTP API shapes → Task 5. ✅
+- Layer B manifest (pinned SHAs, `enabled` gate) → Task 6. ✅
+- Layer B workflow (boot :9003, healthcheck, vector generation, PID teardown, nightly+PR triggers) → Task 7. ✅
+- Flakiness mitigations: healthcheck-poll (Task 7 step), PID teardown (Task 7), :9003 (Task 7), no external clients on the gate yet so no flake surface (Task 6 `enabled:false`). ✅
+- Phasing: Phase 1 self-contained and green; Phase 2 explicitly out of scope. ✅
+
+**Verification-required tasks** (Task 5 step 2, Task 7 step 2) ask the engineer to confirm the exact `common.apiKey` export name and the production boot script/port handling against the live repo before running — these are real lookups, not placeholders, because those names are repo-version-specific.
+
+**Type consistency:** `WireVector` fields (`name/initialText/changeset/pool/resultText`) are defined in Task 1 and used identically in Tasks 2–3. `generateVectors()` signature is stable across Tasks 1/3. Manifest keys (`enabled`, `vectorTest`, `smokeCmd`) in Task 6 match the workflow's `.filter(c=>c.enabled)` consumer in Task 7.
diff --git a/docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md b/docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md
new file mode 100644
index 000000000..47e7ecc74
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md
@@ -0,0 +1,154 @@
+# Downstream Client Compatibility Tests — Design
+
+**Date:** 2026-06-09
+**Status:** Approved (brainstorming complete)
+**Target repo:** `ether/etherpad` (core), branch `develop`
+**Downstream repos in scope:** `etherpad-pad` (Rust terminal editor + `etherpad-client` crate), `etherpad-cli-client` (Node/TS CLI), `etherpad-desktop` (Electron desktop + Capacitor mobile)
+
+## Problem
+
+The three downstream clients live in **separate repos** and consume core's wire
+protocols rather than importing core as a library:
+
+- **etherpad-cli-client** ships its *own copies* of `Changeset.ts` + `AttributePool.ts`
+ and talks the socket.io `message` protocol. No test script today.
+- **etherpad-pad** (Rust) hand-rolls engine.io v4 / socket.io v4 + changeset
+ decoding in its `etherpad-client` crate. Has CI + a `mock-socket` test feature.
+- **etherpad-desktop** wraps / points at a core server URL. Has vitest + Playwright e2e + CI.
+
+A core PR can change the **HTTP API**, the **socket.io handshake / `message`
+sequence**, or the **changeset / attribpool wire format** and break these clients
+**silently**, because their CI never runs against the new core. The goal: a PR
+against core `develop` (and friends) must detect downstream breakage before merge.
+
+## Decisions (locked during brainstorming)
+
+| Decision | Choice |
+|---|---|
+| Detection strategy | **Hybrid**: fast contract tests in core (every PR) + downstream smoke (every PR) |
+| Smoke cadence | **Every PR** (with strong flakiness mitigations) |
+| How CI gets clients | **Git clone at pinned refs**, recorded in a manifest in core |
+| Contract depth | **Shared golden vectors** — core generates canonical fixtures, each client decodes the same fixtures with its own decoder |
+| Sequencing | **Phase by layer** — Phase 1 all core-side; Phase 2 one client repo at a time |
+
+## Architecture
+
+Two-layer compatibility gate on every core PR:
+
+```
+core PR ──┬─► Layer A: Contract tests (hermetic, fast, no network/clients)
+ │ • golden-vector assertions (changeset/attribpool roundtrip)
+ │ • socket.io message-sequence test (CLIENT_READY → CLIENT_VARS,
+ │ USER_CHANGES → ACCEPT_COMMIT / NEW_CHANGES)
+ │ • HTTP API shape snapshots
+ │
+ └─► Layer B: Downstream smoke (boots a real server, runs real clients)
+ • build + boot Etherpad from the PR on :9003 with a known API key
+ • healthcheck-poll until ready
+ • matrix over manifest: clone client @ pinned ref, set up toolchain,
+ inject core's freshly-generated vectors, run client `test:vectors`,
+ run client smoke: connect → create/open pad → write text →
+ read back via HTTP API → assert equality
+ • tear down server by PID
+```
+
+## Layer A — Contract tests (Phase 1, core)
+
+### Golden vectors
+- Generator script: `src/tests/downstream/generate-vectors.ts` (run via a package
+ script, e.g. `pnpm run vectors:gen`).
+- Output fixture: `src/tests/fixtures/wire-vectors.json`. Each record:
+ `{ name, initialAText, changeset, pool, resultAText }` covering the operation
+ classes clients must decode: plain insert, delete, format/attrib op,
+ multi-line insert (char_bank ending in `\n`), attribpool reuse across ops.
+- Core test `src/tests/backend/specs/wire-vectors.ts`: regenerate in-memory and
+ assert it matches the committed fixture exactly (drift requires a deliberate
+ commit, which is the signal a wire change happened).
+
+### Socket message-sequence test
+- `src/tests/backend/specs/wire-socket-sequence.ts`: drive a socket.io client
+ against the in-process server, assert the handshake message sequence and the
+ shape of `CLIENT_VARS`, `USER_CHANGES` → `ACCEPT_COMMIT`, and broadcast
+ `NEW_CHANGES`. Reuses the existing backend socket test helpers.
+
+### HTTP API shape snapshots
+- `src/tests/backend/specs/wire-http-api.ts`: snapshot the response *shapes*
+ (keys / types, not volatile values) of the API endpoints clients call
+ (`createPad`, `setText`, `getText`, `getRevisionsCount`, session/auth as needed).
+
+These join the existing `backend-tests.yml` run — no new per-PR job, no new infra.
+
+## Layer B — Downstream smoke (Phase 1 scaffold, Phase 2 per client)
+
+### Manifest
+`src/tests/downstream/clients.json`:
+```json
+[
+ { "name": "etherpad-pad", "repo": "https://github.com/ether/pad.git", "ref": "", "kind": "rust", "smokeCmd": "..." },
+ { "name": "etherpad-cli-client", "repo": "https://github.com/ether/etherpad-cli-client.git", "ref": "", "kind": "node", "smokeCmd": "..." },
+ { "name": "etherpad-desktop", "repo": "https://github.com/ether/etherpad-desktop.git", "ref": "", "kind": "desktop", "smokeCmd": "..." }
+]
+```
+Refs are pinned to a specific commit SHA (not `main`) so a client's own pushes
+cannot redden core CI; bumping a ref is a deliberate PR. Current `main` HEADs at
+authoring time: pad `31176d6`, cli-client `edbe0bb`, desktop `ad273c1`.
+Pinned refs mean a client's *own* breakage never randomly reddens core; picking up
+a client fix is a deliberate ref-bump PR. Clients are added to the manifest as
+their Phase-2 smoke lands — the workflow only runs what's registered.
+
+### Workflow
+`.github/workflows/downstream-smoke.yml` (triggers: `pull_request` + nightly
+`schedule` against `develop`):
+1. Build core from the PR, install deps.
+2. Boot Etherpad on **:9003** with a known `APIKEY` in the background; record PID.
+3. Healthcheck-poll the server (bounded timeout) before proceeding.
+4. Matrix over manifest entries: clone @ pinned ref → set up toolchain
+ (node+pnpm / rust / electron+xvfb) → copy core's freshly-generated
+ `wire-vectors.json` into the client → run `test:vectors` → run smoke.
+5. Tear down: kill the recorded **PID** (never `pkill -f`).
+
+### Per-client smoke (Phase 2)
+Minimal roundtrip exercising the real protocol end-to-end:
+- **etherpad-pad** (`rust`): integration test gated by `ETHERPAD_SMOKE_URL`, using
+ the real tungstenite socket — connect, open pad, send a changeset, read back
+ via HTTP `getText`, assert. Plus `cargo test` vector consumer reading the
+ injected fixture.
+- **etherpad-cli-client** (`node`): add a minimal test runner (none today —
+ `node:test` or vitest), a `test:vectors` decoding the fixture, and a smoke
+ using the client lib: connect → write → verify via HTTP `getText`.
+- **etherpad-desktop** (`desktop`): **headless-light** vitest smoke that points
+ the shell/webview at the booted URL and roundtrips. The full Electron e2e stays
+ in desktop's own CI — it is **not** in the core gate. If Electron is
+ unavoidable here, run under `xvfb-run`.
+
+## Flakiness mitigations (because smoke runs on every PR)
+
+- Healthcheck-poll-with-timeout before any client runs.
+- Bounded timeout + 1 retry per client smoke.
+- Desktop kept headless-light; heavy Electron e2e excluded from the gate.
+- PID-based teardown, never `pkill -f` (would kill the developer's other servers).
+- Pinned manifest refs isolate core CI from clients' own breakage.
+- Tests that bind a port use **:9003** (9001 is reserved for ad-hoc local use).
+
+## Phasing
+
+- **Phase 1 (core only, lands first, immediately useful):** vector generator +
+ `wire-vectors.json` + three contract specs + `downstream-smoke.yml` +
+ `clients.json` manifest. Harness proven with **one** reference client wired in
+ (the Rust `etherpad-pad`, which already has test infra).
+- **Phase 2 (one client repo at a time):** order `etherpad-pad` →
+ `etherpad-cli-client` → `etherpad-desktop`. Each PR adds that client's
+ `test:vectors` + smoke and registers it in the core manifest.
+
+## Out of scope
+
+- Replacing clients' existing full e2e suites (they stay in their own repos).
+- ep_kaput (excluded from all sweeps per standing instruction).
+- Changing the wire protocol itself — this work only *observes* it.
+
+## Success criteria
+
+- A core PR that alters changeset serialization, the socket message sequence, or a
+ client-facing API shape fails Layer A (contract) and/or Layer B (smoke) before merge.
+- Phase 1 lands green on core `develop` with the Rust client wired into the smoke matrix.
+- Bumping a client's pinned ref is the only way a client's own changes affect core CI.
diff --git a/package.json b/package.json
index 6e4fb53ca..46e5a7cc0 100644
--- a/package.json
+++ b/package.json
@@ -51,7 +51,7 @@
"url": "https://github.com/ether/etherpad.git"
},
"engineStrict": true,
- "version": "3.3.0",
+ "version": "3.3.1",
"license": "Apache-2.0",
"pnpm": {
"onlyBuiltDependencies": [
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5508c72de..b24b3cc56 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -78,11 +78,11 @@ importers:
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.17)
'@typescript-eslint/eslint-plugin':
- specifier: ^8.60.1
- version: 8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)
+ specifier: ^8.61.0
+ version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)
'@typescript-eslint/parser':
- specifier: ^8.60.1
- version: 8.60.1(eslint@10.4.1)(typescript@6.0.3)
+ specifier: ^8.61.0
+ version: 8.61.0(eslint@10.4.1)(typescript@6.0.3)
'@vitejs/plugin-react':
specifier: ^6.0.2
version: 6.0.2(babel-plugin-react-compiler@19.1.0-rc.3)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(tsx@4.22.4))
@@ -153,14 +153,14 @@ importers:
specifier: ^6.9.1
version: 6.9.1
semver:
- specifier: ^7.8.2
- version: 7.8.2
+ specifier: ^7.8.3
+ version: 7.8.3
tsx:
specifier: ^4.22.4
version: 4.22.4
ueberdb2:
- specifier: ^6.1.8
- version: 6.1.8(@elastic/elasticsearch@9.4.2)(async@3.2.6)(cassandra-driver@4.8.0)(dirty-ts@1.1.8)(mongodb@7.2.0)(mssql@12.5.5(@azure/core-client@1.10.1))(mysql2@3.22.5(@types/node@25.9.2))(nano@11.0.5)(pg@8.21.0)(redis@6.0.0(@opentelemetry/api@1.9.1))(rethinkdb@2.4.2)(rusty-store-kv@1.3.1)(surrealdb@2.0.3(tslib@2.8.1)(typescript@6.0.3))
+ specifier: ^6.1.9
+ version: 6.1.9(@elastic/elasticsearch@9.4.2)(async@3.2.6)(cassandra-driver@4.8.0)(dirty-ts@1.1.8)(mongodb@7.2.0)(mssql@12.5.5(@azure/core-client@1.10.1))(mysql2@3.22.5(@types/node@25.9.2))(nano@11.0.5)(pg@8.21.0)(redis@6.0.0(@opentelemetry/api@1.9.1))(rethinkdb@2.4.2)(rusty-store-kv@1.3.1)(surrealdb@2.0.3(tslib@2.8.1)(typescript@6.0.3))
devDependencies:
'@types/node':
specifier: ^25.9.2
@@ -314,8 +314,8 @@ importers:
specifier: ^2.0.7
version: 2.0.7
rate-limiter-flexible:
- specifier: ^11.1.1
- version: 11.1.1
+ specifier: ^11.2.0
+ version: 11.2.0
redis:
specifier: ^6.0.0
version: 6.0.0(@opentelemetry/api@1.9.1)
@@ -338,8 +338,8 @@ importers:
specifier: 1.0.0
version: 1.0.0
semver:
- specifier: ^7.8.2
- version: 7.8.2
+ specifier: ^7.8.3
+ version: 7.8.3
socket.io:
specifier: ^4.8.3
version: 4.8.3
@@ -359,8 +359,8 @@ importers:
specifier: 4.22.4
version: 4.22.4
ueberdb2:
- specifier: ^6.1.8
- version: 6.1.8(@elastic/elasticsearch@9.4.2)(async@3.2.6)(cassandra-driver@4.8.0)(dirty-ts@1.1.8)(mongodb@7.2.0)(mssql@12.5.5(@azure/core-client@1.10.1))(mysql2@3.22.5(@types/node@25.9.2))(nano@11.0.5)(pg@8.21.0)(redis@6.0.0(@opentelemetry/api@1.9.1))(rethinkdb@2.4.2)(rusty-store-kv@1.3.1)(surrealdb@2.0.3(tslib@2.8.1)(typescript@6.0.3))
+ specifier: ^6.1.9
+ version: 6.1.9(@elastic/elasticsearch@9.4.2)(async@3.2.6)(cassandra-driver@4.8.0)(dirty-ts@1.1.8)(mongodb@7.2.0)(mssql@12.5.5(@azure/core-client@1.10.1))(mysql2@3.22.5(@types/node@25.9.2))(nano@11.0.5)(pg@8.21.0)(redis@6.0.0(@opentelemetry/api@1.9.1))(rethinkdb@2.4.2)(rusty-store-kv@1.3.1)(surrealdb@2.0.3(tslib@2.8.1)(typescript@6.0.3))
underscore:
specifier: 1.13.8
version: 1.13.8
@@ -1941,11 +1941,11 @@ packages:
typescript:
optional: true
- '@typescript-eslint/eslint-plugin@8.60.1':
- resolution: {integrity: sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==}
+ '@typescript-eslint/eslint-plugin@8.61.0':
+ resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^8.60.1
+ '@typescript-eslint/parser': ^8.61.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
@@ -1959,15 +1959,15 @@ packages:
typescript:
optional: true
- '@typescript-eslint/parser@8.60.1':
- resolution: {integrity: sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==}
+ '@typescript-eslint/parser@8.61.0':
+ resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/project-service@8.60.1':
- resolution: {integrity: sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==}
+ '@typescript-eslint/project-service@8.61.0':
+ resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
@@ -1976,12 +1976,12 @@ packages:
resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==}
engines: {node: ^18.18.0 || >=20.0.0}
- '@typescript-eslint/scope-manager@8.60.1':
- resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==}
+ '@typescript-eslint/scope-manager@8.61.0':
+ resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/tsconfig-utils@8.60.1':
- resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==}
+ '@typescript-eslint/tsconfig-utils@8.61.0':
+ resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
@@ -1996,8 +1996,8 @@ packages:
typescript:
optional: true
- '@typescript-eslint/type-utils@8.60.1':
- resolution: {integrity: sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==}
+ '@typescript-eslint/type-utils@8.61.0':
+ resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -2007,8 +2007,8 @@ packages:
resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==}
engines: {node: ^18.18.0 || >=20.0.0}
- '@typescript-eslint/types@8.60.1':
- resolution: {integrity: sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==}
+ '@typescript-eslint/types@8.61.0':
+ resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@7.18.0':
@@ -2020,8 +2020,8 @@ packages:
typescript:
optional: true
- '@typescript-eslint/typescript-estree@8.60.1':
- resolution: {integrity: sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==}
+ '@typescript-eslint/typescript-estree@8.61.0':
+ resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
@@ -2032,8 +2032,8 @@ packages:
peerDependencies:
eslint: ^8.56.0
- '@typescript-eslint/utils@8.60.1':
- resolution: {integrity: sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==}
+ '@typescript-eslint/utils@8.61.0':
+ resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -2043,8 +2043,8 @@ packages:
resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==}
engines: {node: ^18.18.0 || >=20.0.0}
- '@typescript-eslint/visitor-keys@8.60.1':
- resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==}
+ '@typescript-eslint/visitor-keys@8.61.0':
+ resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typespec/ts-http-runtime@0.3.5':
@@ -4661,8 +4661,8 @@ packages:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
- rate-limiter-flexible@11.1.1:
- resolution: {integrity: sha512-ZT/GnTlqjX+vEnI39tubPX8bQY7xIvp0e0q+DgxiA2RTfwj7t/jy6GTgdkJNb22qPCqjXJLoPLxff7RolmGc8Q==}
+ rate-limiter-flexible@11.2.0:
+ resolution: {integrity: sha512-L0eIK+BmFMi6NcvmtEg7RSswOFKi9MMD5RhBIypFfOve+G6Jl1Xbb8qEHgK3uRzNtkOFYF0L9f7P4rSf2PnUVw==}
raw-body@3.0.2:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
@@ -4962,13 +4962,13 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
- semver@7.8.1:
- resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
+ semver@7.8.2:
+ resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==}
engines: {node: '>=10'}
hasBin: true
- semver@7.8.2:
- resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==}
+ semver@7.8.3:
+ resolution: {integrity: sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==}
engines: {node: '>=10'}
hasBin: true
@@ -5367,8 +5367,8 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
- ueberdb2@6.1.8:
- resolution: {integrity: sha512-X8XnjzjSyI3J5cAwf0tSrSi1qs5ciB0qPJSG/G7txuh3H0R/dm+GU8hno2ec+0m/WpHrlWV1bl6DzUoxNU7hBg==}
+ ueberdb2@6.1.9:
+ resolution: {integrity: sha512-AOifNHJT0A9c52eEQqcm77d3/RUZVIvGFN1KFbXEPpfI89fa5c9lS8z7piu6m+CwBgWD+Fq5wAmP7W3LVZ4MwA==}
engines: {node: '>=24.0.0'}
peerDependencies:
'@elastic/elasticsearch': ^9.0.0
@@ -7207,14 +7207,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)':
+ '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
- '@typescript-eslint/scope-manager': 8.60.1
- '@typescript-eslint/type-utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
- '@typescript-eslint/utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
- '@typescript-eslint/visitor-keys': 8.60.1
+ '@typescript-eslint/parser': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/scope-manager': 8.61.0
+ '@typescript-eslint/type-utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.61.0
eslint: 10.4.1
ignore: 7.0.5
natural-compare: 1.4.0
@@ -7236,22 +7236,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3)':
+ '@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.60.1
- '@typescript-eslint/types': 8.60.1
- '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3)
- '@typescript-eslint/visitor-keys': 8.60.1
+ '@typescript-eslint/scope-manager': 8.61.0
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.61.0
debug: 4.4.3(supports-color@8.1.1)
eslint: 10.4.1
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.60.1(typescript@6.0.3)':
+ '@typescript-eslint/project-service@8.61.0(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3)
- '@typescript-eslint/types': 8.60.1
+ '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/types': 8.61.0
debug: 4.4.3(supports-color@8.1.1)
typescript: 6.0.3
transitivePeerDependencies:
@@ -7262,12 +7262,12 @@ snapshots:
'@typescript-eslint/types': 7.18.0
'@typescript-eslint/visitor-keys': 7.18.0
- '@typescript-eslint/scope-manager@8.60.1':
+ '@typescript-eslint/scope-manager@8.61.0':
dependencies:
- '@typescript-eslint/types': 8.60.1
- '@typescript-eslint/visitor-keys': 8.60.1
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/visitor-keys': 8.61.0
- '@typescript-eslint/tsconfig-utils@8.60.1(typescript@6.0.3)':
+ '@typescript-eslint/tsconfig-utils@8.61.0(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
@@ -7283,11 +7283,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/type-utils@8.60.1(eslint@10.4.1)(typescript@6.0.3)':
+ '@typescript-eslint/type-utils@8.61.0(eslint@10.4.1)(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/types': 8.60.1
- '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3)
- '@typescript-eslint/utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
debug: 4.4.3(supports-color@8.1.1)
eslint: 10.4.1
ts-api-utils: 2.5.0(typescript@6.0.3)
@@ -7297,7 +7297,7 @@ snapshots:
'@typescript-eslint/types@7.18.0': {}
- '@typescript-eslint/types@8.60.1': {}
+ '@typescript-eslint/types@8.61.0': {}
'@typescript-eslint/typescript-estree@7.18.0(typescript@6.0.3)':
dependencies:
@@ -7307,22 +7307,22 @@ snapshots:
globby: 11.1.0
is-glob: 4.0.3
minimatch: 10.2.5
- semver: 7.8.1
+ semver: 7.8.2
ts-api-utils: 1.4.3(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/typescript-estree@8.60.1(typescript@6.0.3)':
+ '@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/project-service': 8.60.1(typescript@6.0.3)
- '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3)
- '@typescript-eslint/types': 8.60.1
- '@typescript-eslint/visitor-keys': 8.60.1
+ '@typescript-eslint/project-service': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/visitor-keys': 8.61.0
debug: 4.4.3(supports-color@8.1.1)
minimatch: 10.2.5
- semver: 7.8.1
+ semver: 7.8.2
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
@@ -7340,12 +7340,12 @@ snapshots:
- supports-color
- typescript
- '@typescript-eslint/utils@8.60.1(eslint@10.4.1)(typescript@6.0.3)':
+ '@typescript-eslint/utils@8.61.0(eslint@10.4.1)(typescript@6.0.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1)
- '@typescript-eslint/scope-manager': 8.60.1
- '@typescript-eslint/types': 8.60.1
- '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3)
+ '@typescript-eslint/scope-manager': 8.61.0
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
eslint: 10.4.1
typescript: 6.0.3
transitivePeerDependencies:
@@ -7356,9 +7356,9 @@ snapshots:
'@typescript-eslint/types': 7.18.0
eslint-visitor-keys: 3.4.3
- '@typescript-eslint/visitor-keys@8.60.1':
+ '@typescript-eslint/visitor-keys@8.61.0':
dependencies:
- '@typescript-eslint/types': 8.60.1
+ '@typescript-eslint/types': 8.61.0
eslint-visitor-keys: 5.0.1
'@typespec/ts-http-runtime@0.3.5':
@@ -8339,7 +8339,7 @@ snapshots:
eslint-compat-utils@0.5.1(eslint@10.4.1):
dependencies:
eslint: 10.4.1
- semver: 7.8.1
+ semver: 7.8.2
eslint-config-etherpad@4.0.5(eslint@10.4.1)(typescript@6.0.3):
dependencies:
@@ -8460,7 +8460,7 @@ snapshots:
globals: 15.15.0
globrex: 0.1.2
ignore: 5.3.2
- semver: 7.8.1
+ semver: 7.8.2
ts-declaration-location: 1.0.7(typescript@6.0.3)
transitivePeerDependencies:
- typescript
@@ -9165,7 +9165,7 @@ snapshots:
is-bun-module@1.3.0:
dependencies:
- semver: 7.8.1
+ semver: 7.8.2
is-callable@1.2.7: {}
@@ -9377,7 +9377,7 @@ snapshots:
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
- semver: 7.8.1
+ semver: 7.8.2
jszip@3.10.1:
dependencies:
@@ -9513,7 +9513,7 @@ snapshots:
lockfile: 1.0.4
node-fetch-commonjs: 3.3.2
proxy-agent: 6.5.0
- semver: 7.8.1
+ semver: 7.8.2
tar: 7.5.13
url-join: 4.0.1
transitivePeerDependencies:
@@ -10171,7 +10171,7 @@ snapshots:
proxy-agent@6.5.0:
dependencies:
agent-base: 7.1.3
- debug: 4.4.1
+ debug: 4.4.3(supports-color@8.1.1)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
lru-cache: 7.18.3
@@ -10209,7 +10209,7 @@ snapshots:
range-parser@1.2.1: {}
- rate-limiter-flexible@11.1.1: {}
+ rate-limiter-flexible@11.2.0: {}
raw-body@3.0.2:
dependencies:
@@ -10543,10 +10543,10 @@ snapshots:
semver@6.3.1: {}
- semver@7.8.1: {}
-
semver@7.8.2: {}
+ semver@7.8.3: {}
+
send@1.2.0:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
@@ -11027,7 +11027,7 @@ snapshots:
typescript@6.0.3: {}
- ueberdb2@6.1.8(@elastic/elasticsearch@9.4.2)(async@3.2.6)(cassandra-driver@4.8.0)(dirty-ts@1.1.8)(mongodb@7.2.0)(mssql@12.5.5(@azure/core-client@1.10.1))(mysql2@3.22.5(@types/node@25.9.2))(nano@11.0.5)(pg@8.21.0)(redis@6.0.0(@opentelemetry/api@1.9.1))(rethinkdb@2.4.2)(rusty-store-kv@1.3.1)(surrealdb@2.0.3(tslib@2.8.1)(typescript@6.0.3)):
+ ueberdb2@6.1.9(@elastic/elasticsearch@9.4.2)(async@3.2.6)(cassandra-driver@4.8.0)(dirty-ts@1.1.8)(mongodb@7.2.0)(mssql@12.5.5(@azure/core-client@1.10.1))(mysql2@3.22.5(@types/node@25.9.2))(nano@11.0.5)(pg@8.21.0)(redis@6.0.0(@opentelemetry/api@1.9.1))(rethinkdb@2.4.2)(rusty-store-kv@1.3.1)(surrealdb@2.0.3(tslib@2.8.1)(typescript@6.0.3)):
optionalDependencies:
'@elastic/elasticsearch': 9.4.2
async: 3.2.6
diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts
index 592e5b9e8..0bf559dfd 100644
--- a/src/node/handler/PadMessageHandler.ts
+++ b/src/node/handler/PadMessageHandler.ts
@@ -1287,9 +1287,15 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
// once. Readonly sessions never see it.
const isCreator =
!sessionInfo.readonly && sessionInfo.author === await pad.getRevisionAuthor(0);
- // Skip token issuance when requireAuthentication is on: every creator has a
- // stable identity so the cookie/identity path is sufficient.
- const padDeletionToken = isCreator && !settings.requireAuthentication
+ // Skip token issuance — and so the client never shows the "Save your pad
+ // deletion token" modal (issue #7926) — when the token cannot help:
+ // - requireAuthentication: every creator already has a stable identity, so
+ // the cookie/identity path is sufficient.
+ // - allowPadDeletionByAllUsers: anyone can delete the pad with no token at
+ // all (see handlePadDelete's flagOk branch), so a recovery token is noise
+ // and the modal only overwhelms users who will never need it.
+ const padDeletionToken =
+ isCreator && !settings.requireAuthentication && !settings.allowPadDeletionByAllUsers
? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId)
: null;
diff --git a/src/node/hooks/express/adminsettings.ts b/src/node/hooks/express/adminsettings.ts
index 9381f0de8..678da2568 100644
--- a/src/node/hooks/express/adminsettings.ts
+++ b/src/node/hooks/express/adminsettings.ts
@@ -11,6 +11,7 @@ import settings, {getEpVersion, getGitCommit, reloadSettings} from '../../utils/
import {getLatestVersion} from '../../utils/UpdateCheck';
import {redactSettings} from '../../utils/AdminSettingsRedact';
const padManager = require('../../db/PadManager');
+const db = require('../../db/DB');
const api = require('../../db/API');
import {deleteRevisions} from '../../utils/Cleanup';
@@ -26,6 +27,24 @@ const queryPadLimit = 12;
const PAD_HYDRATE_CONCURRENCY = 16;
const logger = log4js.getLogger('adminSettings');
+// Errors thrown while reading a pad record can embed the raw stored value
+// in their message — e.g. Pad.init's `'pool' in value` TypeError stringifies
+// the offending value ("Cannot use 'in' operator to search for 'pool' in
+// "). For a corrupt record that value may be actual pad text, so
+// logging it verbatim would leak content, bloat the log, and let embedded
+// newlines forge log lines. Reduce any error to its name plus a single-line,
+// length-capped message before logging.
+const safeErr = (err: unknown): string => {
+ const e = err as {name?: unknown, message?: unknown} | null;
+ const name = (e && typeof e.name === 'string' && e.name) || 'Error';
+ const msg = String((e && e.message) ?? err ?? '')
+ .replace(/[\r\n\t]+/g, ' ')
+ .replace(/\s{2,}/g, ' ')
+ .trim()
+ .slice(0, 120);
+ return `${name}: ${msg}`;
+};
+
// Concurrency-limited Promise.all replacement. Preserves the input
// order in the returned array (caller slices later). Used by padLoad
// to bound DB reads during hydration.
@@ -147,6 +166,7 @@ exports.socketio = (hookName: string, {io}: any) => {
socket.on('padLoad', async (query: PadSearchQuery) => {
+ try {
const {padIDs} = await padManager.listAllPads();
// ── 1. Pattern filter (cheap, by name only) ─────────────────────
@@ -172,13 +192,27 @@ exports.socketio = (hookName: string, {io}: any) => {
const needsFullScan = filter !== 'all' || query.sortBy !== 'padName';
const loadMeta = async (padName: string): Promise => {
- const pad = await padManager.getPad(padName);
- return {
- padName,
- lastEdited: await pad.getLastEdit(),
- userCount: api.padUsersCount(padName).padUsersCount,
- revisionNumber: pad.getHeadRevisionNumber(),
- };
+ // A single unreadable record must not take out the whole listing.
+ // `findKeys('pad:*', '*:*:*')` returns every key under the `pad:`
+ // prefix, including legacy/foreign or migration-corrupted records
+ // (e.g. a value stored as a JSON *string* rather than a pad object,
+ // which makes Pad.init throw `'pool' in value`). Before this guard
+ // one such key rejected the whole `padLoad` handler — the admin
+ // "Manage pads" page then showed *no* pads at all (issue #7935) and
+ // the unhandled rejection could exit the server. Surfacing the bad
+ // pad with zeroed metadata lets an admin see and delete it instead.
+ try {
+ const pad = await padManager.getPad(padName);
+ return {
+ padName,
+ lastEdited: await pad.getLastEdit(),
+ userCount: api.padUsersCount(padName).padUsersCount,
+ revisionNumber: pad.getHeadRevisionNumber(),
+ };
+ } catch (err) {
+ logger.warn(`padLoad: skipping unreadable pad "${padName}": ${safeErr(err)}`);
+ return {padName, lastEdited: 0 as any, userCount: 0, revisionNumber: 0};
+ }
};
// Lazily lifted so we don't load every pad twice on the fast path.
@@ -256,16 +290,51 @@ exports.socketio = (hookName: string, {io}: any) => {
const data: {total: number, results?: PadQueryResult[]} = {total, results};
socket.emit('results:padLoad', data);
+ } catch (err) {
+ // Never leave the SPA hanging on a missing reply (it would show an
+ // empty "No results" state forever) and never let this bubble up to
+ // the process-level unhandledRejection handler, which would exit the
+ // whole server. Always emit a terminal reply for the request.
+ logger.error(`padLoad failed: ${safeErr(err)}`);
+ socket.emit('results:padLoad',
+ {total: 0, results: [], error: safeErr(err)});
+ }
})
socket.on('deletePad', async (padId: string) => {
- const padExists = await padManager.doesPadExists(padId);
- if (padExists) {
- logger.info(`Deleting pad: ${padId}`);
- const pad = await padManager.getPad(padId);
- await pad.remove();
+ try {
+ if (await padManager.doesPadExists(padId)) {
+ // Healthy pad — full relational cleanup (revs, chat, readonly,
+ // authors, deletion token, hooks).
+ logger.info(`Deleting pad: ${padId}`);
+ const pad = await padManager.getPad(padId);
+ await pad.remove();
+ socket.emit('results:deletePad', padId);
+ return;
+ }
+
+ // doesPadExists() is false either because nothing is stored under
+ // this id, or because the record is unreadable (a non-object value
+ // leaves `value.atext` undefined). The latter is exactly what
+ // padLoad now surfaces with zeroed metadata — getPad()/Pad.remove()
+ // would throw on it, so fall back to a raw key purge. Without this
+ // the surfaced corrupt pad is undeletable from the admin UI.
+ const raw = await db.get(`pad:${padId}`);
+ if (raw != null) {
+ logger.info(`Deleting unreadable pad record via raw key purge: ${padId}`);
+ // Best-effort sweep of sub-keys (revs/chat/deletionToken/…) and
+ // the readonly mapping, then the main key + pad-list/cache entry.
+ const subKeys: string[] = (await db.findKeys(`pad:${padId}:*`, null)) || [];
+ await Promise.all(subKeys.map((k) => db.remove(k)));
+ await db.remove(`pad2readonly:${padId}`);
+ await padManager.removePad(padId);
+ }
+ // Always emit a terminal reply (even for an already-absent id) so the
+ // UI clears the row instead of silently doing nothing.
socket.emit('results:deletePad', padId);
+ } catch (err) {
+ logger.error(`deletePad failed for "${padId}": ${safeErr(err)}`);
}
})
diff --git a/src/package.json b/src/package.json
index dbedd68ce..99f44cc0e 100644
--- a/src/package.json
+++ b/src/package.json
@@ -72,7 +72,7 @@
"pg": "^8.21.0",
"prom-client": "^15.1.3",
"proxy-addr": "^2.0.7",
- "rate-limiter-flexible": "^11.1.1",
+ "rate-limiter-flexible": "^11.2.0",
"redis": "^6.0.0",
"rehype": "^13.0.2",
"rehype-minify-whitespace": "^6.0.2",
@@ -80,14 +80,14 @@
"rethinkdb": "^2.4.2",
"rusty-store-kv": "^1.3.1",
"security": "1.0.0",
- "semver": "^7.8.2",
+ "semver": "^7.8.3",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"superagent": "10.3.0",
"surrealdb": "^2.0.3",
"tinycon": "0.6.8",
"tsx": "4.22.4",
- "ueberdb2": "^6.1.8",
+ "ueberdb2": "^6.1.9",
"underscore": "1.13.8",
"undici": "^8.4.1",
"unorm": "1.6.0",
@@ -154,6 +154,7 @@
"test-container": "mocha --import=tsx --timeout 30000 --extension ts,js tests/container/specs/api",
"dev": "cross-env NODE_ENV=development node --require tsx/cjs node/server.ts",
"prod": "cross-env NODE_ENV=production node --require tsx/cjs node/server.ts",
+ "vectors:gen": "node --require tsx/cjs tests/backend/specs/downstream/generate-vectors.ts",
"ts-check": "tsc --noEmit",
"ts-check:watch": "tsc --noEmit --watch",
"test-ui": "cross-env NODE_ENV=production npx playwright test",
@@ -163,6 +164,6 @@
"debug:socketio": "cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts",
"test:vitest": "vitest"
},
- "version": "3.3.0",
+ "version": "3.3.1",
"license": "Apache-2.0"
}
diff --git a/src/static/js/domline.ts b/src/static/js/domline.ts
index bb78c3aeb..339616a25 100644
--- a/src/static/js/domline.ts
+++ b/src/static/js/domline.ts
@@ -102,12 +102,21 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
postHtml = `${postHtml}`;
} else {
if (start) { // is it a start of a list with more than one item in?
- if (Number.parseInt(start[1]) === 1) { // if its the first one at this level?
+ // The `start` value comes verbatim from the attribute pool (which
+ // an attacker can populate via a crafted `.etherpad` import), so it
+ // must never be interpolated raw into the markup. An start is
+ // only ever an integer: coerce it and HTML-escape it defensively so
+ // a value such as `1>` cannot break out of the tag.
+ const startNum = Number.parseInt(start[1]);
+ if (startNum === 1) { // if its the first one at this level?
// Add start class to DIV node
lineClass = `${lineClass} ` + `list-start-${listType}`;
}
+ const startAttr = Number.isNaN(startNum)
+ ? ''
+ : ` start="${Security.escapeHTMLAttribute(String(startNum))}"`;
preHtml +=
- ``;
+ ``;
} else {
// Handles pasted contents into existing lists
preHtml += ``;
diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts
index 7e081cb77..d900a35e7 100644
--- a/src/static/js/pad.ts
+++ b/src/static/js/pad.ts
@@ -581,7 +581,10 @@ const pad = {
$('#padsettings-options-linenoscheck').prop('checked', view.showLineNumbers !== false);
$('#padsettings-options-rtlcheck').prop('checked', !!view.rtlIsTrue);
$('#padsettings-viewfontmenu').val(view.padFontFamily || '');
- $('#padsettings-languagemenu').val(padOptions.lang || 'en');
+ // When no pad-wide lang is set, reflect the language html10n actually
+ // detected and rendered (e.g. from the browser) instead of defaulting the
+ // dropdown to English while the UI is in another language. See #7925.
+ $('#padsettings-languagemenu').val(padOptions.lang || html10n.getLanguage() || 'en');
$('#padsettings-enforcecheck').prop('checked', !!padOptions.enforceSettings);
$('#padsettings-options-stickychat, #padsettings-options-chatandusers')
.prop('disabled', padOptions.showChat === false);
@@ -599,7 +602,10 @@ const pad = {
$('#options-linenoscheck').prop('checked', effectiveOptions.view?.showLineNumbers !== false);
$('#options-rtlcheck').prop('checked', !!effectiveOptions.view?.rtlIsTrue);
$('#viewfontmenu').val(effectiveOptions.view?.padFontFamily || '');
- $('#languagemenu').val(effectiveOptions.lang || 'en');
+ // Fall back to the detected language rather than hardcoded English when the
+ // user has not explicitly chosen one, so the dropdown matches the rendered
+ // UI language. See #7925.
+ $('#languagemenu').val(effectiveOptions.lang || html10n.getLanguage() || 'en');
$('#settings input[id^="options-"]').prop('disabled', disabled);
$('#viewfontmenu, #languagemenu').prop('disabled', disabled);
$('#options-stickychat, #options-chatandusers')
diff --git a/src/static/skins/colibris/src/pad-variants.css b/src/static/skins/colibris/src/pad-variants.css
index 1b335d3ab..c67452d22 100644
--- a/src/static/skins/colibris/src/pad-variants.css
+++ b/src/static/skins/colibris/src/pad-variants.css
@@ -133,6 +133,22 @@
box-shadow: 0 0 14px 0px var(--super-dark-color);
}
+/* == Root canvas / iOS status-bar safe area (issue #7606) == */
+/* The variant rules above only paint inner containers, leaving the root
+ transparent. iOS Safari fills the status-bar safe area above the page from
+ the ROOT canvas background (not `theme-color`), so a dark-mode pad showed a
+ white strip above the dark toolbar. Paint the root with the TOOLBAR colour
+ (matching `theme-color` and the OP's request that the bar match the toolbar)
+ so the safe area, toolbar, and address bar are one seamless colour. The
+ colours mirror toolbarColorForTokens / skin_toolbar_colors.ts. `color-scheme`
+ keeps UA-painted chrome (overscroll, scrollbars, form controls) in step. */
+html.super-light-toolbar, html.light-toolbar { color-scheme: light; }
+html.super-dark-toolbar, html.dark-toolbar { color-scheme: dark; }
+html.super-light-toolbar { background-color: var(--super-light-color); }
+html.light-toolbar { background-color: var(--light-color); }
+html.super-dark-toolbar { background-color: var(--super-dark-color); }
+html.dark-toolbar { background-color: var(--dark-color); }
+
diff --git a/src/tests/backend/specs/admin/padLoadResilience.ts b/src/tests/backend/specs/admin/padLoadResilience.ts
new file mode 100644
index 000000000..28c5d284f
--- /dev/null
+++ b/src/tests/backend/specs/admin/padLoadResilience.ts
@@ -0,0 +1,180 @@
+'use strict';
+
+// Regression test for issue #7935 ("Display issue of notes"): pads exist
+// (visible on the welcome page, returned by the API `listAllPads`) but the
+// admin "Manage pads" UI shows none.
+//
+// Root cause: the admin /settings `padLoad` handler hydrates every pad via
+// `padManager.getPad()` to build the listing (the default `lastEdited` sort
+// forces a full scan). `findKeys('pad:*', '*:*:*')` returns *every* key under
+// the `pad:` prefix, including legacy / foreign / migration-corrupted records
+// — e.g. a value stored as a JSON *string* rather than a pad object, which is
+// exactly what a botched dirty.db → PostgreSQL migration produces. Loading
+// such a record makes `Pad.init` throw `Cannot use 'in' operator to search
+// for 'pool' in `. Before the fix that single rejection took out the
+// whole handler: no `results:padLoad` was ever emitted (the SPA showed an
+// empty "No results" state forever) and the unhandled rejection could exit
+// the server. The handler now skips unreadable pads (surfacing them with
+// zeroed metadata so an admin can still delete them) and always emits a
+// terminal reply.
+
+import {strict as assert} from 'assert';
+import setCookieParser from 'set-cookie-parser';
+
+const io = require('socket.io-client');
+const common = require('../../common');
+const settings = require('../../../../node/utils/Settings');
+const padManager = require('../../../../node/db/PadManager');
+const db = require('../../../../node/db/DB');
+
+const adminSocket = async () => {
+ settings.users = settings.users || {};
+ settings.users['test-admin'] = {password: 'test-admin-password', is_admin: true};
+ const savedRequireAuthentication = settings.requireAuthentication;
+ settings.requireAuthentication = true;
+ let res: any;
+ try {
+ res = await (common.agent as any)
+ .get('/admin/')
+ .auth('test-admin', 'test-admin-password');
+ } finally {
+ settings.requireAuthentication = savedRequireAuthentication;
+ }
+ const resCookies = setCookieParser.parse(res, {map: true});
+ const reqCookieHdr = Object.entries(resCookies)
+ .map(([name, cookie]: [string, any]) =>
+ `${name}=${encodeURIComponent(cookie.value)}`)
+ .join('; ');
+ const socket = io(`${common.baseUrl}/settings`, {
+ forceNew: true,
+ query: {cookie: reqCookieHdr},
+ });
+ await new Promise((resolve, reject) => {
+ const onErr = (err: any) => { socket.off('connect', onConn); reject(err); };
+ const onConn = () => { socket.off('connect_error', onErr); resolve(); };
+ socket.once('connect', onConn);
+ socket.once('connect_error', onErr);
+ });
+ return socket;
+};
+
+const ask = (socket: any, evt: string, payload: any, replyEvt: string, timeoutMs = 10000) =>
+ new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error(`no \`${replyEvt}\` reply within ${timeoutMs}ms`)), timeoutMs);
+ socket.once(replyEvt, (data: any) => { clearTimeout(timer); resolve(data); });
+ socket.emit(evt, payload);
+ });
+
+describe(__filename, function () {
+ let socket: any;
+ let savedUsers: any;
+ let savedRequireAuthentication: boolean;
+ let setupCompleted = false;
+ const tag = `padLoadResilience-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
+ const goodId = `${tag}-good`;
+ const corruptId = `${tag}-corrupt`;
+
+ before(async function () {
+ this.timeout(120000);
+ await common.init();
+
+ savedUsers = settings.users;
+ savedRequireAuthentication = settings.requireAuthentication;
+ setupCompleted = true;
+
+ try {
+ socket = await adminSocket();
+ } catch (err: any) {
+ console.warn(
+ `[padLoadResilience] admin socket connect failed (${err && err.message}); ` +
+ "skipping suite — likely an authenticate-hook plugin rejecting the test's " +
+ 'admin credentials.');
+ this.skip();
+ return;
+ }
+
+ // A normal, readable pad — this is what must still show up.
+ await padManager.getPad(goodId, 'good content\n');
+
+ // A pad that enters the pad-name index normally, then has its stored
+ // value clobbered into a non-object (a JSON string) to mimic a
+ // migration-corrupted / foreign `pad:*` record. Evicting it from the
+ // in-memory cache forces the next getPad() to re-read the bad value.
+ await padManager.getPad(corruptId, 'temp\n');
+ await db.set(`pad:${corruptId}`, 'corrupt-non-object-value');
+ padManager.unloadPad(corruptId);
+
+ // Sanity-check that the setup actually reproduces the failing read; if
+ // this stops throwing the test is no longer exercising the bug.
+ await assert.rejects(padManager.getPad(corruptId),
+ 'expected the corrupted pad record to make getPad throw');
+ });
+
+ after(async function () {
+ if (socket) socket.disconnect();
+ if (!setupCompleted) return;
+ if (settings.users) delete settings.users['test-admin'];
+ settings.users = savedUsers;
+ settings.requireAuthentication = savedRequireAuthentication;
+ for (const id of [goodId, corruptId]) {
+ try { await db.remove(`pad:${id}`); } catch { /* ignore */ }
+ try { await db.remove(`pad:${id}:revs:0`); } catch { /* ignore */ }
+ try { padManager.unloadPad(id); } catch { /* ignore */ }
+ }
+ });
+
+ it('a single corrupt pad does not hide every other pad (issue #7935)', async function () {
+ this.timeout(30000);
+ // The default query the SPA sends on initial load: lastEdited sort forces
+ // the full-scan hydration path that touches every pad — including the
+ // corrupt one.
+ const res = await ask(socket, 'padLoad', {
+ pattern: tag, offset: 0, limit: 12,
+ sortBy: 'lastEdited', ascending: false, filter: 'all',
+ }, 'results:padLoad');
+
+ const names = res.results.map((r: any) => r.padName);
+ assert.ok(names.includes(goodId),
+ `the readable pad must still be listed; got ${JSON.stringify(names)}`);
+ // The bad pad is surfaced (zeroed metadata) rather than silently dropped,
+ // so an admin can see and delete it.
+ assert.ok(names.includes(corruptId),
+ `the corrupt pad should surface for deletion; got ${JSON.stringify(names)}`);
+ assert.equal(res.total, 2, `expected total=2, got ${JSON.stringify(res)}`);
+ });
+
+ it('still replies on the fast path (padName sort) with a corrupt pad present', async function () {
+ this.timeout(30000);
+ const res = await ask(socket, 'padLoad', {
+ pattern: tag, offset: 0, limit: 12,
+ sortBy: 'padName', ascending: true, filter: 'all',
+ }, 'results:padLoad');
+ const names = res.results.map((r: any) => r.padName);
+ assert.ok(names.includes(goodId), `got ${JSON.stringify(names)}`);
+ assert.ok(names.includes(corruptId), `got ${JSON.stringify(names)}`);
+ });
+
+ // Runs last: surfacing a corrupt pad is only useful if it can be removed.
+ // deletePad's normal path (doesPadExists + getPad + Pad.remove) can't touch
+ // an unreadable record, so it must fall back to a raw key purge.
+ it('a surfaced corrupt pad can be deleted from the admin UI', async function () {
+ this.timeout(30000);
+ const ack = await ask(socket, 'deletePad', corruptId, 'results:deletePad');
+ assert.equal(ack, corruptId, `expected deletePad to ack "${corruptId}", got ${JSON.stringify(ack)}`);
+
+ // Assert the user-facing outcome — the corrupt pad is gone from the
+ // listing (its pad-list entry was dropped) while the good pad stays.
+ // (We deliberately don't probe `db.get('pad:')` here: ueberdb2's
+ // per-backend read/write buffering can still return the just-removed
+ // value immediately after `remove()`, so that would be a flaky
+ // storage-internals assertion rather than a behavioural one.)
+ const res = await ask(socket, 'padLoad', {
+ pattern: tag, offset: 0, limit: 12,
+ sortBy: 'padName', ascending: true, filter: 'all',
+ }, 'results:padLoad');
+ const names = res.results.map((r: any) => r.padName);
+ assert.ok(!names.includes(corruptId), `corrupt pad still listed: ${JSON.stringify(names)}`);
+ assert.ok(names.includes(goodId), `good pad missing after delete: ${JSON.stringify(names)}`);
+ });
+});
diff --git a/src/tests/backend/specs/domline_list_start.ts b/src/tests/backend/specs/domline_list_start.ts
new file mode 100644
index 000000000..17700483e
--- /dev/null
+++ b/src/tests/backend/specs/domline_list_start.ts
@@ -0,0 +1,62 @@
+'use strict';
+
+/*
+ * Regression test for GHSA-f7h5-v9hm-548j.
+ *
+ * The numbered-list branch of `domline.appendSpan` used to interpolate the
+ * line's `start` attribute value into an `` tag unquoted and
+ * unescaped, then assign the result to `node.innerHTML`. The value comes
+ * verbatim from the attribute pool, which an attacker can populate via a
+ * crafted `.etherpad` import, so a value such as `1>`
+ * broke out of the tag and produced a live element -> stored XSS for every
+ * viewer of the pad/timeslider.
+ */
+
+const assert = require('assert').strict;
+const domline = require('../../../static/js/domline').domline;
+const {lineAttributeMarker} = require('../../../static/js/linestylefilter');
+import jsdom from 'jsdom';
+
+// Build the per-span class string exactly as linestylefilter would for a
+// numbered-list line marker carrying the given `start` value.
+const listCls = (start: string) =>
+ `${lineAttributeMarker} list:number1 start:${start}`;
+
+// Render a single line-marker span through the real domline sink and return
+// the resulting DOM node.
+const renderLine = (cls: string) => {
+ const {window} = new jsdom.JSDOM('');
+ const node = domline.createDomLine(true, false, window, window.document);
+ node.clearSpans();
+ node.appendSpan('*', cls);
+ node.finishUpdate();
+ return node.node as HTMLElement;
+};
+
+describe(__filename, function () {
+ it('does not create a live element from a malicious start value', async function () {
+ // Space-free payload: satisfies the `\S+` capture the sink matches on.
+ const node = renderLine(listCls('1>'));
+ assert.equal(node.querySelector('svg'), null,
+ 'malicious start value must not be parsed into a live element');
+ assert.ok(!node.innerHTML.includes(' tag: ${node.innerHTML}`);
+ // The numbered list itself still renders; only the bogus value is dropped.
+ assert.ok(node.querySelector('ol'), 'a numbered list should still render');
+ });
+
+ it('renders a legitimate integer start value safely', async function () {
+ const node = renderLine(listCls('2'));
+ const ol = node.querySelector('ol');
+ assert.ok(ol, 'a numbered list should render');
+ assert.equal(ol!.getAttribute('start'), '2');
+ });
+
+ it('coerces a non-integer start value away instead of emitting it', async function () {
+ const node = renderLine(listCls('notanumber'));
+ const ol = node.querySelector('ol');
+ assert.ok(ol, 'a numbered list should still render');
+ assert.equal(ol!.getAttribute('start'), null,
+ 'a non-integer start value must not reach the start attribute');
+ });
+});
diff --git a/src/tests/backend/specs/downstream/generate-vectors.ts b/src/tests/backend/specs/downstream/generate-vectors.ts
new file mode 100644
index 000000000..5d821a3b1
--- /dev/null
+++ b/src/tests/backend/specs/downstream/generate-vectors.ts
@@ -0,0 +1,77 @@
+'use strict';
+
+/**
+ * Single source of truth for the downstream wire-compatibility fixtures.
+ *
+ * Each vector is a self-contained changeset application: given `initialText`
+ * and `pool`, applying `changeset` yields `resultText`. Downstream clients
+ * (which reimplement Etherpad's changeset/attribpool decoders) consume the
+ * exact same JSON and must reproduce `resultText`. See the Phase 1 plan
+ * at docs/superpowers/plans/2026-06-09-downstream-client-compat-tests-phase1.md.
+ *
+ * Runnable as a CLI to (re)write src/tests/fixtures/wire-vectors.json:
+ * pnpm run vectors:gen
+ */
+
+import * as Changeset from '../../../../static/js/Changeset';
+import AttributePool from '../../../../static/js/AttributePool';
+
+export type WireVector = {
+ name: string;
+ initialText: string;
+ changeset: string;
+ pool: ReturnType;
+ resultText: string;
+};
+
+const vector = (
+ name: string,
+ initialText: string,
+ build: (pool: AttributePool) => string,
+): WireVector => {
+ const pool = new AttributePool();
+ const changeset = build(pool);
+ Changeset.checkRep(changeset);
+ return {
+ name,
+ initialText,
+ changeset,
+ pool: pool.toJsonable(),
+ resultText: Changeset.applyToText(changeset, initialText),
+ };
+};
+
+export const generateVectors = (): WireVector[] => [
+ vector('plain-insert', 'abc\n', () =>
+ Changeset.makeSplice('abc\n', 3, 0, 'XYZ')),
+
+ vector('plain-delete', 'abcdef\n', () =>
+ Changeset.makeSplice('abcdef\n', 1, 3, '')),
+
+ vector('formatted-insert', 'abc\n', (pool) =>
+ Changeset.makeSplice('abc\n', 3, 0, 'bold', [['bold', 'true']], pool)),
+
+ vector('multiline-insert', 'abc\n', () =>
+ Changeset.makeSplice('abc\n', 3, 0, 'one\ntwo\n')),
+
+ vector('attrib-reuse', 'abc\n', (pool) => {
+ // Two formatted inserts sharing one pool entry exercises pool index reuse.
+ const cs1 = Changeset.makeSplice('abc\n', 0, 0, 'A', [['bold', 'true']], pool);
+ const mid = Changeset.applyToText(cs1, 'abc\n');
+ const cs2 = Changeset.makeSplice(mid, mid.length - 1, 0, 'B', [['bold', 'true']], pool);
+ return Changeset.compose(cs1, cs2, pool);
+ }),
+];
+
+// CLI entry: write the canonical fixture to disk.
+if (require.main === module) {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const fs = require('fs');
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const path = require('path');
+ const out = path.join(__dirname, '../../../fixtures/wire-vectors.json');
+ fs.mkdirSync(path.dirname(out), {recursive: true});
+ fs.writeFileSync(out, `${JSON.stringify(generateVectors(), null, 2)}\n`);
+ // eslint-disable-next-line no-console
+ console.log(`wrote ${out}`);
+}
diff --git a/src/tests/backend/specs/downstream/wire-http-api.ts b/src/tests/backend/specs/downstream/wire-http-api.ts
new file mode 100644
index 000000000..ce688151b
--- /dev/null
+++ b/src/tests/backend/specs/downstream/wire-http-api.ts
@@ -0,0 +1,54 @@
+'use strict';
+
+/**
+ * Snapshots the *shapes* (keys/types, not volatile values) of the HTTP API
+ * endpoints downstream clients call to create pads and round-trip text.
+ * Auth in the test harness is via JWT (common.generateJWTToken), matching the
+ * rest of the api specs — see api/createDiffHTML.ts.
+ */
+
+const assert = require('assert').strict;
+const common = require('../../common');
+
+describe(__filename, function () {
+ let agent: any;
+ let apiVersion = 1;
+ const padId = `wireHttp_${common.randomString()}`;
+ const endPoint = (point: string) => `/api/${apiVersion}/${point}`;
+
+ before(async function () {
+ agent = await common.init();
+ const res = await agent.get('/api/').expect(200).expect('Content-Type', /json/);
+ apiVersion = res.body.currentVersion;
+ assert(apiVersion);
+ });
+
+ it('createPad returns the standard {code,data,message} envelope', async function () {
+ const res = await agent.get(`${endPoint('createPad')}?padID=${padId}&text=hello%0A`)
+ .set('Authorization', await common.generateJWTToken())
+ .expect(200);
+ assert.deepEqual(Object.keys(res.body).sort(), ['code', 'data', 'message']);
+ assert.equal(res.body.code, 0);
+ });
+
+ it('setText + getText round-trips text through the documented shape', async function () {
+ await agent.post(endPoint('setText'))
+ .set('Authorization', await common.generateJWTToken())
+ .send({padID: padId, text: 'world\n'})
+ .expect(200);
+ const res = await agent.get(`${endPoint('getText')}?padID=${padId}`)
+ .set('Authorization', await common.generateJWTToken())
+ .expect(200);
+ assert.equal(res.body.code, 0);
+ assert.equal(typeof res.body.data.text, 'string');
+ assert.equal(res.body.data.text, 'world\n');
+ });
+
+ it('getRevisionsCount exposes a numeric revisions field', async function () {
+ const res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${padId}`)
+ .set('Authorization', await common.generateJWTToken())
+ .expect(200);
+ assert.equal(res.body.code, 0);
+ assert.equal(typeof res.body.data.revisions, 'number');
+ });
+});
diff --git a/src/tests/backend/specs/downstream/wire-socket-sequence.ts b/src/tests/backend/specs/downstream/wire-socket-sequence.ts
new file mode 100644
index 000000000..3aa829362
--- /dev/null
+++ b/src/tests/backend/specs/downstream/wire-socket-sequence.ts
@@ -0,0 +1,60 @@
+'use strict';
+
+/**
+ * Pins the socket.io message sequence + shapes that every realtime client
+ * depends on: handshake -> CLIENT_VARS, then USER_CHANGES -> ACCEPT_COMMIT.
+ * A change here is a wire-protocol change that will break downstream clients
+ * (the Rust terminal editor and the Node CLI both speak this sequence by hand).
+ */
+
+const assert = require('assert').strict;
+const common = require('../../common');
+const padManager = require('../../../../node/db/PadManager');
+
+describe(__filename, function () {
+ let agent: any;
+ let socket: any;
+ let pad: any;
+ let padId: string;
+
+ before(async function () { agent = await common.init(); });
+
+ beforeEach(async function () {
+ padId = common.randomString();
+ pad = await padManager.getPad(padId, 'dummy\n');
+ await pad.setText('\n'); // ensure the pad exists at a known empty state
+ const res = await agent.get(`/p/${padId}`).expect(200);
+ socket = await common.connect(res);
+ });
+
+ afterEach(async function () {
+ if (socket != null) socket.close();
+ socket = null;
+ if (pad != null) await pad.remove();
+ pad = null;
+ });
+
+ it('handshake returns CLIENT_VARS with the client-facing shape', async function () {
+ const {type, data} = await common.handshake(socket, padId);
+ assert.equal(type, 'CLIENT_VARS');
+ assert.ok(data.userId, 'CLIENT_VARS.userId missing');
+ assert.ok(data.collab_client_vars, 'collab_client_vars missing');
+ assert.equal(typeof data.collab_client_vars.rev, 'number');
+ assert.ok(data.collab_client_vars.initialAttributedText,
+ 'collab_client_vars.initialAttributedText missing');
+ });
+
+ it('USER_CHANGES is acknowledged with ACCEPT_COMMIT and a bumped rev', async function () {
+ const {data: clientVars} = await common.handshake(socket, padId);
+ const rev = clientVars.collab_client_vars.rev;
+ const authorId = clientVars.userId;
+ // Insert ops must carry the session author attribute (`*0+N` + matching
+ // apool entry) or the server rejects with {disconnect:'badChangeset'}.
+ const apool = {numToAttrib: {0: ['author', authorId]}, nextNum: 1};
+ await Promise.all([
+ common.waitForAcceptCommit(socket, rev + 1),
+ common.sendUserChanges(socket, {baseRev: rev, changeset: 'Z:1>5*0+5$hello', apool}),
+ ]);
+ assert.equal(pad.text(), 'hello\n');
+ });
+});
diff --git a/src/tests/backend/specs/downstream/wire-vectors.ts b/src/tests/backend/specs/downstream/wire-vectors.ts
new file mode 100644
index 000000000..3fc5c092d
--- /dev/null
+++ b/src/tests/backend/specs/downstream/wire-vectors.ts
@@ -0,0 +1,33 @@
+'use strict';
+
+/**
+ * Guards the downstream wire-format contract:
+ * - the committed fixture exactly matches a fresh regeneration (any drift is a
+ * deliberate wire change and must be re-generated + reviewed in the same PR), and
+ * - every vector is internally consistent under core's own Changeset engine.
+ */
+
+const assert = require('assert').strict;
+import fs from 'fs';
+import path from 'path';
+import * as Changeset from '../../../../static/js/Changeset';
+import {generateVectors} from './generate-vectors';
+
+const fixturePath = path.join(__dirname, '../../../fixtures/wire-vectors.json');
+
+describe(__filename, function () {
+ it('committed fixture matches a fresh regeneration', function () {
+ const committed = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
+ const fresh = generateVectors();
+ assert.deepEqual(committed, fresh,
+ 'wire-vectors.json is stale — run `pnpm run vectors:gen` and commit the result');
+ });
+
+ it('every vector applies to its result under core Changeset', function () {
+ for (const v of generateVectors()) {
+ Changeset.checkRep(v.changeset);
+ assert.equal(Changeset.applyToText(v.changeset, v.initialText), v.resultText,
+ `vector ${v.name} result mismatch`);
+ }
+ });
+});
diff --git a/src/tests/backend/specs/socketio.ts b/src/tests/backend/specs/socketio.ts
index 43da20b15..84a306282 100644
--- a/src/tests/backend/specs/socketio.ts
+++ b/src/tests/backend/specs/socketio.ts
@@ -34,7 +34,7 @@ describe(__filename, function () {
plugins.hooks[hookName] = [];
}
backups.settings = {};
- for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users', 'enablePadWideSettings']) {
+ for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users', 'enablePadWideSettings', 'allowPadDeletionByAllUsers']) {
// @ts-ignore
backups.settings[setting] = settings[setting];
}
@@ -492,6 +492,49 @@ describe(__filename, function () {
});
});
+ describe('Pad deletion token issuance (#7926)', function () {
+ const removeIfExists = async (padId: string) => {
+ if (await padManager.doesPadExist(padId)) {
+ const p = await padManager.getPad(padId);
+ await p.remove();
+ }
+ };
+
+ beforeEach(async function () {
+ // @ts-ignore - public setting toggled per test
+ settings.allowPadDeletionByAllUsers = false;
+ await removeIfExists('pad');
+ });
+ afterEach(async function () {
+ if (socket) socket.close();
+ socket = null;
+ await removeIfExists('pad');
+ });
+
+ it('anonymous creator receives a deletion token by default', async function () {
+ const res = await agent.get('/p/pad').expect(200);
+ socket = await common.connect(res);
+ const cv: any = await common.handshake(socket, 'pad');
+ assert.equal(cv.type, 'CLIENT_VARS');
+ assert.equal(typeof cv.data.padDeletionToken, 'string',
+ 'creator should get a token so the client can show the save-token modal');
+ assert.ok(cv.data.padDeletionToken.length >= 32);
+ });
+
+ it('no token (and so no modal) when allowPadDeletionByAllUsers is true', async function () {
+ // @ts-ignore - public setting
+ settings.allowPadDeletionByAllUsers = true;
+ const res = await agent.get('/p/pad').expect(200);
+ socket = await common.connect(res);
+ const cv: any = await common.handshake(socket, 'pad');
+ assert.equal(cv.type, 'CLIENT_VARS');
+ // A null token means showDeletionTokenModalIfPresent() returns early on the
+ // client, so the "Save your pad deletion token" modal never appears. Anyone
+ // can already delete the pad without a token in this configuration.
+ assert.equal(cv.data.padDeletionToken, null);
+ });
+ });
+
describe('SocketIORouter.js', function () {
const Module = class {
setSocketIO(io:any) {}
diff --git a/src/tests/downstream/clients.json b/src/tests/downstream/clients.json
new file mode 100644
index 000000000..b4013a72f
--- /dev/null
+++ b/src/tests/downstream/clients.json
@@ -0,0 +1,29 @@
+[
+ {
+ "name": "etherpad-pad",
+ "repo": "https://github.com/ether/pad.git",
+ "ref": "91620c67c49536bb77e90b39f298ea70ae93c4a0",
+ "kind": "rust",
+ "enabled": true,
+ "vectorTest": "cargo test --test vectors",
+ "smokeCmd": "cargo test --test smoke -- --ignored"
+ },
+ {
+ "name": "etherpad-cli-client",
+ "repo": "https://github.com/ether/etherpad-cli-client.git",
+ "ref": "ebc516ef1a4e7a0c97ccd7a3f2db65e99f8e177c",
+ "kind": "node",
+ "enabled": true,
+ "vectorTest": "pnpm run test:vectors",
+ "smokeCmd": "pnpm run test:smoke"
+ },
+ {
+ "name": "etherpad-desktop",
+ "repo": "https://github.com/ether/etherpad-desktop.git",
+ "ref": "ab83da645b8683afbbc203e4a3fa6f3622a55709",
+ "kind": "desktop",
+ "enabled": true,
+ "vectorTest": "pnpm run test:vectors",
+ "smokeCmd": "pnpm run test:smoke"
+ }
+]
diff --git a/src/tests/downstream/run-clients.sh b/src/tests/downstream/run-clients.sh
new file mode 100755
index 000000000..e2f386820
--- /dev/null
+++ b/src/tests/downstream/run-clients.sh
@@ -0,0 +1,86 @@
+#!/usr/bin/env bash
+#
+# Runs each enabled downstream client from clients.json against an already-booted
+# Etherpad: clone @ pinned ref, set up its toolchain, point it at core's freshly
+# generated wire-vectors fixture, then run the client's vectorTest + smokeCmd.
+#
+# The fixture is injected via $ETHERPAD_WIRE_VECTORS (absolute) so clients test
+# against CURRENT core's serialization, not their vendored snapshot. The smoke
+# reaches the server via $ETHERPAD_SMOKE_URL + $ETHERPAD_SMOKE_APIKEY.
+#
+# Env (all optional except APIKEY):
+# SMOKE_URL default http://localhost:9003
+# SMOKE_APIKEY required for the live smoke (clients skip cleanly without it)
+# MANIFEST default src/tests/downstream/clients.json (relative to repo root)
+# WIRE_VECTORS default /src/tests/fixtures/wire-vectors.json
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
+MANIFEST="${MANIFEST:-$REPO_ROOT/src/tests/downstream/clients.json}"
+WIRE_VECTORS="${WIRE_VECTORS:-$REPO_ROOT/src/tests/fixtures/wire-vectors.json}"
+SMOKE_URL="${SMOKE_URL:-http://localhost:9003}"
+SMOKE_APIKEY="${SMOKE_APIKEY:-}"
+
+[ -f "$WIRE_VECTORS" ] || { echo "::error::fixture not found: $WIRE_VECTORS"; exit 1; }
+
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+MANIFEST="$MANIFEST" node -e '
+ const c = require(process.env.MANIFEST).filter((x) => x.enabled);
+ for (const x of c) {
+ process.stdout.write([x.name, x.repo, x.ref, x.kind, x.vectorTest, x.smokeCmd].join("\t") + "\n");
+ }
+' > "$WORK/clients.tsv"
+
+if [ ! -s "$WORK/clients.tsv" ]; then
+ echo "No downstream clients enabled. Nothing to run."
+ exit 0
+fi
+
+export ETHERPAD_WIRE_VECTORS="$WIRE_VECTORS"
+export ETHERPAD_SMOKE_URL="$SMOKE_URL"
+export ETHERPAD_SMOKE_APIKEY="$SMOKE_APIKEY"
+
+fail=0
+while IFS=$'\t' read -r name repo ref kind vectorTest smokeCmd; do
+ echo "::group::$name ($kind) @ ${ref:0:12}"
+ dir="$WORK/$name"
+ # Everything — clone, checkout, AND the tests — runs inside one guarded
+ # subshell so a single client's failure becomes a per-client failure (fail=1)
+ # and the loop continues to the rest. NOTE: `set -e` is suspended inside a
+ # subshell used as an `||` operand, so every step is guarded with an explicit
+ # `|| exit 1` rather than relying on `set -e`. The manifest commands are a
+ # trusted in-repo allowlist; running them via `bash -euo pipefail -c` (not
+ # `eval`) keeps them out of this script's own shell and applies strict mode
+ # (pipeline-stage failures surface) inside the child.
+ (
+ git clone --quiet "$repo" "$dir" || exit 1
+ # A default clone has all branch heads; fetch the pinned commit only if it
+ # is not already reachable (e.g. a non-branch-tip SHA). Fetch errors are
+ # NOT suppressed so the real cause surfaces instead of a vague checkout fail.
+ if ! git -C "$dir" cat-file -e "${ref}^{commit}" 2>/dev/null; then
+ git -C "$dir" fetch --quiet origin "$ref" || exit 1
+ fi
+ git -C "$dir" checkout --quiet "$ref" || exit 1
+
+ cd "$dir" || exit 1
+ case "$kind" in
+ rust)
+ bash -euo pipefail -c "$vectorTest" || exit 1
+ bash -euo pipefail -c "$smokeCmd" || exit 1
+ ;;
+ node|desktop)
+ pnpm install || exit 1
+ bash -euo pipefail -c "$vectorTest" || exit 1
+ bash -euo pipefail -c "$smokeCmd" || exit 1
+ ;;
+ *)
+ echo "::error::unknown client kind: $kind"; exit 1
+ ;;
+ esac
+ ) || { echo "::error::downstream client '$name' failed (clone/checkout/test)"; fail=1; }
+ echo "::endgroup::"
+done < "$WORK/clients.tsv"
+
+exit "$fail"
diff --git a/src/tests/fixtures/wire-vectors.json b/src/tests/fixtures/wire-vectors.json
new file mode 100644
index 000000000..86870ae6e
--- /dev/null
+++ b/src/tests/fixtures/wire-vectors.json
@@ -0,0 +1,62 @@
+[
+ {
+ "name": "plain-insert",
+ "initialText": "abc\n",
+ "changeset": "Z:4>3=3+3$XYZ",
+ "pool": {
+ "numToAttrib": {},
+ "nextNum": 0
+ },
+ "resultText": "abcXYZ\n"
+ },
+ {
+ "name": "plain-delete",
+ "initialText": "abcdef\n",
+ "changeset": "Z:7<3=1-3$",
+ "pool": {
+ "numToAttrib": {},
+ "nextNum": 0
+ },
+ "resultText": "aef\n"
+ },
+ {
+ "name": "formatted-insert",
+ "initialText": "abc\n",
+ "changeset": "Z:4>4=3*0+4$bold",
+ "pool": {
+ "numToAttrib": {
+ "0": [
+ "bold",
+ "true"
+ ]
+ },
+ "nextNum": 1
+ },
+ "resultText": "abcbold\n"
+ },
+ {
+ "name": "multiline-insert",
+ "initialText": "abc\n",
+ "changeset": "Z:4>8=3|2+8$one\ntwo\n",
+ "pool": {
+ "numToAttrib": {},
+ "nextNum": 0
+ },
+ "resultText": "abcone\ntwo\n\n"
+ },
+ {
+ "name": "attrib-reuse",
+ "initialText": "abc\n",
+ "changeset": "Z:4>2*0+1=3*0+1$AB",
+ "pool": {
+ "numToAttrib": {
+ "0": [
+ "bold",
+ "true"
+ ]
+ },
+ "nextNum": 1
+ },
+ "resultText": "AabcB\n"
+ }
+]
diff --git a/src/tests/frontend-new/admin-spec/admin_pads_page.spec.ts b/src/tests/frontend-new/admin-spec/admin_pads_page.spec.ts
new file mode 100644
index 000000000..11b0e60cc
--- /dev/null
+++ b/src/tests/frontend-new/admin-spec/admin_pads_page.spec.ts
@@ -0,0 +1,54 @@
+import {expect, test} from "@playwright/test";
+import {loginToAdmin} from "../helper/adminhelper";
+import {goToPad, writeToPad} from "../helper/padHelper";
+
+// End-to-end coverage for issue #7935: a pad that exists must be visible
+// both on the welcome page's "recent pads" list (driven by localStorage,
+// i.e. gated on the browser having opened the pad) and in the admin
+// "Manage pads" UI (driven by the /settings socket `padLoad` handler,
+// which enumerates the DB). The admin side regressed when a single
+// unreadable pad record made `padLoad` throw and silently return nothing
+// — see specs/admin/padLoadResilience.ts for the server-side guard.
+
+// /admin tests mutate global server state, so keep them serial.
+test.describe.configure({mode: 'serial'});
+
+const ADMIN_URL = 'http://localhost:9001/admin';
+
+test.describe('a created pad shows up on the home page and in /admin', () => {
+ // Unique, URL-safe id so the recent-pads localStorage entry and the admin
+ // search both target exactly this pad and ignore leftovers from other suites.
+ const padId = `pw-pads-7935-${Date.now()}`;
+
+ test('opening a pad lists it in the welcome page recent-pads', async ({page}) => {
+ await goToPad(page, padId);
+ await writeToPad(page, 'hello from 7935');
+
+ // Opening the pad writes it to `recentPads` localStorage (colibris
+ // pad.js). The welcome page renders that list — same browser context,
+ // so the entry carries over.
+ await page.goto('http://localhost:9001/');
+ const recentPad = page.locator('.recent-pad', {hasText: padId});
+ await expect(recentPad).toBeVisible({timeout: 10000});
+ await expect(recentPad.locator('a')).toHaveText(padId);
+ });
+
+ test('the same pad is listed in the admin Manage pads UI', async ({page}) => {
+ await loginToAdmin(page, 'admin', 'changeme1');
+ await page.goto(`${ADMIN_URL}/pads`);
+
+ await expect(page.getByRole('heading', {name: 'Manage pads'}))
+ .toBeVisible({timeout: 30000});
+
+ // Narrow the (full-scan) listing to our pad. The search is debounced
+ // server-side; allow the round-trip to settle.
+ const search = page.getByPlaceholder('Search for pads');
+ await search.fill(padId);
+
+ await expect(page.locator('.pm-pad-title', {hasText: padId}))
+ .toBeVisible({timeout: 15000});
+ // The "No results" empty state must NOT be showing — the exact #7935
+ // symptom was an empty Manage-pads list for pads that demonstrably exist.
+ await expect(page.locator('.pm-empty')).toHaveCount(0);
+ });
+});
diff --git a/src/tests/frontend-new/specs/language.spec.ts b/src/tests/frontend-new/specs/language.spec.ts
index dc80785dd..b32e628e1 100644
--- a/src/tests/frontend-new/specs/language.spec.ts
+++ b/src/tests/frontend-new/specs/language.spec.ts
@@ -90,4 +90,28 @@ test.describe('Language select and change', function () {
await page.waitForSelector('html[dir="ltr"]')
});
+
+ // Regression test for #7925: when the UI language is auto-detected from the
+ // browser (no language cookie, no pad-wide lang set), the language dropdown
+ // must reflect the detected language instead of defaulting to English.
+ test('dropdown reflects the browser-detected language', async function ({browser}) {
+ const context = await browser.newContext({locale: 'de-DE'})
+ await context.clearCookies()
+ const page = await context.newPage()
+ try {
+ await goToNewPad(page)
+
+ // The toolbar should have rendered in German (detection works) — the
+ // bold button's parent carries the localized German tooltip.
+ await expect(page.locator('.buttonicon-bold').locator('..'))
+ .toHaveAttribute('title', 'Fett (Strg-B)')
+
+ // ... and the language dropdown must show the detected language, not
+ // English, even though it was never explicitly selected.
+ await showSettings(page)
+ await expect(langDropdown(page).locator('.current')).toHaveText('Deutsch')
+ } finally {
+ await context.close()
+ }
+ });
});
diff --git a/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts b/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts
index 056d232e9..bd0206f6a 100644
--- a/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts
+++ b/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts
@@ -59,4 +59,18 @@ test.describe('dark color scheme', () => {
// before pad.css so it takes effect at first paint.
await expect(page.locator('html')).toHaveClass(/super-dark-editor/);
});
+
+ test('root canvas matches the toolbar so the iOS status-bar area is not white',
+ async ({page}) => {
+ await goToNewPad(page);
+ // The root must carry the toolbar colour as its background — iOS
+ // Safari paints the status-bar safe area from the root canvas, not from
+ // theme-color, so leaving it transparent produced a white strip above
+ // the dark pad (issue #7606). #485365 == --super-dark-color, the dark
+ // toolbar colour.
+ await expect
+ .poll(() => page.evaluate(() =>
+ getComputedStyle(document.documentElement).backgroundColor))
+ .toBe('rgb(72, 83, 101)');
+ });
});