@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.
- package/README.md +6 -2
- package/dist/assurance.d.ts +71 -5
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +90 -25
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +6 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +52 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/durable-effect.js +1 -1
- package/dist/durable-effect.js.map +1 -1
- package/dist/durable-saga.d.ts +6 -0
- package/dist/durable-saga.d.ts.map +1 -1
- package/dist/durable-saga.js +83 -10
- package/dist/durable-saga.js.map +1 -1
- package/dist/edge-kit.d.ts +7 -2
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +7 -2
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +16 -5
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +9 -1
- package/dist/evidence.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/durable-shared.d.ts +1 -2
- package/dist/internal/durable-shared.d.ts.map +1 -1
- package/dist/internal/durable-shared.js +25 -44
- package/dist/internal/durable-shared.js.map +1 -1
- package/dist/internal/durable-types.d.ts +3 -0
- package/dist/internal/durable-types.d.ts.map +1 -1
- package/dist/internal/hook-origins.d.ts +67 -0
- package/dist/internal/hook-origins.d.ts.map +1 -0
- package/dist/internal/hook-origins.js +86 -0
- package/dist/internal/hook-origins.js.map +1 -0
- package/dist/internal/route-assurance.d.ts +6 -3
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +29 -14
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/internal/route-program.d.ts +1 -1
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +59 -16
- package/dist/internal/route-program.js.map +1 -1
- package/dist/reconciliation-worker.d.ts.map +1 -1
- package/dist/reconciliation-worker.js +12 -2
- package/dist/reconciliation-worker.js.map +1 -1
- package/dist/reflection.d.ts +53 -2
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +352 -5
- package/dist/reflection.js.map +1 -1
- package/dist/router/pattern.d.ts +1 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +6 -3
- package/dist/router/pattern.js.map +1 -1
- package/dist/security/baseline.d.ts +3 -0
- package/dist/security/baseline.d.ts.map +1 -1
- package/dist/security/baseline.js +9 -5
- package/dist/security/baseline.js.map +1 -1
- package/dist/server/auth.d.ts +32 -3
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +23 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/body.d.ts +7 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +34 -1
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +3 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +2 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -3
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +38 -17
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +2 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +2 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/logger.js +3 -1
- package/dist/server/logger.js.map +1 -1
- package/dist/server/methods.d.ts +1 -0
- package/dist/server/methods.d.ts.map +1 -1
- package/dist/server/methods.js +1 -0
- package/dist/server/methods.js.map +1 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +29 -11
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/platform-adapters.d.ts.map +1 -1
- package/dist/server/platform-adapters.js +3 -2
- package/dist/server/platform-adapters.js.map +1 -1
- package/dist/server/request-context.d.ts +2 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +4 -1
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +44 -11
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +192 -79
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -41
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +2 -0
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +29 -5
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +44 -31
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +444 -321
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +11 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -3
- package/dist/server/sse.js.map +1 -1
- package/dist/server/transport-lane.d.ts +7 -1
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/webhook.d.ts +5 -2
- package/dist/server/webhook.d.ts.map +1 -1
- package/dist/server/webhook.js +17 -1
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +22 -14
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +21 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +7 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +3 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts +6 -0
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +20 -1
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +71 -40
- package/dist/tool-contract.js.map +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +3 -1
- package/dist/ws.js.map +1 -1
- package/package.json +1 -1
- package/src/assurance.ts +180 -31
- package/src/capabilities.ts +59 -0
- package/src/durable-effect.ts +1 -1
- package/src/durable-saga.ts +94 -10
- package/src/edge-kit.ts +7 -2
- package/src/effect-scope.ts +15 -5
- package/src/errors.ts +1 -0
- package/src/evidence.ts +12 -3
- package/src/index.ts +6 -1
- package/src/internal/durable-shared.ts +25 -37
- package/src/internal/durable-types.ts +3 -0
- package/src/internal/hook-origins.ts +153 -0
- package/src/internal/route-assurance.ts +32 -18
- package/src/internal/route-program.ts +98 -44
- package/src/reconciliation-worker.ts +10 -2
- package/src/reflection.ts +455 -4
- package/src/router/pattern.ts +6 -3
- package/src/security/baseline.ts +10 -5
- package/src/server/auth.ts +45 -3
- package/src/server/body.ts +34 -1
- package/src/server/client-ip.ts +3 -0
- package/src/server/context.ts +2 -1
- package/src/server/idempotency-lane.ts +64 -22
- package/src/server/install.ts +3 -0
- package/src/server/logger.ts +3 -1
- package/src/server/methods.ts +1 -0
- package/src/server/node-outcome.ts +34 -12
- package/src/server/platform-adapters.ts +3 -2
- package/src/server/request-context.ts +5 -0
- package/src/server/respond.ts +222 -69
- package/src/server/response-observer-runtime.ts +16 -38
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +29 -5
- package/src/server/server.ts +659 -530
- package/src/server/sse.ts +84 -55
- package/src/server/transport-lane.ts +7 -1
- package/src/server/webhook.ts +22 -3
- package/src/server/websocket.ts +44 -5
- package/src/server/ws-hook.ts +5 -0
- package/src/server.ts +5 -0
- package/src/single-copy.ts +20 -1
- package/src/tool-contract.ts +67 -39
- 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
|
|
268
|
-
* are ignored; a missing/throwing
|
|
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
|
|
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:
|
|
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
|
+
}
|
package/src/router/pattern.ts
CHANGED
|
@@ -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(
|
|
447
|
-
if (
|
|
448
|
-
throw new RouteConfigError("INVALID_PATH", `path must start with "/": "${
|
|
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[] = []
|
package/src/security/baseline.ts
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
145
|
+
match: { exceptMethods: NOT_MUTATING },
|
|
141
146
|
require: [...contract, ...rateLimit],
|
|
142
147
|
requireCsrfWithAuthenticated: true,
|
|
143
148
|
requireProvenance: provenance,
|
package/src/server/auth.ts
CHANGED
|
@@ -16,15 +16,31 @@ export interface AuthHeaders {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* The authentication trust boundary.
|
|
20
|
-
*
|
|
21
|
-
* strings until the
|
|
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
|
+
}
|