@centralping/ergo 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/http/accepts.js +69 -0
- package/http/authorization.js +65 -0
- package/http/body.js +311 -0
- package/http/cache-control.js +123 -0
- package/http/compress.js +157 -0
- package/http/cookie.js +39 -0
- package/http/cors.js +79 -0
- package/http/csrf.js +76 -0
- package/http/handler.js +74 -0
- package/http/index.js +13 -0
- package/http/json-api-query.js +53 -0
- package/http/logger.js +167 -0
- package/http/main.js +140 -0
- package/http/precondition.js +53 -0
- package/http/prefer.js +36 -0
- package/http/rate-limit.js +66 -0
- package/http/security-headers.js +62 -0
- package/http/send.js +399 -0
- package/http/timeout.js +47 -0
- package/http/url.js +47 -0
- package/http/validate.js +84 -0
- package/lib/accepts.js +49 -0
- package/lib/attach-instance.js +23 -0
- package/lib/authorization.js +187 -0
- package/lib/body/multiparse.js +173 -0
- package/lib/body/multipart/headers.js +69 -0
- package/lib/body/writer.js +73 -0
- package/lib/cookie/cookie.js +192 -0
- package/lib/cookie/index.js +14 -0
- package/lib/cookie/jar.js +106 -0
- package/lib/cookie/parse.js +101 -0
- package/lib/cors.js +191 -0
- package/lib/csrf.js +96 -0
- package/lib/from-connect.js +69 -0
- package/lib/json-api-query/index.js +25 -0
- package/lib/json-api-query/schema.json +105 -0
- package/lib/json-api-query/validate.js +56 -0
- package/lib/link.js +96 -0
- package/lib/prefer.js +52 -0
- package/lib/query.js +113 -0
- package/lib/rate-limit.js +115 -0
- package/lib/sanitize-quoted-string.js +28 -0
- package/lib/security-headers.js +125 -0
- package/lib/validate.js +80 -0
- package/lib/vary.js +40 -0
- package/package.json +158 -0
- package/types/http/accepts.d.ts +8 -0
- package/types/http/authorization.d.ts +8 -0
- package/types/http/body.d.ts +20 -0
- package/types/http/cache-control.d.ts +16 -0
- package/types/http/compress.d.ts +5 -0
- package/types/http/cookie.d.ts +2 -0
- package/types/http/cors.d.ts +9 -0
- package/types/http/csrf.d.ts +9 -0
- package/types/http/handler.d.ts +2 -0
- package/types/http/index.d.ts +1 -0
- package/types/http/json-api-query.d.ts +2 -0
- package/types/http/logger.d.ts +9 -0
- package/types/http/main.d.ts +142 -0
- package/types/http/precondition.d.ts +44 -0
- package/types/http/prefer.d.ts +2 -0
- package/types/http/rate-limit.d.ts +17 -0
- package/types/http/security-headers.d.ts +10 -0
- package/types/http/send.d.ts +8 -0
- package/types/http/timeout.d.ts +5 -0
- package/types/http/url.d.ts +2 -0
- package/types/http/validate.d.ts +6 -0
- package/types/lib/accepts.d.ts +7 -0
- package/types/lib/attach-instance.d.ts +19 -0
- package/types/lib/authorization.d.ts +6 -0
- package/types/lib/body/multiparse.d.ts +9 -0
- package/types/lib/body/multipart/headers.d.ts +2 -0
- package/types/lib/body/writer.d.ts +2 -0
- package/types/lib/cookie/cookie.d.ts +32 -0
- package/types/lib/cookie/index.d.ts +2 -0
- package/types/lib/cookie/jar.d.ts +8 -0
- package/types/lib/cookie/parse.d.ts +19 -0
- package/types/lib/cors.d.ts +9 -0
- package/types/lib/csrf.d.ts +32 -0
- package/types/lib/from-connect.d.ts +47 -0
- package/types/lib/json-api-query/index.d.ts +123 -0
- package/types/lib/json-api-query/validate.d.ts +5 -0
- package/types/lib/link.d.ts +37 -0
- package/types/lib/prefer.d.ts +36 -0
- package/types/lib/query.d.ts +6 -0
- package/types/lib/rate-limit.d.ts +76 -0
- package/types/lib/sanitize-quoted-string.d.ts +19 -0
- package/types/lib/security-headers.d.ts +24 -0
- package/types/lib/validate.d.ts +16 -0
- package/types/lib/vary.d.ts +17 -0
- package/types/utils/attempt.d.ts +2 -0
- package/types/utils/buffers/index.d.ts +2 -0
- package/types/utils/buffers/match.d.ts +10 -0
- package/types/utils/buffers/split.d.ts +10 -0
- package/types/utils/compose-with.d.ts +40 -0
- package/types/utils/compose.d.ts +83 -0
- package/types/utils/flat-array.d.ts +2 -0
- package/types/utils/get.d.ts +5 -0
- package/types/utils/http-errors.d.ts +22 -0
- package/types/utils/iterables/buffer-split.d.ts +2 -0
- package/types/utils/iterables/chain.d.ts +2 -0
- package/types/utils/iterables/exec-all.d.ts +2 -0
- package/types/utils/iterables/filter.d.ts +2 -0
- package/types/utils/iterables/for-each.d.ts +2 -0
- package/types/utils/iterables/from-stream.d.ts +2 -0
- package/types/utils/iterables/index.d.ts +10 -0
- package/types/utils/iterables/map.d.ts +2 -0
- package/types/utils/iterables/range.d.ts +24 -0
- package/types/utils/iterables/reduce.d.ts +2 -0
- package/types/utils/iterables/take.d.ts +2 -0
- package/types/utils/observables/buffer-split.d.ts +2 -0
- package/types/utils/observables/chain.d.ts +2 -0
- package/types/utils/observables/index.d.ts +4 -0
- package/types/utils/observables/map.d.ts +2 -0
- package/types/utils/observables/take.d.ts +2 -0
- package/types/utils/pick.d.ts +2 -0
- package/types/utils/set.d.ts +2 -0
- package/types/utils/streams/index.d.ts +2 -0
- package/types/utils/streams/meter.d.ts +5 -0
- package/types/utils/streams/tee.d.ts +2 -0
- package/types/utils/type.d.ts +2 -0
- package/utils/attempt.js +37 -0
- package/utils/buffers/index.js +13 -0
- package/utils/buffers/match.js +96 -0
- package/utils/buffers/split.js +55 -0
- package/utils/compose-with.js +232 -0
- package/utils/compose.js +165 -0
- package/utils/flat-array.js +24 -0
- package/utils/get.js +39 -0
- package/utils/http-errors.js +113 -0
- package/utils/iterables/buffer-split.js +117 -0
- package/utils/iterables/chain.js +32 -0
- package/utils/iterables/exec-all.js +42 -0
- package/utils/iterables/filter.js +35 -0
- package/utils/iterables/for-each.js +33 -0
- package/utils/iterables/from-stream.js +29 -0
- package/utils/iterables/index.js +21 -0
- package/utils/iterables/map.js +47 -0
- package/utils/iterables/range.js +34 -0
- package/utils/iterables/reduce.js +43 -0
- package/utils/iterables/take.js +36 -0
- package/utils/observables/buffer-split.js +109 -0
- package/utils/observables/chain.js +33 -0
- package/utils/observables/index.js +19 -0
- package/utils/observables/map.js +34 -0
- package/utils/observables/take.js +40 -0
- package/utils/pick.js +41 -0
- package/utils/set.js +38 -0
- package/utils/streams/index.js +11 -0
- package/utils/streams/meter.js +98 -0
- package/utils/streams/tee.js +84 -0
- package/utils/type.js +47 -0
|
@@ -0,0 +1,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
|
+
}
|