@nifrajs/core 5.5.1 → 5.6.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 (138) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js.map +1 -1
  4. package/dist/evidence.d.ts +2 -0
  5. package/dist/evidence.d.ts.map +1 -1
  6. package/dist/evidence.js +2 -0
  7. package/dist/evidence.js.map +1 -1
  8. package/dist/file-type.d.ts +30 -0
  9. package/dist/file-type.d.ts.map +1 -0
  10. package/dist/file-type.js +118 -0
  11. package/dist/file-type.js.map +1 -0
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal/route-program.d.ts +11 -0
  17. package/dist/internal/route-program.d.ts.map +1 -1
  18. package/dist/internal/route-program.js +133 -85
  19. package/dist/internal/route-program.js.map +1 -1
  20. package/dist/reflection.d.ts +14 -1
  21. package/dist/reflection.d.ts.map +1 -1
  22. package/dist/reflection.js +34 -7
  23. package/dist/reflection.js.map +1 -1
  24. package/dist/response-observer.d.ts +1 -0
  25. package/dist/response-observer.d.ts.map +1 -1
  26. package/dist/response-observer.js +1 -0
  27. package/dist/response-observer.js.map +1 -1
  28. package/dist/server/bare-error-lane.d.ts +4 -1
  29. package/dist/server/bare-error-lane.d.ts.map +1 -1
  30. package/dist/server/bare-error-lane.js +9 -3
  31. package/dist/server/bare-error-lane.js.map +1 -1
  32. package/dist/server/before-handler.d.ts +10 -0
  33. package/dist/server/before-handler.d.ts.map +1 -0
  34. package/dist/server/before-handler.js +12 -0
  35. package/dist/server/before-handler.js.map +1 -0
  36. package/dist/server/body-lane.d.ts.map +1 -1
  37. package/dist/server/body-lane.js +8 -2
  38. package/dist/server/body-lane.js.map +1 -1
  39. package/dist/server/body-parser.d.ts.map +1 -1
  40. package/dist/server/body-parser.js +2 -5
  41. package/dist/server/body-parser.js.map +1 -1
  42. package/dist/server/body.d.ts +24 -8
  43. package/dist/server/body.d.ts.map +1 -1
  44. package/dist/server/body.js +86 -33
  45. package/dist/server/body.js.map +1 -1
  46. package/dist/server/core-copies.js +1 -1
  47. package/dist/server/http.d.ts.map +1 -1
  48. package/dist/server/http.js +6 -17
  49. package/dist/server/http.js.map +1 -1
  50. package/dist/server/multipart.d.ts +3 -0
  51. package/dist/server/multipart.d.ts.map +1 -1
  52. package/dist/server/multipart.js +226 -97
  53. package/dist/server/multipart.js.map +1 -1
  54. package/dist/server/proto-guard.d.ts.map +1 -1
  55. package/dist/server/proto-guard.js +39 -4
  56. package/dist/server/proto-guard.js.map +1 -1
  57. package/dist/server/query.d.ts.map +1 -1
  58. package/dist/server/query.js +8 -14
  59. package/dist/server/query.js.map +1 -1
  60. package/dist/server/repeated-prefix.d.ts +17 -0
  61. package/dist/server/repeated-prefix.d.ts.map +1 -0
  62. package/dist/server/repeated-prefix.js +17 -0
  63. package/dist/server/repeated-prefix.js.map +1 -0
  64. package/dist/server/request-context.d.ts.map +1 -1
  65. package/dist/server/request-context.js +1 -3
  66. package/dist/server/request-context.js.map +1 -1
  67. package/dist/server/respond.d.ts +1 -3
  68. package/dist/server/respond.d.ts.map +1 -1
  69. package/dist/server/respond.js +21 -16
  70. package/dist/server/respond.js.map +1 -1
  71. package/dist/server/response-contract-lane.d.ts +12 -2
  72. package/dist/server/response-contract-lane.d.ts.map +1 -1
  73. package/dist/server/response-contract-lane.js +135 -8
  74. package/dist/server/response-contract-lane.js.map +1 -1
  75. package/dist/server/runtime-core.d.ts +7 -0
  76. package/dist/server/runtime-core.d.ts.map +1 -1
  77. package/dist/server/runtime-core.js +8 -1
  78. package/dist/server/runtime-core.js.map +1 -1
  79. package/dist/server/server-types.d.ts +3 -2
  80. package/dist/server/server-types.d.ts.map +1 -1
  81. package/dist/server/server.d.ts +6 -2
  82. package/dist/server/server.d.ts.map +1 -1
  83. package/dist/server/server.js +239 -78
  84. package/dist/server/server.js.map +1 -1
  85. package/dist/server/sse.d.ts +1 -1
  86. package/dist/server/sse.d.ts.map +1 -1
  87. package/dist/server/sse.js +5 -4
  88. package/dist/server/sse.js.map +1 -1
  89. package/dist/server/webhook.d.ts +1 -1
  90. package/dist/server/webhook.d.ts.map +1 -1
  91. package/dist/server/webhook.js +24 -19
  92. package/dist/server/webhook.js.map +1 -1
  93. package/dist/server/websocket.d.ts +18 -3
  94. package/dist/server/websocket.d.ts.map +1 -1
  95. package/dist/server/websocket.js +45 -5
  96. package/dist/server/websocket.js.map +1 -1
  97. package/dist/server/ws-bun.d.ts.map +1 -1
  98. package/dist/server/ws-bun.js +7 -4
  99. package/dist/server/ws-bun.js.map +1 -1
  100. package/dist/server.d.ts +1 -0
  101. package/dist/server.d.ts.map +1 -1
  102. package/dist/server.js +1 -0
  103. package/dist/server.js.map +1 -1
  104. package/dist/tool-cli.d.ts +64 -0
  105. package/dist/tool-cli.d.ts.map +1 -0
  106. package/dist/tool-cli.js +495 -0
  107. package/dist/tool-cli.js.map +1 -0
  108. package/package.json +11 -1
  109. package/src/assurance.ts +1 -0
  110. package/src/evidence.ts +4 -0
  111. package/src/file-type.ts +130 -0
  112. package/src/index.ts +2 -1
  113. package/src/internal/route-program.ts +261 -93
  114. package/src/reflection.ts +53 -7
  115. package/src/response-observer.ts +6 -0
  116. package/src/server/bare-error-lane.ts +9 -4
  117. package/src/server/before-handler.ts +14 -0
  118. package/src/server/body-lane.ts +19 -9
  119. package/src/server/body-parser.ts +1 -5
  120. package/src/server/body.ts +96 -36
  121. package/src/server/core-copies.ts +1 -1
  122. package/src/server/http.ts +6 -15
  123. package/src/server/multipart.ts +232 -100
  124. package/src/server/proto-guard.ts +39 -4
  125. package/src/server/query.ts +7 -13
  126. package/src/server/repeated-prefix.ts +25 -0
  127. package/src/server/request-context.ts +1 -3
  128. package/src/server/respond.ts +23 -15
  129. package/src/server/response-contract-lane.ts +130 -8
  130. package/src/server/runtime-core.ts +10 -1
  131. package/src/server/server-types.ts +3 -2
  132. package/src/server/server.ts +265 -76
  133. package/src/server/sse.ts +6 -5
  134. package/src/server/webhook.ts +29 -24
  135. package/src/server/websocket.ts +57 -8
  136. package/src/server/ws-bun.ts +7 -3
  137. package/src/server.ts +1 -0
  138. package/src/tool-cli.ts +584 -0
