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/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. 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. 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/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", diff --git a/src/node/db/API.ts b/src/node/db/API.ts index 9ca5ca03c4b..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'); @@ -30,6 +31,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,19 +520,30 @@ exports.createPad = async (padID: string, text: string, authorId = '') => { // create pad await getPadSafe(padID, false, text, authorId); + return {deletionToken: await padDeletionManager.createDeletionTokenIfAbsent(padID)}; }; /** -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(); }; 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..e37a240b6da --- /dev/null +++ b/src/node/db/PadDeletionManager.ts @@ -0,0 +1,48 @@ +'use strict'; + +import crypto from 'node:crypto'; +import randomString from '../utils/randomstring'; + +const DB = require('./DB'); + +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 => { + 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); + }); + inflightCreate.set(padId, tracked); + return next; +}; + +exports.isValidDeletionToken = async (padId: string, deletionToken: string | null | undefined) => { + if (typeof deletionToken !== 'string' || deletionToken === '') return false; + 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.db.remove(getDeletionTokenKey(padId)); 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'], diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 8285a3a8a52..a0da70284a9 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); @@ -1068,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 && @@ -1081,6 +1089,7 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { }, enableDarkMode: settings.enableDarkMode, enablePadWideSettings: settings.enablePadWideSettings, + padDeletionToken, automaticReconnectionTimeout: settings.automaticReconnectionTimeout, initialRevisionList: [], initialOptions: pad.getPadSettings(), 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/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'))) { diff --git a/src/static/js/types/SocketIOMessage.ts b/src/static/js/types/SocketIOMessage.ts index 08be6a03ee5..1d30d7d76b9 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 @@ -200,6 +202,7 @@ export type PadDeleteMessage = { type: 'PAD_DELETE' data: { padId: string + deletionToken?: string } } 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; +} + diff --git a/src/templates/pad.html b/src/templates/pad.html index 5e593f6d7aa..ac1cb4cfa3e 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -242,11 +242,34 @@

<% } %> +
+ Delete with token + + + +

About

Powered by Etherpad <% if (settings.exposeVersion) { %>(commit <%= settings.gitVersion %>)<% } %> + + + + + 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); + }); +}); diff --git a/src/tests/backend/specs/padDeletionManager.ts b/src/tests/backend/specs/padDeletionManager.ts new file mode 100644 index 00000000000..d6cbbe04b10 --- /dev/null +++ b/src/tests/backend/specs/padDeletionManager.ts @@ -0,0 +1,104 @@ +'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); + }); + + 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 () { + 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 + }); + }); +}); diff --git a/src/tests/frontend-new/helper/padHelper.ts b/src/tests/frontend-new/helper/padHelper.ts index c1dcbecee3c..63d63063188 100644 --- a/src/tests/frontend-new/helper/padHelper.ts +++ b/src/tests/frontend-new/helper/padHelper.ts @@ -120,6 +120,21 @@ 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. + // 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; } 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..64d6629b0b3 --- /dev/null +++ b/src/tests/frontend-new/specs/pad_deletion_token.spec.ts @@ -0,0 +1,84 @@ +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 newPadKeepingModal(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 newPadKeepingModal(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 newPadKeepingModal(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'); + 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); + + await page.reload(); + await expect(page.locator('#editorcontainer.initialized')).toBeVisible(); + await context2.close(); + }); +});