From 7024d29e3ca5661db07a5d4fd6b164d80e5fd05a Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Fri, 23 Oct 2026 17:23:51 -0400 Subject: [PATCH 1/3] feat(backend): add experimental POPPY metadata helper --- .changeset/poppy-metadata-helper.md | 5 + packages/backend/src/__tests__/poppy.test.ts | 183 +++++++++++++++++++ packages/backend/src/index.ts | 4 + packages/backend/src/poppy.ts | 85 +++++++++ 4 files changed, 277 insertions(+) create mode 100644 .changeset/poppy-metadata-helper.md create mode 100644 packages/backend/src/__tests__/poppy.test.ts create mode 100644 packages/backend/src/poppy.ts diff --git a/.changeset/poppy-metadata-helper.md b/.changeset/poppy-metadata-helper.md new file mode 100644 index 00000000000..f705f04561c --- /dev/null +++ b/.changeset/poppy-metadata-helper.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Add `clerkClient.__experimental_getPoppyMetadata()` to fetch instance POPPY metadata for a customer-hosted discovery route without sending credentials. diff --git a/packages/backend/src/__tests__/poppy.test.ts b/packages/backend/src/__tests__/poppy.test.ts new file mode 100644 index 00000000000..96ebc4e58a4 --- /dev/null +++ b/packages/backend/src/__tests__/poppy.test.ts @@ -0,0 +1,183 @@ +import { http, HttpResponse } from 'msw'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ClerkAPIResponseError } from '../errors'; +import { createClerkClient } from '../index'; +import { server } from '../mock-server'; +import { runtime } from '../runtime'; + +const metadata = { + protocol_version: '0.2', + organization: { name: 'C1', domain: 'c1.example' }, +}; +const publishableKey = (host: string, mode = 'test') => `pk_${mode}_${btoa(`${host}$`)}`; +const endpoint = 'https://example.clerk.accounts.dev/.well-known/poppy.json'; + +afterEach(() => vi.restoreAllMocks()); + +describe('clerkClient.__experimental_getPoppyMetadata', () => { + it.each([ + { name: 'development', host: 'example.clerk.accounts.dev', mode: 'test' }, + { name: 'production', host: 'clerk.c1.example', mode: 'live' }, + ])('fetches the $name instance without a secret key', async ({ host, mode }) => { + server.use(http.get(`https://${host}/.well-known/poppy.json`, () => HttpResponse.json(metadata))); + + const client = createClerkClient({ publishableKey: publishableKey(host, mode) }); + + await expect(client.__experimental_getPoppyMetadata()).resolves.toEqual(metadata); + }); + + it('uses only the publishable key host and sends no credentials', async () => { + const fetchSpy = vi.spyOn(runtime, 'fetch'); + server.use( + http.get(endpoint, ({ request }) => { + expect(request.headers.get('accept')).toBe('application/json'); + expect(request.headers.get('authorization')).toBeNull(); + expect(request.headers.get('cookie')).toBeNull(); + expect(request.credentials).toBe('omit'); + expect(request.redirect).toBe('error'); + expect(request.cache).toBe('no-store'); + return HttpResponse.json(metadata); + }), + ); + const client = createClerkClient({ + publishableKey: publishableKey('example.clerk.accounts.dev'), + secretKey: 'sk_test_example', + apiUrl: 'https://backend.example', + proxyUrl: 'https://proxy.example', + domain: 'satellite.example', + isSatellite: true, + }); + + await expect(client.__experimental_getPoppyMetadata()).resolves.toEqual(metadata); + expect(fetchSpy).toHaveBeenCalledExactlyOnceWith(new URL(endpoint), { + headers: { Accept: 'application/json' }, + credentials: 'omit', + redirect: 'error', + cache: 'no-store', + }); + }); + + it('preserves future fields and fetches each document without a cache', async () => { + const futureMetadata = { + ...metadata, + protocol_version: '0.3', + organization: { ...metadata.organization, future_field: 'value' }, + future_capability: { nested_field: ['one', 'two'] }, + }; + let requests = 0; + server.use( + http.get(endpoint, () => { + requests++; + return HttpResponse.json(futureMetadata); + }), + ); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect(client.__experimental_getPoppyMetadata()).resolves.toEqual(futureMetadata); + await expect(client.__experimental_getPoppyMetadata()).resolves.toEqual(futureMetadata); + expect(requests).toBe(2); + }); + + it('keeps independently configured clients separate', async () => { + server.use( + http.get(endpoint, () => HttpResponse.json(metadata)), + http.get('https://clerk.other.example/.well-known/poppy.json', () => + HttpResponse.json({ ...metadata, organization: { name: 'Other', domain: 'other.example' } }), + ), + ); + const first = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + const second = createClerkClient({ publishableKey: publishableKey('clerk.other.example', 'live') }); + + await expect(first.__experimental_getPoppyMetadata()).resolves.toEqual(metadata); + await expect(second.__experimental_getPoppyMetadata()).resolves.toMatchObject({ + organization: { domain: 'other.example' }, + }); + }); + + it.each([ + { name: 'missing key', key: undefined }, + { name: 'invalid key', key: 'invalid' }, + ...[ + 'user@example.com', + 'example.com/path', + 'example.com?query', + 'example.com#fragment', + 'example.com:443', + 'https://example.com', + 'example.com\\path', + 'example.com\n', + ].map(host => ({ name: host, key: publishableKey(host) })), + ])('rejects $name before fetching', async ({ key }) => { + const fetchSpy = vi.spyOn(runtime, 'fetch'); + const client = createClerkClient({ publishableKey: key }); + + await expect(client.__experimental_getPoppyMetadata()).rejects.toThrow(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it.each([404, 500])('preserves HTTP %s and standard Clerk errors without retrying', async status => { + let requests = 0; + server.use( + http.get(endpoint, () => { + requests++; + return HttpResponse.json( + { + errors: [{ code: 'not_available', message: 'Not available', long_message: 'Try later' }], + clerk_trace_id: 'trace', + }, + { status }, + ); + }), + ); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + const error = await client.__experimental_getPoppyMetadata().catch(error => error); + expect(error).toBeInstanceOf(ClerkAPIResponseError); + expect(error).toMatchObject({ + status, + clerkTraceId: 'trace', + errors: [{ code: 'not_available', message: 'Not available', longMessage: 'Try later' }], + }); + expect(requests).toBe(1); + }); + + it.each([ + { name: 'HTML', response: () => new HttpResponse('Error', { status: 500 }) }, + { + name: 'malformed error data', + response: () => HttpResponse.json({ errors: [null, {}, 'error'] }, { status: 500 }), + }, + ])('preserves status for $name error responses', async ({ response }) => { + server.use(http.get(endpoint, response)); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect(client.__experimental_getPoppyMetadata()).rejects.toMatchObject({ status: 500, errors: [] }); + }); + + it.each([ + null, + [], + {}, + { ...metadata, protocol_version: 2 }, + { ...metadata, organization: null }, + { ...metadata, organization: { name: 'C1' } }, + { ...metadata, organization: { name: 1, domain: 'c1.example' } }, + ])('rejects invalid metadata %j', async body => { + server.use(http.get(endpoint, () => HttpResponse.json(body))); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect(client.__experimental_getPoppyMetadata()).rejects.toThrow('Invalid POPPY metadata response.'); + }); + + it.each([ + { name: 'invalid JSON', response: () => new HttpResponse('not json') }, + { name: 'network failure', response: () => HttpResponse.error() }, + { name: 'redirect', response: () => HttpResponse.redirect('https://other.example/poppy.json') }, + ])('rejects $name', async ({ response }) => { + server.use(http.get(endpoint, response)); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect(client.__experimental_getPoppyMetadata()).rejects.toThrow(); + }); +}); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 9c77851ce76..04736ef44a7 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -5,6 +5,7 @@ import type { SDKMetadata } from '@clerk/shared/types'; import type { ApiClient, CreateBackendApiOptions } from './api'; import { createBackendApiClient } from './api'; import { withLegacyReturn } from './jwt/legacyReturn'; +import { createPoppyClient } from './poppy'; import type { CreateAuthenticateRequestOptions } from './tokens/factory'; import { createAuthenticateRequest } from './tokens/factory'; import { verifyToken as _verifyToken } from './tokens/verify'; @@ -24,6 +25,7 @@ export type ClerkOptions = Omit & ReturnType; export function createClerkClient(options: ClerkOptions): ClerkClient { @@ -41,6 +43,7 @@ export function createClerkClient(options: ClerkOptions): ClerkClient { return { ...apiClient, ...requestState, + ...createPoppyClient(opts.publishableKey), telemetry, }; } @@ -50,6 +53,7 @@ export function createClerkClient(options: ClerkOptions): ClerkClient { */ export type { OrganizationMembershipRole } from './api/resources'; export type { VerifyTokenOptions } from './tokens/verify'; +export type { PoppyMetadata } from './poppy'; /** * JSON types */ diff --git a/packages/backend/src/poppy.ts b/packages/backend/src/poppy.ts new file mode 100644 index 00000000000..2c53fa170ac --- /dev/null +++ b/packages/backend/src/poppy.ts @@ -0,0 +1,85 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { parsePublishableKey } from '@clerk/shared/keys'; + +import { runtime } from './runtime'; + +/** POPPY discovery metadata, including fields added by future protocol versions. */ +export type PoppyMetadata = { + protocol_version: string; + organization: { + name: string; + domain: string; + [key: string]: unknown; + }; + [key: string]: unknown; +}; + +export function createPoppyClient(publishableKey: string | undefined) { + return { + /** + * Fetches public POPPY metadata for the configured instance without sending credentials. + * @experimental This API is subject to change. + * @returns The metadata document, unchanged, for a customer-hosted discovery route. + * @example + * ### Return metadata from a server route + * ```ts + * return Response.json(await clerkClient.__experimental_getPoppyMetadata()); + * ``` + */ + async __experimental_getPoppyMetadata(): Promise { + const { frontendApi } = parsePublishableKey(publishableKey, { fatal: true }); + const origin = new URL(`https://${frontendApi}`); + if ( + origin.host !== frontendApi || + origin.username || + origin.password || + origin.pathname !== '/' || + origin.search || + origin.hash || + origin.port + ) { + throw new Error('Publishable key has an invalid frontend API hostname.'); + } + + const response = await runtime.fetch(new URL('/.well-known/poppy.json', origin), { + headers: { Accept: 'application/json' }, + credentials: 'omit', + redirect: 'error', + cache: 'no-store', + }); + + if (!response.ok) { + const body: unknown = await response.json().catch(() => null); + const errors = isRecord(body) && Array.isArray(body.errors) ? body.errors.filter(isApiError) : []; + throw new ClerkAPIResponseError(response.statusText, { + status: response.status, + data: errors, + clerkTraceId: + isRecord(body) && typeof body.clerk_trace_id === 'string' + ? body.clerk_trace_id + : response.headers.get('cf-ray') || undefined, + }); + } + + const metadata: unknown = await response.json(); + if ( + !isRecord(metadata) || + typeof metadata.protocol_version !== 'string' || + !isRecord(metadata.organization) || + typeof metadata.organization.name !== 'string' || + typeof metadata.organization.domain !== 'string' + ) { + throw new Error('Invalid POPPY metadata response.'); + } + return metadata as PoppyMetadata; + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isApiError(value: unknown): value is { code: string; message: string } { + return isRecord(value) && typeof value.code === 'string' && typeof value.message === 'string'; +} From 2e3d5fe4ce77675994ff02546a8ecc30dfe1decd Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Fri, 23 Oct 2026 17:30:43 -0400 Subject: [PATCH 2/3] refactor(backend): return POPPY metadata as unknown --- packages/backend/src/__tests__/poppy.test.ts | 15 +++++++++--- packages/backend/src/index.ts | 1 - packages/backend/src/poppy.ts | 25 ++------------------ 3 files changed, 14 insertions(+), 27 deletions(-) diff --git a/packages/backend/src/__tests__/poppy.test.ts b/packages/backend/src/__tests__/poppy.test.ts index 96ebc4e58a4..1355ac2a3cd 100644 --- a/packages/backend/src/__tests__/poppy.test.ts +++ b/packages/backend/src/__tests__/poppy.test.ts @@ -1,5 +1,5 @@ import { http, HttpResponse } from 'msw'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import { ClerkAPIResponseError } from '../errors'; import { createClerkClient } from '../index'; @@ -16,6 +16,12 @@ const endpoint = 'https://example.clerk.accounts.dev/.well-known/poppy.json'; afterEach(() => vi.restoreAllMocks()); describe('clerkClient.__experimental_getPoppyMetadata', () => { + it('returns an unknown response type', () => { + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + expectTypeOf(client.__experimental_getPoppyMetadata).returns.toEqualTypeOf>(); + }); + it.each([ { name: 'development', host: 'example.clerk.accounts.dev', mode: 'test' }, { name: 'production', host: 'clerk.c1.example', mode: 'live' }, @@ -159,15 +165,18 @@ describe('clerkClient.__experimental_getPoppyMetadata', () => { null, [], {}, + 'metadata', + 42, + true, { ...metadata, protocol_version: 2 }, { ...metadata, organization: null }, { ...metadata, organization: { name: 'C1' } }, { ...metadata, organization: { name: 1, domain: 'c1.example' } }, - ])('rejects invalid metadata %j', async body => { + ])('returns JSON %j unchanged', async body => { server.use(http.get(endpoint, () => HttpResponse.json(body))); const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); - await expect(client.__experimental_getPoppyMetadata()).rejects.toThrow('Invalid POPPY metadata response.'); + await expect(client.__experimental_getPoppyMetadata()).resolves.toEqual(body); }); it.each([ diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 04736ef44a7..ffedd438773 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -53,7 +53,6 @@ export function createClerkClient(options: ClerkOptions): ClerkClient { */ export type { OrganizationMembershipRole } from './api/resources'; export type { VerifyTokenOptions } from './tokens/verify'; -export type { PoppyMetadata } from './poppy'; /** * JSON types */ diff --git a/packages/backend/src/poppy.ts b/packages/backend/src/poppy.ts index 2c53fa170ac..50b48904880 100644 --- a/packages/backend/src/poppy.ts +++ b/packages/backend/src/poppy.ts @@ -3,17 +3,6 @@ import { parsePublishableKey } from '@clerk/shared/keys'; import { runtime } from './runtime'; -/** POPPY discovery metadata, including fields added by future protocol versions. */ -export type PoppyMetadata = { - protocol_version: string; - organization: { - name: string; - domain: string; - [key: string]: unknown; - }; - [key: string]: unknown; -}; - export function createPoppyClient(publishableKey: string | undefined) { return { /** @@ -26,7 +15,7 @@ export function createPoppyClient(publishableKey: string | undefined) { * return Response.json(await clerkClient.__experimental_getPoppyMetadata()); * ``` */ - async __experimental_getPoppyMetadata(): Promise { + async __experimental_getPoppyMetadata(): Promise { const { frontendApi } = parsePublishableKey(publishableKey, { fatal: true }); const origin = new URL(`https://${frontendApi}`); if ( @@ -61,17 +50,7 @@ export function createPoppyClient(publishableKey: string | undefined) { }); } - const metadata: unknown = await response.json(); - if ( - !isRecord(metadata) || - typeof metadata.protocol_version !== 'string' || - !isRecord(metadata.organization) || - typeof metadata.organization.name !== 'string' || - typeof metadata.organization.domain !== 'string' - ) { - throw new Error('Invalid POPPY metadata response.'); - } - return metadata as PoppyMetadata; + return response.json(); }, }; } From 7aaa946aaf96289bb7935a22d4b5fca2b91b4a01 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Fri, 23 Oct 2026 17:34:13 -0400 Subject: [PATCH 3/3] feat(backend): support explicit POPPY metadata proxy URLs --- .changeset/poppy-metadata-helper.md | 2 +- packages/backend/src/__tests__/poppy.test.ts | 120 +++++++++++++++++++ packages/backend/src/poppy.ts | 54 ++++++--- 3 files changed, 161 insertions(+), 15 deletions(-) diff --git a/.changeset/poppy-metadata-helper.md b/.changeset/poppy-metadata-helper.md index f705f04561c..6a83035579b 100644 --- a/.changeset/poppy-metadata-helper.md +++ b/.changeset/poppy-metadata-helper.md @@ -2,4 +2,4 @@ '@clerk/backend': minor --- -Add `clerkClient.__experimental_getPoppyMetadata()` to fetch instance POPPY metadata for a customer-hosted discovery route without sending credentials. +Add `clerkClient.__experimental_getPoppyMetadata()` to fetch instance POPPY metadata for a customer-hosted discovery route without sending credentials. Pass an absolute `proxyUrl` to fetch through a customer-hosted Frontend API proxy instead. diff --git a/packages/backend/src/__tests__/poppy.test.ts b/packages/backend/src/__tests__/poppy.test.ts index 1355ac2a3cd..9467fa191fb 100644 --- a/packages/backend/src/__tests__/poppy.test.ts +++ b/packages/backend/src/__tests__/poppy.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import { ClerkAPIResponseError } from '../errors'; import { createClerkClient } from '../index'; import { server } from '../mock-server'; +import { clerkFrontendApiProxy } from '../proxy'; import { runtime } from '../runtime'; const metadata = { @@ -101,6 +102,125 @@ describe('clerkClient.__experimental_getPoppyMetadata', () => { }); }); + it.each([ + { proxyUrl: 'https://customer.example/__clerk', target: 'https://customer.example/__clerk/.well-known/poppy.json' }, + { + proxyUrl: 'https://customer.example/__clerk/', + target: 'https://customer.example/__clerk/.well-known/poppy.json', + }, + { + proxyUrl: 'https://customer.example/nested/__clerk///', + target: 'https://customer.example/nested/__clerk/.well-known/poppy.json', + }, + { + proxyUrl: new URL('https://customer.example/nested/__clerk/'), + target: 'https://customer.example/nested/__clerk/.well-known/poppy.json', + }, + { + proxyUrl: new URL('/__clerk', 'https://customer.example/nested/route'), + target: 'https://customer.example/__clerk/.well-known/poppy.json', + }, + { proxyUrl: 'http://localhost:3000/__clerk', target: 'http://localhost:3000/__clerk/.well-known/poppy.json' }, + ])('fetches through explicit proxy $proxyUrl without a publishable key', async ({ proxyUrl, target }) => { + const fetchSpy = vi.spyOn(runtime, 'fetch'); + const originalUrl = proxyUrl.toString(); + server.use(http.get(target, () => HttpResponse.json(metadata))); + const client = createClerkClient({}); + + await expect(client.__experimental_getPoppyMetadata({ proxyUrl })).resolves.toEqual(metadata); + expect(fetchSpy).toHaveBeenCalledExactlyOnceWith(new URL(target), { + headers: { Accept: 'application/json' }, + credentials: 'omit', + redirect: 'error', + cache: 'no-store', + }); + expect(proxyUrl.toString()).toBe(originalUrl); + }); + + it.each([ + '/__clerk', + '//customer.example/__clerk', + 'file:///__clerk', + 'ftp://customer.example/__clerk', + 'https://user:password@customer.example/__clerk', + 'https://customer.example/__clerk?query=value', + 'https://customer.example/__clerk#fragment', + ])('rejects invalid proxy %s before fetching', async proxyUrl => { + const fetchSpy = vi.spyOn(runtime, 'fetch'); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect(client.__experimental_getPoppyMetadata({ proxyUrl })).rejects.toThrow(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it.each([ + { name: 'HTTP 404', response: () => new HttpResponse(null, { status: 404 }) }, + { name: 'network failure', response: () => HttpResponse.error() }, + { name: 'redirect', response: () => HttpResponse.redirect(endpoint) }, + ])('does not fall back to direct FAPI after proxy $name', async ({ response }) => { + const fetchSpy = vi.spyOn(runtime, 'fetch'); + server.use(http.get('https://customer.example/__clerk/.well-known/poppy.json', response)); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect( + client.__experimental_getPoppyMetadata({ proxyUrl: 'https://customer.example/__clerk' }), + ).rejects.toThrow(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('does not retain an explicit proxy for later direct calls', async () => { + const directMetadata = { source: 'direct' }; + server.use( + http.get('https://customer.example/__clerk/.well-known/poppy.json', () => HttpResponse.json(metadata)), + http.get(endpoint, () => HttpResponse.json(directMetadata)), + ); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect( + client.__experimental_getPoppyMetadata({ proxyUrl: 'https://customer.example/__clerk' }), + ).resolves.toEqual(metadata); + await expect(client.__experimental_getPoppyMetadata()).resolves.toEqual(directMetadata); + }); + + it('forwards the well-known path through the existing customer proxy without sending credentials to it', async () => { + let proxyRequests = 0; + let upstreamRequests = 0; + server.use( + http.get('https://customer.example/__clerk/.well-known/poppy.json', async ({ request }) => { + proxyRequests++; + expect(request.headers.get('accept')).toBe('application/json'); + expect(request.headers.get('authorization')).toBeNull(); + expect(request.headers.get('cookie')).toBeNull(); + expect(request.headers.get('clerk-secret-key')).toBeNull(); + expect(request.headers.get('clerk-proxy-url')).toBeNull(); + return clerkFrontendApiProxy(request, { + publishableKey: publishableKey('example.clerk.accounts.dev'), + secretKey: 'sk_test_proxy', + fapiUrl: 'https://frontend-api.clerk.example', + }); + }), + http.get('https://frontend-api.clerk.example/.well-known/poppy.json', ({ request }) => { + upstreamRequests++; + expect(request.headers.get('clerk-secret-key')).toBe('sk_test_proxy'); + expect(request.headers.get('clerk-proxy-url')).toBe('https://customer.example/__clerk'); + expect(request.headers.get('authorization')).toBeNull(); + expect(request.headers.get('cookie')).toBeNull(); + return HttpResponse.json(metadata); + }), + ); + const client = createClerkClient({ + secretKey: 'sk_test_helper', + proxyUrl: 'https://unused.example', + apiUrl: 'https://backend.example', + }); + + await expect( + client.__experimental_getPoppyMetadata({ proxyUrl: 'https://customer.example/__clerk' }), + ).resolves.toEqual(metadata); + expect(proxyRequests).toBe(1); + expect(upstreamRequests).toBe(1); + }); + it.each([ { name: 'missing key', key: undefined }, { name: 'invalid key', key: 'invalid' }, diff --git a/packages/backend/src/poppy.ts b/packages/backend/src/poppy.ts index 50b48904880..bd7f329ddc9 100644 --- a/packages/backend/src/poppy.ts +++ b/packages/backend/src/poppy.ts @@ -1,6 +1,7 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; import { parsePublishableKey } from '@clerk/shared/keys'; +import { stripTrailingSlashes } from './proxy'; import { runtime } from './runtime'; export function createPoppyClient(publishableKey: string | undefined) { @@ -8,29 +9,54 @@ export function createPoppyClient(publishableKey: string | undefined) { /** * Fetches public POPPY metadata for the configured instance without sending credentials. * @experimental This API is subject to change. + * @param options - Optional public proxy configuration. + * @param options.proxyUrl - Absolute HTTP(S) proxy URL, including its path prefix. Omit to use the instance directly. * @returns The metadata document, unchanged, for a customer-hosted discovery route. * @example * ### Return metadata from a server route * ```ts * return Response.json(await clerkClient.__experimental_getPoppyMetadata()); * ``` + * @example + * ### Fetch through a customer-hosted proxy + * ```ts + * return Response.json(await clerkClient.__experimental_getPoppyMetadata({ + * proxyUrl: new URL('/__clerk', request.url), + * })); + * ``` */ - async __experimental_getPoppyMetadata(): Promise { - const { frontendApi } = parsePublishableKey(publishableKey, { fatal: true }); - const origin = new URL(`https://${frontendApi}`); - if ( - origin.host !== frontendApi || - origin.username || - origin.password || - origin.pathname !== '/' || - origin.search || - origin.hash || - origin.port - ) { - throw new Error('Publishable key has an invalid frontend API hostname.'); + async __experimental_getPoppyMetadata(options?: { proxyUrl?: string | URL }): Promise { + let url: URL; + if (options?.proxyUrl !== undefined) { + url = new URL(options.proxyUrl); + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error('Proxy URL must use HTTP(S) without credentials, a query string, or a fragment.'); + } + url.pathname = `${stripTrailingSlashes(url.pathname)}/.well-known/poppy.json`; + } else { + const { frontendApi } = parsePublishableKey(publishableKey, { fatal: true }); + url = new URL(`https://${frontendApi}`); + if ( + url.host !== frontendApi || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash || + url.port + ) { + throw new Error('Publishable key has an invalid frontend API hostname.'); + } + url.pathname = '/.well-known/poppy.json'; } - const response = await runtime.fetch(new URL('/.well-known/poppy.json', origin), { + const response = await runtime.fetch(url, { headers: { Accept: 'application/json' }, credentials: 'omit', redirect: 'error',