zuplo 6.71.10 → 6.71.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -61,13 +61,14 @@ reads the API key from the `Authorization` header, validates it, and sets
61
61
 
62
62
  ## Configuration options
63
63
 
64
- | Option | Type | Default | Description |
65
- | -------------------- | ------------------ | ----------------- | ------------------------------------------------- |
66
- | `meters` | object | _(none)_ | Map of meter keys to increment values |
67
- | `meterOnStatusCodes` | string or number[] | `"200-299"` | Status code range to meter |
68
- | `authHeader` | string | `"authorization"` | Header to read the API key from |
69
- | `authScheme` | string | `"Bearer"` | Expected auth scheme prefix |
70
- | `cacheTtlSeconds` | number | `60` | How long to cache subscription data (minimum 60s) |
64
+ | Option | Type | Default | Description |
65
+ | ---------------------- | ------------------ | ----------------- | ----------------------------------------------------- |
66
+ | `meters` | object | _(none)_ | Map of meter keys to increment values |
67
+ | `requiredEntitlements` | string[] | _(none)_ | Entitlement keys the subscription must have access to |
68
+ | `meterOnStatusCodes` | string or number[] | `"200-299"` | Status code range to meter |
69
+ | `authHeader` | string | `"authorization"` | Header to read the API key from |
70
+ | `authScheme` | string | `"Bearer"` | Expected auth scheme prefix |
71
+ | `cacheTtlSeconds` | number | `60` | How long to cache subscription data (minimum 60s) |
71
72
 
72
73
  ### `meters`
73
74
 
@@ -91,6 +92,23 @@ see the [Dynamic Metering](./dynamic-metering.md) guide.
91
92
  { "meters": { "api_credits": 10 } }
92
93
  ```
93
94
 
95
+ ### `requiredEntitlements`
96
+
97
+ A list of [entitlement](./features.mdx) keys the caller's subscription must have
98
+ access to before the request is allowed. Use it to gate a route on a feature —
99
+ for example, restrict it to plans that include `custom_domains` — without
100
+ writing code.
101
+
102
+ ```json
103
+ { "requiredEntitlements": ["custom_domains"] }
104
+ ```
105
+
106
+ The request is allowed only when **every** listed entitlement is present with
107
+ `hasAccess` set to `true`. If any is missing, disabled, or has exhausted its
108
+ quota, the policy returns `403 Forbidden` before the request reaches your
109
+ backend. For access checks that need runtime logic, gate in code instead — see
110
+ [Programmatic Monetization](./programmatic-monetization.md).
111
+
94
112
  ### `meterOnStatusCodes`
95
113
 
96
114
  Controls which responses count toward metering. By default, only successful
@@ -256,22 +274,23 @@ the RFC 7807 Problem Details format:
256
274
 
257
275
  Common error details:
258
276
 
259
- | Condition | `detail` message |
260
- | -------------------------------- | ------------------------------------------------------------------- |
261
- | No auth header | `"No Authorization Header"` |
262
- | Wrong auth scheme | `"Invalid Authorization Scheme"` |
263
- | Empty key after the auth scheme | `"No key present"` |
264
- | Cached invalid key or 401 | `"Authorization Failed"` |
265
- | Invalid API key | `"API Key is invalid or does not have access to the API"` |
266
- | Expired API key | `"API Key has expired."` |
267
- | Expired subscription | `"API Key has an expired subscription."` |
268
- | Subscription has no payment | `"Subscription payment status is not available."` |
269
- | Payment not made | `"Payment has not been made."` |
270
- | Payment overdue | `"Payment is overdue. Please update your payment method."` |
271
- | Subscription has no entitlements | `"Subscription entitlements are not available."` |
272
- | Meter not in subscription | `"API Key does not have \"X\" meter provided by the subscription."` |
273
- | Quota exhausted | `"API Key has exceeded the allowed limit for \"X\" meter."` |
274
- | Meter access denied | `"API Key does not have access to \"X\" meter."` |
277
+ | Condition | `detail` message |
278
+ | -------------------------------- | ---------------------------------------------------------------------------------- |
279
+ | No auth header | `"No Authorization Header"` |
280
+ | Wrong auth scheme | `"Invalid Authorization Scheme"` |
281
+ | Empty key after the auth scheme | `"No key present"` |
282
+ | Cached invalid key or 401 | `"Authorization Failed"` |
283
+ | Invalid API key | `"API Key is invalid or does not have access to the API"` |
284
+ | Expired API key | `"API Key has expired."` |
285
+ | Expired subscription | `"API Key has an expired subscription."` |
286
+ | Subscription has no payment | `"Subscription payment status is not available."` |
287
+ | Payment not made | `"Payment has not been made."` |
288
+ | Payment overdue | `"Payment is overdue. Please update your payment method."` |
289
+ | Subscription has no entitlements | `"Subscription entitlements are not available."` |
290
+ | Meter not in subscription | `"API Key does not have \"X\" meter provided by the subscription."` |
291
+ | Quota exhausted | `"API Key has exceeded the allowed limit for \"X\" meter."` |
292
+ | Meter access denied | `"API Key does not have access to \"X\" meter."` |
293
+ | Required entitlement missing | `"The required \"X\" entitlement is not allowed or its quota has been exhausted."` |
275
294
 
276
295
  ## Pipeline ordering
277
296
 
@@ -74,6 +74,35 @@ if (!advancedSearch?.hasAccess) {
74
74
  }
75
75
  ```
76
76
 
