From 1dc36cef73962441befca71678d09fbefdd08c9c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:29:28 -0700 Subject: [PATCH] Structure relay auth parsing errors (#3290) Co-authored-by: codex --- packages/shared/src/relayAuth.test.ts | 62 ++++++++++++++++++++ packages/shared/src/relayAuth.ts | 82 +++++++++++++++++++++++++-- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/relayAuth.test.ts b/packages/shared/src/relayAuth.test.ts index dc06ce5323c288542630d7e7e4dbc989e319d4d1..3abff9b52109efc8ae57cea98f1575a7e992b35b 100644 --- a/packages/shared/src/relayAuth.test.ts +++ b/packages/shared/src/relayAuth.test.ts @@ -1,17 +1,79 @@ import { describe, expect, it } from "vite-plus/test"; import { + ClerkPublishableKeyDecodeError, + ClerkPublishableKeyFrontendApiError, clerkFrontendApiHostnameFromPublishableKey, + clerkFrontendApiUrlFromPublishableKey, isAllowedClerkFrontendApiHostname, } from "./relayAuth.ts"; const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`; +const captureError = (run: () => unknown): unknown => { + try { + run(); + } catch (cause) { + return cause; + } + throw new Error("Expected operation to throw"); +}; + describe("Clerk relay auth", () => { it("derives a custom Frontend API hostname from a Clerk publishable key", () => { expect(clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe( "clerk.t3.codes", ); + expect(clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe( + "https://clerk.t3.codes", + ); + }); + + it("preserves Clerk publishable key decoding failures", () => { + const error = captureError(() => clerkFrontendApiUrlFromPublishableKey("pk_test_%")); + + expect(error).toBeInstanceOf(ClerkPublishableKeyDecodeError); + expect(error).toMatchObject({ keyPrefix: "pk_test" }); + expect((error as ClerkPublishableKeyDecodeError).cause).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Failed to decode Clerk publishable key (pk_test)."); + }); + + it("reports semantic frontend API failures without inventing a cause", () => { + const emptyError = captureError(() => clerkFrontendApiUrlFromPublishableKey("pk_test_")); + const pathFrontendApi = "clerk.t3.codes/path"; + const pathError = captureError(() => + clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey(pathFrontendApi)), + ); + + expect(emptyError).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); + expect(emptyError).toMatchObject({ + keyPrefix: "pk_test", + frontendApi: "", + reason: "empty", + }); + expect((emptyError as Error & { cause?: unknown }).cause).toBeUndefined(); + expect(pathError).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); + expect(pathError).toMatchObject({ + keyPrefix: "pk_test", + frontendApi: pathFrontendApi, + reason: "contains-path", + }); + expect((pathError as Error & { cause?: unknown }).cause).toBeUndefined(); + }); + + it("preserves URL parser failures for decoded frontend APIs", () => { + const frontendApi = "[invalid-host"; + const error = captureError(() => + clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey(frontendApi)), + ); + + expect(error).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); + expect(error).toMatchObject({ + keyPrefix: "pk_test", + frontendApi, + reason: "invalid-url", + }); + expect((error as ClerkPublishableKeyFrontendApiError).cause).toBeInstanceOf(Error); }); it("allows standard Clerk hosts and an exact configured custom hostname", () => { diff --git a/packages/shared/src/relayAuth.ts b/packages/shared/src/relayAuth.ts index bf5fb61ee3b307e670d7c1fc52afe3dcb62980d1..a384db77d8acf3de2c4744155eb49842e2130a32 100644 --- a/packages/shared/src/relayAuth.ts +++ b/packages/shared/src/relayAuth.ts @@ -1,14 +1,84 @@ -export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string { +import * as Schema from "effect/Schema"; + +const ClerkPublishableKeyPrefix = Schema.Literals(["pk_test", "pk_live", "unknown"]); + +export class ClerkPublishableKeyDecodeError extends Schema.TaggedErrorClass()( + "ClerkPublishableKeyDecodeError", + { + keyPrefix: ClerkPublishableKeyPrefix, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode Clerk publishable key (${this.keyPrefix}).`; + } +} + +export class ClerkPublishableKeyFrontendApiError extends Schema.TaggedErrorClass()( + "ClerkPublishableKeyFrontendApiError", + { + keyPrefix: ClerkPublishableKeyPrefix, + frontendApi: Schema.String, + reason: Schema.Literals(["empty", "contains-path", "invalid-url"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Invalid Clerk frontend API decoded from publishable key (${this.keyPrefix}; ${this.reason}).`; + } +} + +function parseClerkFrontendApi(publishableKey: string): { + readonly hostname: string; + readonly url: string; +} { + const keyPrefix = publishableKey.startsWith("pk_test_") + ? "pk_test" + : publishableKey.startsWith("pk_live_") + ? "pk_live" + : "unknown"; const encodedFrontendApi = publishableKey.split("_").slice(2).join("_"); - const frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, ""); - if (frontendApi.length === 0 || frontendApi.includes("/")) { - throw new Error("Invalid Clerk publishable key."); + let frontendApi: string; + try { + frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, ""); + } catch (cause) { + throw new ClerkPublishableKeyDecodeError({ keyPrefix, cause }); } - return `https://${frontendApi}`; + + if (frontendApi.length === 0) { + throw new ClerkPublishableKeyFrontendApiError({ + keyPrefix, + frontendApi, + reason: "empty", + }); + } + if (frontendApi.includes("/")) { + throw new ClerkPublishableKeyFrontendApiError({ + keyPrefix, + frontendApi, + reason: "contains-path", + }); + } + + const url = `https://${frontendApi}`; + try { + return { hostname: new URL(url).hostname, url }; + } catch (cause) { + throw new ClerkPublishableKeyFrontendApiError({ + keyPrefix, + frontendApi, + reason: "invalid-url", + cause, + }); + } +} + +export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string { + return parseClerkFrontendApi(publishableKey).url; } export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: string): string { - return new URL(clerkFrontendApiUrlFromPublishableKey(publishableKey)).hostname; + return parseClerkFrontendApi(publishableKey).hostname; } export function isAllowedClerkFrontendApiHostname( -- 2.54.0