diff --git a/.changeset/native-apple-api.md b/.changeset/native-apple-api.md new file mode 100644 index 000000000..60856ab2c --- /dev/null +++ b/.changeset/native-apple-api.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +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/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index da251ca46..03366dc59 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. 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. @@ -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..b8a4bcc1f 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,34 @@ 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 "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": + 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 c094287c1..ead42f388 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,111 @@ describe("deploy", () => { ); }); + 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", + }; + + 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: productionConnection }, + }); + 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("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); + 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(mockPatchInstanceConfig).toHaveBeenCalledWith( + "app_xyz789", + "ins_prod_native_apple", + { + connection_oauth_apple: { + enabled: true, + client_id: "services-id", + team_id: "team-id", + key_id: "key-id", + client_secret: key, + }, + }, + ); + }); + }); + 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 +1543,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..d7489aff3 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -10,6 +10,7 @@ import { interruptedExitCode } from "../../lib/signals.ts"; import { setProfile } from "../../lib/config.ts"; import { createProductionInstance as apiCreateProductionInstance, + fetchInstanceConfig, patchInstanceConfig, type CnameTarget, type ProductionInstanceResponse, @@ -32,6 +33,8 @@ import { dnsRecords, domainsDashboardUrl, instanceDashboardUrl, + nativeAppleGuidance, + nativeApplicationsDashboardUrl, nextStepsBody, pendingCnameTargets, pausedOperationNotice, @@ -43,6 +46,7 @@ import { providerLabel, providerSetupIntro, showOAuthWalkthrough, + type NativeAppleConfiguration, type OAuthProvider, type OAuthProviderDescriptor, } from "./providers.ts"; @@ -53,6 +57,7 @@ import { collectCustomDomain, collectOAuthCredentials, confirmCreateProductionInstance, + confirmAppleWebCredentials, confirmExportBindZone, confirmProceed, } from "./prompts.ts"; @@ -69,6 +74,7 @@ import { resolveDeployState, resolveLiveApplicationContext, resolveLiveDeploySnapshot, + resolveNativeAppleConfiguration, waitForDeployStatus, type DeployProgressHandlers, type DeployStatusOutcome, @@ -335,6 +341,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { }, }, snapshot.oauthProviderDescriptors, + snapshot.nativeApple ?? null, ); snapshot.completedOAuthProviders = completed; } @@ -624,6 +631,8 @@ async function runOAuthSetup( ctx: DeployContext, state: DeployOperationState, descriptors: readonly OAuthProviderDescriptor[], + /** 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); @@ -652,6 +661,7 @@ async function runOAuthSetup( state.domain, productionInstanceId, state.frontendApiUrl, + nativeApple, ); if (!saved) { throwDeployPaused( @@ -699,7 +709,38 @@ async function collectAndSaveOAuthCredentials( domain: string, productionInstanceId: string, frontendApiUrl?: string, + nativeApple?: NativeAppleConfiguration | null, ): Promise { + 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); log.blank(); 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/providers.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts index b756b3860..47d3d940b 100644 --- a/packages/cli-core/src/commands/deploy/providers.test.ts +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -1,11 +1,13 @@ import { describe, expect, test } from "bun:test"; import { buildOAuthProviderDescriptors, + inspectNativeAppleConfiguration, providerFields, providerLabel, + type NativeAppleStatus, 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 +29,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 +60,19 @@ 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, + }; +} + describe("deploy OAuth provider descriptors", () => { test("builds a descriptor for public providers from schema and shared metadata", () => { const result = buildOAuthProviderDescriptors( @@ -147,22 +177,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 +205,89 @@ describe("deploy OAuth provider descriptors", () => { ]); }); + describe("native-only Apple readiness", () => { + const apple = descriptorByProvider( + buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ).supported, + "apple", + ); + const connection = { enabled: true, authenticatable: true, bundle_id: "com.example.app" }; + const registered = [iosApplication("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", + }, + { + 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"), + ], + 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.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(); + }, + ); + }); + 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..185eb4573 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"; @@ -53,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; }; /** @@ -63,6 +71,24 @@ export type OAuthProviderDescriptorResult = { unsupported: string[]; }; +export type NativeAppleStatus = + | "ready" + | "bundle-id-missing" + | "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; redirectLabel?: string; @@ -213,6 +239,65 @@ export function hasProviderRequiredCredentials( }); } +/** + * 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 nativeAppleBundleId( + config: Record, + descriptor: OAuthProviderDescriptor, +): string | undefined { + if (descriptor.provider !== "apple") return undefined; + const value = config[descriptor.configKey]; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const providerConfig = value as Record; + 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; + }); +} + +/** + * 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 }; + } + + 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 + ? { status: "ready", bundleId } + : { status: "native-api-disabled", bundleId }; +} + 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..ddafe4740 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -1117,6 +1117,29 @@ 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", + dashboardUrl: "https://dashboard.clerk.com/native-applications", + }, + }); + expect(line).toStartWith(domainGuidance); + expect(line).toContain("Native API is disabled"); + expect(line).toContain("https://dashboard.clerk.com/native-applications"); + }, + ); // 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 8cb522876..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, @@ -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 + ? ` ${nativeAppleGuidance(step.nativeAppleReadinessIssue)}` + : ""; switch (step.kind) { case "not_started": @@ -253,6 +257,14 @@ 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.${nativeApple}` + hostedAction; + } return ( `Domain verified, but these OAuth providers are missing production credentials: ` + `${step.oauthPending.join(", ")}. Run \`clerk deploy\` to finish setup.` @@ -263,25 +275,29 @@ 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 ); } } diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts index 3d659ba37..5c10e716c 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,132 @@ describe("resolveDeployState", () => { expect(state.snapshot.completedOAuthProviders).toEqual(["google"]); } }); + + 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, + development: Record = { enabled: true }, + ): void { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { connection_oauth_apple: connection } + : { connection_oauth_apple: development }, + ); + } + + test("is complete with an exact production registration", async () => { + mockProductionApple(nativeConnection); + mockListIOSApplications.mockResolvedValue([registration]); + + 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"); + }); + + 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 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")); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + 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("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", + }, + nativeConnection, + ); + + 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(); + }); + }); }); describe("waitForDeployStatus", () => { @@ -409,6 +587,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..3951d299b 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, @@ -21,6 +23,8 @@ import { classifyDomainPending, domainsDashboardUrl, instanceDashboardUrl, + nativeAppleGuidance, + nativeApplicationsDashboardUrl, pendingRecordComponents, type DeployComponentStatus, } from "./copy.ts"; @@ -29,6 +33,11 @@ import { OAUTH_KEY_PREFIX, buildOAuthProviderDescriptors, hasProviderRequiredCredentials, + inspectNativeAppleConfiguration, + hasHostedAppleIdentifier, + nativeAppleBundleId, + type NativeAppleConfiguration, + type NativeAppleReadinessIssue, type OAuthProvider, type OAuthProviderDescriptor, } from "./providers.ts"; @@ -88,6 +97,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 +128,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 +164,8 @@ export type LiveDeploySnapshot = Omit< live: boolean; unsupportedOAuthProviderCount: number; unsupportedOAuthProviders: string[]; + nativeApple?: NativeAppleConfiguration; + nativeAppleReadinessIssue?: NativeAppleReadinessIssue; }; export type DeployState = @@ -254,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, @@ -261,6 +283,45 @@ 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"); + 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), + 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. @@ -289,21 +350,26 @@ 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( + const { nativeApple, 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 nativeApple = await resolveNativeAppleConfiguration( + ctx.appId, + productionInstanceId, + config, + oauthProviderDescriptors, + ); + const completedOAuthProviders = oauthProviderDescriptors + .filter( + (descriptor) => + hasProviderRequiredCredentials(config, descriptor) || + (descriptor.provider === "apple" && nativeApple?.status === "ready"), + ) + .map((descriptor) => descriptor.provider); + recordOAuthObservation({ oauthProviders, completedOAuthProviders }); + return { nativeApple, completedOAuthProviders }; }, ); const statusRead = loadInitialDeployStatus(ctx.appId, domain.id, options).then((read) => { @@ -315,14 +381,13 @@ 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 }; + return { ...oauthReadiness, deployStatus, live }; }, ); - const completedOAuthProviders = completedProvidersIn(productionConfig); const pendingOAuthDescriptor = oauthProviderDescriptors.find( (descriptor) => !completedOAuthProviders.includes(descriptor.provider), ); @@ -342,6 +407,15 @@ export async function resolveLiveDeploySnapshot( live, unsupportedOAuthProviderCount: unsupported.length, unsupportedOAuthProviders: unsupported, + nativeApple, + nativeAppleReadinessIssue: + nativeApple && nativeApple.status !== "ready" + ? { + bundleId: nativeApple.bundleId, + reason: nativeApple.status, + dashboardUrl: nativeApplicationsDashboardUrl(ctx.appId, productionInstanceId), + } + : undefined, }; const domainComplete = deployStatus.status === "complete"; @@ -471,6 +545,7 @@ function buildDeployStatusFacts( pending: oauthPending, unsupported: [...snapshot.unsupportedOAuthProviders], }, + nativeAppleReadinessIssue: snapshot.nativeAppleReadinessIssue, urls: snapshot.productionInstanceId ? dashboardUrls(snapshot.appId, snapshot.productionInstanceId) : null, @@ -533,6 +608,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,6 +624,7 @@ export function deployNextStep(report: DeployStatusFacts): DeployNextStep { domain: report.domain ?? "", records: capitalizeFirst(pendingRecordComponents(status)), domainsUrl: report.urls?.domains ?? null, + nativeAppleReadinessIssue: report.nativeAppleReadinessIssue, }; } } @@ -569,6 +646,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": @@ -604,6 +685,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.${nativeApple}` + + 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 +710,8 @@ 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) + + nativeApple ); case "records_unavailable": // The report has nothing to hand over; the Dashboard clause carries the @@ -625,7 +720,8 @@ 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) + + nativeApple ); case "ssl_pending": // Records are verified; the certificate is Clerk's side and nobody can @@ -633,13 +729,15 @@ 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) + + 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 ); } } 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..95cfa2b56 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,6 +176,28 @@ 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) { const keyless = await ctx.getKeylessTarget(); @@ -174,6 +213,15 @@ export async function checkTokenValid(ctx: DoctorContext): Promise return check.pass(`Authenticated as ${userInfo.email}`); } catch (error) { if (isAuthError(error)) { + // 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. const keyless = await ctx.getKeylessTarget(); @@ -230,7 +278,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 +334,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..17f9a119d 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])).toContain("include_secret_keys=false"); + }); + 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..13511d777 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,12 @@ 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, so keep + // secret keys out of this shared diagnostic context. + 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..925c5bea3 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,38 @@ 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", + }); + }); + + 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: error }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "fail", + message: "expired or invalid", + remedy: "clerk auth login", + fix: true, + }); + }, + ); + test("fail when refreshing the stored session requires re-authentication", async () => { const ctx = createMockContext({ token: "expired_token", @@ -440,9 +608,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; 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..3cf22af0f --- /dev/null +++ b/packages/cli-core/src/lib/apple-native-identity.ts @@ -0,0 +1,8 @@ +/** + * 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 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 8e94c2d63..6e3d2a3d1 100644 --- a/packages/cli-core/src/lib/credential-store.test.ts +++ b/packages/cli-core/src/lib/credential-store.test.ts @@ -192,6 +192,82 @@ describe("credential-store", () => { }); }); + test("getValidToken coalesces concurrent refreshes in one process", async () => { + await storeToken({ + accessToken: "expired-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() - 60_000, + tokenType: "Bearer", + }); + const refresh = Promise.withResolvers<{ + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + }>(); + mockRefreshAccessToken.mockImplementation(() => refresh.promise); + + const tokens = Promise.all([getValidToken(), getValidToken()]); + refresh.resolve({ + access_token: "refreshed-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rotated-refresh-token", + }); + + expect(await tokens).toEqual(["refreshed-access-token", "refreshed-access-token"]); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + }); + + 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 getValidToken()).toBe("refreshed-access-token"); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(2); + }); + + 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..e62170bca 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,25 @@ export async function getValidToken(): Promise { return getValidAccessToken(session); } +/** + * 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; + + 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.test.ts b/packages/cli-core/src/lib/plapi.test.ts index 4c3c87b8d..69027090e 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -19,8 +19,12 @@ const { getApplicationDomainStatus, triggerApplicationDomainDNSCheck, listApplicationDomains, + getNativeSettings, + enableNativeApi, + listIOSApplications, + createIOSApplication, } = 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 +256,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 +313,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 +326,17 @@ describe("plapi", () => { expect(url.searchParams.get("include_secret_keys")).toBe("true"); }); + test("sends include_secret_keys=false when the caller opts out", async () => { + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify(mockApp), { status: 200 }); + }); + + await fetchApplication("app_abc", { includeSecretKeys: false }); + expect(new URL(requestedUrl).searchParams.get("include_secret_keys")).toBe("false"); + }); + test("returns parsed application JSON", async () => { stubFetch(async () => new Response(JSON.stringify(mockApp), { status: 200 })); @@ -562,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 ba8f80546..de84d2904 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -69,13 +69,19 @@ 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 { +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}`, 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, @@ -229,9 +235,12 @@ export type TriggerDNSCheckResponse = DomainStatusResponse & { last_run_at: number | null; }; -export async function fetchApplication(applicationId: string): Promise { +export async function fetchApplication( + applicationId: string, + options?: { includeSecretKeys?: boolean }, +): Promise { const url = new URL(`/v1/platform/applications/${applicationId}`, getPlapiBaseUrl()); - url.searchParams.set("include_secret_keys", "true"); + url.searchParams.set("include_secret_keys", String(options?.includeSecretKeys ?? true)); const response = await plapiFetch("GET", url); return response.json() as Promise; } @@ -282,7 +291,7 @@ async function sendInstanceConfig( applicationId: string, instanceId: string, config: Record, - options?: { destructive?: boolean; dryRun?: boolean }, + options?: { destructive?: boolean; dryRun?: boolean; ifMatch?: string }, ): Promise> { const url = new URL( `/v1/platform/applications/${applicationId}/instances/${instanceId}/config`, @@ -294,7 +303,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 +314,14 @@ export const putInstanceConfig = async ( applicationId: string, instanceId: string, config: Record, - options?: { destructive?: boolean; dryRun?: boolean }, + options?: { destructive?: boolean; dryRun?: boolean; ifMatch?: string }, ) => sendInstanceConfig("PUT", applicationId, instanceId, config, options); export const patchInstanceConfig = async ( applicationId: string, instanceId: string, config: Record, - options?: { destructive?: boolean; dryRun?: boolean }, + options?: { destructive?: boolean; dryRun?: boolean; ifMatch?: string }, ) => sendInstanceConfig("PATCH", applicationId, instanceId, config, options); export async function createApplication(name: string): Promise { @@ -331,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()); +}