Skip to content
This repository was archived by the owner on Jul 21, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/web/content/docs/plugins/dash.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
82 changes: 82 additions & 0 deletions apps/web/content/docs/reference/client-options.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion packages/dash/src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions packages/dash/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,8 @@ function getDashboardContext(
actor: getActor(context),
client: context.client as PaymeshClient<boolean>,
database: ensureDatabase(context.client as PaymeshClient<boolean>),
request: context.request,
resolveTrustedUrl: context.resolveTrustedUrl,
schema: context.schema,
};
}
Expand Down
2 changes: 2 additions & 0 deletions packages/dash/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface DashboardRequestContext {
actor: DashActor;
client: PaymeshClient<boolean>;
database: PaymeshDatabaseDriver;
request: Request;
resolveTrustedUrl(url?: string): string | undefined;
schema: ResolvedDatabaseSchema;
}

Expand Down
143 changes: 142 additions & 1 deletion packages/dash/test/dash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -211,7 +351,8 @@ function createDashboardProvider(options?: {
webhooks: true,
},
payments: {
async create() {
async create(data) {
options?.onPaymentCreate?.(data);
return {
id: 'pay_created',
provider: 'stub',
Expand Down
3 changes: 3 additions & 0 deletions packages/paymesh/src/client/managers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function createClientManagers<
hooks: baseHooks,
includeRaw: baseIncludeRaw = false,
plugins = [] as unknown as Plugins,
trustedOrigins,
} = options;

const mergeOptions = createRequestOptionsMerger({
Expand Down Expand Up @@ -86,6 +87,7 @@ export function createClientManagers<
mergeOptions,
provider,
schema,
trustedOrigins,
}),
pix: createPixClient({
assertCapability,
Expand Down Expand Up @@ -162,6 +164,7 @@ export function createClientManagers<
plugins,
provider,
schema,
trustedOrigins,
});

createHookDispatcher = bootstrappedPlugins.createHookDispatcher as unknown;
Expand Down
Loading