@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.
Files changed (45) hide show
  1. package/README.md +183 -4
  2. package/dist/authz/index.js +1 -381
  3. package/dist/authz/index.js.map +1 -1
  4. package/dist/db/index.d.ts +173 -27
  5. package/dist/db/index.js +192 -57
  6. package/dist/db/index.js.map +1 -1
  7. package/dist/env/loader.js +24 -1
  8. package/dist/env/loader.js.map +1 -1
  9. package/dist/errors/index.js +1 -381
  10. package/dist/errors/index.js.map +1 -1
  11. package/dist/logger/index.js +0 -12
  12. package/dist/logger/index.js.map +1 -1
  13. package/dist/middleware/index.js +6 -387
  14. package/dist/middleware/index.js.map +1 -1
  15. package/dist/nextjs/index.d.ts +18 -1
  16. package/dist/nextjs/index.js +40 -1
  17. package/dist/nextjs/index.js.map +1 -1
  18. package/dist/nextjs/server.d.ts +34 -1
  19. package/dist/nextjs/server.js +14 -0
  20. package/dist/nextjs/server.js.map +1 -1
  21. package/dist/ops/index.d.ts +61 -6
  22. package/dist/ops/index.js +330 -30
  23. package/dist/ops/index.js.map +1 -1
  24. package/dist/server/index.js +24 -1
  25. package/dist/server/index.js.map +1 -1
  26. package/docs/file-upload.md +195 -333
  27. package/package.json +6 -5
  28. package/src/cache/README.md +330 -0
  29. package/src/codegen/README.md +516 -0
  30. package/src/config/README.md +326 -0
  31. package/src/contract/README.md +326 -0
  32. package/src/db/README.md +589 -0
  33. package/src/db/manager/README.md +500 -0
  34. package/src/db/schema/README.md +344 -0
  35. package/src/db/transaction/README.md +822 -0
  36. package/src/env/README.md +651 -0
  37. package/src/errors/README.md +429 -0
  38. package/src/event/README.md +736 -0
  39. package/src/job/README.md +514 -0
  40. package/src/logger/README.md +321 -0
  41. package/src/middleware/README.md +634 -0
  42. package/src/nextjs/README.md +608 -0
  43. package/src/route/README.md +738 -0
  44. package/src/security/README.md +100 -0
  45. 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>