etherpad-lite/docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md
John McLear 5e8704f8d8
feat(gdpr): pad deletion controls (PR1 of #6701) (#7546)
* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* feat(gdpr): three-way auth for socket PAD_DELETE

Creator cookie → valid deletion token → allowPadDeletionByAllUsers flag.
Anyone else still gets the existing refusal shout.

* feat(gdpr): optional deletionToken on programmatic deletePad

* feat(gdpr): advertise optional deletionToken on REST deletePad

* test(gdpr): cover deletePad authorisation matrix via REST

* 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.

* i18n(gdpr): strings for deletion-token modal and delete-with-token flow

* feat(gdpr): token modal + delete-with-token disclosure markup

* feat(gdpr): show deletion token once, allow delete via recovery token

* style(gdpr): modal + delete-with-token layout

* test(gdpr): Playwright coverage for deletion-token modal + delete-with-token

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* fix(gdpr): require valid token when supplied, gate on auth, harden a11y/i18n

- PadMessageHandler: a supplied deletion token must validate; do not fall
  back to the creator-cookie path when the token is wrong (was deleting
  the pad anyway when the creator pasted a wrong token into the field).
- Skip token issuance + UI when requireAuthentication is on (creator
  identity is stable, recovery token is redundant noise).
- Server emits messageKey instead of hardcoded English; both shout
  handlers (inline alert and global gritter) localize via html10n.
- Suppress the global "Admin message" gritter for pad.deletionToken.*
  shouts to avoid the "Admin message: undefined" duplicate.
- Token-modal a11y: role=dialog, aria-modal, aria-labelledby/describedby,
  visually-hidden label on the token input, aria-live on Copy, focus to
  the token input on open and restore on dismiss.
- Style the "Delete Pad with Token" disclosure to match the Delete pad
  button; align the Copy/value row; pad the disclosure label.

Tests: Playwright now covers the creator-with-wrong-token path, asserts
no "Admin message" / "undefined" gritter on denial; backend API test
covers requireAuthentication suppressing the token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:50:04 +01:00

7.8 KiB
Raw Permalink Blame History

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 PR2PR5.

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:<padId>: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:

{
  "createdAt": 1712451234567,
  "hash": "<sha256 hex of the token>"
}

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:

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: <string> 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

/*
 * 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:<id>: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.