@nifrajs/core 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/brand.d.ts +11 -0
- package/dist/internal/brand.d.ts.map +1 -0
- package/dist/internal/brand.js +10 -0
- package/dist/internal/brand.js.map +1 -0
- package/dist/internal/secret.d.ts +9 -0
- package/dist/internal/secret.d.ts.map +1 -0
- package/dist/internal/secret.js +19 -0
- package/dist/internal/secret.js.map +1 -0
- package/dist/router/router.d.ts +61 -0
- package/dist/router/router.d.ts.map +1 -0
- package/dist/router/router.js +324 -0
- package/dist/router/router.js.map +1 -0
- package/dist/schema/standard.d.ts +46 -0
- package/dist/schema/standard.d.ts.map +1 -0
- package/dist/schema/standard.js +20 -0
- package/dist/schema/standard.js.map +1 -0
- package/dist/seo.d.ts +48 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +109 -0
- package/dist/seo.js.map +1 -0
- package/dist/server/body.d.ts +39 -0
- package/dist/server/body.d.ts.map +1 -0
- package/dist/server/body.js +85 -0
- package/dist/server/body.js.map +1 -0
- package/dist/server/context.d.ts +111 -0
- package/dist/server/context.d.ts.map +1 -0
- package/dist/server/context.js +2 -0
- package/dist/server/context.js.map +1 -0
- package/dist/server/contract.d.ts +142 -0
- package/dist/server/contract.d.ts.map +1 -0
- package/dist/server/contract.js +59 -0
- package/dist/server/contract.js.map +1 -0
- package/dist/server/cookies.d.ts +41 -0
- package/dist/server/cookies.d.ts.map +1 -0
- package/dist/server/cookies.js +181 -0
- package/dist/server/cookies.js.map +1 -0
- package/dist/server/logger.d.ts +49 -0
- package/dist/server/logger.d.ts.map +1 -0
- package/dist/server/logger.js +125 -0
- package/dist/server/logger.js.map +1 -0
- package/dist/server/registry.d.ts +46 -0
- package/dist/server/registry.d.ts.map +1 -0
- package/dist/server/registry.js +2 -0
- package/dist/server/registry.js.map +1 -0
- package/dist/server/server.d.ts +528 -0
- package/dist/server/server.d.ts.map +1 -0
- package/dist/server/server.js +1660 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/sse.d.ts +54 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +126 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/webhook.d.ts +42 -0
- package/dist/server/webhook.d.ts.map +1 -0
- package/dist/server/webhook.js +149 -0
- package/dist/server/webhook.js.map +1 -0
- package/dist/server/websocket.d.ts +152 -0
- package/dist/server/websocket.d.ts.map +1 -0
- package/dist/server/websocket.js +178 -0
- package/dist/server/websocket.js.map +1 -0
- package/dist/server/ws-bun.d.ts +27 -0
- package/dist/server/ws-bun.d.ts.map +1 -0
- package/dist/server/ws-bun.js +82 -0
- package/dist/server/ws-bun.js.map +1 -0
- package/dist/server/ws-hook.d.ts +36 -0
- package/dist/server/ws-hook.d.ts.map +1 -0
- package/dist/server/ws-hook.js +18 -0
- package/dist/server/ws-hook.js.map +1 -0
- package/dist/ws.d.ts +19 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +26 -0
- package/dist/ws.js.map +1 -0
- package/package.json +63 -0
- package/src/errors.ts +41 -0
- package/src/index.ts +115 -0
- package/src/internal/brand.ts +11 -0
- package/src/internal/secret.ts +24 -0
- package/src/router/router.ts +429 -0
- package/src/schema/standard.ts +76 -0
- package/src/seo.ts +155 -0
- package/src/server/body.ts +89 -0
- package/src/server/context.ts +124 -0
- package/src/server/contract.ts +209 -0
- package/src/server/cookies.ts +198 -0
- package/src/server/logger.ts +167 -0
- package/src/server/registry.ts +63 -0
- package/src/server/server.ts +2479 -0
- package/src/server/sse.ts +161 -0
- package/src/server/webhook.ts +207 -0
- package/src/server/websocket.ts +308 -0
- package/src/server/ws-bun.ts +106 -0
- package/src/server/ws-hook.ts +53 -0
- package/src/ws.ts +40 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie primitives — parse a request `Cookie` header, serialize a `Set-Cookie`, and sign/verify a
|
|
3
|
+
* value with HMAC-SHA256 via **WebCrypto** (`crypto.subtle`), so they're portable across Bun, Node,
|
|
4
|
+
* Deno, and workerd with no `node:crypto` dependency. Pure + runtime-agnostic: `c.cookies` (read) and
|
|
5
|
+
* `c.set.cookie` (write) build on these, and `@nifrajs/auth` builds sessions on top.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { requireSecretBytes } from "../internal/secret.ts"
|
|
9
|
+
|
|
10
|
+
/** Attributes for a `Set-Cookie`. `expires` is a `Date`; `maxAge` is in **seconds**. */
|
|
11
|
+
export interface CookieOptions {
|
|
12
|
+
/** Lifetime in seconds. `0` (with a past `expires`) deletes the cookie. */
|
|
13
|
+
readonly maxAge?: number
|
|
14
|
+
readonly expires?: Date
|
|
15
|
+
readonly path?: string
|
|
16
|
+
readonly domain?: string
|
|
17
|
+
readonly secure?: boolean
|
|
18
|
+
readonly httpOnly?: boolean
|
|
19
|
+
readonly sameSite?: "strict" | "lax" | "none"
|
|
20
|
+
readonly partitioned?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// RFC 6265 cookie-name token: visible ASCII minus separators/whitespace/controls.
|
|
24
|
+
const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
|
|
25
|
+
const MAX_COOKIE_BYTES = 4096 // browsers cap a cookie near 4 KB — reject oversized before the wire
|
|
26
|
+
|
|
27
|
+
/** True if `v` contains a character illegal in a cookie attribute (control char or `;` separator) —
|
|
28
|
+
* the header-injection guard for dev-supplied `Path`/`Domain`. */
|
|
29
|
+
const hasIllegalChar = (v: string): boolean => {
|
|
30
|
+
for (let i = 0; i < v.length; i++) {
|
|
31
|
+
const code = v.charCodeAt(i)
|
|
32
|
+
if (code < 0x20 || code === 0x7f || code === 0x3b /* ; */) return true
|
|
33
|
+
}
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const safeDecode = (v: string): string => {
|
|
38
|
+
// No `%` ⇒ nothing to decode — skip the call AND its try frame (profiled: cookie values are
|
|
39
|
+
// overwhelmingly plain; this was ~3% of a realistic request).
|
|
40
|
+
if (!v.includes("%")) return v
|
|
41
|
+
try {
|
|
42
|
+
return decodeURIComponent(v)
|
|
43
|
+
} catch {
|
|
44
|
+
return v // a malformed %-escape from a hostile/garbage cookie must not throw (it'd 500 the request)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Parse a request `Cookie` header into a name→value map (values URL-decoded). Unparseable pairs are
|
|
49
|
+
* skipped rather than throwing — a junk `Cookie` header shouldn't fail the request.
|
|
50
|
+
*
|
|
51
|
+
* Hand-rolled index walk instead of `split(";")` + `trim()` chains: those allocated an array + up
|
|
52
|
+
* to three substrings per pair and showed up at ~6% of a realistic (auth + cookie) request. Same
|
|
53
|
+
* semantics, two slices per pair, no intermediate array. */
|
|
54
|
+
export function parseCookies(header: string | null | undefined): Record<string, string> {
|
|
55
|
+
// Null-prototype: cookie NAMES come straight from the untrusted header, so a cookie named
|
|
56
|
+
// `constructor`/`__proto__`/`toString` must be an inert own key, not a shadow of a prototype
|
|
57
|
+
// member. Matches the query/form/params parsers (all `Object.create(null)`); values are always
|
|
58
|
+
// strings so global pollution was never possible, but this keeps the whole request-parse layer
|
|
59
|
+
// consistent and safe to treat as a plain dictionary.
|
|
60
|
+
const out: Record<string, string> = Object.create(null)
|
|
61
|
+
if (!header) return out
|
|
62
|
+
const len = header.length
|
|
63
|
+
let pos = 0
|
|
64
|
+
while (pos < len) {
|
|
65
|
+
let end = header.indexOf(";", pos)
|
|
66
|
+
if (end === -1) end = len
|
|
67
|
+
// trim() equivalent on the segment, without allocating the untrimmed slice
|
|
68
|
+
let start = pos
|
|
69
|
+
while (start < end && header.charCodeAt(start) === 32) start++
|
|
70
|
+
let stop = end
|
|
71
|
+
while (stop > start && header.charCodeAt(stop - 1) === 32) stop--
|
|
72
|
+
const eq = header.indexOf("=", start)
|
|
73
|
+
if (eq > start && eq < stop) {
|
|
74
|
+
// name: trim trailing spaces before '='; value: trim leading spaces after it
|
|
75
|
+
let nameEnd = eq
|
|
76
|
+
while (nameEnd > start && header.charCodeAt(nameEnd - 1) === 32) nameEnd--
|
|
77
|
+
let valueStart = eq + 1
|
|
78
|
+
while (valueStart < stop && header.charCodeAt(valueStart) === 32) valueStart++
|
|
79
|
+
if (nameEnd > start) {
|
|
80
|
+
const name = header.slice(start, nameEnd)
|
|
81
|
+
let value: string
|
|
82
|
+
if (
|
|
83
|
+
stop - valueStart >= 2 &&
|
|
84
|
+
header.charCodeAt(valueStart) === 34 /* " */ &&
|
|
85
|
+
header.charCodeAt(stop - 1) === 34
|
|
86
|
+
) {
|
|
87
|
+
value = header.slice(valueStart + 1, stop - 1)
|
|
88
|
+
} else {
|
|
89
|
+
value = header.slice(valueStart, stop)
|
|
90
|
+
}
|
|
91
|
+
out[name] = safeDecode(value)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
pos = end + 1
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Serialize a `Set-Cookie` header value. Pure — applies **no** security defaults (the caller, e.g.
|
|
101
|
+
* `c.set.cookie`, layers `HttpOnly`/`Secure`/`SameSite` on). Throws on an invalid cookie name, a
|
|
102
|
+
* header-injecting `Path`/`Domain`, a non-integer `maxAge`, or an oversized result — a serialization
|
|
103
|
+
* bug should fail loudly, not silently emit a cookie the browser drops.
|
|
104
|
+
*/
|
|
105
|
+
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
|
|
106
|
+
if (!COOKIE_NAME.test(name)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[nifra] invalid cookie name ${JSON.stringify(name)}: must be an RFC 6265 token`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
let str = `${name}=${encodeURIComponent(value)}`
|
|
112
|
+
if (options.maxAge !== undefined) {
|
|
113
|
+
if (!Number.isInteger(options.maxAge)) {
|
|
114
|
+
throw new Error("[nifra] cookie maxAge must be an integer number of seconds")
|
|
115
|
+
}
|
|
116
|
+
str += `; Max-Age=${options.maxAge}`
|
|
117
|
+
}
|
|
118
|
+
if (options.domain !== undefined) {
|
|
119
|
+
if (hasIllegalChar(options.domain))
|
|
120
|
+
throw new Error("[nifra] cookie Domain contains an illegal character")
|
|
121
|
+
str += `; Domain=${options.domain}`
|
|
122
|
+
}
|
|
123
|
+
if (options.path !== undefined) {
|
|
124
|
+
if (hasIllegalChar(options.path))
|
|
125
|
+
throw new Error("[nifra] cookie Path contains an illegal character")
|
|
126
|
+
str += `; Path=${options.path}`
|
|
127
|
+
}
|
|
128
|
+
if (options.expires !== undefined) str += `; Expires=${options.expires.toUTCString()}`
|
|
129
|
+
if (options.httpOnly === true) str += "; HttpOnly"
|
|
130
|
+
if (options.secure === true) str += "; Secure"
|
|
131
|
+
if (options.partitioned === true) str += "; Partitioned"
|
|
132
|
+
if (options.sameSite !== undefined) {
|
|
133
|
+
str += `; SameSite=${options.sameSite === "strict" ? "Strict" : options.sameSite === "none" ? "None" : "Lax"}`
|
|
134
|
+
}
|
|
135
|
+
if (str.length > MAX_COOKIE_BYTES) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`[nifra] cookie ${JSON.stringify(name)} is ${str.length}B, over the ${MAX_COOKIE_BYTES}B limit`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
return str
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const TEXT = new TextEncoder()
|
|
144
|
+
|
|
145
|
+
const importHmacKey = (secret: string): Promise<CryptoKey> => {
|
|
146
|
+
// Reject a weak secret at the choke point both signing and verification flow through (so signed
|
|
147
|
+
// cookies AND @nifrajs/auth sessions, which delegate here via signValue/unsignValue, are covered).
|
|
148
|
+
requireSecretBytes(secret, "signed-cookie")
|
|
149
|
+
return crypto.subtle.importKey(
|
|
150
|
+
"raw",
|
|
151
|
+
TEXT.encode(secret),
|
|
152
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
153
|
+
false,
|
|
154
|
+
["sign", "verify"],
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const toBase64Url = (buf: ArrayBuffer): string => {
|
|
159
|
+
let bin = ""
|
|
160
|
+
for (const b of new Uint8Array(buf)) bin += String.fromCharCode(b)
|
|
161
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Returns `Uint8Array<ArrayBuffer>` (not the generic `Uint8Array<ArrayBufferLike>`) so it satisfies
|
|
165
|
+
// WebCrypto's `BufferSource` parameter under TS 5.7+'s typed-array generics.
|
|
166
|
+
const fromBase64Url = (s: string): Uint8Array<ArrayBuffer> | null => {
|
|
167
|
+
try {
|
|
168
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"))
|
|
169
|
+
const bytes = new Uint8Array(bin.length)
|
|
170
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
|
171
|
+
return bytes
|
|
172
|
+
} catch {
|
|
173
|
+
return null // not valid base64 — treat as a bad signature
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Append an HMAC-SHA256 signature to a value → `value.signature` (base64url). For signed cookies. */
|
|
178
|
+
export async function signValue(value: string, secret: string): Promise<string> {
|
|
179
|
+
const key = await importHmacKey(secret)
|
|
180
|
+
const sig = await crypto.subtle.sign("HMAC", key, TEXT.encode(value))
|
|
181
|
+
return `${value}.${toBase64Url(sig)}`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Verify a `value.signature` produced by {@link signValue} and return the value, or `null` if the
|
|
186
|
+
* signature is missing, malformed, or doesn't match. Verification is **constant-time**
|
|
187
|
+
* (`crypto.subtle.verify`), so a wrong signature can't be discovered byte-by-byte via timing.
|
|
188
|
+
*/
|
|
189
|
+
export async function unsignValue(signed: string, secret: string): Promise<string | null> {
|
|
190
|
+
const dot = signed.lastIndexOf(".")
|
|
191
|
+
if (dot < 1) return null // no signature segment (or empty value)
|
|
192
|
+
const value = signed.slice(0, dot)
|
|
193
|
+
const sig = fromBase64Url(signed.slice(dot + 1))
|
|
194
|
+
if (sig === null) return null
|
|
195
|
+
const key = await importHmacKey(secret)
|
|
196
|
+
const ok = await crypto.subtle.verify("HMAC", key, sig, TEXT.encode(value))
|
|
197
|
+
return ok ? value : null
|
|
198
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured, redacting logger. The framework logs through this interface so
|
|
3
|
+
* secrets/PII are scrubbed once, centrally (per the project's logging rule), not at
|
|
4
|
+
* each call site. Bring your own by passing `logger` to `server()`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type LogFields = Record<string, unknown>
|
|
8
|
+
|
|
9
|
+
export interface Logger {
|
|
10
|
+
debug(message: string, fields?: LogFields): void
|
|
11
|
+
info(message: string, fields?: LogFields): void
|
|
12
|
+
warn(message: string, fields?: LogFields): void
|
|
13
|
+
error(message: string, fields?: LogFields): void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const REDACTED = "[REDACTED]"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Field-name fragments whose values are replaced with `[REDACTED]` (case-insensitive
|
|
20
|
+
* substring match). Key-name redaction is the default + always on.
|
|
21
|
+
*/
|
|
22
|
+
const SENSITIVE_KEY_PARTS: ReadonlyArray<string> = [
|
|
23
|
+
"authorization",
|
|
24
|
+
"cookie",
|
|
25
|
+
"password",
|
|
26
|
+
"passwd",
|
|
27
|
+
"secret",
|
|
28
|
+
"token",
|
|
29
|
+
"apikey",
|
|
30
|
+
"api_key",
|
|
31
|
+
"api-key",
|
|
32
|
+
"credential",
|
|
33
|
+
"ssn",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Tunes redaction. Key-name redaction always runs; the rest is **opt-in**:
|
|
38
|
+
* - `keyParts` — extra case-insensitive key fragments, added to the built-in denylist.
|
|
39
|
+
* - `valuePatterns` — regexes matched against string **values** *and* the log message; each match is
|
|
40
|
+
* replaced with the placeholder. This is the value-scanning hook for secrets that land in a value or
|
|
41
|
+
* message (e.g. `err.message`), which key-name redaction can't catch. Off unless provided — the
|
|
42
|
+
* default path does no value scanning, so it stays allocation-light. See {@link commonSecretPatterns}.
|
|
43
|
+
* - `placeholder` — the replacement string (default `[REDACTED]`).
|
|
44
|
+
*/
|
|
45
|
+
export interface RedactOptions {
|
|
46
|
+
readonly keyParts?: readonly string[]
|
|
47
|
+
readonly valuePatterns?: readonly RegExp[]
|
|
48
|
+
readonly placeholder?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A conservative, high-signal set of patterns for {@link RedactOptions.valuePatterns} — opt in by
|
|
53
|
+
* passing it (or a subset) to `jsonLogger`/`redactLogFields`. Covers bearer tokens, JWTs, emails, and a
|
|
54
|
+
* few well-known key formats (Stripe, GitHub, AWS access-key ids). Chosen to minimize false positives;
|
|
55
|
+
* add your own (e.g. internal id formats) as needed. Every pattern is global so all matches are scrubbed.
|
|
56
|
+
*/
|
|
57
|
+
export const commonSecretPatterns: ReadonlyArray<RegExp> = [
|
|
58
|
+
/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, // Authorization: Bearer <token>
|
|
59
|
+
/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, // JWT (header.payload.signature)
|
|
60
|
+
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, // email
|
|
61
|
+
/\b[sprk]k_(?:live|test)_[A-Za-z0-9]{8,}/g, // Stripe-style secret/restricted keys
|
|
62
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub personal/OAuth/refresh tokens
|
|
63
|
+
/\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
interface RedactConfig {
|
|
67
|
+
readonly keyParts: readonly string[]
|
|
68
|
+
readonly valuePatterns: readonly RegExp[]
|
|
69
|
+
readonly placeholder: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// `String.replace` with a global regex replaces every match and is stateless across calls; a
|
|
73
|
+
// non-global pattern would only scrub the first occurrence, so normalize to global once up front.
|
|
74
|
+
const ensureGlobal = (re: RegExp): RegExp =>
|
|
75
|
+
re.flags.includes("g") ? re : new RegExp(re.source, `${re.flags}g`)
|
|
76
|
+
|
|
77
|
+
function buildConfig(options?: RedactOptions): RedactConfig {
|
|
78
|
+
const extra = options?.keyParts
|
|
79
|
+
return {
|
|
80
|
+
keyParts:
|
|
81
|
+
extra !== undefined && extra.length > 0
|
|
82
|
+
? [...SENSITIVE_KEY_PARTS, ...extra.map((k) => k.toLowerCase())]
|
|
83
|
+
: SENSITIVE_KEY_PARTS,
|
|
84
|
+
valuePatterns: (options?.valuePatterns ?? []).map(ensureGlobal),
|
|
85
|
+
placeholder: options?.placeholder ?? REDACTED,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isSensitiveKey(key: string, parts: readonly string[]): boolean {
|
|
90
|
+
const lower = key.toLowerCase()
|
|
91
|
+
return parts.some((part) => lower.includes(part))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function redactString(value: string, config: RedactConfig): string {
|
|
95
|
+
if (config.valuePatterns.length === 0) return value // default fast path: no value scanning
|
|
96
|
+
let out = value
|
|
97
|
+
for (const pattern of config.valuePatterns) out = out.replace(pattern, config.placeholder)
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function redactValue(value: unknown, seen: WeakSet<object>, config: RedactConfig): unknown {
|
|
102
|
+
if (typeof value === "string") return redactString(value, config)
|
|
103
|
+
if (value === null || typeof value !== "object") return value
|
|
104
|
+
if (seen.has(value)) return "[Circular]"
|
|
105
|
+
seen.add(value)
|
|
106
|
+
if (Array.isArray(value)) return value.map((item) => redactValue(item, seen, config))
|
|
107
|
+
const out: Record<string, unknown> = {}
|
|
108
|
+
for (const [key, val] of Object.entries(value)) {
|
|
109
|
+
// A sensitive key wins outright (don't scan it) — the whole value is replaced.
|
|
110
|
+
out[key] = isSensitiveKey(key, config.keyParts)
|
|
111
|
+
? config.placeholder
|
|
112
|
+
: redactValue(val, seen, config)
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Deep-copy `fields`, replacing values under sensitive keys with the placeholder; cycle-safe. With
|
|
119
|
+
* `options.valuePatterns`, also scans string values for those patterns (opt-in). Without options, this
|
|
120
|
+
* is pure key-name redaction (the long-standing default).
|
|
121
|
+
*/
|
|
122
|
+
export function redactLogFields(fields: LogFields, options?: RedactOptions): LogFields {
|
|
123
|
+
return redactValue(fields, new WeakSet(), buildConfig(options)) as LogFields
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writeToStderr(line: string): void {
|
|
127
|
+
process.stderr.write(`${line}\n`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The default logger: one redacted JSON object per line. `write` is injectable for tests or
|
|
132
|
+
* alternative sinks (defaults to stderr). `options` tunes redaction — pass `valuePatterns` (e.g.
|
|
133
|
+
* {@link commonSecretPatterns}) to also scrub secrets embedded in values + the message. Framework keys
|
|
134
|
+
* (`level`, `message`, `time`) always win over user fields of the same name.
|
|
135
|
+
*/
|
|
136
|
+
export function jsonLogger(
|
|
137
|
+
write: (line: string) => void = writeToStderr,
|
|
138
|
+
options?: RedactOptions,
|
|
139
|
+
): Logger {
|
|
140
|
+
const config = buildConfig(options) // normalize patterns once, reuse per entry
|
|
141
|
+
const emit = (level: string, message: string, fields?: LogFields): void => {
|
|
142
|
+
const entry: Record<string, unknown> =
|
|
143
|
+
fields === undefined
|
|
144
|
+
? {}
|
|
145
|
+
: (redactValue(fields, new WeakSet(), config) as Record<string, unknown>)
|
|
146
|
+
entry.level = level
|
|
147
|
+
entry.message = redactString(message, config) // scan the message too (no-op without valuePatterns)
|
|
148
|
+
entry.time = new Date().toISOString()
|
|
149
|
+
write(JSON.stringify(entry))
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
debug: (message, fields) => emit("debug", message, fields),
|
|
153
|
+
info: (message, fields) => emit("info", message, fields),
|
|
154
|
+
warn: (message, fields) => emit("warn", message, fields),
|
|
155
|
+
error: (message, fields) => emit("error", message, fields),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const noop = (): void => undefined
|
|
160
|
+
|
|
161
|
+
/** Discards everything — for tests, or when log output is handled elsewhere. */
|
|
162
|
+
export const silentLogger: Logger = {
|
|
163
|
+
debug: noop,
|
|
164
|
+
info: noop,
|
|
165
|
+
warn: noop,
|
|
166
|
+
error: noop,
|
|
167
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
|
|
2
|
+
import type { Params, RouteSchema } from "./context.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One route's input/output shape as the **client** will consume it. `query`/`body`
|
|
6
|
+
* are `never` when the route declares no schema for them, so the client can detect
|
|
7
|
+
* "this route takes no body" via `[body] extends [never]`. `output` is the
|
|
8
|
+
* handler's raw return type (the client applies `Jsonify` when reading it).
|
|
9
|
+
*/
|
|
10
|
+
export interface RouteInfo {
|
|
11
|
+
// `object` is the *bound*; each route stores its precise `Params<Path>` (e.g.
|
|
12
|
+
// `{ id: string }`), which a generic `Path` can't be proven to fit into
|
|
13
|
+
// `Record<string, string>`. The precise type survives in the accumulated
|
|
14
|
+
// registry, so exact param-key checking is preserved downstream.
|
|
15
|
+
readonly params: object
|
|
16
|
+
readonly query: unknown
|
|
17
|
+
readonly body: unknown
|
|
18
|
+
readonly output: unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The accumulated, type-level map of every route on a Server: path → method → RouteInfo. */
|
|
22
|
+
export type Registry = Record<string, Record<string, RouteInfo>>
|
|
23
|
+
|
|
24
|
+
/** The empty registry (no routes). `NonNullable<unknown>` is `{}` without tripping noBannedTypes. */
|
|
25
|
+
export type EmptyRegistry = NonNullable<unknown>
|
|
26
|
+
|
|
27
|
+
type RegistryBody<S extends RouteSchema> = S extends { body: infer B extends StandardSchemaV1 }
|
|
28
|
+
? InferOutput<B>
|
|
29
|
+
: never
|
|
30
|
+
|
|
31
|
+
type RegistryQuery<S extends RouteSchema> = S extends { query: infer Q extends StandardSchemaV1 }
|
|
32
|
+
? InferOutput<Q>
|
|
33
|
+
: never
|
|
34
|
+
|
|
35
|
+
/** The client-visible output: the declared `response` contract when present (so the client sees the
|
|
36
|
+
* contract, not the handler's incidental return), otherwise the inferred handler output. */
|
|
37
|
+
type RegistryOutput<S extends RouteSchema, Output> = S extends {
|
|
38
|
+
response: infer R extends StandardSchemaV1
|
|
39
|
+
}
|
|
40
|
+
? InferOutput<R>
|
|
41
|
+
: Output
|
|
42
|
+
|
|
43
|
+
/** Build a {@link RouteInfo} from a route's path, schema, and handler output type. */
|
|
44
|
+
export type RouteInfoFor<Path extends string, S extends RouteSchema, Output> = {
|
|
45
|
+
readonly params: Params<Path>
|
|
46
|
+
readonly query: RegistryQuery<S>
|
|
47
|
+
readonly body: RegistryBody<S>
|
|
48
|
+
readonly output: RegistryOutput<S, Output>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The client-visible output of a handler: its awaited return, minus raw `Response`. */
|
|
52
|
+
export type OutputOf<H extends (...args: never[]) => unknown> = Exclude<
|
|
53
|
+
Awaited<ReturnType<H>>,
|
|
54
|
+
Response
|
|
55
|
+
>
|
|
56
|
+
|
|
57
|
+
/** Merge a new route into the registry, combining methods that share a path. */
|
|
58
|
+
export type AddRoute<
|
|
59
|
+
R extends Registry,
|
|
60
|
+
Method extends string,
|
|
61
|
+
Path extends string,
|
|
62
|
+
Info extends RouteInfo,
|
|
63
|
+
> = R & { [P in Path]: { [M in Method]: Info } }
|