@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.
- package/README.md +229 -2
- package/index.mjs +23 -0
- package/package.json +71 -4
- package/source/application/Kernel/index.mjs +1137 -0
- package/source/application/Server/index.mjs +1201 -0
- package/source/application/configurations/Http2CleartextServerConfiguration/index.mjs +168 -0
- package/source/application/configurations/Http2SecureServerConfiguration/index.mjs +172 -0
- package/source/application/configurations/HttpServerConfiguration/index.mjs +169 -0
- package/source/application/configurations/HttpsServerConfiguration/index.mjs +172 -0
- package/source/application/configurations/ServerConfiguration/Options/CORSOptions/CORSOptionsValue.mjs +158 -0
- package/source/application/configurations/ServerConfiguration/Options/CORSOptions/index.mjs +87 -0
- package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/CSRFOptionsValue.mjs +178 -0
- package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/index.mjs +80 -0
- package/source/application/configurations/ServerConfiguration/Options/CompressOptions/CompressOptionsValue.mjs +126 -0
- package/source/application/configurations/ServerConfiguration/Options/CompressOptions/index.mjs +81 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/CookieParserOptionsValue.mjs +107 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/index.mjs +82 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/CookieSessionOptionsValue.mjs +182 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/index.mjs +86 -0
- package/source/application/configurations/ServerConfiguration/Options/ListenOptions/ListenOptionsValue.mjs +95 -0
- package/source/application/configurations/ServerConfiguration/Options/ListenOptions/index.mjs +47 -0
- package/source/application/configurations/ServerConfiguration/Options/MiddlewareOption.mjs +102 -0
- package/source/application/configurations/ServerConfiguration/Options/Options.mjs +105 -0
- package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/RateLimitOptionsValue.mjs +164 -0
- package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/index.mjs +84 -0
- package/source/application/configurations/ServerConfiguration/Options/RequestOptions/RequestOptionsValue.mjs +108 -0
- package/source/application/configurations/ServerConfiguration/Options/RequestOptions/index.mjs +47 -0
- package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/ResponseOptionsValue.mjs +101 -0
- package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/index.mjs +47 -0
- package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/SecurityHeadersOptionsValue.mjs +272 -0
- package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/index.mjs +85 -0
- package/source/application/configurations/ServerConfiguration/Options/ServerOption.mjs +50 -0
- package/source/application/configurations/ServerConfiguration/Options/StaticOptions/StaticOptionsValue.mjs +164 -0
- package/source/application/configurations/ServerConfiguration/Options/StaticOptions/index.mjs +89 -0
- package/source/application/configurations/ServerConfiguration/index.mjs +781 -0
- package/source/application/resolvers/TransportResolver/index.mjs +119 -0
- package/source/application/use-cases/CloseServer/index.mjs +85 -0
- package/source/application/use-cases/ListenServer/index.mjs +98 -0
- package/source/core/EventSystem/index.mjs +110 -0
- package/source/core/Exchange/ExchangeContext.mjs +291 -0
- package/source/core/Exchange/Telemetry.mjs +193 -0
- package/source/core/Exchange/index.mjs +604 -0
- package/source/core/ExchangeLayerBuilder/index.mjs +133 -0
- package/source/core/IPCMain/IPCMain.mjs +507 -0
- package/source/core/IPCMain/IPCProtocol.mjs +441 -0
- package/source/core/IPCMain/IPCServerChannel.mjs +331 -0
- package/source/core/IPCMain/WebSocketAdapter.mjs +189 -0
- package/source/core/Request/BodyParser/BodyParser.mjs +197 -0
- package/source/core/Request/BodyParser/JSONParser.mjs +70 -0
- package/source/core/Request/BodyParser/MultipartParser.mjs +612 -0
- package/source/core/Request/BodyParser/URLEncodedParser.mjs +71 -0
- package/source/core/Request/RequestHeaders.mjs +177 -0
- package/source/core/Request/index.mjs +805 -0
- package/source/core/Response/ResponseHeaders.mjs +402 -0
- package/source/core/Response/index.mjs +1370 -0
- package/source/core/middlewares/CORSMiddleware/index.mjs +289 -0
- package/source/core/middlewares/CSRFMiddleware/index.mjs +284 -0
- package/source/core/middlewares/CompressMiddleware/index.mjs +460 -0
- package/source/core/middlewares/CookieParserMiddleware/index.mjs +194 -0
- package/source/core/middlewares/CookieSessionMiddleware/index.mjs +460 -0
- package/source/core/middlewares/RateLimitMiddleware/index.mjs +245 -0
- package/source/core/middlewares/SecurityHeadersMiddleware/index.mjs +151 -0
- package/source/core/middlewares/StaticMiddleware/index.mjs +628 -0
- package/source/core/routing/ExchangeLayer/ExchangeMatcher.mjs +127 -0
- package/source/core/routing/ExchangeLayer/index.mjs +254 -0
- package/source/core/routing/Pipeline/index.mjs +157 -0
- package/source/core/routing/Router/Route.mjs +184 -0
- package/source/core/routing/Router/index.mjs +523 -0
- package/source/core/routing/RouterRegister/OptionsMiddleware.mjs +147 -0
- package/source/core/routing/RouterRegister/index.mjs +150 -0
- package/source/core/routing/RouterSystem/index.mjs +225 -0
- package/source/core/value-objects/Address/index.mjs +281 -0
- package/source/core/value-objects/Endpoint/index.mjs +293 -0
- package/source/index.mjs +62 -0
- package/source/infrastructure/adapters/Http2Adapter/index.mjs +144 -0
- package/source/infrastructure/adapters/Http2CleartextAdapter/index.mjs +147 -0
- package/source/infrastructure/adapters/HttpAdapter/index.mjs +124 -0
- package/source/infrastructure/adapters/HttpsAdapter/index.mjs +124 -0
- package/source/infrastructure/adapters/TransportAdapter/createWebSocketOpenRequest.mjs +843 -0
- package/source/infrastructure/adapters/TransportAdapter/index.mjs +555 -0
- package/source/shared/Identifier/index.mjs +671 -0
- package/source/shared/constants/symbols/brands/index.mjs +45 -0
- package/source/shared/domain/UUIDV7/index.mjs +346 -0
- package/source/shared/errors/BaseError/index.mjs +157 -0
- package/source/shared/errors/IPCError/index.mjs +97 -0
- package/source/shared/errors/MiddlewareError/index.mjs +106 -0
- package/source/shared/errors/PathnamePatternError/index.mjs +103 -0
- package/source/shared/errors/RequestError/index.mjs +116 -0
- package/source/shared/errors/ResponseError/index.mjs +140 -0
- package/source/shared/errors/RouterError/index.mjs +90 -0
- package/source/shared/errors/ServerConfigurationError/index.mjs +551 -0
- package/source/shared/errors/ServerError/index.mjs +127 -0
- package/source/shared/errors/WebSocketError/index.mjs +58 -0
- package/source/shared/outcome/ErrorSchema.mjs +361 -0
- package/source/shared/outcome/Result/index.mjs +239 -0
- package/source/shared/outcome/Validation/index.mjs +208 -0
- package/source/shared/validations/Middleware/index.mjs +160 -0
- package/source/shared/validations/PathnamePattern/index.mjs +256 -0
- package/source/shared/validations/Server/NotFoundHandler/index.mjs +94 -0
- package/source/shared/validations/Server/Router/index.mjs +68 -0
- package/source/shared/validations/Server/ServerConfiguration/index.mjs +68 -0
- package/source/shared/validations/Server/SystemErrorHandler/index.mjs +94 -0
- package/source/shared/validations/ServerConfiguration/CORSOptions/index.mjs +310 -0
- package/source/shared/validations/ServerConfiguration/CSRFOptions/index.mjs +299 -0
- package/source/shared/validations/ServerConfiguration/CompressOptions/index.mjs +204 -0
- package/source/shared/validations/ServerConfiguration/CookieParserOptions/index.mjs +102 -0
- package/source/shared/validations/ServerConfiguration/CookieSessionOptions/index.mjs +332 -0
- package/source/shared/validations/ServerConfiguration/ListenOptions/index.mjs +178 -0
- package/source/shared/validations/ServerConfiguration/RateLimitOptions/index.mjs +229 -0
- package/source/shared/validations/ServerConfiguration/RequestOptions/index.mjs +132 -0
- package/source/shared/validations/ServerConfiguration/ResponseOptions/index.mjs +122 -0
- package/source/shared/validations/ServerConfiguration/SecurityHeadersOptions/index.mjs +137 -0
- package/source/shared/validations/ServerConfiguration/StaticOptions/index.mjs +248 -0
- package/types/index.d.ts +719 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Defines the Cookie Session middleware.
|
|
4
|
+
*
|
|
5
|
+
* @description Stores session data entirely in a signed cookie. Supports key
|
|
6
|
+
* rotation, configurable cookie attributes, and automatic persistence of
|
|
7
|
+
* session mutations after the rest of the pipeline completes.
|
|
8
|
+
*
|
|
9
|
+
* Session state is exposed on `exchange.state.session` as a mutable object
|
|
10
|
+
* without a prototype. When the request carries no valid session cookie, an
|
|
11
|
+
* empty object is provided. Assigning `null` or `undefined` to
|
|
12
|
+
* `exchange.state.session` clears the cookie.
|
|
13
|
+
*
|
|
14
|
+
* Configuration is expected to be validated and normalized before the
|
|
15
|
+
* middleware is created.
|
|
16
|
+
*
|
|
17
|
+
* @module CookieSessionMiddleware
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ━━ IMPORT MODULES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
21
|
+
// » IMPORT NODE MODULES
|
|
22
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
// ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
25
|
+
/**
|
|
26
|
+
* Represents an HTTP exchange.
|
|
27
|
+
*
|
|
28
|
+
* @typedef {import('#Exchange').default} Exchange
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Represents an outgoing HTTP response.
|
|
33
|
+
*
|
|
34
|
+
* @typedef {import('#Response').default} Response
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Represents a function that delegates execution to the next middleware
|
|
39
|
+
* layer.
|
|
40
|
+
*
|
|
41
|
+
* @typedef {() => void | Promise<void>} NextFunction
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Represents session data stored in the cookie.
|
|
46
|
+
*
|
|
47
|
+
* @typedef {Record<string, unknown>} SessionData
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Represents normalized Cookie Session middleware configuration.
|
|
52
|
+
*
|
|
53
|
+
* @typedef {object} CookieSessionOptionsData
|
|
54
|
+
* @property {string} name - Name of the session cookie.
|
|
55
|
+
* @property {string[]} keys - Signing keys. The first key is used to sign; all keys are tried when verifying.
|
|
56
|
+
* @property {number|undefined} maxAge - Cookie max-age in milliseconds. When `undefined`, the cookie is a session cookie.
|
|
57
|
+
* @property {string} path - Cookie path attribute.
|
|
58
|
+
* @property {string|undefined} domain - Cookie domain attribute.
|
|
59
|
+
* @property {boolean} secure - Whether the cookie is marked Secure.
|
|
60
|
+
* @property {boolean} httpOnly - Whether the cookie is marked HttpOnly.
|
|
61
|
+
* @property {'Strict'|'Lax'|'None'|undefined} sameSite - Cookie SameSite attribute.
|
|
62
|
+
* @property {boolean} overwrite - Whether an existing Set-Cookie header with the same name should be overwritten.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
// ━━ FUNCTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
66
|
+
/**
|
|
67
|
+
* Normalizes the Cookie request header into a single string.
|
|
68
|
+
*
|
|
69
|
+
* When the header is repeated, the values are joined with `"; "` so that a
|
|
70
|
+
* single parse pass can process every cookie pair.
|
|
71
|
+
*
|
|
72
|
+
* @private
|
|
73
|
+
* @param {string|string[]|undefined} value - Cookie header value to normalize.
|
|
74
|
+
* @returns {string|undefined} The normalized header string, or `undefined` when no value exists.
|
|
75
|
+
*/
|
|
76
|
+
function normalizeCookieHeader(value) {
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
return value.length > 0 ? value.join('; ') : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Encodes a buffer as base64url without padding.
|
|
86
|
+
*
|
|
87
|
+
* @private
|
|
88
|
+
* @param {Buffer} buffer - Buffer to encode.
|
|
89
|
+
* @returns {string} The base64url string.
|
|
90
|
+
*/
|
|
91
|
+
function toBase64Url(buffer) {
|
|
92
|
+
return buffer.toString('base64url');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Decodes a base64url string into a buffer.
|
|
97
|
+
*
|
|
98
|
+
* @private
|
|
99
|
+
* @param {string} value - Base64url string.
|
|
100
|
+
* @returns {Buffer} The decoded buffer.
|
|
101
|
+
*/
|
|
102
|
+
function fromBase64Url(value) {
|
|
103
|
+
return Buffer.from(value, 'base64url');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Signs a payload with HMAC-SHA256 using the given key.
|
|
108
|
+
*
|
|
109
|
+
* @private
|
|
110
|
+
* @param {string} payload - Base64url payload to sign.
|
|
111
|
+
* @param {string} key - Signing key.
|
|
112
|
+
* @returns {string} The base64url signature.
|
|
113
|
+
*/
|
|
114
|
+
function sign(payload, key) {
|
|
115
|
+
return toBase64Url(createHmac('sha256', key).update(payload).digest());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Compares two signatures in constant time.
|
|
120
|
+
*
|
|
121
|
+
* @private
|
|
122
|
+
* @param {string} left - First signature.
|
|
123
|
+
* @param {string} right - Second signature.
|
|
124
|
+
* @returns {boolean} Whether the signatures are equal.
|
|
125
|
+
*/
|
|
126
|
+
function signaturesEqual(left, right) {
|
|
127
|
+
const leftBuffer = Buffer.from(left);
|
|
128
|
+
const rightBuffer = Buffer.from(right);
|
|
129
|
+
|
|
130
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Verifies a signed cookie value against the configured keys.
|
|
139
|
+
*
|
|
140
|
+
* @private
|
|
141
|
+
* @param {string} value - Raw cookie value in the form `payload.signature`.
|
|
142
|
+
* @param {string[]} keys - Signing keys to try.
|
|
143
|
+
* @returns {string|null} The verified base64url payload, or `null` when verification fails.
|
|
144
|
+
*/
|
|
145
|
+
function verify(value, keys) {
|
|
146
|
+
const separatorIndex = value.lastIndexOf('.');
|
|
147
|
+
|
|
148
|
+
if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const payload = value.slice(0, separatorIndex);
|
|
153
|
+
const signature = value.slice(separatorIndex + 1);
|
|
154
|
+
|
|
155
|
+
for (const key of keys) {
|
|
156
|
+
const expected = sign(payload, key);
|
|
157
|
+
|
|
158
|
+
if (signaturesEqual(signature, expected)) {
|
|
159
|
+
return payload;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Serializes session data into a signed cookie value.
|
|
168
|
+
*
|
|
169
|
+
* @private
|
|
170
|
+
* @param {SessionData} session - Session object to serialize.
|
|
171
|
+
* @param {string} key - Signing key.
|
|
172
|
+
* @returns {string} The signed cookie value.
|
|
173
|
+
*/
|
|
174
|
+
function encodeSession(session, key) {
|
|
175
|
+
const json = JSON.stringify(session);
|
|
176
|
+
const payload = toBase64Url(Buffer.from(json, 'utf8'));
|
|
177
|
+
const signature = sign(payload, key);
|
|
178
|
+
|
|
179
|
+
return `${payload}.${signature}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Deserializes a verified payload into session data.
|
|
184
|
+
*
|
|
185
|
+
* @private
|
|
186
|
+
* @param {string} payload - Verified base64url payload.
|
|
187
|
+
* @returns {SessionData|null} The session object, or `null` when the payload is invalid.
|
|
188
|
+
*/
|
|
189
|
+
function decodeSession(payload) {
|
|
190
|
+
const buffer = fromBase64Url(payload);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const parsed = JSON.parse(buffer.toString('utf8'));
|
|
194
|
+
|
|
195
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
196
|
+
return /** @type {SessionData} */ (parsed);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return null;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Extracts the last occurrence of the session cookie from the Cookie header.
|
|
207
|
+
*
|
|
208
|
+
* @private
|
|
209
|
+
* @param {string|undefined} header - Raw Cookie header.
|
|
210
|
+
* @param {string} name - Session cookie name.
|
|
211
|
+
* @returns {string|undefined} The cookie value, or `undefined` when absent.
|
|
212
|
+
*/
|
|
213
|
+
function readCookie(header, name) {
|
|
214
|
+
if (typeof header !== 'string' || header.length === 0) {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const matchedSegment = header.split(';').findLast(segment => {
|
|
219
|
+
const separatorIndex = segment.indexOf('=');
|
|
220
|
+
|
|
221
|
+
return separatorIndex !== -1 && segment.slice(0, separatorIndex).trim() === name;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (matchedSegment === undefined) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const separatorIndex = matchedSegment.indexOf('=');
|
|
229
|
+
const rawValue = matchedSegment.slice(separatorIndex + 1).trim();
|
|
230
|
+
|
|
231
|
+
return rawValue.length >= 2 && rawValue.startsWith('"') && rawValue.endsWith('"')
|
|
232
|
+
? rawValue.slice(1, -1)
|
|
233
|
+
: rawValue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Builds a Set-Cookie header value for the session cookie.
|
|
238
|
+
*
|
|
239
|
+
* @private
|
|
240
|
+
* @param {string} name - Cookie name.
|
|
241
|
+
* @param {string} value - Cookie value. An empty string clears the cookie.
|
|
242
|
+
* @param {CookieSessionOptionsData} options - Cookie attributes.
|
|
243
|
+
* @param {boolean} clear - Whether the cookie should be expired.
|
|
244
|
+
* @returns {string} The Set-Cookie header value.
|
|
245
|
+
*/
|
|
246
|
+
function buildSetCookie(name, value, options, clear) {
|
|
247
|
+
const parts = [`${name}=${value}`];
|
|
248
|
+
|
|
249
|
+
if (clear) {
|
|
250
|
+
parts.push('Max-Age=0');
|
|
251
|
+
} else if (typeof options.maxAge === 'number') {
|
|
252
|
+
parts.push(`Max-Age=${Math.floor(options.maxAge / 1000)}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
parts.push(`Path=${options.path}`);
|
|
256
|
+
|
|
257
|
+
if (typeof options.domain === 'string') {
|
|
258
|
+
parts.push(`Domain=${options.domain}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (options.secure) {
|
|
262
|
+
parts.push('Secure');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (options.httpOnly) {
|
|
266
|
+
parts.push('HttpOnly');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (typeof options.sameSite === 'string') {
|
|
270
|
+
parts.push(`SameSite=${options.sameSite}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return parts.join('; ');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Writes or replaces the session Set-Cookie header on the response.
|
|
278
|
+
*
|
|
279
|
+
* When `overwrite` is `false` and a cookie with the same name already exists,
|
|
280
|
+
* the existing value is preserved and no header is written.
|
|
281
|
+
*
|
|
282
|
+
* @private
|
|
283
|
+
* @param {Response} response - Response to modify.
|
|
284
|
+
* @param {string} setCookie - Set-Cookie header value.
|
|
285
|
+
* @param {string} name - Session cookie name.
|
|
286
|
+
* @param {boolean} overwrite - Whether to replace an existing cookie with the same name.
|
|
287
|
+
*/
|
|
288
|
+
function writeSetCookie(response, setCookie, name, overwrite) {
|
|
289
|
+
const existing = response.getHeader('Set-Cookie');
|
|
290
|
+
|
|
291
|
+
if (existing === undefined) {
|
|
292
|
+
response.setHeader('Set-Cookie', setCookie);
|
|
293
|
+
} else if (overwrite) {
|
|
294
|
+
const prefix = `${name}=`;
|
|
295
|
+
const values = Array.isArray(existing)
|
|
296
|
+
? existing.map(value => String(value))
|
|
297
|
+
: [String(existing)];
|
|
298
|
+
const filtered = values.filter(value => !value.startsWith(prefix));
|
|
299
|
+
|
|
300
|
+
filtered.push(setCookie);
|
|
301
|
+
response.setHeader('Set-Cookie', filtered);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Produces a stable JSON snapshot of the session for change detection.
|
|
307
|
+
*
|
|
308
|
+
* @private
|
|
309
|
+
* @param {SessionData|null|undefined} session - Session to snapshot.
|
|
310
|
+
* @returns {string} Serialized snapshot.
|
|
311
|
+
*/
|
|
312
|
+
function snapshot(session) {
|
|
313
|
+
if (session === null || session === undefined) {
|
|
314
|
+
return '';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return JSON.stringify(session);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
321
|
+
/**
|
|
322
|
+
* Stores session data in a signed cookie.
|
|
323
|
+
*
|
|
324
|
+
* @classdesc Loads a signed session cookie into `exchange.state.session`,
|
|
325
|
+
* delegates to the rest of the pipeline, and persists any mutations back to
|
|
326
|
+
* the cookie. Invalid or missing cookies result in an empty object.
|
|
327
|
+
*
|
|
328
|
+
* Session state is exposed on `exchange.state.session` as a mutable object
|
|
329
|
+
* without a prototype. Assigning `null` or `undefined` to
|
|
330
|
+
* `exchange.state.session` clears the cookie.
|
|
331
|
+
*
|
|
332
|
+
* Signing uses HMAC-SHA256. The first configured key is used to sign; all
|
|
333
|
+
* keys are accepted when verifying so that key rotation is possible.
|
|
334
|
+
*
|
|
335
|
+
* @example
|
|
336
|
+
* ```js
|
|
337
|
+
* const middleware = new CookieSessionMiddleware({
|
|
338
|
+
* name: 'sid',
|
|
339
|
+
* keys: ['current-secret', 'previous-secret'],
|
|
340
|
+
* maxAge: 86_400_000,
|
|
341
|
+
* path: '/',
|
|
342
|
+
* domain: undefined,
|
|
343
|
+
* secure: true,
|
|
344
|
+
* httpOnly: true,
|
|
345
|
+
* sameSite: 'Lax',
|
|
346
|
+
* overwrite: true,
|
|
347
|
+
* });
|
|
348
|
+
* ```
|
|
349
|
+
*/
|
|
350
|
+
class CookieSessionMiddleware {
|
|
351
|
+
/**
|
|
352
|
+
* Normalized Cookie Session middleware options.
|
|
353
|
+
*
|
|
354
|
+
* @type {Readonly<CookieSessionOptionsData>}
|
|
355
|
+
*/
|
|
356
|
+
#options;
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Creates a Cookie Session middleware.
|
|
360
|
+
*
|
|
361
|
+
* @param {CookieSessionOptionsData} options - Validated and normalized cookie session options.
|
|
362
|
+
*
|
|
363
|
+
* @example
|
|
364
|
+
* ```js
|
|
365
|
+
* const middleware = new CookieSessionMiddleware({
|
|
366
|
+
* name: 'sid',
|
|
367
|
+
* keys: ['current-secret', 'previous-secret'],
|
|
368
|
+
* maxAge: 86_400_000,
|
|
369
|
+
* path: '/',
|
|
370
|
+
* domain: undefined,
|
|
371
|
+
* secure: true,
|
|
372
|
+
* httpOnly: true,
|
|
373
|
+
* sameSite: 'Lax',
|
|
374
|
+
* overwrite: true,
|
|
375
|
+
* });
|
|
376
|
+
* ```
|
|
377
|
+
*/
|
|
378
|
+
constructor(options) {
|
|
379
|
+
this.#options = options;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Processes an HTTP exchange and manages the session cookie.
|
|
384
|
+
*
|
|
385
|
+
* @param {Exchange} exchange - Exchange to process.
|
|
386
|
+
* @param {NextFunction} next - Function that delegates to the next middleware.
|
|
387
|
+
* @returns {Promise<void>} Resolves when request processing has completed.
|
|
388
|
+
*
|
|
389
|
+
* @example
|
|
390
|
+
* ```js
|
|
391
|
+
* await middleware.handle(exchange, next);
|
|
392
|
+
* // exchange.state.session → { userId: '42' }
|
|
393
|
+
* ```
|
|
394
|
+
*/
|
|
395
|
+
async handle(exchange, next) {
|
|
396
|
+
const { request, response, state } = exchange;
|
|
397
|
+
const { name, keys, overwrite } = this.#options;
|
|
398
|
+
|
|
399
|
+
const header = normalizeCookieHeader(request.getHeader('cookie'));
|
|
400
|
+
const rawCookie = readCookie(header, name);
|
|
401
|
+
const { session, snapshot: initialSnapshot } = this.#loadSession(rawCookie, keys);
|
|
402
|
+
|
|
403
|
+
state.session = session;
|
|
404
|
+
|
|
405
|
+
await next();
|
|
406
|
+
|
|
407
|
+
const current = state.session;
|
|
408
|
+
const currentSnapshot = snapshot(
|
|
409
|
+
current !== null &&
|
|
410
|
+
current !== undefined &&
|
|
411
|
+
typeof current === 'object' &&
|
|
412
|
+
!Array.isArray(current)
|
|
413
|
+
? /** @type {SessionData} */ (current)
|
|
414
|
+
: null,
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
if (currentSnapshot !== initialSnapshot) {
|
|
418
|
+
if (current === null || current === undefined) {
|
|
419
|
+
writeSetCookie(response, buildSetCookie(name, '', this.#options, true), name, overwrite);
|
|
420
|
+
} else if (typeof current === 'object' && !Array.isArray(current)) {
|
|
421
|
+
const encoded = encodeSession(/** @type {SessionData} */ (current), keys[0]);
|
|
422
|
+
|
|
423
|
+
writeSetCookie(
|
|
424
|
+
response,
|
|
425
|
+
buildSetCookie(name, encoded, this.#options, false),
|
|
426
|
+
name,
|
|
427
|
+
overwrite,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Loads and verifies the session from the raw cookie value.
|
|
435
|
+
*
|
|
436
|
+
* @param {string|undefined} rawCookie - Raw cookie value.
|
|
437
|
+
* @param {string[]} keys - Signing keys.
|
|
438
|
+
* @returns {{ session: SessionData, snapshot: string }} Loaded session and its initial snapshot.
|
|
439
|
+
*/
|
|
440
|
+
#loadSession(rawCookie, keys) {
|
|
441
|
+
if (typeof rawCookie === 'string' && rawCookie.length > 0) {
|
|
442
|
+
const payload = verify(rawCookie, keys);
|
|
443
|
+
|
|
444
|
+
if (payload !== null) {
|
|
445
|
+
const decoded = decodeSession(payload);
|
|
446
|
+
|
|
447
|
+
if (decoded !== null) {
|
|
448
|
+
return { session: decoded, snapshot: snapshot(decoded) };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const empty = /** @type {SessionData} */ (Object.create(null));
|
|
454
|
+
|
|
455
|
+
return { session: empty, snapshot: snapshot(empty) };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
460
|
+
export default CookieSessionMiddleware;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Defines the Rate Limit middleware.
|
|
4
|
+
*
|
|
5
|
+
* @description Limits the number of requests a client can make within a
|
|
6
|
+
* fixed time window. Uses an in-memory store keyed by client IP address.
|
|
7
|
+
* Supports both the modern RateLimit-* headers and the legacy X-RateLimit-*
|
|
8
|
+
* headers.
|
|
9
|
+
*
|
|
10
|
+
* This middleware maintains rate-limit state in process memory. It is
|
|
11
|
+
* therefore local to the current server process and does not provide
|
|
12
|
+
* distributed rate limiting across multiple processes or instances.
|
|
13
|
+
*
|
|
14
|
+
* Configuration is expected to be validated and normalized before the
|
|
15
|
+
* middleware is created.
|
|
16
|
+
*
|
|
17
|
+
* @module RateLimitMiddleware
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
21
|
+
/**
|
|
22
|
+
* Represents an HTTP exchange.
|
|
23
|
+
*
|
|
24
|
+
* @typedef {import('#Exchange').default} Exchange
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Represents an outgoing HTTP response.
|
|
29
|
+
*
|
|
30
|
+
* @typedef {import('#Response').default} Response
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Represents a function that delegates execution to the next middleware
|
|
35
|
+
* layer.
|
|
36
|
+
*
|
|
37
|
+
* @typedef {() => void | Promise<void>} NextFunction
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Represents a single client's rate-limit record.
|
|
42
|
+
*
|
|
43
|
+
* @typedef {object} RateLimitRecord
|
|
44
|
+
* @property {number} count - Number of requests observed in the current window.
|
|
45
|
+
* @property {number} resetTime - Unix timestamp (ms) when the current window expires.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Represents normalized Rate Limit middleware configuration.
|
|
50
|
+
*
|
|
51
|
+
* @typedef {object} RateLimitOptionsData
|
|
52
|
+
* @property {number} windowMs - Duration of the rate-limit window in milliseconds.
|
|
53
|
+
* @property {number} max - Maximum number of requests allowed per window per client.
|
|
54
|
+
* @property {number} statusCode - HTTP status code returned when the limit is exceeded.
|
|
55
|
+
* @property {Record<string, unknown>} message - JSON body sent when the limit is exceeded.
|
|
56
|
+
* @property {boolean} standardHeaders - Whether to send the modern `RateLimit-*` headers.
|
|
57
|
+
* @property {boolean} legacyHeaders - Whether to send the legacy `X-RateLimit-*` headers.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
// ━━ CONSTANTS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
61
|
+
|
|
62
|
+
// ━━ FUNCTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
63
|
+
/**
|
|
64
|
+
* Writes the configured rate-limit headers onto the response.
|
|
65
|
+
*
|
|
66
|
+
* `RateLimit-Reset` and `X-RateLimit-Reset` contain the number of seconds
|
|
67
|
+
* remaining until the current window resets, not an absolute Unix timestamp.
|
|
68
|
+
*
|
|
69
|
+
* @private
|
|
70
|
+
* @param {Response} response - Response to which the rate-limit headers are written.
|
|
71
|
+
* @param {boolean} standardHeaders - Whether to send modern RateLimit headers.
|
|
72
|
+
* @param {boolean} legacyHeaders - Whether to send legacy X-RateLimit headers.
|
|
73
|
+
* @param {number} limit - Configured maximum requests.
|
|
74
|
+
* @param {number} remaining - Remaining requests in the current window.
|
|
75
|
+
* @param {number} resetTime - Unix timestamp (ms) when the window resets.
|
|
76
|
+
* @param {number} now - Current time in milliseconds.
|
|
77
|
+
*/
|
|
78
|
+
function setRateLimitHeaders(
|
|
79
|
+
response,
|
|
80
|
+
standardHeaders,
|
|
81
|
+
legacyHeaders,
|
|
82
|
+
limit,
|
|
83
|
+
remaining,
|
|
84
|
+
resetTime,
|
|
85
|
+
now,
|
|
86
|
+
) {
|
|
87
|
+
const resetDeltaSeconds = Math.max(0, Math.ceil((resetTime - now) / 1000));
|
|
88
|
+
const normalizedRemaining = Math.max(0, remaining);
|
|
89
|
+
|
|
90
|
+
if (standardHeaders) {
|
|
91
|
+
response.setHeader('RateLimit-Limit', String(limit));
|
|
92
|
+
response.setHeader('RateLimit-Remaining', String(normalizedRemaining));
|
|
93
|
+
response.setHeader('RateLimit-Reset', String(resetDeltaSeconds));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (legacyHeaders) {
|
|
97
|
+
response.setHeader('X-RateLimit-Limit', String(limit));
|
|
98
|
+
response.setHeader('X-RateLimit-Remaining', String(normalizedRemaining));
|
|
99
|
+
response.setHeader('X-RateLimit-Reset', String(resetDeltaSeconds));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Removes expired entries from the rate-limit store.
|
|
105
|
+
*
|
|
106
|
+
* @private
|
|
107
|
+
* @param {Map<string, RateLimitRecord>} store - In-memory store.
|
|
108
|
+
* @param {number} now - Current time in milliseconds.
|
|
109
|
+
*/
|
|
110
|
+
function purgeExpired(store, now) {
|
|
111
|
+
for (const [key, record] of store) {
|
|
112
|
+
if (now >= record.resetTime) {
|
|
113
|
+
store.delete(key);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
119
|
+
/**
|
|
120
|
+
* Limits the number of requests a client can make within a fixed time
|
|
121
|
+
* window.
|
|
122
|
+
*
|
|
123
|
+
* @classdesc Maintains an in-memory store of request counts keyed by client
|
|
124
|
+
* IP. When a client exceeds the configured maximum, the middleware responds
|
|
125
|
+
* with the configured status code and message and does not call `next()`.
|
|
126
|
+
*
|
|
127
|
+
* Rate-limit state is local to the current server process. When the request
|
|
128
|
+
* IP is unavailable, rate limiting is skipped and control is delegated to
|
|
129
|
+
* `next()`.
|
|
130
|
+
*/
|
|
131
|
+
class RateLimitMiddleware {
|
|
132
|
+
/**
|
|
133
|
+
* Normalized Rate Limit middleware options.
|
|
134
|
+
*
|
|
135
|
+
* @type {Readonly<RateLimitOptionsData>}
|
|
136
|
+
*/
|
|
137
|
+
#options;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* In-memory store of rate-limit records keyed by client IP.
|
|
141
|
+
*
|
|
142
|
+
* @type {Map<string, RateLimitRecord>}
|
|
143
|
+
*/
|
|
144
|
+
#store = new Map();
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Timestamp of the last store cleanup.
|
|
148
|
+
*
|
|
149
|
+
* @type {number}
|
|
150
|
+
*/
|
|
151
|
+
#lastCleanup = Date.now();
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Creates a Rate Limit middleware.
|
|
155
|
+
*
|
|
156
|
+
* @param {RateLimitOptionsData} options - Validated and normalized rate limit options.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```js
|
|
160
|
+
* const middleware = new RateLimitMiddleware({
|
|
161
|
+
* windowMs: 60_000,
|
|
162
|
+
* max: 100,
|
|
163
|
+
* statusCode: 429,
|
|
164
|
+
* message: { error: 'Too Many Requests' },
|
|
165
|
+
* standardHeaders: true,
|
|
166
|
+
* legacyHeaders: false,
|
|
167
|
+
* });
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
constructor(options) {
|
|
171
|
+
this.#options = options;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Processes an HTTP exchange and enforces the configured rate limit.
|
|
176
|
+
*
|
|
177
|
+
* When the client is under the limit the middleware sets the appropriate
|
|
178
|
+
* rate-limit headers and calls `next()`. When the limit is exceeded it
|
|
179
|
+
* writes the error response, including `Retry-After`, and ends the
|
|
180
|
+
* pipeline.
|
|
181
|
+
*
|
|
182
|
+
* If the request has no resolvable IP address, rate limiting is skipped
|
|
183
|
+
* and control is passed to the next middleware.
|
|
184
|
+
*
|
|
185
|
+
* @param {Exchange} exchange - Exchange to process.
|
|
186
|
+
* @param {NextFunction} next - Function that delegates to the next middleware.
|
|
187
|
+
* @returns {Promise<void>} Resolves when request processing has completed.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```js
|
|
191
|
+
* await middleware.handle(exchange, next);
|
|
192
|
+
* ```
|
|
193
|
+
*/
|
|
194
|
+
async handle(exchange, next) {
|
|
195
|
+
const { request, response } = exchange;
|
|
196
|
+
const { windowMs, max, statusCode, message, standardHeaders, legacyHeaders } = this.#options;
|
|
197
|
+
|
|
198
|
+
const ip = request.ip;
|
|
199
|
+
|
|
200
|
+
if (typeof ip !== 'string') {
|
|
201
|
+
await next();
|
|
202
|
+
} else {
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
const existing = this.#store.get(ip);
|
|
205
|
+
|
|
206
|
+
const record =
|
|
207
|
+
existing === undefined || now >= existing.resetTime
|
|
208
|
+
? { count: 0, resetTime: now + windowMs }
|
|
209
|
+
: existing;
|
|
210
|
+
|
|
211
|
+
record.count += 1;
|
|
212
|
+
this.#store.set(ip, record);
|
|
213
|
+
|
|
214
|
+
// Time-based cleanup prevents unbounded growth on low-traffic servers.
|
|
215
|
+
if (now - this.#lastCleanup >= windowMs) {
|
|
216
|
+
purgeExpired(this.#store, now);
|
|
217
|
+
this.#lastCleanup = now;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const remaining = max - record.count;
|
|
221
|
+
|
|
222
|
+
setRateLimitHeaders(
|
|
223
|
+
response,
|
|
224
|
+
standardHeaders,
|
|
225
|
+
legacyHeaders,
|
|
226
|
+
max,
|
|
227
|
+
remaining,
|
|
228
|
+
record.resetTime,
|
|
229
|
+
now,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
if (record.count > max) {
|
|
233
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((record.resetTime - now) / 1000));
|
|
234
|
+
|
|
235
|
+
response.setHeader('Retry-After', String(retryAfterSeconds));
|
|
236
|
+
response.status(statusCode).json(message);
|
|
237
|
+
} else {
|
|
238
|
+
await next();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
245
|
+
export default RateLimitMiddleware;
|