0http-bun 1.2.2 → 1.3.0

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.
@@ -28,8 +28,8 @@ function createCORS(options = {}) {
28
28
  const allowedOrigin = getAllowedOrigin(origin, requestOrigin, req)
29
29
 
30
30
  const addCorsHeaders = (response) => {
31
- // Add Vary header for dynamic origins (regardless of whether origin is allowed)
32
- if (typeof origin === 'function' || Array.isArray(origin)) {
31
+ // Add Vary header for non-wildcard origins to prevent CDN cache poisoning
32
+ if (origin !== '*') {
33
33
  const existingVary = response.headers.get('Vary')
34
34
  if (existingVary) {
35
35
  if (!existingVary.includes('Origin')) {
@@ -42,32 +42,51 @@ function createCORS(options = {}) {
42
42
 
43
43
  if (allowedOrigin !== false) {
44
44
  response.headers.set('Access-Control-Allow-Origin', allowedOrigin)
45
- }
46
45
 
47
- // Don't allow wildcard origin with credentials
48
- if (credentials && allowedOrigin !== '*') {
49
- response.headers.set('Access-Control-Allow-Credentials', 'true')
50
- }
46
+ // Don't allow wildcard origin with credentials
47
+ if (credentials && allowedOrigin !== '*') {
48
+ response.headers.set('Access-Control-Allow-Credentials', 'true')
49
+ }
51
50
 
52
- // Handle exposedHeaders (can be string or array)
53
- const exposedHeadersList = Array.isArray(exposedHeaders)
54
- ? exposedHeaders
55
- : typeof exposedHeaders === 'string'
56
- ? [exposedHeaders]
57
- : []
58
- if (exposedHeadersList.length > 0) {
51
+ // Handle exposedHeaders (can be string or array)
52
+ const exposedHeadersList = Array.isArray(exposedHeaders)
53
+ ? exposedHeaders
54
+ : typeof exposedHeaders === 'string'
55
+ ? [exposedHeaders]
56
+ : []
57
+ if (exposedHeadersList.length > 0) {
58
+ response.headers.set(
59
+ 'Access-Control-Expose-Headers',
60
+ exposedHeadersList.join(', '),
61
+ )
62
+ }
63
+
64
+ // Add method and header info
65
+ response.headers.set(
66
+ 'Access-Control-Allow-Methods',
67
+ (Array.isArray(methods) ? methods : [methods]).join(', '),
68
+ )
69
+
70
+ const resolvedAllowedHeaders =
71
+ typeof allowedHeaders === 'function'
72
+ ? allowedHeaders(req)
73
+ : allowedHeaders
74
+ const allowedHeadersList = Array.isArray(resolvedAllowedHeaders)
75
+ ? resolvedAllowedHeaders
76
+ : typeof resolvedAllowedHeaders === 'string'
77
+ ? [resolvedAllowedHeaders]
78
+ : []
59
79
  response.headers.set(
60
- 'Access-Control-Expose-Headers',
61
- exposedHeadersList.join(', '),
80
+ 'Access-Control-Allow-Headers',
81
+ allowedHeadersList.join(', '),
62
82
  )
63
83
  }
64
84
 
65
- // Add method and header info for all requests (not just OPTIONS)
66
- response.headers.set(
67
- 'Access-Control-Allow-Methods',
68
- (Array.isArray(methods) ? methods : [methods]).join(', '),
69
- )
85
+ return response
86
+ }
70
87
 
88
+ if (req.method === 'OPTIONS') {
89
+ // I-2: Resolve allowedHeaders once for the entire preflight handling
71
90
  const resolvedAllowedHeaders =
72
91
  typeof allowedHeaders === 'function'
73
92
  ? allowedHeaders(req)
@@ -77,15 +96,7 @@ function createCORS(options = {}) {
77
96
  : typeof resolvedAllowedHeaders === 'string'
78
97
  ? [resolvedAllowedHeaders]
79
98
  : []
80
- response.headers.set(
81
- 'Access-Control-Allow-Headers',
82
- allowedHeadersList.join(', '),
83
- )
84
99
 
85
- return response
86
- }
87
-
88
- if (req.method === 'OPTIONS') {
89
100
  // Handle preflight request
90
101
  const requestMethod = req.headers.get('access-control-request-method')
91
102
  const requestHeaders = req.headers.get('access-control-request-headers')
@@ -98,13 +109,6 @@ function createCORS(options = {}) {
98
109
  // Check if requested headers are allowed
99
110
  if (requestHeaders) {
100
111
  const requestedHeaders = requestHeaders.split(',').map((h) => h.trim())
101
- const resolvedAllowedHeaders =
102
- typeof allowedHeaders === 'function'
103
- ? allowedHeaders(req)
104
- : allowedHeaders
105
- const allowedHeadersList = Array.isArray(resolvedAllowedHeaders)
106
- ? resolvedAllowedHeaders
107
- : []
108
112
 
109
113
  const hasDisallowedHeaders = requestedHeaders.some(
110
114
  (header) =>
@@ -127,31 +131,24 @@ function createCORS(options = {}) {
127
131
  if (typeof origin === 'function' || Array.isArray(origin)) {
128
132
  response.headers.set('Vary', 'Origin')
129
133
  }
130
- }
131
134
 
132
- // Don't allow wildcard origin with credentials
133
- if (credentials && allowedOrigin !== '*') {
134
- response.headers.set('Access-Control-Allow-Credentials', 'true')
135
- }
135
+ // Don't allow wildcard origin with credentials
136
+ if (credentials && allowedOrigin !== '*') {
137
+ response.headers.set('Access-Control-Allow-Credentials', 'true')
138
+ }
136
139
 
137
- response.headers.set(
138
- 'Access-Control-Allow-Methods',
139
- (Array.isArray(methods) ? methods : [methods]).join(', '),
140
- )
140
+ response.headers.set(
141
+ 'Access-Control-Allow-Methods',
142
+ (Array.isArray(methods) ? methods : [methods]).join(', '),
143
+ )
141
144
 
142
- const resolvedAllowedHeaders =
143
- typeof allowedHeaders === 'function'
144
- ? allowedHeaders(req)
145
- : allowedHeaders
146
- const allowedHeadersList = Array.isArray(resolvedAllowedHeaders)
147
- ? resolvedAllowedHeaders
148
- : []
149
- response.headers.set(
150
- 'Access-Control-Allow-Headers',
151
- allowedHeadersList.join(', '),
152
- )
145
+ response.headers.set(
146
+ 'Access-Control-Allow-Headers',
147
+ allowedHeadersList.join(', '),
148
+ )
153
149
 
154
- response.headers.set('Access-Control-Max-Age', maxAge.toString())
150
+ response.headers.set('Access-Control-Max-Age', maxAge.toString())
151
+ }
155
152
 
156
153
  if (preflightContinue) {
157
154
  const result = next()
@@ -193,10 +190,13 @@ function getAllowedOrigin(origin, requestOrigin, req) {
193
190
  }
194
191
 
195
192
  if (Array.isArray(origin)) {
193
+ if (!requestOrigin || requestOrigin === 'null') return false
196
194
  return origin.includes(requestOrigin) ? requestOrigin : false
197
195
  }
198
196
 
199
197
  if (typeof origin === 'function') {
198
+ // Reject null/missing origins to prevent bypass via sandboxed iframes
199
+ if (!requestOrigin || requestOrigin === 'null') return false
200
200
  const result = origin(requestOrigin)
201
201
  return result === true ? requestOrigin : result || false
202
202
  }
@@ -65,6 +65,8 @@ export interface JWTAuthOptions {
65
65
  audience?: string | string[]
66
66
  issuer?: string
67
67
  algorithms?: string[]
68
+ // Token type validation
69
+ requiredTokenType?: string
68
70
  // Custom response and error handling
69
71
  unauthorizedResponse?:
70
72
  | Response
@@ -95,29 +97,14 @@ export interface TokenExtractionOptions {
95
97
  export function createJWTAuth(options?: JWTAuthOptions): RequestHandler
96
98
  export function createAPIKeyAuth(options: APIKeyAuthOptions): RequestHandler
97
99
  export function extractTokenFromHeader(req: ZeroRequest): string | null
98
- export function extractToken(
99
- req: ZeroRequest,
100
- options?: TokenExtractionOptions,
101
- ): string | null
102
- export function validateApiKeyInternal(
103
- apiKey: string,
104
- apiKeys: JWTAuthOptions['apiKeys'],
105
- apiKeyValidator: JWTAuthOptions['apiKeyValidator'],
106
- req: ZeroRequest,
107
- ): Promise<boolean | any>
108
- export function handleAuthError(
109
- error: Error,
110
- handlers: {
111
- unauthorizedResponse?: JWTAuthOptions['unauthorizedResponse']
112
- onError?: JWTAuthOptions['onError']
113
- },
114
- req: ZeroRequest,
115
- ): Response
100
+ export const API_KEY_SYMBOL: symbol
101
+ export function maskApiKey(key: string): string
116
102
 
117
103
  // Rate limiting middleware types
118
104
  export interface RateLimitOptions {
119
105
  windowMs?: number
120
106
  max?: number
107
+ message?: string
121
108
  keyGenerator?: (req: ZeroRequest) => Promise<string> | string
122
109
  handler?: (
123
110
  req: ZeroRequest,
@@ -126,7 +113,7 @@ export interface RateLimitOptions {
126
113
  resetTime: Date,
127
114
  ) => Promise<Response> | Response
128
115
  store?: RateLimitStore
129
- standardHeaders?: boolean
116
+ standardHeaders?: boolean | 'minimal'
130
117
  excludePaths?: string[]
131
118
  skip?: (req: ZeroRequest) => boolean
132
119
  }
@@ -169,7 +156,7 @@ export interface CORSOptions {
169
156
  | boolean
170
157
  | ((origin: string, req: ZeroRequest) => boolean | string)
171
158
  methods?: string[]
172
- allowedHeaders?: string[]
159
+ allowedHeaders?: string[] | ((req: ZeroRequest) => string[])
173
160
  exposedHeaders?: string[]
174
161
  credentials?: boolean
175
162
  maxAge?: number
@@ -187,25 +174,30 @@ export function getAllowedOrigin(
187
174
 
188
175
  // Body parser middleware types
189
176
  export interface JSONParserOptions {
190
- limit?: number
177
+ limit?: number | string
191
178
  reviver?: (key: string, value: any) => any
192
179
  strict?: boolean
193
180
  type?: string
181
+ deferNext?: boolean
194
182
  }
195
183
 
196
184
  export interface TextParserOptions {
197
- limit?: number
185
+ limit?: number | string
198
186
  type?: string
199
187
  defaultCharset?: string
188
+ deferNext?: boolean
200
189
  }
201
190
 
202
191
  export interface URLEncodedParserOptions {
203
- limit?: number
192
+ limit?: number | string
204
193
  extended?: boolean
194
+ parseNestedObjects?: boolean
195
+ deferNext?: boolean
205
196
  }
206
197
 
207
198
  export interface MultipartParserOptions {
208
- limit?: number
199
+ limit?: number | string
200
+ deferNext?: boolean
209
201
  }
210
202
 
211
203
  export interface BodyParserOptions {
@@ -213,6 +205,15 @@ export interface BodyParserOptions {
213
205
  text?: TextParserOptions
214
206
  urlencoded?: URLEncodedParserOptions
215
207
  multipart?: MultipartParserOptions
208
+ jsonTypes?: string[]
209
+ jsonParser?: (text: string) => any
210
+ onError?: (error: Error, req: ZeroRequest, next: () => any) => any
211
+ verify?: (req: ZeroRequest, rawBody: string) => void
212
+ parseNestedObjects?: boolean
213
+ jsonLimit?: number | string
214
+ textLimit?: number | string
215
+ urlencodedLimit?: number | string
216
+ multipartLimit?: number | string
216
217
  }
217
218
 
218
219
  export interface ParsedFile {
@@ -233,6 +234,8 @@ export function createMultipartParser(
233
234
  export function createBodyParser(options?: BodyParserOptions): RequestHandler
234
235
  export function hasBody(req: ZeroRequest): boolean
235
236
  export function shouldParse(req: ZeroRequest, type: string): boolean
237
+ export function parseLimit(limit: number | string): number
238
+ export const RAW_BODY_SYMBOL: symbol
236
239
 
237
240
  // Prometheus metrics middleware types
238
241
  export interface PrometheusMetrics {
@@ -23,6 +23,8 @@ module.exports = {
23
23
  createJWTAuth: jwtAuthModule.createJWTAuth,
24
24
  createAPIKeyAuth: jwtAuthModule.createAPIKeyAuth,
25
25
  extractTokenFromHeader: jwtAuthModule.extractTokenFromHeader,
26
+ API_KEY_SYMBOL: jwtAuthModule.API_KEY_SYMBOL,
27
+ maskApiKey: jwtAuthModule.maskApiKey,
26
28
 
27
29
  // Rate limiting middleware
28
30
  createRateLimit: rateLimitModule.createRateLimit,
@@ -44,6 +46,8 @@ module.exports = {
44
46
  createBodyParser: bodyParserModule.createBodyParser,
45
47
  hasBody: bodyParserModule.hasBody,
46
48
  shouldParse: bodyParserModule.shouldParse,
49
+ parseLimit: bodyParserModule.parseLimit,
50
+ RAW_BODY_SYMBOL: bodyParserModule.RAW_BODY_SYMBOL,
47
51
 
48
52
  // Prometheus metrics middleware
49
53
  createPrometheusMiddleware: prometheusModule.createPrometheusMiddleware,
@@ -13,6 +13,40 @@ function loadJose() {
13
13
  return joseLib
14
14
  }
15
15
 
16
+ const crypto = require('crypto')
17
+
18
+ /**
19
+ * Symbol key for storing raw API key to prevent accidental serialization (M-7 fix)
20
+ */
21
+ const API_KEY_SYMBOL = Symbol.for('0http.apiKey')
22
+
23
+ /**
24
+ * Masks an API key for safe storage/logging.
25
+ * Shows first 4 and last 4 characters, masks the rest.
26
+ * @param {string} key - Raw API key
27
+ * @returns {string} Masked key
28
+ */
29
+ function maskApiKey(key) {
30
+ if (!key || typeof key !== 'string') return '****'
31
+ if (key.length <= 8) return '****'
32
+ return key.slice(0, 4) + '****' + key.slice(-4)
33
+ }
34
+
35
+ /**
36
+ * Constant-time string comparison to prevent timing attacks
37
+ */
38
+ function timingSafeCompare(a, b) {
39
+ if (typeof a !== 'string' || typeof b !== 'string') return false
40
+ const aBuf = Buffer.from(a)
41
+ const bBuf = Buffer.from(b)
42
+ if (aBuf.length !== bBuf.length) {
43
+ // Still perform comparison to maintain constant time
44
+ crypto.timingSafeEqual(aBuf, aBuf)
45
+ return false
46
+ }
47
+ return crypto.timingSafeEqual(aBuf, bBuf)
48
+ }
49
+
16
50
  /**
17
51
  * Creates JWT authentication middleware
18
52
  * @param {Object} options - JWT configuration options
@@ -53,6 +87,7 @@ function createJWTAuth(options = {}) {
53
87
  audience,
54
88
  issuer,
55
89
  algorithms,
90
+ requiredTokenType,
56
91
  } = options
57
92
 
58
93
  // API key mode doesn't require JWT secret
@@ -61,6 +96,35 @@ function createJWTAuth(options = {}) {
61
96
  throw new Error('JWT middleware requires either secret or jwksUri')
62
97
  }
63
98
 
99
+ // H-8: Warn about security risk of JWT tokens in query parameters
100
+ if (tokenQuery) {
101
+ console.warn(
102
+ `[0http-bun] SECURITY WARNING: JWT tokenQuery ("${tokenQuery}") is deprecated. ` +
103
+ 'Tokens in query parameters are logged in server access logs, browser history, and Referer headers. ' +
104
+ 'Use Authorization header or a custom getToken function instead.',
105
+ )
106
+ }
107
+
108
+ // M-8: Validate JWKS URI uses HTTPS in production
109
+ if (jwksUri) {
110
+ const parsedUri = new URL(jwksUri)
111
+ if (
112
+ parsedUri.protocol !== 'https:' &&
113
+ process.env.NODE_ENV === 'production'
114
+ ) {
115
+ throw new Error(
116
+ 'JWT middleware: JWKS URI must use HTTPS in production to prevent MitM key substitution. ' +
117
+ `Got: ${parsedUri.protocol}// Set NODE_ENV to a non-production value to bypass this check.`,
118
+ )
119
+ }
120
+ if (parsedUri.protocol !== 'https:') {
121
+ console.warn(
122
+ `[0http-bun] SECURITY WARNING: JWKS URI "${jwksUri}" uses ${parsedUri.protocol} instead of https:. ` +
123
+ 'This is insecure and will be rejected in production (NODE_ENV=production).',
124
+ )
125
+ }
126
+ }
127
+
64
128
  // Setup key resolver for JWT
65
129
  let keyLike
66
130
  if (jwks) {
@@ -82,18 +146,36 @@ function createJWTAuth(options = {}) {
82
146
  }
83
147
 
84
148
  // Default JWT verification options
149
+ const resolvedAlgorithms = algorithms || jwtOptions.algorithms || ['HS256']
150
+
151
+ // Prevent algorithm confusion attacks
152
+ const hasSymmetric = resolvedAlgorithms.some((alg) => alg.startsWith('HS'))
153
+ const hasAsymmetric = resolvedAlgorithms.some(
154
+ (alg) =>
155
+ alg.startsWith('RS') || alg.startsWith('ES') || alg.startsWith('PS'),
156
+ )
157
+ if (hasSymmetric && hasAsymmetric) {
158
+ throw new Error(
159
+ 'JWT middleware: mixing symmetric (HS*) and asymmetric (RS*/ES*/PS*) algorithms is not allowed. This prevents algorithm confusion attacks.',
160
+ )
161
+ }
162
+
85
163
  const defaultJwtOptions = {
86
- algorithms: algorithms || ['HS256', 'RS256'],
164
+ ...jwtOptions,
165
+ algorithms: resolvedAlgorithms,
87
166
  audience,
88
167
  issuer,
89
- ...jwtOptions,
90
168
  }
91
169
 
92
170
  return async function jwtAuthMiddleware(req, next) {
93
171
  const url = new URL(req.url)
94
172
 
95
173
  // Skip authentication for excluded paths
96
- if (excludePaths.some((path) => url.pathname.startsWith(path))) {
174
+ if (
175
+ excludePaths.some(
176
+ (path) => url.pathname === path || url.pathname.startsWith(path + '/'),
177
+ )
178
+ ) {
97
179
  return next()
98
180
  }
99
181
 
@@ -109,18 +191,19 @@ function createJWTAuth(options = {}) {
109
191
  req,
110
192
  )
111
193
  if (validationResult !== false) {
112
- // Set API key context
194
+ // Set API key context — store masked version to prevent leakage (M-7 fix)
113
195
  req.ctx = req.ctx || {}
114
- req.ctx.apiKey = apiKey
196
+ req.ctx.apiKey = maskApiKey(apiKey)
197
+ req[API_KEY_SYMBOL] = apiKey // Raw key via Symbol for programmatic access only
115
198
 
116
199
  // If validation result is an object, use it as user data, otherwise default
117
200
  const userData =
118
201
  validationResult && typeof validationResult === 'object'
119
202
  ? validationResult
120
- : {apiKey}
203
+ : {apiKey: maskApiKey(apiKey)}
121
204
 
122
205
  req.ctx.user = userData
123
- req.apiKey = apiKey
206
+ req.apiKey = maskApiKey(apiKey)
124
207
  req.user = userData
125
208
  return next()
126
209
  } else {
@@ -164,24 +247,40 @@ function createJWTAuth(options = {}) {
164
247
  defaultJwtOptions,
165
248
  )
166
249
 
250
+ // L-4: Validate JWT token type header if configured
251
+ if (requiredTokenType) {
252
+ const tokenType = protectedHeader.typ
253
+ if (
254
+ !tokenType ||
255
+ tokenType.toLowerCase() !== requiredTokenType.toLowerCase()
256
+ ) {
257
+ return handleAuthError(
258
+ new Error('Invalid token type'),
259
+ {unauthorizedResponse, onError},
260
+ req,
261
+ )
262
+ }
263
+ }
264
+
167
265
  // Add user info to request context
168
266
  req.ctx = req.ctx || {}
169
267
  req.ctx.user = payload
170
268
  req.ctx.jwt = {
171
269
  payload,
172
270
  header: protectedHeader,
173
- token,
174
271
  }
175
272
  req.user = payload // Mirror to root for compatibility
176
273
  req.jwt = {
177
274
  payload,
178
275
  header: protectedHeader,
179
- token,
180
276
  }
181
277
 
182
278
  return next()
183
279
  } catch (error) {
184
280
  if (optional && (!hasApiKeyMode || !req.headers.get(apiKeyHeader))) {
281
+ req.ctx = req.ctx || {}
282
+ req.ctx.authError = error.message
283
+ req.ctx.authAttempted = true
185
284
  return next()
186
285
  }
187
286
 
@@ -200,12 +299,6 @@ function createJWTAuth(options = {}) {
200
299
  */
201
300
  async function validateApiKeyInternal(apiKey, apiKeys, apiKeyValidator, req) {
202
301
  if (apiKeyValidator) {
203
- // Check if this is the simplified validateApiKey function (expects only key)
204
- if (apiKeyValidator.length === 1) {
205
- const result = await apiKeyValidator(apiKey)
206
- return result || false
207
- }
208
- // Otherwise call with both key and req
209
302
  const result = await apiKeyValidator(apiKey, req)
210
303
  return result || false
211
304
  }
@@ -216,10 +309,10 @@ async function validateApiKeyInternal(apiKey, apiKeys, apiKeyValidator, req) {
216
309
  }
217
310
 
218
311
  if (Array.isArray(apiKeys)) {
219
- return apiKeys.includes(apiKey)
312
+ return apiKeys.some((key) => timingSafeCompare(key, apiKey))
220
313
  }
221
314
 
222
- return apiKeys === apiKey
315
+ return timingSafeCompare(apiKeys, apiKey)
223
316
  }
224
317
 
225
318
  /**
@@ -271,7 +364,8 @@ function handleAuthError(error, handlers = {}, req) {
271
364
  return result
272
365
  }
273
366
  } catch (handlerError) {
274
- // Fall back to default handling if custom handler fails
367
+ // I-4: Log errors from custom handlers to aid debugging
368
+ console.error('[0http-bun] Custom onError handler threw:', handlerError)
275
369
  }
276
370
  }
277
371
 
@@ -303,7 +397,11 @@ function handleAuthError(error, handlers = {}, req) {
303
397
  }
304
398
  }
305
399
  } catch (responseError) {
306
- // Fall back to default response if custom response fails
400
+ // I-4: Log errors from custom response handlers to aid debugging
401
+ console.error(
402
+ '[0http-bun] Custom unauthorizedResponse handler threw:',
403
+ responseError,
404
+ )
307
405
  }
308
406
  }
309
407
 
@@ -317,19 +415,10 @@ function handleAuthError(error, handlers = {}, req) {
317
415
  message = 'Invalid API key'
318
416
  } else if (error.message === 'JWT verification not configured') {
319
417
  message = 'JWT verification not configured'
418
+ } else if (error.message === 'Invalid token type') {
419
+ message = 'Invalid token type'
320
420
  } else {
321
- const {errors} = loadJose()
322
- if (error instanceof errors.JWTExpired) {
323
- message = 'Token expired'
324
- } else if (error instanceof errors.JWTInvalid) {
325
- message = 'Invalid token format'
326
- } else if (error instanceof errors.JWKSNoMatchingKey) {
327
- message = 'Token signature verification failed'
328
- } else if (error.message.includes('audience')) {
329
- message = 'Invalid token audience'
330
- } else if (error.message.includes('issuer')) {
331
- message = 'Invalid token issuer'
332
- }
421
+ message = 'Invalid or expired token'
333
422
  }
334
423
 
335
424
  return new Response(JSON.stringify({error: message}), {
@@ -376,7 +465,10 @@ function createAPIKeyAuth(options = {}) {
376
465
  const validateKey =
377
466
  typeof keys === 'function'
378
467
  ? keys
379
- : (key) => (Array.isArray(keys) ? keys.includes(key) : keys === key)
468
+ : (key) =>
469
+ Array.isArray(keys)
470
+ ? keys.some((k) => timingSafeCompare(k, key))
471
+ : timingSafeCompare(keys, key)
380
472
 
381
473
  return async function apiKeyAuthMiddleware(req, next) {
382
474
  try {
@@ -400,9 +492,10 @@ function createAPIKeyAuth(options = {}) {
400
492
  })
401
493
  }
402
494
 
403
- // Add API key info to context
495
+ // Add API key info to context — store masked version (M-7 fix)
404
496
  req.ctx = req.ctx || {}
405
- req.ctx.apiKey = apiKey
497
+ req.ctx.apiKey = maskApiKey(apiKey)
498
+ req[API_KEY_SYMBOL] = apiKey // Raw key via Symbol for programmatic access only
406
499
 
407
500
  return next()
408
501
  } catch (error) {
@@ -418,7 +511,6 @@ module.exports = {
418
511
  createJWTAuth,
419
512
  createAPIKeyAuth,
420
513
  extractTokenFromHeader,
421
- extractToken,
422
- validateApiKeyInternal,
423
- handleAuthError,
514
+ API_KEY_SYMBOL,
515
+ maskApiKey,
424
516
  }