zuplo 6.70.68 → 6.70.70

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.
Files changed (50) hide show
  1. package/docs/ai-gateway/getting-started.mdx +12 -8
  2. package/docs/ai-gateway/introduction.mdx +11 -9
  3. package/docs/articles/api-key-buckets.mdx +4 -2
  4. package/docs/articles/archiving-requests-to-storage.mdx +4 -4
  5. package/docs/articles/branch-based-deployments.mdx +10 -8
  6. package/docs/articles/ci-cd-github/cleanup-on-branch-delete.mdx +52 -31
  7. package/docs/articles/ci-cd-github/pr-preview-environments.mdx +17 -6
  8. package/docs/articles/custom-ci-cd-azure.mdx +1 -1
  9. package/docs/articles/custom-ci-cd-bitbucket.mdx +1 -1
  10. package/docs/articles/custom-ci-cd-circleci.mdx +1 -1
  11. package/docs/articles/custom-ci-cd-github.mdx +1 -1
  12. package/docs/articles/custom-ci-cd-gitlab.mdx +1 -1
  13. package/docs/articles/graphql.mdx +276 -0
  14. package/docs/articles/monorepo-deployment.mdx +17 -3
  15. package/docs/articles/opentelemetry.mdx +5 -2
  16. package/docs/articles/per-user-rate-limits-using-db.mdx +5 -6
  17. package/docs/articles/securing-the-gateway-with-client-mtls.mdx +68 -43
  18. package/docs/articles/step-1-setup-basic-gateway.mdx +1 -3
  19. package/docs/articles/step-2-add-rate-limiting.mdx +1 -1
  20. package/docs/articles/testing.mdx +1 -1
  21. package/docs/articles/troubleshooting.md +7 -3
  22. package/docs/articles/waf-ddos-akamai.md +35 -16
  23. package/docs/articles/waf-ddos-aws-waf-shield.mdx +35 -16
  24. package/docs/articles/waf-ddos-fastly.mdx +10 -7
  25. package/docs/cli/deploy.mdx +13 -10
  26. package/docs/cli/deploy.partial.mdx +13 -10
  27. package/docs/dev-portal/zudoku/components/sidecar-box.mdx +131 -0
  28. package/docs/dev-portal/zudoku/configuration/api-catalog.md +62 -42
  29. package/docs/dev-portal/zudoku/configuration/api-reference.md +5 -4
  30. package/docs/dev-portal/zudoku/configuration/navigation.mdx +70 -7
  31. package/docs/guides/canary-routing-for-employees.mdx +103 -39
  32. package/docs/guides/modify-openapi-paths.mdx +3 -3
  33. package/docs/handlers/legacy-dev-portal-handler.mdx +1 -1
  34. package/docs/handlers/mcp-server.mdx +13 -11
  35. package/docs/handlers/url-forward.mdx +5 -1
  36. package/docs/handlers/url-rewrite.mdx +7 -2
  37. package/docs/handlers/websocket-handler.mdx +5 -1
  38. package/docs/mcp-gateway/observability/logging.mdx +19 -12
  39. package/docs/mcp-server/resources.mdx +27 -15
  40. package/docs/mcp-server/testing.mdx +0 -2
  41. package/docs/policies/archive-request-azure-storage-inbound/doc.md +1 -1
  42. package/docs/policies/archive-response-azure-storage-outbound/doc.md +1 -1
  43. package/docs/policies/ip-restriction-inbound/policy.ts +1 -1
  44. package/docs/programmable-api/http-problems.mdx +0 -18
  45. package/docs/programmable-api/jwt-service-plugin.mdx +131 -109
  46. package/docs/programmable-api/runtime-behaviors.mdx +4 -2
  47. package/docs/programmable-api/streaming-zone-cache.mdx +4 -6
  48. package/docs/programmable-api/web-crypto-apis.mdx +10 -6
  49. package/package.json +4 -4
  50. package/docs/errors/get-head-body-error.mdx +0 -41
