Merge branch 'develop'

This commit is contained in:
Etherpad Release Bot 2026-06-10 10:03:40 +00:00
commit 92e23bf7d4
28 changed files with 1951 additions and 120 deletions

View file

@ -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

108
.github/workflows/downstream-smoke.yml vendored Normal file
View file

@ -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

View file

@ -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 `<ol start>` 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 `<select>` 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.

View file

@ -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",

View file

@ -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",

View file

@ -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<AttributePool['toJsonable']>;
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 13. ✅
- 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 23. `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.

View file

@ -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": "<sha on main>", "kind": "rust", "smokeCmd": "..." },
{ "name": "etherpad-cli-client", "repo": "https://github.com/ether/etherpad-cli-client.git", "ref": "<sha on main>", "kind": "node", "smokeCmd": "..." },
{ "name": "etherpad-desktop", "repo": "https://github.com/ether/etherpad-desktop.git", "ref": "<sha on main>", "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.

View file

@ -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": [

178
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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;

View file

@ -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
// <value>"). 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<PadQueryResult> => {
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)}`);
}
})

View file

@ -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"
}

View file

@ -102,12 +102,21 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
postHtml = `</li></ul>${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 <ol> start is
// only ever an integer: coerce it and HTML-escape it defensively so
// a value such as `1><svg/onload=...>` 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 +=
`<ol start=${start[1]} class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
`<ol${startAttr} class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
} else {
// Handles pasted contents into existing lists
preHtml += `<ol class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;

View file

@ -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')

View file

@ -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 <html> 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); }

View file

@ -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 <string>`. 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<void>((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<any>((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:<id>')` 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)}`);
});
});

View file

@ -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 `<ol start=...>` 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><svg/onload=alert(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('<!DOCTYPE html><html><body></body></html>');
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><svg/onload=alert(document.domain)>'));
assert.equal(node.querySelector('svg'), null,
'malicious start value must not be parsed into a live <svg> element');
assert.ok(!node.innerHTML.includes('<svg'),
`rendered markup must not contain a raw <svg> 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 <ol> start attribute');
});
});

View file

@ -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<AttributePool['toJsonable']>;
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}`);
}

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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`);
}
});
});

View file

@ -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) {}

View file

@ -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"
}
]

View file

@ -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 <repo>/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"

62
src/tests/fixtures/wire-vectors.json vendored Normal file
View file

@ -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"
}
]

View file

@ -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);
});
});

View file

@ -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 <li> 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()
}
});
});

View file

@ -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 <html> 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)');
});
});