@stacksjs/defaults 0.75.63 → 0.75.64

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.
@@ -1,13 +1,40 @@
1
- import { createRateLimitMiddleware, Middleware, parseThrottleString } from '@stacksjs/router'
1
+ import { clientAddress, createRateLimitMiddleware, Middleware, parseThrottleString } from '@stacksjs/router'
2
2
 
3
3
  // Cache for rate limiters to avoid creating new instances for each request
4
4
  const limiterCache = new Map<string, ReturnType<typeof createRateLimitMiddleware>>()
5
5
 
6
+ const RATE_LIMIT_HEADERS = ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset'] as const
7
+
8
+ /**
9
+ * Whose budget a request spends.
10
+ *
11
+ * A signed-in request spends its user's: the Auth middleware stamps
12
+ * `_authenticatedUser`, and Throttle runs after it (priority 2 to Auth's 1),
13
+ * so people sharing an address - an office, a phone carrier's NAT, every test
14
+ * user on 127.0.0.1 - do not exhaust each other. bun-router's own default
15
+ * reads `request.user.id`, which on a Stacks request is the async `user()`
16
+ * accessor, so it never saw a user and every request fell through to an
17
+ * address.
18
+ *
19
+ * Anything else spends its client address's, as `clientAddress()` resolves
20
+ * it: the socket peer, or - only when that peer is a trusted proxy - the
21
+ * forwarding headers it vouches for (`CF-Connecting-IP` only when the hop
22
+ * that delivered the request is Cloudflare). A client-written
23
+ * `X-Forwarded-For` no longer buys a fresh budget.
24
+ */
25
+ function throttleKey(request: { _authenticatedUser?: unknown }): string {
26
+ const user = request._authenticatedUser as { id?: unknown } | null | undefined
27
+ const id = user?.id
28
+ if ((typeof id === 'number' && Number.isFinite(id)) || (typeof id === 'string' && id !== ''))
29
+ return `user:${id}`
30
+ return `ip:${clientAddress(request as unknown as Request) ?? 'unknown'}`
31
+ }
32
+
6
33
  /**
7
34
  * Throttle Middleware
8
35
  *
9
- * Rate limits requests based on IP address or authenticated user.
10
- * Uses Laravel-style throttle patterns.
36
+ * Rate limits requests per authenticated user, or per client address for
37
+ * anonymous requests (see `throttleKey`). Uses Laravel-style throttle patterns.
11
38
  *
12
39
  * Usage:
13
40
  * .middleware('throttle:60,1') // 60 requests per 1 minute
@@ -29,7 +56,7 @@ const limiterCache = new Map<string, ReturnType<typeof createRateLimitMiddleware
29
56
  * "retryAfter": X
30
57
  * }
31
58
  *
32
- * Headers added to all responses:
59
+ * Headers added to all responses (successful ones via `_responseHeaders`):
33
60
  * - X-RateLimit-Limit: Maximum requests allowed
34
61
  * - X-RateLimit-Remaining: Remaining requests in current window
35
62
  * - X-RateLimit-Reset: Unix timestamp when the limit resets
@@ -37,7 +64,9 @@ const limiterCache = new Map<string, ReturnType<typeof createRateLimitMiddleware
37
64
  */
38
65
  export default new Middleware({
39
66
  name: 'throttle',
40
- priority: 1, // Run early, after maintenance but before auth
67
+ // After Auth (1), so an authenticated request is keyed on its user rather
68
+ // than on an address it may share with strangers.
69
+ priority: 2,
41
70
 
42
71
  async handle(request) {
43
72
  // Get throttle params from middleware params (e.g., '60,1' from 'throttle:60,1')
@@ -48,24 +77,25 @@ export default new Middleware({
48
77
  if (!limiter) {
49
78
  try {
50
79
  const config = parseThrottleString(params as Parameters<typeof parseThrottleString>[0])
51
- limiter = createRateLimitMiddleware(config, `throttle:${params}`)
80
+ limiter = createRateLimitMiddleware({ ...config, keyGenerator: throttleKey }, `throttle:${params}`)
52
81
  limiterCache.set(params, limiter)
53
82
  }
54
83
  catch (error) {
55
84
  console.error(`[Throttle] Invalid throttle pattern: ${params}`, error)
56
85
  // Use default if pattern is invalid
57
86
  const defaultConfig = parseThrottleString('60,1')
58
- limiter = createRateLimitMiddleware(defaultConfig, 'throttle:default')
87
+ limiter = createRateLimitMiddleware({ ...defaultConfig, keyGenerator: throttleKey }, 'throttle:default')
59
88
  limiterCache.set(params, limiter)
60
89
  }
61
90
  }
62
91
 
63
- // Create a next function that continues the request
64
- // The middleware will handle rate limiting and return 429 if needed
65
- const result = await limiter(request as any, async () => null)
92
+ // The limiter stamps its X-RateLimit-* headers onto whatever `next()`
93
+ // answers, so hand it a placeholder and read them back. Passing `null`
94
+ // here meant a request that got through never learned its budget.
95
+ const result = await limiter(request as any, async () => new Response(null, { status: 204 }))
66
96
 
67
- // If result is a Response (429), throw it to short-circuit the request
68
- if (result instanceof Response) {
97
+ // A 429 short-circuits the request
98
+ if (result instanceof Response && result.status === 429) {
69
99
  // Transform the response to match stacks format
70
100
  const retryAfter = result.headers.get('Retry-After') || '60'
71
101
 
@@ -90,6 +120,15 @@ export default new Middleware({
90
120
  })
91
121
  }
92
122
 
93
- // Continue normally - rate limit not exceeded
123
+ // Within the limit: ask the router to put the budget on the real response.
124
+ if (result instanceof Response) {
125
+ const headers: Record<string, string> = { ...request._responseHeaders }
126
+ for (const name of RATE_LIMIT_HEADERS) {
127
+ const value = result.headers.get(name)
128
+ if (value !== null)
129
+ headers[name] = value
130
+ }
131
+ request._responseHeaders = headers
132
+ }
94
133
  },
95
134
  })
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.63",
5
+ "version": "0.75.64",
6
6
  "description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.63",
5
+ "version": "0.75.64",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.35",
58
- "@stacksjs/mobile": "^0.75.63",
58
+ "@stacksjs/mobile": "^0.75.64",
59
59
  "@stacksjs/sanitizer": "^0.2.348",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }