authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:29:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:29:28-07:00
log1dc36cef73962441befca71678d09fbefdd08c9c
tree6bfaa7e64327e7da666ad48989c2f462070a3c3b
parent833c8ab7c66f4e976c9ae0641d5b1d2780348734
signature Signed by PGP key B5690EEEBB952194

Structure relay auth parsing errors (#3290)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 138 insertions(+), 6 deletions(-)

packages/shared/src/relayAuth.test.ts+62
......@@ -1,17 +1,79 @@
11import { describe, expect, it } from "vite-plus/test";
22
33import {
4 ClerkPublishableKeyDecodeError,
5 ClerkPublishableKeyFrontendApiError,
46 clerkFrontendApiHostnameFromPublishableKey,
7 clerkFrontendApiUrlFromPublishableKey,
58 isAllowedClerkFrontendApiHostname,
69} from "./relayAuth.ts";
710
811const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`;
912
13const 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
1022describe("Clerk relay auth", () => {
1123 it("derives a custom Frontend API hostname from a Clerk publishable key", () => {
1224 expect(clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe(
1325 "clerk.t3.codes",
1426 );
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);
1577 });
1678
1779 it("allows standard Clerk hosts and an exact configured custom hostname", () => {
packages/shared/src/relayAuth.ts+76-6
......@@ -1,14 +1,84 @@
1export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string {
1import * as Schema from "effect/Schema";
2
3const ClerkPublishableKeyPrefix = Schema.Literals(["pk_test", "pk_live", "unknown"]);
4
5export 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
17export 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
31function 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";
240 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 });
646 }
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
76export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string {
77 return parseClerkFrontendApi(publishableKey).url;
878}
979
1080export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: string): string {
11 return new URL(clerkFrontendApiUrlFromPublishableKey(publishableKey)).hostname;
81 return parseClerkFrontendApi(publishableKey).hostname;
1282}
1383
1484export function isAllowedClerkFrontendApiHostname(