diff --git a/apps/web/content/docs/plugins/dash.mdx b/apps/web/content/docs/plugins/dash.mdx index 6a090da..a7891f4 100644 --- a/apps/web/content/docs/plugins/dash.mdx +++ b/apps/web/content/docs/plugins/dash.mdx @@ -39,3 +39,38 @@ dash({ ## Auth callback The key design detail is that `auth()` receives the request object. That lets you integrate the dashboard with your own session, header, proxy, or gateway logic without coupling Dash to a specific auth library. + +## Relative checkout redirects + +When the client is configured with `trustedOrigins`, the dashboard payment API can accept relative checkout redirect URLs and resolve them against the incoming request origin. +That allowlist can contain exact origins or wildcard patterns like `*`, `*.com.br`, or `rewritetoday.com*`. + +```ts title="src/lib/paymesh.ts" +export const paymesh = createClient({ + provider: stripe({ + secret: "sk_test_123", + }), + trustedOrigins: [ + "https://app.example.com", + "http://localhost:3000", + ], + plugins: [ + dash({ + path: "/admin/paymesh", + auth({ request }) { + const email = request.headers.get("x-user-email"); + + if (!email) { + throw new Error("Unauthorized"); + } + + return { id: "user_123", email }; + }, + }), + ], +}); +``` + +In that setup, dashboard requests can submit values like `"/success"` and `"/cancel"`. Dash resolves them to absolute URLs only when the current request origin is present in `trustedOrigins`. + +If the request comes from an origin outside the allowlist, Dash rejects the redirect URL before it reaches the provider client. That keeps the redirect surface explicit and avoids silently accepting a hostile origin. diff --git a/apps/web/content/docs/reference/client-options.mdx b/apps/web/content/docs/reference/client-options.mdx index 442a383..2ff32c5 100644 --- a/apps/web/content/docs/reference/client-options.mdx +++ b/apps/web/content/docs/reference/client-options.mdx @@ -12,6 +12,7 @@ Every Paymesh installation starts from `createClient`. This is the composition p - how built-in tables are named - whether plugins extend routes, hooks, and schema - whether normalized objects should carry raw provider payloads +- which origins are allowed for checkout redirect URLs ```ts title="src/lib/paymesh.ts" import { createClient } from "paymesh"; @@ -61,6 +62,7 @@ export const paymesh = createClient({ | `provider` | yes | none | The provider implementation created by packages such as `@paymesh/stripe` or `@paymesh/polar`. | | `database` | no | `undefined` | Persists normalized data, enables migrations, webhook deduplication, catalog sync, plugins that require storage, and operational tooling. | | `includeRaw` | no | `false` | Propagates raw provider payloads through client reads and webhook events when enabled. | +| `trustedOrigins` | no | `undefined` | Allowlist of origins accepted for checkout redirect URLs. | | `sandbox` | no | `undefined` | Asserts sandbox mode matches the provider. Throws if the provider reports a different mode. | | `schema` | no | generated defaults | Renames built-in tables, changes the prefix, and adds extra columns or custom tables. | | `plugins` | no | `[]` | Registers plugin routes, hooks, schema extensions, setup logic, and runtime client extensions. | @@ -209,6 +211,86 @@ Use `includeRaw` when: Keep it off when you want application code to stay strict about the normalized contract. +## `trustedOrigins` + +`trustedOrigins` lets you restrict which origins Paymesh will accept in checkout redirect fields such as `successUrl`, `cancelUrl`, and `returnUrl`. +It accepts exact origins and simple wildcard patterns. + +```ts title="src/lib/paymesh.ts" +createClient({ + provider: stripe({ + secret: "sk_test_123", + }), + trustedOrigins: [ + "https://app.example.com", + "http://localhost:3000", + "*.com.br", + "rewritetoday.com*", + "*", + ], +}); +``` + +Rules: + +- exact entries must be absolute origins only +- wildcard entries can use `*` anywhere and are matched against the request origin or host +- `*` allows any origin +- direct `payments.create()` calls must still pass absolute redirect URLs +- when `trustedOrigins` is configured, direct checkout calls are rejected if a redirect URL points at an untrusted origin +- relative redirect URLs are only supported inside request-backed plugin routes that explicitly resolve them against the current request + +### What gets checked + +Paymesh validates these fields when present: + +- `successUrl` +- `cancelUrl` +- `returnUrl` + +If any of them is absolute, its origin must be in `trustedOrigins`. +If any of them is relative, Paymesh rejects it in direct client calls and only allows it when a request-backed route resolves it first. + +Wildcard examples: + +- `*.com.br` matches `https://foo.com.br` +- `rewritetoday.com*` matches `https://rewritetoday.com` and `https://rewritetoday.com.br` +- `*` matches every origin + +Wildcard rules stay on the origin/host boundary. Paymesh does not use these patterns to match paths. + +### Practical examples + +```ts title="src/server/checkout.ts" +// Allowed: absolute URL on a trusted origin. +await paymesh.payments.create({ + amount: 5900, + currency: "USD", + successUrl: "https://app.example.com/success", +}); + +// Rejected: relative URL from direct app code. +await paymesh.payments.create({ + amount: 5900, + currency: "USD", + successUrl: "/success", +}); +``` + +```ts title="src/server/dash.ts" +// Allowed inside a request-backed route when Dash resolves the URL. +const successUrl = context.resolveTrustedUrl("/success"); +``` + +### Failure modes + +Paymesh throws `invalid_request` when: + +- a `trustedOrigins` entry is not an origin-only URL +- a `trustedOrigins` wildcard pattern is malformed +- a redirect URL points at an origin that is not trusted +- a relative redirect URL is used without a request-backed route to resolve it + ## `schema` `schema` lets you control physical persistence details without rewriting the client contract. diff --git a/packages/dash/src/data.ts b/packages/dash/src/data.ts index 546a8eb..cb978ab 100644 --- a/packages/dash/src/data.ts +++ b/packages/dash/src/data.ts @@ -502,7 +502,12 @@ export async function createPayment( successUrl?: string; }, ) { - return context.client.payments.create(body); + return context.client.payments.create({ + ...body, + cancelUrl: context.resolveTrustedUrl(body.cancelUrl), + returnUrl: context.resolveTrustedUrl(body.returnUrl), + successUrl: context.resolveTrustedUrl(body.successUrl), + }); } export async function createPix( diff --git a/packages/dash/src/routes.ts b/packages/dash/src/routes.ts index 0a779c6..e47c457 100644 --- a/packages/dash/src/routes.ts +++ b/packages/dash/src/routes.ts @@ -577,6 +577,8 @@ function getDashboardContext( actor: getActor(context), client: context.client as PaymeshClient, database: ensureDatabase(context.client as PaymeshClient), + request: context.request, + resolveTrustedUrl: context.resolveTrustedUrl, schema: context.schema, }; } diff --git a/packages/dash/src/types.ts b/packages/dash/src/types.ts index 0b1c411..6d2232a 100644 --- a/packages/dash/src/types.ts +++ b/packages/dash/src/types.ts @@ -33,6 +33,8 @@ export interface DashboardRequestContext { actor: DashActor; client: PaymeshClient; database: PaymeshDatabaseDriver; + request: Request; + resolveTrustedUrl(url?: string): string | undefined; schema: ResolvedDatabaseSchema; } diff --git a/packages/dash/test/dash.test.ts b/packages/dash/test/dash.test.ts index b1841ca..8328d23 100644 --- a/packages/dash/test/dash.test.ts +++ b/packages/dash/test/dash.test.ts @@ -194,11 +194,151 @@ describe('@paymesh/dash', () => { }), ); }); + + test('resolves relative checkout redirect URLs through the dashboard API', async () => { + const database = createDashboardDatabase(); + let paymentInput: + | { + cancelUrl?: string; + returnUrl?: string; + successUrl?: string; + } + | undefined; + const client = createClient({ + provider: createDashboardProvider({ + onPaymentCreate(data) { + paymentInput = data; + }, + }), + database, + trustedOrigins: ['https://app.test'], + plugins: [ + dash({ + auth() { + return { + id: 'usr_pay', + }; + }, + }), + ] as const, + }); + const handler = Dashboard({ client }); + + const response = await handler( + new Request('https://app.test/admin/paymesh/api/payments', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + amount: 1200, + currency: 'USD', + successUrl: '/success', + cancelUrl: '/cancel', + }), + }), + ); + + expect(response.status).toBe(200); + expect(paymentInput).toMatchObject({ + successUrl: 'https://app.test/success', + cancelUrl: 'https://app.test/cancel', + }); + }); + + test('rejects untrusted relative checkout redirect URLs through the dashboard API', async () => { + const database = createDashboardDatabase(); + const client = createClient({ + provider: createDashboardProvider(), + database, + trustedOrigins: ['https://app.test'], + plugins: [ + dash({ + auth() { + return { + id: 'usr_pay', + }; + }, + }), + ] as const, + }); + const handler = Dashboard({ client }); + + const response = await handler( + new Request('https://admin.test/admin/paymesh/api/payments', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + amount: 1200, + currency: 'USD', + successUrl: '/success', + }), + }), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'invalid_request', + message: 'Untrusted origin for request origin: "https://admin.test".', + }); + }); + + test('resolves relative checkout redirect URLs with wildcard trusted origins', async () => { + const database = createDashboardDatabase(); + let paymentInput: + | { + successUrl?: string; + } + | undefined; + const client = createClient({ + provider: createDashboardProvider({ + onPaymentCreate(data) { + paymentInput = data; + }, + }), + database, + trustedOrigins: ['rewritetoday.com*'], + plugins: [ + dash({ + auth() { + return { + id: 'usr_pay', + }; + }, + }), + ] as const, + }); + const handler = Dashboard({ client }); + + const response = await handler( + new Request('https://rewritetoday.com/admin/paymesh/api/payments', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + amount: 1200, + currency: 'USD', + successUrl: '/success', + }), + }), + ); + + expect(response.status).toBe(200); + expect(paymentInput?.successUrl).toBe('https://rewritetoday.com/success'); + }); }); function createDashboardProvider(options?: { onCustomerUpsert?(data: { email?: string; name?: string }): void; onPixCreate?(data: { amount: number }): void; + onPaymentCreate?(data: { + cancelUrl?: string; + returnUrl?: string; + successUrl?: string; + }): void; }) { return defineProvider({ id: 'stub', @@ -211,7 +351,8 @@ function createDashboardProvider(options?: { webhooks: true, }, payments: { - async create() { + async create(data) { + options?.onPaymentCreate?.(data); return { id: 'pay_created', provider: 'stub', diff --git a/packages/paymesh/src/client/managers.ts b/packages/paymesh/src/client/managers.ts index 3842e39..285a3fe 100644 --- a/packages/paymesh/src/client/managers.ts +++ b/packages/paymesh/src/client/managers.ts @@ -50,6 +50,7 @@ export function createClientManagers< hooks: baseHooks, includeRaw: baseIncludeRaw = false, plugins = [] as unknown as Plugins, + trustedOrigins, } = options; const mergeOptions = createRequestOptionsMerger({ @@ -86,6 +87,7 @@ export function createClientManagers< mergeOptions, provider, schema, + trustedOrigins, }), pix: createPixClient({ assertCapability, @@ -162,6 +164,7 @@ export function createClientManagers< plugins, provider, schema, + trustedOrigins, }); createHookDispatcher = bootstrappedPlugins.createHookDispatcher as unknown; diff --git a/packages/paymesh/src/client/payments.ts b/packages/paymesh/src/client/payments.ts index 3e5bb3c..ed5dd13 100644 --- a/packages/paymesh/src/client/payments.ts +++ b/packages/paymesh/src/client/payments.ts @@ -1,3 +1,4 @@ +import { PaymeshError } from '../errors'; import { splitExtraFields } from '../shared/database/fields'; import type { PaymeshPayment, PaymeshPaymentCreateData } from '../types/client'; import type { @@ -17,6 +18,7 @@ export function createPaymentsClient< mergeOptions, provider, schema, + trustedOrigins, }: { assertCapability: (capability: 'checkout') => void; database?: PaymeshDatabaseDriver; @@ -25,6 +27,7 @@ export function createPaymentsClient< ) => ProviderRequestOptions; provider: P; schema: ResolvedDatabaseSchema; + trustedOrigins?: string[]; }) { return { create: async ( @@ -33,6 +36,58 @@ export function createPaymentsClient< ) => { assertCapability('checkout'); + for (const field of ['successUrl', 'cancelUrl', 'returnUrl'] as const) { + const value = data[field]; + if (!value) continue; + + let url: URL; + + try { + url = new URL(value); + } catch { + throw new PaymeshError({ + code: 'invalid_request', + message: `Payment ${field} must be an absolute URL.`, + }); + } + + if (!trustedOrigins?.length) continue; + + let trusted = false; + for (const trustedOrigin of trustedOrigins) { + if (trustedOrigin === '*') { + trusted = true; + break; + } + + if (!trustedOrigin.includes('*')) { + if (url.origin === trustedOrigin) { + trusted = true; + break; + } + + continue; + } + + const pattern = trustedOrigin.includes('://') ? url.origin : url.host; + const regexp = new RegExp( + `^${trustedOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('\\*', '.*')}$`, + ); + + if (regexp.test(pattern)) { + trusted = true; + break; + } + } + + if (!trusted) { + throw new PaymeshError({ + code: 'invalid_request', + message: `Untrusted origin for ${field}: "${url.origin}".`, + }); + } + } + const { input, extra } = splitExtraFields( data, schema.tables.checkouts.fields, diff --git a/packages/paymesh/src/index.ts b/packages/paymesh/src/index.ts index 5c04c6e..86fa01c 100644 --- a/packages/paymesh/src/index.ts +++ b/packages/paymesh/src/index.ts @@ -74,6 +74,49 @@ export function createClient< }); const plugins = options.plugins ?? ([] as unknown as Plugins); + + const trustedOrigins = options.trustedOrigins?.map((value) => { + if (value === '*') return value; + + if (value.includes('*')) { + const candidate = value.includes('://') + ? value.replaceAll('*', 'placeholder') + : `https://${value.replaceAll('*', 'placeholder')}`; + + const url = new URL(candidate); + + if ( + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) + throw new PaymeshError({ + code: 'invalid_request', + message: `Invalid trusted origin "${value}". trustedOrigins entries must be origin-only patterns.`, + }); + + return value; + } + + const url = new URL(value); + + if ( + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) + throw new PaymeshError({ + code: 'invalid_request', + message: `Invalid trusted origin "${value}". trustedOrigins entries must be origin-only URLs.`, + }); + + return url.origin; + }); + const schema = resolveDatabaseSchema( resolveClientSchemaOptions(options.schema, plugins), ); @@ -83,6 +126,7 @@ export function createClient< options: { ...options, plugins, + trustedOrigins, }, schema, }); diff --git a/packages/paymesh/src/plugins/runtime.ts b/packages/paymesh/src/plugins/runtime.ts index 6c32cb2..179f654 100644 --- a/packages/paymesh/src/plugins/runtime.ts +++ b/packages/paymesh/src/plugins/runtime.ts @@ -59,6 +59,7 @@ interface BootstrapPluginsOptions< plugins: Plugins; provider: Provider; schema: ResolvedDatabaseSchema; + trustedOrigins?: string[]; } interface BootstrappedPlugins< @@ -105,6 +106,7 @@ export function bootstrapPlugins< plugins, provider, schema, + trustedOrigins, }: BootstrapPluginsOptions< IncludeRaw, Schema, @@ -357,6 +359,102 @@ export function bootstrapPlugins< locals: {}, params: resolved.params, request, + resolveTrustedUrl: (value?: string) => { + if (!value) return; + + try { + const url = new URL(value); + + if (trustedOrigins?.length) { + let trusted = false; + for (const trustedOrigin of trustedOrigins) { + if (trustedOrigin === '*') { + trusted = true; + break; + } + + if (!trustedOrigin.includes('*')) { + if (url.origin === trustedOrigin) { + trusted = true; + break; + } + + continue; + } + + const pattern = trustedOrigin.includes('://') + ? url.origin + : url.host; + const regexp = new RegExp( + `^${trustedOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('\\*', '.*')}$`, + ); + + if (regexp.test(pattern)) { + trusted = true; + break; + } + } + + if (!trusted) { + throw new PaymeshError({ + code: 'invalid_request', + message: `Untrusted origin for redirect URL: "${url.origin}".`, + }); + } + } + + return url.toString(); + } catch (error) { + if (error instanceof PaymeshError) throw error; + } + + if (!trustedOrigins?.length) { + throw new PaymeshError({ + code: 'invalid_request', + message: + 'Relative redirect URLs require createClient({ trustedOrigins }).', + }); + } + + const requestOrigin = new URL(request.url).origin; + let trusted = false; + for (const trustedOrigin of trustedOrigins) { + if (trustedOrigin === '*') { + trusted = true; + break; + } + + if (!trustedOrigin.includes('*')) { + if (requestOrigin === trustedOrigin) { + trusted = true; + break; + } + + continue; + } + + const pattern = trustedOrigin.includes('://') + ? requestOrigin + : new URL(requestOrigin).host; + const regexp = new RegExp( + `^${trustedOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('\\*', '.*')}$`, + ); + + if (regexp.test(pattern)) { + trusted = true; + break; + } + } + + if (!trusted) { + throw new PaymeshError({ + code: 'invalid_request', + message: `Untrusted origin for request origin: "${requestOrigin}".`, + }); + } + + return new URL(value, request.url).toString(); + }, route: resolved.metadata, client: client as never, plugin: resolved.pluginMetadata as never, @@ -423,6 +521,7 @@ type RuntimeRouteContext = { locals: Record; params: Record; request: Request; + resolveTrustedUrl(url?: string): string | undefined; route: RegisteredPluginRoute; client: TClient; plugin: RegisteredPaymeshPlugin; diff --git a/packages/paymesh/src/types/client.ts b/packages/paymesh/src/types/client.ts index c6daf0a..2092ada 100644 --- a/packages/paymesh/src/types/client.ts +++ b/packages/paymesh/src/types/client.ts @@ -393,6 +393,8 @@ export interface ClientOptions< timeout?: number; /** Explicit sandbox expectation for the configured provider. Throws when it mismatches `provider.isSandbox()`. */ sandbox?: boolean; + /** Allowlist of trusted origins used to validate checkout redirect URLs. */ + trustedOrigins?: string[]; /** Default retry configuration. */ retry?: RetryOptions; /** Fetch implementation to use. */ diff --git a/packages/paymesh/src/types/plugins.ts b/packages/paymesh/src/types/plugins.ts index 72ad94f..783446c 100644 --- a/packages/paymesh/src/types/plugins.ts +++ b/packages/paymesh/src/types/plugins.ts @@ -240,6 +240,8 @@ export interface PluginRouteContext< params: PluginRouteParams; /** Incoming request. */ request: Request; + /** Resolves and validates redirect URLs against the client trusted origin allowlist. */ + resolveTrustedUrl(url?: string): string | undefined; /** Route metadata. */ route: RegisteredPluginRoute; } diff --git a/packages/paymesh/test/client.test.ts b/packages/paymesh/test/client.test.ts index 35c1a9f..7a4d9bb 100644 --- a/packages/paymesh/test/client.test.ts +++ b/packages/paymesh/test/client.test.ts @@ -464,6 +464,233 @@ describe('client', () => { expect(calls[0]).toHaveProperty('sandbox', undefined); }); + test('normalizes trusted origins at client creation', async () => { + let redirectUrl: string | undefined; + const client = createClient({ + provider: createStubProvider({ + onPaymentCreate(data) { + redirectUrl = data.successUrl; + return Promise.resolve( + withRaw( + { + id: 'pay_trusted', + provider: 'stub', + sandbox: false, + amount: 1000, + currency: 'usd', + status: 'paid' as const, + }, + null, + false, + ), + ); + }, + }), + trustedOrigins: ['https://app.test/'], + }); + + await client.payments.create({ + amount: 1000, + currency: 'USD', + successUrl: 'https://app.test/success', + }); + + expect(redirectUrl).toBe('https://app.test/success'); + }); + + test('rejects invalid trusted origins at client creation', () => { + expect(() => + createClient({ + provider: createStubProvider(), + trustedOrigins: ['https://app.test/path'], + }), + ).toThrow( + new PaymeshError({ + code: 'invalid_request', + message: + 'Invalid trusted origin "https://app.test/path". trustedOrigins entries must be origin-only URLs.', + }), + ); + }); + + test('rejects relative checkout redirect URLs in direct payments.create calls', async () => { + const client = createClient({ + provider: createStubProvider(), + }); + + await expect( + client.payments.create({ + amount: 1000, + currency: 'USD', + successUrl: '/success', + }), + ).rejects.toMatchObject({ + code: 'invalid_request', + message: 'Payment successUrl must be an absolute URL.', + }); + }); + + test('rejects untrusted absolute checkout redirect URLs', async () => { + const client = createClient({ + provider: createStubProvider(), + trustedOrigins: ['https://app.test'], + }); + + await expect( + client.payments.create({ + amount: 1000, + currency: 'USD', + successUrl: 'https://evil.test/success', + }), + ).rejects.toMatchObject({ + code: 'invalid_request', + message: 'Untrusted origin for successUrl: "https://evil.test".', + }); + }); + + test('allows any origin when trustedOrigins contains "*" ', async () => { + const client = createClient({ + provider: createStubProvider(), + trustedOrigins: ['*'], + }); + + await expect( + client.payments.create({ + amount: 1000, + currency: 'USD', + successUrl: 'https://evil.test/success', + }), + ).resolves.toBeDefined(); + }); + + test('matches wildcard host patterns in trustedOrigins', async () => { + const client = createClient({ + provider: createStubProvider(), + trustedOrigins: ['*.com.br'], + }); + + await expect( + client.payments.create({ + amount: 1000, + currency: 'USD', + successUrl: 'https://foo.com.br/success', + }), + ).resolves.toBeDefined(); + }); + + test('matches wildcard prefix host patterns in trustedOrigins', async () => { + const client = createClient({ + provider: createStubProvider(), + trustedOrigins: ['rewritetoday.com*'], + }); + + await expect( + client.payments.create({ + amount: 1000, + currency: 'USD', + successUrl: 'https://rewritetoday.com/success', + }), + ).resolves.toBeDefined(); + }); + + test('resolves trusted route URLs inside plugin routes', async () => { + const client = createClient({ + provider: createStubProvider(), + trustedOrigins: ['https://app.test'], + plugins: [ + definePlugin({ + id: 'route-helper', + routes: [ + { + method: 'POST', + path: '/resolve', + async handler(context) { + return Response.json({ + url: context.resolveTrustedUrl('/success'), + }); + }, + }, + ], + }), + ] as const, + }); + + const response = await client.routes.handle( + new Request('https://app.test/resolve', { method: 'POST' }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + url: 'https://app.test/success', + }); + }); + + test('rejects relative route URLs when request origin is not trusted', async () => { + const client = createClient({ + provider: createStubProvider(), + trustedOrigins: ['https://app.test'], + plugins: [ + definePlugin({ + id: 'route-helper', + routes: [ + { + method: 'POST', + path: '/resolve', + async handler(context) { + return Response.json({ + url: context.resolveTrustedUrl('/success'), + }); + }, + }, + ], + }), + ] as const, + }); + + const response = await client.routes.handle( + new Request('https://admin.test/resolve', { method: 'POST' }), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'invalid_request', + message: 'Untrusted origin for request origin: "https://admin.test".', + }); + }); + + test('rejects untrusted absolute route URLs inside plugin routes', async () => { + const client = createClient({ + provider: createStubProvider(), + trustedOrigins: ['https://app.test'], + plugins: [ + definePlugin({ + id: 'route-helper', + routes: [ + { + method: 'POST', + path: '/resolve', + async handler(context) { + return Response.json({ + url: context.resolveTrustedUrl('https://evil.test/success'), + }); + }, + }, + ], + }), + ] as const, + }); + + const response = await client.routes.handle( + new Request('https://app.test/resolve', { method: 'POST' }), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'invalid_request', + message: 'Untrusted origin for redirect URL: "https://evil.test".', + }); + }); + test('types onEvent as a discriminated union of normalized webhook events', () => { const client = createClient({ provider: createStubProvider(), @@ -1286,7 +1513,13 @@ function createStubProvider({ }: { sandbox?: boolean; onPaymentCreate?: ( - data: { amount: number; currency: string }, + data: { + amount: number; + currency: string; + cancelUrl?: string; + returnUrl?: string; + successUrl?: string; + }, options?: ProviderRequestOptions, ) => Promise>; onPixCreate?: (