Skip to content

feat(mobile): add in-app permanent account deletion - #481

Open
xiaoland wants to merge 10 commits into
masterfrom
yihong/code-292
Open

feat(mobile): add in-app permanent account deletion#481
xiaoland wants to merge 10 commits into
masterfrom
yihong/code-292

Conversation

@xiaoland

@xiaoland xiaoland commented Aug 25, 2026

Copy link
Copy Markdown
Member

Cross-repo context

One-third of CODE-292 (App Store Guideline 5.1.1(v) in-app account deletion). This is the mobile-facing surface: destructive entry point, server-directed re-authentication, one delete mutation, and local teardown.

Companion PRs: linkcodehq#51, auth#20. Design, decisions, and the full verification record live in the local CODE-292 task packet.

Rollout order

Merge and deploy auth#20 and linkcodehq#51 before cutting any TestFlight or App Store build from this PR. This ordering is a release gate: the mobile deletion button must not ship until the deployed Cloud serves GET /account/deletion-requirements and DELETE /account and the deployed Auth serves the revocation contract.

Summary

  • deleteAccount() first reads the server-owned native/browser requirement; device capability is never treated as an account fact. A device that cannot satisfy a required native Apple flow gets an explicit, non-retryable device message.
  • Native re-authentication supplies the fresh IdP token and Apple authorization code. Ordinary Apple sign-in does not require the deletion-only authorization code.
  • Browser re-authentication requires the authoritative Cloud session id to change while the Cloud user id remains the same, so cancellation and account switching cannot reach deletion.
  • Failures are tagged by requirements, native provider, IdP token, browser sign-in, Cloud identity, response, or transport stage; intentional Apple cancellation and known device limitations are not reported.
  • runAccountDeletionTeardown() clears both local authentication states, device enrollment, and tunnel-derived hosts while preserving direct/LAN hosts.

Verification

  • Related Mobile tests: 3 files, 23 passed.
  • Full pnpm check:ci passes.
  • Full pnpm test: 355 files passed, 1 skipped; 3038 tests passed, 1 skipped.
  • Continuous iOS journey passed: register through ArcBox Auth UI → return to LinkCode → use the app and persist a direct host → browser re-authenticate → delete → observe success → cold launch signed out.
  • Cloud user/account/session/device rows are gone; the central Auth user/session remain. Direct hosts CODE292 Journey and Probe survive teardown.
  • The negative browser case was also observed: an incomplete authorization flow did not send DELETE /account.

Known gaps (non-blocking)

  • The native Apple/SIWA revocation branch remains unverified end-to-end because the local Auth environment lacks real Apple provider credentials.

Copilot AI lite review requested due to automatic review settings August 25, 2026 13:06
@linear-code

linear-code Bot commented Aug 25, 2026

Copy link
Copy Markdown

CODE-292

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds permanent in-app account deletion to the mobile account screen, including server-directed reauthentication and local teardown.

  • Adds native Apple and browser-based reauthentication with account-consistency checks.
  • Calls the Cloud deletion endpoint and distinguishes completed, pending, reauthentication, device-capability, and failure outcomes.
  • Clears local authentication, device enrollment, and tunnel-derived hosts after deletion is accepted.
  • Adds localized UI, endpoint overrides, and focused deletion and reauthentication tests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/mobile/src/runtime/cloud/deletion.ts Implements requirement lookup, reauthentication dispatch, deletion-result classification, and best-effort local teardown; the previously reported transport-response issue is fixed.
apps/mobile/src/runtime/cloud/account.ts Adds authoritative browser reauthentication checks requiring a new session for the same Cloud user.
apps/mobile/src/runtime/cloud/idp.ts Refactors native Apple authentication to return fresh IdP and authorization credentials for deletion while retaining normal sign-in.
apps/mobile/src/components/account/delete-account-section.tsx Adds confirmation, busy-state handling, outcome-specific messaging, and teardown after accepted deletion.
apps/mobile/src/runtime/cloud/tests/deletion.test.ts Covers deletion requirements, reauthentication branches, response classification, transport failure handling, and teardown behavior.

