@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.
Files changed (155) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +139 -0
  4. package/http/accepts.js +69 -0
  5. package/http/authorization.js +65 -0
  6. package/http/body.js +311 -0
  7. package/http/cache-control.js +123 -0
  8. package/http/compress.js +157 -0
  9. package/http/cookie.js +39 -0
  10. package/http/cors.js +79 -0
  11. package/http/csrf.js +76 -0
  12. package/http/handler.js +74 -0
  13. package/http/index.js +13 -0
  14. package/http/json-api-query.js +53 -0
  15. package/http/logger.js +167 -0
  16. package/http/main.js +140 -0
  17. package/http/precondition.js +53 -0
  18. package/http/prefer.js +36 -0
  19. package/http/rate-limit.js +66 -0
  20. package/http/security-headers.js +62 -0
  21. package/http/send.js +399 -0
  22. package/http/timeout.js +47 -0
  23. package/http/url.js +47 -0
  24. package/http/validate.js +84 -0
  25. package/lib/accepts.js +49 -0
  26. package/lib/attach-instance.js +23 -0
  27. package/lib/authorization.js +187 -0
  28. package/lib/body/multiparse.js +173 -0
  29. package/lib/body/multipart/headers.js +69 -0
  30. package/lib/body/writer.js +73 -0
  31. package/lib/cookie/cookie.js +192 -0
  32. package/lib/cookie/index.js +14 -0
  33. package/lib/cookie/jar.js +106 -0
  34. package/lib/cookie/parse.js +101 -0
  35. package/lib/cors.js +191 -0
  36. package/lib/csrf.js +96 -0
  37. package/lib/from-connect.js +69 -0
  38. package/lib/json-api-query/index.js +25 -0
  39. package/lib/json-api-query/schema.json +105 -0
  40. package/lib/json-api-query/validate.js +56 -0
  41. package/lib/link.js +96 -0
  42. package/lib/prefer.js +52 -0
  43. package/lib/query.js +113 -0
  44. package/lib/rate-limit.js +115 -0
  45. package/lib/sanitize-quoted-string.js +28 -0
  46. package/lib/security-headers.js +125 -0
  47. package/lib/validate.js +80 -0
  48. package/lib/vary.js +40 -0
  49. package/package.json +158 -0
  50. package/types/http/accepts.d.ts +8 -0
  51. package/types/http/authorization.d.ts +8 -0
  52. package/types/http/body.d.ts +20 -0
  53. package/types/http/cache-control.d.ts +16 -0
  54. package/types/http/compress.d.ts +5 -0
  55. package/types/http/cookie.d.ts +2 -0
  56. package/types/http/cors.d.ts +9 -0
  57. package/types/http/csrf.d.ts +9 -0
  58. package/types/http/handler.d.ts +2 -0
  59. package/types/http/index.d.ts +1 -0
  60. package/types/http/json-api-query.d.ts +2 -0
  61. package/types/http/logger.d.ts +9 -0
  62. package/types/http/main.d.ts +142 -0
  63. package/types/http/precondition.d.ts +44 -0
  64. package/types/http/prefer.d.ts +2 -0
  65. package/types/http/rate-limit.d.ts +17 -0
  66. package/types/http/security-headers.d.ts +10 -0
  67. package/types/http/send.d.ts +8 -0
  68. package/types/http/timeout.d.ts +5 -0
  69. package/types/http/url.d.ts +2 -0
  70. package/types/http/validate.d.ts +6 -0
  71. package/types/lib/accepts.d.ts +7 -0
  72. package/types/lib/attach-instance.d.ts +19 -0
  73. package/types/lib/authorization.d.ts +6 -0
  74. package/types/lib/body/multiparse.d.ts +9 -0
  75. package/types/lib/body/multipart/headers.d.ts +2 -0
  76. package/types/lib/body/writer.d.ts +2 -0
  77. package/types/lib/cookie/cookie.d.ts +32 -0
  78. package/types/lib/cookie/index.d.ts +2 -0
  79. package/types/lib/cookie/jar.d.ts +8 -0
  80. package/types/lib/cookie/parse.d.ts +19 -0
  81. package/types/lib/cors.d.ts +9 -0
  82. package/types/lib/csrf.d.ts +32 -0
  83. package/types/lib/from-connect.d.ts +47 -0
  84. package/types/lib/json-api-query/index.d.ts +123 -0
  85. package/types/lib/json-api-query/validate.d.ts +5 -0
  86. package/types/lib/link.d.ts +37 -0
  87. package/types/lib/prefer.d.ts +36 -0
  88. package/types/lib/query.d.ts +6 -0
  89. package/types/lib/rate-limit.d.ts +76 -0
  90. package/types/lib/sanitize-quoted-string.d.ts +19 -0
  91. package/types/lib/security-headers.d.ts +24 -0
  92. package/types/lib/validate.d.ts +16 -0
  93. package/types/lib/vary.d.ts +17 -0
  94. package/types/utils/attempt.d.ts +2 -0
  95. package/types/utils/buffers/index.d.ts +2 -0
  96. package/types/utils/buffers/match.d.ts +10 -0
  97. package/types/utils/buffers/split.d.ts +10 -0
  98. package/types/utils/compose-with.d.ts +40 -0
  99. package/types/utils/compose.d.ts +83 -0
  100. package/types/utils/flat-array.d.ts +2 -0
  101. package/types/utils/get.d.ts +5 -0
  102. package/types/utils/http-errors.d.ts +22 -0
  103. package/types/utils/iterables/buffer-split.d.ts +2 -0
  104. package/types/utils/iterables/chain.d.ts +2 -0
  105. package/types/utils/iterables/exec-all.d.ts +2 -0
  106. package/types/utils/iterables/filter.d.ts +2 -0
  107. package/types/utils/iterables/for-each.d.ts +2 -0
  108. package/types/utils/iterables/from-stream.d.ts +2 -0
  109. package/types/utils/iterables/index.d.ts +10 -0
  110. package/types/utils/iterables/map.d.ts +2 -0
  111. package/types/utils/iterables/range.d.ts +24 -0
  112. package/types/utils/iterables/reduce.d.ts +2 -0
  113. package/types/utils/iterables/take.d.ts +2 -0
  114. package/types/utils/observables/buffer-split.d.ts +2 -0
  115. package/types/utils/observables/chain.d.ts +2 -0
  116. package/types/utils/observables/index.d.ts +4 -0
  117. package/types/utils/observables/map.d.ts +2 -0
  118. package/types/utils/observables/take.d.ts +2 -0
  119. package/types/utils/pick.d.ts +2 -0
  120. package/types/utils/set.d.ts +2 -0
  121. package/types/utils/streams/index.d.ts +2 -0
  122. package/types/utils/streams/meter.d.ts +5 -0
  123. package/types/utils/streams/tee.d.ts +2 -0
  124. package/types/utils/type.d.ts +2 -0
  125. package/utils/attempt.js +37 -0
  126. package/utils/buffers/index.js +13 -0
  127. package/utils/buffers/match.js +96 -0
  128. package/utils/buffers/split.js +55 -0
  129. package/utils/compose-with.js +232 -0
  130. package/utils/compose.js +165 -0
  131. package/utils/flat-array.js +24 -0
  132. package/utils/get.js +39 -0
  133. package/utils/http-errors.js +113 -0
  134. package/utils/iterables/buffer-split.js +117 -0
  135. package/utils/iterables/chain.js +32 -0
  136. package/utils/iterables/exec-all.js +42 -0
  137. package/utils/iterables/filter.js +35 -0
  138. package/utils/iterables/for-each.js +33 -0
  139. package/utils/iterables/from-stream.js +29 -0
  140. package/utils/iterables/index.js +21 -0
  141. package/utils/iterables/map.js +47 -0
  142. package/utils/iterables/range.js +34 -0
  143. package/utils/iterables/reduce.js +43 -0
  144. package/utils/iterables/take.js +36 -0
  145. package/utils/observables/buffer-split.js +109 -0
  146. package/utils/observables/chain.js +33 -0
  147. package/utils/observables/index.js +19 -0
  148. package/utils/observables/map.js +34 -0
  149. package/utils/observables/take.js +40 -0
  150. package/utils/pick.js +41 -0
  151. package/utils/set.js +38 -0
  152. package/utils/streams/index.js +11 -0
  153. package/utils/streams/meter.js +98 -0
  154. package/utils/streams/tee.js +84 -0
  155. 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
+ }
@@ -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
+ });
@@ -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
+ };