@@ -4,9 +4,10 @@
4
4
  * this reads the body bounded (DoS guard, shared with `c.boundedBody`) and verifies before handing
5
5
  * back the raw text for the handler to parse with its own schema.
6
6
  *
7
- * Verification is **constant-time**: the provider's signature is fed to `crypto.subtle.verify`
8
- * (WebCrypto), which checks the HMAC without a byte-by-byte string compare - so a wrong signature
9
- * can't be discovered through timing. WebCrypto-only, so it runs on Bun / Node / Deno / workerd.
7
+ * Verification is **constant-time**: the expected HMAC is computed once per secret with
8
+ * `crypto.subtle.sign` (WebCrypto) and compared to each signature the header lists without an early
9
+ * exit - so a wrong signature can't be discovered through timing, and a header listing many
10
+ * signatures costs no extra HMAC over the body. WebCrypto-only, so it runs on Bun / Node / Deno / workerd.
10
11
  *
11
12
  * Presets cover Stripe (`t=…,v1=…` with a replay-window check) and GitHub (`sha256=…`); `generic`
12
13
  * takes an explicit header + encoding for any other provider.
@@ -48,6 +49,7 @@ export type WebhookFailureReason =
48
49
  | "malformed_signature"
49
50
  | "payload_too_large"
50
51
  | "invalid_content_length"
