@nifrajs/core 4.0.2 → 5.0.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 (196) hide show
  1. package/README.md +6 -2
  2. package/dist/assurance.d.ts +71 -5
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +90 -25
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +6 -0
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +52 -0
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/durable-effect.js +1 -1
  11. package/dist/durable-effect.js.map +1 -1
  12. package/dist/durable-saga.d.ts +6 -0
  13. package/dist/durable-saga.d.ts.map +1 -1
  14. package/dist/durable-saga.js +83 -10
  15. package/dist/durable-saga.js.map +1 -1
  16. package/dist/edge-kit.d.ts +7 -2
  17. package/dist/edge-kit.d.ts.map +1 -1
  18. package/dist/edge-kit.js +7 -2
  19. package/dist/edge-kit.js.map +1 -1
  20. package/dist/effect-scope.d.ts.map +1 -1
  21. package/dist/effect-scope.js +16 -5
  22. package/dist/effect-scope.js.map +1 -1
  23. package/dist/errors.d.ts +1 -1
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js.map +1 -1
  26. package/dist/evidence.d.ts +2 -0
  27. package/dist/evidence.d.ts.map +1 -1
  28. package/dist/evidence.js +9 -1
  29. package/dist/evidence.js.map +1 -1
  30. package/dist/index.d.ts +4 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/internal/durable-shared.d.ts +1 -2
  35. package/dist/internal/durable-shared.d.ts.map +1 -1
  36. package/dist/internal/durable-shared.js +25 -44
  37. package/dist/internal/durable-shared.js.map +1 -1
  38. package/dist/internal/durable-types.d.ts +3 -0
  39. package/dist/internal/durable-types.d.ts.map +1 -1
  40. package/dist/internal/hook-origins.d.ts +67 -0
  41. package/dist/internal/hook-origins.d.ts.map +1 -0
  42. package/dist/internal/hook-origins.js +86 -0
  43. package/dist/internal/hook-origins.js.map +1 -0
  44. package/dist/internal/route-assurance.d.ts +6 -3
  45. package/dist/internal/route-assurance.d.ts.map +1 -1
  46. package/dist/internal/route-assurance.js +29 -14
  47. package/dist/internal/route-assurance.js.map +1 -1
  48. package/dist/internal/route-program.d.ts +1 -1
  49. package/dist/internal/route-program.d.ts.map +1 -1
  50. package/dist/internal/route-program.js +59 -16
  51. package/dist/internal/route-program.js.map +1 -1
  52. package/dist/reconciliation-worker.d.ts.map +1 -1
  53. package/dist/reconciliation-worker.js +12 -2
  54. package/dist/reconciliation-worker.js.map +1 -1
  55. package/dist/reflection.d.ts +53 -2
  56. package/dist/reflection.d.ts.map +1 -1
  57. package/dist/reflection.js +352 -5
  58. package/dist/reflection.js.map +1 -1
  59. package/dist/router/pattern.d.ts +1 -1
  60. package/dist/router/pattern.d.ts.map +1 -1
  61. package/dist/router/pattern.js +6 -3
  62. package/dist/router/pattern.js.map +1 -1
  63. package/dist/security/baseline.d.ts +3 -0
  64. package/dist/security/baseline.d.ts.map +1 -1
  65. package/dist/security/baseline.js +9 -5
  66. package/dist/security/baseline.js.map +1 -1
  67. package/dist/server/auth.d.ts +32 -3
  68. package/dist/server/auth.d.ts.map +1 -1
  69. package/dist/server/auth.js +23 -0
  70. package/dist/server/auth.js.map +1 -1
  71. package/dist/server/body.d.ts +7 -0
  72. package/dist/server/body.d.ts.map +1 -1
  73. package/dist/server/body.js +34 -1
  74. package/dist/server/body.js.map +1 -1
  75. package/dist/server/client-ip.d.ts +3 -0
  76. package/dist/server/client-ip.d.ts.map +1 -1
  77. package/dist/server/client-ip.js.map +1 -1
  78. package/dist/server/context.d.ts +2 -1
  79. package/dist/server/context.d.ts.map +1 -1
  80. package/dist/server/idempotency-lane.d.ts +6 -3
  81. package/dist/server/idempotency-lane.d.ts.map +1 -1
  82. package/dist/server/idempotency-lane.js +38 -17
  83. package/dist/server/idempotency-lane.js.map +1 -1
  84. package/dist/server/install.d.ts +2 -0
  85. package/dist/server/install.d.ts.map +1 -1
  86. package/dist/server/install.js +2 -0
  87. package/dist/server/install.js.map +1 -1
  88. package/dist/server/logger.d.ts.map +1 -1
  89. package/dist/server/logger.js +3 -1
  90. package/dist/server/logger.js.map +1 -1
  91. package/dist/server/methods.d.ts +1 -0
  92. package/dist/server/methods.d.ts.map +1 -1
  93. package/dist/server/methods.js +1 -0
  94. package/dist/server/methods.js.map +1 -1
  95. package/dist/server/node-outcome.d.ts.map +1 -1
  96. package/dist/server/node-outcome.js +29 -11
  97. package/dist/server/node-outcome.js.map +1 -1
  98. package/dist/server/platform-adapters.d.ts.map +1 -1
  99. package/dist/server/platform-adapters.js +3 -2
  100. package/dist/server/platform-adapters.js.map +1 -1
  101. package/dist/server/request-context.d.ts +2 -1
  102. package/dist/server/request-context.d.ts.map +1 -1
  103. package/dist/server/request-context.js +4 -1
  104. package/dist/server/request-context.js.map +1 -1
  105. package/dist/server/respond.d.ts +44 -11
  106. package/dist/server/respond.d.ts.map +1 -1
  107. package/dist/server/respond.js +192 -79
  108. package/dist/server/respond.js.map +1 -1
  109. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  110. package/dist/server/response-observer-runtime.js +10 -41
  111. package/dist/server/response-observer-runtime.js.map +1 -1
  112. package/dist/server/runtime-core.d.ts +2 -0
  113. package/dist/server/runtime-core.d.ts.map +1 -1
  114. package/dist/server/runtime-core.js +2 -0
  115. package/dist/server/runtime-core.js.map +1 -1
  116. package/dist/server/server-types.d.ts +29 -5
  117. package/dist/server/server-types.d.ts.map +1 -1
  118. package/dist/server/server.d.ts +44 -31
  119. package/dist/server/server.d.ts.map +1 -1
  120. package/dist/server/server.js +444 -321
  121. package/dist/server/server.js.map +1 -1
  122. package/dist/server/sse.d.ts +11 -1
  123. package/dist/server/sse.d.ts.map +1 -1
  124. package/dist/server/sse.js +18 -3
  125. package/dist/server/sse.js.map +1 -1
  126. package/dist/server/transport-lane.d.ts +7 -1
  127. package/dist/server/transport-lane.d.ts.map +1 -1
  128. package/dist/server/transport-lane.js.map +1 -1
  129. package/dist/server/webhook.d.ts +5 -2
  130. package/dist/server/webhook.d.ts.map +1 -1
  131. package/dist/server/webhook.js +17 -1
  132. package/dist/server/webhook.js.map +1 -1
  133. package/dist/server/websocket.d.ts +22 -14
  134. package/dist/server/websocket.d.ts.map +1 -1
  135. package/dist/server/websocket.js +21 -0
  136. package/dist/server/websocket.js.map +1 -1
  137. package/dist/server/ws-hook.d.ts +7 -1
  138. package/dist/server/ws-hook.d.ts.map +1 -1
  139. package/dist/server.d.ts +3 -2
  140. package/dist/server.d.ts.map +1 -1
  141. package/dist/server.js +2 -1
  142. package/dist/server.js.map +1 -1
  143. package/dist/single-copy.d.ts +6 -0
  144. package/dist/single-copy.d.ts.map +1 -1
  145. package/dist/single-copy.js +20 -1
  146. package/dist/single-copy.js.map +1 -1
  147. package/dist/tool-contract.d.ts.map +1 -1
  148. package/dist/tool-contract.js +71 -40
  149. package/dist/tool-contract.js.map +1 -1
  150. package/dist/ws.d.ts.map +1 -1
  151. package/dist/ws.js +3 -1
  152. package/dist/ws.js.map +1 -1
  153. package/package.json +1 -1
  154. package/src/assurance.ts +180 -31
  155. package/src/capabilities.ts +59 -0
  156. package/src/durable-effect.ts +1 -1
  157. package/src/durable-saga.ts +94 -10
  158. package/src/edge-kit.ts +7 -2
  159. package/src/effect-scope.ts +15 -5
  160. package/src/errors.ts +1 -0
  161. package/src/evidence.ts +12 -3
  162. package/src/index.ts +6 -1
  163. package/src/internal/durable-shared.ts +25 -37
  164. package/src/internal/durable-types.ts +3 -0
  165. package/src/internal/hook-origins.ts +153 -0
  166. package/src/internal/route-assurance.ts +32 -18
  167. package/src/internal/route-program.ts +98 -44
  168. package/src/reconciliation-worker.ts +10 -2
  169. package/src/reflection.ts +455 -4
  170. package/src/router/pattern.ts +6 -3
  171. package/src/security/baseline.ts +10 -5
  172. package/src/server/auth.ts +45 -3
  173. package/src/server/body.ts +34 -1
  174. package/src/server/client-ip.ts +3 -0
  175. package/src/server/context.ts +2 -1
  176. package/src/server/idempotency-lane.ts +64 -22
  177. package/src/server/install.ts +3 -0
  178. package/src/server/logger.ts +3 -1
  179. package/src/server/methods.ts +1 -0
  180. package/src/server/node-outcome.ts +34 -12
  181. package/src/server/platform-adapters.ts +3 -2
  182. package/src/server/request-context.ts +5 -0
  183. package/src/server/respond.ts +222 -69
  184. package/src/server/response-observer-runtime.ts +16 -38
  185. package/src/server/runtime-core.ts +2 -0
  186. package/src/server/server-types.ts +29 -5
  187. package/src/server/server.ts +659 -530
  188. package/src/server/sse.ts +84 -55
  189. package/src/server/transport-lane.ts +7 -1
  190. package/src/server/webhook.ts +22 -3
  191. package/src/server/websocket.ts +44 -5
  192. package/src/server/ws-hook.ts +5 -0
  193. package/src/server.ts +5 -0
  194. package/src/single-copy.ts +20 -1
  195. package/src/tool-contract.ts +67 -39
  196. package/src/ws.ts +4 -0
