@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,105 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "jsonapi.json",
4
+ "title": "JSON API Request Validation",
5
+ "description": "Validation schema for validating JSON API querystrings",
6
+ "type": "object",
7
+ "properties": {
8
+ "fields": {
9
+ "description": "https://jsonapi.org/format/#fetching-sparse-fieldsets",
10
+ "type": "object",
11
+ "additionalProperties": {
12
+ "type": "array",
13
+ "items": {
14
+ "type": "string"
15
+ },
16
+ "uniqueItems": true,
17
+ "minItems": 1
18
+ },
19
+ "minProperties": 1
20
+ },
21
+ "filter": {
22
+ "description": "https://jsonapi.org/format/#fetching-filtering",
23
+ "type": "object",
24
+ "minProperties": 1
25
+ },
26
+ "include": {
27
+ "description": "https://jsonapi.org/format/#fetching-includes",
28
+ "type": "array",
29
+ "items": {
30
+ "type": "string"
31
+ },
32
+ "uniqueItems": true,
33
+ "minItems": 1
34
+ },
35
+ "page": {
36
+ "description": "https://jsonapi.org/format/#fetching-pagination",
37
+ "type": "object",
38
+ "properties": {
39
+ "cursor": {
40
+ "type": "string",
41
+ "minLength": 1
42
+ },
43
+ "size": {
44
+ "type": "integer",
45
+ "minimum": 1
46
+ },
47
+ "number": {
48
+ "type": "integer",
49
+ "minimum": 1
50
+ },
51
+ "limit": {
52
+ "type": "integer",
53
+ "minimum": 1
54
+ },
55
+ "offset": {
56
+ "type": "integer",
57
+ "minimum": 0
58
+ }
59
+ },
60
+ "dependentRequired": {
61
+ "offset": ["limit"],
62
+ "number": ["size"]
63
+ },
64
+ "additionalProperties": false,
65
+ "minProperties": 1,
66
+ "if": {"required": ["cursor"]},
67
+ "then": {"maxProperties": 1},
68
+ "else": {
69
+ "maxProperties": 2,
70
+ "if": {"required": ["size"]},
71
+ "then": {"not": {"required": ["limit"]}}
72
+ }
73
+ },
74
+ "sort": {
75
+ "description": "https://jsonapi.org/format/#fetching-sorting",
76
+ "type": "array",
77
+ "items": {
78
+ "type": "string"
79
+ },
80
+ "uniqueItems": true,
81
+ "minItems": 1
82
+ }
83
+ },
84
+ "patternProperties": {
85
+ "[^a-z]": {
86
+ "description": "https://jsonapi.org/format/#query-parameters",
87
+ "type": ["array", "boolean", "integer", "null", "number", "object", "string"]
88
+ }
89
+ },
90
+ "additionalProperties": false,
91
+ "if": {
92
+ "required": ["page"],
93
+ "properties": {"page": {"required": ["cursor"]}}
94
+ },
95
+ "then": {
96
+ "not": {
97
+ "anyOf": [
98
+ {"required": ["fields"]},
99
+ {"required": ["filter"]},
100
+ {"required": ["include"]},
101
+ {"required": ["sort"]}
102
+ ]
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @fileoverview JSON:API query parameter validator using AJV 8 with JSON Schema 2020-12.
3
+ *
4
+ * Compiles the JSON:API query schema once and returns a validator function. The compiled
5
+ * validator is augmented with an `errors` property (populated on validation failure) for
6
+ * downstream error reporting.
7
+ *
8
+ * Options mirror a subset of AJV constructor options. By default, array coercion is enabled
9
+ * to handle single-value query parameters that may be strings instead of arrays.
10
+ *
11
+ * @module lib/json-api-query/validate
12
+ * @version 0.1.0
13
+ * @since 0.1.0
14
+ * @requires ajv/dist/2020.js
15
+ * @requires ./schema.json
16
+ *
17
+ * @example
18
+ * import validate from 'ergo/lib/json-api-query/validate';
19
+ *
20
+ * const validator = validate();
21
+ * const valid = validator({include: ['author'], fields: {articles: ['title']}});
22
+ * if (!valid) console.log(validator.errors);
23
+ */
24
+ import Ajv2020 from 'ajv/dist/2020.js';
25
+
26
+ import defaultSchema from './schema.json' with {type: 'json'};
27
+
28
+ /**
29
+ * Creates a compiled JSON API query validator.
30
+ *
31
+ * @example
32
+ * const validator = validate();
33
+ * const valid = validator(queryParams); // where queryParams is an object
34
+ *
35
+ * if (!valid) {
36
+ * console.log(validator.errors);
37
+ * }
38
+ *
39
+ * @param {object} [options] - Any AJV option.
40
+ * @param {boolean|string} [options.coerceTypes='array'] - Coerce validated values to specified types.
41
+ * @param {boolean} [options.ownProperties=true] - Restrict validation to own properties of data object.
42
+ * @param {object} [schema] - JSON Schema 2020-12. Defaults to the included schema.
43
+ * @returns {function} - The configured validator function.
44
+ */
45
+ export default (
46
+ {coerceTypes = 'array', ownProperties = true, ...ajvOptions} = {},
47
+ schema = defaultSchema
48
+ ) => {
49
+ const ajv = new Ajv2020({
50
+ ...ajvOptions,
51
+ coerceTypes,
52
+ ownProperties
53
+ });
54
+
55
+ return ajv.compile(schema);
56
+ };
package/lib/link.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @fileoverview RFC 8288 Web Linking utilities.
3
+ *
4
+ * Provides functions for formatting `Link` response headers per RFC 8288.
5
+ * `formatLinkHeader` is the low-level formatter; `paginationLinks` is a
6
+ * convenience helper that generates `first`, `prev`, `next`, `last` link
7
+ * objects from pagination parameters.
8
+ *
9
+ * These are pure utility functions (not middleware). Consumers wire the
10
+ * formatted header value into the accumulator's `headers` array for `send()`.
11
+ *
12
+ * @module lib/link
13
+ * @version 0.1.0
14
+ * @since 0.1.0
15
+ *
16
+ * @see {@link https://www.rfc-editor.org/rfc/rfc8288 RFC 8288 - Web Linking}
17
+ *
18
+ * @example
19
+ * import {formatLinkHeader, paginationLinks} from 'ergo/lib/link';
20
+ *
21
+ * const links = paginationLinks({
22
+ * baseUrl: '/articles',
23
+ * searchParams: 'sort=date',
24
+ * page: 3,
25
+ * perPage: 25,
26
+ * total: 100
27
+ * });
28
+ * const header = formatLinkHeader(links);
29
+ * // '</articles?sort=date&page=1&per_page=25>; rel="first", ...'
30
+ */
31
+ import sanitizeQuotedString from './sanitize-quoted-string.js';
32
+
33
+ const TOKEN_RE = /^[!#$%&'*+\-.^_`|~\w]+$/;
34
+
35
+ /**
36
+ * Formats an array of link objects into an RFC 8288 `Link` header value.
37
+ *
38
+ * @param {Array<{href: string, rel: string}>} links - Link descriptors. Each object
39
+ * must have `href` and `rel`; additional properties become link parameters.
40
+ * @returns {string} - Formatted header value (e.g. `<url>; rel="next", <url>; rel="prev"`)
41
+ * @throws {TypeError} If `href` contains `>` or a parameter key is not a valid token
42
+ */
43
+ export function formatLinkHeader(links) {
44
+ return links
45
+ .map(({href, rel, ...params}) => {
46
+ if (String(href).includes('>')) {
47
+ throw new TypeError('Link href must not contain ">"');
48
+ }
49
+ let entry = `<${href}>; rel="${sanitizeQuotedString(rel)}"`;
50
+ for (const [key, value] of Object.entries(params)) {
51
+ if (!TOKEN_RE.test(key)) {
52
+ throw new TypeError(`Link parameter key "${key}" is not a valid token`);
53
+ }
54
+ entry += `; ${key}="${sanitizeQuotedString(value)}"`;
55
+ }
56
+ return entry;
57
+ })
58
+ .join(', ');
59
+ }
60
+
61
+ /**
62
+ * Generates pagination link objects for first, prev, next, and last pages.
63
+ *
64
+ * Only includes `prev` when `page > 1` and `next` when `page < lastPage`.
65
+ * `first` and `last` are always included.
66
+ *
67
+ * @param {object} options - Pagination parameters
68
+ * @param {string} options.baseUrl - Base URL path (e.g. '/articles')
69
+ * @param {number} options.page - Current page number (1-based)
70
+ * @param {number} options.perPage - Items per page
71
+ * @param {number} options.total - Total item count
72
+ * @param {string} [options.searchParams=''] - Additional query parameters to preserve
73
+ * (e.g. 'sort=date&filter=active'). Appended before pagination params.
74
+ * @returns {Array<{href: string, rel: string}>} - Array of link objects
75
+ */
76
+ export function paginationLinks({baseUrl, page, perPage, total, searchParams = ''}) {
77
+ const lastPage = Math.max(1, Math.ceil(total / perPage));
78
+ const sep = searchParams ? '&' : '';
79
+ const prefix = `${baseUrl}?${searchParams}${sep}`;
80
+
81
+ const buildHref = p => `${prefix}page=${p}&per_page=${perPage}`;
82
+
83
+ const links = [{href: buildHref(1), rel: 'first'}];
84
+
85
+ if (page > 1) {
86
+ links.push({href: buildHref(page - 1), rel: 'prev'});
87
+ }
88
+
89
+ if (page < lastPage) {
90
+ links.push({href: buildHref(page + 1), rel: 'next'});
91
+ }
92
+
93
+ links.push({href: buildHref(lastPage), rel: 'last'});
94
+
95
+ return links;
96
+ }
package/lib/prefer.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @fileoverview Pure Prefer header parser (RFC 7240).
3
+ *
4
+ * Parses the HTTP `Prefer` request header into a plain object of preference
5
+ * name-value pairs. Supports:
6
+ * - Simple tokens (`respond-async` -> `{'respond-async': true}`)
7
+ * - Token=value pairs (`return=minimal` -> `{return: 'minimal'}`)
8
+ * - Quoted values (`foo="bar baz"` -> `{foo: 'bar baz'}`)
9
+ * - Multiple comma-separated preferences
10
+ * - Per-preference parameters after semicolons (stripped; only the main token is kept)
11
+ *
12
+ * Used by:
13
+ * - `http/prefer.js` (ergo pipeline middleware)
14
+ *
15
+ * @module lib/prefer
16
+ * @version 0.1.0
17
+ * @since 0.1.0
18
+ *
19
+ * @example
20
+ * import parsePrefer from 'ergo/lib/prefer';
21
+ *
22
+ * parsePrefer('return=minimal');
23
+ * // {return: 'minimal'}
24
+ *
25
+ * parsePrefer('respond-async, wait=100');
26
+ * // {'respond-async': true, wait: '100'}
27
+ *
28
+ * @see {@link https://www.rfc-editor.org/rfc/rfc7240 RFC 7240 - Prefer Header for HTTP}
29
+ */
30
+
31
+ /**
32
+ * Parse a Prefer header value into a preferences object.
33
+ *
34
+ * @param {string} [header] - Raw Prefer header value
35
+ * @returns {object} - Map of preference name to value (string) or `true` for bare tokens
36
+ */
37
+ export default function parsePrefer(header) {
38
+ if (!header) return Object.create(null);
39
+
40
+ const preferences = Object.create(null);
41
+
42
+ for (const part of header.split(',')) {
43
+ const [main] = part.split(';');
44
+ const match = main.trim().match(/^([a-zA-Z][\w-]*)(?:\s*=\s*"([^"]*)"|\s*=\s*(\S+))?$/);
45
+
46
+ if (match) {
47
+ preferences[match[1]] = match[2] ?? match[3] ?? true;
48
+ }
49
+ }
50
+
51
+ return preferences;
52
+ }
package/lib/query.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * @fileoverview Query string parser for URL query strings and JSON:API parameters.
3
+ *
4
+ * Parses raw query strings (after `?`) into nested objects, supporting:
5
+ * - Bracket notation: `fields[articles]=title` → `{fields: {articles: 'title'}}`
6
+ * - Array notation: `include[]=a&include[]=b` → `{include: ['a', 'b']}`
7
+ * - Comma-separated values: `sort=name,age` → `{sort: ['name', 'age']}`
8
+ * - Repeated keys: `tag=a&tag=b` → `{tag: ['a', 'b']}`
9
+ *
10
+ * Used by `http/url.js` for URL query parsing and `lib/json-api-query` for
11
+ * JSON:API query parameter parsing.
12
+ *
13
+ * @module lib/query
14
+ * @version 0.1.0
15
+ * @since 0.1.0
16
+ * @requires ../utils/set.js
17
+ *
18
+ * @example
19
+ * import parse from 'ergo/lib/query';
20
+ *
21
+ * parse('include=author&fields%5Barticles%5D=title%2Cbody');
22
+ * // => {include: 'author', fields: {articles: ['title', 'body']}}
23
+ *
24
+ * parse('tag=a&tag=b');
25
+ * // => {tag: ['a', 'b']}
26
+ */
27
+ import set from '../utils/set.js';
28
+
29
+ const subpropRegExp = /^([^[]+)(?:\[(.*)\]|)$/;
30
+
31
+ /**
32
+ * Safely decode a URI component, returning the raw string on invalid sequences.
33
+ * @param {string} s - Percent-encoded URI component
34
+ * @returns {string} - Decoded string, or the raw input if decoding fails
35
+ */
36
+ function safeDecode(s) {
37
+ try {
38
+ return decodeURIComponent(s);
39
+ } catch {
40
+ return s;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Parses a query string into key-value pairs with support for bracket notation
46
+ * and multi-value parameters.
47
+ *
48
+ * @param {string} query - Raw query string (portion after `?`)
49
+ * @param {object} [options] - Parser options
50
+ * @param {boolean} [options.split=true] - Split comma-separated values into arrays
51
+ * @param {number} [options.maxPairs=256] - Maximum number of query pairs to parse (DoS protection)
52
+ * @param {number} [options.maxLength=8192] - Maximum raw query string length (prevents allocation bomb)
53
+ * @returns {object} - Parsed key-value object; multi-value keys become arrays
54
+ */
55
+ export default (query, {split = true, maxPairs = 256, maxLength = 8192} = {}) => {
56
+ if (!query) {
57
+ return Object.create(null);
58
+ }
59
+
60
+ const bounded = query.length > maxLength ? query.slice(0, maxLength) : query;
61
+
62
+ // Single-pass parse: split on '&', decode, accumulate repeated keys
63
+ const q = Object.create(null);
64
+ const pairs = bounded.split('&');
65
+ const limit = Math.min(pairs.length, maxPairs);
66
+
67
+ for (let i = 0; i < limit; i++) {
68
+ const pair = pairs[i];
69
+ if (!pair) {
70
+ continue;
71
+ }
72
+ const eqIdx = pair.indexOf('=');
73
+ const rawKey = eqIdx === -1 ? pair : pair.slice(0, eqIdx);
74
+ const rawVal = eqIdx === -1 ? '' : pair.slice(eqIdx + 1);
75
+ const key = safeDecode(rawKey.replaceAll('+', ' '));
76
+ const val = safeDecode(rawVal.replaceAll('+', ' '));
77
+
78
+ if (key in q) {
79
+ if (Array.isArray(q[key])) {
80
+ q[key].push(val);
81
+ } else {
82
+ q[key] = [q[key], val];
83
+ }
84
+ } else {
85
+ q[key] = val;
86
+ }
87
+ }
88
+
89
+ const acc = Object.create(null);
90
+
91
+ for (const [k, v] of Object.entries(q)) {
92
+ const match = subpropRegExp.exec(k);
93
+ if (!match) continue;
94
+ let [, prop, subprop] = match;
95
+ let val = v;
96
+
97
+ if (subprop === '') {
98
+ val = [v].flat();
99
+ } else {
100
+ if (subprop !== undefined) {
101
+ prop += `.${subprop}`;
102
+ }
103
+
104
+ if (typeof v === 'string' && v.includes(',') && split) {
105
+ val = v.split(',').map(s => s.trim());
106
+ }
107
+ }
108
+
109
+ set(acc, prop, val);
110
+ }
111
+
112
+ return acc;
113
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @fileoverview Rate limiting shared primitives.
3
+ *
4
+ * Provides the core building blocks for both pipeline-level and transport-level
5
+ * rate limiting. The `MemoryStore` implements a sliding-window counter using
6
+ * per-key timestamp arrays. `checkRateLimit` computes the current state
7
+ * (remaining quota, reset time, whether the client is limited) from any store
8
+ * that implements the `hit(key, windowMs)` interface.
9
+ *
10
+ * Used by:
11
+ * - `http/rate-limit.js` (ergo pipeline middleware)
12
+ * - `ergo-router/lib/transport/rate-limit.js` (transport-level rate limiting)
13
+ *
14
+ * @module lib/rate-limit
15
+ * @version 0.1.0
16
+ * @since 0.1.0
17
+ *
18
+ * @example
19
+ * import {MemoryStore, checkRateLimit, defaultKeyGenerator} from 'ergo/lib/rate-limit';
20
+ *
21
+ * const store = new MemoryStore();
22
+ * const key = defaultKeyGenerator(req);
23
+ * const result = checkRateLimit(store, key, 100, 60000);
24
+ * // result.limited === true when over quota
25
+ *
26
+ * @see {@link https://www.rfc-editor.org/rfc/rfc6585#section-4 RFC 6585 Section 4 - 429 Too Many Requests}
27
+ */
28
+
29
+ /**
30
+ * In-memory sliding-window rate limit store.
31
+ * Each key maps to an array of request timestamps. Expired entries are pruned
32
+ * on every `hit()` call.
33
+ */
34
+ export class MemoryStore {
35
+ constructor({maxKeys = 10_000} = {}) {
36
+ this._hits = new Map();
37
+ this._maxKeys = maxKeys;
38
+ }
39
+
40
+ /**
41
+ * Record a hit and return the current count within the window.
42
+ * @param {string} key - Client identifier
43
+ * @param {number} windowMs - Window size in milliseconds
44
+ * @returns {{count: number, resetMs: number}} - Current count and ms until the oldest entry expires
45
+ */
46
+ hit(key, windowMs) {
47
+ const now = Date.now();
48
+ const cutoff = now - windowMs;
49
+ let timestamps = this._hits.get(key);
50
+
51
+ if (!timestamps) {
52
+ timestamps = [];
53
+ this._hits.set(key, timestamps);
54
+ }
55
+
56
+ while (timestamps.length && timestamps[0] <= cutoff) {
57
+ timestamps.shift();
58
+ }
59
+
60
+ if (!timestamps.length) {
61
+ // Delete-then-set refreshes Map insertion order for FIFO eviction
62
+ this._hits.delete(key);
63
+ timestamps = [now];
64
+ this._hits.set(key, timestamps);
65
+ } else {
66
+ timestamps.push(now);
67
+ }
68
+
69
+ // Eviction is silent by design; custom stores can add observability
70
+ if (this._hits.size > this._maxKeys) {
71
+ const oldest = this._hits.keys().next().value;
72
+ this._hits.delete(oldest);
73
+ }
74
+
75
+ const resetMs = timestamps[0] + windowMs - now;
76
+
77
+ return {count: timestamps.length, resetMs};
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Compute rate limit state from a store hit.
83
+ *
84
+ * @param {object} store - Store implementing `hit(key, windowMs) => {count, resetMs}`
85
+ * @param {string} key - Client identifier
86
+ * @param {number} max - Maximum requests allowed per window
87
+ * @param {number} windowMs - Window size in milliseconds
88
+ * @returns {{count: number, remaining: number, reset: number, limited: boolean, retryAfter: number|undefined}}
89
+ * - `count`: total hits in the current window
90
+ * - `remaining`: requests remaining before limit
91
+ * - `reset`: Unix timestamp (seconds) when the window resets
92
+ * - `limited`: true when count exceeds max
93
+ * - `retryAfter`: seconds until retry is allowed (only when limited)
94
+ */
95
+ export function checkRateLimit(store, key, max, windowMs) {
96
+ const {count, resetMs} = store.hit(key, windowMs);
97
+ const limited = count > max;
98
+
99
+ return {
100
+ count,
101
+ remaining: Math.max(0, max - count),
102
+ reset: Math.ceil((Date.now() + resetMs) / 1000),
103
+ limited,
104
+ retryAfter: limited ? Math.ceil(resetMs / 1000) : undefined
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Default key generator: uses the remote IP address.
110
+ * @param {object} req - HTTP request
111
+ * @returns {string} - Client identifier
112
+ */
113
+ export function defaultKeyGenerator(req) {
114
+ return req.socket?.remoteAddress ?? 'unknown';
115
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @fileoverview Shared quoted-string sanitizer per RFC 7230 section 3.2.6.
3
+ *
4
+ * Escapes backslashes and double-quotes and strips control characters (all CTL chars
5
+ * except HTAB, which is valid in `qdtext` per RFC 7230 §3.2.6) so the result is safe
6
+ * for inclusion between double-quote delimiters in HTTP headers such as
7
+ * `WWW-Authenticate`, `Link`, and `Set-Cookie`.
8
+ *
9
+ * @module lib/sanitize-quoted-string
10
+ * @version 0.1.0
11
+ * @since 0.1.0
12
+ */
13
+
14
+ /**
15
+ * Escape a value for use inside a quoted-string per RFC 7230 section 3.2.6.
16
+ *
17
+ * @param {string} str - Raw value
18
+ * @returns {string} - Value safe for inclusion between double-quote delimiters
19
+ */
20
+ export default function sanitizeQuotedString(str) {
21
+ return (
22
+ String(str)
23
+ .replaceAll('\\', '\\\\')
24
+ .replaceAll('"', '\\"')
25
+ // eslint-disable-next-line no-control-regex -- intentional: strip CTLs per RFC 7230 §3.2.6
26
+ .replaceAll(/[\x00-\x08\x0a-\x1f\x7f]/g, '')
27
+ );
28
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @fileoverview Shared security header tuple builder.
3
+ *
4
+ * Builds an array of `[header-name, header-value]` tuples from a configuration
5
+ * object. Used by both ergo's pipeline middleware (`http/security-headers.js`)
6
+ * and ergo-router's transport layer. Each header can be overridden (string),
7
+ * disabled (`false`), or left at its default.
8
+ *
9
+ * `strictTransportSecurity` accepts either a directive string (e.g.
10
+ * `'max-age=31536000; includeSubDomains'`) or a structured object
11
+ * `{maxAge, includeSubDomains, preload}` for programmatic construction.
12
+ *
13
+ * @module lib/security-headers
14
+ * @version 0.1.0
15
+ * @since 0.1.0
16
+ *
17
+ * @example
18
+ * import buildSecurityHeaderTuples from 'ergo/lib/security-headers';
19
+ *
20
+ * const tuples = buildSecurityHeaderTuples({
21
+ * xFrameOptions: 'SAMEORIGIN',
22
+ * strictTransportSecurity: {maxAge: 31536000, includeSubDomains: true}
23
+ * });
24
+ * // [['Content-Security-Policy', "default-src 'none'"], ['Strict-Transport-Security', 'max-age=31536000; includeSubDomains'], ...]
25
+ *
26
+ * @see {@link https://www.rfc-editor.org/rfc/rfc6797 RFC 6797 - HTTP Strict Transport Security}
27
+ * @see {@link https://www.w3.org/TR/CSP3/ W3C Content Security Policy Level 3}
28
+ */
29
+
30
+ /**
31
+ * Default security header values for a REST API.
32
+ * @type {object}
33
+ */
34
+ const DEFAULTS = {
35
+ contentSecurityPolicy: "default-src 'none'",
36
+ strictTransportSecurity: false,
37
+ xContentTypeOptions: 'nosniff',
38
+ xFrameOptions: 'DENY',
39
+ referrerPolicy: 'no-referrer',
40
+ xXssProtection: '0',
41
+ permissionsPolicy: undefined
42
+ };
43
+
44
+ /**
45
+ * Build an array of security header tuples from a configuration object.
46
+ *
47
+ * @param {object} [options] - Security header configuration
48
+ * @param {string|false} [options.contentSecurityPolicy="default-src 'none'"] - Content-Security-Policy
49
+ * @param {string|object|false} [options.strictTransportSecurity=false] - HSTS directive string or
50
+ * `{maxAge, includeSubDomains, preload}` object. Defaults to `false`.
51
+ * @param {string|boolean|false} [options.xContentTypeOptions='nosniff'] - X-Content-Type-Options.
52
+ * `true` is treated as `'nosniff'`.
53
+ * @param {string|false} [options.xFrameOptions='DENY'] - X-Frame-Options
54
+ * @param {string|false} [options.referrerPolicy='no-referrer'] - Referrer-Policy
55
+ * @param {string|false} [options.xXssProtection='0'] - X-XSS-Protection
56
+ * @param {string} [options.permissionsPolicy] - Permissions-Policy (omitted by default)
57
+ * @returns {Array<[string, string]>} - Header tuples suitable for `res.setHeader()` or accumulator storage
58
+ */
59
+ export default function buildSecurityHeaderTuples({
60
+ contentSecurityPolicy = DEFAULTS.contentSecurityPolicy,
61
+ strictTransportSecurity = DEFAULTS.strictTransportSecurity,
62
+ xContentTypeOptions = DEFAULTS.xContentTypeOptions,
63
+ xFrameOptions = DEFAULTS.xFrameOptions,
64
+ referrerPolicy = DEFAULTS.referrerPolicy,
65
+ xXssProtection = DEFAULTS.xXssProtection,
66
+ permissionsPolicy = DEFAULTS.permissionsPolicy
67
+ } = {}) {
68
+ const tuples = [];
69
+
70
+ if (contentSecurityPolicy !== false && contentSecurityPolicy) {
71
+ tuples.push(['Content-Security-Policy', contentSecurityPolicy]);
72
+ }
73
+
74
+ if (strictTransportSecurity !== false && strictTransportSecurity) {
75
+ const value =
76
+ typeof strictTransportSecurity === 'object'
77
+ ? buildHstsDirective(strictTransportSecurity)
78
+ : strictTransportSecurity;
79
+ tuples.push(['Strict-Transport-Security', value]);
80
+ }
81
+
82
+ if (xContentTypeOptions !== false && xContentTypeOptions) {
83
+ tuples.push([
84
+ 'X-Content-Type-Options',
85
+ xContentTypeOptions === true ? 'nosniff' : xContentTypeOptions
86
+ ]);
87
+ }
88
+
89
+ if (xFrameOptions !== false && xFrameOptions) {
90
+ tuples.push(['X-Frame-Options', xFrameOptions]);
91
+ }
92
+
93
+ if (referrerPolicy !== false && referrerPolicy) {
94
+ tuples.push(['Referrer-Policy', referrerPolicy]);
95
+ }
96
+
97
+ if (xXssProtection !== false && xXssProtection !== undefined) {
98
+ tuples.push(['X-XSS-Protection', String(xXssProtection)]);
99
+ }
100
+
101
+ if (permissionsPolicy) {
102
+ tuples.push(['Permissions-Policy', permissionsPolicy]);
103
+ }
104
+
105
+ return tuples;
106
+ }
107
+
108
+ /**
109
+ * Build an HSTS directive string from a structured options object.
110
+ * @param {object} opts - HSTS options
111
+ * @param {number} [opts.maxAge=31536000] - max-age in seconds
112
+ * @param {boolean} [opts.includeSubDomains=true] - include includeSubDomains directive
113
+ * @param {boolean} [opts.preload=false] - include preload directive
114
+ * @returns {string} - HSTS directive string
115
+ */
116
+ function buildHstsDirective({maxAge = 31536000, includeSubDomains = true, preload = false} = {}) {
117
+ let value = `max-age=${maxAge}`;
118
+ if (includeSubDomains !== false) {
119
+ value += '; includeSubDomains';
120
+ }
121
+ if (preload) {
122
+ value += '; preload';
123
+ }
124
+ return value;
125
+ }