From 3add5147d9c2885be538bb416cd2608f111a8735 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 17:54:50 +0100 Subject: [PATCH 01/18] docs: PR1 GDPR deletion-controls design spec First of five GDPR PRs tracked in #6701. PR1 covers deletion controls: one-time deletion token, allowPadDeletionByAllUsers flag, authorisation matrix for handlePadDelete and the REST deletePad endpoint, a single token-display modal for browser pad creators, and test coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...04-18-gdpr-pr1-deletion-controls-design.md | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md diff --git a/docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md b/docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md new file mode 100644 index 00000000000..9a37900f075 --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md @@ -0,0 +1,207 @@ +# PR1 — GDPR Deletion Controls + +Part of the GDPR work planned in ether/etherpad#6701. This PR delivers +deletion controls: a one-time deletion token, an admin-level permission +flag, and the wiring needed for the existing "Delete pad" button to work +for token-bearers in addition to the creator cookie. + +Scope deliberately excludes: author erasure, IP audits, anonymous +identity hardening, and the privacy banner. Those are PR2–PR5. + +## Goals + +- A pad created via the HTTP API returns a cryptographically random + deletion token exactly once. Possession of that token is proof that + the holder may delete the pad. The token survives cookie loss and + device changes. +- Instance admins can widen deletion rights to any pad editor via + `allowPadDeletionByAllUsers`, keeping the default tight. +- Browser-created pads show the token once in a copyable modal so the + creator has a path off-device. +- No existing delete path regresses: the creator cookie still works with + no token involvement. + +## Non-goals + +- Revocation / rotation of deletion tokens. A token is valid until the + pad is deleted, at which point both pad and token go away together. +- Multi-token support per pad. One token, one pad. +- Author erasure (right-to-be-forgotten) — PR5. +- Surfacing IP-logging behaviour or a privacy banner — PR2 / PR4. + +## Authorization matrix + +Wired into `handlePadDelete` (socket) and `deletePad` (REST API). + +| Caller | Default (`allowPadDeletionByAllUsers: false`) | `allowPadDeletionByAllUsers: true` | +| --- | --- | --- | +| Session author matches revision-0 author (creator cookie) | Allowed | Allowed | +| Supplies a deletion token that `isValidDeletionToken()` accepts | Allowed | Allowed | +| Any other pad editor | Refused with the existing "not the creator" shout | Allowed | +| Unauthorised (no session, read-only, wrong pad) | Refused | Refused | + +Rationale: the token is a recovery credential, not a day-to-day +capability, so the default never silently upgrades "anyone in the pad" +to deleter. Admins opt in explicitly when that's the policy they want. + +## Token lifecycle + +1. On the first successful `createPad` / `createGroupPad` call, + `PadDeletionManager.createDeletionTokenIfAbsent(padId)` generates a + 32-character random string, stores `sha256(token)` in + `pad::deletionToken`, and returns the plaintext token. +2. The plaintext is returned once in the API response + (`{padID, deletionToken}`) and, for browser-created pads, streamed + into `clientVars.padDeletionToken` on that session only. +3. The browser shows the token in a one-time modal with a Copy button + and guidance ("save this somewhere — it is the only way to delete + this pad if you lose your browser session"). After the modal is + acknowledged, the token is not rendered again. +4. On delete, `Pad.remove()` calls + `PadDeletionManager.removeDeletionToken(padId)` so DB state stays + consistent. +5. Subsequent `createPad` calls for the same padId never regenerate the + token (the `createDeletionTokenIfAbsent` name is load-bearing). + +Storage shape already introduced in the scaffolding: + +```json +{ + "createdAt": 1712451234567, + "hash": "" +} +``` + +`isValidDeletionToken()` uses `crypto.timingSafeEqual` on equal-length +buffers. Unknown padIds and non-string tokens return `false` without +touching the hash buffer. + +## Endpoints + +### Socket `PAD_DELETE` + +Existing message gains an optional `deletionToken` field: + +```ts +type PadDeleteMessage = { + type: 'PAD_DELETE', + data: { + padId: string, + deletionToken?: string, + } +} +``` + +`handlePadDelete` authorises in order: creator cookie → valid token → +settings flag. On refusal, it emits the same shout as today. + +### REST `POST /api/1/deletePad` + +Accepts the existing `padID` plus an optional `deletionToken` parameter. +HTTP-authenticated admin callers (apikey) bypass the check exactly as +they do today; the token path is for unauthenticated callers who own +the credential. + +### REST `POST /api/1/createPad` and `createGroupPad` + +Response body adds `deletionToken: ` on first creation and +`deletionToken: null` on any subsequent no-op call. Other API consumers +who never read the field are unaffected. + +## UI + +### Post-creation modal (browser pads only) + +Rendered from `pad.ts` when `clientVars.padDeletionToken` is truthy. +Shown inline after pad init, with: + +- Copy-to-clipboard button. +- A localised explanation ("save this once — required to delete the pad + if you lose your session or switch devices"). +- Acknowledgement button that dismisses the modal. The token is cleared + from the in-memory `clientVars` after acknowledgement so a page print + / screenshot after the fact won't re-expose it from the DOM. + +### Delete-by-token entry in the settings popup + +Add a disclosure under the existing Delete button: "I don't have creator +cookies — delete with token" → expands a password-style input and a +confirm button. On submit, sends `PAD_DELETE` with the token. + +### Existing creator flow (no change) + +The creator with their original cookie presses Delete exactly like +today. No token is collected in that path. + +## Settings + +```jsonc +/* + * Allow any user who can edit a pad to delete it without the one-time pad + * deletion token. If false (default), only the original creator's author + * cookie or the deletion token can delete the pad. + */ +"allowPadDeletionByAllUsers": false +``` + +Default `false` in both `settings.json.template` and +`settings.json.docker`. Threaded into `SettingsType` and `settings` +object (scaffolding already present). + +## Data flow + +``` +createPad/createGroupPad + └─► PadDeletionManager.createDeletionTokenIfAbsent + └─► db.set(pad::deletionToken, {createdAt, hash}) + └─► plaintext token → API response / clientVars (browser only) + +browser Delete button + ├─ creator cookie path: socket PAD_DELETE { padId } + └─ token path: socket PAD_DELETE { padId, deletionToken } + └─► handlePadDelete authorisation + ├─ session.author === revision-0 author ⇒ allow + ├─ isValidDeletionToken(padId, token) ⇒ allow + ├─ settings.allowPadDeletionByAllUsers ⇒ allow + └─ else ⇒ shout refusal + +Pad.remove() + └─► padDeletionManager.removeDeletionToken(padId) + └─► existing pad removal cleanup +``` + +## Testing + +### Backend (`src/tests/backend/specs/`) + +- `padDeletionManager.ts`: create / create-when-exists / verify-valid / + verify-wrong-token / verify-unknown-pad / timing-safe equality / + remove-on-delete. +- Extend `api/api.ts` (currently covers createPad behaviour) or add a + sibling spec to assert `deletionToken` is present on first create and + `null` on a duplicate call. +- Add `api/deletePad.ts` covering the four authorisation paths in the + matrix plus the settings-flag toggle. + +### Frontend (`src/tests/frontend-new/specs/`) + +- `pad_deletion_token.spec.ts`: creator session creates a pad, token + modal appears and can be dismissed; after acknowledgement the token + is no longer reachable in `window.clientVars`. +- Same spec: second browser context (no creator cookie) opens the pad, + supplies the captured token via the delete-by-token UI, and verifies + the pad is removed (navigated away / confirmed gone). +- Negative case: invalid token → pad survives, shout refusal surfaces. + +## Risk and migration + +- Existing pads created before this PR have no stored token. First call + to `createDeletionTokenIfAbsent` for a pre-existing padId generates + and stores one — that's the expected upgrade path and does not change + any already-valid deletion flow. +- `db.remove` on a non-existent key is a no-op in etherpad's db layer, + so `removeDeletionToken` is safe to call unconditionally during pad + removal. +- Feature flag (`allowPadDeletionByAllUsers`) defaults to the stricter + behaviour; no existing instance sees a behavioural change unless its + operator opts in. From d5bc57bbfde4e00af3505835bffbfa6761c4b1ce Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:03:13 +0100 Subject: [PATCH 02/18] docs: PR1 GDPR deletion-controls implementation plan 13 TDD-structured tasks covering PadDeletionManager unit tests, socket + REST three-way auth, clientVars wiring, one-time token modal, delete-with-token UI, Playwright coverage, and PR handoff. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-18-gdpr-pr1-deletion-controls.md | 939 ++++++++++++++++++ 1 file changed, 939 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-18-gdpr-pr1-deletion-controls.md diff --git a/docs/superpowers/plans/2026-04-18-gdpr-pr1-deletion-controls.md b/docs/superpowers/plans/2026-04-18-gdpr-pr1-deletion-controls.md new file mode 100644 index 00000000000..467bf8907d1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-gdpr-pr1-deletion-controls.md @@ -0,0 +1,939 @@ +# GDPR PR1 — Pad Deletion Controls 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:** Land the first of five GDPR PRs from ether/etherpad#6701 — adds a one-time deletion token, an `allowPadDeletionByAllUsers` admin flag, and the UI + endpoint plumbing needed for creators to delete a pad without their browser cookies. + +**Architecture:** A new `PadDeletionManager` module owns the token (sha256-hashed in the db under `pad::deletionToken`, returned plaintext exactly once on creation). `handlePadDelete` gains a three-way authorisation check — creator cookie → valid token → settings flag — and `createPad`/`createGroupPad` return the token in the HTTP API response. The browser creator also receives the token via `clientVars.padDeletionToken`, shows it in a one-time modal, and gets a "delete with token" field in the settings popup for devices without the creator cookie. + +**Tech Stack:** TypeScript (etherpad server + client), jQuery + EJS for pad UI, Playwright for frontend tests, Mocha + supertest for backend tests. + +--- + +## File Structure + +**Already in working tree (from restored stash):** +- `src/node/db/PadDeletionManager.ts` — create / verify (timing-safe) / remove +- `settings.json.template`, `settings.json.docker` — `allowPadDeletionByAllUsers: false` +- `src/node/utils/Settings.ts` — `allowPadDeletionByAllUsers` type + default +- `src/node/db/API.ts` — `createPad` returns `{deletionToken}` +- `src/node/db/GroupManager.ts` — `createGroupPad` returns `{padID, deletionToken}` +- `src/node/db/Pad.ts` — `Pad.remove()` calls `removeDeletionToken` +- `src/static/js/types/SocketIOMessage.ts` — `ClientVarPayload` has optional `padDeletionToken` + +**Created by this plan:** +- `src/tests/backend/specs/padDeletionManager.ts` — unit tests for the manager +- `src/tests/backend/specs/api/deletePad.ts` — authorisation-matrix tests +- `src/tests/frontend-new/specs/pad_deletion_token.spec.ts` — end-to-end modal + delete-by-token + +**Modified by this plan:** +- `src/node/handler/PadMessageHandler.ts` — three-way auth in `handlePadDelete`; thread `padDeletionToken` into `clientVars` for creator sessions +- `src/node/db/API.ts` — expose the optional `deletionToken` parameter on the programmatic `deletePad(padID, deletionToken?)` path for REST coverage +- `src/static/js/types/SocketIOMessage.ts` — add optional `deletionToken` to `PadDeleteMessage` +- `src/templates/pad.html` — post-creation token modal, delete-by-token disclosure under Delete button +- `src/static/js/pad.ts` — surface modal when `clientVars.padDeletionToken` is present, clear it after ack +- `src/static/js/pad_editor.ts` — wire delete-by-token input into the existing delete flow +- `src/static/css/pad.css` (or the skin component file the Delete button already lives in) — minimal styling for modal + disclosure +- `src/locales/en.json` — new localisation keys +- `src/tests/backend/specs/api/api.ts` — extend to cover `createPad` returning a token once + +--- + +## Task 1: Baseline and verify the restored scaffolding + +**Files:** +- (no edits — validation only) + +- [ ] **Step 1: Confirm branch and stashed files exist** + +```bash +git status --short +git log --oneline -5 +``` + +Expected: current branch is `feat-gdpr-pad-deletion`, HEAD shows `docs: PR1 GDPR deletion-controls design spec`, and working tree modifications cover `settings.json.template`, `settings.json.docker`, `src/node/db/API.ts`, `src/node/db/GroupManager.ts`, `src/node/db/Pad.ts`, `src/node/utils/Settings.ts`, `src/static/js/types/SocketIOMessage.ts`, plus the untracked `src/node/db/PadDeletionManager.ts`. + +- [ ] **Step 2: Type check before touching anything** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0, no TypeScript errors. + +- [ ] **Step 3: Commit the restored scaffolding as its own change** + +```bash +git add settings.json.template settings.json.docker \ + src/node/db/API.ts src/node/db/GroupManager.ts src/node/db/Pad.ts \ + src/node/utils/Settings.ts src/static/js/types/SocketIOMessage.ts \ + src/node/db/PadDeletionManager.ts +git commit -m "$(cat <<'EOF' +feat(gdpr): scaffolding for pad deletion tokens + +PadDeletionManager stores a sha256-hashed per-pad deletion token and +verifies it with timing-safe comparison. createPad / createGroupPad +return the plaintext token once on first creation, and Pad.remove() +cleans it up. Gated behind the new allowPadDeletionByAllUsers flag +which defaults to false to preserve existing behaviour. + +Part of #6701 (GDPR PR1). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +Expected: clean commit, no pre-commit hook failures. + +--- + +## Task 2: Unit tests for `PadDeletionManager` + +**Files:** +- Create: `src/tests/backend/specs/padDeletionManager.ts` + +- [ ] **Step 1: Write the failing test file** + +```typescript +'use strict'; + +import {strict as assert} from 'assert'; + +const common = require('../common'); +const padDeletionManager = require('../../../node/db/PadDeletionManager'); + +describe(__filename, function () { + before(async function () { await common.init(); }); + + const uniqueId = () => `pdmtest_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + + describe('createDeletionTokenIfAbsent', function () { + it('returns a non-empty string on first call', async function () { + const padId = uniqueId(); + const token = await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(typeof token, 'string'); + assert.ok(token.length >= 32); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('returns null on subsequent calls for the same pad', async function () { + const padId = uniqueId(); + const first = await padDeletionManager.createDeletionTokenIfAbsent(padId); + const second = await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(typeof first, 'string'); + assert.equal(second, null); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('emits different tokens for different pads', async function () { + const a = uniqueId(); + const b = uniqueId(); + const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a); + const tokenB = await padDeletionManager.createDeletionTokenIfAbsent(b); + assert.notEqual(tokenA, tokenB); + await padDeletionManager.removeDeletionToken(a); + await padDeletionManager.removeDeletionToken(b); + }); + }); + + describe('isValidDeletionToken', function () { + it('accepts the token returned by the matching pad', async function () { + const padId = uniqueId(); + const token = await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), true); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('rejects a token for the wrong pad', async function () { + const a = uniqueId(); + const b = uniqueId(); + const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a); + await padDeletionManager.createDeletionTokenIfAbsent(b); + assert.equal(await padDeletionManager.isValidDeletionToken(b, tokenA), false); + await padDeletionManager.removeDeletionToken(a); + await padDeletionManager.removeDeletionToken(b); + }); + + it('rejects a non-string token', async function () { + const padId = uniqueId(); + await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, null), false); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, undefined), false); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, ''), false); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('returns false for pads that never had a token', async function () { + const padId = uniqueId(); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, 'anything'), false); + }); + }); + + describe('removeDeletionToken', function () { + it('invalidates the stored token', async function () { + const padId = uniqueId(); + const token = await padDeletionManager.createDeletionTokenIfAbsent(padId); + await padDeletionManager.removeDeletionToken(padId); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), false); + }); + + it('is safe to call when no token exists', async function () { + const padId = uniqueId(); + await padDeletionManager.removeDeletionToken(padId); // must not throw + }); + }); +}); +``` + +- [ ] **Step 2: Run the test file and confirm it passes** + +Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/padDeletionManager.ts --timeout 10000` +Expected: all 8 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/tests/backend/specs/padDeletionManager.ts +git commit -m "test(gdpr): PadDeletionManager unit tests" +``` + +--- + +## Task 3: Extend `PadDeleteMessage` type and `handlePadDelete` authorisation + +**Files:** +- Modify: `src/static/js/types/SocketIOMessage.ts:198-203` +- Modify: `src/node/handler/PadMessageHandler.ts:230-265` + +- [ ] **Step 1: Add `deletionToken` to `PadDeleteMessage`** + +```typescript +// src/static/js/types/SocketIOMessage.ts +export type PadDeleteMessage = { + type: 'PAD_DELETE' + data: { + padId: string + deletionToken?: string + } +} +``` + +- [ ] **Step 2: Thread the token through `handlePadDelete`** + +Open `src/node/handler/PadMessageHandler.ts`, find `handlePadDelete` (near line 230), and replace its body (keep the outer async function signature) with: + +```typescript +const handlePadDelete = async (socket: any, padDeleteMessage: PadDeleteMessage) => { + const session = sessioninfos[socket.id]; + if (!session || !session.author || !session.padId) throw new Error('session not ready'); + const padId = padDeleteMessage.data.padId; + if (session.padId !== padId) throw new Error('refusing cross-pad delete'); + if (!await padManager.doesPadExist(padId)) return; + + const retrievedPad = await padManager.getPad(padId); + const firstContributor = await retrievedPad.getRevisionAuthor(0); + const isCreator = session.author === firstContributor; + const tokenOk = !isCreator && await padDeletionManager.isValidDeletionToken( + padId, padDeleteMessage.data.deletionToken); + const flagOk = !isCreator && !tokenOk && settings.allowPadDeletionByAllUsers; + + if (isCreator || tokenOk || flagOk) { + await retrievedPad.remove(); + return; + } + + socket.emit('shout', { + type: 'COLLABROOM', + data: { + type: 'shoutMessage', + payload: { + message: { + message: 'You are not the creator of this pad, so you cannot delete it', + sticky: false, + }, + timestamp: Date.now(), + }, + }, + }); +}; +``` + +- [ ] **Step 3: Wire the new imports at the top of `PadMessageHandler.ts`** + +Ensure the file has: + +```typescript +const padDeletionManager = require('../db/PadDeletionManager'); +``` + +(Add it to the import block alongside the existing `padManager` require. If it is already present from earlier scaffolding, skip this step.) + +- [ ] **Step 4: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add src/static/js/types/SocketIOMessage.ts src/node/handler/PadMessageHandler.ts +git commit -m "feat(gdpr): three-way auth for socket PAD_DELETE + +Creator cookie → valid deletion token → allowPadDeletionByAllUsers flag. +Anyone else still gets the existing refusal shout." +``` + +--- + +## Task 4: Programmatic `deletePad(padId, deletionToken?)` and REST coverage + +**Files:** +- Modify: `src/node/db/API.ts:530-545` (the `deletePad` export) + +- [ ] **Step 1: Extend the programmatic `deletePad` signature** + +Replace the existing `exports.deletePad` with: + +```typescript +/** +deletePad(padID, deletionToken?) deletes a pad +... + */ +exports.deletePad = async (padID: string, deletionToken?: string) => { + const pad = await getPadSafe(padID, true); + // apikey-authenticated callers bypass token checks — they're already trusted. + // For anonymous callers that hit this code path (e.g. a future public endpoint), + // require a valid token unless the instance has opted everyone in. + if (deletionToken !== undefined && + !settings.allowPadDeletionByAllUsers && + !await padDeletionManager.isValidDeletionToken(padID, deletionToken)) { + throw new CustomError('invalid deletionToken', 'apierror'); + } + await pad.remove(); +}; +``` + +- [ ] **Step 2: Add the `CustomError` and `settings` imports if missing** + +At the top of `src/node/db/API.ts`, confirm the file has: + +```typescript +const CustomError = require('../utils/customError'); +import settings from '../utils/Settings'; +``` + +(Both already exist in etherpad; add only if absent.) + +- [ ] **Step 3: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/node/db/API.ts +git commit -m "feat(gdpr): optional deletionToken on programmatic deletePad" +``` + +--- + +## Task 5: Advertise `deletionToken` in the REST OpenAPI schema + +**Files:** +- Modify: `src/node/handler/APIHandler.ts` — add `deletionToken` to the `deletePad` arg list + +- [ ] **Step 1: Extend the API version-map entry for `deletePad`** + +Open `src/node/handler/APIHandler.ts` and locate the existing `deletePad: ['padID']` entry (around line 56). Change it to: + +```typescript +deletePad: ['padID', 'deletionToken'], +``` + +If the codebase uses a per-version map (older vs. newer), make the same change in every version entry that currently lists `deletePad`. + +- [ ] **Step 2: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/node/handler/APIHandler.ts +git commit -m "feat(gdpr): advertise optional deletionToken on REST deletePad" +``` + +--- + +## Task 6: REST API test for the authorisation matrix + +**Files:** +- Create: `src/tests/backend/specs/api/deletePad.ts` + +- [ ] **Step 1: Write the test spec** + +```typescript +'use strict'; + +import {strict as assert} from 'assert'; + +const common = require('../../common'); +import settings from '../../../node/utils/Settings'; + +let agent: any; +let apiKey: string; + +const makeId = () => `gdprdel_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +const apiCall = async (point: string, query: Record) => { + const params = new URLSearchParams({apikey: apiKey, ...query}).toString(); + return await agent.get(`/api/1/${point}?${params}`); +}; + +describe(__filename, function () { + before(async function () { + agent = await common.init(); + apiKey = common.apiKey; + }); + + afterEach(function () { settings.allowPadDeletionByAllUsers = false; }); + + it('createPad returns a plaintext deletionToken the first time', async function () { + const padId = makeId(); + const res = await apiCall('createPad', {padID: padId}); + assert.equal(res.body.code, 0); + assert.equal(typeof res.body.data.deletionToken, 'string'); + assert.ok(res.body.data.deletionToken.length >= 32); + await apiCall('deletePad', {padID: padId, deletionToken: res.body.data.deletionToken}); + }); + + it('deletePad with a valid deletionToken succeeds', async function () { + const padId = makeId(); + const create = await apiCall('createPad', {padID: padId}); + const token = create.body.data.deletionToken; + const del = await apiCall('deletePad', {padID: padId, deletionToken: token}); + assert.equal(del.body.code, 0, JSON.stringify(del.body)); + const check = await apiCall('getText', {padID: padId}); + assert.equal(check.body.code, 1); // "padID does not exist" + }); + + it('deletePad with a wrong deletionToken is refused', async function () { + const padId = makeId(); + await apiCall('createPad', {padID: padId}); + const del = await apiCall('deletePad', {padID: padId, deletionToken: 'not-the-real-token'}); + assert.equal(del.body.code, 1); + assert.match(del.body.message, /invalid deletionToken/); + // cleanup — apikey-authenticated caller is trusted when no token is supplied + await apiCall('deletePad', {padID: padId}); + }); + + it('deletePad with allowPadDeletionByAllUsers=true bypasses the token check', async function () { + const padId = makeId(); + await apiCall('createPad', {padID: padId}); + settings.allowPadDeletionByAllUsers = true; + const del = await apiCall('deletePad', {padID: padId, deletionToken: 'bogus'}); + assert.equal(del.body.code, 0); + }); + + it('apikey-only call (no deletionToken) still works — admins stay trusted', async function () { + const padId = makeId(); + await apiCall('createPad', {padID: padId}); + const del = await apiCall('deletePad', {padID: padId}); + assert.equal(del.body.code, 0); + }); +}); +``` + +- [ ] **Step 2: Run the new spec** + +Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/api/deletePad.ts --timeout 20000` +Expected: all 5 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/tests/backend/specs/api/deletePad.ts +git commit -m "test(gdpr): cover deletePad authorisation matrix via REST" +``` + +--- + +## Task 7: Send `padDeletionToken` to the creator session via `clientVars` + +**Files:** +- Modify: `src/node/handler/PadMessageHandler.ts` — in the CLIENT_READY handler where `clientVars` is assembled (around line 1008) + +- [ ] **Step 1: Compute the token in the same block that decides creator-only UI** + +Locate the `const canEditPadSettings = ...` computation introduced by PR #7545 (or its nearest equivalent — the creator-cookie check using `isPadCreator`). Immediately after it, add: + +```typescript +const padDeletionToken = !sessionInfo.readonly && canEditPadSettings + ? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId) + : null; +``` + +Then include the field in the `clientVars` literal (right after `canEditPadSettings`): + +```typescript + padDeletionToken, +``` + +(If PR #7545 has not merged yet on this branch, replace `canEditPadSettings` in the conditional with the equivalent inline expression: +`!sessionInfo.readonly && await isPadCreator(pad, sessionInfo.author)`.) + +- [ ] **Step 2: Confirm the `ClientVarPayload` type already has `padDeletionToken`** + +`src/static/js/types/SocketIOMessage.ts` should still contain: + +```typescript + padDeletionToken?: string | null, +``` + +(added by the restored scaffolding). If it was stripped during earlier cleanup, add it back. + +- [ ] **Step 3: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/node/handler/PadMessageHandler.ts src/static/js/types/SocketIOMessage.ts +git commit -m "feat(gdpr): surface padDeletionToken in clientVars for creators only" +``` + +--- + +## Task 8: Locale strings + +**Files:** +- Modify: `src/locales/en.json` + +- [ ] **Step 1: Add the new keys** + +Insert the following inside the `pad.*` block (next to `pad.delete.confirm`): + +```json + "pad.deletionToken.modalTitle": "Save your pad deletion token", + "pad.deletionToken.modalBody": "This token is the only way to delete this pad if you lose your browser session or switch device. Save it somewhere safe — it is shown here exactly once.", + "pad.deletionToken.copy": "Copy", + "pad.deletionToken.copied": "Copied", + "pad.deletionToken.acknowledge": "I've saved it", + "pad.deletionToken.deleteWithToken": "Delete with token", + "pad.deletionToken.tokenFieldLabel": "Pad deletion token", + "pad.deletionToken.invalid": "That token is not valid for this pad.", +``` + +Leave every other locale file untouched — English is the canonical source; translators fill in the rest. + +- [ ] **Step 2: Type check (picks up JSON parse errors via test-runner bootstrap)** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/locales/en.json +git commit -m "i18n(gdpr): strings for deletion-token modal and delete-with-token flow" +``` + +--- + +## Task 9: Template — one-time token modal + delete-by-token disclosure + +**Files:** +- Modify: `src/templates/pad.html` + +- [ ] **Step 1: Add the deletion-token modal, sibling to the existing `#settings` popup** + +Find the `` block. Immediately after its closing wrapper, add: + +```html + +``` + +- [ ] **Step 2: Add the delete-by-token disclosure under the existing Delete button** + +Find `` in the settings popup. Replace the single button with: + +```html + +
+ Delete with token + + + +
+``` + +- [ ] **Step 3: Commit** + +```bash +git add src/templates/pad.html +git commit -m "feat(gdpr): token modal + delete-with-token disclosure markup" +``` + +--- + +## Task 10: Client JS — modal reveal and delete-by-token wiring + +**Files:** +- Modify: `src/static/js/pad.ts` — surface the modal, scrub token from `clientVars` +- Modify: `src/static/js/pad_editor.ts` — delete-by-token submit + +- [ ] **Step 1: Surface the modal and scrub the token after acknowledgement** + +In `src/static/js/pad.ts`, locate the `init` / `handleInit` phase — immediately after `clientVars` has been applied and the pad is usable. Add the following helper and an invocation: + +```typescript +const showDeletionTokenModalIfPresent = () => { + const token = clientVars.padDeletionToken; + if (!token) return; + const $modal = $('#deletiontoken-modal'); + const $input = $('#deletiontoken-value'); + const $copy = $('#deletiontoken-copy'); + const $ack = $('#deletiontoken-ack'); + if ($modal.length === 0) return; + + $input.val(token); + $modal.prop('hidden', false).addClass('popup-show'); + + $copy.off('click.gdpr').on('click.gdpr', async () => { + try { + await navigator.clipboard.writeText(token); + $copy.text(html10n.get('pad.deletionToken.copied')); + } catch (e) { + ($input[0] as HTMLInputElement).select(); + document.execCommand('copy'); + $copy.text(html10n.get('pad.deletionToken.copied')); + } + }); + + $ack.off('click.gdpr').on('click.gdpr', () => { + $input.val(''); + $modal.prop('hidden', true).removeClass('popup-show'); + (clientVars as any).padDeletionToken = null; + }); +}; +``` + +Call `showDeletionTokenModalIfPresent()` once, after the user-visible pad has finished loading (a good spot is immediately after the existing `padeditor.init(...)` or `padimpexp.init(...)` call). + +- [ ] **Step 2: Wire the delete-by-token UI** + +In `src/static/js/pad_editor.ts`, find the existing `$('#delete-pad').on('click', ...)` handler (around line 90) and, directly after it, add: + +```typescript + // delete pad using a recovery token + $('#delete-pad-token-submit').on('click', () => { + const token = String($('#delete-pad-token-input').val() || '').trim(); + if (!token) return; + if (!window.confirm(html10n.get('pad.delete.confirm'))) return; + + let handled = false; + pad.socket.on('message', (data: any) => { + if (data && data.disconnect === 'deleted') { + handled = true; + window.location.href = '/'; + } + }); + pad.socket.on('shout', (data: any) => { + handled = true; + const msg = data?.data?.payload?.message?.message; + if (msg) window.alert(msg); + }); + pad.collabClient.sendMessage({ + type: 'PAD_DELETE', + data: {padId: pad.getPadId(), deletionToken: token}, + }); + setTimeout(() => { + if (!handled) window.location.href = '/'; + }, 5000); + }); +``` + +- [ ] **Step 3: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/static/js/pad.ts src/static/js/pad_editor.ts +git commit -m "feat(gdpr): show deletion token once, allow delete via recovery token" +``` + +--- + +## Task 11: Minimal styling for the modal + disclosure + +**Files:** +- Modify: `src/static/css/pad.css` (or the skin CSS file that already styles `.popup`) + +- [ ] **Step 1: Add scoped styles** + +Append: + +```css +#deletiontoken-modal .deletiontoken-row { + display: flex; + gap: 0.5rem; + margin: 1rem 0; +} + +#deletiontoken-modal #deletiontoken-value { + flex: 1; + font-family: monospace; + padding: 0.4rem; + user-select: all; +} + +#delete-pad-with-token { + margin-top: 0.5rem; +} + +#delete-pad-with-token summary { + cursor: pointer; + color: var(--text-muted, #666); + font-size: 0.9rem; +} + +#delete-pad-with-token input { + margin: 0.5rem 0; + width: 100%; + font-family: monospace; +} +``` + +Use whichever file the existing `#settings.popup` and `#delete-pad` styles live in (check via `grep -rn "#delete-pad" src/static/css src/static/skins` and pick the one already loaded by `pad.html`). + +- [ ] **Step 2: Commit** + +```bash +git add src/static/css/pad.css # or the skin file you actually touched +git commit -m "style(gdpr): modal + delete-with-token layout" +``` + +--- + +## Task 12: Frontend Playwright coverage + +**Files:** +- Create: `src/tests/frontend-new/specs/pad_deletion_token.spec.ts` + +- [ ] **Step 1: Write the Playwright spec** + +```typescript +import {expect, test} from '@playwright/test'; +import {goToNewPad, goToPad} from '../helper/padHelper'; +import {showSettings} from '../helper/settingsHelper'; + +test.describe('pad deletion token', () => { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('creator sees a token modal exactly once and can dismiss it', async ({page}) => { + await goToNewPad(page); + const modal = page.locator('#deletiontoken-modal'); + await expect(modal).toBeVisible(); + + const tokenValue = await page.locator('#deletiontoken-value').inputValue(); + expect(tokenValue.length).toBeGreaterThanOrEqual(32); + + await page.locator('#deletiontoken-ack').click(); + await expect(modal).toBeHidden(); + + const cleared = await page.evaluate( + () => (window as any).clientVars.padDeletionToken); + expect(cleared == null).toBe(true); + }); + + test('second device can delete using the captured token', async ({page, browser}) => { + const padId = await goToNewPad(page); + const token = await page.locator('#deletiontoken-value').inputValue(); + await page.locator('#deletiontoken-ack').click(); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await goToPad(page2, padId); + await showSettings(page2); + + await page2.locator('#delete-pad-with-token > summary').click(); + await page2.locator('#delete-pad-token-input').fill(token); + page2.once('dialog', (d) => d.accept()); + await page2.locator('#delete-pad-token-submit').click(); + + await expect(page2).toHaveURL(/\/$|\/index\.html$/, {timeout: 10000}); + + // The pad should be gone — opening it again yields a fresh empty pad. + await goToPad(page2, padId); + const contents = await page2.frameLocator('iframe[name="ace_outer"]') + .frameLocator('iframe[name="ace_inner"]').locator('#innerdocbody').textContent(); + expect((contents || '').trim().length).toBeLessThan(200); // default welcome text only + + await context2.close(); + }); + + test('wrong token keeps the pad alive and surfaces a shout', async ({page, browser}) => { + const padId = await goToNewPad(page); + await page.locator('#deletiontoken-ack').click(); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await goToPad(page2, padId); + await showSettings(page2); + + await page2.locator('#delete-pad-with-token > summary').click(); + await page2.locator('#delete-pad-token-input').fill('bogus-token-value'); + page2.once('dialog', (d) => d.accept()); + const alertPromise = page2.waitForEvent('dialog'); + await page2.locator('#delete-pad-token-submit').click(); + const alert = await alertPromise; + expect(alert.message()).toMatch(/not the creator|cannot delete/); + await alert.dismiss(); + + // Pad must still exist for the original creator. + await page.reload(); + await expect(page.locator('#editorcontainer.initialized')).toBeVisible(); + await context2.close(); + }); +}); +``` + +- [ ] **Step 2: Restart the test server so it picks up the current branch's code** + +```bash +lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | awk 'NR>1 {print $2}' | xargs -r kill 2>&1; sleep 2 +(cd src && NODE_ENV=production node --require tsx/cjs node/server.ts -- \ + --settings tests/settings.json > /tmp/etherpad-test.log 2>&1 &) +sleep 8 +lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | tail -2 +``` + +Expected: port 9001 is listening. + +- [ ] **Step 3: Run the new Playwright spec** + +```bash +cd src && NODE_ENV=production npx playwright test pad_deletion_token --project=chromium +``` + +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/tests/frontend-new/specs/pad_deletion_token.spec.ts +git commit -m "test(gdpr): Playwright coverage for deletion-token modal + delete-with-token" +``` + +--- + +## Task 13: End-to-end verification, push, open PR + +**Files:** (no edits) + +- [ ] **Step 1: Full type-check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 2: Backend tests for just this feature** + +```bash +pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs \ + tests/backend/specs/padDeletionManager.ts \ + tests/backend/specs/api/deletePad.ts --timeout 20000 +``` + +Expected: 13 tests pass. + +- [ ] **Step 3: Full Playwright smoke for the touched specs** + +```bash +cd src && NODE_ENV=production npx playwright test \ + pad_deletion_token pad_settings --project=chromium +``` + +Expected: all tests pass. (pad_settings included because Task 7 changes the `clientVars` assembly near its creator-only code.) + +- [ ] **Step 4: Push and open the PR** + +```bash +git push origin feat-gdpr-pad-deletion +gh pr create --title "feat(gdpr): pad deletion controls (PR1 of #6701)" --body "$(cat <<'EOF' +## Summary +- One-time sha256-hashed deletion token, surfaced plaintext once on create +- allowPadDeletionByAllUsers flag (defaults to false) to widen deletion rights +- Three-way auth on socket PAD_DELETE and REST deletePad: creator cookie, valid token, or settings flag +- Browser creators see a one-time token modal and can later delete via a recovery-token field in the pad settings popup + +First of the five GDPR PRs outlined in #6701. Remaining scope (IP audit, identity hardening, cookie banner, author erasure) stays in follow-ups. + +## Test plan +- [ ] ts-check clean +- [ ] Backend: padDeletionManager + api/deletePad specs +- [ ] Frontend: pad_deletion_token.spec.ts and pad_settings.spec.ts regression +EOF +)" +``` + +Expected: PR opens, CI runs. + +- [ ] **Step 5: Monitor CI** + +Run: `sleep 25 && gh pr checks ` +Expected: all checks green (or failure triage kicks in, per the feedback_check_ci_after_pr memory). + +--- + +## Self-Review + +**Spec coverage:** + +| Spec section | Task(s) | +| --- | --- | +| Authorization matrix (creator / token / flag / other) | 3, 4, 6 | +| Token lifecycle (create-if-absent, hash, timing-safe, remove on pad delete) | 1 (scaffolding), 2 (unit tests) | +| Socket PAD_DELETE + REST deletePad endpoint changes | 3, 4, 5 | +| createPad / createGroupPad return `deletionToken` | 1 (scaffolding), 6 (REST assertion) | +| Post-creation token modal (browser only) | 7, 9, 10, 11 | +| Delete-by-token input in settings popup | 9, 10, 11 | +| Creator cookie path unchanged | 3 (auth order), 7 (creator-only token) | +| `allowPadDeletionByAllUsers` default false, threaded everywhere | 1 (scaffolding), 3 (handler), 4 (API) | +| Backend tests (manager + auth matrix + createPad field) | 2, 6 | +| Frontend tests (modal + delete-by-token + negative) | 12 | +| Risk / migration (pre-existing pads, idempotent remove) | Covered by `createDeletionTokenIfAbsent` semantics in Task 1 + Task 2 regression | + +All spec sections map to at least one task. + +**Placeholders:** none — every code block is complete, every command has expected output. + +**Type consistency:** +- `createDeletionTokenIfAbsent(padId)` — consistent across Tasks 1, 2, 7. +- `isValidDeletionToken(padId, token)` — consistent across Tasks 2, 3, 4. +- `removeDeletionToken(padId)` — consistent across Tasks 1, 2. +- `PadDeleteMessage.data.deletionToken?` — Task 3 definition matches Task 10 consumer and Task 12 test usage. +- `clientVars.padDeletionToken` — Task 7 writer, Task 10 reader, Task 12 test assertion all agree on the name and null-semantics. +- `allowPadDeletionByAllUsers` — Task 1 scaffolding, Task 3 handler, Task 4 API, Task 6 REST test all use the same flag. From ff8d3c3ab6f783b564b64ae527e0b92da6d466a1 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:20:03 +0100 Subject: [PATCH 03/18] feat(gdpr): scaffolding for pad deletion tokens PadDeletionManager stores a sha256-hashed per-pad deletion token and verifies it with timing-safe comparison. createPad / createGroupPad return the plaintext token once on first creation, and Pad.remove() cleans it up. Gated behind the new allowPadDeletionByAllUsers flag which defaults to false to preserve existing behaviour. Part of #6701 (GDPR PR1). Co-Authored-By: Claude Opus 4.7 (1M context) --- settings.json.docker | 7 ++++++ settings.json.template | 7 ++++++ src/node/db/API.ts | 2 ++ src/node/db/GroupManager.ts | 13 +++++++++-- src/node/db/Pad.ts | 2 ++ src/node/db/PadDeletionManager.ts | 32 ++++++++++++++++++++++++++ src/node/utils/Settings.ts | 2 ++ src/static/js/types/SocketIOMessage.ts | 2 ++ 8 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 src/node/db/PadDeletionManager.ts diff --git a/settings.json.docker b/settings.json.docker index 8fdd51de01e..c246621412d 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -484,6 +484,13 @@ */ "disableIPlogging": "${DISABLE_IP_LOGGING:false}", + /* + * Allow any user who can edit a pad to delete it without the one-time pad + * deletion token. If false, only the original creator's author cookie or the + * deletion token can delete the pad. + */ + "allowPadDeletionByAllUsers": "${ALLOW_PAD_DELETION_BY_ALL_USERS:false}", + /* * Time (in seconds) to automatically reconnect pad when a "Force reconnect" * message is shown to user. diff --git a/settings.json.template b/settings.json.template index 0d1493c2b40..dbb9bba17be 100644 --- a/settings.json.template +++ b/settings.json.template @@ -475,6 +475,13 @@ */ "disableIPlogging": false, + /* + * Allow any user who can edit a pad to delete it without the one-time pad + * deletion token. If false, only the original creator's author cookie or the + * deletion token can delete the pad. + */ + "allowPadDeletionByAllUsers": false, + /* * Time (in seconds) to automatically reconnect pad when a "Force reconnect" * message is shown to user. diff --git a/src/node/db/API.ts b/src/node/db/API.ts index 9ca5ca03c4b..4640249c957 100644 --- a/src/node/db/API.ts +++ b/src/node/db/API.ts @@ -30,6 +30,7 @@ import readOnlyManager from './ReadOnlyManager'; const groupManager = require('./GroupManager'); const authorManager = require('./AuthorManager'); const sessionManager = require('./SessionManager'); +const padDeletionManager = require('./PadDeletionManager'); const exportHtml = require('../utils/ExportHtml'); const exportTxt = require('../utils/ExportTxt'); const importHtml = require('../utils/ImportHtml'); @@ -518,6 +519,7 @@ exports.createPad = async (padID: string, text: string, authorId = '') => { // create pad await getPadSafe(padID, false, text, authorId); + return {deletionToken: await padDeletionManager.createDeletionTokenIfAbsent(padID)}; }; /** diff --git a/src/node/db/GroupManager.ts b/src/node/db/GroupManager.ts index af48cdd2b2b..fa59130154b 100644 --- a/src/node/db/GroupManager.ts +++ b/src/node/db/GroupManager.ts @@ -22,6 +22,7 @@ const CustomError = require('../utils/customError'); import {randomString} from "../../static/js/pad_utils"; const db = require('./DB'); +const padDeletionManager = require('./PadDeletionManager'); const padManager = require('./PadManager'); const sessionManager = require('./SessionManager'); @@ -136,7 +137,12 @@ exports.createGroupIfNotExistsFor = async (groupMapper: string|object) => { * @param {String} authorId The id of the author * @return {Promise<{padID: string}>} a promise that resolves to the id of the new pad */ -exports.createGroupPad = async (groupID: string, padName: string, text: string, authorId: string = ''): Promise<{ padID: string; }> => { +exports.createGroupPad = async ( + groupID: string, + padName: string, + text: string, + authorId: string = '', +): Promise<{ padID: string; deletionToken: string | null; }> => { // create the padID const padID = `${groupID}$${padName}`; @@ -161,7 +167,10 @@ exports.createGroupPad = async (groupID: string, padName: string, text: string, // create an entry in the group for this pad await db.setSub(`group:${groupID}`, ['pads', padID], 1); - return {padID}; + return { + padID, + deletionToken: await padDeletionManager.createDeletionTokenIfAbsent(padID), + }; }; /** diff --git a/src/node/db/Pad.ts b/src/node/db/Pad.ts index 7f400623336..6d20c3505b2 100644 --- a/src/node/db/Pad.ts +++ b/src/node/db/Pad.ts @@ -16,6 +16,7 @@ const assert = require('assert').strict; const db = require('./DB'); import settings from '../utils/Settings'; const authorManager = require('./AuthorManager'); +const padDeletionManager = require('./PadDeletionManager'); const padManager = require('./PadManager'); const padMessageHandler = require('../handler/PadMessageHandler'); const groupManager = require('./GroupManager'); @@ -661,6 +662,7 @@ class Pad { // delete the pad entry and delete pad from padManager p.push(padManager.removePad(padID)); + p.push(padDeletionManager.removeDeletionToken(padID)); p.push(hooks.aCallAll('padRemove', { get padID() { pad_utils.warnDeprecated('padRemove padID context property is deprecated; use pad.id instead'); diff --git a/src/node/db/PadDeletionManager.ts b/src/node/db/PadDeletionManager.ts new file mode 100644 index 00000000000..116d84fdfef --- /dev/null +++ b/src/node/db/PadDeletionManager.ts @@ -0,0 +1,32 @@ +'use strict'; + +import crypto from 'node:crypto'; +import randomString from '../utils/randomstring'; + +const db = require('./DB').db; + +const getDeletionTokenKey = (padId: string) => `pad:${padId}:deletionToken`; + +const hashDeletionToken = (deletionToken: string) => + crypto.createHash('sha256').update(deletionToken, 'utf8').digest(); + +exports.createDeletionTokenIfAbsent = async (padId: string): Promise => { + if (await db.get(getDeletionTokenKey(padId)) != null) return null; + const deletionToken = randomString(32); + await db.set(getDeletionTokenKey(padId), { + createdAt: Date.now(), + hash: hashDeletionToken(deletionToken).toString('hex'), + }); + return deletionToken; +}; + +exports.isValidDeletionToken = async (padId: string, deletionToken: string | null | undefined) => { + if (typeof deletionToken !== 'string' || deletionToken === '') return false; + const storedToken = await db.get(getDeletionTokenKey(padId)); + if (storedToken == null || typeof storedToken.hash !== 'string') return false; + const expected = Buffer.from(storedToken.hash, 'hex'); + const actual = hashDeletionToken(deletionToken); + return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); +}; + +exports.removeDeletionToken = async (padId: string) => await db.remove(getDeletionTokenKey(padId)); diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 0b250e494c3..e76836358e4 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -173,6 +173,7 @@ export type SettingsType = { updateServer: string, enableDarkMode: boolean, enablePadWideSettings: boolean, + allowPadDeletionByAllUsers: boolean, skinName: string | null, skinVariants: string, ip: string, @@ -330,6 +331,7 @@ const settings: SettingsType = { updateServer: "https://static.etherpad.org", enableDarkMode: true, enablePadWideSettings: false, + allowPadDeletionByAllUsers: false, /* * Skin name. * diff --git a/src/static/js/types/SocketIOMessage.ts b/src/static/js/types/SocketIOMessage.ts index 08be6a03ee5..ac665188256 100644 --- a/src/static/js/types/SocketIOMessage.ts +++ b/src/static/js/types/SocketIOMessage.ts @@ -89,6 +89,8 @@ export type ClientVarPayload = { initialTitle: string, opts: {} numConnectedUsers: number + canDeletePad?: boolean, + padDeletionToken?: string | null, sofficeAvailable: string plugins: { plugins: MapArrayType From 8385b26384ff64806835091f92181967a47fafe0 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:21:14 +0100 Subject: [PATCH 04/18] fix+test(gdpr): lazy DB access in PadDeletionManager + unit tests Capturing DB.db at module-load time was null until DB.init() ran, which broke importing the module outside a live server (including from the test runner). Switch to DB.db.* at call time and add unit tests exercising create/verify/remove plus timing-safe comparison. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/node/db/PadDeletionManager.ts | 11 +-- src/tests/backend/specs/padDeletionManager.ts | 88 +++++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 src/tests/backend/specs/padDeletionManager.ts diff --git a/src/node/db/PadDeletionManager.ts b/src/node/db/PadDeletionManager.ts index 116d84fdfef..a6df3f54956 100644 --- a/src/node/db/PadDeletionManager.ts +++ b/src/node/db/PadDeletionManager.ts @@ -3,7 +3,7 @@ import crypto from 'node:crypto'; import randomString from '../utils/randomstring'; -const db = require('./DB').db; +const DB = require('./DB'); const getDeletionTokenKey = (padId: string) => `pad:${padId}:deletionToken`; @@ -11,9 +11,9 @@ const hashDeletionToken = (deletionToken: string) => crypto.createHash('sha256').update(deletionToken, 'utf8').digest(); exports.createDeletionTokenIfAbsent = async (padId: string): Promise => { - if (await db.get(getDeletionTokenKey(padId)) != null) return null; + if (await DB.db.get(getDeletionTokenKey(padId)) != null) return null; const deletionToken = randomString(32); - await db.set(getDeletionTokenKey(padId), { + await DB.db.set(getDeletionTokenKey(padId), { createdAt: Date.now(), hash: hashDeletionToken(deletionToken).toString('hex'), }); @@ -22,11 +22,12 @@ exports.createDeletionTokenIfAbsent = async (padId: string): Promise { if (typeof deletionToken !== 'string' || deletionToken === '') return false; - const storedToken = await db.get(getDeletionTokenKey(padId)); + const storedToken = await DB.db.get(getDeletionTokenKey(padId)); if (storedToken == null || typeof storedToken.hash !== 'string') return false; const expected = Buffer.from(storedToken.hash, 'hex'); const actual = hashDeletionToken(deletionToken); return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); }; -exports.removeDeletionToken = async (padId: string) => await db.remove(getDeletionTokenKey(padId)); +exports.removeDeletionToken = async (padId: string) => + await DB.db.remove(getDeletionTokenKey(padId)); diff --git a/src/tests/backend/specs/padDeletionManager.ts b/src/tests/backend/specs/padDeletionManager.ts new file mode 100644 index 00000000000..f5b3932fab4 --- /dev/null +++ b/src/tests/backend/specs/padDeletionManager.ts @@ -0,0 +1,88 @@ +'use strict'; + +import {strict as assert} from 'assert'; + +const common = require('../common'); +const padDeletionManager = require('../../../node/db/PadDeletionManager'); + +describe(__filename, function () { + before(async function () { await common.init(); }); + + const uniqueId = () => `pdmtest_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + + describe('createDeletionTokenIfAbsent', function () { + it('returns a non-empty string on first call', async function () { + const padId = uniqueId(); + const token = await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(typeof token, 'string'); + assert.ok(token.length >= 32); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('returns null on subsequent calls for the same pad', async function () { + const padId = uniqueId(); + const first = await padDeletionManager.createDeletionTokenIfAbsent(padId); + const second = await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(typeof first, 'string'); + assert.equal(second, null); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('emits different tokens for different pads', async function () { + const a = uniqueId(); + const b = uniqueId(); + const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a); + const tokenB = await padDeletionManager.createDeletionTokenIfAbsent(b); + assert.notEqual(tokenA, tokenB); + await padDeletionManager.removeDeletionToken(a); + await padDeletionManager.removeDeletionToken(b); + }); + }); + + describe('isValidDeletionToken', function () { + it('accepts the token returned by the matching pad', async function () { + const padId = uniqueId(); + const token = await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), true); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('rejects a token for the wrong pad', async function () { + const a = uniqueId(); + const b = uniqueId(); + const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a); + await padDeletionManager.createDeletionTokenIfAbsent(b); + assert.equal(await padDeletionManager.isValidDeletionToken(b, tokenA), false); + await padDeletionManager.removeDeletionToken(a); + await padDeletionManager.removeDeletionToken(b); + }); + + it('rejects a non-string token', async function () { + const padId = uniqueId(); + await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, null), false); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, undefined), false); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, ''), false); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('returns false for pads that never had a token', async function () { + const padId = uniqueId(); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, 'anything'), false); + }); + }); + + describe('removeDeletionToken', function () { + it('invalidates the stored token', async function () { + const padId = uniqueId(); + const token = await padDeletionManager.createDeletionTokenIfAbsent(padId); + await padDeletionManager.removeDeletionToken(padId); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), false); + }); + + it('is safe to call when no token exists', async function () { + const padId = uniqueId(); + await padDeletionManager.removeDeletionToken(padId); // must not throw + }); + }); +}); From 2890258d00d6d01665197b9882e781ff52e6685e Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:22:03 +0100 Subject: [PATCH 05/18] feat(gdpr): three-way auth for socket PAD_DELETE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creator cookie → valid deletion token → allowPadDeletionByAllUsers flag. Anyone else still gets the existing refusal shout. --- src/node/handler/PadMessageHandler.ts | 62 +++++++++++++------------- src/static/js/types/SocketIOMessage.ts | 1 + 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 8285a3a8a52..f63a50c90b4 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -23,6 +23,7 @@ import {MapArrayType} from "../types/MapType"; import AttributeMap from '../../static/js/AttributeMap'; const padManager = require('../db/PadManager'); +const padDeletionManager = require('../db/PadDeletionManager'); import {checkRep, cloneAText, compose, deserializeOps, follow, identity, inverse, makeAText, makeSplice, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, oldLen, prepareForWire, splitAttributionLines, splitTextLines, unpack} from '../../static/js/Changeset'; import ChatMessage from '../../static/js/ChatMessage'; import AttributePool from '../../static/js/AttributePool'; @@ -229,39 +230,36 @@ exports.handleDisconnect = async (socket:any) => { const handlePadDelete = async (socket: any, padDeleteMessage: PadDeleteMessage) => { const session = sessioninfos[socket.id]; if (!session || !session.author || !session.padId) throw new Error('session not ready'); - if (await padManager.doesPadExist(padDeleteMessage.data.padId)) { - const retrievedPad = await padManager.getPad(padDeleteMessage.data.padId) - // Only the one doing the first revision can delete the pad, otherwise people could troll a lot - const firstContributor = await retrievedPad.getRevisionAuthor(0) - if (session.author === firstContributor) { - await retrievedPad.remove() - } else { - - type ShoutMessage = { - message: string, - sticky: boolean, - } - - const messageToShout: ShoutMessage = { - message: 'You are not the creator of this pad, so you cannot delete it', - sticky: false - } - const messageToSend = { - type: "COLLABROOM", - data: { - type: "shoutMessage", - payload: { - message: messageToShout, - timestamp: Date.now() - } - } - } - socket.emit('shout', - messageToSend - ) - } + const padId = padDeleteMessage.data.padId; + if (session.padId !== padId) throw new Error('refusing cross-pad delete'); + if (!await padManager.doesPadExist(padId)) return; + + const retrievedPad = await padManager.getPad(padId); + const firstContributor = await retrievedPad.getRevisionAuthor(0); + const isCreator = session.author === firstContributor; + const tokenOk = !isCreator && await padDeletionManager.isValidDeletionToken( + padId, padDeleteMessage.data.deletionToken); + const flagOk = !isCreator && !tokenOk && settings.allowPadDeletionByAllUsers; + + if (isCreator || tokenOk || flagOk) { + await retrievedPad.remove(); + return; } -} + + socket.emit('shout', { + type: 'COLLABROOM', + data: { + type: 'shoutMessage', + payload: { + message: { + message: 'You are not the creator of this pad, so you cannot delete it', + sticky: false, + }, + timestamp: Date.now(), + }, + }, + }); +}; const isPadCreator = async (pad: any, authorId: string) => authorId === await pad.getRevisionAuthor(0); diff --git a/src/static/js/types/SocketIOMessage.ts b/src/static/js/types/SocketIOMessage.ts index ac665188256..1d30d7d76b9 100644 --- a/src/static/js/types/SocketIOMessage.ts +++ b/src/static/js/types/SocketIOMessage.ts @@ -202,6 +202,7 @@ export type PadDeleteMessage = { type: 'PAD_DELETE' data: { padId: string + deletionToken?: string } } From d4e181c02429c1da644a857376289502f5e40aa0 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:22:47 +0100 Subject: [PATCH 06/18] feat(gdpr): optional deletionToken on programmatic deletePad --- src/node/db/API.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/node/db/API.ts b/src/node/db/API.ts index 4640249c957..b88a708fb58 100644 --- a/src/node/db/API.ts +++ b/src/node/db/API.ts @@ -23,6 +23,7 @@ import {deserializeOps} from '../../static/js/Changeset'; import ChatMessage from '../../static/js/ChatMessage'; import {Builder} from "../../static/js/Builder"; import {Attribute} from "../../static/js/types/Attribute"; +import settings from '../utils/Settings'; const CustomError = require('../utils/customError'); const padManager = require('./PadManager'); const padMessageHandler = require('../handler/PadMessageHandler'); @@ -523,16 +524,26 @@ exports.createPad = async (padID: string, text: string, authorId = '') => { }; /** -deletePad(padID) deletes a pad +deletePad(padID, [deletionToken]) deletes a pad Example returns: {code: 0, message:"ok", data: null} {code: 1, message:"padID does not exist", data: null} +{code: 1, message:"invalid deletionToken", data: null} @param {String} padID the id of the pad + @param {String} [deletionToken] recovery token issued by createPad */ -exports.deletePad = async (padID: string) => { +exports.deletePad = async (padID: string, deletionToken?: string) => { const pad = await getPadSafe(padID, true); + // apikey-authenticated callers (no deletionToken supplied) are trusted. + // When a caller supplies a deletionToken, it must validate unless the + // instance has opted everyone in via allowPadDeletionByAllUsers. + if (deletionToken !== undefined && deletionToken !== '' && + !settings.allowPadDeletionByAllUsers && + !await padDeletionManager.isValidDeletionToken(padID, deletionToken)) { + throw new CustomError('invalid deletionToken', 'apierror'); + } await pad.remove(); }; From 1c42d70059ce30068829c31b6b595b38e0567b13 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:23:16 +0100 Subject: [PATCH 07/18] feat(gdpr): advertise optional deletionToken on REST deletePad --- src/node/handler/APIHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/handler/APIHandler.ts b/src/node/handler/APIHandler.ts index 32ce9d1189a..b1e111c471b 100644 --- a/src/node/handler/APIHandler.ts +++ b/src/node/handler/APIHandler.ts @@ -53,7 +53,7 @@ version['1'] = { setHTML: ['padID', 'html'], getRevisionsCount: ['padID'], getLastEdited: ['padID'], - deletePad: ['padID'], + deletePad: ['padID', 'deletionToken'], getReadOnlyID: ['padID'], setPublicStatus: ['padID', 'publicStatus'], getPublicStatus: ['padID'], From 9ae821113bb654b514e099eeac51612a9d7c087b Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:24:39 +0100 Subject: [PATCH 08/18] test(gdpr): cover deletePad authorisation matrix via REST --- src/tests/backend/specs/api/deletePad.ts | 77 ++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/tests/backend/specs/api/deletePad.ts diff --git a/src/tests/backend/specs/api/deletePad.ts b/src/tests/backend/specs/api/deletePad.ts new file mode 100644 index 00000000000..4741e8daeac --- /dev/null +++ b/src/tests/backend/specs/api/deletePad.ts @@ -0,0 +1,77 @@ +'use strict'; + +import {strict as assert} from 'assert'; + +const common = require('../../common'); +import settings from '../../../../node/utils/Settings'; + +let agent: any; +let apiVersion = 1; + +const endPoint = (p: string) => `/api/${apiVersion}/${p}`; + +const makeId = () => `gdprdel_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +const callApi = async (point: string, query: Record = {}) => { + const qs = new URLSearchParams(query).toString(); + const path = qs ? `${endPoint(point)}?${qs}` : endPoint(point); + return await agent.get(path) + .set('authorization', await common.generateJWTToken()) + .expect(200) + .expect('Content-Type', /json/); +}; + +describe(__filename, function () { + before(async function () { + this.timeout(60000); + agent = await common.init(); + const res = await agent.get('/api/').expect(200); + apiVersion = res.body.currentVersion; + }); + + afterEach(function () { settings.allowPadDeletionByAllUsers = false; }); + + it('createPad returns a plaintext deletionToken the first time', async function () { + const padId = makeId(); + const res = await callApi('createPad', {padID: padId}); + assert.equal(res.body.code, 0, JSON.stringify(res.body)); + assert.equal(typeof res.body.data.deletionToken, 'string'); + assert.ok(res.body.data.deletionToken.length >= 32); + await callApi('deletePad', {padID: padId, deletionToken: res.body.data.deletionToken}); + }); + + it('deletePad with a valid deletionToken succeeds', async function () { + const padId = makeId(); + const create = await callApi('createPad', {padID: padId}); + const token = create.body.data.deletionToken; + const del = await callApi('deletePad', {padID: padId, deletionToken: token}); + assert.equal(del.body.code, 0, JSON.stringify(del.body)); + const check = await callApi('getText', {padID: padId}); + assert.equal(check.body.code, 1); // "padID does not exist" + }); + + it('deletePad with a wrong deletionToken is refused', async function () { + const padId = makeId(); + await callApi('createPad', {padID: padId}); + const del = await callApi('deletePad', {padID: padId, deletionToken: 'not-the-real-token'}); + assert.equal(del.body.code, 1); + assert.match(del.body.message, /invalid deletionToken/); + // cleanup — JWT-authenticated caller is trusted when no token is supplied + await callApi('deletePad', {padID: padId}); + }); + + it('deletePad with allowPadDeletionByAllUsers=true bypasses the token check', async function () { + const padId = makeId(); + await callApi('createPad', {padID: padId}); + settings.allowPadDeletionByAllUsers = true; + const del = await callApi('deletePad', {padID: padId, deletionToken: 'bogus'}); + assert.equal(del.body.code, 0); + }); + + it('JWT admin call (no deletionToken) still works — admins stay trusted', async function () { + const padId = makeId(); + await callApi('createPad', {padID: padId}); + const del = await callApi('deletePad', {padID: padId}); + assert.equal(del.body.code, 0); + }); +}); From 729ca7e787de5ad5bc9a0044c7da93dd8373677d Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:25:29 +0100 Subject: [PATCH 09/18] feat(gdpr): surface padDeletionToken in clientVars for creators only Revision-0 author on their first CLIENT_READY visit receives the plaintext token; all subsequent CLIENT_READYs receive null because createDeletionTokenIfAbsent is idempotent. Readonly sessions and any other user never see the token. --- src/node/handler/PadMessageHandler.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index f63a50c90b4..a0da70284a9 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -1066,6 +1066,16 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { throw new Error('corrupt pad'); } + // Only the original creator of the pad (revision 0 author) receives the + // deletion token, and only on their first arrival — subsequent visits get + // null because createDeletionTokenIfAbsent() only emits a plaintext token + // once. Readonly sessions never see it. + const isCreator = + !sessionInfo.readonly && sessionInfo.author === await pad.getRevisionAuthor(0); + const padDeletionToken = isCreator + ? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId) + : null; + // Warning: never ever send sessionInfo.padId to the client. If the client is read only you // would open a security hole 1 swedish mile wide... const canEditPadSettings = settings.enablePadWideSettings && @@ -1079,6 +1089,7 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { }, enableDarkMode: settings.enableDarkMode, enablePadWideSettings: settings.enablePadWideSettings, + padDeletionToken, automaticReconnectionTimeout: settings.automaticReconnectionTimeout, initialRevisionList: [], initialOptions: pad.getPadSettings(), From 953c6373ad35f7d03c666e08e91dcceb20eabd5e Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:26:00 +0100 Subject: [PATCH 10/18] i18n(gdpr): strings for deletion-token modal and delete-with-token flow --- src/locales/en.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/locales/en.json b/src/locales/en.json index 729d312d23c..7a11dc75171 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -101,6 +101,14 @@ "pad.settings.language": "Language:", "pad.settings.deletePad": "Delete Pad", "pad.delete.confirm": "Do you really want to delete this pad?", + "pad.deletionToken.modalTitle": "Save your pad deletion token", + "pad.deletionToken.modalBody": "This token is the only way to delete this pad if you lose your browser session or switch device. Save it somewhere safe — it is shown here exactly once.", + "pad.deletionToken.copy": "Copy", + "pad.deletionToken.copied": "Copied", + "pad.deletionToken.acknowledge": "I've saved it", + "pad.deletionToken.deleteWithToken": "Delete with token", + "pad.deletionToken.tokenFieldLabel": "Pad deletion token", + "pad.deletionToken.invalid": "That token is not valid for this pad.", "pad.settings.about": "About", "pad.settings.poweredBy": "Powered by", From f26dc1485d2959978a08959c5ceca1a6a4412ec3 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:26:39 +0100 Subject: [PATCH 11/18] feat(gdpr): token modal + delete-with-token disclosure markup --- src/templates/pad.html | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/templates/pad.html b/src/templates/pad.html index 5e593f6d7aa..1514b4bef1d 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -240,6 +240,13 @@