package/src/reflection.ts CHANGED
@@ -13,9 +13,20 @@ import {
13
13
  routeClassification,
14
14
  } from "./classification.ts"
15
15
  import { normalizeRouteCapabilities } from "./internal/capability-runtime.ts"
16
+ import {
17
+ adoptionsOf,
18
+ type HookKind,
19
+ type HookOrigin,
20
+ hookOriginOf,
21
+ hookScopeOf,
22
+ hookSourceOf,
23
+ staticHeadersOf,
24
+ } from "./internal/hook-origins.ts"
16
25
  import {
17
26
  type AssuranceEvidence,
27
+ assuranceEvidenceFor,
18
28
  evidenceProvenance,
29
+ NIFRA_ASSURANCE_IDS,
19
30
  validEvidence,
20
31
  } from "./internal/route-assurance.ts"
21
32
  import { NIFRA_BACKEND_EVIDENCE } from "./mount.ts"
@@ -263,9 +274,50 @@ const reflectedAssurance = (value: unknown): readonly AssuranceEvidence[] | unde
263
274
  return evidence.length > 0 ? Object.freeze(evidence) : undefined
264
275
  }
265
276
 
277
+ /** The evidence a WebSocket handshake enforces: its route chain runs the auth guards, and the app's
278
+ * request hooks run the rate limiter. Anything else a `GET` at the same path carries comes from code
279
+ * a socket's message handlers never pass through, so a socket does not claim it. */
280
+ const HANDSHAKE_EVIDENCE: ReadonlySet<string> = new Set([
281
+ NIFRA_ASSURANCE_IDS.AUTHENTICATED,
282
+ NIFRA_ASSURANCE_IDS.RATE_LIMITED,
283
+ ])
284
+
285
+ /** A server's WebSocket routes as `"WS"` routes, read off its own table the way `reflectMounts` reads
286
+ * `fetchMounts`, so serving pays nothing for them. */
287
+ const reflectedSockets = (source: unknown): ReflectedRoute[] => {
288
+ const record = recordOf(source)
289
+ const sockets = record?.wsRoutes
290
+ if (!Array.isArray(sockets)) return []
291
+ const global = Array.isArray(record?.globalAssurance) ? record.globalAssurance : []
292
+ const reflected: ReflectedRoute[] = []
293
+ for (const candidate of sockets) {
294
+ const socket = recordOf(candidate)
295
+ if (typeof socket?.path !== "string" || !Array.isArray(socket.assurance)) continue
296
+ // The server stored them normalized at registration.
297
+ const capabilities = Array.isArray(socket.capabilities)
298
+ ? socket.capabilities.filter((id): id is string => typeof id === "string")
299
+ : []
300
+ // Declarations scoped by method match the `GET` a handshake is.
301
+ const evidence = reflectedAssurance(
302
+ assuranceEvidenceFor([...socket.assurance, ...global], "GET", socket.path).filter((item) =>
303
+ HANDSHAKE_EVIDENCE.has(item.id),
304
+ ),
305
+ )
306
+ const route: { -readonly [K in keyof ReflectedRoute]: ReflectedRoute[K] } = {
307
+ method: "WS",
308
+ path: socket.path,
309
+ }
310
+ if (evidence !== undefined) route.assurance = evidence
311
+ if (capabilities.length > 0) route.capabilities = capabilities
312
+ reflected.push(route)
313
+ }
314
+ return reflected
315
+ }
316
+
266
317
  /**
267
- * Safely enumerate and normalize route descriptors from an app or descriptor array. Invalid entries
268
- * are ignored; a missing/throwing `routes()` method yields an empty array.
318
+ * Safely enumerate and normalize route descriptors from an app or descriptor array, and a server's
319
+ * WebSocket routes under the pseudo-method `"WS"`. Invalid entries are ignored; a missing/throwing
320
+ * `routes()` method yields only the sockets.
269
321
  */
