@boo-labs/server 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +229 -2
  2. package/index.mjs +23 -0
  3. package/package.json +71 -4
  4. package/source/application/Kernel/index.mjs +1137 -0
  5. package/source/application/Server/index.mjs +1201 -0
  6. package/source/application/configurations/Http2CleartextServerConfiguration/index.mjs +168 -0
  7. package/source/application/configurations/Http2SecureServerConfiguration/index.mjs +172 -0
  8. package/source/application/configurations/HttpServerConfiguration/index.mjs +169 -0
  9. package/source/application/configurations/HttpsServerConfiguration/index.mjs +172 -0
  10. package/source/application/configurations/ServerConfiguration/Options/CORSOptions/CORSOptionsValue.mjs +158 -0
  11. package/source/application/configurations/ServerConfiguration/Options/CORSOptions/index.mjs +87 -0
  12. package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/CSRFOptionsValue.mjs +178 -0
  13. package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/index.mjs +80 -0
  14. package/source/application/configurations/ServerConfiguration/Options/CompressOptions/CompressOptionsValue.mjs +126 -0
  15. package/source/application/configurations/ServerConfiguration/Options/CompressOptions/index.mjs +81 -0
  16. package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/CookieParserOptionsValue.mjs +107 -0
  17. package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/index.mjs +82 -0
  18. package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/CookieSessionOptionsValue.mjs +182 -0
  19. package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/index.mjs +86 -0
  20. package/source/application/configurations/ServerConfiguration/Options/ListenOptions/ListenOptionsValue.mjs +95 -0
  21. package/source/application/configurations/ServerConfiguration/Options/ListenOptions/index.mjs +47 -0
  22. package/source/application/configurations/ServerConfiguration/Options/MiddlewareOption.mjs +102 -0
  23. package/source/application/configurations/ServerConfiguration/Options/Options.mjs +105 -0
  24. package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/RateLimitOptionsValue.mjs +164 -0
  25. package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/index.mjs +84 -0
  26. package/source/application/configurations/ServerConfiguration/Options/RequestOptions/RequestOptionsValue.mjs +108 -0
  27. package/source/application/configurations/ServerConfiguration/Options/RequestOptions/index.mjs +47 -0
  28. package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/ResponseOptionsValue.mjs +101 -0
  29. package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/index.mjs +47 -0
  30. package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/SecurityHeadersOptionsValue.mjs +272 -0
  31. package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/index.mjs +85 -0
  32. package/source/application/configurations/ServerConfiguration/Options/ServerOption.mjs +50 -0
  33. package/source/application/configurations/ServerConfiguration/Options/StaticOptions/StaticOptionsValue.mjs +164 -0
  34. package/source/application/configurations/ServerConfiguration/Options/StaticOptions/index.mjs +89 -0
  35. package/source/application/configurations/ServerConfiguration/index.mjs +781 -0
  36. package/source/application/resolvers/TransportResolver/index.mjs +119 -0
  37. package/source/application/use-cases/CloseServer/index.mjs +85 -0
  38. package/source/application/use-cases/ListenServer/index.mjs +98 -0
  39. package/source/core/EventSystem/index.mjs +110 -0
  40. package/source/core/Exchange/ExchangeContext.mjs +291 -0
  41. package/source/core/Exchange/Telemetry.mjs +193 -0
  42. package/source/core/Exchange/index.mjs +604 -0
  43. package/source/core/ExchangeLayerBuilder/index.mjs +133 -0
  44. package/source/core/IPCMain/IPCMain.mjs +507 -0
  45. package/source/core/IPCMain/IPCProtocol.mjs +441 -0
  46. package/source/core/IPCMain/IPCServerChannel.mjs +331 -0
  47. package/source/core/IPCMain/WebSocketAdapter.mjs +189 -0
  48. package/source/core/Request/BodyParser/BodyParser.mjs +197 -0
  49. package/source/core/Request/BodyParser/JSONParser.mjs +70 -0
  50. package/source/core/Request/BodyParser/MultipartParser.mjs +612 -0
  51. package/source/core/Request/BodyParser/URLEncodedParser.mjs +71 -0
  52. package/source/core/Request/RequestHeaders.mjs +177 -0
  53. package/source/core/Request/index.mjs +805 -0
  54. package/source/core/Response/ResponseHeaders.mjs +402 -0
  55. package/source/core/Response/index.mjs +1370 -0
  56. package/source/core/middlewares/CORSMiddleware/index.mjs +289 -0
  57. package/source/core/middlewares/CSRFMiddleware/index.mjs +284 -0
  58. package/source/core/middlewares/CompressMiddleware/index.mjs +460 -0
  59. package/source/core/middlewares/CookieParserMiddleware/index.mjs +194 -0
  60. package/source/core/middlewares/CookieSessionMiddleware/index.mjs +460 -0
  61. package/source/core/middlewares/RateLimitMiddleware/index.mjs +245 -0
  62. package/source/core/middlewares/SecurityHeadersMiddleware/index.mjs +151 -0
  63. package/source/core/middlewares/StaticMiddleware/index.mjs +628 -0
  64. package/source/core/routing/ExchangeLayer/ExchangeMatcher.mjs +127 -0
  65. package/source/core/routing/ExchangeLayer/index.mjs +254 -0
  66. package/source/core/routing/Pipeline/index.mjs +157 -0
  67. package/source/core/routing/Router/Route.mjs +184 -0
  68. package/source/core/routing/Router/index.mjs +523 -0
  69. package/source/core/routing/RouterRegister/OptionsMiddleware.mjs +147 -0
  70. package/source/core/routing/RouterRegister/index.mjs +150 -0
  71. package/source/core/routing/RouterSystem/index.mjs +225 -0
  72. package/source/core/value-objects/Address/index.mjs +281 -0
  73. package/source/core/value-objects/Endpoint/index.mjs +293 -0
  74. package/source/index.mjs +62 -0
  75. package/source/infrastructure/adapters/Http2Adapter/index.mjs +144 -0
  76. package/source/infrastructure/adapters/Http2CleartextAdapter/index.mjs +147 -0
  77. package/source/infrastructure/adapters/HttpAdapter/index.mjs +124 -0
  78. package/source/infrastructure/adapters/HttpsAdapter/index.mjs +124 -0
  79. package/source/infrastructure/adapters/TransportAdapter/createWebSocketOpenRequest.mjs +843 -0
  80. package/source/infrastructure/adapters/TransportAdapter/index.mjs +555 -0
  81. package/source/shared/Identifier/index.mjs +671 -0
  82. package/source/shared/constants/symbols/brands/index.mjs +45 -0
  83. package/source/shared/domain/UUIDV7/index.mjs +346 -0
  84. package/source/shared/errors/BaseError/index.mjs +157 -0
  85. package/source/shared/errors/IPCError/index.mjs +97 -0
  86. package/source/shared/errors/MiddlewareError/index.mjs +106 -0
  87. package/source/shared/errors/PathnamePatternError/index.mjs +103 -0
  88. package/source/shared/errors/RequestError/index.mjs +116 -0
  89. package/source/shared/errors/ResponseError/index.mjs +140 -0
  90. package/source/shared/errors/RouterError/index.mjs +90 -0
  91. package/source/shared/errors/ServerConfigurationError/index.mjs +551 -0
  92. package/source/shared/errors/ServerError/index.mjs +127 -0
  93. package/source/shared/errors/WebSocketError/index.mjs +58 -0
  94. package/source/shared/outcome/ErrorSchema.mjs +361 -0
  95. package/source/shared/outcome/Result/index.mjs +239 -0
  96. package/source/shared/outcome/Validation/index.mjs +208 -0
  97. package/source/shared/validations/Middleware/index.mjs +160 -0
  98. package/source/shared/validations/PathnamePattern/index.mjs +256 -0
  99. package/source/shared/validations/Server/NotFoundHandler/index.mjs +94 -0
  100. package/source/shared/validations/Server/Router/index.mjs +68 -0
  101. package/source/shared/validations/Server/ServerConfiguration/index.mjs +68 -0
  102. package/source/shared/validations/Server/SystemErrorHandler/index.mjs +94 -0
  103. package/source/shared/validations/ServerConfiguration/CORSOptions/index.mjs +310 -0
  104. package/source/shared/validations/ServerConfiguration/CSRFOptions/index.mjs +299 -0
  105. package/source/shared/validations/ServerConfiguration/CompressOptions/index.mjs +204 -0
  106. package/source/shared/validations/ServerConfiguration/CookieParserOptions/index.mjs +102 -0
  107. package/source/shared/validations/ServerConfiguration/CookieSessionOptions/index.mjs +332 -0
  108. package/source/shared/validations/ServerConfiguration/ListenOptions/index.mjs +178 -0
  109. package/source/shared/validations/ServerConfiguration/RateLimitOptions/index.mjs +229 -0
  110. package/source/shared/validations/ServerConfiguration/RequestOptions/index.mjs +132 -0
  111. package/source/shared/validations/ServerConfiguration/ResponseOptions/index.mjs +122 -0
  112. package/source/shared/validations/ServerConfiguration/SecurityHeadersOptions/index.mjs +137 -0
  113. package/source/shared/validations/ServerConfiguration/StaticOptions/index.mjs +248 -0
  114. package/types/index.d.ts +719 -0
