feat(gdpr): author erasure (PR5 of #6701) (#7550)

* docs: PR5 GDPR author erasure design spec

* docs: PR5 GDPR author erasure implementation plan

* feat(gdpr): AuthorManager.anonymizeAuthor — Art. 17 erasure

* test(gdpr): AuthorManager.anonymizeAuthor unit tests

* feat(gdpr): REST anonymizeAuthor on API version 1.3.1

* test(gdpr): REST anonymizeAuthor end-to-end

* docs(gdpr): right-to-erasure section + anonymizeAuthor example

* fix(gdpr): make anonymizeAuthor resumable on partial failure

Qodo review: the `erased: true` sentinel was written before the chat
scrub loop, so a throw during scrub left chat messages untouched
while subsequent calls short-circuited on `existing.erased` and never
finished. Split the write: zero the display identity first (still
hides the name), run the chat scrub, and only then stamp
`erased: true` so a retry resumes the sweep. Regression test
covers the partial-run → retry path.

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-03 19:30:49 +08:00 committed by GitHub
parent 487842006c
commit 69bb1e19c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1093 additions and 6 deletions

View file

@ -63,6 +63,26 @@ exports.listAllPads = padManager.listAllPads;
exports.createAuthor = authorManager.createAuthor;
exports.createAuthorIfNotExistsFor = authorManager.createAuthorIfNotExistsFor;
exports.getAuthorName = authorManager.getAuthorName;
/**
* anonymizeAuthor(authorID) GDPR Art. 17 erasure. See doc/privacy.md.
*
* Returns counters describing what was touched:
* {affectedPads, removedTokenMappings, removedExternalMappings,
* clearedChatMessages}.
*/
exports.anonymizeAuthor = async (authorID: string) => {
if (!settings.gdprAuthorErasure || !settings.gdprAuthorErasure.enabled) {
throw new CustomError(
'anonymizeAuthor is disabled — set gdprAuthorErasure.enabled = true ' +
'in settings.json to enable GDPR Art. 17 erasure',
'apierror');
}
if (!authorID || typeof authorID !== 'string') {
throw new CustomError('authorID is required', 'apierror');
}
return await authorManager.anonymizeAuthor(authorID);
};
exports.listPadsOfAuthor = authorManager.listPadsOfAuthor;
exports.padUsers = padMessageHandler.padUsers;
exports.padUsersCount = padMessageHandler.padUsersCount;

View file

@ -313,3 +313,105 @@ exports.removePad = async (authorID: string, padID: string) => {
await db.set(`globalAuthor:${authorID}`, author);
}
};
/**
* GDPR Art. 17: anonymise an author. Zeroes the display identity on
* `globalAuthor:<authorID>`, deletes the token/mapper bindings that link a
* person to this authorID, and nulls authorship on chat messages they
* posted. Leaves pad content, revisions, and attribute pools intact
* changeset references are opaque without the identity record, so the
* link to the real person is severed even though the bytes survive.
*
* Idempotent: once `erased: true` is set on the author record, subsequent
* calls short-circuit and return zero counters.
*/
exports.anonymizeAuthor = async (authorID: string): Promise<{
affectedPads: number,
removedTokenMappings: number,
removedExternalMappings: number,
clearedChatMessages: number,
}> => {
// Lazy-require to dodge the AuthorManager ↔ PadManager ↔ Pad cycle.
const padManager = require('./PadManager');
const existing = await db.get(`globalAuthor:${authorID}`);
if (existing == null || existing.erased) {
return {
affectedPads: 0,
removedTokenMappings: 0,
removedExternalMappings: 0,
clearedChatMessages: 0,
};
}
// Drop the token/mapper mappings first, before touching anything else, so
// a concurrent getAuthorId() can no longer resolve this author through
// its old bindings mid-erasure. These operations are independently
// idempotent — rerunning a failed call later still produces the same
// final state, just with zero counters for anything already done.
let removedTokenMappings = 0;
const tokenKeys: string[] = await db.findKeys('token2author:*', null);
for (const key of tokenKeys) {
if (await db.get(key) === authorID) {
await db.remove(key);
removedTokenMappings++;
}
}
let removedExternalMappings = 0;
const mapperKeys: string[] = await db.findKeys('mapper2author:*', null);
for (const key of mapperKeys) {
if (await db.get(key) === authorID) {
await db.remove(key);
removedExternalMappings++;
}
}
// Zero the display identity now — without the `erased` sentinel — so a
// partial run still hides the name. The sentinel itself is only set at
// the end (below) so a failure in chat scrub lets the next call resume.
await db.set(`globalAuthor:${authorID}`, {
colorId: 0,
name: null,
timestamp: Date.now(),
padIDs: existing.padIDs || {},
});
// Null authorship on chat messages the author posted. If this throws
// partway through, the function re-runs the loop on the next call
// because `erased: true` is not set yet.
const padIDs = Object.keys(existing.padIDs || {});
let clearedChatMessages = 0;
for (const padID of padIDs) {
if (!await padManager.doesPadExist(padID)) continue;
const pad = await padManager.getPad(padID);
const chatHead = pad.chatHead;
if (typeof chatHead !== 'number' || chatHead < 0) continue;
for (let i = 0; i <= chatHead; i++) {
const chatKey = `pad:${padID}:chat:${i}`;
const msg = await db.get(chatKey);
if (msg != null && msg.authorId === authorID) {
msg.authorId = null;
await db.set(chatKey, msg);
clearedChatMessages++;
}
}
}
// Everything succeeded — stamp the sentinel so subsequent calls
// short-circuit. Merge with the zeroed record we just wrote so padIDs
// and timestamp persist.
await db.set(`globalAuthor:${authorID}`, {
colorId: 0,
name: null,
timestamp: Date.now(),
padIDs: existing.padIDs || {},
erased: true,
erasedAt: new Date().toISOString(),
});
return {
affectedPads: padIDs.length,
removedTokenMappings,
removedExternalMappings,
clearedChatMessages,
};
};

View file

@ -145,9 +145,9 @@ version['1.3.0'] = {
version['1.3.1'] = {
...version['1.3.0'],
compactPad: ['padID', 'keepRevisions'],
anonymizeAuthor: ['authorID'],
};
// set the latest available API version here
exports.latestApiVersion = '1.3.1';

View file

@ -286,6 +286,9 @@ export type SettingsType = {
enabled: boolean,
keepRevisions: number,
},
gdprAuthorErasure: {
enabled: boolean,
},
scrollWhenFocusLineIsOutOfViewport: {
percentage: {
editionAboveViewport: number,
@ -639,6 +642,13 @@ const settings: SettingsType = {
enabled: false,
keepRevisions: 100,
},
/*
* GDPR Art. 17 author erasure REST endpoint (anonymizeAuthor).
* Disabled by default; operators must opt in.
*/
gdprAuthorErasure: {
enabled: false,
},
/*
* By default, when caret is moved out of viewport, it scrolls the minimum
* height needed to make this line visible.