From 9fd1eadc95642968a3103fb0d4ed1ce2f5373a8a Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 03:03:18 +0100 Subject: [PATCH] docs: adr 0016 account deletion --- CONTEXT.md | 12 +++ README.md | 1 + ...0014-http-adapter-and-one-refusal-shape.md | 2 +- docs/adr/0016-account-deletion.md | 87 +++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0016-account-deletion.md diff --git a/CONTEXT.md b/CONTEXT.md index 62631c60..a1c3c60f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,3 +15,15 @@ _Avoid_: Review (that is the record itself) **Spoiler Flag**: An author-set marker on a Review stating that its entire comment reveals plot details. _Avoid_: Spoiler warning, spoiler tag, spoiler markup + +**Account deletion**: +Two-phase removal of a user. The request tombstones the account immediately; a background purge then erases its data and notifies the fediverse. +_Avoid_: Account removal, deactivation + +**Tombstone**: +The data-free users row (plus signing keypair) an Account deletion leaves behind so queued fediverse `Delete` deliveries can still be signed; removed after a retention period. +_Avoid_: Soft delete + +**Retired Username**: +A username of a deleted account, refused for every future registration. +_Avoid_: Reserved username, banned username diff --git a/README.md b/README.md index dae48a09..c3f3ac07 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth - Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, watch medium breakdown, rewatches, budget analysis); directors/actors filtered by minimum watch count for statistical relevance; shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January - Goals — set a "watch N movies in YEAR" target with a progress bar; progress computed from existing reviews (backwards compatible); per-user federation toggle in settings; displayed on profile (SPA: interactive with create/edit/delete, classic HTML: read-only glassmorphic card) - Profile trends — top directors, genre breakdown, rating distribution histogram, watch medium breakdown, monthly activity chart; all computed from the user's review history +- Account deletion — self-service (own password) or admin (admin's password) from the settings page, profile page, or `DELETE /api/v1/users/me` / `DELETE /api/v1/users/{id}`; the account is tombstoned at once, then a background purge erases its data and tells the fediverse; usernames are never reused (ADR-0016) - CSV and JSON diary export - File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports - REST API v1 (`/api/v1/`) with full feature parity with the HTML interface diff --git a/docs/adr/0014-http-adapter-and-one-refusal-shape.md b/docs/adr/0014-http-adapter-and-one-refusal-shape.md index 9d03fbed..c921e66d 100644 --- a/docs/adr/0014-http-adapter-and-one-refusal-shape.md +++ b/docs/adr/0014-http-adapter-and-one-refusal-shape.md @@ -22,7 +22,7 @@ check; unlike `UNAUTHORIZED` it must not trigger a token refresh), `FORBIDDEN`, **One constructor.** `errors::refuse(status, code, message) -> Response` is the only place a JSON error body is built. `ApiError` maps every `DomainError` variant to it with the status mapping that existed before (`InvalidRating`/`ValidationError` 400, `NotFound` 404, `Unauthorized` 401, -`Forbidden` 403, `InfrastructureError` 500 with the generic message `internal server error` and the +`Forbidden` 403, `InvalidCredentials` 401 (`INVALID_CREDENTIALS`), `InfrastructureError` 500 with the generic message `internal server error` and the cause logged, never sent). **Extractors.** `extractors::Body`, `Params` and `PathId` wrap axum's `Json`, `Query` and diff --git a/docs/adr/0016-account-deletion.md b/docs/adr/0016-account-deletion.md new file mode 100644 index 00000000..8936268b --- /dev/null +++ b/docs/adr/0016-account-deletion.md @@ -0,0 +1,87 @@ +# Account deletion is a tombstone now and a purge later + +Deleting an account has to take effect at once (the person is gone from the instance and the +fediverse) yet erase a lot: dozens of tables, images in object storage, follows on other servers, +and signed `Delete` deliveries that need the account's key long after the user row would +normally be gone. Doing it in one request would hold a transaction over federation and storage +calls, and a crash halfway would leave a half-deleted person. + +## Decision + +**Two phases.** + +1. *Request* (`request_account_deletion`). `UserRepository::mark_deleted` runs one transaction + that stamps `users.deleted_at` (the tombstone), inserts the username into `retired_usernames`, + deletes the user's `refresh_sessions`, and writes a `UserDeleted` event to the outbox + (ADR-0015). Migration `0102_account_deletion.sql` adds `users.deleted_at`, `users.purged_at` + and `retired_usernames` (`username` primary key, `COLLATE NOCASE`, `retired_at`). +2. *Purge* (`AccountPurgeHandler`, handler name `account_purge`, runs in the worker on + `UserDeleted`). + +**Authorization.** A user deletes themselves, an admin deletes anyone (others get 403; +`DELETE /api/v1/users/{id}` takes `AuthenticatedUser` and the use case enforces the role, so +there is one policy point). The proof is always the *actor's* password: the user's own, or the +admin's. A wrong password is `DomainError::InvalidCredentials`: 401 with code +`INVALID_CREDENTIALS` (ADR-0014), distinct from `UNAUTHORIZED` so the SPA does not mistake it for +an expired session and refresh and retry. The last active admin cannot be deleted. The guard +is in the `mark_deleted` `UPDATE` itself (the row is stamped only if another non-deleted admin +exists), so two admins deleting each other concurrently cannot both succeed; the use case's +`count_active_admins` check is only the early, friendly refusal (409). + +**Immediate effects, before the purge runs.** Login is refused and tokens are rejected +(`ensure_active` in the extractors; the user lookups filter `deleted_at IS NULL`). The profile +and `?embed=true` pages 404. The ActivityPub actor document, inbox, outbox and featured answer +410 (`tombstone_gate` for the collection endpoints, `actor_is_gone` for the actor). WebFinger +answers 404 (owned by k-ap). + +**Purge.** Order matters and every step is idempotent: + +1. Notify the fediverse through `FederationDeletionNotifier`, implemented over k-ap's + `broadcast_delete`. That only queues signed deliveries. A notifier failure is logged and never + blocks the purge, and the notice is skipped once the account is purged. +2. `AccountPurger::purge`: one transaction first strips the `users` row (which also claims the + purge: only a tombstoned, unpurged account matches), then deletes `ap_announces` of the + user's content and by the user, the *finished* `event_queue` rows whose payload names the + user, other accounts' follows of this actor (`ap_followers`/`ap_following` by actor URL), and + every row in `PURGED_USER_TABLES` (including tables with no foreign key to `users`, such as + `watch_events`, `wrap_up_records`, `import_*`, `blocked_actors`). `PURGED_USER_TABLES`, + `URL_KEYED_USER_COLUMNS` and `PAYLOAD_KEYED_USER_COLUMNS` are checked against the schema by a + guard test, so a new user-owned table cannot be forgotten. +3. Image objects (avatar, banner) are deleted after the commit, then the references are dropped + (`forget_images`), so a crash in between is repaired by redelivery. + +**The tombstone.** The stripped `users` row keeps `id`, `username`, `role` and the `ap_local_actors` +keypair (`RETAINED_USER_TABLES`), and nothing else: the email becomes the placeholder +`deleted-@deleted.invalid` (which frees the real address for a new registration), the +password hash becomes `!purged`, display name, bio, `also_known_as` and image paths are cleared, +and `purged_at` is set. The row stays so queued `Delete` deliveries can still be signed. +Broadcasts for a tombstoned author are suppressed (the handler skips them and succeeds) except +the `Delete` itself. + +**Cleanup.** `TombstoneCleanupJob` runs daily and removes tombstones whose `purged_at` is older +than `TOMBSTONE_RETENTION_DAYS` (7): all remaining `event_queue` rows naming the user (the +`UserDeleted` trigger and the `Delete` deliveries), the keypair, and the `users` row. A tombstone +that an unfinished (pending/processing) event still names waits for the next run; unfinished +events are never deleted. + +**Usernames are never reused.** `retired_usernames` is permanent and survives cleanup; the +registration path refuses a retired username, so a deleted name cannot be impersonated. + +## Known gaps + +- Events already relayed to NATS JetStream are not erased; they live as long as the stream's + retention allows. +- A Follow arriving on the shared inbox can create a `Pending` `ap_followers` row for the + tombstoned user; it is removed by the cleanup. +- Remote servers that stay down through the delivery retries, or that ignore `Delete`, may keep + stale copies. +- A handler that defers a tombstone-naming event forever delays the tombstone (and keypair) + removal. No event is lost, and nothing logs this case. + +## Consequences + +- Deletion is visible instantly and has no undo; once the purge has run the data is gone. +- Federation and storage failures cannot strand an account half-deleted: the purge is + redelivered and each step tolerates a re-run. +- The worker owns the destructive work, so the server needs no new infrastructure beyond the + outbox it already writes to.