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>
This commit is contained in:
John McLear 2026-05-01 20:50:04 +08:00 committed by GitHub
parent 7357871acd
commit 5e8704f8d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1836 additions and 45 deletions

View file

@ -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'],

View file

@ -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';
@ -259,39 +260,48 @@ 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 {
const padId = padDeleteMessage.data.padId;
if (session.padId !== padId) throw new Error('refusing cross-pad delete');
if (!await padManager.doesPadExist(padId)) return;
type ShoutMessage = {
message: string,
sticky: boolean,
}
const retrievedPad = await padManager.getPad(padId);
const firstContributor = await retrievedPad.getRevisionAuthor(0);
const isCreator = session.author === firstContributor;
const suppliedToken = padDeleteMessage.data.deletionToken;
const tokenSupplied = typeof suppliedToken === 'string' && suppliedToken !== '';
const tokenOk = tokenSupplied &&
await padDeletionManager.isValidDeletionToken(padId, suppliedToken);
// When a token is supplied it must validate. We deliberately do NOT fall
// back to the creator-cookie path, otherwise a creator pasting a wrong
// recovery token into the disclosure field would still succeed — masking a
// typo and contradicting the UI.
const creatorOk = !tokenSupplied && isCreator;
const flagOk = !tokenSupplied && !isCreator && settings.allowPadDeletionByAllUsers;
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
)
}
if (creatorOk || tokenOk || flagOk) {
await retrievedPad.remove();
return;
}
}
// tokenSupplied-but-invalid is a different user-facing message from
// not-the-creator. The client localizes via the l10n key.
const messageKey = tokenSupplied
? 'pad.deletionToken.invalid'
: 'pad.deletionToken.notCreator';
socket.emit('shout', {
type: 'COLLABROOM',
data: {
type: 'shoutMessage',
payload: {
message: {
messageKey,
sticky: false,
},
timestamp: Date.now(),
},
},
});
};
const isPadCreator = async (pad: any, authorId: string) => authorId === await pad.getRevisionAuthor(0);
@ -1099,6 +1109,19 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
}
const pluginsSanitized = sanitizePluginsForWire(plugins.plugins);
// 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);
// Skip token issuance when requireAuthentication is on: every creator has a
// stable identity so the cookie/identity path is sufficient.
const padDeletionToken = isCreator && !settings.requireAuthentication
? 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 &&
@ -1112,6 +1135,7 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
},
enableDarkMode: settings.enableDarkMode,
enablePadWideSettings: settings.enablePadWideSettings,
padDeletionToken,
automaticReconnectionTimeout: settings.automaticReconnectionTimeout,
initialRevisionList: [],
initialOptions: pad.getPadSettings(),