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: '线程',