Skip to content

[ios] In-app account deletion (App Store 5.1.1(v)) - #38

Merged
Harrolee merged 5 commits into
mainfrom
ios/account-deletion
Aug 11, 2026
Merged

Harrolee merged 5 commits into
mainfrom
ios/account-deletion

Conversation

@Harrolee

Copy link
Copy Markdown
Owner

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:

  1. The reference photo objects, first. user_profiles.reference_photo_url can hold a photograph of the member's face. The function calls the same deleteReferencePhotos used by coach-visualizer's likeness revocation (byte-identical vendored copy of functions/shared/reference-photo.js), which sweeps the whole member-reference/<user id>/ prefix rather than the single path the column names — so a photo stored under an older extension cannot survive. The bucket sets soft_delete_policy retention 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.
  2. Every public-schema row, through 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 legacy subscriptions row, creator_profiles, user_profiles.
  3. The auth.users row, 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_nudges also keys off user_id and was not in the issue's list.
  • subscriptions (the legacy Stripe/SMS row) references user_profiles(phone_number) with no ON DELETE action, so leaving one behind does not orphan anything — it makes the user_profiles delete fail outright.
  • Legacy user_profiles rows may be keyed by id = auth.users.id or by email rather than by user_id (handle_new_user, the SMS funnel). All three are matched.
  • creator_profiles.user_id is already ON 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_profiles had both owner links cascading:

user_id    uuid REFERENCES auth.users(id)      ON DELETE CASCADE
user_email text REFERENCES user_profiles(email) ON DELETE CASCADE

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_products and coach_nudges — for members paying Apple monthly for that coach.

Worse in practice: 20250531093301 seeded the five default personas against the founder's own user_profiles row, and 20260811090100 deliberately left user_email alone 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 (and user_email becomes nullable, since SET NULL cannot fire on a NOT NULL column). The worst case is now an unowned coach rather than a deleted one — and delete_member_account() never leans on either, it decides explicitly.

Decision: creator-owned coaches are neither deleted nor orphaned

Case Outcome
Attributed to another creator (the default-personas case) Detached. user_id/user_email cleared; listing and attribution untouched.
Theirs, and other members are subscribed or have threads Retained and unlisted. It keeps working for the people paying for it; nobody new can subscribe to a coach with nobody behind it.
Theirs, and nobody else uses it Deleted, with its content chunks and store products.

Their creator_profiles row is deleted if no coach still points at it, and otherwise survives with user_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() and get_my_coaches() both LEFT JOIN creator_profiles, so a scrubbed or absent creator does not drop rows. coach_profiles.subscriber_count stays honest because the AFTER DELETE trigger on coach_subscriptions recomputes 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/storage sends every request to STORAGE_EMULATOR_HOST when it is set, so the probe stands up harness/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:

  • both objects are absent from the bucket, and nothing at all remains under the prefix;
  • the client genuinely issued the deletes;
  • the sweep was by prefix, not by the URI in the column;
  • with deletes failing, the request returns 503 and the profile, the messages and the photo are all still there.

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 — clean
  • node --check on functions/account-deletion/{index,reference-photo}.js and the harness files — clean
  • cd _infra && terraform validate — Success
  • supabase db reset from scratch with the new migration, and a re-apply on top of an already-migrated database — both clean (idempotent)
  • Probes against the local stack: account-deletion 64/64, flow 34/34, viz-realtime 33/33, sms-image 55/55, prompt-eval/crisis 126/126
  • rls-probe 44 passed / 4 failed — pre-existing on main, reproduced on a db reset with this migration removed. The coach-detail embedded join returns permission denied for table user_profiles because the legacy phone-era policies (Users can view their own coaches) subquery user_profiles directly instead of going through the SECURITY DEFINER owns_coach(), and anon has 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 newer by uid ones 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 one DELETE in delete_member_account().

🤖 Generated with Claude Code

Harrolee and others added 5 commits August 11, 2026 17:12
…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>
@Harrolee
Harrolee merged commit de73523 into main Aug 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ios] No in-app account deletion, which App Store review requires

1 participant