@luckystack/api 0.6.7 → 0.7.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.
@@ -1,188 +1,188 @@
1
- # Rate Limiting
2
-
3
- > Last updated: 2026-05-20
4
-
5
- ## Overview
6
-
7
- Every API request runs through two rate-limit buckets, in order. The per-route bucket protects an individual endpoint from a single caller; the global per-IP bucket protects the server as a whole from a single source hitting many endpoints. Both buckets use the same strategy backend (configurable via `registerRateLimitStrategy` in `@luckystack/core`; defaults to memory-or-redis depending on `rateLimiting.store`), share the same `windowMs`, and emit the same `rateLimitExceeded` hook on rejection.
8
-
9
- Rate-limit logic is identical on both transports — `handleApiRequest` and `handleHttpApiRequest` apply the same buckets in the same order. The only transport-specific difference is where the IP comes from: the socket transport reads `socket.handshake.address`; the HTTP transport takes a `requesterIp` parameter (the HTTP listener in `@luckystack/server` derives it from `req.socket.remoteAddress` + optional forwarded-header parsing).
10
-
11
- Rejection responds with `api.rateLimitExceeded` + HTTP `429`, and includes `errorParams: [{ key: 'seconds', value: resetIn }]` so consumers can render a localized "try again in N seconds" message. The hook payload exposes the full key, scope, and counts for dashboards / alerting.
12
-
13
- ## The two buckets
14
-
15
- ### 1. Per-route bucket
16
-
17
- Keyed per-caller per-route. Drops requests that exceed `apiEntry.rateLimit` (per route) or `rateLimiting.defaultApiLimit` (fallback) within `windowMs`.
18
-
19
- **Key format:**
20
-
21
- | Caller | Key |
22
- | --- | --- |
23
- | Authenticated (`token` present) | `token:<token>:api:<resolvedName>` |
24
- | Anonymous (no token) | `ip:<requesterIp>:api:<resolvedName>` |
25
-
26
- The `<resolvedName>` is the normalized route name returned by `parseTransportRouteName` (e.g. `settings/updateUser/v1`). Using the token (not user ID) as the key ensures the bucket sticks to the session even if multiple sessions exist for the same user under `session.perUser === 'multiple'`.
27
-
28
- **Per-route override:** a route file can declare its own limit (or disable it):
29
-
30
- ```ts
31
- // src/settings/_api/updateUser_v1.ts
32
- export const rateLimit: number | false = 30; // 30 requests per window
33
- // or:
34
- export const rateLimit = false; // No per-route bucket
35
- ```
36
-
37
- When `rateLimit === false` or `0`, the per-route bucket is skipped entirely — the global IP bucket still applies.
38
-
39
- When `rateLimit === undefined` (the export is absent), the handler falls back to `rateLimiting.defaultApiLimit`. If that is also `false`, the per-route bucket is skipped.
40
-
41
- ### 2. Global per-IP bucket
42
-
43
- Keyed only by IP, shared across every route. Drops requests from a single IP that exceed `rateLimiting.defaultIpLimit` in `windowMs`, regardless of which routes they hit.
44
-
45
- **Key format:** `ip:<requesterIp>:api:all`
46
-
47
- When `defaultIpLimit === false` or `0`, the global bucket is skipped entirely. Per-route limits still apply.
48
-
49
- The global bucket runs **after** the per-route bucket — a route that has burned through its per-route allowance returns `api.rateLimitExceeded` without ever touching the global bucket, so the per-route violator does not also consume global capacity.
50
-
51
- ## Bucket evaluation order
52
-
53
- Both transports execute exactly:
54
-
55
- 1. Compute `effectiveApiLimit = apiEntry.rateLimit ?? rateLimiting.defaultApiLimit`.
56
- 2. If `effectiveApiLimit !== false && effectiveApiLimit > 0`:
57
- - Build the per-route key.
58
- - `await checkRateLimit({ key, limit, windowMs })`.
59
- - On `!allowed`, dispatch `rateLimitExceeded` with `scope: token ? 'user' : 'ip'` (the bucket is user-keyed only when a token is present; an anonymous per-route bucket is IP-keyed, so it reports `ip` — with `route` still set to distinguish it from the global `:api:all` IP bucket below), respond `api.rateLimitExceeded` + `429`, abort.
60
- 3. Read `defaultIpLimit = rateLimiting.defaultIpLimit`.
61
- 4. If `defaultIpLimit !== false && defaultIpLimit > 0`:
62
- - Build the global IP key.
63
- - `await checkRateLimit(...)`.
64
- - On `!allowed`, dispatch `rateLimitExceeded` with `scope: 'ip'`, respond `api.rateLimitExceeded` + `429`, abort.
65
-
66
- The rate-limit step sits between auth and validation. Auth runs first so unauthenticated probes can't burn through anonymous IP buckets enumerating routes. Validation runs after rate limit so a flood of malformed payloads from a single caller is throttled the same way as well-formed traffic.
67
-
68
- ## API Reference
69
-
70
- ### `applyApiRateLimits` (internal, socket transport)
71
-
72
- **Signature** (paraphrased):
73
-
74
- ```ts
75
- const applyApiRateLimits = async (params: {
76
- apiEntry: RuntimeApiEntry;
77
- resolvedName: string;
78
- token: string | null;
79
- socket: Socket;
80
- user: SessionLayout | null;
81
- emitApiError: EmitApiError;
82
- }): Promise<boolean>;
83
- ```
84
-
85
- | Param | Notes |
86
- | --- | --- |
87
- | `apiEntry` | Read `rateLimit` field. |
88
- | `resolvedName` | Used to build the per-route key. |
89
- | `token` | When non-null, drives `token:<token>:...` key + `scope: 'user'`. |
90
- | `socket` | Read `socket.handshake.address` for the IP fallback. |
91
- | `user` | Surfaced as `userId` in the `rateLimitExceeded` payload. |
92
- | `emitApiError` | Closure that emits the localized error envelope on the response channel. |
93
-
94
- **Returns**: `Promise<boolean>` — `true` if both buckets allowed the request, `false` if either rejected.
95
-
96
- ### Inline rate-limit block in `handleHttpApiRequest`
97
-
98
- Same logic, inlined rather than extracted into a helper. Reads `requesterIp` (or `'anonymous'` for the per-route bucket key, `'unknown'` for the global bucket key) instead of `socket.handshake.address`.
99
-
100
- ### `checkRateLimit({ key, limit, windowMs })` (re-exported from `@luckystack/core`)
101
-
102
- The underlying strategy call.
103
-
104
- **Signature**:
105
-
106
- ```ts
107
- async function checkRateLimit(params: {
108
- key: string;
109
- limit: number;
110
- windowMs: number;
111
- }): Promise<{ allowed: boolean; resetIn: number; current: number }>;
112
- ```
113
-
114
- **Behavior**:
115
-
116
- - Delegates to the registered strategy (`registerRateLimitStrategy`). Default strategy uses Redis when `rateLimiting.store === 'redis'`, otherwise an in-memory token-bucket.
117
- - `resetIn` is seconds until the window resets, used as the `seconds` error param.
118
- - Backend implementation lives in `@luckystack/core/src/rateLimiter.ts`; see its docs for sliding-window semantics and the `redisKeyPrefix` config option.
119
-
120
- ## Hooks dispatched
121
-
122
- | Hook | When | Payload (key fields) |
123
- | --- | --- | --- |
124
- | `rateLimitExceeded` | A bucket rejects | `scope: 'user' \| 'route' \| 'ip'`, `key: string`, `limit: number`, `windowMs: number`, `count: number` (always `limit + 1` here), `route?: string`, `userId?: string`, `ip?: string` |
125
-
126
- The hook fires before the error response is emitted, so handlers that want to escalate (alerting, IP-banning) can act on the same request. Handlers run inside `void dispatchHook(...)` — they cannot block or alter the rejection.
127
-
128
- `scope` distinguishes the three cases:
129
-
130
- - `'user'` — per-route bucket rejected an authenticated caller (`token` was non-null).
131
- - `'route'` — per-route bucket rejected an anonymous caller (no token; keyed by IP).
132
- - `'ip'` — global per-IP bucket rejected.
133
-
134
- ## Error response
135
-
136
- Both transports return the same envelope on rejection:
137
-
138
- ```jsonc
139
- {
140
- "status": "error",
141
- "httpStatus": 429,
142
- "errorCode": "api.rateLimitExceeded",
143
- "errorParams": [{ "key": "seconds", "value": 42 }],
144
- "message": "Too many requests. Try again in 42 seconds."
145
- // message comes from the registered localized normalizer using errorCode + errorParams
146
- }
147
- ```
148
-
149
- `resetIn` reflects the strategy's view of when the bucket frees up. The localized normalizer renders the message via the consumer's translate function — see [`./error-handling.md`](./error-handling.md).
150
-
151
- ## Config keys
152
-
153
- All read at request time via `getProjectConfig()`.
154
-
155
- | Key | Type | Effect |
156
- | --- | --- | --- |
157
- | `rateLimiting.defaultApiLimit` | `number \| false` | Fallback per-route limit when the route does not export `rateLimit`. `false` disables. |
158
- | `rateLimiting.defaultIpLimit` | `number \| false` | Global per-IP limit across all routes. `false` disables. |
159
- | `rateLimiting.windowMs` | `number` | Window size for both buckets, in ms. Default 60000 (one minute). |
160
- | `rateLimiting.store` | `'memory' \| 'redis'` | Backend for the default strategy. `'redis'` is required for multi-instance consistency. |
161
- | `rateLimiting.redisKeyPrefix` | `string` | Prefix prepended to keys when using the Redis backend. |
162
- | `rateLimiting.enabled` | `boolean` | Master switch on the default strategy. When `false`, every `checkRateLimit` call returns `{ allowed: true }`. |
163
-
164
- ## Disabling rate limiting
165
-
166
- | Scope | How |
167
- | --- | --- |
168
- | One route, per-route bucket only | `export const rateLimit = false;` in the route file. |
169
- | One route, both buckets | `export const rateLimit = false;` + run the consumer behind a load balancer that absorbs the IP bucket. There is no per-route opt-out for the global bucket. |
170
- | Globally, per-route bucket | `rateLimiting.defaultApiLimit: false` in `registerProjectConfig`. |
171
- | Globally, IP bucket | `rateLimiting.defaultIpLimit: false`. |
172
- | Globally, everything | `rateLimiting.enabled: false` (default strategy honors this and short-circuits). |
173
- | Replace backend | `registerRateLimitStrategy(myStrategy)` from `@luckystack/core` — useful for edge-KV-backed limits, per-tier quotas, or no-op test strategies. |
174
-
175
- ## Edge cases
176
-
177
- - **Anonymous request with no IP** — the socket transport falls back to `'unknown'` for `socket.handshake.address`; the HTTP transport falls back to `'anonymous'` for the per-route bucket and `'unknown'` for the global bucket. All anonymous callers that lack an IP share the same bucket. Front your server with a load balancer that always supplies `X-Forwarded-For` (parsed by `@luckystack/server`'s request adapter) to avoid this collision.
178
- - **Token present but session deleted** — the bucket is still keyed by the token string; subsequent requests with the same expired token still throttle. Once the session is fully GC'd from `getSession`, the auth gate rejects with `auth.required` before rate-limit checks run (auth runs first).
179
- - **Race within `windowMs`** — `checkRateLimit` is atomic on the strategy backend (`INCR` + `EXPIRE` for Redis, lock-free counter for memory). Two requests arriving at the same millisecond both count toward the limit.
180
- - **Per-route `rateLimit = 0`** — equivalent to `false` (bucket skipped). The handler explicitly guards on `effectiveApiLimit > 0`.
181
- - **Custom strategy returns `allowed: false` with `resetIn: 0`** — the response still echoes `seconds: 0`. Renderers should treat `seconds <= 0` as "right now" / "very soon".
182
-
183
- ## Related
184
-
185
- - API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
186
- - Error handling: [`./error-handling.md`](./error-handling.md)
187
- - Rate-limit strategy: `@luckystack/core/docs/rate-limit-strategy.md`
188
- - Architecture: [`/docs/ARCHITECTURE_API.md`](../../../docs/ARCHITECTURE_API.md) (see "Rate Limiting")
1
+ # Rate Limiting
2
+
3
+ > Last updated: 2026-05-20
4
+
5
+ ## Overview
6
+
7
+ Every API request runs through two rate-limit buckets, in order. The per-route bucket protects an individual endpoint from a single caller; the global per-IP bucket protects the server as a whole from a single source hitting many endpoints. Both buckets use the same strategy backend (configurable via `registerRateLimitStrategy` in `@luckystack/core`; defaults to memory-or-redis depending on `rateLimiting.store`), share the same `windowMs`, and emit the same `rateLimitExceeded` hook on rejection.
8
+
9
+ Rate-limit logic is identical on both transports — `handleApiRequest` and `handleHttpApiRequest` apply the same buckets in the same order. The only transport-specific difference is where the IP comes from: the socket transport reads `socket.handshake.address`; the HTTP transport takes a `requesterIp` parameter (the HTTP listener in `@luckystack/server` derives it from `req.socket.remoteAddress` + optional forwarded-header parsing).
10
+
11
+ Rejection responds with `api.rateLimitExceeded` + HTTP `429`, and includes `errorParams: [{ key: 'seconds', value: resetIn }]` so consumers can render a localized "try again in N seconds" message. The hook payload exposes the full key, scope, and counts for dashboards / alerting.
12
+
13
+ ## The two buckets
14
+
15
+ ### 1. Per-route bucket
16
+
17
+ Keyed per-caller per-route. Drops requests that exceed `apiEntry.rateLimit` (per route) or `rateLimiting.defaultApiLimit` (fallback) within `windowMs`.
18
+
19
+ **Key format:**
20
+
21
+ | Caller | Key |
22
+ | --- | --- |
23
+ | Authenticated (`token` present) | `token:<token>:api:<resolvedName>` |
24
+ | Anonymous (no token) | `ip:<requesterIp>:api:<resolvedName>` |
25
+
26
+ The `<resolvedName>` is the normalized route name returned by `parseTransportRouteName` (e.g. `settings/updateUser/v1`). Using the token (not user ID) as the key ensures the bucket sticks to the session even if multiple sessions exist for the same user under `session.perUser === 'multiple'`.
27
+
28
+ **Per-route override:** a route file can declare its own limit (or disable it):
29
+
30
+ ```ts
31
+ // src/settings/_api/updateUser_v1.ts
32
+ export const rateLimit: number | false = 30; // 30 requests per window
33
+ // or:
34
+ export const rateLimit = false; // No per-route bucket
35
+ ```
36
+
37
+ When `rateLimit === false` or `0`, the per-route bucket is skipped entirely — the global IP bucket still applies.
38
+
39
+ When `rateLimit === undefined` (the export is absent), the handler falls back to `rateLimiting.defaultApiLimit`. If that is also `false`, the per-route bucket is skipped.
40
+
41
+ ### 2. Global per-IP bucket
42
+
43
+ Keyed only by IP, shared across every route. Drops requests from a single IP that exceed `rateLimiting.defaultIpLimit` in `windowMs`, regardless of which routes they hit.
44
+
45
+ **Key format:** `ip:<requesterIp>:api:all`
46
+
47
+ When `defaultIpLimit === false` or `0`, the global bucket is skipped entirely. Per-route limits still apply.
48
+
49
+ The global bucket runs **after** the per-route bucket — a route that has burned through its per-route allowance returns `api.rateLimitExceeded` without ever touching the global bucket, so the per-route violator does not also consume global capacity.
50
+
51
+ ## Bucket evaluation order
52
+
53
+ Both transports execute exactly:
54
+
55
+ 1. Compute `effectiveApiLimit = apiEntry.rateLimit ?? rateLimiting.defaultApiLimit`.
56
+ 2. If `effectiveApiLimit !== false && effectiveApiLimit > 0`:
57
+ - Build the per-route key.
58
+ - `await checkRateLimit({ key, limit, windowMs })`.
59
+ - On `!allowed`, dispatch `rateLimitExceeded` with `scope: token ? 'user' : 'ip'` (the bucket is user-keyed only when a token is present; an anonymous per-route bucket is IP-keyed, so it reports `ip` — with `route` still set to distinguish it from the global `:api:all` IP bucket below), respond `api.rateLimitExceeded` + `429`, abort.
60
+ 3. Read `defaultIpLimit = rateLimiting.defaultIpLimit`.
61
+ 4. If `defaultIpLimit !== false && defaultIpLimit > 0`:
62
+ - Build the global IP key.
63
+ - `await checkRateLimit(...)`.
64
+ - On `!allowed`, dispatch `rateLimitExceeded` with `scope: 'ip'`, respond `api.rateLimitExceeded` + `429`, abort.
65
+
66
+ The rate-limit step sits between auth and validation. Auth runs first so unauthenticated probes can't burn through anonymous IP buckets enumerating routes. Validation runs after rate limit so a flood of malformed payloads from a single caller is throttled the same way as well-formed traffic.
67
+
68
+ ## API Reference
69
+
70
+ ### `applyApiRateLimits` (internal, socket transport)
71
+
72
+ **Signature** (paraphrased):
73
+
74
+ ```ts
75
+ const applyApiRateLimits = async (params: {
76
+ apiEntry: RuntimeApiEntry;
77
+ resolvedName: string;
78
+ token: string | null;
79
+ socket: Socket;
80
+ user: SessionLayout | null;
81
+ emitApiError: EmitApiError;
82
+ }): Promise<boolean>;
83
+ ```
84
+
85
+ | Param | Notes |
86
+ | --- | --- |
87
+ | `apiEntry` | Read `rateLimit` field. |
88
+ | `resolvedName` | Used to build the per-route key. |
89
+ | `token` | When non-null, drives `token:<token>:...` key + `scope: 'user'`. |
90
+ | `socket` | Read `socket.handshake.address` for the IP fallback. |
91
+ | `user` | Surfaced as `userId` in the `rateLimitExceeded` payload. |
92
+ | `emitApiError` | Closure that emits the localized error envelope on the response channel. |
93
+
94
+ **Returns**: `Promise<boolean>` — `true` if both buckets allowed the request, `false` if either rejected.
95
+
96
+ ### Inline rate-limit block in `handleHttpApiRequest`
97
+
98
+ Same logic, inlined rather than extracted into a helper. Reads `requesterIp` (or `'anonymous'` for the per-route bucket key, `'unknown'` for the global bucket key) instead of `socket.handshake.address`.
99
+
100
+ ### `checkRateLimit({ key, limit, windowMs })` (re-exported from `@luckystack/core`)
101
+
102
+ The underlying strategy call.
103
+
104
+ **Signature**:
105
+
106
+ ```ts
107
+ async function checkRateLimit(params: {
108
+ key: string;
109
+ limit: number;
110
+ windowMs: number;
111
+ }): Promise<{ allowed: boolean; resetIn: number; current: number }>;
112
+ ```
113
+
114
+ **Behavior**:
115
+
116
+ - Delegates to the registered strategy (`registerRateLimitStrategy`). Default strategy uses Redis when `rateLimiting.store === 'redis'`, otherwise an in-memory token-bucket.
117
+ - `resetIn` is seconds until the window resets, used as the `seconds` error param.
118
+ - Backend implementation lives in `@luckystack/core/src/rateLimiter.ts`; see its docs for sliding-window semantics and the `redisKeyPrefix` config option.
119
+
120
+ ## Hooks dispatched
121
+
122
+ | Hook | When | Payload (key fields) |
123
+ | --- | --- | --- |
124
+ | `rateLimitExceeded` | A bucket rejects | `scope: 'user' \| 'route' \| 'ip'`, `key: string`, `limit: number`, `windowMs: number`, `count: number` (always `limit + 1` here), `route?: string`, `userId?: string`, `ip?: string` |
125
+
126
+ The hook fires before the error response is emitted, so handlers that want to escalate (alerting, IP-banning) can act on the same request. Handlers run inside `void dispatchHook(...)` — they cannot block or alter the rejection.
127
+
128
+ `scope` distinguishes the three cases:
129
+
130
+ - `'user'` — per-route bucket rejected an authenticated caller (`token` was non-null).
131
+ - `'route'` — per-route bucket rejected an anonymous caller (no token; keyed by IP).
132
+ - `'ip'` — global per-IP bucket rejected.
133
+
134
+ ## Error response
135
+
136
+ Both transports return the same envelope on rejection:
137
+
138
+ ```jsonc
139
+ {
140
+ "status": "error",
141
+ "httpStatus": 429,
142
+ "errorCode": "api.rateLimitExceeded",
143
+ "errorParams": [{ "key": "seconds", "value": 42 }],
144
+ "message": "Too many requests. Try again in 42 seconds."
145
+ // message comes from the registered localized normalizer using errorCode + errorParams
146
+ }
147
+ ```
148
+
149
+ `resetIn` reflects the strategy's view of when the bucket frees up. The localized normalizer renders the message via the consumer's translate function — see [`./error-handling.md`](./error-handling.md).
150
+
151
+ ## Config keys
152
+
153
+ All read at request time via `getProjectConfig()`.
154
+
155
+ | Key | Type | Effect |
156
+ | --- | --- | --- |
157
+ | `rateLimiting.defaultApiLimit` | `number \| false` | Fallback per-route limit when the route does not export `rateLimit`. `false` disables. |
158
+ | `rateLimiting.defaultIpLimit` | `number \| false` | Global per-IP limit across all routes. `false` disables. |
159
+ | `rateLimiting.windowMs` | `number` | Window size for both buckets, in ms. Default 60000 (one minute). |
160
+ | `rateLimiting.store` | `'memory' \| 'redis'` | Backend for the default strategy. `'redis'` is required for multi-instance consistency. |
161
+ | `rateLimiting.redisKeyPrefix` | `string` | Prefix prepended to keys when using the Redis backend. |
162
+ | `rateLimiting.enabled` | `boolean` | Master switch on the default strategy. When `false`, every `checkRateLimit` call returns `{ allowed: true }`. |
163
+
164
+ ## Disabling rate limiting
165
+
166
+ | Scope | How |
167
+ | --- | --- |
168
+ | One route, per-route bucket only | `export const rateLimit = false;` in the route file. |
169
+ | One route, both buckets | `export const rateLimit = false;` + run the consumer behind a load balancer that absorbs the IP bucket. There is no per-route opt-out for the global bucket. |
170
+ | Globally, per-route bucket | `rateLimiting.defaultApiLimit: false` in `registerProjectConfig`. |
171
+ | Globally, IP bucket | `rateLimiting.defaultIpLimit: false`. |
172
+ | Globally, everything | `rateLimiting.enabled: false` (default strategy honors this and short-circuits). |
173
+ | Replace backend | `registerRateLimitStrategy(myStrategy)` from `@luckystack/core` — useful for edge-KV-backed limits, per-tier quotas, or no-op test strategies. |
174
+
175
+ ## Edge cases
176
+
177
+ - **Anonymous request with no IP** — the socket transport falls back to `'unknown'` for `socket.handshake.address`; the HTTP transport falls back to `'anonymous'` for the per-route bucket and `'unknown'` for the global bucket. All anonymous callers that lack an IP share the same bucket. Front your server with a load balancer that always supplies `X-Forwarded-For` (parsed by `@luckystack/server`'s request adapter) to avoid this collision.
178
+ - **Token present but session deleted** — the bucket is still keyed by the token string; subsequent requests with the same expired token still throttle. Once the session is fully GC'd from `getSession`, the auth gate rejects with `auth.required` before rate-limit checks run (auth runs first).
179
+ - **Race within `windowMs`** — `checkRateLimit` is atomic on the strategy backend (`INCR` + `EXPIRE` for Redis, lock-free counter for memory). Two requests arriving at the same millisecond both count toward the limit.
180
+ - **Per-route `rateLimit = 0`** — equivalent to `false` (bucket skipped). The handler explicitly guards on `effectiveApiLimit > 0`.
181
+ - **Custom strategy returns `allowed: false` with `resetIn: 0`** — the response still echoes `seconds: 0`. Renderers should treat `seconds <= 0` as "right now" / "very soon".
182
+
183
+ ## Related
184
+
185
+ - API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
186
+ - Error handling: [`./error-handling.md`](./error-handling.md)
187
+ - Rate-limit strategy: `@luckystack/core/docs/rate-limit-strategy.md`
188
+ - Architecture: [`/docs/ARCHITECTURE_API.md`](../../../docs/ARCHITECTURE_API.md) (see "Rate Limiting")