diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index b0d84bb12b5..f8c354a8599 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -6,10 +6,15 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { + BuildScriptError, createStageWorkspaceConfig, createStagePnpmConfig, createBuildConfig, DESKTOP_ASAR_UNPACK, + InvalidMacPasskeyRpDomainError, + InvalidMacPasskeyPublishableKeyError, + isMacPasskeySigningConfigurationError, + MissingMacPasskeyProvisioningProfileError, renderMacPasskeyEntitlements, resolveClerkPasskeyNativeArtifacts, resolveMacPasskeySigningConfiguration, @@ -214,23 +219,43 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); it("rejects incomplete macOS passkey signing configuration", () => { - assert.throws( - () => - resolveMacPasskeySigningConfiguration({ - T3CODE_APPLE_TEAM_ID: "ABC1234567", - T3CODE_CLERK_PASSKEY_RP_DOMAINS: "example.clerk.accounts.dev", - }), - /T3CODE_MACOS_PROVISIONING_PROFILE/u, - ); - assert.throws( - () => - resolveMacPasskeySigningConfiguration({ - T3CODE_APPLE_TEAM_ID: "ABC1234567", - T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", - T3CODE_CLERK_PASSKEY_RP_DOMAINS: "https://example.clerk.accounts.dev/path", - }), - /Invalid passkey RP domain/u, + const captureError = (env: Readonly>) => { + try { + resolveMacPasskeySigningConfiguration(env); + } catch (error) { + return error; + } + return assert.fail("Expected passkey signing configuration to fail."); + }; + + const missingProfileError = captureError({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: "example.clerk.accounts.dev", + }); + assert.instanceOf(missingProfileError, MissingMacPasskeyProvisioningProfileError); + assert.equal( + missingProfileError.message, + "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile.", ); + + const unsafeDomain = + "https://domain-user:domain-secret@example.clerk.accounts.dev/path?token=query-secret"; + const invalidDomainError = captureError({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: unsafeDomain, + }); + assert.instanceOf(invalidDomainError, InvalidMacPasskeyRpDomainError); + assert.equal(invalidDomainError.reason, "scheme-not-allowed"); + assert.equal(invalidDomainError.inputLength, unsafeDomain.length); + assert.equal(invalidDomainError.message, "Invalid passkey RP domain (scheme-not-allowed)."); + assert.notProperty(invalidDomainError, "domain"); + assert.notProperty(invalidDomainError, "cause"); + const serializedInvalidDomainError = JSON.stringify(invalidDomainError); + assert.notInclude(serializedInvalidDomainError, unsafeDomain); + assert.notInclude(serializedInvalidDomainError, "domain-user"); + assert.notInclude(serializedInvalidDomainError, "domain-secret"); + assert.notInclude(serializedInvalidDomainError, "query-secret"); assert.throws( () => resolveMacPasskeySigningConfiguration({ @@ -240,6 +265,38 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), /Invalid passkey RP domain/u, ); + const invalidPublishableKeyError = captureError({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_%", + }); + assert.instanceOf(invalidPublishableKeyError, InvalidMacPasskeyPublishableKeyError); + assert.ok(invalidPublishableKeyError.cause); + assert.equal(invalidPublishableKeyError.message, "T3CODE_CLERK_PUBLISHABLE_KEY is invalid."); + assert.notProperty(invalidPublishableKeyError, "publishableKey"); + assert.notInclude(invalidPublishableKeyError.message, "pk_test_%"); + }); + + it("preserves known passkey signing configuration errors at the build boundary", () => { + const decodingCause = new Error("publishable-key-decode-failed"); + const knownError = new InvalidMacPasskeyPublishableKeyError({ cause: decodingCause }); + const error = BuildScriptError.fromMacPasskeySigningConfiguration(knownError); + + assert.strictEqual(error, knownError); + assert.instanceOf(error, InvalidMacPasskeyPublishableKeyError); + assert.strictEqual(error.cause, decodingCause); + assert.isTrue(isMacPasskeySigningConfigurationError(error)); + }); + + it("wraps unknown passkey signing configuration defects without copying cause text", () => { + const secret = "pk_test_do-not-retain"; + const cause = new Error(secret); + const error = BuildScriptError.fromMacPasskeySigningConfiguration(cause); + + assert.instanceOf(error, BuildScriptError); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to resolve macOS passkey signing configuration."); + assert.notInclude(error.message, secret); }); it.effect("adds passkey entitlements and both renderer protocols to signed macOS builds", () => diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 2d708057e38..6f13783f2d1 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -126,10 +126,21 @@ const getDefaultArch = Effect.fn("getDefaultArch")(function* (platform: typeof B return yield* getDefaultBuildArch(platform, config); }); -class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ +export class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ readonly message: string; readonly cause?: unknown; -}> {} +}> { + static fromMacPasskeySigningConfiguration( + cause: unknown, + ): MacPasskeySigningConfigurationError | BuildScriptError { + return isMacPasskeySigningConfigurationError(cause) + ? cause + : new BuildScriptError({ + message: "Failed to resolve macOS passkey signing configuration.", + cause, + }); + } +} const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => stream.pipe( @@ -306,26 +317,128 @@ export interface MacPasskeySigningConfiguration { readonly provisioningProfilePath: string; } +export const InvalidMacPasskeyRpDomainReason = Schema.Literals([ + "empty", + "scheme-not-allowed", + "parse-failed", + "credentials-not-allowed", + "port-not-allowed", + "path-not-allowed", + "query-not-allowed", + "fragment-not-allowed", + "hostname-mismatch", +]); +export type InvalidMacPasskeyRpDomainReason = typeof InvalidMacPasskeyRpDomainReason.Type; + +export class InvalidMacPasskeyRpDomainError extends Schema.TaggedErrorClass()( + "InvalidMacPasskeyRpDomainError", + { + reason: InvalidMacPasskeyRpDomainReason, + inputLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Invalid passkey RP domain (${this.reason}).`; + } +} + +export class InvalidAppleTeamIdError extends Schema.TaggedErrorClass()( + "InvalidAppleTeamIdError", + { + teamId: Schema.String, + }, +) { + override get message(): string { + return `T3CODE_APPLE_TEAM_ID '${this.teamId}' must be a 10-character Apple Developer Team ID.`; + } +} + +export class MissingMacPasskeyProvisioningProfileError extends Schema.TaggedErrorClass()( + "MissingMacPasskeyProvisioningProfileError", + {}, +) { + override get message(): string { + return "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile."; + } +} + +export class MissingMacPasskeyDomainConfigurationError extends Schema.TaggedErrorClass()( + "MissingMacPasskeyDomainConfigurationError", + {}, +) { + override get message(): string { + return "T3CODE_CLERK_PUBLISHABLE_KEY or T3CODE_CLERK_PASSKEY_RP_DOMAINS is required for signed macOS passkey builds."; + } +} + +export class InvalidMacPasskeyPublishableKeyError extends Schema.TaggedErrorClass()( + "InvalidMacPasskeyPublishableKeyError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "T3CODE_CLERK_PUBLISHABLE_KEY is invalid."; + } +} + +export class MissingMacPasskeyRpDomainError extends Schema.TaggedErrorClass()( + "MissingMacPasskeyRpDomainError", + {}, +) { + override get message(): string { + return "At least one Clerk passkey RP domain is required."; + } +} + +export const MacPasskeySigningConfigurationError = Schema.Union([ + InvalidMacPasskeyRpDomainError, + InvalidAppleTeamIdError, + MissingMacPasskeyProvisioningProfileError, + MissingMacPasskeyDomainConfigurationError, + InvalidMacPasskeyPublishableKeyError, + MissingMacPasskeyRpDomainError, +]); +export type MacPasskeySigningConfigurationError = typeof MacPasskeySigningConfigurationError.Type; +export const isMacPasskeySigningConfigurationError = Schema.is(MacPasskeySigningConfigurationError); + function normalizePasskeyRpDomain(value: string): string { const normalized = value.trim().toLowerCase(); + const inputLength = value.length; + if (normalized.length === 0) { + throw new InvalidMacPasskeyRpDomainError({ reason: "empty", inputLength }); + } + if (/^[a-z][a-z\d+.-]*:\/\//u.test(normalized)) { + throw new InvalidMacPasskeyRpDomainError({ + reason: "scheme-not-allowed", + inputLength, + }); + } + let parsed: URL; try { parsed = new URL(`https://${normalized}`); - } catch { - throw new Error(`Invalid passkey RP domain: ${value}`); + } catch (cause) { + throw new InvalidMacPasskeyRpDomainError({ reason: "parse-failed", inputLength, cause }); } - if ( - normalized.length === 0 || - parsed.host !== normalized || - parsed.username.length > 0 || - parsed.password.length > 0 || - parsed.port.length > 0 || - parsed.pathname !== "/" || - parsed.search.length > 0 || - parsed.hash.length > 0 - ) { - throw new Error(`Invalid passkey RP domain: ${value}`); + let reason: InvalidMacPasskeyRpDomainReason | undefined; + if (parsed.username.length > 0 || parsed.password.length > 0) { + reason = "credentials-not-allowed"; + } else if (parsed.port.length > 0) { + reason = "port-not-allowed"; + } else if (parsed.pathname !== "/") { + reason = "path-not-allowed"; + } else if (parsed.search.length > 0) { + reason = "query-not-allowed"; + } else if (parsed.hash.length > 0) { + reason = "fragment-not-allowed"; + } else if (parsed.host !== normalized) { + reason = "hostname-mismatch"; + } + if (reason) { + throw new InvalidMacPasskeyRpDomainError({ reason, inputLength }); } return parsed.hostname; @@ -336,14 +449,12 @@ export function resolveMacPasskeySigningConfiguration( ): MacPasskeySigningConfiguration { const teamId = env.T3CODE_APPLE_TEAM_ID?.trim().toUpperCase() ?? ""; if (!APPLE_TEAM_ID_PATTERN.test(teamId)) { - throw new Error("T3CODE_APPLE_TEAM_ID must be a 10-character Apple Developer Team ID."); + throw new InvalidAppleTeamIdError({ teamId }); } const provisioningProfilePath = env.T3CODE_MACOS_PROVISIONING_PROFILE?.trim() ?? ""; if (provisioningProfilePath.length === 0) { - throw new Error( - "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile.", - ); + throw new MissingMacPasskeyProvisioningProfileError(); } const configuredRpDomains = env.T3CODE_CLERK_PASSKEY_RP_DOMAINS?.trim(); @@ -353,18 +464,20 @@ export function resolveMacPasskeySigningConfiguration( } else { const publishableKey = env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim(); if (!publishableKey) { - throw new Error( - "T3CODE_CLERK_PUBLISHABLE_KEY or T3CODE_CLERK_PASSKEY_RP_DOMAINS is required for signed macOS passkey builds.", - ); + throw new MissingMacPasskeyDomainConfigurationError(); } - rpDomains = [ - normalizePasskeyRpDomain(clerkFrontendApiHostnameFromPublishableKey(publishableKey)), - ]; + let hostname: string; + try { + hostname = clerkFrontendApiHostnameFromPublishableKey(publishableKey); + } catch (cause) { + throw new InvalidMacPasskeyPublishableKeyError({ cause }); + } + rpDomains = [normalizePasskeyRpDomain(hostname)]; } const uniqueRpDomains = [...new Set(rpDomains)]; if (uniqueRpDomains.length === 0) { - throw new Error("At least one Clerk passkey RP domain is required."); + throw new MissingMacPasskeyRpDomainError(); } return { @@ -1150,11 +1263,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.platform === "mac" && options.signed ? yield* Effect.try({ try: () => resolveMacPasskeySigningConfiguration(loadRepoEnv({ repoRoot })), - catch: (cause) => - new BuildScriptError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), + catch: BuildScriptError.fromMacPasskeySigningConfiguration, }) : undefined; const macPasskeySigning = configuredMacPasskeySigning