77
+ :::tip
78
+
79
+ If you only need to check that a feature's entitlement has access — with no
80
+ other runtime logic — skip the custom code and use the policy's
81
+ [`requiredEntitlements`](./monetization-policy.md#requiredentitlements) option
82
+ instead. It rejects the request with `403 Forbidden` when any listed entitlement
83
+ is missing or out of quota:
84
+
85
+ ```json
86
+ // config/policies.json
87
+ {
88
+ "name": "monetization-inbound",
89
+ "policyType": "monetization-inbound",
90
+ "handler": {
91
+ "export": "MonetizationInboundPolicy",
92
+ "module": "$import(@zuplo/runtime)",
93
+ "options": {
94
+ "meters": { "api_requests": 1 },
95
+ "requiredEntitlements": ["advanced_search"]
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ Reach for the code path below when the decision needs more than a presence check
102
+ — inspecting the plan key, reading the request, or combining conditions.
103
+
104
+ :::
105
+
77
106
  Register the policy and apply it after `monetization-inbound` on the routes you
78
107
  want to protect:
79
108
 
@@ -88,12 +88,26 @@ zuplo ca-certificate list --account your-account
88
88
  See the [`ca-certificate` CLI reference](../cli/ca-certificate-create.mdx) for
89
89
  all available subcommands (`create`, `list`, `describe`, `update`, `delete`).
90
90
 
91
- :::tip{title="Using an intermediate CA"}
91
+ :::caution{title="Upload the self-signed root CA, not an intermediate"}
92
92
 
93
- If your client certificates are issued by an intermediate CA (rather than
94
- directly by your root), upload the root CA certificate that anchors the chain.
95
- Clients must send the leaf certificate plus any intermediate certificates when
96
- they connect.
93
+ Zuplo must build a complete chain from the presented client certificate up to a
94
+ trust anchor. Upload the **self-signed root** CA that anchors the chain — not an
95
+ intermediate or subordinate CA. Uploading a subordinate CA is the most common
96
+ cause of the `FAILED to get issuer certificate` error (see
97
+ [Troubleshooting](#failed-to-get-issuer-certificate)).
98
+
99
+ To confirm a certificate is a self-signed root, check that its subject and
100
+ issuer are identical:
101
+
102
+ ```bash
103
+ openssl x509 -in ca.pem -noout -subject -issuer -nameopt RFC2253
104
+ ```
105
+
106
+ If `subject` and `issuer` match, it's a root. If they differ, the file is an
107
+ intermediate CA — trace the chain to the root and upload that instead. When your
108
+ client certificates are issued by an intermediate CA, clients still send the
109
+ leaf certificate plus any intermediates when they connect (see
110
+ [Test with curl](#4-test-with-curl)).
97
111
 
98
112
  :::
99
113
 
@@ -315,11 +329,73 @@ the policy in a working-copy or preview environment.
315
329
  `context.incomingRequestProperties.clientMtlsVerificationReason` to see why
316
330
  verification failed.
317
331
 
332
+ ### `FAILED to get issuer certificate`
333
+
334
+ This error means Zuplo can't build a complete chain from the presented client
335
+ certificate up to a trusted root. The usual cause is uploading an **intermediate
336
+ or subordinate CA** instead of the **self-signed root** CA that anchors the
337
+ chain.
338
+
339
+ Confirm what you uploaded. A self-signed root has an identical subject and
340
+ issuer:
341
+
342
+ ```bash
343
+ openssl x509 -in ca.pem -noout -subject -issuer -nameopt RFC2253
344
+ ```
345
+
346
+ If `subject` and `issuer` differ, the file is an intermediate CA. Find the root
347
+ that anchors the chain and re-upload it:
348
+
349
+ ```bash
350
+ # Remove the incorrect CA
351
+ zuplo ca-certificate delete --cert-id mtlsca_abc123 --account your-account
352
+
353
+ # Upload the self-signed root instead
354
+ zuplo ca-certificate create --name my_ca --cert ./root-ca.pem --account your-account
355
+ ```
356
+
357
+ If your CA is an Active Directory Certificate Services (AD CS) deployment,
358
+ export the issuing CA's own certificate and inspect it:
359
+
360
+ ```bash
361
+ # Export the CA certificate (often DER-encoded)
362
+ certutil -ca.cert ca.cer
363
+
364
+ # Convert DER to the PEM format Zuplo requires
365
+ openssl x509 -inform der -in ca.cer -out ca.pem
366
+
367
+ # Verify subject == issuer (a root); if not, export the root above it instead
368
+ openssl x509 -in ca.pem -noout -subject -issuer -nameopt RFC2253
369
+ ```
370
+
371
+ ### `client certificate metadata not provided`
372
+
373
+ The certificate verified at the edge, but the parsed certificate wasn't
374
+ forwarded to your gateway workers, so `request.user.data.mtlsAuth` is empty even
375
+ though the request was authenticated.
376
+
377
+ The most common cause is an **oversized leaf client certificate**. Zuplo's edge
378
+ can only forward client certificates up to roughly 10 KB of DER-encoded data.
379
+ Certificates with large RSA keys, many Subject Alternative Names, or large
380
+ custom extensions can exceed this. Check the DER size of the leaf certificate:
381
+
382
+ ```bash
383
+ openssl x509 -in client.pem -outform der | wc -c
384
+ ```
385
+
386
+ If the result is near or above ~10,000 bytes, reissue a smaller leaf
387
+ certificate. Trim unnecessary extensions and SANs, or switch to ECDSA keys
388
+ instead of large (4096-bit) RSA keys. This limit applies to the **leaf**
389
+ certificate the edge forwards, not the full chain.
390
+
318
391
  ### `request.user.data.mtlsAuth` is missing
319
392
 
320
393
  - The policy only attaches metadata when a parseable client certificate is
321
394
  present on the request. Confirm the client is sending one.
322
395
  - Verify the route includes the `mtls-auth-inbound` policy.
396
+ - If the certificate verifies but metadata is still missing, check the leaf
397
+ certificate size (see
398
+ [`client certificate metadata not provided`](#client-certificate-metadata-not-provided)).
323
399
 
324
400
  ### Custom domains
325
401
 
@@ -331,6 +407,27 @@ that custom domain.
331
407
  If you add a custom domain later and your clients aren't being verified against
332
408
  it, contact [support@zuplo.com](mailto:support@zuplo.com).
333
409
 
410
+ ### Custom domains behind your own CDN
411
+
412
+ Inbound mTLS requires the TLS handshake to terminate at Zuplo's edge, because
413
+ that's where the client certificate is verified and parsed. If you front your
414
+ Zuplo gateway with **your own CDN** (for example, your own Cloudflare zone) that
415
+ terminates TLS before traffic reaches Zuplo, the handshake — and the client
416
+ certificate — ends at your CDN. Zuplo never sees the certificate, so the
417
+ `mtls-auth-inbound` policy has nothing to verify.
418
+
419
+ You have two supported options:
420
+
421
+ - **Let Zuplo terminate TLS.** Point clients at a Zuplo-managed gateway domain,
422
+ or configure your custom domain directly on Zuplo so Zuplo terminates TLS. The
423
+ client certificate then reaches Zuplo's edge and inbound mTLS works as
424
+ documented above.
425
+ - **Verify mTLS at your CDN.** If you must keep your own CDN in front, terminate
426
+ and verify the client certificate at the CDN, then forward the verified
427
+ identity to Zuplo in a request header. Validate that header in a Zuplo policy
428
+ instead of relying on `mtls-auth-inbound`. Make sure the header can't be
429
+ spoofed by clients that bypass your CDN.
430
+
334
431
  ## Additional resources
335
432
 
336
433
  - [`mtls-auth-inbound` policy reference](../policies/mtls-auth-inbound.mdx)
@@ -143,7 +143,10 @@ type NavigationCategory = {
143
143
  collapsible?: boolean;
144
144
  collapsed?: boolean;
145
145
  stack?: boolean; // open the category's items as a stacked sub-nav
146
- link?: string | { type: "doc"; file: string; label?: string; path?: string };
146
+ link?:
147
+ | string
148
+ | { type: "doc"; file: string; label?: string; path?: string }
149
+ | { type: "link"; to: string; label?: string };
147
150
  display?:
148
151
  | "auth"
149
152
  | "anon"
@@ -158,9 +161,11 @@ type NavigationCategory = {
158
161
  #### Category links
159
162
 
160
163
  A category can have a `link` property that makes the category label itself clickable, navigating to
161
- a document. This is useful when you want a category that acts as both a group and a landing page.
164
+ a document, a path, or an external URL. This is useful when you want a category that acts as both a
165
+ group and a landing page.
162
166
 
163
- The `link` can be a simple string pointing to a file path, or an object for more control:
167
+ The `link` can be a simple string pointing to a file path, a `doc` object for more control, or a
168
+ `link` object that points the category label at an arbitrary path or external URL:
164
169
 
165
170
  ```tsx title="String shorthand"
166
171
  {
@@ -190,7 +195,7 @@ The `link` can be a simple string pointing to a file path, or an object for more
190
195
  }
191
196
  ```
192
197
 
193
- The object form supports these properties:
198
+ The `doc` object form supports these properties:
194
199
 
195
200
  | Property | Type | Description |
196
201
  | -------- | -------- | -------------------------------------------------------- |
@@ -199,6 +204,29 @@ The object form supports these properties:
199
204
  | `label` | `string` | Override the label (defaults to the document title) |
200
205
  | `path` | `string` | Custom URL path (overrides the default file-based route) |
201
206
 
207
+ ```tsx title="Link form pointing to a path or external URL"
208
+ {
209
+ type: "category",
210
+ label: "API Reference",
211
+ link: {
212
+ type: "link",
213
+ to: "/api",
214
+ },
215
+ items: [
216
+ "guides/authentication",
217
+ "guides/rate-limits",
218
+ ],
219
+ }
220
+ ```
221
+
222
+ The `link` object form supports these properties:
223
+
224
+ | Property | Type | Description |
225
+ | -------- | -------- | ----------------------------------- |
226
+ | `type` | `"link"` | Must be `"link"` |
227
+ | `to` | `string` | Path or external URL to navigate to |
228
+ | `label` | `string` | Override the category label |
229
+
202
230
  ### `type: doc`
203
231
 
204
232
  Doc is used to reference markdown files. The `label` is the text that will be displayed, and the
@@ -0,0 +1,307 @@
1
+ ---
2
+ title: Transform Route Parameters for URL Rewrite
3
+ sidebar_label: Transform Route Parameters
4
+ description:
5
+ Learn how to use an inbound policy to transform route parameter values before
6
+ the URL Rewrite handler forwards the request to your backend.
7
+ tags:
8
+ - custom-code
9
+ - backends
10
+ ---
11
+
12
+ This guide explains how to transform incoming route parameter values in an
13
+ inbound policy before the [URL Rewrite handler](../handlers/url-rewrite.mdx)
14
+ uses them to build the upstream URL. This pattern is useful when your public API
15
+ paths use different naming conventions than your internal backend.
16
+
17
+ ## Overview
18
+
19
+ When you use the URL Rewrite handler, it builds the upstream URL by
20
+ interpolating values like `${params.resourceType}` directly from the incoming
21
+ route parameters. Sometimes, however, you need to **change** those values before
22
+ the rewrite happens. Common scenarios include:
23
+
24
+ - **Value mapping** — translating a public-facing parameter like `order` to an
25
+ internal value like `customerorder`
26
+ - **Case normalization** — converting `Products` to `products` before forwarding
27
+ - **Path translation** — mapping user-friendly slugs to internal identifiers
28
+
29
+ The recommended approach is to read the route parameters in an inbound policy,
30
+ transform them, store the results on
31
+ [`context.custom`](../programmable-api/zuplo-context.mdx), and reference the
32
+ transformed values in the URL Rewrite pattern.
33
+
34
+ ## Step-by-Step Example
35
+
36
+ The solution has three parts: an **inbound policy** that reads `request.params`
37
+ and stores transformed values on `context.custom`, a **URL Rewrite handler**
38
+ that references those values using `${context.custom.*}` in the
39
+ `rewritePattern`, and **route configuration** that wires the two together.
40
+
41
+ Imagine your public API exposes a route like `/api/:resourceType/:resourceId`,
42
+ but your backend expects the resource type to be prefixed with `customer`. A
43
+ request to `/api/order/123` should be forwarded to
44
+ `https://backend.example.com/api/customerorder/123`.
45
+
46
+ ### 1. Write the Inbound Policy
47
+
48
+ Create a custom inbound policy that reads the route parameters, transforms the
49
+ values, and stores them on `context.custom`:
50
+
51
+ ```ts title="modules/transform-params.ts"
52
+ import { ZuploContext, ZuploRequest } from "@zuplo/runtime";
53
+
54
+ export default async function (
55
+ request: ZuploRequest,
56
+ context: ZuploContext,
57
+ options: any,
58
+ policyName: string,
59
+ ): Promise<ZuploRequest | Response> {
60
+ // Read the original route parameter
61
+ const resourceType = request.params.resourceType;
62
+
63
+ // Transform the value — prefix with "customer"
64
+ const transformedResourceType = `customer${resourceType}`;
65
+
66
+ // Store the transformed value on context.custom
67
+ context.custom.transformedResourceType = transformedResourceType;
68
+
69
+ context.log.info({
70
+ message: "Transformed route parameter",
71
+ original: resourceType,
72
+ transformed: transformedResourceType,
73
+ });
74
+
75
+ return request;
76
+ }
77
+ ```
78
+
79
+ ### 2. Register the Policy
80
+
81
+ Add the policy to `config/policies.json`:
82
+
83
+ ```json title="config/policies.json"
84
+ {
85
+ "policies": [
86
+ {
87
+ "name": "transform-params",
88
+ "policyType": "custom-code-inbound",
89
+ "handler": {
90
+ "export": "default",
91
+ "module": "$import(./modules/transform-params)"
92
+ }
93
+ }
94
+ ]
95
+ }
96
+ ```
97
+
98
+ ### 3. Configure the Route
99
+
100
+ Define the route in `config/routes.oas.json` with the inbound policy and a URL
101
+ Rewrite handler that references `context.custom`:
102
+
103
+ ```json title="config/routes.oas.json"
104
+ {
105
+ "paths": {
106
+ "/api/{resourceType}/{resourceId}": {
107
+ "x-zuplo-path": {
108
+ "pathMode": "open-api"
109
+ },
110
+ "get": {
111
+ "summary": "Get resource by type and ID",
112
+ "x-zuplo-route": {
113
+ "corsPolicy": "none",
114
+ "handler": {
115
+ "export": "urlRewriteHandler",
116
+ "module": "$import(@zuplo/runtime)",
117
+ "options": {
118
+ "rewritePattern": "https://backend.example.com/api/${context.custom.transformedResourceType}/${params.resourceId}"
119
+ }
120
+ },
121
+ "policies": {
122
+ "inbound": ["transform-params"]
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ ```
130
+
131
+ With this configuration, a request to `/api/order/123` flows through the
132
+ pipeline as follows:
133
+
134
+ 1. The route matches with `params.resourceType = "order"` and
135
+ `params.resourceId = "123"`
136
+ 2. The `transform-params` inbound policy runs and sets
137
+ `context.custom.transformedResourceType = "customerorder"`
138
+ 3. The URL Rewrite handler builds the upstream URL:
139
+ `https://backend.example.com/api/customerorder/123`
140
+
141
+ ## Common Pitfall: Modifying `request.params` Directly
142
+
143
+ :::caution
144
+
145
+ Do not try to transform route parameters by constructing a new `ZuploRequest`
146
+ with modified `params` and expecting the URL Rewrite handler to pick them up.
147
+
148
+ :::
149
+
150
+ A common first attempt is to create a new
151
+ [`ZuploRequest`](../programmable-api/zuplo-request.mdx) with different `params`:
152
+
153
+ ```ts
154
+ // ⚠️ This approach does NOT work as expected with URL Rewrite
155
+ const newRequest = new ZuploRequest(request, {
156
+ params: {
157
+ ...request.params,
158
+ resourceType: "customerorder",
159
+ },
160
+ });
161
+ return newRequest;
162
+ ```
163
+
164
+ In practice, the URL Rewrite handler evaluates `${params.*}` against the
165
+ route-level parameters rather than the request object returned by a policy. This
166
+ means the rewritten URL may contain `undefined` segments instead of your
167
+ transformed values. Use `context.custom` for reliable interpolation of
168
+ transformed values — the URL Rewrite handler's `rewritePattern` fully supports
169
+ `${context.custom.*}`, and values set in an inbound policy are available when
170
+ the handler runs.
171
+
172
+ ## Variations
173
+
174
+ ### Using a Lookup Map
175
+
176
+ For more complex mappings where the transformation is not a simple string
177
+ operation, use a lookup object:
178
+
179
+ ```ts title="modules/transform-params-map.ts"
180
+ import { ZuploContext, ZuploRequest, HttpProblems } from "@zuplo/runtime";
181
+
182
+ // Map public resource types to internal names
183
+ const RESOURCE_TYPE_MAP: Record<string, string> = {
184
+ order: "customerorder",
185
+ invoice: "billing-invoice",
186
+ profile: "user-profile",
187
+ subscription: "recurring-plan",
188
+ };
189
+
190
+ export default async function (
191
+ request: ZuploRequest,
192
+ context: ZuploContext,
193
+ options: any,
194
+ policyName: string,
195
+ ): Promise<ZuploRequest | Response> {
196
+ const resourceType = request.params.resourceType;
197
+ const mappedType = RESOURCE_TYPE_MAP[resourceType];
198
+
199
+ if (!mappedType) {
200
+ return HttpProblems.notFound(request, context, {
201
+ detail: `Unknown resource type: ${resourceType}`,
202
+ });
203
+ }
204
+
205
+ context.custom.transformedResourceType = mappedType;
206
+
207
+ return request;
208
+ }
209
+ ```
210
+
211
+ ### Transforming Multiple Parameters
212
+
213
+ You can transform any number of route parameters and store each on
214
+ `context.custom`. Reference them individually in the rewrite pattern:
215
+
216
+ ```ts title="modules/transform-multiple-params.ts"
217
+ import { ZuploContext, ZuploRequest } from "@zuplo/runtime";
218
+
219
+ export default async function (
220
+ request: ZuploRequest,
221
+ context: ZuploContext,
222
+ options: any,
223
+ policyName: string,
224
+ ): Promise<ZuploRequest | Response> {
225
+ // Normalize casing
226
+ context.custom.version = request.params.version?.toLowerCase();
227
+
228
+ // Map resource type
229
+ context.custom.resource =
230
+ request.params.resource === "users" ? "customers" : request.params.resource;
231
+
232
+ return request;
233
+ }
234
+ ```
235
+
236
+ Then use both values in the rewrite pattern:
237
+
238
+ ```json
239
+ {
240
+ "rewritePattern": "https://backend.example.com/${context.custom.version}/${context.custom.resource}/${params.id}"
241
+ }
242
+ ```
243
+
244
+ ### Combining with Body Transformation
245
+
246
+ If your API also needs to transform values in the request body alongside route
247
+ parameters, you can handle both in the same inbound policy. Create a new
248
+ `ZuploRequest` with a modified body while storing the route parameter
249
+ transformations on `context.custom`:
250
+
251
+ ```ts title="modules/transform-params-and-body.ts"
252
+ import { ZuploContext, ZuploRequest } from "@zuplo/runtime";
253
+
254
+ export default async function (
255
+ request: ZuploRequest,
256
+ context: ZuploContext,
257
+ options: any,
258
+ policyName: string,
259
+ ): Promise<ZuploRequest | Response> {
260
+ // Transform route parameter
261
+ context.custom.transformedResourceType = `customer${request.params.resourceType}`;
262
+
263
+ // Transform the request body if present
264
+ if (request.headers.get("content-type")?.includes("application/json")) {
265
+ const body = await request.json();
266
+
267
+ // Map fields in the body to match the backend schema
268
+ const transformedBody = {
269
+ ...body,
270
+ type: context.custom.transformedResourceType,
271
+ };
272
+
273
+ // Return a new request with the modified body
274
+ return new ZuploRequest(request, {
275
+ body: JSON.stringify(transformedBody),
276
+ });
277
+ }
278
+
279
+ return request;
280
+ }
281
+ ```
282
+
283
+ ## Best Practices
284
+
285
+ - **Use descriptive keys on `context.custom`** — names like
286
+ `context.custom.transformedResourceType` are easier to debug than generic keys
287
+ like `context.custom.value`
288
+ - **Log transformations** — use `context.log` to record original and transformed
289
+ values so you can trace issues in production
290
+ - **Validate before transforming** — return an appropriate error response (using
291
+ [`HttpProblems`](../programmable-api/http-problems.mdx)) if a parameter value
292
+ is unexpected, rather than forwarding bad data to your backend
293
+ - **Keep the policy focused** — if your transformation logic is complex,
294
+ consider splitting it into a separate utility module imported by the policy
295
+
296
+ ## Next Steps
297
+
298
+ - [URL Rewrite Handler](../handlers/url-rewrite.mdx) — full reference for
299
+ rewrite patterns and available interpolation variables
300
+ - [Custom Code Patterns](../articles/custom-code-patterns.md) — common patterns
301
+ for writing inbound policies, outbound policies, and handlers
302
+ - [ZuploContext](../programmable-api/zuplo-context.mdx) — reference for
303
+ `context.custom` and other context properties
304
+ - [ZuploRequest](../programmable-api/zuplo-request.mdx) — reference for
305
+ `request.params` and constructing new requests
306
+ - [User-Based Backend Routing](./user-based-backend-routing.mdx) — a related
307
+ pattern using `context.custom` with URL Rewrite for routing by user identity
@@ -0,0 +1,204 @@
1
+ ---
2
+ title: "Cross App Access (XAA)"
3
+ sidebar_label: "Overview"
4
+ description: |
5
+ What Cross App Access (XAA) and the Identity Assertion JWT Authorization Grant
6
+ (ID-JAG) are, the problem they solve for AI agents and MCP, and how the Zuplo
7
+ MCP Gateway performs the token exchange so your MCP servers and clients don't
8
+ have to.
9
+ ---
10
+
11
+ :::note{title="Beta"}
12
+
13
+ Cross App Access support is in beta and builds on the
14
+ [MCP Gateway](../introduction.mdx), which is also in beta. The configuration
15
+ model and policy options may change before general availability.
16
+
17
+ :::
18
+
19
+ Cross App Access (XAA) lets one application reach another application's API on a
20
+ user's behalf **through the identity provider both apps already trust** —
21
+ instead of running a separate, point-to-point OAuth connection between them. For
22
+ AI agents and MCP, that means an agent can call a protected upstream MCP server
23
+ for a user without the user re-consenting to every app pair, and without
24
+ credentials sprawling outside the identity provider's view.
25
+
26
+ The Zuplo MCP Gateway sits in the middle of this flow and runs the XAA token
27
+ exchange for you. An MCP client connects to the gateway with ordinary MCP OAuth;
28
+ the gateway, acting as the XAA _requesting app_, mints the cross-app grant from
29
+ your identity provider and redeems it at the upstream's authorization server.
30
+ Neither the MCP client nor the upstream MCP server has to implement XAA itself.
31
+
32
+ ## The problem XAA solves
33
+
34
+ Single sign-on (SSO) solves _login_: a user authenticates once with an identity
35
+ provider (Okta, Microsoft Entra, Auth0) and gets into every connected app. SSO
36
+ does **not** solve _app-to-app API access_. When one app needs to call another
37
+ app's API on the user's behalf, the two apps today run a direct OAuth flow
38
+ between themselves. The
39
+ [IETF draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)
40
+ describes this as a connection that "bypasses the trusted identity provider" and
41
+ is "invisible to the identity provider managing the ecosystem."
42
+
43
+ AI agents make this gap urgent. An agent in one app increasingly needs to reach
44
+ into other SaaS apps' data on the user's behalf. The naive approach produces:
45
+
46
+ - **Consent-screen sprawl** — the user re-authorizes every agent against every
47
+ downstream app individually.
48
+ - **Credential sprawl** — long-lived tokens scattered per app pair, invisible to
49
+ IT.
50
+ - **No central governance** — security teams can't see or control what connects
51
+ to what, and offboarding means hunting access down service by service.
52
+
53
+ XAA routes app-to-app access back through the identity provider both apps
54
+ already trust. The IdP becomes the single place where IT decides which app can
55
+ talk to which app, enforces conditional-access policy, and audits every
56
+ delegation — while the upstream app's authorization server stays the
57
+ access-token issuer. The trust boundary isn't broken; it's brokered.
58
+
59
+ ## How the flow works
60
+
61
+ XAA is defined by the **Identity Assertion JWT Authorization Grant (ID-JAG)** —
62
+ `draft-ietf-oauth-identity-assertion-authz-grant`. It chains two OAuth exchanges
63
+ across two authorization servers:
64
+
65
+ 1. **Identity assertion → ID-JAG**, at the identity provider, using
66
+ [RFC 8693 token exchange](https://www.rfc-editor.org/rfc/rfc8693). The
67
+ requesting app presents the user's ID token (the identity assertion) and asks
68
+ for a grant scoped to one specific target authorization server. The IdP
69
+ evaluates policy and returns a short-lived, audience-restricted **ID-JAG** —
70
+ a signed JWT with the header `typ: oauth-id-jag+jwt`.
71
+ 2. **ID-JAG → access token**, at the upstream's resource authorization server,
72
+ using the
73
+ [RFC 7523 JWT bearer grant](https://www.rfc-editor.org/rfc/rfc7523). The
74
+ requesting app presents the ID-JAG as an `assertion` and receives a normal
75
+ access token for the upstream API.
76
+
77
+ The ID-JAG is a _grant_, not an access token — it can't call an API directly. It
78
+ exists only to be redeemed in the second exchange.
79
+
80
+ When the Zuplo gateway fronts an XAA-protected upstream, the gateway plays the
81
+ **requesting app** for both exchanges:
82
+
83
+ <Diagram height="h-80">
84
+ <DiagramNode id="client">MCP Client</DiagramNode>
85
+ <DiagramGroup id="gateway" label="Zuplo MCP Gateway (requesting app)">
86
+ <DiagramNode id="inbound" variant="zuplo">
87
+ OAuth 2.1 server
88
+ </DiagramNode>
89
+ <DiagramNode id="requestor" variant="zuplo">
90
+ XAA requestor
91
+ </DiagramNode>
92
+ </DiagramGroup>
93
+ <DiagramNode id="idp">Identity Provider</DiagramNode>
94
+ <DiagramGroup id="upstream" label="Upstream app">
95
+ <DiagramNode id="ras">Resource AS</DiagramNode>
96
+ <DiagramNode id="mcp">MCP server</DiagramNode>
97
+ </DiagramGroup>
98
+ <DiagramEdge from="client" to="inbound" label="MCP OAuth (no XAA)" />
99
+ <DiagramEdge from="requestor" to="idp" label="1. ID token → ID-JAG" />
100
+ <DiagramEdge from="requestor" to="ras" label="2. ID-JAG → access token" />
101
+ <DiagramEdge from="requestor" to="mcp" label="3. Bearer token" />
102
+ </Diagram>
103
+
104
+ Step by step, for a tool call to an XAA-protected upstream route:
105
+
106
+ 1. The MCP client connects to the gateway route with **ordinary MCP OAuth** —
107
+ discovery, PKCE, and a bearer token issued by the gateway. This leg is _not_
108
+ XAA. During the gateway's browser-login step, the user authenticates with the
109
+ identity provider, and the gateway captures the user's IdP identity
110
+ assertion.
111
+ 2. On a tool call, the gateway exchanges the user's ID token at the IdP for an
112
+ **ID-JAG** audience-restricted to the upstream's resource authorization
113
+ server (RFC 8693 token exchange).
114
+ 3. The gateway redeems the ID-JAG at the upstream resource authorization server
115
+ for an upstream **access token** (RFC 7523 JWT bearer grant).
116
+ 4. The gateway forwards the tool call to the upstream MCP server with
117
+ `Authorization: Bearer <upstream access token>` and caches the upstream token
118
+ per user for subsequent calls.
119
+
120
+ :::note{title="In requesting-app mode, the client → gateway leg is plain OAuth"}
121
+
122
+ This is the most common point of confusion. In the flow above, the MCP client
123
+ never speaks XAA — it runs the standard MCP authorization flow against the
124
+ gateway, and all ID-JAG exchanges happen on the gateway's _outbound_ side. A
125
+ client doesn't need any XAA support to benefit from it.
126
+
127
+ :::
128
+
129
+ ## What the gateway does for you
130
+
131
+ In the XAA flow the gateway plays the requesting app, so the MCP client and the
132
+ upstream MCP server are each insulated from the protocol:
133
+
134
+ - **The MCP client** does nothing new. It runs the same MCP OAuth flow it would
135
+ for any gateway route. Claude, Cursor, ChatGPT, VS Code, and any
136
+ spec-compliant client work unchanged.
137
+ - **The two-exchange dance** — the RFC 8693 token exchange at the IdP and the
138
+ RFC 7523 JWT-bearer redemption at the upstream — runs inside the gateway. You
139
+ configure endpoints and credentials; the gateway mints, redeems, caches, and
140
+ refreshes.
141
+ - **Per-user identity** is preserved end to end. The IdP sees the specific user,
142
+ the upstream sees a token minted for that user, and gateway analytics record
143
+ the same subject.
144
+
145
+ ## Glossary
146
+
147
+ - **Identity assertion** — Proof the IdP authenticated the user, carried as an
148
+ OIDC ID token (`urn:ietf:params:oauth:token-type:id_token`) or SAML 2.0
149
+ assertion. It's the _input_ to the XAA flow.
150
+ - **ID-JAG (Identity Assertion JWT Authorization Grant)** — A short-lived signed
151
+ JWT the IdP mints, audience-restricted to one resource authorization server.
152
+ Used once to obtain an access token there. JWT header `typ: oauth-id-jag+jwt`;
153
+ token type `urn:ietf:params:oauth:token-type:id-jag`. It is **not** an access
154
+ token.
155
+ - **Identity provider (IdP)** — The SSO authority both apps trust (Okta, Entra,
156
+ Auth0). In XAA it also brokers cross-app access by issuing ID-JAGs after
157
+ evaluating policy. Identified by `iss` in the ID-JAG.
158
+ - **Resource authorization server** — The _target_ app's OAuth server. It
159
+ consumes the ID-JAG via the JWT-bearer grant and issues the real access token.
160
+ Identified by `aud` in the ID-JAG. In Zuplo's outbound flow, this is the
161
+ upstream's server, not the gateway.
162
+ - **Requesting app** — The party that obtains and redeems the ID-JAG. When the
163
+ gateway fronts an XAA-protected upstream, the gateway is the requesting app.
164
+ - **Token exchange (RFC 8693)** — The grant used at the IdP to swap the identity
165
+ assertion for the ID-JAG.
166
+ - **JWT bearer grant (RFC 7523)** — The grant used at the resource authorization
167
+ server to redeem the ID-JAG for an access token.
168
+
169
+ ## When to use XAA
170
+
171
+ Reach for XAA when:
172
+
173
+ - An upstream MCP server is protected by an **enterprise resource authorization
174
+ server** that accepts ID-JAGs (the "enterprise-managed authorization"
175
+ pattern), and you want the gateway to broker access through your IdP rather
176
+ than running a separate OAuth client per upstream.
177
+ - You need **central governance** — one place in the IdP to grant, audit, and
178
+ revoke which apps can reach which APIs on a user's behalf.
179
+
180
+ For an upstream that uses plain per-user or shared OAuth (Linear, Notion,
181
+ Stripe, and most SaaS MCP servers today), use
182
+ [upstream OAuth](../auth/upstream-oauth.mdx) instead — XAA isn't required.
183
+
184
+ ## Try it and configure it
185
+
186
+ - [Quickstart](./quickstart.mdx) — see XAA work end to end in minutes using
187
+ Okta's hosted [xaa.dev](https://xaa.dev) playground, with no identity tenant
188
+ required.
189
+ - [Configuration reference](./policy-reference.mdx) — every `idJag` option on
190
+ the token-exchange policy.
191
+
192
+ ## Learn more
193
+
194
+ - [Identity Assertion JWT Authorization Grant (IETF draft)](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)
195
+ — the ID-JAG specification. Note this is an active draft, not yet a finalized
196
+ RFC.
197
+ - [MCP authorization spec](https://modelcontextprotocol.io/specification/latest/basic/authorization)
198
+ and the
199
+ [Enterprise-Managed Authorization extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization)
200
+ — where ID-JAG plugs into MCP.
201
+ - [Okta — Cross App Access](https://developer.okta.com/blog/2025/09/03/cross-app-access)
202
+ — Okta's explainer and walkthrough.
203
+ - [How the MCP Gateway works](../how-it-works.mdx) — the gateway's two OAuth
204
+ surfaces and request lifecycle.
@@ -0,0 +1,161 @@
1
+ ---
2
+ title: "Cross App Access configuration reference"
3
+ sidebar_label: "Configuration reference"
4
+ description: |
5
+ Every configuration option for Cross App Access (XAA) on the Zuplo MCP Gateway
6
+ — the id-jag mode on the token-exchange policy, where the gateway acts as the
7
+ XAA requesting app.
8
+ ---
9
+
10
+ Cross App Access is configured on the
11
+ [`mcp-token-exchange-inbound`](#gateway-as-requesting-app) policy. Setting
12
+ `authMode: "id-jag"` and providing an `idJag` block makes the gateway act as the
13
+ XAA **requesting app**: it mints an ID-JAG from your IdP and redeems it at an
14
+ upstream resource authorization server. This is the configuration the
15
+ [quickstart](./quickstart.mdx) uses.
16
+
17
+ :::note
18
+
19
+ The authoritative source for these options is the policy's runtime schema. The
20
+ generated `mcp-token-exchange-inbound` reference page predates `id-jag` mode;
21
+ the options below reflect the runtime behavior.
22
+
23
+ :::
24
+
25
+ ## Gateway as requesting app
26
+
27
+ Set `authMode: "id-jag"` on a `mcp-token-exchange-inbound` policy and provide an
28
+ `idJag` block. Attach the policy to the upstream route after the inbound MCP
29
+ OAuth policy.
30
+
31
+ ```jsonc title="config/policies.json"
32
+ {
33
+ "name": "id-jag-upstream",
34
+ "policyType": "mcp-token-exchange-inbound",
35
+ "handler": {
36
+ "module": "$import(@zuplo/runtime/mcp-gateway)",
37
+ "export": "McpTokenExchangeInboundPolicy",
38
+ "options": {
39
+ "displayName": "Upstream",
40
+ "authMode": "id-jag",
41
+ "idJag": {
42
+ "scopes": ["mcp:tools"],
43
+ "scopeDelimiter": " ",
44
+ "idp": {
45
+ "tokenUrl": "https://idp.example.com/token",
46
+ "clientAuth": {
47
+ "method": "client_secret_post",
48
+ "clientId": "$env(IDP_CLIENT_ID)",
49
+ "clientSecret": "$env(IDP_CLIENT_SECRET)",
50
+ },
51
+ },
52
+ "resourceAs": {
53
+ "tokenUrl": "https://upstream.example.com/token",
54
+ "audience": "https://upstream.example.com",
55
+ "resource": "https://upstream.example.com/mcp",
56
+ "clientAuth": {
57
+ "method": "client_secret_post",
58
+ "clientId": "$env(RESOURCE_AS_CLIENT_ID)",
59
+ "clientSecret": "$env(RESOURCE_AS_CLIENT_SECRET)",
60
+ },
61
+ },
62
+ },
63
+ },
64
+ },
65
+ }
66
+ ```
67
+
68
+ ### idJag options
69
+
70
+ | Option | Type | Default | Description |
71
+ | ---------------- | ---------- | ------- | -------------------------------------------------------------------- |
72
+ | `scopes` | `string[]` | `[]` | Scopes requested in both exchanges. |
73
+ | `scopeDelimiter` | `string` | `" "` | Delimiter used to join scopes. |
74
+ | `idp` | object | — | Where the gateway mints the ID-JAG (RFC 8693 token exchange). |
75
+ | `resourceAs` | object | — | Where the gateway redeems the ID-JAG for an access token (RFC 7523). |
76
+
77
+ ### idp
78
+
79
+ The identity provider that issues the ID-JAG.
80
+
81
+ | Option | Type | Description |
82
+ | ------------ | ------ | ----------------------------------------------------- |
83
+ | `tokenUrl` | string | The IdP token endpoint. |
84
+ | `clientAuth` | object | How the gateway authenticates to the IdP (see below). |
85
+
86
+ ### resourceAs
87
+
88
+ The upstream's resource authorization server that issues the access token.
89
+
90
+ | Option | Type | Description |
91
+ | ------------ | ------ | --------------------------------------------------------------------------------------------------------------------- |
92
+ | `tokenUrl` | string | The resource authorization server's token endpoint. |
93
+ | `audience` | string | **Required.** The resource AS identifier; sent as the token-exchange `audience` and becomes the ID-JAG `aud`. |
94
+ | `resource` | string | Optional [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) resource indicator. Defaults to the route's upstream URL. |
95
+ | `clientAuth` | object | How the gateway authenticates to the resource AS (see below). |
96
+
97
+ ### clientAuth
98
+
99
+ Both `idp.clientAuth` and `resourceAs.clientAuth` take the same shape. The
100
+ `method` selects how the gateway authenticates:
101
+
102
+ ```jsonc
103
+ // client_secret_post (or client_secret_basic)
104
+ "clientAuth": {
105
+ "method": "client_secret_post",
106
+ "clientId": "...",
107
+ "clientSecret": "$env(...)"
108
+ }
109
+ ```
110
+
111
+ ```jsonc
112
+ // private_key_jwt
113
+ "clientAuth": {
114
+ "method": "private_key_jwt",
115
+ "clientId": "...",
116
+ "privateKeyPem": "$env(...)",
117
+ "algorithm": "RS256",
118
+ "keyId": "...",
119
+ "expiresInSeconds": 300
120
+ }
121
+ ```
122
+
123
+ | Option | Type | Default | Applies to | Description |
124
+ | ------------------ | ------ | ------- | ----------------- | ------------------------------------------------------------------ |
125
+ | `method` | enum | — | all | `client_secret_post`, `client_secret_basic`, or `private_key_jwt`. |
126
+ | `clientId` | string | — | all | The OAuth client ID. |
127
+ | `clientSecret` | string | — | secret methods | The OAuth client secret. |
128
+ | `privateKeyPem` | string | — | `private_key_jwt` | PEM private key used to sign the client-assertion JWT. |
129
+ | `algorithm` | enum | `RS256` | `private_key_jwt` | `RS256`/`RS384`/`RS512`/`ES256`/`ES384`/`ES512`. |
130
+ | `keyId` | string | — | `private_key_jwt` | Optional `kid` header on the client assertion. |
131
+ | `audience` | string | — | `private_key_jwt` | Optional audience override for the client assertion. |
132
+ | `expiresInSeconds` | number | `300` | `private_key_jwt` | Client-assertion lifetime, max `3600`. |
133
+
134
+ ### What the gateway does at request time
135
+
136
+ On a tool call to the route, the gateway:
137
+
138
+ 1. Resolves the user's stored IdP identity assertion (bound during the inbound
139
+ browser login). If absent or expired, it returns a connect-required error and
140
+ refreshes the subject token when it can.
141
+ 2. Runs an RFC 8693 token exchange at `idp.tokenUrl`, requesting an ID-JAG
142
+ audience-restricted to `resourceAs.audience`.
143
+ 3. Redeems the ID-JAG at `resourceAs.tokenUrl` as an RFC 7523 JWT-bearer grant
144
+ to get the upstream access token.
145
+ 4. Caches the upstream token per user and forwards the tool call with
146
+ `Authorization: Bearer <upstream token>`.
147
+
148
+ ## Notes and limitations
149
+
150
+ - The gateway issues **opaque** access tokens, not JWTs.
151
+ - **DPoP is not supported.** Requests with a `DPoP` header are rejected.
152
+ - XAA requires a prior inbound browser login so the gateway has an IdP identity
153
+ assertion to exchange.
154
+
155
+ ## Related
156
+
157
+ - [Overview](./overview.mdx) — the protocol and the gateway's role.
158
+ - [Quickstart](./quickstart.mdx) — a working `id-jag` configuration on the
159
+ playground.
160
+ - [`mcp-token-exchange-inbound` policy](/policies/mcp-token-exchange-inbound) —
161
+ the base token-exchange policy reference.
@@ -0,0 +1,330 @@
1
+ ---
2
+ title: "Cross App Access quickstart"
3
+ sidebar_label: "Quickstart"
4
+ description: |
5
+ Build a Zuplo MCP Gateway that reaches an XAA-protected MCP server in the
6
+ Zuplo Portal — no repository to clone. The gateway fronts Okta's hosted
7
+ xaa.dev Todo0 server and performs the Cross App Access (XAA) token exchange on
8
+ every request.
9
+ ---
10
+
11
+ This quickstart builds a working Cross App Access (XAA) gateway entirely in the
12
+ Zuplo Portal. An MCP client connects to the gateway with ordinary OAuth; the
13
+ gateway then performs the XAA token exchange against
14
+ [Okta's hosted xaa.dev playground](https://xaa.dev) and reaches the protected
15
+ Todo0 MCP server on the user's behalf.
16
+
17
+ There is no repository to clone and no identity tenant to stand up. Everything
18
+ runs in the portal against the public xaa.dev playground.
19
+
20
+ ## Why xaa.dev
21
+
22
+ [xaa.dev](https://xaa.dev) is Okta's hosted Cross App Access playground: a
23
+ public identity provider, resource authorization server, and Todo0 MCP server
24
+ that are already wired together for the XAA token exchange. Standing up your own
25
+ XAA stack means provisioning an IdP, a resource authorization server, and a
26
+ protected MCP server — and configuring the trust relationships between all
27
+ three. The playground gives you all of that for free, so today it's the fastest
28
+ way to see a real Cross App Access flow end to end. Once the gateway works
29
+ against xaa.dev, swapping in your own identity provider and upstream is just a
30
+ change of endpoints and credentials.
31
+
32
+ ## What you'll build
33
+
34
+ A single gateway route with two policies does all the work:
35
+
36
+ - An **MCP OAuth** policy (`mcp-oauth-inbound`) secures the client → gateway
37
+ leg. The client authenticates to the gateway with plain MCP OAuth — this leg
38
+ is not XAA.
39
+ - A **token-exchange** policy (`mcp-token-exchange-inbound`) in `id-jag` mode
40
+ performs the outbound XAA exchange to the upstream: it mints an ID-JAG at the
41
+ playground identity provider (IdenX), redeems it at the resource authorization
42
+ server, and calls the Todo0 MCP server with the resulting access token.
43
+
44
+ The XAA exchange happens entirely on the gateway's outbound side. See
45
+ [the overview](./overview.mdx#how-the-flow-works) for the full sequence.
46
+
47
+ ## Prerequisites
48
+
49
+ - A [Zuplo account](https://portal.zuplo.com)
50
+ - A free account on [xaa.dev](https://xaa.dev)
51
+ - [MCP Jam](https://www.mcpjam.com) — a browser-based MCP client used to drive
52
+ the flow. Any MCP client that supports OAuth works.
53
+
54
+ ## Steps
55
+
56
+ <Stepper>
57
+
58
+ 1. **Create a Zuplo project.**
59
+
60
+ In the [Zuplo Portal](https://portal.zuplo.com/+/account/projects), select
61
+ **New Project** and create an empty **API Gateway** project. Name it
62
+ `xaa-quick-start`.
63
+
64
+ On the project **Overview** page, copy the deployment URL shown at the top
65
+ (for example, `https://xaa-quick-start-main-abc1234.d2.zuplo.dev`). This is
66
+ your gateway's public origin — you'll need it for the next two steps.
67
+
68
+ :::note
69
+
70
+ Throughout this guide, replace `https://your-gateway.zuplo.dev` with your
71
+ project's actual deployment URL.
72
+
73
+ :::
74
+
75
+ 2. **Register a requesting app on xaa.dev.**
76
+
77
+ Sign in to [xaa.dev](https://xaa.dev) and open
78
+ [the developer registration page](https://xaa.dev/developer/register). Enter
79
+ your email, then select **Register New App** and fill in:
80
+ - **Application Name** — anything, for example `xaa-quick-start gateway`.
81
+ - **Redirect URIs** — your gateway's OAuth callback:
82
+
83
+ ```text
84
+ https://your-gateway.zuplo.dev/__zuplo/oauth/callback
85
+ ```
86
+
87
+ Under **Resource Connections**, select **Todo0 MCP Server**, keep both scopes
88
+ (`todos.read` and `mcp.access`) checked, and select **Add Connection**. Then
89
+ select **Register App**.
90
+
91
+ <BrowserScreenshot url="https://xaa.dev/developer/register">
92
+
93
+ ![Registered requesting app on xaa.dev](../../../public/media/cross-app-access/xaa-playground.png)
94
+
95
+ </BrowserScreenshot>
96
+
97
+ Registration shows four values. Copy all of them — the secrets are shown only
98
+ once:
99
+
100
+ | xaa.dev value | Used for |
101
+ | ---------------------- | ------------------------------- |
102
+ | Client ID | `XAA_CLIENT_ID` |
103
+ | Client Secret | `XAA_CLIENT_SECRET` |
104
+ | Resource Client ID | `XAA_RESOURCE_AS_CLIENT_ID` |
105
+ | Resource Client Secret | `XAA_RESOURCE_AS_CLIENT_SECRET` |
106
+
107
+ :::tip
108
+
109
+ The playground identity provider (IdenX) accepts any email with no password,
110
+ so you can sign in as any test user when you drive the flow.
111
+
112
+ :::
113
+
114
+ 3. **Add the gateway configuration.**
115
+
116
+ Open the project's code editor (the **Code** tab). The gateway needs three
117
+ files.
118
+
119
+ First, define the two policies. Open `config/policies.json` (use the raw
120
+ `policies.json` tab, not the visual Policy List) and replace its contents:
121
+
122
+ ```json title="config/policies.json"
123
+ {
124
+ "policies": [
125
+ {
126
+ "name": "xaa-inbound",
127
+ "policyType": "mcp-oauth-inbound",
128
+ "handler": {
129
+ "module": "$import(@zuplo/runtime/mcp-gateway)",
130
+ "export": "McpOAuthInboundPolicy",
131
+ "options": {
132
+ "oidc": {
133
+ "issuer": "https://idp.xaa.dev",
134
+ "jwksUrl": "https://idp.xaa.dev/jwks",
135
+ "audience": "$env(GATEWAY_AUDIENCE)"
136
+ },
137
+ "browserLogin": {
138
+ "url": "https://idp.xaa.dev/authorize",
139
+ "tokenUrl": "https://idp.xaa.dev/token",
140
+ "clientId": "$env(XAA_CLIENT_ID)",
141
+ "clientSecret": "$env(XAA_CLIENT_SECRET)",
142
+ "scope": "openid profile email",
143
+ "audience": "$env(GATEWAY_AUDIENCE)",
144
+ "pkce": "S256"
145
+ }
146
+ }
147
+ }
148
+ },
149
+ {
150
+ "name": "id-jag-upstream",
151
+ "policyType": "mcp-token-exchange-inbound",
152
+ "handler": {
153
+ "module": "$import(@zuplo/runtime/mcp-gateway)",
154
+ "export": "McpTokenExchangeInboundPolicy",
155
+ "options": {
156
+ "id": "id-jag-upstream",
157
+ "displayName": "Todo0",
158
+ "summary": "xaa.dev Todo0 MCP server, reached via Cross App Access (ID-JAG).",
159
+ "authMode": "id-jag",
160
+ "idJag": {
161
+ "scopes": ["todos.read", "mcp.access"],
162
+ "idp": {
163
+ "tokenUrl": "https://idp.xaa.dev/token",
164
+ "clientAuth": {
165
+ "method": "client_secret_post",
166
+ "clientId": "$env(XAA_CLIENT_ID)",
167
+ "clientSecret": "$env(XAA_CLIENT_SECRET)"
168
+ }
169
+ },
170
+ "resourceAs": {
171
+ "tokenUrl": "https://auth.resource.xaa.dev/token",
172
+ "audience": "https://auth.resource.xaa.dev",
173
+ "resource": "https://mcp.xaa.dev/mcp",
174
+ "clientAuth": {
175
+ "method": "client_secret_post",
176
+ "clientId": "$env(XAA_RESOURCE_AS_CLIENT_ID)",
177
+ "clientSecret": "$env(XAA_RESOURCE_AS_CLIENT_SECRET)"
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }
184
+ ]
185
+ }
186
+ ```
187
+
188
+ The IdP, resource authorization server, and upstream endpoints are wired to
189
+ the playground here. Only the credentials and the gateway audience are
190
+ environment-driven, so you can plug in your own xaa.dev app. For every
191
+ option, see the [configuration reference](./policy-reference.mdx).
192
+
193
+ Next, add the route that exposes the gateway. Open `config/routes.oas.json`
194
+ (the raw `routes.oas.json` tab) and replace its contents:
195
+
196
+ ```json title="config/routes.oas.json"
197
+ {
198
+ "openapi": "3.1.0",
199
+ "info": {
200
+ "version": "1.0.0",
201
+ "title": "XAA MCP Gateway",
202
+ "description": "MCP gateway that bridges to the xaa.dev Todo0 MCP server via Cross App Access (ID-JAG)."
203
+ },
204
+ "paths": {
205
+ "/mcp/todo0": {
206
+ "post": {
207
+ "operationId": "todo0Bridge",
208
+ "summary": "Bridge to the xaa.dev Todo0 MCP server",
209
+ "x-zuplo-route": {
210
+ "corsPolicy": "none",
211
+ "handler": {
212
+ "module": "$import(@zuplo/runtime/mcp-gateway)",
213
+ "export": "McpProxyHandler",
214
+ "options": {
215
+ "rewritePattern": "https://mcp.xaa.dev/mcp"
216
+ }
217
+ },
218
+ "policies": {
219
+ "inbound": ["xaa-inbound", "id-jag-upstream"]
220
+ }
221
+ }
222
+ }
223
+ }
224
+ }
225
+ }
226
+ ```
227
+
228
+ The `McpProxyHandler` forwards the request to the upstream Todo0 server, and
229
+ both policies run inbound — first the MCP OAuth check, then the XAA exchange.
230
+
231
+ Finally, register the MCP Gateway plugin. In the file tree, right-click the
232
+ `modules` folder, select **New Runtime Extension**, and replace the generated
233
+ file's contents:
234
+
235
+ ```typescript title="modules/zuplo.runtime.ts"
236
+ import { RuntimeExtensions } from "@zuplo/runtime";
237
+ import { McpGatewayPlugin } from "@zuplo/runtime/mcp-gateway";
238
+
239
+ // Registers the MCP Gateway, which adds the OAuth and upstream-connection
240
+ // routes used to expose and secure MCP servers through your gateway.
241
+ // Docs: https://zuplo.com/docs/mcp-server/introduction
242
+ export function runtimeInit(runtime: RuntimeExtensions) {
243
+ runtime.addPlugin(new McpGatewayPlugin());
244
+ }
245
+ ```
246
+
247
+ Save each file. The build fails until the environment variables exist —
248
+ that's the next step.
249
+
250
+ 4. **Set the environment variables.**
251
+
252
+ Open **Settings →
253
+ [Environment Variables](https://portal.zuplo.com/+/account/project/settings/environment-variables)**
254
+ and add each variable below with **Add variable**. Mark the two `*_SECRET`
255
+ values as **Secret** so they're encrypted and hidden after saving. Leave all
256
+ three environments (Production, Preview, Development) selected.
257
+
258
+ | Variable | Value | Secret |
259
+ | ------------------------------- | ----------------------------------------------------------------------------- | ------ |
260
+ | `GATEWAY_AUDIENCE` | Your gateway's deployment URL (for example, `https://your-gateway.zuplo.dev`) | No |
261
+ | `XAA_CLIENT_ID` | Client ID from the xaa.dev app | No |
262
+ | `XAA_CLIENT_SECRET` | Client Secret from the xaa.dev app | Yes |
263
+ | `XAA_RESOURCE_AS_CLIENT_ID` | Resource Client ID from the Todo0 connection | No |
264
+ | `XAA_RESOURCE_AS_CLIENT_SECRET` | Resource Client Secret from the Todo0 connection | Yes |
265
+
266
+ Saving an environment variable triggers a new deployment. Once it finishes,
267
+ the gateway is live.
268
+
269
+ 5. **Connect with MCP Jam and list the todos.**
270
+
271
+ Open the [MCP Jam web inspector](https://www.mcpjam.com), go to **Connect**,
272
+ and select **Add Server**:
273
+ - **Server Name** — `xaa-quick-start`
274
+ - **Connection Type** — `HTTPS`, with the route URL
275
+ `https://your-gateway.zuplo.dev/mcp/todo0`
276
+ - **Authentication** — `OAuth`
277
+
278
+ <ModalScreenshot>
279
+
280
+ ![Add the gateway as an MCP server in MCP Jam](../../../public/media/cross-app-access/add-mcp-server.png)
281
+
282
+ </ModalScreenshot>
283
+
284
+ Select **Add Server**. MCP Jam starts the OAuth flow and redirects you to
285
+ sign in. Sign in at the IdenX screen (any email, no password) and approve the
286
+ consent screen. MCP Jam returns to the inspector and the server shows as
287
+ **Connected**.
288
+
289
+ <Framed>
290
+
291
+ ![The gateway connected in MCP Jam](../../../public/media/cross-app-access/connected-card.png)
292
+
293
+ </Framed>
294
+
295
+ Open the **Resources** panel and select **List all todos**. The todos come
296
+ back as JSON — the agent never touched the XAA protocol. Behind the scenes
297
+ the gateway minted an ID-JAG from the playground IdP, redeemed it at the
298
+ resource authorization server, and called the Todo0 MCP server with the
299
+ resulting access token.
300
+
301
+ <Framed>
302
+
303
+ ![Todo0 resources and todos returned through the gateway in MCP Jam](../../../public/media/cross-app-access/todo-resources.png)
304
+
305
+ </Framed>
306
+
307
+ </Stepper>
308
+
309
+ ## Verify from the command line (optional)
310
+
311
+ To confirm the gateway is deployed and secured without a client, check its OAuth
312
+ metadata and the protected route:
313
+
314
+ ```bash
315
+ # Protected-resource metadata is published for the route (expect 200)
316
+ curl -s https://your-gateway.zuplo.dev/.well-known/oauth-protected-resource/mcp/todo0
317
+
318
+ # The MCP route rejects unauthenticated calls (expect 401)
319
+ curl -s -o /dev/null -w "%{http_code}\n" -X POST \
320
+ https://your-gateway.zuplo.dev/mcp/todo0 \
321
+ -H "Content-Type: application/json" \
322
+ -H "Accept: application/json, text/event-stream" \
323
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}'
324
+ ```
325
+
326
+ ## Next steps
327
+
328
+ - [Configuration reference](./policy-reference.mdx) — the full `idJag` option
329
+ set.
330
+ - [Overview](./overview.mdx) — the protocol and the gateway's role explained.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zuplo",
3
- "version": "6.71.10",
3
+ "version": "6.71.12",
4
4
  "type": "module",
5
5
  "description": "The programmable API Gateway",
6
6
  "author": "Zuplo, Inc.",
@@ -19,9 +19,9 @@
19
19
  "zuplo": "zuplo.js"
20
20
  },
21
21
  "dependencies": {
22
- "@zuplo/cli": "6.71.10",
23
- "@zuplo/core": "6.71.10",
24
- "@zuplo/runtime": "6.71.10",
22
+ "@zuplo/cli": "6.71.12",
23
+ "@zuplo/core": "6.71.12",
24
+ "@zuplo/runtime": "6.71.12",
25
25
  "@zuplo/test": "1.4.0"
26
26
  }
27
27
  }