@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,14 @@
1
+ /**
2
+ * @fileoverview Cookie module barrel export.
3
+ *
4
+ * Provides `parse` for parsing the `Cookie` header string into a key-value map,
5
+ * and `jar` for creating a cookie jar instance that manages cookie state for a request.
6
+ *
7
+ * @module lib/cookie
8
+ * @version 0.1.0
9
+ * @since 0.1.0
10
+ * @requires ./parse.js
11
+ * @requires ./jar.js
12
+ */
13
+ export {default as parse} from './parse.js';
14
+ export {default as jar} from './jar.js';
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @fileoverview Cookie jar factory for managing per-request cookie state.
3
+ *
4
+ * Creates a cookie jar with dual-storage semantics for per-request cookie management.
5
+ *
6
+ * **Incoming cookies** (from the `Cookie` header) are parsed as raw `name: value` strings
7
+ * and stored as own properties via `Object.assign()`. Per RFC 6265 §5.4, the browser sends
8
+ * only `name=value` pairs — no directives. Access them directly: `jar.session`.
9
+ *
10
+ * **Outgoing cookies** (created via `set()`) are full cookie objects with directives,
11
+ * stored in an internal `Map`. Only these are serialized by `toHeader()` into `Set-Cookie`
12
+ * headers. A server should not echo all received cookies back — only cookies it explicitly
13
+ * creates or modifies should produce `Set-Cookie` headers.
14
+ *
15
+ * Methods:
16
+ * - `get(name)` — returns an outgoing cookie object from the Map, or an iterator over all
17
+ * - `set(name, value, opts)` — creates and stores an outgoing cookie (delegates to `lib/cookie/cookie`)
18
+ * - `clear(name?)` — deletes one outgoing cookie by name, or clears all
19
+ * - `toHeader()` — serializes outgoing cookies to `Set-Cookie` header strings
20
+ *
21
+ * @module lib/cookie/jar
22
+ * @version 0.1.0
23
+ * @since 0.1.0
24
+ * @requires ./cookie.js
25
+ * @requires ../../utils/iterables/index.js
26
+ *
27
+ * @example
28
+ * import jar from 'ergo/lib/cookie/jar';
29
+ * import parse from 'ergo/lib/cookie/parse';
30
+ *
31
+ * const cookies = jar(parse('session=abc123; lang=en'));
32
+ * cookies.session; // => 'abc123' (incoming, own property)
33
+ * cookies.lang; // => 'en' (incoming, own property)
34
+ * cookies.get('session'); // => undefined (not in the outgoing Map)
35
+ * cookies.set('theme', 'dark', {maxAge: 86400});
36
+ * cookies.get('theme'); // => cookie object (outgoing, in the Map)
37
+ * cookies.toHeader(); // => ['theme=dark; Max-Age=86400'] (outgoing only)
38
+ */
39
+ export default jar;
40
+
41
+ import cookie from './cookie.js';
42
+ import {chain, map} from '../../utils/iterables/index.js';
43
+
44
+ const clay = Object.create(
45
+ {},
46
+ {
47
+ set: {
48
+ value(n, ...args) {
49
+ return this.jar.set(n, cookie(n, ...args));
50
+ }
51
+ },
52
+ get: {
53
+ value(n) {
54
+ if (n) {
55
+ return this.jar.get(n);
56
+ }
57
+
58
+ return this.jar.values();
59
+ }
60
+ },
61
+ clear: {
62
+ value(n) {
63
+ if (n) {
64
+ return this.jar.delete(n);
65
+ } else {
66
+ return this.jar.clear();
67
+ }
68
+ }
69
+ },
70
+ toHeader: {
71
+ value() {
72
+ return [
73
+ ...chain(
74
+ this.get(),
75
+ map(c => c.toHeader())
76
+ )
77
+ ];
78
+ }
79
+ },
80
+ size: {
81
+ get() {
82
+ return this.jar.size;
83
+ }
84
+ },
85
+ isJar: {
86
+ value: true
87
+ }
88
+ }
89
+ );
90
+
91
+ /**
92
+ * Creates a cookie jar pre-populated from a parsed cookie object.
93
+ *
94
+ * @param {object} [cookies={}] - Initial cookie values (from `parse()`)
95
+ * @returns {object} - Cookie jar with `get`, `set`, `clear`, `toHeader`, `size` members
96
+ */
97
+ function jar(cookies = {}) {
98
+ return Object.assign(
99
+ Object.create(clay, {
100
+ jar: {
101
+ value: new Map()
102
+ }
103
+ }),
104
+ cookies
105
+ );
106
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @fileoverview RFC 6265 and RFC 2109 cookie string parser using generators.
3
+ *
4
+ * Parses raw `Cookie` header strings into key-value maps using compiled regular expressions
5
+ * derived from the RFC token/value grammars. Supports both RFC 6265 (strict) and RFC 2109
6
+ * (loose/legacy) parsing modes.
7
+ *
8
+ * Multi-value cookies (same name appearing multiple times) are optionally aggregated into
9
+ * arrays via the `collection` option. A `max` option enforces a limit on total cookie count
10
+ * to protect against header-stuffing attacks.
11
+ *
12
+ * @module lib/cookie/parse
13
+ * @version 0.1.0
14
+ * @since 0.1.0
15
+ * @requires ../../utils/iterables/index.js
16
+ *
17
+ * @see {@link https://www.rfc-editor.org/rfc/rfc6265 RFC 6265 - HTTP State Management Mechanism}
18
+ * @see {@link https://www.rfc-editor.org/rfc/rfc2109 RFC 2109 - HTTP State Management Mechanism (obsoleted)}
19
+ *
20
+ * @example
21
+ * import parse from 'ergo/lib/cookie/parse';
22
+ *
23
+ * parse('session=abc123; user=alice');
24
+ * // => {session: 'abc123', user: 'alice'}
25
+ *
26
+ * parse('tag=a; tag=b', {collection: true});
27
+ * // => {tag: ['a', 'b']}
28
+ */
29
+ import {chain, reduce, map, forEach} from '../../utils/iterables/index.js';
30
+
31
+ export default parse;
32
+
33
+ /*
34
+ https://www.rfc-editor.org/rfc/rfc2109
35
+ https://www.rfc-editor.org/rfc/rfc6265
36
+ */
37
+ const tokenRFC2109 = /[\x21-\x3a\x3c\x3e-\x7e]+/;
38
+ const valueRFC2109 = /[\x20-\x3a\x3c-\x7e]*/;
39
+
40
+ const tokenRFC6265 = /[\x21\x23-\x27\x2a\x2b\x2d\x2e\x30-\x39\x41-\x5a\x5c\x5e-\x7a\x7c\x7e]+/;
41
+ const valueRFC6265 = /[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*/;
42
+ const dblQuote = /\x22/;
43
+ const pairSeparator = /\x3d/;
44
+ const listSeparator = /(?:\x3b\x20+|$)/;
45
+ const matchRFC6265 = new RegExp(
46
+ `(${tokenRFC6265.source})${pairSeparator.source}(?:(${dblQuote.source}?)(${valueRFC6265.source})\\2)?${listSeparator.source}`,
47
+ 'g'
48
+ );
49
+ const matchRFC2109 = new RegExp(
50
+ `(${tokenRFC2109.source})\x20*(?:${pairSeparator.source}\x20*(${dblQuote.source}?)(${valueRFC2109.source})\\2\x20*)?${listSeparator.source}`,
51
+ 'g'
52
+ );
53
+
54
+ /**
55
+ * Parses a `Cookie` header string into a key-value map.
56
+ *
57
+ * @param {string} [cookie=''] - Raw value of the `Cookie` HTTP header
58
+ * @param {object} [options] - Parser configuration
59
+ * @param {function} [options.decoder=v=>v] - Transform applied to each `[name, value]` pair
60
+ * @param {boolean} [options.loose=false] - Use RFC 2109 (lenient) instead of RFC 6265 (strict) parsing
61
+ * @param {boolean} [options.collection=true] - Aggregate duplicate names into arrays
62
+ * @param {number} [options.max=50] - Maximum number of cookies; throws on excess
63
+ * @returns {object} - Plain object of cookie name → value (or string[])
64
+ * @throws {Error} If `max` is exceeded
65
+ */
66
+ function parse(cookie = '', {decoder = v => v, loose = false, collection = true, max = 50} = {}) {
67
+ const process = chain(
68
+ matchGenerator(loose ? matchRFC2109 : matchRFC6265, cookie),
69
+ forEach((v, i) => {
70
+ if (i >= max) {
71
+ throw new Error(`Too many cookies (max ${max})`);
72
+ }
73
+ }),
74
+ map(decoder),
75
+ reduce((cookies, [name, value]) => {
76
+ cookies[name] =
77
+ collection && Object.hasOwn(cookies, name) ? [cookies[name], value].flat() : value;
78
+
79
+ return cookies;
80
+ }, Object.create(null))
81
+ );
82
+
83
+ return process;
84
+ }
85
+
86
+ /**
87
+ * Generator that yields `[name, value]` pairs for all cookie matches in a string.
88
+ *
89
+ * @param {RegExp} re - Compiled RFC cookie regex (must have `g` flag)
90
+ * @param {string} str - The raw cookie header string to scan
91
+ * @yields {[string, string|undefined]} Name-value pair for each matched cookie
92
+ */
93
+ function* matchGenerator(re, str) {
94
+ let match;
95
+
96
+ while ((match = re.exec(str)) !== null) {
97
+ const [, name, , value] = match;
98
+
99
+ yield [name, value];
100
+ }
101
+ }
package/lib/cors.js ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * @fileoverview CORS validation logic implementing the W3C CORS specification.
3
+ *
4
+ * Provides a factory that compiles CORS policy options into efficient validators,
5
+ * then processes each request returning `{allowed, info}`. Used by `http/cors.js`
6
+ * as the pure-logic backing implementation.
7
+ *
8
+ * Supports:
9
+ * - Wildcard origins (`*`) with optional credentials support
10
+ * - String, RegExp, function, and array-based origin validation
11
+ * - Pre-flight (`OPTIONS`) and simple CORS requests
12
+ * - Header filtering (wildcard, RegExp, function, string list)
13
+ * - `Access-Control-Max-Age`, `Access-Control-Expose-Headers`, `Vary`
14
+ *
15
+ * @see {@link https://www.w3.org/TR/2014/REC-cors-20140116/}
16
+ *
17
+ * @module lib/cors
18
+ * @version 0.1.0
19
+ * @since 0.1.0
20
+ * @requires ../utils/type.js
21
+ * @requires ../utils/flat-array.js
22
+ *
23
+ * @example
24
+ * import cors from 'ergo/lib/cors';
25
+ *
26
+ * const corsValidator = cors({
27
+ * origins: ['https://app.example.com'],
28
+ * allowMethods: ['GET', 'POST'],
29
+ * allowCredentials: true
30
+ * });
31
+ *
32
+ * const result = corsValidator({
33
+ * origin: 'https://app.example.com',
34
+ * method: 'GET'
35
+ * });
36
+ * // result.allowed => true
37
+ * // result.info.headers => [{h: 'Access-Control-Allow-Origin', v: '...'}, ...]
38
+ *
39
+ * @see {@link https://fetch.spec.whatwg.org/#http-cors-protocol Fetch Standard - CORS Protocol}
40
+ */
41
+ import type from '../utils/type.js';
42
+ import flatArray from '../utils/flat-array.js';
43
+
44
+ /*
45
+ https://www.w3.org/TR/2014/REC-cors-20140116/
46
+ */
47
+
48
+ const defaultMethods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'TRACE'];
49
+
50
+ /**
51
+ * Creates a CORS policy validator from the given options.
52
+ *
53
+ * @param {object} [options] - CORS policy configuration
54
+ * @param {string|RegExp|function|Array} [options.origins='*'] - Allowed origins
55
+ * @param {string[]} [options.allowMethods] - Allowed HTTP methods
56
+ * @param {boolean} [options.allowCredentials=false] - Whether credentials are allowed
57
+ * @param {string|RegExp|function|Array} [options.allowHeaders='*'] - Allowed request headers
58
+ * @param {string|string[]} [options.exposeHeaders] - Headers to expose to the client
59
+ * @param {number} [options.maxAge] - Pre-flight cache duration in seconds
60
+ * @returns {function} - `({origin, method, requestMethod, requestHeaders}) => {allowed, info}`
61
+ */
62
+ export default ({
63
+ origins = '*', // '*', 'foo.com', /foo/, ['foo', /bar/]
64
+ allowMethods = defaultMethods,
65
+ allowCredentials = false,
66
+ allowHeaders = '*',
67
+ exposeHeaders,
68
+ maxAge
69
+ } = {}) => {
70
+ const methods = flatArray(allowMethods);
71
+ const headerValidator = configHeaderValidator(allowHeaders);
72
+ const originValidator = configOriginValidator(origins, allowCredentials);
73
+ const baseHeaders = flatArray(
74
+ allowCredentials ? {h: 'Access-Control-Allow-Credentials', v: true} : [],
75
+ exposeHeaders !== undefined
76
+ ? {h: 'Access-Control-Expose-Headers', v: flatArray(exposeHeaders)}
77
+ : [],
78
+ type(origins) !== 'String' || origins === '*' ? {h: 'Vary', v: 'Origin'} : []
79
+ );
80
+ const preflightHeaders = flatArray(
81
+ {h: 'Access-Control-Allow-Methods', v: methods},
82
+ {h: 'Vary', v: 'Access-Control-Request-Methods'},
83
+ maxAge !== undefined ? {h: 'Access-Control-Max-Age', v: maxAge} : [],
84
+ type(allowHeaders) !== 'String' || allowHeaders === '*'
85
+ ? {h: 'Vary', v: 'Access-Control-Request-Headers'}
86
+ : []
87
+ );
88
+
89
+ return ({origin, method, requestMethod, requestHeaders} = {}) => {
90
+ const allowedOrigin = originValidator(origin);
91
+ const isPreflight = method === 'OPTIONS' && requestMethod !== undefined;
92
+
93
+ if (allowedOrigin === false) {
94
+ return {
95
+ allowed: false,
96
+ info: {
97
+ type: 'invalid_origin',
98
+ origin
99
+ }
100
+ };
101
+ }
102
+
103
+ if (!methods.includes(isPreflight ? requestMethod : method)) {
104
+ return {
105
+ allowed: false,
106
+ info: {
107
+ type: 'invalid_method',
108
+ origin,
109
+ method: isPreflight ? requestMethod : method
110
+ }
111
+ };
112
+ }
113
+
114
+ return {
115
+ allowed: true,
116
+ info: {
117
+ origin,
118
+ headers: flatArray(
119
+ {h: 'Access-Control-Allow-Origin', v: allowedOrigin},
120
+ baseHeaders,
121
+ isPreflight
122
+ ? flatArray(preflightHeaders, getAllowHeaders(method, headerValidator, requestHeaders))
123
+ : []
124
+ )
125
+ }
126
+ };
127
+ };
128
+ };
129
+
130
+ /**
131
+ * Builds an origin validator function from a policy value.
132
+ *
133
+ * @param {string|RegExp|function|Array<string|RegExp>} valid - Origin policy
134
+ * @param {boolean} [allowCreds] - If true, wildcard reflects the request origin
135
+ * @returns {function} - `(origin: string) => string|false` — returns the allowed origin or false
136
+ */
137
+ function configOriginValidator(valid, allowCreds) {
138
+ if (valid === '*') {
139
+ return allowCreds ? origin => origin : () => valid;
140
+ } else if (type(valid) === 'Function') {
141
+ return origin => valid(origin) && origin;
142
+ } else {
143
+ const validators = flatArray(valid).map(v =>
144
+ type(v) === 'RegExp' ? origin => v.test(origin) : origin => v === origin
145
+ );
146
+
147
+ return origin => validators.some(v => v(origin)) && origin;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Builds a request header validator function from an allowed-headers policy.
153
+ *
154
+ * @param {string|RegExp|function|Array<string|RegExp>} allowed - Header policy
155
+ * @returns {function} - `(headers: string[]) => string[]` — returns filtered allowed headers
156
+ */
157
+ function configHeaderValidator(allowed) {
158
+ if (allowed === '*') {
159
+ // Current living standard has proposed allowing wildcards for non credential requests.
160
+ // For now, echo back the request headers.
161
+ return headers => headers;
162
+ } else if (type(allowed) === 'Function') {
163
+ return headers => allowed(headers);
164
+ } else {
165
+ const validators = flatArray(allowed).map(v =>
166
+ type(v) === 'RegExp' ? header => v.test(header) : header => v === header
167
+ );
168
+
169
+ return (headers = []) => headers.filter(h => validators.some(v => v(h)));
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Returns the `Access-Control-Allow-Headers` header entry for pre-flight requests.
175
+ *
176
+ * @param {string} method - HTTP method (must be 'OPTIONS' for pre-flight)
177
+ * @param {function} validator - Header validator returned by `configHeaderValidator`
178
+ * @param {string[]|undefined} headers - Requested headers from `Access-Control-Request-Headers`
179
+ * @returns {{h: string, v: string[]}|Array} - Header entry or empty array if N/A
180
+ */
181
+ function getAllowHeaders(method, validator, headers) {
182
+ if (method === 'OPTIONS' && headers !== undefined) {
183
+ const allowedHeaders = validator(headers);
184
+
185
+ if (allowedHeaders !== undefined && allowedHeaders.length) {
186
+ return {h: 'Access-Control-Allow-Headers', v: allowedHeaders};
187
+ }
188
+ }
189
+
190
+ return [];
191
+ }
package/lib/csrf.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @fileoverview CSRF token issuance and verification using HMAC-SHA256.
3
+ *
4
+ * Issues UUID-backed CSRF tokens signed with a shared secret. Verification uses
5
+ * `crypto.timingSafeEqual()` to prevent timing side-channel attacks.
6
+ *
7
+ * Token format: `HMAC-SHA256(secret, uuid)` encoded as `base64` (default) or any
8
+ * Node.js digest encoding. The UUID is stored separately (in a cookie) so the token
9
+ * can be reissued without changing the UUID.
10
+ *
11
+ * @module lib/csrf
12
+ * @version 0.1.0
13
+ * @since 0.1.0
14
+ * @requires node:crypto
15
+ * @requires ../utils/type.js
16
+ *
17
+ * @example
18
+ * import {issue, verify} from 'ergo/lib/csrf';
19
+ *
20
+ * const {token, uuid} = issue('my-secret');
21
+ * // token => 'base64-encoded-hmac'
22
+ * // uuid => 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
23
+ *
24
+ * verify(token, {secret: 'my-secret', uuid}); // => true
25
+ * verify('tampered', {secret: 'my-secret', uuid}); // => false
26
+ *
27
+ * @see {@link https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html OWASP CSRF Prevention Cheat Sheet}
28
+ */
29
+ import {createHmac, timingSafeEqual, randomUUID} from 'node:crypto';
30
+
31
+ import type from '../utils/type.js';
32
+
33
+ export {issue};
34
+ export {verify};
35
+
36
+ /**
37
+ * Issues a new CSRF token/UUID pair signed with HMAC-SHA256.
38
+ *
39
+ * @param {string} secret - Shared HMAC secret; throws `TypeError` if missing
40
+ * @param {string} [uuid=randomUUID()] - Pre-existing UUID to bind the token to
41
+ * @param {string} [encoding='base64'] - Node.js digest encoding for the token
42
+ * @returns {{token: string, uuid: string}} - The signed token and its associated UUID
43
+ * @throws {TypeError} If `secret` is not provided
44
+ */
45
+ function issue(
46
+ secret = new TypeError('Missing required parameter: "secret"'),
47
+ uuid = randomUUID(),
48
+ encoding = 'base64'
49
+ ) {
50
+ if (type(secret) === 'TypeError') {
51
+ throw secret;
52
+ }
53
+
54
+ return {
55
+ token: createHmac('SHA256', secret).update(uuid).digest(encoding),
56
+ uuid
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Verifies a CSRF token against the expected value for the given secret and UUID.
62
+ *
63
+ * Uses `crypto.timingSafeEqual()` to prevent timing attacks. Returns `false` (not throws)
64
+ * for any mismatch, length difference, or type error.
65
+ *
66
+ * @param {string} token - The token from the request header
67
+ * @param {object} key - Key components used to re-derive the expected token
68
+ * @param {string} key.secret - Shared HMAC secret; throws `TypeError` if missing
69
+ * @param {string} key.uuid - UUID from the CSRF cookie; throws `TypeError` if missing
70
+ * @param {string} [key.encoding='base64'] - Encoding used when the token was issued
71
+ * @returns {boolean} - `true` if the token is valid, `false` otherwise
72
+ * @throws {TypeError} If `secret` or `uuid` are not provided
73
+ */
74
+ function verify(
75
+ token,
76
+ {
77
+ secret = new TypeError('Missing required parameter: "secret"'),
78
+ uuid = new TypeError('Missing required parameter: "uuid"'),
79
+ encoding = 'base64'
80
+ } = {}
81
+ ) {
82
+ if (type(secret) === 'TypeError') {
83
+ throw secret;
84
+ }
85
+ if (type(uuid) === 'TypeError') {
86
+ throw uuid;
87
+ }
88
+
89
+ const expected = issue(secret, uuid, encoding).token;
90
+
91
+ if (typeof token !== 'string' || token.length !== expected.length) {
92
+ return false;
93
+ }
94
+
95
+ return timingSafeEqual(Buffer.from(token), Buffer.from(expected));
96
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @fileoverview Adapter for Connect/Express-style middleware.
3
+ *
4
+ * Wraps a Connect-style `(req, res, next)` middleware into ergo's
5
+ * `(req, res) => Promise<undefined>` signature so it can be used inside
6
+ * ergo's `compose()` pipeline.
7
+ *
8
+ * The adapter handles two completion signals:
9
+ * - `next()` called — the standard path; promise resolves and the pipeline continues.
10
+ * - `res` `finish` event — fallback for middleware that ends the response directly
11
+ * (e.g., CORS preflight, rate limiter sending 429) without calling `next()`.
12
+ *
13
+ * A `settled` guard prevents double-resolution from buggy middleware that calls
14
+ * `next()` multiple times or calls `next()` after ending the response.
15
+ *
16
+ * Returns `undefined` so neither the domain nor response accumulator is affected.
17
+ * Connect middleware communicates via side effects on `res` headers.
18
+ *
19
+ * Limitations:
20
+ * - Express error-handling middleware `(err, req, res, next)` is not supported.
21
+ * Ergo handles errors via `handler()` / `attempt()` try/catch.
22
+ * - Connect middleware may mutate `req` (e.g., `req.user`). This violates ergo's
23
+ * no-mutation convention but is accepted at the interop boundary.
24
+ *
25
+ * @module lib/from-connect
26
+ * @version 0.1.0
27
+ * @since 0.1.0
28
+ *
29
+ * @example
30
+ * import {compose, handler} from 'ergo';
31
+ * import fromConnect from 'ergo/lib/from-connect';
32
+ * import helmet from 'helmet';
33
+ *
34
+ * const pipeline = compose(
35
+ * fromConnect(helmet()),
36
+ * () => ({response: {body: {ok: true}}})
37
+ * );
38
+ *
39
+ * export default handler(pipeline);
40
+ */
41
+
42
+ /**
43
+ * Wrap a Connect/Express-style middleware for use in an ergo pipeline.
44
+ *
45
+ * @param {function} middleware - Connect middleware `(req, res, next) => void`
46
+ * @returns {function} - Ergo-compatible middleware `(req, res) => Promise<undefined>`
47
+ */
48
+ export default function fromConnect(middleware) {
49
+ return (req, res) =>
50
+ new Promise((resolve, reject) => {
51
+ let settled = false;
52
+
53
+ const onFinish = () => {
54
+ if (settled) return;
55
+ settled = true;
56
+ resolve();
57
+ };
58
+
59
+ res.once('finish', onFinish);
60
+
61
+ middleware(req, res, err => {
62
+ if (settled) return;
63
+ settled = true;
64
+ res.removeListener('finish', onFinish);
65
+ if (err) return reject(err);
66
+ resolve();
67
+ });
68
+ });
69
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @fileoverview Vendored JSON:API query module with AJV 8 and JSON Schema 2020-12.
3
+ *
4
+ * Provides the JSON:API query parameter validator and the default schema as a deep-cloneable
5
+ * getter. The schema clone pattern prevents mutation from affecting the original definition.
6
+ *
7
+ * Original: `@centralping/json-api-query`. Vendored and upgraded to AJV 8 / JSON Schema 2020-12.
8
+ *
9
+ * @module lib/json-api-query
10
+ * @version 0.1.0
11
+ * @since 0.1.0
12
+ * @requires ./schema.json
13
+ * @requires ./validate.js
14
+ */
15
+ import schemaSource from './schema.json' with {type: 'json'};
16
+
17
+ export {default as validate} from './validate.js';
18
+
19
+ /**
20
+ * A deep copy of the default JSON API query schema, computed once at module load.
21
+ * Callers should clone again if mutation is needed.
22
+ *
23
+ * @member {object} schema JSON Schema 2020-12
24
+ */
25
+ export const schema = structuredClone(schemaSource);