@nifrajs/middleware 0.1.0-alpha.1

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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/_utils.d.ts +27 -0
  4. package/dist/_utils.d.ts.map +1 -0
  5. package/dist/_utils.js +141 -0
  6. package/dist/_utils.js.map +1 -0
  7. package/dist/basic-auth.d.ts +27 -0
  8. package/dist/basic-auth.d.ts.map +1 -0
  9. package/dist/basic-auth.js +64 -0
  10. package/dist/basic-auth.js.map +1 -0
  11. package/dist/body-limit.d.ts +19 -0
  12. package/dist/body-limit.d.ts.map +1 -0
  13. package/dist/body-limit.js +42 -0
  14. package/dist/body-limit.js.map +1 -0
  15. package/dist/cache-control.d.ts +23 -0
  16. package/dist/cache-control.d.ts.map +1 -0
  17. package/dist/cache-control.js +35 -0
  18. package/dist/cache-control.js.map +1 -0
  19. package/dist/cache.d.ts +60 -0
  20. package/dist/cache.d.ts.map +1 -0
  21. package/dist/cache.js +224 -0
  22. package/dist/cache.js.map +1 -0
  23. package/dist/combine.d.ts +7 -0
  24. package/dist/combine.d.ts.map +1 -0
  25. package/dist/combine.js +21 -0
  26. package/dist/combine.js.map +1 -0
  27. package/dist/compression.d.ts +29 -0
  28. package/dist/compression.d.ts.map +1 -0
  29. package/dist/compression.js +105 -0
  30. package/dist/compression.js.map +1 -0
  31. package/dist/context-storage.d.ts +14 -0
  32. package/dist/context-storage.d.ts.map +1 -0
  33. package/dist/context-storage.js +30 -0
  34. package/dist/context-storage.js.map +1 -0
  35. package/dist/cors.d.ts +25 -0
  36. package/dist/cors.d.ts.map +1 -0
  37. package/dist/cors.js +75 -0
  38. package/dist/cors.js.map +1 -0
  39. package/dist/csrf.d.ts +23 -0
  40. package/dist/csrf.d.ts.map +1 -0
  41. package/dist/csrf.js +68 -0
  42. package/dist/csrf.js.map +1 -0
  43. package/dist/etag.d.ts +14 -0
  44. package/dist/etag.d.ts.map +1 -0
  45. package/dist/etag.js +95 -0
  46. package/dist/etag.js.map +1 -0
  47. package/dist/healthcheck.d.ts +25 -0
  48. package/dist/healthcheck.d.ts.map +1 -0
  49. package/dist/healthcheck.js +41 -0
  50. package/dist/healthcheck.js.map +1 -0
  51. package/dist/idempotency.d.ts +83 -0
  52. package/dist/idempotency.d.ts.map +1 -0
  53. package/dist/idempotency.js +217 -0
  54. package/dist/idempotency.js.map +1 -0
  55. package/dist/index.d.ts +33 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +33 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/ip-restriction.d.ts +21 -0
  60. package/dist/ip-restriction.d.ts.map +1 -0
  61. package/dist/ip-restriction.js +161 -0
  62. package/dist/ip-restriction.js.map +1 -0
  63. package/dist/jwt.d.ts +82 -0
  64. package/dist/jwt.d.ts.map +1 -0
  65. package/dist/jwt.js +327 -0
  66. package/dist/jwt.js.map +1 -0
  67. package/dist/language.d.ts +20 -0
  68. package/dist/language.d.ts.map +1 -0
  69. package/dist/language.js +94 -0
  70. package/dist/language.js.map +1 -0
  71. package/dist/logger.d.ts +19 -0
  72. package/dist/logger.d.ts.map +1 -0
  73. package/dist/logger.js +28 -0
  74. package/dist/logger.js.map +1 -0
  75. package/dist/method-override.d.ts +28 -0
  76. package/dist/method-override.d.ts.map +1 -0
  77. package/dist/method-override.js +67 -0
  78. package/dist/method-override.js.map +1 -0
  79. package/dist/openapi.d.ts +83 -0
  80. package/dist/openapi.d.ts.map +1 -0
  81. package/dist/openapi.js +100 -0
  82. package/dist/openapi.js.map +1 -0
  83. package/dist/powered-by.d.ts +15 -0
  84. package/dist/powered-by.d.ts.map +1 -0
  85. package/dist/powered-by.js +23 -0
  86. package/dist/powered-by.js.map +1 -0
  87. package/dist/pretty-json.d.ts +18 -0
  88. package/dist/pretty-json.d.ts.map +1 -0
  89. package/dist/pretty-json.js +88 -0
  90. package/dist/pretty-json.js.map +1 -0
  91. package/dist/rate-limit.d.ts +92 -0
  92. package/dist/rate-limit.d.ts.map +1 -0
  93. package/dist/rate-limit.js +172 -0
  94. package/dist/rate-limit.js.map +1 -0
  95. package/dist/request-id.d.ts +18 -0
  96. package/dist/request-id.d.ts.map +1 -0
  97. package/dist/request-id.js +21 -0
  98. package/dist/request-id.js.map +1 -0
  99. package/dist/security-headers.d.ts +23 -0
  100. package/dist/security-headers.d.ts.map +1 -0
  101. package/dist/security-headers.js +36 -0
  102. package/dist/security-headers.js.map +1 -0
  103. package/dist/timing.d.ts +25 -0
  104. package/dist/timing.d.ts.map +1 -0
  105. package/dist/timing.js +101 -0
  106. package/dist/timing.js.map +1 -0
  107. package/dist/token-auth.d.ts +65 -0
  108. package/dist/token-auth.d.ts.map +1 -0
  109. package/dist/token-auth.js +90 -0
  110. package/dist/token-auth.js.map +1 -0
  111. package/dist/trailing-slash.d.ts +23 -0
  112. package/dist/trailing-slash.d.ts.map +1 -0
  113. package/dist/trailing-slash.js +70 -0
  114. package/dist/trailing-slash.js.map +1 -0
  115. package/package.json +68 -0
  116. package/src/_utils.ts +163 -0
  117. package/src/basic-auth.ts +109 -0
  118. package/src/body-limit.ts +53 -0
  119. package/src/cache-control.ts +45 -0
  120. package/src/cache.ts +277 -0
  121. package/src/combine.ts +24 -0
  122. package/src/compression.ts +111 -0
  123. package/src/context-storage.ts +36 -0
  124. package/src/cors.ts +90 -0
  125. package/src/csrf.ts +102 -0
  126. package/src/etag.ts +99 -0
  127. package/src/healthcheck.ts +59 -0
  128. package/src/idempotency.ts +313 -0
  129. package/src/index.ts +110 -0
  130. package/src/ip-restriction.ts +187 -0
  131. package/src/jwt.ts +441 -0
  132. package/src/language.ts +109 -0
  133. package/src/logger.ts +44 -0
  134. package/src/method-override.ts +87 -0
  135. package/src/openapi.ts +174 -0
  136. package/src/powered-by.ts +31 -0
  137. package/src/pretty-json.ts +97 -0
  138. package/src/rate-limit.ts +248 -0
  139. package/src/request-id.ts +30 -0
  140. package/src/security-headers.ts +51 -0
  141. package/src/timing.ts +133 -0
  142. package/src/token-auth.ts +172 -0
  143. package/src/trailing-slash.ts +94 -0
