Unified · Split
| @@ -1,17 +1,79 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | + ClerkPublishableKeyDecodeError, | |
| 5 | + ClerkPublishableKeyFrontendApiError, | |
| 4 | 6 | clerkFrontendApiHostnameFromPublishableKey, |
| 7 | + clerkFrontendApiUrlFromPublishableKey, | |
| 5 | 8 | isAllowedClerkFrontendApiHostname, |
| 6 | 9 | } from "./relayAuth.ts"; |
| 7 | 10 | |
| 8 | 11 | const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`; |
| 9 | 12 | |
| 13 | +const captureError = (run: () => unknown): unknown => { | |
| 14 | + try { | |
| 15 | + run(); | |
| 16 | + } catch (cause) { | |
| 17 | + return cause; | |
| 18 | + } | |
| 19 | + throw new Error("Expected operation to throw"); | |
| 20 | +}; | |
| 21 | + | |
| 10 | 22 | describe("Clerk relay auth", () => { |
| 11 | 23 | it("derives a custom Frontend API hostname from a Clerk publishable key", () => { |
| 12 | 24 | expect(clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe( |
| 13 | 25 | "clerk.t3.codes", |
| 14 | 26 | ); |
| 27 | + expect(clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe( | |
| 28 | + "https://clerk.t3.codes", | |
| 29 | + ); | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it("preserves Clerk publishable key decoding failures", () => { | |
| 33 | + const error = captureError(() => clerkFrontendApiUrlFromPublishableKey("pk_test_%")); | |
| 34 | + | |
| 35 | + expect(error).toBeInstanceOf(ClerkPublishableKeyDecodeError); | |
| 36 | + expect(error).toMatchObject({ keyPrefix: "pk_test" }); | |
| 37 | + expect((error as ClerkPublishableKeyDecodeError).cause).toBeInstanceOf(Error); | |
| 38 | + expect((error as Error).message).toBe("Failed to decode Clerk publishable key (pk_test)."); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it("reports semantic frontend API failures without inventing a cause", () => { | |
| 42 | + const emptyError = captureError(() => clerkFrontendApiUrlFromPublishableKey("pk_test_")); | |
| 43 | + const pathFrontendApi = "clerk.t3.codes/path"; | |
| 44 | + const pathError = captureError(() => | |
| 45 | + clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey(pathFrontendApi)), | |
| 46 | + ); | |
| 47 | + | |
| 48 | + expect(emptyError).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); | |
| 49 | + expect(emptyError).toMatchObject({ | |
| 50 | + keyPrefix: "pk_test", | |
| 51 | + frontendApi: "", | |
| 52 | + reason: "empty", | |
| 53 | + }); | |
| 54 | + expect((emptyError as Error & { cause?: unknown }).cause).toBeUndefined(); | |
| 55 | + expect(pathError).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); | |
| 56 | + expect(pathError).toMatchObject({ | |
| 57 | + keyPrefix: "pk_test", | |
| 58 | + frontendApi: pathFrontendApi, | |
| 59 | + reason: "contains-path", | |
| 60 | + }); | |
| 61 | + expect((pathError as Error & { cause?: unknown }).cause).toBeUndefined(); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + it("preserves URL parser failures for decoded frontend APIs", () => { | |
| 65 | + const frontendApi = "[invalid-host"; | |
| 66 | + const error = captureError(() => | |
| 67 | + clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey(frontendApi)), | |
| 68 | + ); | |
| 69 | + | |
| 70 | + expect(error).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); | |
| 71 | + expect(error).toMatchObject({ | |
| 72 | + keyPrefix: "pk_test", | |
| 73 | + frontendApi, | |
| 74 | + reason: "invalid-url", | |
| 75 | + }); | |
| 76 | + expect((error as ClerkPublishableKeyFrontendApiError).cause).toBeInstanceOf(Error); | |
| 15 | 77 | }); |
| 16 | 78 | |
| 17 | 79 | it("allows standard Clerk hosts and an exact configured custom hostname", () => { |
| @@ -1,14 +1,84 @@ | ||
| 1 | −export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string { | |
| 1 | +import * as Schema from "effect/Schema"; | |
| 2 | + | |
| 3 | +const ClerkPublishableKeyPrefix = Schema.Literals(["pk_test", "pk_live", "unknown"]); | |
| 4 | + | |
| 5 | +export class ClerkPublishableKeyDecodeError extends Schema.TaggedErrorClass<ClerkPublishableKeyDecodeError>()( | |
| 6 | + "ClerkPublishableKeyDecodeError", | |
| 7 | + { | |
| 8 | + keyPrefix: ClerkPublishableKeyPrefix, | |
| 9 | + cause: Schema.Defect(), | |
| 10 | + }, | |
| 11 | +) { | |
| 12 | + override get message(): string { | |
| 13 | + return `Failed to decode Clerk publishable key (${this.keyPrefix}).`; | |
| 14 | + } | |
| 15 | +} | |
| 16 | + | |
| 17 | +export class ClerkPublishableKeyFrontendApiError extends Schema.TaggedErrorClass<ClerkPublishableKeyFrontendApiError>()( | |
| 18 | + "ClerkPublishableKeyFrontendApiError", | |
| 19 | + { | |
| 20 | + keyPrefix: ClerkPublishableKeyPrefix, | |
| 21 | + frontendApi: Schema.String, | |
| 22 | + reason: Schema.Literals(["empty", "contains-path", "invalid-url"]), | |
| 23 | + cause: Schema.optional(Schema.Defect()), | |
| 24 | + }, | |
| 25 | +) { | |
| 26 | + override get message(): string { | |
| 27 | + return `Invalid Clerk frontend API decoded from publishable key (${this.keyPrefix}; ${this.reason}).`; | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +function parseClerkFrontendApi(publishableKey: string): { | |
| 32 | + readonly hostname: string; | |
| 33 | + readonly url: string; | |
| 34 | +} { | |
| 35 | + const keyPrefix = publishableKey.startsWith("pk_test_") | |
| 36 | + ? "pk_test" | |
| 37 | + : publishableKey.startsWith("pk_live_") | |
| 38 | + ? "pk_live" | |
| 39 | + : "unknown"; | |
| 2 | 40 | const encodedFrontendApi = publishableKey.split("_").slice(2).join("_"); |
| 3 | − const frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, ""); | |
| 4 | − if (frontendApi.length === 0 || frontendApi.includes("/")) { | |
| 5 | − throw new Error("Invalid Clerk publishable key."); | |
| 41 | + let frontendApi: string; | |
| 42 | + try { | |
| 43 | + frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, ""); | |
| 44 | + } catch (cause) { | |
| 45 | + throw new ClerkPublishableKeyDecodeError({ keyPrefix, cause }); | |
| 6 | 46 | } |
| 7 | − return `https://${frontendApi}`; | |
| 47 | + | |
| 48 | + if (frontendApi.length === 0) { | |
| 49 | + throw new ClerkPublishableKeyFrontendApiError({ | |
| 50 | + keyPrefix, | |
| 51 | + frontendApi, | |
| 52 | + reason: "empty", | |
| 53 | + }); | |
| 54 | + } | |
| 55 | + if (frontendApi.includes("/")) { | |
| 56 | + throw new ClerkPublishableKeyFrontendApiError({ | |
| 57 | + keyPrefix, | |
| 58 | + frontendApi, | |
| 59 | + reason: "contains-path", | |
| 60 | + }); | |
| 61 | + } | |
| 62 | + | |
| 63 | + const url = `https://${frontendApi}`; | |
| 64 | + try { | |
| 65 | + return { hostname: new URL(url).hostname, url }; | |
| 66 | + } catch (cause) { | |
| 67 | + throw new ClerkPublishableKeyFrontendApiError({ | |
| 68 | + keyPrefix, | |
| 69 | + frontendApi, | |
| 70 | + reason: "invalid-url", | |
| 71 | + cause, | |
| 72 | + }); | |
| 73 | + } | |
| 74 | +} | |
| 75 | + | |
| 76 | +export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string { | |
| 77 | + return parseClerkFrontendApi(publishableKey).url; | |
| 8 | 78 | } |
| 9 | 79 | |
| 10 | 80 | export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: string): string { |
| 11 | − return new URL(clerkFrontendApiUrlFromPublishableKey(publishableKey)).hostname; | |
| 81 | + return parseClerkFrontendApi(publishableKey).hostname; | |
| 12 | 82 | } |
| 13 | 83 | |
| 14 | 84 | export function isAllowedClerkFrontendApiHostname( |