@centralping/ergo 0.1.0-beta.2 → 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.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @fileoverview Idempotency-Key pipeline middleware.
3
+ *
4
+ * Detects duplicate requests by `Idempotency-Key` header value per
5
+ * draft-ietf-httpapi-idempotency-key-header-07. When a matching key with
6
+ * the same request fingerprint is found, the stored response is replayed.
7
+ * When a key matches but the fingerprint differs, a 409 Conflict is returned.
8
+ *
9
+ * Placed in Stage 3 (Validation) after body parsing so the request body
10
+ * fingerprint can be computed from the parsed body.
11
+ *
12
+ * @module http/idempotency
13
+ * @since 0.1.0-beta.2
14
+ * @requires ../lib/idempotency.js
15
+ *
16
+ * @example
17
+ * import {compose, body, idempotency} from '@centralping/ergo';
18
+ *
19
+ * const pipeline = compose(
20
+ * [body(), 'body'],
21
+ * [idempotency({required: true}), 'idempotency'],
22
+ * (req, res, acc) => ({response: {statusCode: 201, body: {created: true}}})
23
+ * );
24
+ *
25
+ * @see {@link https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/ Idempotency-Key Header (IETF Draft)}
26
+ */
27
+ import {IdempotencyStore, parseIdempotencyKey, generateFingerprint} from '../lib/idempotency.js';
28
+
29
+ const DEFAULT_METHODS = new Set(['POST', 'PATCH']);
30
+
31
+ /**
32
+ * Create an idempotency middleware.
33
+ *
34
+ * @param {object} [options]
35
+ * @param {object} [options.store] - Pluggable store (must implement get/set/complete/delete).
36
+ * Defaults to an in-memory store with 24h TTL.
37
+ * @param {number} [options.ttlMs=86400000] - TTL for stored entries in milliseconds (default: 24h).
38
+ * Only used when creating the default in-memory store.
39
+ * @param {boolean} [options.required=false] - When true, returns 400 if the header is missing
40
+ * on applicable methods
41
+ * @param {Set<string>|string[]} [options.methods] - HTTP methods to apply idempotency to
42
+ * (default: POST, PATCH)
43
+ * @returns {function} - Middleware `(req, res, domainAcc) => {value?, response?}`
44
+ */
45
+ export default function idempotency({store, ttlMs, required = false, methods} = {}) {
46
+ const _store = store ?? new IdempotencyStore(ttlMs ? {ttlMs} : undefined);
47
+ const _methods = methods instanceof Set ? methods : new Set(methods ?? DEFAULT_METHODS);
48
+
49
+ return (req, _res, domainAcc) => {
50
+ if (!_methods.has(req.method)) return {};
51
+
52
+ const key = parseIdempotencyKey(req.headers?.['idempotency-key']);
53
+
54
+ if (!key) {
55
+ if (required) {
56
+ return {
57
+ response: {
58
+ statusCode: 400,
59
+ detail: 'Idempotency-Key header is required'
60
+ }
61
+ };
62
+ }
63
+ return {};
64
+ }
65
+
66
+ const rawBody = domainAcc?.body?.raw ?? domainAcc?.body?.parsed ?? '';
67
+ const fingerprint = generateFingerprint(
68
+ typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody)
69
+ );
70
+
71
+ const existing = _store.get(key);
72
+
73
+ if (existing) {
74
+ if (existing.fingerprint !== fingerprint) {
75
+ return {
76
+ response: {
77
+ statusCode: 409,
78
+ detail: 'Idempotency key already used with a different request'
79
+ }
80
+ };
81
+ }
82
+
83
+ if (existing.status === 'complete' && existing.response) {
84
+ return {
85
+ value: {replayed: true},
86
+ response: existing.response
87
+ };
88
+ }
89
+
90
+ // Still processing — concurrent duplicate
91
+ return {
92
+ response: {
93
+ statusCode: 409,
94
+ detail: 'A request with this idempotency key is already being processed'
95
+ }
96
+ };
97
+ }
98
+
99
+ _store.set(key, fingerprint);
100
+
101
+ return {
102
+ value: {
103
+ key,
104
+ fingerprint,
105
+ complete: response => _store.complete(key, response),
106
+ discard: () => _store.delete(key)
107
+ }
108
+ };
109
+ };
110
+ }
package/http/main.js CHANGED
@@ -80,6 +80,7 @@ import rateLimit from './rate-limit.js';
80
80
  import securityHeaders from './security-headers.js';
