@nifrajs/middleware 2.11.0 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_utils.d.ts +14 -2
- package/dist/_utils.d.ts.map +1 -1
- package/dist/_utils.js +25 -10
- package/dist/_utils.js.map +1 -1
- package/dist/basic-auth.d.ts +2 -2
- package/dist/basic-auth.d.ts.map +1 -1
- package/dist/basic-auth.js +2 -2
- package/dist/basic-auth.js.map +1 -1
- package/dist/cache-control.d.ts +1 -1
- package/dist/cache-control.d.ts.map +1 -1
- package/dist/cache-control.js +2 -2
- package/dist/cache-control.js.map +1 -1
- package/dist/cache.d.ts +2 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +13 -1
- package/dist/cache.js.map +1 -1
- package/dist/combine.d.ts +11 -5
- package/dist/combine.d.ts.map +1 -1
- package/dist/combine.js +11 -4
- package/dist/combine.js.map +1 -1
- package/dist/compression.d.ts +8 -1
- package/dist/compression.d.ts.map +1 -1
- package/dist/compression.js +9 -2
- package/dist/compression.js.map +1 -1
- package/dist/csrf.d.ts +11 -4
- package/dist/csrf.d.ts.map +1 -1
- package/dist/csrf.js +19 -5
- package/dist/csrf.js.map +1 -1
- package/dist/durable-command.d.ts +2 -2
- package/dist/durable-command.d.ts.map +1 -1
- package/dist/durable-command.js +2 -2
- package/dist/durable-command.js.map +1 -1
- package/dist/etag.d.ts +2 -2
- package/dist/etag.d.ts.map +1 -1
- package/dist/etag.js +3 -3
- package/dist/etag.js.map +1 -1
- package/dist/healthcheck.d.ts +2 -1
- package/dist/healthcheck.d.ts.map +1 -1
- package/dist/healthcheck.js +5 -2
- package/dist/healthcheck.js.map +1 -1
- package/dist/idempotency.d.ts +51 -5
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +110 -13
- package/dist/idempotency.js.map +1 -1
- package/dist/jwt.d.ts +2 -2
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +14 -3
- package/dist/jwt.js.map +1 -1
- package/dist/language.d.ts +9 -1
- package/dist/language.d.ts.map +1 -1
- package/dist/language.js +8 -2
- package/dist/language.js.map +1 -1
- package/dist/logger.d.ts +9 -4
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +9 -5
- package/dist/logger.js.map +1 -1
- package/dist/method-override.d.ts +1 -1
- package/dist/method-override.d.ts.map +1 -1
- package/dist/method-override.js +2 -2
- package/dist/method-override.js.map +1 -1
- package/dist/openapi.d.ts +2 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +4 -2
- package/dist/openapi.js.map +1 -1
- package/dist/pretty-json.d.ts +1 -1
- package/dist/pretty-json.d.ts.map +1 -1
- package/dist/pretty-json.js +2 -2
- package/dist/pretty-json.js.map +1 -1
- package/dist/problem-details.d.ts +1 -1
- package/dist/problem-details.d.ts.map +1 -1
- package/dist/problem-details.js +2 -2
- package/dist/problem-details.js.map +1 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +6 -8
- package/dist/rate-limit.js.map +1 -1
- package/dist/request-id.d.ts +12 -4
- package/dist/request-id.d.ts.map +1 -1
- package/dist/request-id.js +62 -7
- package/dist/request-id.js.map +1 -1
- package/dist/security-headers.d.ts +11 -0
- package/dist/security-headers.d.ts.map +1 -1
- package/dist/security-headers.js +12 -0
- package/dist/security-headers.js.map +1 -1
- package/dist/timing.d.ts +4 -1
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +2 -2
- package/dist/timing.js.map +1 -1
- package/dist/token-auth.d.ts +2 -2
- package/dist/token-auth.d.ts.map +1 -1
- package/dist/token-auth.js +2 -2
- package/dist/token-auth.js.map +1 -1
- package/dist/trailing-slash.d.ts +2 -2
- package/dist/trailing-slash.d.ts.map +1 -1
- package/dist/trailing-slash.js +3 -3
- package/dist/trailing-slash.js.map +1 -1
- package/package.json +3 -3
- package/src/_utils.ts +27 -10
- package/src/basic-auth.ts +3 -3
- package/src/cache-control.ts +2 -2
- package/src/cache.ts +20 -1
- package/src/combine.ts +22 -10
- package/src/compression.ts +9 -2
- package/src/csrf.ts +31 -15
- package/src/durable-command.ts +3 -3
- package/src/etag.ts +3 -3
- package/src/healthcheck.ts +6 -3
- package/src/idempotency.ts +146 -18
- package/src/jwt.ts +22 -4
- package/src/language.ts +26 -16
- package/src/logger.ts +10 -6
- package/src/method-override.ts +2 -2
- package/src/openapi.ts +5 -3
- package/src/pretty-json.ts +2 -2
- package/src/problem-details.ts +2 -2
- package/src/rate-limit.ts +6 -8
- package/src/request-id.ts +63 -11
- package/src/security-headers.ts +23 -0
- package/src/timing.ts +3 -3
- package/src/token-auth.ts +3 -3
- package/src/trailing-slash.ts +3 -3
package/src/csrf.ts
CHANGED
|
@@ -11,9 +11,26 @@ import {
|
|
|
11
11
|
verifyHmacSha256,
|
|
12
12
|
} from "./_utils.ts"
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* An HMAC secret, or a rotation list of them. With a list, the **first** secret signs new tokens
|
|
16
|
+
* and any listed secret verifies - rotate by prepending the new secret and dropping the old one
|
|
17
|
+
* once outstanding tokens have expired. Every entry must meet the 32-byte floor; an empty list
|
|
18
|
+
* throws.
|
|
19
|
+
*/
|
|
20
|
+
export type CsrfSecret = string | Uint8Array | ReadonlyArray<string | Uint8Array>
|
|
21
|
+
|
|
22
|
+
/** Normalize to derived keys, validating every entry's 32-byte floor up front (fail loud at
|
|
23
|
+
* construction, not on the first request that happens to reach an old secret). */
|
|
24
|
+
function csrfKeys(secret: CsrfSecret): ReadonlyArray<Uint8Array> {
|
|
25
|
+
const list: ReadonlyArray<string | Uint8Array> =
|
|
26
|
+
typeof secret === "string" || secret instanceof Uint8Array ? [secret] : secret
|
|
27
|
+
if (list.length === 0) throw new Error("csrf: secret list cannot be empty")
|
|
28
|
+
return list.map((s) => secretBytes(s, "csrf"))
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
export interface CsrfOptions {
|
|
15
|
-
/** HMAC secret
|
|
16
|
-
readonly secret:
|
|
32
|
+
/** HMAC secret (≥ 32 bytes), or a rotation list - see {@link CsrfSecret}. */
|
|
33
|
+
readonly secret: CsrfSecret
|
|
17
34
|
/** Cookie carrying the signed token. Default `"csrf-token"`. */
|
|
18
35
|
readonly cookie?: string
|
|
19
36
|
/** Header carrying the same signed token. Default `"x-csrf-token"`. */
|
|
@@ -46,11 +63,8 @@ function originAllowed(req: Request, origins: Set<string> | undefined): boolean
|
|
|
46
63
|
}
|
|
47
64
|
}
|
|
48
65
|
|
|
49
|
-
export async function createCsrfToken(
|
|
50
|
-
secret
|
|
51
|
-
nonce?: string,
|
|
52
|
-
): Promise<string> {
|
|
53
|
-
const key = secretBytes(secret, "csrf")
|
|
66
|
+
export async function createCsrfToken(secret: CsrfSecret, nonce?: string): Promise<string> {
|
|
67
|
+
const key = csrfKeys(secret)[0] as Uint8Array
|
|
54
68
|
const tokenNonce = nonce ?? base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))
|
|
55
69
|
if (!/^[A-Za-z0-9_-]{22,}$/.test(tokenNonce)) {
|
|
56
70
|
throw new Error("csrf: nonce must be base64url-like and at least 22 characters")
|
|
@@ -59,17 +73,19 @@ export async function createCsrfToken(
|
|
|
59
73
|
return `${payload}.${await hmacSha256(payload, key)}`
|
|
60
74
|
}
|
|
61
75
|
|
|
62
|
-
export async function verifyCsrfToken(
|
|
63
|
-
|
|
64
|
-
secret: string | Uint8Array,
|
|
65
|
-
): Promise<boolean> {
|
|
66
|
-
const key = secretBytes(secret, "csrf")
|
|
76
|
+
export async function verifyCsrfToken(token: string, secret: CsrfSecret): Promise<boolean> {
|
|
77
|
+
const keys = csrfKeys(secret)
|
|
67
78
|
const parts = token.split(".")
|
|
68
79
|
if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX || parts[1] === "" || parts[2] === "") {
|
|
69
80
|
return false
|
|
70
81
|
}
|
|
71
82
|
const [prefix, nonce, signature] = parts as [string, string, string]
|
|
72
|
-
|
|
83
|
+
// Rotation: accept a token signed by any listed secret. Each comparison is constant-time; which
|
|
84
|
+
// generation matched is not attacker-meaningful, so the early exit between keys leaks nothing.
|
|
85
|
+
for (const key of keys) {
|
|
86
|
+
if (await verifyHmacSha256(`${prefix}.${nonce}`, signature, key)) return true
|
|
87
|
+
}
|
|
88
|
+
return false
|
|
73
89
|
}
|
|
74
90
|
|
|
75
91
|
/**
|
|
@@ -77,7 +93,7 @@ export async function verifyCsrfToken(
|
|
|
77
93
|
* cookie and a header, and must come from an allowed Origin/Referer unless `checkOrigin:false` is set.
|
|
78
94
|
*/
|
|
79
95
|
export function csrf(options: CsrfOptions): Middleware {
|
|
80
|
-
const
|
|
96
|
+
const keys = csrfKeys(options.secret)
|
|
81
97
|
const cookie = options.cookie ?? "csrf-token"
|
|
82
98
|
const header = (options.header ?? "x-csrf-token").toLowerCase()
|
|
83
99
|
const methods =
|
|
@@ -97,7 +113,7 @@ export function csrf(options: CsrfOptions): Middleware {
|
|
|
97
113
|
if (!(await timingSafeEqualString(cookieToken, headerToken))) {
|
|
98
114
|
return jsonError(403, "csrf_failed")
|
|
99
115
|
}
|
|
100
|
-
return (await verifyCsrfToken(cookieToken,
|
|
116
|
+
return (await verifyCsrfToken(cookieToken, keys)) ? undefined : jsonError(403, "csrf_failed")
|
|
101
117
|
},
|
|
102
118
|
}
|
|
103
119
|
return withRouteAssurance(middleware, {
|
package/src/durable-command.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
attachCapabilityJournal,
|
|
4
4
|
type CapabilityExecutionJournal,
|
|
5
5
|
} from "@nifrajs/core/capabilities"
|
|
6
|
-
import {
|
|
6
|
+
import { defineIdentityPlugin, type IdentityPlugin } from "@nifrajs/core/server"
|
|
7
7
|
|
|
8
8
|
export interface DurableCommandOptions {
|
|
9
9
|
/** Where effect transitions are recorded. `createDurableEffectJournal` from
|
|
@@ -36,7 +36,7 @@ export interface DurableCommandOptions {
|
|
|
36
36
|
* deliberately never proves this one: replaying a stored response after a crash mid-effect does not
|
|
37
37
|
* make the effect exactly-once, and the journal is what survives the crash.
|
|
38
38
|
*/
|
|
39
|
-
export function durableCommand(options: DurableCommandOptions):
|
|
39
|
+
export function durableCommand(options: DurableCommandOptions): IdentityPlugin {
|
|
40
40
|
const journal = options.journal
|
|
41
41
|
if (journal === null || typeof journal !== "object") {
|
|
42
42
|
throw new TypeError("durableCommand: journal must be a CapabilityExecutionJournal")
|
|
@@ -48,7 +48,7 @@ export function durableCommand(options: DurableCommandOptions): NifraPlugin {
|
|
|
48
48
|
throw new TypeError(`durableCommand: journal.${method} must be a function`)
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
const plugin =
|
|
51
|
+
const plugin = defineIdentityPlugin("nifra:durable-command", (app) =>
|
|
52
52
|
app.beforeHandle((c: object) => {
|
|
53
53
|
attachCapabilityJournal(c, journal)
|
|
54
54
|
return undefined
|
package/src/etag.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defineRouterPlugin, type ResponseBodyReplacement } from "@nifrajs/core/server"
|
|
2
2
|
|
|
3
3
|
/** 32-bit FNV-1a over bytes → hex. A fast, dependency-free content fingerprint for ETags - not crypto. */
|
|
4
4
|
function fnv1a(bytes: Uint8Array): string {
|
|
@@ -92,7 +92,7 @@ function replayFrom(res: Response, chunks: readonly Uint8Array[], reader: ChunkR
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
* A {@link
|
|
95
|
+
* A {@link defineRouterPlugin} plugin that adds a content-hash `ETag` to `GET` `200` responses and returns
|
|
96
96
|
* **`304 Not Modified`** when the client's `If-None-Match` matches - saving bandwidth on unchanged
|
|
97
97
|
* responses. Built on the portable `onResponseBody` tier: the hook receives the final
|
|
98
98
|
* framework-serialized bytes on every runtime with nothing drained, so the middleware stays on
|
|
@@ -106,7 +106,7 @@ export function etag(options: ETagOptions = {}) {
|
|
|
106
106
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
|
|
107
107
|
throw new Error("etag: maxBytes must be a non-negative safe integer")
|
|
108
108
|
}
|
|
109
|
-
return
|
|
109
|
+
return defineRouterPlugin("etag", (app) =>
|
|
110
110
|
app.use({
|
|
111
111
|
onResponseBody(body, headers, req, status) {
|
|
112
112
|
if (req.method !== "GET" || status !== 200) return undefined
|
package/src/healthcheck.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defineRouterPlugin, type IdentityPlugin } from "@nifrajs/core/server"
|
|
2
2
|
|
|
3
3
|
type MaybePromise<T> = T | Promise<T>
|
|
4
4
|
|
|
@@ -26,12 +26,15 @@ const noStore = { "cache-control": "no-store" } as const
|
|
|
26
26
|
* app.use(healthcheck({ checks: { db: () => db.ping() } })).use(bearer({ verify }))
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
-
export function healthcheck(options: HealthcheckOptions = {}) {
|
|
29
|
+
export function healthcheck(options: HealthcheckOptions = {}): IdentityPlugin {
|
|
30
30
|
const path = options.path ?? "/health"
|
|
31
31
|
const readyPath = options.readyPath ?? "/ready"
|
|
32
32
|
const checks = options.checks ?? {}
|
|
33
33
|
const checkNames = Object.keys(checks)
|
|
34
|
-
|
|
34
|
+
// A router plugin: the probes are registered as a side effect and the app comes back with its type
|
|
35
|
+
// unchanged, so routes declared after `.use(healthcheck())` keep theirs. The probes are deliberately
|
|
36
|
+
// absent from the caller's typed registry - they are operational endpoints, not part of the contract.
|
|
37
|
+
return defineRouterPlugin("healthcheck", (app) => {
|
|
35
38
|
app.register("GET", path, undefined, () =>
|
|
36
39
|
Response.json({ status: "ok" }, { headers: noStore }),
|
|
37
40
|
)
|
package/src/idempotency.ts
CHANGED
|
@@ -47,15 +47,34 @@ export interface IdempotencyStore {
|
|
|
47
47
|
export interface MemoryIdempotencyStoreOptions {
|
|
48
48
|
/** Allow the in-memory store in production. Off by default - a per-instance store can't dedupe across instances. */
|
|
49
49
|
readonly allowInProduction?: boolean
|
|
50
|
+
/** Maximum retained locks + records. Default `10_000`. */
|
|
51
|
+
readonly maxEntries?: number
|
|
52
|
+
/** Maximum key length in UTF-8 bytes. Default `1024`. */
|
|
53
|
+
readonly maxKeyBytes?: number
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
type Entry =
|
|
53
57
|
| { readonly kind: "lock"; readonly expiresAt: number }
|
|
54
58
|
| { readonly kind: "record"; readonly record: IdempotencyRecord; readonly expiresAt: number }
|
|
55
59
|
|
|
60
|
+
const KEY_ENCODER = new TextEncoder()
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Thrown by a store that is full of entries none of which may be discarded. The middleware turns it
|
|
64
|
+
* into a `503` with `retry-after`; a store that can grow (Redis) never raises it.
|
|
65
|
+
*/
|
|
66
|
+
export class IdempotencyCapacityError extends Error {
|
|
67
|
+
constructor() {
|
|
68
|
+
super("idempotency store is at capacity")
|
|
69
|
+
this.name = "IdempotencyCapacityError"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
56
73
|
/** In-process store. Refuses to run in production unless explicitly allowed (per-instance ⇒ no cross-instance dedupe). */
|
|
57
74
|
export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
58
75
|
private readonly entries = new Map<string, Entry>()
|
|
76
|
+
private readonly maxEntries: number
|
|
77
|
+
private readonly maxKeyBytes: number
|
|
59
78
|
|
|
60
79
|
constructor(options: MemoryIdempotencyStoreOptions = {}) {
|
|
61
80
|
if (options.allowInProduction !== true && process.env.NODE_ENV === "production") {
|
|
@@ -64,11 +83,51 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
64
83
|
"Use a shared store (e.g. Redis), or pass { allowInProduction: true } for a single-instance deploy.",
|
|
65
84
|
)
|
|
66
85
|
}
|
|
86
|
+
this.maxEntries = options.maxEntries ?? 10_000
|
|
87
|
+
this.maxKeyBytes = options.maxKeyBytes ?? 1024
|
|
88
|
+
if (!Number.isSafeInteger(this.maxEntries) || this.maxEntries < 1)
|
|
89
|
+
throw new RangeError("MemoryIdempotencyStore: maxEntries must be a positive safe integer")
|
|
90
|
+
if (!Number.isSafeInteger(this.maxKeyBytes) || this.maxKeyBytes < 1)
|
|
91
|
+
throw new RangeError("MemoryIdempotencyStore: maxKeyBytes must be a positive safe integer")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private validateKey(key: string): void {
|
|
95
|
+
if (KEY_ENCODER.encode(key).byteLength > this.maxKeyBytes)
|
|
96
|
+
throw new RangeError("MemoryIdempotencyStore: key is too large")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private maintain(now: number): void {
|
|
100
|
+
// Expiry cleanup is incremental so normal requests never scan the full map.
|
|
101
|
+
let checked = 0
|
|
102
|
+
for (const [key, entry] of this.entries) {
|
|
103
|
+
if (entry.expiresAt <= now) this.entries.delete(key)
|
|
104
|
+
if (++checked >= 16) break
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private reserve(key: string, entry: Entry): void {
|
|
109
|
+
if (!this.entries.has(key) && this.entries.size >= this.maxEntries) {
|
|
110
|
+
// The incremental sweep only samples, so pay for a full one before declaring the store full.
|
|
111
|
+
const now = Date.now()
|
|
112
|
+
for (const [existing, held] of this.entries) {
|
|
113
|
+
if (held.expiresAt <= now) this.entries.delete(existing)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!this.entries.has(key) && this.entries.size >= this.maxEntries) {
|
|
117
|
+
// Refuse rather than evict. Every entry here is either a lock on a request still running or a
|
|
118
|
+
// response another request is entitled to replay, so dropping one to make room hands out
|
|
119
|
+
// exactly the duplicate execution this store exists to prevent - and does it silently, under
|
|
120
|
+
// the load where it is most likely to matter. A 503 is the honest answer; raise `maxEntries`.
|
|
121
|
+
throw new IdempotencyCapacityError()
|
|
122
|
+
}
|
|
123
|
+
this.entries.set(key, entry)
|
|
67
124
|
}
|
|
68
125
|
|
|
69
126
|
begin(key: string, lockTtlMs: number): Promise<IdempotencyClaim> {
|
|
127
|
+
this.validateKey(key)
|
|
70
128
|
assertPositiveTtl(lockTtlMs, "lockTtlMs")
|
|
71
129
|
const now = Date.now()
|
|
130
|
+
this.maintain(now)
|
|
72
131
|
const entry = this.entries.get(key)
|
|
73
132
|
if (entry !== undefined && entry.expiresAt > now) {
|
|
74
133
|
return Promise.resolve(
|
|
@@ -78,13 +137,14 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
78
137
|
)
|
|
79
138
|
}
|
|
80
139
|
// Free (or expired): take the lock. Synchronous Map write ⇒ atomic on a single instance.
|
|
81
|
-
this.
|
|
140
|
+
this.reserve(key, { kind: "lock", expiresAt: now + lockTtlMs })
|
|
82
141
|
return Promise.resolve({ state: "new" })
|
|
83
142
|
}
|
|
84
143
|
|
|
85
144
|
complete(key: string, record: IdempotencyRecord, ttlMs: number): Promise<void> {
|
|
145
|
+
this.validateKey(key)
|
|
86
146
|
assertPositiveTtl(ttlMs, "ttlMs")
|
|
87
|
-
this.
|
|
147
|
+
this.reserve(key, { kind: "record", record, expiresAt: Date.now() + ttlMs })
|
|
88
148
|
return Promise.resolve()
|
|
89
149
|
}
|
|
90
150
|
|
|
@@ -111,12 +171,29 @@ export interface IdempotencyOptions {
|
|
|
111
171
|
/** Whether a response should be cached for replay. Default: status `< 500` (don't replay transient 5xx). */
|
|
112
172
|
readonly shouldCache?: (response: Response) => boolean
|
|
113
173
|
/**
|
|
114
|
-
* Derive the store key from the request. Default: the `header` value
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
174
|
+
* Derive the store key from the request. Default: the `header` value scoped by method + path **and
|
|
175
|
+
* by a digest of the caller's credentials** (see `principalHeaders`), so neither a different
|
|
176
|
+
* endpoint nor a different caller can collide on one key. Return `null`/`""` to skip dedupe for
|
|
177
|
+
* this request.
|
|
178
|
+
*
|
|
179
|
+
* Supplying your own replaces that scoping entirely - a custom key MUST fold in the principal
|
|
180
|
+
* itself (e.g. `` `${userId}:${req.headers.get("idempotency-key")}` ``), or one user can replay
|
|
181
|
+
* another's stored response by guessing their key.
|
|
182
|
+
*/
|
|
183
|
+
readonly key?: (req: Request, header: string) => string | null | Promise<string | null>
|
|
184
|
+
/**
|
|
185
|
+
* Headers whose values identify the caller. Their digest scopes the default key, so the same
|
|
186
|
+
* `Idempotency-Key` from two callers addresses two entries and neither can read the other's cached
|
|
187
|
+
* response. Defaults to Authorization, Cookie, and x-api-key. Only a digest is stored - a raw
|
|
188
|
+
* credential must never become a store key, which would put it in front of every Redis `KEYS` dump
|
|
189
|
+
* and slow-log line. Ignored when `key` is supplied.
|
|
190
|
+
*
|
|
191
|
+
* The digest covers the header value verbatim, so anything that varies between two requests from the
|
|
192
|
+
* same caller - a rotated bearer token, an analytics cookie appended mid-session - lands on a fresh
|
|
193
|
+
* key and the retry executes again. Narrow the list (or supply `key`) when the app has a stable
|
|
194
|
+
* principal id to scope by; that is strictly better than digesting a whole `Cookie` header.
|
|
118
195
|
*/
|
|
119
|
-
readonly
|
|
196
|
+
readonly principalHeaders?: readonly string[]
|
|
120
197
|
}
|
|
121
198
|
|
|
122
199
|
const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const
|
|
@@ -258,23 +335,54 @@ function replay(record: IdempotencyRecord): Response {
|
|
|
258
335
|
return new Response(bodyFor(fromBase64(record.body)), { status: record.status, headers })
|
|
259
336
|
}
|
|
260
337
|
|
|
338
|
+
const DEFAULT_PRINCIPAL_HEADERS = ["authorization", "cookie", "x-api-key"] as const
|
|
339
|
+
|
|
340
|
+
/** SHA-256 of the caller's credential headers, base64url. Only the digest ever becomes part of a store
|
|
341
|
+
* key: a raw `Authorization` value used as a key would be printed by every Redis `KEYS` dump, slow-log
|
|
342
|
+
* line, and store-side metric that treats keys as non-sensitive. */
|
|
343
|
+
async function principalDigest(req: Request, headers: readonly string[]): Promise<string | null> {
|
|
344
|
+
let material = ""
|
|
345
|
+
for (const name of headers) {
|
|
346
|
+
const value = req.headers.get(name)
|
|
347
|
+
// The header name is part of the material so `authorization: x` and `x-api-key: x` can't collide.
|
|
348
|
+
if (value !== null) material += `${name}:${value}\n`
|
|
349
|
+
}
|
|
350
|
+
if (material === "") return null // no credentials presented - an anonymous caller
|
|
351
|
+
const hash = await crypto.subtle.digest("SHA-256", KEY_ENCODER.encode(material))
|
|
352
|
+
return toBase64(new Uint8Array(hash)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The default store key: the `header` value scoped by method + path (so the same key on a different
|
|
357
|
+
* endpoint can't collide) **and by a digest of the caller's credentials** (so one caller can't replay
|
|
358
|
+
* another's stored response by presenting their key). `null` when no key header is present - no dedupe.
|
|
359
|
+
*/
|
|
360
|
+
function defaultIdempotencyKey(
|
|
361
|
+
req: Request,
|
|
362
|
+
header: string,
|
|
363
|
+
principalHeaders: readonly string[],
|
|
364
|
+
): Promise<string | null> | null {
|
|
365
|
+
const raw = req.headers.get(header)
|
|
366
|
+
if (raw === null || raw === "") return null
|
|
367
|
+
const scope = `${req.method.toUpperCase()} ${new URL(req.url).pathname}`
|
|
368
|
+
return principalDigest(req, principalHeaders).then(
|
|
369
|
+
(principal) => `${scope}\n${principal ?? "anon"}\n${raw}`,
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
261
373
|
/**
|
|
262
374
|
* Idempotency-key middleware. Apply with `app.use(idempotency({ store }))`.
|
|
263
375
|
*
|
|
376
|
+
* The store key is scoped by method + path **and** by a digest of the caller's credential headers, so
|
|
377
|
+
* a key one caller chose addresses only that caller's entry - presenting someone else's key replays
|
|
378
|
+
* nothing. See `principalHeaders`; a custom `key` replaces that scoping and must do it itself.
|
|
379
|
+
*
|
|
264
380
|
* **`Set-Cookie` is intentionally not cached or replayed** - a cookie set on the first request is
|
|
265
381
|
* session-specific, so replaying it to a different caller (key collision or abuse) would leak/fixate a
|
|
266
382
|
* session. Cache the body + status + the rest of the headers; let auth cookies re-issue per request.
|
|
267
383
|
*
|
|
268
384
|
* Caching buffers the response body, so apply this to JSON/API routes, not streaming SSR responses.
|
|
269
385
|
*/
|
|
270
|
-
/** The default store key: the `header` value scoped by method + path, so the same key on a different
|
|
271
|
-
* endpoint can't collide (cross-resource replay). `null` when no key header is present (no dedupe). */
|
|
272
|
-
function defaultIdempotencyKey(req: Request, header: string): string | null {
|
|
273
|
-
const raw = req.headers.get(header)
|
|
274
|
-
if (raw === null || raw === "") return null
|
|
275
|
-
return `${req.method.toUpperCase()} ${new URL(req.url).pathname}\n${raw}`
|
|
276
|
-
}
|
|
277
|
-
|
|
278
386
|
export function idempotency(options: IdempotencyOptions): Middleware {
|
|
279
387
|
const { store } = options
|
|
280
388
|
const header = (options.header ?? "idempotency-key").toLowerCase()
|
|
@@ -288,16 +396,36 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
288
396
|
throw new Error("idempotency: maxBytes must be a non-negative integer")
|
|
289
397
|
}
|
|
290
398
|
const shouldCache = options.shouldCache ?? ((res: Response) => res.status < 500)
|
|
291
|
-
const
|
|
399
|
+
const principalHeaders = (options.principalHeaders ?? DEFAULT_PRINCIPAL_HEADERS).map((name) =>
|
|
400
|
+
name.toLowerCase(),
|
|
401
|
+
)
|
|
402
|
+
const custom = options.key
|
|
403
|
+
const keyOf =
|
|
404
|
+
custom === undefined
|
|
405
|
+
? (req: Request): Promise<string | null> | null =>
|
|
406
|
+
defaultIdempotencyKey(req, header, principalHeaders)
|
|
407
|
+
: (req: Request): string | null | Promise<string | null> => custom(req, header)
|
|
292
408
|
const claimed = new WeakMap<Request, string>()
|
|
293
409
|
|
|
294
410
|
const middleware: Middleware = {
|
|
295
411
|
name: "idempotency",
|
|
296
412
|
async onRequest(req) {
|
|
297
413
|
if (!methods.has(req.method.toUpperCase())) return undefined
|
|
298
|
-
const key = keyOf(req
|
|
414
|
+
const key = await keyOf(req)
|
|
299
415
|
if (key === null || key === "") return undefined // opt-in per request - no key ⇒ no dedupe
|
|
300
|
-
|
|
416
|
+
let claim: IdempotencyClaim
|
|
417
|
+
try {
|
|
418
|
+
claim = await store.begin(key, lockTtlMs)
|
|
419
|
+
} catch (err) {
|
|
420
|
+
// A full store can't tell a first request from a retry, and guessing either way is wrong:
|
|
421
|
+
// serving the request risks a duplicate side effect, replaying nothing risks losing one. Say
|
|
422
|
+
// so with a 503 the client is expected to retry, rather than a 500 that reads as a bug.
|
|
423
|
+
if (!(err instanceof IdempotencyCapacityError)) throw err
|
|
424
|
+
return new Response(JSON.stringify({ ok: false, error: "idempotency_unavailable" }), {
|
|
425
|
+
status: 503,
|
|
426
|
+
headers: { "content-type": "application/json", "retry-after": "1" },
|
|
427
|
+
})
|
|
428
|
+
}
|
|
301
429
|
if (claim.state === "replay") return replay(claim.record)
|
|
302
430
|
if (claim.state === "in_flight") {
|
|
303
431
|
return new Response(JSON.stringify({ ok: false, error: "idempotency_in_progress" }), {
|
package/src/jwt.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
|
-
import {
|
|
2
|
+
import { defineIdentityPlugin, type IdentityPlugin } from "@nifrajs/core/server"
|
|
3
3
|
import {
|
|
4
4
|
base64UrlDecode,
|
|
5
5
|
jsonError,
|
|
@@ -76,7 +76,7 @@ export interface JwtOptions extends VerifyJwtOptions {
|
|
|
76
76
|
readonly cookie?: string
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
export type JwtPlugin<C extends JwtClaims = JwtClaims> =
|
|
79
|
+
export type JwtPlugin<C extends JwtClaims = JwtClaims> = IdentityPlugin & {
|
|
80
80
|
claims(request: Request): C | null
|
|
81
81
|
requireClaims(request: Request): C
|
|
82
82
|
}
|
|
@@ -152,7 +152,25 @@ async function importKey(
|
|
|
152
152
|
kid: string | undefined,
|
|
153
153
|
): Promise<CryptoKey> {
|
|
154
154
|
const hash = HASH[alg]
|
|
155
|
-
if (key instanceof CryptoKey)
|
|
155
|
+
if (key instanceof CryptoKey) {
|
|
156
|
+
const algorithm = key.algorithm as {
|
|
157
|
+
readonly name: string
|
|
158
|
+
readonly hash?: { readonly name?: string }
|
|
159
|
+
}
|
|
160
|
+
const expectedName = HMAC_ALGS.has(alg) ? "HMAC" : "RSASSA-PKCS1-v1_5"
|
|
161
|
+
const expectedType = HMAC_ALGS.has(alg) ? "secret" : "public"
|
|
162
|
+
const actualHash =
|
|
163
|
+
"hash" in algorithm && typeof algorithm.hash === "object" ? algorithm.hash.name : undefined
|
|
164
|
+
if (
|
|
165
|
+
key.type !== expectedType ||
|
|
166
|
+
algorithm.name !== expectedName ||
|
|
167
|
+
actualHash !== hash ||
|
|
168
|
+
!key.usages.includes("verify")
|
|
169
|
+
) {
|
|
170
|
+
throw jwtError("CryptoKey does not match the requested JWT algorithm")
|
|
171
|
+
}
|
|
172
|
+
return key
|
|
173
|
+
}
|
|
156
174
|
if (typeof key === "string" || key instanceof Uint8Array) {
|
|
157
175
|
if (!HMAC_ALGS.has(alg)) throw jwtError("symmetric key cannot verify an RSA token")
|
|
158
176
|
return crypto.subtle.importKey("raw", secretBytes(key, "jwt"), { name: "HMAC", hash }, false, [
|
|
@@ -306,7 +324,7 @@ export function jwt<C extends JwtClaims = JwtClaims>(options: JwtOptions): JwtPl
|
|
|
306
324
|
const header = (options.header ?? "authorization").toLowerCase()
|
|
307
325
|
const store = new WeakMap<Request, C>()
|
|
308
326
|
|
|
309
|
-
const plugin =
|
|
327
|
+
const plugin = defineIdentityPlugin("jwt", (app) =>
|
|
310
328
|
app.beforeHandle(async (c: { readonly req: Request }) => {
|
|
311
329
|
const token = tokenFromRequest(c.req, header, options.cookie)
|
|
312
330
|
if (token === null) return optional ? undefined : reject(realm)
|
package/src/language.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type ContextPlugin, defineContextPlugin } from "@nifrajs/core/server"
|
|
2
2
|
|
|
3
3
|
export interface LanguageMatch {
|
|
4
4
|
readonly language: string
|
|
@@ -78,8 +78,14 @@ export function pickLanguage<const L extends readonly string[]>(
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* Derives `c.language` from `Accept-Language` and emits `Content-Language` by default.
|
|
81
|
+
*
|
|
82
|
+
* Header-only by design. For the full detector - `?lang=` query parameter → cookie →
|
|
83
|
+
* `Accept-Language`, with optional cookie persistence - use `localeDetector()` from
|
|
84
|
+
* `@nifrajs/i18n/detector`; use one or the other, not both.
|
|
81
85
|
*/
|
|
82
|
-
export function language<const L extends readonly string[]>(
|
|
86
|
+
export function language<const L extends readonly string[]>(
|
|
87
|
+
options: LanguageOptions<L>,
|
|
88
|
+
): ContextPlugin<{ language: L[number]; languageMatch: LanguageMatch }> {
|
|
83
89
|
const { supported, defaultLanguage } = options
|
|
84
90
|
if (supported.length === 0) throw new Error("language: supported must not be empty")
|
|
85
91
|
if (!supported.includes(defaultLanguage)) {
|
|
@@ -90,19 +96,23 @@ export function language<const L extends readonly string[]>(options: LanguageOpt
|
|
|
90
96
|
// instead of pairing state through a WeakMap - that is what lets the Node twin exist at all (it
|
|
91
97
|
// has no `Request` identity to key on), keeps both hooks trivially identical, and lets the derive
|
|
92
98
|
// read the header without materializing a Web `Request` on the Node adapter.
|
|
93
|
-
return
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
headers
|
|
105
|
-
|
|
106
|
-
|
|
99
|
+
return defineContextPlugin<{ language: L[number]; languageMatch: LanguageMatch }>(
|
|
100
|
+
"language",
|
|
101
|
+
(app) =>
|
|
102
|
+
app
|
|
103
|
+
.derive((c) => {
|
|
104
|
+
const match = pickLanguage(c.header("accept-language"), supported, defaultLanguage)
|
|
105
|
+
// `pickLanguage` only ever returns a member of `supported` (or `defaultLanguage`), so the
|
|
106
|
+
// narrower literal union is the honest type for handlers to switch on.
|
|
107
|
+
return { language: match.language as L[number], languageMatch: match }
|
|
108
|
+
})
|
|
109
|
+
.use({
|
|
110
|
+
onResponseHeaders(headers, req) {
|
|
111
|
+
if (!emitHeader) return
|
|
112
|
+
if (headers.has("content-language")) return
|
|
113
|
+
const match = pickLanguage(req.header("accept-language"), supported, defaultLanguage)
|
|
114
|
+
headers.set("content-language", match.language)
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
107
117
|
)
|
|
108
118
|
}
|
package/src/logger.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defineIdentityPlugin, type IdentityPlugin, pathnameOf } from "@nifrajs/core/server"
|
|
2
2
|
|
|
3
3
|
/** Structured fields logged per request. */
|
|
4
4
|
export interface RequestLogFields {
|
|
@@ -15,18 +15,22 @@ export interface LoggerOptions {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* A {@link
|
|
19
|
-
* and duration - via `onRequest`/`onResponse` (so it covers 404s and errors too). The start
|
|
20
|
-
* paired to the request through a `WeakMap` (no per-request allocation leak). Idempotent.
|
|
18
|
+
* A {@link defineIdentityPlugin} plugin that logs one structured line per request - method, path,
|
|
19
|
+
* status, and duration - via `onRequest`/`onResponse` (so it covers 404s and errors too). The start
|
|
20
|
+
* time is paired to the request through a `WeakMap` (no per-request allocation leak). Idempotent.
|
|
21
|
+
*
|
|
22
|
+
* It adds no context and registers no routes, so it is type-identity: routes declared after
|
|
23
|
+
* `.use(logger())` keep their types. (Under `definePlugin` they would not - see that helper's
|
|
24
|
+
* FOOTGUN note.)
|
|
21
25
|
*/
|
|
22
|
-
export function logger(options: LoggerOptions = {}) {
|
|
26
|
+
export function logger(options: LoggerOptions = {}): IdentityPlugin {
|
|
23
27
|
// A request logger's whole job is to log; the default writes JSON to stdout, routable via `log`.
|
|
24
28
|
const sink = options.log ?? ((fields: RequestLogFields) => console.log(JSON.stringify(fields)))
|
|
25
29
|
const starts = new WeakMap<Request, number>()
|
|
26
30
|
// Twin-side starts, keyed by the NodeRequestContext identity (the same object reaches the request
|
|
27
31
|
// and response twins), so Node logging never has to materialize a Web `Request`.
|
|
28
32
|
const nativeStarts = new WeakMap<object, number>()
|
|
29
|
-
return
|
|
33
|
+
return defineIdentityPlugin("logger", (app) =>
|
|
30
34
|
app.use({
|
|
31
35
|
onRequest(req) {
|
|
32
36
|
starts.set(req, performance.now())
|
package/src/method-override.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defineRouterPlugin, METHODS, type Method } from "@nifrajs/core/server"
|
|
2
2
|
import { jsonError } from "./_utils.ts"
|
|
3
3
|
|
|
4
4
|
export interface MethodOverrideOptions {
|
|
@@ -54,7 +54,7 @@ export function methodOverride(options: MethodOverrideOptions = {}) {
|
|
|
54
54
|
if (header !== false && header.trim() === "") throw new Error("methodOverride: header is empty")
|
|
55
55
|
if (query !== false && query.trim() === "") throw new Error("methodOverride: query is empty")
|
|
56
56
|
|
|
57
|
-
return
|
|
57
|
+
return defineRouterPlugin("methodOverride", (app) =>
|
|
58
58
|
app.onRequest((req) => {
|
|
59
59
|
if (!sourceMethods.has(req.method.toUpperCase())) return undefined
|
|
60
60
|
|
package/src/openapi.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defineRouterPlugin, type IdentityPlugin } from "@nifrajs/core/server"
|
|
2
2
|
// The OpenAPI generator only - `@nifrajs/schema/openapi` is pure (no TypeBox runtime), so the openapi()
|
|
3
3
|
// plugin gets full `t`-schema introspection without pulling the `t` builder into every consumer.
|
|
4
4
|
import { toOpenAPI } from "@nifrajs/schema/openapi"
|
|
@@ -140,14 +140,16 @@ function scalarPage(specUrl: string, title: string, cdn: string): string {
|
|
|
140
140
|
* app.use(openapi({ info: { title: "My API", version: "1.0.0" }, ui: true }))
|
|
141
141
|
* ```
|
|
142
142
|
*/
|
|
143
|
-
export function openapi(options: OpenApiOptions = {}) {
|
|
143
|
+
export function openapi(options: OpenApiOptions = {}): IdentityPlugin {
|
|
144
144
|
const docPath = options.path ?? "/openapi.json"
|
|
145
145
|
const ui: OpenApiUiOptions | undefined =
|
|
146
146
|
options.ui === true ? {} : options.ui === false ? undefined : options.ui
|
|
147
147
|
const uiPath = ui !== undefined ? (ui.path ?? "/reference") : undefined
|
|
148
148
|
let cached: string | undefined
|
|
149
149
|
let uiCached: string | undefined
|
|
150
|
-
|
|
150
|
+
// A router plugin: it serves the spec (and optionally the UI page) as a side effect and returns the
|
|
151
|
+
// app type-unchanged, so every route declared after `.use(openapi())` keeps its types.
|
|
152
|
+
return defineRouterPlugin("openapi", (app) => {
|
|
151
153
|
// Capture `app` (the same mutable builder every later `.get()`/`.post()` registers on) and read its
|
|
152
154
|
// routes lazily, at request time - so all routes exist regardless of where the plugin sits.
|
|
153
155
|
app.register("GET", docPath, undefined, () => {
|
package/src/pretty-json.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defineRouterPlugin, type NodeRequestContext } from "@nifrajs/core/server"
|
|
2
2
|
|
|
3
3
|
export interface PrettyJsonOptions {
|
|
4
4
|
/** JSON indentation spaces. Default `2`. */
|
|
@@ -113,7 +113,7 @@ export function prettyJson(options: PrettyJsonOptions = {}) {
|
|
|
113
113
|
|
|
114
114
|
const decoder = new TextDecoder()
|
|
115
115
|
const encoder = new TextEncoder()
|
|
116
|
-
return
|
|
116
|
+
return defineRouterPlugin("prettyJson", (app) =>
|
|
117
117
|
app.use({
|
|
118
118
|
onResponseBody(body, headers, req) {
|
|
119
119
|
if (!isEnabled(req)) return undefined
|
package/src/problem-details.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defineRouterPlugin, pathnameOf } from "@nifrajs/core/server"
|
|
2
2
|
|
|
3
3
|
const JSON_TYPE = /^(?:application\/json|[^/]+\/[^;]+\+json)(?:\s*;|$)/i
|
|
4
4
|
const DEFAULT_MAX_BYTES = 64 * 1024
|
|
@@ -144,7 +144,7 @@ export function problemDetails(options: ProblemDetailsOptions = {}) {
|
|
|
144
144
|
const instance = options.includeInstance === true
|
|
145
145
|
const typeBase = options.typeBase
|
|
146
146
|
|
|
147
|
-
return
|
|
147
|
+
return defineRouterPlugin("problemDetails", (app) =>
|
|
148
148
|
app.use({
|
|
149
149
|
onResponseBody(body, headers, req, status) {
|
|
150
150
|
if (
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
2
|
import type { Middleware, NodeRequestContext, NodeResponseContext } from "@nifrajs/core/server"
|
|
3
|
-
import {
|
|
3
|
+
import { setNodeHeader, withHeaders } from "./_utils.ts"
|
|
4
4
|
|
|
5
5
|
export interface RateLimitResult {
|
|
6
6
|
/** Hits recorded in the current window, including this one. */
|
|
@@ -301,13 +301,11 @@ export function rateLimit(options: RateLimitOptions): Middleware {
|
|
|
301
301
|
const info = nativeQuota.get(req)
|
|
302
302
|
if (info === undefined) return
|
|
303
303
|
nativeQuota.delete(req)
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
headers["ratelimit-reset"] = String(info.resetSeconds)
|
|
310
|
-
})
|
|
304
|
+
// Lowercase on purpose: the Web path's `Headers` lowercases names on the wire, so this
|
|
305
|
+
// is byte-identical output.
|
|
306
|
+
setNodeHeader(res, "ratelimit-limit", String(max))
|
|
307
|
+
setNodeHeader(res, "ratelimit-remaining", String(info.remaining))
|
|
308
|
+
setNodeHeader(res, "ratelimit-reset", String(info.resetSeconds))
|
|
311
309
|
},
|
|
312
310
|
}
|
|
313
311
|
: {}),
|