Sequence Diagram

sequenceDiagram
    actor User
    participant App as Mobile App
    participant Cloud as LinkCode Cloud
    participant IdP as Central IdP / Apple
    User->>App: Confirm account deletion
    App->>Cloud: GET deletion requirements
    Cloud-->>App: native or browser
    App->>IdP: Reauthenticate
    IdP-->>App: Fresh identity proof
    App->>Cloud: DELETE account
    alt Request fails or is rejected
        Cloud-->>App: Failure
        App-->>User: Keep local state and show error
    else Deletion accepted
        Cloud-->>App: completed or pending
        App->>App: Clear sessions, enrollment, and tunnel hosts
        App-->>User: Show deletion result
    end
Loading

Reviews (7): Last reviewed commit: "Update apps/mobile/src/runtime/cloud/acc..." | Re-trigger Greptile

Comment thread apps/mobile/src/runtime/cloud/deletion.ts Outdated
Comment thread apps/mobile/src/runtime/cloud/deletion.ts
A thrown fetch means no response ever arrived; report it as a
retryable failure instead of tearing down local state on a guess.
Copilot AI review requested due to automatic review settings August 26, 2026 07:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread apps/mobile/src/runtime/cloud/deletion.ts
@xiaoland

Copy link
Copy Markdown
Member Author

Pushed a fix for the transport-failure P1 (7343154): a thrown fetch (no HTTP response ever received) now returns { kind: 'failed' } instead of { kind: 'pending' } — no local teardown, no "deletion received" message, and the user can safely retry since the server's deletion CAS is idempotent either way. The unparseable-success-body case is unchanged (pending) since an HTTP 2xx did occur there.

Recorded as D-23 in the task packet (gitignored, local only). Two things from that review thread are deliberately deferred, not fixed here:

  • The second P1 (failed teardown steps are never retried on next launch/foreground) — real gap, no code change in this pass.
  • The fuller idempotency-key + deletion-status/replay-endpoint design you suggested — not required for correctness given the existing idempotent CAS, but would remove the need for a blind client retry. Follow-up.

Matches the linkcodehq-side rename (D-24): the field is a
provider-agnostic deletion-completion status, not something mobile or
linkcodehq should name after a specific provider.
Copilot AI review requested due to automatic review settings August 26, 2026 07:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@xiaoland

xiaoland commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

Verification (in-progress)

  1. auth Web sign up -> make some operations, produce some user data -> Delete Account
  2. SIWA -> make some operations -> Delete Account

1

image image

Probe:

  • Cloud user/account/session/device are all 0
  • Central Auth user/session reserved。
  • Cold-start keeps logging-out, no new/reuse Cloud user

2

Wait for test-flight and auth+linkcodehq's staging environment.

Copilot AI review requested due to automatic review settings August 28, 2026 06:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@xiaoland
xiaoland marked this pull request as ready for review August 28, 2026 14:10
@xiaoland

Copy link
Copy Markdown
Member Author

@copilot please fix the merge conflicts in this pull request.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

On the browser re-authentication branch, reauthenticateToCloud() proves the Cloud session changed but never proves it still belongs to the same account — so a system browser signed in as a different LinkCode account leads to DELETE /account permanently deleting the wrong one. Details inline on account.ts.

Reviewed changes — full initial review of the mobile-facing third of CODE-292 (7 commits, 12 files) at 74e5b902; the Cloud and IdP halves live in other repos and were not reviewed.

  • New deletion clientruntime/cloud/deletion.ts reads the server-owned native/browser requirement, re-authenticates on that branch, issues one DELETE /account, and maps the result into a four-variant outcome union with per-stage Sentry tagging.
  • Local teardownrunAccountDeletionTeardown() runs cloud sign-out, IdP sign-out, and device-enrollment clearing under Promise.allSettled, then removes only tunnel-derived hosts, preserving direct/LAN profiles.
  • Shared Apple re-auth coreidp.ts factors sign-in and deletion re-auth into a private authenticateWithAppleNatively(), adds an Apple state round-trip check, requires credential.authorizationCode, and introduces IdpTokenAcquisitionError.
  • Browser re-authaccount.ts gains reauthenticateToCloud(), which re-runs the OAuth flow and asserts the authoritative session id changed.
  • Destructive entry pointDeleteAccountSection renders a role="destructive" button in its own Section below Sign out, behind a confirm alert, with outcome-specific copy in en and zh-cn.
  • Dev-stack overridesEXPO_PUBLIC_CLOUD_URL / EXPO_PUBLIC_IDP_URL now override the production Cloud and IdP origins; /tasks/ is gitignored.