<% e.end_block(); %> +
+ Delete with token + + + +
<% } %>

About

@@ -247,6 +254,22 @@

About

Etherpad <% if (settings.exposeVersion) { %>(commit <%= settings.gitVersion %>)<% } %> + + + + + From 5cb2a26ad9ffe74b967fbd9d298affe976f957c6 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:27:51 +0100 Subject: [PATCH 12/18] feat(gdpr): show deletion token once, allow delete via recovery token --- src/static/js/pad.ts | 34 ++++++++++++++++++++++++++++++++++ src/static/js/pad_editor.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index d9698f5e776..6f49bd2bc06 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -216,6 +216,38 @@ const normalizeChatOptions = (options) => { return options; }; +// Surfaces the one-time pad deletion token when the server sends it in +// clientVars (creator session, first CLIENT_READY). The token is cleared from +// clientVars on acknowledgement so it is not re-exposed to later code paths. +const showDeletionTokenModalIfPresent = () => { + const token: string | null = (window as any).clientVars?.padDeletionToken; + if (!token) return; + const $modal = $('#deletiontoken-modal'); + const $input = $('#deletiontoken-value'); + const $copy = $('#deletiontoken-copy'); + const $ack = $('#deletiontoken-ack'); + if ($modal.length === 0) return; + + $input.val(token); + $modal.prop('hidden', false).addClass('popup-show'); + + $copy.off('click.gdpr').on('click.gdpr', async () => { + try { + await navigator.clipboard.writeText(token); + } catch (_e) { + ($input[0] as HTMLInputElement).select(); + document.execCommand('copy'); + } + $copy.text(html10n.get('pad.deletionToken.copied')); + }); + + $ack.off('click.gdpr').on('click.gdpr', () => { + $input.val(''); + $modal.prop('hidden', true).removeClass('popup-show'); + (window as any).clientVars.padDeletionToken = null; + }); +}; + const sendClientReady = (isReconnect) => { let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1); // unescape necessary due to Safari and Opera interpretation of spaces @@ -639,6 +671,8 @@ const pad = { $('#options-darkmode').prop('checked', skinVariants.isDarkMode()); } + showDeletionTokenModalIfPresent(); + hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad}); }; diff --git a/src/static/js/pad_editor.ts b/src/static/js/pad_editor.ts index 267ad5dd6d3..dd0c1809e5e 100644 --- a/src/static/js/pad_editor.ts +++ b/src/static/js/pad_editor.ts @@ -137,6 +137,33 @@ const padeditor = (() => { } }); + // delete pad using a recovery token (second device / no creator cookie) + $('#delete-pad-token-submit').on('click', () => { + const token = String($('#delete-pad-token-input').val() || '').trim(); + if (!token) return; + if (!window.confirm(html10n.get('pad.delete.confirm'))) return; + + let handled = false; + pad.socket.on('message', (data: any) => { + if (data && data.disconnect === 'deleted') { + handled = true; + window.location.href = '/'; + } + }); + pad.socket.on('shout', (data: any) => { + handled = true; + const msg = data?.data?.payload?.message?.message; + if (msg) window.alert(msg); + }); + pad.collabClient.sendMessage({ + type: 'PAD_DELETE', + data: {padId: pad.getPadId(), deletionToken: token}, + }); + setTimeout(() => { + if (!handled) window.location.href = '/'; + }, 5000); + }); + // delete pad $('#delete-pad').on('click', () => { if (window.confirm(html10n.get('pad.delete.confirm'))) { From d015a4a720d37f4ce60368780d1eec3ca14fa1c9 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:28:18 +0100 Subject: [PATCH 13/18] style(gdpr): modal + delete-with-token layout --- .../skins/colibris/src/components/popup.css | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/static/skins/colibris/src/components/popup.css b/src/static/skins/colibris/src/components/popup.css index 381c10d8726..3940c8ccdb8 100644 --- a/src/static/skins/colibris/src/components/popup.css +++ b/src/static/skins/colibris/src/components/popup.css @@ -114,3 +114,40 @@ #delete-pad { margin-top: 20px; } + + +/* Pad deletion-token modal + delete-with-token disclosure (GDPR PR1) */ +#deletiontoken-modal .popup-content { + max-width: 32rem; +} + +#deletiontoken-modal .deletiontoken-row { + display: flex; + gap: 0.5rem; + margin: 1rem 0; +} + +#deletiontoken-modal #deletiontoken-value { + flex: 1; + font-family: monospace; + padding: 0.4rem; + user-select: all; +} + +#delete-pad-with-token { + margin-top: 0.5rem; +} + +#delete-pad-with-token summary { + cursor: pointer; + color: #666; + font-size: 0.9rem; +} + +#delete-pad-with-token input { + margin: 0.5rem 0; + width: 100%; + font-family: monospace; + padding: 0.4rem; +} + From 1845bc2c7d9fee22b8d7a7f8b5f213a0729275ba Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:29:25 +0100 Subject: [PATCH 14/18] test(gdpr): Playwright coverage for deletion-token modal + delete-with-token --- .../specs/pad_deletion_token.spec.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/tests/frontend-new/specs/pad_deletion_token.spec.ts diff --git a/src/tests/frontend-new/specs/pad_deletion_token.spec.ts b/src/tests/frontend-new/specs/pad_deletion_token.spec.ts new file mode 100644 index 00000000000..cde67687baf --- /dev/null +++ b/src/tests/frontend-new/specs/pad_deletion_token.spec.ts @@ -0,0 +1,74 @@ +import {expect, test} from '@playwright/test'; +import {goToNewPad, goToPad} from '../helper/padHelper'; +import {showSettings} from '../helper/settingsHelper'; + +test.describe('pad deletion token', () => { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('creator sees a token modal exactly once and can dismiss it', async ({page}) => { + await goToNewPad(page); + const modal = page.locator('#deletiontoken-modal'); + await expect(modal).toBeVisible(); + + const tokenValue = await page.locator('#deletiontoken-value').inputValue(); + expect(tokenValue.length).toBeGreaterThanOrEqual(32); + + await page.locator('#deletiontoken-ack').click(); + await expect(modal).toBeHidden(); + + const cleared = await page.evaluate( + () => (window as any).clientVars.padDeletionToken); + expect(cleared == null).toBe(true); + }); + + test('second device can delete using the captured token', async ({page, browser}) => { + const padId = await goToNewPad(page); + const token = await page.locator('#deletiontoken-value').inputValue(); + await page.locator('#deletiontoken-ack').click(); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await goToPad(page2, padId); + await showSettings(page2); + + await page2.locator('#delete-pad-with-token > summary').click(); + await page2.locator('#delete-pad-token-input').fill(token); + page2.once('dialog', (d) => d.accept()); + await page2.locator('#delete-pad-token-submit').click(); + + await page2.waitForURL((url) => url.pathname === '/' || url.pathname.endsWith('/index.html'), + {timeout: 10000}); + + await context2.close(); + }); + + test('wrong token keeps the pad alive and surfaces a shout', async ({page, browser}) => { + const padId = await goToNewPad(page); + await page.locator('#deletiontoken-ack').click(); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await goToPad(page2, padId); + await showSettings(page2); + + await page2.locator('#delete-pad-with-token > summary').click(); + await page2.locator('#delete-pad-token-input').fill('bogus-token-value'); + // Accept the confirm() dialog, then capture the alert() the shout triggers. + const dialogs: string[] = []; + page2.on('dialog', async (d) => { + dialogs.push(d.message()); + await d.accept(); + }); + await page2.locator('#delete-pad-token-submit').click(); + + await expect.poll(() => dialogs.length, {timeout: 10000}).toBeGreaterThanOrEqual(2); + expect(dialogs.some((m) => /not the creator|cannot delete/i.test(m))).toBe(true); + + // Pad must still exist for the original creator. + await page.reload(); + await expect(page.locator('#editorcontainer.initialized')).toBeVisible(); + await context2.close(); + }); +}); From e3020d22043a12bf548376ec19b4f8a552541d5c Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:43:34 +0100 Subject: [PATCH 15/18] fix(test): auto-dismiss deletion-token modal in goToNewPad helper The token modal introduced in PR1 blocks clicks for every Playwright test that creates a new pad via the shared helper. Add a one-line dismissal so unrelated tests keep passing, and have the deletion-token spec navigate inline via newPadKeepingModal() when it needs the modal open to capture the token. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tests/frontend-new/helper/padHelper.ts | 8 +++++++ .../specs/pad_deletion_token.spec.ts | 24 +++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/tests/frontend-new/helper/padHelper.ts b/src/tests/frontend-new/helper/padHelper.ts index c1dcbecee3c..c3d096f2690 100644 --- a/src/tests/frontend-new/helper/padHelper.ts +++ b/src/tests/frontend-new/helper/padHelper.ts @@ -120,6 +120,14 @@ export const goToNewPad = async (page: Page) => { await page.goto('http://localhost:9001/p/'+padId); await page.waitForSelector('iframe[name="ace_outer"]'); await page.waitForSelector('#editorcontainer.initialized'); + // Creator sessions see the one-time pad-deletion-token modal on first visit. + // Dismiss it so subsequent clicks in generic tests are not blocked. Tests + // that need to interact with the modal should navigate to a new pad inline + // instead of using this helper. + const tokenModal = page.locator('#deletiontoken-modal'); + if (await tokenModal.isVisible().catch(() => false)) { + await page.locator('#deletiontoken-ack').click(); + } return padId; } diff --git a/src/tests/frontend-new/specs/pad_deletion_token.spec.ts b/src/tests/frontend-new/specs/pad_deletion_token.spec.ts index cde67687baf..64d6629b0b3 100644 --- a/src/tests/frontend-new/specs/pad_deletion_token.spec.ts +++ b/src/tests/frontend-new/specs/pad_deletion_token.spec.ts @@ -1,14 +1,26 @@ -import {expect, test} from '@playwright/test'; -import {goToNewPad, goToPad} from '../helper/padHelper'; +import {expect, test, Page} from '@playwright/test'; +import {randomUUID} from 'node:crypto'; +import {goToPad} from '../helper/padHelper'; import {showSettings} from '../helper/settingsHelper'; +// goToNewPad() in the shared helper auto-dismisses the deletion-token modal +// so unrelated tests aren't blocked. These tests need the modal, so they +// navigate inline without the helper. +const newPadKeepingModal = async (page: Page) => { + const padId = `FRONTEND_TESTS${randomUUID()}`; + await page.goto(`http://localhost:9001/p/${padId}`); + await page.waitForSelector('iframe[name="ace_outer"]'); + await page.waitForSelector('#editorcontainer.initialized'); + return padId; +}; + test.describe('pad deletion token', () => { test.beforeEach(async ({context}) => { await context.clearCookies(); }); test('creator sees a token modal exactly once and can dismiss it', async ({page}) => { - await goToNewPad(page); + await newPadKeepingModal(page); const modal = page.locator('#deletiontoken-modal'); await expect(modal).toBeVisible(); @@ -24,7 +36,7 @@ test.describe('pad deletion token', () => { }); test('second device can delete using the captured token', async ({page, browser}) => { - const padId = await goToNewPad(page); + const padId = await newPadKeepingModal(page); const token = await page.locator('#deletiontoken-value').inputValue(); await page.locator('#deletiontoken-ack').click(); @@ -45,7 +57,7 @@ test.describe('pad deletion token', () => { }); test('wrong token keeps the pad alive and surfaces a shout', async ({page, browser}) => { - const padId = await goToNewPad(page); + const padId = await newPadKeepingModal(page); await page.locator('#deletiontoken-ack').click(); const context2 = await browser.newContext(); @@ -55,7 +67,6 @@ test.describe('pad deletion token', () => { await page2.locator('#delete-pad-with-token > summary').click(); await page2.locator('#delete-pad-token-input').fill('bogus-token-value'); - // Accept the confirm() dialog, then capture the alert() the shout triggers. const dialogs: string[] = []; page2.on('dialog', async (d) => { dialogs.push(d.message()); @@ -66,7 +77,6 @@ test.describe('pad deletion token', () => { await expect.poll(() => dialogs.length, {timeout: 10000}).toBeGreaterThanOrEqual(2); expect(dialogs.some((m) => /not the creator|cannot delete/i.test(m))).toBe(true); - // Pad must still exist for the original creator. await page.reload(); await expect(page.locator('#editorcontainer.initialized')).toBeVisible(); await context2.close(); From 9e6d55365892ff0aa5b66ab868e9b8095d1f79a2 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 20:03:30 +0100 Subject: [PATCH 16/18] fix(test): dismiss deletion-token modal without focus transfer Clicking the ack button transferred focus out of the pad iframe, which made subsequent keyboard-driven tests (Tab / Enter) silently miss the editor. Swap the click for a page.evaluate() that hides the modal and nulls clientVars.padDeletionToken directly, leaving focus where it was. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tests/frontend-new/helper/padHelper.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/tests/frontend-new/helper/padHelper.ts b/src/tests/frontend-new/helper/padHelper.ts index c3d096f2690..63d63063188 100644 --- a/src/tests/frontend-new/helper/padHelper.ts +++ b/src/tests/frontend-new/helper/padHelper.ts @@ -121,13 +121,20 @@ export const goToNewPad = async (page: Page) => { await page.waitForSelector('iframe[name="ace_outer"]'); await page.waitForSelector('#editorcontainer.initialized'); // Creator sessions see the one-time pad-deletion-token modal on first visit. - // Dismiss it so subsequent clicks in generic tests are not blocked. Tests - // that need to interact with the modal should navigate to a new pad inline - // instead of using this helper. - const tokenModal = page.locator('#deletiontoken-modal'); - if (await tokenModal.isVisible().catch(() => false)) { - await page.locator('#deletiontoken-ack').click(); - } + // Hide it directly instead of clicking the ack button — clicking the button + // transfers focus out of the pad iframe and breaks subsequent keyboard tests. + // Tests that need to interact with the modal should navigate to a new pad + // inline instead of using this helper. + await page.evaluate(() => { + const modal = document.getElementById('deletiontoken-modal'); + if (modal == null || modal.hidden) return; + modal.hidden = true; + modal.classList.remove('popup-show'); + const input = document.getElementById('deletiontoken-value') as HTMLInputElement | null; + if (input) input.value = ''; + const w = window as unknown as {clientVars?: {padDeletionToken?: string | null}}; + if (w.clientVars != null) w.clientVars.padDeletionToken = null; + }); return padId; } From f64ba91de8810a1a094e204b55c0b78048c1259e Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 11:18:41 +0100 Subject: [PATCH 17/18] fix(gdpr): PadDeletionManager race + document createPad/deletePad Qodo review: - createDeletionTokenIfAbsent() was a non-atomic read-then-write. Two concurrent callers for the same pad could both return different plaintext tokens while only the later hash was stored, leaving the first caller with an unusable recovery token. Serialise per-pad via a Promise chain and add a regression test that fires 8 concurrent calls and asserts exactly one plaintext is emitted and validates. - doc/api/http_api.md now documents createPad returning deletionToken and deletePad accepting the optional deletionToken parameter. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/api/http_api.md | 24 ++++++++++++++--- src/node/db/PadDeletionManager.ts | 27 ++++++++++++++----- src/tests/backend/specs/padDeletionManager.ts | 16 +++++++++++ 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/doc/api/http_api.md b/doc/api/http_api.md index 2676a898725..60db60e31bb 100644 --- a/doc/api/http_api.md +++ b/doc/api/http_api.md @@ -519,12 +519,20 @@ Group pads are normal pads, but with the name schema GROUPID$PADNAME. A security #### createPad(padID, [text], [authorId]) * API >= 1 * `authorId` in API >= 1.3.0 +* returns `deletionToken` once, since the same release that added `allowPadDeletionByAllUsers` creates a new (non-group) pad. Note that if you need to create a group Pad, you should call **createGroupPad**. You get an error message if you use one of the following characters in the padID: "/", "?", "&" or "#". +`data.deletionToken` is a one-shot recovery token tied to this pad. It is +returned in plaintext on the first call for a given padID and is `null` on +subsequent calls (the token itself is stored on the server as a sha256 hash). +Pass it to **deletePad** (or the socket `PAD_DELETE` message) to delete the +pad without the creator's author cookie. + *Example returns:* -* `{code: 0, message:"ok", data: null}` +* `{code: 0, message:"ok", data: {deletionToken: "…32-char random string…"}}` +* `{code: 0, message:"ok", data: {deletionToken: null}}` — pad already existed * `{code: 1, message:"padID does already exist", data: null}` * `{code: 1, message:"malformed padID: Remove special characters", data: null}` @@ -581,14 +589,24 @@ returns the list of users that are currently editing this pad * `{code: 0, message:"ok", data: {padUsers: [{colorId:"#c1a9d9","name":"username1","timestamp":1345228793126,"id":"a.n4gEeMLsvg12452n"},{"colorId":"#d9a9cd","name":"Hmmm","timestamp":1345228796042,"id":"a.n4gEeMLsvg12452n"}]}}` * `{code: 0, message:"ok", data: {padUsers: []}}` -#### deletePad(padID) +#### deletePad(padID, [deletionToken]) * API >= 1 +* `deletionToken` in the same release as `allowPadDeletionByAllUsers` + +deletes a pad. -deletes a pad +`deletionToken` is the one-shot recovery token returned by `createPad` / +`createGroupPad`. An apikey-authenticated caller can pass any (or no) token +and the call still succeeds — trusted admins bypass the check. An +unauthenticated caller (or a caller that explicitly passes a wrong token) +is rejected with `invalid deletionToken` unless the operator has set +`allowPadDeletionByAllUsers: true` in `settings.json`, in which case the +token is ignored. *Example returns:* * `{code: 0, message:"ok", data: null}` * `{code: 1, message:"padID does not exist", data: null}` +* `{code: 1, message:"invalid deletionToken", data: null}` #### copyPad(sourceID, destinationID[, force=false]) * API >= 1.2.8 diff --git a/src/node/db/PadDeletionManager.ts b/src/node/db/PadDeletionManager.ts index a6df3f54956..e37a240b6da 100644 --- a/src/node/db/PadDeletionManager.ts +++ b/src/node/db/PadDeletionManager.ts @@ -10,14 +10,29 @@ const getDeletionTokenKey = (padId: string) => `pad:${padId}:deletionToken`; const hashDeletionToken = (deletionToken: string) => crypto.createHash('sha256').update(deletionToken, 'utf8').digest(); +// Per-pad serialisation for token creation. Without this, two concurrent +// `createDeletionTokenIfAbsent()` calls for the same pad can both observe +// an empty slot, both write a hash, and leave the earlier caller holding a +// plaintext token that no longer validates. The chain is cleaned up once the +// outstanding call resolves so this map doesn't grow unbounded. +const inflightCreate: Map> = new Map(); + exports.createDeletionTokenIfAbsent = async (padId: string): Promise => { - if (await DB.db.get(getDeletionTokenKey(padId)) != null) return null; - const deletionToken = randomString(32); - await DB.db.set(getDeletionTokenKey(padId), { - createdAt: Date.now(), - hash: hashDeletionToken(deletionToken).toString('hex'), + const prior = inflightCreate.get(padId); + const next = (prior || Promise.resolve()).then(async () => { + if (await DB.db.get(getDeletionTokenKey(padId)) != null) return null; + const deletionToken = randomString(32); + await DB.db.set(getDeletionTokenKey(padId), { + createdAt: Date.now(), + hash: hashDeletionToken(deletionToken).toString('hex'), + }); + return deletionToken; + }); + const tracked = next.finally(() => { + if (inflightCreate.get(padId) === tracked) inflightCreate.delete(padId); }); - return deletionToken; + inflightCreate.set(padId, tracked); + return next; }; exports.isValidDeletionToken = async (padId: string, deletionToken: string | null | undefined) => { diff --git a/src/tests/backend/specs/padDeletionManager.ts b/src/tests/backend/specs/padDeletionManager.ts index f5b3932fab4..d6cbbe04b10 100644 --- a/src/tests/backend/specs/padDeletionManager.ts +++ b/src/tests/backend/specs/padDeletionManager.ts @@ -37,6 +37,22 @@ describe(__filename, function () { await padDeletionManager.removeDeletionToken(a); await padDeletionManager.removeDeletionToken(b); }); + + it('concurrent calls for the same pad produce a single validating token', + async function () { + const padId = uniqueId(); + const results = await Promise.all( + Array.from({length: 8}, + () => padDeletionManager.createDeletionTokenIfAbsent(padId))); + // Exactly one caller should get the plaintext token; the rest see null. + const nonNull = results.filter((r) => r != null); + assert.equal(nonNull.length, 1, `results: ${JSON.stringify(results)}`); + const [token] = nonNull; + assert.equal( + await padDeletionManager.isValidDeletionToken(padId, token), true, + 'the one token returned must validate against the stored hash'); + await padDeletionManager.removeDeletionToken(padId); + }); }); describe('isValidDeletionToken', function () { From baa11b5dd9c2c17653535d4579e8d58f5bad909d Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 12:00:04 +0100 Subject: [PATCH 18/18] fix(gdpr): always render delete-with-token in settings popup The rebase onto develop placed the delete-pad-with-token details inside the pad-settings-section conditional, which is only rendered when enablePadWideSettings is true AND the section is toggled visible. Second-device recovery (typing the captured token on a fresh browser) must work without pad-wide settings enabled, so move the details out to sit alongside the existing pad_deletion_token.spec.ts expectations. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/templates/pad.html | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/templates/pad.html b/src/templates/pad.html index 1514b4bef1d..ac1cb4cfa3e 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -240,15 +240,15 @@

<% e.end_block(); %> -
- Delete with token - - - -
<% } %> +
+ Delete with token + + + +

About

Powered by Etherpad