@nifrajs/core 4.0.0 → 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/errors.ts CHANGED
@@ -31,6 +31,7 @@ export type RouteConfigErrorCode =
31
31
  | "INVALID_VALIDATION_ORDER"
32
32
  | "INVALID_BODY_LIMIT"
33
33
  | "PLUGIN_RECONFIGURED"
34
+ | "MOUNT_CYCLE"
34
35
 
35
36
  /**
36
37
  * Thrown at route registration when a route is misconfigured. This is the
package/src/evidence.ts CHANGED
@@ -34,6 +34,8 @@ export interface ProjectEvidenceSchemaPart {
34
34
  }
35
35
 
36
36
  export interface ProjectEvidenceSchema {
37
+ /** Set only when the route chose it; absent means the default for its auth stages. */
38
+ readonly validationOrder?: "validate-before-auth" | "auth-before-validation"
37
39
  readonly bodyLimit?: number | "unlimited"
38
40
  readonly bodyLimitReason?: string
39
41
  readonly headers?: ProjectEvidenceSchemaPart
@@ -160,6 +162,7 @@ function schemaOf(route: ReflectedRoute): ProjectEvidenceSchema | undefined {
160
162
  if (part !== undefined) errors[status] = part
161
163
  }
162
164
  const schema: ProjectEvidenceSchema = {
165
+ ...(source.validationOrder !== undefined ? { validationOrder: source.validationOrder } : {}),
163
166
  ...(source.bodyLimit !== undefined ? { bodyLimit: source.bodyLimit } : {}),
164
167
  ...(source.bodyLimitReason !== undefined ? { bodyLimitReason: source.bodyLimitReason } : {}),
165
168
  ...(headers !== undefined ? { headers } : {}),
@@ -328,9 +331,14 @@ function remapEvidenceSnapshot(
328
331
  const key = route(item.method, item.path)
329
332
  return Object.freeze({ ...item, ...key })
330
333
  })
331
- const mounts = evidence.mounts?.map((item) =>
332
- Object.freeze({ ...item, path: composedPath(pathPrefix, item.path) }),
333
- )
334
+ const mounts = evidence.mounts?.map((item) => {
335
+ const path = composedPath(pathPrefix, item.path)
336
+ if (item.routes === undefined) return Object.freeze({ ...item, path })
337
+ const routes = item.routes.map((declared) =>
338
+ Object.freeze({ ...declared, path: composedPath(pathPrefix, declared.path) }),
339
+ )
340
+ return Object.freeze({ ...item, path, routes: Object.freeze(routes) })
341
+ })
334
342
  const assurance =
335
343
  evidence.assurance === undefined
336
344
  ? undefined
@@ -499,6 +507,7 @@ function schemaFromEvidence(schema: ProjectEvidenceSchema | undefined): Reflecte
499
507
  const response = schemaPartToReflection(schema.response)
500
508
  const sse = schemaPartToReflection(schema.sse)
