diff --git a/.changeset/ios-aware-doctor.md b/.changeset/ios-aware-doctor.md new file mode 100644 index 000000000..3784ffa51 --- /dev/null +++ b/.changeset/ios-aware-doctor.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add native iOS project diagnostics to `clerk doctor`. diff --git a/packages/cli-core/src/commands/doctor/README.md b/packages/cli-core/src/commands/doctor/README.md index 443043cc7..8b6eb7700 100644 --- a/packages/cli-core/src/commands/doctor/README.md +++ b/packages/cli-core/src/commands/doctor/README.md @@ -2,7 +2,7 @@ Runs a series of diagnostic checks on your Clerk CLI setup and reports the status of each check. The command is read-only and never modifies -any state (unless `--fix` is used). +project or remote application state unless `--fix` is used. ## Usage @@ -12,6 +12,7 @@ clerk doctor --verbose # Show detailed output clerk doctor --json # Output results as JSON clerk doctor --spotlight # Only show warnings and failures clerk doctor --fix # Offer to auto-fix issues +clerk doctor --target MyApp ``` ## Options @@ -22,21 +23,56 @@ clerk doctor --fix # Offer to auto-fix issues | `--json` | Output results as machine-readable JSON | | `--spotlight` | Only show warnings and failures (hide passing checks) | | `--fix` | Offer to auto-fix issues with known remedies | +| `--target` | Select an iOS application target by name or object ID | ## Checks | 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 | -| Environment variables | Environment | .env.local or .env has Clerk keys | +| Environment variables | Environment | Non-iOS projects have Clerk keys in `.env.local` or `.env` | | CLI configuration | Configuration | CLI config file exists and parses | | Shell completion | Configuration | Shell autocompletion is installed for the detected shell | | MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers the `initialize` handshake; warns on an unreadable client config (skipped when nothing is installed; warns, never fails) | +### iOS projects + +When the current directory contains an Xcode project or `--target` is provided, +doctor replaces the web `.env` check with the same semantic Xcode, Swift, and +entitlements inspection used by `clerk init`. It reports separate results for: + +- application-target selection; +- ClerkKit and ClerkKitUI product linkage; +- `Clerk.configure` and, for direct literal configuration, the selected target's effective development key; +- SwiftUI environment injection and authentication-flow evidence; +- AuthView's enabled methods and required local Apple capability; +- Associated Domains and the optional Sign in with Apple entitlement; +- Native API state and the exact Bundle ID registration on the linked + development instance; and +- the Clerk Apple connection when the selected target already declares the + native Apple entitlement. + +iOS diagnostics never require a secret key in the Xcode project or an env +file. A direct literal publishable key is compared with the linked development +application using only redacted Frontend API host metadata. For a single +startup `Clerk.configure` call that uses a custom publishable-key source, +Doctor verifies that the call exists but does not inspect its value. Once the +project is linked, Doctor uses the explicitly selected development application +for read-only AuthView, Native Application, Associated Domains, and Apple +checks; this does not prove that the custom publishable key belongs to that +application. Keys, provider credentials, and raw remote config are not included +in human or JSON output. AuthView, Native Application, and Apple remote checks +are GET-only. Their remedies point back to `clerk init`; `doctor --fix` never +enables an auth strategy or changes Native Application state. + +`clerk doctor` inspects configuration and remote Clerk state without invoking +Xcode package resolution, builds, or Simulator execution. Build and runtime +verification remain with Xcode and the project's existing test workflow. + ### Keyless applications The Authentication token, Token validity, and Project linkage checks resolve @@ -117,8 +153,13 @@ Exit code 1 signals one or more checks failed. ## API Endpoints -| Method | Endpoint | Description | -| ------ | ----------------------------------- | --------------------------------------------------------------- | -| `GET` | `/oauth/userinfo` | Validates the stored auth token | -| `GET` | `/v1/platform/applications/{appId}` | Verifies the linked app and its instances exist | -| `GET` | `/v1/instance` | Names the keyless application (best-effort, via its secret key) | +| Method | Endpoint | Description | +| ------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `GET` | `/oauth/userinfo` | Validates the stored auth token | +| `GET` | `/v1/platform/applications/{appId}` | Verifies the linked app and its instances exist | +| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/native_settings` | Verifies Native API state for iOS projects | +| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/native_applications/ios` | Verifies the exact iOS Bundle ID registration | +| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Audits the Apple connection when native Apple is relevant | +| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/config/schema` | Determines whether an unhealthy Apple connection can be safely reconciled by init | +| `GET` | `https://{fapiHost}/v1/environment` | Verifies whether AuthView currently offers native Apple sign-in | +| `GET` | `/v1/instance` | Names the keyless application (best-effort, via its secret key) | diff --git a/packages/cli-core/src/commands/doctor/checks.ts b/packages/cli-core/src/commands/doctor/checks.ts index a7cf51c62..e10b4deab 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 { @@ -93,15 +92,33 @@ async function claimHint(ctx: DoctorContext): Promise { export async function checkLoggedIn(ctx: DoctorContext): Promise { const check = defineCheck("Logged in", 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}`, { @@ -158,8 +175,36 @@ export async function checkHostExecution(): Promise { export async function checkTokenValid(ctx: DoctorContext): Promise { const check = defineCheck("Authentication valid", ctx.fixes.login); + if (ctx.hasPlatformAPIKey()) { + try { + await ctx.verifyAccountAccess(); + return check.pass("Platform API key access verified"); + } catch (error) { + if ( + isAuthError(error) || + (error instanceof CliError && error.code === ERROR_CODE.INVALID_KEY_FORMAT) + ) { + return check.fail("Platform API key is invalid or lacks applications:read access", { + remedy: + "Replace CLERK_PLATFORM_API_KEY with a valid key that has applications:read access, then rerun `clerk doctor`.", + fixable: false, + }); + } + return check.warn("Could not reach Clerk to verify the Platform API key", { + detail: errorMessage(error), + remedy: "Check your network connection and rerun `clerk doctor`.", + fixable: false, + }); + } + } const storedToken = await ctx.getToken(); if (!storedToken) { + if (await ctx.hasAccountCredentials()) { + return check.warn("Account credentials are configured but could not be verified", { + remedy: "Check your Clerk authentication and rerun `clerk doctor`.", + fixable: false, + }); + } const keyless = await ctx.getKeylessTarget(); return keyless ? check.pass("No account session — not required for this keyless application") @@ -173,6 +218,37 @@ export async function checkTokenValid(ctx: DoctorContext): Promise return check.pass(`Authenticated as ${userInfo.email}`); } catch (error) { if (isAuthError(error)) { + // The OAuth userinfo surface is not available in every environment that + // can accept the same account credential through PLAPI. Verify it with + // an account-scoped application-list request: unlike getApplication(), + // this does not depend on the current directory being linked or its + // linked application continuing to exist. + try { + await ctx.verifyAccountAccess(); + return check.pass("Account access verified through the Clerk API"); + } catch (verificationError) { + if (!isAuthError(verificationError)) { + if (verificationError instanceof PlapiError) { + const unavailable = verificationError.status === 404 ? "endpoint" : "API"; + return check.warn( + `Could not verify authentication — Clerk ${unavailable} unavailable`, + { + detail: errorMessage(verificationError), + remedy: + "Check the Clerk environment and service status, then rerun `clerk doctor`.", + fixable: false, + }, + ); + } + + return check.warn("Could not reach Clerk to verify authentication — network issue", { + detail: errorMessage(verificationError), + remedy: "Check your network connection, then rerun `clerk doctor`.", + fixable: false, + }); + } + } + // Same fallback whoami uses: an expired session doesn't strand a keyless // project, so don't tell the user their setup is broken. const keyless = await ctx.getKeylessTarget(); @@ -229,7 +305,7 @@ export async function checkProjectLinked(ctx: DoctorContext): Promise { const check = defineCheck("Application reachable", 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. @@ -286,8 +361,7 @@ export async function checkLinkedAppExists(ctx: DoctorContext): Promise { const check = defineCheck("Instance IDs", ctx.fixes.link); - const token = await ctx.getToken(); - if (!token) { + if (!(await ctx.hasAccountCredentials())) { // A linked profile's dev/prod instance IDs are an account-only concept — // the secret key on disk already addresses its one instance directly. const keyless = await ctx.getKeylessTarget(); diff --git a/packages/cli-core/src/commands/doctor/context.test.ts b/packages/cli-core/src/commands/doctor/context.test.ts index 878aa731f..6fbf07d0d 100644 --- a/packages/cli-core/src/commands/doctor/context.test.ts +++ b/packages/cli-core/src/commands/doctor/context.test.ts @@ -38,7 +38,7 @@ mock.module("../../lib/bapi.ts", () => ({ })); // stubFetch instead of mock.module for plapi — mock.module leaks globally in Bun -let mockAppResponse: Application | null = null; +let mockAppResponse: Application | Application[] | null = null; let mockAppError: Error | null = null; const mockFetch = mock(); @@ -95,6 +95,37 @@ describe("createDoctorContext", () => { }); }); + describe("verifyAccountAccess", () => { + test("performs one memoized read-only application-list request, including for an empty list", async () => { + mockAppResponse = []; + + const ctx = createDoctorContext(); + const p1 = ctx.verifyAccountAccess(); + const p2 = ctx.verifyAccountAccess(); + + expect(p1).toBe(p2); + await expect(p1).resolves.toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(String(mockFetch.mock.calls[0]?.[0])).toBe( + "https://api.clerk.com/v1/platform/applications", + ); + expect(mockFetch.mock.calls[0]?.[1]).toMatchObject({ method: "GET" }); + }); + + test("memoizes a failed verification request", async () => { + mockAppError = new TypeError("fetch failed"); + + const ctx = createDoctorContext(); + const p1 = ctx.verifyAccountAccess(); + const p2 = ctx.verifyAccountAccess(); + + expect(p1).toBe(p2); + await expect(p1).rejects.toThrow("fetch failed"); + await expect(p2).rejects.toThrow("fetch failed"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + describe("getProfile", () => { test("returns the same promise on repeated calls", async () => { const profile = { @@ -135,6 +166,7 @@ describe("createDoctorContext", () => { }); test("returns null when no token", async () => { + delete process.env.CLERK_PLATFORM_API_KEY; mockGetToken.mockResolvedValue(null); const ctx = createDoctorContext(); @@ -144,6 +176,21 @@ describe("createDoctorContext", () => { expect(mockFetch).not.toHaveBeenCalled(); }); + test("fetches the public application shape with a Platform API key", async () => { + mockGetToken.mockResolvedValue(null); + mockResolveProfile.mockResolvedValue({ + path: "github.com/org/repo", + profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } }, + resolvedVia: "remote" as const, + }); + mockAppResponse = { application_id: "app_1", name: "My App", instances: [] }; + + const ctx = createDoctorContext(); + expect(await ctx.getApplication()).toEqual(mockAppResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(String(mockFetch.mock.calls[0]?.[0])).not.toContain("include_secret_keys"); + }); + test("returns null when no profile", async () => { mockGetToken.mockResolvedValue("test_token"); mockResolveProfile.mockResolvedValue(undefined); diff --git a/packages/cli-core/src/commands/doctor/context.ts b/packages/cli-core/src/commands/doctor/context.ts index 0e82432cc..2a7fb51f8 100644 --- a/packages/cli-core/src/commands/doctor/context.ts +++ b/packages/cli-core/src/commands/doctor/context.ts @@ -1,6 +1,6 @@ -import { getToken, getValidToken } from "../../lib/credential-store.ts"; +import { getToken, getValidToken, hasAccountCredentials } from "../../lib/credential-store.ts"; import { resolveProfile } from "../../lib/config.ts"; -import { fetchApplication, type Application } from "../../lib/plapi.ts"; +import { fetchApplication, listApplications, type Application } from "../../lib/plapi.ts"; import { resolveKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts"; import { peekKeylessBreadcrumb } from "../../lib/keyless.ts"; import { bapiRequest } from "../../lib/bapi.ts"; @@ -17,6 +17,8 @@ import type { DoctorContext, KeylessInstanceInfo, ResolvedProfile } from "./type export function createDoctorContext(): DoctorContext { let tokenPromise: Promise | undefined; + let accountCredentialsPromise: Promise | undefined; + let accountAccessVerificationPromise: Promise | undefined; let validTokenPromise: Promise | undefined; let profilePromise: Promise | undefined; let appPromise: Promise | undefined; @@ -26,6 +28,24 @@ export function createDoctorContext(): DoctorContext { let keylessKeyError: CliError | undefined; const ctx: DoctorContext = { + hasPlatformAPIKey() { + return Boolean(process.env.CLERK_PLATFORM_API_KEY); + }, + + hasAccountCredentials() { + if (!accountCredentialsPromise) { + accountCredentialsPromise = hasAccountCredentials(); + } + return accountCredentialsPromise; + }, + + verifyAccountAccess() { + if (!accountAccessVerificationPromise) { + accountAccessVerificationPromise = listApplications().then(() => undefined); + } + return accountAccessVerificationPromise; + }, + getToken() { if (!tokenPromise) { tokenPromise = getToken(); @@ -50,11 +70,14 @@ export function createDoctorContext(): DoctorContext { getApplication() { if (!appPromise) { appPromise = (async () => { - const token = await ctx.getToken(); - if (!token) return null; + if (!(await ctx.hasAccountCredentials())) return null; const resolved = await ctx.getProfile(); if (!resolved) return null; - return fetchApplication(resolved.profile.appId); + // Doctor only needs application and instance identity. Keeping + // secret keys out of this long-lived, shared diagnostic context + // prevents unrelated checks from retaining credentials they never + // use (including the iOS checks below). + return fetchApplication(resolved.profile.appId, { includeSecretKeys: false }); })(); } return appPromise; diff --git a/packages/cli-core/src/commands/doctor/doctor.test.ts b/packages/cli-core/src/commands/doctor/doctor.test.ts index 60c213fb9..40695e02c 100644 --- a/packages/cli-core/src/commands/doctor/doctor.test.ts +++ b/packages/cli-core/src/commands/doctor/doctor.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { ApiError, AuthError, CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { ApiError, AuthError, CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; import { _setConfigDir } from "../../lib/config.ts"; import { credentialStoreStubs, @@ -105,13 +105,27 @@ function createMockContext( }; application?: Application | null; applicationError?: Error; + accountAccess?: boolean; + accountAccessError?: Error; keylessTarget?: KeylessTarget; keylessInstance?: KeylessInstanceInfo | null; claimBreadcrumb?: boolean; keylessKeyError?: CliError; + accountCredentials?: boolean; + platformAPIKey?: boolean; + platformAPIKeyError?: Error; } = {}, ): DoctorContext { return { + hasPlatformAPIKey: () => overrides.platformAPIKey ?? false, + hasAccountCredentials: async () => overrides.accountCredentials ?? overrides.token != null, + verifyAccountAccess: async () => { + if (overrides.accountAccessError) throw overrides.accountAccessError; + if (overrides.platformAPIKeyError) throw overrides.platformAPIKeyError; + if (!overrides.platformAPIKey && !overrides.accountAccess) { + throw new ApiError(401, "Unauthorized"); + } + }, getToken: async () => overrides.token ?? null, getValidToken: async () => { if (overrides.validToken instanceof Error) throw overrides.validToken; @@ -212,6 +226,45 @@ describe("checkLoggedIn", () => { expectCheck(result, { name: "Logged in", status: "pass", message: "Logged in" }); }); + test("pass when a Platform API key is configured without an OAuth token", async () => { + const ctx = createMockContext({ + token: null, + accountCredentials: true, + platformAPIKey: true, + }); + const result = await checkLoggedIn(ctx); + expectCheck(result, { + name: "Logged in", + status: "pass", + message: "Platform API key configured", + }); + }); + + test("does not read OAuth credentials when a Platform API key is configured", async () => { + const getToken = mock(async () => { + throw new Error("credential store is unavailable"); + }); + const ctx: DoctorContext = { + ...createMockContext({ + platformAPIKey: true, + keylessKeyError: new CliError("not a secret key", { + code: ERROR_CODE.INVALID_KEY_FORMAT, + }), + }), + getToken, + }; + + const result = await checkLoggedIn(ctx); + + expectCheck(result, { + name: "Logged in", + status: "warn", + message: ["Platform API key configured", "local secret key is unusable", "not a secret key"], + remedy: "Fix or remove the malformed secret key", + }); + expect(getToken).not.toHaveBeenCalled(); + }); + test("fail when no token", async () => { const ctx = createMockContext({ token: null }); const result = await checkLoggedIn(ctx); @@ -317,6 +370,89 @@ describe("checkHostExecution", () => { }); describe("checkTokenValid", () => { + test("passes when a read-only request verifies the Platform API key", async () => { + const ctx = createMockContext({ + token: null, + accountCredentials: true, + platformAPIKey: true, + }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "pass", + message: "Platform API key access verified", + }); + }); + + test("does not validate stale OAuth when PLAPI will use a Platform API key", async () => { + const ctx = createMockContext({ + token: "stale-oauth-token", + validToken: new AuthError({ reason: "session_expired" }), + accountCredentials: true, + platformAPIKey: true, + }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "pass", + message: "Platform API key access verified", + }); + }); + + for (const status of [401, 403]) { + test(`fails when the Platform API rejects the key with ${status}`, async () => { + const ctx = createMockContext({ + platformAPIKey: true, + platformAPIKeyError: new ApiError(status, "Unauthorized"), + }); + + const result = await checkTokenValid(ctx); + + expectCheck(result, { + name: "Authentication valid", + status: "fail", + message: ["Platform API key", "invalid", "applications:read"], + remedy: "CLERK_PLATFORM_API_KEY", + fix: false, + }); + }); + } + + test("warns when a network failure prevents Platform API key verification", async () => { + const ctx = createMockContext({ + platformAPIKey: true, + platformAPIKeyError: new TypeError("fetch failed"), + }); + + const result = await checkTokenValid(ctx); + + expectCheck(result, { + name: "Authentication valid", + status: "warn", + message: "Could not reach Clerk", + detail: "fetch failed", + remedy: "network connection", + fix: false, + }); + }); + + test("warns when Clerk is unavailable during Platform API key verification", async () => { + const ctx = createMockContext({ + platformAPIKey: true, + platformAPIKeyError: new ApiError(503, "Service unavailable"), + }); + + const result = await checkTokenValid(ctx); + + expectCheck(result, { + name: "Authentication valid", + status: "warn", + message: "Could not reach Clerk", + detail: "Service unavailable", + fix: false, + }); + }); + test("pass with valid token", async () => { mockUserInfo = { userId: "user_1", email: "dev@example.com" }; const ctx = createMockContext({ token: "test_token" }); @@ -341,6 +477,94 @@ describe("checkTokenValid", () => { }); }); + test("passes when userinfo rejects a credential that the account-scoped API accepts", async () => { + mockUserInfoError = new ApiError(401, "Unauthorized"); + const ctx = createMockContext({ token: "local_token", accountAccess: true }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "pass", + message: "verified through the Clerk API", + }); + }); + + for (const status of [401, 403]) { + test(`fails when both userinfo and the account-scoped API reject the credential with ${status}`, async () => { + mockUserInfoError = new ApiError(401, "Unauthorized"); + const ctx = createMockContext({ + token: "expired_token", + accountAccessError: new ApiError(status, "Unauthorized"), + }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "fail", + message: "expired or invalid", + remedy: "clerk auth login", + fix: true, + }); + }); + } + + test("does not mislabel a missing account verification endpoint as an expired token", async () => { + mockUserInfoError = new ApiError(401, "Unauthorized"); + const ctx = createMockContext({ + token: "local_token", + accountAccessError: PlapiError.fromBody(404, "Not found"), + }); + + const result = await checkTokenValid(ctx); + + expectCheck(result, { + name: "Authentication valid", + status: "warn", + message: ["Could not verify authentication", "endpoint unavailable"], + messageNot: "expired", + detail: "Not found", + remedy: "Clerk environment", + fix: false, + }); + }); + + test("reports a Clerk API outage without mislabeling the token as expired", async () => { + mockUserInfoError = new ApiError(401, "Unauthorized"); + const ctx = createMockContext({ + token: "local_token", + accountAccessError: PlapiError.fromBody(503, "Service unavailable"), + }); + + const result = await checkTokenValid(ctx); + + expectCheck(result, { + name: "Authentication valid", + status: "warn", + message: ["Could not verify authentication", "API unavailable"], + messageNot: "expired", + detail: "Service unavailable", + fix: false, + }); + }); + + test("reports a fallback transport failure as a network issue", async () => { + mockUserInfoError = new ApiError(401, "Unauthorized"); + const ctx = createMockContext({ + token: "local_token", + accountAccessError: new TypeError("fetch failed"), + }); + + const result = await checkTokenValid(ctx); + + expectCheck(result, { + name: "Authentication valid", + status: "warn", + message: ["Could not reach Clerk", "network issue"], + messageNot: "expired", + detail: "fetch failed", + remedy: "network connection", + fix: false, + }); + }); + test("fail when refreshing the stored session requires re-authentication", async () => { const ctx = createMockContext({ token: "expired_token", @@ -440,9 +664,10 @@ describe("checkProjectLinked", () => { }); test("warn (not fail) when signed in but unlinked, falling back to a keyless application", async () => { - // beforeEach sets CLERK_PLATFORM_API_KEY, so hasAccountCredentials() is true here — - // the directory *could* reach the full account configuration by linking. + // The directory has account credentials, so it could reach the full + // account configuration by linking. const ctx = createMockContext({ + accountCredentials: true, keylessTarget: mockKeylessTarget, keylessInstance: mockKeylessInstance, }); diff --git a/packages/cli-core/src/commands/doctor/index.test.ts b/packages/cli-core/src/commands/doctor/index.test.ts new file mode 100644 index 000000000..8b7836122 --- /dev/null +++ b/packages/cli-core/src/commands/doctor/index.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import type { FrameworkInfo } from "../../lib/framework.ts"; +import * as telemetryMod from "../../lib/telemetry.ts"; +import type { IOSProjectInspectionResult } from "../init/ios/types.ts"; +import { checkEnvVars } from "./checks.ts"; +import { getDoctorChecks, runChecks, type DoctorRunDependencies } from "./index.ts"; +import type { CheckResult, DoctorContext } from "./types.ts"; + +const IOS_FRAMEWORK: FrameworkInfo = { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env", + ecosystem: "swift", +}; + +const IOS_INSPECTION = {} as IOSProjectInspectionResult; +const DOCTOR_CONTEXT = {} as DoctorContext; + +function passingResult(name: string): CheckResult { + return { name, status: "pass", message: `${name} passed` }; +} + +function runDependencies(overrides: Partial = {}): DoctorRunDependencies { + return { + detectFramework: async () => IOS_FRAMEWORK, + getDoctorChecks: () => [async () => passingResult("Common")], + runIOSDoctorChecks: async () => ({ + inspection: IOS_INSPECTION, + results: [passingResult("iOS")], + }), + ...overrides, + }; +} + +describe("getDoctorChecks", () => { + test("replaces the web environment check for iOS projects", () => { + expect(getDoctorChecks(true)).not.toContain(checkEnvVars); + expect(getDoctorChecks(false)).toContain(checkEnvVars); + }); +}); + +describe("doctor telemetry stages", () => { + test("reports the ordered native diagnostic boundaries", async () => { + const stage = spyOn(telemetryMod, "setTelemetryStage"); + try { + await runChecks(DOCTOR_CONTEXT, { target: "MyApp" }, { dependencies: runDependencies() }); + + expect(stage.mock.calls.map((call) => call[0])).toEqual([ + "doctor_checks", + "doctor_ios_audit", + ]); + } finally { + stage.mockRestore(); + } + }); + + test("stops at the iOS audit stage when semantic inspection fails", async () => { + const stage = spyOn(telemetryMod, "setTelemetryStage"); + try { + const results = await runChecks( + DOCTOR_CONTEXT, + { target: "MyApp" }, + { + dependencies: runDependencies({ + runIOSDoctorChecks: async () => { + throw new Error("inspection failed"); + }, + }), + }, + ); + + expect(results.at(-1)?.status).toBe("fail"); + expect(stage.mock.calls.map((call) => call[0]).at(-1)).toBe("doctor_ios_audit"); + } finally { + stage.mockRestore(); + } + }); +}); diff --git a/packages/cli-core/src/commands/doctor/index.ts b/packages/cli-core/src/commands/doctor/index.ts index 813de75ac..030d0a0e1 100644 --- a/packages/cli-core/src/commands/doctor/index.ts +++ b/packages/cli-core/src/commands/doctor/index.ts @@ -1,9 +1,11 @@ import type { Program } from "../../cli-program.ts"; import { isAgent, isHuman } from "../../mode.ts"; import { bold, green, red } from "../../lib/color.ts"; +import { detectFramework } from "../../lib/framework.ts"; import { log } from "../../lib/log.ts"; import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts"; import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts"; +import { setTelemetryStage } from "../../lib/telemetry.ts"; import { createDoctorContext } from "./context.ts"; import { checkLoggedIn, @@ -19,28 +21,56 @@ import { } from "./checks.ts"; import { checkMcp } from "./check-mcp.ts"; import { formatCheckResult, formatJson } from "./format.ts"; +import { runIOSDoctorChecks } from "./ios.ts"; import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts"; -const BASE_CHECKS: CheckFn[] = [ +const ACCOUNT_CHECKS: CheckFn[] = [ checkCliVersion, checkLoggedIn, checkTokenValid, checkProjectLinked, checkLinkedAppExists, checkInstances, - checkEnvVars, - checkConfigFile, - checkShellCompletion, - checkMcp, ]; -function getChecks(): CheckFn[] { - return isAgent() ? [checkHostExecution, ...BASE_CHECKS] : BASE_CHECKS; +const CONFIGURATION_CHECKS: CheckFn[] = [checkConfigFile, checkShellCompletion, checkMcp]; + +export function getDoctorChecks(ios: boolean): CheckFn[] { + const checks = [...ACCOUNT_CHECKS, ...(ios ? [] : [checkEnvVars]), ...CONFIGURATION_CHECKS]; + return isAgent() ? [checkHostExecution, ...checks] : checks; } -async function runChecks(ctx: DoctorContext): Promise { - return Promise.all( - getChecks().map(async (check) => { +export interface DoctorRunDependencies { + detectFramework: typeof detectFramework; + getDoctorChecks: typeof getDoctorChecks; + runIOSDoctorChecks: typeof runIOSDoctorChecks; +} + +const defaultDoctorRunDependencies: DoctorRunDependencies = { + detectFramework, + getDoctorChecks, + runIOSDoctorChecks, +}; + +interface RunChecksOptions { + initialStage?: "doctor_checks" | "doctor_verify"; + dependencies?: DoctorRunDependencies; +} + +export async function runChecks( + ctx: DoctorContext, + options: DoctorOptions, + runOptions: RunChecksOptions = {}, +): Promise { + const dependencies = runOptions.dependencies ?? defaultDoctorRunDependencies; + setTelemetryStage(runOptions.initialStage ?? "doctor_checks"); + const explicitlyRequestsIOS = options.target != null; + const framework = explicitlyRequestsIOS + ? { dep: "ios" } + : await dependencies.detectFramework(process.cwd()); + const ios = framework?.dep === "ios"; + const common = await Promise.all( + dependencies.getDoctorChecks(ios).map(async (check) => { try { return await check(ctx); } catch (error) { @@ -52,6 +82,27 @@ async function runChecks(ctx: DoctorContext): Promise { } }), ); + + if (!ios) return common; + try { + setTelemetryStage("doctor_ios_audit"); + const iosChecks = await dependencies.runIOSDoctorChecks(ctx, { + root: process.cwd(), + ...(options.target ? { target: options.target } : {}), + }); + return [...common, ...iosChecks.results]; + } catch { + return [ + ...common, + { + name: "iOS inspection", + status: "fail", + message: "iOS project inspection failed", + detail: "The semantic Xcode inspection did not complete safely.", + remedy: "Run from the Xcode project root and pass `--target ` if needed.", + }, + ]; + } } function printResults(results: CheckResult[], options: DoctorOptions): void { @@ -69,7 +120,9 @@ export async function doctor(options: DoctorOptions = {}): Promise { } const ctx = createDoctorContext(); - const allResults = await withSpinner("Running diagnostics...", async () => runChecks(ctx)); + const allResults = await withSpinner("Running diagnostics...", async () => + runChecks(ctx, options), + ); if (!options.json) { printResults(allResults, options); @@ -92,6 +145,7 @@ export async function doctor(options: DoctorOptions = {}): Promise { }); if (uniqueFixable.length > 0) { + setTelemetryStage("doctor_fix"); log.blank(); log.info(bold("Auto-fix")); log.blank(); @@ -120,7 +174,7 @@ export async function doctor(options: DoctorOptions = {}): Promise { const verifyCtx = createDoctorContext(); const verifyResults = await withSpinner("Verifying fixes...", async () => - runChecks(verifyCtx), + runChecks(verifyCtx, options, { initialStage: "doctor_verify" }), ); printResults(verifyResults, { ...options, fix: false, spotlight: false }); @@ -130,6 +184,7 @@ export async function doctor(options: DoctorOptions = {}): Promise { code: ERROR_CODE.DOCTOR_FAILED, }); } + setTelemetryStage("done"); await outro("All checks passing"); return; } @@ -141,6 +196,7 @@ export async function doctor(options: DoctorOptions = {}): Promise { code: ERROR_CODE.DOCTOR_FAILED, }); } + setTelemetryStage("done"); await outro("All checks passing"); } @@ -152,12 +208,17 @@ export function registerDoctor(program: Program): void { .option("--json", "Output results as JSON") .option("--spotlight", "Only show warnings and failures") .option("--fix", "Attempt to auto-fix issues") + .option("--target ", "Select an iOS application target") .setExamples([ { command: "clerk doctor", description: "Run all health checks" }, { command: "clerk doctor --verbose", description: "Show detailed output for each check" }, { command: "clerk doctor --json", description: "Output results as machine-readable JSON" }, { command: "clerk doctor --fix", description: "Auto-fix detected issues" }, { command: "clerk doctor --spotlight", description: "Only show warnings and failures" }, + { + command: "clerk doctor --target MyApp", + description: "Audit a specific iOS application target", + }, ]) .action(doctor); } diff --git a/packages/cli-core/src/commands/doctor/ios.test.ts b/packages/cli-core/src/commands/doctor/ios.test.ts new file mode 100644 index 000000000..2c23a9412 --- /dev/null +++ b/packages/cli-core/src/commands/doctor/ios.test.ts @@ -0,0 +1,1414 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createIOSFixture, IOS_FIXTURE_IDS } from "../init/ios/test-helpers.ts"; +import { planIOSSDKInstall } from "../init/ios/install-sdk.ts"; +import type { IOSNativeAppleBlockerCode } from "../init/ios/native-apple.ts"; +import { PlapiError } from "../../lib/errors.ts"; +import type { UserSettingsJSON } from "../../lib/fapi.ts"; +import type { Application } from "../../lib/plapi.ts"; +import { auditIOSPrebuiltAuthEnvironment } from "../init/ios/prebuilt-auth-environment.ts"; +import type { DoctorContext, ResolvedProfile } from "./types.ts"; +import { runIOSDoctorChecks, type IOSDoctorDependencies } from "./ios.ts"; + +const roots: string[] = []; + +const unsupportedAppleAutomationCases: Array<{ + name: string; + code: IOSNativeAppleBlockerCode; + detail: string; +}> = [ + { + name: "unsupported Apple schema", + code: "apple-config-unsupported", + detail: "Automatic Apple schema repair is unavailable.", + }, + { + name: "invalid Apple config version", + code: "apple-config-invalid", + detail: "The Apple config version is invalid.", + }, + { + name: "missing Apple config version", + code: "apple-config-version-unavailable", + detail: "The Apple config version required for repair is missing.", + }, +]; + +function publishableKey(host: string, live = false): string { + return `${live ? "pk_live_" : "pk_test_"}${Buffer.from(`${host}$`).toString("base64")}`; +} + +function context(): DoctorContext { + const profile: ResolvedProfile = { + path: "fixture", + resolvedVia: "directory", + profile: { + workspaceId: "org_test", + appId: "app_test", + instances: { development: "ins_test" }, + }, + }; + const noopFix = () => ({ label: "noop", run: async () => {} }); + return { + hasPlatformAPIKey: () => false, + hasAccountCredentials: async () => true, + verifyAccountAccess: async () => {}, + getToken: async () => "oauth-token", + getValidToken: async () => "oauth-token", + getProfile: async () => profile, + getApplication: async () => null, + getKeylessTarget: async () => undefined, + getKeylessInstance: async () => null, + getKeylessKeyError: async () => undefined, + hasClaimBreadcrumb: async () => false, + fixes: { login: noopFix, link: noopFix, envPull: noopFix }, + }; +} + +async function fixture(options: Parameters[1] = {}): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-doctor-")); + roots.push(root); + await createIOSFixture(root, options); + return root; +} + +async function addAppleEntitlement( + root: string, + value = "Default", +): Promise { + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const entitlements = await readFile(entitlementsPath, "utf8"); + await writeFile( + entitlementsPath, + entitlements.replace("", `com.apple.developer.applesignin${value}`), + ); +} + +async function writeSelectedTargetRunSchemeKey(root: string, key: string): Promise { + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + await writeFile( + join(schemeDirectory, "MyApp.xcscheme"), + ``, + ); +} + +function dependencies(overrides: Partial = {}): IOSDoctorDependencies { + const application: Application = { + application_id: "app_test", + instances: [ + { + instance_id: "ins_test", + environment_type: "development", + publishable_key: publishableKey("clerk.example.test"), + }, + ], + }; + return { + inspectIOSProject: + overrides.inspectIOSProject ?? + (async (...args) => { + const { inspectIOSProject } = await import("../init/ios/inspect.ts"); + return inspectIOSProject(...args); + }), + fetchApplication: overrides.fetchApplication ?? (async () => application), + getNativeSettings: + overrides.getNativeSettings ?? + (async () => ({ object: "native_settings", api_enabled: true })), + listIOSApplications: + overrides.listIOSApplications ?? + (async () => [ + { + object: "ios_application", + id: "iosapp_test", + app_id_prefix: "LEGACY1234", + bundle_id: "com.example.MyApp", + created_at: 1, + updated_at: 1, + }, + ]), + fetchUserSettings: + overrides.fetchUserSettings ?? (async () => ({ social: {} }) as UserSettingsJSON), + auditIOSPrebuiltAuthEnvironment: + overrides.auditIOSPrebuiltAuthEnvironment ?? auditIOSPrebuiltAuthEnvironment, + planIOSAppleEntitlement: + overrides.planIOSAppleEntitlement ?? + (async () => { + throw new Error("Apple planner should not run without local Apple intent or entitlement"); + }), + auditIOSNativeAppleHealth: + overrides.auditIOSNativeAppleHealth ?? + (async () => { + throw new Error( + "Apple remote audit should not run without local Apple intent or entitlement", + ); + }), + planIOSSDKInstall: overrides.planIOSSDKInstall ?? planIOSSDKInstall, + }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("runIOSDoctorChecks", () => { + test("uses semantic iOS checks instead of web environment checks", async () => { + const root = await fixture(); + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + + expect( + audit.results.some((result) => result.name === "iOS: Select the iOS application target"), + ).toBeTrue(); + expect(audit.results.some((result) => result.name === "Environment variables")).toBeFalse(); + expect(audit.results.find((result) => result.name === "iOS: Native Application")?.status).toBe( + "pass", + ); + }); + + test("fails AuthView setup when the linked clerk-ios SDK is incompatible", async () => { + const root = await fixture({ complete: true }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await readFile(projectPath, "utf8"); + await writeFile( + projectPath, + project.replace( + "requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; };", + "requirement = { kind = exactVersion; version = 0.70.0; };", + ), + ); + const before = await readFile(projectPath, "utf8"); + const plannerOptions: Parameters[0][] = []; + + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSSDKInstall: async (options) => { + plannerOptions.push(options); + return planIOSSDKInstall(options); + }, + }), + ); + + const sdk = audit.results.find( + (result) => result.name === "iOS: Install Clerk's iOS SDK for the selected target", + ); + expect(sdk?.status).toBe("fail"); + expect(sdk?.message).toContain("blocked"); + expect(sdk?.detail).toContain("require clerk-ios"); + expect(plannerOptions).toEqual([ + { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + includeClerkKitUI: true, + requirePrebuiltAuthCompatibility: true, + }, + ]); + expect(await readFile(projectPath, "utf8")).toBe(before); + }); + + test("fails strict SDK validation for a core-only target with duplicate products", async () => { + const root = await fixture({ clerkSDK: "core-only", complete: false }); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { Text("Custom auth") } } +} +`, + ); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await readFile(projectPath, "utf8"); + const duplicateProducts = project.replace( + `packageProductDependencies = ( ${IOS_FIXTURE_IDS.clerkKit}, );`, + `packageProductDependencies = ( ${IOS_FIXTURE_IDS.clerkKit}, ${IOS_FIXTURE_IDS.clerkKit}, );`, + ); + expect(duplicateProducts).not.toBe(project); + await writeFile(projectPath, duplicateProducts); + const plannerOptions: Parameters[0][] = []; + + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSSDKInstall: async (options) => { + plannerOptions.push(options); + return planIOSSDKInstall(options); + }, + }), + ); + + const sdk = audit.results.find( + (result) => result.name === "iOS: Install Clerk's iOS SDK for the selected target", + ); + expect(audit.inspection.appTargets[0]?.swift.authViewReferences).toEqual([]); + expect(sdk?.status).toBe("fail"); + expect(sdk?.message).toContain("blocked"); + expect(sdk?.detail).toContain("duplicate object ID"); + expect(plannerOptions).toEqual([ + { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + }, + ]); + expect(await readFile(projectPath, "utf8")).toBe(duplicateProducts); + }); + + test("requires ClerkKitUI for non-AuthView source evidence", async () => { + const root = await fixture({ clerkSDK: false, complete: false }); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKitUI +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { UserButton() } } +} +`, + ); + const plannerOptions: Parameters[0][] = []; + + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSSDKInstall: async (options) => { + plannerOptions.push(options); + return planIOSSDKInstall(options); + }, + }), + ); + + const target = audit.inspection.appTargets[0]; + expect(target?.swift.importsClerkKitUI).toEqual([{ path: "MyApp/MyAppApp.swift" }]); + expect(target?.swift.authViewReferences).toEqual([]); + expect(plannerOptions).toEqual([ + { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + includeClerkKitUI: true, + }, + ]); + }); + + test("does not pass an unused Clerk environment modifier as shipping root wiring", async () => { + const root = await fixture({ complete: false }); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit + import SwiftUI + @main struct MyApp: App { + var body: some Scene { WindowGroup { ContentView() } } + } + struct UnusedHelper: View { + var body: some View { Text("Unused").environment(Clerk.shared) } + }`, + ); + + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + const environment = audit.results.find( + (result) => result.name === "iOS: Inject Clerk into the SwiftUI environment", + ); + + expect(environment?.status).toBe("warn"); + expect(environment?.message).toContain("review needed"); + expect(environment?.detail).toContain("not proven on the shipping WindowGroup root"); + }); + + test("passes Clerk environment injection on the proven shipping root", async () => { + const root = await fixture({ complete: false }); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit + import SwiftUI + @main struct MyApp: App { + var body: some Scene { + WindowGroup { ContentView().environment(Clerk.shared) } + } + }`, + ); + + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + const environment = audit.results.find( + (result) => result.name === "iOS: Inject Clerk into the SwiftUI environment", + ); + + expect(environment?.status).toBe("pass"); + expect(environment?.detail).toContain("proven shipping WindowGroup root"); + }); + + test.each(["\\.self", ".self"])( + "does not pass the invalid EnvironmentValues overload %s", + async (keyPath) => { + const root = await fixture({ complete: true }); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit + import ClerkKitUI + import SwiftUI + @main struct MyApp: App { + var body: some Scene { + WindowGroup { AuthView().environment(${keyPath}, Clerk.shared) } + } + }`, + ); + + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + const environment = audit.results.find( + (result) => result.name === "iOS: Inject Clerk into the SwiftUI environment", + ); + + expect(environment?.status).toBe("fail"); + expect(environment?.message).toContain("setup required"); + expect(environment?.detail).toContain("add `.environment(Clerk.shared)`"); + }, + ); + + test("recognizes custom email-link authentication without judging callback wiring", async () => { + const root = await fixture({ complete: false }); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit + import SwiftUI + @main struct MyApp: App { + var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } + } + func begin(_ signIn: SignIn) async throws { try await signIn.sendEmailLink() }`, + ); + + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + expect( + audit.results.find((result) => result.name === "iOS: Add an authentication flow")?.status, + ).toBe("pass"); + expect( + audit.results.some((result) => result.name === "iOS: Wire custom email-link callbacks"), + ).toBeFalse(); + }); + + test("does not pass an associated domain that differs in simulator builds", async () => { + const root = await fixture({ complete: true, includeKey: false }); + const key = publishableKey("clerk.example.test"); + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + const source = await readFile(sourcePath, "utf8"); + await writeFile( + sourcePath, + source.replace('QuickstartLocalSecrets.load().publishableKey ?? ""', `"${key}"`), + ); + + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await readFile(projectPath, "utf8"); + await writeFile( + projectPath, + project.replaceAll( + 'SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";', + '"ASSOCIATED_DOMAIN_HOST" = "clerk.example.test"; "ASSOCIATED_DOMAIN_HOST[sdk=iphonesimulator*]" = "simulator.example.test"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";', + ), + ); + + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const entitlements = await readFile(entitlementsPath, "utf8"); + await writeFile( + entitlementsPath, + entitlements.replace( + "webcredentials:clerk.example.test", + "webcredentials:$(ASSOCIATED_DOMAIN_HOST)", + ), + ); + + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + + expect( + audit.results.find((result) => result.name === "iOS: Configure Clerk with a publishable key") + ?.status, + ).toBe("pass"); + expect(audit.results.find((result) => result.name === "iOS: Native Application")?.status).toBe( + "pass", + ); + const domain = audit.results.find( + (result) => result.name === "iOS: Add Clerk's associated domain", + ); + expect(domain?.status).toBe("warn"); + expect(domain?.message).not.toContain("configured"); + expect(domain?.detail).toContain("unresolved build settings"); + expect(JSON.stringify(audit.results)).not.toContain(key); + }); + + test("does not pass malformed Associated Domains entries", async () => { + const root = await fixture({ complete: true }); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const entitlements = await readFile(entitlementsPath, "utf8"); + await writeFile( + entitlementsPath, + entitlements.replace( + "webcredentials:clerk.example.test", + "webcredentials:clerk.example.test", + ), + ); + + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + const domain = audit.results.find( + (result) => result.name === "iOS: Add Clerk's associated domain", + ); + + expect(audit.inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.invalid-associated-domains" }), + ); + expect(domain?.status).not.toBe("pass"); + expect(domain?.message).not.toContain("matches the linked application"); + }); + + test("reports missing Native API and registration as a fixable init requirement", async () => { + const root = await fixture(); + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + getNativeSettings: async () => ({ + object: "native_settings", + api_enabled: false, + }), + listIOSApplications: async () => [], + }), + ); + + const remote = audit.results.find((result) => result.name === "iOS: Native Application"); + expect(remote?.status).toBe("fail"); + expect(remote?.message).toContain("setup required"); + expect(remote?.detail).toContain("Register iOS Bundle ID"); + expect(remote?.remedy).toContain("clerk init"); + }); + + test("fails safely when Clerk returns malformed Native API settings", async () => { + const root = await fixture(); + const sensitiveValue = "Bearer malformed-native-settings-secret"; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + getNativeSettings: async () => + ({ + object: "native_settings", + api_enabled: "false", + diagnostic: sensitiveValue, + }) as never, + }), + ); + + const remote = audit.results.find((result) => result.name === "iOS: Native Application"); + expect(remote?.status).toBe("fail"); + expect(remote?.message).toContain("invalid remote response"); + expect(remote?.remedy).toContain("Update the Clerk CLI"); + expect(remote?.remedy).toContain("Clerk support"); + expect(audit.results.some((result) => result.status === "fail")).toBe(true); + expect(JSON.stringify(audit.results)).not.toContain(sensitiveValue); + }); + + test("fails safely when Clerk returns a malformed iOS registration list", async () => { + const root = await fixture(); + const sensitiveValue = "Bearer malformed-registration-secret"; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + listIOSApplications: async () => + [ + { + object: "ios_application", + id: sensitiveValue, + app_id_prefix: "LEGACY1234", + bundle_id: "com.example.MyApp", + created_at: "not-a-number", + updated_at: 1, + }, + ] as never, + }), + ); + + const remote = audit.results.find((result) => result.name === "iOS: Native Application"); + expect(remote?.status).toBe("fail"); + expect(remote?.message).toContain("invalid remote response"); + expect(remote?.remedy).toContain("Update the Clerk CLI"); + expect(remote?.remedy).toContain("Clerk support"); + expect(audit.results.some((result) => result.status === "fail")).toBe(true); + expect(JSON.stringify(audit.results)).not.toContain(sensitiveValue); + }); + + test("directs established apps to integrate their missing authentication flow manually", async () => { + const root = await fixture(); + await writeFile( + join(root, "MyApp", "ContentView.swift"), + `import SwiftUI + +struct ContentView: View { + var body: some View { + NavigationStack { + List { + NavigationLink("Profile") { Text("Existing profile flow") } + } + .navigationTitle("Settings") + } + } +} +`, + ); + + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + const authFlow = audit.results.find( + (result) => result.name === "iOS: Add an authentication flow", + ); + + expect(authFlow?.status).toBe("fail"); + expect(authFlow?.remedy).toContain("signed-out entry point"); + expect(authFlow?.remedy).toContain("AuthView"); + expect(authFlow?.remedy).toContain("custom ClerkKit sign-in/sign-up flow"); + expect(authFlow?.remedy).not.toContain("clerk init"); + }); + + test("does not call remote endpoints until one target is selected", async () => { + const root = await fixture({ secondTarget: true }); + let remoteCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root }, + dependencies({ + getNativeSettings: async () => { + remoteCalls++; + return { object: "native_settings", api_enabled: true }; + }, + listIOSApplications: async () => { + remoteCalls++; + return []; + }, + }), + ); + + expect(remoteCalls).toBe(0); + expect( + audit.results.find((result) => result.name === "iOS: Select the iOS application target") + ?.status, + ).toBe("fail"); + expect( + audit.results.find((result) => result.name === "iOS: Native Application")?.message, + ).toContain("select one iOS target"); + }); + + test("keeps a conflicting local Bundle ID failure when remote state is unavailable", async () => { + const root = await fixture({ conflictingBundle: true }); + let remoteCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + getNativeSettings: async () => { + remoteCalls++; + throw new Error("should not inspect remote state"); + }, + listIOSApplications: async () => { + remoteCalls++; + throw new Error("should not inspect remote state"); + }, + }), + ); + + expect(remoteCalls).toBe(0); + const registration = audit.results.find( + (result) => result.name === "iOS: Register the iOS app in Clerk Dashboard", + ); + expect(registration?.status).toBe("fail"); + expect(registration?.message).toContain("blocked"); + expect(registration?.detail).toContain("single Bundle ID"); + }); + + test("fails locally on conflicting literal App ID Prefix evidence without remote reads", async () => { + const root = await fixture(); + let remoteCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + inspectIOSProject: async (...args) => { + const { inspectIOSProject } = await import("../init/ios/inspect.ts"); + const inspection = await inspectIOSProject(...args); + return { + ...inspection, + appTargets: inspection.appTargets.map((target) => ({ + ...target, + configurations: target.configurations.map((configuration, index) => ({ + ...configuration, + ...(configuration.entitlements + ? { + entitlements: { + ...configuration.entitlements, + literalAppIdentifierPrefix: index === 0 ? "LEGACY1234" : "OTHER12345", + }, + } + : {}), + })), + })), + }; + }, + getNativeSettings: async () => { + remoteCalls++; + throw new Error("should not inspect remote state"); + }, + listIOSApplications: async () => { + remoteCalls++; + throw new Error("should not inspect remote state"); + }, + }), + ); + + expect(remoteCalls).toBe(0); + const prefix = audit.results.find((result) => result.name === "iOS: App ID Prefix evidence"); + expect(prefix?.status).toBe("fail"); + expect(prefix?.message).toContain("conflicting values"); + expect(JSON.stringify(audit.results)).not.toContain("OTHER12345"); + }); + + test("redacts keys while diagnosing a linked development-key mismatch", async () => { + const root = await fixture({ complete: true, includeKey: false }); + const localKey = publishableKey("clerk.example.test"); + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + const source = await readFile(sourcePath, "utf8"); + await writeFile( + sourcePath, + source.replace('QuickstartLocalSecrets.load().publishableKey ?? ""', `"${localKey}"`), + ); + const secret = "sk_test_must_never_escape"; + let environmentCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchApplication: async () => ({ + application_id: "app_test", + instances: [ + { + instance_id: "ins_test", + environment_type: "development", + publishable_key: publishableKey("native.clerk.example"), + secret_key: secret, + }, + ], + }), + fetchUserSettings: async () => { + environmentCalls++; + throw new Error("must not contact an unverified Frontend API host"); + }, + }), + ); + + expect(environmentCalls).toBe(0); + const key = audit.results.find((result) => result.name === "iOS: Linked development key"); + expect(key?.status).toBe("fail"); + expect(key?.message).toContain("different Clerk instance"); + expect(JSON.stringify(audit.results)).not.toContain(localKey); + expect(JSON.stringify(audit.results)).not.toContain(secret); + }); + + test("audits inline AuthView settings only after the linked development key matches", async () => { + const root = await fixture({ complete: true, includeKey: false }); + const inlineKey = publishableKey("clerk.example.test"); + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + const source = await readFile(sourcePath, "utf8"); + await writeFile( + sourcePath, + source.replace('QuickstartLocalSecrets.load().publishableKey ?? ""', `"${inlineKey}"`), + ); + const calls: string[] = []; + + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchApplication: async () => { + calls.push("application:start"); + await Promise.resolve(); + calls.push("application:finish"); + return { + application_id: "app_test", + instances: [ + { + instance_id: "ins_test", + environment_type: "development", + publishable_key: publishableKey("clerk.example.test"), + }, + ], + }; + }, + fetchUserSettings: async (host) => { + calls.push(`environment:${host}`); + return { social: {} } as UserSettingsJSON; + }, + }), + ); + + expect(calls).toEqual([ + "application:start", + "application:finish", + "environment:clerk.example.test", + ]); + expect( + audit.results.find((result) => result.name === "iOS: AuthView authentication methods"), + ).toEqual( + expect.objectContaining({ + status: "pass", + message: "AuthView methods: native Apple sign-in is not currently offered", + }), + ); + }); + + test("uses the explicitly linked application for a custom key source without claiming a match", async () => { + const root = await fixture({ + complete: true, + includeKey: false, + localSecrets: true, + }); + const localKey = publishableKey("clerk.example.test"); + const staleSchemeKey = publishableKey("stale-scheme.clerk.example"); + await writeFile( + join(root, "MyApp", "LocalSecrets.plist"), + `CLERK_PUBLISHABLE_KEY${localKey}`, + ); + await writeSelectedTargetRunSchemeKey(root, staleSchemeKey); + let inspectedEnvironmentHost: string | undefined; + + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchUserSettings: async (host) => { + inspectedEnvironmentHost = host; + return { social: {} } as UserSettingsJSON; + }, + }), + ); + + expect( + audit.results.find((result) => result.name === "iOS: Configure Clerk with a publishable key") + ?.status, + ).toBe("pass"); + expect(audit.results.find((result) => result.name === "iOS: Linked Clerk application")).toEqual( + expect.objectContaining({ + status: "pass", + message: "Linked Clerk application: selected for remote checks", + detail: expect.stringContaining("did not inspect its value or verify"), + }), + ); + expect( + audit.results.some((result) => result.name === "iOS: Linked development key"), + ).toBeFalse(); + expect( + audit.results.find((result) => result.name === "iOS: Add Clerk's associated domain"), + ).toEqual( + expect.objectContaining({ + status: "pass", + message: "Clerk's associated domain: matches the linked application", + detail: expect.stringContaining("custom publishable-key value was not inspected"), + }), + ); + expect(inspectedEnvironmentHost).toBe("clerk.example.test"); + expect(audit.inspection.localPublishableKey).toMatchObject({ + state: "unproven", + }); + expect(JSON.stringify(audit)).not.toContain(localKey); + expect(JSON.stringify(audit)).not.toContain(staleSchemeKey); + }); + + test("reports when a custom source's entitlements do not match the linked application", async () => { + const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + await writeFile( + entitlementsPath, + (await readFile(entitlementsPath, "utf8")).replace( + "webcredentials:clerk.example.test", + "webcredentials:other.clerk.example", + ), + ); + + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + + const domain = audit.results.find( + (result) => result.name === "iOS: Add Clerk's associated domain", + ); + expect(domain).toMatchObject({ + status: "fail", + message: "Clerk's associated domain: does not match the linked application", + }); + expect(domain?.remedy).toContain("--app "); + expect(domain?.detail).toContain("custom publishable-key value was not inspected"); + }); + + test("warns when another same-target configure call is unproven", async () => { + const root = await fixture({ + complete: true, + includeKey: false, + localSecrets: true, + }); + const appPath = join(root, "MyApp", "MyAppApp.swift"); + const source = await readFile(appPath, "utf8"); + await writeFile( + appPath, + `${source}\nfunc reconfigureClerk(with publishableKey: String) {\n Clerk.configure(publishableKey: publishableKey)\n}\n`, + ); + + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + const configuration = audit.results.find( + (result) => result.name === "iOS: Configure Clerk with a publishable key", + ); + + expect(audit.inspection.appTargets[0]?.swift.configureCalls).toHaveLength(2); + expect(configuration?.status).toBe("warn"); + expect(configuration?.message).toContain("review needed"); + expect(configuration?.detail).toContain("More than one Clerk.configure"); + expect( + audit.results.some( + (result) => + result.name === "iOS: Linked development key" || + result.name === "iOS: Linked Clerk application", + ), + ).toBeFalse(); + }); + + test("does not inspect AuthView methods for an unlinked custom key source", async () => { + const root = await fixture({ complete: true }); + let environmentCalls = 0; + const unlinkedContext: DoctorContext = { + ...context(), + getProfile: async () => undefined, + }; + const audit = await runIOSDoctorChecks( + unlinkedContext, + { root, target: "MyApp" }, + dependencies({ + fetchUserSettings: async () => { + environmentCalls++; + return { social: {} } as UserSettingsJSON; + }, + }), + ); + + expect(environmentCalls).toBe(0); + const methods = audit.results.find( + (result) => result.name === "iOS: AuthView authentication methods", + ); + expect(methods?.status).toBe("warn"); + expect(methods?.message).toContain("custom key source has no linked application"); + expect(methods?.remedy).toContain("clerk link --app "); + expect( + audit.results.find((result) => result.name === "iOS: Native Application")?.remedy, + ).toContain("clerk link --app "); + }); + + test("fails when AuthView offers Apple but the selected target lacks its entitlement", async () => { + const root = await fixture({ + complete: true, + includeKey: false, + localSecrets: true, + }); + let environmentCalls = 0; + let appleHealthCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchUserSettings: async () => { + environmentCalls++; + return { + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + private_key: "apple-private-material-must-not-escape", + }, + }, + } as unknown as UserSettingsJSON; + }, + auditIOSNativeAppleHealth: async () => { + appleHealthCalls++; + throw new Error("Apple health must wait for the local entitlement"); + }, + }), + ); + + expect(environmentCalls).toBe(1); + expect(appleHealthCalls).toBe(0); + const methods = audit.results.find( + (result) => result.name === "iOS: AuthView authentication methods", + ); + expect(methods?.status).toBe("fail"); + expect(methods?.message).toContain("lacks its entitlement"); + expect(methods?.remedy).toContain("--sign-in-with-apple"); + expect(JSON.stringify(audit.results)).not.toContain("apple-private-material-must-not-escape"); + }); + + test("does not contact an inline AuthView host before the local project is linked", async () => { + const root = await fixture({ complete: true, includeKey: false }); + const inlineKey = publishableKey("clerk.example.test"); + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + const source = await readFile(sourcePath, "utf8"); + await writeFile( + sourcePath, + source.replace('QuickstartLocalSecrets.load().publishableKey ?? ""', `"${inlineKey}"`), + ); + let environmentCalls = 0; + let nativeCalls = 0; + const unlinkedContext: DoctorContext = { + ...context(), + getProfile: async () => undefined, + }; + const audit = await runIOSDoctorChecks( + unlinkedContext, + { root, target: "MyApp" }, + dependencies({ + fetchUserSettings: async () => { + environmentCalls++; + return { + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + }, + }, + } as unknown as UserSettingsJSON; + }, + getNativeSettings: async () => { + nativeCalls++; + return { object: "native_settings", api_enabled: true }; + }, + listIOSApplications: async () => { + nativeCalls++; + return []; + }, + }), + ); + + expect(environmentCalls).toBe(0); + expect(nativeCalls).toBe(0); + expect( + audit.results.find((result) => result.name === "iOS: AuthView authentication methods"), + ).toEqual( + expect.objectContaining({ + status: "warn", + message: + "AuthView methods: remote state not inspected (runtime environment was not proven)", + }), + ); + expect(audit.results.find((result) => result.name === "iOS: Native Application")?.status).toBe( + "warn", + ); + expect(JSON.stringify(audit.results)).not.toContain(inlineKey); + }); + + test("audits a custom Apple flow without treating it as AuthView", async () => { + const root = await fixture(); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import SwiftUI +import ClerkKit + +@main +struct MyApp: App { + @Environment(Clerk.self) @MainActor private var authClient + var body: some Scene { WindowGroup { Text("Hello") } } + func signIn() async throws { try await authClient.auth.signInWithApple() } +} +`, + ); + let environmentCalls = 0; + let entitlementCalls = 0; + let appleHealthCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchUserSettings: async () => { + environmentCalls++; + return { social: {} } as UserSettingsJSON; + }, + planIOSAppleEntitlement: async (options) => { + entitlementCalls++; + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, + auditIOSNativeAppleHealth: async ({ applicationId, instanceId, bundleIdentifier }) => { + appleHealthCalls++; + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-health", + applicationId, + instanceId, + bundleIdentifier, + runtime: { + status: "required", + connection: "required", + bundleIdentifierConfiguration: "satisfied", + current: { enabled: false, authenticatable: false }, + blockers: [], + }, + automation: { + status: "supported", + configVersion: "v1_12345678", + blockers: [], + }, + }; + }, + }), + ); + + expect(environmentCalls).toBe(0); + expect(entitlementCalls).toBe(1); + expect(appleHealthCalls).toBe(1); + expect( + audit.results.some((result) => result.name === "iOS: AuthView authentication methods"), + ).toBeFalse(); + const entitlement = audit.results.find( + (result) => result.name === "iOS: Sign in with Apple entitlement", + ); + expect(entitlement?.status).toBe("fail"); + expect(entitlement?.remedy).toContain("--sign-in-with-apple"); + const remote = audit.results.find((result) => result.name === "iOS: Clerk Sign in with Apple"); + expect(remote?.status).toBe("fail"); + expect(remote?.message).toContain("custom Apple sign-in is referenced"); + }); + + test("does not run Apple checks for an unrelated custom authentication flow", async () => { + const root = await fixture(); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import SwiftUI +import ClerkKit + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { Text("Hello") } } + func signIn() async throws { + try await Clerk.shared.auth.signInWithPassword(identifier: "person@example.com", password: "secret") + } +} +`, + ); + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + + expect( + audit.results.find((result) => result.name === "iOS: Add an authentication flow")?.status, + ).toBe("pass"); + expect( + audit.results.some((result) => result.name === "iOS: AuthView authentication methods"), + ).toBeFalse(); + expect( + audit.results.some((result) => result.name === "iOS: Sign in with Apple entitlement"), + ).toBeFalse(); + expect( + audit.results.some((result) => result.name === "iOS: Clerk Sign in with Apple"), + ).toBeFalse(); + }); + + test("warns without leaking transport details when AuthView settings are unavailable", async () => { + const root = await fixture({ + complete: true, + includeKey: false, + localSecrets: true, + }); + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchUserSettings: async () => { + throw new TypeError("fetch failed with token authview-transport-secret"); + }, + }), + ); + + const methods = audit.results.find( + (result) => result.name === "iOS: AuthView authentication methods", + ); + expect(methods?.status).toBe("warn"); + expect(methods?.message).toContain("could not be inspected"); + expect(JSON.stringify(audit.results)).not.toContain("authview-transport-secret"); + }); + + test("passes a healthy versionless Clerk Apple connection from a local entitlement", async () => { + const root = await fixture(); + await addAppleEntitlement(root); + let appleCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSAppleEntitlement: async (options) => { + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, + auditIOSNativeAppleHealth: async ({ applicationId, instanceId, bundleIdentifier }) => { + appleCalls++; + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-health", + applicationId, + instanceId, + bundleIdentifier, + runtime: { + status: "satisfied", + connection: "satisfied", + bundleIdentifierConfiguration: "satisfied", + current: { enabled: true, authenticatable: true }, + blockers: [], + }, + automation: { status: "supported", blockers: [] }, + }; + }, + }), + ); + + expect(appleCalls).toBe(1); + expect( + audit.results.find((result) => result.name === "iOS: Sign in with Apple entitlement")?.status, + ).toBe("pass"); + const apple = audit.results.find((result) => result.name === "iOS: Clerk Sign in with Apple"); + expect(apple?.status).toBe("pass"); + expect(apple?.detail).toContain("no automatic repair is required"); + expect(apple?.detail).not.toContain("clerk init"); + }); + + test("audits Apple health with the registered Bundle ID spelling", async () => { + const root = await fixture(); + await addAppleEntitlement(root); + const registeredBundleIdentifier = "COM.EXAMPLE.MYAPP"; + let auditedBundleIdentifier: string | undefined; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + listIOSApplications: async () => [ + { + object: "ios_application", + id: "iosapp_test", + app_id_prefix: "LEGACY1234", + bundle_id: registeredBundleIdentifier, + created_at: 1, + updated_at: 1, + }, + ], + planIOSAppleEntitlement: async (options) => { + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, + auditIOSNativeAppleHealth: async ({ applicationId, instanceId, bundleIdentifier }) => { + auditedBundleIdentifier = bundleIdentifier; + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-health", + applicationId, + instanceId, + bundleIdentifier, + runtime: { + status: "satisfied", + connection: "satisfied", + bundleIdentifierConfiguration: "satisfied", + current: { enabled: true, authenticatable: true }, + blockers: [], + }, + automation: { status: "supported", blockers: [] }, + }; + }, + }), + ); + + expect(audit.inspection.appTargets[0]?.configurations[0]?.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.MyApp", + }); + expect(auditedBundleIdentifier).toBe(registeredBundleIdentifier); + expect(audit.results.find((result) => result.name === "iOS: Native Application")?.detail).toBe( + `Bundle ID: ${registeredBundleIdentifier}`, + ); + expect( + audit.results.find((result) => result.name === "iOS: Clerk Sign in with Apple")?.status, + ).toBe("pass"); + }); + + test("fails the strict Apple check for a malformed present entitlement", async () => { + const root = await fixture(); + await addAppleEntitlement(root, ""); + let entitlementCalls = 0; + let appleHealthCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSAppleEntitlement: async (options) => { + entitlementCalls++; + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, + auditIOSNativeAppleHealth: async ({ applicationId, instanceId, bundleIdentifier }) => { + appleHealthCalls++; + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-health", + applicationId, + instanceId, + bundleIdentifier, + runtime: { + status: "required", + connection: "required", + bundleIdentifierConfiguration: "satisfied", + current: { enabled: false, authenticatable: false }, + blockers: [], + }, + automation: { + status: "supported", + configVersion: "v1_12345678", + blockers: [], + }, + }; + }, + }), + ); + + expect(entitlementCalls).toBe(1); + expect(appleHealthCalls).toBe(1); + expect( + audit.results.find((result) => result.name === "iOS: Sign in with Apple entitlement")?.status, + ).toBe("fail"); + }); + + test("classifies access errors without exposing the API response body", async () => { + const root = await fixture(); + const sensitiveBody = '{"errors":[{"message":"Bearer secret-token-value"}]}'; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + getNativeSettings: async () => { + throw new PlapiError(403, sensitiveBody); + }, + }), + ); + + const remote = audit.results.find((result) => result.name === "iOS: Native Application"); + expect(remote?.status).toBe("fail"); + expect(remote?.message).toContain("not permitted"); + expect(remote?.remedy).toContain("applications:read"); + expect(JSON.stringify(audit.results)).not.toContain("secret-token-value"); + }); + + test("fails when a local Apple entitlement has no matching Clerk connection", async () => { + const root = await fixture(); + await addAppleEntitlement(root); + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSAppleEntitlement: async (options) => { + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, + auditIOSNativeAppleHealth: async ({ applicationId, instanceId, bundleIdentifier }) => ({ + schemaVersion: 1, + kind: "clerk-ios-native-apple-health", + applicationId, + instanceId, + bundleIdentifier, + runtime: { + status: "required", + connection: "required", + bundleIdentifierConfiguration: "required", + current: { enabled: true, authenticatable: true }, + blockers: [], + }, + automation: { + status: "supported", + configVersion: "v1_12345678", + blockers: [], + }, + }), + }), + ); + + const apple = audit.results.find((result) => result.name === "iOS: Clerk Sign in with Apple"); + expect(apple?.status).toBe("fail"); + expect(apple?.message).toContain("not bound to the selected Bundle ID"); + }); + + test.each(unsupportedAppleAutomationCases)( + "directs $name repairs away from clerk init", + async ({ code, detail }) => { + const root = await fixture(); + await addAppleEntitlement(root); + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSAppleEntitlement: async (options) => { + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, + auditIOSNativeAppleHealth: async ({ applicationId, instanceId, bundleIdentifier }) => ({ + schemaVersion: 1, + kind: "clerk-ios-native-apple-health", + applicationId, + instanceId, + bundleIdentifier, + runtime: { + status: "required", + connection: "required", + bundleIdentifierConfiguration: "satisfied", + current: { enabled: false, authenticatable: false }, + blockers: [], + }, + automation: { + status: "unsupported", + blockers: [{ code, message: detail }], + }, + }), + }), + ); + + const apple = audit.results.find((result) => result.name === "iOS: Clerk Sign in with Apple"); + expect(apple?.status).toBe("fail"); + expect(apple?.detail).toContain(detail); + expect(apple?.remedy).toContain("Clerk Dashboard"); + expect(apple?.remedy).toContain("support"); + expect(apple?.remedy).not.toContain("clerk init"); + }, + ); + + test("fails Apple permission errors without exposing the API response body", async () => { + const root = await fixture(); + await addAppleEntitlement(root); + const sensitiveBody = '{"errors":[{"message":"Bearer apple-secret-value"}]}'; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSAppleEntitlement: async (options) => { + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, + auditIOSNativeAppleHealth: async () => { + throw new PlapiError(403, sensitiveBody); + }, + }), + ); + + const apple = audit.results.find((result) => result.name === "iOS: Clerk Sign in with Apple"); + expect(apple?.status).toBe("fail"); + expect(apple?.message).toContain("not permitted"); + expect(apple?.remedy).toContain("applications:manage"); + expect(apple?.remedy).not.toContain("applications:read"); + expect(JSON.stringify(audit.results)).not.toContain("apple-secret-value"); + }); +}); diff --git a/packages/cli-core/src/commands/doctor/ios.ts b/packages/cli-core/src/commands/doctor/ios.ts new file mode 100644 index 000000000..9922ad6dd --- /dev/null +++ b/packages/cli-core/src/commands/doctor/ios.ts @@ -0,0 +1,749 @@ +import { decodePublishableKey, fetchUserSettings } from "../../lib/fapi.ts"; +import { CliError, ERROR_CODE, FapiError, isAuthError, PlapiError } from "../../lib/errors.ts"; +import { + fetchApplication, + getNativeSettings, + listIOSApplications, + type Application, + type IOSApplication, + type NativeSettings, +} from "../../lib/plapi.ts"; +import { planIOSAppleEntitlement } from "../init/ios/apple-entitlement.ts"; +import { associatedDomainMatches } from "../init/ios/associated-domain.ts"; +import { auditIOSPrebuiltAuthEnvironment } from "../init/ios/prebuilt-auth-environment.ts"; +import { inspectIOSProject } from "../init/ios/inspect.ts"; +import { auditIOSNativeAppleHealth } from "../init/ios/native-apple.ts"; +import { buildIOSNativeReadinessAudit } from "../init/ios/native-readiness.ts"; +import { auditIOSNativeRemoteSetup } from "../init/ios/native-remote.ts"; +import { planIOSSDKInstall, type IOSSDKInstallPlan } from "../init/ios/install-sdk.ts"; +import { buildIOSSetupPlan } from "../init/ios/plan.ts"; +import { hasSupportedIOSCustomConfigure } from "../init/ios/products.ts"; +import type { IOSAppTarget, IOSProjectInspectionResult, IOSSetupStep } from "../init/ios/types.ts"; +import type { CheckResult, DoctorContext } from "./types.ts"; + +const LOCAL_STEP_REMEDY = "Run `clerk init --target ` to safely complete this step."; +const AUTH_FLOW_REMEDY = + "Integrate authentication at the app's intended signed-out entry point without replacing existing application UI: present ClerkKitUI's `AuthView`, or build a custom ClerkKit sign-in/sign-up flow."; +const REMOTE_REMEDY = + "Run `clerk init --target ` to preview and apply the missing Native Application setup."; +const ASSOCIATED_DOMAIN_RESULT_NAME = "iOS: Add Clerk's associated domain"; + +export interface IOSDoctorOptions { + root: string; + target?: string; +} + +export interface IOSDoctorDependencies { + inspectIOSProject: typeof inspectIOSProject; + fetchApplication: ( + applicationId: string, + options: { includeSecretKeys: false }, + ) => Promise; + getNativeSettings(applicationId: string, instanceId: string): Promise; + listIOSApplications(applicationId: string, instanceId: string): Promise; + fetchUserSettings: typeof fetchUserSettings; + auditIOSPrebuiltAuthEnvironment: typeof auditIOSPrebuiltAuthEnvironment; + planIOSAppleEntitlement: typeof planIOSAppleEntitlement; + auditIOSNativeAppleHealth: typeof auditIOSNativeAppleHealth; + planIOSSDKInstall: typeof planIOSSDKInstall; +} + +const defaultDependencies: IOSDoctorDependencies = { + inspectIOSProject, + fetchApplication, + getNativeSettings, + listIOSApplications, + fetchUserSettings, + auditIOSPrebuiltAuthEnvironment, + planIOSAppleEntitlement, + auditIOSNativeAppleHealth, + planIOSSDKInstall, +}; + +function selectedTarget(inspection: IOSProjectInspectionResult): IOSAppTarget | undefined { + const selection = inspection.selection; + if (selection.state !== "selected") return undefined; + return inspection.appTargets.find( + (target) => target.id === selection.targetId && target.projectPath === selection.projectPath, + ); +} + +async function authViewEnvironmentResult( + target: IOSAppTarget, + dependencies: IOSDoctorDependencies, + options: { + configureStatus: IOSSetupStep["status"] | undefined; + fapiHost?: string; + customSource: boolean; + linked: boolean; + }, +): Promise { + if (target.swift.authViewReferences.length === 0) return undefined; + + const name = "iOS: AuthView authentication methods"; + if (options.configureStatus !== "satisfied" || !options.fapiHost) { + const customUnlinked = options.customSource && !options.linked; + return { + name, + status: "warn", + message: customUnlinked + ? "AuthView methods: remote state not inspected (custom key source has no linked application)" + : "AuthView methods: remote state not inspected (runtime environment was not proven)", + remedy: customUnlinked + ? "Run `clerk link --app ` for the intended Clerk application, then rerun `clerk doctor`." + : LOCAL_STEP_REMEDY, + }; + } + + try { + const environment = dependencies.auditIOSPrebuiltAuthEnvironment( + await dependencies.fetchUserSettings(options.fapiHost, {}), + ); + const linkedApplicationSuffix = options.customSource ? " in the linked application" : ""; + if (environment.apple === "blocked") { + return { + name, + status: "fail", + message: `AuthView methods: Clerk returned an unsupported Apple provider state${linkedApplicationSuffix}`, + remedy: "Review the Apple connection in Clerk Dashboard, then rerun `clerk doctor`.", + }; + } + if (environment.apple === "not-required") { + return { + name, + status: "pass", + message: `AuthView methods: native Apple sign-in is not currently offered${linkedApplicationSuffix}`, + }; + } + + const entitlementIsComplete = + target.configurations.length > 0 && + target.configurations.every( + (configuration) => configuration.entitlements?.signInWithAppleState === "exact", + ); + return entitlementIsComplete + ? { + name, + status: "pass", + message: `AuthView methods: Apple is enabled${linkedApplicationSuffix} and the local entitlement is present`, + } + : { + name, + status: "fail", + message: "AuthView offers Apple sign-in but the selected target lacks its entitlement", + remedy: + "Run `clerk init --target --sign-in-with-apple` to preview and safely add the required capability.", + }; + } catch (error) { + const serviceUnavailable = + error instanceof TypeError || (error instanceof FapiError && error.status >= 500); + if (serviceUnavailable) { + return { + name, + status: "warn", + message: "AuthView methods: Frontend API state could not be inspected", + remedy: "Check your network connection, then rerun `clerk doctor`.", + }; + } + const malformed = error instanceof CliError && error.code === ERROR_CODE.FAPI_ERROR; + return { + name, + status: "fail", + message: malformed + ? "AuthView methods: Clerk returned malformed Frontend API settings" + : "AuthView methods: the configured Frontend API environment is unavailable", + remedy: + "Verify the selected target's development publishable key and Clerk instance, then rerun `clerk doctor`.", + }; + } +} + +function localStepResult(step: IOSSetupStep): CheckResult { + const name = `iOS: ${step.title}`; + const remedy = + step.id === "select-target" + ? step.description + : step.id === "add-authentication-flow" + ? AUTH_FLOW_REMEDY + : LOCAL_STEP_REMEDY; + switch (step.status) { + case "satisfied": + return { + name, + status: "pass", + message: `${step.title}: configured`, + detail: step.description, + }; + case "review": + return { + name, + status: "warn", + message: `${step.title}: review needed`, + detail: step.description, + remedy: step.description, + }; + case "required": + return { + name, + status: "fail", + message: `${step.title}: setup required`, + detail: step.description, + remedy, + }; + case "blocked": + return { + name, + status: "fail", + message: `${step.title}: blocked`, + detail: step.description, + remedy, + }; + } +} + +function localResults( + inspection: IOSProjectInspectionResult, + sdkInstallPlan?: IOSSDKInstallPlan, +): CheckResult[] { + const plan = buildIOSSetupPlan(inspection, { sdkInstallPlan }); + const results = plan.steps + .filter((step) => step.id !== "register-native-application" || step.status === "blocked") + .map(localStepResult); + const readiness = buildIOSNativeReadinessAudit(inspection); + if ( + readiness.target.status === "selected" && + readiness.target.appIdPrefix.status === "conflicting" + ) { + results.push({ + name: "iOS: App ID Prefix evidence", + status: "fail", + message: "App ID Prefix evidence: conflicting values were found", + detail: + "The selected target's entitlements contain different literal App ID Prefix values across build configurations.", + remedy: + "Make the entitlements consistent and verify the exact App ID Prefix in Apple Developer before rerunning `clerk doctor`.", + }); + } + return results; +} + +async function appleEntitlementResult( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget, + dependencies: IOSDoctorDependencies, +): Promise { + const hasCustomAppleIntent = target.swift.appleAuthReferences.length > 0; + const anyAppleEntitlement = target.configurations.some( + (configuration) => + configuration.entitlements !== undefined && + configuration.entitlements.signInWithAppleState !== "absent", + ); + if (!hasCustomAppleIntent && !anyAppleEntitlement) return undefined; + + const plan = await dependencies.planIOSAppleEntitlement({ + root: inspection.root, + projectPath: target.projectPath, + targetId: target.id, + }); + if (plan.status === "satisfied") { + return { + name: "iOS: Sign in with Apple entitlement", + status: "pass", + message: "Sign in with Apple entitlement: configured", + detail: "Every selected-target configuration has the exact native Apple entitlement.", + }; + } + + const detail = + plan.status === "ready" + ? plan.actions.join("\n") + : plan.blockers.map((blocker) => blocker.message).join("\n"); + return { + name: "iOS: Sign in with Apple entitlement", + status: "fail", + message: "Sign in with Apple entitlement: incomplete", + ...(detail ? { detail } : {}), + remedy: + "Run `clerk init --target --sign-in-with-apple` to preview and safely add the required capability.", + }; +} + +function linkedDevelopmentKeyResult( + inspection: IOSProjectInspectionResult, + application: Application, + developmentInstanceId: string, +): CheckResult { + const name = "iOS: Linked development key"; + const localPublishableKey = inspection.localPublishableKey; + if (localPublishableKey.state !== "valid") { + return { + name, + status: "fail", + message: "Linked development key: local runtime key was not proven", + remedy: LOCAL_STEP_REMEDY, + }; + } + const localHost = localPublishableKey.frontendApiHost; + + const instance = application.instances.find( + (candidate) => candidate.instance_id === developmentInstanceId, + ); + if (!instance) { + return { + name, + status: "fail", + message: "Linked development key: linked instance is stale", + remedy: "Run `clerk link` to select a valid development instance.", + }; + } + + try { + const linked = decodePublishableKey(instance.publishable_key); + if ( + localPublishableKey.instanceType !== "development" || + linked.instanceType !== "development" || + linked.fapiHost !== localHost + ) { + return { + name, + status: "fail", + message: "Linked development key: the Xcode target points to a different Clerk instance", + detail: `Local Frontend API host: ${localHost}\nLinked Frontend API host: ${linked.fapiHost}`, + remedy: LOCAL_STEP_REMEDY, + }; + } + return { + name, + status: "pass", + message: "Linked development key: matches the selected Xcode target", + detail: `Frontend API host: ${localHost}`, + }; + } catch { + return { + name, + status: "fail", + message: "Linked development key: Clerk returned an invalid publishable key", + remedy: "Relink the project or contact Clerk support before changing the Xcode target.", + }; + } +} + +function linkedCustomApplicationResult( + application: Application, + developmentInstanceId: string, +): { result: CheckResult; fapiHost?: string } { + const name = "iOS: Linked Clerk application"; + const instance = application.instances.find( + (candidate) => candidate.instance_id === developmentInstanceId, + ); + if (!instance) { + return { + result: { + name, + status: "fail", + message: "Linked Clerk application: linked development instance is stale", + remedy: "Run `clerk link --app ` to select a valid application.", + }, + }; + } + + try { + const linked = decodePublishableKey(instance.publishable_key); + if (linked.instanceType !== "development") throw new Error("not a development key"); + return { + result: { + name, + status: "pass", + message: "Linked Clerk application: selected for remote checks", + detail: + "Clerk.configure uses a custom publishable-key source. Doctor did not inspect its value or verify that it belongs to the linked application.", + }, + fapiHost: linked.fapiHost, + }; + } catch { + return { + result: { + name, + status: "fail", + message: "Linked Clerk application: Clerk returned an invalid development publishable key", + remedy: "Relink the project or contact Clerk support before changing the Xcode target.", + }, + }; + } +} + +function linkedCustomAssociatedDomainResult( + target: IOSAppTarget, + fapiHost: string, +): CheckResult | undefined { + if ( + target.configurations.length === 0 || + target.configurations.some( + (configuration) => + configuration.entitlements == null || + configuration.entitlements.unresolvedAssociatedDomains.length > 0, + ) + ) { + return undefined; + } + + const expectedDomain = `webcredentials:${fapiHost}`; + const configured = target.configurations.every((configuration) => + configuration.entitlements!.associatedDomains.some((domain) => + associatedDomainMatches(domain, expectedDomain), + ), + ); + return configured + ? { + name: ASSOCIATED_DOMAIN_RESULT_NAME, + status: "pass", + message: "Clerk's associated domain: matches the linked application", + detail: + "The custom publishable-key value was not inspected; this verifies the entitlements against the explicitly linked application.", + } + : { + name: ASSOCIATED_DOMAIN_RESULT_NAME, + status: "fail", + message: "Clerk's associated domain: does not match the linked application", + detail: + "The custom publishable-key value was not inspected; this check uses the explicitly linked application.", + remedy: + "Run `clerk init --target --app ` to preview and add the linked application's exact domain.", + }; +} + +async function remoteResults( + ctx: DoctorContext, + inspection: IOSProjectInspectionResult, + dependencies: IOSDoctorDependencies, +): Promise { + const readiness = buildIOSNativeReadinessAudit(inspection); + const target = selectedTarget(inspection); + const configureStep = buildIOSSetupPlan(inspection).steps.find( + (step) => step.id === "configure-publishable-key", + ); + const customSource = + target != null && + configureStep?.status === "satisfied" && + hasSupportedIOSCustomConfigure(target); + const preliminaryResults: CheckResult[] = []; + + if ( + readiness.target.status === "selected" && + (readiness.target.bundleIdentifier.status !== "resolved" || + readiness.target.appIdPrefix.status === "conflicting") + ) { + // These are fully local identity failures. Avoid hiding them behind an + // authentication/network warning or asking the remote API about an + // identity that the selected Xcode target did not prove. + return preliminaryResults; + } + + const profile = await ctx.getProfile(); + if (!profile) { + if (target) { + const authView = await authViewEnvironmentResult(target, dependencies, { + configureStatus: configureStep?.status, + customSource, + linked: false, + }); + if (authView) preliminaryResults.push(authView); + } + return [ + ...preliminaryResults, + { + name: "iOS: Native Application", + status: "warn", + message: "Native Application: remote state not inspected (project is not linked)", + remedy: customSource + ? "Run `clerk link --app ` for the intended Clerk application, then rerun `clerk doctor`." + : "Run `clerk link`, then rerun `clerk doctor`.", + }, + ]; + } + + if (readiness.target.status !== "selected") { + return [ + ...preliminaryResults, + { + name: "iOS: Native Application", + status: "warn", + message: "Native Application: remote state not inspected (select one iOS target)", + remedy: "Rerun with `clerk doctor --target `.", + }, + ]; + } + + const applicationId = profile.profile.appId; + const instanceId = profile.profile.instances.development; + try { + const [application, remotePlan] = await Promise.all([ + dependencies.fetchApplication(applicationId, { + includeSecretKeys: false, + }), + auditIOSNativeRemoteSetup( + { applicationId, instanceId, target: readiness.target }, + { + getNativeSettings: dependencies.getNativeSettings, + listIOSApplications: dependencies.listIOSApplications, + }, + ), + ]); + const customApplication = customSource + ? linkedCustomApplicationResult(application, instanceId) + : undefined; + const linkedResult = + customApplication?.result ?? + (configureStep?.status === "satisfied" + ? linkedDevelopmentKeyResult(inspection, application, instanceId) + : undefined); + const localPublishableKey = inspection.localPublishableKey; + const verifiedFapiHost = + customApplication?.fapiHost ?? + (!customSource && linkedResult?.status === "pass" && localPublishableKey.state === "valid" + ? localPublishableKey.frontendApiHost + : undefined); + if (target) { + const authView = await authViewEnvironmentResult(target, dependencies, { + configureStatus: configureStep?.status, + fapiHost: verifiedFapiHost, + customSource, + linked: true, + }); + if (authView) preliminaryResults.push(authView); + } + const customAssociatedDomain = + target && customApplication?.fapiHost + ? linkedCustomAssociatedDomainResult(target, customApplication.fapiHost) + : undefined; + const results = [ + ...preliminaryResults, + ...(linkedResult ? [linkedResult] : []), + ...(customAssociatedDomain ? [customAssociatedDomain] : []), + ]; + if (remotePlan.status === "satisfied") { + results.push({ + name: "iOS: Native Application", + status: "pass", + message: "Native API and iOS registration: configured", + detail: remotePlan.bundleIdentifier + ? `Bundle ID: ${remotePlan.bundleIdentifier}` + : undefined, + }); + } else { + const detail = + remotePlan.status === "ready" + ? remotePlan.actions.join("\n") + : remotePlan.blockers.map((blocker) => blocker.message).join("\n"); + results.push({ + name: "iOS: Native Application", + status: "fail", + message: + remotePlan.status === "ready" + ? "Native API or iOS registration: setup required" + : "Native API or iOS registration: blocked", + ...(detail ? { detail } : {}), + remedy: REMOTE_REMEDY, + }); + } + + const bundleIdentifier = readiness.target.bundleIdentifier; + const hasAppleEntitlement = target?.configurations.some( + (configuration) => + configuration.entitlements !== undefined && + configuration.entitlements.signInWithAppleState !== "absent", + ); + const hasCustomAppleIntent = (target?.swift.appleAuthReferences.length ?? 0) > 0; + if ( + (hasAppleEntitlement || hasCustomAppleIntent) && + bundleIdentifier.status === "resolved" && + remotePlan.registration === "satisfied" + ) { + const registeredBundleIdentifier = remotePlan.bundleIdentifier; + if (!registeredBundleIdentifier) { + throw new Error("A satisfied iOS registration must include its Bundle ID."); + } + try { + const apple = await dependencies.auditIOSNativeAppleHealth({ + applicationId, + instanceId, + bundleIdentifier: registeredBundleIdentifier, + }); + if (apple.runtime.status === "satisfied") { + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "pass", + message: "Clerk Sign in with Apple: configured for the selected Bundle ID", + detail: + apple.automation.status === "supported" + ? "The current connection is healthy; no automatic repair is required." + : "The current connection is healthy; automatic repair is unavailable for this instance.", + }); + } else if (apple.runtime.status === "required") { + const automationSupported = apple.automation.status === "supported"; + const automationDetail = apple.automation.blockers + .map((blocker) => blocker.message) + .join("\n"); + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "fail", + message: + apple.runtime.bundleIdentifierConfiguration === "required" + ? "Clerk Sign in with Apple: the connection is not bound to the selected Bundle ID" + : hasAppleEntitlement + ? "Clerk Sign in with Apple: local entitlement is present but the connection is disabled" + : "Clerk Sign in with Apple: custom Apple sign-in is referenced but the connection is disabled", + ...(!automationSupported + ? { + detail: + automationDetail || + "Automatic native Sign in with Apple repair is unavailable for this instance.", + } + : {}), + remedy: automationSupported + ? "Run `clerk init --target --sign-in-with-apple` if this app should offer Apple sign-in." + : "Review the Apple connection in the Clerk Dashboard or contact Clerk support, then rerun `clerk doctor`.", + }); + } else { + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "fail", + message: "Clerk Sign in with Apple: configuration conflict", + detail: apple.runtime.blockers.map((blocker) => blocker.message).join("\n"), + remedy: "Review the Apple connection in Clerk Dashboard, then rerun `clerk doctor`.", + }); + } + } catch (error) { + if (error instanceof PlapiError && error.status === 403) { + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "fail", + message: "Clerk Sign in with Apple: application access is not permitted", + remedy: + "Use an account or Platform API key with applications:manage access to the linked app.", + }); + } else if (isAuthError(error) || (error instanceof PlapiError && error.status === 401)) { + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "fail", + message: "Clerk Sign in with Apple: Clerk authentication is invalid", + remedy: "Run `clerk auth login`, then rerun `clerk doctor`.", + }); + } else if (error instanceof PlapiError && error.status === 404) { + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "fail", + message: "Clerk Sign in with Apple: the linked app or instance was not found", + remedy: "Run `clerk link` to refresh this project's application and instance IDs.", + }); + } else { + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "warn", + message: "Clerk Sign in with Apple: remote state could not be inspected", + remedy: "Check your Clerk authentication and network connection, then rerun doctor.", + }); + } + } + } + return results; + } catch (error) { + if (error instanceof CliError && error.code === ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE) { + return [ + ...preliminaryResults, + { + name: "iOS: Native Application", + status: "fail", + message: "Native Application: Clerk returned an invalid remote response", + remedy: + "Update the Clerk CLI, rerun `clerk doctor`, and contact Clerk support if the response remains invalid.", + }, + ]; + } + if (error instanceof PlapiError && error.status === 403) { + return [ + ...preliminaryResults, + { + name: "iOS: Native Application", + status: "fail", + message: "Native Application: application access is not permitted", + remedy: + "Use an account or Platform API key with applications:read access to the linked app.", + }, + ]; + } + if (isAuthError(error) || (error instanceof PlapiError && error.status === 401)) { + return [ + ...preliminaryResults, + { + name: "iOS: Native Application", + status: "fail", + message: "Native Application: Clerk authentication is invalid", + remedy: "Run `clerk auth login`, then rerun `clerk doctor`.", + }, + ]; + } + if (error instanceof PlapiError && error.status === 404) { + return [ + ...preliminaryResults, + { + name: "iOS: Native Application", + status: "fail", + message: "Native Application: the linked app or development instance was not found", + remedy: "Run `clerk link` to refresh this project's application and instance IDs.", + }, + ]; + } + return [ + ...preliminaryResults, + { + name: "iOS: Native Application", + status: "warn", + message: "Native Application: remote state could not be inspected", + remedy: + "Check your Clerk authentication and network connection, then rerun `clerk doctor`.", + }, + ]; + } +} + +export async function runIOSDoctorChecks( + ctx: DoctorContext, + options: IOSDoctorOptions, + dependencies: IOSDoctorDependencies = defaultDependencies, +): Promise<{ inspection: IOSProjectInspectionResult; results: CheckResult[] }> { + const inspection = await dependencies.inspectIOSProject(options.root, { + target: options.target, + exhaustiveContainerDiscovery: true, + }); + const target = selectedTarget(inspection); + const requiresAuthViewCompatibility = (target?.swift.authViewReferences.length ?? 0) > 0; + const requiresClerkKitUI = + (target?.swift.importsClerkKitUI.length ?? 0) > 0 || requiresAuthViewCompatibility; + const sdkInstallPlan = target + ? await dependencies.planIOSSDKInstall({ + root: inspection.root, + projectPath: target.projectPath, + targetId: target.id, + ...(requiresClerkKitUI ? { includeClerkKitUI: true } : {}), + ...(requiresAuthViewCompatibility ? { requirePrebuiltAuthCompatibility: true } : {}), + }) + : undefined; + const results = localResults(inspection, sdkInstallPlan); + if (target) { + const apple = await appleEntitlementResult(inspection, target, dependencies); + if (apple) results.splice(Math.max(0, results.length - 1), 0, apple); + } + for (const remoteResult of await remoteResults(ctx, inspection, dependencies)) { + const existingIndex = + remoteResult.name === ASSOCIATED_DOMAIN_RESULT_NAME + ? results.findIndex((result) => result.name === remoteResult.name) + : -1; + if (existingIndex === -1) { + results.push(remoteResult); + } else { + results[existingIndex] = remoteResult; + } + } + return { inspection, results }; +} diff --git a/packages/cli-core/src/commands/doctor/types.ts b/packages/cli-core/src/commands/doctor/types.ts index 1b665b8e8..fc953378b 100644 --- a/packages/cli-core/src/commands/doctor/types.ts +++ b/packages/cli-core/src/commands/doctor/types.ts @@ -28,6 +28,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; @@ -68,4 +78,6 @@ export interface DoctorOptions { json?: boolean; spotlight?: boolean; fix?: boolean; + /** Exact Xcode application target name or PBX object ID. */ + target?: string; } diff --git a/packages/cli-core/src/commands/init/ios/build-settings.test.ts b/packages/cli-core/src/commands/init/ios/build-settings.test.ts index 0de416451..13099ac0b 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.test.ts @@ -737,7 +737,9 @@ describe("inspectTargetBuildConfigurations", () => { environmentInjections: [], rootEnvironmentInjections: [], environmentConsumers: [], + authViewReferences: [], authFlowReferences: [], + appleAuthReferences: [], openURLHandlers: [], status: "absent", }, diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index f87ae09d2..acd62a9f5 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1704,6 +1704,7 @@ struct MyApp: App { const swift = inspection.appTargets[0]?.swift; expect(swift?.sourceFilesScanned).toBe(2); + expect(swift?.authViewReferences).toEqual([{ path: "Synced/Included/Auth.swift" }]); expect(swift?.authFlowReferences).toEqual([{ path: "Synced/Included/Auth.swift" }]); expect(swift?.configureCalls).toEqual([]); }); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 3177bc4cc..98e791336 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -97,7 +97,9 @@ function emptySwiftInspection() { environmentInjections: [], rootEnvironmentInjections: [], environmentConsumers: [], + authViewReferences: [], authFlowReferences: [], + appleAuthReferences: [], openURLHandlers: [], status: "absent" as const, }; diff --git a/packages/cli-core/src/commands/init/ios/native-apple.test.ts b/packages/cli-core/src/commands/init/ios/native-apple.test.ts index 7e4fd7230..ad831fa61 100644 --- a/packages/cli-core/src/commands/init/ios/native-apple.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-apple.test.ts @@ -4,11 +4,13 @@ import type { InstanceConfigSchema } from "../../../lib/plapi.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; import { applyIOSNativeAppleConnection, + auditIOSNativeAppleHealth, buildIOSNativeApplePlan, prepareIOSNativeAppleConnection, type IOSNativeAppleAPI, type IOSNativeApplePatchOptions, type IOSNativeApplePrompts, + type IOSNativeAppleReadAPI, } from "./native-apple.ts"; const APPLICATION_ID = "app_native_apple"; @@ -215,6 +217,240 @@ function statefulAPI( } describe("native Sign in with Apple remote setup", () => { + test("audits runtime health through a credential-free GET-only projection", async () => { + const calls: string[] = []; + const api: IOSNativeAppleReadAPI = { + async fetchInstanceConfig(applicationId, instanceId, keys) { + expect([applicationId, instanceId]).toEqual([APPLICATION_ID, INSTANCE_ID]); + expect(keys).toEqual(["connection_oauth_apple"]); + calls.push("GET config"); + return config( + connection(true, true, { + bundle_id: BUNDLE_IDENTIFIER, + client_id: SERVICES_ID, + client_secret: PRIVATE_KEY, + team_id: TEAM_ID, + key_id: KEY_ID, + }), + ); + }, + async fetchInstanceConfigSchema(applicationId, instanceId, keys) { + expect([applicationId, instanceId]).toEqual([APPLICATION_ID, INSTANCE_ID]); + expect(keys).toEqual(["connection_oauth_apple"]); + calls.push("GET schema"); + return {}; + }, + }; + + const result = await auditIOSNativeAppleHealth( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + }, + api, + ); + + expect(result.runtime).toEqual({ + status: "satisfied", + connection: "satisfied", + bundleIdentifierConfiguration: "satisfied", + current: { enabled: true, authenticatable: true }, + blockers: [], + }); + expect(result.automation).toMatchObject({ + status: "unsupported", + configVersion: CONFIG_VERSION, + blockers: [expect.objectContaining({ code: "apple-config-unsupported" })], + }); + expect(result.automation.blockers.map((blocker) => blocker.message).join("\n")).not.toContain( + "clerk init", + ); + expect(calls.sort()).toEqual(["GET config", "GET schema"]); + const serialized = JSON.stringify(result); + for (const sensitive of [SERVICES_ID, PRIVATE_KEY, TEAM_ID, KEY_ID]) { + expect(serialized).not.toContain(sensitive); + } + }); + + test("reports repairable runtime state independently from supported automation", async () => { + const api: IOSNativeAppleReadAPI = { + async fetchInstanceConfig() { + return config(connection(false, true)); + }, + async fetchInstanceConfigSchema() { + return appleSchema(); + }, + }; + + const result = await auditIOSNativeAppleHealth( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + }, + api, + ); + + expect(result.runtime).toMatchObject({ + status: "required", + connection: "required", + bundleIdentifierConfiguration: "required", + blockers: [], + }); + expect(result.automation).toEqual({ + status: "supported", + configVersion: CONFIG_VERSION, + blockers: [], + }); + }); + + test("reports a case-only Bundle ID difference as a supported spelling repair", async () => { + const api: IOSNativeAppleReadAPI = { + async fetchInstanceConfig() { + return config( + connection(true, true, { + bundle_id: BUNDLE_IDENTIFIER.toLowerCase(), + }), + ); + }, + async fetchInstanceConfigSchema() { + return appleSchema(); + }, + }; + + const result = await auditIOSNativeAppleHealth( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + }, + api, + ); + + expect(result.runtime).toEqual({ + status: "required", + connection: "required", + bundleIdentifierConfiguration: "required", + current: { enabled: true, authenticatable: true }, + blockers: [], + }); + expect(result.automation).toEqual({ + status: "supported", + configVersion: CONFIG_VERSION, + blockers: [], + }); + }); + + test("requires a config version only when the health audit finds a repair", async () => { + const api: IOSNativeAppleReadAPI = { + async fetchInstanceConfig() { + return config(connection(false, true), null); + }, + async fetchInstanceConfigSchema() { + return appleSchema(); + }, + }; + + const result = await auditIOSNativeAppleHealth( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + }, + api, + ); + + expect(result.runtime.status).toBe("required"); + expect(result.automation).toMatchObject({ + status: "unsupported", + blockers: [expect.objectContaining({ code: "apple-config-version-unavailable" })], + }); + expect(result.automation.configVersion).toBeUndefined(); + expect(result.automation.blockers.map((blocker) => blocker.message).join("\n")).not.toContain( + "clerk init", + ); + }); + + test("keeps a healthy versionless connection supported when no repair is required", async () => { + const api: IOSNativeAppleReadAPI = { + async fetchInstanceConfig() { + return config(connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), null); + }, + async fetchInstanceConfigSchema() { + return appleSchema(); + }, + }; + + const result = await auditIOSNativeAppleHealth( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + }, + api, + ); + + expect(result.runtime.status).toBe("satisfied"); + expect(result.automation).toEqual({ status: "supported", blockers: [] }); + }); + + test("keeps malformed automation metadata from poisoning healthy runtime state", async () => { + const api: IOSNativeAppleReadAPI = { + async fetchInstanceConfig() { + return config( + connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), + `v1_${PRIVATE_KEY}`, + ); + }, + async fetchInstanceConfigSchema() { + return appleSchema(); + }, + }; + + const result = await auditIOSNativeAppleHealth( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + }, + api, + ); + + expect(result.runtime.status).toBe("satisfied"); + expect(result.automation).toMatchObject({ + status: "unsupported", + blockers: [expect.objectContaining({ code: "apple-config-invalid" })], + }); + expect(result.automation.blockers.map((blocker) => blocker.message).join("\n")).not.toContain( + "clerk init", + ); + expect(JSON.stringify(result)).not.toContain(PRIVATE_KEY); + }); + + test("preserves GET transport errors for diagnostic classification", async () => { + const transportError = new Error(API_SECRET); + const api: IOSNativeAppleReadAPI = { + async fetchInstanceConfig() { + throw transportError; + }, + async fetchInstanceConfigSchema() { + return appleSchema(); + }, + }; + + await expect( + auditIOSNativeAppleHealth( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + }, + api, + ), + ).rejects.toBe(transportError); + }); + test("builds a narrow redacted plan without retaining web credentials", () => { const sensitiveConnection = connection(false, true, { client_id: SERVICES_ID, diff --git a/packages/cli-core/src/commands/init/ios/native-apple.ts b/packages/cli-core/src/commands/init/ios/native-apple.ts index 0e374e0de..207e09bd8 100644 --- a/packages/cli-core/src/commands/init/ios/native-apple.ts +++ b/packages/cli-core/src/commands/init/ios/native-apple.ts @@ -112,6 +112,43 @@ export interface IOSNativeAppleAPI { ): Promise>; } +/** GET-only Apple connection API surface used by read-only diagnostics. */ +export type IOSNativeAppleReadAPI = Pick< + IOSNativeAppleAPI, + "fetchInstanceConfig" | "fetchInstanceConfigSchema" +>; + +export interface AuditIOSNativeAppleHealthOptions { + applicationId: string; + instanceId: string; + bundleIdentifier: string; +} + +/** + * Credential-free health projection of the current Apple runtime state. The + * ability to automate a repair is reported separately so an unsupported patch + * schema cannot make an already-correct runtime configuration look broken. + */ +export interface IOSNativeAppleHealthAudit { + schemaVersion: 1; + kind: "clerk-ios-native-apple-health"; + applicationId: string; + instanceId: string; + bundleIdentifier: string; + runtime: { + status: "required" | "satisfied" | "blocked"; + connection: "required" | "satisfied" | "blocked"; + bundleIdentifierConfiguration: "required" | "satisfied" | "blocked"; + current?: AppleConnectionState; + blockers: IOSNativeAppleBlocker[]; + }; + automation: { + status: "supported" | "unsupported"; + configVersion?: string; + blockers: IOSNativeAppleBlocker[]; + }; +} + const defaultAPI: IOSNativeAppleAPI = { fetchInstanceConfig, fetchInstanceConfigSchema, @@ -262,6 +299,147 @@ function parseConfigVersion( return { status: "valid", value }; } +function buildIOSNativeAppleHealthAudit( + options: AuditIOSNativeAppleHealthOptions & { + config: Record; + schema: InstanceConfigSchema; + }, +): IOSNativeAppleHealthAudit { + const bundleIdentifier = options.bundleIdentifier.trim(); + const runtimeBlockers: IOSNativeAppleBlocker[] = []; + if (!bundleIdentifier) { + runtimeBlockers.push( + blocker( + "bundle-identifier-unavailable", + "Resolve one Bundle ID for the selected iOS target before verifying native Sign in with Apple.", + ), + ); + } + + const parsed = parseConnection(options.config); + if (parsed.status === "invalid") { + runtimeBlockers.push( + blocker( + "apple-config-invalid", + "The existing Apple connection configuration could not be interpreted safely. Review it in the Clerk Dashboard before continuing.", + ), + ); + } + if ( + parsed.status === "valid" && + parsed.bundleIdentifier && + bundleIdentifier && + !bundleIdentifiersEqual(parsed.bundleIdentifier, bundleIdentifier) + ) { + runtimeBlockers.push( + blocker( + "apple-bundle-identifier-conflict", + "The existing Apple connection references a different iOS Bundle ID. clerk init will not replace it.", + ), + ); + } + if (parsed.status === "valid" && parsed.value.enabled && !parsed.value.authenticatable) { + runtimeBlockers.push( + blocker( + "apple-authenticatable-conflict", + "Apple is enabled but intentionally unavailable for authentication. clerk init will not override that policy automatically.", + ), + ); + } + + const current = parsed.status === "valid" ? parsed.value : undefined; + const bundleIdentifierConfiguration = + runtimeBlockers.length > 0 + ? "blocked" + : parsed.status !== "valid" + ? "blocked" + : parsed.bundleIdentifier === bundleIdentifier + ? "satisfied" + : "required"; + const connection = + runtimeBlockers.length > 0 + ? "blocked" + : current?.enabled === true && + current.authenticatable === true && + bundleIdentifierConfiguration === "satisfied" + ? "satisfied" + : "required"; + const runtimeStatus = + connection === "blocked" ? "blocked" : connection === "satisfied" ? "satisfied" : "required"; + + const automationBlockers: IOSNativeAppleBlocker[] = []; + if (!schemaSupportsNarrowApplePatch(options.schema)) { + automationBlockers.push( + blocker( + "apple-config-unsupported", + "This Clerk instance does not expose the narrow native Apple connection configuration required for automatic repair. Review the Apple connection in the Clerk Dashboard or contact Clerk support.", + ), + ); + } + const configVersion = parseConfigVersion(options.config); + if (configVersion.status === "invalid") { + automationBlockers.push( + blocker( + "apple-config-invalid", + "The Apple connection configuration version could not be interpreted safely. Review the Apple connection in the Clerk Dashboard or contact Clerk support before making remote changes.", + ), + ); + } + if (configVersion.status === "missing" && runtimeStatus === "required") { + automationBlockers.push( + blocker( + "apple-config-version-unavailable", + "The Apple connection configuration did not include the version required to protect an automatic repair. Review the Apple connection in the Clerk Dashboard or contact Clerk support.", + ), + ); + } + + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-health", + applicationId: options.applicationId, + instanceId: options.instanceId, + bundleIdentifier, + runtime: { + status: runtimeStatus, + connection, + bundleIdentifierConfiguration, + ...(current ? { current } : {}), + blockers: runtimeBlockers, + }, + automation: { + status: automationBlockers.length === 0 ? "supported" : "unsupported", + ...(configVersion.status === "valid" ? { configVersion: configVersion.value } : {}), + blockers: automationBlockers, + }, + }; +} + +async function readIOSNativeAppleState( + applicationId: string, + instanceId: string, + api: IOSNativeAppleReadAPI, +): Promise<{ config: Record; schema: InstanceConfigSchema }> { + const [config, schema] = await Promise.all([ + api.fetchInstanceConfig(applicationId, instanceId, [APPLE_CONNECTION_KEY]), + api.fetchInstanceConfigSchema(applicationId, instanceId, [APPLE_CONNECTION_KEY]), + ]); + return { config, schema }; +} + +/** + * Reads Apple connection configuration without a spinner, prompt, mutation, + * or error wrapping. Raw config and schema responses remain internal; only a + * credential-free health projection is returned. + */ +export async function auditIOSNativeAppleHealth( + options: AuditIOSNativeAppleHealthOptions, + api: IOSNativeAppleReadAPI = defaultAPI, +): Promise { + const state = await readIOSNativeAppleState(options.applicationId, options.instanceId, api); + return buildIOSNativeAppleHealthAudit({ ...options, ...state }); +} + export function buildIOSNativeApplePlan( options: IOSNativeAppleOptions & { config: Record; @@ -406,18 +584,10 @@ export async function auditIOSNativeAppleConnection( let config: Record; let schema: InstanceConfigSchema; try { - [config, schema] = await withSpinner( + ({ config, schema } = await withSpinner( "Auditing Clerk Sign in with Apple settings...", - async () => - Promise.all([ - api.fetchInstanceConfig(options.applicationId, options.instanceId, [ - APPLE_CONNECTION_KEY, - ]), - api.fetchInstanceConfigSchema(options.applicationId, options.instanceId, [ - APPLE_CONNECTION_KEY, - ]), - ]), - ); + async () => readIOSNativeAppleState(options.applicationId, options.instanceId, api), + )); } catch (error) { rethrowKnownAppleError(error); throw iosAppleError( diff --git a/packages/cli-core/src/commands/init/ios/native-remote.test.ts b/packages/cli-core/src/commands/init/ios/native-remote.test.ts index f94b8485f..39c03158e 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.test.ts @@ -5,6 +5,7 @@ import { useCaptureLog } from "../../../test/lib/stubs.ts"; import type { IOSNativeReadinessTarget } from "./native-readiness.ts"; import { applyIOSNativeRemoteSetup, + auditIOSNativeRemoteSetup, buildIOSNativeRemotePlan, prepareIOSNativeRemoteSetup, validateAppIdPrefix, @@ -14,6 +15,7 @@ import { type IOSNativeRemotePrompts, type IOSNativeRemoteTargetReader, type IOSNativeRemoteTargetSnapshot, + type IOSNativeRemoteReadAPI, } from "./native-remote.ts"; import { validateNativeSettings, @@ -319,6 +321,66 @@ function prompts( } describe("Clerk Native Application remote setup", () => { + test("audits through a GET-only API and returns the redacted remote plan", async () => { + const calls: string[] = []; + const sensitiveUnexpectedField = "pk_test_MUST_NOT_ESCAPE"; + const api: IOSNativeRemoteReadAPI = { + async getNativeSettings(applicationId, instanceId) { + expect([applicationId, instanceId]).toEqual([APPLICATION_ID, INSTANCE_ID]); + calls.push("GET native settings"); + return { + ...nativeSettings(true), + publishable_key: sensitiveUnexpectedField, + } as NativeSettings; + }, + async listIOSApplications(applicationId, instanceId) { + expect([applicationId, instanceId]).toEqual([APPLICATION_ID, INSTANCE_ID]); + calls.push("GET iOS registrations"); + return [registration()]; + }, + }; + + const result = await auditIOSNativeRemoteSetup( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget(), + }, + api, + ); + + expect(result).toMatchObject({ + status: "satisfied", + nativeApi: "satisfied", + registration: "satisfied", + }); + expect(calls.sort()).toEqual(["GET iOS registrations", "GET native settings"]); + expect(JSON.stringify(result)).not.toContain(sensitiveUnexpectedField); + }); + + test("preserves GET transport errors for diagnostic classification", async () => { + const transportError = new Error("native audit transport failure"); + const api: IOSNativeRemoteReadAPI = { + async getNativeSettings() { + throw transportError; + }, + async listIOSApplications() { + return []; + }, + }; + + await expect( + auditIOSNativeRemoteSetup( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget(), + }, + api, + ), + ).rejects.toBe(transportError); + }); + test("validates Apple identity formats without equating a prefix to the Team ID", () => { expect(validateAppIdPrefix(" LeGaCy1234 ")).toBe("LeGaCy1234"); expect(validateAppIdPrefix("legacy.prefix-value")).toBeUndefined(); diff --git a/packages/cli-core/src/commands/init/ios/native-remote.ts b/packages/cli-core/src/commands/init/ios/native-remote.ts index eb2b9e038..385cf2a7b 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -128,6 +128,21 @@ export interface IOSNativeRemoteAPI { ): Promise; } +/** GET-only Native Application API surface used by read-only diagnostics. */ +export type IOSNativeRemoteReadAPI = Pick< + IOSNativeRemoteAPI, + "getNativeSettings" | "listIOSApplications" +>; + +export interface AuditIOSNativeRemoteSetupOptions { + applicationId: string; + instanceId: string; + /** Included by mutating init plans so their exact local target can be revalidated. */ + root?: string; + target: IOSNativeReadinessTarget; + requestedAppIdPrefix?: string; +} + const defaultAPI: IOSNativeRemoteAPI = { getNativeSettings, enableNativeApi, @@ -473,7 +488,7 @@ export function buildIOSNativeRemotePlan(options: { async function readRemoteState( applicationId: string, instanceId: string, - api: IOSNativeRemoteAPI, + api: IOSNativeRemoteReadAPI, ): Promise<{ nativeSettings: NativeSettings; registrations: IOSApplication[] }> { const [nativeSettings, registrations] = await Promise.all([ api.getNativeSettings(applicationId, instanceId), @@ -485,6 +500,39 @@ async function readRemoteState( }; } +async function readIOSNativeRemoteAudit( + options: AuditIOSNativeRemoteSetupOptions, + api: IOSNativeRemoteReadAPI, +): Promise<{ + plan: IOSNativeRemotePlan; + state: Awaited>; +}> { + const state = await readRemoteState(options.applicationId, options.instanceId, api); + return { + plan: buildIOSNativeRemotePlan({ + applicationId: options.applicationId, + instanceId: options.instanceId, + root: options.root, + target: options.target, + requestedAppIdPrefix: options.requestedAppIdPrefix, + ...state, + }), + state, + }; +} + +/** + * Reads Native API and iOS registration state without prompting, mutating, or + * wrapping transport errors. The returned plan is a redacted projection of + * the two GET responses; raw response objects are not exposed. + */ +export async function auditIOSNativeRemoteSetup( + options: AuditIOSNativeRemoteSetupOptions, + api: IOSNativeRemoteReadAPI = defaultAPI, +): Promise { + return (await readIOSNativeRemoteAudit(options, api)).plan; +} + function formatBlockers(plan: IOSNativeRemotePlan): string { return plan.blockers.map((item) => ` • ${item.message}`).join("\n"); } @@ -586,10 +634,22 @@ export async function prepareIOSNativeRemoteSetup( const api = dependencies.api ?? defaultAPI; const prompts = dependencies.prompts ?? defaultPrompts; let state: Awaited>; + let plan: IOSNativeRemotePlan; try { - state = await withSpinner("Auditing Clerk Native Application settings...", async () => - readRemoteState(options.applicationId, options.instanceId, api), + const audit = await withSpinner("Auditing Clerk Native Application settings...", async () => + readIOSNativeRemoteAudit( + { + applicationId: options.applicationId, + instanceId: options.instanceId, + root: options.root, + target: options.target, + requestedAppIdPrefix: options.appIdPrefix, + }, + api, + ), ); + state = audit.state; + plan = audit.plan; } catch (error) { logSuppressedFailure("Could not inspect Clerk Native Application settings"); rethrowKnownRemoteError(error); @@ -598,15 +658,6 @@ export async function prepareIOSNativeRemoteSetup( ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, ); } - let plan = buildIOSNativeRemotePlan({ - applicationId: options.applicationId, - instanceId: options.instanceId, - root: options.root, - target: options.target, - requestedAppIdPrefix: options.appIdPrefix, - ...state, - }); - const onlyMissingPrefix = plan.status === "blocked" && plan.blockers.length === 1 && diff --git a/packages/cli-core/src/commands/init/ios/products.test.ts b/packages/cli-core/src/commands/init/ios/products.test.ts index ef9aa0186..362289d61 100644 --- a/packages/cli-core/src/commands/init/ios/products.test.ts +++ b/packages/cli-core/src/commands/init/ios/products.test.ts @@ -25,7 +25,9 @@ function target(): IOSAppTarget { environmentInjections: [], rootEnvironmentInjections: [], environmentConsumers: [], + authViewReferences: [], authFlowReferences: [], + appleAuthReferences: [], openURLHandlers: [], status: "absent", }, diff --git a/packages/cli-core/src/commands/init/ios/swift.test.ts b/packages/cli-core/src/commands/init/ios/swift.test.ts index 2deb14d49..49e30bdf8 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -425,6 +425,9 @@ Clerk.configure(publishableKey: key)`, `import ClerkKit import ClerkKitUI let native = #/Clerk.shared.auth.signInWithApple()/# + let text = "Clerk.shared.auth.signUpWithApple()" + // Clerk.shared.auth.signInWithApple() + /* Clerk.shared.auth.signUpWithApple() */ let prebuilt = /AuthView()/`, ); @@ -434,6 +437,7 @@ Clerk.configure(publishableKey: key)`, expect(inspection.evidenceComplete).toBe(true); expect(inspection.authFlowReferences).toEqual([]); + expect(inspection.appleAuthReferences).toEqual([]); }); test("still records a real authentication call adjacent to a regex literal", async () => { @@ -446,6 +450,7 @@ Clerk.configure(publishableKey: key)`, let matcher = #/Clerk.shared.auth.signInWithApple()/# func authenticate() async throws { try await Clerk.shared.auth.signInWithApple() + try await Clerk.shared.auth.signUpWithApple() }`, ); @@ -455,6 +460,249 @@ Clerk.configure(publishableKey: key)`, expect(inspection.evidenceComplete).toBe(true); expect(inspection.authFlowReferences).toEqual([{ path: "Authentication.swift" }]); + expect(inspection.appleAuthReferences).toEqual([{ path: "Authentication.swift" }]); + }); + + test("recognizes native Apple calls through the declared Clerk environment identifier", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const declaredPath = join(root, "Declared.swift"); + const undeclaredPath = join(root, "Undeclared.swift"); + await Bun.write( + declaredPath, + `import ClerkKit + struct DeclaredFlow { + @Environment(Clerk.self) private var authClient + func authenticate() async throws { + try await authClient.auth.signInWithApple() + } + }`, + ); + await Bun.write( + undeclaredPath, + `import ClerkKit + struct UnrelatedFlow { + func authenticate() async throws { + try await authClient.auth.signInWithApple() + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: declaredPath, relativePath: "Declared.swift" }, + { absolutePath: undeclaredPath, relativePath: "Undeclared.swift" }, + ]); + + expect(inspection.authFlowReferences).toEqual([{ path: "Declared.swift" }]); + expect(inspection.appleAuthReferences).toEqual([{ path: "Declared.swift" }]); + }); + + test("recognizes a Clerk environment declaration with an intervening global-actor attribute", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "MainActorFlow.swift"); + await Bun.write( + path, + `import ClerkKit + struct MainActorFlow { + @Environment(Clerk.self) @MainActor private var authClient + func authenticate() async throws { + try await authClient.auth.signInWithApple() + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "MainActorFlow.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.authFlowReferences).toEqual([{ path: "MainActorFlow.swift" }]); + expect(inspection.appleAuthReferences).toEqual([{ path: "MainActorFlow.swift" }]); + }); + + test("recognizes a Clerk environment declaration with a parameterized attribute", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "AvailableFlow.swift"); + await Bun.write( + path, + `import ClerkKit + struct AvailableFlow { + @Environment(Clerk.self) @available(iOS 17.0, *) private var authClient + func authenticate() async throws { + try await authClient.auth.signInWithApple() + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "AvailableFlow.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.authFlowReferences).toEqual([{ path: "AvailableFlow.swift" }]); + expect(inspection.appleAuthReferences).toEqual([{ path: "AvailableFlow.swift" }]); + }); + + test("fails closed for malformed attributes after a Clerk environment declaration", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "MalformedFlow.swift"); + await Bun.write( + path, + `import ClerkKit + struct MalformedFlow { + @Environment(Clerk.self) @available(iOS 17.0, * private var authClient + func authenticate() async throws { + try await authClient.auth.signInWithApple() + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "MalformedFlow.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(false); + expect(inspection.authFlowReferences).toEqual([]); + expect(inspection.appleAuthReferences).toEqual([]); + }); + + test("fails closed for unsupported modifiers after a Clerk environment declaration", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "UnsupportedFlow.swift"); + await Bun.write( + path, + `import ClerkKit + struct UnsupportedFlow { + @Environment(Clerk.self) borrowing var authClient + func authenticate() async throws { + try await authClient.auth.signInWithApple() + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "UnsupportedFlow.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(false); + expect(inspection.authFlowReferences).toEqual([]); + expect(inspection.appleAuthReferences).toEqual([]); + }); + + test("does not attribute a same-named receiver in another type to the Clerk environment", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "MixedFlows.swift"); + await Bun.write( + path, + `import ClerkKit + struct ClerkView { + @Environment(Clerk.self) private var authClient + } + struct OtherFlow { + let authClient: OtherSDK + func authenticate() async throws { + try await authClient.auth.signInWithApple() + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "MixedFlows.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.authFlowReferences).toEqual([]); + expect(inspection.appleAuthReferences).toEqual([]); + }); + + test("recognizes a Clerk environment alias used by a same-type extension", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "ExtendedFlow.swift"); + await Bun.write( + path, + `import ClerkKit + struct ClerkView { + @Environment(Clerk.self) private var authClient + } + extension ClerkView { + func authenticate() async throws { + try await authClient.auth.signInWithApple() + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "ExtendedFlow.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.authFlowReferences).toEqual([{ path: "ExtendedFlow.swift" }]); + expect(inspection.appleAuthReferences).toEqual([{ path: "ExtendedFlow.swift" }]); + }); + + test("recognizes an internal Clerk environment alias used by an extension in another file", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const viewPath = join(root, "ClerkView.swift"); + const extensionPath = join(root, "ClerkView+Auth.swift"); + await Bun.write( + viewPath, + `import ClerkKit + struct ClerkView { + @Environment(Clerk.self) var authClient + }`, + ); + await Bun.write( + extensionPath, + `import ClerkKit + extension ClerkView { + func authenticate() async throws { + try await authClient.auth.signInWithApple() + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: viewPath, relativePath: "ClerkView.swift" }, + { absolutePath: extensionPath, relativePath: "ClerkView+Auth.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.authFlowReferences).toEqual([{ path: "ClerkView+Auth.swift" }]); + expect(inspection.appleAuthReferences).toEqual([{ path: "ClerkView+Auth.swift" }]); + }); + + test("leaves a closure capture that rebinds the Clerk alias unresolved", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "CapturedFlow.swift"); + await Bun.write( + path, + `import ClerkKit + struct ClerkView { + @Environment(Clerk.self) private var authClient + let otherSDK: OtherSDK + func authenticate() { + Task { [authClient = otherSDK] in + try await authClient.auth.signInWithApple() + } + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "CapturedFlow.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(false); + expect(inspection.authFlowReferences).toEqual([]); + expect(inspection.appleAuthReferences).toEqual([]); }); test("marks source evidence incomplete for an unclosed regex literal", async () => { @@ -474,6 +722,7 @@ Clerk.configure(publishableKey: key)`, expect(inspection.evidenceComplete).toBe(false); expect(inspection.authFlowReferences).toEqual([]); + expect(inspection.appleAuthReferences).toEqual([]); }); test("records real Clerk evidence without retaining key expressions", async () => { @@ -512,7 +761,9 @@ Clerk.configure(publishableKey: key)`, }, ]); expect(inspection.environmentInjections).toEqual([{ path: "App.swift" }]); + expect(inspection.authViewReferences).toEqual([{ path: "App.swift" }]); expect(inspection.authFlowReferences).toEqual([{ path: "App.swift" }]); + expect(inspection.appleAuthReferences).toEqual([]); expect(inspection.openURLHandlers).toEqual([{ path: "App.swift" }]); expect(JSON.stringify(inspection)).not.toContain("must-not-leak"); }); @@ -1029,6 +1280,8 @@ Clerk.configure(publishableKey: key)`, { path: "Password.swift" }, { path: "SignUp.swift" }, ]); + expect(inspection.authViewReferences).toEqual([]); + expect(inspection.appleAuthReferences).toEqual([]); }); test("marks multiple entry points as ambiguous", async () => { @@ -1093,6 +1346,7 @@ Clerk.configure(publishableKey: key)`, { absolutePath: path, relativePath: "ContentView.swift" }, ]); + expect(inspection.authViewReferences).toEqual([]); expect(inspection.authFlowReferences).toEqual([]); }); }); diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index 9e1e88c58..717c0dbb8 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -13,10 +13,15 @@ const MAX_SWIFT_FILE_BYTES = 1_000_000; const CLERK_CONFIGURE_CALL = /\bClerk\s*\.\s*configure\s*\(/; const CLERK_URL_HANDLER = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*handle\s*\(/; -const CLERK_NATIVE_AUTH_FLOW = - /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*(?:signIn(?:With(?:Password|EmailCode|EmailLink|PhoneCode|OAuth|IdToken|Apple|Passkey|EnterpriseSSO|Ticket))?|signUp(?:With(?:OAuth|Apple|IdToken|EnterpriseSSO|Ticket))?|startHostedAuth)\s*\(/; +const CLERK_NATIVE_AUTH_METHOD = + "(?:signIn(?:With(?:Password|EmailCode|EmailLink|PhoneCode|OAuth|IdToken|Apple|Passkey|EnterpriseSSO|Ticket))?|signUp(?:With(?:OAuth|Apple|IdToken|EnterpriseSSO|Ticket))?|startHostedAuth)"; +const CLERK_NATIVE_AUTH_FLOW = new RegExp( + `\\b(?:Clerk\\s*\\.\\s*shared|clerk)\\s*\\.\\s*auth\\s*\\.\\s*${CLERK_NATIVE_AUTH_METHOD}\\s*\\(`, +); const CLERK_EMAIL_LINK_AUTH_FLOW = /(?:\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*signInWithEmailLink|\.\s*sendEmailLink)\s*\(/; +const CLERK_NATIVE_APPLE_AUTH_FLOW = + /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*(?:signIn|signUp)WithApple\s*\(/; const CLERK_ENVIRONMENT_INJECTION = /\.\s*environment\s*\(\s*Clerk\s*\.\s*shared\s*\)/; const CLERK_ENVIRONMENT_CONSUMER = /@Environment\s*\(\s*Clerk\s*\.\s*self\s*\)/; const CLERK_AUTH_VIEW = /\bAuthView\s*\(/; @@ -99,6 +104,7 @@ const CLERK_EVIDENCE_PATTERNS = [ CLERK_URL_HANDLER, CLERK_NATIVE_AUTH_FLOW, CLERK_EMAIL_LINK_AUTH_FLOW, + CLERK_NATIVE_APPLE_AUTH_FLOW, CLERK_ENVIRONMENT_INJECTION, CLERK_ENVIRONMENT_CONSUMER, CLERK_AUTH_VIEW, @@ -244,6 +250,312 @@ function has(source: string, pattern: RegExp): boolean { return pattern.test(source); } +const CLERK_ENVIRONMENT_ATTRIBUTE = /@Environment\s*\(\s*Clerk\s*\.\s*self\s*\)/g; +const CLERK_ENVIRONMENT_PROPERTY_MODIFIERS = new Set([ + "class", + "dynamic", + "fileprivate", + "final", + "internal", + "lazy", + "nonisolated", + "open", + "override", + "package", + "private", + "public", + "static", + "unowned", + "weak", +]); +const CLERK_ENVIRONMENT_ACCESS_MODIFIERS = new Set([ + "fileprivate", + "internal", + "open", + "package", + "private", + "public", +]); + +interface ClerkEnvironmentAlias { + identifier: string; + declarationIndex: number; + declarationEnd: number; + crossFileVisible: boolean; + typeBody: NominalTypeBody; +} + +interface ProjectClerkEnvironmentAlias { + identifier: string; + sourcePath: string; + typeIdentity: string; + crossFileVisible: boolean; +} + +interface ClerkEnvironmentAuthInspection { + native: boolean; + apple: boolean; + complete: boolean; +} + +interface ParsedClerkEnvironmentDeclaration { + identifier: string; + declarationEnd: number; + crossFileVisible: boolean; +} + +interface NominalTypeBody extends SourceBodyRange { + identity: string; +} + +function escapeRegularExpression(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function nominalTypeBodies(source: string): NominalTypeBody[] { + const bodies: NominalTypeBody[] = []; + const declaration = /\b(struct|class|enum|actor|extension)\s+([A-Za-z_][A-Za-z0-9_.]*)/g; + let match: RegExpExecArray | null; + while ((match = declaration.exec(source)) !== null) { + const openingBrace = source.indexOf("{", match.index + match[0].length); + if (openingBrace === -1) continue; + const header = source.slice(match.index + match[0].length, openingBrace); + if (/[;}]/.test(header) || /\b(?:struct|class|enum|actor|extension)\b/.test(header)) { + continue; + } + const closingBrace = matchingBrace(source, openingBrace); + if (closingBrace == null) continue; + const kind = match[1]; + const name = match[2]; + if (!kind || !name) continue; + const parent = innermostTypeBodyAt(bodies, match.index); + const identity = kind === "extension" || !parent ? name : `${parent.identity}.${name}`; + bodies.push({ openingBrace, closingBrace, identity }); + } + return bodies; +} + +function innermostTypeBodyAt( + bodies: NominalTypeBody[], + position: number, +): NominalTypeBody | undefined { + return bodies + .filter((body) => position > body.openingBrace && position < body.closingBrace) + .sort( + (left, right) => + left.closingBrace - left.openingBrace - (right.closingBrace - right.openingBrace), + )[0]; +} + +function parseClerkEnvironmentDeclaration( + source: string, + start: number, +): ParsedClerkEnvironmentDeclaration | undefined { + let cursor = start; + let crossFileVisible = true; + + while (true) { + cursor = skipWhitespace(source, cursor); + + if (source[cursor] === "@") { + const attribute = /^@[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*/.exec( + source.slice(cursor), + ); + if (!attribute) return undefined; + cursor += attribute[0].length; + + const argumentsStart = skipWhitespace(source, cursor); + if (source[argumentsStart] === "(") { + const argumentsEnd = matchingParenthesis(source, argumentsStart); + if (argumentsEnd == null) return undefined; + cursor = argumentsEnd + 1; + } + continue; + } + + const token = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(cursor))?.[0]; + if (!token) return undefined; + cursor += token.length; + + if (token === "var") { + const identifierStart = skipWhitespace(source, cursor); + const identifier = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(identifierStart))?.[0]; + if (!identifier) return undefined; + return { + identifier, + declarationEnd: identifierStart + identifier.length, + crossFileVisible, + }; + } + + if (!CLERK_ENVIRONMENT_PROPERTY_MODIFIERS.has(token)) return undefined; + + const argumentsStart = skipWhitespace(source, cursor); + if (source[argumentsStart] !== "(") { + if (token === "private" || token === "fileprivate") crossFileVisible = false; + continue; + } + const argumentsEnd = matchingParenthesis(source, argumentsStart); + if (argumentsEnd == null) return undefined; + const argument = source.slice(argumentsStart + 1, argumentsEnd).replace(/\s+/g, ""); + const validArgument = + (CLERK_ENVIRONMENT_ACCESS_MODIFIERS.has(token) && argument === "set") || + (token === "nonisolated" && argument === "unsafe") || + (token === "unowned" && (argument === "safe" || argument === "unsafe")); + if (!validArgument) return undefined; + cursor = argumentsEnd + 1; + } +} + +function clerkEnvironmentAliases( + source: string, + typeBodies: NominalTypeBody[], +): { aliases: ClerkEnvironmentAlias[]; complete: boolean } { + const aliases: ClerkEnvironmentAlias[] = []; + let complete = true; + CLERK_ENVIRONMENT_ATTRIBUTE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = CLERK_ENVIRONMENT_ATTRIBUTE.exec(source)) !== null) { + const declaration = parseClerkEnvironmentDeclaration(source, match.index + match[0].length); + const typeBody = innermostTypeBodyAt(typeBodies, match.index); + if ( + !declaration || + !typeBody || + braceDepthAt(source, typeBody.openingBrace, match.index) !== 1 + ) { + complete = false; + continue; + } + aliases.push({ + identifier: declaration.identifier, + declarationIndex: match.index, + declarationEnd: declaration.declarationEnd, + crossFileVisible: declaration.crossFileVisible, + typeBody, + }); + } + return { aliases, complete }; +} + +function aliasIsShadowedInBody( + source: string, + identifier: string, + declaration: ClerkEnvironmentAlias | undefined, + body: NominalTypeBody, + typeBodies: NominalTypeBody[], +): boolean { + const escaped = escapeRegularExpression(identifier); + const typeSource = source.slice(body.openingBrace + 1, body.closingBrace); + const typeOffset = body.openingBrace + 1; + const declarations = [ + ...typeSource.matchAll(new RegExp(`\\b(?:let|var)\\s+${escaped}\\b`, "g")), + ].filter((match) => { + const index = typeOffset + match.index; + return innermostTypeBodyAt(typeBodies, index) === body; + }); + if (declaration && body === declaration.typeBody) { + const declarationIndex = typeOffset + (declarations[0]?.index ?? -1); + if ( + declarations.length !== 1 || + declarationIndex < declaration.declarationIndex || + declarationIndex >= declaration.declarationEnd + ) { + return true; + } + } else if (declarations.length !== 0) { + return true; + } + return new RegExp( + `(?:[(,]\\s*(?:[A-Za-z_][A-Za-z0-9_]*\\s+)?${escaped}\\s*:|\\bfor\\s+(?:case\\s+)?${escaped}\\b|\\b${escaped}\\s+in\\b|(?:\\[|,)\\s*(?:(?:weak|unowned(?:\\s*\\(\\s*(?:safe|unsafe)\\s*\\))?)\\s+)?${escaped}\\s*=)`, + ).test(typeSource); +} + +function hasScopedAliasCall( + source: string, + identifier: string, + methodPattern: string, + body: NominalTypeBody, + typeBodies: NominalTypeBody[], +): boolean { + const call = new RegExp( + `\\b${escapeRegularExpression(identifier)}\\s*\\.\\s*auth\\s*\\.\\s*${methodPattern}\\s*\\(`, + "g", + ); + let match: RegExpExecArray | null; + while ((match = call.exec(source)) !== null) { + if (innermostTypeBodyAt(typeBodies, match.index) === body) return true; + } + return false; +} + +function inspectClerkEnvironmentAuth( + source: string, + sourcePath: string, + projectAliases: ProjectClerkEnvironmentAlias[], +): ClerkEnvironmentAuthInspection { + const typeBodies = nominalTypeBodies(source); + const environment = clerkEnvironmentAliases(source, typeBodies); + let native = false; + let apple = false; + let complete = environment.complete; + + const aliases = new Map< + string, + { + identifier: string; + typeIdentity: string; + declaration?: ClerkEnvironmentAlias; + } + >(); + for (const declaration of environment.aliases) { + aliases.set(`${declaration.typeBody.identity}\0${declaration.identifier}`, { + identifier: declaration.identifier, + typeIdentity: declaration.typeBody.identity, + declaration, + }); + } + for (const alias of projectAliases) { + if (alias.sourcePath === sourcePath || !alias.crossFileVisible) continue; + const key = `${alias.typeIdentity}\0${alias.identifier}`; + if (!aliases.has(key)) { + aliases.set(key, { + identifier: alias.identifier, + typeIdentity: alias.typeIdentity, + }); + } + } + + for (const alias of aliases.values()) { + for (const body of typeBodies.filter( + (candidate) => candidate.identity === alias.typeIdentity, + )) { + const hasNativeCall = hasScopedAliasCall( + source, + alias.identifier, + CLERK_NATIVE_AUTH_METHOD, + body, + typeBodies, + ); + const hasAppleCall = hasScopedAliasCall( + source, + alias.identifier, + "(?:signIn|signUp)WithApple", + body, + typeBodies, + ); + if (!hasNativeCall && !hasAppleCall) continue; + if (aliasIsShadowedInBody(source, alias.identifier, alias.declaration, body, typeBodies)) { + complete = false; + continue; + } + native ||= hasNativeCall; + apple ||= hasAppleCall; + } + } + + return { native, apple, complete }; +} + function matchingBrace(source: string, openingBrace: number): number | undefined { let depth = 0; for (let index = openingBrace; index < source.length; index++) { @@ -766,12 +1078,25 @@ export async function inspectSwiftSources( const environmentInjections: IOSSourceEvidence[] = []; const rootEnvironmentInjections: IOSSourceEvidence[] = []; const environmentConsumers: IOSSourceEvidence[] = []; + const authViewReferences: IOSSourceEvidence[] = []; const authFlowReferences: IOSSourceEvidence[] = []; + const appleAuthReferences: IOSSourceEvidence[] = []; const openURLHandlers: IOSSourceEvidence[] = []; let sourceFilesScanned = 0; let evidenceComplete = options.membershipComplete ?? true; - - for (const file of sourceFiles.sort((a, b) => a.relativePath.localeCompare(b.relativePath))) { + const preparedSources: Array<{ + file: { absolutePath: string; relativePath: string }; + source: string; + structuralSource: SwiftSourceSanitization; + sanitized: string; + uncertain: string; + importsKit: boolean; + importsUI: boolean; + }> = []; + + for (const file of [...sourceFiles].sort((a, b) => + a.relativePath.localeCompare(b.relativePath), + )) { const diskFile = Bun.file(file.absolutePath); if (!(await diskFile.exists()) || diskFile.size > MAX_SWIFT_FILE_BYTES) { evidenceComplete = false; @@ -799,9 +1124,47 @@ export async function inspectSwiftSources( const uncertain = platformSource ? withoutPreviewOnlyRegions(platformSource.uncertainSource) : ""; - const evidence = { path: file.relativePath }; const importsKit = has(sanitized, CLERK_KIT_IMPORT); const importsUI = has(sanitized, CLERK_KIT_UI_IMPORT); + preparedSources.push({ + file, + source, + structuralSource, + sanitized, + uncertain, + importsKit, + importsUI, + }); + } + + const projectAliases: ProjectClerkEnvironmentAlias[] = []; + const projectAliasOrigins = new Map(); + for (const prepared of preparedSources) { + if (!prepared.importsKit && !prepared.importsUI) continue; + const typeBodies = nominalTypeBodies(prepared.sanitized); + const environment = clerkEnvironmentAliases(prepared.sanitized, typeBodies); + if (!environment.complete) evidenceComplete = false; + for (const alias of environment.aliases) { + const key = `${alias.typeBody.identity}\0${alias.identifier}`; + const existingOrigin = projectAliasOrigins.get(key); + if (existingOrigin && existingOrigin !== prepared.file.relativePath) { + evidenceComplete = false; + } else { + projectAliasOrigins.set(key, prepared.file.relativePath); + } + projectAliases.push({ + identifier: alias.identifier, + sourcePath: prepared.file.relativePath, + typeIdentity: alias.typeBody.identity, + crossFileVisible: alias.crossFileVisible, + }); + } + } + + for (const prepared of preparedSources) { + const { file, source, structuralSource, sanitized, uncertain, importsKit, importsUI } = + prepared; + const evidence = { path: file.relativePath }; const importsClerkModule = importsKit || importsUI; if (hasConditionalSetupEvidence(uncertain, importsClerkModule)) evidenceComplete = false; const appRoot = structuralSource.complete ? inspectSwiftUIAppRoot(sanitized) : undefined; @@ -826,10 +1189,26 @@ export async function inspectSwiftSources( if (importsClerkModule && has(sanitized, CLERK_ENVIRONMENT_CONSUMER)) { environmentConsumers.push(evidence); } + const constructsAuthView = importsUI && has(sanitized, CLERK_AUTH_VIEW); + const environmentAuth = importsClerkModule + ? inspectClerkEnvironmentAuth(sanitized, file.relativePath, projectAliases) + : { native: false, apple: false, complete: true }; + if (!environmentAuth.complete) evidenceComplete = false; + if (constructsAuthView) { + authViewReferences.push(evidence); + } + if ( + importsClerkModule && + (has(sanitized, CLERK_NATIVE_APPLE_AUTH_FLOW) || environmentAuth.apple) + ) { + appleAuthReferences.push(evidence); + } if ( - (importsUI && has(sanitized, CLERK_AUTH_VIEW)) || + constructsAuthView || (importsClerkModule && - (has(sanitized, CLERK_NATIVE_AUTH_FLOW) || has(sanitized, CLERK_EMAIL_LINK_AUTH_FLOW))) + (has(sanitized, CLERK_NATIVE_AUTH_FLOW) || + has(sanitized, CLERK_EMAIL_LINK_AUTH_FLOW) || + environmentAuth.native)) ) { authFlowReferences.push(evidence); } @@ -874,7 +1253,9 @@ export async function inspectSwiftSources( environmentInjections, rootEnvironmentInjections: provenRootEnvironmentInjections, environmentConsumers, + authViewReferences, authFlowReferences, + appleAuthReferences, openURLHandlers, status, }; diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index d86b21071..3f248f513 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -127,7 +127,12 @@ export interface IOSSwiftInspection { /** Clerk environment injection directly attached to the proven shipping WindowGroup root. */ rootEnvironmentInjections: IOSSourceEvidence[]; environmentConsumers: IOSSourceEvidence[]; + /** Selected-target sources that import ClerkKitUI and construct AuthView. */ + authViewReferences: IOSSourceEvidence[]; + /** Selected-target sources that reference AuthView or a custom native Clerk auth flow. */ authFlowReferences: IOSSourceEvidence[]; + /** Selected-target sources that call Clerk's native Sign in with Apple flow. */ + appleAuthReferences: IOSSourceEvidence[]; /** Broad lexical evidence retained for diagnostics and conflict detection only. */ openURLHandlers: IOSSourceEvidence[]; status: "complete" | "partial" | "absent" | "ambiguous"; diff --git a/packages/cli-core/src/lib/credential-store.test.ts b/packages/cli-core/src/lib/credential-store.test.ts index 8e94c2d63..d7d7b584c 100644 --- a/packages/cli-core/src/lib/credential-store.test.ts +++ b/packages/cli-core/src/lib/credential-store.test.ts @@ -192,6 +192,88 @@ describe("credential-store", () => { }); }); + test("getValidToken coalesces concurrent refreshes in one process", async () => { + const session = { + accessToken: "expired-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() - 60_000, + tokenType: "Bearer", + }; + await storeToken(session); + + let releaseRefresh: + | ((value: { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + }) => void) + | null = null; + let markRefreshStarted: (() => void) | null = null; + const refreshStarted = new Promise((resolve) => { + markRefreshStarted = resolve; + }); + const refreshResult = new Promise<{ + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + }>((resolve) => { + releaseRefresh = resolve; + }); + mockRefreshAccessToken.mockImplementation(() => { + markRefreshStarted!(); + return refreshResult; + }); + + const first = getValidToken(); + const second = getValidToken(); + + await refreshStarted; + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + + releaseRefresh!({ + access_token: "refreshed-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rotated-refresh-token", + }); + + expect(await Promise.all([first, second])).toEqual([ + "refreshed-access-token", + "refreshed-access-token", + ]); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + }); + + test("concurrent callers share invalid_grant recovery from another process", async () => { + const session = { + accessToken: "expired-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() - 60_000, + tokenType: "Bearer", + }; + const refreshedSession = { + accessToken: "other-process-access-token", + refreshToken: "other-process-refresh-token", + expiresAt: Date.now() + 60_000, + tokenType: "Bearer", + }; + await storeToken(session); + + mockRefreshAccessToken.mockImplementation(async () => { + await storeToken(refreshedSession); + throw new ApiError(400, "invalid_grant"); + }); + + expect(await Promise.all([getValidToken(), getValidToken()])).toEqual([ + "other-process-access-token", + "other-process-access-token", + ]); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + expect(await getStoredSession()).toEqual(refreshedSession); + }); + test("getValidToken recovers from a concurrent refresh race when another process completes the refresh first (invalid_grant)", async () => { const session = { accessToken: "expired-access-token", diff --git a/packages/cli-core/src/lib/credential-store.ts b/packages/cli-core/src/lib/credential-store.ts index 1cdabf2cc..d22c38706 100644 --- a/packages/cli-core/src/lib/credential-store.ts +++ b/packages/cli-core/src/lib/credential-store.ts @@ -453,6 +453,7 @@ export async function storeToken(value: OAuthSession): Promise { } let tokenOverride: string | null | undefined; +let validTokenPromise: Promise | undefined; /** Test-only: override getToken() result. Pass undefined to clear. */ export function _setTokenOverride(value: string | null | undefined): void { @@ -486,7 +487,7 @@ export async function hasAccountCredentials(): Promise { return hasStoredCredentials(); } -export async function getValidToken(): Promise { +async function resolveValidToken(): Promise { const session = await getStoredSession(); if (!session) { if (await hasStoredCredentials()) { @@ -498,6 +499,30 @@ export async function getValidToken(): Promise { return getValidAccessToken(session); } +/** + * Resolve a usable OAuth access token once per process at a time. + * + * A refresh token rotates when it is redeemed. Without this in-flight guard, + * concurrent API calls can both read the same expired session and attempt to + * refresh it: one succeeds while the other receives `invalid_grant`. The + * cross-process recovery in `refreshStoredSession` still handles a different + * CLI process winning that race; this guard prevents the race between callers + * that already share this module instance. + */ +export async function getValidToken(): Promise { + if (validTokenPromise) return validTokenPromise; + + const pending = resolveValidToken(); + validTokenPromise = pending; + try { + return await pending; + } finally { + if (validTokenPromise === pending) { + validTokenPromise = undefined; + } + } +} + export async function deleteToken(): Promise { await keyringDelete(); await fileDelete(); diff --git a/packages/cli-core/src/lib/telemetry.ts b/packages/cli-core/src/lib/telemetry.ts index fb83c6f0d..ec7396111 100644 --- a/packages/cli-core/src/lib/telemetry.ts +++ b/packages/cli-core/src/lib/telemetry.ts @@ -70,6 +70,11 @@ export type TelemetryStage = | "ios_local_setup" | "ios_native_setup" | "ios_apple_setup" + // `clerk doctor` + | "doctor_checks" + | "doctor_ios_audit" + | "doctor_fix" + | "doctor_verify" // `clerk auth login` | "session_check" | "awaiting_callback"