@znight/http 0.0.0-stage → 0.1.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 +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- package/src/validate.ts +65 -0
package/src/cors.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS policy (RFC 0001 §5.4): option validation and the headers of preflights and replies. Pure:
|
|
3
|
+
* the pipeline decides when to apply it (preflights are answered before routing and guards, the
|
|
4
|
+
* same way on every adapter).
|
|
5
|
+
*/
|
|
6
|
+
import type { HeaderValue } from './exceptions.ts'
|
|
7
|
+
|
|
8
|
+
import { isHeaderName } from './headers.ts'
|
|
9
|
+
import { invalidOption } from './option-error.ts'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Origins allowed to read responses: `'*'` (any origin), an exact origin
|
|
13
|
+
* (`'https://app.example.com'`), an anchored `RegExp` (`/^https:\/\/[a-z0-9-]+\.example\.com$/`), a
|
|
14
|
+
* list of those, or a function (awaited; a function that throws or rejects denies the origin).
|
|
15
|
+
*/
|
|
16
|
+
export type CorsOrigin =
|
|
17
|
+
| '*'
|
|
18
|
+
| string
|
|
19
|
+
| RegExp
|
|
20
|
+
| readonly (string | RegExp)[]
|
|
21
|
+
| ((origin: string) => boolean | Promise<boolean>)
|
|
22
|
+
|
|
23
|
+
/** The `cors` option of `HttpPlugin.configure`. */
|
|
24
|
+
export interface CorsOptions {
|
|
25
|
+
/** Allowed origins (required). */
|
|
26
|
+
readonly origin: CorsOrigin
|
|
27
|
+
/** `access-control-allow-credentials: true` (cookies, `Authorization`). Not with `'*'`. Default `false`. */
|
|
28
|
+
readonly credentials?: boolean
|
|
29
|
+
/**
|
|
30
|
+
* `access-control-allow-methods` of preflights. Default: the methods routed on the requested path.
|
|
31
|
+
* `'*'` (any method) is refused with `credentials`, where browsers read it as a method named `*`.
|
|
32
|
+
*/
|
|
33
|
+
readonly methods?: readonly string[]
|
|
34
|
+
/**
|
|
35
|
+
* `access-control-allow-headers` of preflights. Default: the headers the browser asks for
|
|
36
|
+
* (`access-control-request-headers`). `'*'` is refused with `credentials`; it covers
|
|
37
|
+
* `Authorization` too (sent as `*, authorization` when the browser asks for it: browsers never let
|
|
38
|
+
* `*` alone cover it).
|
|
39
|
+
*/
|
|
40
|
+
readonly allowHeaders?: readonly string[] | '*'
|
|
41
|
+
/**
|
|
42
|
+
* `access-control-expose-headers` of replies. Default: the request id header (none when it is
|
|
43
|
+
* `false`). `'*'` (every header) is refused with `credentials`, where browsers read it literally.
|
|
44
|
+
*/
|
|
45
|
+
readonly exposeHeaders?: readonly string[]
|
|
46
|
+
/** `access-control-max-age` of preflights, in seconds (0 to 86400). Default `600`. */
|
|
47
|
+
readonly maxAge?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A resolved CORS policy (internal). */
|
|
51
|
+
export interface CorsPolicy {
|
|
52
|
+
/** Any origin is allowed: `access-control-allow-origin: *`. */
|
|
53
|
+
readonly wildcard: boolean
|
|
54
|
+
/**
|
|
55
|
+
* Replies get `vary: origin` (every policy but a wildcard one, whose replies all carry
|
|
56
|
+
* `access-control-allow-origin: *`).
|
|
57
|
+
*/
|
|
58
|
+
readonly varyOrigin: boolean
|
|
59
|
+
readonly credentials: boolean
|
|
60
|
+
/** Joined `methods`, `undefined` for the default (the methods of the path). */
|
|
61
|
+
readonly methods: string | undefined
|
|
62
|
+
/** Joined `allowHeaders` or `*`, `undefined` for the default (echo the request). */
|
|
63
|
+
readonly allowHeaders: string | undefined
|
|
64
|
+
/** Joined `exposeHeaders`, `undefined` when there are none. */
|
|
65
|
+
readonly exposeHeaders: string | undefined
|
|
66
|
+
readonly maxAge: string
|
|
67
|
+
/** Whether `origin` (the request `origin` header) is allowed. May throw or reject: the caller denies it then. */
|
|
68
|
+
allows(origin: string): boolean | Promise<boolean>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const KEYS = new Set(['origin', 'credentials', 'methods', 'allowHeaders', 'exposeHeaders', 'maxAge'])
|
|
72
|
+
/** RFC 9110 token (methods). */
|
|
73
|
+
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
|
|
74
|
+
const MAX_AGE_LIMIT = 86_400
|
|
75
|
+
const DEFAULT_MAX_AGE = 600
|
|
76
|
+
const PREFLIGHT_VARY = 'origin, access-control-request-method, access-control-request-headers'
|
|
77
|
+
|
|
78
|
+
/** Validates the `cors` option (ZNIGHT005). `undefined` / `false`: CORS is disabled. */
|
|
79
|
+
export function resolveCors(
|
|
80
|
+
options: CorsOptions | false | undefined,
|
|
81
|
+
requestIdHeader: string | undefined,
|
|
82
|
+
): CorsPolicy | undefined {
|
|
83
|
+
if (options === undefined || options === false) return undefined
|
|
84
|
+
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
|
|
85
|
+
throw invalidOption(
|
|
86
|
+
'cors',
|
|
87
|
+
'{ origin, credentials?, methods?, allowHeaders?, exposeHeaders?, maxAge? } or false',
|
|
88
|
+
options,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
for (const key of Object.keys(options)) {
|
|
92
|
+
if (!KEYS.has(key)) {
|
|
93
|
+
throw invalidOption('cors', '{ origin, credentials?, methods?, allowHeaders?, exposeHeaders?, maxAge? }', options)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const credentials = options.credentials ?? false
|
|
97
|
+
if (typeof credentials !== 'boolean') throw invalidOption('cors.credentials', 'a boolean', credentials)
|
|
98
|
+
const origin = resolveOrigin(options.origin)
|
|
99
|
+
if (credentials && origin.wildcard) {
|
|
100
|
+
throw invalidOption(
|
|
101
|
+
'cors.origin',
|
|
102
|
+
"explicit origins when credentials is true (not '*')",
|
|
103
|
+
options.origin,
|
|
104
|
+
undefined,
|
|
105
|
+
["Browsers reject 'access-control-allow-origin: *' on credentialed requests: list the origins"],
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
const methods = tokenList('cors.methods', options.methods, TOKEN, 'HTTP method names')
|
|
109
|
+
if (credentials) {
|
|
110
|
+
refuseWildcard('cors.methods', options.methods, 'HTTP method names', 'a method named "*"')
|
|
111
|
+
refuseWildcard('cors.allowHeaders', options.allowHeaders, 'header names', 'a header named "*"')
|
|
112
|
+
refuseWildcard('cors.exposeHeaders', options.exposeHeaders, 'header names', 'a header named "*"')
|
|
113
|
+
}
|
|
114
|
+
let allowHeaders: string | undefined
|
|
115
|
+
if (options.allowHeaders === '*') {
|
|
116
|
+
if (credentials) {
|
|
117
|
+
throw invalidOption(
|
|
118
|
+
'cors.allowHeaders',
|
|
119
|
+
"a list of header names when credentials is true (not '*')",
|
|
120
|
+
'*',
|
|
121
|
+
undefined,
|
|
122
|
+
["Browsers ignore 'access-control-allow-headers: *' on credentialed requests"],
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
allowHeaders = '*'
|
|
126
|
+
} else {
|
|
127
|
+
allowHeaders = headerList('cors.allowHeaders', options.allowHeaders)
|
|
128
|
+
}
|
|
129
|
+
const expose =
|
|
130
|
+
options.exposeHeaders === undefined ? requestIdHeader : headerList('cors.exposeHeaders', options.exposeHeaders)
|
|
131
|
+
const maxAge = options.maxAge ?? DEFAULT_MAX_AGE
|
|
132
|
+
if (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > MAX_AGE_LIMIT) {
|
|
133
|
+
throw invalidOption('cors.maxAge', `an integer number of seconds between 0 and ${MAX_AGE_LIMIT}`, maxAge)
|
|
134
|
+
}
|
|
135
|
+
return Object.freeze({
|
|
136
|
+
wildcard: origin.wildcard,
|
|
137
|
+
varyOrigin: !origin.wildcard,
|
|
138
|
+
credentials,
|
|
139
|
+
methods,
|
|
140
|
+
allowHeaders,
|
|
141
|
+
exposeHeaders: expose === '' ? undefined : expose,
|
|
142
|
+
maxAge: String(maxAge),
|
|
143
|
+
allows: origin.allows,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface ResolvedOrigin {
|
|
148
|
+
readonly wildcard: boolean
|
|
149
|
+
readonly allows: (origin: string) => boolean | Promise<boolean>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveOrigin(value: CorsOrigin | undefined): ResolvedOrigin {
|
|
153
|
+
if (value === undefined) {
|
|
154
|
+
throw invalidOption(
|
|
155
|
+
'cors.origin',
|
|
156
|
+
"'*', an origin, an anchored RegExp, a list of them or a function",
|
|
157
|
+
value,
|
|
158
|
+
undefined,
|
|
159
|
+
["cors: { origin: 'https://app.example.com' }"],
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
if (typeof value === 'function') {
|
|
163
|
+
return { wildcard: false, allows: value }
|
|
164
|
+
}
|
|
165
|
+
const items: readonly unknown[] = Array.isArray(value) ? value : [value]
|
|
166
|
+
const exact = new Set<string>()
|
|
167
|
+
const patterns: RegExp[] = []
|
|
168
|
+
let wildcard = false
|
|
169
|
+
for (const item of items) {
|
|
170
|
+
if (item === '*') wildcard = true
|
|
171
|
+
else if (typeof item === 'string') exact.add(checkOrigin(item))
|
|
172
|
+
else if (item instanceof RegExp) patterns.push(checkPattern(item))
|
|
173
|
+
else {
|
|
174
|
+
throw invalidOption('cors.origin', "'*', an origin, an anchored RegExp, a list of them or a function", value)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (wildcard) return { wildcard: true, allows: () => true }
|
|
178
|
+
return {
|
|
179
|
+
wildcard: false,
|
|
180
|
+
allows: (origin: string): boolean => exact.has(origin) || patterns.some(pattern => pattern.test(origin)),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** An origin must be written exactly as browsers send it: `scheme://host[:port]`. */
|
|
185
|
+
function checkOrigin(value: string): string {
|
|
186
|
+
if (value === 'null') {
|
|
187
|
+
throw invalidOption('cors.origin', 'a real origin', value, undefined, [
|
|
188
|
+
'The "null" origin is shared by sandboxed iframes, local files and redirects: never trust it',
|
|
189
|
+
])
|
|
190
|
+
}
|
|
191
|
+
let url: URL | undefined
|
|
192
|
+
try {
|
|
193
|
+
url = new URL(value)
|
|
194
|
+
} catch {
|
|
195
|
+
url = undefined
|
|
196
|
+
}
|
|
197
|
+
if (url === undefined || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
|
|
198
|
+
throw invalidOption('cors.origin', 'an http(s) origin such as "https://app.example.com"', value)
|
|
199
|
+
}
|
|
200
|
+
if (url.origin !== value) {
|
|
201
|
+
throw invalidOption(
|
|
202
|
+
'cors.origin',
|
|
203
|
+
`an origin without path, trailing slash or default port ("${url.origin}")`,
|
|
204
|
+
value,
|
|
205
|
+
undefined,
|
|
206
|
+
[`Write "${url.origin}": browsers send the origin in this exact form`],
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
return value
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function checkPattern(pattern: RegExp): RegExp {
|
|
213
|
+
const source = pattern.source
|
|
214
|
+
const trailingBackslashes = /\\*\$$/.exec(source)?.[0].length ?? 0
|
|
215
|
+
// `\$` at the end matches a dollar sign: the pattern is not anchored
|
|
216
|
+
const anchoredEnd = source.endsWith('$') && (trailingBackslashes - 1) % 2 === 0
|
|
217
|
+
if (!source.startsWith('^') || !anchoredEnd) {
|
|
218
|
+
throw invalidOption('cors.origin', 'a RegExp anchored with ^ and $', pattern, undefined, [
|
|
219
|
+
'Without anchors, /example\\.com/ also allows https://example.com.evil.net',
|
|
220
|
+
])
|
|
221
|
+
}
|
|
222
|
+
if (hasTopLevelAlternation(source)) {
|
|
223
|
+
throw invalidOption('cors.origin', 'a RegExp anchored with ^ and $ around every alternative', pattern, undefined, [
|
|
224
|
+
'In /^a|b$/ the anchors bind to one alternative each: write /^(?:a|b)$/',
|
|
225
|
+
])
|
|
226
|
+
}
|
|
227
|
+
if (pattern.global || pattern.sticky) {
|
|
228
|
+
throw invalidOption('cors.origin', 'a RegExp without the g or y flag (test() would be stateful)', pattern)
|
|
229
|
+
}
|
|
230
|
+
return pattern
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Whether a pattern source has a `|` outside any group and character class. */
|
|
234
|
+
function hasTopLevelAlternation(source: string): boolean {
|
|
235
|
+
let depth = 0
|
|
236
|
+
let inClass = false
|
|
237
|
+
for (let index = 0; index < source.length; index++) {
|
|
238
|
+
const char = source[index]
|
|
239
|
+
if (char === '\\') {
|
|
240
|
+
index++
|
|
241
|
+
} else if (inClass) {
|
|
242
|
+
if (char === ']') inClass = false
|
|
243
|
+
} else if (char === '[') {
|
|
244
|
+
inClass = true
|
|
245
|
+
} else if (char === '(') {
|
|
246
|
+
depth++
|
|
247
|
+
} else if (char === ')') {
|
|
248
|
+
depth--
|
|
249
|
+
} else if (char === '|' && depth === 0) {
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** `'*'` in a list of a credentialed policy: browsers read it literally there (Fetch, CORS protocol). */
|
|
257
|
+
function refuseWildcard(name: string, value: unknown, what: string, literal: string): void {
|
|
258
|
+
if (!Array.isArray(value) || !value.includes('*')) return
|
|
259
|
+
throw invalidOption(name, `a list of ${what} when credentials is true (not '*')`, value, undefined, [
|
|
260
|
+
`On credentialed requests browsers read '*' as ${literal}, not as a wildcard: list the names`,
|
|
261
|
+
])
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function tokenList(name: string, value: unknown, grammar: RegExp, what: string): string | undefined {
|
|
265
|
+
if (value === undefined) return undefined
|
|
266
|
+
if (!Array.isArray(value) || !value.every(item => typeof item === 'string' && grammar.test(item))) {
|
|
267
|
+
throw invalidOption(name, `an array of ${what}`, value)
|
|
268
|
+
}
|
|
269
|
+
return value.join(', ')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function headerList(name: string, value: unknown): string | undefined {
|
|
273
|
+
if (value === undefined) return undefined
|
|
274
|
+
if (!Array.isArray(value) || !value.every(item => typeof item === 'string' && isHeaderName(item))) {
|
|
275
|
+
throw invalidOption(name, 'an array of header names', value)
|
|
276
|
+
}
|
|
277
|
+
return value.map((item: string) => item.toLowerCase()).join(', ')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Whether a request is a CORS preflight: `OPTIONS` with `origin` and
|
|
282
|
+
* `access-control-request-method`.
|
|
283
|
+
*/
|
|
284
|
+
export function isPreflight(method: string, headers: Readonly<Record<string, string | undefined>>): boolean {
|
|
285
|
+
return (
|
|
286
|
+
method === 'OPTIONS' && headers['origin'] !== undefined && headers['access-control-request-method'] !== undefined
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Headers of a `204` preflight answer for an allowed origin. */
|
|
291
|
+
export function preflightHeaders(
|
|
292
|
+
policy: CorsPolicy,
|
|
293
|
+
origin: string,
|
|
294
|
+
requestHeaders: Readonly<Record<string, string | undefined>>,
|
|
295
|
+
allow: string,
|
|
296
|
+
): Record<string, HeaderValue> {
|
|
297
|
+
const headers: Record<string, HeaderValue> = {
|
|
298
|
+
'access-control-allow-origin': policy.wildcard ? '*' : origin,
|
|
299
|
+
}
|
|
300
|
+
if (policy.credentials) headers['access-control-allow-credentials'] = 'true'
|
|
301
|
+
const methods = policy.methods ?? allow
|
|
302
|
+
if (methods !== '') headers['access-control-allow-methods'] = methods
|
|
303
|
+
const requested = requestedHeaders(requestHeaders['access-control-request-headers'])
|
|
304
|
+
let allowHeaders = policy.allowHeaders ?? requested
|
|
305
|
+
// `*` never covers Authorization (Fetch): name it when the browser asks for it
|
|
306
|
+
if (allowHeaders !== undefined && requested?.split(', ').includes('authorization') === true) {
|
|
307
|
+
const allowed = allowHeaders.split(', ')
|
|
308
|
+
if (allowed.includes('*') && !allowed.includes('authorization')) allowHeaders = `${allowHeaders}, authorization`
|
|
309
|
+
}
|
|
310
|
+
if (allowHeaders !== undefined && allowHeaders !== '') headers['access-control-allow-headers'] = allowHeaders
|
|
311
|
+
headers['access-control-max-age'] = policy.maxAge
|
|
312
|
+
headers['allow'] = allow
|
|
313
|
+
headers['vary'] = PREFLIGHT_VARY
|
|
314
|
+
return headers
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** `access-control-request-headers` when it is a valid list of header names, lower-cased. */
|
|
318
|
+
function requestedHeaders(value: string | undefined): string | undefined {
|
|
319
|
+
if (value === undefined) return undefined
|
|
320
|
+
const names: string[] = []
|
|
321
|
+
for (const item of value.split(',')) {
|
|
322
|
+
const name = item.trim()
|
|
323
|
+
if (name === '') continue
|
|
324
|
+
if (!isHeaderName(name)) return undefined
|
|
325
|
+
names.push(name.toLowerCase())
|
|
326
|
+
}
|
|
327
|
+
return names.length === 0 ? undefined : names.join(', ')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** CORS headers of a reply to an allowed origin (added when missing). */
|
|
331
|
+
export function corsReplyHeaders(policy: CorsPolicy, origin: string): Record<string, HeaderValue> {
|
|
332
|
+
const headers: Record<string, HeaderValue> = { 'access-control-allow-origin': policy.wildcard ? '*' : origin }
|
|
333
|
+
if (policy.credentials) headers['access-control-allow-credentials'] = 'true'
|
|
334
|
+
if (policy.exposeHeaders !== undefined) headers['access-control-expose-headers'] = policy.exposeHeaders
|
|
335
|
+
return headers
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Adds `token` to the `vary` header, unless it (or `*`) is already listed. */
|
|
339
|
+
export function mergeVary(headers: Record<string, HeaderValue>, token: string): void {
|
|
340
|
+
const existing = headers['vary']
|
|
341
|
+
if (existing === undefined) {
|
|
342
|
+
headers['vary'] = token
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
const text = typeof existing === 'string' ? existing : existing.join(', ')
|
|
346
|
+
const tokens = text.split(',').map(item => item.trim().toLowerCase())
|
|
347
|
+
if (tokens.includes('*') || tokens.includes(token)) return
|
|
348
|
+
headers['vary'] = text.trim() === '' ? token : `${text}, ${token}`
|
|
349
|
+
}
|
package/src/decisions.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route decisions (RFC 0001 §5.1, §5.2): route decorator groups as known at boot, the effective
|
|
3
|
+
* metadata of a route (the nearest decision of a group wins) and the boot check of required and
|
|
4
|
+
* enforced groups (ZNIGHT023).
|
|
5
|
+
*/
|
|
6
|
+
import type { CoreLogger } from '@znight/core'
|
|
7
|
+
import type { RouteDecoratorInfo } from './decorators.ts'
|
|
8
|
+
import type { HttpMetadata } from './metadata.ts'
|
|
9
|
+
|
|
10
|
+
import { ErrorCode, ZnightError } from '@znight/core'
|
|
11
|
+
|
|
12
|
+
import { GROUP_NAME } from './decorators.ts'
|
|
13
|
+
|
|
14
|
+
/** Route metadata: key → value. */
|
|
15
|
+
export type RouteMeta = Readonly<Record<string, unknown>>
|
|
16
|
+
|
|
17
|
+
/** Where a group declaration comes from (messages). */
|
|
18
|
+
interface Declaration {
|
|
19
|
+
readonly group: string
|
|
20
|
+
readonly required: boolean
|
|
21
|
+
readonly source: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The route decorator groups of an application: the compiled `groups` metadata, completed by the
|
|
26
|
+
* `enforces` declarations of the guards and the `meta` entries of plugin routes. A key absent from
|
|
27
|
+
* every source is its own group, not required.
|
|
28
|
+
*/
|
|
29
|
+
export class RouteGroups {
|
|
30
|
+
readonly #keys = new Map<string, Declaration>()
|
|
31
|
+
readonly #groups = new Map<string, Declaration>()
|
|
32
|
+
readonly #members = new Map<string, Set<string>>()
|
|
33
|
+
|
|
34
|
+
/** Reads `metadata.extensions.http.groups` (ZNIGHT007 when malformed). */
|
|
35
|
+
static fromMetadata(groups: HttpMetadata['groups']): RouteGroups {
|
|
36
|
+
const result = new RouteGroups()
|
|
37
|
+
if (groups === undefined) return result
|
|
38
|
+
if (groups === null || typeof groups !== 'object' || Array.isArray(groups)) {
|
|
39
|
+
throw malformed(`expected an object of { keys, required } (received ${describe(groups)})`)
|
|
40
|
+
}
|
|
41
|
+
for (const group of Object.keys(groups)) {
|
|
42
|
+
const entry: unknown = groups[group]
|
|
43
|
+
const { keys, required } = (entry ?? {}) as { keys?: unknown; required?: unknown }
|
|
44
|
+
// a group is named explicitly (grammar) or after its single key (a decorator without group)
|
|
45
|
+
const ownGroup = Array.isArray(keys) && keys.length === 1 && keys[0] === group
|
|
46
|
+
if (!ownGroup && !GROUP_NAME.test(group)) throw malformed(`invalid group name ${JSON.stringify(group)}`)
|
|
47
|
+
if (
|
|
48
|
+
entry === null ||
|
|
49
|
+
typeof entry !== 'object' ||
|
|
50
|
+
!Array.isArray(keys) ||
|
|
51
|
+
keys.length === 0 ||
|
|
52
|
+
!keys.every(key => typeof key === 'string' && key !== '') ||
|
|
53
|
+
new Set(keys).size !== keys.length ||
|
|
54
|
+
typeof required !== 'boolean'
|
|
55
|
+
) {
|
|
56
|
+
throw malformed(`group "${group}" must be { keys: [unique non-empty strings], required: boolean }`)
|
|
57
|
+
}
|
|
58
|
+
for (const key of keys as string[]) {
|
|
59
|
+
const previous = result.#keys.get(key)
|
|
60
|
+
if (previous !== undefined) {
|
|
61
|
+
throw malformed(`the key "${key}" belongs to the groups "${previous.group}" and "${group}"`)
|
|
62
|
+
}
|
|
63
|
+
result.#add(key, { group, required, source: 'the compiled metadata' })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return result
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Declares the group of a key (guard `enforces`, plugin route `meta`). A declaration that
|
|
71
|
+
* contradicts an earlier one is ZNIGHT005.
|
|
72
|
+
*/
|
|
73
|
+
declare(info: RouteDecoratorInfo, source: string): void {
|
|
74
|
+
const known = this.#keys.get(info.key)
|
|
75
|
+
if (known !== undefined && (known.group !== info.group || known.required !== info.required)) {
|
|
76
|
+
throw conflict(
|
|
77
|
+
`the route decorator "${info.key}" is declared with ${describeGroup(info)} by ${source} but with ${describeGroup({ key: info.key, ...known })} by ${known.source}`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
const group = this.#groups.get(info.group)
|
|
81
|
+
if (group !== undefined && group.required !== info.required) {
|
|
82
|
+
throw conflict(
|
|
83
|
+
`the "${info.group}" group is ${info.required ? '' : 'not '}required for "${info.key}" (${source}) but ${group.required ? '' : 'not '}required for ${[...(this.#members.get(info.group) ?? [])].map(key => `"${key}"`).join(', ')} (${group.source})`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
if (known === undefined) this.#add(info.key, { group: info.group, required: info.required, source })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The group of a key (the key itself when no source declares one). */
|
|
90
|
+
groupOf(key: string): string {
|
|
91
|
+
return this.#keys.get(key)?.group ?? key
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Groups every route must decide. */
|
|
95
|
+
required(): Set<string> {
|
|
96
|
+
const result = new Set<string>()
|
|
97
|
+
for (const [group, declaration] of this.#groups) if (declaration.required) result.add(group)
|
|
98
|
+
return result
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Known keys of a group, sorted. */
|
|
102
|
+
keysOf(group: string): string[] {
|
|
103
|
+
return [...(this.#members.get(group) ?? [group])].sort()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#add(key: string, declaration: Declaration): void {
|
|
107
|
+
this.#keys.set(key, declaration)
|
|
108
|
+
if (!this.#groups.has(declaration.group)) this.#groups.set(declaration.group, declaration)
|
|
109
|
+
let members = this.#members.get(declaration.group)
|
|
110
|
+
if (members === undefined) {
|
|
111
|
+
members = new Set()
|
|
112
|
+
this.#members.set(declaration.group, members)
|
|
113
|
+
}
|
|
114
|
+
members.add(key)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function describeGroup(info: { readonly key: string; readonly group: string; readonly required: boolean }): string {
|
|
119
|
+
const group = info.group === info.key ? 'its own group' : `the group "${info.group}"`
|
|
120
|
+
return info.required ? `${group} (required)` : group
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The effective metadata of a route: the controller's entries whose group the route does not
|
|
125
|
+
* decide, then every entry of the route. Frozen.
|
|
126
|
+
*/
|
|
127
|
+
export function effectiveMeta(controller: RouteMeta, route: RouteMeta, groupOf: (key: string) => string): RouteMeta {
|
|
128
|
+
const decided = new Set(Object.keys(route).map(groupOf))
|
|
129
|
+
const result: Record<string, unknown> = {}
|
|
130
|
+
for (const key of Object.keys(controller)) {
|
|
131
|
+
if (!decided.has(groupOf(key))) result[key] = controller[key]
|
|
132
|
+
}
|
|
133
|
+
return Object.freeze(Object.assign(result, route))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The key of `meta` sharing its group with another key, as `[first, second, group]`. */
|
|
137
|
+
export function conflictingKeys(
|
|
138
|
+
meta: RouteMeta,
|
|
139
|
+
groupOf: (key: string) => string,
|
|
140
|
+
): readonly [string, string, string] | undefined {
|
|
141
|
+
const seen = new Map<string, string>()
|
|
142
|
+
for (const key of Object.keys(meta)) {
|
|
143
|
+
const group = groupOf(key)
|
|
144
|
+
const first = seen.get(group)
|
|
145
|
+
if (first !== undefined) return [first, key, group]
|
|
146
|
+
seen.set(group, key)
|
|
147
|
+
}
|
|
148
|
+
return undefined
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The groups listed by `static enforces` of a guard class (ZNIGHT005 when malformed). */
|
|
152
|
+
export function enforcedBy(guard: unknown, name: string): readonly RouteDecoratorInfo[] {
|
|
153
|
+
if (typeof guard !== 'function') return []
|
|
154
|
+
let enforces: unknown
|
|
155
|
+
try {
|
|
156
|
+
enforces = (guard as { enforces?: unknown }).enforces
|
|
157
|
+
} catch (error) {
|
|
158
|
+
throw invalidEnforces(name, 'reading it threw', error)
|
|
159
|
+
}
|
|
160
|
+
if (enforces === undefined) return []
|
|
161
|
+
if (!Array.isArray(enforces))
|
|
162
|
+
throw invalidEnforces(name, `expected an array of route decorators (received ${describe(enforces)})`)
|
|
163
|
+
for (const entry of enforces as unknown[]) {
|
|
164
|
+
const info = (typeof entry === 'function' || (typeof entry === 'object' && entry !== null) ? entry : {}) as {
|
|
165
|
+
key?: unknown
|
|
166
|
+
group?: unknown
|
|
167
|
+
required?: unknown
|
|
168
|
+
}
|
|
169
|
+
if (typeof info.key !== 'string' || typeof info.group !== 'string' || typeof info.required !== 'boolean') {
|
|
170
|
+
throw invalidEnforces(
|
|
171
|
+
name,
|
|
172
|
+
`every entry must be a route decorator ({ key, group, required }), received ${describe(entry)}`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return enforces as readonly RouteDecoratorInfo[]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function invalidEnforces(name: string, reason: string, cause?: unknown): ZnightError {
|
|
180
|
+
return new ZnightError(
|
|
181
|
+
ErrorCode.InvalidDecorator,
|
|
182
|
+
`HttpPlugin: invalid static enforces of the guard ${name}: ${reason}`,
|
|
183
|
+
{
|
|
184
|
+
hints: [
|
|
185
|
+
'static readonly enforces = [Public, Authenticated, Roles] (decorators created with createRouteDecorator)',
|
|
186
|
+
],
|
|
187
|
+
...(cause === undefined ? {} : { cause }),
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A route as seen by the boot check: one per declared route (no HEAD or trailing-slash variant). */
|
|
193
|
+
export interface CheckedRoute {
|
|
194
|
+
/** `GET /books (BookController.list)`. */
|
|
195
|
+
readonly description: string
|
|
196
|
+
/** Effective metadata. */
|
|
197
|
+
readonly meta: RouteMeta
|
|
198
|
+
/** Groups enforced by the guards of its chain (global, controller, route). */
|
|
199
|
+
readonly enforced: ReadonlySet<string>
|
|
200
|
+
/** Name of the plugin declaring the route, for plugin routes. */
|
|
201
|
+
readonly plugin?: string
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** How the boot check reports problems. */
|
|
205
|
+
export type EnforcementMode = 'error' | 'warn'
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The boot check (RFC 0001 §5.2): every route decides every required group, and every decision of
|
|
209
|
+
* a required or enforced group is enforced by a guard of the route's chain. A group is enforced when
|
|
210
|
+
* a guard of some chain, or a guard class of the application (`enforcers`, in a chain or not),
|
|
211
|
+
* declares it: a guard registered nowhere does not make its decisions inert. Problems are reported
|
|
212
|
+
* as one ZNIGHT023 error (`'error'`) or one warning per group (`'warn'`).
|
|
213
|
+
*/
|
|
214
|
+
export function checkDecisions(
|
|
215
|
+
routes: readonly CheckedRoute[],
|
|
216
|
+
groups: RouteGroups,
|
|
217
|
+
mode: EnforcementMode,
|
|
218
|
+
logger: CoreLogger,
|
|
219
|
+
enforcers: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
|
|
220
|
+
): void {
|
|
221
|
+
const required = groups.required()
|
|
222
|
+
const known = new Set<string>(enforcers.keys())
|
|
223
|
+
for (const route of routes) for (const group of route.enforced) known.add(group)
|
|
224
|
+
const problems = new Map<string, { missing: string[]; unenforced: string[]; plugins: Set<string> }>()
|
|
225
|
+
const problem = (group: string): { missing: string[]; unenforced: string[]; plugins: Set<string> } => {
|
|
226
|
+
let entry = problems.get(group)
|
|
227
|
+
if (entry === undefined) {
|
|
228
|
+
entry = { missing: [], unenforced: [], plugins: new Set() }
|
|
229
|
+
problems.set(group, entry)
|
|
230
|
+
}
|
|
231
|
+
return entry
|
|
232
|
+
}
|
|
233
|
+
for (const route of routes) {
|
|
234
|
+
const decided = new Set(Object.keys(route.meta).map(key => groups.groupOf(key)))
|
|
235
|
+
for (const group of required) {
|
|
236
|
+
if (decided.has(group)) continue
|
|
237
|
+
const entry = problem(group)
|
|
238
|
+
entry.missing.push(route.description)
|
|
239
|
+
if (route.plugin !== undefined) entry.plugins.add(route.plugin)
|
|
240
|
+
}
|
|
241
|
+
for (const group of decided) {
|
|
242
|
+
if ((required.has(group) || known.has(group)) && !route.enforced.has(group)) {
|
|
243
|
+
problem(group).unenforced.push(route.description)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (problems.size === 0) return
|
|
248
|
+
const messages: string[] = []
|
|
249
|
+
const hints: string[] = []
|
|
250
|
+
for (const group of [...problems.keys()].sort()) {
|
|
251
|
+
const entry = problems.get(group) as { missing: string[]; unenforced: string[]; plugins: Set<string> }
|
|
252
|
+
const keys = groups.keysOf(group)
|
|
253
|
+
const parts: string[] = []
|
|
254
|
+
const groupHints: string[] = []
|
|
255
|
+
if (entry.missing.length > 0) {
|
|
256
|
+
parts.push(
|
|
257
|
+
`${count(entry.missing.length)} ${entry.missing.length === 1 ? 'has' : 'have'} no "${group}" decision: ${entry.missing.join(', ')}`,
|
|
258
|
+
)
|
|
259
|
+
groupHints.push(`Add a decision of the "${group}" group (${keys.join(', ')}) to the route or to its controller`)
|
|
260
|
+
for (const plugin of [...entry.plugins].sort()) {
|
|
261
|
+
groupHints.push(
|
|
262
|
+
`Routes of the plugin "${plugin}" take their metadata from its options: meta: [Decorator.meta()]`,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (entry.unenforced.length > 0) {
|
|
267
|
+
parts.push(
|
|
268
|
+
`no guard enforces the ${required.has(group) ? 'required ' : ''}"${group}" group on ${count(entry.unenforced.length)}: ${entry.unenforced.join(', ')}`,
|
|
269
|
+
)
|
|
270
|
+
const guards = [...(enforcers.get(group) ?? [])].sort()
|
|
271
|
+
// a guard of the application that enforces the group, else a name for the one to write
|
|
272
|
+
const example = guards[0] ?? guardName(group)
|
|
273
|
+
groupHints.push(
|
|
274
|
+
`Register a guard declaring \`static enforces\` with the decorators of the "${group}" group (${keys.join(', ')}), e.g. HttpPlugin.configure({ guards: [${example}] })`,
|
|
275
|
+
)
|
|
276
|
+
if (guards.length > 0) {
|
|
277
|
+
groupHints.push(`Guards of the application enforcing the "${group}" group: ${guards.join(', ')}`)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const message = parts.join('; ')
|
|
281
|
+
if (mode === 'warn') {
|
|
282
|
+
logger.warn(`HttpPlugin: ${message}`, { code: ErrorCode.MissingRouteDecision, group })
|
|
283
|
+
} else {
|
|
284
|
+
messages.push(message)
|
|
285
|
+
hints.push(...groupHints)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (mode === 'error') {
|
|
289
|
+
hints.push("Tests that deliberately run without guards can set enforcement: 'warn'")
|
|
290
|
+
throw new ZnightError(ErrorCode.MissingRouteDecision, `HttpPlugin: ${capitalize(messages.join('; '))}`, { hints })
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function count(routes: number): string {
|
|
295
|
+
return routes === 1 ? '1 route' : `${routes} routes`
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function capitalize(text: string): string {
|
|
299
|
+
return text.charAt(0).toUpperCase() + text.slice(1)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** A class name for the guard of a group: `rateLimit` → `RateLimitGuard`, `auth:public` → `AuthPublicGuard`. */
|
|
303
|
+
function guardName(group: string): string {
|
|
304
|
+
const name = group
|
|
305
|
+
.split(/[^A-Za-z0-9]+/)
|
|
306
|
+
.map(capitalize)
|
|
307
|
+
.join('')
|
|
308
|
+
.replace(/^\d+/, '')
|
|
309
|
+
return `${name === '' ? 'Decision' : name}Guard`
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function malformed(reason: string): ZnightError {
|
|
313
|
+
return new ZnightError(ErrorCode.InvalidRoute, `Invalid metadata.extensions.http.groups: ${reason}`)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function conflict(reason: string): ZnightError {
|
|
317
|
+
return new ZnightError(ErrorCode.InvalidDecorator, `HttpPlugin: inconsistent route decorator groups: ${reason}`, {
|
|
318
|
+
hints: [
|
|
319
|
+
'Every definition of a key must use the same options, and the decorators of a group must agree on required',
|
|
320
|
+
'Regenerate the metadata when createRouteDecorator options changed',
|
|
321
|
+
],
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function describe(value: unknown): string {
|
|
326
|
+
try {
|
|
327
|
+
return typeof value === 'function' ? 'a function' : (JSON.stringify(value) ?? String(value))
|
|
328
|
+
} catch {
|
|
329
|
+
return typeof value
|
|
330
|
+
}
|
|
331
|
+
}
|