@@ -52,7 +52,24 @@ export const RateLimiterConfig = z.discriminatedUnion("type", [
5252
5353export type RateLimiterConfig = z . infer < typeof RateLimiterConfig > ;
5454
55- type LimitConfigOverrideFunction = ( authorizationValue : string ) => Promise < unknown > ;
55+ /**
56+ * Result of an override lookup for a given Authorization header.
57+ *
58+ * - `config`: the rate limiter configuration to apply (bucket size). When
59+ * absent, the default limiter is used.
60+ * - `identifier`: the value to key the rate limit bucket on. When absent, the
61+ * hashed Authorization header is used (the legacy per-key behavior). Supply a
62+ * stable value (e.g. an environment id) so multiple credentials that should
63+ * share a bucket collapse onto one. Never a secret: it lands in Redis keys.
64+ */
65+ type RateLimitOverride = {
66+ config ?: unknown ;
67+ identifier ?: string ;
68+ } ;
69+
70+ type LimitConfigOverrideFunction = (
71+ authorizationValue : string
72+ ) => Promise < RateLimitOverride | undefined > ;
5673
5774type Options = {
5875 redis : RedisWithClusterOptions ;
@@ -80,16 +97,22 @@ type Options = {
8097 } ;
8198} ;
8299
83- async function resolveLimitConfig (
100+ type ResolvedRateLimit = {
101+ config : RateLimiterConfig ;
102+ // Bucket key to use, or undefined to fall back to the hashed Authorization header.
103+ identifier ?: string ;
104+ } ;
105+
106+ async function resolveRateLimit (
84107 authorizationValue : string ,
85108 hashedAuthorizationValue : string ,
86109 defaultLimiter : RateLimiterConfig ,
87- cache : UnkeyCache < { limiter : RateLimiterConfig } > ,
110+ cache : UnkeyCache < { limiter : ResolvedRateLimit } > ,
88111 logsEnabled : boolean ,
89112 limiterConfigOverride ?: LimitConfigOverrideFunction
90- ) : Promise < RateLimiterConfig > {
113+ ) : Promise < ResolvedRateLimit > {
91114 if ( ! limiterConfigOverride ) {
92- return defaultLimiter ;
115+ return { config : defaultLimiter } ;
93116 }
94117
95118 if ( logsEnabled ) {
@@ -110,18 +133,26 @@ async function resolveLimitConfig(
110133 } ) ;
111134 }
112135
113- return defaultLimiter ;
136+ return { config : defaultLimiter } satisfies ResolvedRateLimit ;
114137 }
115138
116- const parsedOverride = RateLimiterConfig . safeParse ( override ) ;
139+ // The identifier (if any) is trusted through even when the config falls back
140+ // to the default: bucketing and bucket size are independent concerns.
141+ const identifier = override . identifier ;
142+
143+ if ( ! override . config ) {
144+ return { config : defaultLimiter , identifier } satisfies ResolvedRateLimit ;
145+ }
146+
147+ const parsedOverride = RateLimiterConfig . safeParse ( override . config ) ;
117148
118149 if ( ! parsedOverride . success ) {
119150 logger . error ( "Error parsing rate limiter override" , {
120151 override,
121152 errors : parsedOverride . error . errors ,
122153 } ) ;
123154
124- return defaultLimiter ;
155+ return { config : defaultLimiter , identifier } satisfies ResolvedRateLimit ;
125156 }
126157
127158 if ( logsEnabled && parsedOverride . data ) {
@@ -132,10 +163,10 @@ async function resolveLimitConfig(
132163 } ) ;
133164 }
134165
135- return parsedOverride . data ;
166+ return { config : parsedOverride . data , identifier } satisfies ResolvedRateLimit ;
136167 } ) ;
137168
138- return cacheResult . val ?? defaultLimiter ;
169+ return cacheResult . val ?? { config : defaultLimiter } ;
139170}
140171
141172/**
@@ -176,7 +207,7 @@ export function authorizationRateLimitMiddleware({
176207
177208 // This cache holds the rate limit configuration for each org, so we don't have to fetch it every request
178209 const cache = createCache ( {
179- limiter : new Namespace < RateLimiterConfig > ( ctx , {
210+ limiter : new Namespace < ResolvedRateLimit > ( ctx , {
180211 stores : [ memory , redisCacheStore ] ,
181212 fresh : limiterCache ?. fresh ?? 30_000 ,
182213 stale : limiterCache ?. stale ?? 60_000 ,
@@ -269,7 +300,7 @@ export function authorizationRateLimitMiddleware({
269300 hash . update ( authorizationValue ) ;
270301 const hashedAuthorizationValue = hash . digest ( "hex" ) ;
271302
272- const limiterConfig = await resolveLimitConfig (
303+ const { config : limiterConfig , identifier } = await resolveRateLimit (
273304 authorizationValue ,
274305 hashedAuthorizationValue ,
275306 defaultLimiter ,
@@ -278,6 +309,11 @@ export function authorizationRateLimitMiddleware({
278309 limiterConfigOverride
279310 ) ;
280311
312+ // Bucket key: an override-supplied identifier (e.g. environment id, so all
313+ // additional API keys for an environment share one bucket) or, by default,
314+ // the hashed Authorization header (legacy per-key behavior).
315+ const rateLimitIdentifier = identifier ?? hashedAuthorizationValue ;
316+
281317 const limiter = createLimiterFromConfig ( limiterConfig ) ;
282318
283319 const rateLimiter = new RateLimiter ( {
@@ -288,7 +324,7 @@ export function authorizationRateLimitMiddleware({
288324 logFailure : log . rejections ,
289325 } ) ;
290326
291- const { success, limit, reset, remaining } = await rateLimiter . limit ( hashedAuthorizationValue ) ;
327+ const { success, limit, reset, remaining } = await rateLimiter . limit ( rateLimitIdentifier ) ;
292328
293329 const $remaining = Math . max ( 0 , remaining ) ; // remaining can be negative if the user has exceeded the limit, so clamp it to 0
294330
0 commit comments