@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,187 @@
1
+ /**
2
+ * @fileoverview Authorization header parsing and strategy dispatch logic.
3
+ *
4
+ * Parses the HTTP `Authorization` header and dispatches to the matching strategy handler.
5
+ * Supports three built-in schemes with pluggable `authorizer` callbacks:
6
+ * - **Basic** — decodes base64 `username:password` credentials, passes `(attributes, username, password)`
7
+ * - **Bearer** — passes the raw token string unchanged `(attributes, token)`; no decoding (JWTs and opaque tokens are not base64 encoded at the HTTP layer)
8
+ * - **$default** — passes raw credentials for any custom scheme `(attributes, credentials)`
9
+ *
10
+ * Each strategy returns `{authorized: boolean, info: object}`. Authorization failure returns
11
+ * 401 for known schemes (with a `WWW-Authenticate` challenge) or 403 for unrecognized schemes.
12
+ *
13
+ * @module lib/authorization
14
+ * @version 0.1.0
15
+ * @since 0.1.0
16
+ *
17
+ * @example
18
+ * import authorize from 'ergo/lib/authorization';
19
+ *
20
+ * const authorizer = authorize([{
21
+ * type: 'Bearer',
22
+ * attributes: {realm: 'API'},
23
+ * authorizer: async (attrs, token) => {
24
+ * const user = await verifyJwt(token);
25
+ * return user ? {authorized: true, info: {user}} : {authorized: false, info: {}};
26
+ * }
27
+ * }]);
28
+ *
29
+ * const result = await authorizer('Bearer eyJ...');
30
+ * // result => {authorized: true, info: {user: {...}}}
31
+ *
32
+ * @see {@link https://www.rfc-editor.org/rfc/rfc6750 RFC 6750 - Bearer Token Usage}
33
+ * @see {@link https://www.rfc-editor.org/rfc/rfc7617 RFC 7617 - The 'Basic' HTTP Authentication Scheme}
34
+ * @see {@link https://www.rfc-editor.org/rfc/rfc7235 RFC 7235 - HTTP Authentication}
35
+ */
36
+ import sanitizeQuotedString from './sanitize-quoted-string.js';
37
+
38
+ /**
39
+ * Creates an authorization dispatcher for the given strategy list.
40
+ *
41
+ * @param {Array<{type: string, attributes?: object, authorizer: function}>} strategies - Authentication strategy definitions
42
+ * @returns {function} - Async `(authorization) => {authorized, info}`
43
+ */
44
+ export default (strategies = []) => {
45
+ const dispatcher = createDispatcher(strategies);
46
+
47
+ return async (authorization = '') => {
48
+ const [type, rawCredentials] = authorization.split(/ (.*)$/);
49
+ const strategy = dispatcher[type.toLowerCase()];
50
+
51
+ if (strategy === undefined || rawCredentials === undefined) {
52
+ const obj = {
53
+ authorized: false,
54
+ info: {
55
+ statusCode: 403
56
+ }
57
+ };
58
+
59
+ if (Object.keys(dispatcher).length > 0) {
60
+ obj.info = {
61
+ statusCode: 401,
62
+ authenticate: Object.values(dispatcher).map(s => s.authenticate)
63
+ };
64
+ }
65
+
66
+ return obj;
67
+ }
68
+
69
+ return await strategy.authorizer(rawCredentials);
70
+ };
71
+ };
72
+
73
+ const dispatchHelper = new Proxy(
74
+ {
75
+ basic({authorizer, attributes, authenticate}) {
76
+ return {
77
+ authenticate,
78
+ authorizer: async rawCredentials => {
79
+ const decoded = Buffer.from(rawCredentials, 'base64').toString();
80
+ const [username, password] = decoded.split(/:(.*)$/);
81
+ const {authorized = false, info = {}} = await authorizer(attributes, username, password);
82
+
83
+ if (authorized === false) {
84
+ return {
85
+ authorized,
86
+ info: {
87
+ statusCode: 403
88
+ }
89
+ };
90
+ }
91
+
92
+ return {authorized, info};
93
+ }
94
+ };
95
+ },
96
+ bearer({authorizer, attributes, authenticate}) {
97
+ return {
98
+ authenticate,
99
+ authorizer: async rawCredentials => {
100
+ const {authorized = false, info = {}} = await authorizer(attributes, rawCredentials);
101
+
102
+ if (authorized === false) {
103
+ return {
104
+ authorized,
105
+ info: {
106
+ statusCode:
107
+ info.type === 'invalid_request'
108
+ ? 400
109
+ : info.type === 'insufficient_scope'
110
+ ? 403
111
+ : 401, // default and 'invalid_token'
112
+ authenticate: [authenticate, ...formatError(info)].join(', ')
113
+ }
114
+ };
115
+ }
116
+
117
+ return {authorized, info};
118
+ }
119
+ };
120
+ },
121
+ $default({authorizer, attributes, authenticate}) {
122
+ return {
123
+ authenticate,
124
+ authorizer: async rawCredentials => {
125
+ const {authorized = false, info = {}} = await authorizer(attributes, rawCredentials);
126
+
127
+ if (authorized === false) {
128
+ return {
129
+ authorized,
130
+ info: {
131
+ statusCode: 403
132
+ }
133
+ };
134
+ }
135
+
136
+ return {authorized, info};
137
+ }
138
+ };
139
+ }
140
+ },
141
+ {
142
+ get(o, p) {
143
+ return Object.hasOwn(o, p) ? o[p] : o.$default;
144
+ }
145
+ }
146
+ );
147
+
148
+ /**
149
+ * Builds a dispatcher map from the strategy array, keyed by lowercase scheme name.
150
+ *
151
+ * @param {Array<{type: string, attributes?: object, authorizer: function}>} strategies - Authentication strategy definitions
152
+ * @returns {object} - Dispatcher map keyed by lowercase scheme name
153
+ */
154
+ function createDispatcher(strategies) {
155
+ return strategies.reduce((o, {type, attributes = {realm: 'Users'}, authorizer}) => {
156
+ o[type.toLowerCase()] = dispatchHelper[type.toLowerCase()]({
157
+ attributes,
158
+ authorizer,
159
+ authenticate: `${type} ${Object.entries(attributes)
160
+ .map(([k, v]) => `${k}="${sanitizeQuotedString(v)}"`)
161
+ .join(', ')}`
162
+ });
163
+
164
+ return o;
165
+ }, {});
166
+ }
167
+
168
+ const errorPropMap = [
169
+ ['type', 'error'],
170
+ ['desc', 'error_description'],
171
+ ['uri', 'error_uri']
172
+ ];
173
+
174
+ /**
175
+ * Formats a Bearer token error object into WWW-Authenticate parameter strings (RFC 6750).
176
+ *
177
+ * @param {object} error - Error info from the authorizer
178
+ * @param {string} [error.type] - OAuth error type (e.g. 'invalid_token')
179
+ * @param {string} [error.desc] - Human-readable error description
180
+ * @param {string} [error.uri] - URI to more error information
181
+ * @returns {string[]} - Array of key="value" attribute strings for the WWW-Authenticate header
182
+ */
183
+ function formatError(error) {
184
+ return errorPropMap
185
+ .filter(([p]) => error[p] !== undefined)
186
+ .map(([p, k]) => `${k}="${sanitizeQuotedString(error[p])}"`);
187
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * @fileoverview RFC 7578 multipart/form-data parser.
3
+ *
4
+ * Parses a fully buffered `multipart/form-data` body into an array of part descriptors.
5
+ * Each part contains parsed headers, the binary body buffer, and convenience shortcuts
6
+ * for the `Content-Disposition` `name` and `filename` parameters.
7
+ *
8
+ * Uses Buffer-level KMP split (`utils/iterables/buffer-split`) for efficient binary
9
+ * boundary detection without converting the entire body to a string.
10
+ *
11
+ * Only the MIME headers explicitly allowed by RFC 7578 §4.8 are parsed:
12
+ * `Content-Disposition`, `Content-Type`, `Content-Transfer-Encoding`.
13
+ *
14
+ * @module lib/body/multiparse
15
+ * @version 0.1.0
16
+ * @since 0.1.0
17
+ * @requires ./multipart/headers.js
18
+ * @requires ../../utils/iterables/buffer-split.js
19
+ * @requires ../../utils/iterables/chain.js
20
+ *
21
+ * @see {@link https://www.rfc-editor.org/rfc/rfc7578}
22
+ *
23
+ * @example
24
+ * import multiparse from 'ergo/lib/body/multiparse';
25
+ *
26
+ * const parts = multiparse(rawBodyBuffer, 'boundary-string');
27
+ * // parts => [{headers: {...}, name: 'file', filename: 'upload.txt', body: Buffer}]
28
+ */
29
+ // https://www.rfc-editor.org/rfc/rfc7578
30
+ import parseHeaders from './multipart/headers.js';
31
+ import bufferSplit from '../../utils/iterables/buffer-split.js';
32
+ import chain from '../../utils/iterables/chain.js';
33
+
34
+ const CRLF = Buffer.from('\r\n');
35
+ const CRLFCRLF = Buffer.from('\r\n\r\n');
36
+
37
+ /**
38
+ * Parses a multipart/form-data body buffer according to RFC 7578.
39
+ *
40
+ * Each part is returned as an object with:
41
+ * - headers: parsed headers (content-disposition, content-type, etc.)
42
+ * - body: Buffer of the part body
43
+ * - name: shortcut to content-disposition name parameter
44
+ * - filename: shortcut to content-disposition filename parameter (if present)
45
+ *
46
+ * @param {import('node:buffer').Buffer|string} rawbody - the raw body buffer (from writer.js)
47
+ * @param {string} boundary - the multipart boundary string
48
+ * @param {object} [options] - Parser options
49
+ * @param {number} [options.maxParts=100] - Maximum number of parts to parse
50
+ * @returns {object[]} - array of parsed parts
51
+ */
52
+ const DEFAULT_MAX_PARTS = 100;
53
+
54
+ export default (rawbody, boundary, {maxParts = DEFAULT_MAX_PARTS} = {}) => {
55
+ // Boundaries are prefixed with '--' per RFC 7578
56
+ const delimiter = Buffer.from(`--${boundary}`);
57
+
58
+ // Wrap in an array so bufferSplit iterable works over a single-chunk source
59
+ const source = [Buffer.isBuffer(rawbody) ? rawbody : Buffer.from(rawbody)];
60
+
61
+ // Split body on the boundary delimiter; yields [index, buffer] pairs
62
+ const parts = chain(source, bufferSplit(delimiter));
63
+
64
+ const results = [];
65
+
66
+ for (const [index, partBuf] of parts) {
67
+ // index 0 is preamble (before first boundary), skip it
68
+ // The last part will be just '--\r\n' (closing boundary) or empty, skip it too
69
+ if (index === 0) {
70
+ continue;
71
+ }
72
+
73
+ // Each part buffer starts with \r\n after the boundary delimiter
74
+ // and may end with \r\n before the next boundary delimiter
75
+ // Strip leading CRLF
76
+ let content = partBuf;
77
+ if (content.slice(0, CRLF.length).equals(CRLF)) {
78
+ content = content.slice(CRLF.length);
79
+ }
80
+
81
+ // Strip trailing CRLF
82
+ if (content.slice(-CRLF.length).equals(CRLF)) {
83
+ content = content.slice(0, -CRLF.length);
84
+ }
85
+
86
+ // The closing boundary part ends with '--'; skip it
87
+ if (content.length >= 2 && content[0] === 0x2d && content[1] === 0x2d) {
88
+ continue;
89
+ }
90
+
91
+ if (results.length >= maxParts) break;
92
+
93
+ // Split headers from body on the first CRLFCRLF sequence
94
+ const separatorIdx = indexOfSequence(content, CRLFCRLF);
95
+
96
+ if (separatorIdx === -1) {
97
+ // Malformed part: no header/body separator
98
+ continue;
99
+ }
100
+
101
+ const headerSection = content.slice(0, separatorIdx);
102
+ const body = content.slice(separatorIdx + CRLFCRLF.length);
103
+
104
+ // Split header section into individual header lines
105
+ const headerLines = splitBuffer(headerSection, CRLF).map(b => b.toString());
106
+
107
+ const headers = parseHeaders(headerLines);
108
+
109
+ const disposition = headers['content-disposition'] ?? {};
110
+ const params = disposition.parameters ?? {};
111
+
112
+ results.push({
113
+ headers,
114
+ name: params.name,
115
+ filename: params.filename,
116
+ body
117
+ });
118
+ }
119
+
120
+ return results;
121
+ };
122
+
123
+ /**
124
+ * Find the first occurrence of a sequence (needle) in a buffer (haystack).
125
+ * Returns the index of the first byte of the sequence, or -1 if not found.
126
+ *
127
+ * @param {import('node:buffer').Buffer} haystack - Buffer to search within
128
+ * @param {import('node:buffer').Buffer} needle - Byte sequence to find
129
+ * @returns {number} - Index of the first match, or -1
130
+ */
131
+ function indexOfSequence(haystack, needle) {
132
+ const nLen = needle.length;
133
+ outer: for (let i = 0; i <= haystack.length - nLen; i++) {
134
+ for (let j = 0; j < nLen; j++) {
135
+ if (haystack[i + j] !== needle[j]) {
136
+ continue outer;
137
+ }
138
+ }
139
+ return i;
140
+ }
141
+ return -1;
142
+ }
143
+
144
+ /**
145
+ * Split a buffer by a separator buffer, returning an array of Buffer slices.
146
+ *
147
+ * @param {import('node:buffer').Buffer} buf - Buffer to split
148
+ * @param {import('node:buffer').Buffer} sep - Separator byte sequence
149
+ * @returns {import('node:buffer').Buffer[]} - Array of Buffer slices between separators
150
+ */
151
+ function splitBuffer(buf, sep) {
152
+ const results = [];
153
+ let start = 0;
154
+ const sLen = sep.length;
155
+
156
+ for (let i = 0; i <= buf.length - sLen; i++) {
157
+ let match = true;
158
+ for (let j = 0; j < sLen; j++) {
159
+ if (buf[i + j] !== sep[j]) {
160
+ match = false;
161
+ break;
162
+ }
163
+ }
164
+ if (match) {
165
+ results.push(buf.slice(start, i));
166
+ start = i + sLen;
167
+ i += sLen - 1;
168
+ }
169
+ }
170
+
171
+ results.push(buf.slice(start));
172
+ return results;
173
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @fileoverview Multipart MIME header parser for RFC 7578 parts.
3
+ *
4
+ * Parses an array of header line strings from a multipart/form-data part into a structured
5
+ * object. Extracts parameter key-value pairs from headers with directives (e.g.
6
+ * `Content-Disposition: form-data; name="file"; filename="upload.txt"`).
7
+ *
8
+ * Only the headers explicitly allowed by RFC 7578 §4.8 are retained:
9
+ * - `content-disposition`
10
+ * - `content-type`
11
+ * - `content-transfer-encoding` (deprecated but present in legacy clients)
12
+ *
13
+ * @module lib/body/multipart/headers
14
+ * @version 0.1.0
15
+ * @since 0.1.0
16
+ * @requires ../../../utils/iterables/exec-all.js
17
+ *
18
+ * @see {@link https://www.rfc-editor.org/rfc/rfc7578 RFC 7578 - Returning Values from Forms: multipart/form-data}
19
+ *
20
+ * @example
21
+ * import parseHeaders from 'ergo/lib/body/multipart/headers';
22
+ *
23
+ * parseHeaders(['Content-Disposition: form-data; name="field1"']);
24
+ * // => {'content-disposition': {type: 'form-data', parameters: {name: 'field1'}}}
25
+ */
26
+ import execAll from '../../../utils/iterables/exec-all.js';
27
+
28
+ const headerRegExp = /^([^:]+):\s*([^;]+);?\s*(.*?)$/;
29
+ const directivesRegExp = /\s*([^=]+)=\s*(?:"([^"]+)"|([^;\s]+));?/;
30
+
31
+ /**
32
+ * Allowed headers are restricted to:
33
+ * Content-Disposition https://www.rfc-editor.org/rfc/rfc7578#section-4.2,
34
+ * Content-Type https://www.rfc-editor.org/rfc/rfc7578#section-4.4
35
+ * and Content-Transfer-Encoding (deprecated) https://www.rfc-editor.org/rfc/rfc7578#section-4.7.
36
+ *
37
+ * https://www.rfc-editor.org/rfc/rfc7578#section-4.8
38
+ */
39
+ const allowed = ['content-disposition', 'content-type', 'content-transfer-encoding'];
40
+
41
+ const reAll = execAll(directivesRegExp);
42
+
43
+ /**
44
+ * @param {Array<import('node:buffer').Buffer|string>} [buffers=[]] - Array of header line buffers from a multipart part
45
+ * @param {object} [headers] - Initial headers object; defaults to `{content-type: {type: 'text/plain'}}` when omitted
46
+ * @returns {object} - Parsed headers keyed by lowercase header name with `{type, parameters}` values
47
+ */
48
+ export default (buffers = [], headers) => {
49
+ const base =
50
+ headers !== undefined
51
+ ? Object.assign(Object.create(null), headers)
52
+ : Object.assign(Object.create(null), {'content-type': {type: 'text/plain'}});
53
+ return buffers
54
+ .map(buffer => headerRegExp.exec(buffer.toString().trim()))
55
+ .filter(m => m !== null)
56
+ .reduce((obj, [, prop, type, parameters]) => {
57
+ const lcProp = prop.toLowerCase();
58
+
59
+ if (allowed.includes(lcProp)) {
60
+ const params = Object.create(null);
61
+ for (const [k, quoted, unquoted] of reAll(parameters)) {
62
+ params[k] = quoted ?? unquoted;
63
+ }
64
+ obj[lcProp] = {type, parameters: params};
65
+ }
66
+
67
+ return obj;
68
+ }, base);
69
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @fileoverview Writable stream accumulator for collecting request body chunks.
3
+ *
4
+ * Creates a `Writable` stream that buffers all incoming `Buffer` chunks and consolidates
5
+ * them into a single `Buffer` when the stream finishes. Exposed via the `.data` getter.
6
+ *
7
+ * Used by `http/body.js` in the 3-stream decompression pipeline:
8
+ * `pipeline(req, meter, decompressor, writer())`
9
+ *
10
+ * @module lib/body/writer
11
+ * @version 0.1.0
12
+ * @since 0.1.0
13
+ * @requires node:stream
14
+ *
15
+ * @example
16
+ * import {pipeline} from 'node:stream';
17
+ * import writer from 'ergo/lib/body/writer';
18
+ *
19
+ * const w = writer();
20
+ * pipeline(readable, w, err => {
21
+ * if (!err) console.log(w.data); // => Buffer with all accumulated bytes
22
+ * });
23
+ */
24
+ import {Writable} from 'node:stream';
25
+
26
+ const chunksSym = Symbol('chunks');
27
+ const bytesSym = Symbol('bytes');
28
+
29
+ /**
30
+ * Creates a Writable stream that accumulates chunks into a single Buffer.
31
+ *
32
+ * @returns {import('node:stream').Writable} - Writable stream with a `.data` getter for the concatenated Buffer (available after stream ends)
33
+ */
34
+ export default () => {
35
+ const w = Object.defineProperties(new Writable({write, final}), {
36
+ [chunksSym]: {
37
+ value: [],
38
+ writable: true
39
+ },
40
+ [bytesSym]: {
41
+ value: 0,
42
+ writable: true
43
+ },
44
+ data: {
45
+ get() {
46
+ return this[chunksSym];
47
+ }
48
+ }
49
+ });
50
+
51
+ return w;
52
+ };
53
+
54
+ /**
55
+ * @param {import('node:buffer').Buffer} chunk - Incoming data chunk
56
+ * @param {string} encoding - Chunk encoding (ignored for Buffers)
57
+ * @param {function} cb - Callback to signal write completion
58
+ */
59
+ function write(chunk, encoding, cb) {
60
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
61
+ this[chunksSym].push(buf);
62
+ this[bytesSym] += buf.length;
63
+ cb(null);
64
+ }
65
+
66
+ /**
67
+ * @param {function} cb - Callback to signal finalization is complete
68
+ */
69
+ function final(cb) {
70
+ // Consolidate all chunks into a single Buffer
71
+ this[chunksSym] = Buffer.concat(this[chunksSym], this[bytesSym]);
72
+ cb(null);
73
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * @fileoverview Cookie construction factory (RFC 6265 compliant).
3
+ *
4
+ * Creates typed cookie objects with a `toHeader()` method that serializes the cookie
5
+ * to a `Set-Cookie` header string per RFC 6265.
6
+ *
7
+ * Default directives enforce secure cookie practices: `Secure: true`, `HttpOnly: true`,
8
+ * `Path: /`. Cookies without a `value` (or where `value` is undefined) are set to expire
9
+ * immediately by defaulting `maxAge` to 0.
10
+ *
11
+ * @module lib/cookie/cookie
12
+ * @version 0.1.0
13
+ * @since 0.1.0
14
+ *
15
+ * @example
16
+ * import bake from 'ergo/lib/cookie/cookie';
17
+ *
18
+ * const c = bake('session', 'abc123', {maxAge: 3600, sameSite: 'Lax'});
19
+ * c.toHeader(); // 'session=abc123; Path=/; Max-Age=3600; SameSite=Lax; Secure; HttpOnly'
20
+ *
21
+ * // Expire a cookie by omitting value
22
+ * bake('session').toHeader(); // 'session=; Path=/; Max-Age=0; Expires=...; Secure; HttpOnly'
23
+ *
24
+ * @see {@link https://www.rfc-editor.org/rfc/rfc6265 RFC 6265 - HTTP State Management Mechanism}
25
+ */
26
+ export default bake;
27
+
28
+ const dough = Object.create(
29
+ {},
30
+ {
31
+ toHeader: {
32
+ value() {
33
+ return toHeader(this);
34
+ }
35
+ },
36
+ isCookie: {
37
+ value: true
38
+ }
39
+ }
40
+ );
41
+
42
+ /**
43
+ * A cookie factory function.
44
+ * @param {string} name - The name of the cookie.
45
+ * @param {string} [value] - The value of the cookie. If undefined
46
+ * as well as expires and maxAge are undefined, then the cookie will be set to expire.
47
+ * @param {object} [directives] - See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies|Cookies} for more information about directives.
48
+ * @param {string} [directives.domain] - The domain of the cookie. Defaults to
49
+ * the host portion of the current document location. If a domain is specified,
50
+ * subdomains are always included.
51
+ * @param {string} [directives.path='/'] - The absolute path of the cookie. Defaults
52
+ * to the current path of the current document location.
53
+ * @param {boolean} [directives.secure=true] - Indicates whether the cookie is
54
+ * transmitted over secure protocols such as HTTPS.
55
+ * @param {boolean} [directives.httpOnly=true] - Indicates whether the cookie is
56
+ * accessible via client JavaScript (e.g. document.cookie, Request, XMLHttpRequest, etc.).
57
+ * @param {'lax'|'strict'|'none'} [directives.sameSite] - Indicates if a cookie shouldn't be sent
58
+ * with cross-site requests. See {@link https://www.owasp.org/index.php/SameSite|SameSite} for more information.
59
+ * @param {number} [directives.maxAge] - The maximum age of a cookie in seconds.
60
+ * @param {(Date|string|number)} [directives.expires] - The GMT timestamp of the cookie
61
+ * expiration.
62
+ * @returns {object} - A cookie object with name, value, directives, and toHeader().
63
+ */
64
+ function bake(
65
+ name,
66
+ value,
67
+ {domain, path = '/', maxAge, expires, sameSite, secure = true, httpOnly = true} = {}
68
+ ) {
69
+ // RFC 6265bis §5.4.7: SameSite=None requires the Secure attribute
70
+ const effectiveSecure =
71
+ sameSite !== undefined && String(sameSite).toLowerCase() === 'none' ? true : secure;
72
+
73
+ return Object.assign(Object.create(dough), {
74
+ name,
75
+ value,
76
+ domain,
77
+ path,
78
+ maxAge,
79
+ expires,
80
+ sameSite,
81
+ secure: effectiveSecure,
82
+ httpOnly
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Creates a new cookie header.
88
+ * @param {object} cookie - The cookie object.
89
+ * @param {string} [cookie.name] - The name of the cookie.
90
+ * @param {string} [cookie.value] - The value of the cookie. If undefined
91
+ * and expires/maxAge are undefined, the cookie will be set to expire.
92
+ * @param {string} [cookie.domain] - The domain of the cookie. Defaults to
93
+ * the host portion of the current document location. If a domain is specified,
94
+ * subdomains are always included.
95
+ * @param {string} [cookie.path] - The absolute path of the cookie. Defaults
96
+ * to the current path of the current document location.
97
+ * @param {boolean} [cookie.secure] - Indicates whether the cookie is
98
+ * transmitted over secure protocols such as HTTPS.
99
+ * @param {string} [cookie.sameSite] - Indicates if a cookie shouldn't be sent
100
+ * with cross-site requests. See {@link https://www.owasp.org/index.php/SameSite|SameSite} for more information.
101
+ * @param {boolean} [cookie.httpOnly] - Indicates whether the cookie is inaccessible
102
+ * to client-side JavaScript (e.g. `document.cookie`). See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies|HttpOnly} for more information.
103
+ * @param {number} [cookie.maxAge] - The maximum age of a cookie in seconds.
104
+ * @param {(Date|string|number)} [cookie.expires] - The GMT timestamp of the cookie
105
+ * expiration.
106
+ * @returns {string} - A serialized Set-Cookie header string.
107
+ */
108
+ // RFC 6265 §4.1.1: cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
109
+ // Reject anything outside cookie-octet: SP, ", comma, semicolon, backslash, CTLs, DEL
110
+ // eslint-disable-next-line no-control-regex -- intentional: reject CTLs per RFC 6265 cookie-octet
111
+ const COOKIE_VALUE_UNSAFE_RE = /[\x00-\x20",;\\\x7f]/;
112
+ // RFC 7230 §3.2.6: token = 1*tchar (no CTLs, SP, or delimiters)
113
+ const TOKEN_RE = /^[!#$%&'*+\-.^_`|~\w]+$/;
114
+
115
+ /**
116
+ * Rejects cookie values containing characters outside RFC 6265 cookie-octet.
117
+ * @param {*} val - Field value to check
118
+ * @param {string} field - Field name for the error message
119
+ * @throws {TypeError} If the value contains forbidden characters
120
+ */
121
+ function assertSafeValue(val, field) {
122
+ if (typeof val === 'string' && COOKIE_VALUE_UNSAFE_RE.test(val)) {
123
+ throw new TypeError(`Cookie ${field} contains forbidden characters`);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Rejects cookie names that are not valid HTTP tokens per RFC 7230.
129
+ * @param {*} val - Name to check
130
+ * @throws {TypeError} If the name is not a valid token
131
+ */
132
+ function assertSafeName(val) {
133
+ if (typeof val === 'string' && !TOKEN_RE.test(val)) {
134
+ throw new TypeError('Cookie name contains forbidden characters');
135
+ }
136
+ }
137
+
138
+ function toHeader({
139
+ name,
140
+ value,
141
+ domain,
142
+ path,
143
+ secure,
144
+ sameSite,
145
+ httpOnly,
146
+ maxAge = value === undefined ? 0 : undefined,
147
+ expires = value === undefined ? Date.now() + maxAge : undefined
148
+ } = {}) {
149
+ assertSafeName(name);
150
+ assertSafeValue(value, 'value');
151
+ assertSafeValue(domain, 'domain');
152
+ assertSafeValue(path, 'path');
153
+
154
+ let header = `${name === undefined ? '' : name}=${value === undefined ? '' : value}`;
155
+
156
+ /*
157
+ * RFC 6265 uses OWS (optional whitespace) after semicolons; a space is
158
+ * conventional and maximizes compatibility with existing parsers.
159
+ * https://www.rfc-editor.org/rfc/rfc6265
160
+ */
161
+ if (domain !== undefined) {
162
+ header += `; Domain=${domain}`;
163
+ }
164
+
165
+ if (path !== undefined) {
166
+ header += `; Path=${path}`;
167
+ }
168
+
169
+ if (maxAge !== undefined) {
170
+ header += `; Max-Age=${maxAge}`;
171
+ }
172
+
173
+ if (expires !== undefined) {
174
+ header += `; Expires=${new Date(expires).toUTCString()}`;
175
+ }
176
+
177
+ assertSafeValue(sameSite, 'sameSite');
178
+
179
+ if (sameSite !== undefined) {
180
+ header += `; SameSite=${sameSite}`;
181
+ }
182
+
183
+ if (secure) {
184
+ header += '; Secure';
185
+ }
186
+
187
+ if (httpOnly) {
188
+ header += '; HttpOnly';
189
+ }
190
+
191
+ return header;
192
+ }