@@ -0,0 +1,289 @@
1
+ // @ts-check
2
+ /**
3
+ * @file Defines the CORS middleware.
4
+ *
5
+ * @description Implements Cross-Origin Resource Sharing (CORS) without
6
+ * external dependencies. Supports origin reflection and allow-lists,
7
+ * preflight handling, credentials, exposed headers, and configurable
8
+ * preflight caching.
9
+ *
10
+ * Configuration is expected to be validated and normalized before the
11
+ * middleware is created.
12
+ *
13
+ * @module CorsMiddleware
14
+ */
15
+
16
+ // ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
17
+ /**
18
+ * Represents an HTTP exchange.
19
+ *
20
+ * @typedef {import('#Exchange').default} Exchange
21
+ */
22
+
23
+ /**
24
+ * Represents an outgoing HTTP response.
25
+ *
26
+ * @typedef {import('#Response').default} Response
27
+ */
28
+
29
+ /**
30
+ * Represents a function that delegates execution to the next middleware
31
+ * layer.
32
+ *
33
+ * @typedef {() => void | Promise<void>} NextFunction
34
+ */
35
+
36
+ /**
37
+ * Represents normalized CORS middleware configuration.
38
+ *
39
+ * @typedef {object} CorsOptionsData
40
+ * @property {true|string|string[]} origin - Allowed origin(s).
41
+ * @property {string[]} methods - Allowed HTTP methods for preflight responses.
42
+ * @property {string[]|undefined} allowedHeaders - Headers the client is allowed to send. When `undefined`, the value of Access-Control-Request-Headers is reflected.
43
+ * @property {string[]|undefined} exposedHeaders - Headers the client is allowed to read from the response.
44
+ * @property {boolean} credentials - Whether the response may include credentials.
45
+ * @property {number|undefined} maxAge - How long (in seconds) the preflight response may be cached.
46
+ * @property {boolean} preflightContinue - Whether to call `next()` on successful preflight instead of ending the response.
47
+ * @property {number} optionsSuccessStatus - Status code used for successful preflight responses.
48
+ */
49
+
50
+ // ━━ FUNCTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
51
+ /**
52
+ * Reads a header value as a single string.
53
+ *
54
+ * @private
55
+ * @param {string|string[]|undefined} value - Header value to normalize.
56
+ * @returns {string|undefined} The first header value, or `undefined` when no value exists.
57
+ */
58
+ function firstHeaderValue(value) {
59
+ return Array.isArray(value) ? value[0] : value;
60
+ }
61
+
62
+ /**
63
+ * Resolves the value that should be written to
64
+ * Access-Control-Allow-Origin.
65
+ *
66
+ * @private
67
+ * @param {true|string|string[]} originConfig - Configured origin option.
68
+ * @param {string|undefined} requestOrigin - Origin header from the request.
69
+ * @returns {string|null} The header value to set, or `null` when the origin is not allowed.
70
+ */
71
+ function resolveAllowOrigin(originConfig, requestOrigin) {
72
+ if (originConfig === true) {
73
+ return typeof requestOrigin === 'string' ? requestOrigin : null;
74
+ }
75
+
76
+ if (typeof originConfig === 'string') {
77
+ if (originConfig === '*') {
78
+ return '*';
79
+ }
80
+
81
+ return originConfig === requestOrigin ? originConfig : null;
82
+ }
83
+
84
+ if (typeof requestOrigin === 'string' && originConfig.includes(requestOrigin)) {
85
+ return requestOrigin;
86
+ }
87
+
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Serializes an array of tokens into a comma-separated header value.
93
+ *
94
+ * @private
95
+ * @param {string[]} values - Header tokens to serialize.
96
+ * @returns {string} The serialized header value.
97
+ */
98
+ function joinHeaderValues(values) {
99
+ return values.join(', ');
100
+ }
101
+
102
+ /**
103
+ * Applies the CORS headers shared by preflight and actual requests.
104
+ *
105
+ * @private
106
+ * @param {Response} response - Response to which the CORS headers are applied.
107
+ * @param {string} allowOrigin - Value for the Access-Control-Allow-Origin header.
108
+ * @param {boolean} credentials - Whether to allow credentials in the response.
109
+ */
110
+ function applyBaseCorsHeaders(response, allowOrigin, credentials) {
111
+ response.setHeader('Access-Control-Allow-Origin', allowOrigin);
112
+
113
+ if (credentials) {
114
+ response.setHeader('Access-Control-Allow-Credentials', 'true');
115
+ }
116
+ }
117
+
118
+ // ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
119
+ /**
120
+ * Implements Cross-Origin Resource Sharing (CORS).
121
+ *
122
+ * @classdesc The middleware handles both simple CORS requests and preflight
123
+ * (OPTIONS) requests according to the Fetch specification.
124
+ *
125
+ * When the request origin is not allowed, no CORS headers are written and
126
+ * processing is delegated to `next()`.
127
+ */
128
+ class CorsMiddleware {
129
+ /**
130
+ * Normalized CORS middleware options.
131
+ *
132
+ * @type {Readonly<CorsOptionsData>}
133
+ */
134
+ #options;
135
+
136
+ /**
137
+ * Creates a CORS middleware.
138
+ *
139
+ * The `origin` option may be `true` to reflect the request origin, a
140
+ * string for a fixed origin or `"*"`, or an array of allowed origins.
141
+ *
142
+ * @param {CorsOptionsData} options - Validated and normalized CORS options.
143
+ *
144
+ * @example
145
+ * ```js
146
+ * const middleware = new CorsMiddleware({
147
+ * origin: true,
148
+ * methods: ['GET', 'POST'],
149
+ * credentials: true,
150
+ * preflightContinue: false,
151
+ * optionsSuccessStatus: 204,
152
+ * });
153
+ * ```
154
+ *
155
+ * @example
156
+ * ```js
157
+ * const middleware = new CorsMiddleware({
158
+ * origin: true,
159
+ * methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
160
+ * allowedHeaders: undefined,
161
+ * exposedHeaders: ['X-Request-Id'],
162
+ * credentials: true,
163
+ * maxAge: 86400,
164
+ * preflightContinue: false,
165
+ * optionsSuccessStatus: 204,
166
+ * });
167
+ * ```
168
+ */
169
+ constructor(options) {
170
+ this.#options = options;
171
+ }
172
+
173
+ /**
174
+ * Processes an HTTP exchange and applies CORS headers.
175
+ *
176
+ * For preflight (OPTIONS) requests the middleware may end the response
177
+ * early, depending on `preflightContinue`. For non-preflight requests,
178
+ * it applies the appropriate CORS response headers and delegates
179
+ * processing to `next()`.
180
+ *
181
+ * @param {Exchange} exchange - Exchange to process.
182
+ * @param {NextFunction} next - Function that delegates to the next middleware.
183
+ * @returns {Promise<void>} Resolves when request processing has completed.
184
+ *
185
+ * @example
186
+ * ```js
187
+ * await middleware.handle(exchange, next);
188
+ * ```
189
+ */
190
+ async handle(exchange, next) {
191
+ const { request, response } = exchange;
192
+ const { origin: originConfig } = this.#options;
193
+
194
+ const requestOrigin = firstHeaderValue(request.getHeader('origin'));
195
+ const allowOrigin = resolveAllowOrigin(originConfig, requestOrigin);
196
+
197
+ if (allowOrigin === null) {
198
+ await next();
199
+ } else {
200
+ if (originConfig === true || Array.isArray(originConfig)) {
201
+ response.vary('Origin');
202
+ }
203
+
204
+ if (request.method === 'OPTIONS') {
205
+ await this.#handlePreflight(exchange, allowOrigin, next);
206
+ } else {
207
+ await this.#handleActualRequest(exchange, allowOrigin, next);
208
+ }
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Handles preflight (OPTIONS) requests.
214
+ *
215
+ * @param {Exchange} exchange - Exchange to process.
216
+ * @param {string} allowOrigin - Value for the Access-Control-Allow-Origin header.
217
+ * @param {NextFunction} next - Function that delegates to the next middleware.
218
+ * @returns {Promise<void>} Resolves when request processing has completed.
219
+ */
220
+ async #handlePreflight(exchange, allowOrigin, next) {
221
+ const { request, response } = exchange;
222
+ const {
223
+ methods,
224
+ allowedHeaders,
225
+ credentials,
226
+ maxAge,
227
+ preflightContinue,
228
+ optionsSuccessStatus,
229
+ } = this.#options;
230
+
231
+ const requestMethod = firstHeaderValue(request.getHeader('access-control-request-method'));
232
+
233
+ if (typeof requestMethod !== 'string' || !methods.includes(requestMethod)) {
234
+ await next();
235
+ return;
236
+ }
237
+
238
+ applyBaseCorsHeaders(response, allowOrigin, credentials);
239
+ response.setHeader('Access-Control-Allow-Methods', joinHeaderValues(methods));
240
+
241
+ if (allowedHeaders !== undefined) {
242
+ response.setHeader('Access-Control-Allow-Headers', joinHeaderValues(allowedHeaders));
243
+ } else {
244
+ const requestHeaders = request.getHeader('access-control-request-headers');
245
+
246
+ if (typeof requestHeaders === 'string') {
247
+ response.setHeader('Access-Control-Allow-Headers', requestHeaders);
248
+ response.vary('Access-Control-Request-Headers');
249
+ } else if (Array.isArray(requestHeaders) && requestHeaders.length > 0) {
250
+ response.setHeader('Access-Control-Allow-Headers', joinHeaderValues(requestHeaders));
251
+ response.vary('Access-Control-Request-Headers');
252
+ }
253
+ }
254
+
255
+ if (typeof maxAge === 'number') {
256
+ response.setHeader('Access-Control-Max-Age', String(maxAge));
257
+ }
258
+
259
+ if (preflightContinue) {
260
+ await next();
261
+ } else {
262
+ response.status(optionsSuccessStatus).end();
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Handles actual (non-preflight) CORS requests.
268
+ *
269
+ * @param {Exchange} exchange - Exchange to process.
270
+ * @param {string} allowOrigin - Value for the Access-Control-Allow-Origin header.
271
+ * @param {NextFunction} next - Function that delegates to the next middleware.
272
+ * @returns {Promise<void>} Resolves when request processing has completed.
273
+ */
274
+ async #handleActualRequest(exchange, allowOrigin, next) {
275
+ const { response } = exchange;
276
+ const { exposedHeaders, credentials } = this.#options;
277
+
278
+ applyBaseCorsHeaders(response, allowOrigin, credentials);
279
+
280
+ if (exposedHeaders !== undefined && exposedHeaders.length > 0) {
281
+ response.setHeader('Access-Control-Expose-Headers', joinHeaderValues(exposedHeaders));
282
+ }
283
+
284
+ await next();
285
+ }
286
+ }
287
+
288
+ // ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
289
+ export default CorsMiddleware;
@@ -0,0 +1,284 @@
1
+ // @ts-check
2
+ /**
3
+ * @file Defines the CSRF middleware.
4
+ *
5
+ * @description Implements CSRF protection using the Double Submit Cookie
6
+ * pattern. A cryptographically secure token is stored in a cookie and must be
7
+ * echoed by the client in a configurable request header. Safe methods are not
8
+ * validated; they only ensure the cookie is present.
9
+ *
10
+ * This is a classic Double Submit Cookie implementation: it verifies that the
11
+ * cookie token and the header token match. It does not bind the token to a
12
+ * server-side secret or session and therefore does not by itself prevent an
13
+ * attacker who can set a cookie from choosing a known token value.
14
+ *
15
+ * The middleware is independent of session, authentication and CookieParser.
16
+ * It reads the Cookie header directly and writes Set-Cookie when needed.
17
+ *
18
+ * Configuration is expected to be validated and normalized before the
19
+ * middleware is created.
20
+ *
21
+ * @module CSRFMiddleware
22
+ */
23
+
24
+ // ━━ IMPORT MODULES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
25
+ // » IMPORT NODE MODULES
26
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
27
+
28
+ // ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
29
+ /**
30
+ * Represents an HTTP exchange.
31
+ *
32
+ * @typedef {import('#Exchange').default} Exchange
33
+ */
34
+
35
+ /**
36
+ * Represents a function that delegates execution to the next middleware
37
+ * layer.
38
+ *
39
+ * @typedef {() => void | Promise<void>} NextFunction
40
+ */
41
+
42
+ /**
43
+ * Represents normalized CSRF middleware configuration.
44
+ *
45
+ * @typedef {object} CSRFOptionsData
46
+ * @property {string} cookieName - Name of the CSRF cookie.
47
+ * @property {string} headerName - Name of the request header that must carry the token.
48
+ * @property {string[]} safeMethods - HTTP methods that skip CSRF validation.
49
+ * @property {number} tokenBytes - Number of random bytes used to generate the token.
50
+ * @property {string} path - Cookie Path attribute.
51
+ * @property {string|undefined} domain - Cookie Domain attribute.
52
+ * @property {boolean} secure - Whether the cookie is marked Secure.
53
+ * @property {'Strict'|'Lax'|'None'|undefined} sameSite - Cookie SameSite attribute.
54
+ */
55
+
56
+ /**
57
+ * Represents a parsed CSRF cookie.
58
+ *
59
+ * @typedef {(
60
+ * {state: 'absent', value: undefined} |
61
+ * {state: 'ambiguous', value: undefined} |
62
+ * {state: 'present', value: string}
63
+ * )} CookieResult
64
+ */
65
+
66
+ // ━━ FUNCTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
67
+ /**
68
+ * Reads a header value only when it is a single unambiguous string.
69
+ *
70
+ * @private
71
+ * @param {string|string[]|undefined} value - Header value from Request.
72
+ * @returns {string|undefined} The single header value, or `undefined` when absent or ambiguous.
73
+ */
74
+ function readSingleHeaderValue(value) {
75
+ return typeof value === 'string' ? value : undefined;
76
+ }
77
+
78
+ /**
79
+ * Generates a cryptographically secure token.
80
+ *
81
+ * @private
82
+ * @param {number} bytes - Number of random bytes used to generate the token.
83
+ * @returns {string} The token encoded as base64url without padding.
84
+ */
85
+ function generateToken(bytes) {
86
+ return randomBytes(bytes).toString('base64url');
87
+ }
88
+
89
+ /**
90
+ * Compares two tokens using a constant-time comparison.
91
+ *
92
+ * @private
93
+ * @param {string} left - First token.
94
+ * @param {string} right - Second token.
95
+ * @returns {boolean} Whether the tokens are equal.
96
+ */
97
+ function tokensEqual(left, right) {
98
+ const leftBuffer = Buffer.from(left);
99
+ const rightBuffer = Buffer.from(right);
100
+
101
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
102
+ }
103
+
104
+ /**
105
+ * Reads a cookie by exact name.
106
+ *
107
+ * Duplicate occurrences of the same cookie name are treated as ambiguous
108
+ * rather than selecting either the first or last occurrence.
109
+ *
110
+ * @private
111
+ * @param {string|undefined} header - Raw Cookie header.
112
+ * @param {string} name - Cookie name.
113
+ * @returns {CookieResult} Parsed cookie result.
114
+ */
115
+ function readCookie(header, name) {
116
+ if (typeof header !== 'string' || header.length === 0) {
117
+ return { state: 'absent', value: undefined };
118
+ }
119
+
120
+ const segments = header.split(';');
121
+ const matchingSegments = segments.filter(segment => {
122
+ const separatorIndex = segment.indexOf('=');
123
+
124
+ if (separatorIndex === -1) {
125
+ return false;
126
+ }
127
+
128
+ return segment.slice(0, separatorIndex).trim() === name;
129
+ });
130
+
131
+ if (matchingSegments.length === 0) {
132
+ return { state: 'absent', value: undefined };
133
+ }
134
+
135
+ if (matchingSegments.length > 1) {
136
+ return { state: 'ambiguous', value: undefined };
137
+ }
138
+
139
+ const separatorIndex = matchingSegments[0].indexOf('=');
140
+ const rawValue = matchingSegments[0].slice(separatorIndex + 1).trim();
141
+ const value =
142
+ rawValue.length >= 2 && rawValue.startsWith('"') && rawValue.endsWith('"')
143
+ ? rawValue.slice(1, -1)
144
+ : rawValue;
145
+
146
+ if (value.length === 0) {
147
+ return { state: 'absent', value: undefined };
148
+ }
149
+
150
+ return { state: 'present', value };
151
+ }
152
+
153
+ /**
154
+ * Builds a Set-Cookie header value for the CSRF token cookie.
155
+ *
156
+ * The cookie is intentionally not marked HttpOnly so that browser clients
157
+ * can read it and place the value in the request header. The cookie is a
158
+ * session cookie because no Max-Age or Expires attribute is set.
159
+ *
160
+ * @private
161
+ * @param {string} name - Cookie name.
162
+ * @param {string} value - Cookie value.
163
+ * @param {CSRFOptionsData} options - CSRF cookie configuration.
164
+ * @returns {string} The Set-Cookie header value.
165
+ */
166
+ function buildSetCookie(name, value, options) {
167
+ const parts = [`${name}=${value}`, `Path=${options.path}`];
168
+
169
+ if (typeof options.domain === 'string') {
170
+ parts.push(`Domain=${options.domain}`);
171
+ }
172
+
173
+ if (options.secure) {
174
+ parts.push('Secure');
175
+ }
176
+
177
+ if (typeof options.sameSite === 'string') {
178
+ parts.push(`SameSite=${options.sameSite}`);
179
+ }
180
+
181
+ return parts.join('; ');
182
+ }
183
+
184
+ // ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
185
+ /**
186
+ * Protects state-changing requests against Cross-Site Request Forgery.
187
+ *
188
+ * @classdesc Implements the Double Submit Cookie pattern. Safe methods only
189
+ * ensure that a CSRF cookie exists. Other methods require the same token to be
190
+ * sent in the configured request header.
191
+ *
192
+ * Duplicate CSRF cookies and ambiguous CSRF headers are rejected.
193
+ */
194
+ class CSRFMiddleware {
195
+ /**
196
+ * Normalized CSRF middleware options.
197
+ *
198
+ * @type {Readonly<CSRFOptionsData>}
199
+ */
200
+ #options;
201
+
202
+ /**
203
+ * Creates a CSRF middleware.
204
+ *
205
+ * @param {CSRFOptionsData} options - Validated and normalized CSRF options.
206
+ *
207
+ * @example
208
+ * ```js
209
+ * const middleware = new CsrfMiddleware({
210
+ * cookieName: 'csrf-token',
211
+ * headerName: 'x-csrf-token',
212
+ * safeMethods: ['GET', 'HEAD', 'OPTIONS'],
213
+ * tokenBytes: 32,
214
+ * path: '/',
215
+ * domain: undefined,
216
+ * secure: true,
217
+ * sameSite: 'Lax',
218
+ * });
219
+ * ```
220
+ */
221
+ constructor(options) {
222
+ this.#options = options;
223
+ }
224
+
225
+ /**
226
+ * Processes an HTTP exchange and enforces CSRF protection.
227
+ *
228
+ * Safe methods ensure that the CSRF cookie exists. Other methods require a
229
+ * matching and unambiguous request header token. Validation failures end
230
+ * the response with status 403 without invoking `next()`.
231
+ *
232
+ * The current token is exposed on `exchange.state.csrfToken`.
233
+ *
234
+ * @param {Exchange} exchange - Exchange to process.
235
+ * @param {NextFunction} next - Function that delegates to the next middleware.
236
+ * @returns {Promise<void>} Resolves when request processing has completed.
237
+ *
238
+ * @example
239
+ * ```js
240
+ * await middleware.handle(exchange, next);
241
+ * ```
242
+ */
243
+ async handle(exchange, next) {
244
+ const { request, response, state } = exchange;
245
+ const { cookieName, headerName, safeMethods, tokenBytes } = this.#options;
246
+
247
+ const cookieHeader = readSingleHeaderValue(request.getHeader('cookie'));
248
+ const cookie = readCookie(cookieHeader, cookieName);
249
+ const method = request.method;
250
+ const isSafeMethod = typeof method === 'string' && safeMethods.includes(method);
251
+
252
+ if (cookie.state === 'ambiguous') {
253
+ response.status(403).json({ error: 'Forbidden' });
254
+ } else if (isSafeMethod) {
255
+ const token = cookie.state === 'present' ? cookie.value : generateToken(tokenBytes);
256
+
257
+ if (cookie.state !== 'present') {
258
+ response.appendHeader('Set-Cookie', buildSetCookie(cookieName, token, this.#options));
259
+ }
260
+
261
+ state.csrfToken = token;
262
+ await next();
263
+ } else if (cookie.state !== 'present') {
264
+ response.status(403).json({ error: 'Forbidden' });
265
+ } else {
266
+ const headerToken = readSingleHeaderValue(request.getHeader(headerName));
267
+
268
+ const hasMatchingToken =
269
+ typeof headerToken === 'string' &&
270
+ headerToken.length > 0 &&
271
+ tokensEqual(cookie.value, headerToken);
272
+
273
+ if (hasMatchingToken) {
274
+ state.csrfToken = cookie.value;
275
+ await next();
276
+ } else {
277
+ response.status(403).json({ error: 'Forbidden' });
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ // ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
284
+ export default CSRFMiddleware;