@nifrajs/middleware 1.6.0 → 1.11.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.
Files changed (50) hide show
  1. package/dist/admission.d.ts +92 -0
  2. package/dist/admission.d.ts.map +1 -0
  3. package/dist/admission.js +218 -0
  4. package/dist/admission.js.map +1 -0
  5. package/dist/basic-auth.d.ts.map +1 -1
  6. package/dist/basic-auth.js +9 -1
  7. package/dist/basic-auth.js.map +1 -1
  8. package/dist/body-limit.d.ts +1 -1
  9. package/dist/body-limit.d.ts.map +1 -1
  10. package/dist/body-limit.js +15 -1
  11. package/dist/body-limit.js.map +1 -1
  12. package/dist/csrf.d.ts +1 -1
  13. package/dist/csrf.d.ts.map +1 -1
  14. package/dist/csrf.js +13 -1
  15. package/dist/csrf.js.map +1 -1
  16. package/dist/idempotency.d.ts +1 -1
  17. package/dist/idempotency.d.ts.map +1 -1
  18. package/dist/idempotency.js +9 -1
  19. package/dist/idempotency.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/ip-restriction.d.ts.map +1 -1
  25. package/dist/ip-restriction.js +9 -3
  26. package/dist/ip-restriction.js.map +1 -1
  27. package/dist/jwt.d.ts.map +1 -1
  28. package/dist/jwt.js +9 -1
  29. package/dist/jwt.js.map +1 -1
  30. package/dist/rate-limit.d.ts.map +1 -1
  31. package/dist/rate-limit.js +7 -1
  32. package/dist/rate-limit.js.map +1 -1
  33. package/dist/security-headers.d.ts.map +1 -1
  34. package/dist/security-headers.js +7 -2
  35. package/dist/security-headers.js.map +1 -1
  36. package/dist/token-auth.d.ts.map +1 -1
  37. package/dist/token-auth.js +9 -1
  38. package/dist/token-auth.js.map +1 -1
  39. package/package.json +3 -3
  40. package/src/admission.ts +329 -0
  41. package/src/basic-auth.ts +9 -1
  42. package/src/body-limit.ts +15 -2
  43. package/src/csrf.ts +14 -2
  44. package/src/idempotency.ts +9 -2
  45. package/src/index.ts +12 -0
  46. package/src/ip-restriction.ts +24 -13
  47. package/src/jwt.ts +9 -1
  48. package/src/rate-limit.ts +7 -1
  49. package/src/security-headers.ts +21 -13
  50. package/src/token-auth.ts +9 -1
