fix(#243,#247,#248): close the GDPR group-messaging bug cluster#278
Merged
Conversation
Three confirmed Gap-Audit bugs about group conversations, each verified against the real code + live DB before fixing and re-verified after. A 3-agent adversarial design-review caught five defects in the initial designs — all addressed here. #247 Account deletion blocked for group creators (right-to-erasure): both conversations.created_by and group_keys.created_by were ON DELETE NO ACTION, so a user who created a group or distributed group keys could not be deleted. Change both FKs to ON DELETE SET NULL (deleting the creator preserves the group and every OTHER member's key row — CASCADE would destroy the group for everyone), make group_keys.created_by nullable, and relax CHK023 to allow a NULL creator. Adversarial-review catch: SET NULL then let a sole owner be erased, orphaning the group with no owner — every isOwner-gated op (rename/remove/delete/transfer) would fail forever. Added a BEFORE DELETE trigger on conversation_members that auto-transfers ownership to the earliest-joined survivor, so the "a group with members always has an owner" invariant holds through erasure. #248 Data export omits group conversations (data-portability): exportUserData enumerated conversations by participant_1/2 columns, which are NULL for groups, so groups never appeared and their messages were silently absent. Enumerate groups via conversation_members (active memberships), decrypt each message through the group-key path at its own key_version (new shared group-message-decrypt helper, order-agnostic — no .reverse() coupling), include group_name + member roster, and count them in the statistics. Review catches: call restoreKeysFromCache() before the group loop (the export page has no EncryptionKeyGate, so keys are cold → placeholders otherwise); surface system-message plaintext markers instead of running them through decryption. #243 Rotating a group creator's personal keys bricked group history: the reader unwrapped the group key with the creator's CURRENT public key (getUserPublicKey filters revoked=false); after the creator rotated, ECDH mismatched and history bricked with "Encrypted with previous keys" for EVERY member. Capture the creator's wrap-time public JWK on each group_keys row (creator_public_key) and unwrap against THAT; legacy rows fall back to getUserPublicKeyAt, a time-window resolver over the retained key history. A one-shot backfill (run via the Management API, since the immutable-keys UPDATE policy blocks client writes) repaired all 331 existing rows — un-bricking past history. Review catch: the fallback is null-safe on created_by (which #247 can SET NULL). Verification: #243 proven with REAL crypto (group-key-roundtrip.node.test — wrap, rotate the creator, prove unwrap with the current key FAILS and with the stored key SUCCEEDS + a pre-rotation message still decrypts). #247 proven live (RLS DB-contract tests: erasure succeeds, group survives with created_by nulled, ownership auto-transfers to the survivor). #248 unit-tested (group enumeration, roster, decrypt threading, cold-key restore, stats). Full 68-test RLS suite and 179 messaging unit tests green; type-check + lint clean. Schema applied live via the Management API (idempotent, monolithic migration). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the GDPR / group-messaging subset of the Gap-Audit cluster: #243, #247, #248 — the trio about group conversations. Each was verified against the real code + live DB before fixing, and a 3-agent adversarial design review caught five defects in the initial designs that are all fixed here.
The three fixes
#247 — Account deletion blocked for group creators (right-to-erasure)
conversations.created_by+group_keys.created_bywereON DELETE NO ACTION, so a user who created a group or distributed group keys couldn't be erased.ON DELETE SET NULL(deleting the creator preserves the group + every other member's keys; CASCADE would destroy the group for everyone).created_bymade nullable, CHK023 relaxed.isOwner-gated op (rename/remove/delete/transfer) would fail forever. Added aBEFORE DELETEtrigger onconversation_membersthat auto-transfers ownership to the earliest-joined survivor.#248 — Data export omits group conversations (data-portability)
exportUserDataenumerated byparticipant_1/2columns — NULL for groups → groups silently absent.conversation_members, decrypt each message through the group-key path at its ownkey_version(new order-agnosticgroup-message-decrypthelper), includegroup_name+ member roster, count in stats.restoreKeysFromCache()before the group loop (the export page has noEncryptionKeyGate→ cold keys → placeholders otherwise); surface system-message plaintext markers instead of decrypting them to a placeholder.#243 — Rotating a group creator's personal keys bricked group history
The reader unwrapped with the creator's current public key; after rotation, ECDH mismatched → history bricked for every member.
group_keysrow (creator_public_key) and unwrap against it; legacy rows fall back to a time-window resolver over the retained key history.created_by(which Account deletion fails for users who created group chats (GDPR erasure blocked) #247 can SET NULL).Verification
group-key-roundtrip.node.testwraps a key, rotates the creator, proves unwrap with the current key fails and with the stored key succeeds, and a pre-rotation message still decrypts.created_bynulled, ownership auto-transfers to the survivor.type-check+lintclean.Schema applied live via the Management API (idempotent, in the monolithic migration) — no separate merge-time DB step needed; the backfill already ran. No edge-function changes.
🤖 Generated with Claude Code