From c3453943ea5056ccbe80a71b017d0e4c7b1f6431 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 5 Aug 2026 10:09:19 +0100 Subject: [PATCH 01/10] feat(webapp): share rate limit bucket across additional API keys per environment --- .../additional-api-key-rate-limit-bucket.md | 6 ++ .../app/services/apiRateLimit.server.ts | 30 +++++- ...authorizationRateLimitMiddleware.server.ts | 62 +++++++++--- .../authorizationRateLimitMiddleware.test.ts | 94 +++++++++++++++++-- 4 files changed, 166 insertions(+), 26 deletions(-) create mode 100644 .server-changes/additional-api-key-rate-limit-bucket.md diff --git a/.server-changes/additional-api-key-rate-limit-bucket.md b/.server-changes/additional-api-key-rate-limit-bucket.md new file mode 100644 index 00000000000..a5e172b61cb --- /dev/null +++ b/.server-changes/additional-api-key-rate-limit-bucket.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Additional environment API keys now share a single rate limit bucket per environment instead of each key getting its own, so minting more keys no longer multiplies an environment's effective API rate limit. Root and legacy keys are unaffected. The bucket is keyed on the stable environment id resolved from the already-authenticated request, so no extra lookup is added. diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index 1b8d8a3ed1f..9106d5572ce 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -1,4 +1,5 @@ import { tryCatch } from "@trigger.dev/core/v3"; +import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; import { env } from "~/env.server"; import { batchStreamGrants } from "~/runEngine/concerns/batchStreamGrantsInstance.server"; import { authenticateAuthorizationHeader } from "./apiAuth.server"; @@ -40,13 +41,32 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ if (authenticatedEnv.type === "PUBLIC_JWT") { return { - type: "fixedWindow", - window: env.API_RATE_LIMIT_JWT_WINDOW, - tokens: env.API_RATE_LIMIT_JWT_TOKENS, + config: { + type: "fixedWindow", + window: env.API_RATE_LIMIT_JWT_WINDOW, + tokens: env.API_RATE_LIMIT_JWT_TOKENS, + }, }; - } else { - return authenticatedEnv.environment.organization.apiRateLimiterConfig; } + + // Additional API keys (`tr_*_sk_*`) share their environment's rate limit + // bucket rather than each getting their own. Keying on the stable + // environment id (not the secret key, which can rotate) keeps a single + // bucket per environment no matter how many additional keys exist. + // Root/legacy keys fall through to the default per-key (hashed header) + // bucketing: they already map 1:1 to an environment. + // + // This reuses the already-authenticated environment above; the whole + // override result is cached per key by the middleware's SWR cache, so no + // separate lookup or Redis mapping is needed for the identifier. + const apiKey = authenticatedEnv.apiKey; + const identifier = + apiKey && isAdditionalApiKey(apiKey) ? authenticatedEnv.environment.id : undefined; + + return { + config: authenticatedEnv.environment.organization.apiRateLimiterConfig, + identifier, + }; }, pathMatchers: [/^\/api/], // Allow /api/v1/tasks/:id/callback/:secret diff --git a/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts b/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts index 5fcd6eb5450..66b2c6428b5 100644 --- a/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts +++ b/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts @@ -52,7 +52,24 @@ export const RateLimiterConfig = z.discriminatedUnion("type", [ export type RateLimiterConfig = z.infer; -type LimitConfigOverrideFunction = (authorizationValue: string) => Promise; +/** + * Result of an override lookup for a given Authorization header. + * + * - `config`: the rate limiter configuration to apply (bucket size). When + * absent, the default limiter is used. + * - `identifier`: the value to key the rate limit bucket on. When absent, the + * hashed Authorization header is used (the legacy per-key behavior). Supply a + * stable value (e.g. an environment id) so multiple credentials that should + * share a bucket collapse onto one. Never a secret: it lands in Redis keys. + */ +type RateLimitOverride = { + config?: unknown; + identifier?: string; +}; + +type LimitConfigOverrideFunction = ( + authorizationValue: string +) => Promise; type Options = { redis: RedisWithClusterOptions; @@ -80,16 +97,22 @@ type Options = { }; }; -async function resolveLimitConfig( +type ResolvedRateLimit = { + config: RateLimiterConfig; + // Bucket key to use, or undefined to fall back to the hashed Authorization header. + identifier?: string; +}; + +async function resolveRateLimit( authorizationValue: string, hashedAuthorizationValue: string, defaultLimiter: RateLimiterConfig, - cache: UnkeyCache<{ limiter: RateLimiterConfig }>, + cache: UnkeyCache<{ limiter: ResolvedRateLimit }>, logsEnabled: boolean, limiterConfigOverride?: LimitConfigOverrideFunction -): Promise { +): Promise { if (!limiterConfigOverride) { - return defaultLimiter; + return { config: defaultLimiter }; } if (logsEnabled) { @@ -110,10 +133,18 @@ async function resolveLimitConfig( }); } - return defaultLimiter; + return { config: defaultLimiter } satisfies ResolvedRateLimit; } - const parsedOverride = RateLimiterConfig.safeParse(override); + // The identifier (if any) is trusted through even when the config falls back + // to the default: bucketing and bucket size are independent concerns. + const identifier = override.identifier; + + if (!override.config) { + return { config: defaultLimiter, identifier } satisfies ResolvedRateLimit; + } + + const parsedOverride = RateLimiterConfig.safeParse(override.config); if (!parsedOverride.success) { logger.error("Error parsing rate limiter override", { @@ -121,7 +152,7 @@ async function resolveLimitConfig( errors: parsedOverride.error.errors, }); - return defaultLimiter; + return { config: defaultLimiter, identifier } satisfies ResolvedRateLimit; } if (logsEnabled && parsedOverride.data) { @@ -132,10 +163,10 @@ async function resolveLimitConfig( }); } - return parsedOverride.data; + return { config: parsedOverride.data, identifier } satisfies ResolvedRateLimit; }); - return cacheResult.val ?? defaultLimiter; + return cacheResult.val ?? { config: defaultLimiter }; } /** @@ -176,7 +207,7 @@ export function authorizationRateLimitMiddleware({ // This cache holds the rate limit configuration for each org, so we don't have to fetch it every request const cache = createCache({ - limiter: new Namespace(ctx, { + limiter: new Namespace(ctx, { stores: [memory, redisCacheStore], fresh: limiterCache?.fresh ?? 30_000, stale: limiterCache?.stale ?? 60_000, @@ -269,7 +300,7 @@ export function authorizationRateLimitMiddleware({ hash.update(authorizationValue); const hashedAuthorizationValue = hash.digest("hex"); - const limiterConfig = await resolveLimitConfig( + const { config: limiterConfig, identifier } = await resolveRateLimit( authorizationValue, hashedAuthorizationValue, defaultLimiter, @@ -278,6 +309,11 @@ export function authorizationRateLimitMiddleware({ limiterConfigOverride ); + // Bucket key: an override-supplied identifier (e.g. environment id, so all + // additional API keys for an environment share one bucket) or, by default, + // the hashed Authorization header (legacy per-key behavior). + const rateLimitIdentifier = identifier ?? hashedAuthorizationValue; + const limiter = createLimiterFromConfig(limiterConfig); const rateLimiter = new RateLimiter({ @@ -288,7 +324,7 @@ export function authorizationRateLimitMiddleware({ logFailure: log.rejections, }); - const { success, limit, reset, remaining } = await rateLimiter.limit(hashedAuthorizationValue); + const { success, limit, reset, remaining } = await rateLimiter.limit(rateLimitIdentifier); const $remaining = Math.max(0, remaining); // remaining can be negative if the user has exceeded the limit, so clamp it to 0 diff --git a/apps/webapp/test/authorizationRateLimitMiddleware.test.ts b/apps/webapp/test/authorizationRateLimitMiddleware.test.ts index b6076cef0de..8cff40a2b72 100644 --- a/apps/webapp/test/authorizationRateLimitMiddleware.test.ts +++ b/apps/webapp/test/authorizationRateLimitMiddleware.test.ts @@ -150,10 +150,12 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("authorizationRateLimitMiddleware", limiterConfigOverride: async (authorizationValue) => { if (authorizationValue === "Bearer premium-token") { return { - type: "tokenBucket", - refillRate: 10, - interval: "1m", - maxTokens: 100, + config: { + type: "tokenBucket", + refillRate: 10, + interval: "1m", + maxTokens: 100, + }, }; } return undefined; @@ -184,6 +186,80 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("authorizationRateLimitMiddleware", } ); + redisTest( + "should share a bucket across tokens that resolve to the same identifier", + async ({ redisOptions }) => { + const rateLimitMiddleware = authorizationRateLimitMiddleware({ + redis: { ...redisOptions, tlsDisabled: true }, + keyPrefix: "test-identifier", + defaultLimiter: { + type: "tokenBucket", + refillRate: 1, + interval: "1m", + maxTokens: 1, + }, + pathMatchers: [/^\/api/], + // Both tokens map to the same environment identifier, so they should + // consume from a single shared bucket rather than one bucket each. + limiterConfigOverride: async () => ({ identifier: "env_shared" }), + }); + + app.use(rateLimitMiddleware); + app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" })); + + // First token uses the single token in the shared bucket. + const first = await request(app) + .get("/api/test") + .set("Authorization", "Bearer tr_prod_sk_aaaaaaaaaaaaaaaaaaaaaaaa"); + expect(first.status).toBe(200); + + // A different token that resolves to the same identifier is limited, + // because the bucket is shared rather than per-key. + const second = await request(app) + .get("/api/test") + .set("Authorization", "Bearer tr_prod_sk_bbbbbbbbbbbbbbbbbbbbbbbb"); + expect(second.status).toBe(429); + } + ); + + redisTest( + "should key per token when no identifier is supplied", + async ({ redisOptions }) => { + const rateLimitMiddleware = authorizationRateLimitMiddleware({ + redis: { ...redisOptions, tlsDisabled: true }, + keyPrefix: "test-no-identifier", + defaultLimiter: { + type: "tokenBucket", + refillRate: 1, + interval: "1m", + maxTokens: 1, + }, + pathMatchers: [/^\/api/], + // Override supplies a config but no identifier: bucketing stays per-key + // (hashed Authorization header), the legacy behavior. + limiterConfigOverride: async () => ({ + config: { type: "tokenBucket", refillRate: 1, interval: "1m", maxTokens: 1 }, + }), + }); + + app.use(rateLimitMiddleware); + app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" })); + + const first = await request(app).get("/api/test").set("Authorization", "Bearer token-a"); + expect(first.status).toBe(200); + + // Same token is limited... + const firstAgain = await request(app) + .get("/api/test") + .set("Authorization", "Bearer token-a"); + expect(firstAgain.status).toBe(429); + + // ...but a different token gets its own bucket. + const second = await request(app).get("/api/test").set("Authorization", "Bearer token-b"); + expect(second.status).toBe(200); + } + ); + describe("Advanced Cases", () => { // 1. Test different rate limit configurations redisTest("should enforce fixed window rate limiting", async ({ redisOptions }) => { @@ -375,10 +451,12 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("authorizationRateLimitMiddleware", configOverrideCalls++; if (authorizationValue === "Bearer premium-token") { return { - type: "tokenBucket", - refillRate: 10, - interval: "1m", - maxTokens: 100, + config: { + type: "tokenBucket", + refillRate: 10, + interval: "1m", + maxTokens: 100, + }, }; } return undefined; From e9f47283d7a291644877f702eacc2cdc581b48ff Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 5 Aug 2026 10:19:59 +0100 Subject: [PATCH 02/10] remove comments --- apps/webapp/app/services/apiRateLimit.server.ts | 10 +--------- .../authorizationRateLimitMiddleware.server.ts | 15 --------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index 9106d5572ce..5f478afa5ad 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -50,15 +50,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ } // Additional API keys (`tr_*_sk_*`) share their environment's rate limit - // bucket rather than each getting their own. Keying on the stable - // environment id (not the secret key, which can rotate) keeps a single - // bucket per environment no matter how many additional keys exist. - // Root/legacy keys fall through to the default per-key (hashed header) - // bucketing: they already map 1:1 to an environment. - // - // This reuses the already-authenticated environment above; the whole - // override result is cached per key by the middleware's SWR cache, so no - // separate lookup or Redis mapping is needed for the identifier. + // bucket rather than each getting their own. const apiKey = authenticatedEnv.apiKey; const identifier = apiKey && isAdditionalApiKey(apiKey) ? authenticatedEnv.environment.id : undefined; diff --git a/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts b/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts index 66b2c6428b5..e5b57087820 100644 --- a/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts +++ b/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts @@ -52,16 +52,6 @@ export const RateLimiterConfig = z.discriminatedUnion("type", [ export type RateLimiterConfig = z.infer; -/** - * Result of an override lookup for a given Authorization header. - * - * - `config`: the rate limiter configuration to apply (bucket size). When - * absent, the default limiter is used. - * - `identifier`: the value to key the rate limit bucket on. When absent, the - * hashed Authorization header is used (the legacy per-key behavior). Supply a - * stable value (e.g. an environment id) so multiple credentials that should - * share a bucket collapse onto one. Never a secret: it lands in Redis keys. - */ type RateLimitOverride = { config?: unknown; identifier?: string; @@ -136,8 +126,6 @@ async function resolveRateLimit( return { config: defaultLimiter } satisfies ResolvedRateLimit; } - // The identifier (if any) is trusted through even when the config falls back - // to the default: bucketing and bucket size are independent concerns. const identifier = override.identifier; if (!override.config) { @@ -309,9 +297,6 @@ export function authorizationRateLimitMiddleware({ limiterConfigOverride ); - // Bucket key: an override-supplied identifier (e.g. environment id, so all - // additional API keys for an environment share one bucket) or, by default, - // the hashed Authorization header (legacy per-key behavior). const rateLimitIdentifier = identifier ?? hashedAuthorizationValue; const limiter = createLimiterFromConfig(limiterConfig); From a2651e54fffd2412aa06bd749f4884dc799b4bb2 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 5 Aug 2026 10:32:18 +0100 Subject: [PATCH 03/10] fix(webapp): bucket restricted additional API keys by environment too --- .../additional-api-key-rate-limit-bucket.md | 2 +- .../app/models/runtimeEnvironment.server.ts | 54 ++++++++++++++++ .../app/services/apiRateLimit.server.ts | 38 +++++++++--- .../authorizationRateLimitMiddleware.test.ts | 61 +++++++++---------- 4 files changed, 114 insertions(+), 41 deletions(-) diff --git a/.server-changes/additional-api-key-rate-limit-bucket.md b/.server-changes/additional-api-key-rate-limit-bucket.md index a5e172b61cb..cce6eb686c2 100644 --- a/.server-changes/additional-api-key-rate-limit-bucket.md +++ b/.server-changes/additional-api-key-rate-limit-bucket.md @@ -3,4 +3,4 @@ area: webapp type: fix --- -Additional environment API keys now share a single rate limit bucket per environment instead of each key getting its own, so minting more keys no longer multiplies an environment's effective API rate limit. Root and legacy keys are unaffected. The bucket is keyed on the stable environment id resolved from the already-authenticated request, so no extra lookup is added. +Additional environment API keys now share a single rate limit bucket per environment instead of each key getting its own, so minting more keys — including scope-restricted keys — no longer multiplies an environment's effective API rate limit. Root and legacy keys are unaffected. The bucket is keyed on the stable environment id (resolved scope-agnostically for bucketing only, never as an auth decision), and the result is cached per key so no extra per-request lookup is added. diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 84d2979bb10..61ce8fb2c5b 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -301,6 +301,60 @@ export async function findEnvironmentByApiKeyWithResolution( return resolveEnvironmentByApiKey(apiKey, branchName, tx, additionalApiKeyLookupEnabled); } +export type AdditionalApiKeyRateLimitScope = { + environmentId: string; + // Organization rate limiter override (bucket size), if configured. + apiRateLimiterConfig: unknown; +}; + +/** + * Resolve ONLY the environment id (and its organization's rate limiter config) + * for an additional API key, for RATE-LIMIT BUCKETING. + * + * Deliberately scope-agnostic: unlike `findEnvironmentByApiKey`, a + * scope-restricted additional key still resolves here, so every additional key + * for an environment shares that environment's rate limit bucket. This is NOT + * an authentication or authorization decision and must never be used as one — + * request auth still goes through the RBAC bearer controller, which enforces + * scopes. Revoked and expired keys are excluded so they cannot keep a bucket + * warm. + */ +export async function resolveAdditionalApiKeyRateLimitScope( + apiKey: string, + tx: PrismaClientOrTransaction = $replica +): Promise { + if (!isAdditionalApiKey(apiKey)) { + return null; + } + + const now = new Date(); + + const match = await tx.apiKey.findFirst({ + where: { + keyHash: hashApiKey(apiKey), + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + select: { + runtimeEnvironment: { + select: { + id: true, + organization: { select: { apiRateLimiterConfig: true } }, + }, + }, + }, + }); + + if (!match?.runtimeEnvironment) { + return null; + } + + return { + environmentId: match.runtimeEnvironment.id, + apiRateLimiterConfig: match.runtimeEnvironment.organization.apiRateLimiterConfig, + }; +} + /** * @deprecated We don't use public API keys (`pk_*` tokens) anymore — public * access goes through public JWTs (see `isPublicJWT` / `validatePublicJwtKey`). diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index 5f478afa5ad..95f40bd596f 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -1,6 +1,7 @@ import { tryCatch } from "@trigger.dev/core/v3"; import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; import { env } from "~/env.server"; +import { resolveAdditionalApiKeyRateLimitScope } from "~/models/runtimeEnvironment.server"; import { batchStreamGrants } from "~/runEngine/concerns/batchStreamGrantsInstance.server"; import { authenticateAuthorizationHeader } from "./apiAuth.server"; import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server"; @@ -30,6 +31,34 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ maxItems: 1000, }, limiterConfigOverride: async (authorizationValue) => { + const rawApiKey = authorizationValue.replace(/^Bearer /, ""); + + // Additional API keys (`tr_*_sk_*`) share their environment's rate limit + // bucket rather than each getting their own. Keying on the stable + // environment id (not the secret key, which can rotate) keeps a single + // bucket per environment no matter how many additional keys exist. + // + // Resolve scope-agnostically for bucketing: a restricted additional key + // authenticates at the route level via the RBAC controller (and fails + // closed in the legacy header auth below), but for rate limiting it must + // still land on its environment's shared bucket — otherwise minting many + // restricted keys would multiply the effective limit. This is NOT an auth + // decision. The whole override result is cached per key by the + // middleware's SWR cache, so no separate lookup or Redis mapping is needed. + if (isAdditionalApiKey(rawApiKey)) { + const scope = await resolveAdditionalApiKeyRateLimitScope(rawApiKey); + + // Unknown/revoked/expired key: fall back to the default per-key bucket. + if (!scope) { + return; + } + + return { + config: scope.apiRateLimiterConfig, + identifier: scope.environmentId, + }; + } + const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, { allowPublicKey: true, allowJWT: true, @@ -49,15 +78,10 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ }; } - // Additional API keys (`tr_*_sk_*`) share their environment's rate limit - // bucket rather than each getting their own. - const apiKey = authenticatedEnv.apiKey; - const identifier = - apiKey && isAdditionalApiKey(apiKey) ? authenticatedEnv.environment.id : undefined; - + // Root/legacy keys keep per-key (hashed header) bucketing: they already map + // 1:1 to an environment. return { config: authenticatedEnv.environment.organization.apiRateLimiterConfig, - identifier, }; }, pathMatchers: [/^\/api/], diff --git a/apps/webapp/test/authorizationRateLimitMiddleware.test.ts b/apps/webapp/test/authorizationRateLimitMiddleware.test.ts index 8cff40a2b72..29318c319e1 100644 --- a/apps/webapp/test/authorizationRateLimitMiddleware.test.ts +++ b/apps/webapp/test/authorizationRateLimitMiddleware.test.ts @@ -222,43 +222,38 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("authorizationRateLimitMiddleware", } ); - redisTest( - "should key per token when no identifier is supplied", - async ({ redisOptions }) => { - const rateLimitMiddleware = authorizationRateLimitMiddleware({ - redis: { ...redisOptions, tlsDisabled: true }, - keyPrefix: "test-no-identifier", - defaultLimiter: { - type: "tokenBucket", - refillRate: 1, - interval: "1m", - maxTokens: 1, - }, - pathMatchers: [/^\/api/], - // Override supplies a config but no identifier: bucketing stays per-key - // (hashed Authorization header), the legacy behavior. - limiterConfigOverride: async () => ({ - config: { type: "tokenBucket", refillRate: 1, interval: "1m", maxTokens: 1 }, - }), - }); + redisTest("should key per token when no identifier is supplied", async ({ redisOptions }) => { + const rateLimitMiddleware = authorizationRateLimitMiddleware({ + redis: { ...redisOptions, tlsDisabled: true }, + keyPrefix: "test-no-identifier", + defaultLimiter: { + type: "tokenBucket", + refillRate: 1, + interval: "1m", + maxTokens: 1, + }, + pathMatchers: [/^\/api/], + // Override supplies a config but no identifier: bucketing stays per-key + // (hashed Authorization header), the legacy behavior. + limiterConfigOverride: async () => ({ + config: { type: "tokenBucket", refillRate: 1, interval: "1m", maxTokens: 1 }, + }), + }); - app.use(rateLimitMiddleware); - app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" })); + app.use(rateLimitMiddleware); + app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" })); - const first = await request(app).get("/api/test").set("Authorization", "Bearer token-a"); - expect(first.status).toBe(200); + const first = await request(app).get("/api/test").set("Authorization", "Bearer token-a"); + expect(first.status).toBe(200); - // Same token is limited... - const firstAgain = await request(app) - .get("/api/test") - .set("Authorization", "Bearer token-a"); - expect(firstAgain.status).toBe(429); + // Same token is limited... + const firstAgain = await request(app).get("/api/test").set("Authorization", "Bearer token-a"); + expect(firstAgain.status).toBe(429); - // ...but a different token gets its own bucket. - const second = await request(app).get("/api/test").set("Authorization", "Bearer token-b"); - expect(second.status).toBe(200); - } - ); + // ...but a different token gets its own bucket. + const second = await request(app).get("/api/test").set("Authorization", "Bearer token-b"); + expect(second.status).toBe(200); + }); describe("Advanced Cases", () => { // 1. Test different rate limit configurations From 0eae5e18d12c11f08ecc24068015df21276b4b08 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 5 Aug 2026 10:34:08 +0100 Subject: [PATCH 04/10] feat(webapp): bucket root API keys per environment too --- .server-changes/additional-api-key-rate-limit-bucket.md | 2 +- apps/webapp/app/services/apiRateLimit.server.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.server-changes/additional-api-key-rate-limit-bucket.md b/.server-changes/additional-api-key-rate-limit-bucket.md index cce6eb686c2..3c3697308e1 100644 --- a/.server-changes/additional-api-key-rate-limit-bucket.md +++ b/.server-changes/additional-api-key-rate-limit-bucket.md @@ -3,4 +3,4 @@ area: webapp type: fix --- -Additional environment API keys now share a single rate limit bucket per environment instead of each key getting its own, so minting more keys — including scope-restricted keys — no longer multiplies an environment's effective API rate limit. Root and legacy keys are unaffected. The bucket is keyed on the stable environment id (resolved scope-agnostically for bucketing only, never as an auth decision), and the result is cached per key so no extra per-request lookup is added. +API rate limiting is now bucketed per environment instead of per API key. Previously each key (root or additional) got its own bucket, so minting more keys multiplied an environment's effective rate limit. Now all of an environment's keys — root and additional, including scope-restricted keys — share one bucket, so the ceiling is exactly the configured limit. The bucket is keyed on the stable environment id (resolved scope-agnostically for bucketing only, never as an auth decision), and the result is cached per key so no extra per-request lookup is added. Public JWTs keep per-token bucketing. diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index 95f40bd596f..af850ad637b 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -78,10 +78,14 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ }; } - // Root/legacy keys keep per-key (hashed header) bucketing: they already map - // 1:1 to an environment. + // Root/legacy keys also bucket per environment, so an environment's ceiling + // is exactly its configured limit regardless of key mix (root + additional + // keys share one bucket). The environment is already resolved above, so this + // adds no lookup. JWTs intentionally stay on per-token bucketing (handled + // above). return { config: authenticatedEnv.environment.organization.apiRateLimiterConfig, + identifier: authenticatedEnv.environment.id, }; }, pathMatchers: [/^\/api/], From 8c6e59be1458514b8a8626c2001b397293c9d9fe Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 5 Aug 2026 10:37:22 +0100 Subject: [PATCH 05/10] fix(webapp): version rate-limit cache key and validate cached shape on read --- ...authorizationRateLimitMiddleware.server.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts b/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts index e5b57087820..ce0b8b50d21 100644 --- a/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts +++ b/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts @@ -154,7 +154,19 @@ async function resolveRateLimit( return { config: parsedOverride.data, identifier } satisfies ResolvedRateLimit; }); - return cacheResult.val ?? { config: defaultLimiter }; + // Defensive read: the cache is keyed on a shared Redis namespace, so during a + // deploy an entry could have been written by a server running a different + // code version (a different stored shape). Re-validate here so a stale/foreign + // entry can never reach createLimiterFromConfig with an undefined config and + // throw. The cache key is also versioned (see RedisCacheStore keyPrefix), so + // this is belt-and-suspenders. + const cached = cacheResult.val; + const parsedConfig = RateLimiterConfig.safeParse(cached?.config); + + return { + config: parsedConfig.success ? parsedConfig.data : defaultLimiter, + identifier: typeof cached?.identifier === "string" ? cached.identifier : undefined, + }; } /** @@ -188,7 +200,10 @@ export function authorizationRateLimitMiddleware({ const memory = createLRUMemoryStore(limiterCache?.maxItems ?? 1000); const redisCacheStore = new RedisCacheStore({ connection: { - keyPrefix: `cache:${keyPrefix}:rate-limit-cache:`, + // Versioned namespace: the cached value shape is part of this key. Bump + // the version whenever ResolvedRateLimit changes so a rolling deploy never + // reads entries written in a previous shape (and vice versa). + keyPrefix: `cache:${keyPrefix}:rate-limit-cache:v2:`, ...redis, }, }); From 191b9b7a8a19203ae51fe11e9b14283039973479 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 5 Aug 2026 11:21:03 +0100 Subject: [PATCH 06/10] use new resolver for api keys --- .../app/models/runtimeEnvironment.server.ts | 84 ++++++++++++------- .../app/services/apiRateLimit.server.ts | 35 +++----- .../test/findEnvironmentByApiKey.test.ts | 35 +++++++- 3 files changed, 102 insertions(+), 52 deletions(-) diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 61ce8fb2c5b..fc24f220c6d 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -301,57 +301,85 @@ export async function findEnvironmentByApiKeyWithResolution( return resolveEnvironmentByApiKey(apiKey, branchName, tx, additionalApiKeyLookupEnabled); } -export type AdditionalApiKeyRateLimitScope = { +export type PrivateApiKeyRateLimitScope = { environmentId: string; - // Organization rate limiter override (bucket size), if configured. apiRateLimiterConfig: unknown; }; -/** - * Resolve ONLY the environment id (and its organization's rate limiter config) - * for an additional API key, for RATE-LIMIT BUCKETING. - * - * Deliberately scope-agnostic: unlike `findEnvironmentByApiKey`, a - * scope-restricted additional key still resolves here, so every additional key - * for an environment shares that environment's rate limit bucket. This is NOT - * an authentication or authorization decision and must never be used as one — - * request auth still goes through the RBAC bearer controller, which enforces - * scopes. Revoked and expired keys are excluded so they cannot keep a bucket - * warm. - */ -export async function resolveAdditionalApiKeyRateLimitScope( +export async function resolvePrivateApiKeyRateLimitScope( apiKey: string, tx: PrismaClientOrTransaction = $replica -): Promise { - if (!isAdditionalApiKey(apiKey)) { - return null; - } - +): Promise { const now = new Date(); - const match = await tx.apiKey.findFirst({ - where: { - keyHash: hashApiKey(apiKey), - revokedAt: null, - OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + if (isAdditionalApiKey(apiKey)) { + const match = await tx.apiKey.findFirst({ + where: { + keyHash: hashApiKey(apiKey), + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + select: { + runtimeEnvironment: { + select: { + id: true, + organization: { select: { apiRateLimiterConfig: true } }, + }, + }, + }, + }); + + if (!match?.runtimeEnvironment) { + return null; + } + + return { + environmentId: match.runtimeEnvironment.id, + apiRateLimiterConfig: match.runtimeEnvironment.organization.apiRateLimiterConfig, + }; + } + + const environment = await tx.runtimeEnvironment.findFirst({ + where: { apiKey }, + select: { + id: true, + project: { select: { deletedAt: true } }, + organization: { select: { apiRateLimiterConfig: true } }, }, + }); + + if (environment) { + if (environment.project.deletedAt) { + return null; + } + + return { + environmentId: environment.id, + apiRateLimiterConfig: environment.organization.apiRateLimiterConfig, + }; + } + + const revokedApiKey = await tx.revokedApiKey.findFirst({ + where: { apiKey, expiresAt: { gt: now } }, select: { runtimeEnvironment: { select: { id: true, + project: { select: { deletedAt: true } }, organization: { select: { apiRateLimiterConfig: true } }, }, }, }, }); - if (!match?.runtimeEnvironment) { + const revokedEnvironment = revokedApiKey?.runtimeEnvironment; + if (!revokedEnvironment || revokedEnvironment.project.deletedAt) { return null; } return { - environmentId: match.runtimeEnvironment.id, - apiRateLimiterConfig: match.runtimeEnvironment.organization.apiRateLimiterConfig, + environmentId: revokedEnvironment.id, + apiRateLimiterConfig: revokedEnvironment.organization.apiRateLimiterConfig, }; } diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index af850ad637b..9126f6e9183 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -1,13 +1,14 @@ import { tryCatch } from "@trigger.dev/core/v3"; -import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; +import { trail } from "agentcrumbs"; // @crumbs import { env } from "~/env.server"; -import { resolveAdditionalApiKeyRateLimitScope } from "~/models/runtimeEnvironment.server"; +import { resolvePrivateApiKeyRateLimitScope } from "~/models/runtimeEnvironment.server"; import { batchStreamGrants } from "~/runEngine/concerns/batchStreamGrantsInstance.server"; import { authenticateAuthorizationHeader } from "./apiAuth.server"; import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server"; import type { Duration } from "./rateLimiter.server"; const BATCH_STREAM_ITEMS_PATH = /^\/api\/v3\/batches\/([^/]+)\/items$/; +const crumb = trail("webapp"); // @crumbs export const apiRateLimiter = authorizationRateLimitMiddleware({ redis: { @@ -33,26 +34,19 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ limiterConfigOverride: async (authorizationValue) => { const rawApiKey = authorizationValue.replace(/^Bearer /, ""); - // Additional API keys (`tr_*_sk_*`) share their environment's rate limit - // bucket rather than each getting their own. Keying on the stable - // environment id (not the secret key, which can rotate) keeps a single - // bucket per environment no matter how many additional keys exist. - // - // Resolve scope-agnostically for bucketing: a restricted additional key - // authenticates at the route level via the RBAC controller (and fails - // closed in the legacy header auth below), but for rate limiting it must - // still land on its environment's shared bucket — otherwise minting many - // restricted keys would multiply the effective limit. This is NOT an auth - // decision. The whole override result is cached per key by the - // middleware's SWR cache, so no separate lookup or Redis mapping is needed. - if (isAdditionalApiKey(rawApiKey)) { - const scope = await resolveAdditionalApiKeyRateLimitScope(rawApiKey); - - // Unknown/revoked/expired key: fall back to the default per-key bucket. + if (rawApiKey.startsWith("tr_")) { + const scope = await resolvePrivateApiKeyRateLimitScope(rawApiKey); + if (!scope) { return; } + // #region @crumbs + crumb("resolved private API key rate limit scope", { + environmentId: scope.environmentId, + }); + // #endregion @crumbs + return { config: scope.apiRateLimiterConfig, identifier: scope.environmentId, @@ -78,11 +72,6 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ }; } - // Root/legacy keys also bucket per environment, so an environment's ceiling - // is exactly its configured limit regardless of key mix (root + additional - // keys share one bucket). The environment is already resolved above, so this - // adds no lookup. JWTs intentionally stay on per-token bucketing (handled - // above). return { config: authenticatedEnv.environment.organization.apiRateLimiterConfig, identifier: authenticatedEnv.environment.id, diff --git a/apps/webapp/test/findEnvironmentByApiKey.test.ts b/apps/webapp/test/findEnvironmentByApiKey.test.ts index c301a02b201..a4f6dac50e3 100644 --- a/apps/webapp/test/findEnvironmentByApiKey.test.ts +++ b/apps/webapp/test/findEnvironmentByApiKey.test.ts @@ -1,7 +1,10 @@ import { postgresTest } from "@internal/testcontainers"; import { type PrismaClient } from "@trigger.dev/database"; import { describe, expect, it, vi } from "vitest"; -import { findEnvironmentByApiKey } from "~/models/runtimeEnvironment.server"; +import { + findEnvironmentByApiKey, + resolvePrivateApiKeyRateLimitScope, +} from "~/models/runtimeEnvironment.server"; import { generateAdditionalApiKey, hashApiKey } from "~/utils/apiKeys"; import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures"; @@ -143,6 +146,36 @@ describe("findEnvironmentByApiKey — PREVIEW (regression guard)", () => { expect(resolved?.apiKey).toBe(previewParent.apiKey); } ); + + postgresTest( + "rate limit scope resolves root and additional keys to the preview parent", + async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const previewParent = await createEnv(prisma, project.id, organization.id, { + type: "PREVIEW", + isBranchableEnvironment: true, + }); + const additional = generateAdditionalApiKey("PREVIEW").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Preview integration", + keyHash: hashApiKey(additional), + lastFour: additional.slice(-4), + runtimeEnvironmentId: previewParent.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + }, + }); + + const rootScope = await resolvePrivateApiKeyRateLimitScope(previewParent.apiKey, prisma); + const additionalScope = await resolvePrivateApiKeyRateLimitScope(additional, prisma); + + expect(rootScope?.environmentId).toBe(previewParent.id); + expect(additionalScope?.environmentId).toBe(previewParent.id); + } + ); }); describe("findEnvironmentByApiKey — non-branchable", () => { From a4971d25d3be30b14cf41511d533f1a3deefb177 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 5 Aug 2026 11:57:20 +0100 Subject: [PATCH 07/10] fix(webapp): keep environment rate-limit buckets consistent Use environment identifiers when displaying remaining API capacity and ignore additional keys tied to deleted projects. --- .../app/models/runtimeEnvironment.server.ts | 3 ++- .../presenters/v3/LimitsPresenter.server.ts | 21 +++++---------- .../route.tsx | 1 - .../test/findEnvironmentByApiKey.test.ts | 26 +++++++++++++++++++ 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index fc24f220c6d..8dc5a68b63f 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -323,13 +323,14 @@ export async function resolvePrivateApiKeyRateLimitScope( runtimeEnvironment: { select: { id: true, + project: { select: { deletedAt: true } }, organization: { select: { apiRateLimiterConfig: true } }, }, }, }, }); - if (!match?.runtimeEnvironment) { + if (!match?.runtimeEnvironment || match.runtimeEnvironment.project.deletedAt) { return null; } diff --git a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts index e468efd9217..46b47398b2b 100644 --- a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts @@ -1,6 +1,5 @@ import { Ratelimit } from "@upstash/ratelimit"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; -import { createHash } from "node:crypto"; import { env } from "~/env.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; import { @@ -90,13 +89,11 @@ export class LimitsPresenter extends BasePresenter { projectId, environmentId, environmentType, - environmentApiKey, }: { organizationId: string; projectId: string; environmentId: string; environmentType: RuntimeEnvironmentType; - environmentApiKey: string; }): Promise { // Get organization with all limit-related fields const organization = await this._replica.organization.findFirstOrThrow({ @@ -168,10 +165,10 @@ export class LimitsPresenter extends BasePresenter { where: { organizationId }, }); - // Get current rate limit tokens for this environment's API key + // Get current rate limit tokens for this environment's API bucket const apiRateLimitTokens = await getRateLimitRemainingTokens( "api", - environmentApiKey, + environmentId, apiRateLimitConfig ); // Batch rate limiter uses environment ID directly (not hashed) with a different key prefix @@ -454,20 +451,14 @@ function resolveBatchConcurrencyConfig(batchConcurrencyConfig?: unknown): { /** * Query the current remaining tokens for a rate limiter using the Upstash getRemaining method. - * This uses the same configuration and hashing logic as the rate limit middleware. + * The API limiter uses the environment ID as the bucket identifier for private API keys. */ async function getRateLimitRemainingTokens( keyPrefix: string, - apiKey: string, + identifier: string, config: RateLimiterConfig ): Promise { try { - // Hash the authorization header the same way the rate limiter does - const authorizationValue = `Bearer ${apiKey}`; - const hash = createHash("sha256"); - hash.update(authorizationValue); - const hashedKey = hash.digest("hex"); - // Create a Ratelimit instance with the same configuration const limiter = createLimiterFromConfig(config); const ratelimit = new Ratelimit({ @@ -478,9 +469,9 @@ async function getRateLimitRemainingTokens( prefix: `ratelimit:${keyPrefix}`, }); - // Use the getRemaining method to get the current remaining tokens + // Use the same identifier as the API rate-limit middleware. // getRemaining returns a Promise - const remaining = await ratelimit.getRemaining(hashedKey); + const remaining = await ratelimit.getRemaining(identifier); return remaining; } catch (error) { logger.warn("Failed to get rate limit remaining tokens", { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 40eb8356f73..842b5840ccf 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -84,7 +84,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { projectId: project.id, environmentId: environment.id, environmentType: environment.type, - environmentApiKey: environment.apiKey, }) ); diff --git a/apps/webapp/test/findEnvironmentByApiKey.test.ts b/apps/webapp/test/findEnvironmentByApiKey.test.ts index a4f6dac50e3..10425ecbfe7 100644 --- a/apps/webapp/test/findEnvironmentByApiKey.test.ts +++ b/apps/webapp/test/findEnvironmentByApiKey.test.ts @@ -399,4 +399,30 @@ describe("findEnvironmentByApiKey — additional and disabled keys", () => { ).resolves.toMatchObject({ id: environment.id }); } ); + + postgresTest("does not resolve additional keys for deleted projects", async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + }); + const additional = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Deleted project key", + keyHash: hashApiKey(additional), + lastFour: additional.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + }, + }); + await prisma.project.update({ + where: { id: project.id }, + data: { deletedAt: new Date() }, + }); + + await expect(resolvePrivateApiKeyRateLimitScope(additional, prisma)).resolves.toBeNull(); + }); }); From e5f3de087699fbb7cffa278b599cc0858e8a8048 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 5 Aug 2026 12:10:59 +0100 Subject: [PATCH 08/10] Update .server-changes/additional-api-key-rate-limit-bucket.md Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .server-changes/additional-api-key-rate-limit-bucket.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.server-changes/additional-api-key-rate-limit-bucket.md b/.server-changes/additional-api-key-rate-limit-bucket.md index 3c3697308e1..d67a3b8e2aa 100644 --- a/.server-changes/additional-api-key-rate-limit-bucket.md +++ b/.server-changes/additional-api-key-rate-limit-bucket.md @@ -3,4 +3,4 @@ area: webapp type: fix --- -API rate limiting is now bucketed per environment instead of per API key. Previously each key (root or additional) got its own bucket, so minting more keys multiplied an environment's effective rate limit. Now all of an environment's keys — root and additional, including scope-restricted keys — share one bucket, so the ceiling is exactly the configured limit. The bucket is keyed on the stable environment id (resolved scope-agnostically for bucketing only, never as an auth decision), and the result is cached per key so no extra per-request lookup is added. Public JWTs keep per-token bucketing. +API rate limits now apply per environment, so creating extra API keys no longer increases how many requests an environment can make. From cd2a0c07dca9794c9772190f1fccad999724529a Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 5 Aug 2026 12:14:07 +0100 Subject: [PATCH 09/10] fix(webapp): keep public API keys on separate rate-limit buckets --- apps/webapp/app/services/apiRateLimit.server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index 9126f6e9183..aea5f3ef226 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -74,7 +74,8 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ return { config: authenticatedEnv.environment.organization.apiRateLimiterConfig, - identifier: authenticatedEnv.environment.id, + // Public keys are browser-distributed, so keep them on per-key buckets. + identifier: authenticatedEnv.type === "PRIVATE" ? authenticatedEnv.environment.id : undefined, }; }, pathMatchers: [/^\/api/], From eb0e8f7b692524759c5e3e753aa4bf3a7dfe6bc0 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 5 Aug 2026 18:16:21 +0100 Subject: [PATCH 10/10] Update apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../route.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 842b5840ccf..264091159e8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -82,7 +82,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { presenter.call({ organizationId: project.organizationId, projectId: project.id, - environmentId: environment.id, + // API traffic for a branch is bucketed on the parent environment id. + environmentId: environment.parentEnvironmentId ?? environment.id, environmentType: environment.type, }) );