From 4714cc41db4a634d01fb3a0c4cd58999dce261c0 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 2 Oct 2026 00:25:12 -0400 Subject: [PATCH 1/6] feat(native): support Apple registration APIs and deployment readiness --- .changeset/native-apple-api.md | 7 + .../cli-core/src/commands/api/index.test.ts | 2 + .../cli-core/src/commands/config/pull.test.ts | 8 +- .../cli-core/src/commands/config/push.test.ts | 8 +- .../src/commands/config/schema.test.ts | 8 +- .../src/commands/deploy/index.test.ts | 372 +++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 95 +++++ .../src/commands/deploy/providers.test.ts | 210 +++++++++- .../cli-core/src/commands/deploy/providers.ts | 78 +++- .../commands/deploy/status-command.test.ts | 16 +- .../src/commands/deploy/status-command.ts | 52 +++ .../src/commands/deploy/status.test.ts | 392 ++++++++++++++++++ .../cli-core/src/commands/deploy/status.ts | 194 +++++++-- .../src/commands/webhooks/relay-client.ts | 2 +- .../cli-core/src/lib/apple-native-identity.ts | 19 + .../cli-core/src/lib/credential-store.test.ts | 82 ++++ packages/cli-core/src/lib/credential-store.ts | 27 +- packages/cli-core/src/lib/errors.ts | 2 + .../cli-core/src/lib/plapi-native.test.ts | 265 ++++++++++++ packages/cli-core/src/lib/plapi.test.ts | 72 +++- packages/cli-core/src/lib/plapi.ts | 253 ++++++++++- 21 files changed, 2104 insertions(+), 60 deletions(-) create mode 100644 .changeset/native-apple-api.md create mode 100644 packages/cli-core/src/lib/apple-native-identity.ts create mode 100644 packages/cli-core/src/lib/plapi-native.test.ts diff --git a/.changeset/native-apple-api.md b/.changeset/native-apple-api.md new file mode 100644 index 000000000..781fb1b38 --- /dev/null +++ b/.changeset/native-apple-api.md @@ -0,0 +1,7 @@ +--- +"clerk": patch +--- + +Recognize native Sign in with Apple configuration in `clerk deploy` and `clerk deploy status`. Verify the registered Bundle ID and Native API settings before treating native Apple sign-in as ready, and avoid requesting unrelated Apple web credentials. Report missing or conflicting registrations with actionable guidance. + +Add validated native application API helpers, idempotent registration requests, and conditional configuration updates. Allow application metadata reads without secret keys and share concurrent OAuth token refreshes. diff --git a/packages/cli-core/src/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index e79f86ea3..995c1dafa 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -401,6 +401,7 @@ describe("api command", () => { { instance_id: "ins_dev", environment_type: "development", + publishable_key: "pk_test_fixture", secret_key: "sk_test_derived", }, ], @@ -434,6 +435,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 c094287c1..1f5d93af7 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), @@ -238,6 +242,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 }), @@ -272,6 +278,8 @@ describe("deploy", () => { mockFetchInstanceConfig.mockReset(); mockFetchInstanceConfigSchema.mockReset(); mockFetchApplication.mockReset(); + mockListIOSApplications.mockReset(); + mockGetNativeSettings.mockReset(); mockListApplicationDomains.mockReset(); mockCreateProductionInstance.mockReset(); mockGetApplicationDomainStatus.mockReset(); @@ -1377,6 +1385,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" }, @@ -1430,6 +1800,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 224742b68..82331abe5 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, @@ -700,6 +704,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(); @@ -734,6 +742,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 fc209e9f0..dd98a0062 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -43,8 +43,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 8cb522876..3fcf1956a 100644 --- a/packages/cli-core/src/commands/deploy/status-command.ts +++ b/packages/cli-core/src/commands/deploy/status-command.ts @@ -253,6 +253,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.` @@ -285,3 +296,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 3d659ba37..38d2a6402 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -8,6 +8,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(); @@ -16,6 +18,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), @@ -62,14 +66,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(); @@ -169,6 +221,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", () => { @@ -409,6 +775,32 @@ describe("resolveLiveDeploySnapshot records each read as it succeeds", () => { ); } + test("records verified native Apple registration as OAuth complete without web credentials", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => ({ + connection_oauth_apple: { + enabled: true, + authenticatable: true, + ...(instanceId === "ins_prod" && { 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, + }, + ]); + + const { payload, error } = await resolved(); + + expect(error).toBeUndefined(); + expect(payload.components).toEqual({ dns: true, ssl: true, mail: true, oauth: true }); + }); + test("a failed configuration read keeps what the domain read observed", async () => { mockConfigReads(serverError()); mockGetApplicationDomainStatus.mockResolvedValue(completeStatus); diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index b80b69fbc..e8021bac5 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"; @@ -76,6 +79,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; @@ -88,6 +102,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 @@ -118,13 +133,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< @@ -148,6 +169,7 @@ export type LiveDeploySnapshot = Omit< live: boolean; unsupportedOAuthProviderCount: number; unsupportedOAuthProviders: string[]; + nativeAppleReadinessIssue?: NativeAppleReadinessIssue; }; export type DeployState = @@ -289,21 +311,57 @@ export async function resolveLiveDeploySnapshot( const { descriptors: oauthProviderDescriptors, unsupported } = oauth; const oauthProviders = oauthProviderDescriptors.map((descriptor) => descriptor.provider); - const completedProvidersIn = (config: Record): OAuthProvider[] => - oauthProviderDescriptors - .filter((descriptor) => hasProviderRequiredCredentials(config, descriptor)) - .map((descriptor) => descriptor.provider); - - const { productionConfig, deployStatus, live } = await withSpinner( - "Reading production configuration...", - async () => { + const { nativeAppleConfiguration, completedOAuthProviders, deployStatus, live } = + await withSpinner("Reading production configuration...", async () => { const configRead = Promise.resolve(fetchInstanceConfig(ctx.appId, productionInstanceId)).then( - (config) => { - recordOAuthObservation({ - oauthProviders, - completedOAuthProviders: completedProvidersIn(config), - }); - return config; + async (config) => { + const nativeAppleDescriptor = oauthProviderDescriptors.find( + (descriptor) => descriptor.provider === "apple", + ); + const preliminaryNativeAppleConfiguration = nativeAppleDescriptor + ? inspectNativeAppleConfiguration(config, 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( + config, + 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(config, descriptor) || + (descriptor.provider === "apple" && nativeAppleConfiguration?.status === "ready"), + ) + .map((descriptor) => descriptor.provider); + recordOAuthObservation({ oauthProviders, completedOAuthProviders }); + return { nativeAppleConfiguration, completedOAuthProviders }; }, ); const statusRead = loadInitialDeployStatus(ctx.appId, domain.id, options).then((read) => { @@ -315,14 +373,12 @@ export async function resolveLiveDeploySnapshot( // dropped — never wrong, just absent. Waiting for the slower read to // settle would hold a real error behind a hanging request, and nothing // bounds how long that is. - const [productionConfig, { status: deployStatus, live }] = await Promise.all([ + const [oauthReadiness, { status: deployStatus, live }] = await Promise.all([ configRead, statusRead, ]); - return { productionConfig, deployStatus, live }; - }, - ); - const completedOAuthProviders = completedProvidersIn(productionConfig); + return { ...oauthReadiness, deployStatus, live }; + }); const pendingOAuthDescriptor = oauthProviderDescriptors.find( (descriptor) => !completedOAuthProviders.includes(descriptor.provider), ); @@ -342,6 +398,16 @@ export async function resolveLiveDeploySnapshot( live, unsupportedOAuthProviderCount: unsupported.length, unsupportedOAuthProviders: unsupported, + ...(nativeAppleConfiguration && + "bundleId" in nativeAppleConfiguration && + isNativeAppleReadinessIssue(nativeAppleConfiguration.status) + ? { + nativeAppleReadinessIssue: { + bundleId: nativeAppleConfiguration.bundleId, + reason: nativeAppleConfiguration.status, + }, + } + : {}), }; const domainComplete = deployStatus.status === "complete"; @@ -471,6 +537,7 @@ function buildDeployStatusFacts( pending: oauthPending, unsupported: [...snapshot.unsupportedOAuthProviders], }, + nativeAppleReadinessIssue: snapshot.nativeAppleReadinessIssue, urls: snapshot.productionInstanceId ? dashboardUrls(snapshot.appId, snapshot.productionInstanceId) : null, @@ -533,6 +600,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; @@ -548,11 +616,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 @@ -604,6 +723,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 ( @@ -616,7 +748,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 @@ -625,7 +760,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 @@ -633,13 +771,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/webhooks/relay-client.ts b/packages/cli-core/src/commands/webhooks/relay-client.ts index 192cb623a..ab41ef5cf 100644 --- a/packages/cli-core/src/commands/webhooks/relay-client.ts +++ b/packages/cli-core/src/commands/webhooks/relay-client.ts @@ -153,7 +153,7 @@ export class RelayClient implements IRelayClient { this.probeTimer = setInterval(() => { if (Date.now() - this.lastActivityAt < RELAY_SILENCE_TIMEOUT_MS) return; try { - ws.ping(); + (ws as import("bun").WebSocket).ping(); this.lastActivityAt = Date.now(); } catch { ws.close(); diff --git a/packages/cli-core/src/lib/apple-native-identity.ts b/packages/cli-core/src/lib/apple-native-identity.ts new file mode 100644 index 000000000..3e7645166 --- /dev/null +++ b/packages/cli-core/src/lib/apple-native-identity.ts @@ -0,0 +1,19 @@ +/** + * Apple treats Bundle IDs as case-insensitive. Keep the original spelling for + * display and API writes, but use this ASCII-only identity form anywhere a + * Bundle ID participates in matching or persisted retry identity. + */ +export function normalizeBundleIdentifierIdentity(bundleIdentifier: string): string { + return bundleIdentifier.replace(/[A-Z]/g, (character) => character.toLowerCase()); +} + +export function bundleIdentifiersEqual( + left: string | undefined, + right: string | undefined, +): boolean { + return ( + left != null && + right != null && + normalizeBundleIdentifierIdentity(left) === normalizeBundleIdentifierIdentity(right) + ); +} diff --git a/packages/cli-core/src/lib/credential-store.test.ts b/packages/cli-core/src/lib/credential-store.test.ts index 8e94c2d63..d7d7b584c 100644 --- a/packages/cli-core/src/lib/credential-store.test.ts +++ b/packages/cli-core/src/lib/credential-store.test.ts @@ -192,6 +192,88 @@ describe("credential-store", () => { }); }); + test("getValidToken coalesces concurrent refreshes in one process", async () => { + const session = { + accessToken: "expired-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() - 60_000, + tokenType: "Bearer", + }; + await storeToken(session); + + let releaseRefresh: + | ((value: { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + }) => void) + | null = null; + let markRefreshStarted: (() => void) | null = null; + const refreshStarted = new Promise((resolve) => { + markRefreshStarted = resolve; + }); + const refreshResult = new Promise<{ + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + }>((resolve) => { + releaseRefresh = resolve; + }); + mockRefreshAccessToken.mockImplementation(() => { + markRefreshStarted!(); + return refreshResult; + }); + + const first = getValidToken(); + const second = getValidToken(); + + await refreshStarted; + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + + releaseRefresh!({ + access_token: "refreshed-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rotated-refresh-token", + }); + + expect(await Promise.all([first, second])).toEqual([ + "refreshed-access-token", + "refreshed-access-token", + ]); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + }); + + test("concurrent callers share invalid_grant recovery from another process", async () => { + const session = { + accessToken: "expired-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() - 60_000, + tokenType: "Bearer", + }; + const refreshedSession = { + accessToken: "other-process-access-token", + refreshToken: "other-process-refresh-token", + expiresAt: Date.now() + 60_000, + tokenType: "Bearer", + }; + await storeToken(session); + + mockRefreshAccessToken.mockImplementation(async () => { + await storeToken(refreshedSession); + throw new ApiError(400, "invalid_grant"); + }); + + expect(await Promise.all([getValidToken(), getValidToken()])).toEqual([ + "other-process-access-token", + "other-process-access-token", + ]); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + expect(await getStoredSession()).toEqual(refreshedSession); + }); + test("getValidToken recovers from a concurrent refresh race when another process completes the refresh first (invalid_grant)", async () => { const session = { accessToken: "expired-access-token", diff --git a/packages/cli-core/src/lib/credential-store.ts b/packages/cli-core/src/lib/credential-store.ts index 1cdabf2cc..d22c38706 100644 --- a/packages/cli-core/src/lib/credential-store.ts +++ b/packages/cli-core/src/lib/credential-store.ts @@ -453,6 +453,7 @@ export async function storeToken(value: OAuthSession): Promise { } let tokenOverride: string | null | undefined; +let validTokenPromise: Promise | undefined; /** Test-only: override getToken() result. Pass undefined to clear. */ export function _setTokenOverride(value: string | null | undefined): void { @@ -486,7 +487,7 @@ export async function hasAccountCredentials(): Promise { return hasStoredCredentials(); } -export async function getValidToken(): Promise { +async function resolveValidToken(): Promise { const session = await getStoredSession(); if (!session) { if (await hasStoredCredentials()) { @@ -498,6 +499,30 @@ export async function getValidToken(): Promise { return getValidAccessToken(session); } +/** + * Resolve a usable OAuth access token once per process at a time. + * + * A refresh token rotates when it is redeemed. Without this in-flight guard, + * concurrent API calls can both read the same expired session and attempt to + * refresh it: one succeeds while the other receives `invalid_grant`. The + * cross-process recovery in `refreshStoredSession` still handles a different + * CLI process winning that race; this guard prevents the race between callers + * that already share this module instance. + */ +export async function getValidToken(): Promise { + if (validTokenPromise) return validTokenPromise; + + const pending = resolveValidToken(); + validTokenPromise = pending; + try { + return await pending; + } finally { + if (validTokenPromise === pending) { + validTokenPromise = undefined; + } + } +} + export async function deleteToken(): Promise { await keyringDelete(); await fileDelete(); diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 6d7eb9209..54adaffd3 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -139,6 +139,8 @@ export const ERROR_CODE = { OAUTH_NO_CODE: "oauth_no_code", /** The loopback callback server could not bind a local port. */ CALLBACK_BIND_FAILED: "callback_bind_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..a86835441 --- /dev/null +++ b/packages/cli-core/src/lib/plapi-native.test.ts @@ -0,0 +1,265 @@ +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"); + +function captureNativeRequests(responseBody: unknown): Request[] { + const requests: Request[] = []; + stubFetch(async (input, init) => { + requests.push( + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init), + ); + return Response.json(responseBody); + }); + return requests; +} + +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 () => { + const responseBody = { object: "native_settings" as const, api_enabled: false }; + const requests = captureNativeRequests(responseBody); + + const result = await getNativeSettings("app_abc", "development"); + + expect(requests).toHaveLength(1); + const request = requests[0]!; + expect(request.method).toBe("GET"); + expect(request.url).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/development/native_settings", + ); + expect(request.headers.get("Authorization")).toBe("Bearer ak_test_client_token"); + expect(request.headers.get("Accept")).toBe("application/json"); + expect(request.headers.has("Idempotency-Key")).toBe(false); + expect(result).toEqual(responseBody); + }); + + test("enables Native API with an idempotency key", async () => { + const responseBody = { object: "native_settings" as const, api_enabled: true }; + const requests = captureNativeRequests(responseBody); + + const result = await enableNativeApi("app_abc", "ins_dev_123", { + idempotencyKey: "enable-native-api-123", + }); + + expect(requests).toHaveLength(1); + const request = requests[0]!; + expect(request.method).toBe("PATCH"); + expect(request.url).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/ins_dev_123/native_settings", + ); + expect(await request.json()).toEqual({ api_enabled: true }); + expect(request.headers.get("Content-Type")).toBe("application/json"); + expect(request.headers.get("Idempotency-Key")).toBe("enable-native-api-123"); + expect(result).toEqual(responseBody); + }); + + describe.each([ + { method: "GET", request: () => getNativeSettings("app_abc", "development") }, + { + method: "PATCH", + request: () => + enableNativeApi("app_abc", "development", { idempotencyKey: "enable-native-api-123" }), + }, + ])("Native settings $method", ({ request }) => { + 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", async ({ body }) => { + stubFetch(async () => Response.json(body)); + await expect(request()).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + }); + + test("lists the public iOS application DTOs", async () => { + 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", + }, + ]; + const requests = captureNativeRequests(responseBody); + + const result = await listIOSApplications("app_abc", "ins_dev_123"); + + expect(requests).toHaveLength(1); + const request = requests[0]!; + expect(request.method).toBe("GET"); + expect(request.url).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 () => { + 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, + }; + const requests = captureNativeRequests(responseBody); + + const result = await createIOSApplication( + "app_abc", + "development", + { appIdPrefix: "ABCD123456", bundleId: "com.example.coolappy" }, + { idempotencyKey: "create-ios-app-123" }, + ); + + expect(requests).toHaveLength(1); + const request = requests[0]!; + expect(request.method).toBe("POST"); + expect(request.url).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/development/native_applications/ios", + ); + expect(await request.json()).toEqual({ + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + }); + expect(request.headers.get("Content-Type")).toBe("application/json"); + expect(request.headers.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 { From fee46aa96a675ceda6bb5d0142cfc5396f5267ed Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 2 Oct 2026 01:12:06 -0400 Subject: [PATCH 2/6] fix(doctor): recognize valid account credentials across authentication methods --- .changeset/native-apple-api.md | 2 + .../cli-core/src/commands/doctor/README.md | 4 +- .../cli-core/src/commands/doctor/checks.ts | 94 ++++++- .../src/commands/doctor/context.test.ts | 49 +++- .../cli-core/src/commands/doctor/context.ts | 33 ++- .../src/commands/doctor/doctor.test.ts | 231 +++++++++++++++++- .../cli-core/src/commands/doctor/types.ts | 10 + 7 files changed, 402 insertions(+), 21 deletions(-) diff --git a/.changeset/native-apple-api.md b/.changeset/native-apple-api.md index 781fb1b38..adc65d885 100644 --- a/.changeset/native-apple-api.md +++ b/.changeset/native-apple-api.md @@ -5,3 +5,5 @@ Recognize native Sign in with Apple configuration in `clerk deploy` and `clerk deploy status`. Verify the registered Bundle ID and Native API settings before treating native Apple sign-in as ready, and avoid requesting unrelated Apple web credentials. Report missing or conflicting registrations with actionable guidance. Add validated native application API helpers, idempotent registration requests, and conditional configuration updates. Allow application metadata reads without secret keys and share concurrent OAuth token refreshes. + +Recognize Platform API keys in Doctor's account checks and verify account access through the Clerk API when the OAuth userinfo check is unavailable for otherwise valid credentials. diff --git a/packages/cli-core/src/commands/doctor/README.md b/packages/cli-core/src/commands/doctor/README.md index 2c3e1549a..7487a076e 100644 --- a/packages/cli-core/src/commands/doctor/README.md +++ b/packages/cli-core/src/commands/doctor/README.md @@ -27,8 +27,8 @@ clerk doctor --fix # Offer to auto-fix issues | Check | Category | What it verifies | | --------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Authentication token | Authentication | Credential store has a stored token | -| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | +| Account credentials | Authentication | Credential store has a session or a Platform API key is configured | +| Token validity | Authentication | OAuth access is verified through `/oauth/userinfo` or an account-scoped application-list fallback; Platform API-key access uses the same read-only application-list request | | Project linkage | Project | Current directory is linked to a Clerk app | | Linked application | Project | Linked application ID is accessible via the API | | Instances | Project | Configured dev/prod instance IDs match the application's instances | diff --git a/packages/cli-core/src/commands/doctor/checks.ts b/packages/cli-core/src/commands/doctor/checks.ts index 9b80b3617..f75d1b93b 100644 --- a/packages/cli-core/src/commands/doctor/checks.ts +++ b/packages/cli-core/src/commands/doctor/checks.ts @@ -2,10 +2,9 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { getConfigFile } from "../../lib/config.ts"; import { fetchUserInfo } from "../../lib/token-exchange.ts"; -import { errorMessage, isAuthError, PlapiError } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, errorMessage, isAuthError, PlapiError } from "../../lib/errors.ts"; import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts"; import { parseEnvFile } from "../../lib/dotenv.ts"; -import { hasAccountCredentials } from "../../lib/credential-store.ts"; import type { KeylessTarget } from "../../lib/keyless-target.ts"; import { CURRENT_VERSION, IS_DEV_BUILD } from "../../lib/version.ts"; import { @@ -94,15 +93,33 @@ async function claimHint(ctx: DoctorContext): Promise { export async function checkLoggedIn(ctx: DoctorContext): Promise { const check = defineCheck(CHECK_NAME.loggedIn, ctx.fixes.login); - const token = await ctx.getToken(); // Malformed-key detection is a side effect of resolving the keyless target // (see getKeylessKeyError), so resolve it before any early return — a - // stored account token must not hide a broken local CLERK_SECRET_KEY that - // other commands still prefer over the account session. + // Platform API key or stored account token must not hide a broken local + // CLERK_SECRET_KEY that other commands still prefer over account credentials. const keyless = await ctx.getKeylessTarget(); const keyError = await ctx.getKeylessKeyError(); + if (ctx.hasPlatformAPIKey()) { + if (keyError) { + return check.warn( + `Platform API key configured, but the local secret key is unusable: ${keyError.message}`, + { + remedy: + "Fix or remove the malformed secret key — some commands prefer it over account credentials.", + fixable: false, + }, + ); + } + return check.pass("Platform API key configured"); + } + + // Only consult OAuth credential storage when a Platform API key is not + // configured. The Platform key is sufficient for Doctor, and an unreadable + // fallback credential store must not make an otherwise valid setup fail. + const token = await ctx.getToken(); + if (token) { if (keyError) { return check.warn(`Logged in, but the local secret key is unusable: ${keyError.message}`, { @@ -159,8 +176,36 @@ export async function checkHostExecution(): Promise { export async function checkTokenValid(ctx: DoctorContext): Promise { const check = defineCheck(CHECK_NAME.tokenValid, ctx.fixes.login); + if (ctx.hasPlatformAPIKey()) { + try { + await ctx.verifyAccountAccess(); + return check.pass("Platform API key access verified"); + } catch (error) { + if ( + isAuthError(error) || + (error instanceof CliError && error.code === ERROR_CODE.INVALID_KEY_FORMAT) + ) { + return check.fail("Platform API key is invalid or lacks applications:read access", { + remedy: + "Replace CLERK_PLATFORM_API_KEY with a valid key that has applications:read access, then rerun `clerk doctor`.", + fixable: false, + }); + } + return check.warn("Could not reach Clerk to verify the Platform API key", { + detail: errorMessage(error), + remedy: "Check your network connection and rerun `clerk doctor`.", + fixable: false, + }); + } + } const storedToken = await ctx.getToken(); if (!storedToken) { + if (await ctx.hasAccountCredentials()) { + return check.warn("Account credentials are configured but could not be verified", { + remedy: "Check your Clerk authentication and rerun `clerk doctor`.", + fixable: false, + }); + } const keyless = await ctx.getKeylessTarget(); return keyless ? check.pass("No account session — not required for this accountless application") @@ -174,6 +219,37 @@ export async function checkTokenValid(ctx: DoctorContext): Promise return check.pass(`Authenticated as ${userInfo.email}`); } catch (error) { if (isAuthError(error)) { + // The OAuth userinfo surface is not available in every environment that + // can accept the same account credential through PLAPI. Verify it with + // an account-scoped application-list request: unlike getApplication(), + // this does not depend on the current directory being linked or its + // linked application continuing to exist. + try { + await ctx.verifyAccountAccess(); + return check.pass("Account access verified through the Clerk API"); + } catch (verificationError) { + if (!isAuthError(verificationError)) { + if (verificationError instanceof PlapiError) { + const unavailable = verificationError.status === 404 ? "endpoint" : "API"; + return check.warn( + `Could not verify authentication — Clerk ${unavailable} unavailable`, + { + detail: errorMessage(verificationError), + remedy: + "Check the Clerk environment and service status, then rerun `clerk doctor`.", + fixable: false, + }, + ); + } + + return check.warn("Could not reach Clerk to verify authentication — network issue", { + detail: errorMessage(verificationError), + remedy: "Check your network connection, then rerun `clerk doctor`.", + fixable: false, + }); + } + } + // Same fallback whoami uses: an expired session doesn't strand a keyless // project, so don't tell the user their setup is broken. const keyless = await ctx.getKeylessTarget(); @@ -230,7 +306,7 @@ export async function checkProjectLinked(ctx: DoctorContext): Promise { const check = defineCheck(CHECK_NAME.linkedAppExists, ctx.fixes.link); - const token = await ctx.getToken(); - if (!token) { + if (!(await ctx.hasAccountCredentials())) { // This check is account-only — the Platform API application record has no // keyless equivalent — so an unclaimed keyless project has nothing to skip // *over*, just nothing to verify. @@ -287,8 +362,7 @@ export async function checkLinkedAppExists(ctx: DoctorContext): Promise { const check = defineCheck(CHECK_NAME.instances, ctx.fixes.link); - const token = await ctx.getToken(); - if (!token) { + if (!(await ctx.hasAccountCredentials())) { // A linked profile's dev/prod instance IDs are an account-only concept — // the secret key on disk already addresses its one instance directly. const keyless = await ctx.getKeylessTarget(); diff --git a/packages/cli-core/src/commands/doctor/context.test.ts b/packages/cli-core/src/commands/doctor/context.test.ts index 878aa731f..6fbf07d0d 100644 --- a/packages/cli-core/src/commands/doctor/context.test.ts +++ b/packages/cli-core/src/commands/doctor/context.test.ts @@ -38,7 +38,7 @@ mock.module("../../lib/bapi.ts", () => ({ })); // stubFetch instead of mock.module for plapi — mock.module leaks globally in Bun -let mockAppResponse: Application | null = null; +let mockAppResponse: Application | Application[] | null = null; let mockAppError: Error | null = null; const mockFetch = mock(); @@ -95,6 +95,37 @@ describe("createDoctorContext", () => { }); }); + describe("verifyAccountAccess", () => { + test("performs one memoized read-only application-list request, including for an empty list", async () => { + mockAppResponse = []; + + const ctx = createDoctorContext(); + const p1 = ctx.verifyAccountAccess(); + const p2 = ctx.verifyAccountAccess(); + + expect(p1).toBe(p2); + await expect(p1).resolves.toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(String(mockFetch.mock.calls[0]?.[0])).toBe( + "https://api.clerk.com/v1/platform/applications", + ); + expect(mockFetch.mock.calls[0]?.[1]).toMatchObject({ method: "GET" }); + }); + + test("memoizes a failed verification request", async () => { + mockAppError = new TypeError("fetch failed"); + + const ctx = createDoctorContext(); + const p1 = ctx.verifyAccountAccess(); + const p2 = ctx.verifyAccountAccess(); + + expect(p1).toBe(p2); + await expect(p1).rejects.toThrow("fetch failed"); + await expect(p2).rejects.toThrow("fetch failed"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + describe("getProfile", () => { test("returns the same promise on repeated calls", async () => { const profile = { @@ -135,6 +166,7 @@ describe("createDoctorContext", () => { }); test("returns null when no token", async () => { + delete process.env.CLERK_PLATFORM_API_KEY; mockGetToken.mockResolvedValue(null); const ctx = createDoctorContext(); @@ -144,6 +176,21 @@ describe("createDoctorContext", () => { expect(mockFetch).not.toHaveBeenCalled(); }); + test("fetches the public application shape with a Platform API key", async () => { + mockGetToken.mockResolvedValue(null); + mockResolveProfile.mockResolvedValue({ + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote" as const, + }); + mockAppResponse = { application_id: "app_1", name: "My App", instances: [] }; + + const ctx = createDoctorContext(); + expect(await ctx.getApplication()).toEqual(mockAppResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(String(mockFetch.mock.calls[0]?.[0])).not.toContain("include_secret_keys"); + }); + test("returns null when no profile", async () => { mockGetToken.mockResolvedValue("test_token"); mockResolveProfile.mockResolvedValue(undefined); diff --git a/packages/cli-core/src/commands/doctor/context.ts b/packages/cli-core/src/commands/doctor/context.ts index 60dbfef1e..28112f360 100644 --- a/packages/cli-core/src/commands/doctor/context.ts +++ b/packages/cli-core/src/commands/doctor/context.ts @@ -1,6 +1,6 @@ -import { getToken, getValidToken } from "../../lib/credential-store.ts"; +import { getToken, getValidToken, hasAccountCredentials } from "../../lib/credential-store.ts"; import { resolveProfile } from "../../lib/config.ts"; -import { fetchApplication, type Application } from "../../lib/plapi.ts"; +import { fetchApplication, listApplications, type Application } from "../../lib/plapi.ts"; import { resolveKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts"; import { peekKeylessBreadcrumb } from "../../lib/keyless.ts"; import { bapiRequest } from "../../lib/bapi.ts"; @@ -17,6 +17,8 @@ import type { DoctorContext, KeylessInstanceInfo, ResolvedProfile } from "./type export function createDoctorContext(): DoctorContext { let tokenPromise: Promise | undefined; + let accountCredentialsPromise: Promise | undefined; + let accountAccessVerificationPromise: Promise | undefined; let validTokenPromise: Promise | undefined; let profilePromise: Promise | undefined; let appPromise: Promise | undefined; @@ -26,6 +28,24 @@ export function createDoctorContext(): DoctorContext { let keylessKeyError: CliError | undefined; const ctx: DoctorContext = { + hasPlatformAPIKey() { + return Boolean(process.env.CLERK_PLATFORM_API_KEY); + }, + + hasAccountCredentials() { + if (!accountCredentialsPromise) { + accountCredentialsPromise = hasAccountCredentials(); + } + return accountCredentialsPromise; + }, + + verifyAccountAccess() { + if (!accountAccessVerificationPromise) { + accountAccessVerificationPromise = listApplications().then(() => undefined); + } + return accountAccessVerificationPromise; + }, + getToken() { if (!tokenPromise) { tokenPromise = getToken(); @@ -50,11 +70,14 @@ export function createDoctorContext(): DoctorContext { getApplication() { if (!appPromise) { appPromise = (async () => { - const token = await ctx.getToken(); - if (!token) return null; + if (!(await ctx.hasAccountCredentials())) return null; const resolved = await ctx.getProfile(); if (!resolved) return null; - return fetchApplication(resolved.profile.appId); + // Doctor only needs application and instance identity. Keeping + // secret keys out of this long-lived, shared diagnostic context + // prevents unrelated checks from retaining credentials they never + // use (including the iOS checks below). + return fetchApplication(resolved.profile.appId, { includeSecretKeys: false }); })(); } return appPromise; diff --git a/packages/cli-core/src/commands/doctor/doctor.test.ts b/packages/cli-core/src/commands/doctor/doctor.test.ts index b8022f10e..4bb8d143a 100644 --- a/packages/cli-core/src/commands/doctor/doctor.test.ts +++ b/packages/cli-core/src/commands/doctor/doctor.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { ApiError, AuthError, CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { ApiError, AuthError, CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; import { _setConfigDir } from "../../lib/config.ts"; import { credentialStoreStubs, @@ -105,13 +105,27 @@ function createMockContext( }; application?: Application | null; applicationError?: Error; + accountAccess?: boolean; + accountAccessError?: Error; keylessTarget?: KeylessTarget; keylessInstance?: KeylessInstanceInfo | null; claimBreadcrumb?: boolean; keylessKeyError?: CliError; + accountCredentials?: boolean; + platformAPIKey?: boolean; + platformAPIKeyError?: Error; } = {}, ): DoctorContext { return { + hasPlatformAPIKey: () => overrides.platformAPIKey ?? false, + hasAccountCredentials: async () => overrides.accountCredentials ?? overrides.token != null, + verifyAccountAccess: async () => { + if (overrides.accountAccessError) throw overrides.accountAccessError; + if (overrides.platformAPIKeyError) throw overrides.platformAPIKeyError; + if (!overrides.platformAPIKey && !overrides.accountAccess) { + throw new ApiError(401, "Unauthorized"); + } + }, getToken: async () => overrides.token ?? null, getValidToken: async () => { if (overrides.validToken instanceof Error) throw overrides.validToken; @@ -212,6 +226,45 @@ describe("checkLoggedIn", () => { expectCheck(result, { name: "Logged in", status: "pass", message: "Logged in" }); }); + test("pass when a Platform API key is configured without an OAuth token", async () => { + const ctx = createMockContext({ + token: null, + accountCredentials: true, + platformAPIKey: true, + }); + const result = await checkLoggedIn(ctx); + expectCheck(result, { + name: "Logged in", + status: "pass", + message: "Platform API key configured", + }); + }); + + test("does not read OAuth credentials when a Platform API key is configured", async () => { + const getToken = mock(async () => { + throw new Error("credential store is unavailable"); + }); + const ctx: DoctorContext = { + ...createMockContext({ + platformAPIKey: true, + keylessKeyError: new CliError("not a secret key", { + code: ERROR_CODE.INVALID_KEY_FORMAT, + }), + }), + getToken, + }; + + const result = await checkLoggedIn(ctx); + + expectCheck(result, { + name: "Logged in", + status: "warn", + message: ["Platform API key configured", "local secret key is unusable", "not a secret key"], + remedy: "Fix or remove the malformed secret key", + }); + expect(getToken).not.toHaveBeenCalled(); + }); + test("fail when no token", async () => { const ctx = createMockContext({ token: null }); const result = await checkLoggedIn(ctx); @@ -317,6 +370,89 @@ describe("checkHostExecution", () => { }); describe("checkTokenValid", () => { + test("passes when a read-only request verifies the Platform API key", async () => { + const ctx = createMockContext({ + token: null, + accountCredentials: true, + platformAPIKey: true, + }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "pass", + message: "Platform API key access verified", + }); + }); + + test("does not validate stale OAuth when PLAPI will use a Platform API key", async () => { + const ctx = createMockContext({ + token: "stale-oauth-token", + validToken: new AuthError({ reason: "session_expired" }), + accountCredentials: true, + platformAPIKey: true, + }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "pass", + message: "Platform API key access verified", + }); + }); + + for (const status of [401, 403]) { + test(`fails when the Platform API rejects the key with ${status}`, async () => { + const ctx = createMockContext({ + platformAPIKey: true, + platformAPIKeyError: new ApiError(status, "Unauthorized"), + }); + + const result = await checkTokenValid(ctx); + + expectCheck(result, { + name: "Authentication valid", + status: "fail", + message: ["Platform API key", "invalid", "applications:read"], + remedy: "CLERK_PLATFORM_API_KEY", + fix: false, + }); + }); + } + + test("warns when a network failure prevents Platform API key verification", async () => { + const ctx = createMockContext({ + platformAPIKey: true, + platformAPIKeyError: new TypeError("fetch failed"), + }); + + const result = await checkTokenValid(ctx); + + expectCheck(result, { + name: "Authentication valid", + status: "warn", + message: "Could not reach Clerk", + detail: "fetch failed", + remedy: "network connection", + fix: false, + }); + }); + + test("warns when Clerk is unavailable during Platform API key verification", async () => { + const ctx = createMockContext({ + platformAPIKey: true, + platformAPIKeyError: new ApiError(503, "Service unavailable"), + }); + + const result = await checkTokenValid(ctx); + + expectCheck(result, { + name: "Authentication valid", + status: "warn", + message: "Could not reach Clerk", + detail: "Service unavailable", + fix: false, + }); + }); + test("pass with valid token", async () => { mockUserInfo = { userId: "user_1", email: "dev@example.com" }; const ctx = createMockContext({ token: "test_token" }); @@ -341,6 +477,94 @@ describe("checkTokenValid", () => { }); }); + test("passes when userinfo rejects a credential that the account-scoped API accepts", async () => { + mockUserInfoError = new ApiError(401, "Unauthorized"); + const ctx = createMockContext({ token: "local_token", accountAccess: true }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "pass", + message: "verified through the Clerk API", + }); + }); + + for (const status of [401, 403]) { + test(`fails when both userinfo and the account-scoped API reject the credential with ${status}`, async () => { + mockUserInfoError = new ApiError(401, "Unauthorized"); + const ctx = createMockContext({ + token: "expired_token", + accountAccessError: new ApiError(status, "Unauthorized"), + }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "fail", + message: "expired or invalid", + remedy: "clerk auth login", + fix: true, + }); + }); + } + + test("does not mislabel a missing account verification endpoint as an expired token", async () => { + mockUserInfoError = new ApiError(401, "Unauthorized"); + const ctx = createMockContext({ + token: "local_token", + accountAccessError: PlapiError.fromBody(404, "Not found"), + }); + + const result = await checkTokenValid(ctx); + + expectCheck(result, { + name: "Authentication valid", + status: "warn", + message: ["Could not verify authentication", "endpoint unavailable"], + messageNot: "expired", + detail: "Not found", + remedy: "Clerk environment", + fix: false, + }); + }); + + test("reports a Clerk API outage without mislabeling the token as expired", async () => { + mockUserInfoError = new ApiError(401, "Unauthorized"); + const ctx = createMockContext({ + token: "local_token", + accountAccessError: PlapiError.fromBody(503, "Service unavailable"), + }); + + const result = await checkTokenValid(ctx); + + expectCheck(result, { + name: "Authentication valid", + status: "warn", + message: ["Could not verify authentication", "API unavailable"], + messageNot: "expired", + detail: "Service unavailable", + fix: false, + }); + }); + + test("reports a fallback transport failure as a network issue", async () => { + mockUserInfoError = new ApiError(401, "Unauthorized"); + const ctx = createMockContext({ + token: "local_token", + accountAccessError: new TypeError("fetch failed"), + }); + + const result = await checkTokenValid(ctx); + + expectCheck(result, { + name: "Authentication valid", + status: "warn", + message: ["Could not reach Clerk", "network issue"], + messageNot: "expired", + detail: "fetch failed", + remedy: "network connection", + fix: false, + }); + }); + test("fail when refreshing the stored session requires re-authentication", async () => { const ctx = createMockContext({ token: "expired_token", @@ -440,9 +664,10 @@ describe("checkProjectLinked", () => { }); test("warn (not fail) when signed in but unlinked, falling back to a keyless application", async () => { - // beforeEach sets CLERK_PLATFORM_API_KEY, so hasAccountCredentials() is true here — - // the directory *could* reach the full account configuration by linking. + // The directory has account credentials, so it could reach the full + // account configuration by linking. const ctx = createMockContext({ + accountCredentials: true, keylessTarget: mockKeylessTarget, keylessInstance: mockKeylessInstance, }); diff --git a/packages/cli-core/src/commands/doctor/types.ts b/packages/cli-core/src/commands/doctor/types.ts index eadf7b331..57f7c5d3d 100644 --- a/packages/cli-core/src/commands/doctor/types.ts +++ b/packages/cli-core/src/commands/doctor/types.ts @@ -65,6 +65,16 @@ export interface KeylessInstanceInfo { } export interface DoctorContext { + /** PLAPI prefers this credential over any stored OAuth session. */ + hasPlatformAPIKey(): boolean; + /** OAuth or Platform API-key presence; does not perform a network request. */ + hasAccountCredentials(): Promise; + /** + * Read-only, memoized, account-scoped Platform API request used to verify + * either the stored OAuth session or a configured Platform API key without + * depending on this project's link state. + */ + verifyAccountAccess(): Promise; getToken(): Promise; getValidToken(): Promise; getProfile(): Promise; From 37c48376feaf346739d85bd3459902c2ac08fbf4 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 2 Oct 2026 08:58:27 -0400 Subject: [PATCH 3/6] fix(deploy): preserve hosted Apple setup and readiness guidance --- .changeset/native-apple-api.md | 2 +- .../src/commands/deploy/index.test.ts | 65 +++++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 34 +++++++++- .../cli-core/src/commands/deploy/prompts.ts | 7 ++ .../commands/deploy/status-command.test.ts | 19 ++++++ .../src/commands/deploy/status-command.ts | 20 ++++-- .../cli-core/src/commands/deploy/status.ts | 27 ++++---- 7 files changed, 150 insertions(+), 24 deletions(-) diff --git a/.changeset/native-apple-api.md b/.changeset/native-apple-api.md index adc65d885..49d43bb0f 100644 --- a/.changeset/native-apple-api.md +++ b/.changeset/native-apple-api.md @@ -2,7 +2,7 @@ "clerk": patch --- -Recognize native Sign in with Apple configuration in `clerk deploy` and `clerk deploy status`. Verify the registered Bundle ID and Native API settings before treating native Apple sign-in as ready, and avoid requesting unrelated Apple web credentials. Report missing or conflicting registrations with actionable guidance. +Recognize native Sign in with Apple configuration in `clerk deploy` and `clerk deploy status`. Verify the registered Bundle ID and Native API settings before treating native Apple sign-in as ready. Let developers keep native-only setup or also configure Apple web credentials. Report missing or conflicting registrations with actionable guidance, including while DNS setup is pending. Add validated native application API helpers, idempotent registration requests, and conditional configuration updates. Allow application metadata reads without secret keys and share concurrent OAuth token refreshes. diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 1f5d93af7..4f397d1d3 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1422,6 +1422,10 @@ describe("deploy", () => { expect(mockListIOSApplications).toHaveBeenCalledWith("app_xyz789", "ins_prod_native_apple"); expect(mockGetNativeSettings).toHaveBeenCalledWith("app_xyz789", "ins_prod_native_apple"); + expect(mockConfirm).toHaveBeenCalledWith({ + message: expect.stringContaining("Also configure Apple web sign-in credentials?"), + default: false, + }); expect(mockSelect).not.toHaveBeenCalled(); expect(mockInput).not.toHaveBeenCalled(); expect(mockPassword).not.toHaveBeenCalled(); @@ -1432,6 +1436,66 @@ describe("deploy", () => { expect(err).not.toContain("Configure Apple OAuth for production"); }); + test.each(["ready", "registration-missing", "authentication-disabled"])( + "allows web Apple credentials alongside a native Bundle ID (%s)", + async (readiness) => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { connection_oauth_apple: { enabled: true } }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: readiness !== "authentication-disabled", + bundle_id: "com.example.native", + }, + }, + }); + if (readiness === "ready") + 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); + mockConfirm.mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("have-credentials"); + const keyPath = join(tempDir, "AuthKey.p8"); + const key = "-----BEGIN PRIVATE KEY-----\nfixture\n-----END PRIVATE KEY-----\n"; + await Bun.write(keyPath, key); + mockInput + .mockResolvedValueOnce("services-id") + .mockResolvedValueOnce("team-id") + .mockResolvedValueOnce("key-id") + .mockResolvedValueOnce(keyPath); + + await runDeploy({}); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith( + "app_xyz789", + "ins_prod_native_apple", + { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + client_id: "services-id", + team_id: "team-id", + key_id: "key-id", + client_secret: key, + }, + }, + ); + }, + ); + test("refuses case-only Apple registration mismatches without suggesting another registration", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, @@ -1794,6 +1858,7 @@ describe("deploy", () => { connection_oauth_apple: { enabled: true, client_id: "apple-services-id", + authenticatable: true, team_id: "apple-team-id", key_id: "apple-key-id", client_secret: diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 82331abe5..2b943c115 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -57,6 +57,7 @@ import { collectCustomDomain, collectOAuthCredentials, confirmCreateProductionInstance, + confirmAppleWebCredentials, confirmExportBindZone, confirmProceed, } from "./prompts.ts"; @@ -303,6 +304,16 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { // way, since the configuration read succeeded. recordDeployObservation({ kind: "active", snapshot }); + const configureAppleWebCredentials = snapshot.nativeAppleBundleId + ? await confirmAppleWebCredentials(snapshot.nativeAppleBundleId) + : undefined; + if (configureAppleWebCredentials) { + snapshot.completedOAuthProviders = snapshot.completedOAuthProviders.filter( + (provider) => provider !== "apple", + ); + snapshot.pending = { type: "oauth", provider: "apple" }; + } + log.blank(); for (const line of printPlan(ctx.appLabel, buildLiveDeployPlan(snapshot))) { log.info(line); @@ -339,6 +350,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { }, }, snapshot.oauthProviderDescriptors, + configureAppleWebCredentials, ); snapshot.completedOAuthProviders = completed; } @@ -628,6 +640,7 @@ async function runOAuthSetup( ctx: DeployContext, state: DeployOperationState, descriptors: readonly OAuthProviderDescriptor[], + configureAppleWebCredentials?: boolean, ): Promise { const completed = new Set(state.completedOAuthProviders as OAuthProvider[]); const oauthProviders = descriptors.map((descriptor) => descriptor.provider); @@ -656,6 +669,7 @@ async function runOAuthSetup( state.domain, productionInstanceId, state.frontendApiUrl, + configureAppleWebCredentials, ); if (!saved) { throwDeployPaused( @@ -703,8 +717,16 @@ async function collectAndSaveOAuthCredentials( domain: string, productionInstanceId: string, frontendApiUrl?: string, + configureAppleWebCredentials?: boolean, ): Promise { - if (await nativeAppleCredentialsAreAlreadyConfigured(ctx, descriptor, productionInstanceId)) { + if ( + await nativeAppleCredentialsAreAlreadyConfigured( + ctx, + descriptor, + productionInstanceId, + configureAppleWebCredentials, + ) + ) { return true; } @@ -734,6 +756,7 @@ async function collectAndSaveOAuthCredentials( await patchInstanceConfig(ctx.appId, productionInstanceId, { [descriptor.configKey]: { enabled: true, + ...(descriptor.provider === "apple" ? { authenticatable: true } : {}), ...credentials, }, }); @@ -746,14 +769,21 @@ async function nativeAppleCredentialsAreAlreadyConfigured( ctx: DeployContext, descriptor: OAuthProviderDescriptor, productionInstanceId: string, + configureAppleWebCredentials?: boolean, ): Promise { - if (descriptor.provider !== "apple") return false; + if (descriptor.provider !== "apple" || configureAppleWebCredentials) return false; const productionConfig = await withSpinner( "Checking production Sign in with Apple configuration...", async () => fetchInstanceConfig(ctx.appId, productionInstanceId), ); const preliminary = inspectNativeAppleConfiguration(productionConfig, descriptor, []); + if ( + "bundleId" in preliminary && + configureAppleWebCredentials === undefined && + (await confirmAppleWebCredentials(preliminary.bundleId)) + ) + return false; 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. ` + diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts index 80ee77f0b..9cda958c4 100644 --- a/packages/cli-core/src/commands/deploy/prompts.ts +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -78,6 +78,13 @@ export async function confirmExportBindZone(): Promise { }); } +export async function confirmAppleWebCredentials(bundleId: string): Promise { + return confirm({ + message: `Apple has a native Bundle ID (${bundleId}). Also configure Apple web sign-in credentials?`, + default: false, + }); +} + export async function chooseOAuthCredentialAction( descriptor: OAuthProviderDescriptor, options: { includeWalkthrough?: boolean } = {}, 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 dd98a0062..152ace795 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -1129,6 +1129,25 @@ async function routePlapiFetch( } describe("humanNextAction", () => { + test.each(["records_available", "records_unavailable", "ssl_pending", "finalizing"] as const)( + "%s preserves domain guidance and adds the native Apple blocker", + (kind) => { + const step = { + kind, + domain: "example.com", + records: "DNS", + domainsUrl: "https://dashboard.clerk.com/domains", + }; + const domainGuidance = humanNextAction(step); + const line = humanNextAction({ + ...step, + nativeAppleReadinessIssue: { bundleId: "com.example.app", reason: "native-api-disabled" }, + }); + expect(line).toStartWith(domainGuidance); + expect(line).toContain("Native API is disabled"); + expect(line).toContain("com.example.app"); + }, + ); // These are the three things that used to leak from the agent sentence into // the human one. Now that each audience has its own renderer, this pins the // human side for every state rather than trusting seven replace rules. diff --git a/packages/cli-core/src/commands/deploy/status-command.ts b/packages/cli-core/src/commands/deploy/status-command.ts index 3fcf1956a..ccc2b7056 100644 --- a/packages/cli-core/src/commands/deploy/status-command.ts +++ b/packages/cli-core/src/commands/deploy/status-command.ts @@ -225,6 +225,10 @@ export function renderHuman(report: DeployStatusReport): void { export function humanNextAction(step: DeployNextStep): string { const domains = (url: string | null): string => url ? ` Visit the Clerk Dashboard domains page to monitor its status there: ${url}` : ""; + const nativeApple = + "nativeAppleReadinessIssue" in step && step.nativeAppleReadinessIssue + ? ` ${humanNativeAppleReadinessNextAction(step.nativeAppleReadinessIssue)}` + : ""; switch (step.kind) { case "not_started": @@ -274,33 +278,35 @@ export function humanNextAction(step: DeployNextStep): string { return ( `${step.records} records not found yet for ${step.domain}. ` + `Once they're added, run \`clerk deploy\` again to resume. Propagation usually takes minutes.` + - domains(step.domainsUrl) + domains(step.domainsUrl) + + nativeApple ); case "records_unavailable": return ( `${step.records} records not found yet for ${step.domain}, but Clerk didn't return the list of records to add. ` + `Find them on the Domains page in the Clerk Dashboard, add them, then run \`clerk deploy\` again to resume.` + - domains(step.domainsUrl) + domains(step.domainsUrl) + + nativeApple ); case "ssl_pending": 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) + + nativeApple ); 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) + + nativeApple ); } } function humanNativeAppleReadinessNextAction( - issue: NonNullable< - Extract["nativeAppleReadinessIssue"] - >, + issue: NonNullable, ): string { if (issue.reason === "verification-unavailable") { return ( diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index e8021bac5..cc32f3464 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -169,6 +169,7 @@ export type LiveDeploySnapshot = Omit< live: boolean; unsupportedOAuthProviderCount: number; unsupportedOAuthProviders: string[]; + nativeAppleBundleId?: string; nativeAppleReadinessIssue?: NativeAppleReadinessIssue; }; @@ -327,20 +328,15 @@ export async function resolveLiveDeploySnapshot( 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( - config, - nativeAppleDescriptor, - iosApplications, - nativeSettings, - ); - }, + const [iosApplications, nativeSettings] = await Promise.all([ + listIOSApplications(ctx.appId, productionInstanceId), + getNativeSettings(ctx.appId, productionInstanceId), + ]); + nativeAppleConfiguration = inspectNativeAppleConfiguration( + config, + nativeAppleDescriptor, + iosApplications, + nativeSettings, ); } catch (error) { if (error instanceof UserAbortError) throw error; @@ -398,6 +394,9 @@ export async function resolveLiveDeploySnapshot( live, unsupportedOAuthProviderCount: unsupported.length, unsupportedOAuthProviders: unsupported, + ...(nativeAppleConfiguration && "bundleId" in nativeAppleConfiguration + ? { nativeAppleBundleId: nativeAppleConfiguration.bundleId } + : {}), ...(nativeAppleConfiguration && "bundleId" in nativeAppleConfiguration && isNativeAppleReadinessIssue(nativeAppleConfiguration.status) From 8603b57c7150a88df7fffc5c5582656cf9ce2ecd Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 2 Oct 2026 23:51:54 -0400 Subject: [PATCH 4/6] fix(deploy): pause native Apple deploys with production guidance - Ask about Apple web credentials only while native Apple isn't ready, and pause like a skipped provider instead of failing, linking the production Native Applications page. - Share one native readiness lookup between `deploy status` and the wizard, and keep its guidance in copy.ts. - Shape the Native API helpers like the Android ones (#483): shared URL builder, escaped IDs, checks on the fields used, error codes, If-Match. Drop the application validator applied to every fetchApplication call. - Keep doctor's "expired" result unless the Clerk API confirms access. - Leave hosted Apple saves unchanged and revert an unrelated cast. Co-Authored-By: Claude Opus 5.5 --- .changeset/native-apple-api.md | 6 +- .../cli-core/src/commands/api/index.test.ts | 2 - .../cli-core/src/commands/config/pull.test.ts | 8 +- .../cli-core/src/commands/config/push.test.ts | 8 +- .../src/commands/config/schema.test.ts | 8 +- .../cli-core/src/commands/deploy/README.md | 27 +- packages/cli-core/src/commands/deploy/copy.ts | 27 ++ .../src/commands/deploy/index.test.ts | 439 ++---------------- .../cli-core/src/commands/deploy/index.ts | 158 ++----- .../src/commands/deploy/providers.test.ts | 226 +++------ .../cli-core/src/commands/deploy/providers.ts | 93 ++-- .../commands/deploy/status-command.test.ts | 24 +- .../src/commands/deploy/status-command.ts | 48 +- .../src/commands/deploy/status.test.ts | 382 ++++----------- .../cli-core/src/commands/deploy/status.ts | 188 +++----- .../cli-core/src/commands/doctor/checks.ts | 44 +- .../src/commands/doctor/context.test.ts | 2 +- .../cli-core/src/commands/doctor/context.ts | 6 +- .../src/commands/doctor/doctor.test.ts | 78 +--- .../src/commands/webhooks/relay-client.ts | 2 +- .../cli-core/src/lib/apple-native-identity.ts | 21 +- .../cli-core/src/lib/credential-store.test.ts | 58 ++- packages/cli-core/src/lib/credential-store.ts | 11 +- .../cli-core/src/lib/plapi-native.test.ts | 265 ----------- packages/cli-core/src/lib/plapi.test.ts | 129 +++-- packages/cli-core/src/lib/plapi.ts | 338 ++++---------- 26 files changed, 657 insertions(+), 1941 deletions(-) delete mode 100644 packages/cli-core/src/lib/plapi-native.test.ts diff --git a/.changeset/native-apple-api.md b/.changeset/native-apple-api.md index 49d43bb0f..60856ab2c 100644 --- a/.changeset/native-apple-api.md +++ b/.changeset/native-apple-api.md @@ -2,8 +2,4 @@ "clerk": patch --- -Recognize native Sign in with Apple configuration in `clerk deploy` and `clerk deploy status`. Verify the registered Bundle ID and Native API settings before treating native Apple sign-in as ready. Let developers keep native-only setup or also configure Apple web credentials. Report missing or conflicting registrations with actionable guidance, including while DNS setup is pending. - -Add validated native application API helpers, idempotent registration requests, and conditional configuration updates. Allow application metadata reads without secret keys and share concurrent OAuth token refreshes. - -Recognize Platform API keys in Doctor's account checks and verify account access through the Clerk API when the OAuth userinfo check is unavailable for otherwise valid credentials. +Recognize native-only Sign in with Apple in `clerk deploy` and `clerk deploy status`. Apple counts as configured once its production Bundle ID registration and Native API are ready; until then deploy offers Apple web credentials or pauses with a link to the production Native Applications page. Doctor recognizes Platform API keys and verifies account access through the Clerk API when OAuth userinfo rejects a valid session. Concurrent OAuth token refreshes in one process now share a single refresh. diff --git a/packages/cli-core/src/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index 995c1dafa..e79f86ea3 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -401,7 +401,6 @@ describe("api command", () => { { instance_id: "ins_dev", environment_type: "development", - publishable_key: "pk_test_fixture", secret_key: "sk_test_derived", }, ], @@ -435,7 +434,6 @@ 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 6eceb120c..fed5d17d7 100644 --- a/packages/cli-core/src/commands/config/pull.test.ts +++ b/packages/cli-core/src/commands/config/pull.test.ts @@ -98,13 +98,7 @@ 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", - publishable_key: "pk_test_fixture", - }, - ], + instances: [{ instance_id: "ins_dev", environment_type: "development" }], }; 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 0c61f5701..780edddee 100644 --- a/packages/cli-core/src/commands/config/push.test.ts +++ b/packages/cli-core/src/commands/config/push.test.ts @@ -205,13 +205,7 @@ describe("config push", () => { return new Response( JSON.stringify({ application_id: "app_1", - instances: [ - { - instance_id: "ins_dev", - environment_type: "development", - publishable_key: "pk_test_fixture", - }, - ], + instances: [{ instance_id: "ins_dev", environment_type: "development" }], }), { 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 ff97f7bf4..e56bef577 100644 --- a/packages/cli-core/src/commands/config/schema.test.ts +++ b/packages/cli-core/src/commands/config/schema.test.ts @@ -87,13 +87,7 @@ 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", - publishable_key: "pk_test_fixture", - }, - ], + instances: [{ instance_id: "ins_dev", environment_type: "development" }], }; stubFetch(async (input) => { diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index da251ca46..a70632bd8 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -51,6 +51,7 @@ In agent mode, `clerk deploy status` emits JSON on stdout with: - `domainStatus`: per-component DNS, SSL, and email DNS status when a domain exists. - `pendingDnsRecords`: CNAME records still tied to pending DNS-backed checks, each with `host`, `value`, and Clerk's `required` flag (some targets are optional). - `oauth`: configured, pending, and unsupported provider slugs. +- `nativeAppleReadinessIssue`: present only when Apple is native-only (a `bundle_id` and no hosted credentials) and not yet ready in production. Holds the `bundleId`, a `reason` (`authentication-disabled`, `registration-missing`, `registration-ambiguous`, `registration-bundle-case-mismatch`, `native-api-disabled`, or `verification-unavailable`), and the production Native Applications `dashboardUrl`. `nextAction` includes the matching guidance. - `urls`: the production instance's Dashboard page (`instance`) and its Domains page (`domains`), or `null` before a production instance exists. The same URLs appear in `nextAction` prose; this field is the one to read programmatically. - `nextAction`: the next step an agent should present to the user. While domain setup remains (`domain_provisioning` and `domain_pending`) it includes the Clerk Dashboard domains URL, and agents should ask whether to open that URL for the user; `not_started`, `interrupted`, and `oauth_pending` carry no URL, and `complete` carries the instance root instead. While DNS or email DNS records are unverified it says to add the records in `pendingDnsRecords` at the domain's DNS provider rather than to keep polling; if that list is empty (the API returned no CNAME targets) it says so and points at the Dashboard Domains page instead; when only SSL is pending it says to wait. At `complete` it says the production keys still have to reach the host (`clerk env pull --instance prod`, alongside the other Clerk variables in the env file) and to sign up on the domain to confirm — "complete" is Clerk's side only — and links the instance root (users, settings, billing) instead of the domains page, since nothing is left to monitor there. At `complete` and `oauth_pending` it also names any providers in `oauth.unsupported`, since `oauth.complete` covers only what the CLI manages and those providers' sign-in fails in production until configured in the Dashboard. `oauth_pending` carries no Domains URL (the domain is verified). Human mode prints its own sentence, rendered from the same classification of the report (`deployNextStep` in `status.ts`) rather than by rewriting the agent's: no unsupported-provider clause (the warning row above already says it), no "ask the user" (the reader is the user), no `--wait` (human mode always waits; the wizard is what resumes setup), and, when records are pending, the records themselves printed first so the sentence only says what happens once they are added. Before a production instance exists, human mode omits the OAuth row rather than printing "pending: none" for something that was never checked. @@ -168,17 +169,19 @@ sequenceDiagram All endpoints are on the **Platform API** (`/v1/platform/...`) and are live HTTP calls. The deploy command calls the helpers in `lib/plapi.ts` directly. -| Step | Method | Endpoint | Helper | -| --------------------------- | ------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| Auth | n/a | Local config | Token stored from `clerk auth login` or `CLERK_PLATFORM_API_KEY`. | -| Read instance config | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | `fetchInstanceConfig` from `lib/plapi.ts`. Discovers enabled `connection_oauth_*` providers. | -| Read instance config schema | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config/schema` | `fetchInstanceConfigSchema`. Reads schemas for supported OAuth config keys so deploy can derive credential prompts. | -| Patch instance config | `PATCH` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | `patchInstanceConfig`. Writes production OAuth credentials. | -| Read application | `GET` | `/v1/platform/applications/{appID}` | `fetchApplication`. Resolves development and production instance IDs. | -| List production domains | `GET` | `/v1/platform/applications/{appID}/domains` | `listApplicationDomains`. Recovers production domain name and CNAME targets on each run. | -| Create production instance | `POST` | `/v1/platform/applications/{appID}/instances` | `createProductionInstance`. Returns prod instance, primary domain, keys, and DNS records nested under `active_domain.cname_targets[]`. | -| Trigger domain DNS check | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/dns_check` | `triggerApplicationDomainDNSCheck`. Called by the wizard and by `clerk deploy status`; agent mode waits briefly, then reads one status snapshot. | -| Poll domain status | `GET` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | `getApplicationDomainStatus`. Drives the wizard spinner and the human-mode `clerk deploy status` wait loop over the full domain status response. | +| Step | Method | Endpoint | Helper | +| --------------------------- | ------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Auth | n/a | Local config | Token stored from `clerk auth login` or `CLERK_PLATFORM_API_KEY`. | +| Read instance config | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | `fetchInstanceConfig` from `lib/plapi.ts`. Discovers enabled `connection_oauth_*` providers. | +| Read instance config schema | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config/schema` | `fetchInstanceConfigSchema`. Reads schemas for supported OAuth config keys so deploy can derive credential prompts. | +| Patch instance config | `PATCH` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | `patchInstanceConfig`. Writes production OAuth credentials. | +| Read application | `GET` | `/v1/platform/applications/{appID}` | `fetchApplication`. Resolves development and production instance IDs. | +| List production domains | `GET` | `/v1/platform/applications/{appID}/domains` | `listApplicationDomains`. Recovers production domain name and CNAME targets on each run. | +| Read native settings | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/native_settings` | `getNativeSettings`. Checks Native API for native-only Apple readiness. | +| List iOS registrations | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/native_applications/ios` | `listIOSApplications`. Checks the production registration for a native-only Apple Bundle ID. | +| Create production instance | `POST` | `/v1/platform/applications/{appID}/instances` | `createProductionInstance`. Returns prod instance, primary domain, keys, and DNS records nested under `active_domain.cname_targets[]`. | +| Trigger domain DNS check | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/dns_check` | `triggerApplicationDomainDNSCheck`. Called by the wizard and by `clerk deploy status`; agent mode waits briefly, then reads one status snapshot. | +| Poll domain status | `GET` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | `getApplicationDomainStatus`. Drives the wizard spinner and the human-mode `clerk deploy status` wait loop over the full domain status response. | ## OAuth Provider Config Format @@ -209,6 +212,8 @@ The CLI keeps small local overrides for provider setup details that schema does | Google | Optional Google Cloud Console JSON import, OAuth consent screen warning, and a tip that the consent-screen name is what users see, so choose the name you want them to see | | Apple | `.p8` file import, production-required `team_id` and `key_id`, native-only field omissions | +Apple configured with only a `bundle_id` (no `client_id`, `client_secret`, `team_id`, or `key_id`, matching the backend's rule) is native-only. It counts as configured once Apple is enabled for authentication, the Bundle ID has exactly one production iOS registration, and Native API is enabled. Until then the wizard offers to add Apple web credentials; declining prints the fix with a link to the production Native Applications page and pauses the deploy, as skipping any provider does. + For Google, the wizard can load `client_id` and `client_secret` from the top-level `web` object in a Google Cloud Console OAuth client JSON file, or from `installed` for desktop-style client downloads. The file contents are used in memory and are not written to CLI config. Providers not currently supported by automated deploy setup are cloned to production without automated credential setup. Configure those providers from the Clerk Dashboard before going live. diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index f1004a542..b51d2dd9f 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -1,5 +1,6 @@ import { bold, cyan, dim, green, yellow } from "../../lib/color.ts"; import type { CnameTarget } from "../../lib/plapi.ts"; +import type { NativeAppleReadinessIssue } from "./providers.ts"; import { buildDashboardUrl } from "../../lib/environment.ts"; import { wrap } from "../../lib/wrap.ts"; @@ -608,6 +609,32 @@ export function domainsDashboardUrl(appId: string, productionInstanceId: string) return buildDashboardUrl(appId, productionInstanceId, "domains"); } +export function nativeApplicationsDashboardUrl( + appId: string, + productionInstanceId: string, +): string { + return buildDashboardUrl(appId, productionInstanceId, "native-applications"); +} + +/** What blocks native-only Sign in with Apple in production, and how to fix it. */ +export function nativeAppleGuidance(issue: NativeAppleReadinessIssue): string { + const { bundleId, dashboardUrl } = issue; + switch (issue.reason) { + case "authentication-disabled": + return `Sign in with Apple for ${bundleId} is not enabled for authentication in production. Enable it in the Apple connection settings in the Clerk Dashboard.`; + case "registration-missing": + return `Sign in with Apple needs a production iOS registration for ${bundleId}. Register it at ${dashboardUrl}`; + case "registration-ambiguous": + return `${bundleId} is registered under more than one App ID Prefix in production. Remove the extra registrations at ${dashboardUrl}`; + case "registration-bundle-case-mismatch": + return `The Apple connection's Bundle ID ${bundleId} differs in letter case from its production iOS registration. Make them match exactly at ${dashboardUrl}`; + case "native-api-disabled": + return `Native API is disabled on the production instance. Enable it at ${dashboardUrl}`; + case "verification-unavailable": + return `Clerk could not verify the production iOS registration for ${bundleId}. Retry shortly, and don't create another registration based on this result.`; + } +} + export function pausedMessage(stepDescription: string): string { return `Deploy paused at: ${stepDescription} diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 4f397d1d3..33217eef7 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1385,86 +1385,63 @@ 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(mockConfirm).toHaveBeenCalledWith({ - message: expect.stringContaining("Also configure Apple web sign-in credentials?"), - default: false, - }); - 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"); - }); + describe("native-only Apple", () => { + const nativeConnection = { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }; + const registration = { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + }; - test.each(["ready", "registration-missing", "authentication-disabled"])( - "allows web Apple credentials alongside a native Bundle ID (%s)", - async (readiness) => { + async function nativeAppleDeploy(): Promise { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, }); mockLiveProduction({ instanceId: "ins_prod_native_apple", - developmentConfig: { connection_oauth_apple: { enabled: true } }, - productionConfig: { - connection_oauth_apple: { - enabled: true, - authenticatable: readiness !== "authentication-disabled", - bundle_id: "com.example.native", - }, - }, + developmentConfig: { connection_oauth_apple: nativeConnection }, + productionConfig: { connection_oauth_apple: nativeConnection }, }); - if (readiness === "ready") - 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); + } + + test("needs no prompts once the production registration is ready", async () => { + await nativeAppleDeploy(); + mockListIOSApplications.mockResolvedValue([registration]); + + await runDeploy({}); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + expect(stripAnsi(captured.err)).toContain("No deploy actions remain."); + }); + + test("pauses with production guidance when the user declines web credentials", async () => { + await nativeAppleDeploy(); + mockConfirm.mockResolvedValueOnce(false); + + const error = await runDeploy({}).catch((caught: unknown) => caught as CliError); + + expect(mockConfirm).toHaveBeenCalledWith({ + message: expect.stringContaining("Also configure Apple web sign-in credentials?"), + default: false, + }); + expect(error?.message).toContain("Deploy paused at: Apple OAuth credential setup"); + expect(flat(stripAnsi(captured.err))).toContain( + "https://dashboard.clerk.com/apps/app_xyz789/instances/ins_prod_native_apple/native-applications", + ); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + }); + + test("saves hosted credentials unchanged when the user adds web sign-in", async () => { + await nativeAppleDeploy(); mockConfirm.mockResolvedValueOnce(true); mockSelect.mockResolvedValueOnce("have-credentials"); const keyPath = join(tempDir, "AuthKey.p8"); @@ -1478,14 +1455,12 @@ describe("deploy", () => { await runDeploy({}); - expect(mockConfirm).toHaveBeenCalledTimes(1); expect(mockPatchInstanceConfig).toHaveBeenCalledWith( "app_xyz789", "ins_prod_native_apple", { connection_oauth_apple: { enabled: true, - authenticatable: true, client_id: "services-id", team_id: "team-id", key_id: "key-id", @@ -1493,322 +1468,7 @@ describe("deploy", () => { }, }, ); - }, - ); - - 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 () => { @@ -1858,7 +1518,6 @@ describe("deploy", () => { connection_oauth_apple: { enabled: true, client_id: "apple-services-id", - authenticatable: true, team_id: "apple-team-id", key_id: "apple-key-id", client_secret: diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 2b943c115..d7489aff3 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -11,8 +11,6 @@ import { setProfile } from "../../lib/config.ts"; import { createProductionInstance as apiCreateProductionInstance, fetchInstanceConfig, - getNativeSettings, - listIOSApplications, patchInstanceConfig, type CnameTarget, type ProductionInstanceResponse, @@ -35,6 +33,8 @@ import { dnsRecords, domainsDashboardUrl, instanceDashboardUrl, + nativeAppleGuidance, + nativeApplicationsDashboardUrl, nextStepsBody, pendingCnameTargets, pausedOperationNotice, @@ -43,10 +43,10 @@ import { } from "./copy.ts"; import { mapDeployError } from "./errors.ts"; import { - inspectNativeAppleConfiguration, providerLabel, providerSetupIntro, showOAuthWalkthrough, + type NativeAppleConfiguration, type OAuthProvider, type OAuthProviderDescriptor, } from "./providers.ts"; @@ -74,6 +74,7 @@ import { resolveDeployState, resolveLiveApplicationContext, resolveLiveDeploySnapshot, + resolveNativeAppleConfiguration, waitForDeployStatus, type DeployProgressHandlers, type DeployStatusOutcome, @@ -304,16 +305,6 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { // way, since the configuration read succeeded. recordDeployObservation({ kind: "active", snapshot }); - const configureAppleWebCredentials = snapshot.nativeAppleBundleId - ? await confirmAppleWebCredentials(snapshot.nativeAppleBundleId) - : undefined; - if (configureAppleWebCredentials) { - snapshot.completedOAuthProviders = snapshot.completedOAuthProviders.filter( - (provider) => provider !== "apple", - ); - snapshot.pending = { type: "oauth", provider: "apple" }; - } - log.blank(); for (const line of printPlan(ctx.appLabel, buildLiveDeployPlan(snapshot))) { log.info(line); @@ -350,7 +341,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { }, }, snapshot.oauthProviderDescriptors, - configureAppleWebCredentials, + snapshot.nativeApple ?? null, ); snapshot.completedOAuthProviders = completed; } @@ -640,7 +631,8 @@ async function runOAuthSetup( ctx: DeployContext, state: DeployOperationState, descriptors: readonly OAuthProviderDescriptor[], - configureAppleWebCredentials?: boolean, + /** Undefined when unknown (a fresh deploy); null when Apple is not native-only. */ + nativeApple?: NativeAppleConfiguration | null, ): Promise { const completed = new Set(state.completedOAuthProviders as OAuthProvider[]); const oauthProviders = descriptors.map((descriptor) => descriptor.provider); @@ -669,7 +661,7 @@ async function runOAuthSetup( state.domain, productionInstanceId, state.frontendApiUrl, - configureAppleWebCredentials, + nativeApple, ); if (!saved) { throwDeployPaused( @@ -717,17 +709,36 @@ async function collectAndSaveOAuthCredentials( domain: string, productionInstanceId: string, frontendApiUrl?: string, - configureAppleWebCredentials?: boolean, + nativeApple?: NativeAppleConfiguration | null, ): Promise { - if ( - await nativeAppleCredentialsAreAlreadyConfigured( - ctx, - descriptor, - productionInstanceId, - configureAppleWebCredentials, - ) - ) { - return true; + if (descriptor.provider === "apple") { + const native = + nativeApple !== undefined + ? nativeApple + : await withSpinner("Checking production Sign in with Apple configuration...", async () => + resolveNativeAppleConfiguration( + ctx.appId, + productionInstanceId, + await fetchInstanceConfig(ctx.appId, productionInstanceId), + [descriptor], + ), + ); + if (native?.status === "ready") { + log.success( + `Native Sign in with Apple is ready for ${native.bundleId}; Apple web credentials are not required`, + ); + return true; + } + if (native && !(await confirmAppleWebCredentials(native.bundleId))) { + log.warn( + nativeAppleGuidance({ + bundleId: native.bundleId, + reason: native.status, + dashboardUrl: nativeApplicationsDashboardUrl(ctx.appId, productionInstanceId), + }), + ); + return false; + } } for (const line of providerSetupIntro(descriptor)) log.info(line); @@ -756,7 +767,6 @@ async function collectAndSaveOAuthCredentials( await patchInstanceConfig(ctx.appId, productionInstanceId, { [descriptor.configKey]: { enabled: true, - ...(descriptor.provider === "apple" ? { authenticatable: true } : {}), ...credentials, }, }); @@ -765,100 +775,6 @@ async function collectAndSaveOAuthCredentials( return true; } -async function nativeAppleCredentialsAreAlreadyConfigured( - ctx: DeployContext, - descriptor: OAuthProviderDescriptor, - productionInstanceId: string, - configureAppleWebCredentials?: boolean, -): Promise { - if (descriptor.provider !== "apple" || configureAppleWebCredentials) return false; - - const productionConfig = await withSpinner( - "Checking production Sign in with Apple configuration...", - async () => fetchInstanceConfig(ctx.appId, productionInstanceId), - ); - const preliminary = inspectNativeAppleConfiguration(productionConfig, descriptor, []); - if ( - "bundleId" in preliminary && - configureAppleWebCredentials === undefined && - (await confirmAppleWebCredentials(preliminary.bundleId)) - ) - return false; - 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 cb3d0fb0c..47d3d940b 100644 --- a/packages/cli-core/src/commands/deploy/providers.test.ts +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -4,6 +4,7 @@ import { inspectNativeAppleConfiguration, providerFields, providerLabel, + type NativeAppleStatus, type OAuthProviderDescriptor, } from "./providers.ts"; import type { IOSApplication, InstanceConfigSchema } from "../../lib/plapi.ts"; @@ -69,8 +70,6 @@ function iosApplication( id, app_id_prefix: appIdPrefix, bundle_id: bundleId, - created_at: 1, - updated_at: 1, }; } @@ -206,164 +205,87 @@ 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 }), + describe("native-only Apple readiness", () => { + const apple = descriptorByProvider( + buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ).supported, + "apple", ); - 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" }); - }); + const connection = { enabled: true, authenticatable: true, bundle_id: "com.example.app" }; + const registered = [iosApplication("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", + test.each([ + { name: "an exact registration", apps: registered, expected: "ready" }, + { + name: "duplicate registrations under one prefix", + apps: [ + iosApplication("com.example.app", "PREFIX_ONE", "ios_a"), + iosApplication("com.example.app", "PREFIX_ONE", "ios_b"), + ], + expected: "ready", }, - }; - - expect( - inspectNativeAppleConfiguration( - config, - apple, - [ + { + name: "no registration", + apps: [iosApplication("com.example.other")], + expected: "registration-missing", + }, + { + name: "registrations under two prefixes", + apps: [ 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" }); - }); + expected: "registration-ambiguous", + }, + { + name: "a registration that differs in case", + apps: [iosApplication("COM.EXAMPLE.APP")], + expected: "registration-bundle-case-mismatch", + }, + { + name: "Native API disabled", + apps: registered, + apiEnabled: false, + expected: "native-api-disabled", + }, + { + name: "a connection that cannot authenticate", + apps: registered, + override: { authenticatable: false }, + expected: "authentication-disabled", + }, + { + name: "a disabled connection", + apps: registered, + override: { enabled: false }, + expected: "authentication-disabled", + }, + ])("reports $expected for $name", ({ apps, apiEnabled = true, override = {}, expected }) => { + expect( + inspectNativeAppleConfiguration( + { connection_oauth_apple: { ...connection, ...override } }, + apple, + apps, + { object: "native_settings", api_enabled: apiEnabled }, + ), + ).toEqual({ status: expected as NativeAppleStatus, bundleId: "com.example.app" }); + }); - test("keeps hosted Apple credentials on the hosted OAuth path", () => { - const result = buildOAuthProviderDescriptors( - ["apple"], - schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + test.each(["client_id", "client_secret", "team_id", "key_id"])( + "treats Apple with %s as hosted, like the backend", + (key) => { + expect( + inspectNativeAppleConfiguration( + { connection_oauth_apple: { ...connection, [key]: "value" } }, + apple, + registered, + { object: "native_settings", api_enabled: true }, + ), + ).toBeUndefined(); + }, ); - 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", () => { diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index f81083681..ca980d46b 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -69,19 +69,22 @@ 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; - }; +export type NativeAppleStatus = + | "ready" + | "authentication-disabled" + | "registration-missing" + | "registration-bundle-case-mismatch" + | "registration-ambiguous" + | "native-api-disabled" + | "verification-unavailable"; + +export type NativeAppleConfiguration = { status: NativeAppleStatus; bundleId: string }; + +export type NativeAppleReadinessIssue = { + bundleId: string; + reason: Exclude; + dashboardUrl: string; +}; type ProviderOverride = { credentialLabel?: string; @@ -234,31 +237,40 @@ 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. + * The Bundle ID of a native-only Apple connection, or undefined for hosted or + * unconfigured Apple. Mirrors the backend: any hosted identifier makes the + * connection hosted, even when it also has a Bundle ID. */ -export function inspectNativeAppleConfiguration( +export function nativeAppleBundleId( config: Record, descriptor: OAuthProviderDescriptor, - iosApplications: readonly IOSApplication[], - nativeSettings?: NativeSettings, -): NativeAppleConfiguration { - if (descriptor.provider !== "apple") return { status: "not-apple" }; - +): string | undefined { + if (descriptor.provider !== "apple") return undefined; const value = config[descriptor.configKey]; - if (!value || typeof value !== "object" || Array.isArray(value)) { - return { status: "hosted-or-unconfigured" }; - } + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const providerConfig = value as Record; - if (hasAppleHostedIdentifier(providerConfig)) { - return { status: "hosted-or-unconfigured" }; - } + const hosted = ["client_id", "client_secret", "team_id", "key_id"].some((key) => { + const field = providerConfig[key]; + return typeof field === "string" && field.trim().length > 0; + }); + if (hosted || typeof providerConfig.bundle_id !== "string") return undefined; + return providerConfig.bundle_id.trim() || undefined; +} - const rawBundleId = providerConfig.bundle_id; - const bundleId = typeof rawBundleId === "string" ? rawBundleId.trim() : ""; - if (!bundleId) return { status: "hosted-or-unconfigured" }; +/** + * Native-only production Apple sign-in is ready only when it is enabled for + * authentication, its Bundle ID has exactly one registration, and Native API + * is enabled on that production instance. + */ +export function inspectNativeAppleConfiguration( + config: Record, + descriptor: OAuthProviderDescriptor, + iosApplications: readonly IOSApplication[], + nativeSettings: NativeSettings, +): NativeAppleConfiguration | undefined { + const bundleId = nativeAppleBundleId(config, descriptor); + if (!bundleId) return undefined; + const providerConfig = config[descriptor.configKey] as Record; if (providerConfig.enabled !== true || providerConfig.authenticatable !== true) { return { status: "authentication-disabled", bundleId }; } @@ -268,27 +280,16 @@ export function inspectNativeAppleConfiguration( .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 (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 + return nativeSettings.api_enabled ? { 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 152ace795..ddafe4740 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -43,20 +43,8 @@ function stripAnsi(value: string): string { } function appWith(production: boolean) { - 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", - }); - } + const instances = [{ instance_id: "ins_dev", environment_type: "development" }]; + if (production) instances.push({ instance_id: "ins_prod", environment_type: "production" }); return { application_id: "app_1", name: "app", instances }; } @@ -1141,11 +1129,15 @@ describe("humanNextAction", () => { const domainGuidance = humanNextAction(step); const line = humanNextAction({ ...step, - nativeAppleReadinessIssue: { bundleId: "com.example.app", reason: "native-api-disabled" }, + nativeAppleReadinessIssue: { + bundleId: "com.example.app", + reason: "native-api-disabled", + dashboardUrl: "https://dashboard.clerk.com/native-applications", + }, }); expect(line).toStartWith(domainGuidance); expect(line).toContain("Native API is disabled"); - expect(line).toContain("com.example.app"); + expect(line).toContain("https://dashboard.clerk.com/native-applications"); }, ); // These are the three things that used to leak from the agent sentence into diff --git a/packages/cli-core/src/commands/deploy/status-command.ts b/packages/cli-core/src/commands/deploy/status-command.ts index ccc2b7056..43c370cf4 100644 --- a/packages/cli-core/src/commands/deploy/status-command.ts +++ b/packages/cli-core/src/commands/deploy/status-command.ts @@ -5,7 +5,7 @@ import { interruptedExitCode } from "../../lib/signals.ts"; import { sleep } from "../../lib/sleep.ts"; import { withSpinner } from "../../lib/spinner.ts"; import { declareSoftExitOutcome } from "../../lib/telemetry.ts"; -import { deployComponentLabels, dnsRecords } from "./copy.ts"; +import { deployComponentLabels, dnsRecords, nativeAppleGuidance } from "./copy.ts"; import { buildDeployStatusReport, buildInterruptedDeployStatusReport, @@ -227,7 +227,7 @@ export function humanNextAction(step: DeployNextStep): string { url ? ` Visit the Clerk Dashboard domains page to monitor its status there: ${url}` : ""; const nativeApple = "nativeAppleReadinessIssue" in step && step.nativeAppleReadinessIssue - ? ` ${humanNativeAppleReadinessNextAction(step.nativeAppleReadinessIssue)}` + ? ` ${nativeAppleGuidance(step.nativeAppleReadinessIssue)}` : ""; switch (step.kind) { @@ -263,10 +263,7 @@ export function humanNextAction(step: DeployNextStep): string { 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 setup is incomplete.${nativeApple}` + hostedAction; } return ( `Domain verified, but these OAuth providers are missing production credentials: ` + @@ -304,42 +301,3 @@ export function humanNextAction(step: DeployNextStep): string { ); } } - -function humanNativeAppleReadinessNextAction( - issue: NonNullable, -): 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 38d2a6402..cf8e9a0b0 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -222,318 +222,104 @@ describe("resolveDeployState", () => { } }); - 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", + describe("native-only Apple", () => { + const nativeConnection = { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }; + const registration = { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + }; + + function mockProductionApple(connection: Record): void { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { connection_oauth_apple: connection } + : { connection_oauth_apple: { enabled: true } }, ); } - }); - - 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("is complete with an exact production registration", async () => { + mockProductionApple(nativeConnection); + mockListIOSApplications.mockResolvedValue([registration]); - 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" }); - 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(buildDeployStatusReport(state, null).complete).toBe(true); + } + expect(mockListIOSApplications).toHaveBeenCalledWith("app_1", "ins_prod"); + expect(mockGetNativeSettings).toHaveBeenCalledWith("app_1", "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("points a missing registration at the production Native Applications page", async () => { + mockProductionApple(nativeConnection); + mockListIOSApplications.mockResolvedValue([ + { ...registration, bundle_id: "com.example.other" }, + ]); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.pending).toEqual({ type: "oauth", provider: "apple" }); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "registration-missing", + dashboardUrl: + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/native-applications", + }); + const report = buildDeployStatusReport(state, null); + expect(report.state).toBe("oauth_pending"); + expect(report.nextAction).toContain( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/native-applications", + ); + expect(report.nextAction).not.toContain("missing production credentials: apple"); + } + }); - 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 } }, - ); + test("reports unverifiable rather than missing when native reads fail", async () => { + mockProductionApple(nativeConnection); + mockListIOSApplications.mockRejectedValue(new Error("native endpoint unavailable")); - const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + 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(); - }); + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.nativeAppleReadinessIssue?.reason).toBe("verification-unavailable"); + expect(buildDeployStatusReport(state, null).nextAction).toContain( + "don't create another registration", + ); + } + }); - 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", + test("leaves hosted Apple credential-based without native reads", async () => { + mockProductionApple({ + enabled: true, 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", + client_id: "com.example.web", + client_secret: "REDACTED", + team_id: "TEAM123456", + key_id: "KEY1234567", }); - 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" }); + 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(); + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual(["apple"]); + expect(state.snapshot.nativeAppleReadinessIssue).toBeUndefined(); + } + expect(mockListIOSApplications).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index cc32f3464..5baf3f466 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -23,6 +23,8 @@ import { classifyDomainPending, domainsDashboardUrl, instanceDashboardUrl, + nativeAppleGuidance, + nativeApplicationsDashboardUrl, pendingRecordComponents, type DeployComponentStatus, } from "./copy.ts"; @@ -32,6 +34,9 @@ import { buildOAuthProviderDescriptors, hasProviderRequiredCredentials, inspectNativeAppleConfiguration, + nativeAppleBundleId, + type NativeAppleConfiguration, + type NativeAppleReadinessIssue, type OAuthProvider, type OAuthProviderDescriptor, } from "./providers.ts"; @@ -79,17 +84,6 @@ 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; @@ -169,7 +163,7 @@ export type LiveDeploySnapshot = Omit< live: boolean; unsupportedOAuthProviderCount: number; unsupportedOAuthProviders: string[]; - nativeAppleBundleId?: string; + nativeApple?: NativeAppleConfiguration; nativeAppleReadinessIssue?: NativeAppleReadinessIssue; }; @@ -284,6 +278,33 @@ export async function loadDevelopmentOAuthProviders( }); } +/** + * Native-only Apple readiness for a production instance, or undefined when + * Apple is hosted or not configured. A failed read is reported as + * unverifiable rather than missing, so no one registers a duplicate from it. + */ +export async function resolveNativeAppleConfiguration( + appId: string, + productionInstanceId: string, + config: Record, + descriptors: readonly OAuthProviderDescriptor[], +): Promise { + const descriptor = descriptors.find((candidate) => candidate.provider === "apple"); + const bundleId = descriptor && nativeAppleBundleId(config, descriptor); + if (!descriptor || !bundleId) return undefined; + try { + const [iosApplications, nativeSettings] = await Promise.all([ + listIOSApplications(appId, productionInstanceId), + getNativeSettings(appId, productionInstanceId), + ]); + return inspectNativeAppleConfiguration(config, descriptor, iosApplications, nativeSettings); + } catch (error) { + if (error instanceof UserAbortError) throw error; + log.debug(`Could not read production Native Application settings: ${errorMessage(error)}`); + return { status: "verification-unavailable", bundleId }; + } +} + /** * Read the deploy's live state: the production domain, the providers enabled * in development, the production configuration and the domain status. @@ -312,52 +333,26 @@ export async function resolveLiveDeploySnapshot( const { descriptors: oauthProviderDescriptors, unsupported } = oauth; const oauthProviders = oauthProviderDescriptors.map((descriptor) => descriptor.provider); - const { nativeAppleConfiguration, completedOAuthProviders, deployStatus, live } = - await withSpinner("Reading production configuration...", async () => { + const { nativeApple, completedOAuthProviders, deployStatus, live } = await withSpinner( + "Reading production configuration...", + async () => { const configRead = Promise.resolve(fetchInstanceConfig(ctx.appId, productionInstanceId)).then( async (config) => { - const nativeAppleDescriptor = oauthProviderDescriptors.find( - (descriptor) => descriptor.provider === "apple", + const nativeApple = await resolveNativeAppleConfiguration( + ctx.appId, + productionInstanceId, + config, + oauthProviderDescriptors, ); - const preliminaryNativeAppleConfiguration = nativeAppleDescriptor - ? inspectNativeAppleConfiguration(config, nativeAppleDescriptor, []) - : undefined; - let nativeAppleConfiguration = preliminaryNativeAppleConfiguration; - if ( - nativeAppleDescriptor && - preliminaryNativeAppleConfiguration?.status === "registration-missing" - ) { - try { - const [iosApplications, nativeSettings] = await Promise.all([ - listIOSApplications(ctx.appId, productionInstanceId), - getNativeSettings(ctx.appId, productionInstanceId), - ]); - nativeAppleConfiguration = inspectNativeAppleConfiguration( - config, - 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(config, descriptor) || - (descriptor.provider === "apple" && nativeAppleConfiguration?.status === "ready"), + (descriptor.provider === "apple" && nativeApple?.status === "ready"), ) .map((descriptor) => descriptor.provider); recordOAuthObservation({ oauthProviders, completedOAuthProviders }); - return { nativeAppleConfiguration, completedOAuthProviders }; + return { nativeApple, completedOAuthProviders }; }, ); const statusRead = loadInitialDeployStatus(ctx.appId, domain.id, options).then((read) => { @@ -374,7 +369,8 @@ export async function resolveLiveDeploySnapshot( statusRead, ]); return { ...oauthReadiness, deployStatus, live }; - }); + }, + ); const pendingOAuthDescriptor = oauthProviderDescriptors.find( (descriptor) => !completedOAuthProviders.includes(descriptor.provider), ); @@ -394,19 +390,15 @@ export async function resolveLiveDeploySnapshot( live, unsupportedOAuthProviderCount: unsupported.length, unsupportedOAuthProviders: unsupported, - ...(nativeAppleConfiguration && "bundleId" in nativeAppleConfiguration - ? { nativeAppleBundleId: nativeAppleConfiguration.bundleId } - : {}), - ...(nativeAppleConfiguration && - "bundleId" in nativeAppleConfiguration && - isNativeAppleReadinessIssue(nativeAppleConfiguration.status) - ? { - nativeAppleReadinessIssue: { - bundleId: nativeAppleConfiguration.bundleId, - reason: nativeAppleConfiguration.status, - }, - } - : {}), + nativeApple, + nativeAppleReadinessIssue: + nativeApple && nativeApple.status !== "ready" + ? { + bundleId: nativeApple.bundleId, + reason: nativeApple.status, + dashboardUrl: nativeApplicationsDashboardUrl(ctx.appId, productionInstanceId), + } + : undefined, }; const domainComplete = deployStatus.status === "complete"; @@ -621,56 +613,6 @@ export function deployNextStep(report: DeployStatusFacts): DeployNextStep { } } -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 @@ -687,6 +629,10 @@ export function agentNextAction(step: DeployNextStep): string { url ? ` Ask the user to visit the Clerk Dashboard domains page, or offer to open it: ${url}` : ""; + const nativeApple = + "nativeAppleReadinessIssue" in step && step.nativeAppleReadinessIssue + ? ` ${nativeAppleGuidance(step.nativeAppleReadinessIssue)}` + : ""; switch (step.kind) { case "not_started": @@ -729,7 +675,7 @@ export function agentNextAction(step: DeployNextStep): string { ? ` These OAuth providers are also missing production credentials: ${hostedPending.join(", ")}.` : ""; return ( - `Domain verified, but setup is incomplete. ${nativeAppleReadinessNextAction(step.nativeAppleReadinessIssue)}` + + `Domain verified, but setup is incomplete.${nativeApple}` + hostedAction + " After resolving those items, run `clerk deploy status` again." + unsupported(step.oauthUnsupported) @@ -748,9 +694,7 @@ export function agentNextAction(step: DeployNextStep): string { `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) + - (step.nativeAppleReadinessIssue - ? ` ${nativeAppleReadinessNextAction(step.nativeAppleReadinessIssue)}` - : "") + nativeApple ); case "records_unavailable": // The report has nothing to hand over; the Dashboard clause carries the @@ -760,9 +704,7 @@ export function agentNextAction(step: DeployNextStep): string { `Find the records to add on the Domains page in the Clerk Dashboard, then re-run ` + `\`clerk deploy status --wait\`.` + domains(step.domainsUrl) + - (step.nativeAppleReadinessIssue - ? ` ${nativeAppleReadinessNextAction(step.nativeAppleReadinessIssue)}` - : "") + nativeApple ); case "ssl_pending": // Records are verified; the certificate is Clerk's side and nobody can @@ -771,18 +713,14 @@ export function agentNextAction(step: DeployNextStep): string { `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) + - (step.nativeAppleReadinessIssue - ? ` ${nativeAppleReadinessNextAction(step.nativeAppleReadinessIssue)}` - : "") + nativeApple ); 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) + - (step.nativeAppleReadinessIssue - ? ` ${nativeAppleReadinessNextAction(step.nativeAppleReadinessIssue)}` - : "") + nativeApple ); } } diff --git a/packages/cli-core/src/commands/doctor/checks.ts b/packages/cli-core/src/commands/doctor/checks.ts index f75d1b93b..95cfa2b56 100644 --- a/packages/cli-core/src/commands/doctor/checks.ts +++ b/packages/cli-core/src/commands/doctor/checks.ts @@ -200,12 +200,6 @@ export async function checkTokenValid(ctx: DoctorContext): Promise } const storedToken = await ctx.getToken(); if (!storedToken) { - if (await ctx.hasAccountCredentials()) { - return check.warn("Account credentials are configured but could not be verified", { - remedy: "Check your Clerk authentication and rerun `clerk doctor`.", - fixable: false, - }); - } const keyless = await ctx.getKeylessTarget(); return keyless ? check.pass("No account session — not required for this accountless application") @@ -219,36 +213,14 @@ export async function checkTokenValid(ctx: DoctorContext): Promise return check.pass(`Authenticated as ${userInfo.email}`); } catch (error) { if (isAuthError(error)) { - // The OAuth userinfo surface is not available in every environment that - // can accept the same account credential through PLAPI. Verify it with - // an account-scoped application-list request: unlike getApplication(), - // this does not depend on the current directory being linked or its - // linked application continuing to exist. - try { - await ctx.verifyAccountAccess(); - return check.pass("Account access verified through the Clerk API"); - } catch (verificationError) { - if (!isAuthError(verificationError)) { - if (verificationError instanceof PlapiError) { - const unavailable = verificationError.status === 404 ? "endpoint" : "API"; - return check.warn( - `Could not verify authentication — Clerk ${unavailable} unavailable`, - { - detail: errorMessage(verificationError), - remedy: - "Check the Clerk environment and service status, then rerun `clerk doctor`.", - fixable: false, - }, - ); - } - - return check.warn("Could not reach Clerk to verify authentication — network issue", { - detail: errorMessage(verificationError), - remedy: "Check your network connection, then rerun `clerk doctor`.", - fixable: false, - }); - } - } + // OAuth userinfo isn't available in every environment that accepts the + // same session through the Platform API. Only a confirmed account read + // overrides the rejection; anything else keeps the expired result. + const verified = await ctx.verifyAccountAccess().then( + () => true, + () => false, + ); + if (verified) return check.pass("Account access verified through the Clerk API"); // Same fallback whoami uses: an expired session doesn't strand a keyless // project, so don't tell the user their setup is broken. diff --git a/packages/cli-core/src/commands/doctor/context.test.ts b/packages/cli-core/src/commands/doctor/context.test.ts index 6fbf07d0d..17f9a119d 100644 --- a/packages/cli-core/src/commands/doctor/context.test.ts +++ b/packages/cli-core/src/commands/doctor/context.test.ts @@ -188,7 +188,7 @@ describe("createDoctorContext", () => { const ctx = createDoctorContext(); expect(await ctx.getApplication()).toEqual(mockAppResponse); expect(mockFetch).toHaveBeenCalledTimes(1); - expect(String(mockFetch.mock.calls[0]?.[0])).not.toContain("include_secret_keys"); + expect(String(mockFetch.mock.calls[0]?.[0])).toContain("include_secret_keys=false"); }); test("returns null when no profile", async () => { diff --git a/packages/cli-core/src/commands/doctor/context.ts b/packages/cli-core/src/commands/doctor/context.ts index 28112f360..13511d777 100644 --- a/packages/cli-core/src/commands/doctor/context.ts +++ b/packages/cli-core/src/commands/doctor/context.ts @@ -73,10 +73,8 @@ export function createDoctorContext(): DoctorContext { if (!(await ctx.hasAccountCredentials())) return null; const resolved = await ctx.getProfile(); if (!resolved) return null; - // Doctor only needs application and instance identity. Keeping - // secret keys out of this long-lived, shared diagnostic context - // prevents unrelated checks from retaining credentials they never - // use (including the iOS checks below). + // Doctor only needs application and instance identity, so keep + // secret keys out of this shared diagnostic context. return fetchApplication(resolved.profile.appId, { includeSecretKeys: false }); })(); } diff --git a/packages/cli-core/src/commands/doctor/doctor.test.ts b/packages/cli-core/src/commands/doctor/doctor.test.ts index 4bb8d143a..925c5bea3 100644 --- a/packages/cli-core/src/commands/doctor/doctor.test.ts +++ b/packages/cli-core/src/commands/doctor/doctor.test.ts @@ -488,13 +488,16 @@ describe("checkTokenValid", () => { }); }); - for (const status of [401, 403]) { - test(`fails when both userinfo and the account-scoped API reject the credential with ${status}`, async () => { + test.each([ + { name: "401", error: new ApiError(401, "Unauthorized") }, + { name: "403", error: new ApiError(403, "Forbidden") }, + { name: "an outage", error: PlapiError.fromBody(503, "Service unavailable") }, + { name: "a network failure", error: new TypeError("fetch failed") }, + ])( + "keeps the expired result when the account-scoped fallback fails with $name", + async ({ error }) => { mockUserInfoError = new ApiError(401, "Unauthorized"); - const ctx = createMockContext({ - token: "expired_token", - accountAccessError: new ApiError(status, "Unauthorized"), - }); + const ctx = createMockContext({ token: "expired_token", accountAccessError: error }); const result = await checkTokenValid(ctx); expectCheck(result, { name: "Authentication valid", @@ -503,67 +506,8 @@ describe("checkTokenValid", () => { remedy: "clerk auth login", fix: true, }); - }); - } - - test("does not mislabel a missing account verification endpoint as an expired token", async () => { - mockUserInfoError = new ApiError(401, "Unauthorized"); - const ctx = createMockContext({ - token: "local_token", - accountAccessError: PlapiError.fromBody(404, "Not found"), - }); - - const result = await checkTokenValid(ctx); - - expectCheck(result, { - name: "Authentication valid", - status: "warn", - message: ["Could not verify authentication", "endpoint unavailable"], - messageNot: "expired", - detail: "Not found", - remedy: "Clerk environment", - fix: false, - }); - }); - - test("reports a Clerk API outage without mislabeling the token as expired", async () => { - mockUserInfoError = new ApiError(401, "Unauthorized"); - const ctx = createMockContext({ - token: "local_token", - accountAccessError: PlapiError.fromBody(503, "Service unavailable"), - }); - - const result = await checkTokenValid(ctx); - - expectCheck(result, { - name: "Authentication valid", - status: "warn", - message: ["Could not verify authentication", "API unavailable"], - messageNot: "expired", - detail: "Service unavailable", - fix: false, - }); - }); - - test("reports a fallback transport failure as a network issue", async () => { - mockUserInfoError = new ApiError(401, "Unauthorized"); - const ctx = createMockContext({ - token: "local_token", - accountAccessError: new TypeError("fetch failed"), - }); - - const result = await checkTokenValid(ctx); - - expectCheck(result, { - name: "Authentication valid", - status: "warn", - message: ["Could not reach Clerk", "network issue"], - messageNot: "expired", - detail: "fetch failed", - remedy: "network connection", - fix: false, - }); - }); + }, + ); test("fail when refreshing the stored session requires re-authentication", async () => { const ctx = createMockContext({ diff --git a/packages/cli-core/src/commands/webhooks/relay-client.ts b/packages/cli-core/src/commands/webhooks/relay-client.ts index ab41ef5cf..192cb623a 100644 --- a/packages/cli-core/src/commands/webhooks/relay-client.ts +++ b/packages/cli-core/src/commands/webhooks/relay-client.ts @@ -153,7 +153,7 @@ export class RelayClient implements IRelayClient { this.probeTimer = setInterval(() => { if (Date.now() - this.lastActivityAt < RELAY_SILENCE_TIMEOUT_MS) return; try { - (ws as import("bun").WebSocket).ping(); + ws.ping(); this.lastActivityAt = Date.now(); } catch { ws.close(); diff --git a/packages/cli-core/src/lib/apple-native-identity.ts b/packages/cli-core/src/lib/apple-native-identity.ts index 3e7645166..3cf22af0f 100644 --- a/packages/cli-core/src/lib/apple-native-identity.ts +++ b/packages/cli-core/src/lib/apple-native-identity.ts @@ -1,19 +1,8 @@ /** - * Apple treats Bundle IDs as case-insensitive. Keep the original spelling for - * display and API writes, but use this ASCII-only identity form anywhere a - * Bundle ID participates in matching or persisted retry identity. + * Apple treats Bundle IDs as case-insensitive, while Clerk matches native + * registrations exactly. Use this to spot a registration that differs only in + * letter case; keep the original spelling for display and API writes. */ -export function normalizeBundleIdentifierIdentity(bundleIdentifier: string): string { - return bundleIdentifier.replace(/[A-Z]/g, (character) => character.toLowerCase()); -} - -export function bundleIdentifiersEqual( - left: string | undefined, - right: string | undefined, -): boolean { - return ( - left != null && - right != null && - normalizeBundleIdentifierIdentity(left) === normalizeBundleIdentifierIdentity(right) - ); +export function bundleIdentifiersEqual(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); } diff --git a/packages/cli-core/src/lib/credential-store.test.ts b/packages/cli-core/src/lib/credential-store.test.ts index d7d7b584c..6e3d2a3d1 100644 --- a/packages/cli-core/src/lib/credential-store.test.ts +++ b/packages/cli-core/src/lib/credential-store.test.ts @@ -193,57 +193,51 @@ describe("credential-store", () => { }); test("getValidToken coalesces concurrent refreshes in one process", async () => { - const session = { + await storeToken({ accessToken: "expired-access-token", refreshToken: "refresh-token", expiresAt: Date.now() - 60_000, tokenType: "Bearer", - }; - await storeToken(session); - - let releaseRefresh: - | ((value: { - access_token: string; - token_type: string; - expires_in: number; - refresh_token: string; - }) => void) - | null = null; - let markRefreshStarted: (() => void) | null = null; - const refreshStarted = new Promise((resolve) => { - markRefreshStarted = resolve; }); - const refreshResult = new Promise<{ + const refresh = Promise.withResolvers<{ access_token: string; token_type: string; expires_in: number; refresh_token: string; - }>((resolve) => { - releaseRefresh = resolve; - }); - mockRefreshAccessToken.mockImplementation(() => { - markRefreshStarted!(); - return refreshResult; - }); + }>(); + mockRefreshAccessToken.mockImplementation(() => refresh.promise); - const first = getValidToken(); - const second = getValidToken(); + const tokens = Promise.all([getValidToken(), getValidToken()]); + refresh.resolve({ + access_token: "refreshed-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rotated-refresh-token", + }); - await refreshStarted; + expect(await tokens).toEqual(["refreshed-access-token", "refreshed-access-token"]); expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + }); - releaseRefresh!({ + test("getValidToken retries after a shared refresh fails", async () => { + await storeToken({ + accessToken: "expired-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() - 60_000, + tokenType: "Bearer", + }); + mockRefreshAccessToken.mockRejectedValueOnce(new Error("network down")); + + await expect(getValidToken()).rejects.toThrow(); + mockRefreshAccessToken.mockResolvedValueOnce({ access_token: "refreshed-access-token", token_type: "Bearer", expires_in: 3600, refresh_token: "rotated-refresh-token", }); - expect(await Promise.all([first, second])).toEqual([ - "refreshed-access-token", - "refreshed-access-token", - ]); - expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + expect(await getValidToken()).toBe("refreshed-access-token"); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(2); }); test("concurrent callers share invalid_grant recovery from another process", async () => { diff --git a/packages/cli-core/src/lib/credential-store.ts b/packages/cli-core/src/lib/credential-store.ts index d22c38706..e62170bca 100644 --- a/packages/cli-core/src/lib/credential-store.ts +++ b/packages/cli-core/src/lib/credential-store.ts @@ -500,14 +500,9 @@ async function resolveValidToken(): Promise { } /** - * Resolve a usable OAuth access token once per process at a time. - * - * A refresh token rotates when it is redeemed. Without this in-flight guard, - * concurrent API calls can both read the same expired session and attempt to - * refresh it: one succeeds while the other receives `invalid_grant`. The - * cross-process recovery in `refreshStoredSession` still handles a different - * CLI process winning that race; this guard prevents the race between callers - * that already share this module instance. + * Refresh tokens rotate when redeemed, so concurrent callers in this process + * share one in-flight resolution instead of racing each other to + * `invalid_grant`. `refreshStoredSession` still handles another process winning. */ export async function getValidToken(): Promise { if (validTokenPromise) return validTokenPromise; diff --git a/packages/cli-core/src/lib/plapi-native.test.ts b/packages/cli-core/src/lib/plapi-native.test.ts deleted file mode 100644 index a86835441..000000000 --- a/packages/cli-core/src/lib/plapi-native.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -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"); - -function captureNativeRequests(responseBody: unknown): Request[] { - const requests: Request[] = []; - stubFetch(async (input, init) => { - requests.push( - input instanceof Request ? new Request(input, init) : new Request(input.toString(), init), - ); - return Response.json(responseBody); - }); - return requests; -} - -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 () => { - const responseBody = { object: "native_settings" as const, api_enabled: false }; - const requests = captureNativeRequests(responseBody); - - const result = await getNativeSettings("app_abc", "development"); - - expect(requests).toHaveLength(1); - const request = requests[0]!; - expect(request.method).toBe("GET"); - expect(request.url).toBe( - "https://api.clerk.com/v1/platform/applications/app_abc/instances/development/native_settings", - ); - expect(request.headers.get("Authorization")).toBe("Bearer ak_test_client_token"); - expect(request.headers.get("Accept")).toBe("application/json"); - expect(request.headers.has("Idempotency-Key")).toBe(false); - expect(result).toEqual(responseBody); - }); - - test("enables Native API with an idempotency key", async () => { - const responseBody = { object: "native_settings" as const, api_enabled: true }; - const requests = captureNativeRequests(responseBody); - - const result = await enableNativeApi("app_abc", "ins_dev_123", { - idempotencyKey: "enable-native-api-123", - }); - - expect(requests).toHaveLength(1); - const request = requests[0]!; - expect(request.method).toBe("PATCH"); - expect(request.url).toBe( - "https://api.clerk.com/v1/platform/applications/app_abc/instances/ins_dev_123/native_settings", - ); - expect(await request.json()).toEqual({ api_enabled: true }); - expect(request.headers.get("Content-Type")).toBe("application/json"); - expect(request.headers.get("Idempotency-Key")).toBe("enable-native-api-123"); - expect(result).toEqual(responseBody); - }); - - describe.each([ - { method: "GET", request: () => getNativeSettings("app_abc", "development") }, - { - method: "PATCH", - request: () => - enableNativeApi("app_abc", "development", { idempotencyKey: "enable-native-api-123" }), - }, - ])("Native settings $method", ({ request }) => { - 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", async ({ body }) => { - stubFetch(async () => Response.json(body)); - await expect(request()).rejects.toMatchObject({ - name: "CliError", - code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, - }); - }); - }); - - test("lists the public iOS application DTOs", async () => { - 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", - }, - ]; - const requests = captureNativeRequests(responseBody); - - const result = await listIOSApplications("app_abc", "ins_dev_123"); - - expect(requests).toHaveLength(1); - const request = requests[0]!; - expect(request.method).toBe("GET"); - expect(request.url).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 () => { - 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, - }; - const requests = captureNativeRequests(responseBody); - - const result = await createIOSApplication( - "app_abc", - "development", - { appIdPrefix: "ABCD123456", bundleId: "com.example.coolappy" }, - { idempotencyKey: "create-ios-app-123" }, - ); - - expect(requests).toHaveLength(1); - const request = requests[0]!; - expect(request.method).toBe("POST"); - expect(request.url).toBe( - "https://api.clerk.com/v1/platform/applications/app_abc/instances/development/native_applications/ios", - ); - expect(await request.json()).toEqual({ - app_id_prefix: "ABCD123456", - bundle_id: "com.example.coolappy", - }); - expect(request.headers.get("Content-Type")).toBe("application/json"); - expect(request.headers.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 2af068f8a..69027090e 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -19,6 +19,10 @@ const { getApplicationDomainStatus, triggerApplicationDomainDNSCheck, listApplicationDomains, + getNativeSettings, + enableNativeApi, + listIOSApplications, + createIOSApplication, } = await import("./plapi.ts"); const { AuthError, ERROR_CODE, PlapiError } = await import("./errors.ts"); @@ -322,7 +326,7 @@ describe("plapi", () => { expect(url.searchParams.get("include_secret_keys")).toBe("true"); }); - test("omits include_secret_keys when the caller only needs public metadata", async () => { + test("sends include_secret_keys=false when the caller opts out", async () => { let requestedUrl = ""; stubFetch(async (input) => { requestedUrl = input.toString(); @@ -330,9 +334,7 @@ describe("plapi", () => { }); 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); + expect(new URL(requestedUrl).searchParams.get("include_secret_keys")).toBe("false"); }); test("returns parsed application JSON", async () => { @@ -342,45 +344,6 @@ 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 })); @@ -630,4 +593,84 @@ describe("plapi", () => { expect(result).toEqual(responseBody); }); }); + + describe("native applications", () => { + function captureRequests(body: unknown): Request[] { + const requests: Request[] = []; + stubFetch(async (input, init) => { + requests.push(new Request(input.toString(), init)); + return Response.json(body); + }); + return requests; + } + + const iosApp = { + object: "ios_application" as const, + id: "iosapp_1", + app_id_prefix: "ABCD123456", + bundle_id: "com.example.app", + }; + + test("reads native settings", async () => { + const requests = captureRequests({ object: "native_settings", api_enabled: false }); + + expect(await getNativeSettings("app_1", "ins_1")).toEqual({ + object: "native_settings", + api_enabled: false, + }); + expect(requests[0]?.method).toBe("GET"); + expect(requests[0]?.url).toBe( + "https://api.clerk.com/v1/platform/applications/app_1/instances/ins_1/native_settings", + ); + }); + + test("enables Native API", async () => { + const requests = captureRequests({ object: "native_settings", api_enabled: true }); + + await enableNativeApi("app_1", "ins_1"); + expect(requests[0]?.method).toBe("PATCH"); + expect(await requests[0]?.json()).toEqual({ api_enabled: true }); + }); + + test("lists iOS registrations", async () => { + const requests = captureRequests([{ ...iosApp, created_at: 1 }]); + + expect(await listIOSApplications("app_1", "ins_1")).toEqual([iosApp]); + expect(requests[0]?.url).toBe( + "https://api.clerk.com/v1/platform/applications/app_1/instances/ins_1/native_applications/ios", + ); + }); + + test("creates an iOS registration with an idempotency key", async () => { + const requests = captureRequests(iosApp); + + await createIOSApplication( + "app_1", + "ins_1", + { app_id_prefix: "ABCD123456", bundle_id: "com.example.app" }, + "key_1", + ); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.headers.get("Idempotency-Key")).toBe("key_1"); + expect(await requests[0]?.json()).toEqual({ + app_id_prefix: "ABCD123456", + bundle_id: "com.example.app", + }); + }); + + test.each([ + { name: "native settings", request: () => getNativeSettings("app_1", "ins_1"), body: [] }, + { + name: "an iOS registration list", + request: () => listIOSApplications("app_1", "ins_1"), + body: [{ ...iosApp, bundle_id: undefined }], + }, + ])("rejects unexpected $name", async ({ request, body }) => { + stubFetch(async () => Response.json(body)); + + await expect(request()).rejects.toMatchObject({ + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + }); }); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 5d2404dbc..de84d2904 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -69,14 +69,11 @@ 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. */ -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 { +async function plapiFetch( + method: string, + url: URL, + init?: { body?: string; idempotencyKey?: string; ifMatch?: string }, +): Promise { const token = await getAuthToken(); const headers: Record = { Authorization: `Bearer ${token}`, @@ -170,54 +167,6 @@ 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; @@ -286,182 +235,14 @@ export type TriggerDNSCheckResponse = DomainStatusResponse & { last_run_at: number | null; }; -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 = {}, + options?: { includeSecretKeys?: boolean }, ): Promise { const url = new URL(`/v1/platform/applications/${applicationId}`, getPlapiBaseUrl()); - if (options.includeSecretKeys !== false) { - url.searchParams.set("include_secret_keys", "true"); - } + url.searchParams.set("include_secret_keys", String(options?.includeSecretKeys ?? true)); const response = await plapiFetch("GET", url); - return readApplicationResponse(response); + return response.json() as Promise; } export async function listApplicationDomains( @@ -505,18 +286,12 @@ 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?: InstanceConfigMutationOptions, + options?: { destructive?: boolean; dryRun?: boolean; ifMatch?: string }, ): Promise> { const url = new URL( `/v1/platform/applications/${applicationId}/instances/${instanceId}/config`, @@ -539,14 +314,14 @@ export const putInstanceConfig = async ( applicationId: string, instanceId: string, config: Record, - options?: InstanceConfigMutationOptions, + options?: { destructive?: boolean; dryRun?: boolean; ifMatch?: string }, ) => sendInstanceConfig("PUT", applicationId, instanceId, config, options); export const patchInstanceConfig = async ( applicationId: string, instanceId: string, config: Record, - options?: InstanceConfigMutationOptions, + options?: { destructive?: boolean; dryRun?: boolean; ifMatch?: string }, ) => sendInstanceConfig("PATCH", applicationId, instanceId, config, options); export async function createApplication(name: string): Promise { @@ -568,3 +343,94 @@ export async function listApplications(): Promise { const response = await plapiFetch("GET", url); return response.json() as Promise; } + +export type NativeSettings = { object: "native_settings"; api_enabled: boolean }; +export type IOSApplication = { + object: "ios_application"; + id: string; + app_id_prefix: string; + bundle_id: string; +}; + +function nativeUrl(appId: string, instanceId: string, path: string): URL { + return new URL( + `/v1/platform/applications/${encodeURIComponent(appId)}/instances/${encodeURIComponent(instanceId)}/${path}`, + getPlapiBaseUrl(), + ); +} + +function unexpectedResponse(resource: string): CliError { + return new CliError(`Clerk returned an unexpected ${resource} response.`, { + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); +} + +async function readNativeSettings(response: Response): Promise { + const value = (await response.json()) as Partial | null; + if (value?.object !== "native_settings" || typeof value.api_enabled !== "boolean") { + throw unexpectedResponse("Native API settings"); + } + return { object: "native_settings", api_enabled: value.api_enabled }; +} + +function readIOSApplication(value: unknown): IOSApplication { + const app = value as Partial | null; + if ( + app?.object !== "ios_application" || + typeof app.id !== "string" || + typeof app.app_id_prefix !== "string" || + typeof app.bundle_id !== "string" + ) { + throw unexpectedResponse("iOS registration"); + } + return { + object: "ios_application", + id: app.id, + app_id_prefix: app.app_id_prefix, + bundle_id: app.bundle_id, + }; +} + +export async function getNativeSettings( + appId: string, + instanceId: string, +): Promise { + return readNativeSettings( + await plapiFetch("GET", nativeUrl(appId, instanceId, "native_settings")), + ); +} + +export async function enableNativeApi(appId: string, instanceId: string): Promise { + return readNativeSettings( + await plapiFetch("PATCH", nativeUrl(appId, instanceId, "native_settings"), { + body: JSON.stringify({ api_enabled: true }), + }), + ); +} + +export async function listIOSApplications( + appId: string, + instanceId: string, +): Promise { + const response = await plapiFetch("GET", nativeUrl(appId, instanceId, "native_applications/ios")); + const apps: unknown = await response.json(); + if (!Array.isArray(apps)) throw unexpectedResponse("iOS registrations"); + return apps.map(readIOSApplication); +} + +export async function createIOSApplication( + appId: string, + instanceId: string, + params: { app_id_prefix: string; bundle_id: string }, + idempotencyKey: string, +): Promise { + const response = await plapiFetch( + "POST", + nativeUrl(appId, instanceId, "native_applications/ios"), + { + body: JSON.stringify(params), + idempotencyKey, + }, + ); + return readIOSApplication(await response.json()); +} From 714866d25fa37d106b658bdcdfa62aa051af1611 Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 5 Oct 2026 15:48:04 -0400 Subject: [PATCH 5/6] fix(deploy): keep native-only Apple intent when production is cloned Creating production clones Apple without its provider settings, so a native-only development connection arrives with neither a Bundle ID nor web credentials, and deploy fell through to the web credential wizard without native guidance. Deploy now carries development's native Bundle ID and, when production Apple has neither a Bundle ID nor hosted credentials, reports bundle-id-missing with guidance to set the Bundle ID and register the app. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/deploy/copy.ts | 2 + .../src/commands/deploy/index.test.ts | 23 +++++++++- .../cli-core/src/commands/deploy/providers.ts | 14 ++++-- .../src/commands/deploy/status.test.ts | 46 +++++++++++++++---- .../cli-core/src/commands/deploy/status.ts | 21 ++++++++- 5 files changed, 89 insertions(+), 17 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index b51d2dd9f..b8a4bcc1f 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -620,6 +620,8 @@ export function nativeApplicationsDashboardUrl( export function nativeAppleGuidance(issue: NativeAppleReadinessIssue): string { const { bundleId, dashboardUrl } = issue; switch (issue.reason) { + case "bundle-id-missing": + return `Production Sign in with Apple has no Bundle ID, so native sign-in for ${bundleId} isn't set up there yet. In the Clerk Dashboard, set the production Apple connection's Bundle ID to ${bundleId}, then register the app and enable Native API at ${dashboardUrl}`; case "authentication-disabled": return `Sign in with Apple for ${bundleId} is not enabled for authentication in production. Enable it in the Apple connection settings in the Clerk Dashboard.`; case "registration-missing": diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 33217eef7..ead42f388 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1398,14 +1398,16 @@ describe("deploy", () => { bundle_id: "com.example.native", }; - async function nativeAppleDeploy(): Promise { + async function nativeAppleDeploy( + productionConnection: Record = nativeConnection, + ): Promise { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, }); mockLiveProduction({ instanceId: "ins_prod_native_apple", developmentConfig: { connection_oauth_apple: nativeConnection }, - productionConfig: { connection_oauth_apple: nativeConnection }, + productionConfig: { connection_oauth_apple: productionConnection }, }); mockIsAgent.mockReturnValue(false); } @@ -1440,6 +1442,23 @@ describe("deploy", () => { expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); }); + test("explains a production Apple connection cloned without its Bundle ID", async () => { + await nativeAppleDeploy({ enabled: true, authenticatable: true }); + mockConfirm.mockResolvedValueOnce(false); + + const error = await runDeploy({}).catch((caught: unknown) => caught as CliError); + + expect(mockConfirm).toHaveBeenCalledWith({ + message: expect.stringContaining("Also configure Apple web sign-in credentials?"), + default: false, + }); + expect(error?.message).toContain("Deploy paused at: Apple OAuth credential setup"); + expect(flat(stripAnsi(captured.err))).toContain( + "Production Sign in with Apple has no Bundle ID", + ); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + }); + test("saves hosted credentials unchanged when the user adds web sign-in", async () => { await nativeAppleDeploy(); mockConfirm.mockResolvedValueOnce(true); diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index ca980d46b..185eb4573 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -59,6 +59,8 @@ export type OAuthProviderDescriptor = { fields: OAuthPromptField[]; requiredCredentialKeys: string[]; credentialSources: Array<"manual" | "google-json">; + /** Development's native-only Apple Bundle ID; creating production drops it from the clone. */ + developmentNativeBundleId?: string; }; /** @@ -71,6 +73,7 @@ export type OAuthProviderDescriptorResult = { export type NativeAppleStatus = | "ready" + | "bundle-id-missing" | "authentication-disabled" | "registration-missing" | "registration-bundle-case-mismatch" @@ -249,12 +252,17 @@ export function nativeAppleBundleId( const value = config[descriptor.configKey]; if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const providerConfig = value as Record; - const hosted = ["client_id", "client_secret", "team_id", "key_id"].some((key) => { + if (hasHostedAppleIdentifier(providerConfig) || typeof providerConfig.bundle_id !== "string") + return undefined; + return providerConfig.bundle_id.trim() || undefined; +} + +/** Whether Apple has any hosted (web) identifier, which makes the connection hosted. */ +export function hasHostedAppleIdentifier(providerConfig: Record): boolean { + return ["client_id", "client_secret", "team_id", "key_id"].some((key) => { const field = providerConfig[key]; return typeof field === "string" && field.trim().length > 0; }); - if (hosted || typeof providerConfig.bundle_id !== "string") return undefined; - return providerConfig.bundle_id.trim() || undefined; } /** diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts index cf8e9a0b0..5c10e716c 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -235,12 +235,15 @@ describe("resolveDeployState", () => { bundle_id: "com.example.native", }; - function mockProductionApple(connection: Record): void { + function mockProductionApple( + connection: Record, + development: Record = { enabled: true }, + ): void { mockActiveProductionEnvironment(); mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => instanceId === "ins_prod" ? { connection_oauth_apple: connection } - : { connection_oauth_apple: { enabled: true } }, + : { connection_oauth_apple: development }, ); } @@ -286,6 +289,26 @@ describe("resolveDeployState", () => { } }); + test("keeps development's native-only intent when production was cloned without a Bundle ID", async () => { + // Creating production clones Apple without its provider settings. + mockProductionApple({ enabled: true, authenticatable: true }, nativeConnection); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.pending).toEqual({ type: "oauth", provider: "apple" }); + expect(state.snapshot.nativeAppleReadinessIssue).toMatchObject({ + bundleId: "com.example.native", + reason: "bundle-id-missing", + }); + const report = buildDeployStatusReport(state, null); + expect(report.nextAction).toContain("has no Bundle ID"); + expect(report.nextAction).not.toContain("missing production credentials: apple"); + } + expect(mockListIOSApplications).not.toHaveBeenCalled(); + }); + test("reports unverifiable rather than missing when native reads fail", async () => { mockProductionApple(nativeConnection); mockListIOSApplications.mockRejectedValue(new Error("native endpoint unavailable")); @@ -302,14 +325,17 @@ describe("resolveDeployState", () => { }); test("leaves hosted Apple credential-based without native reads", async () => { - mockProductionApple({ - enabled: true, - bundle_id: "com.example.native", - client_id: "com.example.web", - client_secret: "REDACTED", - team_id: "TEAM123456", - key_id: "KEY1234567", - }); + mockProductionApple( + { + enabled: true, + bundle_id: "com.example.native", + client_id: "com.example.web", + client_secret: "REDACTED", + team_id: "TEAM123456", + key_id: "KEY1234567", + }, + nativeConnection, + ); const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index 5baf3f466..3951d299b 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -34,6 +34,7 @@ import { buildOAuthProviderDescriptors, hasProviderRequiredCredentials, inspectNativeAppleConfiguration, + hasHostedAppleIdentifier, nativeAppleBundleId, type NativeAppleConfiguration, type NativeAppleReadinessIssue, @@ -271,6 +272,10 @@ export async function loadDevelopmentOAuthProviders( ? await fetchInstanceConfigSchema(ctx.appId, ctx.developmentInstanceId, schemaKeys) : { properties: {} }; const result = buildOAuthProviderDescriptors(providerSlugs, schema); + for (const descriptor of result.supported) { + const bundleId = nativeAppleBundleId(config, descriptor); + if (bundleId) descriptor.developmentNativeBundleId = bundleId; + } return { descriptors: result.supported, unsupported: result.unsupported, @@ -290,8 +295,20 @@ export async function resolveNativeAppleConfiguration( descriptors: readonly OAuthProviderDescriptor[], ): Promise { const descriptor = descriptors.find((candidate) => candidate.provider === "apple"); - const bundleId = descriptor && nativeAppleBundleId(config, descriptor); - if (!descriptor || !bundleId) return undefined; + if (!descriptor) return undefined; + const bundleId = nativeAppleBundleId(config, descriptor); + if (!bundleId) { + // Creating production clones Apple without its provider settings, so a native-only + // development connection arrives with neither a Bundle ID nor web credentials. + const production = config[descriptor.configKey]; + const hosted = + typeof production === "object" && + production !== null && + hasHostedAppleIdentifier(production as Record); + return descriptor.developmentNativeBundleId && !hosted + ? { status: "bundle-id-missing", bundleId: descriptor.developmentNativeBundleId } + : undefined; + } try { const [iosApplications, nativeSettings] = await Promise.all([ listIOSApplications(appId, productionInstanceId), From 915addcbf24257e70e2d2bc9c54ae2a37ada62d5 Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 5 Oct 2026 15:48:15 -0400 Subject: [PATCH 6/6] docs(deploy): document the bundle-id-missing readiness reason Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/deploy/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index a70632bd8..03366dc59 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -51,7 +51,7 @@ In agent mode, `clerk deploy status` emits JSON on stdout with: - `domainStatus`: per-component DNS, SSL, and email DNS status when a domain exists. - `pendingDnsRecords`: CNAME records still tied to pending DNS-backed checks, each with `host`, `value`, and Clerk's `required` flag (some targets are optional). - `oauth`: configured, pending, and unsupported provider slugs. -- `nativeAppleReadinessIssue`: present only when Apple is native-only (a `bundle_id` and no hosted credentials) and not yet ready in production. Holds the `bundleId`, a `reason` (`authentication-disabled`, `registration-missing`, `registration-ambiguous`, `registration-bundle-case-mismatch`, `native-api-disabled`, or `verification-unavailable`), and the production Native Applications `dashboardUrl`. `nextAction` includes the matching guidance. +- `nativeAppleReadinessIssue`: present only when Apple is native-only (a `bundle_id` and no hosted credentials) and not yet ready in production. Development's native-only connection counts when production Apple has neither a Bundle ID nor hosted credentials, as creating production leaves it. Holds the `bundleId`, a `reason` (`bundle-id-missing`, `authentication-disabled`, `registration-missing`, `registration-ambiguous`, `registration-bundle-case-mismatch`, `native-api-disabled`, or `verification-unavailable`), and the production Native Applications `dashboardUrl`. `nextAction` includes the matching guidance. - `urls`: the production instance's Dashboard page (`instance`) and its Domains page (`domains`), or `null` before a production instance exists. The same URLs appear in `nextAction` prose; this field is the one to read programmatically. - `nextAction`: the next step an agent should present to the user. While domain setup remains (`domain_provisioning` and `domain_pending`) it includes the Clerk Dashboard domains URL, and agents should ask whether to open that URL for the user; `not_started`, `interrupted`, and `oauth_pending` carry no URL, and `complete` carries the instance root instead. While DNS or email DNS records are unverified it says to add the records in `pendingDnsRecords` at the domain's DNS provider rather than to keep polling; if that list is empty (the API returned no CNAME targets) it says so and points at the Dashboard Domains page instead; when only SSL is pending it says to wait. At `complete` it says the production keys still have to reach the host (`clerk env pull --instance prod`, alongside the other Clerk variables in the env file) and to sign up on the domain to confirm — "complete" is Clerk's side only — and links the instance root (users, settings, billing) instead of the domains page, since nothing is left to monitor there. At `complete` and `oauth_pending` it also names any providers in `oauth.unsupported`, since `oauth.complete` covers only what the CLI manages and those providers' sign-in fails in production until configured in the Dashboard. `oauth_pending` carries no Domains URL (the domain is verified). Human mode prints its own sentence, rendered from the same classification of the report (`deployNextStep` in `status.ts`) rather than by rewriting the agent's: no unsupported-provider clause (the warning row above already says it), no "ask the user" (the reader is the user), no `--wait` (human mode always waits; the wizard is what resumes setup), and, when records are pending, the records themselves printed first so the sentence only says what happens once they are added. Before a production instance exists, human mode omits the OAuth row rather than printing "pending: none" for something that was never checked.