270
322
  export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
271
323
  const reflected: ReflectedRoute[] = []
@@ -296,6 +348,7 @@ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
296
348
  ...(tool !== undefined ? { tool } : {}),
297
349
  })
298
350
  }
351
+ reflected.push(...reflectedSockets(source))
299
352
  return reflected
300
353
  }
301
354
 
@@ -305,6 +358,66 @@ export interface ReflectedMount {
305
358
  readonly path: string
306
359
  /** Why the child is not analyzed, as declared by `opaque` on the mount. */
307
360
  readonly opaque?: string
361
+ /** The routes the mount declares it serves. Declared, not analyzed. */
362
+ readonly routes?: readonly ReflectedMountRoute[]
363
+ }
364
+
365
+ /** A route an opaque mount declares, normalized: method upper-cased, capabilities deduplicated. */
366
+ export interface ReflectedMountRoute {
367
+ readonly method: string
368
+ readonly path: string
369
+ readonly capabilities: readonly string[]
370
+ }
371
+
372
+ const METHOD_TOKEN = /^[A-Za-z]+$/
373
+
374
+ /** The declared routes of the mount at `prefix`, or a TypeError naming the first bad entry: a
375
+ * declaration assurance reads must say exactly what it claims. */
376
+ function declaredMountRoutes(
377
+ prefix: string,
378
+ shown: string,
379
+ value: unknown,
380
+ ): readonly ReflectedMountRoute[] | undefined {
381
+ if (value === undefined) return undefined
382
+ if (!Array.isArray(value)) throw new TypeError(`mount ${shown}: opaque routes must be an array`)
383
+ const routes: ReflectedMountRoute[] = []
384
+ const seen = new Set<string>()
385
+ value.forEach((candidate: unknown, index) => {
386
+ const at = `mount ${shown}: declared route ${index + 1}`
387
+ const entry = recordOf(candidate)
388
+ if (entry === undefined) throw new TypeError(`${at} is not an object`)
389
+ const { method, path, capabilities = [] } = entry
390
+ if (typeof method !== "string" || !METHOD_TOKEN.test(method)) {
391
+ throw new TypeError(`${at} has no valid method`)
392
+ }
393
+ if (typeof path !== "string" || !path.startsWith("/") || /[?#]/.test(path)) {
394
+ throw new TypeError(`${at} needs a path starting with "/", without a query or fragment`)
395
+ }
396
+ if (prefix !== "/" && path !== prefix && !path.startsWith(`${prefix}/`)) {
397
+ throw new TypeError(`${at} path ${path} is outside the mount`)
398
+ }
399
+ if (
400
+ !Array.isArray(capabilities) ||
401
+ !capabilities.every((id: unknown) => typeof id === "string")
402
+ ) {
403
+ throw new TypeError(`${at} capabilities must be an array of capability ids`)
404
+ }
405
+ const key = `${method.toUpperCase()} ${path}`
406
+ if (seen.has(key)) throw new TypeError(`mount ${shown} declares ${key} twice`)
407
+ seen.add(key)
408
+ routes.push(
409
+ Object.freeze({
410
+ method: method.toUpperCase(),
411
+ path,
412
+ capabilities: Object.freeze([...new Set<string>(capabilities)]),
413
+ }),
414
+ )
415
+ })
416
+ return Object.freeze(
417
+ routes.sort((a, b) =>
418
+ a.path < b.path ? -1 : a.path > b.path ? 1 : a.method < b.method ? -1 : 1,
419
+ ),
420
+ )
308
421
  }
309
422
 
310
423
  /**
@@ -324,13 +437,351 @@ export function reflectMounts(source: unknown): readonly ReflectedMount[] {
324
437
  const app = mount.app as { readonly [NIFRA_BACKEND_EVIDENCE]?: unknown } | undefined
325
438
  if (typeof app?.[NIFRA_BACKEND_EVIDENCE] === "function") continue
326
439
  // Checked here, at analysis time, so serving pays nothing: a blank reason is no reason.
327
- const opaque = typeof mount.opaque === "string" ? mount.opaque.trim() || undefined : undefined
440
+ const declaration = recordOf(mount.opaque)
441
+ const reason = typeof mount.opaque === "string" ? mount.opaque : declaration?.reason
442
+ const opaque = typeof reason === "string" ? reason.trim() || undefined : undefined
443
+ const shown = mount.path === "/" ? "/*" : `${mount.path}/*`
444
+ // Routes count only beside a reason: without one the mount stays undeclared and fails as before.
445
+ const routes =
446
+ opaque === undefined ? undefined : declaredMountRoutes(mount.path, shown, declaration?.routes)
328
447
  reflected.push(
329
448
  Object.freeze({
330
- path: mount.path === "/" ? "/*" : `${mount.path}/*`,
449
+ path: shown,
331
450
  ...(opaque !== undefined ? { opaque } : {}),
451
+ ...(routes !== undefined ? { routes } : {}),
332
452
  }),
333
453
  )
334
454
  }
335
455
  return reflected.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
336
456
  }
457
+
458
+ /** One hook that runs for a route or an unmatched path: what it is, who added it, and where. */
459
+ export interface ReflectedHook {
460
+ readonly kind: HookKind
461
+ /** The plugin that registered it, else the function's own name, else `(anonymous)`. */
462
+ readonly name: string
463
+ /** Where it was registered: `app`, `group /api`, `merge #1`, `mount /auth`, or `unknown`. */
464
+ readonly site: string
465
+ /** The header names a static `responseHeaders()` declaration sets. */
466
+ readonly headers?: readonly string[]
467
+ }
468
+
469
+ export interface ReflectedRouteHooks {
470
+ readonly method: string
471
+ readonly path: string
472
+ /** In the order they run: request hooks, the route chain, then response hooks innermost first. */
473
+ readonly hooks: readonly ReflectedHook[]
474
+ }
475
+
476
+ /** The hooks a path under `prefix` that no route matches still runs - its 404s and 405s. */
477
+ export interface ReflectedUnmatchedHooks {
478
+ readonly prefix: string
479
+ readonly hooks: readonly ReflectedHook[]
480
+ }
481
+
482
+ export interface ReflectedHookReport {
483
+ /**
484
+ * False when a hook has no recorded origin and is listed with site `unknown`. Origins are recorded
485
+ * only outside production builds, so reflect a development load of the app.
486
+ */
487
+ readonly complete: boolean
488
+ readonly routes: readonly ReflectedRouteHooks[]
489
+ readonly unmatched: readonly ReflectedUnmatchedHooks[]
490
+ }
491
+
492
+ export type { HookKind }
493
+
494
+ /** One server in the chain a request passes through: the app, then each mounted child it reaches. */
495
+ interface HookFrame {
496
+ readonly server: Readonly<Record<string, unknown>>
497
+ /** What this server's own paths are prefixed with in the report (a stripped mount's path). */
498
+ readonly base: string
499
+ }
500
+
501
+ interface HookStep {
502
+ readonly server: Readonly<Record<string, unknown>>
503
+ readonly probe: HookProbe
504
+ }
505
+
506
+ interface HookProbe {
507
+ readonly method: string
508
+ /** A route's pattern path, or an unmatched path, in the frame's own coordinates. */
509
+ readonly path: string
510
+ readonly matched: boolean
511
+ }
512
+
513
+ // A middleware method's own name says nothing about who registered it.
514
+ const HOOK_FIELD_NAMES = new Set([
515
+ "onRequest",
516
+ "onResponse",
517
+ "onResponseHeaders",
518
+ "onResponseFinalized",
519
+ "around",
520
+ "derive",
521
+ "beforeHandle",
522
+ "afterHandle",
523
+ "onError",
524
+ "run",
525
+ "web",
526
+ ])
527
+ const UNMATCHED_PROBE = "__nifra_unmatched__"
528
+
529
+ const listOf = (server: Readonly<Record<string, unknown>>, field: string): readonly unknown[] => {
530
+ const value = server[field]
531
+ return Array.isArray(value) ? value : []
532
+ }
533
+
534
+ const isServerRecord = (value: unknown): value is Readonly<Record<string, unknown>> => {
535
+ const record = recordOf(value)
536
+ return (
537
+ record !== undefined &&
538
+ Array.isArray(record.onRequestHooks) &&
539
+ typeof recordOf(record.catalog)?.entries === "function"
540
+ )
541
+ }
542
+
543
+ const catalogOf = (
544
+ server: Readonly<Record<string, unknown>>,
545
+ ): readonly { readonly method: string; readonly path: string; readonly entry: unknown }[] => {
546
+ const entries = recordOf(server.catalog)?.entries
547
+ const list: unknown =
548
+ typeof entries === "function" ? Reflect.apply(entries, server.catalog, []) : []
549
+ const routes: { method: string; path: string; entry: unknown }[] = []
550
+ for (const candidate of Array.isArray(list) ? list : []) {
551
+ const route = recordOf(candidate)
552
+ if (typeof route?.method === "string" && typeof route.path === "string") {
553
+ routes.push({ method: route.method, path: route.path, entry: route.entry })
554
+ }
555
+ }
556
+ return routes
557
+ }
558
+
559
+ const joinPath = (base: string, path: string): string =>
560
+ base === "" ? path : path === "/" ? base : `${base}${path}`
561
+
562
+ /**
563
+ * Every hook that actually runs for each route, and for each prefix's unmatched paths, with the plugin
564
+ * that registered it and where: the app, a `group()`, a `merge()`d server, or a `mount()`ed one. It
565
+ * follows the same scope rules the server applies - a group's hooks cover its prefix, a merged server's
566
+ * request hooks cover only its own routes, a mount runs its child's whole lifecycle inside the app's
567
+ * global hooks - and reads the provenance a development load records, so reflect the app outside
568
+ * production. A mount of a server already on the path (a self-mount) is reported once, as that
569
+ * prefix's unmatched entry. Anything that is not a nifra server yields an empty report.
570
+ */
571
+ export function reflectHooks(source: unknown): ReflectedHookReport {
572
+ if (!isServerRecord(source)) return { complete: true, routes: [], unmatched: [] }
573
+ const labels = new Map<object, string>()
574
+ let merges = 0
575
+ const label = (server: object, context: string): void => {
576
+ for (const adoption of adoptionsOf(server)) {
577
+ if (labels.has(adoption.source)) continue
578
+ if (adoption.prefix === undefined) merges += 1
579
+ const own = adoption.prefix !== undefined ? `group ${adoption.prefix}` : `merge #${merges}`
580
+ const full = context === "" ? own : `${context} > ${own}`
581
+ labels.set(adoption.source, full)
582
+ label(adoption.source, adoption.prefix !== undefined ? context : full)
583
+ }
584
+ }
585
+ labels.set(source, "app")
586
+ label(source, "")
587
+
588
+ let complete = true
589
+ const siteOf = (owner: object): string => {
590
+ const site = labels.get(owner)
591
+ if (site === undefined) complete = false
592
+ return site ?? "unknown"
593
+ }
594
+ const pluginsOf = (owner: object): readonly (readonly [string, unknown])[] => {
595
+ const plugins = recordOf(owner)?.appliedPlugins
596
+ return plugins instanceof Map ? [...plugins.entries()] : []
597
+ }
598
+ const nameOf = (hook: unknown, origin: HookOrigin): string => {
599
+ if (origin.plugin !== undefined) return origin.plugin
600
+ const candidates: unknown[] = [hook]
601
+ for (let next = hookSourceOf(hook); next !== undefined; next = hookSourceOf(next)) {
602
+ candidates.push(next)
603
+ }
604
+ for (const [name, plugin] of pluginsOf(origin.owner)) {
605
+ const bundle = recordOf(plugin)
606
+ if (bundle === undefined) continue
607
+ const fields = Object.values(bundle)
608
+ if (candidates.some((candidate) => fields.includes(candidate))) return name
609
+ }
610
+ for (const candidate of candidates) {
611
+ const name = typeof candidate === "function" ? candidate.name : ""
612
+ if (name !== "" && !HOOK_FIELD_NAMES.has(name)) return name
613
+ }
614
+ const id = recordOf(hook)?.id
615
+ return typeof id === "string" && id !== "" ? id : "(anonymous)"
616
+ }
617
+ const staticEntries = (server: object): ReflectedHook[] =>
618
+ staticHeadersOf(server).map((tag) => {
619
+ let name = tag.plugin
620
+ if (name === undefined) {
621
+ for (const [pluginName, plugin] of pluginsOf(tag.owner)) {
622
+ if (recordOf(plugin)?.responseHeaders === tag.source) name = pluginName
623
+ }
624
+ }
625
+ return {
626
+ kind: "responseHeaders",
627
+ name: name ?? "(static)",
628
+ site: siteOf(tag.owner),
629
+ headers: tag.names,
630
+ }
631
+ })
632
+ const entriesOf = (hook: unknown, kind: HookKind): ReflectedHook[] => {
633
+ const origin = hookOriginOf(hook)
634
+ if (origin === undefined) {
635
+ complete = false
636
+ const name = typeof hook === "function" && hook.name !== "" ? hook.name : "(anonymous)"
637
+ return [{ kind, name, site: "unknown" }]
638
+ }
639
+ if (origin.statics === true) return staticEntries(origin.owner)
640
+ return [{ kind: origin.kind, name: nameOf(hook, origin), site: siteOf(origin.owner) }]
641
+ }
642
+ // The hook a scoped wrapper gates, or undefined when the probe falls outside its scope.
643
+ const inScope = (hook: unknown, probe: HookProbe): unknown => {
644
+ let current = hook
645
+ for (let scope = hookScopeOf(current); scope !== undefined; scope = hookScopeOf(current)) {
646
+ const covered =
647
+ "prefix" in scope
648
+ ? probe.path === scope.prefix || probe.path.startsWith(`${scope.prefix}/`)
649
+ : probe.matched &&
650
+ scope.routes.some((route) => route.method === probe.method && route.path === probe.path)
651
+ if (!covered) return undefined
652
+ current = scope.inner
653
+ }
654
+ return current
655
+ }
656
+ const scoped = (hooks: readonly unknown[], kind: HookKind, probe: HookProbe): ReflectedHook[] =>
657
+ hooks.flatMap((hook) => {
658
+ const inner = inScope(hook, probe)
659
+ return inner === undefined ? [] : entriesOf(inner, kind)
660
+ })
661
+ const probesOf = (
662
+ frames: readonly HookFrame[],
663
+ path: string,
664
+ method: string,
665
+ matched: boolean,
666
+ ): readonly HookStep[] =>
667
+ frames.map((frame, index) => {
668
+ // Each frame sees the path its own mount stripped; the innermost frame's path is `path`.
669
+ const inner = frames
670
+ .slice(index + 1)
671
+ .reduceRight((acc, next) => joinPath(next.base, acc), path)
672
+ return {
673
+ server: frame.server,
674
+ probe: { method, path: inner, matched: matched && index === frames.length - 1 },
675
+ }
676
+ })
677
+ const requestPhase = (steps: readonly HookStep[]) =>
678
+ steps.flatMap(({ server, probe }) =>
679
+ scoped(listOf(server, "onRequestHooks"), "onRequest", probe),
680
+ )
681
+ const responsePhase = (steps: readonly HookStep[]) =>
682
+ steps
683
+ .map(({ server, probe }) => [
684
+ ...staticEntries(server),
685
+ ...scoped(listOf(server, "onResponseHooks"), "onResponse", probe),
686
+ ...scoped(listOf(server, "onResponseFinalizedHooks"), "onResponseFinalized", probe),
687
+ ])
688
+ .reverse()
689
+ .flat()
690
+
691
+ const routes: ReflectedRouteHooks[] = []
692
+ const unmatched: ReflectedUnmatchedHooks[] = []
693
+ const visit = (frames: readonly HookFrame[], frame: HookFrame): void => {
694
+ const reportPath = (path: string) =>
695
+ frames.reduceRight((acc, current) => joinPath(current.base, acc), path)
696
+ for (const route of catalogOf(frame.server)) {
697
+ const probes = probesOf(frames, route.path, route.method, true)
698
+ const entry = recordOf(route.entry)
699
+ const chain = (field: string, kind: HookKind) =>
700
+ entry === undefined ? [] : listOf(entry, field).flatMap((hook) => entriesOf(hook, kind))
701
+ routes.push({
702
+ method: route.method,
703
+ path: reportPath(route.path),
704
+ hooks: [
705
+ ...requestPhase(probes),
706
+ ...chain("around", "around"),
707
+ ...chain("authStages", "authenticate"),
708
+ ...chain("derives", "derive"),
709
+ ...chain("beforeHandle", "beforeHandle"),
710
+ ...chain("afterHandle", "afterHandle"),
711
+ ...chain("onError", "onError"),
712
+ ...responsePhase(probes),
713
+ ],
714
+ })
715
+ }
716
+ // A handshake runs the request hooks and the route chain up to `upgrade()`: no `around`, no
717
+ // `afterHandle`, and no response phase, since an accepted handshake sends no response through it.
718
+ for (const candidate of listOf(frame.server, "wsRoutes")) {
719
+ const socket = recordOf(candidate)
720
+ const chain = recordOf(socket?.chain)
721
+ if (typeof socket?.path !== "string" || chain === undefined) continue
722
+ const entries = (field: string, kind: HookKind) =>
723
+ listOf(chain, field).flatMap((hook) => entriesOf(hook, kind))
724
+ routes.push({
725
+ method: "WS",
726
+ path: reportPath(socket.path),
727
+ hooks: [
728
+ ...requestPhase(probesOf(frames, socket.path, "WS", true)),
729
+ ...entries("authStages", "authenticate"),
730
+ ...entries("derives", "derive"),
731
+ ...entries("beforeHandle", "beforeHandle"),
732
+ ...entries("onError", "onError"),
733
+ ],
734
+ })
735
+ }
736
+ const prefixes = new Set<string>(["/"])
737
+ const collect = (server: object): void => {
738
+ for (const adoption of adoptionsOf(server)) {
739
+ if (adoption.prefix !== undefined) prefixes.add(adoption.prefix)
740
+ collect(adoption.source)
741
+ }
742
+ }
743
+ collect(frame.server)
744
+ const mounts = listOf(frame.server, "fetchMounts").flatMap((candidate) => {
745
+ const mount = recordOf(candidate)
746
+ return typeof mount?.path === "string"
747
+ ? [{ path: mount.path, app: mount.app, stripPrefix: mount.stripPrefix === true }]
748
+ : []
749
+ })
750
+ for (const prefix of prefixes) {
751
+ const probePath = `${prefix === "/" ? "" : prefix}/${UNMATCHED_PROBE}`
752
+ const probes = probesOf(frames, probePath, "GET", false)
753
+ unmatched.push({
754
+ prefix: reportPath(prefix),
755
+ hooks: [...requestPhase(probes), ...responsePhase(probes)],
756
+ })
757
+ }
758
+ for (const { path, ...mount } of mounts) {
759
+ // A server already on this request's path (a self-mount, or two servers mounting each other)
760
+ // is listed like a plain fetch mount: followed, the walk would never end.
761
+ const cyclic = frames.some((outer) => outer.server === mount.app)
762
+ if (isServerRecord(mount.app) && !cyclic) {
763
+ const child = mount.app
764
+ if (!labels.has(child)) {
765
+ const context = `mount ${reportPath(path)}`
766
+ labels.set(child, context)
767
+ label(child, context)
768
+ }
769
+ const next = { server: child, base: mount.stripPrefix ? path : "" }
770
+ visit([...frames, next], next)
771
+ } else {
772
+ const probes = probesOf(frames, `${path}/${UNMATCHED_PROBE}`, "GET", false)
773
+ unmatched.push({
774
+ prefix: reportPath(path),
775
+ hooks: [...requestPhase(probes), ...responsePhase(probes)],
776
+ })
777
+ }
778
+ }
779
+ }
780
+ const root = { server: source, base: "" }
781
+ visit([root], root)
782
+ const byPath = (a: { readonly path: string }, b: { readonly path: string }) =>
783
+ a.path < b.path ? -1 : a.path > b.path ? 1 : 0
784
+ routes.sort((a, b) => byPath(a, b) || (a.method < b.method ? -1 : a.method > b.method ? 1 : 0))
785
+ unmatched.sort((a, b) => byPath({ path: a.prefix }, { path: b.prefix }))
786
+ return { complete, routes, unmatched }
787
+ }
@@ -443,10 +443,13 @@ export function expandOptionalParams(pattern: string): readonly string[] {
443
443
  }
444
444
 
445
445
  /** Parse and validate Nifra's strict route grammar once. Trailing slashes remain significant. */
446
- export function compileRoutePattern(pattern: string): CompiledRoutePattern {
447
- if (pattern.length === 0 || pattern.charCodeAt(0) !== 47 /* / */) {
448
- throw new RouteConfigError("INVALID_PATH", `path must start with "/": "${pattern}"`)
446
+ export function compileRoutePattern(path: string): CompiledRoutePattern {
447
+ if (path.length === 0 || path.charCodeAt(0) !== 47 /* / */) {
448
+ throw new RouteConfigError("INVALID_PATH", `path must start with "/": "${path}"`)
449
449
  }
450
+ // Routing compares the raw request path, and a browser sends `/café` as `/caf%C3%A9`: literal text
451
+ // is registered as a request spells it. Only controls, space and non-ASCII are encoded.
452
+ const pattern = path.normalize("NFC").replace(/[^!-~]/gu, encodeURIComponent)
450
453
  const raw = pattern === "/" ? [] : pattern.slice(1).split("/")
451
454
  const segments: RoutePatternSegment[] = []
452
455
  const paramNames: string[] = []
@@ -1,5 +1,6 @@
1
1
  import type { AssurancePolicy, AssuranceRule } from "../assurance.ts"
2
2
  import { NIFRA_ASSURANCE_IDS } from "../internal/route-assurance.ts"
3
+ import type { Method } from "../router/router.ts"
3
4
 
4
5
  /**
5
6
  * How much a {@link securityBaseline} demands. Each level is a superset of the one before it, so
@@ -40,7 +41,8 @@ export interface SecurityBaselineOptions {
40
41
  readonly requireRuntimeProvenance?: boolean
41
42
  }
42
43
 
43
- const MUTATING = ["POST", "PUT", "PATCH", "DELETE"] as const
44
+ /** A mutation is every method these leave out: POST, PUT, PATCH, DELETE and any custom method. */
45
+ const NOT_MUTATING: readonly (Method | "WS")[] = ["GET", "HEAD", "OPTIONS", "WS"]
44
46
  const READING = ["GET", "HEAD"] as const
45
47
  /** The classification threshold above which the baseline demands authentication. */
46
48
  const SENSITIVE = "pii" as const
@@ -68,6 +70,9 @@ const SENSITIVE = "pii" as const
68
70
  * | every read carries security headers | | | ✓ |
69
71
  * | every mutation is rate limited | | | ✓ |
70
72
  *
73
+ * A mutation is any method but GET, HEAD and OPTIONS, so a route under a custom method such as
74
+ * `PURGE` is held to the mutation rules, CSRF included. WebSocket routes are not mutations here.
75
+ *
71
76
  * The baseline deliberately does not select on `access`/`zone` capabilities: those selectors require
72
77
  * capability definitions and would make the preset refuse an app that declares none. Extend the
73
78
  * returned policy's `rules` for capability- or path-specific requirements; put more specific rules
@@ -112,14 +117,14 @@ export function securityBaseline(options: SecurityBaselineOptions = {}): Assuran
112
117
  rules.push(
113
118
  {
114
119
  name: "sensitive-mutation-body",
115
- match: { methods: [...MUTATING], hasBody: true, classificationAtLeast: SENSITIVE },
120
+ match: { exceptMethods: NOT_MUTATING, hasBody: true, classificationAtLeast: SENSITIVE },
116
121
  require: [A.BODY_BOUNDED, ...authed, ...contract, ...rateLimit],
117
122
  requireCsrfWithAuthenticated: true,
118
123
  requireProvenance: provenance,
119
124
  },
120
125
  {
121
126
  name: "sensitive-mutation",
122
- match: { methods: [...MUTATING], classificationAtLeast: SENSITIVE },
127
+ match: { exceptMethods: NOT_MUTATING, classificationAtLeast: SENSITIVE },
123
128
  require: [...authed, ...contract, ...rateLimit],
124
129
  requireCsrfWithAuthenticated: true,
125
130
  requireProvenance: provenance,
@@ -130,14 +135,14 @@ export function securityBaseline(options: SecurityBaselineOptions = {}): Assuran
130
135
  rules.push(
131
136
  {
132
137
  name: "mutation-body",
133
- match: { methods: [...MUTATING], hasBody: true },
138
+ match: { exceptMethods: NOT_MUTATING, hasBody: true },
134
139
  require: [A.BODY_BOUNDED, ...contract, ...rateLimit],
135
140
  requireCsrfWithAuthenticated: true,
136
141
  requireProvenance: provenance,
137
142
  },
138
143
  {
139
144
  name: "mutation",
140
- match: { methods: [...MUTATING] },
145
+ match: { exceptMethods: NOT_MUTATING },
141
146
  require: [...contract, ...rateLimit],
142
147
  requireCsrfWithAuthenticated: true,
143
148
  requireProvenance: provenance,
@@ -16,15 +16,31 @@ export interface AuthHeaders {
16
16
  }
17
17
 
18
18
  /**
19
- * The authentication trust boundary. There is intentionally no `Request`, `body`, or parsed query
20
- * here. Route parameters are present only for selecting an identity policy and remain untrusted
21
- * strings until the normal route validation stage completes.
19
+ * The authentication trust boundary. A stage runs before input validation, so everything here is
20
+ * untrusted: route parameters and the query select an identity policy or carry a credential, and
21
+ * stay unvalidated strings until the route's own validation completes.
22
22
  */
23
23
  export interface AuthenticationInput<Env = unknown> {
24
24
  readonly method: string
25
25
  readonly pathname: string
26
26
  readonly headers: AuthHeaders
27
27
  readonly params: Readonly<Record<string, string>>
28
+ /**
29
+ * The raw query string as received, `""` or `"?..."`, for a scheme that signs it (a presigned
30
+ * URL). A credential in a URL reaches access logs, `Referer` and browser history: accept only a
31
+ * short-lived, single-use ticket there.
32
+ */
33
+ readonly search: string
34
+ /**
35
+ * The query, parsed by the same parser as `c.query` and frozen; the route validates its own copy.
36
+ * A repeated key arrives as an array, so read a credential as absent unless it is a string.
37
+ */
38
+ readonly query: Readonly<Record<string, string | readonly string[]>>
39
+ /**
40
+ * The request `c.req` is, to key per-request state such as a verified principal. The stage runs
41
+ * before the body is validated or limited: it must not read the body.
42
+ */
43
+ readonly request: Request
28
44
  readonly env: Env
29
45
  readonly clientIp: string | undefined
30
46
  readonly signal: AbortSignal
@@ -73,3 +89,29 @@ export function rejected(
73
89
  ? { kind: "rejected", reason }
74
90
  : { kind: "rejected", reason, response }
75
91
  }
92
+
93
+ // Keyed in the global symbol registry so two copies of core agree on one record.
94
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion lint/plugin/noChainedTypeAssertions: only this module writes the slot, and only a WeakSet<Request>.
95
+ const shared = globalThis as unknown as Record<symbol, WeakSet<Request> | undefined>
96
+ const AUTHENTICATED = Symbol.for("nifra.authenticated")
97
+ shared[AUTHENTICATED] ??= new WeakSet()
98
+ const authenticatedRequests = shared[AUTHENTICATED]
99
+
100
+ /**
101
+ * Record that `request` carries a verified credential, so a response-side store keyed without it - a
102
+ * response cache, an idempotency replay - treats the response as personalized whichever header
103
+ * carried the credential. The server records every request an authentication stage accepts; call
104
+ * this from an integration that verifies a credential elsewhere (an optional guard in `beforeHandle`).
105
+ */
106
+ export function markAuthenticatedRequest(request: Request): void {
107
+ authenticatedRequests.add(request)
108
+ }
109
+
110
+ /**
111
+ * Whether `request` passed an authentication stage or was marked with
112
+ * {@link markAuthenticatedRequest}. The server records the request object its response hooks
113
+ * receive, including one an `onRequest` hook replaced.
114
+ */
115
+ export function isAuthenticatedRequest(request: Request): boolean {
116
+ return authenticatedRequests.has(request)
117
+ }