81
81
  import send from './send.js';
82
82
  import timeout from './timeout.js';
83
+ import idempotency from './idempotency.js';
83
84
  import validate from './validate.js';
84
85
  import httpErrors from '../utils/http-errors.js';
85
86
  import fromConnect from '../lib/from-connect.js';
@@ -99,6 +100,7 @@ export {
99
100
  csrf,
100
101
  fromConnect,
101
102
  httpErrors,
103
+ idempotency,
102
104
  jsonApiQuery,
103
105
  logger,
104
106
  prefer,
@@ -126,6 +128,7 @@ export default {
126
128
  csrf,
127
129
  fromConnect,
128
130
  httpErrors,
131
+ idempotency,
129
132
  jsonApiQuery,
130
133
  logger,
131
134
  prefer,
@@ -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
+ }
package/lib/link.js CHANGED
@@ -2,9 +2,9 @@
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` is a
6
- * convenience helper that generates `first`, `prev`, `next`, `last` link
7
- * objects from pagination parameters.
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()`.
@@ -15,8 +15,10 @@
15
15
  * @see {@link https://www.rfc-editor.org/rfc/rfc8288 RFC 8288 - Web Linking}
16
16
  *
17
17
  * @example
18
- * import {formatLinkHeader, paginationLinks} from '@centralping/ergo/lib/link';
18
+ * import {formatLinkHeader, paginationLinks, cursorPaginationLinks}
19
+ * from '@centralping/ergo/lib/link';
19
20
  *
21
+ * // Offset-based pagination
20
22
  * const links = paginationLinks({
21
23
  * baseUrl: '/articles',
22
24
  * searchParams: 'sort=date',
@@ -26,6 +28,15 @@
26
28
  * });
27
29
  * const header = formatLinkHeader(links);
28
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"'
29
40
  */
30
41
  import sanitizeQuotedString from './sanitize-quoted-string.js';
31
42
 
@@ -93,3 +104,45 @@ export function paginationLinks({baseUrl, page, perPage, total, searchParams = '
93
104
 
94
105
  return links;
95
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@centralping/ergo",
3
- "version": "0.1.0-beta.2",
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",
@@ -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;
@@ -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 };
@@ -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
+ }
@@ -35,3 +35,31 @@ export function paginationLinks({ baseUrl, page, perPage, total, searchParams }:
35
35
  href: string;
36
36
  rel: string;
37
37
  }>;
38
+ /**
39
+ * Generates cursor-based pagination link objects for first, prev, and next pages.
40
+ *
41
+ * Unlike offset pagination, cursor-based pagination uses opaque continuation
42
+ * tokens instead of page numbers. There is no `last` link because the total
43
+ * number of pages is unknown in cursor-based schemes.
44
+ *
45
+ * The `first` link is always present and points to the base URL without any
46
+ * cursor parameter (requesting the first page). `prev` and `next` are
47
+ * included only when the corresponding cursor token is provided.
48
+ *
49
+ * @param {object} options - Cursor pagination parameters
50
+ * @param {string} options.baseUrl - Base URL path (e.g. '/articles')
51
+ * @param {string} [options.searchParams=''] - Additional query parameters to preserve
52
+ * (e.g. 'sort=date&filter=active'). Appended before the cursor parameter.
53
+ * @param {string} [options.nextCursor] - Opaque cursor token for the next page
54
+ * @param {string} [options.prevCursor] - Opaque cursor token for the previous page
55
+ * @returns {Array<{href: string, rel: string}>} - Array of link objects
56
+ */
57
+ export function cursorPaginationLinks({ baseUrl, searchParams, nextCursor, prevCursor }: {
58
+ baseUrl: string;
59
+ searchParams?: string | undefined;
60
+ nextCursor?: string | undefined;
61
+ prevCursor?: string | undefined;
62
+ }): Array<{
63
+ href: string;
64
+ rel: string;
65
+ }>;