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/ diff --git a/apps/mobile/src/app/account.tsx b/apps/mobile/src/app/account.tsx index f42306ea2..e56edba84 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'; @@ -34,6 +35,7 @@ export default function AccountScreen(): React.ReactNode { onPress={() => signOutOfCloud().catch(() => Alert.alert(t('signOutError')))} /> + )} 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..12a360f0d --- /dev/null +++ b/apps/mobile/src/components/account/delete-account-section.tsx @@ -0,0 +1,85 @@ +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 { 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 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(); + if (outcome.kind === 'reauthentication-failed') { + Alert.alert(t('deleteReauthenticationFailed')); + return; + } + if (outcome.kind === 'apple-device-required') { + 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; + } + + // 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; + } + // A failed revocation still leaves deletion successful and needs manual Apple follow-up. + if (outcome.authorizationRevocation === '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() { + void run(); + }, + }, + ]); + }; + + return ( +
+
+ ); +} 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__/account.test.ts b/apps/mobile/src/runtime/cloud/__tests__/account.test.ts new file mode 100644 index 000000000..77c258da2 --- /dev/null +++ b/apps/mobile/src/runtime/cloud/__tests__/account.test.ts @@ -0,0 +1,86 @@ +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', () => ({ + CLOUD_URL: 'https://api.linkcode.ai', + cloudAuthClient: { + $fetch: mocks.fetchSession, + signIn: { social: mocks.signInSocial }, + signOut: mocks.signOut, + }, +})); + +vi.mock('@mobile/runtime/notifications', () => ({ + disableDeviceNotifications: vi.fn(() => Promise.resolve(true)), +})); + +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' }, user: { id: 'user-1' } }, + error: null, + }) + .mockResolvedValueOnce({ + data: { session: { id: 'old-session' }, user: { id: 'user-1' } }, + 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' }, user: { id: 'user-1' } }, + error: null, + }) + .mockResolvedValueOnce({ + data: { session: { id: 'new-session' }, user: { id: 'user-1' } }, + error: null, + }); + + await expect(reauthenticateToCloud()).resolves.toBeUndefined(); + }); + + it('rejects when the browser creates a session for another account', async () => { + mocks.signInSocial.mockResolvedValueOnce({ error: null }); + mocks.fetchSession + .mockResolvedValueOnce({ + data: { session: { id: 'old-session' }, user: { id: 'user-1' } }, + error: null, + }) + .mockResolvedValueOnce({ + data: { session: { id: 'new-session' }, user: { id: 'user-2' } }, + error: null, + }); + + 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 new file mode 100644 index 000000000..71367b810 --- /dev/null +++ b/apps/mobile/src/runtime/cloud/__tests__/deletion.test.ts @@ -0,0 +1,330 @@ +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'; + } + 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(), + isAppleAuthenticationAvailable: vi.fn(() => Promise.resolve(true)), + // 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(), + CloudAccountMismatchError, + IdpTokenAcquisitionError, + }; +}); + +vi.mock('@sentry/react-native', () => ({ captureException: mocks.captureException })); + +vi.mock('../client', () => ({ + CLOUD_URL: 'https://api.linkcode.ai', + cloudAuthClient: { + $fetch: (url: string, options: unknown) => + url.endsWith('/deletion-requirements') + ? mocks.fetchRequirements(url, options) + : mocks.fetchDelete(url, options), + signOut: mocks.signOutCloud, + }, +})); + +vi.mock('../account', () => ({ + CloudAccountMismatchError: mocks.CloudAccountMismatchError, + reauthenticateToCloud: mocks.reauthenticateToCloud, +})); + +vi.mock('../idp', () => ({ + IdpTokenAcquisitionError: mocks.IdpTokenAcquisitionError, + isAppleAuthenticationAvailable: mocks.isAppleAuthenticationAvailable, + isAppleSignInCancel: (error: unknown) => + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ERR_REQUEST_CANCELED', + 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'; + +beforeEach(() => { + mocks.fetchRequirements.mockResolvedValue({ data: { method: 'browser' }, error: null }); + mocks.isAppleAuthenticationAvailable.mockResolvedValue(true); +}); + +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.fetchDelete.mockResolvedValueOnce({ + data: { status: 'completed', authorizationRevocation: 'completed' }, + error: null, + }); + + const result = await deleteAccount(); + + expect(result).toEqual({ kind: 'completed', authorizationRevocation: 'completed' }); + expect(mocks.fetchDelete).toHaveBeenCalledWith( + 'https://api.linkcode.ai/account', + expect.objectContaining({ + method: 'DELETE', + body: { idpToken: 'jwt-1', appleAuthorizationCode: 'apple-code-1' }, + }), + ); + }); + + it('does not report or delete when native re-authentication is unavailable', async () => { + mocks.fetchRequirements.mockResolvedValueOnce({ data: { method: 'native' }, error: null }); + mocks.isAppleAuthenticationAvailable.mockResolvedValueOnce(false); + + const result = await deleteAccount(); + + expect(result).toEqual({ kind: 'apple-device-required' }); + expect(mocks.reauthenticateWithApple).not.toHaveBeenCalled(); + expect(mocks.fetchDelete).not.toHaveBeenCalled(); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it('on the non-Apple branch, re-runs the browser sign-in and sends the request without an idpToken', async () => { + mocks.fetchDelete.mockResolvedValueOnce({ + data: { status: 'completed', authorizationRevocation: 'not_applicable' }, + error: null, + }); + + const result = await deleteAccount(); + + expect(result).toEqual({ kind: 'completed', authorizationRevocation: 'not_applicable' }); + expect(mocks.reauthenticateToCloud).toHaveBeenCalledTimes(1); + expect(mocks.reauthenticateWithApple).not.toHaveBeenCalled(); + expect(mocks.fetchDelete).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.reauthenticateToCloud.mockRejectedValueOnce(new Error('dismissed')); + + const result = await deleteAccount(); + + expect(result).toEqual({ kind: 'reauthentication-failed' }); + 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')); + + const result = await deleteAccount(); + + expect(result).toEqual({ kind: 'reauthentication-failed' }); + 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.fetchDelete.mockResolvedValueOnce({ data: null, error: { status: 401 } }); + + 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.fetchDelete.mockResolvedValueOnce({ + data: null, + error: { status: 409, code: 'ACCOUNT_DELETION_SOLE_ORGANIZATION_OWNER' }, + }); + + const result = await deleteAccount(); + + expect(result).toEqual({ + kind: 'failed', + code: 'ACCOUNT_DELETION_SOLE_ORGANIZATION_OWNER', + }); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it('reports a server failure from the deletion endpoint', async () => { + mocks.fetchDelete.mockResolvedValueOnce({ data: null, error: { status: 503 } }); + + const result = await deleteAccount(); + + expect(result).toEqual({ kind: 'failed' }); + expect(mocks.captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ tags: { account_deletion_stage: 'response' } }), + ); + }); + + 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' }, + error: null, + }); + + 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.fetchDelete.mockRejectedValueOnce(new Error('offline')); + + const result = await deleteAccount(); + + expect(result).toEqual({ kind: 'failed' }); + }); + + it('treats an unparseable success response as pending rather than completed', async () => { + mocks.fetchDelete.mockResolvedValueOnce({ data: { unexpected: true }, error: null }); + + const result = await deleteAccount(); + + expect(result).toEqual({ kind: 'pending' }); + expect(mocks.captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ tags: { account_deletion_stage: 'response' } }), + ); + }); +}); + +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/account.ts b/apps/mobile/src/runtime/cloud/account.ts index ae380fcd2..6489fc9dd 100644 --- a/apps/mobile/src/runtime/cloud/account.ts +++ b/apps/mobile/src/runtime/cloud/account.ts @@ -1,6 +1,7 @@ import { disableDeviceNotifications } from '@mobile/runtime/notifications'; import { falseFn, noop, trueFn } from 'foxts/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. */ @@ -39,6 +40,38 @@ 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) }), + 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`, + {}, + ); + if (error) throw new Error(`session read failed (${error.status})`); + const { session, user } = freshSessionSchema.parse(data); + return { sessionId: session.id, userId: user.id }; +} + +export async function reauthenticateToCloud(): Promise { + const previous = await readAuthoritativeSession(); + await signInToCloud(); + const current = await readAuthoritativeSession(); + if (current.sessionId === previous.sessionId) { + throw new Error('browser re-authentication did not create a fresh session'); + } + if (current.userId !== previous.userId) { + await cloudAuthClient.signOut().catch(noop); + throw new CloudAccountMismatchError('browser re-authentication signed in a different account'); + } +} + export async function signOutOfCloud(options: { revokePushToken?: boolean } = {}): Promise { let pushDeliveryDisabled = false; let signedOut = false; diff --git a/apps/mobile/src/runtime/cloud/client.ts b/apps/mobile/src/runtime/cloud/client.ts index 402d6995e..5a97ddde7 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. + * 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..98131a694 --- /dev/null +++ b/apps/mobile/src/runtime/cloud/deletion.ts @@ -0,0 +1,191 @@ +import { useHostRegistryStore } from '@mobile/stores/host-store'; +import * as Sentry from '@sentry/react-native'; +import { z } from 'zod'; +import { CloudAccountMismatchError, reauthenticateToCloud } from './account'; +import { CLOUD_URL, cloudAuthClient } from './client'; +import { clearDeviceEnrollment } from './devices'; +import { + IdpTokenAcquisitionError, + isAppleAuthenticationAvailable, + isAppleSignInCancel, + reauthenticateWithApple, + signOutOfIdp, +} from './idp'; + +export type AccountDeletionRevocation = 'completed' | 'failed' | 'not_applicable'; + +export type AccountDeletionOutcome = + | { kind: 'completed'; authorizationRevocation: AccountDeletionRevocation } + | { kind: 'pending'; reference?: string } + /** Reauthentication itself failed (wrong account, cancelled, expired) — the + * account is untouched; PONR was never reached. */ + | { 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`), + * for copy that names the specific reason. */ + | { kind: 'failed'; code?: string }; + +const deletionResponseSchema = z.object({ + status: z.enum(['completed', 'pending']), + authorizationRevocation: z.enum(['completed', 'failed', 'not_applicable']).optional(), + 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' + | 'response' + | '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 } }); +} + +/** + * 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(): Promise { + let idpToken: string | undefined; + let appleAuthorizationCode: string | undefined; + + let method: 'native' | 'browser'; + try { + 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 { + if (!(await isAppleAuthenticationAvailable())) { + return { kind: 'apple-device-required' }; + } + const reauth = await reauthenticateWithApple(); + idpToken = reauth.idpToken; + appleAuthorizationCode = reauth.authorizationCode; + } catch (error) { + if (!isAppleSignInCancel(error)) { + reportFailure( + error instanceof IdpTokenAcquisitionError ? 'idp-token' : 'native-provider', + error, + ); + } + return { kind: 'reauthentication-failed' }; + } + } else { + try { + // 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' }; + } + } + + let response: { + data: unknown; + error: { status: number; code?: unknown } | null; + }; + try { + response = await cloudAuthClient.$fetch(`${CLOUD_URL}/account`, { + method: 'DELETE', + body: { idpToken, appleAuthorizationCode }, + }); + } catch (error) { + reportFailure('transport', error); + // Without an HTTP response, never claim that the server accepted deletion. + return { kind: 'failed' }; + } + + if (response.error) { + if (response.error.status === 401) { + reportFailure('cloud-identity', new Error('Cloud rejected account re-authentication')); + return { kind: 'reauthentication-failed' }; + } + if (response.error.status !== 409) { + reportFailure('response', new Error(`account deletion failed (${response.error.status})`)); + } + return { + kind: 'failed', + code: typeof response.error.code === 'string' ? response.error.code : undefined, + }; + } + + const parsed = deletionResponseSchema.safeParse(response.data); + if (!parsed.success) { + reportFailure('response', parsed.error); + // A successful HTTP response proves acceptance even when its body is unreadable. + return { kind: 'pending' }; + } + if (parsed.data.status === 'pending') { + return { kind: 'pending', reference: parsed.data.reference }; + } + return { + kind: 'completed', + authorizationRevocation: parsed.data.authorizationRevocation ?? '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 (let index = 0, length = results.length; index < length; index += 1) { + const result = results[index]; + if (result.status === 'rejected') { + Sentry.captureException(result.reason); + } + } + try { + removeTunnelHosts(); + } catch (error) { + Sentry.captureException(error); + } +} + +/** Removes account-scoped tunnel profiles. Reactive consumers handle connection disposal. */ +function removeTunnelHosts(): void { + const { hosts, removeHost } = useHostRegistryStore.getState(); + for (let index = 0, length = hosts.length; index < length; index += 1) { + const host = hosts[index]; + 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..f58c0100a 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,25 @@ 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 authorization code from this authentication. */ + 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 + * 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,49 +55,80 @@ 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. const { givenName, familyName } = credential.fullName ?? {}; - const signedIn = await idpAuthClient.signIn.social({ - provider: 'apple', - idToken: { - token: credential.identityToken, - nonce: rawNonce, - ...((givenName || familyName) && { - user: { - name: { - firstName: givenName ?? undefined, - lastName: familyName ?? undefined, + try { + const signedIn = await idpAuthClient.signIn.social({ + provider: 'apple', + idToken: { + token: credential.identityToken, + nonce: rawNonce, + ...((givenName || familyName) && { + user: { + name: { + firstName: givenName ?? undefined, + lastName: familyName ?? undefined, + }, }, - }, - }), - }, - }); - if (signedIn.error) { - throw new Error(`IdP sign-in failed (${signedIn.error.status})`); + }), + }, + }); + if (signedIn.error) { + throw new Error(`IdP sign-in failed (${signedIn.error.status})`); + } + + 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 }; + } catch (error) { + throw new IdpTokenAcquisitionError('Could not acquire an IdP token', { cause: error }); } +} - 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'); +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})`); } } +export const reauthenticateWithApple = authenticateWithAppleNatively; + +export async function isAppleAuthenticationAvailable(): Promise { + return AppleAuthentication.isAvailableAsync(); +} + +/** + * Clears the IdP's own SecureStore session (`arcbox-idp` prefix) — never + * touched by `signOutOfCloud()`, which only knows about the cloud session. + * Best-effort: a failure here does not roll back an accepted deletion. + */ +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 ( diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 05d58c9da..2f5d51b8e 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -57,6 +57,8 @@ Point a client at something other than production LinkCode Cloud. All default to | `LINKCODE_CLOUD_SIGN_IN_URL` | `apps/desktop/src/main/cloud-auth/client.ts` | `https://linkcode.ai/sign-in` | | `VITE_LINKCODE_CLOUD_API_URL` | `apps/webview/src/cloud/auth.ts` | `https://api.linkcode.ai` (build-time inlined) | | `LINKCODE_CLOUD_URL` | `apps/daemon/src/cloud/login.ts` | `DEFAULT_CLOUD_URL` in `apps/daemon/src/cloud/api.ts` | +| `EXPO_PUBLIC_CLOUD_URL` | `apps/mobile/src/runtime/cloud/client.ts` | `https://api.linkcode.ai` (inlined by Metro/EAS; local `svc dev` stacks only) | +| `EXPO_PUBLIC_IDP_URL` | `apps/mobile/src/runtime/cloud/idp.ts` | `https://auth.arcbox.dev` (inlined by Metro/EAS; local `svc dev` stacks only) | `LINKCODE_CLOUD_URL` falls back on an empty string (`||`); the desktop/webview overrides use `??`, so setting them to `''` yields an empty base URL rather than the default. diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 8e090ed84..abb19a542 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1293,6 +1293,28 @@ 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', + 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.', + 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 05b1739a2..aca536701 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1255,6 +1255,23 @@ export const zhCN = { revokeThisDeviceMessage: '这是当前使用的手机——撤销后本机也会退出登录。', revokeCancel: '取消', revokeError: '撤销设备失败。', + deleteAccount: '删除账号', + deleteTitle: '删除你的账号?', + deleteMessage: + '此操作将永久删除你的 LinkCode Cloud 账号,且无法撤销。与该账号关联的设备、隧道连接与消息记录都会被移除。Apple 内购订阅不会自动取消,请在 App Store 设置中管理。', + deleteCancel: '取消', + deleteConfirm: '删除', + deleteReauthenticationFailed: '无法确认身份,请重试。', + deleteAppleDeviceRequired: '此账号必须在支持「通过 Apple 登录」的设备上确认身份后删除。', + deleteAccountMismatch: '刚才登录的是另一个账号。请重新登录你想删除的账号。', + deleteFailed: '删除账号失败,请重试。', + deleteSoleOwner: '你是某个共享组织的唯一所有者。请先转移所有权,再删除账号。', + deleteEmergencyHold: '该账号暂不支持自助删除,请联系支持完成删除。', + deleteCompleted: '你的账号已删除。', + deletePending: '已收到删除请求,部分步骤需人工处理,将在 3~5 个工作日内完成。', + deleteRevocationFailedTitle: '移除 LinkCode 的 Apple 登录权限', + deleteRevocationFailedMessage: + '账号已删除,但未能自动移除 Apple 的登录授权。请到「设置 →[你的姓名]→ 使用 Apple ID 登录」中移除 LinkCode。', }, sessions: { title: '线程',