@@ -0,0 +1,329 @@
1
+ import type { AdmissionController, AdmissionDecision } from "@nifrajs/core"
2
+
3
+ /**
4
+ * Adaptive capacity admission. Rate limiting bounds request *frequency* and `@nifrajs/budget` bounds
5
+ * request *duration*; neither stops a healthy instance from accepting more *concurrent* work than it
6
+ * can finish. This gate admits on live capacity evidence — in-flight count + event-loop lag — briefly
7
+ * queues at the edge, and sheds the rest with `429` + `Retry-After`, so p99 stays bounded under load
8
+ * instead of collapsing.
9
+ *
10
+ * Public mechanics (in-flight + loop lag) know nothing about tenants. An application-supplied {@link AdmissionPolicy}
11
+ * hook layers tenant priority and reserved capacity on top without leaking
12
+ * those concerns into the OSS core.
13
+ *
14
+ * Wire it as the server's `admission` option (NOT an `onRequest` hook — a hook disables the native
15
+ * route table). Off by default: when unset, the request path is untouched.
16
+ */
17
+
18
+ export type ShedReason = "inflight" | "loop-lag" | "queue-timeout" | "policy" | "cancelled"
19
+
20
+ /** Pure capacity evidence handed to the policy hook. The mechanics never invent tenant concepts. */
21
+ export interface AdmissionEvidence {
22
+ readonly inFlight: number
23
+ readonly maxInFlight: number
24
+ readonly lagMs: number
25
+ readonly maxLagMs: number
26
+ readonly queued: number
27
+ }
28
+
29
+ /**
30
+ * Application-supplied admission policy. Return a decision to override the default mechanics
31
+ * for this request, or `undefined` to defer to them. `admit` may draw from reserved headroom above
32
+ * `maxInFlight`; `shed` forces rejection.
33
+ */
34
+ export type AdmissionPolicy = (
35
+ req: Request,
36
+ evidence: AdmissionEvidence,
37
+ ) => { decision: "admit" | "shed"; retryAfterSec?: number } | undefined
38
+
39
+ export interface AdmissionOptions {
40
+ /** Max requests running concurrently before the gate queues or sheds. */
41
+ readonly maxInFlight: number
42
+ /** Event-loop lag ceiling (ms); above it, shed even when slots are free (protects p99). Default ∞. */
43
+ readonly maxLagMs?: number
44
+ /** How many requests may briefly wait for a slot before shedding. `0` (default) never queues. */
45
+ readonly maxQueue?: number
46
+ /** How long a queued request waits for a slot before shedding. Default 50ms. */
47
+ readonly queueTimeoutMs?: number
48
+ /** Reserved slots ABOVE `maxInFlight` that only a policy `admit` may draw from. Default 0. */
49
+ readonly reservedForPolicy?: number
50
+ /** Base `Retry-After`, in seconds, for shed responses. Default 1. */
51
+ readonly baseRetryAfterSec?: number
52
+ /**
53
+ * Live event-loop lag source, in ms. Pass {@link createEventLoopLagSampler} to sample the real loop;
54
+ * default `() => 0` disables lag-based shedding (in-flight only).
55
+ */
56
+ readonly lagMs?: () => number
57
+ readonly policy?: AdmissionPolicy
58
+ /** Test seam for deterministic queue timeouts; defaults to real timers. */
59
+ readonly setTimer?: (fn: () => void, ms: number) => { cancel(): void }
60
+ }
61
+
62
+ export interface AdmissionSnapshot {
63
+ readonly inFlight: number
64
+ readonly queued: number
65
+ readonly fastPathAdmits: number
66
+ readonly slowPathEntries: number
67
+ readonly everQueued: number
68
+ readonly shed: number
69
+ }
70
+
71
+ export interface AdmissionControllerHandle extends AdmissionController {
72
+ /** Point-in-time counters for observability (otel gauges/counters). */
73
+ snapshot(): AdmissionSnapshot
74
+ }
75
+
76
+ interface Waiter {
77
+ settled: boolean
78
+ transfer(): void
79
+ shed(): void
80
+ cancel(): void
81
+ cleanup(): void
82
+ timer: { cancel(): void }
83
+ }
84
+
85
+ const OVERLOADED_BODY = JSON.stringify({ ok: false, error: "overloaded" })
86
+
87
+ /** The slice of a `perf_hooks` event-loop-delay histogram the sampler needs. */
88
+ export interface LoopDelayHistogram {
89
+ enable(): void
90
+ reset(): void
91
+ readonly mean: number
92
+ }
93
+
94
+ /**
95
+ * Acquires a loop-delay histogram for a resolution, or `undefined` when the runtime has none. This is
96
+ * an optional test/runtime seam; the default sampler is a portable timer-drift monitor.
97
+ */
98
+ export type LoopDelayMonitor = (resolutionMs: number) => LoopDelayHistogram | undefined
99
+
100
+ /** Shared no-op sampler — the fallback when an explicitly supplied monitor has no histogram. */
101
+ const NO_LAG: () => number = () => 0
102
+
103
+ /**
104
+ * Event-loop-lag sampler. By default it measures timer drift using only Web/JS runtime primitives, so
105
+ * it works under Node ESM, Bun, Deno, and workers without a hidden CommonJS `require` fallback. An
106
+ * injected histogram remains available for deterministic tests or a runtime-native monitor. Each read
107
+ * returns recent mean lag and resets the sampling window.
108
+ */
109
+ export function createEventLoopLagSampler(
110
+ resolutionMs = 20,
111
+ monitor?: LoopDelayMonitor,
112
+ ): () => number {
113
+ if (!Number.isFinite(resolutionMs) || resolutionMs <= 0) {
114
+ throw new RangeError("admission: resolutionMs must be a finite positive number")
115
+ }
116
+ if (monitor === undefined) {
117
+ let expected = performance.now() + resolutionMs
118
+ let totalLagMs = 0
119
+ let samples = 0
120
+ const timer = setInterval(() => {
121
+ const now = performance.now()
122
+ totalLagMs += Math.max(0, now - expected)
123
+ samples++
124
+ // Reset from `now` so one long stall is measured once instead of as a train of catch-up ticks.
125
+ expected = now + resolutionMs
126
+ }, resolutionMs)
127
+ // A metrics sampler must never keep a Node/Bun process alive by itself. Browser/Deno numeric timer
128
+ // handles simply do not expose `unref`.
129
+ ;(timer as ReturnType<typeof setInterval> & { unref?: () => void }).unref?.()
130
+ return () => {
131
+ const mean = samples === 0 ? 0 : totalLagMs / samples
132
+ totalLagMs = 0
133
+ samples = 0
134
+ return Number.isFinite(mean) ? mean : 0
135
+ }
136
+ }
137
+ let histogram: LoopDelayHistogram | undefined
138
+ try {
139
+ histogram = monitor(resolutionMs)
140
+ } catch {
141
+ return NO_LAG
142
+ }
143
+ if (histogram === undefined) return NO_LAG
144
+ histogram.enable()
145
+ return () => {
146
+ const meanNs = histogram.mean
147
+ histogram.reset()
148
+ return Number.isFinite(meanNs) ? Math.max(0, meanNs / 1e6 - resolutionMs) : 0
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Build a capacity-admission controller. Pass the returned handle as the server's `admission` option.
154
+ */
155
+ export function createAdmissionController(options: AdmissionOptions): AdmissionControllerHandle {
156
+ const maxInFlight = options.maxInFlight
157
+ if (!Number.isInteger(maxInFlight) || maxInFlight < 1) {
158
+ throw new Error("admission: maxInFlight must be a positive integer")
159
+ }
160
+ const maxLagMs = options.maxLagMs ?? Number.POSITIVE_INFINITY
161
+ if (Number.isNaN(maxLagMs) || maxLagMs < 0) {
162
+ throw new Error("admission: maxLagMs must be a non-negative number")
163
+ }
164
+ const maxQueue = options.maxQueue ?? 0
165
+ if (!Number.isInteger(maxQueue) || maxQueue < 0) {
166
+ throw new Error("admission: maxQueue must be a non-negative integer")
167
+ }
168
+ const queueTimeoutMs = options.queueTimeoutMs ?? 50
169
+ const reserved = options.reservedForPolicy ?? 0
170
+ const baseRetryAfterSec = options.baseRetryAfterSec ?? 1
171
+ if (!Number.isFinite(queueTimeoutMs) || queueTimeoutMs < 0) {
172
+ throw new Error("admission: queueTimeoutMs must be a finite non-negative number")
173
+ }
174
+ if (!Number.isInteger(reserved) || reserved < 0) {
175
+ throw new Error("admission: reservedForPolicy must be a non-negative integer")
176
+ }
177
+ if (!Number.isFinite(baseRetryAfterSec) || baseRetryAfterSec <= 0) {
178
+ throw new Error("admission: baseRetryAfterSec must be a finite positive number")
179
+ }
180
+ const lagMs = options.lagMs ?? (() => 0)
181
+ const policy = options.policy
182
+ const setTimer =
183
+ options.setTimer ??
184
+ ((fn, ms) => {
185
+ const id = setTimeout(fn, ms)
186
+ return { cancel: () => clearTimeout(id) }
187
+ })
188
+
189
+ let inFlight = 0
190
+ const queue: Waiter[] = []
191
+ const stats = { fastPathAdmits: 0, slowPathEntries: 0, everQueued: 0, shed: 0 }
192
+
193
+ const shedResponse = (
194
+ reason: ShedReason,
195
+ retryAfterSec = baseRetryAfterSec,
196
+ ): AdmissionDecision => {
197
+ const retry =
198
+ Number.isFinite(retryAfterSec) && retryAfterSec >= 0 ? retryAfterSec : baseRetryAfterSec
199
+ stats.shed++
200
+ return {
201
+ admitted: false,
202
+ response: new Response(OVERLOADED_BODY, {
203
+ status: 429,
204
+ headers: {
205
+ "content-type": "application/json",
206
+ "retry-after": String(Math.max(1, Math.ceil(retry))),
207
+ "x-nifra-shed-reason": reason,
208
+ },
209
+ }),
210
+ }
211
+ }
212
+
213
+ const cancelledResponse = (): AdmissionDecision => ({
214
+ admitted: false,
215
+ response: new Response(JSON.stringify({ ok: false, error: "request_cancelled" }), {
216
+ status: 499,
217
+ headers: {
218
+ "content-type": "application/json",
219
+ "x-nifra-shed-reason": "cancelled",
220
+ },
221
+ }),
222
+ })
223
+
224
+ // A release handle over an already-occupied slot. Decrement first, then transfer only when total
225
+ // occupancy is below the public limit. This matters when policy traffic occupies reserved headroom:
226
+ // releasing a reserved slot must NOT hand that capacity to an ordinary queued request.
227
+ const makeHandle = (): AdmissionDecision => {
228
+ let released = false
229
+ return {
230
+ admitted: true,
231
+ release() {
232
+ if (released) return
233
+ released = true
234
+ inFlight--
235
+ while (inFlight < maxInFlight && queue.length > 0) {
236
+ const next = queue.shift() as Waiter
237
+ if (next.settled) continue
238
+ next.settled = true
239
+ next.cleanup()
240
+ inFlight++
241
+ next.transfer()
242
+ return
243
+ }
244
+ },
245
+ }
246
+ }
247
+
248
+ /** Take a brand-new slot (fast path, policy admit, reserved headroom). */
249
+ const occupy = (): AdmissionDecision => {
250
+ inFlight++
251
+ return makeHandle()
252
+ }
253
+
254
+ function admit(req: Request): AdmissionDecision | Promise<AdmissionDecision> {
255
+ const measuredLag = lagMs()
256
+ // Invalid capacity evidence must not silently turn protection off.
257
+ const lag =
258
+ Number.isFinite(measuredLag) && measuredLag >= 0 ? measuredLag : Number.POSITIVE_INFINITY
259
+ const evidence: AdmissionEvidence = {
260
+ inFlight,
261
+ maxInFlight,
262
+ lagMs: lag,
263
+ maxLagMs,
264
+ queued: queue.length,
265
+ }
266
+
267
+ // Policy hook first: may force-admit (reserved headroom) or force-shed.
268
+ if (policy !== undefined) {
269
+ const verdict = policy(req, evidence)
270
+ if (verdict?.decision === "shed")
271
+ return shedResponse("policy", verdict.retryAfterSec ?? baseRetryAfterSec)
272
+ if (verdict?.decision === "admit") {
273
+ return inFlight < maxInFlight + reserved ? occupy() : shedResponse("policy")
274
+ }
275
+ }
276
+
277
+ // Loop-lag shed: the box is behind on the event loop — reject regardless of slot count.
278
+ if (lag > maxLagMs) return shedResponse("loop-lag")
279
+
280
+ // FAST PATH (O(1)): a slot is free and lag is fine. No timer, no queue, no extra await.
281
+ if (inFlight < maxInFlight) {
282
+ stats.fastPathAdmits++
283
+ return occupy()
284
+ }
285
+
286
+ // SLOW PATH: saturated. Briefly queue, else shed.
287
+ stats.slowPathEntries++
288
+ if (maxQueue <= 0 || queue.length >= maxQueue) return shedResponse("inflight")
289
+ stats.everQueued++
290
+ return new Promise<AdmissionDecision>((resolve) => {
291
+ const remove = (waiter: Waiter): void => {
292
+ const i = queue.indexOf(waiter)
293
+ if (i >= 0) queue.splice(i, 1)
294
+ }
295
+ const waiter: Waiter = {
296
+ settled: false,
297
+ transfer: () => resolve(makeHandle()),
298
+ shed: () => resolve(shedResponse("queue-timeout")),
299
+ cancel: () => resolve(cancelledResponse()),
300
+ cleanup: () => {
301
+ waiter.timer.cancel()
302
+ req.signal.removeEventListener("abort", onAbort)
303
+ },
304
+ timer: setTimer(() => {
305
+ if (waiter.settled) return
306
+ waiter.settled = true
307
+ remove(waiter)
308
+ waiter.cleanup()
309
+ waiter.shed()
310
+ }, queueTimeoutMs),
311
+ }
312
+ const onAbort = (): void => {
313
+ if (waiter.settled) return
314
+ waiter.settled = true
315
+ remove(waiter)
316
+ waiter.cleanup()
317
+ waiter.cancel()
318
+ }
319
+ queue.push(waiter)
320
+ req.signal.addEventListener("abort", onAbort, { once: true })
321
+ if (req.signal.aborted) onAbort()
322
+ })
323
+ }
324
+
325
+ return {
326
+ admit,
327
+ snapshot: () => ({ inFlight, queued: queue.length, ...stats }),
328
+ }
329
+ }
package/src/basic-auth.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { definePlugin, type NifraPlugin } from "@nifrajs/core"
2
+ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
3
  import {
3
4
  decodeBase64,
4
5
  jsonError,
@@ -98,7 +99,14 @@ export function basicAuth<P>(
98
99
  return optional ? undefined : reject(realm)
99
100
  }),
100
101
  )
101
- return Object.assign(plugin, {
102
+ const instrumented = optional
103
+ ? plugin
104
+ : withRouteAssurance(plugin, {
105
+ id: NIFRA_ASSURANCE.AUTHENTICATED,
106
+ source: "basicAuth",
107
+ scope: "subsequent",
108
+ })
109
+ return Object.assign(instrumented, {
102
110
  principal: (request: Request): P | string | null => store.get(request) ?? null,
103
111
  requirePrincipal: (request: Request): P | string => {
104
112
  const principal = store.get(request)
package/src/body-limit.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Middleware } from "@nifrajs/core"
1
+ import { METHODS, type Middleware } from "@nifrajs/core"
2
+ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
3
  import { jsonError, SAFE_METHODS } from "./_utils.ts"
3
4
 
4
5
  export interface BodyLimitOptions {
@@ -35,7 +36,7 @@ export function bodyLimit(options: BodyLimitOptions): Middleware {
35
36
  const error = options.error ?? "payload_too_large"
36
37
  const allowLengthless = options.allowLengthless === true
37
38
 
38
- return {
39
+ const middleware: Middleware = {
39
40
  name: "body-limit",
40
41
  onRequest(req) {
41
42
  if (methods !== undefined ? !methods.has(req.method) : SAFE_METHODS.has(req.method)) {
@@ -50,4 +51,16 @@ export function bodyLimit(options: BodyLimitOptions): Middleware {
50
51
  return undefined
51
52
  },
52
53
  }
54
+ // Lengthless bodies are deliberately allowed in this mode, so claiming BODY_BOUNDED for the
55
+ // whole route would be false. The middleware still enforces declared Content-Length at runtime.
56
+ if (allowLengthless) return middleware
57
+ return withRouteAssurance(middleware, {
58
+ id: NIFRA_ASSURANCE.BODY_BOUNDED,
59
+ source: "body-limit",
60
+ scope: "global",
61
+ methods:
62
+ options.methods === undefined
63
+ ? ["POST", "PUT", "PATCH", "DELETE"]
64
+ : METHODS.filter((method) => methods?.has(method)),
65
+ })
53
66
  }
package/src/csrf.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Middleware } from "@nifrajs/core"
1
+ import { METHODS, type Middleware } from "@nifrajs/core"
2
+ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
3
  import {
3
4
  base64UrlEncode,
4
5
  hmacSha256,
@@ -84,7 +85,7 @@ export function csrf(options: CsrfOptions): Middleware {
84
85
  const origins = options.origins !== undefined ? new Set(options.origins) : undefined
85
86
  const checkOrigin = options.checkOrigin !== false
86
87
 
87
- return {
88
+ const middleware: Middleware = {
88
89
  name: "csrf",
89
90
  async onRequest(req) {
90
91
  if (!protectedMethod(req.method, methods)) return undefined
@@ -99,4 +100,15 @@ export function csrf(options: CsrfOptions): Middleware {
99
100
  return (await verifyCsrfToken(cookieToken, key)) ? undefined : jsonError(403, "csrf_failed")
100
101
  },
101
102
  }
103
+ return withRouteAssurance(middleware, {
104
+ id: NIFRA_ASSURANCE.CSRF,
105
+ source: "csrf",
106
+ scope: "global",
107
+ // Middleware may intentionally mention methods the Nifra router does not expose (for example
108
+ // TRACE). Keep runtime behavior unchanged, but publish evidence only for registerable routes.
109
+ methods:
110
+ options.methods === undefined
111
+ ? ["POST", "PUT", "PATCH", "DELETE"]
112
+ : METHODS.filter((method) => methods?.has(method)),
113
+ })
102
114
  }
@@ -1,4 +1,5 @@
1
- import type { Middleware } from "@nifrajs/core"
1
+ import { METHODS, type Middleware } from "@nifrajs/core"
2
+ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
3
 
3
4
  /**
4
5
  * Idempotency keys for unsafe requests — a client retrying a `POST` (dropped connection, impatient
@@ -280,7 +281,7 @@ export function idempotency(options: IdempotencyOptions): Middleware {
280
281
  const keyOf = options.key ?? defaultIdempotencyKey
281
282
  const claimed = new WeakMap<Request, string>()
282
283
 
283
- return {
284
+ const middleware: Middleware = {
284
285
  name: "idempotency",
285
286
  async onRequest(req) {
286
287
  if (!methods.has(req.method.toUpperCase())) return undefined
@@ -326,4 +327,10 @@ export function idempotency(options: IdempotencyOptions): Middleware {
326
327
  return captured.response
327
328
  },
328
329
  }
330
+ return withRouteAssurance(middleware, {
331
+ id: NIFRA_ASSURANCE.IDEMPOTENCY_KEY,
332
+ source: "idempotency",
333
+ scope: "global",
334
+ methods: METHODS.filter((method) => methods.has(method)),
335
+ })
329
336
  }
package/src/index.ts CHANGED
@@ -6,6 +6,18 @@
6
6
  * `c.requestId` into the handler context.
7
7
  */
8
8
 
9
+ export {
10
+ type AdmissionControllerHandle,
11
+ type AdmissionEvidence,
12
+ type AdmissionOptions,
13
+ type AdmissionPolicy,
14
+ type AdmissionSnapshot,
15
+ createAdmissionController,
16
+ createEventLoopLagSampler,
17
+ type LoopDelayHistogram,
18
+ type LoopDelayMonitor,
19
+ type ShedReason,
20
+ } from "./admission.ts"
9
21
  export {
10
22
  type BasicAuthPlugin,
11
23
  type BasicAuthStaticOptions,
@@ -1,4 +1,5 @@
1
1
  import type { Middleware } from "@nifrajs/core"
2
+ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
3
  import { jsonError, type MaybePromise } from "./_utils.ts"
3
4
 
4
5
  export type IpMatcher = string | ((ip: string, request: Request) => MaybePromise<boolean>)
@@ -170,18 +171,28 @@ export function ipRestriction(options: IpRestrictionOptions): Middleware {
170
171
  }
171
172
  const error = options.error ?? "ip_forbidden"
172
173
 
173
- return {
174
- name: "ip-restriction",
175
- async onRequest(req) {
176
- const ipText = await resolveClientIp(req, options)
177
- if (ipText === null) return jsonError(403, error)
178
- const ip = parseIp(ipText)
179
- if (ip === null) return jsonError(403, error)
180
- if (await matches(ipText, ip, deny, req)) return jsonError(403, error)
181
- if (allow.ranges.length + allow.fns.length > 0 && !(await matches(ipText, ip, allow, req))) {
182
- return jsonError(403, error)
183
- }
184
- return undefined
174
+ return withRouteAssurance<Middleware>(
175
+ {
176
+ name: "ip-restriction",
177
+ async onRequest(req) {
178
+ const ipText = await resolveClientIp(req, options)
179
+ if (ipText === null) return jsonError(403, error)
180
+ const ip = parseIp(ipText)
181
+ if (ip === null) return jsonError(403, error)
182
+ if (await matches(ipText, ip, deny, req)) return jsonError(403, error)
183
+ if (
184
+ allow.ranges.length + allow.fns.length > 0 &&
185
+ !(await matches(ipText, ip, allow, req))
186
+ ) {
187
+ return jsonError(403, error)
188
+ }
189
+ return undefined
190
+ },
185
191
  },
186
- }
192
+ {
193
+ id: NIFRA_ASSURANCE.IP_RESTRICTED,
194
+ source: "ip-restriction",
195
+ scope: "global",
196
+ },
197
+ )
187
198
  }
package/src/jwt.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { definePlugin, type NifraPlugin } from "@nifrajs/core"
2
+ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
3
  import {
3
4
  base64UrlDecode,
4
5
  jsonError,
@@ -318,7 +319,14 @@ export function jwt<C extends JwtClaims = JwtClaims>(options: JwtOptions): JwtPl
318
319
  }
319
320
  }),
320
321
  )
321
- return Object.assign(plugin, {
322
+ const instrumented = optional
323
+ ? plugin
324
+ : withRouteAssurance(plugin, {
325
+ id: NIFRA_ASSURANCE.AUTHENTICATED,
326
+ source: "jwt",
327
+ scope: "subsequent",
328
+ })
329
+ return Object.assign(instrumented, {
322
330
  claims: (request: Request): C | null => store.get(request) ?? null,
323
331
  requireClaims: (request: Request): C => {
324
332
  const claims = store.get(request)
package/src/rate-limit.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Middleware } from "@nifrajs/core"
2
+ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
3
 
3
4
  export interface RateLimitResult {
4
5
  /** Hits recorded in the current window, including this one. */
@@ -216,7 +217,7 @@ export function rateLimit(options: RateLimitOptions): Middleware {
216
217
  options.key ?? ((req: Request) => defaultKey(req, trustedProxies, header, allowGlobalKey))
217
218
  const quota = new WeakMap<Request, { remaining: number; resetSeconds: number }>()
218
219
 
219
- return {
220
+ const middleware: Middleware = {
220
221
  name: "rate-limit",
221
222
  async onRequest(req) {
222
223
  const key = keyOf(req)
@@ -245,4 +246,9 @@ export function rateLimit(options: RateLimitOptions): Middleware {
245
246
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
246
247
  },
247
248
  }
249
+ return withRouteAssurance(middleware, {
250
+ id: NIFRA_ASSURANCE.RATE_LIMITED,
251
+ source: "rate-limit",
252
+ scope: "global",
253
+ })
248
254
  }
@@ -1,4 +1,5 @@
1
1
  import type { Middleware } from "@nifrajs/core"
2
+ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
3
  import { withHeaders } from "./_utils.ts"
3
4
 
4
5
  export interface SecurityHeadersOptions {
@@ -35,17 +36,24 @@ export function securityHeaders(options: SecurityHeadersOptions = {}): Middlewar
35
36
  hstsValue = parts.join("; ")
36
37
  }
37
38
 
38
- return {
39
- name: "security-headers",
40
- // Mutate in place on the common (mutable-headers) response; clone only for an immutable one
41
- // (see withHeaders — the old always-clone was ~3% of a realistic request).
42
- onResponse: (res) =>
43
- withHeaders(res, (headers) => {
44
- headers.set("X-Content-Type-Options", "nosniff")
45
- headers.set("X-Frame-Options", frameOptions)
46
- headers.set("Referrer-Policy", referrerPolicy)
47
- if (hstsValue !== undefined) headers.set("Strict-Transport-Security", hstsValue)
48
- if (csp !== undefined) headers.set("Content-Security-Policy", csp)
49
- }),
50
- }
39
+ return withRouteAssurance<Middleware>(
40
+ {
41
+ name: "security-headers",
42
+ // Mutate in place on the common (mutable-headers) response; clone only for an immutable one
43
+ // (see withHeaders — the old always-clone was ~3% of a realistic request).
44
+ onResponse: (res) =>
45
+ withHeaders(res, (headers) => {
46
+ headers.set("X-Content-Type-Options", "nosniff")
47
+ headers.set("X-Frame-Options", frameOptions)
48
+ headers.set("Referrer-Policy", referrerPolicy)
49
+ if (hstsValue !== undefined) headers.set("Strict-Transport-Security", hstsValue)
50
+ if (csp !== undefined) headers.set("Content-Security-Policy", csp)
51
+ }),
52
+ },
53
+ {
54
+ id: NIFRA_ASSURANCE.SECURITY_HEADERS,
55
+ source: "security-headers",
56
+ scope: "global",
57
+ },
58
+ )
51
59
  }
package/src/token-auth.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { definePlugin, type NifraPlugin } from "@nifrajs/core"
2
+ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
3
 
3
4
  type MaybePromise<T> = T | Promise<T>
4
5
 
@@ -59,7 +60,14 @@ function createTokenAuth<P>(config: TokenAuthConfig<P>): AuthPlugin<P> {
59
60
  return config.optional ? undefined : reject() // 401 short-circuits (skips the handler)
60
61
  }),
61
62
  )
62
- return Object.assign(plugin, {
63
+ const instrumented = config.optional
64
+ ? plugin
65
+ : withRouteAssurance(plugin, {
66
+ id: NIFRA_ASSURANCE.AUTHENTICATED,
67
+ source: config.name,
68
+ scope: "subsequent",
69
+ })
70
+ return Object.assign(instrumented, {
63
71
  principal: (request: Request): P | null => store.get(request) ?? null,
64
72
  requirePrincipal: (request: Request): P => {
65
73
  const principal = store.get(request)