From 6147b31b12db8c11042db6ac02cbcae3c257cf1c Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Tue, 25 Aug 2026 15:15:00 +0800 Subject: [PATCH 1/9] chore: ignore volatile task packets --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ca71308d3..dc11ae93e 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,9 @@ devenv.local.yaml .amp/* !.amp/services.yaml +# Volatile task packets +/tasks/ + # Vendored Effect source for the effect-ts skill's research prerequisite (see root AGENTS.md). # Bootstrap: git clone https://github.com/Effect-TS/effect-smol .repos/effect .repos/ From 5d7694193847e2f0ecba521801c243ce9ade46f7 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Tue, 25 Aug 2026 15:18:01 +0800 Subject: [PATCH 2/9] feat(mobile): add cloud account-deletion client --- apps/mobile/src/env.d.ts | 4 + .../runtime/cloud/__tests__/deletion.test.ts | 207 ++++++++++++++++++ apps/mobile/src/runtime/cloud/client.ts | 9 +- apps/mobile/src/runtime/cloud/deletion.ts | 142 ++++++++++++ apps/mobile/src/runtime/cloud/idp.ts | 52 ++++- 5 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts create mode 100644 apps/mobile/src/runtime/cloud/deletion.ts diff --git a/apps/mobile/src/env.d.ts b/apps/mobile/src/env.d.ts index b8848f750..cbd7c956c 100644 --- a/apps/mobile/src/env.d.ts +++ b/apps/mobile/src/env.d.ts @@ -11,6 +11,10 @@ declare namespace NodeJS { EXPO_PUBLIC_POSTHOG_PROJECT_TOKEN?: string; EXPO_PUBLIC_POSTHOG_HOST?: string; EXPO_PUBLIC_CONFIG_SIGNING_POC?: string; + /** Overrides the Cloud API base URL; unset defaults to production. */ + EXPO_PUBLIC_CLOUD_URL?: string; + /** Overrides the central IdP base URL; unset defaults to production. */ + EXPO_PUBLIC_IDP_URL?: string; } } diff --git a/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts b/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts new file mode 100644 index 000000000..2d4da7b08 --- /dev/null +++ b/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + fetchAccount: vi.fn(), + // `vi.hoisted` runs above this file's imports, so `foxts/noop`'s `asyncNoop` + // isn't in scope here yet — these three are the narrow exception to using it. + // eslint-disable-next-line sukka/prefer-foxts-noop -- see above + signOutCloud: vi.fn(() => Promise.resolve()), + // eslint-disable-next-line sukka/prefer-foxts-noop -- see above + signInToCloud: vi.fn(() => Promise.resolve()), + reauthenticateWithApple: vi.fn(), + // eslint-disable-next-line sukka/prefer-foxts-noop -- see above + signOutOfIdp: vi.fn(() => Promise.resolve()), + // eslint-disable-next-line sukka/prefer-foxts-noop -- see above + clearDeviceEnrollment: vi.fn(() => Promise.resolve()), + captureException: vi.fn(), + hosts: [] as Array<{ + id: string; + name: string; + createdAt: number; + tunnelHostId?: string; + url?: string; + }>, + removeHost: vi.fn(), +})); + +vi.mock('@sentry/react-native', () => ({ captureException: mocks.captureException })); + +vi.mock('../client', () => ({ + CLOUD_URL: 'https://api.linkcode.ai', + cloudAuthClient: { + $fetch: mocks.fetchAccount, + signOut: mocks.signOutCloud, + }, +})); + +vi.mock('../account', () => ({ + signInToCloud: mocks.signInToCloud, +})); + +vi.mock('../idp', () => ({ + reauthenticateWithApple: mocks.reauthenticateWithApple, + signOutOfIdp: mocks.signOutOfIdp, +})); + +vi.mock('../devices', () => ({ + clearDeviceEnrollment: mocks.clearDeviceEnrollment, +})); + +vi.mock('@mobile/stores/host-store', () => ({ + useHostRegistryStore: { + getState: () => ({ hosts: mocks.hosts, removeHost: mocks.removeHost }), + }, +})); + +import { deleteAccount, runAccountDeletionTeardown } from '../deletion'; + +afterEach(() => { + vi.clearAllMocks(); + mocks.hosts = []; +}); + +describe('deleteAccount', () => { + it('on the Apple branch, forwards the fresh idpToken and authorizationCode', async () => { + mocks.reauthenticateWithApple.mockResolvedValueOnce({ + idpToken: 'jwt-1', + authorizationCode: 'apple-code-1', + }); + mocks.fetchAccount.mockResolvedValueOnce({ + data: { status: 'completed', revocation: 'completed' }, + error: null, + }); + + const result = await deleteAccount({ isAppleAvailable: true }); + + expect(result).toEqual({ kind: 'completed', revocation: 'completed' }); + expect(mocks.fetchAccount).toHaveBeenCalledWith( + 'https://api.linkcode.ai/account', + expect.objectContaining({ + method: 'DELETE', + body: { idpToken: 'jwt-1', appleAuthorizationCode: 'apple-code-1' }, + }), + ); + }); + + it('on the non-Apple branch, re-runs the browser sign-in and sends the request without an idpToken', async () => { + mocks.fetchAccount.mockResolvedValueOnce({ + data: { status: 'completed', revocation: 'not_applicable' }, + error: null, + }); + + const result = await deleteAccount({ isAppleAvailable: false }); + + expect(result).toEqual({ kind: 'completed', revocation: 'not_applicable' }); + expect(mocks.signInToCloud).toHaveBeenCalledTimes(1); + expect(mocks.reauthenticateWithApple).not.toHaveBeenCalled(); + expect(mocks.fetchAccount).toHaveBeenCalledWith( + 'https://api.linkcode.ai/account', + expect.objectContaining({ + body: { idpToken: undefined, appleAuthorizationCode: undefined }, + }), + ); + }); + + it('on the non-Apple branch, a failed browser sign-in is reauthentication-failed, and never sends the delete request', async () => { + mocks.signInToCloud.mockRejectedValueOnce(new Error('dismissed')); + + const result = await deleteAccount({ isAppleAvailable: false }); + + expect(result).toEqual({ kind: 'reauthentication-failed' }); + expect(mocks.fetchAccount).not.toHaveBeenCalled(); + }); + + it('reports reauthentication-failed without ever sending the delete request', async () => { + mocks.reauthenticateWithApple.mockRejectedValueOnce(new Error('cancelled')); + + const result = await deleteAccount({ isAppleAvailable: true }); + + expect(result).toEqual({ kind: 'reauthentication-failed' }); + expect(mocks.fetchAccount).not.toHaveBeenCalled(); + }); + + it('maps a 401 response to reauthentication-failed', async () => { + mocks.fetchAccount.mockResolvedValueOnce({ data: null, error: { status: 401 } }); + + const result = await deleteAccount({ isAppleAvailable: false }); + + expect(result).toEqual({ kind: 'reauthentication-failed' }); + }); + + it('maps a 409 pre-check response to failed, carrying the biz code through', async () => { + mocks.fetchAccount.mockResolvedValueOnce({ + data: null, + error: { status: 409, code: 'ACCOUNT_DELETION_SOLE_ORGANIZATION_OWNER' }, + }); + + const result = await deleteAccount({ isAppleAvailable: false }); + + expect(result).toEqual({ + kind: 'failed', + code: 'ACCOUNT_DELETION_SOLE_ORGANIZATION_OWNER', + }); + }); + + it('treats a pending server response as pending, carrying the reference through', async () => { + mocks.fetchAccount.mockResolvedValueOnce({ + data: { status: 'pending', reference: 'ref-1' }, + error: null, + }); + + const result = await deleteAccount({ isAppleAvailable: false }); + + expect(result).toEqual({ kind: 'pending', reference: 'ref-1' }); + }); + + it('treats a thrown network error as pending — never as failed', async () => { + mocks.fetchAccount.mockRejectedValueOnce(new Error('offline')); + + const result = await deleteAccount({ isAppleAvailable: false }); + + expect(result).toEqual({ kind: 'pending' }); + }); + + it('treats an unparseable success response as pending rather than completed', async () => { + mocks.fetchAccount.mockResolvedValueOnce({ data: { unexpected: true }, error: null }); + + const result = await deleteAccount({ isAppleAvailable: false }); + + expect(result).toEqual({ kind: 'pending' }); + }); +}); + +describe('runAccountDeletionTeardown', () => { + it('clears the cloud session, the IdP session, and device enrollment', async () => { + await runAccountDeletionTeardown(); + + expect(mocks.signOutCloud).toHaveBeenCalledTimes(1); + expect(mocks.signOutOfIdp).toHaveBeenCalledTimes(1); + expect(mocks.clearDeviceEnrollment).toHaveBeenCalledTimes(1); + }); + + it('removes only tunnel-derived hosts, leaving direct/LAN hosts untouched', async () => { + mocks.hosts = [ + { id: 'host-1', name: 'Tunnel host', createdAt: 1, tunnelHostId: 'device-1' }, + { id: 'host-2', name: 'LAN host', createdAt: 2, url: 'http://192.168.1.5:9000' }, + ]; + + await runAccountDeletionTeardown(); + + expect(mocks.removeHost).toHaveBeenCalledExactlyOnceWith('host-1'); + }); + + it('reports one step failing without throwing, so the others still run', async () => { + mocks.signOutCloud.mockRejectedValueOnce(new Error('network down')); + + await expect(runAccountDeletionTeardown()).resolves.toBeUndefined(); + + expect(mocks.captureException).toHaveBeenCalledTimes(1); + expect(mocks.signOutOfIdp).toHaveBeenCalledTimes(1); + expect(mocks.clearDeviceEnrollment).toHaveBeenCalledTimes(1); + }); + + it('is safe to call again on an already-clean state', async () => { + await runAccountDeletionTeardown(); + await expect(runAccountDeletionTeardown()).resolves.toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/runtime/cloud/client.ts b/apps/mobile/src/runtime/cloud/client.ts index 402d6995e..8a50d3fb0 100644 --- a/apps/mobile/src/runtime/cloud/client.ts +++ b/apps/mobile/src/runtime/cloud/client.ts @@ -6,9 +6,14 @@ import { z } from 'zod'; /** * The single better-auth client for LinkCode Cloud: session cookie in SecureStore, * OAuth in the system browser landing back through the `linkcode://` scheme. + * + * `EXPO_PUBLIC_CLOUD_URL` (an Expo built-in inlined-at-build-time env var, set via + * `.env.local` — gitignored, never committed) overrides the production origin for a + * local dev build pointed at a local `svc dev` stack (CODE-292; see AGENTS.local.md). + * Unset in any build that isn't explicitly configured for local dev, so production + * and EAS builds are unaffected. */ - -export const CLOUD_URL = 'https://api.linkcode.ai'; +export const CLOUD_URL = process.env.EXPO_PUBLIC_CLOUD_URL ?? 'https://api.linkcode.ai'; export const cloudAuthClient = createAuthClient({ baseURL: `${CLOUD_URL}/auth`, diff --git a/apps/mobile/src/runtime/cloud/deletion.ts b/apps/mobile/src/runtime/cloud/deletion.ts new file mode 100644 index 000000000..ff28ec95a --- /dev/null +++ b/apps/mobile/src/runtime/cloud/deletion.ts @@ -0,0 +1,142 @@ +import { useHostRegistryStore } from '@mobile/stores/host-store'; +import * as Sentry from '@sentry/react-native'; +import { z } from 'zod'; +import { signInToCloud } from './account'; +import { CLOUD_URL, cloudAuthClient } from './client'; +import { clearDeviceEnrollment } from './devices'; +import { reauthenticateWithApple, signOutOfIdp } from './idp'; + +/** + * CODE-292: permanent, in-app account deletion. `deleteAccount` is the whole + * flow — reauthentication, the single `DELETE /account` request, and local + * teardown; `runAccountDeletionTeardown` is exported separately only so a + * retry (best-effort, on next launch/foreground) can re-run just that part. + */ + +export type AccountDeletionRevocation = 'completed' | 'failed' | 'not_applicable'; + +export type AccountDeletionOutcome = + | { kind: 'completed'; revocation: AccountDeletionRevocation } + | { kind: 'pending'; reference?: string } + /** Reauthentication itself failed (wrong account, cancelled, expired) — the + * account is untouched; PONR was never reached. */ + | { kind: 'reauthentication-failed' } + /** The delete request failed before any state changed (network error, or + * a 409 pre-check) — the account is untouched. `code` is the server's biz + * code when available (e.g. `ACCOUNT_DELETION_SOLE_ORGANIZATION_OWNER`), + * for copy that names the specific reason. */ + | { kind: 'failed'; code?: string }; + +const deletionResponseSchema = z.object({ + status: z.enum(['completed', 'pending']), + revocation: z.enum(['completed', 'failed', 'not_applicable']).optional(), + reference: z.string().optional(), +}); + +/** + * Re-authenticates and submits the single delete request. `isAppleAvailable` + * mirrors the sign-in screen's own capability check + * (`AppleAuthentication.isAvailableAsync()`) — mobile has no local signal for + * *which* provider a given central identity actually uses (D-19's accepted + * gap), so, like the sign-in screen, this branches on device capability, not + * account provider. + */ +export async function deleteAccount(options: { + isAppleAvailable: boolean; +}): Promise { + let idpToken: string | undefined; + let appleAuthorizationCode: string | undefined; + + try { + if (options.isAppleAvailable) { + const reauth = await reauthenticateWithApple(); + idpToken = reauth.idpToken; + appleAuthorizationCode = reauth.authorizationCode; + } else { + // No local proof to mint for this branch (D-19's accepted gap) — the + // request below instead relies on the server's session-freshness + // check, so re-running the existing browser sign-in flow (which mints + // a fresh session) *is* this branch's re-authentication. + await signInToCloud(); + } + } catch { + return { kind: 'reauthentication-failed' }; + } + + let response: { + data: unknown; + error: { status: number; code?: unknown } | null; + }; + try { + response = await cloudAuthClient.$fetch(`${CLOUD_URL}/account`, { + method: 'DELETE', + body: { idpToken, appleAuthorizationCode }, + }); + } catch { + // Ambiguous — the request may have landed past the point of no return. + // Treat it the same as an accepted-but-unfinished deletion: the account + // must be assumed gone from this point on (design.md §3.4). + return { kind: 'pending' }; + } + + if (response.error) { + if (response.error.status === 401) return { kind: 'reauthentication-failed' }; + // Any other status (409 pre-check, or a pre-PONR 500): the design's + // contract is that only a pre-PONR failure returns an error status at + // all, so the account is untouched either way. + return { + kind: 'failed', + code: typeof response.error.code === 'string' ? response.error.code : undefined, + }; + } + + const parsed = deletionResponseSchema.safeParse(response.data); + if (!parsed.success) { + // The server accepted the request but the response shape is unreadable — + // ambiguous in the same way a network failure is: assume accepted. + return { kind: 'pending' }; + } + if (parsed.data.status === 'pending') { + return { kind: 'pending', reference: parsed.data.reference }; + } + return { kind: 'completed', revocation: parsed.data.revocation ?? 'not_applicable' }; +} + +/** + * Best-effort local cleanup once deletion has been accepted (`completed` or + * `pending` — never call this for `reauthentication-failed` or `failed`, + * where the account is still active). Every step is independent; one + * failing must never look like "deletion failed" to the caller, since the + * server has already committed to it. Safe to call again — every step is + * idempotent on an already-clean state. + */ +export async function runAccountDeletionTeardown(): Promise { + const results = await Promise.allSettled([ + cloudAuthClient.signOut(), + signOutOfIdp(), + clearDeviceEnrollment(), + ]); + for (const result of results) { + if (result.status === 'rejected') { + Sentry.captureException(result.reason); + } + } + try { + removeTunnelHosts(); + } catch (error) { + Sentry.captureException(error); + } +} + +/** Removes every tunnel-derived host profile — account-scoped, so it can't + * outlive the account (design.md §3.5). Direct/LAN profiles, which name a + * URL rather than an account-issued host id, are left alone. Selected-host + * fallback and connection disposal are already handled by existing reactive + * code (`useSelectedHost`, `HostConnectionScope`) once these rows disappear — + * this function must not re-implement either. */ +function removeTunnelHosts(): void { + const { hosts, removeHost } = useHostRegistryStore.getState(); + for (const host of hosts) { + if ('tunnelHostId' in host) removeHost(host.id); + } +} diff --git a/apps/mobile/src/runtime/cloud/idp.ts b/apps/mobile/src/runtime/cloud/idp.ts index 14a0a8d95..0cf24dc76 100644 --- a/apps/mobile/src/runtime/cloud/idp.ts +++ b/apps/mobile/src/runtime/cloud/idp.ts @@ -12,7 +12,8 @@ import { CLOUD_URL, cloudAuthClient } from './client'; * The IdP session has its own SecureStore slot; signing out of the cloud never touches it. */ -const IDP_URL = 'https://auth.arcbox.dev'; +// See `client.ts`'s `CLOUD_URL` for the `EXPO_PUBLIC_*` override convention this mirrors. +const IDP_URL = process.env.EXPO_PUBLIC_IDP_URL ?? 'https://auth.arcbox.dev'; const idpAuthClient = createAuthClient({ baseURL: `${IDP_URL}/api/auth`, @@ -25,7 +26,22 @@ const idpAuthClient = createAuthClient({ ], }); -export async function signInWithApple(): Promise { +interface AppleNativeAuthentication { + /** A fresh, short-lived IdP JWT (`GET /api/auth/token`) naming this account's central identity. */ + idpToken: string; + /** Apple's single-use, 5-minute authorization code from this same authentication — the + * account-deletion flow's only use for it (CODE-292 D-7). */ + authorizationCode: string; +} + +/** + * The shared core of both the sign-in and the account-deletion re-authentication + * flows: prove the user is present via Face ID / passcode through Apple's native + * sheet, sign that proof in to the central IdP, and mint a fresh IdP JWT from the + * resulting session. Callers decide what happens next (exchange to a cloud + * session, or hand the JWT to a delete request) — this never touches `cloudAuthClient`. + */ +async function authenticateWithAppleNatively(): Promise { // Fresh nonce per attempt: Apple embeds the SHA-256 we hand it into the // id_token; the IdP re-hashes the raw value we send and compares. const rawNonce = Crypto.randomUUID(); @@ -36,10 +52,17 @@ export async function signInWithApple(): Promise { AppleAuthentication.AppleAuthenticationScope.EMAIL, ], nonce: hashedNonce, + state: rawNonce, }); + if (credential.state !== rawNonce) { + throw new Error('Apple sign-in returned a mismatched state — possible replay'); + } if (!credential.identityToken) { throw new Error('Apple sign-in returned no identity token'); } + if (!credential.authorizationCode) { + throw new Error('Apple sign-in returned no authorization code'); + } // Apple only discloses the name on the very first authorization — forward // it so the IdP profile starts populated instead of empty. @@ -68,17 +91,40 @@ export async function signInWithApple(): Promise { const parsed = z.object({ token: z.string().min(1) }).safeParse(jwt.data); if (!parsed.success) throw new Error('IdP token endpoint returned an unexpected shape'); + return { idpToken: parsed.data.token, authorizationCode: credential.authorizationCode }; +} + +export async function signInWithApple(): Promise { + const { idpToken } = await authenticateWithAppleNatively(); + // Exchange on the cloud client so its response hook captures the session // cookie into SecureStore and flips `useSession` reactively. const exchanged = await cloudAuthClient.$fetch(`${CLOUD_URL}/auth/exchange/idp-token`, { method: 'POST', - body: { token: parsed.data.token }, + body: { token: idpToken }, }); if (exchanged.error) { throw new Error(`cloud token exchange failed (${exchanged.error.status})`); } } +/** + * Account-deletion re-authentication for Apple-signed-in accounts (CODE-292 + * D-5/D-19): re-proves presence and returns both the fresh IdP JWT (identity + * proof) and the fresh `authorizationCode` (Apple revocation credential) — + * never touching the cloud session, which the delete request itself replaces. + */ +export const reauthenticateWithApple = authenticateWithAppleNatively; + +/** + * Clears the IdP's own SecureStore session (`arcbox-idp` prefix) — never + * touched by `signOutOfCloud()`, which only knows about the cloud session + * (CODE-292 §3.5). Best-effort: a failure here doesn't roll back anything. + */ +export async function signOutOfIdp(): Promise { + await idpAuthClient.signOut(); +} + /** Apple's dismissal surfaces as an exception — a non-event, not a failure. */ export function isAppleSignInCancel(error: unknown): boolean { return ( From dc4a97f4d551de03005a3bcd179db3d4c7dbf6b4 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Tue, 25 Aug 2026 15:18:01 +0800 Subject: [PATCH 3/9] feat(mobile): add delete-account section to the account screen --- apps/mobile/src/app/account.tsx | 2 + .../account/delete-account-section.tsx | 89 +++++++++++++++++++ packages/presentation/i18n/src/locales/en.ts | 19 ++++ .../presentation/i18n/src/locales/zh-cn.ts | 16 ++++ 4 files changed, 126 insertions(+) create mode 100644 apps/mobile/src/components/account/delete-account-section.tsx diff --git a/apps/mobile/src/app/account.tsx b/apps/mobile/src/app/account.tsx index aeaa5770c..03d6b5a79 100644 --- a/apps/mobile/src/app/account.tsx +++ b/apps/mobile/src/app/account.tsx @@ -1,4 +1,5 @@ import { Button, Form, Host, ProgressView, Section } from '@expo/ui/swift-ui'; +import { DeleteAccountSection } from '@mobile/components/account/delete-account-section'; import { DevicesSection } from '@mobile/components/account/devices-section'; import { ProfileRow } from '@mobile/components/account/profile-row'; import { signOutOfCloud, useCloudAccount } from '@mobile/runtime/cloud/account'; @@ -35,6 +36,7 @@ export default function AccountScreen(): React.ReactNode { }} /> + )} diff --git a/apps/mobile/src/components/account/delete-account-section.tsx b/apps/mobile/src/components/account/delete-account-section.tsx new file mode 100644 index 000000000..a2ebb2b7d --- /dev/null +++ b/apps/mobile/src/components/account/delete-account-section.tsx @@ -0,0 +1,89 @@ +import { Button, Section } from '@expo/ui/swift-ui'; +import { disabled } from '@expo/ui/swift-ui/modifiers'; +import { deleteAccount, runAccountDeletionTeardown } from '@mobile/runtime/cloud/deletion'; +import * as AppleAuthentication from 'expo-apple-authentication'; +import { noop } from 'foxact/noop'; +import { useEffect, useState } from 'react'; +import { Alert } from 'react-native'; +import { useTranslations } from 'use-intl'; + +/** + * Permanent, in-app account deletion (App Store Guideline 5.1.1(v)). Its own + * Section, below Sign out, `Button role="destructive"` — not hidden behind + * any secondary menu, matching `DevicesSection`'s destructive-row precedent. + */ +export function DeleteAccountSection(): React.ReactNode { + const t = useTranslations('mobile.account'); + const [busy, setBusy] = useState(false); + const [appleAvailable, setAppleAvailable] = useState(false); + + useEffect(() => { + AppleAuthentication.isAvailableAsync().then(setAppleAvailable).catch(noop); + }, []); + + const failureMessage = (code: string | undefined): string => { + if (code === 'ACCOUNT_DELETION_EMERGENCY_AUDIT_HOLD') return t('deleteEmergencyHold'); + if (code === 'ACCOUNT_DELETION_SOLE_ORGANIZATION_OWNER') return t('deleteSoleOwner'); + return t('deleteFailed'); + }; + + const run = async () => { + setBusy(true); + try { + const outcome = await deleteAccount({ isAppleAvailable: appleAvailable }); + if (outcome.kind === 'reauthentication-failed') { + Alert.alert(t('deleteReauthenticationFailed')); + return; + } + if (outcome.kind === 'failed') { + Alert.alert(failureMessage(outcome.code)); + return; + } + + // Both remaining outcomes (`pending` and `completed`) mean the server + // already accepted the deletion — local teardown runs regardless. + await runAccountDeletionTeardown(); + if (outcome.kind === 'pending') { + Alert.alert(t('deletePending')); + return; + } + // Success never says "contact support" — a failed revocation is still + // a successful deletion, just with a manual Apple follow-up + // (design.md §3.4, TN3194). + if (outcome.revocation === 'failed') { + Alert.alert(t('deleteRevocationFailedTitle'), t('deleteRevocationFailedMessage')); + } else { + Alert.alert(t('deleteCompleted')); + } + } finally { + setBusy(false); + } + }; + + const confirmDelete = () => { + Alert.alert(t('deleteTitle'), t('deleteMessage'), [ + { text: t('deleteCancel'), style: 'cancel' }, + { + text: t('deleteConfirm'), + style: 'destructive', + onPress() { + // Deliberately no retry affordance — the server is idempotent on + // replay, but a client-side retry button would invite repeating + // an operation that may have already fully succeeded. + void run(); + }, + }, + ]); + }; + + return ( +
+
+ ); +} diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 6afb64469..794980653 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1278,6 +1278,25 @@ export const en = { 'This is the phone you are using — revoking it also signs you out here.', revokeCancel: 'Cancel', revokeError: 'Could not revoke the device.', + deleteAccount: 'Delete Account', + deleteTitle: 'Delete your account?', + deleteMessage: + 'This permanently deletes your LinkCode Cloud account and cannot be undone. Devices, tunnel connections, and message history tied to this account are removed. Apple In-App Purchase subscriptions are not cancelled automatically — manage them in App Store Settings.', + deleteCancel: 'Cancel', + deleteConfirm: 'Delete', + deleteInProgress: 'Deleting your account…', + deleteReauthenticationFailed: 'Could not confirm it’s you. Please try again.', + deleteFailed: 'Could not delete your account. Please try again.', + deleteSoleOwner: + 'You own a shared organization with other members. Transfer ownership before deleting your account.', + deleteEmergencyHold: + 'This account can’t be self-deleted. Contact support to complete the deletion.', + deleteCompleted: 'Your account has been deleted.', + deletePending: + 'Your deletion request was received. A few steps need manual follow-up and will complete within 3–5 business days.', + deleteRevocationFailedTitle: 'Remove LinkCode’s Apple sign-in access', + deleteRevocationFailedMessage: + 'Your account was deleted, but we couldn’t automatically remove Apple’s sign-in permission. Go to Settings → [Your Name] → Sign in with Apple, and remove LinkCode from the list.', }, sessions: { title: 'Threads', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 6dda18272..a7d401653 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1242,6 +1242,22 @@ export const zhCN = { revokeThisDeviceMessage: '这是当前使用的手机——撤销后本机也会退出登录。', revokeCancel: '取消', revokeError: '撤销设备失败。', + deleteAccount: '删除账号', + deleteTitle: '删除你的账号?', + deleteMessage: + '此操作将永久删除你的 LinkCode Cloud 账号,且无法撤销。与该账号关联的设备、隧道连接与消息记录都会被移除。Apple 内购订阅不会自动取消,请在 App Store 设置中管理。', + deleteCancel: '取消', + deleteConfirm: '删除', + deleteInProgress: '正在删除账号…', + deleteReauthenticationFailed: '无法确认身份,请重试。', + deleteFailed: '删除账号失败,请重试。', + deleteSoleOwner: '你是某个共享组织的唯一所有者。请先转移所有权,再删除账号。', + deleteEmergencyHold: '该账号暂不支持自助删除,请联系支持完成删除。', + deleteCompleted: '你的账号已删除。', + deletePending: '已收到删除请求,部分步骤需人工处理,将在 3~5 个工作日内完成。', + deleteRevocationFailedTitle: '移除 LinkCode 的 Apple 登录权限', + deleteRevocationFailedMessage: + '账号已删除,但未能自动移除 Apple 的登录授权。请到「设置 →[你的姓名]→ 使用 Apple ID 登录」中移除 LinkCode。', }, sessions: { title: '线程', From 6035e799daa992459de5f620410acc1a4f7b82b6 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Tue, 25 Aug 2026 20:54:58 +0800 Subject: [PATCH 4/9] refactor(mobile): rename deletion outcome field to siwaRevocation --- .../src/components/account/delete-account-section.tsx | 2 +- apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts | 8 ++++---- apps/mobile/src/runtime/cloud/deletion.ts | 9 ++++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/components/account/delete-account-section.tsx b/apps/mobile/src/components/account/delete-account-section.tsx index a2ebb2b7d..d3d24896d 100644 --- a/apps/mobile/src/components/account/delete-account-section.tsx +++ b/apps/mobile/src/components/account/delete-account-section.tsx @@ -50,7 +50,7 @@ export function DeleteAccountSection(): React.ReactNode { // Success never says "contact support" — a failed revocation is still // a successful deletion, just with a manual Apple follow-up // (design.md §3.4, TN3194). - if (outcome.revocation === 'failed') { + if (outcome.siwaRevocation === 'failed') { Alert.alert(t('deleteRevocationFailedTitle'), t('deleteRevocationFailedMessage')); } else { Alert.alert(t('deleteCompleted')); diff --git a/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts b/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts index 2d4da7b08..e2a4b7e7f 100644 --- a/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts +++ b/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts @@ -67,13 +67,13 @@ describe('deleteAccount', () => { authorizationCode: 'apple-code-1', }); mocks.fetchAccount.mockResolvedValueOnce({ - data: { status: 'completed', revocation: 'completed' }, + data: { status: 'completed', siwaRevocation: 'completed' }, error: null, }); const result = await deleteAccount({ isAppleAvailable: true }); - expect(result).toEqual({ kind: 'completed', revocation: 'completed' }); + expect(result).toEqual({ kind: 'completed', siwaRevocation: 'completed' }); expect(mocks.fetchAccount).toHaveBeenCalledWith( 'https://api.linkcode.ai/account', expect.objectContaining({ @@ -85,13 +85,13 @@ describe('deleteAccount', () => { it('on the non-Apple branch, re-runs the browser sign-in and sends the request without an idpToken', async () => { mocks.fetchAccount.mockResolvedValueOnce({ - data: { status: 'completed', revocation: 'not_applicable' }, + data: { status: 'completed', siwaRevocation: 'not_applicable' }, error: null, }); const result = await deleteAccount({ isAppleAvailable: false }); - expect(result).toEqual({ kind: 'completed', revocation: 'not_applicable' }); + expect(result).toEqual({ kind: 'completed', siwaRevocation: 'not_applicable' }); expect(mocks.signInToCloud).toHaveBeenCalledTimes(1); expect(mocks.reauthenticateWithApple).not.toHaveBeenCalled(); expect(mocks.fetchAccount).toHaveBeenCalledWith( diff --git a/apps/mobile/src/runtime/cloud/deletion.ts b/apps/mobile/src/runtime/cloud/deletion.ts index ff28ec95a..c5de86bf7 100644 --- a/apps/mobile/src/runtime/cloud/deletion.ts +++ b/apps/mobile/src/runtime/cloud/deletion.ts @@ -16,7 +16,7 @@ import { reauthenticateWithApple, signOutOfIdp } from './idp'; export type AccountDeletionRevocation = 'completed' | 'failed' | 'not_applicable'; export type AccountDeletionOutcome = - | { kind: 'completed'; revocation: AccountDeletionRevocation } + | { kind: 'completed'; siwaRevocation: AccountDeletionRevocation } | { kind: 'pending'; reference?: string } /** Reauthentication itself failed (wrong account, cancelled, expired) — the * account is untouched; PONR was never reached. */ @@ -29,7 +29,7 @@ export type AccountDeletionOutcome = const deletionResponseSchema = z.object({ status: z.enum(['completed', 'pending']), - revocation: z.enum(['completed', 'failed', 'not_applicable']).optional(), + siwaRevocation: z.enum(['completed', 'failed', 'not_applicable']).optional(), reference: z.string().optional(), }); @@ -99,7 +99,10 @@ export async function deleteAccount(options: { if (parsed.data.status === 'pending') { return { kind: 'pending', reference: parsed.data.reference }; } - return { kind: 'completed', revocation: parsed.data.revocation ?? 'not_applicable' }; + return { + kind: 'completed', + siwaRevocation: parsed.data.siwaRevocation ?? 'not_applicable', + }; } /** From 73431547872726cb6eae6f5327e1770242624f94 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Wed, 26 Aug 2026 15:13:57 +0800 Subject: [PATCH 5/9] fix(mobile): stop treating a lost DELETE /account response as accepted A thrown fetch means no response ever arrived; report it as a retryable failure instead of tearing down local state on a guess. --- .../src/runtime/cloud/__tests__/deletion.test.ts | 4 ++-- apps/mobile/src/runtime/cloud/deletion.ts | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts b/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts index e2a4b7e7f..ebd538bbb 100644 --- a/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts +++ b/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts @@ -153,12 +153,12 @@ describe('deleteAccount', () => { expect(result).toEqual({ kind: 'pending', reference: 'ref-1' }); }); - it('treats a thrown network error as pending — never as failed', async () => { + it('treats a thrown network error as failed — never assumes acceptance', async () => { mocks.fetchAccount.mockRejectedValueOnce(new Error('offline')); const result = await deleteAccount({ isAppleAvailable: false }); - expect(result).toEqual({ kind: 'pending' }); + expect(result).toEqual({ kind: 'failed' }); }); it('treats an unparseable success response as pending rather than completed', async () => { diff --git a/apps/mobile/src/runtime/cloud/deletion.ts b/apps/mobile/src/runtime/cloud/deletion.ts index c5de86bf7..970ed06b5 100644 --- a/apps/mobile/src/runtime/cloud/deletion.ts +++ b/apps/mobile/src/runtime/cloud/deletion.ts @@ -73,10 +73,12 @@ export async function deleteAccount(options: { body: { idpToken, appleAuthorizationCode }, }); } catch { - // Ambiguous — the request may have landed past the point of no return. - // Treat it the same as an accepted-but-unfinished deletion: the account - // must be assumed gone from this point on (design.md §3.4). - return { kind: 'pending' }; + // No response ever arrived — never report acceptance on a guess (D-23, + // reversing the original §3.4 "treat as pending" call). Retrying is + // always safe: the server's deletion CAS is idempotent, so if this + // request actually landed, retrying just observes `completed` without + // repeating any side effect. + return { kind: 'failed' }; } if (response.error) { From 13a6c6eea46b6628dda76698d02ad703a3903c03 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Wed, 26 Aug 2026 15:56:56 +0800 Subject: [PATCH 6/9] refactor(mobile): rename siwaRevocation to authorizationRevocation 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. --- .../src/components/account/delete-account-section.tsx | 2 +- apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts | 8 ++++---- apps/mobile/src/runtime/cloud/deletion.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/components/account/delete-account-section.tsx b/apps/mobile/src/components/account/delete-account-section.tsx index d3d24896d..e96edfc70 100644 --- a/apps/mobile/src/components/account/delete-account-section.tsx +++ b/apps/mobile/src/components/account/delete-account-section.tsx @@ -50,7 +50,7 @@ export function DeleteAccountSection(): React.ReactNode { // Success never says "contact support" — a failed revocation is still // a successful deletion, just with a manual Apple follow-up // (design.md §3.4, TN3194). - if (outcome.siwaRevocation === 'failed') { + if (outcome.authorizationRevocation === 'failed') { Alert.alert(t('deleteRevocationFailedTitle'), t('deleteRevocationFailedMessage')); } else { Alert.alert(t('deleteCompleted')); diff --git a/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts b/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts index ebd538bbb..1062a5e8c 100644 --- a/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts +++ b/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts @@ -67,13 +67,13 @@ describe('deleteAccount', () => { authorizationCode: 'apple-code-1', }); mocks.fetchAccount.mockResolvedValueOnce({ - data: { status: 'completed', siwaRevocation: 'completed' }, + data: { status: 'completed', authorizationRevocation: 'completed' }, error: null, }); const result = await deleteAccount({ isAppleAvailable: true }); - expect(result).toEqual({ kind: 'completed', siwaRevocation: 'completed' }); + expect(result).toEqual({ kind: 'completed', authorizationRevocation: 'completed' }); expect(mocks.fetchAccount).toHaveBeenCalledWith( 'https://api.linkcode.ai/account', expect.objectContaining({ @@ -85,13 +85,13 @@ describe('deleteAccount', () => { it('on the non-Apple branch, re-runs the browser sign-in and sends the request without an idpToken', async () => { mocks.fetchAccount.mockResolvedValueOnce({ - data: { status: 'completed', siwaRevocation: 'not_applicable' }, + data: { status: 'completed', authorizationRevocation: 'not_applicable' }, error: null, }); const result = await deleteAccount({ isAppleAvailable: false }); - expect(result).toEqual({ kind: 'completed', siwaRevocation: 'not_applicable' }); + expect(result).toEqual({ kind: 'completed', authorizationRevocation: 'not_applicable' }); expect(mocks.signInToCloud).toHaveBeenCalledTimes(1); expect(mocks.reauthenticateWithApple).not.toHaveBeenCalled(); expect(mocks.fetchAccount).toHaveBeenCalledWith( diff --git a/apps/mobile/src/runtime/cloud/deletion.ts b/apps/mobile/src/runtime/cloud/deletion.ts index 970ed06b5..28af975b8 100644 --- a/apps/mobile/src/runtime/cloud/deletion.ts +++ b/apps/mobile/src/runtime/cloud/deletion.ts @@ -16,7 +16,7 @@ import { reauthenticateWithApple, signOutOfIdp } from './idp'; export type AccountDeletionRevocation = 'completed' | 'failed' | 'not_applicable'; export type AccountDeletionOutcome = - | { kind: 'completed'; siwaRevocation: AccountDeletionRevocation } + | { kind: 'completed'; authorizationRevocation: AccountDeletionRevocation } | { kind: 'pending'; reference?: string } /** Reauthentication itself failed (wrong account, cancelled, expired) — the * account is untouched; PONR was never reached. */ @@ -29,7 +29,7 @@ export type AccountDeletionOutcome = const deletionResponseSchema = z.object({ status: z.enum(['completed', 'pending']), - siwaRevocation: z.enum(['completed', 'failed', 'not_applicable']).optional(), + authorizationRevocation: z.enum(['completed', 'failed', 'not_applicable']).optional(), reference: z.string().optional(), }); @@ -103,7 +103,7 @@ export async function deleteAccount(options: { } return { kind: 'completed', - siwaRevocation: parsed.data.siwaRevocation ?? 'not_applicable', + authorizationRevocation: parsed.data.authorizationRevocation ?? 'not_applicable', }; } From 74e5b9022fffef32921d77ec873e2748910ba87f Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Fri, 28 Aug 2026 14:49:55 +0800 Subject: [PATCH 7/9] fix(mobile): drive deletion reauthentication from server --- .../account/delete-account-section.tsx | 11 +- .../runtime/cloud/__tests__/account.test.ts | 50 ++++++ .../runtime/cloud/__tests__/deletion.test.ts | 160 ++++++++++++------ apps/mobile/src/runtime/cloud/account.ts | 25 ++- apps/mobile/src/runtime/cloud/deletion.ts | 85 ++++++++-- apps/mobile/src/runtime/cloud/idp.ts | 52 +++--- 6 files changed, 284 insertions(+), 99 deletions(-) create mode 100644 apps/mobile/src/runtime/cloud/__tests__/account.test.ts diff --git a/apps/mobile/src/components/account/delete-account-section.tsx b/apps/mobile/src/components/account/delete-account-section.tsx index e96edfc70..49011f709 100644 --- a/apps/mobile/src/components/account/delete-account-section.tsx +++ b/apps/mobile/src/components/account/delete-account-section.tsx @@ -1,9 +1,7 @@ import { Button, Section } from '@expo/ui/swift-ui'; import { disabled } from '@expo/ui/swift-ui/modifiers'; import { deleteAccount, runAccountDeletionTeardown } from '@mobile/runtime/cloud/deletion'; -import * as AppleAuthentication from 'expo-apple-authentication'; -import { noop } from 'foxact/noop'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Alert } from 'react-native'; import { useTranslations } from 'use-intl'; @@ -15,11 +13,6 @@ import { useTranslations } from 'use-intl'; export function DeleteAccountSection(): React.ReactNode { const t = useTranslations('mobile.account'); const [busy, setBusy] = useState(false); - const [appleAvailable, setAppleAvailable] = useState(false); - - useEffect(() => { - AppleAuthentication.isAvailableAsync().then(setAppleAvailable).catch(noop); - }, []); const failureMessage = (code: string | undefined): string => { if (code === 'ACCOUNT_DELETION_EMERGENCY_AUDIT_HOLD') return t('deleteEmergencyHold'); @@ -30,7 +23,7 @@ export function DeleteAccountSection(): React.ReactNode { const run = async () => { setBusy(true); try { - const outcome = await deleteAccount({ isAppleAvailable: appleAvailable }); + const outcome = await deleteAccount(); if (outcome.kind === 'reauthentication-failed') { Alert.alert(t('deleteReauthenticationFailed')); return; diff --git a/apps/mobile/src/runtime/cloud/__tests__/account.test.ts b/apps/mobile/src/runtime/cloud/__tests__/account.test.ts new file mode 100644 index 000000000..4ae58f64d --- /dev/null +++ b/apps/mobile/src/runtime/cloud/__tests__/account.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + fetchSession: vi.fn(), + signInSocial: vi.fn(), +})); + +vi.mock('../client', () => ({ + CLOUD_URL: 'https://api.linkcode.ai', + cloudAuthClient: { + $fetch: mocks.fetchSession, + signIn: { social: mocks.signInSocial }, + }, +})); + +vi.mock('../devices', () => ({ clearDeviceEnrollment: vi.fn() })); + +import { reauthenticateToCloud } from '../account'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('reauthenticateToCloud', () => { + it('rejects when the browser resolves without replacing the old session', async () => { + mocks.signInSocial.mockResolvedValueOnce({ error: null }); + mocks.fetchSession + .mockResolvedValueOnce({ data: { session: { id: 'old-session' } }, error: null }) + .mockResolvedValueOnce({ data: { session: { id: 'old-session' } }, error: null }); + + await expect(reauthenticateToCloud()).rejects.toThrow( + 'browser re-authentication did not create a fresh session', + ); + expect(mocks.fetchSession).toHaveBeenCalledTimes(2); + expect(mocks.fetchSession).toHaveBeenNthCalledWith( + 2, + 'https://api.linkcode.ai/auth/get-session?disableCookieCache=true', + {}, + ); + }); + + it('accepts a session created by the current browser flow', async () => { + mocks.signInSocial.mockResolvedValueOnce({ error: null }); + mocks.fetchSession + .mockResolvedValueOnce({ data: { session: { id: 'old-session' } }, error: null }) + .mockResolvedValueOnce({ data: { session: { id: 'new-session' } }, error: null }); + + await expect(reauthenticateToCloud()).resolves.toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts b/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts index 1062a5e8c..aa5655ec9 100644 --- a/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts +++ b/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts @@ -1,44 +1,60 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ - fetchAccount: vi.fn(), - // `vi.hoisted` runs above this file's imports, so `foxts/noop`'s `asyncNoop` - // isn't in scope here yet — these three are the narrow exception to using it. - // eslint-disable-next-line sukka/prefer-foxts-noop -- see above - signOutCloud: vi.fn(() => Promise.resolve()), - // eslint-disable-next-line sukka/prefer-foxts-noop -- see above - signInToCloud: vi.fn(() => Promise.resolve()), - reauthenticateWithApple: vi.fn(), - // eslint-disable-next-line sukka/prefer-foxts-noop -- see above - signOutOfIdp: vi.fn(() => Promise.resolve()), - // eslint-disable-next-line sukka/prefer-foxts-noop -- see above - clearDeviceEnrollment: vi.fn(() => Promise.resolve()), - captureException: vi.fn(), - hosts: [] as Array<{ - id: string; - name: string; - createdAt: number; - tunnelHostId?: string; - url?: string; - }>, - removeHost: vi.fn(), -})); +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + class IdpTokenAcquisitionError extends Error { + override name = 'IdpTokenAcquisitionError'; + } + return { + fetchDelete: vi.fn(), + fetchRequirements: vi.fn(), + // `vi.hoisted` runs above this file's imports, so `foxts/noop`'s `asyncNoop` + // isn't in scope here yet — these three are the narrow exception to using it. + // eslint-disable-next-line sukka/prefer-foxts-noop -- see above + signOutCloud: vi.fn(() => Promise.resolve()), + // eslint-disable-next-line sukka/prefer-foxts-noop -- see above + reauthenticateToCloud: vi.fn(() => Promise.resolve()), + reauthenticateWithApple: vi.fn(), + // eslint-disable-next-line sukka/prefer-foxts-noop -- see above + signOutOfIdp: vi.fn(() => Promise.resolve()), + // eslint-disable-next-line sukka/prefer-foxts-noop -- see above + clearDeviceEnrollment: vi.fn(() => Promise.resolve()), + captureException: vi.fn(), + hosts: [] as Array<{ + id: string; + name: string; + createdAt: number; + tunnelHostId?: string; + url?: string; + }>, + removeHost: vi.fn(), + IdpTokenAcquisitionError, + }; +}); vi.mock('@sentry/react-native', () => ({ captureException: mocks.captureException })); vi.mock('../client', () => ({ CLOUD_URL: 'https://api.linkcode.ai', cloudAuthClient: { - $fetch: mocks.fetchAccount, + $fetch: (url: string, options: unknown) => + url.endsWith('/deletion-requirements') + ? mocks.fetchRequirements(url, options) + : mocks.fetchDelete(url, options), signOut: mocks.signOutCloud, }, })); vi.mock('../account', () => ({ - signInToCloud: mocks.signInToCloud, + reauthenticateToCloud: mocks.reauthenticateToCloud, })); vi.mock('../idp', () => ({ + IdpTokenAcquisitionError: mocks.IdpTokenAcquisitionError, + isAppleSignInCancel: (error: unknown) => + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ERR_REQUEST_CANCELED', reauthenticateWithApple: mocks.reauthenticateWithApple, signOutOfIdp: mocks.signOutOfIdp, })); @@ -55,26 +71,46 @@ vi.mock('@mobile/stores/host-store', () => ({ import { deleteAccount, runAccountDeletionTeardown } from '../deletion'; +beforeEach(() => { + mocks.fetchRequirements.mockResolvedValue({ data: { method: 'browser' }, error: null }); +}); + afterEach(() => { vi.clearAllMocks(); mocks.hosts = []; }); describe('deleteAccount', () => { + it('does not re-authenticate or delete when requirements cannot be read', async () => { + mocks.fetchRequirements.mockResolvedValueOnce({ data: null, error: { status: 503 } }); + + const result = await deleteAccount(); + + expect(result).toEqual({ kind: 'failed' }); + expect(mocks.reauthenticateWithApple).not.toHaveBeenCalled(); + expect(mocks.reauthenticateToCloud).not.toHaveBeenCalled(); + expect(mocks.fetchDelete).not.toHaveBeenCalled(); + expect(mocks.captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ tags: { account_deletion_stage: 'requirements' } }), + ); + }); + it('on the Apple branch, forwards the fresh idpToken and authorizationCode', async () => { + mocks.fetchRequirements.mockResolvedValueOnce({ data: { method: 'native' }, error: null }); mocks.reauthenticateWithApple.mockResolvedValueOnce({ idpToken: 'jwt-1', authorizationCode: 'apple-code-1', }); - mocks.fetchAccount.mockResolvedValueOnce({ + mocks.fetchDelete.mockResolvedValueOnce({ data: { status: 'completed', authorizationRevocation: 'completed' }, error: null, }); - const result = await deleteAccount({ isAppleAvailable: true }); + const result = await deleteAccount(); expect(result).toEqual({ kind: 'completed', authorizationRevocation: 'completed' }); - expect(mocks.fetchAccount).toHaveBeenCalledWith( + expect(mocks.fetchDelete).toHaveBeenCalledWith( 'https://api.linkcode.ai/account', expect.objectContaining({ method: 'DELETE', @@ -84,17 +120,17 @@ describe('deleteAccount', () => { }); it('on the non-Apple branch, re-runs the browser sign-in and sends the request without an idpToken', async () => { - mocks.fetchAccount.mockResolvedValueOnce({ + mocks.fetchDelete.mockResolvedValueOnce({ data: { status: 'completed', authorizationRevocation: 'not_applicable' }, error: null, }); - const result = await deleteAccount({ isAppleAvailable: false }); + const result = await deleteAccount(); expect(result).toEqual({ kind: 'completed', authorizationRevocation: 'not_applicable' }); - expect(mocks.signInToCloud).toHaveBeenCalledTimes(1); + expect(mocks.reauthenticateToCloud).toHaveBeenCalledTimes(1); expect(mocks.reauthenticateWithApple).not.toHaveBeenCalled(); - expect(mocks.fetchAccount).toHaveBeenCalledWith( + expect(mocks.fetchDelete).toHaveBeenCalledWith( 'https://api.linkcode.ai/account', expect.objectContaining({ body: { idpToken: undefined, appleAuthorizationCode: undefined }, @@ -103,38 +139,64 @@ describe('deleteAccount', () => { }); it('on the non-Apple branch, a failed browser sign-in is reauthentication-failed, and never sends the delete request', async () => { - mocks.signInToCloud.mockRejectedValueOnce(new Error('dismissed')); + mocks.reauthenticateToCloud.mockRejectedValueOnce(new Error('dismissed')); - const result = await deleteAccount({ isAppleAvailable: false }); + const result = await deleteAccount(); expect(result).toEqual({ kind: 'reauthentication-failed' }); - expect(mocks.fetchAccount).not.toHaveBeenCalled(); + expect(mocks.fetchDelete).not.toHaveBeenCalled(); }); it('reports reauthentication-failed without ever sending the delete request', async () => { + mocks.fetchRequirements.mockResolvedValueOnce({ data: { method: 'native' }, error: null }); mocks.reauthenticateWithApple.mockRejectedValueOnce(new Error('cancelled')); - const result = await deleteAccount({ isAppleAvailable: true }); + const result = await deleteAccount(); expect(result).toEqual({ kind: 'reauthentication-failed' }); - expect(mocks.fetchAccount).not.toHaveBeenCalled(); + expect(mocks.fetchDelete).not.toHaveBeenCalled(); + }); + + it('does not report an intentional Apple cancellation', async () => { + mocks.fetchRequirements.mockResolvedValueOnce({ data: { method: 'native' }, error: null }); + mocks.reauthenticateWithApple.mockRejectedValueOnce({ code: 'ERR_REQUEST_CANCELED' }); + + const result = await deleteAccount(); + + expect(result).toEqual({ kind: 'reauthentication-failed' }); + expect(mocks.captureException).not.toHaveBeenCalled(); + expect(mocks.fetchDelete).not.toHaveBeenCalled(); + }); + + it('reports IdP token acquisition separately from the native provider', async () => { + mocks.fetchRequirements.mockResolvedValueOnce({ data: { method: 'native' }, error: null }); + mocks.reauthenticateWithApple.mockRejectedValueOnce( + new mocks.IdpTokenAcquisitionError('IdP unavailable'), + ); + + await deleteAccount(); + + expect(mocks.captureException).toHaveBeenCalledWith( + expect.any(mocks.IdpTokenAcquisitionError), + expect.objectContaining({ tags: { account_deletion_stage: 'idp-token' } }), + ); }); it('maps a 401 response to reauthentication-failed', async () => { - mocks.fetchAccount.mockResolvedValueOnce({ data: null, error: { status: 401 } }); + mocks.fetchDelete.mockResolvedValueOnce({ data: null, error: { status: 401 } }); - const result = await deleteAccount({ isAppleAvailable: false }); + const result = await deleteAccount(); expect(result).toEqual({ kind: 'reauthentication-failed' }); }); it('maps a 409 pre-check response to failed, carrying the biz code through', async () => { - mocks.fetchAccount.mockResolvedValueOnce({ + mocks.fetchDelete.mockResolvedValueOnce({ data: null, error: { status: 409, code: 'ACCOUNT_DELETION_SOLE_ORGANIZATION_OWNER' }, }); - const result = await deleteAccount({ isAppleAvailable: false }); + const result = await deleteAccount(); expect(result).toEqual({ kind: 'failed', @@ -143,28 +205,28 @@ describe('deleteAccount', () => { }); it('treats a pending server response as pending, carrying the reference through', async () => { - mocks.fetchAccount.mockResolvedValueOnce({ + mocks.fetchDelete.mockResolvedValueOnce({ data: { status: 'pending', reference: 'ref-1' }, error: null, }); - const result = await deleteAccount({ isAppleAvailable: false }); + const result = await deleteAccount(); expect(result).toEqual({ kind: 'pending', reference: 'ref-1' }); }); it('treats a thrown network error as failed — never assumes acceptance', async () => { - mocks.fetchAccount.mockRejectedValueOnce(new Error('offline')); + mocks.fetchDelete.mockRejectedValueOnce(new Error('offline')); - const result = await deleteAccount({ isAppleAvailable: false }); + const result = await deleteAccount(); expect(result).toEqual({ kind: 'failed' }); }); it('treats an unparseable success response as pending rather than completed', async () => { - mocks.fetchAccount.mockResolvedValueOnce({ data: { unexpected: true }, error: null }); + mocks.fetchDelete.mockResolvedValueOnce({ data: { unexpected: true }, error: null }); - const result = await deleteAccount({ isAppleAvailable: false }); + const result = await deleteAccount(); expect(result).toEqual({ kind: 'pending' }); }); diff --git a/apps/mobile/src/runtime/cloud/account.ts b/apps/mobile/src/runtime/cloud/account.ts index 664e4850c..08de1e5c2 100644 --- a/apps/mobile/src/runtime/cloud/account.ts +++ b/apps/mobile/src/runtime/cloud/account.ts @@ -1,5 +1,6 @@ import { noop } from 'foxact/noop'; -import { cloudAuthClient } from './client'; +import { z } from 'zod'; +import { CLOUD_URL, cloudAuthClient } from './client'; import { clearDeviceEnrollment } from './devices'; /** The cloud's genericOAuth provider id — the central IdP is the only sign-in path. */ @@ -38,6 +39,28 @@ export async function signInToCloud(): Promise { if (error) throw new Error(`sign-in failed (${error.status})`); } +const freshSessionSchema = z.object({ + session: z.object({ id: z.string().min(1) }), +}); + +async function getAuthoritativeSessionId(): Promise { + const { data, error } = await cloudAuthClient.$fetch( + `${CLOUD_URL}/auth/get-session?disableCookieCache=true`, + {}, + ); + if (error) throw new Error(`session read failed (${error.status})`); + return freshSessionSchema.parse(data).session.id; +} + +export async function reauthenticateToCloud(): Promise { + const previousSessionId = await getAuthoritativeSessionId(); + await signInToCloud(); + const currentSessionId = await getAuthoritativeSessionId(); + if (currentSessionId === previousSessionId) { + throw new Error('browser re-authentication did not create a fresh session'); + } +} + export async function signOutOfCloud(): Promise { await cloudAuthClient.signOut(); // Forget the enrollment so a different account signing in on this phone diff --git a/apps/mobile/src/runtime/cloud/deletion.ts b/apps/mobile/src/runtime/cloud/deletion.ts index 28af975b8..b1244aae7 100644 --- a/apps/mobile/src/runtime/cloud/deletion.ts +++ b/apps/mobile/src/runtime/cloud/deletion.ts @@ -1,10 +1,15 @@ import { useHostRegistryStore } from '@mobile/stores/host-store'; import * as Sentry from '@sentry/react-native'; import { z } from 'zod'; -import { signInToCloud } from './account'; +import { reauthenticateToCloud } from './account'; import { CLOUD_URL, cloudAuthClient } from './client'; import { clearDeviceEnrollment } from './devices'; -import { reauthenticateWithApple, signOutOfIdp } from './idp'; +import { + IdpTokenAcquisitionError, + isAppleSignInCancel, + reauthenticateWithApple, + signOutOfIdp, +} from './idp'; /** * CODE-292: permanent, in-app account deletion. `deleteAccount` is the whole @@ -33,34 +38,74 @@ const deletionResponseSchema = z.object({ reference: z.string().optional(), }); +const deletionRequirementsSchema = z.object({ + method: z.enum(['native', 'browser']), +}); + +type AccountDeletionFailureStage = + | 'requirements' + | 'native-provider' + | 'idp-token' + | 'browser-sign-in' + | 'cloud-identity' + | 'transport'; + +function reportFailure(stage: AccountDeletionFailureStage, error: unknown): void { + if (typeof __DEV__ !== 'undefined' && __DEV__) { + // eslint-disable-next-line no-console -- local acceptance has no Sentry DSN + console.error('Account deletion failed', { stage, error }); + } + Sentry.captureException(error, { tags: { account_deletion_stage: stage } }); +} + /** - * Re-authenticates and submits the single delete request. `isAppleAvailable` - * mirrors the sign-in screen's own capability check - * (`AppleAuthentication.isAvailableAsync()`) — mobile has no local signal for - * *which* provider a given central identity actually uses (D-19's accepted - * gap), so, like the sign-in screen, this branches on device capability, not - * account provider. + * Reads the server-owned re-authentication requirement, re-authenticates, and + * submits one delete mutation. Device capability is not an account fact. */ -export async function deleteAccount(options: { - isAppleAvailable: boolean; -}): Promise { +export async function deleteAccount(): Promise { let idpToken: string | undefined; let appleAuthorizationCode: string | undefined; + let method: 'native' | 'browser'; try { - if (options.isAppleAvailable) { + const requirements = await cloudAuthClient.$fetch( + `${CLOUD_URL}/account/deletion-requirements`, + {}, + ); + if (requirements.error) { + throw new Error(`deletion requirements failed (${requirements.error.status})`); + } + method = deletionRequirementsSchema.parse(requirements.data).method; + } catch (error) { + reportFailure('requirements', error); + return { kind: 'failed' }; + } + + if (method === 'native') { + try { const reauth = await reauthenticateWithApple(); idpToken = reauth.idpToken; appleAuthorizationCode = reauth.authorizationCode; - } else { + } catch (error) { + if (!isAppleSignInCancel(error)) { + reportFailure( + error instanceof IdpTokenAcquisitionError ? 'idp-token' : 'native-provider', + error, + ); + } + return { kind: 'reauthentication-failed' }; + } + } else { + try { // No local proof to mint for this branch (D-19's accepted gap) — the // request below instead relies on the server's session-freshness // check, so re-running the existing browser sign-in flow (which mints // a fresh session) *is* this branch's re-authentication. - await signInToCloud(); + await reauthenticateToCloud(); + } catch (error) { + reportFailure('browser-sign-in', error); + return { kind: 'reauthentication-failed' }; } - } catch { - return { kind: 'reauthentication-failed' }; } let response: { @@ -72,7 +117,8 @@ export async function deleteAccount(options: { method: 'DELETE', body: { idpToken, appleAuthorizationCode }, }); - } catch { + } catch (error) { + reportFailure('transport', error); // No response ever arrived — never report acceptance on a guess (D-23, // reversing the original §3.4 "treat as pending" call). Retrying is // always safe: the server's deletion CAS is idempotent, so if this @@ -82,7 +128,10 @@ export async function deleteAccount(options: { } if (response.error) { - if (response.error.status === 401) return { kind: 'reauthentication-failed' }; + if (response.error.status === 401) { + reportFailure('cloud-identity', new Error('Cloud rejected account re-authentication')); + return { kind: 'reauthentication-failed' }; + } // Any other status (409 pre-check, or a pre-PONR 500): the design's // contract is that only a pre-PONR failure returns an error status at // all, so the account is untouched either way. diff --git a/apps/mobile/src/runtime/cloud/idp.ts b/apps/mobile/src/runtime/cloud/idp.ts index 0cf24dc76..4be23a54c 100644 --- a/apps/mobile/src/runtime/cloud/idp.ts +++ b/apps/mobile/src/runtime/cloud/idp.ts @@ -34,6 +34,10 @@ interface AppleNativeAuthentication { authorizationCode: string; } +export class IdpTokenAcquisitionError extends Error { + override name = 'IdpTokenAcquisitionError'; +} + /** * The shared core of both the sign-in and the account-deletion re-authentication * flows: prove the user is present via Face ID / passcode through Apple's native @@ -67,31 +71,35 @@ async function authenticateWithAppleNatively(): Promise(`${IDP_URL}/api/auth/token`, {}); - if (jwt.error) throw new Error(`IdP token mint failed (${jwt.error.status})`); - const parsed = z.object({ token: z.string().min(1) }).safeParse(jwt.data); - if (!parsed.success) throw new Error('IdP token endpoint returned an unexpected shape'); + const jwt = await idpAuthClient.$fetch(`${IDP_URL}/api/auth/token`, {}); + if (jwt.error) throw new Error(`IdP token mint failed (${jwt.error.status})`); + const parsed = z.object({ token: z.string().min(1) }).safeParse(jwt.data); + if (!parsed.success) throw new Error('IdP token endpoint returned an unexpected shape'); - return { idpToken: parsed.data.token, authorizationCode: credential.authorizationCode }; + return { idpToken: parsed.data.token, authorizationCode: credential.authorizationCode }; + } catch (error) { + throw new IdpTokenAcquisitionError('Could not acquire an IdP token', { cause: error }); + } } export async function signInWithApple(): Promise { From 5274851606f4c3ac288882d6af726badcec593a0 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Sun, 30 Aug 2026 11:52:52 +0800 Subject: [PATCH 8/9] fix(mobile): make deletion reauthentication fail safe --- .../account/delete-account-section.tsx | 4 ++ .../runtime/cloud/__tests__/account.test.ts | 3 + .../runtime/cloud/__tests__/deletion.test.ts | 29 +++++++++ .../src/runtime/cloud/__tests__/idp.test.ts | 61 ------------------- apps/mobile/src/runtime/cloud/account.ts | 7 ++- apps/mobile/src/runtime/cloud/deletion.ts | 9 ++- apps/mobile/src/runtime/cloud/idp.ts | 21 ++----- packages/presentation/i18n/src/locales/en.ts | 2 + .../presentation/i18n/src/locales/zh-cn.ts | 1 + 9 files changed, 58 insertions(+), 79 deletions(-) delete mode 100644 apps/mobile/src/runtime/cloud/__tests__/idp.test.ts diff --git a/apps/mobile/src/components/account/delete-account-section.tsx b/apps/mobile/src/components/account/delete-account-section.tsx index 3c46e940b..12a360f0d 100644 --- a/apps/mobile/src/components/account/delete-account-section.tsx +++ b/apps/mobile/src/components/account/delete-account-section.tsx @@ -32,6 +32,10 @@ export function DeleteAccountSection(): React.ReactNode { Alert.alert(t('deleteAppleDeviceRequired')); return; } + if (outcome.kind === 'account-mismatch') { + Alert.alert(t('deleteAccountMismatch')); + return; + } if (outcome.kind === 'failed') { Alert.alert(failureMessage(outcome.code)); return; diff --git a/apps/mobile/src/runtime/cloud/__tests__/account.test.ts b/apps/mobile/src/runtime/cloud/__tests__/account.test.ts index 87813530c..77c258da2 100644 --- a/apps/mobile/src/runtime/cloud/__tests__/account.test.ts +++ b/apps/mobile/src/runtime/cloud/__tests__/account.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ fetchSession: vi.fn(), signInSocial: vi.fn(), + signOut: vi.fn(() => Promise.resolve({ error: null })), })); vi.mock('../client', () => ({ @@ -10,6 +11,7 @@ vi.mock('../client', () => ({ cloudAuthClient: { $fetch: mocks.fetchSession, signIn: { social: mocks.signInSocial }, + signOut: mocks.signOut, }, })); @@ -79,5 +81,6 @@ describe('reauthenticateToCloud', () => { await expect(reauthenticateToCloud()).rejects.toThrow( 'browser re-authentication signed in a different account', ); + expect(mocks.signOut).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts b/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts index ffda5dc23..71367b810 100644 --- a/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts +++ b/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => { + class CloudAccountMismatchError extends Error { + override name = 'CloudAccountMismatchError'; + } class IdpTokenAcquisitionError extends Error { override name = 'IdpTokenAcquisitionError'; } @@ -28,6 +31,7 @@ const mocks = vi.hoisted(() => { url?: string; }>, removeHost: vi.fn(), + CloudAccountMismatchError, IdpTokenAcquisitionError, }; }); @@ -46,6 +50,7 @@ vi.mock('../client', () => ({ })); vi.mock('../account', () => ({ + CloudAccountMismatchError: mocks.CloudAccountMismatchError, reauthenticateToCloud: mocks.reauthenticateToCloud, })); @@ -162,6 +167,18 @@ describe('deleteAccount', () => { expect(mocks.fetchDelete).not.toHaveBeenCalled(); }); + it('does not report or delete after browser re-authentication switches accounts', async () => { + mocks.reauthenticateToCloud.mockRejectedValueOnce( + new mocks.CloudAccountMismatchError('different account'), + ); + + const result = await deleteAccount(); + + expect(result).toEqual({ kind: 'account-mismatch' }); + expect(mocks.fetchDelete).not.toHaveBeenCalled(); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + it('reports reauthentication-failed without ever sending the delete request', async () => { mocks.fetchRequirements.mockResolvedValueOnce({ data: { method: 'native' }, error: null }); mocks.reauthenticateWithApple.mockRejectedValueOnce(new Error('cancelled')); @@ -232,6 +249,18 @@ describe('deleteAccount', () => { ); }); + it('reports an unexpected client error from the deletion endpoint', async () => { + mocks.fetchDelete.mockResolvedValueOnce({ data: null, error: { status: 403 } }); + + const result = await deleteAccount(); + + expect(result).toEqual({ kind: 'failed' }); + expect(mocks.captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ tags: { account_deletion_stage: 'response' } }), + ); + }); + it('treats a pending server response as pending, carrying the reference through', async () => { mocks.fetchDelete.mockResolvedValueOnce({ data: { status: 'pending', reference: 'ref-1' }, diff --git a/apps/mobile/src/runtime/cloud/__tests__/idp.test.ts b/apps/mobile/src/runtime/cloud/__tests__/idp.test.ts deleted file mode 100644 index a9d49e02d..000000000 --- a/apps/mobile/src/runtime/cloud/__tests__/idp.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ - appleSignIn: vi.fn(), - idpFetch: vi.fn(), - idpSignIn: vi.fn(), - cloudFetch: vi.fn(), -})); - -vi.mock('@better-auth/expo/client', () => ({ expoClient: vi.fn(() => ({})) })); -vi.mock('better-auth/react', () => ({ - createAuthClient: vi.fn(() => ({ - $fetch: mocks.idpFetch, - signIn: { social: mocks.idpSignIn }, - signOut: vi.fn(), - })), -})); -vi.mock('expo-apple-authentication', () => ({ - AppleAuthenticationScope: { FULL_NAME: 0, EMAIL: 1 }, - isAvailableAsync: vi.fn(() => Promise.resolve(true)), - signInAsync: mocks.appleSignIn, -})); -vi.mock('expo-crypto', () => ({ - CryptoDigestAlgorithm: { SHA256: 'SHA256' }, - digestStringAsync: vi.fn(() => Promise.resolve('hashed-nonce')), - randomUUID: vi.fn(() => 'nonce'), -})); -vi.mock('expo-secure-store', () => ({})); -vi.mock('../client', () => ({ - CLOUD_URL: 'https://api.linkcode.ai', - cloudAuthClient: { $fetch: mocks.cloudFetch }, -})); - -import { reauthenticateWithApple, signInWithApple } from '../idp'; - -beforeEach(() => { - vi.clearAllMocks(); - mocks.appleSignIn.mockResolvedValue({ - authorizationCode: null, - fullName: null, - identityToken: 'apple-token', - state: 'nonce', - }); - mocks.idpSignIn.mockResolvedValue({ error: null }); - mocks.idpFetch.mockResolvedValue({ data: { token: 'idp-token' }, error: null }); - mocks.cloudFetch.mockResolvedValue({ data: {}, error: null }); -}); - -describe('Apple authorization code requirements', () => { - it('allows ordinary sign-in when Apple returns no authorization code', async () => { - await expect(signInWithApple()).resolves.toBeUndefined(); - - expect(mocks.cloudFetch).toHaveBeenCalledTimes(1); - }); - - it('rejects deletion re-authentication when Apple returns no authorization code', async () => { - await expect(reauthenticateWithApple()).rejects.toThrow( - 'Apple sign-in returned no authorization code', - ); - }); -}); diff --git a/apps/mobile/src/runtime/cloud/account.ts b/apps/mobile/src/runtime/cloud/account.ts index 9fb0bedac..0a390b898 100644 --- a/apps/mobile/src/runtime/cloud/account.ts +++ b/apps/mobile/src/runtime/cloud/account.ts @@ -45,6 +45,10 @@ const freshSessionSchema = z.object({ user: z.object({ id: z.string().min(1) }), }); +export class CloudAccountMismatchError extends Error { + override name = 'CloudAccountMismatchError'; +} + async function readAuthoritativeSession(): Promise<{ sessionId: string; userId: string }> { const { data, error } = await cloudAuthClient.$fetch( `${CLOUD_URL}/auth/get-session?disableCookieCache=true`, @@ -63,7 +67,8 @@ export async function reauthenticateToCloud(): Promise { throw new Error('browser re-authentication did not create a fresh session'); } if (current.userId !== previous.userId) { - throw new Error('browser re-authentication signed in a different account'); + await cloudAuthClient.signOut(); + throw new CloudAccountMismatchError('browser re-authentication signed in a different account'); } } diff --git a/apps/mobile/src/runtime/cloud/deletion.ts b/apps/mobile/src/runtime/cloud/deletion.ts index b875543c8..98131a694 100644 --- a/apps/mobile/src/runtime/cloud/deletion.ts +++ b/apps/mobile/src/runtime/cloud/deletion.ts @@ -1,7 +1,7 @@ import { useHostRegistryStore } from '@mobile/stores/host-store'; import * as Sentry from '@sentry/react-native'; import { z } from 'zod'; -import { reauthenticateToCloud } from './account'; +import { CloudAccountMismatchError, reauthenticateToCloud } from './account'; import { CLOUD_URL, cloudAuthClient } from './client'; import { clearDeviceEnrollment } from './devices'; import { @@ -22,6 +22,8 @@ export type AccountDeletionOutcome = | { kind: 'reauthentication-failed' } /** The server requires Apple re-authentication, which this device cannot perform. */ | { kind: 'apple-device-required' } + /** Browser re-authentication signed in a different Cloud account. */ + | { kind: 'account-mismatch' } /** The delete request failed before any state changed (network error, or * a 409 pre-check) — the account is untouched. `code` is the server's biz * code when available (e.g. `ACCOUNT_DELETION_SOLE_ORGANIZATION_OWNER`), @@ -100,6 +102,9 @@ export async function deleteAccount(): Promise { // Browser re-authentication relies on the server's session-freshness check. await reauthenticateToCloud(); } catch (error) { + if (error instanceof CloudAccountMismatchError) { + return { kind: 'account-mismatch' }; + } reportFailure('browser-sign-in', error); return { kind: 'reauthentication-failed' }; } @@ -125,7 +130,7 @@ export async function deleteAccount(): Promise { reportFailure('cloud-identity', new Error('Cloud rejected account re-authentication')); return { kind: 'reauthentication-failed' }; } - if (response.error.status >= 500) { + if (response.error.status !== 409) { reportFailure('response', new Error(`account deletion failed (${response.error.status})`)); } return { diff --git a/apps/mobile/src/runtime/cloud/idp.ts b/apps/mobile/src/runtime/cloud/idp.ts index b1445f0f0..f58c0100a 100644 --- a/apps/mobile/src/runtime/cloud/idp.ts +++ b/apps/mobile/src/runtime/cloud/idp.ts @@ -29,8 +29,8 @@ const idpAuthClient = createAuthClient({ interface AppleNativeAuthentication { /** A fresh, short-lived IdP JWT (`GET /api/auth/token`) naming this account's central identity. */ idpToken: string; - /** Apple's single-use authorization code. Only account deletion requires it. */ - authorizationCode: string | null; + /** Apple's single-use authorization code from this authentication. */ + authorizationCode: string; } export class IdpTokenAcquisitionError extends Error { @@ -63,6 +63,9 @@ async function authenticateWithAppleNatively(): Promise { } } -/** - * Account deletion needs both identity proof and Apple's revocation credential. - */ -export async function reauthenticateWithApple(): Promise<{ - idpToken: string; - authorizationCode: string; -}> { - const authentication = await authenticateWithAppleNatively(); - if (!authentication.authorizationCode) { - throw new Error('Apple sign-in returned no authorization code'); - } - return { ...authentication, authorizationCode: authentication.authorizationCode }; -} +export const reauthenticateWithApple = authenticateWithAppleNatively; export async function isAppleAuthenticationAvailable(): Promise { return AppleAuthentication.isAvailableAsync(); diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 0d27ed07f..abb19a542 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1302,6 +1302,8 @@ export const en = { deleteReauthenticationFailed: 'Could not confirm it’s you. Please try again.', deleteAppleDeviceRequired: 'This account must be confirmed on a device that supports Sign in with Apple.', + deleteAccountMismatch: + 'A different account was signed in. Sign in again with the account you want to delete.', deleteFailed: 'Could not delete your account. Please try again.', deleteSoleOwner: 'You own a shared organization with other members. Transfer ownership before deleting your account.', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index baeb6cffc..aca536701 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1263,6 +1263,7 @@ export const zhCN = { deleteConfirm: '删除', deleteReauthenticationFailed: '无法确认身份,请重试。', deleteAppleDeviceRequired: '此账号必须在支持「通过 Apple 登录」的设备上确认身份后删除。', + deleteAccountMismatch: '刚才登录的是另一个账号。请重新登录你想删除的账号。', deleteFailed: '删除账号失败,请重试。', deleteSoleOwner: '你是某个共享组织的唯一所有者。请先转移所有权,再删除账号。', deleteEmergencyHold: '该账号暂不支持自助删除,请联系支持完成删除。', From ea8878bc6fb69a760b44a590d467c50126a559b8 Mon Sep 17 00:00:00 2001 From: Lanzhijiang Date: Sun, 30 Aug 2026 12:08:19 +0800 Subject: [PATCH 9/9] Update apps/mobile/src/runtime/cloud/account.ts Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> --- apps/mobile/src/runtime/cloud/account.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/runtime/cloud/account.ts b/apps/mobile/src/runtime/cloud/account.ts index 0a390b898..6489fc9dd 100644 --- a/apps/mobile/src/runtime/cloud/account.ts +++ b/apps/mobile/src/runtime/cloud/account.ts @@ -67,7 +67,7 @@ export async function reauthenticateToCloud(): Promise { throw new Error('browser re-authentication did not create a fresh session'); } if (current.userId !== previous.userId) { - await cloudAuthClient.signOut(); + await cloudAuthClient.signOut().catch(noop); throw new CloudAccountMismatchError('browser re-authentication signed in a different account'); } }