@@ -0,0 +1,248 @@
1
+ import type { Middleware } from "@nifrajs/core"
2
+
3
+ export interface RateLimitResult {
4
+ /** Hits recorded in the current window, including this one. */
5
+ readonly count: number
6
+ /** Epoch-ms when the current window resets. */
7
+ readonly resetAt: number
8
+ }
9
+
10
+ /**
11
+ * Counter backend. Production deploys MUST use a shared store (Redis, etc.) so the
12
+ * limit holds across instances — that's a user dependency, not ours, hence the
13
+ * interface. {@link MemoryStore} is for dev / single-instance only.
14
+ */
15
+ export interface RateLimitStore {
16
+ hit(key: string, windowMs: number): Promise<RateLimitResult>
17
+ }
18
+
19
+ export interface MemoryStoreOptions {
20
+ /** Allow the in-memory store in production. Off by default — a per-instance limiter is unsafe across instances. */
21
+ readonly allowInProduction?: boolean
22
+ /** Hard cap on tracked client keys; expired keys are evicted first, then oldest active keys. Default `100_000`.
23
+ * Bounds memory against an unbounded key space (bot scans, per-IP buckets). */
24
+ readonly maxKeys?: number
25
+ /** Minimum interval (ms) between amortized sweeps of expired windows. Default `30_000`. */
26
+ readonly sweepIntervalMs?: number
27
+ }
28
+
29
+ /** Max entries scanned (oldest-first) per eviction looking for an expired victim before falling back
30
+ * to evicting the oldest-inserted. Bounds eviction to O(1) per insertion instead of a full O(n) sweep
31
+ * under a distinct-key flood — the abuse the key cap defends against. */
32
+ const MAX_EVICTION_SCAN = 64
33
+
34
+ /**
35
+ * In-process fixed-window store. Refuses to run in production unless explicitly allowed.
36
+ *
37
+ * Bounded against unbounded growth: expired windows are swept lazily (amortized, at most once per
38
+ * `sweepIntervalMs`) and the key set is hard-capped at `maxKeys` (expired keys are evicted first,
39
+ * then oldest active keys).
40
+ * Without this, expired entries for keys never seen again — and the unbounded key space of a bot scan
41
+ * — would accumulate in the map forever and OOM a single-instance deploy.
42
+ */
43
+ export class MemoryStore implements RateLimitStore {
44
+ private readonly windows = new Map<string, { count: number; resetAt: number }>()
45
+ private readonly maxKeys: number
46
+ private readonly sweepIntervalMs: number
47
+ private lastSweep = 0
48
+ private evictionScans = 0
49
+
50
+ /** Cumulative entries inspected by the eviction scan since construction — an eviction-pressure
51
+ * gauge. The bounded scan keeps this ~O(1) per over-cap insert (≤ {@link MAX_EVICTION_SCAN}); a
52
+ * regressed full O(n) sweep would make it ~maxKeys per insert (what the regression test asserts). */
53
+ get evictionScanCount(): number {
54
+ return this.evictionScans
55
+ }
56
+
57
+ constructor(options: MemoryStoreOptions = {}) {
58
+ if (options.allowInProduction !== true && process.env.NODE_ENV === "production") {
59
+ throw new Error(
60
+ "MemoryStore is per-instance and unsafe in production (each instance limits separately). " +
61
+ "Use a shared store (e.g. Redis), or pass { allowInProduction: true } for a single-instance deploy.",
62
+ )
63
+ }
64
+ this.maxKeys = options.maxKeys ?? 100_000
65
+ if (!Number.isInteger(this.maxKeys) || this.maxKeys < 1) {
66
+ throw new Error("MemoryStore: maxKeys must be a positive integer")
67
+ }
68
+ this.sweepIntervalMs = options.sweepIntervalMs ?? 30_000
69
+ if (!Number.isFinite(this.sweepIntervalMs) || this.sweepIntervalMs < 0) {
70
+ throw new Error("MemoryStore: sweepIntervalMs must be a non-negative number")
71
+ }
72
+ }
73
+
74
+ hit(key: string, windowMs: number): Promise<RateLimitResult> {
75
+ const now = Date.now()
76
+ // Amortized GC: at most once per sweepIntervalMs, drop every window whose reset has passed. This
77
+ // bounds memory to ~the active key set instead of leaking an entry per distinct key ever seen.
78
+ if (now - this.lastSweep >= this.sweepIntervalMs) {
79
+ this.sweepExpired(now)
80
+ this.lastSweep = now
81
+ }
82
+ const current = this.windows.get(key)
83
+ if (current === undefined || now >= current.resetAt) {
84
+ const fresh = { count: 1, resetAt: now + windowMs }
85
+ this.windows.set(key, fresh)
86
+ this.enforceMaxKeys(now)
87
+ return Promise.resolve({ count: fresh.count, resetAt: fresh.resetAt })
88
+ }
89
+ current.count += 1
90
+ return Promise.resolve({ count: current.count, resetAt: current.resetAt })
91
+ }
92
+
93
+ private sweepExpired(now: number): void {
94
+ for (const [k, v] of this.windows) if (now >= v.resetAt) this.windows.delete(k)
95
+ }
96
+
97
+ private enforceMaxKeys(now: number): void {
98
+ if (this.windows.size <= this.maxKeys) return
99
+ // Prefer evicting an expired window over an active user's, but bound the work. A full sweep on
100
+ // every insertion is O(n)/request under a distinct-key flood — the exact abuse the cap defends
101
+ // against. Scan only a small fixed budget (oldest-first, where expired entries cluster) for a
102
+ // victim; if none is found, evict the oldest-inserted (O(1)). Expired entries beyond the budget
103
+ // are still reclaimed by the amortized sweep in `hit`.
104
+ while (this.windows.size > this.maxKeys) {
105
+ let victim: string | undefined
106
+ let scanned = 0
107
+ for (const [k, v] of this.windows) {
108
+ this.evictionScans++
109
+ if (now >= v.resetAt) {
110
+ victim = k
111
+ break
112
+ }
113
+ if (++scanned >= MAX_EVICTION_SCAN) break
114
+ }
115
+ if (victim === undefined) victim = this.windows.keys().next().value
116
+ if (victim === undefined) break
117
+ this.windows.delete(victim)
118
+ }
119
+ }
120
+ }
121
+
122
+ export interface RateLimitOptions {
123
+ /** Where counters live. `MemoryStore` for dev; a shared store in production. */
124
+ readonly store: RateLimitStore
125
+ /** Max requests allowed per window. */
126
+ readonly max: number
127
+ /** Window length, in milliseconds. */
128
+ readonly windowMs: number
129
+ /**
130
+ * How many trusted reverse proxies sit in front of the app and append to `X-Forwarded-For`.
131
+ * Default `0`.
132
+ *
133
+ * The default key reads the client IP from `X-Forwarded-For` as the address your **edge** proxy
134
+ * observed — the entry `trustedProxies` from the right (1 proxy → the rightmost hop; 2 → the
135
+ * second-from-right; …). Your proxies append on the right, so a client can only inject fake hops on
136
+ * the *left*, which this skips → not spoofable when `trustedProxies` matches your topology.
137
+ *
138
+ * ⚠️ With the default `0`, `X-Forwarded-For` is treated as fully client-controlled and **ignored**.
139
+ * Reading the
140
+ * *first* XFF hop — the old behavior — let any client mint a fresh bucket per request and defeat the
141
+ * limiter. Set `trustedProxies` (only safe behind a proxy you control that appends XFF), configure a
142
+ * trusted single-IP {@link header}, or supply a custom {@link key} (e.g. an authenticated user id).
143
+ */
144
+ readonly trustedProxies?: number
145
+ /** Exact trusted single-IP header, e.g. an infra-set `x-real-ip`. Not read unless configured. */
146
+ readonly header?: string
147
+ /**
148
+ * Allow one shared bucket when no per-request key can be derived. Off by default because it lets one
149
+ * client consume the quota for everyone. Enable only for intentional global throttles.
150
+ */
151
+ readonly allowGlobalKey?: boolean
152
+ /**
153
+ * Bucket key for a request. Overrides the default XFF-based key entirely — set this for accurate
154
+ * per-client limiting (e.g. an authenticated user id, or a header your proxy sets). A `Middleware`
155
+ * can't see the socket IP (that needs the server instance).
156
+ */
157
+ readonly key?: (req: Request) => string
158
+ }
159
+
160
+ function defaultKey(
161
+ req: Request,
162
+ trustedProxies: number,
163
+ header: string | undefined,
164
+ allowGlobalKey: boolean,
165
+ ): string | null {
166
+ if (header !== undefined) {
167
+ const ip = req.headers.get(header)
168
+ if (ip !== null && ip.trim() !== "") return ip.trim()
169
+ }
170
+ if (trustedProxies > 0) {
171
+ const xff = req.headers.get("x-forwarded-for")
172
+ if (xff !== null) {
173
+ const parts = xff.split(",")
174
+ // The leftmost of the trusted suffix (your proxies append on the right) = the address your edge
175
+ // proxy observed = the real client. A client can only prepend fakes further left, which this
176
+ // index skips. A chain shorter than `trustedProxies` (misconfig) → undefined → fall through.
177
+ const ip = parts[parts.length - trustedProxies]?.trim()
178
+ if (ip !== undefined && ip !== "") return ip
179
+ }
180
+ }
181
+ // No trusted proxy (or XFF absent/too-short): XFF isn't trustworthy, so don't derive a per-client key
182
+ // from it. A shared bucket is only safe when the app deliberately asked for a global throttle.
183
+ return allowGlobalKey ? "global" : null
184
+ }
185
+
186
+ /**
187
+ * Rate limiting as a {@link Middleware}. Runs in `onRequest` (before routing, so it
188
+ * also covers 404s); over the limit → `429` + `Retry-After`. Every response carries
189
+ * `RateLimit-Limit/Remaining/Reset` (added in `onResponse`, keyed off the request).
190
+ */
191
+ export function rateLimit(options: RateLimitOptions): Middleware {
192
+ const { store, max, windowMs } = options
193
+ if (!Number.isInteger(max) || max < 1)
194
+ throw new Error("rateLimit: max must be a positive integer")
195
+ if (!Number.isFinite(windowMs) || windowMs <= 0) {
196
+ throw new Error("rateLimit: windowMs must be a positive number")
197
+ }
198
+ const trustedProxies = options.trustedProxies ?? 0
199
+ if (!Number.isInteger(trustedProxies) || trustedProxies < 0) {
200
+ throw new Error("rateLimit: trustedProxies must be a non-negative integer")
201
+ }
202
+ const header = options.header?.trim().toLowerCase()
203
+ if (header !== undefined && header.trim() === "") throw new Error("rateLimit: header is empty")
204
+ const allowGlobalKey = options.allowGlobalKey === true
205
+ if (
206
+ options.key === undefined &&
207
+ header === undefined &&
208
+ trustedProxies === 0 &&
209
+ !allowGlobalKey
210
+ ) {
211
+ throw new Error(
212
+ "rateLimit: configure key, header, or trustedProxies; pass allowGlobalKey: true only for an intentional shared bucket",
213
+ )
214
+ }
215
+ const keyOf =
216
+ options.key ?? ((req: Request) => defaultKey(req, trustedProxies, header, allowGlobalKey))
217
+ const quota = new WeakMap<Request, { remaining: number; resetSeconds: number }>()
218
+
219
+ return {
220
+ name: "rate-limit",
221
+ async onRequest(req) {
222
+ const key = keyOf(req)
223
+ if (typeof key !== "string" || key.trim() === "") {
224
+ return Response.json({ ok: false, error: "rate_limit_key_unavailable" }, { status: 500 })
225
+ }
226
+ const { count, resetAt } = await store.hit(key, windowMs)
227
+ const resetSeconds = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000))
228
+ quota.set(req, { remaining: Math.max(0, max - count), resetSeconds })
229
+ if (count > max) {
230
+ return new Response(JSON.stringify({ ok: false, error: "rate_limited" }), {
231
+ status: 429,
232
+ headers: { "content-type": "application/json", "retry-after": String(resetSeconds) },
233
+ })
234
+ }
235
+ return undefined
236
+ },
237
+ onResponse(res, req) {
238
+ const info = quota.get(req)
239
+ if (info === undefined) return res
240
+ quota.delete(req)
241
+ const headers = new Headers(res.headers)
242
+ headers.set("RateLimit-Limit", String(max))
243
+ headers.set("RateLimit-Remaining", String(info.remaining))
244
+ headers.set("RateLimit-Reset", String(info.resetSeconds))
245
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
246
+ },
247
+ }
248
+ }
@@ -0,0 +1,30 @@
1
+ import { definePlugin } from "@nifrajs/core"
2
+
3
+ export interface RequestIdOptions {
4
+ /** Header read for an inbound id (trace propagation) + echoed on the response. Default `"x-request-id"`. */
5
+ readonly header?: string
6
+ /** Generate an id when the inbound header is absent. Default `crypto.randomUUID()`. */
7
+ readonly generate?: () => string
8
+ }
9
+
10
+ /**
11
+ * A {@link definePlugin} plugin that gives every request a stable id: it reuses an inbound
12
+ * `x-request-id` (or generates one), exposes it on the handler context as **`c.requestId`** (typed,
13
+ * threaded by `derive`), and echoes it on the response header. Idempotent — applying it twice is a
14
+ * no-op.
15
+ *
16
+ * ```ts
17
+ * app.use(requestId()) // c.requestId available downstream; X-Request-Id on responses
18
+ * ```
19
+ */
20
+ export function requestId(options: RequestIdOptions = {}) {
21
+ const header = options.header ?? "x-request-id"
22
+ const generate = options.generate ?? (() => crypto.randomUUID())
23
+ return definePlugin("requestId", (app) =>
24
+ app.derive((c) => {
25
+ const id = c.req.headers.get(header) ?? generate()
26
+ c.set.headers[header] = id // echo on the response (route handlers)
27
+ return { requestId: id }
28
+ }),
29
+ )
30
+ }
@@ -0,0 +1,51 @@
1
+ import type { Middleware } from "@nifrajs/core"
2
+ import { withHeaders } from "./_utils.ts"
3
+
4
+ export interface SecurityHeadersOptions {
5
+ /** `Strict-Transport-Security`. Off by default — opt in once you're sure you're HTTPS-only. */
6
+ readonly hsts?: {
7
+ readonly maxAge: number
8
+ readonly includeSubDomains?: boolean
9
+ readonly preload?: boolean
10
+ }
11
+ /** `Content-Security-Policy` value. Off by default (app-specific). */
12
+ readonly contentSecurityPolicy?: string
13
+ /** `X-Frame-Options`. Default `"DENY"`. */
14
+ readonly frameOptions?: "DENY" | "SAMEORIGIN"
15
+ /** `Referrer-Policy`. Default `"no-referrer"`. */
16
+ readonly referrerPolicy?: string
17
+ }
18
+
19
+ /**
20
+ * A safe-by-default set of response security headers (`onResponse`, so they cover
21
+ * errors and 404s too): `X-Content-Type-Options: nosniff`, `X-Frame-Options`, and
22
+ * `Referrer-Policy` always; `Strict-Transport-Security` and `Content-Security-Policy`
23
+ * only when configured (both are environment-/app-specific).
24
+ */
25
+ export function securityHeaders(options: SecurityHeadersOptions = {}): Middleware {
26
+ const frameOptions = options.frameOptions ?? "DENY"
27
+ const referrerPolicy = options.referrerPolicy ?? "no-referrer"
28
+ const csp = options.contentSecurityPolicy
29
+
30
+ let hstsValue: string | undefined
31
+ if (options.hsts !== undefined) {
32
+ const parts = [`max-age=${options.hsts.maxAge}`]
33
+ if (options.hsts.includeSubDomains) parts.push("includeSubDomains")
34
+ if (options.hsts.preload) parts.push("preload")
35
+ hstsValue = parts.join("; ")
36
+ }
37
+
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
+ }
51
+ }
package/src/timing.ts ADDED
@@ -0,0 +1,133 @@
1
+ import { definePlugin } from "@nifrajs/core"
2
+ import { withHeaders } from "./_utils.ts"
3
+
4
+ export interface TimingMetric {
5
+ readonly name: string
6
+ readonly duration: number
7
+ readonly description?: string
8
+ }
9
+
10
+ export interface TimingControls {
11
+ mark(name: string): void
12
+ measure(name: string, start: string, end?: string): void
13
+ metric(name: string, duration: number, description?: string): void
14
+ }
15
+
16
+ export interface TimingOptions {
17
+ /** Name for the automatic total request metric. Default `"total"`; set `false` to disable. */
18
+ readonly total?: string | false
19
+ /** Decimal places for durations. Default `1`. */
20
+ readonly precision?: number
21
+ /** Only emit timing for matching requests. Default `true`. */
22
+ readonly enabled?: boolean | ((request: Request) => boolean)
23
+ }
24
+
25
+ interface TimingState {
26
+ readonly start: number
27
+ readonly marks: Map<string, number>
28
+ readonly metrics: TimingMetric[]
29
+ }
30
+
31
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
32
+
33
+ function assertMetricName(name: string): void {
34
+ if (!TOKEN.test(name)) throw new Error(`timing: invalid metric name "${name}"`)
35
+ }
36
+
37
+ function assertDuration(duration: number): void {
38
+ if (!Number.isFinite(duration) || duration < 0) {
39
+ throw new Error("timing: duration must be a finite non-negative number")
40
+ }
41
+ }
42
+
43
+ function quoteDescription(value: string): string {
44
+ let out = ""
45
+ for (let i = 0; i < value.length; i++) {
46
+ const code = value.charCodeAt(i)
47
+ if (code < 0x20 || code === 0x7f) continue
48
+ const ch = value[i] ?? ""
49
+ out += ch === "\\" || ch === '"' ? `\\${ch}` : ch
50
+ }
51
+ return out
52
+ }
53
+
54
+ function formatMetric(metric: TimingMetric, precision: number): string {
55
+ let out = `${metric.name};dur=${metric.duration.toFixed(precision)}`
56
+ if (metric.description !== undefined) out += `;desc="${quoteDescription(metric.description)}"`
57
+ return out
58
+ }
59
+
60
+ /**
61
+ * Adds a `Server-Timing` response header and typed `c.timing` controls for custom metrics.
62
+ * Put request-rewriting middleware (for example `methodOverride`) before `timing()` so timing is
63
+ * attached to the final routed request.
64
+ */
65
+ export function timing(options: TimingOptions = {}) {
66
+ const totalName = options.total === undefined ? "total" : options.total
67
+ if (totalName !== false) assertMetricName(totalName)
68
+ const precision = options.precision ?? 1
69
+ if (!Number.isInteger(precision) || precision < 0 || precision > 6) {
70
+ throw new Error("timing: precision must be an integer from 0 to 6")
71
+ }
72
+ const enabled = options.enabled ?? true
73
+ const isEnabled = typeof enabled === "function" ? enabled : enabled ? () => true : () => false
74
+ const states = new WeakMap<Request, TimingState>()
75
+
76
+ const controls = (req: Request): TimingControls => ({
77
+ mark(name) {
78
+ assertMetricName(name)
79
+ const state = states.get(req)
80
+ if (state !== undefined) state.marks.set(name, performance.now())
81
+ },
82
+ measure(name, start, end) {
83
+ assertMetricName(name)
84
+ const state = states.get(req)
85
+ if (state === undefined) return
86
+ const startAt = state.marks.get(start)
87
+ const endAt = end === undefined ? performance.now() : state.marks.get(end)
88
+ if (startAt === undefined || endAt === undefined) {
89
+ throw new Error("timing: unknown mark")
90
+ }
91
+ const duration = endAt - startAt
92
+ assertDuration(duration)
93
+ state.metrics.push({ name, duration })
94
+ },
95
+ metric(name, duration, description) {
96
+ assertMetricName(name)
97
+ assertDuration(duration)
98
+ const state = states.get(req)
99
+ if (state !== undefined) {
100
+ state.metrics.push(
101
+ description === undefined ? { name, duration } : { name, duration, description },
102
+ )
103
+ }
104
+ },
105
+ })
106
+
107
+ return definePlugin("timing", (app) =>
108
+ app
109
+ .onRequest((req) => {
110
+ if (!isEnabled(req)) return undefined
111
+ states.set(req, { start: performance.now(), marks: new Map(), metrics: [] })
112
+ return undefined
113
+ })
114
+ .derive((c) => ({ timing: controls(c.req) }))
115
+ .onResponse((res, req) => {
116
+ const state = states.get(req)
117
+ if (state === undefined) return res
118
+ states.delete(req)
119
+
120
+ const metrics =
121
+ totalName === false
122
+ ? state.metrics
123
+ : [{ name: totalName, duration: performance.now() - state.start }, ...state.metrics]
124
+ if (metrics.length === 0) return res
125
+
126
+ const value = metrics.map((metric) => formatMetric(metric, precision)).join(", ")
127
+ return withHeaders(res, (headers) => {
128
+ const existing = headers.get("server-timing")
129
+ headers.set("server-timing", existing === null ? value : `${existing}, ${value}`)
130
+ })
131
+ }),
132
+ )
133
+ }
@@ -0,0 +1,172 @@
1
+ import { definePlugin, type NifraPlugin } from "@nifrajs/core"
2
+
3
+ type MaybePromise<T> = T | Promise<T>
4
+
5
+ /**
6
+ * A token-auth plugin (`bearer` / `apiKey`). Apply it with `app.use(auth)` — it rejects unauthorized
7
+ * requests to **routes defined after it** with `401` (unless `optional`). Read the verified principal
8
+ * inside a handler/loader via {@link AuthPlugin.principal} (nullable) or
9
+ * {@link AuthPlugin.requirePrincipal} (throws `401` when absent). The principal is verified once per
10
+ * request and cached, so reading it is free.
11
+ *
12
+ * `beforeHandle` is **order-scoped**: place `app.use(auth)` before the routes it should guard. Hold the
13
+ * returned instance to read the principal (mirrors `@nifrajs/auth`'s `sessions.get(c)` and
14
+ * `@nifrajs/better-auth`'s `getSession(auth, req)`):
15
+ *
16
+ * ```ts
17
+ * const auth = bearer({ verify: (t) => lookupUser(t) }) // P = User (inferred)
18
+ * const app = server().use(auth).get("/me", (c) => auth.requirePrincipal(c.req))
19
+ * ```
20
+ */
21
+ export type AuthPlugin<P> = NifraPlugin & {
22
+ /** The verified principal for this request, or `null` (no/invalid token in `optional` mode). */
23
+ principal(request: Request): P | null
24
+ /** The verified principal, or **throws a `401` `Response`** when absent. */
25
+ requirePrincipal(request: Request): P
26
+ }
27
+
28
+ interface TokenAuthConfig<P> {
29
+ readonly name: string
30
+ /** Pull the credential out of the request (e.g. the `Bearer` token or an `x-api-key` header). */
31
+ readonly extract: (request: Request) => string | undefined
32
+ /** Verify the credential → a principal (truthy) or `null`/`undefined` (rejected). */
33
+ readonly verify: (token: string) => MaybePromise<P | null | undefined>
34
+ readonly optional: boolean
35
+ /** `WWW-Authenticate` challenge value sent on `401` (bearer only). */
36
+ readonly challenge?: string
37
+ }
38
+
39
+ function createTokenAuth<P>(config: TokenAuthConfig<P>): AuthPlugin<P> {
40
+ // Per-request principal cache: verify runs once (in beforeHandle), reads are free + can't desync.
41
+ const store = new WeakMap<Request, P>()
42
+ const reject = (): Response =>
43
+ Response.json(
44
+ { ok: false, error: "unauthorized" },
45
+ {
46
+ status: 401,
47
+ headers: config.challenge !== undefined ? { "www-authenticate": config.challenge } : {},
48
+ },
49
+ )
50
+ const plugin = definePlugin(config.name, (app) =>
51
+ app.beforeHandle(async (c: { readonly req: Request }) => {
52
+ const token = config.extract(c.req)
53
+ // Empty string is treated as "no credential" — never passed to verify.
54
+ const principal = token !== undefined && token !== "" ? await config.verify(token) : null
55
+ if (principal !== null && principal !== undefined) {
56
+ store.set(c.req, principal)
57
+ return undefined // authorized — continue to the handler
58
+ }
59
+ return config.optional ? undefined : reject() // 401 short-circuits (skips the handler)
60
+ }),
61
+ )
62
+ return Object.assign(plugin, {
63
+ principal: (request: Request): P | null => store.get(request) ?? null,
64
+ requirePrincipal: (request: Request): P => {
65
+ const principal = store.get(request)
66
+ if (principal === undefined) throw reject()
67
+ return principal
68
+ },
69
+ }) as AuthPlugin<P>
70
+ }
71
+
72
+ export interface BearerOptions<P> {
73
+ /** Verify a bearer token → a principal (truthy) or `null`/`undefined` (rejected). May be async
74
+ * (DB/JWT lookup). For a constant-secret comparison, use a constant-time compare — never `===`. */
75
+ readonly verify: (token: string) => MaybePromise<P | null | undefined>
76
+ /** When `true`, requests without a valid token pass through (`principal` is `null`) instead of `401`. */
77
+ readonly optional?: boolean
78
+ /** `realm` for the `WWW-Authenticate: Bearer` header on `401`. Default `"api"`. */
79
+ readonly realm?: string
80
+ }
81
+
82
+ /**
83
+ * `Authorization: Bearer <token>` authentication. Parses the header, runs `verify`, and rejects with
84
+ * `401` (+ `WWW-Authenticate: Bearer`) when the token is missing/invalid (unless `optional`). The
85
+ * verified principal is read via the returned instance — see {@link AuthPlugin}.
86
+ */
87
+ export function bearer<P>(options: BearerOptions<P>): AuthPlugin<P> {
88
+ const realm = options.realm ?? "api"
89
+ return createTokenAuth({
90
+ name: "bearer",
91
+ extract: (request) => {
92
+ const header = request.headers.get("authorization")
93
+ return header?.startsWith("Bearer ") === true ? header.slice(7).trim() : undefined
94
+ },
95
+ verify: options.verify,
96
+ optional: options.optional === true,
97
+ challenge: `Bearer realm="${realm}"`,
98
+ })
99
+ }
100
+
101
+ export interface ApiKeyVerifyOptions<P> {
102
+ /** Verify an API key → a principal or `null`/`undefined` (rejected). May be async. */
103
+ readonly verify: (key: string) => MaybePromise<P | null | undefined>
104
+ /** Header carrying the key. Default `"x-api-key"`. */
105
+ readonly header?: string
106
+ readonly optional?: boolean
107
+ }
108
+
109
+ export interface ApiKeyStaticOptions {
110
+ /** A fixed set of valid keys. Compared in **constant time** (see below); the matched key becomes the
111
+ * principal. Use `verify` instead for DB-backed / per-tenant keys. */
112
+ readonly keys: readonly string[]
113
+ readonly header?: string
114
+ readonly optional?: boolean
115
+ }
116
+
117
+ const textEncoder = new TextEncoder()
118
+
119
+ async function sha256(input: string): Promise<Uint8Array> {
120
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", textEncoder.encode(input)))
121
+ }
122
+
123
+ /** Constant-time byte compare. Inputs here are always 32-byte SHA-256 digests, so the early
124
+ * length check never leaks key length (digests are fixed-width). */
125
+ function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): boolean {
126
+ if (a.length !== b.length) return false
127
+ let diff = 0
128
+ for (let i = 0; i < a.length; i++) diff |= (a[i] ?? 0) ^ (b[i] ?? 0)
129
+ return diff === 0
130
+ }
131
+
132
+ /**
133
+ * Build a constant-time verifier for a fixed key set. Each valid key is hashed once (SHA-256, fixed
134
+ * 32 bytes); per request the candidate is hashed and compared against every digest **without early
135
+ * exit**, so timing depends only on the (public) key count — never on the secret value or which key
136
+ * matched. Portable across all runtimes via WebCrypto.
137
+ */
138
+ function staticKeyVerify(keys: readonly string[]): (candidate: string) => Promise<string | null> {
139
+ const validDigests = Promise.all(keys.map((key) => sha256(key)))
140
+ return async (candidate) => {
141
+ const candidateDigest = await sha256(candidate)
142
+ let matched = false
143
+ for (const digest of await validDigests) {
144
+ // No early `break`: every comparison runs so total time is independent of which key (if any) hit.
145
+ if (timingSafeEqualBytes(candidateDigest, digest)) matched = true
146
+ }
147
+ return matched ? candidate : null
148
+ }
149
+ }
150
+
151
+ /**
152
+ * API-key authentication via a header (default `x-api-key`). Two forms:
153
+ * - `apiKey({ keys })` — a fixed key set, compared in **constant time**; the matched key is the principal.
154
+ * - `apiKey({ verify })` — custom (e.g. DB-backed) verification returning a typed principal.
155
+ *
156
+ * Rejects missing/invalid keys with `401` (unless `optional`). Read the principal via the returned
157
+ * instance — see {@link AuthPlugin}.
158
+ */
159
+ export function apiKey(options: ApiKeyStaticOptions): AuthPlugin<string>
160
+ export function apiKey<P>(options: ApiKeyVerifyOptions<P>): AuthPlugin<P>
161
+ export function apiKey<P>(
162
+ options: ApiKeyStaticOptions | ApiKeyVerifyOptions<P>,
163
+ ): AuthPlugin<P | string> {
164
+ const header = (options.header ?? "x-api-key").toLowerCase()
165
+ const verify = "keys" in options ? staticKeyVerify(options.keys) : options.verify
166
+ return createTokenAuth<P | string>({
167
+ name: "apiKey",
168
+ extract: (request) => request.headers.get(header) ?? undefined,
169
+ verify,
170
+ optional: options.optional === true,
171
+ })
172
+ }