diff --git a/.changeset/ios-native-reconciliation.md b/.changeset/ios-native-reconciliation.md new file mode 100644 index 000000000..1db676389 --- /dev/null +++ b/.changeset/ios-native-reconciliation.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Add validated Native API, iOS application registration, and native Apple connection reconciliation support. diff --git a/packages/cli-core/src/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index 96d48a378..b92608fa0 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -400,6 +400,7 @@ describe("api command", () => { { instance_id: "ins_dev", environment_type: "development", + publishable_key: "pk_test_fixture", secret_key: "sk_test_derived", }, ], @@ -433,6 +434,7 @@ describe("api command", () => { { instance_id: "ins_dev", environment_type: "development", + publishable_key: "pk_test_fixture", secret_key: "sk_test_oauth", }, ], diff --git a/packages/cli-core/src/commands/config/pull.test.ts b/packages/cli-core/src/commands/config/pull.test.ts index fed5d17d7..6eceb120c 100644 --- a/packages/cli-core/src/commands/config/pull.test.ts +++ b/packages/cli-core/src/commands/config/pull.test.ts @@ -98,7 +98,13 @@ describe("config pull", () => { test("supports --app without a linked profile", async () => { const mockApp = { application_id: "app_1", - instances: [{ instance_id: "ins_dev", environment_type: "development" }], + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_fixture", + }, + ], }; stubFetch(async (input) => { diff --git a/packages/cli-core/src/commands/config/push.test.ts b/packages/cli-core/src/commands/config/push.test.ts index 780edddee..0c61f5701 100644 --- a/packages/cli-core/src/commands/config/push.test.ts +++ b/packages/cli-core/src/commands/config/push.test.ts @@ -205,7 +205,13 @@ describe("config push", () => { return new Response( JSON.stringify({ application_id: "app_1", - instances: [{ instance_id: "ins_dev", environment_type: "development" }], + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_fixture", + }, + ], }), { status: 200 }, ); diff --git a/packages/cli-core/src/commands/config/schema.test.ts b/packages/cli-core/src/commands/config/schema.test.ts index e56bef577..ff97f7bf4 100644 --- a/packages/cli-core/src/commands/config/schema.test.ts +++ b/packages/cli-core/src/commands/config/schema.test.ts @@ -87,7 +87,13 @@ describe("config schema", () => { test("supports --app without a linked profile", async () => { const mockApp = { application_id: "app_1", - instances: [{ instance_id: "ins_dev", environment_type: "development" }], + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_fixture", + }, + ], }; stubFetch(async (input) => { diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 297907195..383f2ac34 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -27,6 +27,8 @@ const mockPatchInstanceConfig = mock(); const mockFetchInstanceConfig = mock(); const mockFetchInstanceConfigSchema = mock(); const mockFetchApplication = mock(); +const mockListIOSApplications = mock(); +const mockGetNativeSettings = mock(); const mockListApplicationDomains = mock(); const mockCreateProductionInstance = mock(); const mockGetApplicationDomainStatus = mock(); @@ -49,6 +51,8 @@ mock.module("../../lib/plapi.ts", () => ({ fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), fetchInstanceConfigSchema: (...args: unknown[]) => mockFetchInstanceConfigSchema(...args), fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), + listIOSApplications: (...args: unknown[]) => mockListIOSApplications(...args), + getNativeSettings: (...args: unknown[]) => mockGetNativeSettings(...args), listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), createProductionInstance: (...args: unknown[]) => mockCreateProductionInstance(...args), getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args), @@ -236,6 +240,8 @@ describe("deploy", () => { mockGetApplicationDomainStatus.mockResolvedValue( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), ); + mockListIOSApplications.mockResolvedValue([]); + mockGetNativeSettings.mockResolvedValue({ object: "native_settings", api_enabled: true }); stubCreateProductionInstance(); mockTriggerApplicationDomainDNSCheck.mockResolvedValue( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), @@ -269,6 +275,8 @@ describe("deploy", () => { mockFetchInstanceConfig.mockReset(); mockFetchInstanceConfigSchema.mockReset(); mockFetchApplication.mockReset(); + mockListIOSApplications.mockReset(); + mockGetNativeSettings.mockReset(); mockListApplicationDomains.mockReset(); mockCreateProductionInstance.mockReset(); mockGetApplicationDomainStatus.mockReset(); @@ -1353,6 +1361,368 @@ describe("deploy", () => { ); }); + test("skips Apple web credential prompts for an exact native-only production registration", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockResolvedValueOnce([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + mockIsAgent.mockReturnValue(false); + + await runDeploy({}); + + expect(mockListIOSApplications).toHaveBeenCalledWith("app_xyz789", "ins_prod_native_apple"); + expect(mockGetNativeSettings).toHaveBeenCalledWith("app_xyz789", "ins_prod_native_apple"); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + const err = stripAnsi(captured.err); + expect(err).toContain("No deploy actions remain."); + expect(err).toContain("OAuth Apple"); + expect(err).not.toContain("Configure Apple OAuth for production"); + }); + + test("refuses case-only Apple registration mismatches without suggesting another registration", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.Example.Native", + created_at: 1, + updated_at: 1, + }, + ]); + mockIsAgent.mockReturnValue(false); + + const thrown = await runDeploy({}).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(CliError); + const message = (thrown as Error).message; + expect(message).toContain("letter casing does not exactly match"); + expect(message).toContain("registration's exact Bundle ID spelling"); + expect(message).toContain("Do not create another registration"); + expect(message).not.toContain("Register it at"); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + }); + + test("refuses ambiguous App ID Prefix registrations for native-only Apple", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_first", + app_id_prefix: "FIRST12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + { + object: "ios_application", + id: "ios_second", + app_id_prefix: "SECOND1234", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + mockIsAgent.mockReturnValue(false); + + let thrown: unknown; + try { + await runDeploy({}); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(CliError); + const message = (thrown as Error).message; + expect(message).toContain("more than one App ID Prefix registration"); + expect(message).toContain("Review the existing registrations"); + expect(message).toContain("Do not create another registration"); + expect(message).not.toContain("Register it at"); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + expect(stripAnsi(captured.err)).toContain("Failed"); + }); + + test("does not recommend registration creation when native Apple verification is unavailable", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockRejectedValue(new Error("native endpoint unavailable")); + mockIsAgent.mockReturnValue(false); + + let thrown: unknown; + try { + await runDeploy({}); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(CliError); + const message = (thrown as Error).message; + expect(message).toContain( + "could not verify the production Native Application registration for com.example.native", + ); + expect(message).toContain("no registration should be created from this unverified result"); + expect(message).toContain("Retry `clerk deploy`"); + expect(message).not.toContain("Register it at"); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + expect(stripAnsi(captured.err)).toContain("Failed"); + }); + + test("refuses to infer an App ID Prefix when native Apple lacks an exact production registration", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockResolvedValueOnce([ + { + object: "ios_application", + id: "ios_other", + app_id_prefix: "OTHER12345", + bundle_id: "com.example.other", + created_at: 1, + updated_at: 1, + }, + ]); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({})).rejects.toThrow( + "the production instance does not have an exact iOS Native Application registration for that Bundle ID", + ); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + expect(stripAnsi(captured.err)).toContain("Failed"); + }); + + test("preserves Ctrl-C while verifying a native-only Apple registration", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications + .mockRejectedValueOnce(new Error("native status endpoint unavailable")) + .mockRejectedValueOnce(promptExitError()); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({})).rejects.toMatchObject({ exitCode: EXIT_CODE.SIGINT }); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + expect(stripAnsi(captured.err)).toContain("Paused"); + }); + + test("refuses native-only Apple when production Native API is disabled", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + mockGetNativeSettings.mockResolvedValue({ + object: "native_settings", + api_enabled: false, + }); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({})).rejects.toThrow( + "Enable Native API at https://dashboard.clerk.com/~/native-applications", + ); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + }); + + test("refuses disabled native-only Apple without requesting hosted credentials", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: false, + authenticatable: false, + bundle_id: "com.example.native", + }, + }, + }); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({})).rejects.toThrow( + "Apple is not explicitly enabled for authentication on the production instance", + ); + + expect(mockListIOSApplications).not.toHaveBeenCalled(); + expect(mockGetNativeSettings).not.toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + }); + test("Apple .p8 file prompt validates path and PEM framing before continuing", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_apple" }, @@ -1406,6 +1776,8 @@ describe("deploy", () => { "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg\n-----END PRIVATE KEY-----\n", }, }); + expect(mockListIOSApplications).not.toHaveBeenCalled(); + expect(mockGetNativeSettings).not.toHaveBeenCalled(); const p8Input = mockInput.mock.calls.find((call) => String((call[0] as { message?: string }).message).includes("Apple Private Key"), )?.[0] as { validate: (value: string) => Promise }; diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 34be656bf..0a625ce72 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -10,6 +10,9 @@ import { interruptedExitCode } from "../../lib/signals.ts"; import { setProfile } from "../../lib/config.ts"; import { createProductionInstance as apiCreateProductionInstance, + fetchInstanceConfig, + getNativeSettings, + listIOSApplications, patchInstanceConfig, type CnameTarget, type ProductionInstanceResponse, @@ -40,6 +43,7 @@ import { } from "./copy.ts"; import { mapDeployError } from "./errors.ts"; import { + inspectNativeAppleConfiguration, providerLabel, providerSetupIntro, showOAuthWalkthrough, @@ -641,6 +645,10 @@ async function collectAndSaveOAuthCredentials( productionInstanceId: string, frontendApiUrl?: string, ): Promise { + if (await nativeAppleCredentialsAreAlreadyConfigured(ctx, descriptor, productionInstanceId)) { + return true; + } + for (const line of providerSetupIntro(descriptor)) log.info(line); log.blank(); @@ -675,6 +683,93 @@ async function collectAndSaveOAuthCredentials( return true; } +async function nativeAppleCredentialsAreAlreadyConfigured( + ctx: DeployContext, + descriptor: OAuthProviderDescriptor, + productionInstanceId: string, +): Promise { + if (descriptor.provider !== "apple") return false; + + const productionConfig = await withSpinner( + "Checking production Sign in with Apple configuration...", + async () => fetchInstanceConfig(ctx.appId, productionInstanceId), + ); + const preliminary = inspectNativeAppleConfiguration(productionConfig, descriptor, []); + if (preliminary.status === "authentication-disabled") { + throwUsageError( + `Native Sign in with Apple is configured for ${preliminary.bundleId}, but Apple is not explicitly enabled for authentication on the production instance. ` + + "Review the Apple connection in the Clerk Dashboard, then rerun `clerk deploy`. No Apple web credentials were requested.", + ); + } + if (preliminary.status !== "registration-missing") { + return false; + } + + let nativeConfiguration: ReturnType; + try { + const [iosApplications, nativeSettings] = await withSpinner( + "Checking production Native Application settings...", + async () => + Promise.all([ + listIOSApplications(ctx.appId, productionInstanceId), + getNativeSettings(ctx.appId, productionInstanceId), + ]), + ); + nativeConfiguration = inspectNativeAppleConfiguration( + productionConfig, + descriptor, + iosApplications, + nativeSettings, + ); + } catch (error) { + if (error instanceof UserAbortError) throw error; + nativeConfiguration = { + status: "verification-unavailable", + bundleId: preliminary.bundleId, + }; + } + + if (nativeConfiguration.status === "verification-unavailable") { + throw new CliError( + `clerk deploy could not verify the production Native Application registration for ${preliminary.bundleId}. ` + + "No Apple web credentials were requested and no registration should be created from this unverified result. Retry `clerk deploy`, or review the existing registrations at https://dashboard.clerk.com/~/native-applications.", + ); + } + + if (nativeConfiguration.status === "ready") { + log.success( + `Native Sign in with Apple is configured for ${nativeConfiguration.bundleId}; Apple web credentials are not required`, + ); + return true; + } + + if (nativeConfiguration.status === "native-api-disabled") { + throwUsageError( + `Native Sign in with Apple is configured for ${nativeConfiguration.bundleId}, but Native API is disabled on the production instance. ` + + "Enable Native API at https://dashboard.clerk.com/~/native-applications, then rerun `clerk deploy`. The CLI will not infer an App ID Prefix or request unrelated Apple web credentials.", + ); + } + + if (nativeConfiguration.status === "registration-ambiguous") { + throwUsageError( + `Native Sign in with Apple has more than one App ID Prefix registration for ${nativeConfiguration.bundleId}. ` + + "Review the existing registrations at https://dashboard.clerk.com/~/native-applications, then rerun `clerk deploy`. Do not create another registration or add unrelated Apple web credentials.", + ); + } + + if (nativeConfiguration.status === "registration-bundle-case-mismatch") { + throwUsageError( + `Native Sign in with Apple uses Bundle ID ${nativeConfiguration.bundleId}, but its letter casing does not exactly match the existing iOS Native Application registration. ` + + "Update the Apple connection to use the registration's exact Bundle ID spelling in the Clerk Dashboard, then rerun `clerk deploy`. Do not create another registration or add unrelated Apple web credentials.", + ); + } + + throwUsageError( + `Native Sign in with Apple is configured for ${preliminary.bundleId}, but the production instance does not have an exact iOS Native Application registration for that Bundle ID. ` + + "Register it at https://dashboard.clerk.com/~/native-applications, then rerun `clerk deploy`. The CLI will not infer an App ID Prefix or request unrelated Apple web credentials.", + ); +} + async function persistProductionInstance(ctx: DeployContext, productionInstanceId: string) { await setProfile(ctx.profileKey, { ...ctx.profile, diff --git a/packages/cli-core/src/commands/deploy/providers.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts index b756b3860..cb3d0fb0c 100644 --- a/packages/cli-core/src/commands/deploy/providers.test.ts +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from "bun:test"; import { buildOAuthProviderDescriptors, + inspectNativeAppleConfiguration, providerFields, providerLabel, type OAuthProviderDescriptor, } from "./providers.ts"; -import type { InstanceConfigSchema } from "../../lib/plapi.ts"; +import type { IOSApplication, InstanceConfigSchema } from "../../lib/plapi.ts"; const oauthSchema = (properties: Record) => ({ type: "object", @@ -27,6 +28,21 @@ const basicOAuthSchema = oauthSchema({ }, }); +const appleOAuthSchema = oauthSchema({ + client_id: { type: "string", description: "Apple Services ID" }, + client_secret: { + type: "string", + description: "Apple Private Key", + "x-clerk-sensitive": true, + }, + key_id: { type: "string", description: "Apple Key ID" }, + team_id: { type: "string", description: "Apple Team ID" }, + bundle_id: { + type: "string", + description: "iOS app Bundle ID for native Sign in with Apple", + }, +}); + const schemaResponse = (properties: Record): InstanceConfigSchema => ({ $schema: "https://json-schema.org/draft/2020-12/schema", $id: "https://clerk.com/schemas/platform-config/2025-01-01", @@ -43,6 +59,21 @@ function descriptorByProvider( return descriptor; } +function iosApplication( + bundleId: string, + appIdPrefix = "ABCDE12345", + id = `ios_${appIdPrefix}_${bundleId}`, +): IOSApplication { + return { + object: "ios_application", + id, + app_id_prefix: appIdPrefix, + bundle_id: bundleId, + created_at: 1, + updated_at: 1, + }; +} + describe("deploy OAuth provider descriptors", () => { test("builds a descriptor for public providers from schema and shared metadata", () => { const result = buildOAuthProviderDescriptors( @@ -147,22 +178,7 @@ describe("deploy OAuth provider descriptors", () => { test("applies Apple production credential overrides", () => { const result = buildOAuthProviderDescriptors( ["apple"], - schemaResponse({ - connection_oauth_apple: oauthSchema({ - client_id: { type: "string", description: "Apple Services ID" }, - client_secret: { - type: "string", - description: "Apple Private Key", - "x-clerk-sensitive": true, - }, - key_id: { type: "string", description: "Apple Key ID" }, - team_id: { type: "string", description: "Apple Team ID" }, - bundle_id: { - type: "string", - description: "iOS app Bundle ID for native Sign in with Apple", - }, - }), - }), + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), ); const apple = descriptorByProvider(result.supported, "apple"); @@ -190,6 +206,166 @@ describe("deploy OAuth provider descriptors", () => { ]); }); + test("recognizes native-only Apple only for an exact production registration", () => { + const result = buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ); + const apple = descriptorByProvider(result.supported, "apple"); + const config = { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.app", + }, + }; + + expect( + inspectNativeAppleConfiguration(config, apple, [iosApplication("com.example.app")], { + object: "native_settings", + api_enabled: true, + }), + ).toEqual({ status: "ready", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration(config, apple, [iosApplication("COM.EXAMPLE.APP")], { + object: "native_settings", + api_enabled: true, + }), + ).toEqual({ + status: "registration-bundle-case-mismatch", + bundleId: "com.example.app", + }); + expect( + inspectNativeAppleConfiguration(config, apple, [iosApplication("com.example.other")], { + object: "native_settings", + api_enabled: true, + }), + ).toEqual({ status: "registration-missing", bundleId: "com.example.app" }); + }); + + test("rejects multiple App ID prefixes for one native Apple Bundle ID", () => { + const result = buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ); + const apple = descriptorByProvider(result.supported, "apple"); + const config = { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.app", + }, + }; + + expect( + inspectNativeAppleConfiguration( + config, + apple, + [ + iosApplication("com.example.app", "PREFIX_ONE"), + iosApplication("com.example.app", "PREFIX_TWO"), + ], + { object: "native_settings", api_enabled: true }, + ), + ).toEqual({ status: "registration-ambiguous", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration( + config, + apple, + [ + iosApplication("com.example.app", "PREFIX_ONE", "ios_first"), + iosApplication("com.example.app", "PREFIX_ONE", "ios_duplicate"), + ], + { object: "native_settings", api_enabled: true }, + ), + ).toEqual({ status: "ready", bundleId: "com.example.app" }); + }); + + test("requires Native API and authenticatable Apple settings for native readiness", () => { + const result = buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ); + const apple = descriptorByProvider(result.supported, "apple"); + const iosApplications = [iosApplication("com.example.app")]; + const connection = { + enabled: true, + authenticatable: true, + bundle_id: "com.example.app", + }; + + expect( + inspectNativeAppleConfiguration( + { connection_oauth_apple: connection }, + apple, + iosApplications, + { object: "native_settings", api_enabled: false }, + ), + ).toEqual({ status: "native-api-disabled", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration( + { + connection_oauth_apple: { + ...connection, + authenticatable: false, + }, + }, + apple, + iosApplications, + { object: "native_settings", api_enabled: true }, + ), + ).toEqual({ status: "authentication-disabled", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration( + { + connection_oauth_apple: { + enabled: false, + authenticatable: false, + bundle_id: "com.example.app", + }, + }, + apple, + iosApplications, + { object: "native_settings", api_enabled: true }, + ), + ).toEqual({ status: "authentication-disabled", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration( + { + connection_oauth_apple: { + enabled: true, + bundle_id: "com.example.app", + }, + }, + apple, + iosApplications, + { object: "native_settings", api_enabled: true }, + ), + ).toEqual({ status: "authentication-disabled", bundleId: "com.example.app" }); + }); + + test("keeps hosted Apple credentials on the hosted OAuth path", () => { + const result = buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ); + const apple = descriptorByProvider(result.supported, "apple"); + + expect( + inspectNativeAppleConfiguration( + { + connection_oauth_apple: { + enabled: false, + bundle_id: "com.example.app", + client_id: "com.example.web", + }, + }, + apple, + [iosApplication("com.example.app")], + ), + ).toEqual({ status: "hosted-or-unconfigured" }); + }); + test("keeps compatibility prompt labels only for behavioral overrides", () => { expect(providerFields("google").map((field) => field.label)).toEqual([ "Client ID", diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index 52624ef05..f81083681 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -1,10 +1,16 @@ import { OAUTH_PROVIDERS } from "@clerk/shared/oauth"; +import { bundleIdentifiersEqual } from "../../lib/apple-native-identity.ts"; import { bold, cyan, dim, yellow } from "../../lib/color.ts"; import { clerkSubdomains } from "./copy.ts"; import { log } from "../../lib/log.ts"; import { wrap } from "../../lib/wrap.ts"; import { openBrowser } from "../../lib/open.ts"; -import type { ConfigSchemaProperty, InstanceConfigSchema } from "../../lib/plapi.ts"; +import type { + ConfigSchemaProperty, + IOSApplication, + InstanceConfigSchema, + NativeSettings, +} from "../../lib/plapi.ts"; const DEFAULT_DOCS_URL_PREFIX = "https://clerk.com/docs/guides/configure/auth-strategies/social-connections"; @@ -63,6 +69,20 @@ export type OAuthProviderDescriptorResult = { unsupported: string[]; }; +export type NativeAppleConfiguration = + | { status: "not-apple" | "hosted-or-unconfigured" } + | { + status: + | "ready" + | "authentication-disabled" + | "registration-missing" + | "registration-bundle-case-mismatch" + | "registration-ambiguous" + | "native-api-disabled" + | "verification-unavailable"; + bundleId: string; + }; + type ProviderOverride = { credentialLabel?: string; redirectLabel?: string; @@ -213,6 +233,62 @@ export function hasProviderRequiredCredentials( }); } +/** + * Distinguish native-only Apple configuration from hosted Apple OAuth without + * treating an unrelated iOS registration as proof. Native-only production + * setup is ready only when it is authenticatable, its explicit Bundle ID has + * an exact registration, and Native API is enabled on that production instance. + */ +export function inspectNativeAppleConfiguration( + config: Record, + descriptor: OAuthProviderDescriptor, + iosApplications: readonly IOSApplication[], + nativeSettings?: NativeSettings, +): NativeAppleConfiguration { + if (descriptor.provider !== "apple") return { status: "not-apple" }; + + const value = config[descriptor.configKey]; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { status: "hosted-or-unconfigured" }; + } + const providerConfig = value as Record; + if (hasAppleHostedIdentifier(providerConfig)) { + return { status: "hosted-or-unconfigured" }; + } + + const rawBundleId = providerConfig.bundle_id; + const bundleId = typeof rawBundleId === "string" ? rawBundleId.trim() : ""; + if (!bundleId) return { status: "hosted-or-unconfigured" }; + if (providerConfig.enabled !== true || providerConfig.authenticatable !== true) { + return { status: "authentication-disabled", bundleId }; + } + + const registeredPrefixes = new Set( + iosApplications + .filter((application) => bundleIdentifiersEqual(application.bundle_id, bundleId)) + .map((application) => application.app_id_prefix), + ); + if (registeredPrefixes.size === 0) { + return { status: "registration-missing", bundleId }; + } + if (registeredPrefixes.size > 1) { + return { status: "registration-ambiguous", bundleId }; + } + if (!iosApplications.some((application) => application.bundle_id === bundleId)) { + return { status: "registration-bundle-case-mismatch", bundleId }; + } + return nativeSettings?.api_enabled === true + ? { status: "ready", bundleId } + : { status: "native-api-disabled", bundleId }; +} + +function hasAppleHostedIdentifier(config: Record): boolean { + return ["client_id", "client_secret", "team_id", "key_id"].some((key) => { + const value = config[key]; + return typeof value === "string" && value.trim().length > 0; + }); +} + function buildOAuthProviderDescriptor( provider: string, schema: InstanceConfigSchema, diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts index 72836d9c3..f85dd1b8c 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -35,8 +35,20 @@ function stripAnsi(value: string): string { } function appWith(production: boolean) { - const instances = [{ instance_id: "ins_dev", environment_type: "development" }]; - if (production) instances.push({ instance_id: "ins_prod", environment_type: "production" }); + const instances = [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_fixture", + }, + ]; + if (production) { + instances.push({ + instance_id: "ins_prod", + environment_type: "production", + publishable_key: "pk_live_fixture", + }); + } return { application_id: "app_1", name: "app", instances }; } diff --git a/packages/cli-core/src/commands/deploy/status-command.ts b/packages/cli-core/src/commands/deploy/status-command.ts index 39705c6bd..d8abc7100 100644 --- a/packages/cli-core/src/commands/deploy/status-command.ts +++ b/packages/cli-core/src/commands/deploy/status-command.ts @@ -232,6 +232,17 @@ export function humanNextAction(step: DeployNextStep): string { : "") ); case "oauth_pending": + if (step.nativeAppleReadinessIssue) { + const hostedPending = step.oauthPending.filter((provider) => provider !== "apple"); + const hostedAction = + hostedPending.length > 0 + ? ` These OAuth providers are also missing production credentials: ${hostedPending.join(", ")}. Run \`clerk deploy\` to configure them.` + : ""; + return ( + `Domain verified, but setup is incomplete. ${humanNativeAppleReadinessNextAction(step.nativeAppleReadinessIssue)}` + + hostedAction + ); + } return ( `Domain verified, but these OAuth providers are missing production credentials: ` + `${step.oauthPending.join(", ")}. Run \`clerk deploy\` to finish setup.` @@ -264,3 +275,44 @@ export function humanNextAction(step: DeployNextStep): string { ); } } + +function humanNativeAppleReadinessNextAction( + issue: NonNullable< + Extract["nativeAppleReadinessIssue"] + >, +): string { + if (issue.reason === "verification-unavailable") { + return ( + `Clerk could not verify the production Native Application registration for ${issue.bundleId}. ` + + "Retry `clerk deploy status`; do not create another registration based on this unverified result." + ); + } + if (issue.reason === "registration-ambiguous") { + return ( + `Native Sign in with Apple has more than one App ID Prefix registration for ${issue.bundleId}. ` + + "Review the existing registrations in the Clerk Dashboard before continuing; do not create another registration." + ); + } + if (issue.reason === "registration-bundle-case-mismatch") { + return ( + `The Apple connection Bundle ID ${issue.bundleId} differs only by letter casing from its existing iOS Native Application registration. ` + + "Update the Apple connection to use the registration's exact Bundle ID spelling in the Clerk Dashboard." + ); + } + if (issue.reason === "authentication-disabled") { + return ( + `Apple is not explicitly enabled for authentication on the production instance for ${issue.bundleId}. ` + + "Review the Apple connection in the Clerk Dashboard; do not add web credentials for a native-only setup." + ); + } + if (issue.reason === "native-api-disabled") { + return ( + `Native API is disabled on the production instance for ${issue.bundleId}. ` + + "Enable it in the Clerk Dashboard under Native Applications." + ); + } + return ( + `Native Sign in with Apple is missing an exact production iOS Native Application registration for ${issue.bundleId}. ` + + "Register that Bundle ID in the Clerk Dashboard under Native Applications." + ); +} diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts index 4325335cf..1a8ba39a2 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -4,6 +4,8 @@ import type { LiveDeploySnapshot } from "./status.ts"; const mockFetchApplication = mock(); const mockListApplicationDomains = mock(); +const mockListIOSApplications = mock(); +const mockGetNativeSettings = mock(); const mockFetchInstanceConfig = mock(); const mockFetchInstanceConfigSchema = mock(); const mockGetApplicationDomainStatus = mock(); @@ -12,6 +14,8 @@ const mockTriggerApplicationDomainDNSCheck = mock(); mock.module("../../lib/plapi.ts", () => ({ fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), + listIOSApplications: (...args: unknown[]) => mockListIOSApplications(...args), + getNativeSettings: (...args: unknown[]) => mockGetNativeSettings(...args), fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), fetchInstanceConfigSchema: (...args: unknown[]) => mockFetchInstanceConfigSchema(...args), getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args), @@ -52,14 +56,62 @@ const passthroughHandlers = { work({ update: () => {} }), }; +const appleOAuthSchema = { + type: "object", + properties: { + enabled: { type: "boolean" }, + authenticatable: { type: "boolean" }, + client_id: { type: "string" }, + client_secret: { type: "string", "x-clerk-sensitive": true }, + team_id: { type: "string" }, + key_id: { type: "string" }, + bundle_id: { type: "string" }, + }, +}; + +function mockActiveProductionEnvironment(): void { + mockFetchApplication.mockResolvedValue({ + application_id: "app_1", + name: "app", + instances: [ + { instance_id: "ins_dev", environment_type: "development" }, + { instance_id: "ins_prod", environment_type: "production" }, + ], + }); + mockListApplicationDomains.mockResolvedValue({ + data: [ + { + object: "domain", + id: "dmn_1", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.example.com", + accounts_portal_url: "https://accounts.example.com", + development_origin: "", + cname_targets: [], + }, + ], + total_count: 1, + }); + mockFetchInstanceConfigSchema.mockResolvedValue({ + properties: { connection_oauth_apple: appleOAuthSchema }, + }); + mockGetApplicationDomainStatus.mockResolvedValue(completeStatus); +} + beforeEach(() => { mockFetchInstanceConfig.mockResolvedValue({}); mockFetchInstanceConfigSchema.mockResolvedValue({ properties: {} }); + mockListIOSApplications.mockResolvedValue([]); + mockGetNativeSettings.mockResolvedValue({ object: "native_settings", api_enabled: true }); }); afterEach(() => { mockFetchApplication.mockReset(); mockListApplicationDomains.mockReset(); + mockListIOSApplications.mockReset(); + mockGetNativeSettings.mockReset(); mockFetchInstanceConfig.mockReset(); mockFetchInstanceConfigSchema.mockReset(); mockGetApplicationDomainStatus.mockReset(); @@ -159,6 +211,320 @@ describe("resolveDeployState", () => { expect(state.snapshot.completedOAuthProviders).toEqual(["google"]); } }); + + test("treats exact native-only Apple production registration as complete", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual(["apple"]); + expect(state.snapshot.pending).toBeUndefined(); + expect(state.snapshot.nativeAppleReadinessIssue).toBeUndefined(); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(true); + expect(report.oauth).toMatchObject({ complete: true, configured: ["apple"], pending: [] }); + } + expect(mockListIOSApplications).toHaveBeenCalledWith("app_1", "ins_prod"); + expect(mockGetNativeSettings).toHaveBeenCalledWith("app_1", "ins_prod"); + }); + + test("reports an actionable incomplete state for a missing exact native Apple registration", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_other", + app_id_prefix: "OTHER12345", + bundle_id: "com.example.other", + created_at: 1, + updated_at: 1, + }, + ]); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.pending).toEqual({ type: "oauth", provider: "apple" }); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "registration-missing", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.state).toBe("oauth_pending"); + expect(report.oauth.pending).toEqual(["apple"]); + expect(report.nextAction).toContain("com.example.native"); + expect(report.nextAction).toContain("https://dashboard.clerk.com/~/native-applications"); + expect(report.nextAction).toContain("will not infer an App ID Prefix"); + expect(report.nextAction).not.toContain( + "OAuth providers are missing production credentials: apple", + ); + } + }); + + test("reports a case-only native Apple registration mismatch without suggesting a duplicate", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.Example.Native", + created_at: 1, + updated_at: 1, + }, + ]); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "registration-bundle-case-mismatch", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain("differs only by letter casing"); + expect(report.nextAction).toContain("exact Bundle ID spelling"); + expect(report.nextAction).toContain("do not create another registration"); + expect(report.nextAction).not.toContain("Register that Bundle ID"); + } + }); + + test("reports native Apple verification as unavailable when native endpoint reads fail", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockRejectedValue(new Error("native endpoint unavailable")); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.pending).toEqual({ type: "oauth", provider: "apple" }); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "verification-unavailable", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain("could not verify"); + expect(report.nextAction).toContain("Retry `clerk deploy status`"); + expect(report.nextAction).toContain("do not create another registration"); + expect(report.nextAction).not.toContain("Register that Bundle ID"); + } + }); + + test("reports multiple App ID prefixes for one native Apple Bundle ID as ambiguous", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_first", + app_id_prefix: "FIRST12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + { + object: "ios_application", + id: "ios_second", + app_id_prefix: "SECOND1234", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.pending).toEqual({ type: "oauth", provider: "apple" }); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "registration-ambiguous", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain("more than one App ID Prefix registration"); + expect(report.nextAction).toContain("Review the existing registrations"); + expect(report.nextAction).toContain("do not create another registration"); + expect(report.nextAction).not.toContain("Register that Bundle ID"); + } + }); + + test("keeps hosted Apple completion credential-based without reading iOS registrations", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + bundle_id: "com.example.native", + client_id: "com.example.web", + client_secret: "REDACTED", + team_id: "TEAM123456", + key_id: "KEY1234567", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual(["apple"]); + expect(state.snapshot.nativeAppleReadinessIssue).toBeUndefined(); + } + expect(mockListIOSApplications).not.toHaveBeenCalled(); + expect(mockGetNativeSettings).not.toHaveBeenCalled(); + }); + + test("keeps exact native Apple incomplete when production Native API is disabled", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + mockGetNativeSettings.mockResolvedValue({ object: "native_settings", api_enabled: false }); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "native-api-disabled", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain("Native API is disabled"); + expect(report.nextAction).toContain("https://dashboard.clerk.com/~/native-applications"); + expect(report.nextAction).toContain("will not infer an App ID Prefix"); + } + }); + + test("reports disabled native Apple without reading native endpoints", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: false, + authenticatable: false, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "authentication-disabled", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain("not explicitly enabled for authentication"); + expect(report.nextAction).toContain("no web credentials should be added"); + } + expect(mockListIOSApplications).not.toHaveBeenCalled(); + expect(mockGetNativeSettings).not.toHaveBeenCalled(); + }); }); describe("waitForDeployStatus", () => { diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index 341a9f430..2042e1060 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -1,12 +1,14 @@ import { resolveProfile } from "../../lib/config.ts"; -import { PlapiError } from "../../lib/errors.ts"; +import { errorMessage, PlapiError, UserAbortError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { fetchApplication, fetchInstanceConfig, fetchInstanceConfigSchema, + getNativeSettings, getApplicationDomainStatus, listApplicationDomains, + listIOSApplications, triggerApplicationDomainDNSCheck, type ApplicationDomain, type DomainStatusResponse, @@ -29,6 +31,7 @@ import { OAUTH_KEY_PREFIX, buildOAuthProviderDescriptors, hasProviderRequiredCredentials, + inspectNativeAppleConfiguration, type OAuthProvider, type OAuthProviderDescriptor, } from "./providers.ts"; @@ -72,6 +75,17 @@ export type DeployStatusState = */ export type DomainComponentState = "complete" | "pending"; +type NativeAppleReadinessIssue = { + bundleId: string; + reason: + | "authentication-disabled" + | "registration-missing" + | "registration-bundle-case-mismatch" + | "registration-ambiguous" + | "native-api-disabled" + | "verification-unavailable"; +}; + export interface DeployStatusReport { complete: boolean; state: DeployStatusState; @@ -84,6 +98,7 @@ export interface DeployStatusReport { } | null; pendingDnsRecords: { type: "CNAME"; host: string; value: string; required: boolean }[]; oauth: { complete: boolean; configured: string[]; pending: string[]; unsupported: string[] }; + nativeAppleReadinessIssue?: NativeAppleReadinessIssue; /** * Dashboard pages for this deploy: the production instance and its Domains * page. Null before a production instance exists, and when the run was @@ -114,13 +129,19 @@ export type DeployNextStep = oauthUnsupported: readonly string[]; instanceUrl: string | null; } - | { kind: "oauth_pending"; oauthPending: readonly string[]; oauthUnsupported: readonly string[] } + | { + kind: "oauth_pending"; + oauthPending: readonly string[]; + oauthUnsupported: readonly string[]; + nativeAppleReadinessIssue?: NativeAppleReadinessIssue; + } | { kind: "records_available" | "records_unavailable" | "ssl_pending" | "finalizing"; domain: string; /** "DNS", "Email DNS", or "DNS and email DNS": the record kinds still unverified. */ records: string; domainsUrl: string | null; + nativeAppleReadinessIssue?: NativeAppleReadinessIssue; }; export type LiveDeploySnapshot = Omit< @@ -135,6 +156,7 @@ export type LiveDeploySnapshot = Omit< componentStatus: DeployComponentStatus; unsupportedOAuthProviderCount: number; unsupportedOAuthProviders: string[]; + nativeAppleReadinessIssue?: NativeAppleReadinessIssue; }; export type DeployState = @@ -265,8 +287,48 @@ export async function resolveLiveDeploySnapshot( domain.id, options, ); + const nativeAppleDescriptor = oauthProviderDescriptors.find( + (descriptor) => descriptor.provider === "apple", + ); + const preliminaryNativeAppleConfiguration = nativeAppleDescriptor + ? inspectNativeAppleConfiguration(productionConfig, nativeAppleDescriptor, []) + : undefined; + let nativeAppleConfiguration = preliminaryNativeAppleConfiguration; + if ( + nativeAppleDescriptor && + preliminaryNativeAppleConfiguration?.status === "registration-missing" + ) { + try { + nativeAppleConfiguration = await withSpinner( + "Reading production Native Application settings...", + async () => { + const [iosApplications, nativeSettings] = await Promise.all([ + listIOSApplications(ctx.appId, productionInstanceId), + getNativeSettings(ctx.appId, productionInstanceId), + ]); + return inspectNativeAppleConfiguration( + productionConfig, + nativeAppleDescriptor, + iosApplications, + nativeSettings, + ); + }, + ); + } catch (error) { + if (error instanceof UserAbortError) throw error; + log.debug(`Could not read production Native Application settings: ${errorMessage(error)}`); + nativeAppleConfiguration = { + status: "verification-unavailable", + bundleId: preliminaryNativeAppleConfiguration.bundleId, + }; + } + } const completedOAuthProviders = oauthProviderDescriptors - .filter((descriptor) => hasProviderRequiredCredentials(productionConfig, descriptor)) + .filter( + (descriptor) => + hasProviderRequiredCredentials(productionConfig, descriptor) || + (descriptor.provider === "apple" && nativeAppleConfiguration?.status === "ready"), + ) .map((descriptor) => descriptor.provider); const pendingOAuthDescriptor = oauthProviderDescriptors.find( (descriptor) => !completedOAuthProviders.includes(descriptor.provider), @@ -286,6 +348,16 @@ export async function resolveLiveDeploySnapshot( componentStatus: deployComponentStatusFromDomainStatus(deployStatus), unsupportedOAuthProviderCount: unsupported.length, unsupportedOAuthProviders: unsupported, + ...(nativeAppleConfiguration && + "bundleId" in nativeAppleConfiguration && + isNativeAppleReadinessIssue(nativeAppleConfiguration.status) + ? { + nativeAppleReadinessIssue: { + bundleId: nativeAppleConfiguration.bundleId, + reason: nativeAppleConfiguration.status, + }, + } + : {}), }; const domainComplete = deployStatus.status === "complete"; @@ -437,6 +509,7 @@ function buildDeployStatusFacts( pending: oauthPending, unsupported: [...snapshot.unsupportedOAuthProviders], }, + nativeAppleReadinessIssue: snapshot.nativeAppleReadinessIssue, urls: snapshot.productionInstanceId ? dashboardUrls(snapshot.appId, snapshot.productionInstanceId) : null, @@ -505,6 +578,7 @@ export function deployNextStep(report: DeployStatusFacts): DeployNextStep { kind: "oauth_pending", oauthPending: report.oauth.pending, oauthUnsupported: report.oauth.unsupported, + nativeAppleReadinessIssue: report.nativeAppleReadinessIssue, }; case "domain_pending": { // DNS and email DNS are records someone has to add at the registrar; @@ -520,11 +594,62 @@ export function deployNextStep(report: DeployStatusFacts): DeployNextStep { domain: report.domain ?? "", records: capitalizeFirst(pendingRecordComponents(status)), domainsUrl: report.urls?.domains ?? null, + nativeAppleReadinessIssue: report.nativeAppleReadinessIssue, }; } } } +function isNativeAppleReadinessIssue( + status: string, +): status is NativeAppleReadinessIssue["reason"] { + return ( + status === "authentication-disabled" || + status === "registration-missing" || + status === "registration-bundle-case-mismatch" || + status === "registration-ambiguous" || + status === "native-api-disabled" || + status === "verification-unavailable" + ); +} + +function nativeAppleReadinessNextAction(issue: NativeAppleReadinessIssue): string { + if (issue.reason === "verification-unavailable") { + return ( + `Clerk could not verify the production Native Application registration for ${issue.bundleId}. ` + + "Retry `clerk deploy status`; do not create another registration based on this unverified result." + ); + } + if (issue.reason === "registration-ambiguous") { + return ( + `Native Sign in with Apple has more than one App ID Prefix registration for ${issue.bundleId}. ` + + "Review the existing registrations at https://dashboard.clerk.com/~/native-applications before continuing; do not create another registration." + ); + } + if (issue.reason === "registration-bundle-case-mismatch") { + return ( + `The Apple connection Bundle ID ${issue.bundleId} differs only by letter casing from its existing iOS Native Application registration. ` + + "Update the Apple connection to use the registration's exact Bundle ID spelling in the Clerk Dashboard; do not create another registration." + ); + } + if (issue.reason === "authentication-disabled") { + return ( + `Apple is not explicitly enabled for authentication on the production instance for ${issue.bundleId}. ` + + "Review the Apple connection in the Clerk Dashboard; no web credentials should be added for a native-only setup." + ); + } + if (issue.reason === "native-api-disabled") { + return ( + `Native API is disabled on the production instance for ${issue.bundleId}. ` + + "Enable it at https://dashboard.clerk.com/~/native-applications; the CLI will not infer an App ID Prefix." + ); + } + return ( + `Native Sign in with Apple is missing an exact production iOS Native Application registration for ${issue.bundleId}. ` + + "Register that Bundle ID at https://dashboard.clerk.com/~/native-applications; the CLI will not infer an App ID Prefix." + ); +} + /** The `nextAction` sentence: written for an agent that will relay it to a person. */ export function agentNextAction(step: DeployNextStep): string { // In development Clerk supplies shared OAuth credentials; in production it @@ -576,6 +701,19 @@ export function agentNextAction(step: DeployNextStep): string { : "") ); case "oauth_pending": + if (step.nativeAppleReadinessIssue) { + const hostedPending = step.oauthPending.filter((provider) => provider !== "apple"); + const hostedAction = + hostedPending.length > 0 + ? ` These OAuth providers are also missing production credentials: ${hostedPending.join(", ")}.` + : ""; + return ( + `Domain verified, but setup is incomplete. ${nativeAppleReadinessNextAction(step.nativeAppleReadinessIssue)}` + + hostedAction + + " After resolving those items, run `clerk deploy status` again." + + unsupported(step.oauthUnsupported) + ); + } // The domain is verified, so there is nothing to monitor on the Domains // page; the wizard is the only way to supply credentials. return ( @@ -588,7 +726,10 @@ export function agentNextAction(step: DeployNextStep): string { `${step.records} records not found yet for ${step.domain}. ` + `Add the records in \`pendingDnsRecords\` at the domain's DNS provider if you haven't already, ` + `then re-run \`clerk deploy status --wait\`. Propagation usually takes minutes.` + - domains(step.domainsUrl) + domains(step.domainsUrl) + + (step.nativeAppleReadinessIssue + ? ` ${nativeAppleReadinessNextAction(step.nativeAppleReadinessIssue)}` + : "") ); case "records_unavailable": // The report has nothing to hand over; the Dashboard clause carries the @@ -597,7 +738,10 @@ export function agentNextAction(step: DeployNextStep): string { `${step.records} records not found yet for ${step.domain}, but this report has no record list. ` + `Find the records to add on the Domains page in the Clerk Dashboard, then re-run ` + `\`clerk deploy status --wait\`.` + - domains(step.domainsUrl) + domains(step.domainsUrl) + + (step.nativeAppleReadinessIssue + ? ` ${nativeAppleReadinessNextAction(step.nativeAppleReadinessIssue)}` + : "") ); case "ssl_pending": // Records are verified; the certificate is Clerk's side and nobody can @@ -605,13 +749,19 @@ export function agentNextAction(step: DeployNextStep): string { return ( `SSL certificate still pending for ${step.domain}. Clerk issues it automatically now that ` + `DNS is verified; re-run \`clerk deploy status\` in a few minutes.` + - domains(step.domainsUrl) + domains(step.domainsUrl) + + (step.nativeAppleReadinessIssue + ? ` ${nativeAppleReadinessNextAction(step.nativeAppleReadinessIssue)}` + : "") ); case "finalizing": return ( `Production setup for ${step.domain} is still finalizing on Clerk's side. ` + `Re-run \`clerk deploy status\` in a few minutes.` + - domains(step.domainsUrl) + domains(step.domainsUrl) + + (step.nativeAppleReadinessIssue + ? ` ${nativeAppleReadinessNextAction(step.nativeAppleReadinessIssue)}` + : "") ); } } diff --git a/packages/cli-core/src/commands/init/ios/development-key.test.ts b/packages/cli-core/src/commands/init/ios/development-key.test.ts new file mode 100644 index 000000000..ca100dbb5 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/development-key.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as plapi from "../../../lib/plapi.ts"; +import { resolveIOSDevelopmentPublicKey } from "./development-key.ts"; + +const spies: Array<{ mockRestore(): void }> = []; + +afterEach(() => { + for (const spy of spies.splice(0)) spy.mockRestore(); +}); + +describe("iOS development publishable-key resolution", () => { + test("fetches an exact application without secret keys and selects its development instance", async () => { + const fetchApplication = spyOn(plapi, "fetchApplication").mockResolvedValue({ + application_id: "app_native", + instances: [ + { + instance_id: "ins_production", + environment_type: "production", + publishable_key: "pk_live_redacted", + secret_key: "sk_live_must_not_escape", + }, + { + instance_id: "ins_development", + environment_type: "development", + publishable_key: "pk_test_redacted", + secret_key: "sk_test_must_not_escape", + }, + ], + }); + spies.push(fetchApplication); + + const resolved = await resolveIOSDevelopmentPublicKey("app_native"); + + expect(fetchApplication).toHaveBeenCalledWith("app_native", { includeSecretKeys: false }); + expect(resolved).toEqual({ + applicationId: "app_native", + instanceId: "ins_development", + publishableKey: "pk_test_redacted", + }); + expect(resolved).not.toHaveProperty("secretKey"); + }); + + test("requires the exact application to have a development instance", async () => { + const fetchApplication = spyOn(plapi, "fetchApplication").mockResolvedValue({ + application_id: "app_production_only", + instances: [ + { + instance_id: "ins_production", + environment_type: "production", + publishable_key: "pk_live_redacted", + }, + ], + }); + spies.push(fetchApplication); + + await expect(resolveIOSDevelopmentPublicKey("app_production_only")).rejects.toThrow( + "No development instance found", + ); + }); + + test("returns the fetched application identity for the commit-time stale check", async () => { + const fetchApplication = spyOn(plapi, "fetchApplication").mockResolvedValue({ + application_id: "app_changed", + instances: [ + { + instance_id: "ins_development", + environment_type: "development", + publishable_key: "pk_test_redacted", + }, + ], + }); + spies.push(fetchApplication); + + const resolved = await resolveIOSDevelopmentPublicKey("app_requested"); + + expect(resolved.applicationId).toBe("app_changed"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/development-key.ts b/packages/cli-core/src/commands/init/ios/development-key.ts new file mode 100644 index 000000000..1b7b7e785 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/development-key.ts @@ -0,0 +1,41 @@ +import { resolveFetchedApplicationInstance } from "../../../lib/config.ts"; +import { CliError, ERROR_CODE, withApiContext } from "../../../lib/errors.ts"; +import { fetchApplication } from "../../../lib/plapi.ts"; + +export interface IOSDevelopmentPublicKey { + applicationId: string; + instanceId: string; + publishableKey: string; +} + +/** Resolve only the public development identity needed by native iOS setup. */ +export async function resolveIOSDevelopmentPublicKey( + applicationId: string, +): Promise { + const application = await withApiContext( + fetchApplication(applicationId, { includeSecretKeys: false }), + "Failed to fetch the iOS development publishable key", + ); + const resolved = resolveFetchedApplicationInstance(applicationId, application); + if (!resolved.found) { + throw new CliError( + `Development instance ${resolved.instanceId} not found in application ${applicationId}.`, + { code: ERROR_CODE.INSTANCE_NOT_FOUND }, + ); + } + if ( + resolved.instanceLabel !== "development" || + resolved.instance.environment_type !== "development" + ) { + throw new CliError( + "Automatic iOS configuration is limited to the linked development instance. No local setup changes were written.", + { code: ERROR_CODE.INVALID_ENVIRONMENT }, + ); + } + + return { + applicationId: application.application_id, + instanceId: resolved.instanceId, + publishableKey: resolved.instance.publishable_key, + }; +} diff --git a/packages/cli-core/src/commands/init/ios/native-apple.test.ts b/packages/cli-core/src/commands/init/ios/native-apple.test.ts new file mode 100644 index 000000000..7e4fd7230 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-apple.test.ts @@ -0,0 +1,851 @@ +import { describe, expect, test } from "bun:test"; +import { ERROR_CODE, UserAbortError } from "../../../lib/errors.ts"; +import type { InstanceConfigSchema } from "../../../lib/plapi.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import { + applyIOSNativeAppleConnection, + buildIOSNativeApplePlan, + prepareIOSNativeAppleConnection, + type IOSNativeAppleAPI, + type IOSNativeApplePatchOptions, + type IOSNativeApplePrompts, +} from "./native-apple.ts"; + +const APPLICATION_ID = "app_native_apple"; +const INSTANCE_ID = "ins_native_apple"; +const BUNDLE_IDENTIFIER = "com.example.NativeApple"; +const CONFIG_VERSION = "v1_1234abcd"; +const NEXT_CONFIG_VERSION = "v1_9876fedc"; +const SERVICES_ID = "com.example.web.sign-in"; +const TEAM_ID = "APPLE_TEAM_ID_MUST_NOT_ESCAPE"; +const KEY_ID = "APPLE_KEY_ID_MUST_NOT_ESCAPE"; +const PRIVATE_KEY = "APPLE_PRIVATE_KEY_MUST_NOT_ESCAPE"; +const API_SECRET = "Bearer ak_PLATFORM_TOKEN_MUST_NOT_ESCAPE"; + +const captured = useCaptureLog(); + +type AppleConnection = Record & { + enabled: boolean; + authenticatable: boolean; +}; + +function appleSchema(): InstanceConfigSchema { + return { + type: "object", + properties: { + connection_oauth_apple: { + type: "object", + properties: { + enabled: { type: "boolean" }, + authenticatable: { type: "boolean" }, + client_id: { type: "string" }, + client_secret: { type: "string", "x-clerk-sensitive": true }, + team_id: { type: "string" }, + key_id: { type: "string" }, + bundle_id: { type: "string" }, + }, + }, + }, + }; +} + +function connection( + enabled = false, + authenticatable = true, + extras: Record = {}, +): AppleConnection { + return { enabled, authenticatable, ...extras }; +} + +function config(value: AppleConnection, configVersion: string | null = CONFIG_VERSION) { + return { + ...(configVersion ? { config_version: configVersion } : {}), + connection_oauth_apple: { ...value }, + }; +} + +function baseOptions( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + requested: true, + agent: false, + yes: true, + ...overrides, + }; +} + +function unexpectedPrompts(overrides: Partial = {}): IOSNativeApplePrompts { + return { + enableNativeApple: + overrides.enableNativeApple ?? + (async () => { + throw new Error("unexpected Apple opt-in prompt"); + }), + confirmChanges: + overrides.confirmChanges ?? + (async () => { + throw new Error("unexpected Apple mutation prompt"); + }), + }; +} + +type PatchCall = { + config: Record; + options: IOSNativeApplePatchOptions; +}; + +function statefulAPI( + options: { + initial?: AppleConnection; + schema?: InstanceConfigSchema; + version?: string | null; + failFetch?: unknown; + failDryRun?: unknown; + failActual?: unknown; + malformedDryRun?: boolean; + replaceProjection?: boolean; + dryRunProjectionOverride?: Record; + actualProjectionOverride?: Record; + persistedActualState?: AppleConnection; + persistActual?: boolean; + } = {}, +): { + api: IOSNativeAppleAPI; + calls: string[]; + patchCalls: PatchCall[]; + actualWrites(): number; + current(): AppleConnection; + setCurrent(value: AppleConnection): void; + setVersion(value: string | undefined): void; +} { + let current = { + ...(options.initial ?? connection()), + } as AppleConnection; + let version: string | undefined = + options.version === null ? undefined : (options.version ?? CONFIG_VERSION); + let writes = 0; + const calls: string[] = []; + const patchCalls: PatchCall[] = []; + + const api: IOSNativeAppleAPI = { + async fetchInstanceConfig(applicationId, instanceId, keys) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + expect(keys).toEqual(["connection_oauth_apple"]); + calls.push("GET config"); + if (options.failFetch) throw options.failFetch; + return config(current, version ?? null); + }, + async fetchInstanceConfigSchema(applicationId, instanceId, keys) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + expect(keys).toEqual(["connection_oauth_apple"]); + calls.push("GET schema"); + if (options.failFetch) throw options.failFetch; + return options.schema ?? appleSchema(); + }, + async patchInstanceConfig(applicationId, instanceId, patch, patchOptions) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + calls.push(patchOptions.dryRun ? "PATCH dry-run" : "PATCH apply"); + patchCalls.push({ + config: structuredClone(patch), + options: { ...patchOptions }, + }); + + if (patchOptions.ifMatch !== version) { + throw new Error("config version conflict"); + } + if (patchOptions.dryRun && options.failDryRun) throw options.failDryRun; + if (!patchOptions.dryRun && options.failActual) throw options.failActual; + + const update = patch.connection_oauth_apple; + if (typeof update !== "object" || update == null || Array.isArray(update)) { + throw new Error("invalid test patch"); + } + const before = { ...current }; + const after = ( + options.replaceProjection + ? { ...(update as Record) } + : { ...current, ...(update as Record) } + ) as AppleConnection; + const projectionOverride = patchOptions.dryRun + ? options.dryRunProjectionOverride + : options.actualProjectionOverride; + if (projectionOverride) Object.assign(after, structuredClone(projectionOverride)); + if (patchOptions.dryRun && options.malformedDryRun) { + return { config_version: version, dry_run: true, before: {}, after: {} }; + } + if (!patchOptions.dryRun) { + writes += 1; + if (options.persistActual !== false) { + current = options.persistedActualState + ? structuredClone(options.persistedActualState) + : after; + } + version = NEXT_CONFIG_VERSION; + } + return { + config_version: patchOptions.dryRun ? version : NEXT_CONFIG_VERSION, + dry_run: patchOptions.dryRun, + before: { connection_oauth_apple: before }, + after: { connection_oauth_apple: after }, + }; + }, + }; + + return { + api, + calls, + patchCalls, + actualWrites: () => writes, + current: () => ({ ...current }), + setCurrent(value) { + current = { ...value }; + }, + setVersion(value) { + version = value; + }, + }; +} + +describe("native Sign in with Apple remote setup", () => { + test("builds a narrow redacted plan without retaining web credentials", () => { + const sensitiveConnection = connection(false, true, { + client_id: SERVICES_ID, + client_secret: PRIVATE_KEY, + team_id: TEAM_ID, + key_id: KEY_ID, + }); + const plan = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + config: config(sensitiveConnection), + schema: appleSchema(), + }); + + expect(plan).toMatchObject({ + status: "ready", + connection: "required", + bundleIdentifierConfiguration: "required", + current: { enabled: false, authenticatable: true }, + desired: { enabled: true, authenticatable: true }, + configVersion: CONFIG_VERSION, + blockers: [], + }); + expect(plan.actions).toHaveLength(1); + const serialized = JSON.stringify(plan); + for (const sensitive of [SERVICES_ID, PRIVATE_KEY, TEAM_ID, KEY_ID]) { + expect(serialized).not.toContain(sensitive); + } + }); + + test("treats an existing enabled and authenticatable connection as a no-op", async () => { + const harness = statefulAPI({ + initial: connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), + }); + const plan = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + + expect(plan.status).toBe("satisfied"); + expect(harness.patchCalls).toHaveLength(0); + if (plan.status === "satisfied") { + await applyIOSNativeAppleConnection(plan, harness.api); + } + expect(harness.patchCalls).toHaveLength(0); + expect(harness.calls.filter((call) => call === "GET config")).toHaveLength(2); + expect(harness.calls.filter((call) => call === "GET schema")).toHaveLength(2); + expect(captured.err).toContain("already enabled"); + }); + + test("normalizes a case-only Apple config difference to the registration's spelling", async () => { + const harness = statefulAPI({ + initial: connection(true, true, { bundle_id: "com.example.nativeapple" }), + }); + const plan = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + + expect(plan).toMatchObject({ + status: "ready", + bundleIdentifier: BUNDLE_IDENTIFIER, + bundleIdentifierConfiguration: "required", + blockers: [], + }); + if (plan.status !== "ready") throw new Error("expected ready plan"); + await applyIOSNativeAppleConnection(plan, harness.api); + + expect(harness.patchCalls).toHaveLength(2); + expect(harness.patchCalls.map((call) => call.config)).toEqual([ + { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: BUNDLE_IDENTIFIER, + }, + }, + { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: BUNDLE_IDENTIFIER, + }, + }, + ]); + expect(harness.actualWrites()).toBe(1); + expect(harness.current().bundle_id).toBe(BUNDLE_IDENTIFIER); + }); + + test("keeps a versionless already-satisfied connection read-only", async () => { + const harness = statefulAPI({ + initial: connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), + version: null, + }); + const plan = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + + expect(plan.status).toBe("satisfied"); + if (plan.status !== "satisfied") throw new Error("expected satisfied plan"); + await applyIOSNativeAppleConnection(plan, harness.api); + + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + expect(harness.calls.filter((call) => call === "GET config")).toHaveLength(2); + }); + + test("rejects a satisfied plan when the connection changes after prepare", async () => { + const harness = statefulAPI({ + initial: connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), + }); + const plan = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (plan.status !== "satisfied") throw new Error("expected satisfied plan"); + + harness.setCurrent( + connection(false, true, { + bundle_id: BUNDLE_IDENTIFIER, + client_secret: PRIVATE_KEY, + }), + ); + + let thrown: unknown; + try { + await applyIOSNativeAppleConnection(plan, harness.api); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + message: expect.stringContaining("changed after the approved preview"), + }); + expect(String(thrown)).not.toContain(PRIVATE_KEY); + expect(captured.err).not.toContain(PRIVATE_KEY); + expect(harness.patchCalls).toHaveLength(0); + expect(harness.calls.filter((call) => call === "GET config")).toHaveLength(2); + expect(harness.calls.filter((call) => call === "GET schema")).toHaveLength(2); + }); + + test("prepares before a planned native registration, then preserves web credentials on apply", async () => { + const initial = connection(false, false, { + client_id: SERVICES_ID, + client_secret: "REDACTED", + team_id: TEAM_ID, + key_id: KEY_ID, + unrelated_provider_setting: "keep-me", + }); + const harness = statefulAPI({ initial }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + // The exact iOS registration may still be an approved prerequisite here. + // Server validation is intentionally deferred until apply, after the + // registration transaction has run. + expect(harness.patchCalls).toHaveLength(0); + + await applyIOSNativeAppleConnection(prepared, harness.api); + + expect(harness.actualWrites()).toBe(1); + expect(harness.patchCalls).toHaveLength(2); + for (const call of harness.patchCalls) { + expect(call.config).toEqual({ + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: BUNDLE_IDENTIFIER, + }, + }); + expect(call.options.ifMatch).toBe(CONFIG_VERSION); + expect(JSON.stringify(call.config)).not.toContain(SERVICES_ID); + expect(JSON.stringify(call.config)).not.toContain(TEAM_ID); + expect(JSON.stringify(call.config)).not.toContain(KEY_ID); + } + expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true, false]); + expect(harness.current()).toEqual({ + ...initial, + enabled: true, + authenticatable: true, + bundle_id: BUNDLE_IDENTIFIER, + }); + }); + + test("requires the exact native Bundle ID even when Apple is already authenticatable", async () => { + const harness = statefulAPI({ initial: connection(true, true) }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + + expect(prepared).toMatchObject({ + status: "ready", + connection: "required", + bundleIdentifierConfiguration: "required", + }); + expect(harness.patchCalls).toHaveLength(0); + }); + + test("keeps global --yes from opting an agent into Apple", async () => { + const harness = statefulAPI(); + const prepared = await prepareIOSNativeAppleConnection( + baseOptions({ requested: undefined, agent: true, yes: true }), + { api: harness.api, prompts: unexpectedPrompts() }, + ); + + expect(prepared).toEqual({ + schemaVersion: 1, + kind: "clerk-ios-native-apple-connection", + status: "skipped", + reason: "not-requested", + }); + expect(harness.calls).toEqual([]); + }); + + test("lets a human decline the opt-in before any remote read", async () => { + const harness = statefulAPI(); + let optInCalls = 0; + const prepared = await prepareIOSNativeAppleConnection( + baseOptions({ requested: undefined, yes: true }), + { + api: harness.api, + prompts: unexpectedPrompts({ + enableNativeApple: async (bundleIdentifier) => { + optInCalls += 1; + expect(bundleIdentifier).toBe(BUNDLE_IDENTIFIER); + return false; + }, + }), + }, + ); + + expect(prepared.status).toBe("skipped"); + expect(optInCalls).toBe(1); + expect(harness.calls).toEqual([]); + }); + + test("requires separate human mutation consent without calling the mutation endpoint", async () => { + const harness = statefulAPI(); + let consentCalls = 0; + + await expect( + prepareIOSNativeAppleConnection(baseOptions({ yes: false }), { + api: harness.api, + prompts: unexpectedPrompts({ + confirmChanges: async () => { + consentCalls += 1; + return false; + }, + }), + }), + ).rejects.toBeInstanceOf(UserAbortError); + + expect(consentCalls).toBe(1); + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + + test("requires --yes for an explicitly requested agent mutation", async () => { + const harness = statefulAPI(); + + await expect( + prepareIOSNativeAppleConnection(baseOptions({ agent: true, yes: false }), { + api: harness.api, + prompts: unexpectedPrompts(), + }), + ).rejects.toThrow("requires explicit mutation consent"); + + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + + test.each([ + { + name: "the exact native application is not ready", + nativeApplicationReady: false, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: connection(), + schema: appleSchema(), + blocker: "native-application-not-ready", + }, + { + name: "the Bundle ID is missing", + nativeApplicationReady: true, + bundleIdentifier: " ", + value: connection(), + schema: appleSchema(), + blocker: "bundle-identifier-unavailable", + }, + { + name: "the schema does not prove the exact native Bundle ID patch", + nativeApplicationReady: true, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: connection(), + schema: { + type: "object", + properties: { + connection_oauth_apple: { + type: "object", + properties: { + enabled: { type: "boolean" }, + authenticatable: { type: "boolean" }, + }, + }, + }, + } as InstanceConfigSchema, + blocker: "apple-config-unsupported", + }, + { + name: "the current config is malformed", + nativeApplicationReady: true, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: { enabled: "yes", authenticatable: true } as unknown as AppleConnection, + schema: appleSchema(), + blocker: "apple-config-invalid", + }, + { + name: "Apple is enabled but deliberately not authenticatable", + nativeApplicationReady: true, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: connection(true, false), + schema: appleSchema(), + blocker: "apple-authenticatable-conflict", + }, + { + name: "an existing Apple Bundle ID conflicts", + nativeApplicationReady: true, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: connection(false, true, { bundle_id: "com.example.OtherApp" }), + schema: appleSchema(), + blocker: "apple-bundle-identifier-conflict", + }, + ])("fails closed when $name", (fixture) => { + const plan = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: fixture.bundleIdentifier, + nativeApplicationReady: fixture.nativeApplicationReady, + config: config(fixture.value), + schema: fixture.schema, + }); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toContainEqual(expect.objectContaining({ code: fixture.blocker })); + }); + + test("fails before writing when the approved config version becomes stale", async () => { + const harness = statefulAPI(); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + harness.setVersion(NEXT_CONFIG_VERSION); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + "changed after the approved preview", + ); + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + + test("blocks a remote Apple change when its configuration version is unavailable", async () => { + const harness = statefulAPI({ version: null }); + + await expect( + prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }), + ).rejects.toThrow("version required to protect a remote change"); + + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + + test("rejects a serialized writable plan which is missing its configuration version", async () => { + const harness = statefulAPI(); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + const incomplete = { ...prepared, configVersion: undefined }; + + await expect(applyIOSNativeAppleConnection(incomplete, harness.api)).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_PLAN_INVALID, + }); + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + + test("requires a valid server dry-run projection before the actual write", async () => { + const harness = statefulAPI({ malformedDryRun: true }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + "could not safely validate native Sign in with Apple", + ); + expect(harness.actualWrites()).toBe(0); + }); + + test("rejects a dry-run projection that drops existing Apple credential fields", async () => { + const harness = statefulAPI({ + initial: connection(false, false, { + client_id: SERVICES_ID, + client_secret: PRIVATE_KEY, + team_id: TEAM_ID, + key_id: KEY_ID, + }), + replaceProjection: true, + }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + "could not safely validate native Sign in with Apple", + ); + expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true]); + expect(harness.actualWrites()).toBe(0); + expect(captured.err).not.toContain(PRIVATE_KEY); + }); + + test("rejects a dry-run projection that changes a nested preserved field", async () => { + const harness = statefulAPI({ + initial: connection(false, false, { + unrelated_provider_setting: { + nested: { mode: "keep", secret: PRIVATE_KEY }, + }, + }), + dryRunProjectionOverride: { + unrelated_provider_setting: { + nested: { mode: "changed", secret: PRIVATE_KEY }, + }, + }, + }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + "could not safely validate native Sign in with Apple", + ); + expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true]); + expect(harness.actualWrites()).toBe(0); + expect(captured.err).not.toContain(PRIVATE_KEY); + }); + + test("rejects an actual-write projection that changes a preserved credential value", async () => { + const changedSecret = `${PRIVATE_KEY}_CHANGED`; + const harness = statefulAPI({ + initial: connection(false, false, { client_secret: PRIVATE_KEY }), + actualProjectionOverride: { client_secret: changedSecret }, + }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + let thrown: unknown; + try { + await applyIOSNativeAppleConnection(prepared, harness.api); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + message: expect.stringContaining("removed or changed existing fields"), + }); + expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true, false]); + expect(harness.actualWrites()).toBe(1); + expect(String(thrown)).not.toContain(PRIVATE_KEY); + expect(String(thrown)).not.toContain(changedSecret); + expect(captured.err).not.toContain(PRIVATE_KEY); + expect(captured.err).not.toContain(changedSecret); + }); + + test("rejects a final state that drops a secret despite preserving projections", async () => { + const initial = connection(false, true, { + client_id: SERVICES_ID, + client_secret: PRIVATE_KEY, + unrelated_provider_setting: { nested: { mode: "keep" } }, + }); + const harness = statefulAPI({ + initial, + persistedActualState: connection(true, true, { + bundle_id: BUNDLE_IDENTIFIER, + client_id: SERVICES_ID, + unrelated_provider_setting: { nested: { mode: "keep" } }, + }), + }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + let thrown: unknown; + try { + await applyIOSNativeAppleConnection(prepared, harness.api); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + message: expect.stringContaining("did not pass final verification"), + }); + expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true, false]); + expect(harness.actualWrites()).toBe(1); + expect(String(thrown)).not.toContain(PRIVATE_KEY); + expect(JSON.stringify(prepared)).not.toContain(PRIVATE_KEY); + expect(captured.err).not.toContain(PRIVATE_KEY); + }); + + test("rereads final state and rejects a write that did not persist", async () => { + const harness = statefulAPI({ persistActual: false }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + message: expect.stringContaining("did not pass final verification"), + }); + expect(harness.actualWrites()).toBe(1); + expect(harness.current().enabled).toBe(false); + }); + + test("sanitizes read, dry-run, and write API failures", async () => { + const readHarness = statefulAPI({ failFetch: new Error(API_SECRET) }); + let readError: unknown; + try { + await prepareIOSNativeAppleConnection(baseOptions(), { + api: readHarness.api, + prompts: unexpectedPrompts(), + }); + } catch (error) { + readError = error; + } + expect(String(readError)).not.toContain(API_SECRET); + + const dryRunHarness = statefulAPI({ failDryRun: new Error(PRIVATE_KEY) }); + const dryRunPrepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: dryRunHarness.api, + prompts: unexpectedPrompts(), + }); + if (dryRunPrepared.status !== "ready") throw new Error("expected ready plan"); + let dryRunError: unknown; + try { + await applyIOSNativeAppleConnection(dryRunPrepared, dryRunHarness.api); + } catch (error) { + dryRunError = error; + } + expect(String(dryRunError)).not.toContain(PRIVATE_KEY); + + const writeHarness = statefulAPI({ failActual: new Error(TEAM_ID) }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: writeHarness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + let writeError: unknown; + try { + await applyIOSNativeAppleConnection(prepared, writeHarness.api); + } catch (error) { + writeError = error; + } + expect(String(writeError)).not.toContain(TEAM_ID); + + const allOutput = `${captured.err}\n${JSON.stringify({ readError, dryRunError, writeError })}`; + for (const sensitive of [API_SECRET, PRIVATE_KEY, TEAM_ID, KEY_ID, SERVICES_ID]) { + expect(allOutput).not.toContain(sensitive); + } + }); + + test("requires a config version for writes but allows a versionless no-op", () => { + const withoutVersion = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + config: { connection_oauth_apple: connection() }, + schema: appleSchema(), + }); + expect(withoutVersion.status).toBe("blocked"); + expect(withoutVersion.configVersion).toBeUndefined(); + expect(withoutVersion.blockers).toContainEqual( + expect.objectContaining({ code: "apple-config-version-unavailable" }), + ); + + const satisfiedWithoutVersion = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + config: { + connection_oauth_apple: connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), + }, + schema: appleSchema(), + }); + expect(satisfiedWithoutVersion.status).toBe("satisfied"); + + const sensitiveVersion = `v1_${PRIVATE_KEY}`; + const malformedVersion = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + config: config(connection(), sensitiveVersion), + schema: appleSchema(), + }); + expect(malformedVersion.status).toBe("blocked"); + expect(JSON.stringify(malformedVersion)).not.toContain(PRIVATE_KEY); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/native-apple.ts b/packages/cli-core/src/commands/init/ios/native-apple.ts new file mode 100644 index 000000000..0e374e0de --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-apple.ts @@ -0,0 +1,744 @@ +import { isDeepStrictEqual } from "node:util"; +import { bundleIdentifiersEqual } from "../../../lib/apple-native-identity.ts"; +import { dim, yellow } from "../../../lib/color.ts"; +import { + ApiError, + CliError, + ERROR_CODE, + type ErrorCode, + throwUsageError, + throwUserAbort, +} from "../../../lib/errors.ts"; +import { log } from "../../../lib/log.ts"; +import { + fetchInstanceConfig, + fetchInstanceConfigSchema, + patchInstanceConfig, + type InstanceConfigSchema, +} from "../../../lib/plapi.ts"; +import { confirm } from "../../../lib/prompts.ts"; +import { withSpinner } from "../../../lib/spinner.ts"; + +const APPLE_CONNECTION_KEY = "connection_oauth_apple"; +const CONFIG_VERSION_PATTERN = /^v1_[0-9a-f]{8}$/; +const NATIVE_APPLE_PATCH_FIELDS = new Set(["enabled", "authenticatable", "bundle_id"]); + +function iosAppleError( + message: string, + code: ErrorCode = ERROR_CODE.IOS_REMOTE_APPLY_FAILED, +): CliError { + return new CliError(message, { code }); +} + +function rethrowKnownAppleError(error: unknown): void { + if (error instanceof CliError || error instanceof ApiError) throw error; +} + +type AppleConnectionState = { + enabled: boolean; + authenticatable: boolean; +}; + +export type IOSNativeAppleBlockerCode = + | "native-application-not-ready" + | "bundle-identifier-unavailable" + | "apple-config-unsupported" + | "apple-config-invalid" + | "apple-config-version-unavailable" + | "apple-authenticatable-conflict" + | "apple-bundle-identifier-conflict"; + +export interface IOSNativeAppleBlocker { + code: IOSNativeAppleBlockerCode; + message: string; +} + +/** + * Serializable, credential-free preview of the remote Apple connection work. + * The raw Platform Config response must never be attached to this value. + */ +export type IOSNativeApplePlan = { + schemaVersion: 1; + kind: "clerk-ios-native-apple-connection"; + status: "ready" | "satisfied" | "blocked"; + applicationId: string; + instanceId: string; + bundleIdentifier: string; + configVersion?: string; + connection: "required" | "satisfied" | "blocked"; + bundleIdentifierConfiguration: "required" | "satisfied" | "blocked"; + current?: AppleConnectionState; + desired: AppleConnectionState; + actions: string[]; + blockers: IOSNativeAppleBlocker[]; +}; + +export type IOSNativeAppleSkipped = { + schemaVersion: 1; + kind: "clerk-ios-native-apple-connection"; + status: "skipped"; + reason: "not-requested" | "declined"; +}; + +export type IOSNativeApplePreparation = IOSNativeApplePlan | IOSNativeAppleSkipped; + +const preservedAppleFieldFingerprints = new WeakMap< + IOSNativeApplePlan, + ReadonlyMap +>(); + +export interface IOSNativeApplePatchOptions { + dryRun: boolean; + /** Required for every mutation attempt, including the server dry run. */ + ifMatch: string; +} + +export interface IOSNativeAppleAPI { + fetchInstanceConfig( + applicationId: string, + instanceId: string, + keys?: string[], + ): Promise>; + fetchInstanceConfigSchema( + applicationId: string, + instanceId: string, + keys?: string[], + ): Promise; + patchInstanceConfig( + applicationId: string, + instanceId: string, + config: Record, + options: IOSNativeApplePatchOptions, + ): Promise>; +} + +const defaultAPI: IOSNativeAppleAPI = { + fetchInstanceConfig, + fetchInstanceConfigSchema, + patchInstanceConfig: async (applicationId, instanceId, config, options) => + patchInstanceConfig(applicationId, instanceId, config, { + dryRun: options.dryRun, + ifMatch: options.ifMatch, + }), +}; + +export interface IOSNativeApplePrompts { + enableNativeApple(bundleIdentifier: string): Promise; + confirmChanges(): Promise; +} + +const defaultPrompts: IOSNativeApplePrompts = { + enableNativeApple: async (bundleIdentifier) => + confirm({ + message: `Enable native Sign in with Apple for ${bundleIdentifier}?`, + default: false, + }), + confirmChanges: async () => + confirm({ + message: "Apply this remote Clerk Sign in with Apple change?", + default: false, + }), +}; + +export interface IOSNativeAppleOptions { + applicationId: string; + instanceId: string; + bundleIdentifier: string; + /** + * The exact selected target's registration is already satisfied or is an + * approved prerequisite which the caller will apply before this plan. + */ + nativeApplicationReady: boolean; +} + +export interface PrepareIOSNativeAppleOptions extends IOSNativeAppleOptions { + /** + * `undefined` prompts a human but defaults to skipped in agent mode. `--yes` + * is mutation consent only and never opts a project into Apple by itself. + */ + requested?: boolean; + agent: boolean; + yes: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function canonicalConfigValue(value: unknown): string | undefined { + if (value === null) return "null"; + if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "number") return Number.isFinite(value) ? JSON.stringify(value) : undefined; + if (Array.isArray(value)) { + const items = value.map(canonicalConfigValue); + return items.some((item) => item == null) ? undefined : `[${items.join(",")}]`; + } + if (!isRecord(value)) return undefined; + + const entries: string[] = []; + for (const key of Object.keys(value).sort()) { + const item = canonicalConfigValue(value[key]); + if (item == null) return undefined; + entries.push(`${JSON.stringify(key)}:${item}`); + } + return `{${entries.join(",")}}`; +} + +function preservedFieldFingerprints( + container: Record, +): ReadonlyMap | undefined { + const connection = container[APPLE_CONNECTION_KEY]; + if (!isRecord(connection)) return undefined; + + const fingerprints = new Map(); + for (const [key, value] of Object.entries(connection)) { + if (NATIVE_APPLE_PATCH_FIELDS.has(key)) continue; + const canonical = canonicalConfigValue(value); + if (canonical == null) return undefined; + fingerprints.set(key, new Bun.CryptoHasher("sha256").update(canonical).digest("hex")); + } + return fingerprints; +} + +function preservedFieldsMatch(before: IOSNativeApplePlan, after: IOSNativeApplePlan): boolean { + const beforeFingerprints = preservedAppleFieldFingerprints.get(before); + const afterFingerprints = preservedAppleFieldFingerprints.get(after); + if (!beforeFingerprints || !afterFingerprints) return false; + return [...beforeFingerprints].every( + ([key, fingerprint]) => afterFingerprints.get(key) === fingerprint, + ); +} + +function blocker(code: IOSNativeAppleBlockerCode, message: string): IOSNativeAppleBlocker { + return { code, message }; +} + +function schemaSupportsNarrowApplePatch(schema: InstanceConfigSchema): boolean { + const connection = schema.properties?.[APPLE_CONNECTION_KEY]; + return ( + connection?.type === "object" && + connection.properties?.enabled?.type === "boolean" && + connection.properties?.authenticatable?.type === "boolean" && + connection.properties?.bundle_id?.type === "string" + ); +} + +type ParsedConnection = + | { status: "valid"; value: AppleConnectionState; bundleIdentifier?: string } + | { status: "invalid" }; + +function parseConnection(container: unknown): ParsedConnection { + if (!isRecord(container)) return { status: "invalid" }; + const connection = container[APPLE_CONNECTION_KEY]; + if (!isRecord(connection)) return { status: "invalid" }; + if (typeof connection.enabled !== "boolean" || typeof connection.authenticatable !== "boolean") { + return { status: "invalid" }; + } + + const bundleIdentifier = connection.bundle_id; + if (bundleIdentifier !== undefined && typeof bundleIdentifier !== "string") { + return { status: "invalid" }; + } + return { + status: "valid", + value: { + enabled: connection.enabled, + authenticatable: connection.authenticatable, + }, + ...(typeof bundleIdentifier === "string" && bundleIdentifier.trim() + ? { bundleIdentifier: bundleIdentifier.trim() } + : {}), + }; +} + +function parseConfigVersion( + container: Record, +): { status: "missing" } | { status: "valid"; value: string } | { status: "invalid" } { + const value = container.config_version; + if (value == null) return { status: "missing" }; + if (typeof value !== "string" || !CONFIG_VERSION_PATTERN.test(value)) { + return { status: "invalid" }; + } + return { status: "valid", value }; +} + +export function buildIOSNativeApplePlan( + options: IOSNativeAppleOptions & { + config: Record; + schema: InstanceConfigSchema; + }, +): IOSNativeApplePlan { + const blockers: IOSNativeAppleBlocker[] = []; + const bundleIdentifier = options.bundleIdentifier.trim(); + if (!bundleIdentifier) { + blockers.push( + blocker( + "bundle-identifier-unavailable", + "Resolve one Bundle ID for the selected iOS target before enabling native Sign in with Apple.", + ), + ); + } + if (!options.nativeApplicationReady) { + blockers.push( + blocker( + "native-application-not-ready", + "Verify the exact selected iOS target's Clerk Native Application registration before enabling native Sign in with Apple.", + ), + ); + } + if (!schemaSupportsNarrowApplePatch(options.schema)) { + blockers.push( + blocker( + "apple-config-unsupported", + "This Clerk instance does not expose the narrow native Apple connection configuration required by clerk init.", + ), + ); + } + + const parsed = parseConnection(options.config); + if (parsed.status === "invalid") { + blockers.push( + blocker( + "apple-config-invalid", + "The existing Apple connection configuration could not be interpreted safely. Review it in the Clerk Dashboard before continuing.", + ), + ); + } + + const configVersion = parseConfigVersion(options.config); + if (configVersion.status === "invalid") { + blockers.push( + blocker( + "apple-config-invalid", + "The Apple connection configuration version could not be interpreted safely. Rerun clerk init before making remote changes.", + ), + ); + } + + if ( + parsed.status === "valid" && + parsed.bundleIdentifier && + bundleIdentifier && + !bundleIdentifiersEqual(parsed.bundleIdentifier, bundleIdentifier) + ) { + blockers.push( + blocker( + "apple-bundle-identifier-conflict", + "The existing Apple connection references a different iOS Bundle ID. clerk init will not replace it.", + ), + ); + } + + if (parsed.status === "valid" && parsed.value.enabled && !parsed.value.authenticatable) { + blockers.push( + blocker( + "apple-authenticatable-conflict", + "Apple is enabled but intentionally unavailable for authentication. clerk init will not override that policy automatically.", + ), + ); + } + + const alreadySatisfied = + parsed.status === "valid" && + parsed.value.enabled && + parsed.value.authenticatable && + parsed.bundleIdentifier === bundleIdentifier; + if (blockers.length === 0 && configVersion.status === "missing" && !alreadySatisfied) { + blockers.push( + blocker( + "apple-config-version-unavailable", + "The Apple connection configuration did not include the version required to protect a remote change. Rerun clerk init before continuing.", + ), + ); + } + + const current = parsed.status === "valid" ? parsed.value : undefined; + const desired: AppleConnectionState = { enabled: true, authenticatable: true }; + const bundleIdentifierConfiguration = + blockers.length > 0 + ? "blocked" + : parsed.status !== "valid" + ? "blocked" + : parsed.bundleIdentifier === bundleIdentifier + ? "satisfied" + : "required"; + const connection = + blockers.length > 0 + ? "blocked" + : current?.enabled === true && + current.authenticatable === true && + bundleIdentifierConfiguration === "satisfied" + ? "satisfied" + : "required"; + const status = + connection === "blocked" ? "blocked" : connection === "satisfied" ? "satisfied" : "ready"; + const actions = + status === "ready" + ? [ + `Enable native Sign in with Apple for ${bundleIdentifier} by setting enabled, authenticatable, and the exact registered Bundle ID; preserve all existing web credential fields.`, + ] + : []; + + const plan: IOSNativeApplePlan = { + schemaVersion: 1, + kind: "clerk-ios-native-apple-connection", + status, + applicationId: options.applicationId, + instanceId: options.instanceId, + bundleIdentifier, + ...(configVersion.status === "valid" ? { configVersion: configVersion.value } : {}), + connection, + bundleIdentifierConfiguration, + ...(current ? { current } : {}), + desired, + actions, + blockers, + }; + const fingerprints = preservedFieldFingerprints(options.config); + if (fingerprints) preservedAppleFieldFingerprints.set(plan, fingerprints); + return plan; +} + +export async function auditIOSNativeAppleConnection( + options: IOSNativeAppleOptions, + api: IOSNativeAppleAPI = defaultAPI, +): Promise { + let config: Record; + let schema: InstanceConfigSchema; + try { + [config, schema] = await withSpinner( + "Auditing Clerk Sign in with Apple settings...", + async () => + Promise.all([ + api.fetchInstanceConfig(options.applicationId, options.instanceId, [ + APPLE_CONNECTION_KEY, + ]), + api.fetchInstanceConfigSchema(options.applicationId, options.instanceId, [ + APPLE_CONNECTION_KEY, + ]), + ]), + ); + } catch (error) { + rethrowKnownAppleError(error); + throw iosAppleError( + "Clerk Sign in with Apple settings could not be inspected safely. No remote Apple connection changes were made; verify application access and rerun clerk init.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + + return buildIOSNativeApplePlan({ ...options, config, schema }); +} + +function skipped(reason: IOSNativeAppleSkipped["reason"]): IOSNativeAppleSkipped { + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-connection", + status: "skipped", + reason, + }; +} + +function formatBlockers(plan: IOSNativeApplePlan): string { + return plan.blockers.map((item) => ` • ${item.message}`).join("\n"); +} + +function patchOptions(plan: IOSNativeApplePlan, dryRun: boolean): IOSNativeApplePatchOptions { + if (!plan.configVersion) { + throw iosAppleError( + "The approved native Apple connection plan is missing the configuration version required to protect a remote change.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + return { + dryRun, + ifMatch: plan.configVersion, + }; +} + +function applePatch(bundleIdentifier: string): Record { + // This intentionally excludes client_id, client_secret, team_id, key_id, + // and every other hosted/web credential field. The exact registered native + // Bundle ID is the only provider setting written. PLAPI's nested merge + // semantics preserve fields which are not explicitly provided. + return { + [APPLE_CONNECTION_KEY]: { + enabled: true, + authenticatable: true, + bundle_id: bundleIdentifier, + }, + }; +} + +function validatePatchProjection( + response: Record, + expectedBefore: AppleConnectionState, + expectedBundleConfiguration: IOSNativeApplePlan["bundleIdentifierConfiguration"], + bundleIdentifier: string, + dryRun: boolean, +): void { + if (response.dry_run !== dryRun || !isRecord(response.before) || !isRecord(response.after)) { + throw iosAppleError( + "Clerk returned an invalid Apple configuration projection.", + ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + ); + } + const beforeConnection = response.before[APPLE_CONNECTION_KEY]; + const afterConnection = response.after[APPLE_CONNECTION_KEY]; + if ( + !isRecord(beforeConnection) || + !isRecord(afterConnection) || + Object.entries(beforeConnection).some( + ([key, value]) => + !Object.hasOwn(afterConnection, key) || + (!NATIVE_APPLE_PATCH_FIELDS.has(key) && !isDeepStrictEqual(afterConnection[key], value)), + ) + ) { + throw iosAppleError( + "Clerk returned an Apple configuration projection that removed or changed existing fields.", + ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + ); + } + const before = parseConnection(response.before); + const after = parseConnection(response.after); + const beforeBundleConfiguration = + before.status !== "valid" + ? "blocked" + : before.bundleIdentifier === bundleIdentifier + ? "satisfied" + : before.bundleIdentifier == null + ? "required" + : bundleIdentifiersEqual(before.bundleIdentifier, bundleIdentifier) + ? "required" + : "blocked"; + if ( + before.status !== "valid" || + after.status !== "valid" || + before.value.enabled !== expectedBefore.enabled || + before.value.authenticatable !== expectedBefore.authenticatable || + beforeBundleConfiguration !== expectedBundleConfiguration || + !after.value.enabled || + !after.value.authenticatable || + after.bundleIdentifier !== bundleIdentifier + ) { + throw iosAppleError( + "Clerk returned an Apple configuration projection that did not match the approved change.", + ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + ); + } + if (parseConfigVersion(response).status === "invalid") { + throw iosAppleError( + "Clerk returned an invalid Apple configuration version.", + ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + ); + } +} + +async function validateServerPatch( + plan: IOSNativeApplePlan, + api: IOSNativeAppleAPI, + dryRun: boolean, +): Promise { + if (!plan.current) { + throw iosAppleError( + "The approved native Apple connection plan is missing its current state.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + const response = await api.patchInstanceConfig( + plan.applicationId, + plan.instanceId, + applePatch(plan.bundleIdentifier), + patchOptions(plan, dryRun), + ); + validatePatchProjection( + response, + plan.current, + plan.bundleIdentifierConfiguration, + plan.bundleIdentifier, + dryRun, + ); +} + +async function preflightIOSNativeAppleConnection( + plan: IOSNativeApplePlan, + api: IOSNativeAppleAPI, +): Promise { + try { + await withSpinner("Validating the native Apple connection change...", async () => + validateServerPatch(plan, api, true), + ); + } catch (error) { + if (error instanceof ApiError) throw error; + throw iosAppleError( + "Clerk could not safely validate native Sign in with Apple. No remote Apple connection changes were made; verify the Native Application registration and existing Apple connection, then rerun clerk init.", + error instanceof CliError && error.code ? error.code : ERROR_CODE.IOS_REMOTE_APPLY_FAILED, + ); + } +} + +export async function prepareIOSNativeAppleConnection( + options: PrepareIOSNativeAppleOptions, + dependencies: { + api?: IOSNativeAppleAPI; + prompts?: IOSNativeApplePrompts; + } = {}, +): Promise { + const api = dependencies.api ?? defaultAPI; + const prompts = dependencies.prompts ?? defaultPrompts; + + if (options.requested === false || (options.requested == null && options.agent)) { + return skipped("not-requested"); + } + if ( + options.requested == null && + !(await prompts.enableNativeApple(options.bundleIdentifier.trim())) + ) { + return skipped("declined"); + } + + const plan = await auditIOSNativeAppleConnection(options, api); + if (plan.status === "blocked") { + throw iosAppleError( + `Native Sign in with Apple could not be enabled safely. No remote Apple connection changes were made:\n${formatBlockers(plan)}`, + ERROR_CODE.IOS_SETUP_BLOCKED, + ); + } + if (plan.status === "satisfied") { + log.info(dim("Native Sign in with Apple is already enabled in Clerk.")); + return plan; + } + + log.info("\nclerk init will make the following remote Clerk change:\n"); + for (const action of plan.actions) log.info(` ${yellow("REMOTE")} ${action}`); + log.info( + dim( + "\n This native-only setup will not request, replace, or print an Apple Services ID, Team ID, Key ID, or private key.", + ), + ); + log.blank(); + + if (options.agent && !options.yes) { + throwUsageError( + "Changing the Clerk Apple connection in agent mode requires explicit mutation consent. Rerun the same command with --yes after reviewing the plan.", + ); + } + if (!options.yes && !(await prompts.confirmChanges())) throwUserAbort(); + return plan; +} + +function planIdentityMatches(approved: IOSNativeApplePlan, current: IOSNativeApplePlan): boolean { + return ( + current.applicationId === approved.applicationId && + current.instanceId === approved.instanceId && + bundleIdentifiersEqual(current.bundleIdentifier, approved.bundleIdentifier) + ); +} + +function planVersionMatches(approved: IOSNativeApplePlan, current: IOSNativeApplePlan): boolean { + return approved.configVersion != null && current.configVersion === approved.configVersion; +} + +export async function applyIOSNativeAppleConnection( + plan: IOSNativeApplePlan, + api: IOSNativeAppleAPI = defaultAPI, +): Promise { + if ( + plan.status === "blocked" || + !plan.current || + !plan.bundleIdentifier || + (plan.status === "ready" && !plan.configVersion) + ) { + throw iosAppleError( + "The approved native Apple connection plan is incomplete. No remote Apple connection changes were made; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + const approvedWasSatisfied = plan.status === "satisfied"; + + let current: IOSNativeApplePlan; + try { + current = await auditIOSNativeAppleConnection( + { + applicationId: plan.applicationId, + instanceId: plan.instanceId, + bundleIdentifier: plan.bundleIdentifier, + nativeApplicationReady: true, + }, + api, + ); + } catch (error) { + rethrowKnownAppleError(error); + throw iosAppleError( + "Clerk Sign in with Apple settings could not be rechecked. No remote Apple connection changes were made; rerun clerk init.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + + if (!planIdentityMatches(plan, current)) { + throw iosAppleError( + "The approved native Apple connection target changed. No remote Apple connection changes were made; rerun clerk init to review the new plan.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (approvedWasSatisfied) { + if (current.status !== "satisfied") { + throw iosAppleError( + "The Clerk Apple connection changed after the approved preview. No remote Apple connection changes were made; rerun clerk init to review the current state.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + return; + } + if (current.status === "satisfied") return; + if ( + current.status !== "ready" || + !current.current || + !planVersionMatches(plan, current) || + current.current.enabled !== plan.current.enabled || + current.current.authenticatable !== plan.current.authenticatable + ) { + throw iosAppleError( + "The Clerk Apple connection changed after the approved preview. No remote Apple connection changes were made; rerun clerk init to review the current state.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + + await preflightIOSNativeAppleConnection(current, api); + + try { + await withSpinner("Enabling native Sign in with Apple in Clerk...", async () => + validateServerPatch(current, api, false), + ); + } catch (error) { + rethrowKnownAppleError(error); + throw iosAppleError( + "Native Sign in with Apple could not be enabled or confirmed. No credential material was exposed; rerun clerk init to reconcile the remote state safely.", + ); + } + + let finalPlan: IOSNativeApplePlan; + try { + finalPlan = await auditIOSNativeAppleConnection( + { + applicationId: plan.applicationId, + instanceId: plan.instanceId, + bundleIdentifier: plan.bundleIdentifier, + nativeApplicationReady: true, + }, + api, + ); + } catch (error) { + rethrowKnownAppleError(error); + throw iosAppleError( + "Native Sign in with Apple was submitted but its final Clerk state could not be verified. Rerun clerk init to inspect it safely.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + if (finalPlan.status !== "satisfied" || !preservedFieldsMatch(current, finalPlan)) { + throw iosAppleError( + "Native Sign in with Apple did not pass final verification. Rerun clerk init to reconcile the remote state safely.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + log.success("Native Sign in with Apple enabled in Clerk"); +} diff --git a/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts b/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts new file mode 100644 index 000000000..46911b105 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readdir, readFile, rm, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + IOSNativeRegistrationRetryLockError, + createIOSNativeRegistrationRetryStore, + type IOSNativeRegistrationRetryIdentity, +} from "./native-registration-retry.ts"; + +const temporaryDirectories: string[] = []; + +async function temporaryStateDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "clerk-ios-registration-retry-")); + temporaryDirectories.push(directory); + return directory; +} + +function identity( + overrides: Partial = {}, +): IOSNativeRegistrationRetryIdentity { + return { + applicationId: "app_native_test", + instanceId: "ins_native_development", + bundleIdentifier: "com.example.NativeApp", + appIdPrefix: "ABCDE12345", + ...overrides, + }; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("iOS native registration retry state", () => { + test("atomically reuses one key across concurrent callers and store instances", async () => { + const stateDirectory = await temporaryStateDirectory(); + const firstStore = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const secondStore = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const target = identity(); + + const keys = await Promise.all([ + firstStore.getOrCreate(target), + secondStore.getOrCreate(target), + firstStore.getOrCreate(target), + secondStore.getOrCreate(target), + ]); + + expect(new Set(keys).size).toBe(1); + expect(keys[0]).toStartWith("clerk-init-ios-registration-"); + }); + + test("scopes pending operations to the complete remote registration identity", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const target = identity(); + const first = await store.getOrCreate(target); + + for (const changed of [ + identity({ applicationId: "app_other" }), + identity({ instanceId: "ins_other" }), + identity({ bundleIdentifier: "com.example.Other" }), + identity({ appIdPrefix: "OTHER12345" }), + ]) { + expect(await store.getOrCreate(changed)).not.toBe(first); + } + }); + + test("reuses retry state when only Bundle ID casing changes", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const first = await store.getOrCreate(identity()); + const caseOnlyRerun = identity({ bundleIdentifier: "COM.EXAMPLE.nativeapp" }); + + expect(await store.peek(caseOnlyRerun)).toBe(first); + expect(await store.getOrCreate(caseOnlyRerun)).toBe(first); + expect(await store.clear(caseOnlyRerun, first)).toBe(true); + expect(await store.peek(identity())).toBeUndefined(); + }); + + test("clears a verified operation so a later registration receives a new key", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const target = identity(); + const first = await store.getOrCreate(target); + + expect(await store.clear(target, first)).toBe(true); + + expect(await store.getOrCreate(target)).not.toBe(first); + }); + + test("does not let a delayed clear remove a newer registration generation", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const target = identity(); + const first = await store.getOrCreate(target); + expect(await store.clear(target, first)).toBe(true); + const newer = await store.getOrCreate(target); + + expect(await store.clear(target, first)).toBe(false); + expect(await store.peek(target)).toBe(newer); + }); + + test("retains an old pending operation until remote verification clears it", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const target = identity(); + const first = await store.getOrCreate(target); + const directory = join(stateDirectory, "idempotency"); + const [filename] = await readdir(directory); + const path = join(directory, filename!); + const record = JSON.parse(await readFile(path, "utf8")) as Record; + record.createdAt = "2000-01-01T00:00:00.000Z"; + await writeFile(path, `${JSON.stringify(record, null, 2)}\n`); + + expect(await store.getOrCreate(target)).toBe(first); + }); + + test("fails closed instead of replacing a malformed pending record", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const target = identity(); + await store.getOrCreate(target); + const directory = join(stateDirectory, "idempotency"); + const [filename] = await readdir(directory); + await writeFile(join(directory, filename!), "{ malformed"); + + await expect(store.getOrCreate(target)).rejects.toThrow("retry record is malformed"); + }); + + test("reports an actionable stale lock without stealing it and reuses the key after recovery", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory, { + lockRetryMs: 1, + lockTimeoutMs: 10, + lockStaleMs: 5, + }); + const target = identity(); + const first = await store.getOrCreate(target); + const directory = join(stateDirectory, "idempotency"); + const [filename] = await readdir(directory); + const lock = join(directory, `${filename!}.lock`); + await mkdir(lock); + const stale = new Date(Date.now() - 60_000); + await utimes(lock, stale, stale); + + expect(first).toStartWith("clerk-init-ios-registration-"); + let caught: unknown; + try { + await store.getOrCreate(target); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(IOSNativeRegistrationRetryLockError); + expect(caught).toMatchObject({ + status: "stale", + recoveryPath: join("Clerk CLI config directory", "idempotency", `${filename!}.lock`), + }); + expect((caught as Error).message).not.toContain(stateDirectory); + expect(await readdir(lock)).toEqual([]); + + // Manual recovery removes only the empty lock. The pending record remains, + // so an ambiguous POST is retried with the exact same idempotency key. + await rm(lock, { recursive: true }); + expect(await store.getOrCreate(target)).toBe(first); + }); + + test("distinguishes a live-looking busy lock without suggesting stale recovery", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory, { + lockRetryMs: 1, + lockTimeoutMs: 5, + lockStaleMs: 60_000, + }); + const target = identity(); + await store.getOrCreate(target); + const directory = join(stateDirectory, "idempotency"); + const [filename] = await readdir(directory); + const lock = join(directory, `${filename!}.lock`); + await mkdir(lock); + + let caught: unknown; + try { + await store.getOrCreate(target); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(IOSNativeRegistrationRetryLockError); + expect(caught).toMatchObject({ + status: "busy", + recoveryPath: join("Clerk CLI config directory", "idempotency", `${filename!}.lock`), + }); + expect((caught as Error).message).not.toContain(stateDirectory); + expect(await readdir(lock)).toEqual([]); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/native-registration-retry.ts b/packages/cli-core/src/commands/init/ios/native-registration-retry.ts new file mode 100644 index 000000000..4f9d35973 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-registration-retry.ts @@ -0,0 +1,374 @@ +import { createHash, randomUUID } from "node:crypto"; +import { lstat, mkdir, readFile, rmdir, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { setTimeout as sleep } from "node:timers/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + bundleIdentifiersEqual, + normalizeBundleIdentifierIdentity, +} from "../../../lib/apple-native-identity.ts"; +import { getConfigFile } from "../../../lib/config.ts"; +import { withHomeFsAccess } from "../../../lib/host-execution.ts"; + +const RETRY_DIRECTORY = "idempotency"; +const RETRY_FILE_PREFIX = "ios-native-registration-"; +const IDEMPOTENCY_KEY_PREFIX = "clerk-init-ios-registration-"; +const IDEMPOTENCY_KEY_PATTERN = + /^clerk-init-ios-registration-[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const CONCURRENT_WRITE_ATTEMPTS = 20; +const CONCURRENT_WRITE_RETRY_MS = 5; +const LOCK_RETRY_MS = 10; +const LOCK_TIMEOUT_MS = 5_000; +const LOCK_STALE_MS = 30_000; + +interface IOSNativeRegistrationRetryStoreOptions { + lockRetryMs?: number; + lockTimeoutMs?: number; + lockStaleMs?: number; +} + +export interface IOSNativeRegistrationRetryIdentity { + applicationId: string; + instanceId: string; + bundleIdentifier: string; + appIdPrefix: string; +} + +export interface IOSNativeRegistrationRetryStore { + getOrCreate(identity: IOSNativeRegistrationRetryIdentity): Promise; + peek(identity: IOSNativeRegistrationRetryIdentity): Promise; + clear(identity: IOSNativeRegistrationRetryIdentity, expectedKey: string): Promise; +} + +export type IOSNativeRegistrationRetryLockStatus = "busy" | "stale"; + +/** + * A fail-closed retry-state lock failure with a path safe to render publicly. + * `recoveryPath` is always relative to the user's home or Clerk config root; + * the raw absolute filesystem path must stay out of logs and telemetry. + */ +export class IOSNativeRegistrationRetryLockError extends Error { + readonly status: IOSNativeRegistrationRetryLockStatus; + readonly recoveryPath: string; + + constructor(status: IOSNativeRegistrationRetryLockStatus, recoveryPath: string) { + super( + status === "stale" + ? `The Clerk iOS registration retry-state lock is stale: ${recoveryPath}` + : "Timed out waiting for the Clerk iOS registration retry-state lock.", + ); + this.name = "IOSNativeRegistrationRetryLockError"; + this.status = status; + this.recoveryPath = recoveryPath; + } +} + +interface IOSNativeRegistrationRetryRecord { + schemaVersion: 1; + kind: "clerk-ios-native-registration-retry"; + applicationId: string; + instanceId: string; + bundleIdentifier: string; + appIdPrefix: string; + idempotencyKey: string; + createdAt: string; +} + +function retryFingerprint(identity: IOSNativeRegistrationRetryIdentity): string { + return createHash("sha256") + .update( + JSON.stringify({ + applicationId: identity.applicationId, + instanceId: identity.instanceId, + bundleIdentifier: normalizeBundleIdentifierIdentity(identity.bundleIdentifier), + appIdPrefix: identity.appIdPrefix, + }), + ) + .digest("hex") + .slice(0, 24); +} + +function retryDirectory(baseDirectory: string): string { + return join(baseDirectory, RETRY_DIRECTORY); +} + +function retryPath(baseDirectory: string, identity: IOSNativeRegistrationRetryIdentity): string { + return join( + retryDirectory(baseDirectory), + `${RETRY_FILE_PREFIX}${retryFingerprint(identity)}.json`, + ); +} + +function isMissingFile(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +function isExistingFile(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === "EEXIST"; +} + +function lockPath(baseDirectory: string, identity: IOSNativeRegistrationRetryIdentity): string { + return `${retryPath(baseDirectory, identity)}.lock`; +} + +function containedRelativePath(root: string, path: string): string | undefined { + const candidate = relative(resolve(root), resolve(path)); + if ( + candidate === "" || + candidate === ".." || + candidate.startsWith(`..${sep}`) || + isAbsolute(candidate) + ) { + return undefined; + } + return candidate; +} + +function publicLockPath(baseDirectory: string, path: string): string { + const relativeToConfig = containedRelativePath(baseDirectory, path); + // `path` is constructed below this base. Keep a defensive basename-only + // fallback so an unexpected caller can never put an absolute path in output. + const configPath = relativeToConfig ?? join(RETRY_DIRECTORY, path.split(sep).at(-1) ?? "lock"); + const configuredDirectory = process.env.CLERK_CONFIG_DIR; + if (configuredDirectory && resolve(configuredDirectory) === resolve(baseDirectory)) { + return join("$CLERK_CONFIG_DIR", configPath); + } + + const relativeToHome = containedRelativePath(homedir(), path); + if (relativeToHome) return join("~", relativeToHome); + + return join("Clerk CLI config directory", configPath); +} + +async function acquireLock( + baseDirectory: string, + identity: IOSNativeRegistrationRetryIdentity, + options: Required, +): Promise { + await mkdir(retryDirectory(baseDirectory), { recursive: true, mode: 0o700 }); + const path = lockPath(baseDirectory, identity); + const deadline = Date.now() + options.lockTimeoutMs; + while (true) { + try { + await mkdir(path, { mode: 0o700 }); + return path; + } catch (error) { + if (!isExistingFile(error)) throw error; + if (Date.now() >= deadline) { + let stale = false; + try { + stale = Date.now() - (await lstat(path)).mtimeMs >= options.lockStaleMs; + } catch (statError) { + if (isMissingFile(statError)) continue; + throw statError; + } + throw new IOSNativeRegistrationRetryLockError( + stale ? "stale" : "busy", + publicLockPath(baseDirectory, path), + ); + } + await sleep(options.lockRetryMs); + } + } +} + +async function withIdentityLock( + baseDirectory: string, + identity: IOSNativeRegistrationRetryIdentity, + options: Required, + operation: () => Promise, +): Promise { + const path = await acquireLock(baseDirectory, identity, options); + const release = async () => { + try { + await rmdir(path); + } catch (error) { + if (!isMissingFile(error)) throw error; + } + }; + try { + const result = await operation(); + await release(); + return result; + } catch (operationError) { + try { + await release(); + } catch (releaseError) { + throw new AggregateError( + [operationError, releaseError], + "The Clerk iOS registration retry operation and lock release both failed.", + ); + } + throw operationError; + } +} + +function isRetryRecord( + value: unknown, + identity: IOSNativeRegistrationRetryIdentity, +): value is IOSNativeRegistrationRetryRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const record = value as Record; + return ( + record.schemaVersion === 1 && + record.kind === "clerk-ios-native-registration-retry" && + record.applicationId === identity.applicationId && + record.instanceId === identity.instanceId && + typeof record.bundleIdentifier === "string" && + bundleIdentifiersEqual(record.bundleIdentifier, identity.bundleIdentifier) && + record.appIdPrefix === identity.appIdPrefix && + typeof record.idempotencyKey === "string" && + IDEMPOTENCY_KEY_PATTERN.test(record.idempotencyKey) && + typeof record.createdAt === "string" && + !Number.isNaN(Date.parse(record.createdAt)) + ); +} + +async function readRetryRecordOnce( + baseDirectory: string, + identity: IOSNativeRegistrationRetryIdentity, +): Promise { + const path = retryPath(baseDirectory, identity); + let source: string; + try { + source = await readFile(path, "utf8"); + } catch (error) { + if (isMissingFile(error)) return undefined; + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + throw new Error(`The Clerk iOS registration retry record is malformed: ${path}`); + } + if (!isRetryRecord(parsed, identity)) { + throw new Error(`The Clerk iOS registration retry record has an unexpected shape: ${path}`); + } + return parsed; +} + +async function readRetryRecord( + baseDirectory: string, + identity: IOSNativeRegistrationRetryIdentity, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < CONCURRENT_WRITE_ATTEMPTS; attempt += 1) { + try { + return await readRetryRecordOnce(baseDirectory, identity); + } catch (error) { + lastError = error; + if (attempt + 1 < CONCURRENT_WRITE_ATTEMPTS) { + await sleep(CONCURRENT_WRITE_RETRY_MS); + } + } + } + throw lastError; +} + +async function getOrCreateRetryKey( + baseDirectory: string, + identity: IOSNativeRegistrationRetryIdentity, +): Promise { + const existing = await readRetryRecord(baseDirectory, identity); + if (existing) return existing.idempotencyKey; + + const directory = retryDirectory(baseDirectory); + const path = retryPath(baseDirectory, identity); + await mkdir(directory, { recursive: true, mode: 0o700 }); + + const record: IOSNativeRegistrationRetryRecord = { + schemaVersion: 1, + kind: "clerk-ios-native-registration-retry", + applicationId: identity.applicationId, + instanceId: identity.instanceId, + bundleIdentifier: normalizeBundleIdentifierIdentity(identity.bundleIdentifier), + appIdPrefix: identity.appIdPrefix, + idempotencyKey: `${IDEMPOTENCY_KEY_PREFIX}${randomUUID()}`, + createdAt: new Date().toISOString(), + }; + + try { + await writeFile(path, `${JSON.stringify(record, null, 2)}\n`, { + flag: "wx", + mode: 0o600, + }); + return record.idempotencyKey; + } catch (error) { + if (!isExistingFile(error)) throw error; + const concurrent = await readRetryRecord(baseDirectory, identity); + if (!concurrent) { + throw new Error("The Clerk iOS registration retry record disappeared during creation."); + } + return concurrent.idempotencyKey; + } +} + +async function clearRetryKey( + baseDirectory: string, + identity: IOSNativeRegistrationRetryIdentity, + expectedKey: string, +): Promise { + const existing = await readRetryRecord(baseDirectory, identity); + if (!existing) return true; + if (existing.idempotencyKey !== expectedKey) return false; + try { + await unlink(retryPath(baseDirectory, identity)); + } catch (error) { + if (!isMissingFile(error)) throw error; + return true; + } + return true; +} + +export function createIOSNativeRegistrationRetryStore( + resolveBaseDirectory: () => string = () => dirname(getConfigFile()), + options: IOSNativeRegistrationRetryStoreOptions = {}, +): IOSNativeRegistrationRetryStore { + const lockOptions: Required = { + lockRetryMs: options.lockRetryMs ?? LOCK_RETRY_MS, + lockTimeoutMs: options.lockTimeoutMs ?? LOCK_TIMEOUT_MS, + lockStaleMs: options.lockStaleMs ?? LOCK_STALE_MS, + }; + return { + async getOrCreate(identity) { + const baseDirectory = resolveBaseDirectory(); + const path = retryPath(baseDirectory, identity); + return withHomeFsAccess( + { operation: "write", target: path, label: "CLI idempotency state directory" }, + async () => + withIdentityLock(baseDirectory, identity, lockOptions, async () => + getOrCreateRetryKey(baseDirectory, identity), + ), + ); + }, + async peek(identity) { + const baseDirectory = resolveBaseDirectory(); + const path = retryPath(baseDirectory, identity); + return withHomeFsAccess( + { operation: "read", target: path, label: "CLI idempotency state directory" }, + async () => + withIdentityLock( + baseDirectory, + identity, + lockOptions, + async () => (await readRetryRecord(baseDirectory, identity))?.idempotencyKey, + ), + ); + }, + async clear(identity, expectedKey) { + const baseDirectory = resolveBaseDirectory(); + const path = retryPath(baseDirectory, identity); + return withHomeFsAccess( + { operation: "delete", target: path, label: "CLI idempotency state directory" }, + async () => + withIdentityLock(baseDirectory, identity, lockOptions, async () => + clearRetryKey(baseDirectory, identity, expectedKey), + ), + ); + }, + }; +} + +export const cliStateIOSNativeRegistrationRetryStore = createIOSNativeRegistrationRetryStore(); diff --git a/packages/cli-core/src/commands/init/ios/native-remote.test.ts b/packages/cli-core/src/commands/init/ios/native-remote.test.ts new file mode 100644 index 000000000..f94b8485f --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-remote.test.ts @@ -0,0 +1,1712 @@ +import { describe, expect, test } from "bun:test"; +import { ERROR_CODE, UserAbortError } from "../../../lib/errors.ts"; +import { getLogLevel, setLogLevel } from "../../../lib/log.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import type { IOSNativeReadinessTarget } from "./native-readiness.ts"; +import { + applyIOSNativeRemoteSetup, + buildIOSNativeRemotePlan, + prepareIOSNativeRemoteSetup, + validateAppIdPrefix, + validateBundleIdentifier, + type IOSNativeRemoteAPI, + type IOSNativeRemotePlan, + type IOSNativeRemotePrompts, + type IOSNativeRemoteTargetReader, + type IOSNativeRemoteTargetSnapshot, +} from "./native-remote.ts"; +import { + validateNativeSettings, + type IOSApplication, + type NativeSettings, +} from "../../../lib/plapi.ts"; +import { + IOSNativeRegistrationRetryLockError, + type IOSNativeRegistrationRetryIdentity, + type IOSNativeRegistrationRetryStore, +} from "./native-registration-retry.ts"; + +const APPLICATION_ID = "app_native_test"; +const INSTANCE_ID = "ins_native_development"; +const BUNDLE_IDENTIFIER = "com.example.NativeApp"; +const LOCAL_PREFIX = "LEGACY1234"; +const EXPLICIT_PREFIX = "EXPLICIT12"; +const IOS_ROOT = "/tmp/NativeApp"; + +const captured = useCaptureLog(); + +function nativeSettings(apiEnabled: boolean): NativeSettings { + return { object: "native_settings", api_enabled: apiEnabled }; +} + +function malformedNativeSettings(apiEnabled: unknown): NativeSettings { + return { object: "native_settings", api_enabled: apiEnabled } as unknown as NativeSettings; +} + +function registration( + appIdPrefix = LOCAL_PREFIX, + bundleId = BUNDLE_IDENTIFIER, + id = `iosapp_${appIdPrefix}`, +): IOSApplication { + return { + object: "ios_application", + id, + app_id_prefix: appIdPrefix, + bundle_id: bundleId, + created_at: 1_787_000_000_000, + updated_at: 1_787_000_000_000, + }; +} + +function malformedRegistration(): IOSApplication { + return { + object: "ios_application", + id: "iosapp_malformed", + app_id_prefix: LOCAL_PREFIX, + created_at: 1_787_000_000_000, + updated_at: 1_787_000_000_000, + } as unknown as IOSApplication; +} + +function selectedTarget( + options: { + bundleIdentifier?: string; + appIdPrefix?: string | null; + appIdPrefixCandidates?: string[]; + projectPath?: string; + targetId?: string; + } = {}, +): IOSNativeReadinessTarget { + const appIdPrefix = options.appIdPrefix === undefined ? LOCAL_PREFIX : options.appIdPrefix; + return { + status: "selected", + projectPath: options.projectPath ?? "NativeApp.xcodeproj", + targetId: options.targetId ?? "TARGET_NATIVE_APP", + targetName: "NativeApp", + bundleIdentifier: { + status: "resolved", + value: options.bundleIdentifier ?? BUNDLE_IDENTIFIER, + }, + appIdPrefix: + appIdPrefix == null + ? { + status: "missing", + source: "literal-entitlements", + ...(options.appIdPrefixCandidates ? { candidates: options.appIdPrefixCandidates } : {}), + } + : { status: "resolved", source: "literal-entitlements", value: appIdPrefix }, + }; +} + +function targetSnapshot( + target: IOSNativeReadinessTarget = selectedTarget(), +): IOSNativeRemoteTargetSnapshot { + if (target.status !== "selected") throw new Error("test target must be selected"); + return { + root: IOS_ROOT, + projectPath: target.projectPath, + targetId: target.targetId, + bundleIdentifier: target.bundleIdentifier, + appIdPrefix: target.appIdPrefix, + }; +} + +const approvedTargetReader: IOSNativeRemoteTargetReader = async (snapshot) => ({ + status: "selected", + projectPath: snapshot.projectPath, + targetId: snapshot.targetId, + targetName: "NativeApp", + bundleIdentifier: snapshot.bundleIdentifier, + appIdPrefix: snapshot.appIdPrefix, +}); + +function memoryRegistrationRetryStore(): { + store: IOSNativeRegistrationRetryStore; + pending(identity: IOSNativeRegistrationRetryIdentity): string | undefined; +} { + const entries = new Map(); + let issued = 0; + const scope = (identity: IOSNativeRegistrationRetryIdentity) => JSON.stringify(identity); + return { + store: { + async getOrCreate(identity) { + const key = scope(identity); + const existing = entries.get(key); + if (existing) return existing; + issued += 1; + const created = `clerk-init-ios-registration-test-${issued}`; + entries.set(key, created); + return created; + }, + async peek(identity) { + return entries.get(scope(identity)); + }, + async clear(identity, expectedKey) { + const key = scope(identity); + const existing = entries.get(key); + if (existing && existing !== expectedKey) return false; + entries.delete(key); + return true; + }, + }, + pending(identity) { + return entries.get(scope(identity)); + }, + }; +} + +function registrationRetryIdentity( + overrides: Partial = {}, +): IOSNativeRegistrationRetryIdentity { + return { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + appIdPrefix: LOCAL_PREFIX, + ...overrides, + }; +} + +async function applyRemoteSetup( + approved: IOSNativeRemotePlan, + api: IOSNativeRemoteAPI, + targetReader: IOSNativeRemoteTargetReader = approvedTargetReader, + registrationRetryStore: IOSNativeRegistrationRetryStore = memoryRegistrationRetryStore().store, +): Promise { + await applyIOSNativeRemoteSetup(approved, api, targetReader, registrationRetryStore); +} + +function plan(options: { + nativeApi: "required" | "satisfied"; + registration: "required" | "satisfied"; + appIdPrefix?: string; + localAppIdPrefix?: string | null; +}): IOSNativeRemotePlan { + const appIdPrefix = options.appIdPrefix ?? LOCAL_PREFIX; + const localTarget = selectedTarget({ + appIdPrefix: options.localAppIdPrefix === undefined ? appIdPrefix : options.localAppIdPrefix, + }); + return { + schemaVersion: 1, + kind: "clerk-ios-native-remote-setup", + status: + options.nativeApi === "satisfied" && options.registration === "satisfied" + ? "satisfied" + : "ready", + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + localTarget: targetSnapshot(localTarget), + bundleIdentifier: BUNDLE_IDENTIFIER, + appIdPrefix, + nativeApi: options.nativeApi, + registration: options.registration, + actions: [ + ...(options.nativeApi === "required" + ? ["Enable the Native API for the linked development instance."] + : []), + ...(options.registration === "required" + ? [`Register iOS Bundle ID ${BUNDLE_IDENTIFIER} with Apple App ID Prefix ${appIdPrefix}.`] + : []), + ], + blockers: [], + }; +} + +interface ScriptedAPIOptions { + nativeReads?: NativeSettings[]; + registrationReads?: IOSApplication[][]; + expectedAppIdPrefix?: string; + enable?: IOSNativeRemoteAPI["enableNativeApi"]; + create?: IOSNativeRemoteAPI["createIOSApplication"]; +} + +function scriptedAPI(options: ScriptedAPIOptions = {}): { + api: IOSNativeRemoteAPI; + calls: string[]; + registrationIdempotencyKeys: string[]; +} { + const calls: string[] = []; + const registrationIdempotencyKeys: string[] = []; + const nativeReads = options.nativeReads ?? [nativeSettings(false)]; + const registrationReads = options.registrationReads ?? [[]]; + let nativeReadIndex = 0; + let registrationReadIndex = 0; + + const nextNativeSettings = () => + nativeReads[Math.min(nativeReadIndex++, nativeReads.length - 1)]!; + const nextRegistrations = () => + registrationReads[Math.min(registrationReadIndex++, registrationReads.length - 1)]!.map( + (item) => ({ ...item }), + ); + + return { + calls, + registrationIdempotencyKeys, + api: { + async getNativeSettings(applicationId, instanceId) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + calls.push("GET native settings"); + return nextNativeSettings(); + }, + async listIOSApplications(applicationId, instanceId) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + calls.push("GET iOS registrations"); + return nextRegistrations(); + }, + async enableNativeApi(applicationId, instanceId, mutationOptions) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + expect(mutationOptions.idempotencyKey).toStartWith("clerk-init-ios-native-api-"); + calls.push("PATCH native settings"); + if (options.enable) { + return options.enable(applicationId, instanceId, mutationOptions); + } + return nativeSettings(true); + }, + async createIOSApplication(applicationId, instanceId, params, mutationOptions) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + expect(params).toEqual({ + appIdPrefix: options.expectedAppIdPrefix ?? LOCAL_PREFIX, + bundleId: BUNDLE_IDENTIFIER, + }); + expect(mutationOptions.idempotencyKey).toStartWith("clerk-init-ios-registration-"); + registrationIdempotencyKeys.push(mutationOptions.idempotencyKey); + calls.push("POST iOS registration"); + if (options.create) { + return options.create(applicationId, instanceId, params, mutationOptions); + } + return registration(params.appIdPrefix, params.bundleId); + }, + }, + }; +} + +function prepareOptions( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + root: IOS_ROOT, + target: selectedTarget(), + agent: false, + yes: true, + ...overrides, + }; +} + +function prompts( + options: { + appIdPrefix?: IOSNativeRemotePrompts["appIdPrefix"]; + confirmChanges?: () => Promise; + } = {}, +): IOSNativeRemotePrompts { + return { + appIdPrefix: + options.appIdPrefix ?? + (async () => { + throw new Error("unexpected App ID Prefix prompt"); + }), + confirmChanges: + options.confirmChanges ?? + (async () => { + throw new Error("unexpected remote-consent prompt"); + }), + }; +} + +describe("Clerk Native Application remote setup", () => { + test("validates Apple identity formats without equating a prefix to the Team ID", () => { + expect(validateAppIdPrefix(" LeGaCy1234 ")).toBe("LeGaCy1234"); + expect(validateAppIdPrefix("legacy.prefix-value")).toBeUndefined(); + expect(validateAppIdPrefix(" ")).toBeUndefined(); + expect(validateAppIdPrefix("x")).toBeUndefined(); + expect(validateAppIdPrefix("x".repeat(11))).toBeUndefined(); + expect(validateBundleIdentifier("NativeApp")).toBe("NativeApp"); + expect(validateBundleIdentifier("com.example-NativeApp")).toBe("com.example-NativeApp"); + expect(validateBundleIdentifier(".")).toBeUndefined(); + expect(validateBundleIdentifier(".com.example")).toBeUndefined(); + expect(validateBundleIdentifier("com..example")).toBeUndefined(); + expect(validateBundleIdentifier("com.example.")).toBeUndefined(); + expect(validateBundleIdentifier("com.example_bad")).toBeUndefined(); + expect(validateBundleIdentifier("x".repeat(256))).toBeUndefined(); + }); + + test.each([".", ".com.example", "com..example", "com.example."])( + "blocks the malformed Bundle ID %s before planning registration", + (bundleIdentifier) => { + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget({ bundleIdentifier }), + nativeSettings: nativeSettings(false), + registrations: [], + }); + + expect(result.status).toBe("blocked"); + expect(result.registration).toBe("blocked"); + expect(result.actions).not.toContainEqual(expect.stringContaining("Register iOS Bundle ID")); + expect(result.blockers).toContainEqual( + expect.objectContaining({ code: "bundle-identifier-invalid" }), + ); + }, + ); + + test("accepts a legacy App ID Prefix that differs from DEVELOPMENT_TEAM", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[registration(LOCAL_PREFIX)]], + }); + + const result = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + }), + { api, prompts: prompts() }, + ); + + expect(result).toMatchObject({ + status: "satisfied", + appIdPrefix: LOCAL_PREFIX, + registration: "satisfied", + blockers: [], + }); + }); + + test("blocks the malformed Bundle ID and App ID Prefix reproduction together", () => { + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget({ + bundleIdentifier: "com.example_bad", + appIdPrefix: "x", + }), + nativeSettings: nativeSettings(false), + registrations: [], + }); + + expect(result.status).toBe("blocked"); + expect(result.blockers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "bundle-identifier-invalid" }), + expect.objectContaining({ code: "app-id-prefix-invalid" }), + ]), + ); + }); + + test("rejects malformed Native settings before planning", () => { + let thrown: unknown; + try { + buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget(), + nativeSettings: malformedNativeSettings("false"), + registrations: [registration()], + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test("rejects malformed iOS registrations before planning", () => { + let thrown: unknown; + try { + buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget(), + nativeSettings: nativeSettings(true), + registrations: [malformedRegistration()], + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test("rejects malformed registrations during the initial remote audit", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[malformedRegistration()]], + }); + + await expect( + prepareIOSNativeRemoteSetup(prepareOptions(), { api, prompts: prompts() }), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls).not.toContain("POST iOS registration"); + }); + + test.each([ + { + name: "local Bundle ID", + target: selectedTarget({ bundleIdentifier: "com.example_bad" }), + requestedAppIdPrefix: undefined, + registrations: [], + blocker: "bundle-identifier-invalid", + }, + { + name: "local App ID Prefix", + target: selectedTarget({ appIdPrefix: "x" }), + requestedAppIdPrefix: undefined, + registrations: [], + blocker: "app-id-prefix-invalid", + }, + { + name: "partial local App ID Prefix candidate", + target: selectedTarget({ + appIdPrefix: null, + appIdPrefixCandidates: ["invalid-"], + }), + requestedAppIdPrefix: undefined, + registrations: [], + blocker: "app-id-prefix-invalid", + }, + { + name: "explicit App ID Prefix", + target: selectedTarget({ appIdPrefix: null }), + requestedAppIdPrefix: "x", + registrations: [], + blocker: "app-id-prefix-invalid", + }, + { + name: "existing registration App ID Prefix", + target: selectedTarget({ appIdPrefix: null }), + requestedAppIdPrefix: undefined, + registrations: [registration("x")], + blocker: "app-id-prefix-invalid", + }, + ])("blocks an invalid $name before approval", (fixture) => { + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: fixture.target, + requestedAppIdPrefix: fixture.requestedAppIdPrefix, + nativeSettings: nativeSettings(false), + registrations: [...fixture.registrations], + }); + + expect(result.status).toBe("blocked"); + expect(result.registration).toBe("blocked"); + expect(result.actions).not.toContainEqual(expect.stringContaining("Register iOS Bundle ID")); + expect(result.blockers).toContainEqual(expect.objectContaining({ code: fixture.blocker })); + }); + + test.each([ + { + name: "invalid local identity", + target: selectedTarget({ bundleIdentifier: "com.example_bad" }), + registrations: [] as IOSApplication[], + }, + { + name: "invalid existing registration", + target: selectedTarget({ appIdPrefix: null }), + registrations: [registration("x")], + }, + ])("does not request consent or write for an $name", async ({ target, registrations }) => { + let consentCalls = 0; + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[...registrations]], + }); + + await expect( + prepareIOSNativeRemoteSetup(prepareOptions({ target, yes: false }), { + api, + prompts: prompts({ + confirmChanges: async () => { + consentCalls += 1; + return true; + }, + }), + }), + ).rejects.toMatchObject({ code: ERROR_CODE.IOS_SETUP_BLOCKED }); + + expect(consentCalls).toBe(0); + expect(calls).toEqual(["GET native settings", "GET iOS registrations"]); + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test("revalidates a satisfied plan without prompting or writing", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[exactRegistration]], + }); + let inspections = 0; + + const result = await prepareIOSNativeRemoteSetup(prepareOptions({ yes: false }), { + api, + prompts: prompts(), + }); + + expect(result).toMatchObject({ + status: "satisfied", + nativeApi: "satisfied", + registration: "satisfied", + localTarget: { + root: IOS_ROOT, + projectPath: "NativeApp.xcodeproj", + targetId: "TARGET_NATIVE_APP", + bundleIdentifier: { status: "resolved", value: BUNDLE_IDENTIFIER }, + appIdPrefix: { + status: "resolved", + source: "literal-entitlements", + value: LOCAL_PREFIX, + }, + }, + bundleIdentifier: BUNDLE_IDENTIFIER, + appIdPrefix: LOCAL_PREFIX, + actions: [], + blockers: [], + }); + await applyRemoteSetup(result, api, async (snapshot) => { + inspections += 1; + return approvedTargetReader(snapshot); + }); + expect(inspections).toBe(1); + expect(calls).toEqual([ + "GET native settings", + "GET iOS registrations", + "GET native settings", + "GET iOS registrations", + "GET native settings", + "GET iOS registrations", + ]); + expect(captured.err).toContain("already configured"); + }); + + test.each([ + { + name: "Bundle ID", + current: selectedTarget({ bundleIdentifier: "com.example.Changed" }), + }, + { + name: "App ID Prefix", + current: selectedTarget({ appIdPrefix: EXPLICIT_PREFIX }), + }, + ])("fails a satisfied plan before remote access when its $name changes", async ({ current }) => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[registration()]], + }); + let inspections = 0; + const retryOperations: string[] = []; + const retryStore: IOSNativeRegistrationRetryStore = { + async getOrCreate() { + retryOperations.push("getOrCreate"); + return "unexpected"; + }, + async peek() { + retryOperations.push("peek"); + return undefined; + }, + async clear() { + retryOperations.push("clear"); + return true; + }, + }; + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "satisfied" }), + api, + async () => { + inspections += 1; + return current; + }, + retryStore, + ), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + message: expect.stringContaining("Xcode target identity changed"), + }); + + expect(inspections).toBe(1); + expect(retryOperations).toEqual([]); + expect(calls).toEqual([]); + }); + + test("preserves recheck failure semantics for a satisfied plan", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[registration()]], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "satisfied" }), + api, + async () => { + throw new Error("xcconfig unreadable"); + }, + ), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + message: expect.stringContaining("Xcode target identity could not be rechecked"), + }); + + expect(calls).toEqual([]); + }); + + test.each([ + { + name: "Native API was disabled", + nativeReads: [nativeSettings(true), nativeSettings(false)], + registrationReads: [[registration()], [registration()]], + }, + { + name: "the exact iOS registration was deleted", + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[registration()], []], + }, + { + name: "the exact iOS registration prefix changed", + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[registration()], [registration(EXPLICIT_PREFIX)]], + }, + ])( + "fails closed without writing when $name after prepare", + async ({ nativeReads, registrationReads }) => { + const { api, calls } = scriptedAPI({ + nativeReads: [...nativeReads], + registrationReads: registrationReads.map((items) => [...items]), + }); + const approved = await prepareIOSNativeRemoteSetup(prepareOptions(), { + api, + prompts: prompts(), + }); + + await expect(applyRemoteSetup(approved, api)).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + message: + "Clerk Native Application settings changed after the approved preview. No remote changes were made; rerun clerk init to review the new plan.", + }); + + expect(calls).toEqual([ + "GET native settings", + "GET iOS registrations", + "GET native settings", + "GET iOS registrations", + ]); + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }, + ); + + test("uses an explicit prefix when the registration is missing", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[]], + }); + + const result = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + appIdPrefix: EXPLICIT_PREFIX, + agent: true, + }), + { api, prompts: prompts() }, + ); + + expect(result).toMatchObject({ + status: "ready", + nativeApi: "satisfied", + registration: "required", + bundleIdentifier: BUNDLE_IDENTIFIER, + appIdPrefix: EXPLICIT_PREFIX, + blockers: [], + }); + expect(result.actions).toEqual([ + `Register iOS Bundle ID ${BUNDLE_IDENTIFIER} with Apple App ID Prefix ${EXPLICIT_PREFIX}.`, + ]); + }); + + test("asks a human for a missing App ID Prefix before asking for remote consent", async () => { + const promptOrder: string[] = []; + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + const result = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ + appIdPrefix: null, + appIdPrefixCandidates: [LOCAL_PREFIX], + }), + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + yes: false, + }), + { + api, + prompts: prompts({ + appIdPrefix: async (_bundleIdentifier, suggested) => { + promptOrder.push("prefix"); + expect(suggested).toEqual({ + source: "partial-literal-entitlements", + value: LOCAL_PREFIX, + }); + return LOCAL_PREFIX; + }, + confirmChanges: async () => { + promptOrder.push("remote consent"); + return true; + }, + }), + }, + ); + + expect(result.status).toBe("ready"); + expect(result.appIdPrefix).toBe(LOCAL_PREFIX); + expect(promptOrder).toEqual(["prefix", "remote consent"]); + }); + + test("offers the unanimous Xcode Development Team but adopts only the human's choice", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + const result = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + }), + { + api, + prompts: prompts({ + appIdPrefix: async (bundleIdentifier, suggested) => { + expect(bundleIdentifier).toBe(BUNDLE_IDENTIFIER); + expect(suggested).toEqual({ + source: "xcode-development-team", + value: "ABCDE12345", + }); + return EXPLICIT_PREFIX; + }, + }), + }, + ); + + expect(result).toMatchObject({ + status: "ready", + appIdPrefix: EXPLICIT_PREFIX, + registration: "required", + }); + }); + + test("offers an unverified Xcode suggestion in agent mode instead of prompting", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + const error = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + agent: true, + }), + { api, prompts: prompts() }, + ).catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain("ABCDE12345"); + expect(message).toContain("Xcode DEVELOPMENT_TEAM"); + expect(message).toContain("unverified suggestion"); + expect(message).toContain("Ask the user whether to use ABCDE12345 or enter a different"); + expect(message).toContain('--app-id-prefix ""'); + }); + + test("offers partial literal entitlement evidence in agent mode", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + const error = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ + appIdPrefix: null, + appIdPrefixCandidates: [LOCAL_PREFIX], + }), + agent: true, + }), + { api, prompts: prompts() }, + ).catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain(LOCAL_PREFIX); + expect(message).toContain("literal App ID Prefix evidence"); + expect(message).toContain("unverified suggestion"); + expect(message).toContain(`Ask the user whether to use ${LOCAL_PREFIX} or enter a different`); + }); + + test("directs the agent to Apple Developer when no prefix suggestion exists", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + const error = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + agent: true, + }), + { api, prompts: prompts() }, + ).catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain("requires --app-id-prefix"); + expect(message).toContain("copy the value labeled App ID Prefix in Apple Developer"); + expect(message).toContain('--app-id-prefix ""'); + }); + + test("reports an application link that changed before a missing-prefix block", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + const error = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + applicationLinkChange: "link-updated", + agent: true, + }), + { api, prompts: prompts() }, + ).catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("The project's Clerk application link was updated"); + expect((error as Error).message).toContain( + "no Xcode or Clerk Native Application settings changes were written", + ); + expect((error as Error).message).not.toContain("No local or remote setup changes were written"); + }); + + test("blocks an explicit prefix that conflicts with a partial local candidate", () => { + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget({ + appIdPrefix: null, + appIdPrefixCandidates: [LOCAL_PREFIX], + }), + requestedAppIdPrefix: EXPLICIT_PREFIX, + nativeSettings: nativeSettings(false), + registrations: [], + }); + + expect(result.status).toBe("blocked"); + expect(result.blockers).toContainEqual( + expect.objectContaining({ code: "app-id-prefix-conflict" }), + ); + }); + + test("adopts the sole existing registration prefix when local evidence is absent", () => { + const existing = registration(EXPLICIT_PREFIX); + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget({ appIdPrefix: null }), + nativeSettings: nativeSettings(false), + registrations: [existing], + }); + + expect(result).toMatchObject({ + status: "ready", + appIdPrefix: EXPLICIT_PREFIX, + nativeApi: "required", + registration: "satisfied", + blockers: [], + }); + }); + + test("matches Bundle IDs case-insensitively and preserves the registration's stored spelling", () => { + const storedBundleIdentifier = "com.example.nativeapp"; + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + root: IOS_ROOT, + target: selectedTarget(), + nativeSettings: nativeSettings(true), + registrations: [registration(LOCAL_PREFIX, storedBundleIdentifier)], + }); + + expect(result).toMatchObject({ + status: "satisfied", + bundleIdentifier: storedBundleIdentifier, + registration: "satisfied", + blockers: [], + }); + expect(result.localTarget).toMatchObject({ + bundleIdentifier: { status: "resolved", value: BUNDLE_IDENTIFIER }, + }); + }); + + test("keeps a case-only rerun read-only", async () => { + const storedBundleIdentifier = "com.example.nativeapp"; + const approved = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + root: IOS_ROOT, + target: selectedTarget(), + nativeSettings: nativeSettings(true), + registrations: [registration(LOCAL_PREFIX, storedBundleIdentifier)], + }); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [ + [registration(LOCAL_PREFIX, storedBundleIdentifier)], + [registration(LOCAL_PREFIX, storedBundleIdentifier)], + ], + }); + + await applyRemoteSetup(approved, api, approvedTargetReader); + + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test.each([ + { + name: "duplicate prefixes for one Bundle ID", + target: selectedTarget({ appIdPrefix: null }), + registrations: [registration(LOCAL_PREFIX), registration(EXPLICIT_PREFIX)], + blocker: "duplicate-bundle-registration", + }, + { + name: "an existing prefix that conflicts with the selected prefix", + target: selectedTarget(), + registrations: [registration(EXPLICIT_PREFIX)], + blocker: "app-id-prefix-conflict", + }, + ])("blocks $name", ({ target, registrations, blocker }) => { + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target, + nativeSettings: nativeSettings(false), + registrations: [...registrations], + }); + + expect(result.status).toBe("blocked"); + expect(result.blockers).toContainEqual(expect.objectContaining({ code: blocker })); + }); + + test("requires separate consent for the remote mutations", async () => { + let consentCalls = 0; + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + await expect( + prepareIOSNativeRemoteSetup(prepareOptions({ yes: false }), { + api, + prompts: prompts({ + confirmChanges: async () => { + consentCalls += 1; + return false; + }, + }), + }), + ).rejects.toBeInstanceOf(UserAbortError); + + expect(consentCalls).toBe(1); + expect(calls).toEqual(["GET native settings", "GET iOS registrations"]); + expect(captured.err).toContain("remote Clerk changes"); + }); + + test("re-reads before writing and permits the approved action set to shrink", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + // Native API was enabled by another actor after consent. + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + + await applyRemoteSetup( + plan({ nativeApi: "required", registration: "required" }), + api, + approvedTargetReader, + ); + + expect(calls).toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test("blocks before writing when the pre-write re-read expands the approved action set", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ), + ).rejects.toThrow(); + + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test.each([ + { + name: "the Bundle ID changes", + approved: plan({ nativeApi: "satisfied", registration: "required" }), + current: selectedTarget({ bundleIdentifier: "com.example.Changed" }), + }, + { + name: "the proven App ID Prefix changes", + approved: plan({ nativeApi: "satisfied", registration: "required" }), + current: selectedTarget({ appIdPrefix: EXPLICIT_PREFIX }), + }, + { + name: "the proven App ID Prefix disappears", + approved: plan({ nativeApi: "satisfied", registration: "required" }), + current: selectedTarget({ appIdPrefix: null }), + }, + { + name: "the target changes", + approved: plan({ nativeApi: "satisfied", registration: "required" }), + current: selectedTarget({ targetId: "TARGET_CHANGED" }), + }, + { + name: "the project changes", + approved: plan({ nativeApi: "satisfied", registration: "required" }), + current: selectedTarget({ projectPath: "Changed.xcodeproj" }), + }, + { + name: "new evidence conflicts with a user-confirmed prefix", + approved: plan({ + nativeApi: "satisfied", + registration: "required", + appIdPrefix: EXPLICIT_PREFIX, + localAppIdPrefix: null, + }), + current: selectedTarget({ appIdPrefix: LOCAL_PREFIX }), + }, + ])("fails closed before mutation when $name", async ({ approved, current }) => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[]], + expectedAppIdPrefix: approved.appIdPrefix, + }); + + await expect(applyRemoteSetup(approved, api, async () => current)).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + message: expect.stringContaining("Xcode target identity changed"), + }); + + expect(calls).toEqual([]); + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test("fails closed before mutation when the Xcode identity cannot be inspected", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[]], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + async () => { + throw new Error("xcconfig unreadable"); + }, + ), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + message: expect.stringContaining("Xcode target identity could not be rechecked"), + }); + + expect(calls).toEqual([]); + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test("revalidates identity before a Native API-only mutation", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[registration()]], + }); + + await expect( + applyRemoteSetup(plan({ nativeApi: "required", registration: "satisfied" }), api, async () => + selectedTarget({ bundleIdentifier: "com.example.Changed" }), + ), + ).rejects.toMatchObject({ code: ERROR_CODE.IOS_SETUP_STALE }); + + expect(calls).toEqual([]); + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test("accepts unchanged identity and newly proven evidence matching a confirmed prefix", async () => { + const exactRegistration = registration(EXPLICIT_PREFIX); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + expectedAppIdPrefix: EXPLICIT_PREFIX, + }); + let inspections = 0; + const approved = plan({ + nativeApi: "satisfied", + registration: "required", + appIdPrefix: EXPLICIT_PREFIX, + localAppIdPrefix: null, + }); + + await applyRemoteSetup(approved, api, async () => { + inspections += 1; + return selectedTarget({ appIdPrefix: EXPLICIT_PREFIX }); + }); + + expect(inspections).toBe(1); + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + expect(calls).not.toContain("PATCH native settings"); + }); + + test("rejects malformed registrations before the pre-write registration decision", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[malformedRegistration()]], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls).not.toContain("POST iOS registration"); + }); + + test("creates the iOS registration before enabling Native API", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + + await applyRemoteSetup( + plan({ nativeApi: "required", registration: "required" }), + api, + approvedTargetReader, + ); + + expect(calls.indexOf("POST iOS registration")).toBeGreaterThan(-1); + expect(calls.indexOf("POST iOS registration")).toBeLessThan( + calls.indexOf("PATCH native settings"), + ); + }); + + test("surfaces safe manual recovery for a stale retry lock before remote access", async () => { + const recoveryPath = "$CLERK_CONFIG_DIR/idempotency/ios-native-registration-test.json.lock"; + const retryStore: IOSNativeRegistrationRetryStore = { + async getOrCreate() { + throw new IOSNativeRegistrationRetryLockError("stale", recoveryPath); + }, + async peek() { + throw new Error("unexpected peek"); + }, + async clear() { + throw new Error("unexpected clear"); + }, + }; + const { api, calls } = scriptedAPI(); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + retryStore, + ), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_APPLY_FAILED, + message: expect.stringContaining( + `Confirm no other Clerk command is running, then remove only the stale lock directory at \`${recoveryPath}\``, + ), + }); + expect(calls).toEqual([]); + }); + + test("reconciles an ambiguous registration-create error when the exact row now exists", async () => { + const exactRegistration = registration(); + const ambiguousError = new Error("connection reset after create"); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration], [exactRegistration]], + create: async () => { + throw ambiguousError; + }, + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ), + ).resolves.toBeUndefined(); + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + }); + + test("does not let a fallback list hide a malformed registration create response", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + create: async () => malformedRegistration(), + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls.filter((call) => call === "GET iOS registrations")).toHaveLength(1); + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + }); + + test("rejects malformed registrations while confirming an ambiguous create", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[], [malformedRegistration()]], + create: async () => { + throw new Error("connection reset after create"); + }, + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + }); + + test("rejects malformed registrations during final verification", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [malformedRegistration()]], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + }); + + test("reuses a pending registration key across invocations until final verification", async () => { + const retry = memoryRegistrationRetryStore(); + const ambiguous = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[], []], + create: async () => { + throw new Error("connection reset after unknown outcome"); + }, + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + ambiguous.api, + approvedTargetReader, + retry.store, + ), + ).rejects.toThrow("could not be registered"); + const firstKey = ambiguous.registrationIdempotencyKeys[0]!; + expect(retry.pending(registrationRetryIdentity())).toBe(firstKey); + + const exactRegistration = registration(); + const retried = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + await applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + retried.api, + approvedTargetReader, + retry.store, + ); + + expect(retried.registrationIdempotencyKeys).toEqual([firstKey]); + expect(retry.pending(registrationRetryIdentity())).toBeUndefined(); + + const recreated = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + await applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + recreated.api, + approvedTargetReader, + retry.store, + ); + expect(recreated.registrationIdempotencyKeys[0]).not.toBe(firstKey); + }); + + test("clears a pending retry when a rerun verifies that registration already exists", async () => { + const retry = memoryRegistrationRetryStore(); + const pendingKey = await retry.store.getOrCreate(registrationRetryIdentity()); + const exactRegistration = registration(); + const { api, calls, registrationIdempotencyKeys } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[exactRegistration], [exactRegistration]], + }); + + await applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + retry.store, + ); + + expect(pendingKey).toStartWith("clerk-init-ios-registration-"); + expect(registrationIdempotencyKeys).toEqual([]); + expect(calls).not.toContain("POST iOS registration"); + expect(retry.pending(registrationRetryIdentity())).toBeUndefined(); + }); + + test("surfaces stale-lock recovery when verified remote state cannot clear retry state", async () => { + const recoveryPath = "~/.config/clerk-cli/idempotency/ios-native-registration-test.json.lock"; + const retryKey = "clerk-init-ios-registration-11111111-1111-4111-8111-111111111111"; + const retryStore: IOSNativeRegistrationRetryStore = { + async getOrCreate() { + return retryKey; + }, + async peek() { + return retryKey; + }, + async clear() { + throw new IOSNativeRegistrationRetryLockError("stale", recoveryPath); + }, + }; + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + retryStore, + ), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + message: expect.stringContaining( + `no further remote changes are required. Confirm no other Clerk command is running, then remove only the stale lock directory at \`${recoveryPath}\``, + ), + }); + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + }); + + test("rechecks remote state after a paused invocation acquires a newer retry generation", async () => { + const retry = memoryRegistrationRetryStore(); + let releaseGet!: () => void; + const getGate = new Promise((resolve) => { + releaseGet = resolve; + }); + let reportPaused!: () => void; + const paused = new Promise((resolve) => { + reportPaused = resolve; + }); + const pausedStore: IOSNativeRegistrationRetryStore = { + async getOrCreate(identity) { + reportPaused(); + await getGate; + return retry.store.getOrCreate(identity); + }, + async peek(identity) { + return retry.store.peek(identity); + }, + async clear(identity, expectedKey) { + return retry.store.clear(identity, expectedKey); + }, + }; + const exactRegistration = registration(); + const resumed = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[exactRegistration], [exactRegistration]], + }); + + const resumedApply = applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + resumed.api, + approvedTargetReader, + pausedStore, + ); + await paused; + + const first = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + await applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + first.api, + approvedTargetReader, + retry.store, + ); + const completedKey = first.registrationIdempotencyKeys[0]!; + expect(retry.pending(registrationRetryIdentity())).toBeUndefined(); + + releaseGet(); + await resumedApply; + + expect(resumed.registrationIdempotencyKeys).toEqual([]); + expect(resumed.calls).not.toContain("POST iOS registration"); + expect(retry.pending(registrationRetryIdentity())).toBeUndefined(); + expect(completedKey).toStartWith("clerk-init-ios-registration-"); + }); + + test("reconciles an ambiguous Native API error when a re-read shows it enabled", async () => { + const exactRegistration = registration(); + const ambiguousError = new Error("connection reset after enable"); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true), nativeSettings(true)], + registrationReads: [[exactRegistration], [exactRegistration]], + enable: async () => { + throw ambiguousError; + }, + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "required", registration: "satisfied" }), + api, + approvedTargetReader, + ), + ).resolves.toBeUndefined(); + expect(calls.filter((call) => call === "PATCH native settings")).toHaveLength(1); + }); + + test("does not let a follow-up GET hide a malformed Native settings PATCH response", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true)], + registrationReads: [[exactRegistration]], + enable: async () => malformedNativeSettings("false"), + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "required", registration: "satisfied" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls.filter((call) => call === "GET native settings")).toHaveLength(1); + expect(calls.filter((call) => call === "PATCH native settings")).toHaveLength(1); + }); + + test("does not let a follow-up GET hide a Native settings client parser failure", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true)], + registrationReads: [[exactRegistration]], + enable: async () => validateNativeSettings(malformedNativeSettings("false")), + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "required", registration: "satisfied" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls.filter((call) => call === "GET native settings")).toHaveLength(1); + expect(calls.filter((call) => call === "PATCH native settings")).toHaveLength(1); + }); + + test("rejects malformed Native settings returned by ambiguity confirmation", async () => { + const exactRegistration = registration(); + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false), malformedNativeSettings("false")], + registrationReads: [[exactRegistration]], + enable: async () => { + throw new Error("connection reset after enable"); + }, + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "required", registration: "satisfied" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test("rejects malformed Native settings during final verification", async () => { + const exactRegistration = registration(); + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false), malformedNativeSettings("false")], + registrationReads: [[exactRegistration], [exactRegistration]], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "required", registration: "satisfied" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test("fails final verification when the approved remote postcondition is not present", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true)], + registrationReads: [[], []], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "required", registration: "required" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + message: expect.stringContaining("did not pass the final verification"), + }); + }); + + test("does not expose credential or publishable-key material in plans, output, or errors", async () => { + const sensitivePublishableKey = "pk_test_PUBLISHABLE_KEY_MUST_NOT_ESCAPE"; + const sensitiveBearer = "Bearer ak_API_TOKEN_MUST_NOT_ESCAPE"; + const settingsWithUnexpectedSecret = { + ...nativeSettings(false), + publishable_key: sensitivePublishableKey, + } as NativeSettings; + const { api: prepareAPI } = scriptedAPI({ + nativeReads: [settingsWithUnexpectedSecret], + registrationReads: [[]], + }); + + const prepared = await prepareIOSNativeRemoteSetup(prepareOptions(), { + api: prepareAPI, + prompts: prompts(), + }); + expect(JSON.stringify(prepared)).not.toContain(sensitivePublishableKey); + expect(captured.err).not.toContain(sensitivePublishableKey); + + captured.clear(); + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[], []], + create: async () => { + throw new Error(`request failed with ${sensitiveBearer}`); + }, + }); + + let thrown: unknown; + const previousLogLevel = getLogLevel(); + try { + setLogLevel("debug"); + try { + await applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ); + } catch (error) { + thrown = error; + } + } finally { + setLogLevel(previousLogLevel); + } + + expect(thrown).toBeDefined(); + expect(String(thrown)).not.toContain(sensitiveBearer); + expect(JSON.stringify(thrown)).not.toContain(sensitiveBearer); + expect(captured.err).toContain( + "Could not create the iOS application registration; underlying error details were omitted.", + ); + expect(captured.err).not.toContain(sensitiveBearer); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/native-remote.ts b/packages/cli-core/src/commands/init/ios/native-remote.ts new file mode 100644 index 000000000..eb2b9e038 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -0,0 +1,1039 @@ +import { randomUUID } from "node:crypto"; +import { bundleIdentifiersEqual } from "../../../lib/apple-native-identity.ts"; +import { dim, yellow } from "../../../lib/color.ts"; +import { + ApiError, + CliError, + ERROR_CODE, + type ErrorCode, + throwUsageError, + throwUserAbort, +} from "../../../lib/errors.ts"; +import { log } from "../../../lib/log.ts"; +import { select } from "../../../lib/listage.ts"; +import { + createIOSApplication, + enableNativeApi, + getNativeSettings, + listIOSApplications, + validateIOSApplication, + validateIOSApplications, + validateNativeSettings, + type IOSApplication, + type NativeSettings, +} from "../../../lib/plapi.ts"; +import { confirm, text } from "../../../lib/prompts.ts"; +import { withSpinner } from "../../../lib/spinner.ts"; +import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; +import type { + IOSNativeReadinessTarget, + IOSUnverifiedAppIdPrefixSuggestion, +} from "./native-readiness.ts"; +import { buildIOSNativeReadinessAudit } from "./native-readiness.ts"; +import { + IOSNativeRegistrationRetryLockError, + cliStateIOSNativeRegistrationRetryStore, + type IOSNativeRegistrationRetryIdentity, + type IOSNativeRegistrationRetryStore, +} from "./native-registration-retry.ts"; + +const APP_ID_PREFIX_LENGTH = 10; +const BUNDLE_IDENTIFIER_MAX_LENGTH = 255; +const APP_ID_PREFIX_PATTERN = /^[A-Za-z0-9]{10}$/; +const BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9.-]+$/; + +function iosRemoteError( + message: string, + code: ErrorCode = ERROR_CODE.IOS_REMOTE_APPLY_FAILED, +): CliError { + return new CliError(message, { code }); +} + +function rethrowKnownRemoteError(error: unknown): void { + if (error instanceof CliError || error instanceof ApiError) throw error; +} + +function logSuppressedFailure(context: string): void { + // Remote and transport exceptions can contain response bodies, request + // headers, or credentials. Keep verbose diagnostics useful without ever + // interpolating arbitrary exception content. + log.debug(`${context}; underlying error details were omitted.`); +} + +function retryLockFailureMessage( + error: IOSNativeRegistrationRetryLockError, + remoteSettingsVerified: boolean, +): string { + const outcome = remoteSettingsVerified + ? "Clerk Native Application settings were verified, and no further remote changes are required." + : "The local setup remains intact, and no registration request was sent."; + if (error.status === "busy") { + return `Another Clerk command is updating the iOS registration retry state. ${outcome} Wait for it to finish, then rerun \`clerk init\`.`; + } + return `An interrupted Clerk command left a stale iOS registration retry-state lock. ${outcome} Confirm no other Clerk command is running, then remove only the stale lock directory at \`${error.recoveryPath}\` and rerun \`clerk init\`.`; +} + +export type IOSNativeRemoteBlockerCode = + | "target-not-selected" + | "bundle-identifier-unavailable" + | "bundle-identifier-invalid" + | "app-id-prefix-required" + | "app-id-prefix-invalid" + | "app-id-prefix-conflict" + | "duplicate-bundle-registration"; + +export interface IOSNativeRemoteBlocker { + code: IOSNativeRemoteBlockerCode; + message: string; +} + +type IOSSelectedNativeReadinessTarget = Extract; + +export interface IOSNativeRemoteTargetSnapshot { + root: string; + projectPath: string; + targetId: string; + bundleIdentifier: IOSSelectedNativeReadinessTarget["bundleIdentifier"]; + appIdPrefix: IOSSelectedNativeReadinessTarget["appIdPrefix"]; +} + +export type IOSNativeRemotePlan = { + schemaVersion: 1; + kind: "clerk-ios-native-remote-setup"; + status: "ready" | "satisfied" | "blocked"; + applicationId: string; + instanceId: string; + localTarget?: IOSNativeRemoteTargetSnapshot; + bundleIdentifier?: string; + appIdPrefix?: string; + nativeApi: "required" | "satisfied"; + registration: "required" | "satisfied" | "blocked"; + actions: string[]; + blockers: IOSNativeRemoteBlocker[]; +}; + +export interface IOSNativeRemoteAPI { + getNativeSettings(applicationId: string, instanceId: string): Promise; + enableNativeApi( + applicationId: string, + instanceId: string, + options: { idempotencyKey: string }, + ): Promise; + listIOSApplications(applicationId: string, instanceId: string): Promise; + createIOSApplication( + applicationId: string, + instanceId: string, + params: { appIdPrefix: string; bundleId: string }, + options: { idempotencyKey: string }, + ): Promise; +} + +const defaultAPI: IOSNativeRemoteAPI = { + getNativeSettings, + enableNativeApi, + listIOSApplications, + createIOSApplication, +}; + +export interface PrepareIOSNativeRemoteSetupOptions { + applicationId: string; + instanceId: string; + root: string; + target: IOSNativeReadinessTarget; + appIdPrefix?: string; + unverifiedAppIdPrefixSuggestion?: IOSUnverifiedAppIdPrefixSuggestion; + /** A completed application/link change that must be reported if planning stops here. */ + applicationLinkChange?: "created-and-linked" | "link-updated"; + agent: boolean; + yes: boolean; +} + +export type IOSNativeRemoteAppIdPrefixSuggestion = + | IOSUnverifiedAppIdPrefixSuggestion + | { source: "partial-literal-entitlements"; value: string }; + +export type IOSNativeRemoteTargetReader = ( + snapshot: IOSNativeRemoteTargetSnapshot, +) => Promise; + +export interface IOSNativeRemotePrompts { + appIdPrefix( + bundleIdentifier: string, + suggested?: IOSNativeRemoteAppIdPrefixSuggestion, + ): Promise; + confirmChanges(): Promise; +} + +const defaultPrompts: IOSNativeRemotePrompts = { + appIdPrefix: async (bundleIdentifier, suggested) => { + if (suggested?.source === "xcode-development-team") { + const choice = await select({ + message: `Apple App ID Prefix for ${bundleIdentifier}`, + choices: [ + { + name: `Use ${suggested.value}`, + value: "use-suggested" as const, + description: + "Suggested from Xcode DEVELOPMENT_TEAM; usually matches, but legacy Apple accounts can differ.", + }, + { + name: "Enter a different App ID Prefix", + value: "enter-different" as const, + }, + ], + default: "use-suggested" as const, + }); + if (choice === "use-suggested") return suggested.value; + } + + return text({ + message: `Apple App ID Prefix for ${bundleIdentifier}`, + default: suggested?.source === "partial-literal-entitlements" ? suggested.value : undefined, + placeholder: suggested?.value ?? "ABCDE12345", + validate: (value) => + validateAppIdPrefix(value) != null || + `Enter an App ID Prefix containing exactly ${APP_ID_PREFIX_LENGTH} ASCII letters or numbers. Verify it in Apple Developer; it can differ from your Team ID.`, + }); + }, + confirmChanges: async () => + confirm({ message: "Apply these remote Clerk Native Application changes?", default: false }), +}; + +function blocker(code: IOSNativeRemoteBlockerCode, message: string): IOSNativeRemoteBlocker { + return { code, message }; +} + +export function validateAppIdPrefix(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized && APP_ID_PREFIX_PATTERN.test(normalized) ? normalized : undefined; +} + +export function validateBundleIdentifier(value: string | undefined): string | undefined { + return value && + value.length <= BUNDLE_IDENTIFIER_MAX_LENGTH && + BUNDLE_IDENTIFIER_PATTERN.test(value) && + value.split(".").every((component) => component.length > 0) + ? value + : undefined; +} + +function copyTargetSnapshot( + root: string | undefined, + target: IOSNativeReadinessTarget, +): IOSNativeRemoteTargetSnapshot | undefined { + if (!root || target.status !== "selected") return undefined; + return { + root, + projectPath: target.projectPath, + targetId: target.targetId, + bundleIdentifier: + target.bundleIdentifier.status === "conflicting" + ? { ...target.bundleIdentifier, candidates: [...target.bundleIdentifier.candidates] } + : { ...target.bundleIdentifier }, + appIdPrefix: + target.appIdPrefix.status === "resolved" + ? { ...target.appIdPrefix } + : { + ...target.appIdPrefix, + ...(target.appIdPrefix.candidates + ? { candidates: [...target.appIdPrefix.candidates] } + : {}), + }, + }; +} + +const defaultTargetReader: IOSNativeRemoteTargetReader = async (snapshot) => { + const inspection = await inspectIOSProject(snapshot.root, { + target: snapshot.targetId, + exhaustiveContainerDiscovery: true, + }); + if (hasIncompleteIOSContainerDiscovery(inspection)) { + return { status: "blocked", reason: "target-not-selected" }; + } + return buildIOSNativeReadinessAudit(inspection).target; +}; + +function localIdentity(target: IOSNativeReadinessTarget): { + bundleIdentifier?: string; + appIdPrefix?: string; + appIdPrefixCandidates: string[]; + blockers: IOSNativeRemoteBlocker[]; +} { + if (target.status !== "selected") { + return { + appIdPrefixCandidates: [], + blockers: [ + blocker( + "target-not-selected", + "Select exactly one iOS application target before registering it with Clerk.", + ), + ], + }; + } + + if (target.bundleIdentifier.status !== "resolved") { + return { + appIdPrefixCandidates: [], + blockers: [ + blocker( + "bundle-identifier-unavailable", + "Resolve one Bundle ID across every selected-target build configuration before registering the iOS app with Clerk.", + ), + ], + }; + } + + const blockers: IOSNativeRemoteBlocker[] = []; + if (!validateBundleIdentifier(target.bundleIdentifier.value)) { + blockers.push( + blocker( + "bundle-identifier-invalid", + `The selected target's Bundle ID must contain between 1 and ${BUNDLE_IDENTIFIER_MAX_LENGTH} ASCII letters, numbers, hyphens, or periods, with no empty dot-separated components.`, + ), + ); + } + + const appIdPrefixCandidates = + target.appIdPrefix.status === "resolved" + ? [target.appIdPrefix.value] + : target.appIdPrefix.status === "conflicting" + ? target.appIdPrefix.candidates + : (target.appIdPrefix.candidates ?? []); + const invalidLocalPrefixes = appIdPrefixCandidates.filter( + (candidate) => validateAppIdPrefix(candidate) !== candidate, + ); + if (invalidLocalPrefixes.length > 0) { + blockers.push( + blocker( + "app-id-prefix-invalid", + `The selected target contains an invalid Apple App ID Prefix. App ID Prefixes must contain exactly ${APP_ID_PREFIX_LENGTH} ASCII letters or numbers.`, + ), + ); + } + if (target.appIdPrefix.status === "conflicting") { + blockers.push( + blocker( + "app-id-prefix-conflict", + "The selected target contains conflicting literal App ID Prefix evidence across its build configurations.", + ), + ); + } + + return { + bundleIdentifier: target.bundleIdentifier.value, + appIdPrefix: + target.appIdPrefix.status === "resolved" && + validateAppIdPrefix(target.appIdPrefix.value) === target.appIdPrefix.value + ? target.appIdPrefix.value + : undefined, + appIdPrefixCandidates, + blockers, + }; +} + +export function buildIOSNativeRemotePlan(options: { + applicationId: string; + instanceId: string; + root?: string; + target: IOSNativeReadinessTarget; + requestedAppIdPrefix?: string; + nativeSettings: NativeSettings; + registrations: IOSApplication[]; +}): IOSNativeRemotePlan { + const nativeSettings = validateNativeSettings(options.nativeSettings); + const registrations = validateIOSApplications(options.registrations); + const identity = localIdentity(options.target); + const blockers = [...identity.blockers]; + const localBundleIdentifier = identity.bundleIdentifier; + const explicitPrefix = validateAppIdPrefix(options.requestedAppIdPrefix); + if (options.requestedAppIdPrefix != null && !explicitPrefix) { + blockers.push( + blocker( + "app-id-prefix-invalid", + `The supplied Apple App ID Prefix must contain exactly ${APP_ID_PREFIX_LENGTH} ASCII letters or numbers.`, + ), + ); + } + if ( + explicitPrefix && + identity.appIdPrefixCandidates.some((candidate) => candidate !== explicitPrefix) + ) { + blockers.push( + blocker( + "app-id-prefix-conflict", + `The supplied App ID Prefix does not match the literal prefix proven for ${localBundleIdentifier ?? "the selected target"}.`, + ), + ); + } + + const matchingBundle = localBundleIdentifier + ? registrations.filter((registration) => + bundleIdentifiersEqual(registration.bundle_id, localBundleIdentifier), + ) + : []; + // The backend currently uses the registered Bundle ID's original spelling + // for the native Apple lookup. Once a case-insensitive match exists, carry + // that authoritative stored spelling through the rest of reconciliation. + const bundleIdentifier = matchingBundle[0]?.bundle_id ?? localBundleIdentifier; + const invalidRegisteredPrefixes = matchingBundle.filter( + (registration) => + validateAppIdPrefix(registration.app_id_prefix) !== registration.app_id_prefix, + ); + if (invalidRegisteredPrefixes.length > 0) { + blockers.push( + blocker( + "app-id-prefix-invalid", + `An existing Clerk registration for ${bundleIdentifier} contains an invalid Apple App ID Prefix. Review the Native Applications page before continuing.`, + ), + ); + } + const registeredPrefixes = [...new Set(matchingBundle.map((item) => item.app_id_prefix))].sort(); + const selectedPrefix = explicitPrefix ?? identity.appIdPrefix; + let appIdPrefix = selectedPrefix; + let registration: IOSNativeRemotePlan["registration"] = "blocked"; + + const hasInvalidIdentity = blockers.some( + (item) => item.code === "bundle-identifier-invalid" || item.code === "app-id-prefix-invalid", + ); + if (bundleIdentifier && !hasInvalidIdentity) { + if (selectedPrefix) { + const conflicts = registeredPrefixes.filter((prefix) => prefix !== selectedPrefix); + if (conflicts.length > 0) { + blockers.push( + blocker( + "app-id-prefix-conflict", + `${bundleIdentifier} is already registered with a different App ID Prefix. Review the Native Applications page; clerk init will not replace it.`, + ), + ); + } else { + registration = registeredPrefixes.includes(selectedPrefix) ? "satisfied" : "required"; + } + } else if (registeredPrefixes.length === 1) { + appIdPrefix = registeredPrefixes[0]; + if (identity.appIdPrefixCandidates.some((candidate) => candidate !== appIdPrefix)) { + blockers.push( + blocker( + "app-id-prefix-conflict", + `The existing Clerk registration for ${bundleIdentifier} conflicts with literal App ID Prefix evidence in the selected target.`, + ), + ); + } else { + registration = "satisfied"; + } + } else if (registeredPrefixes.length > 1) { + blockers.push( + blocker( + "duplicate-bundle-registration", + `${bundleIdentifier} has more than one App ID Prefix registration. Review the Native Applications page before continuing.`, + ), + ); + } else { + blockers.push( + blocker( + "app-id-prefix-required", + `An Apple App ID Prefix is required to register ${bundleIdentifier}.`, + ), + ); + } + } + + const nativeApi = nativeSettings.api_enabled ? "satisfied" : "required"; + const actions: string[] = []; + if (registration === "required" && appIdPrefix && bundleIdentifier) { + actions.push( + `Register iOS Bundle ID ${bundleIdentifier} with Apple App ID Prefix ${appIdPrefix}.`, + ); + } + if (nativeApi === "required") { + actions.push("Enable the Native API for the linked development instance."); + } + + const status = + blockers.length > 0 + ? "blocked" + : nativeApi === "satisfied" && registration === "satisfied" + ? "satisfied" + : "ready"; + return { + schemaVersion: 1, + kind: "clerk-ios-native-remote-setup", + status, + applicationId: options.applicationId, + instanceId: options.instanceId, + localTarget: copyTargetSnapshot(options.root, options.target), + bundleIdentifier, + appIdPrefix, + nativeApi, + registration, + actions, + blockers, + }; +} + +async function readRemoteState( + applicationId: string, + instanceId: string, + api: IOSNativeRemoteAPI, +): Promise<{ nativeSettings: NativeSettings; registrations: IOSApplication[] }> { + const [nativeSettings, registrations] = await Promise.all([ + api.getNativeSettings(applicationId, instanceId), + api.listIOSApplications(applicationId, instanceId), + ]); + return { + nativeSettings: validateNativeSettings(nativeSettings), + registrations: validateIOSApplications(registrations), + }; +} + +function formatBlockers(plan: IOSNativeRemotePlan): string { + return plan.blockers.map((item) => ` • ${item.message}`).join("\n"); +} + +function nativeSetupOutcome( + applicationLinkChange?: PrepareIOSNativeRemoteSetupOptions["applicationLinkChange"], +): string { + return applicationLinkChange === "created-and-linked" + ? "A new Clerk application was created and linked, but no Xcode or Clerk Native Application settings changes were written." + : applicationLinkChange === "link-updated" + ? "The project's Clerk application link was updated, but no Xcode or Clerk Native Application settings changes were written." + : "No local or remote setup changes were written."; +} + +function agentAppIdPrefixRequiredMessage( + bundleIdentifier: string, + suggestion?: IOSNativeRemoteAppIdPrefixSuggestion, + applicationLinkChange?: PrepareIOSNativeRemoteSetupOptions["applicationLinkChange"], +): string { + const retry = + 'After the user confirms the value, rerun the same command with --app-id-prefix "".'; + const outcome = nativeSetupOutcome(applicationLinkChange); + + if (!suggestion) { + return `Registering ${bundleIdentifier} in agent mode requires --app-id-prefix . Ask the user to copy the value labeled App ID Prefix in Apple Developer. ${retry} ${outcome}`; + } + + const source = + suggestion.source === "xcode-development-team" + ? "the selected target's Xcode DEVELOPMENT_TEAM setting. DEVELOPMENT_TEAM often matches the Apple App ID Prefix, but older Apple Developer accounts can differ" + : "literal App ID Prefix evidence found in only some of the selected target's entitlement configurations, so it could not be verified across the whole target"; + + return `Registering ${bundleIdentifier} in agent mode requires a confirmed App ID Prefix through --app-id-prefix . The CLI found ${suggestion.value} from ${source}. Treat it only as an unverified suggestion: do not use it automatically. Ask the user whether to use ${suggestion.value} or enter a different App ID Prefix. ${retry} ${outcome}`; +} + +function appIdPrefixSuggestion( + target: IOSNativeReadinessTarget, + unverifiedSuggestion?: IOSUnverifiedAppIdPrefixSuggestion, +): IOSNativeRemoteAppIdPrefixSuggestion | undefined { + const literalSuggestion = + target.status === "selected" && target.appIdPrefix.status === "missing" + ? target.appIdPrefix.candidates?.length === 1 + ? { + source: "partial-literal-entitlements" as const, + value: target.appIdPrefix.candidates[0]!, + } + : undefined + : undefined; + return literalSuggestion ?? unverifiedSuggestion; +} + +/** + * A newly created Clerk application cannot already contain the selected iOS + * registration. Stop before application creation when agent mode still needs + * the user to confirm an App ID Prefix. + */ +export function assertIOSAppIdPrefixBeforeApplicationCreation(options: { + target: IOSNativeReadinessTarget; + appIdPrefix?: string; + unverifiedAppIdPrefixSuggestion?: IOSUnverifiedAppIdPrefixSuggestion; +}): void { + const plan = buildIOSNativeRemotePlan({ + applicationId: "preflight", + instanceId: "preflight", + target: options.target, + requestedAppIdPrefix: options.appIdPrefix, + nativeSettings: { object: "native_settings", api_enabled: false }, + registrations: [], + }); + if (plan.status !== "blocked") return; + + const onlyMissingPrefix = + plan.blockers.length === 1 && + plan.blockers[0]?.code === "app-id-prefix-required" && + options.appIdPrefix == null && + plan.bundleIdentifier != null; + if (!onlyMissingPrefix) { + throw iosRemoteError( + `Clerk Native Application readiness could not be completed safely. No local or remote setup changes were written:\n${formatBlockers(plan)}`, + ERROR_CODE.IOS_SETUP_BLOCKED, + ); + } + + throwUsageError( + agentAppIdPrefixRequiredMessage( + plan.bundleIdentifier!, + appIdPrefixSuggestion(options.target, options.unverifiedAppIdPrefixSuggestion), + ), + ); +} + +export async function prepareIOSNativeRemoteSetup( + options: PrepareIOSNativeRemoteSetupOptions, + dependencies: { + api?: IOSNativeRemoteAPI; + prompts?: IOSNativeRemotePrompts; + } = {}, +): Promise { + const api = dependencies.api ?? defaultAPI; + const prompts = dependencies.prompts ?? defaultPrompts; + let state: Awaited>; + try { + state = await withSpinner("Auditing Clerk Native Application settings...", async () => + readRemoteState(options.applicationId, options.instanceId, api), + ); + } catch (error) { + logSuppressedFailure("Could not inspect Clerk Native Application settings"); + rethrowKnownRemoteError(error); + throw iosRemoteError( + `Clerk Native Application settings could not be inspected. ${nativeSetupOutcome(options.applicationLinkChange)} Verify your application access and rerun clerk init.`, + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + let plan = buildIOSNativeRemotePlan({ + applicationId: options.applicationId, + instanceId: options.instanceId, + root: options.root, + target: options.target, + requestedAppIdPrefix: options.appIdPrefix, + ...state, + }); + + const onlyMissingPrefix = + plan.status === "blocked" && + plan.blockers.length === 1 && + plan.blockers[0]?.code === "app-id-prefix-required" && + options.appIdPrefix == null && + plan.bundleIdentifier != null; + if (onlyMissingPrefix) { + const suggestion = appIdPrefixSuggestion( + options.target, + options.unverifiedAppIdPrefixSuggestion, + ); + if (options.agent) { + throwUsageError( + agentAppIdPrefixRequiredMessage( + plan.bundleIdentifier!, + suggestion, + options.applicationLinkChange, + ), + ); + } + const appIdPrefix = await prompts.appIdPrefix(plan.bundleIdentifier!, suggestion); + plan = buildIOSNativeRemotePlan({ + applicationId: options.applicationId, + instanceId: options.instanceId, + root: options.root, + target: options.target, + requestedAppIdPrefix: appIdPrefix, + ...state, + }); + } + + if (plan.status === "blocked") { + throw iosRemoteError( + `Clerk Native Application readiness could not be completed safely. ${nativeSetupOutcome(options.applicationLinkChange)}\n${formatBlockers(plan)}\n Review https://dashboard.clerk.com/~/native-applications`, + ERROR_CODE.IOS_SETUP_BLOCKED, + ); + } + + if (plan.status === "satisfied") { + log.info(dim("Clerk Native API and iOS application registration are already configured.")); + return plan; + } + + log.info("\nclerk init will make the following remote Clerk changes:\n"); + for (const action of plan.actions) log.info(` ${yellow("REMOTE")} ${action}`); + log.info( + dim( + "\n Remote changes are additive. clerk init will not update or delete an existing iOS registration.", + ), + ); + log.blank(); + + if (options.agent && !options.yes) { + throwUsageError( + "Changing Clerk Native Application settings in agent mode requires explicit consent. Rerun with --yes after reviewing the plan.", + ); + } + if (!options.yes && !(await prompts.confirmChanges())) throwUserAbort(); + return plan; +} + +async function reconciledPlan( + plan: IOSNativeRemotePlan, + api: IOSNativeRemoteAPI, +): Promise { + const state = await readRemoteState(plan.applicationId, plan.instanceId, api); + const reconciled = buildIOSNativeRemotePlan({ + applicationId: plan.applicationId, + instanceId: plan.instanceId, + target: { + status: "selected", + projectPath: "", + targetId: "", + targetName: "", + bundleIdentifier: { status: "resolved", value: plan.bundleIdentifier! }, + appIdPrefix: plan.appIdPrefix + ? { status: "resolved", source: "literal-entitlements", value: plan.appIdPrefix } + : { status: "missing", source: "literal-entitlements", candidates: [] }, + }, + requestedAppIdPrefix: plan.appIdPrefix, + ...state, + }); + return { ...reconciled, localTarget: plan.localTarget }; +} + +function prefixEvidenceMatchesApprovedIdentity( + approved: IOSNativeRemoteTargetSnapshot["appIdPrefix"], + current: IOSSelectedNativeReadinessTarget["appIdPrefix"], + appIdPrefix: string, +): boolean { + if (approved.status === "conflicting") return false; + if (approved.status === "resolved") { + return ( + approved.value === appIdPrefix && + current.status === "resolved" && + current.value === appIdPrefix + ); + } + + // A prefix explicitly confirmed by the user or inherited from an existing + // Clerk registration need not become literal Xcode evidence. If evidence + // appears after approval, however, it may only prove that same prefix. + if (approved.candidates?.some((candidate) => candidate !== appIdPrefix)) return false; + if (current.status === "conflicting") return false; + if (current.status === "resolved") return current.value === appIdPrefix; + return !(current.candidates?.some((candidate) => candidate !== appIdPrefix) ?? false); +} + +function localTargetStillMatchesApprovedIdentity( + plan: IOSNativeRemotePlan, + current: IOSNativeReadinessTarget, +): boolean { + const approved = plan.localTarget; + if ( + !approved || + !plan.bundleIdentifier || + !plan.appIdPrefix || + approved.bundleIdentifier.status !== "resolved" || + !bundleIdentifiersEqual(approved.bundleIdentifier.value, plan.bundleIdentifier) || + current.status !== "selected" || + current.projectPath !== approved.projectPath || + current.targetId !== approved.targetId || + current.bundleIdentifier.status !== "resolved" || + !bundleIdentifiersEqual(current.bundleIdentifier.value, plan.bundleIdentifier) + ) { + return false; + } + return prefixEvidenceMatchesApprovedIdentity( + approved.appIdPrefix, + current.appIdPrefix, + plan.appIdPrefix, + ); +} + +async function revalidateLocalTargetBeforeRemoteAccess( + plan: IOSNativeRemotePlan, + targetReader: IOSNativeRemoteTargetReader, +): Promise { + if (!plan.localTarget) { + throw iosRemoteError( + "The approved Clerk Native Application plan does not identify the inspected Xcode target. No remote changes were made; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + + let current: IOSNativeReadinessTarget; + try { + current = await withSpinner("Rechecking the selected Xcode target identity...", async () => + targetReader(plan.localTarget!), + ); + } catch { + logSuppressedFailure("Could not recheck the selected Xcode target identity"); + throw iosRemoteError( + "The selected Xcode target identity could not be rechecked. No remote changes were made; rerun clerk init.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + + if (!localTargetStillMatchesApprovedIdentity(plan, current)) { + throw iosRemoteError( + "The selected Xcode target identity changed after the approved preview. No remote changes were made; rerun clerk init to review the current Bundle ID and App ID Prefix.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } +} + +function revalidatedActionSetIsAuthorized( + approved: IOSNativeRemotePlan, + current: IOSNativeRemotePlan, +): boolean { + if ( + current.status === "blocked" || + current.applicationId !== approved.applicationId || + current.instanceId !== approved.instanceId || + !bundleIdentifiersEqual(current.bundleIdentifier, approved.bundleIdentifier) || + current.appIdPrefix !== approved.appIdPrefix + ) { + return false; + } + // Concurrent completion is harmless. A newly-required action was never + // shown in the approved preview and must force a fresh plan instead. + if (approved.nativeApi === "satisfied" && current.nativeApi !== "satisfied") return false; + if (approved.registration === "satisfied" && current.registration !== "satisfied") { + return false; + } + return true; +} + +function registrationRetryIdentity( + plan: IOSNativeRemotePlan, +): IOSNativeRegistrationRetryIdentity | undefined { + if (!plan.localTarget || !plan.bundleIdentifier || !plan.appIdPrefix) return undefined; + return { + applicationId: plan.applicationId, + instanceId: plan.instanceId, + bundleIdentifier: plan.bundleIdentifier, + appIdPrefix: plan.appIdPrefix, + }; +} + +export async function applyIOSNativeRemoteSetup( + plan: IOSNativeRemotePlan, + api: IOSNativeRemoteAPI = defaultAPI, + targetReader: IOSNativeRemoteTargetReader = defaultTargetReader, + registrationRetryStore: IOSNativeRegistrationRetryStore = cliStateIOSNativeRegistrationRetryStore, +): Promise { + if (plan.status === "blocked" || !plan.bundleIdentifier || !plan.appIdPrefix) { + throw iosRemoteError( + "The approved Clerk Native Application plan is incomplete. No remote changes were made; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + + // The approved local identity is the basis for every remote audit, even + // when the preview found no work to perform. Revalidate before reading + // retry state or Clerk state so a satisfied plan cannot report success for + // a Bundle ID or App ID Prefix that changed after approval. + await revalidateLocalTargetBeforeRemoteAccess(plan, targetReader); + + const nativeAPIIdempotencyKey = `clerk-init-ios-native-api-${randomUUID()}`; + const retryIdentity = registrationRetryIdentity(plan); + let observedRegistrationRetryKey: string | undefined; + + if (retryIdentity) { + try { + observedRegistrationRetryKey = + plan.registration === "required" + ? await registrationRetryStore.getOrCreate(retryIdentity) + : await registrationRetryStore.peek(retryIdentity); + } catch (error) { + logSuppressedFailure("Could not read or preserve the iOS registration retry state"); + if (error instanceof IOSNativeRegistrationRetryLockError) { + throw iosRemoteError(retryLockFailureMessage(error, false)); + } + throw iosRemoteError( + "The iOS application registration retry state could not be read or preserved safely. The local setup remains intact, and no registration request was sent; verify CLI state directory access and rerun clerk init.", + ); + } + } + + // Acquire the stable registration generation before the authoritative + // remote re-read. A second CLI that resumes after another invocation has + // completed must observe that completion before deciding whether to POST. + let currentPlan: IOSNativeRemotePlan; + try { + currentPlan = await withSpinner("Rechecking Clerk Native Application settings...", async () => + reconciledPlan(plan, api), + ); + } catch (error) { + logSuppressedFailure("Could not recheck Clerk Native Application settings"); + rethrowKnownRemoteError(error); + throw iosRemoteError( + "Clerk Native Application settings could not be rechecked after the local setup. No remote changes were made; rerun clerk init.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + if (!revalidatedActionSetIsAuthorized(plan, currentPlan)) { + throw iosRemoteError( + "Clerk Native Application settings changed after the approved preview. No remote changes were made; rerun clerk init to review the new plan.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + + // Register first so Native API is never enabled by this command without a + // matching iOS application registration already present. + if (currentPlan.registration === "required") { + if (!retryIdentity) { + throw iosRemoteError( + "The approved Clerk Native Application plan cannot persist a safe registration retry. No remote changes were made; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if (!observedRegistrationRetryKey) { + throw iosRemoteError( + "The approved Clerk Native Application plan did not retain a safe registration retry. No registration request was sent; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + try { + const created = validateIOSApplication( + await withSpinner("Registering the iOS application with Clerk...", async () => + api.createIOSApplication( + plan.applicationId, + plan.instanceId, + { appIdPrefix: plan.appIdPrefix!, bundleId: plan.bundleIdentifier! }, + { idempotencyKey: observedRegistrationRetryKey }, + ), + ), + ); + if ( + !bundleIdentifiersEqual(created.bundle_id, plan.bundleIdentifier) || + created.app_id_prefix !== plan.appIdPrefix + ) { + throw iosRemoteError( + "Clerk returned an unexpected iOS application registration. The local setup remains intact; rerun clerk init to reconcile remote state.", + ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + ); + } + } catch (error) { + logSuppressedFailure("Could not create the iOS application registration"); + if (error instanceof CliError && error.code === ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE) { + throw error; + } + let registrations: IOSApplication[]; + try { + registrations = validateIOSApplications( + await api.listIOSApplications(plan.applicationId, plan.instanceId), + ); + } catch (fallbackError) { + logSuppressedFailure("Could not confirm the iOS application registration"); + rethrowKnownRemoteError(fallbackError); + throw iosRemoteError( + "The iOS application registration could not be confirmed. The local setup remains intact; rerun clerk init to reconcile remote state.", + ); + } + const exact = registrations.some( + (registration) => + bundleIdentifiersEqual(registration.bundle_id, plan.bundleIdentifier) && + registration.app_id_prefix === plan.appIdPrefix, + ); + if (!exact) { + rethrowKnownRemoteError(error); + throw iosRemoteError( + "The iOS application could not be registered with Clerk. The local setup remains intact; rerun clerk init to retry safely.", + ); + } + } + log.success(`iOS application ${plan.bundleIdentifier} registered with Clerk`); + } + + if (currentPlan.nativeApi === "required") { + let enableError: unknown; + let enabledResponse: unknown; + let enableCompleted = false; + try { + enabledResponse = await withSpinner("Enabling the Clerk Native API...", async () => + api.enableNativeApi(plan.applicationId, plan.instanceId, { + idempotencyKey: nativeAPIIdempotencyKey, + }), + ); + enableCompleted = true; + } catch (error) { + logSuppressedFailure("Could not enable the Clerk Native API"); + if (error instanceof CliError && error.code === ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE) { + throw error; + } + enableError = error; + } + + // A successful HTTP response with a malformed DTO is authoritative + // evidence of a protocol violation, not an ambiguous transport outcome. + // Validate outside the transport catch so a later GET cannot hide it. + if (enableCompleted) { + const enabled = validateNativeSettings(enabledResponse); + if (!enabled.api_enabled) { + enableError = iosRemoteError( + "Clerk did not report the Native API as enabled. The local setup and any completed registration remain intact; rerun clerk init.", + ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + ); + } + } + + if (enableError) { + let current: NativeSettings; + try { + current = validateNativeSettings( + await api.getNativeSettings(plan.applicationId, plan.instanceId), + ); + } catch (fallbackError) { + logSuppressedFailure("Could not confirm Clerk Native API state"); + rethrowKnownRemoteError(fallbackError); + throw iosRemoteError( + "Native API enablement could not be confirmed. The local setup and any completed iOS registration remain intact; rerun clerk init.", + ); + } + if (!current.api_enabled) { + rethrowKnownRemoteError(enableError); + throw iosRemoteError( + "The Native API could not be enabled. The local setup and any completed iOS registration remain intact; rerun clerk init to retry safely.", + ); + } + } + log.success("Clerk Native API enabled for the development instance"); + } + + let finalPlan: IOSNativeRemotePlan; + try { + finalPlan = await withSpinner("Verifying Clerk Native Application settings...", async () => + reconciledPlan(plan, api), + ); + } catch (error) { + logSuppressedFailure("Could not verify Clerk Native Application settings"); + rethrowKnownRemoteError(error); + throw iosRemoteError( + "Clerk Native Application settings could not be verified. The local setup and any completed remote changes remain intact; rerun clerk init.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + if (finalPlan.status !== "satisfied" || !revalidatedActionSetIsAuthorized(plan, finalPlan)) { + throw iosRemoteError( + "Clerk Native Application settings did not pass the final verification. The local iOS setup remains intact; rerun clerk init to reconcile the additive remote steps.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + if (retryIdentity && observedRegistrationRetryKey) { + try { + const cleared = await registrationRetryStore.clear( + retryIdentity, + observedRegistrationRetryKey, + ); + if (!cleared) { + log.debug( + "Preserved a newer iOS registration retry state created after this invocation began.", + ); + } + } catch (error) { + logSuppressedFailure("Could not clear the verified iOS registration retry state"); + if (error instanceof IOSNativeRegistrationRetryLockError) { + throw iosRemoteError( + retryLockFailureMessage(error, true), + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + throw iosRemoteError( + "Clerk Native Application settings were verified, but the local registration retry state could not be cleared. No further remote changes are required; verify CLI state directory access and rerun clerk init.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + } +} diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.test.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.test.ts new file mode 100644 index 000000000..ca64ba4d2 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { auditIOSPrebuiltAuthEnvironment } from "./prebuilt-auth-environment.ts"; + +describe("auditIOSPrebuiltAuthEnvironment", () => { + test("requires the native Apple entitlement when Apple is enabled and authenticatable", () => { + expect( + auditIOSPrebuiltAuthEnvironment({ + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + }, + }, + }), + ).toEqual({ apple: "required" }); + }); + + test("does not require the entitlement when enabled Apple is not authenticatable", () => { + expect( + auditIOSPrebuiltAuthEnvironment({ + social: { + oauth_apple: { + enabled: true, + authenticatable: false, + strategy: "oauth_apple", + }, + }, + }), + ).toEqual({ apple: "not-required" }); + }); + + test("does not require the entitlement when Apple is disabled", () => { + expect( + auditIOSPrebuiltAuthEnvironment({ + social: { + oauth_apple: { + enabled: false, + authenticatable: true, + strategy: "oauth_apple", + }, + }, + }), + ).toEqual({ apple: "not-required" }); + }); + + test("does not require the entitlement when Apple is absent", () => { + expect(auditIOSPrebuiltAuthEnvironment({ social: {} })).toEqual({ + apple: "not-required", + }); + }); + + test.each([ + undefined, + null, + {}, + { social: null }, + { social: [] }, + { social: { oauth_apple: null } }, + { social: { oauth_apple: [] } }, + { social: { oauth_apple: { enabled: "true", authenticatable: true } } }, + { social: { oauth_apple: { enabled: true, authenticatable: "true" } } }, + { social: { oauth_apple: { enabled: true } } }, + { + social: { + alias: { enabled: true, authenticatable: true, strategy: "oauth_apple" }, + }, + }, + { + social: { + oauth_apple: { enabled: true, authenticatable: true, strategy: "oauth_google" }, + }, + }, + ])("blocks malformed or ambiguous provider data", (settings) => { + expect(auditIOSPrebuiltAuthEnvironment(settings)).toEqual({ + apple: "blocked", + message: + "Clerk's Apple sign-in settings could not be safely determined. Review the Apple social connection before applying the prebuilt iOS authentication UI.", + }); + }); + + test("returns only redacted status data and never retains provider details", () => { + const secret = "client-secret-must-not-escape"; + const callbackUrl = "https://example.test/private-callback"; + const settings = { + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + client_secret: secret, + redirect_url: callbackUrl, + nested: { credential: secret }, + }, + }, + }; + + const audit = auditIOSPrebuiltAuthEnvironment(settings); + const serialized = JSON.stringify(audit); + + expect(audit).toEqual({ apple: "required" }); + expect(serialized).toBe('{"apple":"required"}'); + expect(serialized).not.toContain(secret); + expect(serialized).not.toContain(callbackUrl); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.ts new file mode 100644 index 000000000..d34d387e3 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.ts @@ -0,0 +1,52 @@ +export type IOSPrebuiltAuthEnvironmentAudit = + | { apple: "required" } + | { apple: "not-required" } + | { apple: "blocked"; message: string }; + +const APPLE_PROVIDER_STRATEGY = "oauth_apple"; +const BLOCKED_MESSAGE = + "Clerk's Apple sign-in settings could not be safely determined. Review the Apple social connection before applying the prebuilt iOS authentication UI."; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function blocked(): IOSPrebuiltAuthEnvironmentAudit { + return { apple: "blocked", message: BLOCKED_MESSAGE }; +} + +/** + * Determines whether AuthView will offer native Sign in with Apple without + * retaining or returning any Frontend API environment data. + */ +export function auditIOSPrebuiltAuthEnvironment( + settings: unknown, +): IOSPrebuiltAuthEnvironmentAudit { + if (!isRecord(settings) || !isRecord(settings.social)) { + return blocked(); + } + + let appleEnabled = false; + for (const [key, provider] of Object.entries(settings.social)) { + if ( + !isRecord(provider) || + typeof provider.enabled !== "boolean" || + typeof provider.authenticatable !== "boolean" || + typeof provider.strategy !== "string" || + provider.strategy.trim().length === 0 + ) { + return blocked(); + } + + const keyIdentifiesApple = key === APPLE_PROVIDER_STRATEGY; + const strategyIdentifiesApple = provider.strategy === APPLE_PROVIDER_STRATEGY; + if (keyIdentifiesApple !== strategyIdentifiesApple) { + return blocked(); + } + if (strategyIdentifiesApple && provider.enabled && provider.authenticatable) { + appleEnabled = true; + } + } + + return appleEnabled ? { apple: "required" } : { apple: "not-required" }; +} diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index d3fe78ada..b49e60e66 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -136,6 +136,12 @@ export const ERROR_CODE = { IOS_PUBLISHABLE_KEY_UNAVAILABLE: "ios_publishable_key_unavailable", /** The iOS runtime publishable key does not belong to the linked Clerk application. */ IOS_PUBLISHABLE_KEY_MISMATCH: "ios_publishable_key_mismatch", + /** An approved Clerk native configuration change could not be applied or confirmed. */ + IOS_REMOTE_APPLY_FAILED: "ios_remote_apply_failed", + /** Clerk native configuration was readable after apply but did not match the approved state. */ + IOS_REMOTE_VERIFY_FAILED: "ios_remote_verify_failed", + /** Platform API returned a successful response with missing or contradictory data. */ + PLAPI_UNEXPECTED_RESPONSE: "plapi_unexpected_response", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; diff --git a/packages/cli-core/src/lib/plapi-native.test.ts b/packages/cli-core/src/lib/plapi-native.test.ts new file mode 100644 index 000000000..78e546048 --- /dev/null +++ b/packages/cli-core/src/lib/plapi-native.test.ts @@ -0,0 +1,290 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { credentialStoreStubs, stubFetch } from "../test/lib/stubs.ts"; + +const mockGetValidToken = mock(); +mock.module("./credential-store.ts", () => ({ + ...credentialStoreStubs, + getValidToken: (...args: unknown[]) => mockGetValidToken(...args), +})); + +const { createIOSApplication, enableNativeApi, getNativeSettings, listIOSApplications } = + await import("./plapi.ts"); +const { ERROR_CODE, PlapiError } = await import("./errors.ts"); + +describe("PLAPI native application client", () => { + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + mockGetValidToken.mockResolvedValue(null); + process.env.CLERK_PLATFORM_API_KEY = "ak_test_client_token"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + mockGetValidToken.mockReset(); + }); + + test("gets native settings for an environment alias", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + let capturedHeaders: Headers | undefined; + const responseBody = { object: "native_settings" as const, api_enabled: false }; + stubFetch(async (input, init) => { + capturedUrl = input.toString(); + capturedMethod = init?.method ?? "GET"; + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await getNativeSettings("app_abc", "development"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/development/native_settings", + ); + expect(capturedHeaders?.get("Authorization")).toBe("Bearer ak_test_client_token"); + expect(capturedHeaders?.get("Accept")).toBe("application/json"); + expect(capturedHeaders?.has("Idempotency-Key")).toBe(false); + expect(result).toEqual(responseBody); + }); + + test("enables Native API with an idempotency key", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + let capturedBody = ""; + let capturedHeaders: Headers | undefined; + const responseBody = { object: "native_settings" as const, api_enabled: true }; + stubFetch(async (input, init) => { + capturedUrl = input.toString(); + capturedMethod = init?.method ?? "GET"; + capturedBody = init?.body as string; + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await enableNativeApi("app_abc", "ins_dev_123", { + idempotencyKey: "enable-native-api-123", + }); + + expect(capturedMethod).toBe("PATCH"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/ins_dev_123/native_settings", + ); + expect(JSON.parse(capturedBody)).toEqual({ api_enabled: true }); + expect(capturedHeaders?.get("Content-Type")).toBe("application/json"); + expect(capturedHeaders?.get("Idempotency-Key")).toBe("enable-native-api-123"); + expect(result).toEqual(responseBody); + }); + + test.each([ + { name: "an array", body: [] }, + { name: "the wrong object discriminator", body: { object: "instance", api_enabled: true } }, + { + name: "a non-boolean enabled value", + body: { object: "native_settings", api_enabled: "false" }, + }, + ])("rejects $name from the Native settings GET", async ({ body }) => { + stubFetch(async () => Response.json(body)); + + await expect(getNativeSettings("app_abc", "development")).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test.each([ + { name: "an array", body: [] }, + { name: "the wrong object discriminator", body: { object: "instance", api_enabled: true } }, + { + name: "a non-boolean enabled value", + body: { object: "native_settings", api_enabled: "false" }, + }, + ])("rejects $name from the Native settings PATCH", async ({ body }) => { + stubFetch(async () => Response.json(body)); + + await expect( + enableNativeApi("app_abc", "development", { idempotencyKey: "enable-native-api-123" }), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test("lists the public iOS application DTOs", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + const responseBody = [ + { + object: "ios_application" as const, + id: "iosapp_123", + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + created_at: 1_787_000_000_000, + updated_at: 1_787_000_000_000, + future_field: "preserved", + }, + ]; + stubFetch(async (input, init) => { + capturedUrl = input.toString(); + capturedMethod = init?.method ?? "GET"; + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await listIOSApplications("app_abc", "ins_dev_123"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/ins_dev_123/native_applications/ios", + ); + expect(result).toEqual(responseBody); + expect(result[0]).not.toHaveProperty("team_id"); + expect((result[0] as unknown as Record).future_field).toBe("preserved"); + }); + + test.each([ + { name: "a non-array root", body: {} }, + { name: "a null item", body: [null] }, + { + name: "an incomplete item", + body: [ + { + object: "ios_application", + id: "iosapp_123", + app_id_prefix: "ABCD123456", + created_at: 1_787_000_000_000, + updated_at: 1_787_000_000_000, + }, + ], + }, + { + name: "an item with a mistyped field", + body: [ + { + object: "ios_application", + id: "iosapp_123", + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + created_at: "1787000000000", + updated_at: 1_787_000_000_000, + }, + ], + }, + ])("rejects $name from the iOS application list", async ({ body }) => { + stubFetch(async () => Response.json(body)); + + await expect(listIOSApplications("app_abc", "ins_dev_123")).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test("rejects malformed JSON from the iOS application list", async () => { + stubFetch(async () => new Response("{", { status: 200 })); + + await expect(listIOSApplications("app_abc", "ins_dev_123")).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test("creates an iOS application with the public field names and idempotency key", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + let capturedBody = ""; + let capturedHeaders: Headers | undefined; + const responseBody = { + object: "ios_application" as const, + id: "iosapp_123", + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + created_at: 1_787_000_000_000, + updated_at: 1_787_000_000_000, + }; + stubFetch(async (input, init) => { + capturedUrl = input.toString(); + capturedMethod = init?.method ?? "GET"; + capturedBody = init?.body as string; + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await createIOSApplication( + "app_abc", + "development", + { appIdPrefix: "ABCD123456", bundleId: "com.example.coolappy" }, + { idempotencyKey: "create-ios-app-123" }, + ); + + expect(capturedMethod).toBe("POST"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/development/native_applications/ios", + ); + expect(JSON.parse(capturedBody)).toEqual({ + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + }); + expect(capturedHeaders?.get("Content-Type")).toBe("application/json"); + expect(capturedHeaders?.get("Idempotency-Key")).toBe("create-ios-app-123"); + expect(result).toEqual(responseBody); + }); + + test("rejects an incomplete iOS application create response", async () => { + stubFetch(async () => + Response.json( + { + object: "ios_application", + id: "iosapp_123", + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + }, + { status: 200 }, + ), + ); + + await expect( + createIOSApplication( + "app_abc", + "development", + { appIdPrefix: "ABCD123456", bundleId: "com.example.coolappy" }, + { idempotencyKey: "create-ios-app-123" }, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test("rejects malformed JSON from the iOS application create response", async () => { + stubFetch(async () => new Response("{", { status: 200 })); + + await expect( + createIOSApplication( + "app_abc", + "development", + { appIdPrefix: "ABCD123456", bundleId: "com.example.coolappy" }, + { idempotencyKey: "create-ios-app-123" }, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test("preserves typed PLAPI errors from native endpoints without credential data", async () => { + stubFetch( + async () => + new Response(JSON.stringify({ errors: [{ code: "resource_not_found" }] }), { status: 404 }), + ); + + try { + await getNativeSettings("app_missing", "development"); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(PlapiError); + expect((error as InstanceType).status).toBe(404); + expect(JSON.stringify(error)).not.toContain("ak_test_client_token"); + } + }); +}); diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index 4c3c87b8d..2af068f8a 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -20,7 +20,7 @@ const { triggerApplicationDomainDNSCheck, listApplicationDomains, } = await import("./plapi.ts"); -const { AuthError, PlapiError } = await import("./errors.ts"); +const { AuthError, ERROR_CODE, PlapiError } = await import("./errors.ts"); describe("plapi", () => { const originalEnv = { ...process.env }; @@ -252,6 +252,22 @@ describe("plapi", () => { expect(capturedHeaders?.get("Content-Type")).toBe("application/json"); }); + test("sends If-Match when a config version is supplied", async () => { + let capturedHeaders: Headers | undefined; + stubFetch(async (_input, init) => { + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await patchInstanceConfig( + "app_1", + "ins_1", + { connection_oauth_apple: { enabled: true } }, + { ifMatch: "v1_12345678" }, + ); + expect(capturedHeaders?.get("If-Match")).toBe("v1_12345678"); + }); + test("sends JSON body", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { @@ -293,7 +309,7 @@ describe("plapi", () => { ], }; - test("always sends include_secret_keys=true", async () => { + test("sends include_secret_keys=true by default", async () => { let requestedUrl = ""; stubFetch(async (input) => { requestedUrl = input.toString(); @@ -306,6 +322,19 @@ describe("plapi", () => { expect(url.searchParams.get("include_secret_keys")).toBe("true"); }); + test("omits include_secret_keys when the caller only needs public metadata", async () => { + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify(mockApp), { status: 200 }); + }); + + await fetchApplication("app_abc", { includeSecretKeys: false }); + const url = new URL(requestedUrl); + expect(url.pathname).toBe("/v1/platform/applications/app_abc"); + expect(url.searchParams.has("include_secret_keys")).toBe(false); + }); + test("returns parsed application JSON", async () => { stubFetch(async () => new Response(JSON.stringify(mockApp), { status: 200 })); @@ -313,6 +342,45 @@ describe("plapi", () => { expect(result).toEqual(mockApp); }); + test("rejects malformed application JSON", async () => { + stubFetch(async () => new Response("{", { status: 200 })); + + await expect(fetchApplication("app_abc")).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + message: "Clerk returned an invalid application response.", + }); + }); + + test.each([ + { name: "missing instances", body: { application_id: "app_abc" } }, + { + name: "non-array instances", + body: { application_id: "app_abc", instances: {} }, + }, + { + name: "a malformed instance", + body: { + application_id: "app_abc", + instances: [ + { + instance_id: "ins_1", + environment_type: "development", + publishable_key: 123, + }, + ], + }, + }, + ])("rejects $name in an application response", async ({ body }) => { + stubFetch(async () => Response.json(body)); + + await expect(fetchApplication("app_abc")).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + message: "Clerk returned an invalid application response.", + }); + }); + test("throws PlapiError on non-2xx response", async () => { stubFetch(async () => new Response("Not Found", { status: 404 })); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index ba8f80546..5d2404dbc 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -69,13 +69,22 @@ export async function getAuthToken(): Promise { * throws PlapiError on non-ok responses. Debug logging is centralized in * `loggedFetch`; don't add inline `log.debug` calls here or in callers. */ -async function plapiFetch(method: string, url: URL, init?: { body?: string }): Promise { +type PlapiFetchInit = { + body?: string; + idempotencyKey?: string; + /** Config version used for optimistic concurrency control. */ + ifMatch?: string; +}; + +async function plapiFetch(method: string, url: URL, init?: PlapiFetchInit): Promise { const token = await getAuthToken(); const headers: Record = { Authorization: `Bearer ${token}`, Accept: "application/json", }; if (init?.body) headers["Content-Type"] = "application/json"; + if (init?.idempotencyKey) headers["Idempotency-Key"] = init.idempotencyKey; + if (init?.ifMatch) headers["If-Match"] = init.ifMatch; const response = await loggedFetch(url, { tag: "plapi", method, @@ -161,6 +170,54 @@ export interface Application { instances: ApplicationInstance[]; } +function unexpectedApplicationResponse(): CliError { + return new CliError("Clerk returned an invalid application response.", { + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); +} + +function validateApplication(value: unknown): Application { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + throw unexpectedApplicationResponse(); + } + + const application = value as Record; + if ( + typeof application.application_id !== "string" || + (application.name !== undefined && typeof application.name !== "string") || + !Array.isArray(application.instances) + ) { + throw unexpectedApplicationResponse(); + } + + for (const value of application.instances) { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + throw unexpectedApplicationResponse(); + } + const instance = value as Record; + if ( + typeof instance.instance_id !== "string" || + typeof instance.environment_type !== "string" || + typeof instance.publishable_key !== "string" || + (instance.secret_key !== undefined && typeof instance.secret_key !== "string") + ) { + throw unexpectedApplicationResponse(); + } + } + + return value as Application; +} + +async function readApplicationResponse(response: Response): Promise { + let value: unknown; + try { + value = await response.json(); + } catch { + throw unexpectedApplicationResponse(); + } + return validateApplication(value); +} + export type DomainSummary = { id: string; name: string; @@ -229,11 +286,182 @@ export type TriggerDNSCheckResponse = DomainStatusResponse & { last_run_at: number | null; }; -export async function fetchApplication(applicationId: string): Promise { +export type NativeSettings = { + object: "native_settings"; + api_enabled: boolean; +}; + +function unexpectedNativeSettingsResponse(): CliError { + return new CliError("Clerk returned an invalid Native API settings response.", { + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); +} + +/** + * Validate Native settings at runtime. This is exported so callers that inject + * an API implementation in tests or integrations retain the same fail-closed + * behavior as the production HTTP client. + */ +export function validateNativeSettings(value: unknown): NativeSettings { + if ( + value == null || + typeof value !== "object" || + Array.isArray(value) || + (value as Record).object !== "native_settings" || + typeof (value as Record).api_enabled !== "boolean" + ) { + throw unexpectedNativeSettingsResponse(); + } + return value as NativeSettings; +} + +async function readNativeSettingsResponse(response: Response): Promise { + let value: unknown; + try { + value = await response.json(); + } catch { + throw unexpectedNativeSettingsResponse(); + } + return validateNativeSettings(value); +} + +export type IOSApplication = { + object: "ios_application"; + id: string; + app_id_prefix: string; + bundle_id: string; + created_at: number; + updated_at: number; +}; + +function unexpectedIOSApplicationResponse(): CliError { + return new CliError("Clerk returned an invalid iOS application response.", { + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); +} + +export function validateIOSApplication(value: unknown): IOSApplication { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + throw unexpectedIOSApplicationResponse(); + } + const item = value as Record; + if ( + item.object !== "ios_application" || + typeof item.id !== "string" || + typeof item.app_id_prefix !== "string" || + typeof item.bundle_id !== "string" || + typeof item.created_at !== "number" || + !Number.isFinite(item.created_at) || + typeof item.updated_at !== "number" || + !Number.isFinite(item.updated_at) + ) { + throw unexpectedIOSApplicationResponse(); + } + return value as IOSApplication; +} + +export function validateIOSApplications(value: unknown): IOSApplication[] { + if (!Array.isArray(value)) throw unexpectedIOSApplicationResponse(); + return value.map(validateIOSApplication); +} + +async function readIOSApplicationResponse(response: Response): Promise { + try { + return await response.json(); + } catch { + throw unexpectedIOSApplicationResponse(); + } +} + +export type CreateIOSApplicationParams = { + appIdPrefix: string; + bundleId: string; +}; + +export type IdempotentMutationOptions = { + /** Reuse this value when retrying the same mutation. */ + idempotencyKey: string; +}; + +export async function getNativeSettings( + applicationId: string, + envOrInstanceId: string, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${envOrInstanceId}/native_settings`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("GET", url); + return readNativeSettingsResponse(response); +} + +export async function enableNativeApi( + applicationId: string, + envOrInstanceId: string, + options?: IdempotentMutationOptions, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${envOrInstanceId}/native_settings`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("PATCH", url, { + body: JSON.stringify({ api_enabled: true }), + idempotencyKey: options?.idempotencyKey, + }); + return readNativeSettingsResponse(response); +} + +export async function listIOSApplications( + applicationId: string, + envOrInstanceId: string, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${envOrInstanceId}/native_applications/ios`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("GET", url); + return validateIOSApplications(await readIOSApplicationResponse(response)); +} + +export async function createIOSApplication( + applicationId: string, + envOrInstanceId: string, + params: CreateIOSApplicationParams, + options: IdempotentMutationOptions, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${envOrInstanceId}/native_applications/ios`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("POST", url, { + body: JSON.stringify({ + app_id_prefix: params.appIdPrefix, + bundle_id: params.bundleId, + }), + idempotencyKey: options.idempotencyKey, + }); + return validateIOSApplication(await readIOSApplicationResponse(response)); +} + +export interface FetchApplicationOptions { + /** + * Include instance secret keys in the response. This defaults to true for + * backwards compatibility; callers that only need publishable metadata + * should opt out so secret keys never enter their process. + */ + includeSecretKeys?: boolean; +} + +export async function fetchApplication( + applicationId: string, + options: FetchApplicationOptions = {}, +): Promise { const url = new URL(`/v1/platform/applications/${applicationId}`, getPlapiBaseUrl()); - url.searchParams.set("include_secret_keys", "true"); + if (options.includeSecretKeys !== false) { + url.searchParams.set("include_secret_keys", "true"); + } const response = await plapiFetch("GET", url); - return response.json() as Promise; + return readApplicationResponse(response); } export async function listApplicationDomains( @@ -277,12 +505,18 @@ export async function triggerApplicationDomainDNSCheck( return response.json() as Promise; } +export type InstanceConfigMutationOptions = { + destructive?: boolean; + dryRun?: boolean; + ifMatch?: string; +}; + async function sendInstanceConfig( method: "PUT" | "PATCH", applicationId: string, instanceId: string, config: Record, - options?: { destructive?: boolean; dryRun?: boolean }, + options?: InstanceConfigMutationOptions, ): Promise> { const url = new URL( `/v1/platform/applications/${applicationId}/instances/${instanceId}/config`, @@ -294,7 +528,10 @@ async function sendInstanceConfig( if (options?.dryRun) { url.searchParams.set("dry_run", "true"); } - const response = await plapiFetch(method, url, { body: JSON.stringify(config) }); + const response = await plapiFetch(method, url, { + body: JSON.stringify(config), + ifMatch: options?.ifMatch, + }); return response.json() as Promise>; } @@ -302,14 +539,14 @@ export const putInstanceConfig = async ( applicationId: string, instanceId: string, config: Record, - options?: { destructive?: boolean; dryRun?: boolean }, + options?: InstanceConfigMutationOptions, ) => sendInstanceConfig("PUT", applicationId, instanceId, config, options); export const patchInstanceConfig = async ( applicationId: string, instanceId: string, config: Record, - options?: { destructive?: boolean; dryRun?: boolean }, + options?: InstanceConfigMutationOptions, ) => sendInstanceConfig("PATCH", applicationId, instanceId, config, options); export async function createApplication(name: string): Promise {