I ran the new tests locally (pnpm vitest run --project mobile apps/mobile/src/runtime/cloud/__tests__): 18 passed, matching the PR body. I also confirmed three things that looked suspicious but are correct, so nobody re-litigates them: the Apple state round-trip is genuinely supported (expo-apple-authentication types it on AppleAuthenticationSignInOptions, and ios/AppleAuthenticationRequest.swift sets request.state and echoes credential?.state), 'tunnelHostId' in host is a sound discriminant against the HostProfile zod union, and the IdpTokenAcquisitionError wrapping does not defeat isAppleSignInCancel because signInAsync is called before the new try block.

⚠️ Nothing gates this client against a Cloud that does not yet serve these endpoints

GET /account/deletion-requirements and DELETE /account land in linkcodehq#51, and the IdP-side revocation in auth#20. This PR has no feature flag, no capability probe, and no version negotiation, so a mobile build cut before those deploy ships a Delete Account button whose only possible outcome is the requirements-stage failure — the user sees the generic "Could not delete your account. Please try again.", and Sentry collects one account_deletion_stage: requirements report per tap. Worth stating the intended merge/release order explicitly, since the App Store review that motivates CODE-292 will be looking at a shipped binary.

Technical details
# Cross-repo rollout ordering for the deletion endpoints

## Affected sites
- `apps/mobile/src/runtime/cloud/deletion.ts:70-82` — the `requirements` read is
  unconditional and its only failure mode is a generic `{ kind: 'failed' }`.
- `apps/mobile/src/components/account/delete-account-section.tsx:73-79` — the button is
  always rendered whenever the account screen renders its signed-in subtree.

## Required outcome
- A mobile binary can never reach TestFlight/App Store with a Delete Account button that
  the deployed Cloud cannot service.

## Open questions for the human
- Is the intended order "merge + deploy `linkcodehq#51` and `auth#20`, then cut the
  mobile build", enforced only by process? If so, say so in the PR description so the
  release cut is not a judgement call.
- If mobile can ship first, does the button need to hide itself when the requirements
  read returns 404 (endpoint absent) as distinct from 5xx (endpoint down)?

⚠️ The new comments cite design documents that this PR makes permanently unreachable

Commit 6147b31b gitignores /tasks/, and the PR description confirms tasks/CODE-292/ is "intentionally untracked" — yet the code added here cites it eight times (design.md §3.4, design.md §3.5, D-5, D-7, D-19, D-23, CODE-292 §3.5). client.ts:12 also points at AGENTS.local.md, which does not exist anywhere in the repo. Root AGENTS.md is explicit on both counts — "No CODE-xxx issue references — traceability belongs in commits and PR descriptions" and "Design rationale longer than two lines belongs in the owning AGENTS.md, not inline" — so these should either move into apps/mobile/AGENTS.md or shrink to the constraint they encode.

Technical details
# Inline comments reference untracked design docs and a nonexistent file

## Affected sites
- `apps/mobile/src/runtime/cloud/client.ts:12` — cites `AGENTS.local.md`; `ls` finds no
  such file at the repo root or under `apps/mobile`, and `.gitignore` does not mention it.
