* 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>
7.8 KiB
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
- On the first successful
createPad/createGroupPadcall,PadDeletionManager.createDeletionTokenIfAbsent(padId)generates a 32-character random string, storessha256(token)inpad:<padId>:deletionToken, and returns the plaintext token. - The plaintext is returned once in the API response
(
{padID, deletionToken}) and, for browser-created pads, streamed intoclientVars.padDeletionTokenon that session only. - 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.
- On delete,
Pad.remove()callsPadDeletionManager.removeDeletionToken(padId)so DB state stays consistent. - Subsequent
createPadcalls for the same padId never regenerate the token (thecreateDeletionTokenIfAbsentname 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
clientVarsafter 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 assertdeletionTokenis present on first create andnullon a duplicate call. - Add
api/deletePad.tscovering 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 inwindow.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
createDeletionTokenIfAbsentfor a pre-existing padId generates and stores one — that's the expected upgrade path and does not change any already-valid deletion flow. db.removeon a non-existent key is a no-op in etherpad's db layer, soremoveDeletionTokenis 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.