@znight/http 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. 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
+ }
@@ -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
+ }