- `apps/mobile/src/runtime/cloud/deletion.ts:15``CODE-292:` prefix on the module doc.
- `apps/mobile/src/runtime/cloud/deletion.ts:100-103` — "D-19's accepted gap".
- `apps/mobile/src/runtime/cloud/deletion.ts:122-127` — "D-23, reversing the original §3.4 call".
- `apps/mobile/src/runtime/cloud/deletion.ts:186` — "design.md §3.5".
- `apps/mobile/src/runtime/cloud/idp.ts:33`, `:120`, `:130``CODE-292 D-7`,
  `CODE-292 D-5/D-19`, `CODE-292 §3.5`.
- `apps/mobile/src/components/account/delete-account-section.tsx:45` — "design.md §3.4, TN3194".

## Required outcome
- No comment in the tree points at a document a reader cannot open. The constraints
  these comments genuinely encode (why teardown is best-effort, why a lost response is
  never reported as accepted, why direct hosts survive) stay discoverable.

## Suggested approach
- Keep the durable rationale — it is good rationale — but move it to
  `apps/mobile/AGENTS.md`, which already owns this app's traps, and reduce each inline
  comment to the one- or two-line constraint. `TN3194` can stay: it is a stable public
  Apple technote, unlike `design.md`.
- Drop the bare `D-nn` / `CODE-292` tokens; the commit messages and this PR body already
  carry that traceability.

ℹ️ The two new EXPO_PUBLIC_* variables are missing from docs/ENVIRONMENT.md

docs/ENVIRONMENT.md tabulates every other build-time mobile variable (EXPO_PUBLIC_SENTRY_DSN, EXPO_PUBLIC_POSTHOG_PROJECT_TOKEN, EXPO_PUBLIC_POSTHOG_HOST), and root AGENTS.md routes "read, add, or override an environment variable" straight at that file. EXPO_PUBLIC_CLOUD_URL and EXPO_PUBLIC_IDP_URL are documented only in env.d.ts JSDoc and a client.ts comment. These two are more load-bearing than the telemetry ones — an accidentally-set value repoints auth and account deletion at another origin — so the reference table is exactly where they belong.

Technical details
# Document EXPO_PUBLIC_CLOUD_URL and EXPO_PUBLIC_IDP_URL

## Affected sites
- `docs/ENVIRONMENT.md` — build-time mobile table (around lines 81-85) lists every other
  `EXPO_PUBLIC_*` variable; these two are absent.
- `apps/mobile/src/env.d.ts:14-17` — declares them.
- `apps/mobile/src/runtime/cloud/client.ts:16` and
  `apps/mobile/src/runtime/cloud/idp.ts:16` — read them.

## Required outcome
- Both variables appear in the `docs/ENVIRONMENT.md` mobile build-time table, stating
  that they are inlined by Metro/EAS, that unset means production, and that they are for
  local `svc dev` stacks only.

ℹ️ Nitpicks

  • deleteInProgress was added to both en.ts:1287 and zh-cn.ts:1251 but has no call site — DeleteAccountSection only reflects busy via disabled(busy). Either wire it to a progress affordance (the browser branch involves a full round trip to the system browser, so the button silently greying out is thin feedback) or drop both strings.
  • deletion.ts:14-19 states runAccountDeletionTeardown "is exported separately only so a retry (best-effort, on next launch/foreground) can re-run just that part." The only non-test call site is delete-account-section.tsx:38; there is no launch or foreground retry, no AppState listener, and no persisted pending-teardown flag. Either build the retry or drop the clause, since as written the comment justifies the export with code that does not exist.
  • deletion.ts:146-147 says an unparseable success body is "ambiguous in the same way a network failure is" — but commit 73431547 deliberately made the network-failure branch return failed, not pending. The two comments now contradict each other; the honest distinction is that a 2xx was received here, so acceptance is known rather than guessed.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread apps/mobile/src/runtime/cloud/account.ts
Comment thread apps/mobile/src/runtime/cloud/idp.ts
Comment thread apps/mobile/src/runtime/cloud/deletion.ts
Comment thread apps/mobile/src/runtime/cloud/deletion.ts
Copilot AI review requested due to automatic review settings August 30, 2026 02:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

pullfrog[bot]

This comment was marked as resolved.