52
+ | "invalid_body"
51
53
 
52
54
  /** Verified ⇒ the raw `payload` text (parse it with your schema). Rejected ⇒ a stable `reason`. */
53
55
  export type WebhookResult =
@@ -74,7 +76,7 @@ const importHmacKey = (secret: string): Promise<CryptoKey> => {
74
76
  TEXT.encode(secret),
75
77
  { name: "HMAC", hash: "SHA-256" },
76
78
  false,
77
- ["verify"],
79
+ ["sign"],
78
80
  )
79
81
  }
80
82
 
@@ -120,17 +122,26 @@ const decodeSignature = (
120
122
  ? fromBase64Url(value)
121
123
  : fromBase64(value)
122
124
 
123
- /** True iff `sig` is a valid HMAC-SHA256 of `payload` under any of `keys` (constant-time per check). */
124
- async function verifyAny(
125
- keys: readonly CryptoKey[],
126
- sig: Uint8Array<ArrayBuffer>,
127
- payload: Uint8Array,
128
- ): Promise<boolean> {
129
- for (const key of keys) {
130
- if (await crypto.subtle.verify("HMAC", key, sig, payload as Uint8Array<ArrayBuffer>))
131
- return true
125
+ /** The HMAC-SHA256 of `payload` under each of `keys`. */
126
+ const expectedMacs = (keys: readonly CryptoKey[], payload: Uint8Array): Promise<Uint8Array[]> =>
127
+ Promise.all(
128
+ keys.map(
129
+ async (key) =>
130
+ new Uint8Array(await crypto.subtle.sign("HMAC", key, payload as Uint8Array<ArrayBuffer>)),
131
+ ),
132
+ )
133
+
134
+ /** True iff `sig` equals one of `macs`. Every byte of a same-length MAC is visited; a MAC's length is
135
+ * fixed (32 bytes), so a length mismatch reveals nothing. */
136
+ function matchesAny(macs: readonly Uint8Array[], sig: Uint8Array): boolean {
137
+ let matched = false
138
+ for (const mac of macs) {
139
+ if (mac.length !== sig.length) continue
140
+ let diff = 0
141
+ for (let i = 0; i < mac.length; i++) diff |= (mac[i] ?? 0) ^ (sig[i] ?? 0)
142
+ if (diff === 0) matched = true
132
143
  }
133
- return false
144
+ return matched
134
145
  }
135
146
 
136
147
  // Parse a Stripe `Stripe-Signature` header: `t=<unix>,v1=<hex>[,v1=<hex>…]` (other schemes ignored).
@@ -193,10 +204,7 @@ export async function verifyWebhook(
193
204
 
194
205
  const read = await readBoundedBytes(req, maxBytes)
195
206
  if (!read.ok) {
196
- return {
197
- ok: false,
198
- reason: read.status === 413 ? "payload_too_large" : "invalid_content_length",
199
- }
207
+ return { ok: false, reason: read.error }
200
208
  }
201
209
  const payload = new TextDecoder().decode(read.bytes)
202
210
 
@@ -209,14 +217,11 @@ export async function verifyWebhook(
209
217
  if (preset.timestamped) {
210
218
  const parsed = parseStripeHeader(headerValue)
211
219
  if (parsed === null) return { ok: false, reason: "malformed_signature" }
212
- const signedPayload = TEXT.encode(`${parsed.t}.${payload}`)
220
+ const macs = await expectedMacs(keys, TEXT.encode(`${parsed.t}.${payload}`))
213
221
  let matched = false
214
222
  for (const v1 of parsed.v1) {
215
223
  const sig = decodeSignature(v1, encoding)
216
- if (sig !== null && (await verifyAny(keys, sig, signedPayload))) {
217
- matched = true
218
- break
219
- }
224
+ if (sig !== null && matchesAny(macs, sig)) matched = true
220
225
  }
221
226
  if (!matched) return { ok: false, reason: "invalid_signature" }
222
227
  // The signature covers `t`, so `t` is authentic once matched - now enforce the replay window.
@@ -232,6 +237,6 @@ export async function verifyWebhook(
232
237
  prefix !== "" && headerValue.startsWith(prefix) ? headerValue.slice(prefix.length) : headerValue
233
238
  const sig = decodeSignature(raw, encoding)
234
239
  if (sig === null) return { ok: false, reason: "malformed_signature" }
235
- if (await verifyAny(keys, sig, read.bytes)) return { ok: true, payload }
240
+ if (matchesAny(await expectedMacs(keys, read.bytes), sig)) return { ok: true, payload }
236
241
  return { ok: false, reason: "invalid_signature" }
237
242
  }
@@ -18,7 +18,7 @@ import type {
18
18
  import { validateStandard } from "../schema/standard.ts"
19
19
  import { decodeTransportFrame, type TransportCodecRegistry } from "../transport-codec.ts"
20
20
  import { guardDecodedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
21
- import { isResponseResult } from "./runtime-core.ts"
21
+ import { isResponseResult, type StatusResponse } from "./runtime-core.ts"
22
22
 
23
23
  type MaybePromise<T> = T | Promise<T>
24
24
 
@@ -35,7 +35,9 @@ type WsMessageInput<Schema extends StandardSchemaV1 | undefined> = Schema extend
35
35
  export interface NifraWebSocket<Data = unknown> {
36
36
  /** Send a text (`string`) or binary (`ArrayBuffer`/typed-array) frame. No-op once closed. */
37
37
  send(data: string | ArrayBufferView | ArrayBuffer): void
38
- /** Close the connection (optional code 1000-4999 + short reason). */
38
+ /** Close the connection, with a code an endpoint may send (1000-1003, 1007-1014, 3000-4999;
39
+ * 1000 when none is given, on every runtime) and a reason of at most 123 UTF-8 bytes. Any other
40
+ * close throws a `RangeError`, and the socket stays open. */
39
41
  close(code?: number, reason?: string): void
40
42
  /** `WebSocket.readyState` (0 CONNECTING · 1 OPEN · 2 CLOSING · 3 CLOSED). */
41
43
  readonly readyState: number
@@ -170,7 +172,7 @@ export interface WebSocketHandler<
170
172
  * `data: undefined`. A thrown `Response` rejects with it; any other throw goes to the route's
171
173
  * `onError` hooks, then a flat 500.
172
174
  */
173
- upgrade?(c: WebSocketContext<Env> & Extra): MaybePromise<Data | Response>
175
+ upgrade?(c: WebSocketContext<Env> & Extra): MaybePromise<Data | Response | StatusResponse>
174
176
  /**
175
177
  * Contract-first messages: a Standard Schema (`t`, zod, valibot, …) validating each **inbound** frame.
176
178
  * Text frames are parsed as JSON first. When set, `message` receives the validated, typed value; a
@@ -211,6 +213,9 @@ export interface WebSocketHandler<
211
213
  issues: ReadonlyArray<StandardIssue>,
212
214
  raw: WebSocketData,
213
215
  ): MaybePromise<void>
216
+ /** The connection closed. `code` and `reason` are the server's own close when it closed first
217
+ * (`ws.close()`, or 1009 for a message over the cap), the peer's when the peer did, and 1006 when
218
+ * the connection dropped without a close frame. */
214
219
  close?(ws: NifraWebSocket<Data>, code: number, reason: string): MaybePromise<void>
215
220
  error?(ws: NifraWebSocket<Data>, error: unknown): MaybePromise<void>
216
221
  }
@@ -314,9 +319,20 @@ export function attachWebSocket<
314
319
  handler.transport,
315
320
  reportError,
316
321
  )
322
+ // The runtime's close event carries the peer's echo, which may drop the reason; `close` is given
323
+ // the server's own close when the server closed first, as on Bun.
324
+ let sentClose: readonly [number, string] | undefined
325
+ // A close with no code is 1000 here as on Bun, whose close() cannot send one without a status.
326
+ const closeSocket = (code = 1000, reason?: string): void => {
327
+ if (sentClose === undefined && socket.readyState <= 1) sentClose = [code, reason ?? ""]
328
+ socket.close(code, reason)
329
+ }
317
330
  ws = {
318
331
  send,
319
- close: (code, reason) => socket.close(code, reason),
332
+ close: (code, reason) => {
333
+ assertCloseArgs(code, reason)
334
+ closeSocket(code, reason)
335
+ },
320
336
  get readyState() {
321
337
  return socket.readyState
322
338
  },
@@ -342,7 +358,7 @@ export function attachWebSocket<
342
358
  // The runtime hands over each message whole, so this bounds what reaches the handler, not what
343
359
  // the runtime buffered to deliver it.
344
360
  if (maxPayloadBytes !== undefined && exceedsPayloadCap(payload, maxPayloadBytes)) {
345
- socket.close(1009, "message too large")
361
+ closeSocket(1009, "message too large")
346
362
  return
347
363
  }
348
364
  safe(() =>
@@ -357,7 +373,8 @@ export function attachWebSocket<
357
373
  "close",
358
374
  (event: { readonly code?: number; readonly reason?: string }) => {
359
375
  pubsub.unsubscribeAll(ws) // drop topic subscriptions so the registry never holds a dead socket
360
- safe(() => handler.close?.(ws, event.code ?? 1005, event.reason ?? ""))
376
+ const [code, reason] = sentClose ?? [event.code ?? 1005, event.reason ?? ""]
377
+ safe(() => handler.close?.(ws, code, reason))
361
378
  },
362
379
  )
363
380
  socket.addEventListener("error", (event: unknown) => reportError(event))
@@ -372,7 +389,8 @@ export function attachWebSocket<
372
389
  const WS_MESSAGE_DECODER = new TextDecoder()
373
390
 
374
391
  /** Whether a message is over `cap` UTF-8 bytes, without encoding a text one: each UTF-16 unit takes
375
- * 1-3 bytes (a surrogate pair 4 for its 2), so only a length between those bounds is counted. */
392
+ * 1-3 bytes (a surrogate pair 4 for its 2, a lone surrogate the 3 of U+FFFD), so only a length
393
+ * between those bounds is counted. */
376
394
  export function exceedsPayloadCap(
377
395
  message: string | ArrayBuffer | Uint8Array,
378
396
  cap: number,
@@ -384,12 +402,43 @@ export function exceedsPayloadCap(
384
402
  let bytes = 0
385
403
  for (let i = 0; i < units; i++) {
386
404
  const unit = message.charCodeAt(i)
387
- bytes += unit < 0x80 ? 1 : unit < 0x800 || (unit >= 0xd800 && unit <= 0xdfff) ? 2 : 3
405
+ if (unit >= 0xd800 && unit <= 0xdbff && (message.charCodeAt(i + 1) & 0xfc00) === 0xdc00) {
406
+ bytes += 4
407
+ i++
408
+ } else bytes += unit < 0x80 ? 1 : unit < 0x800 ? 2 : 3
388
409
  if (bytes > cap) return true
389
410
  }
390
411
  return false
391
412
  }
392
413
 
414
+ /**
415
+ * Refuse a close RFC 6455 forbids before the runtime sees it: a code outside 1000-1003, 1007-1014 and
416
+ * 3000-4999, a reason over 123 UTF-8 bytes, or a reason without a code. Runtimes disagreed on such a
417
+ * close - Bun and Deno sent it, Node's `ws` threw after marking the socket closing, which left it
418
+ * half-open, and workerd threw - so every adapter throws this `RangeError` first and the socket stays
419
+ * open.
420
+ */
421
+ export function assertCloseArgs(code: number | undefined, reason: string | undefined): void {
422
+ if (code === undefined) {
423
+ if (reason !== undefined && reason !== "")
424
+ throw new RangeError("ws.close(): a reason needs a code")
425
+ return
426
+ }
427
+ if (
428
+ !Number.isInteger(code) ||
429
+ !(
430
+ (code >= 1000 && code <= 1003) ||
431
+ (code >= 1007 && code <= 1014) ||
432
+ (code >= 3000 && code <= 4999)
433
+ )
434
+ )
435
+ throw new RangeError(
436
+ `ws.close(): ${code} is not a code a server may send (1000-1003, 1007-1014, 3000-4999)`,
437
+ )
438
+ if (reason !== undefined && exceedsPayloadCap(reason, 123))
439
+ throw new RangeError("ws.close(): the reason is over 123 UTF-8 bytes")
440
+ }
441
+
393
442
  type WebSocketSendPayload = string | ArrayBufferView | ArrayBuffer
394
443
 
395
444
  const OUTBOUND_VALIDATION_ERROR = "outbound WebSocket frame failed sendSchema validation"
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import {
9
+ assertCloseArgs,
9
10
  createWebSocketSender,
10
11
  exceedsPayloadCap,
11
12
  type NifraWebSocket,
@@ -103,7 +104,10 @@ function wrapBunSocket(
103
104
  )
104
105
  ws = {
105
106
  send,
106
- close: (code, reason) => raw.close(code, reason),
107
+ close: (code, reason) => {
108
+ assertCloseArgs(code, reason)
109
+ raw.close(code, reason)
110
+ },
107
111
  get readyState() {
108
112
  return raw.readyState
109
113
  },
@@ -206,8 +210,8 @@ export function createBunWsHandlers(
206
210
  // Registry mode: drop topic subscriptions so it never holds a dead socket. Native mode: Bun
207
211
  // unsubscribes the socket from every topic on close, so there is nothing to sweep here.
208
212
  if (!nativePubsub) (ws.data.pubsub ?? topics).unsubscribeAll(nifra)
209
- // Bun's own maxPayloadLength drops the connection without a close frame, so the peer sees 1006;
210
- // the handler gets the 1009 every other payload cap reports.
213
+ // Bun's own maxPayloadLength reports 1006 here, whether it dropped the connection (up to 1.4.3)
214
+ // or sent the peer a 1009 (from 1.4.4); the handler gets the 1009 every other payload cap reports.
211
215
  const oversize = code === 1006 && reason === BUN_TOO_BIG_REASON
212
216
  const closeCode = oversize ? 1009 : code
213
217
  const closeReason = oversize ? "message too large" : reason
package/src/server.ts CHANGED
@@ -46,6 +46,7 @@ export {
46
46
  markAuthenticatedRequest,
47
47
  rejected,
48
48
  } from "./server/auth.ts"
49
+ export { beforeHandler } from "./server/before-handler.ts"
49
50
  export type {
50
51
  Context,
51
52
  Params,