Repository navigation
[ios] In-app account deletion (App Store 5.1.1(v)) - #38
Merged
Merged
Conversation
…ction App Store Review Guideline 5.1.1(v) requires in-app account deletion for any app that creates accounts. Cabo creates them via Sign in with Apple and email OTP and had no delete path. The migration first defuses two foreign keys that would have made deletion catastrophic: coach_profiles.user_id and .user_email both cascaded, so deleting one member destroyed every coach they owned — including, for the founder, the five default personas, which still carry their address in the legacy user_email column. Both become ON DELETE SET NULL. delete_member_account() then does the whole public-schema cascade in one transaction and decides explicitly what happens to coaches the member created: detached if they belong to another creator, retained-and-unlisted if other members are subscribed, deleted if nobody else uses them. The Cloud Function deletes the reference photo objects from the member-media bucket FIRST, sweeping the whole per-member prefix the way coach-visualizer's likeness revocation does, and refuses to touch the database if that fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its own service account, scoped to the member-media bucket alone — deleting an account never needs the public image bucket or the conversation archive — and public like the other app-facing functions, since the endpoint authenticates the caller from their Supabase JWT and can only ever delete that caller. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Guideline 5.1.1(v) wants this findable, so it sits with the account at the top of Settings rather than buried, and it is a screen inside the app rather than a link out to a website — Apple rejects that too. The confirmation names what gets destroyed with live counts, and gates on typing DELETE: a two-button alert is one mis-tap away from an irreversible action. The loudest thing on the screen is that this does not cancel Apple subscriptions, because it cannot — they are held against the Apple ID, not the Cabo account — with a link straight to where they can be cancelled. Someone who deletes their account and keeps being charged is entitled to be furious, and finding that out afterwards is how that happens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The claim that needs proving is not "the row is gone" but "the photograph is gone", so the probe drives the function in process against a fake Cloud Storage JSON API — @google-cloud/storage talks to STORAGE_EMULATOR_HOST when it is set, so the real client issues real list and delete calls and the object is observably absent at the end. 64 checks: every table empty for the member, the auth identity gone, the old token dead, signing in again a genuinely new account; the prefix swept rather than the single path the column named; the coach with a paying subscriber kept working while the one nobody used is deleted; get_my_coaches, get_coach_roster and subscriber_count all still honest afterwards; and four ways of not confirming that delete nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the decision that coaches a departing member created are neither deleted with them nor left orphaned — detached, retained-and-unlisted, or deleted, depending on whether anyone else depends on them — and why the two cascading foreign keys had to go first. Also logs a pre-existing bug found while running the probes: the coach detail read fails for anon because the legacy phone-era RLS policies subquery user_profiles directly rather than going through owns_coach(). Reproducible on main; four failures in rls-probe.mjs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Closes #34.
App Store Review Guideline 5.1.1(v) requires in-app account deletion for any app that supports account creation. Cabo creates accounts two ways (Sign in with Apple, email OTP) and had no delete path, so a public submission would very likely be rejected on the first pass.
What a member sees
Settings → Account → Delete account, at the top with the account it deletes rather than buried, and a screen inside the app rather than a link out to a website — Apple rejects that too.
The screen names what will be destroyed with live counts read from
/account-deletion/preview, and gates on typing the word DELETE. A two-button alert is one mis-tap away from an irreversible action.The loudest thing on it is that this does not cancel Apple subscriptions, because it cannot — they are held by Apple against the Apple ID, not by us — with a link straight to the App Store subscription settings. Someone who deletes their account and keeps being charged is entitled to be furious, and is an App Review risk in their own right.
What actually happens
functions/account-deletion, service role, in this order and for this reason:user_profiles.reference_photo_urlcan hold a photograph of the member's face. The function calls the samedeleteReferencePhotosused bycoach-visualizer's likeness revocation (byte-identical vendored copy offunctions/shared/reference-photo.js), which sweeps the wholemember-reference/<user id>/prefix rather than the single path the column names — so a photo stored under an older extension cannot survive. The bucket setssoft_delete_policyretention to 0 so the bytes really go. If this fails, nothing else happens: an orphaned photograph of somebody who no longer has an account is worse than a deletion they have to retry. One retry, then 503 with a message they can act on.delete_member_account()— one transaction, so a half-deleted account is not a possible state:coach_nudges,coach_visualizations,conversation_messages,conversations,member_goals,coach_subscriptions,push_devices, the legacysubscriptionsrow,creator_profiles,user_profiles.auth.usersrow, through GoTrue's admin API so identities, sessions and refresh tokens go with it and the current token stops working.Beyond the list in the issue
Reading the schema rather than trusting the list turned up four things:
coach_nudgesalso keys offuser_idand was not in the issue's list.subscriptions(the legacy Stripe/SMS row) referencesuser_profiles(phone_number)with noON DELETEaction, so leaving one behind does not orphan anything — it makes theuser_profilesdelete fail outright.user_profilesrows may be keyed byid = auth.users.idor by email rather than byuser_id(handle_new_user, the SMS funnel). All three are matched.creator_profiles.user_idis alreadyON DELETE SET NULL, which by itself leaves the row sitting there with the person's name, bio, avatar, email and payout account in it. That is not deletion.The foreign keys that had to go first
coach_profileshad both owner links cascading:Deleting one member silently deleted every coach they owned, and each of those cascaded into other people's
coach_subscriptions,conversations,conversation_messages,member_goals,coach_content_chunks,coach_iap_productsandcoach_nudges— for members paying Apple monthly for that coach.Worse in practice:
20250531093301seeded the five default personas against the founder's ownuser_profilesrow, and20260811090100deliberately leftuser_emailalone when it repointed their attribution at the platform creator. The founder deleting their own account would have taken Zen Master, Gym Bro, Dance Teacher, Drill Sergeant and Frat Bro with it.Both are now
ON DELETE SET NULL(anduser_emailbecomes nullable, sinceSET NULLcannot fire on aNOT NULLcolumn). The worst case is now an unowned coach rather than a deleted one — anddelete_member_account()never leans on either, it decides explicitly.Decision: creator-owned coaches are neither deleted nor orphaned
user_id/user_emailcleared; listing and attribution untouched.Their
creator_profilesrow is deleted if no coach still points at it, and otherwise survives withuser_id, display name, slug, bio, avatar, links, email and payout details all replaced — the slug included, because slugs are usually somebody's name and they appear in the public roster.Documented in
docs/multi-domain-coaches.md§ Deleting an account.Read paths that must not break
get_coach_roster()andget_my_coaches()bothLEFT JOIN creator_profiles, so a scrubbed or absent creator does not drop rows.coach_profiles.subscriber_countstays honest because theAFTER DELETEtrigger oncoach_subscriptionsrecomputes it per row. Both are asserted on after a deletion, from the other member's session.Proving the photo is gone
mobile/e2e/account-deletion-probe.mjs, 64 checks, and the interesting part is how the storage claim is made rather than assumed.@google-cloud/storagesends every request toSTORAGE_EMULATOR_HOSTwhen it is set, so the probe stands upharness/fake-gcs.mjs— a four-endpoint stand-in for the Cloud Storage JSON API — and the real client issues real list and delete calls against it. The probe seeds two objects under the member's prefix (one under an older extension), and afterwards asserts:Plus: every table empty for the member, the auth identity gone, the old access token dead, signing in again a genuinely new account with no history, the retained/detached/deleted coach outcomes, the other member's threads and entitlement intact, and four ways of not confirming (no session, empty body,
"delete-it",true) that delete nothing.Verification
cd mobile && npx tsc --noEmit— cleannode --checkonfunctions/account-deletion/{index,reference-photo}.jsand the harness files — cleancd _infra && terraform validate— Successsupabase db resetfrom scratch with the new migration, and a re-apply on top of an already-migrated database — both clean (idempotent)account-deletion64/64,flow34/34,viz-realtime33/33,sms-image55/55,prompt-eval/crisis126/126rls-probe44 passed / 4 failed — pre-existing onmain, reproduced on adb resetwith this migration removed. The coach-detail embedded join returnspermission denied for table user_profilesbecause the legacy phone-era policies (Users can view their own coaches) subqueryuser_profilesdirectly instead of going through theSECURITY DEFINERowns_coach(), andanonhas no grant on that table. Logged under Known follow-ups rather than fixed here; the fix is to rewrite those three policies the way the newerby uidones already are.Not in scope
Club membership (issue #34 step 5) — #31 has not landed, there are no club tables yet. The deletion is a single transaction keyed on
user_id, so adding a club table is oneDELETEindelete_member_account().🤖 Generated with Claude Code