Copilot AI review requested due to automatic review settings August 30, 2026 03:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — minor suggestions inline.

The blocking finding from my last review is closed, and I verified the mechanism rather than taking the shape of the fix on trust. One cosmetic nit inline on account.ts.

Reviewed changes — the delta since my review at 9f610fe8: one commit, 5274851 fix(mobile): make deletion reauthentication fail safe.

  • Fail-safe sign-out before the mismatch throwreauthenticateToCloud() now calls cloudAuthClient.signOut() and throws the new CloudAccountMismatchError when userId changes, so the retry the previous copy invited can no longer land on the other account's session.
  • New terminal account-mismatch outcomedeletion.ts maps CloudAccountMismatchError to its own outcome with dedicated en / zh-cn copy, keeping "a different account signed in" distinguishable from a dismissed browser. Not reported to Sentry, which reads as deliberate — a user with two accounts is a normal event.
  • Response telemetry widened>= 500 became !== 409, so a 400/403 contract break now produces a response-stage event instead of a silent generic failure. Covered by a new 403 test.
  • authorizationCode guard folded back into the shared corereauthenticateWithApple is now a plain alias of authenticateWithAppleNatively(), and idp.test.ts is deleted. That test drove signInAsync through a mock resolving authorizationCode: null, a state the real wrapper rejects before returning (node_modules/expo-apple-authentication/build/AppleAuthentication.js:44-46), so it covered a split that no longer exists — no real coverage lost.

On the fix itself: it is fail-safe by construction, not just on the happy path. cloudAuthClient.signOut() clears the local session before any network I/O — node_modules/@better-auth/expo/dist/client.js:410 calls clearSessionCache() from inside the init plugin hook, and clearSessionCache (client.js:270-279) writes "{}" to both the cookie and the local cache and nulls the session atom. So an offline or failing /sign-out request still cannot leave the other account's cookie behind, and readAuthoritativeSession()'s ?disableCookieCache=true means no residual data can make it observe the wrong identity either. The two prior-round informational items — the Android dead end for native accounts and the cross-repo rollout ordering — are unchanged and still open as product/process decisions; I'm not re-raising them here.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread apps/mobile/src/runtime/cloud/account.ts Outdated
Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 30, 2026 04:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — the delta since my review at 5274851: one commit, ea8878bc Update apps/mobile/src/runtime/cloud/account.ts, which is a single line.

  • Mismatch sign-out no longer masks its own outcomeaccount.ts:70 became await cloudAuthClient.signOut().catch(noop), so a rejecting sign-out can no longer replace CloudAccountMismatchError with a raw error. deletion.ts:105's instanceof check now always matches, and a cross-account browser re-authentication always terminates in the account-mismatch copy ("A different account was signed in…") rather than the generic "Could not confirm it's you. Please try again."

That is the suggestion from my last review applied verbatim, and it holds up on inspection rather than on shape: foxts/noop exports noop as Noop = (...args: any[]) => any (node_modules/foxts/dist/noop/index.d.ts), so it is a valid catch handler and not one of the foxts helpers whose name misleads; it was already imported at account.ts:2, so the diff adds no import. I ran npx eslint apps/mobile/src/runtime/cloud/account.ts (clean) and npx vitest run --project mobile apps/mobile/src/runtime/cloud/__tests__ (2 files, 23 tests passed) at this head. account.test.ts:84's expect(mocks.signOut).toHaveBeenCalledTimes(1) still pins that sign-out happens before the throw, which is the load-bearing half of the guarantee — the swallowed rejection is a best-effort network call whose local effect (clearSessionCache runs inside the init plugin hook, before any I/O) has already landed.

Nothing else in the tree changed, and all six of my earlier inline threads are resolved. The two standing informational items — the Android dead end for accounts the server marks native, and the cross-repo rollout ordering against linkcodehq#51 / auth#20, which the PR body now states as a release gate — are unchanged product and process decisions, not code findings; I'm not re-raising them.

Pullfrog  | View workflow run | Using Claude Opus𝕏

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.

2 participants