@spfn/core 0.3.0-beta.4 → 0.3.0-beta.6
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.
- package/README.md +183 -4
- package/dist/authz/index.js +1 -381
- package/dist/authz/index.js.map +1 -1
- package/dist/db/index.d.ts +173 -27
- package/dist/db/index.js +192 -57
- package/dist/db/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.js +1 -381
- package/dist/errors/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js +6 -387
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +18 -1
- package/dist/nextjs/index.js +40 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +34 -1
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +61 -6
- package/dist/ops/index.js +330 -30
- package/dist/ops/index.js.map +1 -1
- package/dist/server/index.js +24 -1
- package/dist/server/index.js.map +1 -1
- package/docs/file-upload.md +195 -333
- package/package.json +6 -5
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +344 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +736 -0
- package/src/job/README.md +514 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
# @spfn/core/middleware — Built-in HTTP middleware (error handling + request logging + proxy-guard + rate limiting)
|
|
2
|
+
|
|
3
|
+
Production Hono middleware factories and a masking helper: `ErrorHandler` (serializes
|
|
4
|
+
thrown errors into HTTP responses), `RequestLogger` (structured request/response logging
|
|
5
|
+
with request IDs, slow-request detection, and sensitive-data masking), `createProxyGuard`
|
|
6
|
+
(verifies a trusted-proxy HMAC signature + origin allowlist and tags `clientType`), and
|
|
7
|
+
`rateLimit` (Redis-backed fixed-window limiter for brute-force / DoS protection).
|
|
8
|
+
|
|
9
|
+
> **These are the exports of this module.** `defineMiddleware` (custom named
|
|
10
|
+
> middleware), `.use()` / `.skip()` (route-level wiring), and `Transactional` (DB
|
|
11
|
+
> transaction middleware) are **not** here — they live in `@spfn/core/route` and
|
|
12
|
+
> `@spfn/core/db`. See [Related](#related).
|
|
13
|
+
|
|
14
|
+
## Import paths
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import {
|
|
18
|
+
ErrorHandler,
|
|
19
|
+
RequestLogger,
|
|
20
|
+
maskSensitiveData,
|
|
21
|
+
createProxyGuard,
|
|
22
|
+
createCacheNonceStore,
|
|
23
|
+
rateLimit,
|
|
24
|
+
getClientIp,
|
|
25
|
+
} from '@spfn/core/middleware';
|
|
26
|
+
|
|
27
|
+
import type {
|
|
28
|
+
ErrorHandlerOptions,
|
|
29
|
+
OnErrorContext,
|
|
30
|
+
RequestLoggerOptions,
|
|
31
|
+
RequestLoggerConfig, // deprecated alias of RequestLoggerOptions
|
|
32
|
+
ProxyGuardConfig,
|
|
33
|
+
ProxyGuardMode,
|
|
34
|
+
ClientType,
|
|
35
|
+
NonceStore,
|
|
36
|
+
RateLimitOptions,
|
|
37
|
+
} from '@spfn/core/middleware';
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
There is **no** `@spfn/core` root barrel — always import from `@spfn/core/middleware`.
|
|
41
|
+
`import { ErrorHandler } from '@spfn/core'` does not resolve.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Public API (complete)
|
|
46
|
+
|
|
47
|
+
From `@spfn/core/middleware`:
|
|
48
|
+
|
|
49
|
+
- `ErrorHandler(options?: ErrorHandlerOptions)` → `(err, c) => Response | Promise<Response>`
|
|
50
|
+
— register with `app.onError(...)`, **not** `app.use(...)`.
|
|
51
|
+
- `RequestLogger(options?: RequestLoggerOptions)` → Hono middleware `(c, next) => Promise<void>`
|
|
52
|
+
— register with `app.use(...)`.
|
|
53
|
+
- `maskSensitiveData(obj, sensitiveFields, seen?)` → deep-masked copy of `obj`.
|
|
54
|
+
- `createProxyGuard(config?: ProxyGuardConfig)` → Hono middleware. Verifies the proxy→backend
|
|
55
|
+
HMAC signature (rotating key set, keyed by `keyId`) over `method+path+query+body`, plus an
|
|
56
|
+
optional origin allowlist, then sets `c.get('clientType')` (`'web'` | `'untrusted'`). Modes:
|
|
57
|
+
`off` (default) / `tag` / `strict` — every gate is evaluated in BOTH modes, only enforcement
|
|
58
|
+
differs (strict rejects 403/413, tag tags `untrusted` and continues). `maxBodyBytes` caps the
|
|
59
|
+
hashed body (stream-measured). Usually enabled via `defineServerConfig().proxyGuard({...})`,
|
|
60
|
+
not `app.use` directly.
|
|
61
|
+
- `createCacheNonceStore(cache, prefix?)` → `NonceStore` for hard replay rejection (Redis
|
|
62
|
+
`SET … PX NX`). Pass to `proxyGuard.nonceStore` (auto-wired from a cache when `nonce: true`).
|
|
63
|
+
- `rateLimit(options: RateLimitOptions)` → Hono middleware. Redis-backed fixed-window limiter,
|
|
64
|
+
registered under the named `'rateLimit'` slot so routes can `.skip(['rateLimit'])`. Attach
|
|
65
|
+
with `.use([rateLimit({...})])`. See [rateLimit](#ratelimit).
|
|
66
|
+
- `getClientIp(c)` → best-effort client IP from the proxy chain (leftmost `X-Forwarded-For`,
|
|
67
|
+
then `X-Real-IP`, else `'unknown'`). Spoofable — see the caveat in [rateLimit](#ratelimit).
|
|
68
|
+
|
|
69
|
+
Types: `ErrorHandlerOptions`, `OnErrorContext`, `RequestLoggerOptions`, `RequestLoggerConfig`,
|
|
70
|
+
`ProxyGuardConfig`, `ProxyGuardMode`, `ClientType`, `NonceStore`, `RateLimitOptions`.
|
|
71
|
+
|
|
72
|
+
See the root `PROXY-BACKEND-AUTH-SPEC.md` for the threat model, key rotation, and `.env`
|
|
73
|
+
placement (`SPFN_PROXY_SECRET` in `.env.local`; grace `SPFN_PROXY_SECRET_PREVIOUS` in `.env.server`).
|
|
74
|
+
|
|
75
|
+
> **`RequestLoggerConfig` is deprecated** — it is a type alias of `RequestLoggerOptions`.
|
|
76
|
+
> Use `RequestLoggerOptions` in new code.
|
|
77
|
+
|
|
78
|
+
> **Most SPFN apps never call these directly.** `createServer` / `defineServerConfig`
|
|
79
|
+
> auto-register both — see [Auto-registration](#auto-registration-the-spfn-default-path)
|
|
80
|
+
> below. Manual `app.use` / `app.onError` is the raw-Hono path.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Auto-registration (the SPFN default path)
|
|
85
|
+
|
|
86
|
+
When you start a server via `@spfn/core/server`, **`RequestLogger` and `ErrorHandler` are
|
|
87
|
+
applied for you** (along with CORS). You do not wire them by hand.
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
91
|
+
|
|
92
|
+
export default defineServerConfig()
|
|
93
|
+
.routes(appRouter)
|
|
94
|
+
.build();
|
|
95
|
+
// → RequestLogger() (no options), CORS, then ErrorHandler() are auto-applied.
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Toggle / configure them through `config.middleware`:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
export default defineServerConfig()
|
|
102
|
+
.middleware({
|
|
103
|
+
logger: true, // RequestLogger (default: true) — set false to disable
|
|
104
|
+
cors: true, // CORS (default: true)
|
|
105
|
+
errorHandler: true, // ErrorHandler (default: true) — set false to disable
|
|
106
|
+
onError: (err, ctx) => // forwarded into ErrorHandler({ onError })
|
|
107
|
+
{
|
|
108
|
+
// non-blocking side-effect (Slack, PagerDuty, ...)
|
|
109
|
+
log(ctx.statusCode, ctx.method, ctx.path, err.message);
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
.routes(appRouter)
|
|
113
|
+
.build();
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Notes (from `create-server.ts`):
|
|
117
|
+
|
|
118
|
+
- The auto-applied `RequestLogger()` is called with **no options**, so it uses the
|
|
119
|
+
defaults below (excludes `/_core/health`, `/health`, `/ping`, `/favicon.ico`). To customize excludePaths
|
|
120
|
+
etc., disable it (`middleware.logger: false`) and add your own via `config.use`.
|
|
121
|
+
- `config.middleware.onError` is the **only** `ErrorHandlerOption` exposed through the
|
|
122
|
+
builder; `includeStack` / `enableLogging` fall back to their defaults under auto-config.
|
|
123
|
+
- Order is fixed: `RequestLogger` → CORS → routes → `ErrorHandler` (via `app.onError`).
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Quick Start (raw Hono)
|
|
128
|
+
|
|
129
|
+
Use this only when wiring a bare Hono app yourself (not via `defineServerConfig`).
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
import { Hono } from 'hono';
|
|
133
|
+
import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';
|
|
134
|
+
|
|
135
|
+
const app = new Hono();
|
|
136
|
+
|
|
137
|
+
app.use('*', RequestLogger()); // middleware — runs per request
|
|
138
|
+
app.onError(ErrorHandler()); // error hook — NOT app.use()
|
|
139
|
+
|
|
140
|
+
export default app;
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## ErrorHandler
|
|
146
|
+
|
|
147
|
+
Converts a thrown error into a JSON HTTP response. Register it with **`app.onError()`**
|
|
148
|
+
(it is Hono's error hook, not a `use()` middleware).
|
|
149
|
+
|
|
150
|
+
### Behavior
|
|
151
|
+
|
|
152
|
+
- **`SerializableError`** (from `@spfn/core/errors`) → serialized via its `toJSON()`, using
|
|
153
|
+
the error's own `statusCode` as the HTTP status. Custom fields (`resource`, `fields`, …)
|
|
154
|
+
are preserved. Detection is duck-typed (`toJSON` + numeric `statusCode`) so it survives
|
|
155
|
+
module duplication under tsx/dev.
|
|
156
|
+
- **Standard `Error`** → falls back to `{ __type: 'Error', message }`, status from a
|
|
157
|
+
`statusCode` property on the error if present, else `500`.
|
|
158
|
+
- **`cause` chain** → the root cause message is extracted and added as `cause`.
|
|
159
|
+
- **Production information disclosure** → when `includeStack` is `false` (the production
|
|
160
|
+
default), the client message is genericized for anything whose text may come from the DB
|
|
161
|
+
driver: standard (uncaught) `Error`s become `"Internal Server Error"` with no `cause`, and
|
|
162
|
+
any error exposing `internal === true` (the `DatabaseError` family — `QueryError`,
|
|
163
|
+
`ConnectionError`, `TransactionError`, … carrying raw SQL / table / column / parameter
|
|
164
|
+
text) becomes `"Internal server error"` with no `details`. Full detail is still logged
|
|
165
|
+
server-side. Errors with a safe, constructed message (`EntityNotFoundError`,
|
|
166
|
+
`DuplicateEntryError`, and all non-DB `SerializableError`s) are returned unchanged.
|
|
167
|
+
- **Logging** (when `enableLogging`): `warn` for 4xx, `error` for 5xx, via the
|
|
168
|
+
`@spfn/core:error-handler` logger.
|
|
169
|
+
- **`onError` callback** → fired non-blocking (`Promise.resolve(...).catch(...)`); never
|
|
170
|
+
delays or fails the response.
|
|
171
|
+
|
|
172
|
+
### Options (`ErrorHandlerOptions`)
|
|
173
|
+
|
|
174
|
+
| Option | Type | Default | Effect |
|
|
175
|
+
|--------|------|---------|--------|
|
|
176
|
+
| `includeStack` | `boolean` | `env.NODE_ENV !== 'production'` | Add `stack` to the response body |
|
|
177
|
+
| `enableLogging` | `boolean` | `true` | Log errors (warn 4xx / error 5xx) |
|
|
178
|
+
| `onError` | `(err, ctx: OnErrorContext) => void \| Promise<void>` | — | Non-blocking side-effect callback |
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
app.onError(ErrorHandler({
|
|
182
|
+
includeStack: env.NODE_ENV !== 'production',
|
|
183
|
+
enableLogging: true,
|
|
184
|
+
onError: (err, ctx) => notify(ctx.statusCode, ctx.path, err),
|
|
185
|
+
}));
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Response format
|
|
189
|
+
|
|
190
|
+
`SerializableError` (e.g. `NotFoundError`) — body is its `toJSON()` output; HTTP status is
|
|
191
|
+
the error's `statusCode`:
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
// production
|
|
195
|
+
{ "__type": "NotFoundError", "message": "User not found", "resource": "User",
|
|
196
|
+
"error": { "code": "NotFoundError", "message": "User not found", "requestId": "9f2c…" } }
|
|
197
|
+
|
|
198
|
+
// development (includeStack) adds:
|
|
199
|
+
{ "__type": "NotFoundError", "message": "User not found", "resource": "User",
|
|
200
|
+
"error": { "code": "NotFoundError", "message": "User not found", "requestId": "9f2c…" },
|
|
201
|
+
"stack": "Error: User not found\n at ..." }
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Standard error fallback:
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{ "__type": "Error", "message": "Internal Server Error",
|
|
208
|
+
"error": { "code": "Error", "message": "Internal Server Error", "requestId": "9f2c…" } }
|
|
209
|
+
// + "cause": "..." when the error has a cause
|
|
210
|
+
// + "stack": "..." only when includeStack is true
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
> The serialized field is **`__type`**, and the status code is carried by the **HTTP
|
|
214
|
+
> status**, not a body field. (Older docs showing `{ "error": "...", "statusCode": 400 }`
|
|
215
|
+
> are stale.)
|
|
216
|
+
|
|
217
|
+
#### The `error` envelope
|
|
218
|
+
|
|
219
|
+
Every error response also carries `error: { code, message, requestId }`. Two consumers read
|
|
220
|
+
one body: a TypeScript client restores the error class from `__type` through its error
|
|
221
|
+
registry, while a client generated for another language has no such registry and classifies
|
|
222
|
+
by `code` alone. `code` repeats `__type`; `message` repeats the body's message, masking and
|
|
223
|
+
all; `requestId` is `RequestLogger`'s id when one is set, and a fresh one otherwise.
|
|
224
|
+
|
|
225
|
+
`__type`, `message` and `error` are reserved — an error class declaring a field with one of
|
|
226
|
+
those names throws outside production (see `errors/README.md`).
|
|
227
|
+
|
|
228
|
+
Set `errorEnvelope: false` to leave the body exactly as it was before the envelope existed.
|
|
229
|
+
Only do that to keep bodies byte-identical to an older release; any route a generated client
|
|
230
|
+
calls needs the envelope.
|
|
231
|
+
|
|
232
|
+
### `OnErrorContext`
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
interface OnErrorContext {
|
|
236
|
+
statusCode: number;
|
|
237
|
+
path: string;
|
|
238
|
+
method: string;
|
|
239
|
+
requestId?: string; // present when RequestLogger ran first
|
|
240
|
+
timestamp: string; // ISO 8601
|
|
241
|
+
userId?: string; // from c.get('auth')?.userId, when auth middleware set it
|
|
242
|
+
request: {
|
|
243
|
+
headers: Record<string, string>; // sensitive headers masked to '***'
|
|
244
|
+
query: Record<string, string>;
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Masked request headers: `authorization`, `cookie`, `x-api-key`, `x-auth-token`
|
|
250
|
+
(case-insensitive). `requestId` and `userId` are only populated when the corresponding
|
|
251
|
+
upstream middleware (RequestLogger / auth) has run.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## RequestLogger
|
|
256
|
+
|
|
257
|
+
Per-request logging middleware. Register with **`app.use()`**.
|
|
258
|
+
|
|
259
|
+
### Behavior
|
|
260
|
+
|
|
261
|
+
- Generates a request ID (`req_<timestamp>_<6-byte hex>`) and stores it on the context:
|
|
262
|
+
`c.set('requestId', id)` → read via `c.get('requestId')`.
|
|
263
|
+
- Logs `Request received` (method, path, ip, userAgent) and `Request completed`
|
|
264
|
+
(status, duration). Client IP is taken from `x-forwarded-for` (first hop) → `x-real-ip` →
|
|
265
|
+
`'unknown'`.
|
|
266
|
+
- Log level by status: `info` (<400), `warn` (4xx), `error` (5xx). Logger child:
|
|
267
|
+
`@spfn/core:api`.
|
|
268
|
+
- **Slow requests** (`duration >= slowRequestThreshold`) get `slow: true`.
|
|
269
|
+
- **For 4xx/5xx**: clones the response to attach the error `response` body, and for
|
|
270
|
+
`POST`/`PUT`/`PATCH` attaches the masked request body (`request`).
|
|
271
|
+
- If the downstream throws, logs `Request failed` at `error` level **and re-throws**
|
|
272
|
+
(so `app.onError` / `ErrorHandler` still runs). It does not swallow errors.
|
|
273
|
+
|
|
274
|
+
### Options (`RequestLoggerOptions`)
|
|
275
|
+
|
|
276
|
+
| Option | Type | Default |
|
|
277
|
+
|--------|------|---------|
|
|
278
|
+
| `excludePaths` | `string[]` | `['/_core/health', '/health', '/ping', '/favicon.ico']` |
|
|
279
|
+
| `sensitiveFields` | `string[]` | `['password', 'token', 'apiKey', 'secret', 'authorization']` |
|
|
280
|
+
| `slowRequestThreshold` | `number` (ms) | `1000` |
|
|
281
|
+
|
|
282
|
+
`excludePaths` matches **exact or prefix** — `/health` also excludes `/health/db`. Excluded
|
|
283
|
+
paths skip logging entirely (and get **no** request ID).
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
app.use('*', RequestLogger({
|
|
287
|
+
excludePaths: ['/health', '/metrics', '/_next'],
|
|
288
|
+
sensitiveFields: ['password', 'creditCard', 'ssn'],
|
|
289
|
+
slowRequestThreshold: 500,
|
|
290
|
+
}));
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Reading the request ID
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
// in a route handler
|
|
297
|
+
const requestId = c.get('requestId'); // string | undefined
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Log output examples
|
|
301
|
+
|
|
302
|
+
```json
|
|
303
|
+
// Request received
|
|
304
|
+
{ "level": "info", "module": "api", "msg": "Request received",
|
|
305
|
+
"requestId": "req_1759541628730_qsm7esvo7", "method": "POST", "path": "/users",
|
|
306
|
+
"ip": "127.0.0.1", "userAgent": "..." }
|
|
307
|
+
|
|
308
|
+
// completed (success)
|
|
309
|
+
{ "level": "info", "module": "api", "msg": "Request completed",
|
|
310
|
+
"requestId": "req_...", "method": "POST", "path": "/users", "status": 201, "duration": 45 }
|
|
311
|
+
|
|
312
|
+
// completed (4xx — includes response body + masked request body)
|
|
313
|
+
{ "level": "warn", "module": "api", "msg": "Request completed",
|
|
314
|
+
"status": 400, "duration": 2,
|
|
315
|
+
"response": { "__type": "ValidationError", "message": "Invalid request body" },
|
|
316
|
+
"request": { "status": 123, "password": "***MASKED***" } }
|
|
317
|
+
|
|
318
|
+
// slow
|
|
319
|
+
{ "level": "info", "msg": "Request completed", "status": 200, "duration": 1250, "slow": true }
|
|
320
|
+
|
|
321
|
+
// downstream threw (then re-thrown to ErrorHandler)
|
|
322
|
+
{ "level": "error", "module": "api", "msg": "Request failed",
|
|
323
|
+
"method": "POST", "path": "/users", "duration": 23, "error": { ... } }
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## maskSensitiveData
|
|
329
|
+
|
|
330
|
+
Deep-masks fields whose name (case-insensitive) **contains** any of `sensitiveFields`,
|
|
331
|
+
returning a new structure. Used internally by `RequestLogger`; exported for reuse.
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
import { maskSensitiveData } from '@spfn/core/middleware';
|
|
335
|
+
|
|
336
|
+
maskSensitiveData(
|
|
337
|
+
{ username: 'john', password: 'secret', apiKey: 'sk_live_x' },
|
|
338
|
+
['password', 'apiKey'],
|
|
339
|
+
);
|
|
340
|
+
// → { username: 'john', password: '***MASKED***', apiKey: '***MASKED***' }
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
- **Partial + case-insensitive**: `['password']` masks `userPassword`, `PASSWORD`, etc.
|
|
344
|
+
- **Recursive**: descends into nested objects and arrays.
|
|
345
|
+
- **Immutable**: shallow-clones at each level; the input is untouched.
|
|
346
|
+
- **Circular-safe**: repeated references become `'[Circular]'` (via an internal `WeakSet`).
|
|
347
|
+
- Non-objects (`null`, primitives) are returned as-is.
|
|
348
|
+
|
|
349
|
+
Replacement token is the literal string `'***MASKED***'`.
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## rateLimit
|
|
354
|
+
|
|
355
|
+
Redis-backed **fixed-window** rate limiter. Three ways to apply it: attach per-route with
|
|
356
|
+
`.use([rateLimit({...})])`; enable a [global default](#global-default-limiter) for every route;
|
|
357
|
+
or tag a route with a [named policy](#named-policies--ratelimitpolicyname-fallback) the
|
|
358
|
+
consuming app tunes centrally. The global default and policy tags use the named `'rateLimit'`
|
|
359
|
+
middleware, so routes opt out with `.skip(['rateLimit'])`.
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
import { rateLimit, getClientIp } from '@spfn/core/middleware';
|
|
363
|
+
|
|
364
|
+
// 10 requests / minute per client IP (the default dimension)
|
|
365
|
+
route.post('/_auth/login')
|
|
366
|
+
.use([rateLimit({ limit: 10, windowMs: 60_000 })])
|
|
367
|
+
.handler(/* ... */);
|
|
368
|
+
|
|
369
|
+
// limit on more than one dimension — the strictest wins
|
|
370
|
+
route.post('/_auth/codes')
|
|
371
|
+
.use([rateLimit({
|
|
372
|
+
limit: 5,
|
|
373
|
+
windowMs: 60_000,
|
|
374
|
+
by: (c) => [getClientIp(c)],
|
|
375
|
+
})])
|
|
376
|
+
.handler(/* ... */);
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### Options (`RateLimitOptions`)
|
|
380
|
+
|
|
381
|
+
| Field | Type | Default | Notes |
|
|
382
|
+
|---|---|---|---|
|
|
383
|
+
| `limit` | `number` | — | Max requests per window, applied to **each** dimension. |
|
|
384
|
+
| `windowMs` | `number` | — | Window length in milliseconds. |
|
|
385
|
+
| `scope` | `string` | `` `${method} ${routePath}` `` | Counter-key namespace; defaults to per-route. |
|
|
386
|
+
| `by` | `(c) => (Dimension \| null \| undefined)[]` | `[getClientIp(c)]` | Identity dimensions; each non-empty value is counted separately, strictest wins. A `Dimension` is a `string` (uses `limit`) or `{ key, limit? }` to give that dimension its own limit. |
|
|
387
|
+
|
|
388
|
+
Per-dimension limits let one limiter be loose on IP and tight on account/target — so a
|
|
389
|
+
shared NAT isn't throttled as one user while a single account stays protected:
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
rateLimit({
|
|
393
|
+
limit: 5, // default for dimensions without their own limit
|
|
394
|
+
windowMs: 60_000,
|
|
395
|
+
by: (c) => [
|
|
396
|
+
{ key: `ip:${getClientIp(c)}`, limit: 100 }, // loose per-IP floor
|
|
397
|
+
accountKey(c), // tight per-account (uses limit: 5)
|
|
398
|
+
],
|
|
399
|
+
});
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`@spfn/auth` ships `byIpAndAccount()` / `byIpAndTarget()` builders for exactly this on its
|
|
403
|
+
login, register, and verification-code routes (per-account / per-target tight, per-IP loose),
|
|
404
|
+
so distributed brute force and SMS-bombing are limited by the thing being attacked, not only
|
|
405
|
+
by source IP.
|
|
406
|
+
| `failClosed` | `boolean` | `false` | Reject with 429 when the cache is unavailable instead of allowing through. |
|
|
407
|
+
| `message` | `string` | generic | 429 response message. |
|
|
408
|
+
|
|
409
|
+
### Behavior
|
|
410
|
+
|
|
411
|
+
- **Atomic**: counts via a single Lua `INCR` + `PEXPIRE`, so the expiry is never lost in
|
|
412
|
+
a race between two requests.
|
|
413
|
+
- **Fail-open by default**: when no cache is configured (or it is disabled), requests pass
|
|
414
|
+
and a warning is logged — matching the proxy-guard nonce store's graceful degradation, so
|
|
415
|
+
local dev without Redis still works. Set `failClosed: true` to reject instead.
|
|
416
|
+
- **On exceed**: throws `TooManyRequestsError` (429) and sets a `Retry-After` header derived
|
|
417
|
+
from the key's remaining TTL.
|
|
418
|
+
- **Storage**: keys are `ratelimit:{scope}:{dimension}` in the shared cache.
|
|
419
|
+
|
|
420
|
+
> **IP trust caveat**: `getClientIp` reads the leftmost `X-Forwarded-For` hop, which a client
|
|
421
|
+
> can spoof unless a trusted proxy overwrites it. For security-sensitive limits, pair the IP
|
|
422
|
+
> dimension with an account/target dimension rather than relying on IP alone.
|
|
423
|
+
|
|
424
|
+
### Global default limiter
|
|
425
|
+
|
|
426
|
+
Turn on a default limiter for **every** route from one place — no per-route `.use()`. It is
|
|
427
|
+
registered as the named `'rateLimit'` middleware, so a route opts out with `.skip(['rateLimit'])`.
|
|
428
|
+
Disabled by default (`mode: 'off'`).
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
export default defineServerConfig()
|
|
432
|
+
.rateLimit({
|
|
433
|
+
mode: 'on',
|
|
434
|
+
default: { limit: 100, windowMs: 60_000 },
|
|
435
|
+
})
|
|
436
|
+
.routes(appRouter)
|
|
437
|
+
.build();
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Or via env: `RATE_LIMIT_MODE=on`, `RATE_LIMIT_DEFAULT_LIMIT`, `RATE_LIMIT_DEFAULT_WINDOW_MS`,
|
|
441
|
+
`RATE_LIMIT_FAIL_CLOSED`. Health, SSE and WebSocket endpoints register outside the
|
|
442
|
+
named-middleware pipeline, so they are always exempt.
|
|
443
|
+
|
|
444
|
+
Counters live in the shared cache when `CACHE_URL` points at one, and in the process
|
|
445
|
+
otherwise — see [Limitations & operational notes](#limitations--operational-notes) for what
|
|
446
|
+
per-process counting costs.
|
|
447
|
+
|
|
448
|
+
### Named policies — `rateLimitPolicy(name, fallback)`
|
|
449
|
+
|
|
450
|
+
Lets a **package** tag a sensitive route while the **consuming app** tunes the numbers
|
|
451
|
+
centrally. The package ships the tag with a safe fallback; the app overrides it by name.
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
// in a package (e.g. @spfn/auth)
|
|
455
|
+
route.post('/_auth/login')
|
|
456
|
+
.use([rateLimitPolicy('auth-login', { limit: 5, windowMs: 60_000 })])
|
|
457
|
+
.handler(/* ... */);
|
|
458
|
+
|
|
459
|
+
// in the consuming app — tune every policy in one place
|
|
460
|
+
export default defineServerConfig()
|
|
461
|
+
.rateLimit({
|
|
462
|
+
policies: {
|
|
463
|
+
'auth-login': { limit: 10, windowMs: 60_000 },
|
|
464
|
+
},
|
|
465
|
+
})
|
|
466
|
+
.routes(appRouter)
|
|
467
|
+
.build();
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
- **Resolution**: configured policy (by name) wins, shallow-merged over the fallback; with no
|
|
471
|
+
config the fallback applies, so the route is protected out of the box.
|
|
472
|
+
- **Layered**: the tag registers under a distinct name (`rateLimit:<name>`) and does **not**
|
|
473
|
+
skip the global default, so a tagged route gets both — the global per-IP floor (when
|
|
474
|
+
enabled) *and* this policy's own bucket; whichever is stricter trips first. Opt out of the
|
|
475
|
+
floor on a route with `.skip(['rateLimit'])`.
|
|
476
|
+
- **Shared bucket**: a policy's counter scope defaults to its name, so every route sharing a
|
|
477
|
+
policy shares one bucket (e.g. all OAuth start routes share `oauth-start`). It also keeps
|
|
478
|
+
the key from colliding with the global default's per-route scope. Pass an explicit `scope`
|
|
479
|
+
to override.
|
|
480
|
+
- Policies are registered at server boot whether or not the global default is enabled.
|
|
481
|
+
|
|
482
|
+
### Limitations & operational notes
|
|
483
|
+
|
|
484
|
+
Read these before relying on rate limiting as a security control:
|
|
485
|
+
|
|
486
|
+
- **Default identity is the client IP.** A limiter with no `by` keys only on `getClientIp(c)`,
|
|
487
|
+
so a distributed attacker (many source IPs) isn't stopped per-account and a shared NAT/CGNAT
|
|
488
|
+
can be throttled as one user. For account/target protection (credential stuffing,
|
|
489
|
+
OTP/SMS-bombing) add a `by` dimension returning a stable account/target key alongside the IP
|
|
490
|
+
— ideally with a per-dimension limit (loose IP, tight account). The bundled `@spfn/auth`
|
|
491
|
+
login/register/code routes already do this via `byIpAndAccount()` / `byIpAndTarget()`.
|
|
492
|
+
- **`getClientIp` trusts forwarded headers.** Behind a verified proxy (proxy-guard) it uses
|
|
493
|
+
the real client IP; otherwise it reads `X-Forwarded-For`/`X-Real-IP`, which a direct client
|
|
494
|
+
can spoof (rotate to bypass, or pin to a victim to DoS them). With no forwarding header it
|
|
495
|
+
falls back to the TCP peer address (Node adapter), and only to the literal `'unknown'` when
|
|
496
|
+
even that is unavailable (non-Node runtime). Still: enable the global default behind a proxy
|
|
497
|
+
that sets a trustworthy client IP, since the header — when present — is taken on trust.
|
|
498
|
+
- **Counters fall back to memory, not to nothing.** With no cache configured — or with the
|
|
499
|
+
cache down, or a command failing mid-request — the limiter counts in the process
|
|
500
|
+
(`MemoryRateLimitStore`) instead of having nowhere to count. Limits still apply, but
|
|
501
|
+
**per process**: behind N instances the effective limit is N × the configured one, since
|
|
502
|
+
each keeps its own windows. Point `CACHE_URL` at Redis for the configured limit to be the
|
|
503
|
+
actual limit.
|
|
504
|
+
- **The memory store is bounded** at 10,000 live windows. Expired windows are dropped first;
|
|
505
|
+
if every window is still live the oldest is evicted, losing its count. `evictionCount`
|
|
506
|
+
going up means this process is past what an in-memory limiter should hold — that
|
|
507
|
+
deployment wants a real cache.
|
|
508
|
+
- **`RATE_LIMIT_FAIL_CLOSED=true` refuses instead of counting locally.** For a surface where a
|
|
509
|
+
per-process count is not an acceptable substitute for a shared one; a 429 is preferred to a
|
|
510
|
+
looser limit. It applies to **both** the global default and named policy tags (a tag may opt
|
|
511
|
+
out with `failClosed: false`). Note this rejects every request while the cache is away, so it
|
|
512
|
+
turns a cache outage into an auth outage — which is the point, but it is a deployment's call.
|
|
513
|
+
- **Counter scopes differ by layer.** The global default is keyed per-route
|
|
514
|
+
(`${method} ${routePath}`); a named policy is keyed by its **name**, so routes sharing a
|
|
515
|
+
policy share one bucket. Pass an explicit `scope` to change either.
|
|
516
|
+
- **The SSE token endpoint is not exempt.** Only the SSE *stream*, WebSocket, and health
|
|
517
|
+
endpoints (registered outside the named-middleware pipeline) bypass the global default;
|
|
518
|
+
`POST /events/token` runs `config.middlewares`, so it receives the limiter too.
|
|
519
|
+
|
|
520
|
+
---
|
|
521
|
+
|
|
522
|
+
## Pitfalls & anti-patterns
|
|
523
|
+
|
|
524
|
+
- **`ErrorHandler` goes on `app.onError()`, never `app.use()`.** It returns an
|
|
525
|
+
`(err, c) => Response` error hook, not a `(c, next)` middleware. Putting it in `use()`
|
|
526
|
+
(or `config.middlewares` / `config.use`) will not catch errors.
|
|
527
|
+
- **Don't double-register under `defineServerConfig`.** The server auto-applies both. Only
|
|
528
|
+
use the raw `app.use(RequestLogger())` / `app.onError(ErrorHandler())` calls on a bare
|
|
529
|
+
Hono app. To change RequestLogger options under SPFN, set `middleware.logger: false` and
|
|
530
|
+
add your own via `config.use`.
|
|
531
|
+
- **`config.middleware.onError` is the only ErrorHandler option the builder forwards.**
|
|
532
|
+
`includeStack` / `enableLogging` are not configurable through `defineServerConfig` — they
|
|
533
|
+
use defaults. Need them tuned? Build the Hono app manually.
|
|
534
|
+
- **RequestLogger must run before ErrorHandler** for `requestId` to appear in
|
|
535
|
+
`OnErrorContext`. The auto-config order (logger → … → onError) already guarantees this;
|
|
536
|
+
preserve it if wiring manually (`app.use(RequestLogger())` then `app.onError(...)`).
|
|
537
|
+
- **Excluded paths get no request ID.** `excludePaths` short-circuits before
|
|
538
|
+
`c.set('requestId')`, so handlers on `/health` etc. read `undefined`.
|
|
539
|
+
- **These are not "named middleware."** They have no `.skip()` name and cannot be skipped
|
|
540
|
+
per-route via the route DSL. Route-level skip applies only to `NamedMiddleware` created
|
|
541
|
+
with `defineMiddleware` (`@spfn/core/route`). To exclude paths from logging, use
|
|
542
|
+
`excludePaths`, not `.skip()`.
|
|
543
|
+
- **`sensitiveFields` matches by substring.** A field named `tokenize` is masked because it
|
|
544
|
+
contains `token`. Choose field names with that in mind.
|
|
545
|
+
- **Don't import from `@spfn/core`.** No root barrel exists; use `@spfn/core/middleware`.
|
|
546
|
+
- **`RequestLoggerConfig` is deprecated** — alias of `RequestLoggerOptions`.
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
550
|
+
## Complete example (raw Hono)
|
|
551
|
+
|
|
552
|
+
```typescript
|
|
553
|
+
import { Hono } from 'hono';
|
|
554
|
+
import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';
|
|
555
|
+
import { NotFoundError } from '@spfn/core/errors';
|
|
556
|
+
|
|
557
|
+
const app = new Hono();
|
|
558
|
+
|
|
559
|
+
// 1. RequestLogger first — assigns requestId, times every request
|
|
560
|
+
app.use('*', RequestLogger({
|
|
561
|
+
excludePaths: ['/health'],
|
|
562
|
+
slowRequestThreshold: 500,
|
|
563
|
+
}));
|
|
564
|
+
|
|
565
|
+
app.get('/users/:id', async (c) =>
|
|
566
|
+
{
|
|
567
|
+
const requestId = c.get('requestId');
|
|
568
|
+
const user = await findUser(c.req.param('id'));
|
|
569
|
+
|
|
570
|
+
if (!user)
|
|
571
|
+
{
|
|
572
|
+
throw new NotFoundError({ message: 'User not found', resource: 'User' });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return c.json({ requestId, user });
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// 2. ErrorHandler last — onError hook catches everything above
|
|
579
|
+
app.onError(ErrorHandler({
|
|
580
|
+
includeStack: process.env.NODE_ENV !== 'production',
|
|
581
|
+
onError: (err, ctx) => notify(ctx),
|
|
582
|
+
}));
|
|
583
|
+
|
|
584
|
+
export default app;
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
Under SPFN, the equivalent is just `defineServerConfig().routes(appRouter).build()` — both
|
|
588
|
+
middleware are added automatically.
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
## Types reference
|
|
593
|
+
|
|
594
|
+
```typescript
|
|
595
|
+
interface ErrorHandlerOptions {
|
|
596
|
+
includeStack?: boolean; // default: env.NODE_ENV !== 'production'
|
|
597
|
+
enableLogging?: boolean; // default: true
|
|
598
|
+
onError?: (err: Error, context: OnErrorContext) => Promise<void> | void;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
interface OnErrorContext {
|
|
602
|
+
statusCode: number;
|
|
603
|
+
path: string;
|
|
604
|
+
method: string;
|
|
605
|
+
requestId?: string;
|
|
606
|
+
timestamp: string;
|
|
607
|
+
userId?: string;
|
|
608
|
+
request: { headers: Record<string, string>; query: Record<string, string> };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
interface RequestLoggerOptions {
|
|
612
|
+
excludePaths?: string[]; // default: ['/_core/health', '/health', '/ping', '/favicon.ico']
|
|
613
|
+
sensitiveFields?: string[]; // default: ['password','token','apiKey','secret','authorization']
|
|
614
|
+
slowRequestThreshold?: number; // default: 1000 (ms)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
type RequestLoggerConfig = RequestLoggerOptions; // @deprecated
|
|
618
|
+
|
|
619
|
+
function maskSensitiveData(obj: any, sensitiveFields: string[], seen?: WeakSet<object>): any;
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
## Related
|
|
623
|
+
|
|
624
|
+
- [@spfn/core/route](../route/README.md) — `defineMiddleware` / `defineMiddlewareFactory`
|
|
625
|
+
(custom named middleware), route-level `.use()` / `.skip()` wiring and execution order.
|
|
626
|
+
- [@spfn/core/db](../db/README.md) — `Transactional()` route middleware
|
|
627
|
+
(auto commit/rollback).
|
|
628
|
+
- [@spfn/core/server](../server/README.md) — `defineServerConfig` / `config.middleware`,
|
|
629
|
+
which auto-registers `RequestLogger` + `ErrorHandler`.
|
|
630
|
+
- [@spfn/core/errors](../errors/README.md) — `SerializableError` and the built-in error
|
|
631
|
+
classes that `ErrorHandler` serializes.
|
|
632
|
+
- [@spfn/core/logger](../logger/README.md) — the logger both middleware write to.
|
|
633
|
+
</content>
|
|
634
|
+
</invoke>
|