501
509
  return {
510
+ ...(schema.validationOrder !== undefined ? { validationOrder: schema.validationOrder } : {}),
502
511
  ...(schema.bodyLimit !== undefined ? { bodyLimit: schema.bodyLimit } : {}),
503
512
  ...(schema.bodyLimitReason !== undefined ? { bodyLimitReason: schema.bodyLimitReason } : {}),
504
513
  ...(headers !== undefined ? { headers } : {}),
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
17
17
  * `check:publish` asserts it equals `@nifrajs/core`'s package version.
18
18
  */
19
- export const VERSION = "4.0.0" as const
19
+ export const VERSION = "5.0.0" as const
20
20
 
21
21
  export type Version = typeof VERSION
22
22
 
@@ -54,6 +54,8 @@ export {
54
54
  type AuthHeaders,
55
55
  type AuthMaybePromise,
56
56
  authenticated,
57
+ isAuthenticatedRequest,
58
+ markAuthenticatedRequest,
57
59
  rejected,
58
60
  } from "./server/auth.ts"
59
61
  export type {
@@ -116,6 +118,7 @@ export type {
116
118
  RouteInfoFor,
117
119
  } from "./server/registry.ts"
118
120
  export { replacedRequestOf } from "./server/request-lineage.ts"
121
+ export { writableResponse } from "./server/respond.ts"
119
122
  export {
120
123
  type PlainRender,
121
124
  type ResponseResult,
@@ -127,6 +130,7 @@ export {
127
130
  type AdmissionDecision,
128
131
  type AnyServer,
129
132
  type ContextPlugin,
133
+ type DeclaredMountRoute,
130
134
  type DefinePluginResult,
131
135
  defineContextPlugin,
132
136
  defineIdentityPlugin,
@@ -150,6 +154,7 @@ export {
150
154
  type NodeResponseHook,
151
155
  type NodeServeOutcome,
152
156
  type OnRequestResult,
157
+ type OpaqueMountDeclaration,
153
158
  type PluginTypeCollapsed,
154
159
  type PromptArgument,
155
160
  type PromptMessage,
@@ -63,12 +63,22 @@ export function reconciliationLimit(value = DEFAULT_RECONCILIATION_LIMIT): numbe
63
63
  return value
64
64
  }
65
65
 
66
- export function cursorOffset(cursor?: string): number {
67
- if (cursor === undefined) return 0
68
- if (!/^(?:0|[1-9][0-9]*)$/u.test(cursor)) throw new TypeError("invalid reconciliation cursor")
69
- const offset = Number(cursor)
70
- if (!Number.isSafeInteger(offset)) throw new TypeError("invalid reconciliation cursor")
71
- return offset
66
+ /**
67
+ * One page of `matches` in id order, the cursor being the last id returned. Keyed rather than an
68
+ * offset into the matching set: a caller reconciles each page before asking for the next, so the
69
+ * set shrinks under it, and an offset then lands past records that were never returned.
70
+ */
71
+ function keysetPage<Record>(
72
+ matches: [id: string, record: Record][],
73
+ limit: number,
74
+ ): ReconciliationScanPage<Record> {
75
+ // Code-unit order, the order the `id > cursor` comparisons use.
76
+ matches.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
77
+ const page = matches.slice(0, limit)
78
+ const records = Object.freeze(page.map(([, record]) => cloneValue(record)))
79
+ const last = page.at(-1)
80
+ if (matches.length <= limit || last === undefined) return Object.freeze({ records })
81
+ return Object.freeze({ records, cursor: last[0] })
72
82
  }
73
83
 
74
84
  export function memoryScan<
@@ -76,28 +86,18 @@ export function memoryScan<
76
86
  State extends string,
77
87
  >(
78
88
  source: Iterable<Record>,
89
+ idOf: (record: Record) => string,
79
90
  input: ReconciliationScanOptions<State>,
80
91
  ): ReconciliationScanPage<Record> {
81
92
  const states = new Set(input.states)
82
- const offset = cursorOffset(input.cursor)
83
- const end = offset + input.limit
84
- const records: Record[] = []
85
- let seen = 0
86
- let more = false
93
+ const matches: [string, Record][] = []
87
94
  for (const record of source) {
88
95
  if (!states.has(record.state)) continue
89
96
  if (input.updatedBefore !== undefined && record.updatedAt > input.updatedBefore) continue
90
- if (seen >= end) {
91
- more = true
92
- break
93
- }
94
- if (seen >= offset) records.push(cloneValue(record))
95
- seen++
97
+ const id = idOf(record)
98
+ if (input.cursor === undefined || id > input.cursor) matches.push([id, record])
96
99
  }
97
- return Object.freeze({
98
- records: Object.freeze(records),
99
- ...(more ? { cursor: String(offset + records.length) } : {}),
100
- })
100
+ return keysetPage(matches, input.limit)
101
101
  }
102
102
 
103
103
  export function bucketAdd<State extends string>(
@@ -129,29 +129,17 @@ export function indexedScan<Record extends { readonly updatedAt: number }, State
129
129
  resolve: (id: string) => Record | undefined,
130
130
  input: ReconciliationScanOptions<State>,
131
131
  ): ReconciliationScanPage<Record> {
132
- const offset = cursorOffset(input.cursor)
133
- const end = offset + input.limit
134
- const records: Record[] = []
135
- let seen = 0
136
- let more = false
132
+ const matches: [string, Record][] = []
137
133
  for (const state of new Set(input.states)) {
138
- if (more) break
139
134
  const bucket = byState.get(state)
140
135
  if (bucket === undefined) continue
141
136
  for (const id of bucket) {
137
+ if (input.cursor !== undefined && id <= input.cursor) continue
142
138
  const record = resolve(id)
143
139
  if (record === undefined) continue
144
140
  if (input.updatedBefore !== undefined && record.updatedAt > input.updatedBefore) continue
145
- if (seen >= end) {
146
- more = true
147
- break
148
- }
149
- if (seen >= offset) records.push(cloneValue(record))
150
- seen++
141
+ matches.push([id, record])
151
142
  }
152
143
  }
153
- return Object.freeze({
154
- records: Object.freeze(records),
155
- ...(more ? { cursor: String(offset + records.length) } : {}),
156
- })
144
+ return keysetPage(matches, input.limit)
157
145
  }
@@ -138,6 +138,9 @@ export interface SagaRecord {
138
138
  readonly createdAt: number
139
139
  readonly updatedAt: number
140
140
  readonly version: number
141
+ /** The `resume`/`compensate` call that owns the saga while `leaseUntil` is in the future. */
142
+ readonly owner?: string
143
+ readonly leaseUntil?: number
141
144
  }
142
145
 
143
146
  export interface SagaStore {
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Where each registered hook came from, for `reflectHooks` (`nifra routes --hooks`). Development-only:
3
+ * every write sits behind the inline `process.env.NODE_ENV !== "production"` guard at its call site in
4
+ * `server.ts`, so a production define folds the writes away and this module drops out of the bundle.
5
+ * Reads happen only from reflection, never on the request path.
6
+ */
7
+
8
+ export type HookKind =
9
+ | "onRequest"
10
+ | "around"
11
+ | "authenticate"
12
+ | "derive"
13
+ | "beforeHandle"
14
+ | "afterHandle"
15
+ | "onError"
16
+ | "onResponse"
17
+ | "responseHeaders"
18
+ | "onResponseFinalized"
19
+
20
+ export interface HookOrigin {
21
+ readonly kind: HookKind
22
+ /** The server the hook was registered on. */
23
+ readonly owner: object
24
+ /** The plugin being applied when it was registered, if any. */
25
+ readonly plugin: string | undefined
26
+ /** A group's static headers turned into this hook at adoption: report the owner's declarations. */
27
+ readonly statics?: true
28
+ }
29
+
30
+ /** Which requests an adopted hook still runs for: under a group prefix, or a merged server's routes. */
31
+ export type HookScopeTag =
32
+ | { readonly inner: unknown; readonly prefix: string }
33
+ | {
34
+ readonly inner: unknown
35
+ readonly routes: readonly { readonly method: string; readonly path: string }[]
36
+ }
37
+
38
+ /** One `responseHeaders()` declaration that reached a server's static tier. */
39
+ export interface StaticHeadersTag {
40
+ readonly owner: object
41
+ readonly names: readonly string[]
42
+ readonly source: unknown
43
+ readonly plugin: string | undefined
44
+ }
45
+
46
+ export interface AdoptionTag {
47
+ readonly source: object
48
+ readonly prefix: string | undefined
49
+ }
50
+
51
+ const origins = new WeakMap<object, HookOrigin>()
52
+ const scopes = new WeakMap<object, HookScopeTag>()
53
+ const sources = new WeakMap<object, unknown>()
54
+ const statics = new WeakMap<object, StaticHeadersTag[]>()
55
+ const adoptions = new WeakMap<object, AdoptionTag[]>()
56
+ let applying: string | undefined
57
+ // A merged server whose static headers are about to become one of the target's response hooks.
58
+ let staticsOwner: object | undefined
59
+
60
+ const isRef = (value: unknown): value is object =>
61
+ (typeof value === "function" || typeof value === "object") && value !== null
62
+
63
+ /** Record a hook's origin. The first registration wins, so a hook re-adopted elsewhere keeps its site. */
64
+ export function tagHook(hook: unknown, kind: HookKind, owner: object, groupStatics?: true): void {
65
+ if (!isRef(hook) || origins.has(hook)) return
66
+ if (kind === "responseHeaders" && staticsOwner !== undefined) {
67
+ origins.set(hook, { kind, owner: staticsOwner, plugin: applying, statics: true })
68
+ staticsOwner = undefined
69
+ return
70
+ }
71
+ origins.set(
72
+ hook,
73
+ groupStatics === true
74
+ ? { kind, owner, plugin: applying, statics: true }
75
+ : { kind, owner, plugin: applying },
76
+ )
77
+ }
78
+
79
+ /**
80
+ * A merge folds the source's static headers into the target: into its static tier, or - once the
81
+ * target has a response hook - into a new hook, which the next `responseHeaders` tag then is.
82
+ */
83
+ export function adoptStaticTags(target: object, source: object, becomesHook: boolean): void {
84
+ if (becomesHook) {
85
+ staticsOwner = source
86
+ return
87
+ }
88
+ for (const tag of statics.get(source) ?? []) {
89
+ tagStatic(target, tag.names, tag.source, tag.owner, tag.plugin)
90
+ }
91
+ }
92
+
93
+ /** The author's function (or header record) an internal wrapper adapts, for naming it. */
94
+ export function tagSource(wrapper: unknown, source: unknown): void {
95
+ if (isRef(wrapper)) sources.set(wrapper, source)
96
+ }
97
+
98
+ /** Pair each scoped wrapper with the hook it gates and the scope it gates it by. */
99
+ export function tagScoped(
100
+ originals: readonly unknown[],
101
+ wrapped: readonly unknown[],
102
+ scope:
103
+ | { readonly prefix: string }
104
+ | { readonly routes: readonly { readonly method: string; readonly path: string }[] },
105
+ ): void {
106
+ for (let i = 0; i < wrapped.length; i++) {
107
+ const wrapper = wrapped[i]
108
+ if (isRef(wrapper) && wrapper !== originals[i])
109
+ scopes.set(wrapper, { inner: originals[i], ...scope })
110
+ }
111
+ }
112
+
113
+ export function tagStatic(
114
+ server: object,
115
+ names: readonly string[],
116
+ source: unknown,
117
+ owner: object = server,
118
+ plugin: string | undefined = applying,
119
+ ): void {
120
+ const list = statics.get(server)
121
+ const tag = { owner, names: names.map((name) => name.toLowerCase()), source, plugin }
122
+ if (list === undefined) statics.set(server, [tag])
123
+ else list.push(tag)
124
+ }
125
+
126
+ export function tagAdoption(target: object, source: object, prefix: string | undefined): void {
127
+ const list = adoptions.get(target)
128
+ const tag = { source, prefix }
129
+ if (list === undefined) adoptions.set(target, [tag])
130
+ else list.push(tag)
131
+ }
132
+
133
+ const outer: (string | undefined)[] = []
134
+
135
+ /** Mark the plugin a `use()` is applying, until the matching {@link leavePlugin}. */
136
+ export function enterPlugin(name: string | undefined): void {
137
+ outer.push(applying)
138
+ if (name !== undefined) applying = name
139
+ }
140
+
141
+ export function leavePlugin(): void {
142
+ applying = outer.pop()
143
+ }
144
+
145
+ export const hookOriginOf = (hook: unknown): HookOrigin | undefined =>
146
+ isRef(hook) ? origins.get(hook) : undefined
147
+ export const hookScopeOf = (hook: unknown): HookScopeTag | undefined =>
148
+ isRef(hook) ? scopes.get(hook) : undefined
149
+ export const hookSourceOf = (hook: unknown): unknown =>
150
+ isRef(hook) ? sources.get(hook) : undefined
151
+ export const staticHeadersOf = (server: object): readonly StaticHeadersTag[] =>
152
+ statics.get(server) ?? []
153
+ export const adoptionsOf = (server: object): readonly AdoptionTag[] => adoptions.get(server) ?? []
@@ -1,4 +1,4 @@
1
- import { METHODS, type Method } from "../router/router.ts"
1
+ import { isRegistrableMethod, METHODS, type Method, type RouteMethod } from "../router/router.ts"
2
2
 
3
3
  /** Where enforcement evidence follows Nifra's route-registration semantics. */
4
4
  export type AssuranceScope = "global" | "subsequent" | "plugin"
@@ -15,8 +15,11 @@ export interface AssuranceEvidence {
15
15
  /** Metadata installed on a middleware/plugin by {@link withRouteAssurance}. */
16
16
  export interface AssuranceDeclaration extends AssuranceEvidence {
17
17
  readonly scope: AssuranceScope
18
- /** Restrict evidence to these HTTP methods. Omit for every method. */
19
- readonly methods?: readonly Method[]
18
+ /** Restrict evidence to these HTTP methods, a custom one such as `PURGE` included. Omit for every
19
+ * method. */
20
+ readonly methods?: readonly RouteMethod[]
21
+ /** Withhold evidence from these HTTP methods. Every other method keeps it, custom ones included. */
22
+ readonly exceptMethods?: readonly RouteMethod[]
20
23
  /** Restrict evidence to these absolute route globs. Omit for every path. */
21
24
  readonly paths?: readonly string[]
22
25
  }
@@ -117,12 +120,8 @@ function normalizeDeclaration(value: AssuranceDeclaration): AssuranceDeclaration
117
120
  if (value.scope !== "global" && value.scope !== "subsequent" && value.scope !== "plugin") {
118
121
  throw new Error(`route assurance: invalid scope ${JSON.stringify(value.scope)}`)
119
122
  }
120
- const methods = value.methods?.map((method) => method.toUpperCase())
121
- for (const method of methods ?? []) {
122
- if (!METHOD_SET.has(method)) {
123
- throw new Error(`route assurance: unsupported HTTP method ${JSON.stringify(method)}`)
124
- }
125
- }
123
+ const methods = registrableMethods(value.methods)
124
+ const exceptMethods = registrableMethods(value.exceptMethods)
126
125
  const paths = value.paths?.map((path) => {
127
126
  routeGlob(path)
128
127
  return path
@@ -134,16 +133,31 @@ function normalizeDeclaration(value: AssuranceDeclaration): AssuranceDeclaration
134
133
  ) {
135
134
  throw new Error(`route assurance: invalid provenance ${JSON.stringify(value.provenance)}`)
136
135
  }
137
- return Object.freeze({
136
+ const declaration: { -readonly [K in keyof AssuranceDeclaration]: AssuranceDeclaration[K] } = {
138
137
  id: value.id,
139
138
  source: value.source.trim(),
140
139
  scope: value.scope,
141
- // Carried, not defaulted: evidence attached by an author (rather than installed by a running
142
- // hook) must stay distinguishable so `requireProvenance: "runtime"` rules can still reject it.
143
- ...(value.provenance !== undefined ? { provenance: value.provenance } : {}),
144
- ...(methods !== undefined ? { methods: Object.freeze(methods as Method[]) } : {}),
145
- ...(paths !== undefined ? { paths: Object.freeze(paths) } : {}),
146
- })
140
+ }
141
+ // Carried, not defaulted: evidence attached by an author (rather than installed by a running
142
+ // hook) must stay distinguishable so `requireProvenance: "runtime"` rules can still reject it.
143
+ if (value.provenance !== undefined) declaration.provenance = value.provenance
144
+ if (methods !== undefined) declaration.methods = methods
145
+ if (exceptMethods !== undefined) declaration.exceptMethods = exceptMethods
146
+ if (paths !== undefined) declaration.paths = Object.freeze(paths)
147
+ return Object.freeze(declaration)
148
+ }
149
+
150
+ function registrableMethods(
151
+ values: readonly string[] | undefined,
152
+ ): readonly RouteMethod[] | undefined {
153
+ if (values === undefined) return undefined
154
+ const methods = values.map((method) => method.toUpperCase())
155
+ for (const method of methods) {
156
+ if (!isRegistrableMethod(method)) {
157
+ throw new Error(`route assurance: unsupported HTTP method ${JSON.stringify(method)}`)
158
+ }
159
+ }
160
+ return Object.freeze(methods)
147
161
  }
148
162
 
149
163
  /** Validate and freeze declarations, one or many. */
@@ -179,8 +193,8 @@ export function declarationApplies(
179
193
  method: string,
180
194
  path: string,
181
195
  ): boolean {
182
- if (declaration.methods !== undefined && !declaration.methods.includes(method as Method))
183
- return false
196
+ if (declaration.methods !== undefined && !declaration.methods.includes(method)) return false
197
+ if (declaration.exceptMethods?.includes(method) === true) return false
184
198
  return (
185
199
  declaration.paths === undefined ||
186
200
  declaration.paths.some((pattern) => routeGlob(pattern).test(path))