@centralping/ergo 0.1.0-beta.1 → 0.1.0-beta.3
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/README.md +14 -15
- package/http/accepts.js +1 -2
- package/http/authorization.js +1 -2
- package/http/body.js +1 -2
- package/http/cache-control.js +1 -2
- package/http/compress.js +1 -2
- package/http/cookie.js +1 -2
- package/http/cors.js +1 -2
- package/http/csrf.js +1 -2
- package/http/handler.js +1 -2
- package/http/idempotency.js +110 -0
- package/http/index.js +37 -4
- package/http/json-api-query.js +1 -2
- package/http/logger.js +1 -2
- package/http/main.js +5 -3
- package/http/precondition.js +1 -2
- package/http/prefer.js +1 -2
- package/http/rate-limit.js +1 -2
- package/http/security-headers.js +1 -2
- package/http/send.js +1 -2
- package/http/timeout.js +1 -2
- package/http/url.js +1 -2
- package/http/validate.js +1 -2
- package/lib/accepts.js +1 -2
- package/lib/attach-instance.js +0 -1
- package/lib/authorization.js +1 -2
- package/lib/body/multiparse.js +1 -2
- package/lib/body/multipart/headers.js +1 -2
- package/lib/body/writer.js +1 -2
- package/lib/cookie/cookie.js +1 -2
- package/lib/cookie/index.js +0 -1
- package/lib/cookie/jar.js +16 -12
- package/lib/cookie/parse.js +1 -2
- package/lib/cors.js +1 -2
- package/lib/csrf.js +1 -2
- package/lib/from-connect.js +2 -3
- package/lib/idempotency.js +139 -0
- package/lib/json-api-query/index.js +0 -1
- package/lib/json-api-query/validate.js +1 -2
- package/lib/link.js +57 -5
- package/lib/prefer.js +1 -2
- package/lib/query.js +1 -2
- package/lib/rate-limit.js +1 -2
- package/lib/sanitize-quoted-string.js +0 -1
- package/lib/security-headers.js +1 -2
- package/lib/validate.js +1 -2
- package/lib/vary.js +0 -1
- package/package.json +2 -2
- package/types/http/idempotency.d.ts +20 -0
- package/types/http/main.d.ts +3 -1
- package/types/http/precondition.d.ts +1 -2
- package/types/lib/attach-instance.d.ts +0 -1
- package/types/lib/cookie/jar.d.ts +4 -0
- package/types/lib/from-connect.d.ts +2 -3
- package/types/lib/idempotency.d.ts +64 -0
- package/types/lib/link.d.ts +28 -0
- package/types/lib/prefer.d.ts +1 -2
- package/types/lib/rate-limit.d.ts +1 -2
- package/types/lib/sanitize-quoted-string.d.ts +0 -1
- package/types/lib/vary.d.ts +0 -1
- package/types/utils/compose.d.ts +1 -2
- package/types/utils/iterables/range.d.ts +1 -2
- package/utils/attempt.js +1 -2
- package/utils/buffers/index.js +0 -1
- package/utils/buffers/match.js +1 -2
- package/utils/buffers/split.js +1 -2
- package/utils/compose-with.js +1 -2
- package/utils/compose.js +1 -2
- package/utils/flat-array.js +1 -2
- package/utils/get.js +1 -2
- package/utils/http-errors.js +1 -2
- package/utils/iterables/buffer-split.js +1 -2
- package/utils/iterables/chain.js +1 -2
- package/utils/iterables/exec-all.js +1 -2
- package/utils/iterables/filter.js +1 -2
- package/utils/iterables/for-each.js +1 -2
- package/utils/iterables/from-stream.js +1 -2
- package/utils/iterables/index.js +0 -1
- package/utils/iterables/map.js +1 -2
- package/utils/iterables/range.js +1 -2
- package/utils/iterables/reduce.js +1 -2
- package/utils/iterables/take.js +1 -2
- package/utils/observables/buffer-split.js +0 -1
- package/utils/observables/chain.js +0 -1
- package/utils/observables/index.js +0 -1
- package/utils/observables/map.js +0 -1
- package/utils/observables/take.js +0 -1
- package/utils/pick.js +1 -2
- package/utils/set.js +1 -2
- package/utils/streams/index.js +0 -1
- package/utils/streams/meter.js +1 -2
- package/utils/streams/tee.js +1 -2
- package/utils/type.js +1 -2
package/lib/cookie/cookie.js
CHANGED
|
@@ -9,11 +9,10 @@
|
|
|
9
9
|
* immediately by defaulting `maxAge` to 0.
|
|
10
10
|
*
|
|
11
11
|
* @module lib/cookie/cookie
|
|
12
|
-
* @version 0.1.0
|
|
13
12
|
* @since 0.1.0
|
|
14
13
|
*
|
|
15
14
|
* @example
|
|
16
|
-
* import bake from 'ergo/lib/cookie/cookie';
|
|
15
|
+
* import bake from '@centralping/ergo/lib/cookie/cookie';
|
|
17
16
|
*
|
|
18
17
|
* const c = bake('session', 'abc123', {maxAge: 3600, sameSite: 'Lax'});
|
|
19
18
|
* c.toHeader(); // 'session=abc123; Path=/; Max-Age=3600; SameSite=Lax; Secure; HttpOnly'
|
package/lib/cookie/index.js
CHANGED
package/lib/cookie/jar.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Creates a cookie jar with dual-storage semantics for per-request cookie management.
|
|
5
5
|
*
|
|
6
6
|
* **Incoming cookies** (from the `Cookie` header) are parsed as raw `name: value` strings
|
|
7
|
-
* and stored as own properties
|
|
7
|
+
* and stored as own properties. Per RFC 6265 §5.4, the browser sends
|
|
8
8
|
* only `name=value` pairs — no directives. Access them directly: `jar.session`.
|
|
9
9
|
*
|
|
10
10
|
* **Outgoing cookies** (created via `set()`) are full cookie objects with directives,
|
|
@@ -19,14 +19,13 @@
|
|
|
19
19
|
* - `toHeader()` — serializes outgoing cookies to `Set-Cookie` header strings
|
|
20
20
|
*
|
|
21
21
|
* @module lib/cookie/jar
|
|
22
|
-
* @version 0.1.0
|
|
23
22
|
* @since 0.1.0
|
|
24
23
|
* @requires ./cookie.js
|
|
25
24
|
* @requires ../../utils/iterables/index.js
|
|
26
25
|
*
|
|
27
26
|
* @example
|
|
28
|
-
* import jar from 'ergo/lib/cookie/jar';
|
|
29
|
-
* import parse from 'ergo/lib/cookie/parse';
|
|
27
|
+
* import jar from '@centralping/ergo/lib/cookie/jar';
|
|
28
|
+
* import parse from '@centralping/ergo/lib/cookie/parse';
|
|
30
29
|
*
|
|
31
30
|
* const cookies = jar(parse('session=abc123; lang=en'));
|
|
32
31
|
* cookies.session; // => 'abc123' (incoming, own property)
|
|
@@ -91,16 +90,21 @@ const clay = Object.create(
|
|
|
91
90
|
/**
|
|
92
91
|
* Creates a cookie jar pre-populated from a parsed cookie object.
|
|
93
92
|
*
|
|
93
|
+
* Incoming cookie names that collide with jar prototype methods or own properties
|
|
94
|
+
* (`set`, `get`, `clear`, `toHeader`, `isJar`, `size`, `jar`) are silently dropped
|
|
95
|
+
* to prevent `TypeError` from strict-mode assignment to non-writable properties.
|
|
96
|
+
*
|
|
94
97
|
* @param {object} [cookies={}] - Initial cookie values (from `parse()`)
|
|
95
98
|
* @returns {object} - Cookie jar with `get`, `set`, `clear`, `toHeader`, `size` members
|
|
96
99
|
*/
|
|
97
100
|
function jar(cookies = {}) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
const instance = Object.create(clay, {jar: {value: new Map()}});
|
|
102
|
+
|
|
103
|
+
for (const key of Object.keys(cookies)) {
|
|
104
|
+
if (!(key in instance)) {
|
|
105
|
+
instance[key] = cookies[key];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return instance;
|
|
106
110
|
}
|
package/lib/cookie/parse.js
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
* to protect against header-stuffing attacks.
|
|
11
11
|
*
|
|
12
12
|
* @module lib/cookie/parse
|
|
13
|
-
* @version 0.1.0
|
|
14
13
|
* @since 0.1.0
|
|
15
14
|
* @requires ../../utils/iterables/index.js
|
|
16
15
|
*
|
|
@@ -18,7 +17,7 @@
|
|
|
18
17
|
* @see {@link https://www.rfc-editor.org/rfc/rfc2109 RFC 2109 - HTTP State Management Mechanism (obsoleted)}
|
|
19
18
|
*
|
|
20
19
|
* @example
|
|
21
|
-
* import parse from 'ergo/lib/cookie/parse';
|
|
20
|
+
* import parse from '@centralping/ergo/lib/cookie/parse';
|
|
22
21
|
*
|
|
23
22
|
* parse('session=abc123; user=alice');
|
|
24
23
|
* // => {session: 'abc123', user: 'alice'}
|
package/lib/cors.js
CHANGED
|
@@ -15,13 +15,12 @@
|
|
|
15
15
|
* @see {@link https://www.w3.org/TR/2014/REC-cors-20140116/}
|
|
16
16
|
*
|
|
17
17
|
* @module lib/cors
|
|
18
|
-
* @version 0.1.0
|
|
19
18
|
* @since 0.1.0
|
|
20
19
|
* @requires ../utils/type.js
|
|
21
20
|
* @requires ../utils/flat-array.js
|
|
22
21
|
*
|
|
23
22
|
* @example
|
|
24
|
-
* import cors from 'ergo/lib/cors';
|
|
23
|
+
* import cors from '@centralping/ergo/lib/cors';
|
|
25
24
|
*
|
|
26
25
|
* const corsValidator = cors({
|
|
27
26
|
* origins: ['https://app.example.com'],
|
package/lib/csrf.js
CHANGED
|
@@ -9,13 +9,12 @@
|
|
|
9
9
|
* can be reissued without changing the UUID.
|
|
10
10
|
*
|
|
11
11
|
* @module lib/csrf
|
|
12
|
-
* @version 0.1.0
|
|
13
12
|
* @since 0.1.0
|
|
14
13
|
* @requires node:crypto
|
|
15
14
|
* @requires ../utils/type.js
|
|
16
15
|
*
|
|
17
16
|
* @example
|
|
18
|
-
* import {issue, verify} from 'ergo/lib/csrf';
|
|
17
|
+
* import {issue, verify} from '@centralping/ergo/lib/csrf';
|
|
19
18
|
*
|
|
20
19
|
* const {token, uuid} = issue('my-secret');
|
|
21
20
|
* // token => 'base64-encoded-hmac'
|
package/lib/from-connect.js
CHANGED
|
@@ -23,12 +23,11 @@
|
|
|
23
23
|
* no-mutation convention but is accepted at the interop boundary.
|
|
24
24
|
*
|
|
25
25
|
* @module lib/from-connect
|
|
26
|
-
* @version 0.1.0
|
|
27
26
|
* @since 0.1.0
|
|
28
27
|
*
|
|
29
28
|
* @example
|
|
30
|
-
* import {compose, handler} from 'ergo';
|
|
31
|
-
* import fromConnect from 'ergo/lib/from-connect';
|
|
29
|
+
* import {compose, handler} from '@centralping/ergo';
|
|
30
|
+
* import fromConnect from '@centralping/ergo/lib/from-connect';
|
|
32
31
|
* import helmet from 'helmet';
|
|
33
32
|
*
|
|
34
33
|
* const pipeline = compose(
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Idempotency-Key shared primitives.
|
|
3
|
+
*
|
|
4
|
+
* Provides the core building blocks for idempotent request handling per
|
|
5
|
+
* draft-ietf-httpapi-idempotency-key-header-07. The `IdempotencyStore`
|
|
6
|
+
* manages key lifecycle (storage, expiry, replay). `parseIdempotencyKey`
|
|
7
|
+
* extracts the key value from the RFC 8941 structured field header.
|
|
8
|
+
* `generateFingerprint` creates a SHA-256 hash of the request body for
|
|
9
|
+
* detecting key reuse with different payloads.
|
|
10
|
+
*
|
|
11
|
+
* Used by:
|
|
12
|
+
* - `http/idempotency.js` (ergo pipeline middleware)
|
|
13
|
+
*
|
|
14
|
+
* @module lib/idempotency
|
|
15
|
+
* @since 0.1.0-beta.2
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* import {IdempotencyStore, parseIdempotencyKey, generateFingerprint}
|
|
19
|
+
* from '@centralping/ergo/lib/idempotency';
|
|
20
|
+
*
|
|
21
|
+
* const store = new IdempotencyStore();
|
|
22
|
+
* const key = parseIdempotencyKey(req.headers['idempotency-key']);
|
|
23
|
+
* const fingerprint = generateFingerprint(body);
|
|
24
|
+
*
|
|
25
|
+
* @see {@link https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/ Idempotency-Key Header (IETF Draft)}
|
|
26
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc8941 RFC 8941 - Structured Field Values}
|
|
27
|
+
*/
|
|
28
|
+
import {createHash} from 'node:crypto';
|
|
29
|
+
|
|
30
|
+
const SF_STRING_RE = /^"([^"\\]*(?:\\.[^"\\]*)*)"$/;
|
|
31
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* In-memory idempotency key store with TTL-based expiry.
|
|
35
|
+
*
|
|
36
|
+
* Entries transition through states: `processing` -> `complete`.
|
|
37
|
+
* Expired entries are pruned lazily on `get()` calls.
|
|
38
|
+
*/
|
|
39
|
+
export class IdempotencyStore {
|
|
40
|
+
constructor({maxKeys = 10_000, ttlMs = DEFAULT_TTL_MS} = {}) {
|
|
41
|
+
this._entries = new Map();
|
|
42
|
+
this._maxKeys = maxKeys;
|
|
43
|
+
this._ttlMs = ttlMs;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Retrieve a stored entry by key. Returns `undefined` if not found or expired.
|
|
48
|
+
* @param {string} key - Idempotency key
|
|
49
|
+
* @returns {{fingerprint: string, response: object, status: string, expiresAt: number} | undefined}
|
|
50
|
+
*/
|
|
51
|
+
get(key) {
|
|
52
|
+
const entry = this._entries.get(key);
|
|
53
|
+
if (!entry) return undefined;
|
|
54
|
+
|
|
55
|
+
if (Date.now() > entry.expiresAt) {
|
|
56
|
+
this._entries.delete(key);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return entry;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Store a new idempotency entry in `processing` state.
|
|
65
|
+
* @param {string} key - Idempotency key
|
|
66
|
+
* @param {string} fingerprint - Request body fingerprint
|
|
67
|
+
*/
|
|
68
|
+
set(key, fingerprint) {
|
|
69
|
+
if (this._entries.size >= this._maxKeys) {
|
|
70
|
+
const oldest = this._entries.keys().next().value;
|
|
71
|
+
this._entries.delete(oldest);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const entry = Object.create(null);
|
|
75
|
+
entry.fingerprint = fingerprint;
|
|
76
|
+
entry.response = undefined;
|
|
77
|
+
entry.status = 'processing';
|
|
78
|
+
entry.expiresAt = Date.now() + this._ttlMs;
|
|
79
|
+
this._entries.set(key, entry);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mark an entry as complete with the response to replay.
|
|
84
|
+
* @param {string} key - Idempotency key
|
|
85
|
+
* @param {object} response - Response accumulator snapshot to replay
|
|
86
|
+
*/
|
|
87
|
+
complete(key, response) {
|
|
88
|
+
const entry = this._entries.get(key);
|
|
89
|
+
if (entry) {
|
|
90
|
+
entry.status = 'complete';
|
|
91
|
+
entry.response = response;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Remove an entry (e.g. on request failure where replay is not desired).
|
|
97
|
+
* @param {string} key - Idempotency key
|
|
98
|
+
*/
|
|
99
|
+
delete(key) {
|
|
100
|
+
this._entries.delete(key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse an `Idempotency-Key` header value as an RFC 8941 sf-string.
|
|
106
|
+
*
|
|
107
|
+
* The draft specifies the header is an Item Structured Header whose value
|
|
108
|
+
* is a String. This means the wire format is `"value"` (double-quoted).
|
|
109
|
+
* Returns `undefined` for missing, empty, or malformed values.
|
|
110
|
+
*
|
|
111
|
+
* @param {string | undefined} header - Raw header value
|
|
112
|
+
* @returns {string | undefined} - Parsed key value, or undefined
|
|
113
|
+
*/
|
|
114
|
+
export function parseIdempotencyKey(header) {
|
|
115
|
+
if (!header) return undefined;
|
|
116
|
+
|
|
117
|
+
const trimmed = header.trim();
|
|
118
|
+
const match = SF_STRING_RE.exec(trimmed);
|
|
119
|
+
if (!match) return undefined;
|
|
120
|
+
|
|
121
|
+
const inner = match[1];
|
|
122
|
+
if (inner.replace(/\\["\\]/g, '').includes('\\')) return undefined;
|
|
123
|
+
return inner.replace(/\\(["\\])/g, '$1');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Generate a SHA-256 fingerprint of the request body.
|
|
128
|
+
*
|
|
129
|
+
* Used to detect key reuse with different payloads (which should return 409).
|
|
130
|
+
* Accepts strings or Buffers. Returns the hex digest.
|
|
131
|
+
*
|
|
132
|
+
* @param {string | Buffer} body - Serialized request body
|
|
133
|
+
* @returns {string} - Hex-encoded SHA-256 hash
|
|
134
|
+
*/
|
|
135
|
+
export function generateFingerprint(body) {
|
|
136
|
+
return createHash('sha256')
|
|
137
|
+
.update(body ?? '')
|
|
138
|
+
.digest('hex');
|
|
139
|
+
}
|
|
@@ -9,13 +9,12 @@
|
|
|
9
9
|
* to handle single-value query parameters that may be strings instead of arrays.
|
|
10
10
|
*
|
|
11
11
|
* @module lib/json-api-query/validate
|
|
12
|
-
* @version 0.1.0
|
|
13
12
|
* @since 0.1.0
|
|
14
13
|
* @requires ajv/dist/2020.js
|
|
15
14
|
* @requires ./schema.json
|
|
16
15
|
*
|
|
17
16
|
* @example
|
|
18
|
-
* import validate from 'ergo/lib/json-api-query/validate';
|
|
17
|
+
* import validate from '@centralping/ergo/lib/json-api-query/validate';
|
|
19
18
|
*
|
|
20
19
|
* const validator = validate();
|
|
21
20
|
* const valid = validator({include: ['author'], fields: {articles: ['title']}});
|
package/lib/link.js
CHANGED
|
@@ -2,22 +2,23 @@
|
|
|
2
2
|
* @fileoverview RFC 8288 Web Linking utilities.
|
|
3
3
|
*
|
|
4
4
|
* Provides functions for formatting `Link` response headers per RFC 8288.
|
|
5
|
-
* `formatLinkHeader` is the low-level formatter; `paginationLinks`
|
|
6
|
-
* convenience
|
|
7
|
-
*
|
|
5
|
+
* `formatLinkHeader` is the low-level formatter; `paginationLinks` and
|
|
6
|
+
* `cursorPaginationLinks` are convenience helpers that generate link objects
|
|
7
|
+
* for offset-based and cursor-based pagination respectively.
|
|
8
8
|
*
|
|
9
9
|
* These are pure utility functions (not middleware). Consumers wire the
|
|
10
10
|
* formatted header value into the accumulator's `headers` array for `send()`.
|
|
11
11
|
*
|
|
12
12
|
* @module lib/link
|
|
13
|
-
* @version 0.1.0
|
|
14
13
|
* @since 0.1.0
|
|
15
14
|
*
|
|
16
15
|
* @see {@link https://www.rfc-editor.org/rfc/rfc8288 RFC 8288 - Web Linking}
|
|
17
16
|
*
|
|
18
17
|
* @example
|
|
19
|
-
* import {formatLinkHeader, paginationLinks}
|
|
18
|
+
* import {formatLinkHeader, paginationLinks, cursorPaginationLinks}
|
|
19
|
+
* from '@centralping/ergo/lib/link';
|
|
20
20
|
*
|
|
21
|
+
* // Offset-based pagination
|
|
21
22
|
* const links = paginationLinks({
|
|
22
23
|
* baseUrl: '/articles',
|
|
23
24
|
* searchParams: 'sort=date',
|
|
@@ -27,6 +28,15 @@
|
|
|
27
28
|
* });
|
|
28
29
|
* const header = formatLinkHeader(links);
|
|
29
30
|
* // '</articles?sort=date&page=1&per_page=25>; rel="first", ...'
|
|
31
|
+
*
|
|
32
|
+
* // Cursor-based pagination
|
|
33
|
+
* const cursorLinks = cursorPaginationLinks({
|
|
34
|
+
* baseUrl: '/articles',
|
|
35
|
+
* searchParams: 'sort=date',
|
|
36
|
+
* nextCursor: 'eyJpZCI6NDJ9.abc123'
|
|
37
|
+
* });
|
|
38
|
+
* const cursorHeader = formatLinkHeader(cursorLinks);
|
|
39
|
+
* // '</articles?sort=date>; rel="first", </articles?sort=date&cursor=eyJpZCI6NDJ9.abc123>; rel="next"'
|
|
30
40
|
*/
|
|
31
41
|
import sanitizeQuotedString from './sanitize-quoted-string.js';
|
|
32
42
|
|
|
@@ -94,3 +104,45 @@ export function paginationLinks({baseUrl, page, perPage, total, searchParams = '
|
|
|
94
104
|
|
|
95
105
|
return links;
|
|
96
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Generates cursor-based pagination link objects for first, prev, and next pages.
|
|
110
|
+
*
|
|
111
|
+
* Unlike offset pagination, cursor-based pagination uses opaque continuation
|
|
112
|
+
* tokens instead of page numbers. There is no `last` link because the total
|
|
113
|
+
* number of pages is unknown in cursor-based schemes.
|
|
114
|
+
*
|
|
115
|
+
* The `first` link is always present and points to the base URL without any
|
|
116
|
+
* cursor parameter (requesting the first page). `prev` and `next` are
|
|
117
|
+
* included only when the corresponding cursor token is provided.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} options - Cursor pagination parameters
|
|
120
|
+
* @param {string} options.baseUrl - Base URL path (e.g. '/articles')
|
|
121
|
+
* @param {string} [options.searchParams=''] - Additional query parameters to preserve
|
|
122
|
+
* (e.g. 'sort=date&filter=active'). Appended before the cursor parameter.
|
|
123
|
+
* @param {string} [options.nextCursor] - Opaque cursor token for the next page
|
|
124
|
+
* @param {string} [options.prevCursor] - Opaque cursor token for the previous page
|
|
125
|
+
* @returns {Array<{href: string, rel: string}>} - Array of link objects
|
|
126
|
+
*/
|
|
127
|
+
export function cursorPaginationLinks({baseUrl, searchParams = '', nextCursor, prevCursor}) {
|
|
128
|
+
const base = searchParams ? `${baseUrl}?${searchParams}` : baseUrl;
|
|
129
|
+
const sep = searchParams ? '&' : '?';
|
|
130
|
+
|
|
131
|
+
const links = [{href: base, rel: 'first'}];
|
|
132
|
+
|
|
133
|
+
if (prevCursor !== undefined) {
|
|
134
|
+
links.push({
|
|
135
|
+
href: `${base}${sep}cursor=${encodeURIComponent(prevCursor)}`,
|
|
136
|
+
rel: 'prev'
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (nextCursor !== undefined) {
|
|
141
|
+
links.push({
|
|
142
|
+
href: `${base}${sep}cursor=${encodeURIComponent(nextCursor)}`,
|
|
143
|
+
rel: 'next'
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return links;
|
|
148
|
+
}
|
package/lib/prefer.js
CHANGED
|
@@ -13,11 +13,10 @@
|
|
|
13
13
|
* - `http/prefer.js` (ergo pipeline middleware)
|
|
14
14
|
*
|
|
15
15
|
* @module lib/prefer
|
|
16
|
-
* @version 0.1.0
|
|
17
16
|
* @since 0.1.0
|
|
18
17
|
*
|
|
19
18
|
* @example
|
|
20
|
-
* import parsePrefer from 'ergo/lib/prefer';
|
|
19
|
+
* import parsePrefer from '@centralping/ergo/lib/prefer';
|
|
21
20
|
*
|
|
22
21
|
* parsePrefer('return=minimal');
|
|
23
22
|
* // {return: 'minimal'}
|
package/lib/query.js
CHANGED
|
@@ -11,12 +11,11 @@
|
|
|
11
11
|
* JSON:API query parameter parsing.
|
|
12
12
|
*
|
|
13
13
|
* @module lib/query
|
|
14
|
-
* @version 0.1.0
|
|
15
14
|
* @since 0.1.0
|
|
16
15
|
* @requires ../utils/set.js
|
|
17
16
|
*
|
|
18
17
|
* @example
|
|
19
|
-
* import parse from 'ergo/lib/query';
|
|
18
|
+
* import parse from '@centralping/ergo/lib/query';
|
|
20
19
|
*
|
|
21
20
|
* parse('include=author&fields%5Barticles%5D=title%2Cbody');
|
|
22
21
|
* // => {include: 'author', fields: {articles: ['title', 'body']}}
|
package/lib/rate-limit.js
CHANGED
|
@@ -12,11 +12,10 @@
|
|
|
12
12
|
* - `ergo-router/lib/transport/rate-limit.js` (transport-level rate limiting)
|
|
13
13
|
*
|
|
14
14
|
* @module lib/rate-limit
|
|
15
|
-
* @version 0.1.0
|
|
16
15
|
* @since 0.1.0
|
|
17
16
|
*
|
|
18
17
|
* @example
|
|
19
|
-
* import {MemoryStore, checkRateLimit, defaultKeyGenerator} from 'ergo/lib/rate-limit';
|
|
18
|
+
* import {MemoryStore, checkRateLimit, defaultKeyGenerator} from '@centralping/ergo/lib/rate-limit';
|
|
20
19
|
*
|
|
21
20
|
* const store = new MemoryStore();
|
|
22
21
|
* const key = defaultKeyGenerator(req);
|
package/lib/security-headers.js
CHANGED
|
@@ -11,11 +11,10 @@
|
|
|
11
11
|
* `{maxAge, includeSubDomains, preload}` for programmatic construction.
|
|
12
12
|
*
|
|
13
13
|
* @module lib/security-headers
|
|
14
|
-
* @version 0.1.0
|
|
15
14
|
* @since 0.1.0
|
|
16
15
|
*
|
|
17
16
|
* @example
|
|
18
|
-
* import buildSecurityHeaderTuples from 'ergo/lib/security-headers';
|
|
17
|
+
* import buildSecurityHeaderTuples from '@centralping/ergo/lib/security-headers';
|
|
19
18
|
*
|
|
20
19
|
* const tuples = buildSecurityHeaderTuples({
|
|
21
20
|
* xFrameOptions: 'SAMEORIGIN',
|
package/lib/validate.js
CHANGED
|
@@ -8,13 +8,12 @@
|
|
|
8
8
|
* Used by `http/validate.js` as the pure-logic backing implementation.
|
|
9
9
|
*
|
|
10
10
|
* @module lib/validate
|
|
11
|
-
* @version 0.1.0
|
|
12
11
|
* @since 0.1.0
|
|
13
12
|
* @requires ajv
|
|
14
13
|
* @requires ../utils/http-errors.js
|
|
15
14
|
*
|
|
16
15
|
* @example
|
|
17
|
-
* import createValidator from 'ergo/lib/validate';
|
|
16
|
+
* import createValidator from '@centralping/ergo/lib/validate';
|
|
18
17
|
*
|
|
19
18
|
* const validate = createValidator({
|
|
20
19
|
* type: 'object',
|
package/lib/vary.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@centralping/ergo",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.3",
|
|
4
4
|
"description": "A Fast Fail REST API toolkit for Node.js -- composable middleware with structured Negotiation, Authorization, Validation, and Execution stages.",
|
|
5
5
|
"main": "http/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"publishConfig": {
|
|
110
110
|
"access": "public"
|
|
111
111
|
},
|
|
112
|
-
"homepage": "https://github.
|
|
112
|
+
"homepage": "https://centralping.github.io/packages/ergo/",
|
|
113
113
|
"funding": {
|
|
114
114
|
"type": "github",
|
|
115
115
|
"url": "https://github.com/sponsors/jasoncust"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create an idempotency middleware.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} [options]
|
|
5
|
+
* @param {object} [options.store] - Pluggable store (must implement get/set/complete/delete).
|
|
6
|
+
* Defaults to an in-memory store with 24h TTL.
|
|
7
|
+
* @param {number} [options.ttlMs=86400000] - TTL for stored entries in milliseconds (default: 24h).
|
|
8
|
+
* Only used when creating the default in-memory store.
|
|
9
|
+
* @param {boolean} [options.required=false] - When true, returns 400 if the header is missing
|
|
10
|
+
* on applicable methods
|
|
11
|
+
* @param {Set<string>|string[]} [options.methods] - HTTP methods to apply idempotency to
|
|
12
|
+
* (default: POST, PATCH)
|
|
13
|
+
* @returns {function} - Middleware `(req, res, domainAcc) => {value?, response?}`
|
|
14
|
+
*/
|
|
15
|
+
export default function idempotency({ store, ttlMs, required, methods }?: {
|
|
16
|
+
store?: object | undefined;
|
|
17
|
+
ttlMs?: number | undefined;
|
|
18
|
+
required?: boolean | undefined;
|
|
19
|
+
methods?: string[] | Set<string> | undefined;
|
|
20
|
+
}): Function;
|
package/types/http/main.d.ts
CHANGED
|
@@ -75,6 +75,7 @@ declare const _default: {
|
|
|
75
75
|
}) => object;
|
|
76
76
|
fromConnect: typeof fromConnect;
|
|
77
77
|
httpErrors: typeof httpErrors;
|
|
78
|
+
idempotency: typeof idempotency;
|
|
78
79
|
jsonApiQuery: (...options: any[]) => Function;
|
|
79
80
|
logger: ({ log, error: logError, uuid, headerRequestIdName, headerRequestIpName, redactHeaders }?: {
|
|
80
81
|
log?: Function | undefined;
|
|
@@ -129,6 +130,7 @@ import cors from './cors.js';
|
|
|
129
130
|
import csrf from './csrf.js';
|
|
130
131
|
import fromConnect from '../lib/from-connect.js';
|
|
131
132
|
import httpErrors from '../utils/http-errors.js';
|
|
133
|
+
import idempotency from './idempotency.js';
|
|
132
134
|
import jsonApiQuery from './json-api-query.js';
|
|
133
135
|
import logger from './logger.js';
|
|
134
136
|
import prefer from './prefer.js';
|
|
@@ -139,4 +141,4 @@ import url from './url.js';
|
|
|
139
141
|
import send from './send.js';
|
|
140
142
|
import timeout from './timeout.js';
|
|
141
143
|
import validate from './validate.js';
|
|
142
|
-
export { compose, createResponseAcc, mergeResponse, handler, accepts, authorization, body, cacheControl, compress, cookie, cors, csrf, fromConnect, httpErrors, jsonApiQuery, logger, prefer, precondition, rateLimit, securityHeaders, url, send, timeout, validate };
|
|
144
|
+
export { compose, createResponseAcc, mergeResponse, handler, accepts, authorization, body, cacheControl, compress, cookie, cors, csrf, fromConnect, httpErrors, idempotency, jsonApiQuery, logger, prefer, precondition, rateLimit, securityHeaders, url, send, timeout, validate };
|
|
@@ -10,11 +10,10 @@
|
|
|
10
10
|
* inspection that short-circuits before authorization, body parsing, or execution.
|
|
11
11
|
*
|
|
12
12
|
* @module http/precondition
|
|
13
|
-
* @version 0.1.0
|
|
14
13
|
* @since 0.1.0
|
|
15
14
|
*
|
|
16
15
|
* @example
|
|
17
|
-
* import {compose, precondition} from 'ergo';
|
|
16
|
+
* import {compose, precondition} from '@centralping/ergo';
|
|
18
17
|
*
|
|
19
18
|
* // Enforce on all requests (method scoping handled by pipeline builder)
|
|
20
19
|
* const pipeline = compose(
|
|
@@ -2,6 +2,10 @@ export default jar;
|
|
|
2
2
|
/**
|
|
3
3
|
* Creates a cookie jar pre-populated from a parsed cookie object.
|
|
4
4
|
*
|
|
5
|
+
* Incoming cookie names that collide with jar prototype methods or own properties
|
|
6
|
+
* (`set`, `get`, `clear`, `toHeader`, `isJar`, `size`, `jar`) are silently dropped
|
|
7
|
+
* to prevent `TypeError` from strict-mode assignment to non-writable properties.
|
|
8
|
+
*
|
|
5
9
|
* @param {object} [cookies={}] - Initial cookie values (from `parse()`)
|
|
6
10
|
* @returns {object} - Cookie jar with `get`, `set`, `clear`, `toHeader`, `size` members
|
|
7
11
|
*/
|
|
@@ -23,12 +23,11 @@
|
|
|
23
23
|
* no-mutation convention but is accepted at the interop boundary.
|
|
24
24
|
*
|
|
25
25
|
* @module lib/from-connect
|
|
26
|
-
* @version 0.1.0
|
|
27
26
|
* @since 0.1.0
|
|
28
27
|
*
|
|
29
28
|
* @example
|
|
30
|
-
* import {compose, handler} from 'ergo';
|
|
31
|
-
* import fromConnect from 'ergo/lib/from-connect';
|
|
29
|
+
* import {compose, handler} from '@centralping/ergo';
|
|
30
|
+
* import fromConnect from '@centralping/ergo/lib/from-connect';
|
|
32
31
|
* import helmet from 'helmet';
|
|
33
32
|
*
|
|
34
33
|
* const pipeline = compose(
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse an `Idempotency-Key` header value as an RFC 8941 sf-string.
|
|
3
|
+
*
|
|
4
|
+
* The draft specifies the header is an Item Structured Header whose value
|
|
5
|
+
* is a String. This means the wire format is `"value"` (double-quoted).
|
|
6
|
+
* Returns `undefined` for missing, empty, or malformed values.
|
|
7
|
+
*
|
|
8
|
+
* @param {string | undefined} header - Raw header value
|
|
9
|
+
* @returns {string | undefined} - Parsed key value, or undefined
|
|
10
|
+
*/
|
|
11
|
+
export function parseIdempotencyKey(header: string | undefined): string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Generate a SHA-256 fingerprint of the request body.
|
|
14
|
+
*
|
|
15
|
+
* Used to detect key reuse with different payloads (which should return 409).
|
|
16
|
+
* Accepts strings or Buffers. Returns the hex digest.
|
|
17
|
+
*
|
|
18
|
+
* @param {string | Buffer} body - Serialized request body
|
|
19
|
+
* @returns {string} - Hex-encoded SHA-256 hash
|
|
20
|
+
*/
|
|
21
|
+
export function generateFingerprint(body: string | Buffer): string;
|
|
22
|
+
/**
|
|
23
|
+
* In-memory idempotency key store with TTL-based expiry.
|
|
24
|
+
*
|
|
25
|
+
* Entries transition through states: `processing` -> `complete`.
|
|
26
|
+
* Expired entries are pruned lazily on `get()` calls.
|
|
27
|
+
*/
|
|
28
|
+
export class IdempotencyStore {
|
|
29
|
+
constructor({ maxKeys, ttlMs }?: {
|
|
30
|
+
maxKeys?: number | undefined;
|
|
31
|
+
ttlMs?: number | undefined;
|
|
32
|
+
});
|
|
33
|
+
_entries: Map<any, any>;
|
|
34
|
+
_maxKeys: number;
|
|
35
|
+
_ttlMs: number;
|
|
36
|
+
/**
|
|
37
|
+
* Retrieve a stored entry by key. Returns `undefined` if not found or expired.
|
|
38
|
+
* @param {string} key - Idempotency key
|
|
39
|
+
* @returns {{fingerprint: string, response: object, status: string, expiresAt: number} | undefined}
|
|
40
|
+
*/
|
|
41
|
+
get(key: string): {
|
|
42
|
+
fingerprint: string;
|
|
43
|
+
response: object;
|
|
44
|
+
status: string;
|
|
45
|
+
expiresAt: number;
|
|
46
|
+
} | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Store a new idempotency entry in `processing` state.
|
|
49
|
+
* @param {string} key - Idempotency key
|
|
50
|
+
* @param {string} fingerprint - Request body fingerprint
|
|
51
|
+
*/
|
|
52
|
+
set(key: string, fingerprint: string): void;
|
|
53
|
+
/**
|
|
54
|
+
* Mark an entry as complete with the response to replay.
|
|
55
|
+
* @param {string} key - Idempotency key
|
|
56
|
+
* @param {object} response - Response accumulator snapshot to replay
|
|
57
|
+
*/
|
|
58
|
+
complete(key: string, response: object): void;
|
|
59
|
+
/**
|
|
60
|
+
* Remove an entry (e.g. on request failure where replay is not desired).
|
|
61
|
+
* @param {string} key - Idempotency key
|
|
62
|
+
*/
|
|
63
|
+
delete(key: string): void;
|
|
64
|
+
}
|