@@ -16,11 +16,13 @@ issued by your API.
16
16
 
17
17
  ## JWT Token
18
18
 
19
- This service currently issues JWTs using the EdDSA algorithm. This is the
19
+ By default, this service issues JWTs using the EdDSA algorithm. This is the
20
20
  recommended algorithm for new applications due to its strong security properties
21
- and performance characteristics. However, it's important to note that not every
22
- library supports EdDSA, so you should ensure that your
23
- [client library](https://jwt.io/libraries) can handle this algorithm.
21
+ and performance characteristics. However, not every library supports EdDSA, so
22
+ you should ensure that your [client library](https://jwt.io/libraries) can
23
+ handle this algorithm. If you need a different algorithm (for example, for
24
+ compatibility with an existing key pair or client library), use the `algorithm`
25
+ configuration option.
24
26
 
25
27
  ## Use Cases
26
28
 
@@ -68,9 +70,9 @@ export function runtimeInit(runtime: RuntimeExtensions) {
68
70
  // Custom base path for the issuer endpoint (default: "/__zuplo/issuer")
69
71
  basePath: "/custom",
70
72
 
71
- // Token expiration time (default: 300 seconds)
73
+ // Token expiration time (default: "1h")
72
74
  // Can be a number (seconds) or a time span string
73
- tokenExpiration: "5m", // or 300 for seconds
75
+ expiresIn: "5m", // or 300 for seconds
74
76
  };
75
77
 
76
78
  const jwtService = new JwtServicePlugin(options);
@@ -84,8 +86,16 @@ export function runtimeInit(runtime: RuntimeExtensions) {
84
86
  is `"/__zuplo/issuer"`. This affects the issuer URL and OIDC configuration
85
87
  endpoints.
86
88
 
87
- - **`tokenExpiration`** (optional): Sets the default expiration time for JWTs.
88
- Can be either:
89
+ - **`algorithm`** (optional): The asymmetric signing algorithm used for issued
90
+ JWTs. Default is `"EdDSA"`. Supported values are `EdDSA`, `RS256`, `RS384`,
91
+ `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, and `ES512`. The
92
+ algorithm must match the configured key pair — for example, an RSA key
93
+ requires an `RS*` or `PS*` value and an Ed25519 key requires `EdDSA`.
94
+ Symmetric algorithms (like `HS256`) aren't supported because the plugin
95
+ publishes a JWKS endpoint.
96
+
97
+ - **`expiresIn`** (optional): Sets the default expiration time for JWTs. Default
98
+ is `"1h"`. Can be either:
89
99
  - A **number**: Direct value in seconds (for example, `300` for 5 minutes)
90
100
  - A **string**: Time span format (for example, `"5 minutes"`, `"1 hour"`,
91
101
  `"7 days"`)
@@ -101,11 +111,11 @@ export function runtimeInit(runtime: RuntimeExtensions) {
101
111
  Examples:
102
112
 
103
113
  ```ts
104
- tokenExpiration: 300; // 300 seconds
105
- tokenExpiration: "5 minutes"; // 5 minutes
106
- tokenExpiration: "2 hours"; // 2 hours
107
- tokenExpiration: "7 days"; // 7 days
108
- tokenExpiration: "30 mins"; // 30 minutes
114
+ expiresIn: 300; // 300 seconds
115
+ expiresIn: "5 minutes"; // 5 minutes
116
+ expiresIn: "2 hours"; // 2 hours
117
+ expiresIn: "7 days"; // 7 days
118
+ expiresIn: "30 mins"; // 30 minutes
109
119
  ```
110
120
 
111
121
  Note: Individual JWT creation can override this default by specifying
@@ -180,61 +190,44 @@ export default async function (request: ZuploRequest, context: ZuploContext) {
180
190
  ## Validating JWTs in Upstream Services
181
191
 
182
192
  Upstream services can validate the JWTs issued by your Zuplo API by verifying
183
- the signature and claims. Here's an example of how to validate JWTs in different
184
- environments:
193
+ the signature and claims. The examples below use `EdDSA`, the plugin's default
194
+ signing algorithm. If you configured a different algorithm using the `algorithm`
195
+ option, use that value in the `algorithms` list instead.
185
196
 
186
197
  ### Node.js/Express Example
187
198
 
188
- ```js title="validate-jwt.js"
189
- const jwt = require("jsonwebtoken");
190
- const jwksClient = require("jwks-rsa");
199
+ This example uses the [`jose`](https://github.com/panva/jose) library because
200
+ the popular `jsonwebtoken` library doesn't support the EdDSA algorithm.
201
+
202
+ ```js title="validate-jwt.mjs"
203
+ import { createRemoteJWKSet, jwtVerify } from "jose";
191
204
 
192
205
  // Replace with your actual Zuplo deployment name or custom domain
193
206
  const ISSUER = "https://my-api.zuplo.app/__zuplo/issuer";
194
207
 
195
- // Create a JWKS client to fetch public keys
196
- const client = jwksClient({
197
- jwksUri: `${ISSUER}/.well-known/jwks.json`,
198
- cache: true,
199
- cacheMaxAge: 600000, // 10 minutes
200
- });
201
-
202
- // Function to get the signing key
203
- function getKey(header, callback) {
204
- client.getSigningKey(header.kid, function (err, key) {
205
- if (err) {
206
- return callback(err);
207
- }
208
- const signingKey = key.getPublicKey();
209
- callback(null, signingKey);
210
- });
211
- }
208
+ // Create a remote JWK Set that fetches and caches the public keys
209
+ const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));
212
210
 
213
211
  // Middleware to validate JWT
214
- function validateJwt(req, res, next) {
212
+ async function validateJwt(req, res, next) {
215
213
  const token = req.headers.authorization?.replace("Bearer ", "");
216
214
 
217
215
  if (!token) {
218
216
  return res.status(401).json({ error: "No token provided" });
219
217
  }
220
218
 
221
- jwt.verify(
222
- token,
223
- getKey,
224
- {
219
+ try {
220
+ const { payload } = await jwtVerify(token, JWKS, {
225
221
  issuer: ISSUER,
226
- algorithms: ["RS256"],
227
- },
228
- (err, decoded) => {
229
- if (err) {
230
- return res
231
- .status(401)
232
- .json({ error: "Invalid token", details: err.message });
233
- }
234
- req.user = decoded;
235
- next();
236
- },
237
- );
222
+ algorithms: ["EdDSA"],
223
+ });
224
+ req.user = payload;
225
+ next();
226
+ } catch (err) {
227
+ return res
228
+ .status(401)
229
+ .json({ error: "Invalid token", details: err.message });
230
+ }
238
231
  }
239
232
 
240
233
  // Example usage
@@ -248,11 +241,18 @@ app.get("/protected", validateJwt, (req, res) => {
248
241
 
249
242
  ### Python/FastAPI Example
250
243
 
244
+ EdDSA validation in PyJWT requires the `cryptography` package. Install PyJWT
245
+ with the crypto extra: `pip install pyjwt[crypto]`.
246
+
247
+ The keys in Zuplo's JWKS don't include a `kid`, so this example loads the key
248
+ directly from the JWKS document rather than using `PyJWKClient`, which only
249
+ matches keys by `kid`.
250
+
251
251
  ```python title="validate_jwt.py"
252
252
  from fastapi import FastAPI, Depends, HTTPException, Security
253
253
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
254
254
  import jwt
255
- from jwt import PyJWKClient
255
+ from jwt import PyJWK
256
256
  import requests
257
257
 
258
258
  app = FastAPI()
@@ -262,21 +262,21 @@ security = HTTPBearer()
262
262
  ISSUER = "https://my-api.zuplo.app/__zuplo/issuer"
263
263
  JWKS_URL = f"{ISSUER}/.well-known/jwks.json"
264
264
 
265
- # Initialize JWKS client
266
- jwks_client = PyJWKClient(JWKS_URL)
265
+ def get_signing_key() -> PyJWK:
266
+ # Zuplo publishes a single signing key. Consider caching this
267
+ # response briefly to avoid fetching the JWKS on every request.
268
+ jwks = requests.get(JWKS_URL, timeout=10).json()
269
+ return PyJWK.from_dict(jwks["keys"][0])
267
270
 
268
271
  async def validate_token(credentials: HTTPAuthorizationCredentials = Security(security)):
269
272
  token = credentials.credentials
270
273
 
271
274
  try:
272
- # Get the signing key from JWKS
273
- signing_key = jwks_client.get_signing_key_from_jwt(token)
274
-
275
275
  # Verify and decode the token
276
276
  payload = jwt.decode(
277
277
  token,
278
- signing_key.key,
279
- algorithms=["RS256"],
278
+ get_signing_key(),
279
+ algorithms=["EdDSA"],
280
280
  issuer=ISSUER,
281
281
  options={"verify_exp": True}
282
282
  )
@@ -297,9 +297,11 @@ async def protected_route(token_data: dict = Depends(validate_token)):
297
297
 
298
298
  ### Dynamic OIDC Discovery
299
299
 
300
- For more flexible JWT validation, you can use a library to dynamically discover
301
- the OIDC configuration based on the issuer claim in the JWT. This example uses
302
- the [`oauth4webapi`](https://github.com/panva/oauth4webapi) library.
300
+ For more flexible JWT validation, you can dynamically discover the OIDC
301
+ configuration based on the issuer claim in the JWT. This example fetches the
302
+ issuer's OIDC discovery document to find the JWKS endpoint, then verifies the
303
+ token with the same [`jose`](https://github.com/panva/jose) library used in the
304
+ Node.js example above.
303
305
 
304
306
  :::warning{title="Security Warning"}
305
307
 
@@ -310,8 +312,19 @@ ensure you are only allowing tokens from trusted issuers.
310
312
 
311
313
  :::
312
314
 
313
- ```js title="validate-jwt-dynamic.js"
314
- import * as oauth from "oauth4webapi";
315
+ ```ts title="validate-jwt-dynamic.ts"
316
+ import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
317
+ import type { JWTPayload } from "jose";
318
+ import type { NextFunction, Request, Response } from "express";
319
+
320
+ // Make the verified JWT payload available as req.user
321
+ declare global {
322
+ namespace Express {
323
+ interface Request {
324
+ user?: JWTPayload;
325
+ }
326
+ }
327
+ }
315
328
 
316
329
  const ALLOWED_ISSUERS = [
317
330
  "https://my-api.zuplo.app/__zuplo/issuer",
@@ -319,59 +332,66 @@ const ALLOWED_ISSUERS = [
319
332
  // Add more allowed issuers as needed
320
333
  ];
321
334
 
322
- async function validateJwtDynamic(token) {
323
- try {
324
- // Decode the JWT header and payload without verification first
325
- const parts = token.split(".");
326
- if (parts.length !== 3) {
327
- throw new Error("Invalid JWT format");
335
+ // Cache the remote JWK Set for each issuer so discovery only runs once.
336
+ // jose handles JWKS caching and key rotation automatically.
337
+ const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
338
+
339
+ async function getJwks(issuer: string) {
340
+ let jwks = jwksCache.get(issuer);
341
+ if (!jwks) {
342
+ // Discover the OIDC configuration for the issuer
343
+ const response = await fetch(`${issuer}/.well-known/openid-configuration`);
344
+ if (!response.ok) {
345
+ throw new Error(`OIDC discovery failed with status ${response.status}`);
328
346
  }
329
-
330
- const payload = JSON.parse(
331
- atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")),
332
- );
333
-
334
- // Extract the issuer from the token
335
- const issuer = payload.iss;
336
- if (!issuer) {
337
- throw new Error("No issuer claim in token");
347
+ const metadata = (await response.json()) as {
348
+ issuer?: string;
349
+ jwks_uri?: string;
350
+ };
351
+ if (metadata.issuer !== issuer) {
352
+ throw new Error("Discovery document issuer mismatch");
338
353
  }
339
-
340
- // Validate the issuer against allowed issuers
341
- if (!ALLOWED_ISSUERS.includes(issuer)) {
342
- throw new Error(`Issuer ${issuer} isn't allowed`);
354
+ if (!metadata.jwks_uri) {
355
+ throw new Error("Issuer metadata is missing jwks_uri");
343
356
  }
357
+ jwks = createRemoteJWKSet(new URL(metadata.jwks_uri));
358
+ jwksCache.set(issuer, jwks);
359
+ }
360
+ return jwks;
361
+ }
362
+
363
+ async function validateJwtDynamic(token: string): Promise<JWTPayload> {
364
+ // Read the issuer claim without verifying the signature yet
365
+ const { iss: issuer } = decodeJwt(token);
366
+ if (!issuer) {
367
+ throw new Error("No issuer claim in token");
368
+ }
344
369
 
345
- // Discover the OIDC configuration
346
- const issuerUrl = new URL(issuer);
347
- const as = await oauth
348
- .discoveryRequest(issuerUrl)
349
- .then((response) => oauth.processDiscoveryResponse(issuerUrl, response));
350
-
351
- // Get the JWKS
352
- const jwks = await oauth
353
- .jwksRequest(as)
354
- .then((response) => oauth.processJwksResponse(response));
355
-
356
- // Import the JWT and validate it
357
- const { payload: verifiedPayload, protectedHeader } =
358
- await oauth.validateJwt(token, jwks, {
359
- issuer: issuer,
360
- audience: payload.aud, // Optional: validate audience
361
- });
362
-
363
- return verifiedPayload;
364
- } catch (error) {
365
- throw new Error(`JWT validation failed: ${error.message}`);
370
+ // Validate the issuer against the allow list before fetching anything
371
+ if (!ALLOWED_ISSUERS.includes(issuer)) {
372
+ throw new Error(`Issuer ${issuer} isn't allowed`);
366
373
  }
374
+
375
+ // Verify the signature and standard claims
376
+ const { payload } = await jwtVerify(token, await getJwks(issuer), {
377
+ issuer,
378
+ algorithms: ["EdDSA"],
379
+ });
380
+
381
+ return payload;
367
382
  }
368
383
 
369
384
  // Express middleware example
370
- function validateJwtMiddleware(req, res, next) {
385
+ function validateJwtMiddleware(
386
+ req: Request,
387
+ res: Response,
388
+ next: NextFunction,
389
+ ) {
371
390
  const token = req.headers.authorization?.replace("Bearer ", "");
372
391
 
373
392
  if (!token) {
374
- return res.status(401).json({ error: "No token provided" });
393
+ res.status(401).json({ error: "No token provided" });
394
+ return;
375
395
  }
376
396
 
377
397
  validateJwtDynamic(token)
@@ -379,8 +399,10 @@ function validateJwtMiddleware(req, res, next) {
379
399
  req.user = payload;
380
400
  next();
381
401
  })
382
- .catch((error) => {
383
- res.status(401).json({ error: error.message });
402
+ .catch((error: Error) => {
403
+ res
404
+ .status(401)
405
+ .json({ error: `JWT validation failed: ${error.message}` });
384
406
  });
385
407
  }
386
408
 
@@ -21,5 +21,7 @@ for `Request.body` specifies that on `GET` and `HEAD` requests the value must be
21
21
  `null`. Different APIs, networks, and gateways follow this spec to varying
22
22
  degrees. In some cases they allow in others they don't.
23
23
 
24
- By default, Zuplo will return a 500 error in the event that a `GET` or `HEAD`
25
- request has a body.
24
+ By default, Zuplo removes the body from any `GET` or `HEAD` request and adds a
25
+ `zp-body-removed: true` header so your backend knows the body was removed. The
26
+ request then proceeds as normal. For more details, including an example policy
27
+ that rejects these requests, see [zp-body-removed](./zp-body-removed.mdx).
@@ -75,12 +75,10 @@ export default async function handler(
75
75
  // Cache the response for 1 hour (3600 seconds)
76
76
  await cache.put(cacheKey, streamForCache, 3600);
77
77
 
78
- return new Response(streamForResponse, {
79
- headers: {
80
- ...response.headers,
81
- "X-Cache": "MISS",
82
- },
83
- });
78
+ const headers = new Headers(response.headers);
79
+ headers.set("X-Cache", "MISS");
80
+
81
+ return new Response(streamForResponse, { headers });
84
82
  }
85
83
  ```
86
84
 
@@ -96,15 +96,19 @@ async function sign(value: string, secret: string) {
96
96
 
97
97
  // `mac` is an ArrayBuffer, so you need to make a few changes to get
98
98
  // it into a ByteString, and then a Base64-encoded string.
99
- let base64Mac = btoa(String.fromCharCode(...new Uint8Array(mac)));
100
-
101
- // must convert "+" to "-" as urls encode "+" as " "
102
- base64Mac = base64Mac.replaceAll("+", "-");
103
-
104
- return base64Mac;
99
+ return btoa(String.fromCharCode(...new Uint8Array(mac)));
105
100
  }
106
101
  ```
107
102
 
103
+ :::note
104
+
105
+ The signature is standard Base64 and can contain the characters `+`, `/`, and
106
+ `=`. If you transport the signature in a URL — for example, as a query parameter
107
+ — encode it with `encodeURIComponent()` first, or convert it to the URL-safe
108
+ Base64 alphabet on both the signing and verifying sides.
109
+
110
+ :::
111
+
108
112
  ### Verify a Value
109
113
 
110
114
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zuplo",
3
- "version": "6.70.68",
3
+ "version": "6.70.70",
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.70.68",
23
- "@zuplo/core": "6.70.68",
24
- "@zuplo/runtime": "6.70.68",
22
+ "@zuplo/cli": "6.70.70",
23
+ "@zuplo/core": "6.70.70",
24
+ "@zuplo/runtime": "6.70.70",
25
25
  "@zuplo/test": "1.4.0"
26
26
  }
27
27
  }
@@ -1,41 +0,0 @@
1
- ---
2
- title: GET/HEAD Body Error (GET_HEAD_BODY_ERROR)
3
- ---
4
-
5
- A GET or HEAD request included a body, which is not allowed. The
6
- [Fetch specification](https://fetch.spec.whatwg.org/) defines that GET and HEAD
7
- requests must not have a request body.
8
-
9
- ## Why this happens
10
-
11
- The HTTP specification states that GET and HEAD requests are intended for
12
- retrieving resources and should not include a request body. While some HTTP
13
- clients allow sending a body with GET requests, the Zuplo runtime enforces the
14
- specification and rejects these requests.
15
-
16
- ## How to fix
17
-
18
- - **Use a different HTTP method** - If the request needs to send data in the
19
- body, use `POST`, `PUT`, or `PATCH` instead of `GET` or `HEAD`.
20
- - **Move data to query parameters** - If the request must remain a `GET`,
21
- convert the body data to URL query parameters.
22
- - **Remove the body** - If the body was included unintentionally, remove it from
23
- the request.
24
-
25
- ## Common causes
26
-
27
- - **HTTP client defaults** - Some HTTP client libraries or frameworks
28
- automatically attach a body to requests, even for GET methods. Check the
29
- client configuration.
30
- - **Copied request configuration** - A request configuration copied from a POST
31
- endpoint may still include a body when adapted for a GET endpoint.
32
- - **Framework behavior** - Certain frontend frameworks or API testing tools may
33
- silently include an empty body or content-type header on GET requests.
34
-
35
- :::note
36
-
37
- This is a client-side issue. Update the request on the calling side to remove
38
- the body or change the HTTP method. No changes are needed on the Zuplo gateway
39
- configuration.
40
-
41
- :::