@centralping/ergo 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/http/accepts.js +69 -0
- package/http/authorization.js +65 -0
- package/http/body.js +311 -0
- package/http/cache-control.js +123 -0
- package/http/compress.js +157 -0
- package/http/cookie.js +39 -0
- package/http/cors.js +79 -0
- package/http/csrf.js +76 -0
- package/http/handler.js +74 -0
- package/http/index.js +13 -0
- package/http/json-api-query.js +53 -0
- package/http/logger.js +167 -0
- package/http/main.js +140 -0
- package/http/precondition.js +53 -0
- package/http/prefer.js +36 -0
- package/http/rate-limit.js +66 -0
- package/http/security-headers.js +62 -0
- package/http/send.js +399 -0
- package/http/timeout.js +47 -0
- package/http/url.js +47 -0
- package/http/validate.js +84 -0
- package/lib/accepts.js +49 -0
- package/lib/attach-instance.js +23 -0
- package/lib/authorization.js +187 -0
- package/lib/body/multiparse.js +173 -0
- package/lib/body/multipart/headers.js +69 -0
- package/lib/body/writer.js +73 -0
- package/lib/cookie/cookie.js +192 -0
- package/lib/cookie/index.js +14 -0
- package/lib/cookie/jar.js +106 -0
- package/lib/cookie/parse.js +101 -0
- package/lib/cors.js +191 -0
- package/lib/csrf.js +96 -0
- package/lib/from-connect.js +69 -0
- package/lib/json-api-query/index.js +25 -0
- package/lib/json-api-query/schema.json +105 -0
- package/lib/json-api-query/validate.js +56 -0
- package/lib/link.js +96 -0
- package/lib/prefer.js +52 -0
- package/lib/query.js +113 -0
- package/lib/rate-limit.js +115 -0
- package/lib/sanitize-quoted-string.js +28 -0
- package/lib/security-headers.js +125 -0
- package/lib/validate.js +80 -0
- package/lib/vary.js +40 -0
- package/package.json +158 -0
- package/types/http/accepts.d.ts +8 -0
- package/types/http/authorization.d.ts +8 -0
- package/types/http/body.d.ts +20 -0
- package/types/http/cache-control.d.ts +16 -0
- package/types/http/compress.d.ts +5 -0
- package/types/http/cookie.d.ts +2 -0
- package/types/http/cors.d.ts +9 -0
- package/types/http/csrf.d.ts +9 -0
- package/types/http/handler.d.ts +2 -0
- package/types/http/index.d.ts +1 -0
- package/types/http/json-api-query.d.ts +2 -0
- package/types/http/logger.d.ts +9 -0
- package/types/http/main.d.ts +142 -0
- package/types/http/precondition.d.ts +44 -0
- package/types/http/prefer.d.ts +2 -0
- package/types/http/rate-limit.d.ts +17 -0
- package/types/http/security-headers.d.ts +10 -0
- package/types/http/send.d.ts +8 -0
- package/types/http/timeout.d.ts +5 -0
- package/types/http/url.d.ts +2 -0
- package/types/http/validate.d.ts +6 -0
- package/types/lib/accepts.d.ts +7 -0
- package/types/lib/attach-instance.d.ts +19 -0
- package/types/lib/authorization.d.ts +6 -0
- package/types/lib/body/multiparse.d.ts +9 -0
- package/types/lib/body/multipart/headers.d.ts +2 -0
- package/types/lib/body/writer.d.ts +2 -0
- package/types/lib/cookie/cookie.d.ts +32 -0
- package/types/lib/cookie/index.d.ts +2 -0
- package/types/lib/cookie/jar.d.ts +8 -0
- package/types/lib/cookie/parse.d.ts +19 -0
- package/types/lib/cors.d.ts +9 -0
- package/types/lib/csrf.d.ts +32 -0
- package/types/lib/from-connect.d.ts +47 -0
- package/types/lib/json-api-query/index.d.ts +123 -0
- package/types/lib/json-api-query/validate.d.ts +5 -0
- package/types/lib/link.d.ts +37 -0
- package/types/lib/prefer.d.ts +36 -0
- package/types/lib/query.d.ts +6 -0
- package/types/lib/rate-limit.d.ts +76 -0
- package/types/lib/sanitize-quoted-string.d.ts +19 -0
- package/types/lib/security-headers.d.ts +24 -0
- package/types/lib/validate.d.ts +16 -0
- package/types/lib/vary.d.ts +17 -0
- package/types/utils/attempt.d.ts +2 -0
- package/types/utils/buffers/index.d.ts +2 -0
- package/types/utils/buffers/match.d.ts +10 -0
- package/types/utils/buffers/split.d.ts +10 -0
- package/types/utils/compose-with.d.ts +40 -0
- package/types/utils/compose.d.ts +83 -0
- package/types/utils/flat-array.d.ts +2 -0
- package/types/utils/get.d.ts +5 -0
- package/types/utils/http-errors.d.ts +22 -0
- package/types/utils/iterables/buffer-split.d.ts +2 -0
- package/types/utils/iterables/chain.d.ts +2 -0
- package/types/utils/iterables/exec-all.d.ts +2 -0
- package/types/utils/iterables/filter.d.ts +2 -0
- package/types/utils/iterables/for-each.d.ts +2 -0
- package/types/utils/iterables/from-stream.d.ts +2 -0
- package/types/utils/iterables/index.d.ts +10 -0
- package/types/utils/iterables/map.d.ts +2 -0
- package/types/utils/iterables/range.d.ts +24 -0
- package/types/utils/iterables/reduce.d.ts +2 -0
- package/types/utils/iterables/take.d.ts +2 -0
- package/types/utils/observables/buffer-split.d.ts +2 -0
- package/types/utils/observables/chain.d.ts +2 -0
- package/types/utils/observables/index.d.ts +4 -0
- package/types/utils/observables/map.d.ts +2 -0
- package/types/utils/observables/take.d.ts +2 -0
- package/types/utils/pick.d.ts +2 -0
- package/types/utils/set.d.ts +2 -0
- package/types/utils/streams/index.d.ts +2 -0
- package/types/utils/streams/meter.d.ts +5 -0
- package/types/utils/streams/tee.d.ts +2 -0
- package/types/utils/type.d.ts +2 -0
- package/utils/attempt.js +37 -0
- package/utils/buffers/index.js +13 -0
- package/utils/buffers/match.js +96 -0
- package/utils/buffers/split.js +55 -0
- package/utils/compose-with.js +232 -0
- package/utils/compose.js +165 -0
- package/utils/flat-array.js +24 -0
- package/utils/get.js +39 -0
- package/utils/http-errors.js +113 -0
- package/utils/iterables/buffer-split.js +117 -0
- package/utils/iterables/chain.js +32 -0
- package/utils/iterables/exec-all.js +42 -0
- package/utils/iterables/filter.js +35 -0
- package/utils/iterables/for-each.js +33 -0
- package/utils/iterables/from-stream.js +29 -0
- package/utils/iterables/index.js +21 -0
- package/utils/iterables/map.js +47 -0
- package/utils/iterables/range.js +34 -0
- package/utils/iterables/reduce.js +43 -0
- package/utils/iterables/take.js +36 -0
- package/utils/observables/buffer-split.js +109 -0
- package/utils/observables/chain.js +33 -0
- package/utils/observables/index.js +19 -0
- package/utils/observables/map.js +34 -0
- package/utils/observables/take.js +40 -0
- package/utils/pick.js +41 -0
- package/utils/set.js +38 -0
- package/utils/streams/index.js +11 -0
- package/utils/streams/meter.js +98 -0
- package/utils/streams/tee.js +84 -0
- package/utils/type.js +47 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview HTTP middleware factory for Cache-Control header defaults.
|
|
3
|
+
*
|
|
4
|
+
* Returns header tuples that set the `Cache-Control` response header. The directive
|
|
5
|
+
* string is pre-computed at factory time for zero per-request cost. Accepts either
|
|
6
|
+
* a raw directive string or structured options that are assembled into a directive.
|
|
7
|
+
*
|
|
8
|
+
* @module http/cache-control
|
|
9
|
+
* @version 0.1.0
|
|
10
|
+
* @since 0.1.0
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* import {compose, cacheControl} from 'ergo';
|
|
14
|
+
*
|
|
15
|
+
* // String shorthand
|
|
16
|
+
* const pipeline = compose(
|
|
17
|
+
* [cacheControl({directives: 'public, max-age=3600'}), 'cache'],
|
|
18
|
+
* // ...
|
|
19
|
+
* );
|
|
20
|
+
*
|
|
21
|
+
* // Structured options
|
|
22
|
+
* const pipeline = compose(
|
|
23
|
+
* [cacheControl({private: true, maxAge: 0, mustRevalidate: true}), 'cache'],
|
|
24
|
+
* // ...
|
|
25
|
+
* );
|
|
26
|
+
*
|
|
27
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc9111 RFC 9111 - HTTP Caching}
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Creates a Cache-Control middleware that returns a pre-computed header tuple.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} [options] - Cache-Control configuration
|
|
34
|
+
* @param {string} [options.directives] - Raw directive string (takes precedence over structured options)
|
|
35
|
+
* @param {boolean} [options.public=false] - Add `public` directive
|
|
36
|
+
* @param {boolean} [options.private=false] - Add `private` directive
|
|
37
|
+
* @param {boolean} [options.noCache=false] - Add `no-cache` directive
|
|
38
|
+
* @param {boolean} [options.noStore=false] - Add `no-store` directive
|
|
39
|
+
* @param {boolean} [options.noTransform=false] - Add `no-transform` directive
|
|
40
|
+
* @param {boolean} [options.mustRevalidate=false] - Add `must-revalidate` directive
|
|
41
|
+
* @param {boolean} [options.proxyRevalidate=false] - Add `proxy-revalidate` directive
|
|
42
|
+
* @param {boolean} [options.immutable=false] - Add `immutable` directive
|
|
43
|
+
* @param {number} [options.maxAge] - `max-age` value in seconds
|
|
44
|
+
* @param {number} [options.sMaxAge] - `s-maxage` value in seconds
|
|
45
|
+
* @param {number} [options.staleWhileRevalidate] - `stale-while-revalidate` value in seconds
|
|
46
|
+
* @param {number} [options.staleIfError] - `stale-if-error` value in seconds
|
|
47
|
+
* @returns {function} - Ergo middleware `() => Array<[string, string]>`
|
|
48
|
+
*/
|
|
49
|
+
export default ({
|
|
50
|
+
directives,
|
|
51
|
+
public: isPublic = false,
|
|
52
|
+
private: isPrivate = false,
|
|
53
|
+
noCache = false,
|
|
54
|
+
noStore = false,
|
|
55
|
+
noTransform = false,
|
|
56
|
+
mustRevalidate = false,
|
|
57
|
+
proxyRevalidate = false,
|
|
58
|
+
immutable = false,
|
|
59
|
+
maxAge,
|
|
60
|
+
sMaxAge,
|
|
61
|
+
staleWhileRevalidate,
|
|
62
|
+
staleIfError
|
|
63
|
+
} = {}) => {
|
|
64
|
+
const value =
|
|
65
|
+
directives ??
|
|
66
|
+
buildDirectives({
|
|
67
|
+
isPublic,
|
|
68
|
+
isPrivate,
|
|
69
|
+
noCache,
|
|
70
|
+
noStore,
|
|
71
|
+
noTransform,
|
|
72
|
+
mustRevalidate,
|
|
73
|
+
proxyRevalidate,
|
|
74
|
+
immutable,
|
|
75
|
+
maxAge,
|
|
76
|
+
sMaxAge,
|
|
77
|
+
staleWhileRevalidate,
|
|
78
|
+
staleIfError
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const headerTuples = [['Cache-Control', value]];
|
|
82
|
+
const response = {response: {headers: headerTuples}};
|
|
83
|
+
|
|
84
|
+
return () => response;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Assembles a Cache-Control directive string from structured options.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} opts - Structured directive options
|
|
91
|
+
* @returns {string} - Assembled directive string (e.g. "private, no-cache, max-age=0")
|
|
92
|
+
*/
|
|
93
|
+
function buildDirectives({
|
|
94
|
+
isPublic,
|
|
95
|
+
isPrivate,
|
|
96
|
+
noCache,
|
|
97
|
+
noStore,
|
|
98
|
+
noTransform,
|
|
99
|
+
mustRevalidate,
|
|
100
|
+
proxyRevalidate,
|
|
101
|
+
immutable,
|
|
102
|
+
maxAge,
|
|
103
|
+
sMaxAge,
|
|
104
|
+
staleWhileRevalidate,
|
|
105
|
+
staleIfError
|
|
106
|
+
}) {
|
|
107
|
+
const parts = [];
|
|
108
|
+
|
|
109
|
+
if (isPublic) parts.push('public');
|
|
110
|
+
if (isPrivate) parts.push('private');
|
|
111
|
+
if (noCache) parts.push('no-cache');
|
|
112
|
+
if (noStore) parts.push('no-store');
|
|
113
|
+
if (noTransform) parts.push('no-transform');
|
|
114
|
+
if (mustRevalidate) parts.push('must-revalidate');
|
|
115
|
+
if (proxyRevalidate) parts.push('proxy-revalidate');
|
|
116
|
+
if (immutable) parts.push('immutable');
|
|
117
|
+
if (maxAge != null) parts.push(`max-age=${maxAge}`);
|
|
118
|
+
if (sMaxAge != null) parts.push(`s-maxage=${sMaxAge}`);
|
|
119
|
+
if (staleWhileRevalidate != null) parts.push(`stale-while-revalidate=${staleWhileRevalidate}`);
|
|
120
|
+
if (staleIfError != null) parts.push(`stale-if-error=${staleIfError}`);
|
|
121
|
+
|
|
122
|
+
return parts.join(', ') || 'private, no-cache';
|
|
123
|
+
}
|
package/http/compress.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview HTTP middleware factory for outbound response compression.
|
|
3
|
+
*
|
|
4
|
+
* Negotiates Accept-Encoding and wraps the response stream through
|
|
5
|
+
* the appropriate zlib compressor (gzip, br, deflate).
|
|
6
|
+
*
|
|
7
|
+
* Should be placed early in the pipeline (before send) so it can
|
|
8
|
+
* intercept the response before headers are sent.
|
|
9
|
+
*
|
|
10
|
+
* Compression is skipped for:
|
|
11
|
+
* - Responses with status 204 or 304
|
|
12
|
+
* - Non-compressible content types (binary, images, etc.)
|
|
13
|
+
* - Bodies below the configurable `threshold` byte count (default 1 KiB)
|
|
14
|
+
*
|
|
15
|
+
* @module http/compress
|
|
16
|
+
* @version 0.1.0
|
|
17
|
+
* @since 0.1.0
|
|
18
|
+
* @requires node:zlib
|
|
19
|
+
* @requires negotiator
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* import {compose, compress} from 'ergo';
|
|
23
|
+
*
|
|
24
|
+
* const pipeline = compose(
|
|
25
|
+
* compress({threshold: 1024, encodings: ['br', 'gzip', 'deflate']}),
|
|
26
|
+
* (req, res, acc) => ({response: {body: largePayload}})
|
|
27
|
+
* );
|
|
28
|
+
*
|
|
29
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc9110#section-12.5.3 RFC 9110 Section 12.5.3 - Accept-Encoding}
|
|
30
|
+
*/
|
|
31
|
+
import zlib from 'node:zlib';
|
|
32
|
+
import Negotiator from 'negotiator';
|
|
33
|
+
import appendVary from '../lib/vary.js';
|
|
34
|
+
|
|
35
|
+
const NO_COMPRESS_STATUSES = new Set([204, 304]);
|
|
36
|
+
const COMPRESSIBLE_RE = /^(text\/|application\/(json|javascript|xml|x-www-form-urlencoded))/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates a response compression middleware.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} [options] - Compression configuration
|
|
42
|
+
* @param {number} [options.threshold=1024] - Minimum byte size before compression is applied
|
|
43
|
+
* @param {string[]} [options.encodings=['br','gzip','deflate']] - Supported encodings in priority order
|
|
44
|
+
* @returns {function} - Ergo middleware `(req, res) => void` that wraps `res.write`/`res.end`
|
|
45
|
+
*/
|
|
46
|
+
export default ({threshold = 1024, encodings = ['br', 'gzip', 'deflate']} = {}) => {
|
|
47
|
+
return (req, res) => {
|
|
48
|
+
const acceptEncoding = req.headers['accept-encoding'] ?? '';
|
|
49
|
+
const encoding = negotiate(acceptEncoding, encodings);
|
|
50
|
+
|
|
51
|
+
if (!encoding) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const origEnd = res.end.bind(res);
|
|
56
|
+
const origWrite = res.write.bind(res);
|
|
57
|
+
const origSetHeader = res.setHeader.bind(res);
|
|
58
|
+
|
|
59
|
+
let headersSent = false;
|
|
60
|
+
let compressor;
|
|
61
|
+
|
|
62
|
+
function setupCompressor() {
|
|
63
|
+
if (headersSent) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const contentType = res.getHeader('content-type') ?? '';
|
|
68
|
+
if (!COMPRESSIBLE_RE.test(contentType)) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (NO_COMPRESS_STATUSES.has(res.statusCode)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
compressor = createCompressor(encoding);
|
|
77
|
+
if (!compressor) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
origSetHeader('Content-Encoding', encoding);
|
|
82
|
+
res.removeHeader('Content-Length');
|
|
83
|
+
|
|
84
|
+
appendVary(res, 'Accept-Encoding');
|
|
85
|
+
|
|
86
|
+
headersSent = true;
|
|
87
|
+
|
|
88
|
+
compressor.on('data', chunk => origWrite(chunk));
|
|
89
|
+
compressor.on('end', () => origEnd());
|
|
90
|
+
compressor.on('error', () => origEnd());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
res.write = function compressedWrite(chunk, encodingArg, cb) {
|
|
94
|
+
if (!compressor) {
|
|
95
|
+
setupCompressor();
|
|
96
|
+
}
|
|
97
|
+
if (compressor) {
|
|
98
|
+
return compressor.write(chunk, encodingArg, cb);
|
|
99
|
+
}
|
|
100
|
+
return origWrite(chunk, encodingArg, cb);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
res.end = function compressedEnd(chunk, encodingArg, cb) {
|
|
104
|
+
if (!compressor && chunk) {
|
|
105
|
+
const size = typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length;
|
|
106
|
+
if (size < threshold) {
|
|
107
|
+
return origEnd(chunk, encodingArg, cb);
|
|
108
|
+
}
|
|
109
|
+
setupCompressor();
|
|
110
|
+
}
|
|
111
|
+
if (compressor) {
|
|
112
|
+
if (chunk) {
|
|
113
|
+
compressor.end(chunk, encodingArg);
|
|
114
|
+
} else {
|
|
115
|
+
compressor.end();
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
origEnd(chunk, encodingArg, cb);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Negotiate the best encoding from Accept-Encoding, respecting quality values.
|
|
126
|
+
* Uses the `negotiator` package for RFC 7231 §5.3.4-compliant parsing.
|
|
127
|
+
* Excludes `identity` since we are selecting a compression encoding.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} acceptEncoding - Value of the Accept-Encoding request header
|
|
130
|
+
* @param {string[]} supported - Supported compression encodings in priority order
|
|
131
|
+
* @returns {string|undefined} - The best matched encoding name, or undefined if none matched
|
|
132
|
+
*/
|
|
133
|
+
function negotiate(acceptEncoding, supported) {
|
|
134
|
+
if (!acceptEncoding) return;
|
|
135
|
+
const negotiator = new Negotiator({headers: {'accept-encoding': acceptEncoding}});
|
|
136
|
+
const preferred = negotiator.encodings(supported);
|
|
137
|
+
return preferred.find(e => e !== 'identity');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create a zlib compressor Transform stream for the given encoding.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} encoding - One of 'gzip', 'deflate', 'br'
|
|
144
|
+
* @returns {import('node:zlib').Gzip|import('node:zlib').Deflate|import('node:zlib').BrotliCompress|undefined} - Compressor stream, or undefined for unsupported encodings
|
|
145
|
+
*/
|
|
146
|
+
function createCompressor(encoding) {
|
|
147
|
+
switch (encoding) {
|
|
148
|
+
case 'gzip':
|
|
149
|
+
return zlib.createGzip();
|
|
150
|
+
case 'deflate':
|
|
151
|
+
return zlib.createDeflate();
|
|
152
|
+
case 'br':
|
|
153
|
+
return zlib.createBrotliCompress();
|
|
154
|
+
default:
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
package/http/cookie.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview HTTP middleware factory for cookie parsing.
|
|
3
|
+
*
|
|
4
|
+
* Parses the `Cookie` request header into a cookie jar using the RFC 6265 compliant
|
|
5
|
+
* `lib/cookie` module.
|
|
6
|
+
*
|
|
7
|
+
* The jar uses dual storage: incoming cookies from the header are available as own
|
|
8
|
+
* properties (`acc.cookies.session`), while outgoing cookies created via `set()` are
|
|
9
|
+
* stored in an internal Map and serialized by `toHeader()` into `Set-Cookie` headers.
|
|
10
|
+
*
|
|
11
|
+
* Cookie values are available on the accumulator for CSRF verification, session handling,
|
|
12
|
+
* and other cookie-based workflows.
|
|
13
|
+
*
|
|
14
|
+
* @module http/cookie
|
|
15
|
+
* @version 0.1.0
|
|
16
|
+
* @since 0.1.0
|
|
17
|
+
* @requires ../lib/cookie/index.js
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* import {compose, cookie} from 'ergo';
|
|
21
|
+
*
|
|
22
|
+
* const pipeline = compose(
|
|
23
|
+
* [cookie(), 'cookies'],
|
|
24
|
+
* // acc.cookies.session => 'abc123' (incoming cookie, own property)
|
|
25
|
+
* );
|
|
26
|
+
*
|
|
27
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc6265 RFC 6265 - HTTP State Management Mechanism}
|
|
28
|
+
*/
|
|
29
|
+
import {parse, jar} from '../lib/cookie/index.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates a cookie parsing middleware.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} [options] - Options forwarded to the RFC 6265 cookie parser
|
|
35
|
+
* @returns {function} - Ergo middleware `({headers}) => CookieJar`
|
|
36
|
+
*/
|
|
37
|
+
export default options =>
|
|
38
|
+
({headers: {cookie} = {}} = {}) =>
|
|
39
|
+
jar(parse(cookie, options));
|
package/http/cors.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview HTTP middleware factory for CORS header injection.
|
|
3
|
+
*
|
|
4
|
+
* Validates incoming CORS requests against configured allowed origins, methods, and headers.
|
|
5
|
+
* When the `Origin` request header is present, runs the CORS policy check and either:
|
|
6
|
+
* - Injects the appropriate CORS response headers (allowed), or
|
|
7
|
+
* - Returns `{response: {statusCode: 403}}` (denied)
|
|
8
|
+
*
|
|
9
|
+
* When no `Origin` header is present, the middleware is a no-op (non-CORS requests pass through).
|
|
10
|
+
* Pre-flight `OPTIONS` requests should be handled at the router level using `ergo-router`.
|
|
11
|
+
*
|
|
12
|
+
* @module http/cors
|
|
13
|
+
* @version 0.1.0
|
|
14
|
+
* @since 0.1.0
|
|
15
|
+
* @requires ../lib/cors.js
|
|
16
|
+
* @requires ../utils/http-errors.js
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* import {compose, cors} from 'ergo';
|
|
20
|
+
*
|
|
21
|
+
* const pipeline = compose(
|
|
22
|
+
* [cors({
|
|
23
|
+
* origins: ['https://app.example.com'],
|
|
24
|
+
* allowMethods: ['GET', 'POST'],
|
|
25
|
+
* allowHeaders: ['Authorization', 'Content-Type']
|
|
26
|
+
* }), 'cors'],
|
|
27
|
+
* );
|
|
28
|
+
*
|
|
29
|
+
* @see {@link https://fetch.spec.whatwg.org/#http-cors-protocol Fetch Standard - CORS Protocol}
|
|
30
|
+
*/
|
|
31
|
+
import cors from '../lib/cors.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a CORS validation middleware.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} [options] - CORS policy options forwarded to `lib/cors`
|
|
37
|
+
* @param {string|string[]|RegExp|function} [options.origins='*'] - Allowed origins
|
|
38
|
+
* @param {string[]} [options.allowMethods] - Allowed HTTP methods
|
|
39
|
+
* @param {string|string[]|RegExp|function} [options.allowHeaders='*'] - Allowed request headers
|
|
40
|
+
* @param {string|string[]} [options.exposeHeaders] - Headers to expose to the client
|
|
41
|
+
* @param {boolean} [options.allowCredentials=false] - Whether to allow credentials
|
|
42
|
+
* @param {number} [options.maxAge] - Preflight cache duration in seconds
|
|
43
|
+
* @returns {function} - Ergo middleware that returns `undefined` for non-CORS requests,
|
|
44
|
+
* `{response: {headers}}` when allowed, or `{response: {statusCode: 403}}` when denied
|
|
45
|
+
*/
|
|
46
|
+
export default options => {
|
|
47
|
+
const corsValidator = cors(options);
|
|
48
|
+
|
|
49
|
+
return ({
|
|
50
|
+
headers: {
|
|
51
|
+
origin,
|
|
52
|
+
'access-control-request-method': requestMethod,
|
|
53
|
+
'access-control-request-headers': requestHeadersRaw
|
|
54
|
+
} = {},
|
|
55
|
+
method
|
|
56
|
+
} = {}) => {
|
|
57
|
+
// Only if Origin is defined is it CORS
|
|
58
|
+
if (origin !== undefined) {
|
|
59
|
+
const requestHeaders = requestHeadersRaw
|
|
60
|
+
? requestHeadersRaw.split(',').map(h => h.trim())
|
|
61
|
+
: undefined;
|
|
62
|
+
|
|
63
|
+
const {
|
|
64
|
+
allowed,
|
|
65
|
+
info: {headers}
|
|
66
|
+
} = corsValidator({origin, method, requestMethod, requestHeaders});
|
|
67
|
+
|
|
68
|
+
if (allowed === false) {
|
|
69
|
+
return {response: {statusCode: 403}};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
response: {
|
|
74
|
+
headers: headers.map(({h, v}) => [h, v])
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
};
|
package/http/csrf.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview HTTP middleware factory for CSRF protection.
|
|
3
|
+
*
|
|
4
|
+
* Provides two methods on the returned object:
|
|
5
|
+
* - `issue(req, res, ...acc)` — generates a new CSRF token/UUID pair and sets them as cookies
|
|
6
|
+
* - `verify(req, res, ...acc)` — validates the `X-CSRF-TOKEN` header against the cookie value
|
|
7
|
+
*
|
|
8
|
+
* Tokens are HMAC-signed with a shared `secret` using `lib/csrf`. Verification uses
|
|
9
|
+
* `crypto.timingSafeEqual()` to prevent timing attacks.
|
|
10
|
+
*
|
|
11
|
+
* The CSRF UUID is stored in a separate cookie so the token can be regenerated independently.
|
|
12
|
+
*
|
|
13
|
+
* @module http/csrf
|
|
14
|
+
* @version 0.1.0
|
|
15
|
+
* @since 0.1.0
|
|
16
|
+
* @requires ../lib/csrf.js
|
|
17
|
+
* @requires ../utils/http-errors.js
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* import {compose, cookie, csrf} from 'ergo';
|
|
21
|
+
*
|
|
22
|
+
* const csrfMiddleware = csrf({secret: process.env.CSRF_SECRET});
|
|
23
|
+
*
|
|
24
|
+
* // Issue a token on GET (e.g. page load)
|
|
25
|
+
* const issuePipeline = compose(
|
|
26
|
+
* [cookie(), 'cookies'],
|
|
27
|
+
* [csrfMiddleware.issue, 'csrf'],
|
|
28
|
+
* );
|
|
29
|
+
*
|
|
30
|
+
* // Verify on state-mutating requests
|
|
31
|
+
* const verifyPipeline = compose(
|
|
32
|
+
* [cookie(), 'cookies'],
|
|
33
|
+
* [csrfMiddleware.verify, 'csrf'],
|
|
34
|
+
* );
|
|
35
|
+
*
|
|
36
|
+
* @see {@link https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html OWASP CSRF Prevention Cheat Sheet}
|
|
37
|
+
*/
|
|
38
|
+
import {issue, verify} from '../lib/csrf.js';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates a CSRF token issuance and verification middleware.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} [options] - CSRF configuration
|
|
44
|
+
* @param {string} [options.cookieTokenName='CSRF-TOKEN'] - Cookie name for the CSRF token
|
|
45
|
+
* @param {string} [options.headerTokenName='X-CSRF-TOKEN'] - Request header name for the CSRF token
|
|
46
|
+
* @param {string} [options.cookieUuidName='CSRF-UUID'] - Cookie name for the CSRF UUID
|
|
47
|
+
* @param {string} options.secret - HMAC secret for token signing
|
|
48
|
+
* @param {string} [options.encoding] - Token encoding (default: base64)
|
|
49
|
+
* @param {object} [options.cookieOptions={}] - Cookie directives passed to the cookie factory
|
|
50
|
+
* @returns {object} - Object with `issue(req, res, ...rest)` and `verify(req, res, ...rest)` methods;
|
|
51
|
+
* `verify` returns `{response: {statusCode: 403}}` when CSRF token verification fails
|
|
52
|
+
*/
|
|
53
|
+
export default ({
|
|
54
|
+
cookieTokenName = 'CSRF-TOKEN',
|
|
55
|
+
headerTokenName = 'X-CSRF-TOKEN',
|
|
56
|
+
cookieUuidName = 'CSRF-UUID',
|
|
57
|
+
secret,
|
|
58
|
+
encoding,
|
|
59
|
+
cookieOptions = {}
|
|
60
|
+
} = {}) => ({
|
|
61
|
+
issue(req, res, acc) {
|
|
62
|
+
const {cookies} = acc;
|
|
63
|
+
|
|
64
|
+
const {token, uuid} = issue(secret, undefined, encoding);
|
|
65
|
+
|
|
66
|
+
cookies.set(cookieTokenName, token, {...cookieOptions, httpOnly: false, sameSite: 'Strict'});
|
|
67
|
+
cookies.set(cookieUuidName, uuid, {...cookieOptions, sameSite: 'Strict'});
|
|
68
|
+
},
|
|
69
|
+
verify({headers: {[headerTokenName.toLowerCase()]: headerToken} = {}} = {}, res, acc) {
|
|
70
|
+
const {cookies: {[cookieUuidName]: uuid} = {}} = acc;
|
|
71
|
+
|
|
72
|
+
if (headerToken === undefined || uuid === undefined || !verify(headerToken, {secret, uuid})) {
|
|
73
|
+
return {response: {statusCode: 403, detail: 'CSRF verification failed'}};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
package/http/handler.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview HTTP request handler factory (v2 two-accumulator model).
|
|
3
|
+
*
|
|
4
|
+
* Creates a request handler suitable for `http.createServer()` that:
|
|
5
|
+
* 1. Creates a fresh domain accumulator and response accumulator per request.
|
|
6
|
+
* 2. Runs the composed pipeline with both accumulators.
|
|
7
|
+
* 3. On unexpected errors, sets `responseAcc.statusCode = 500` (unless already set by
|
|
8
|
+
* timeout or an earlier pipeline break), populates `instance` from the request-id
|
|
9
|
+
* header, and emits the error on the response for any listeners.
|
|
10
|
+
* 4. Calls `send()` exactly once with both accumulators.
|
|
11
|
+
*
|
|
12
|
+
* This is the standalone equivalent of ergo-router's `auto-wrap.js` — for users who
|
|
13
|
+
* compose middleware directly without the router.
|
|
14
|
+
*
|
|
15
|
+
* @module http/handler
|
|
16
|
+
* @version 0.2.0
|
|
17
|
+
* @since 0.1.0
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* import {handler, compose, send, logger, authorization, body} from 'ergo';
|
|
21
|
+
*
|
|
22
|
+
* const pipeline = compose(
|
|
23
|
+
* [logger(), 'log'],
|
|
24
|
+
* [authorization({strategies}), 'auth'],
|
|
25
|
+
* [body(), 'body'],
|
|
26
|
+
* (req, res, acc) => ({response: {body: processRequest(acc), statusCode: 200}})
|
|
27
|
+
* );
|
|
28
|
+
*
|
|
29
|
+
* const server = http.createServer(handler(pipeline));
|
|
30
|
+
*/
|
|
31
|
+
import {accumulator} from '../utils/compose.js';
|
|
32
|
+
import {createResponseAcc} from '../utils/compose-with.js';
|
|
33
|
+
import attachInstance from '../lib/attach-instance.js';
|
|
34
|
+
import createSend from './send.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates the outermost request handler.
|
|
38
|
+
*
|
|
39
|
+
* @param {function} pipeline - Composed middleware pipeline
|
|
40
|
+
* @param {object} [sendOptions] - Options forwarded to `send()`
|
|
41
|
+
* @returns {function} - Async handler `(req, res) => void` for `http.createServer()`
|
|
42
|
+
*/
|
|
43
|
+
export default (pipeline, sendOptions = {}) => {
|
|
44
|
+
const send = createSend(sendOptions);
|
|
45
|
+
|
|
46
|
+
return async (req, res) => {
|
|
47
|
+
const domainAcc = accumulator();
|
|
48
|
+
const responseAcc = createResponseAcc();
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await pipeline(req, res, responseAcc, domainAcc);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (responseAcc.statusCode === undefined) {
|
|
54
|
+
responseAcc.statusCode = 500;
|
|
55
|
+
responseAcc.detail = 'Internal Server Error';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
attachInstance(responseAcc, res);
|
|
59
|
+
|
|
60
|
+
if (res.listenerCount('error') > 0) {
|
|
61
|
+
res.emit('error', err);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
send(req, res, responseAcc, domainAcc);
|
|
67
|
+
} catch {
|
|
68
|
+
if (!res.writableEnded) {
|
|
69
|
+
res.statusCode = 500;
|
|
70
|
+
res.end();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
};
|
package/http/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Entry point for the @centralping/ergo package.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the main module for ESM consumers:
|
|
5
|
+
* `import { handler, send } from '@centralping/ergo'`
|
|
6
|
+
*
|
|
7
|
+
* @module @centralping/ergo
|
|
8
|
+
* @version 0.1.0-beta.1
|
|
9
|
+
* @since 0.1.0
|
|
10
|
+
* @requires ./main.js
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export * from './main.js';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview HTTP middleware factory for JSON:API query parameter validation.
|
|
3
|
+
*
|
|
4
|
+
* Validates the parsed query accumulator against the JSON:API query parameter schema,
|
|
5
|
+
* enforcing correct use of `filter`, `sort`, `fields`, `include`, and `page` parameters.
|
|
6
|
+
*
|
|
7
|
+
* Delegates validation to the vendored `lib/json-api-query` module (AJV 8, JSON Schema 2020-12).
|
|
8
|
+
* On validation failure, returns `{response: {statusCode: 400, detail: ...}}` (RFC 9457 body
|
|
9
|
+
* formatted by `send()` after the pipeline).
|
|
10
|
+
*
|
|
11
|
+
* Must be placed after `url()` in the pipeline so that `acc.url` is populated.
|
|
12
|
+
*
|
|
13
|
+
* @module http/json-api-query
|
|
14
|
+
* @version 0.1.0
|
|
15
|
+
* @since 0.1.0
|
|
16
|
+
* @requires ../lib/json-api-query/index.js
|
|
17
|
+
* @requires ../utils/http-errors.js
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* import {compose, url, jsonApiQuery} from 'ergo';
|
|
21
|
+
*
|
|
22
|
+
* const pipeline = compose(
|
|
23
|
+
* [url(), 'url'],
|
|
24
|
+
* [jsonApiQuery(), 'jsonApiQuery'],
|
|
25
|
+
* // Returns 400 response if query params are not JSON:API compliant
|
|
26
|
+
* );
|
|
27
|
+
*/
|
|
28
|
+
import {validate} from '../lib/json-api-query/index.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Creates a JSON:API query validation middleware.
|
|
32
|
+
*
|
|
33
|
+
* @param {...*} options - Options forwarded to the underlying JSON:API validator
|
|
34
|
+
* @returns {function} - Ergo middleware `(req, res, acc) => void`; returns
|
|
35
|
+
* `{response: {statusCode: 400}}` when JSON:API query parameters fail validation
|
|
36
|
+
*/
|
|
37
|
+
export default (...options) => {
|
|
38
|
+
const validator = validate(...options);
|
|
39
|
+
|
|
40
|
+
return (req, res, acc) => {
|
|
41
|
+
const query = acc.url?.query;
|
|
42
|
+
const valid = validator(query);
|
|
43
|
+
|
|
44
|
+
if (!valid) {
|
|
45
|
+
return {
|
|
46
|
+
response: {
|
|
47
|
+
statusCode: 400,
|
|
48
|
+
detail: 'Invalid JSON API query'
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
};
|