@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
package/http/logger.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview HTTP middleware factory for structured request/response logging.
|
|
3
|
+
*
|
|
4
|
+
* Logs request completion events as structured objects to the configured log function.
|
|
5
|
+
* Each log entry includes operationally essential fields:
|
|
6
|
+
* - Request: method, URL, headers, client IP, HTTP version, request ID
|
|
7
|
+
* - Response: status code, duration (ms), headers, and status message
|
|
8
|
+
* - Host: static machine identity (hostname, arch, platform, pid)
|
|
9
|
+
*
|
|
10
|
+
* The request ID is resolved using a three-step chain:
|
|
11
|
+
* 1. `res.getHeader(headerRequestIdName)` — set by ergo-router's transport layer (primary path)
|
|
12
|
+
* 2. `req.headers[headerRequestIdName]` — set by an upstream proxy (standalone fallback)
|
|
13
|
+
* 3. `uuid()` — generates a new UUID when no upstream ID is available
|
|
14
|
+
*
|
|
15
|
+
* The response header is only set when not already present, so a transport-layer ID
|
|
16
|
+
* is never overwritten.
|
|
17
|
+
*
|
|
18
|
+
* System metrics (CPU, memory, load average) are intentionally excluded — OTel treats
|
|
19
|
+
* logs and metrics as distinct observability signals. Collect system metrics via a
|
|
20
|
+
* dedicated metrics pipeline at periodic intervals, not per-request.
|
|
21
|
+
*
|
|
22
|
+
* Environment variables (`process.env`) are intentionally excluded from logs to prevent
|
|
23
|
+
* accidental secret leakage.
|
|
24
|
+
*
|
|
25
|
+
* @module http/logger
|
|
26
|
+
* @version 0.1.0
|
|
27
|
+
* @since 0.1.0
|
|
28
|
+
* @requires node:os
|
|
29
|
+
* @requires node:crypto
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* import {compose, logger} from 'ergo';
|
|
33
|
+
*
|
|
34
|
+
* const pipeline = compose(
|
|
35
|
+
* [logger(), 'log'],
|
|
36
|
+
* // On finish logs: {"requestId":"...","method":"GET","url":"/users","statusCode":200,"duration":12,...}
|
|
37
|
+
* );
|
|
38
|
+
*/
|
|
39
|
+
import {hostname} from 'node:os';
|
|
40
|
+
import {randomUUID} from 'node:crypto';
|
|
41
|
+
|
|
42
|
+
const DEFAULT_REDACTED = new Set(['authorization', 'proxy-authorization', 'cookie', 'set-cookie']);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} headers - Header object to redact
|
|
46
|
+
* @param {Set<string>} redactSet - Header names to replace with '[REDACTED]'
|
|
47
|
+
* @returns {object} - Copy with sensitive values replaced
|
|
48
|
+
*/
|
|
49
|
+
function redact(headers, redactSet) {
|
|
50
|
+
if (!redactSet?.size) return headers;
|
|
51
|
+
const safe = {};
|
|
52
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
53
|
+
safe[k] = redactSet.has(k) ? '[REDACTED]' : v;
|
|
54
|
+
}
|
|
55
|
+
return safe;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const host = Object.freeze({
|
|
59
|
+
hostname: hostname(),
|
|
60
|
+
arch: process.arch,
|
|
61
|
+
platform: process.platform,
|
|
62
|
+
pid: process.pid
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Creates a structured request/response logging middleware.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} [options] - Logger configuration
|
|
69
|
+
* @param {function} [options.log] - Log function for completed requests (default: console.log)
|
|
70
|
+
* @param {function} [options.error] - Log function for errors (default: console.error)
|
|
71
|
+
* @param {function} [options.uuid] - Fallback UUID generator used only when no upstream request ID
|
|
72
|
+
* is found on the response or request headers (default: crypto.randomUUID)
|
|
73
|
+
* @param {string} [options.headerRequestIdName] - Request ID header name (default: 'x-request-id')
|
|
74
|
+
* @param {string} [options.headerRequestIpName] - Client IP header name (default: 'x-real-ip')
|
|
75
|
+
* @param {Set<string>} [options.redactHeaders] - Header names to replace with '[REDACTED]' in logs
|
|
76
|
+
* (default: authorization, proxy-authorization, cookie, set-cookie)
|
|
77
|
+
* @returns {object} - Log entry with request metadata and host info (statusCode/duration added on finish)
|
|
78
|
+
*/
|
|
79
|
+
export default ({
|
|
80
|
+
/* eslint-disable-next-line no-console */
|
|
81
|
+
log = console.log,
|
|
82
|
+
/* eslint-disable-next-line no-console */
|
|
83
|
+
error: logError = console.error,
|
|
84
|
+
uuid = randomUUID,
|
|
85
|
+
headerRequestIdName = 'x-request-id',
|
|
86
|
+
headerRequestIpName = 'x-real-ip',
|
|
87
|
+
redactHeaders = DEFAULT_REDACTED
|
|
88
|
+
} = {}) =>
|
|
89
|
+
(req, res) => {
|
|
90
|
+
const time = performance.now();
|
|
91
|
+
const timestamp = Date.now();
|
|
92
|
+
const requestId =
|
|
93
|
+
res.getHeader(headerRequestIdName) || req.headers[headerRequestIdName] || uuid();
|
|
94
|
+
const ip = req.headers[headerRequestIpName];
|
|
95
|
+
|
|
96
|
+
if (!res.getHeader(headerRequestIdName)) {
|
|
97
|
+
res.setHeader(headerRequestIdName, requestId);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const info = {
|
|
101
|
+
requestId,
|
|
102
|
+
timestamp,
|
|
103
|
+
ip,
|
|
104
|
+
method: req.method,
|
|
105
|
+
url: req.url,
|
|
106
|
+
httpVersion: req.httpVersion,
|
|
107
|
+
host,
|
|
108
|
+
request: {
|
|
109
|
+
headers: redact(req.headers, redactHeaders),
|
|
110
|
+
encrypted: req.socket?.encrypted,
|
|
111
|
+
remoteAddress: req.socket?.remoteAddress,
|
|
112
|
+
remotePort: req.socket?.remotePort
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
res.on('finish', finish);
|
|
117
|
+
res.on('close', abort);
|
|
118
|
+
res.on('error', error);
|
|
119
|
+
|
|
120
|
+
return {...info};
|
|
121
|
+
|
|
122
|
+
/** Removes all response event listeners. */
|
|
123
|
+
function cleanup() {
|
|
124
|
+
res.removeListener('finish', finish);
|
|
125
|
+
res.removeListener('close', abort);
|
|
126
|
+
res.removeListener('error', error);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {boolean} aborted - Whether the connection was aborted before completion
|
|
131
|
+
*/
|
|
132
|
+
function finish(aborted) {
|
|
133
|
+
cleanup();
|
|
134
|
+
|
|
135
|
+
info[aborted ? 'inprogressTime' : 'duration'] = performance.now() - time;
|
|
136
|
+
info.statusCode = res.statusCode;
|
|
137
|
+
|
|
138
|
+
info.response = {
|
|
139
|
+
headers: redact(res.getHeaders(), redactHeaders),
|
|
140
|
+
statusMessage: res.statusMessage,
|
|
141
|
+
writableFinished: res.writableFinished
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
log(info);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Handles connection close before response completes. */
|
|
148
|
+
function abort() {
|
|
149
|
+
finish(true);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {*} err - The error emitted by the response stream
|
|
154
|
+
*/
|
|
155
|
+
function error(err) {
|
|
156
|
+
logError({
|
|
157
|
+
requestId,
|
|
158
|
+
timestamp,
|
|
159
|
+
name: err?.name,
|
|
160
|
+
message: err?.message,
|
|
161
|
+
status: err?.status,
|
|
162
|
+
statusCode: err?.statusCode,
|
|
163
|
+
originalError: err?.originalError,
|
|
164
|
+
stack: err?.stack
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
};
|
package/http/main.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Ergo Fast Fail REST API toolkit - main module export.
|
|
3
|
+
*
|
|
4
|
+
* Provides composable middleware for the four-stage Fast Fail request processing pipeline:
|
|
5
|
+
* 1. Negotiation - Parse/inspect request headers and URL (logger, cors, accepts, cookie, url)
|
|
6
|
+
* 2. Authorization - Authenticate user and verify request integrity (authorization, csrf)
|
|
7
|
+
* 3. Validation - Parse and validate request body (body, jsonApiQuery, validate)
|
|
8
|
+
* 4. Execution - Run the route handler and send the response (handler, send)
|
|
9
|
+
*
|
|
10
|
+
* Middleware is composed via the two-accumulator pattern using `compose`:
|
|
11
|
+
* compose([fn, setPath], ...)
|
|
12
|
+
*
|
|
13
|
+
* Each middleware factory returns `{value?, response?}`. Domain values are
|
|
14
|
+
* accumulated under named keys in `domainAcc`; response properties merge
|
|
15
|
+
* into `responseAcc`. `send()` is called post-pipeline by `handler()`.
|
|
16
|
+
*
|
|
17
|
+
* @module ergo
|
|
18
|
+
* @version 0.1.0
|
|
19
|
+
* @since 0.1.0
|
|
20
|
+
* @requires ./handler.js
|
|
21
|
+
* @requires ./accepts.js
|
|
22
|
+
* @requires ./authorization.js
|
|
23
|
+
* @requires ./body.js
|
|
24
|
+
* @requires ./cache-control.js
|
|
25
|
+
* @requires ./compress.js
|
|
26
|
+
* @requires ./cookie.js
|
|
27
|
+
* @requires ./cors.js
|
|
28
|
+
* @requires ./csrf.js
|
|
29
|
+
* @requires ./json-api-query.js
|
|
30
|
+
* @requires ./logger.js
|
|
31
|
+
* @requires ./prefer.js
|
|
32
|
+
* @requires ./precondition.js
|
|
33
|
+
* @requires ./rate-limit.js
|
|
34
|
+
* @requires ./url.js
|
|
35
|
+
* @requires ./security-headers.js
|
|
36
|
+
* @requires ./send.js
|
|
37
|
+
* @requires ./timeout.js
|
|
38
|
+
* @requires ./validate.js
|
|
39
|
+
* @requires ../utils/compose-with.js
|
|
40
|
+
* @requires ../utils/http-errors.js
|
|
41
|
+
* @requires ../lib/from-connect.js
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* import {compose, handler, logger, cors, authorization, accepts,
|
|
45
|
+
* cookie, url, body} from 'ergo';
|
|
46
|
+
*
|
|
47
|
+
* const pipeline = compose(
|
|
48
|
+
* // Stage 1: Negotiation
|
|
49
|
+
* [logger(), 'log'],
|
|
50
|
+
* [cors(), 'cors'],
|
|
51
|
+
* [accepts({types: ['application/json']}), 'accepts'],
|
|
52
|
+
* [cookie(), 'cookies'],
|
|
53
|
+
* [url(), 'url'],
|
|
54
|
+
* // Stage 2: Authorization
|
|
55
|
+
* [authorization({strategies: [...]}), 'auth'],
|
|
56
|
+
* // Stage 3: Validation
|
|
57
|
+
* [body(), 'body'],
|
|
58
|
+
* // Stage 4: Execution
|
|
59
|
+
* (req, res, acc) => ({response: {body: acc.body.parsed}}),
|
|
60
|
+
* );
|
|
61
|
+
*
|
|
62
|
+
* export default handler(pipeline);
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import compose, {createResponseAcc, mergeResponse} from '../utils/compose-with.js';
|
|
66
|
+
import handler from './handler.js';
|
|
67
|
+
import accepts from './accepts.js';
|
|
68
|
+
import authorization from './authorization.js';
|
|
69
|
+
import body from './body.js';
|
|
70
|
+
import cacheControl from './cache-control.js';
|
|
71
|
+
import compress from './compress.js';
|
|
72
|
+
import cookie from './cookie.js';
|
|
73
|
+
import cors from './cors.js';
|
|
74
|
+
import csrf from './csrf.js';
|
|
75
|
+
import jsonApiQuery from './json-api-query.js';
|
|
76
|
+
import logger from './logger.js';
|
|
77
|
+
import url from './url.js';
|
|
78
|
+
import prefer from './prefer.js';
|
|
79
|
+
import precondition from './precondition.js';
|
|
80
|
+
import rateLimit from './rate-limit.js';
|
|
81
|
+
import securityHeaders from './security-headers.js';
|
|
82
|
+
import send from './send.js';
|
|
83
|
+
import timeout from './timeout.js';
|
|
84
|
+
import validate from './validate.js';
|
|
85
|
+
import httpErrors from '../utils/http-errors.js';
|
|
86
|
+
import fromConnect from '../lib/from-connect.js';
|
|
87
|
+
|
|
88
|
+
export {
|
|
89
|
+
compose,
|
|
90
|
+
createResponseAcc,
|
|
91
|
+
mergeResponse,
|
|
92
|
+
handler,
|
|
93
|
+
accepts,
|
|
94
|
+
authorization,
|
|
95
|
+
body,
|
|
96
|
+
cacheControl,
|
|
97
|
+
compress,
|
|
98
|
+
cookie,
|
|
99
|
+
cors,
|
|
100
|
+
csrf,
|
|
101
|
+
fromConnect,
|
|
102
|
+
httpErrors,
|
|
103
|
+
jsonApiQuery,
|
|
104
|
+
logger,
|
|
105
|
+
prefer,
|
|
106
|
+
precondition,
|
|
107
|
+
rateLimit,
|
|
108
|
+
securityHeaders,
|
|
109
|
+
url,
|
|
110
|
+
send,
|
|
111
|
+
timeout,
|
|
112
|
+
validate
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Default export mirrors the named exports above — keep both in sync.
|
|
116
|
+
/** @type {object} */
|
|
117
|
+
export default {
|
|
118
|
+
compose,
|
|
119
|
+
handler,
|
|
120
|
+
accepts,
|
|
121
|
+
authorization,
|
|
122
|
+
body,
|
|
123
|
+
cacheControl,
|
|
124
|
+
compress,
|
|
125
|
+
cookie,
|
|
126
|
+
cors,
|
|
127
|
+
csrf,
|
|
128
|
+
fromConnect,
|
|
129
|
+
httpErrors,
|
|
130
|
+
jsonApiQuery,
|
|
131
|
+
logger,
|
|
132
|
+
prefer,
|
|
133
|
+
precondition,
|
|
134
|
+
rateLimit,
|
|
135
|
+
securityHeaders,
|
|
136
|
+
url,
|
|
137
|
+
send,
|
|
138
|
+
timeout,
|
|
139
|
+
validate
|
|
140
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Precondition Required middleware (RFC 6585 §3).
|
|
3
|
+
*
|
|
4
|
+
* Enforces that unsafe requests include a conditional header (`If-Match` or
|
|
5
|
+
* `If-Unmodified-Since`) before the pipeline proceeds. This prevents "lost update"
|
|
6
|
+
* problems where a client overwrites changes made by another client without first
|
|
7
|
+
* fetching the current resource state.
|
|
8
|
+
*
|
|
9
|
+
* Placed in Stage 1 (Negotiation) for Fast Fail — the check is a cheap header
|
|
10
|
+
* inspection that short-circuits before authorization, body parsing, or execution.
|
|
11
|
+
*
|
|
12
|
+
* @module http/precondition
|
|
13
|
+
* @version 0.1.0
|
|
14
|
+
* @since 0.1.0
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* import {compose, precondition} from 'ergo';
|
|
18
|
+
*
|
|
19
|
+
* // Enforce on all requests (method scoping handled by pipeline builder)
|
|
20
|
+
* const pipeline = compose(
|
|
21
|
+
* [precondition(), 'precondition'],
|
|
22
|
+
* (req, res, acc) => ({response: {statusCode: 200, body: {updated: true}}})
|
|
23
|
+
* );
|
|
24
|
+
*
|
|
25
|
+
* // Enforce only on specific methods (standalone usage)
|
|
26
|
+
* const pipeline = compose(
|
|
27
|
+
* [precondition({methods: ['PUT', 'PATCH']}), 'precondition'],
|
|
28
|
+
* (req, res, acc) => ({response: {statusCode: 200, body: {updated: true}}})
|
|
29
|
+
* );
|
|
30
|
+
*
|
|
31
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc6585#section-3 RFC 6585 Section 3 - 428 Precondition Required}
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a precondition enforcement middleware.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} [options]
|
|
38
|
+
* @param {string[]|Set<string>} [options.methods] - HTTP methods to enforce on.
|
|
39
|
+
* When omitted, enforces unconditionally (the pipeline builder handles method scoping).
|
|
40
|
+
* When provided, only activates for the specified methods.
|
|
41
|
+
* @returns {function} - Middleware `(req) => void` that returns `{response: {statusCode: 428}}` if no conditional header is present
|
|
42
|
+
*/
|
|
43
|
+
export default function precondition({methods} = {}) {
|
|
44
|
+
const methodSet = methods ? (methods instanceof Set ? methods : new Set(methods)) : undefined;
|
|
45
|
+
|
|
46
|
+
return req => {
|
|
47
|
+
if (methodSet && !methodSet.has(req.method)) return;
|
|
48
|
+
|
|
49
|
+
if (!req.headers['if-match'] && !req.headers['if-unmodified-since']) {
|
|
50
|
+
return {response: {statusCode: 428}};
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
package/http/prefer.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview HTTP middleware factory for Prefer header parsing (RFC 7240).
|
|
3
|
+
*
|
|
4
|
+
* Parses the `Prefer` request header and returns a preferences object for the
|
|
5
|
+
* accumulator. Combined with `send()`'s `preferKey` option, enables automatic
|
|
6
|
+
* `return=minimal` / `return=representation` response handling.
|
|
7
|
+
*
|
|
8
|
+
* Placed in Stage 1 (Negotiation) — cheap header parse with no I/O.
|
|
9
|
+
*
|
|
10
|
+
* @module http/prefer
|
|
11
|
+
* @version 0.1.0
|
|
12
|
+
* @since 0.1.0
|
|
13
|
+
* @requires ../lib/prefer.js
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* import {compose, prefer} from 'ergo';
|
|
17
|
+
*
|
|
18
|
+
* const pipeline = compose(
|
|
19
|
+
* [prefer(), 'prefer'],
|
|
20
|
+
* (req, res, acc) => ({response: {body: {id: 1, name: 'item'}}}),
|
|
21
|
+
* );
|
|
22
|
+
* // Client sends: Prefer: return=minimal
|
|
23
|
+
* // Response: 204 No Content + Preference-Applied: return=minimal
|
|
24
|
+
*
|
|
25
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc7240 RFC 7240 - Prefer Header for HTTP}
|
|
26
|
+
*/
|
|
27
|
+
import parsePrefer from '../lib/prefer.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a Prefer header parsing middleware.
|
|
31
|
+
*
|
|
32
|
+
* @returns {function} - Middleware `(req) => object` returning parsed preferences
|
|
33
|
+
*/
|
|
34
|
+
export default () => {
|
|
35
|
+
return req => parsePrefer(req.headers?.prefer);
|
|
36
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Rate limiting pipeline middleware (RFC 6585 §4).
|
|
3
|
+
*
|
|
4
|
+
* Tracks request counts per client key within a configurable time window.
|
|
5
|
+
* Returns rate-limit header tuples for the response accumulator on allowed requests;
|
|
6
|
+
* returns `{response: {statusCode: 429, retryAfter}}` when the limit is exceeded.
|
|
7
|
+
*
|
|
8
|
+
* Placed in Stage 1 (Negotiation) for Fast Fail — the check is a cheap
|
|
9
|
+
* counter lookup that short-circuits before authorization, body parsing,
|
|
10
|
+
* or execution.
|
|
11
|
+
*
|
|
12
|
+
* @module http/rate-limit
|
|
13
|
+
* @version 0.1.0
|
|
14
|
+
* @since 0.1.0
|
|
15
|
+
* @requires ../lib/rate-limit.js
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* import {compose, rateLimit} from 'ergo';
|
|
19
|
+
*
|
|
20
|
+
* const pipeline = compose(
|
|
21
|
+
* [rateLimit({max: 100, windowMs: 60000}), 'rateLimit'],
|
|
22
|
+
* (req, res, acc) => ({response: {statusCode: 200, body: {ok: true}}})
|
|
23
|
+
* );
|
|
24
|
+
*
|
|
25
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc6585#section-4 RFC 6585 Section 4 - 429 Too Many Requests}
|
|
26
|
+
*/
|
|
27
|
+
import {MemoryStore, checkRateLimit, defaultKeyGenerator} from '../lib/rate-limit.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a rate limiting middleware.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} [options]
|
|
33
|
+
* @param {number} [options.max=100] - Maximum requests per window
|
|
34
|
+
* @param {number} [options.windowMs=60000] - Window size in milliseconds (default: 1 minute)
|
|
35
|
+
* @param {object} [options.store] - Pluggable store (must implement `hit(key, windowMs)`)
|
|
36
|
+
* @param {function} [options.keyGenerator] - `(req) => string` client identifier (default: remote IP)
|
|
37
|
+
* @returns {function} - Middleware `(req) => {response}` that returns rate-limit header tuples on allowed
|
|
38
|
+
* requests and `{response: {statusCode: 429, retryAfter}}` when the limit is exceeded
|
|
39
|
+
*/
|
|
40
|
+
export default function rateLimit({max = 100, windowMs = 60000, store, keyGenerator} = {}) {
|
|
41
|
+
const _store = store ?? new MemoryStore();
|
|
42
|
+
const _keyGen = keyGenerator ?? defaultKeyGenerator;
|
|
43
|
+
|
|
44
|
+
return req => {
|
|
45
|
+
const result = checkRateLimit(_store, _keyGen(req), max, windowMs);
|
|
46
|
+
|
|
47
|
+
if (result.limited) {
|
|
48
|
+
return {
|
|
49
|
+
response: {
|
|
50
|
+
statusCode: 429,
|
|
51
|
+
retryAfter: result.retryAfter
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
response: {
|
|
58
|
+
headers: [
|
|
59
|
+
['X-RateLimit-Limit', String(max)],
|
|
60
|
+
['X-RateLimit-Remaining', String(result.remaining)],
|
|
61
|
+
['X-RateLimit-Reset', String(result.reset)]
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview HTTP middleware factory for security response headers.
|
|
3
|
+
*
|
|
4
|
+
* Returns pre-computed header tuples for common security headers recommended for
|
|
5
|
+
* REST APIs. Each header is individually configurable or disableable (pass `false`).
|
|
6
|
+
* Header tuples are built at factory time for zero per-request overhead.
|
|
7
|
+
*
|
|
8
|
+
* Delegates tuple construction to `lib/security-headers.js` (the shared primitive).
|
|
9
|
+
*
|
|
10
|
+
* @module http/security-headers
|
|
11
|
+
* @version 0.1.0
|
|
12
|
+
* @since 0.1.0
|
|
13
|
+
* @requires ../lib/security-headers.js
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* import {compose, securityHeaders} from 'ergo';
|
|
17
|
+
*
|
|
18
|
+
* // Use defaults
|
|
19
|
+
* const pipeline = compose(
|
|
20
|
+
* [securityHeaders(), 'security'],
|
|
21
|
+
* // ...
|
|
22
|
+
* );
|
|
23
|
+
*
|
|
24
|
+
* // Customize or disable individual headers
|
|
25
|
+
* const pipeline = compose(
|
|
26
|
+
* [securityHeaders({
|
|
27
|
+
* xFrameOptions: 'SAMEORIGIN',
|
|
28
|
+
* permissionsPolicy: 'camera=(), microphone=()',
|
|
29
|
+
* xXssProtection: false // disable
|
|
30
|
+
* }), 'security'],
|
|
31
|
+
* );
|
|
32
|
+
*
|
|
33
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc6797 RFC 6797 - HTTP Strict Transport Security}
|
|
34
|
+
* @see {@link https://www.w3.org/TR/CSP3/ W3C Content Security Policy Level 3}
|
|
35
|
+
*/
|
|
36
|
+
import buildSecurityHeaderTuples from '../lib/security-headers.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates a security headers middleware that returns pre-computed header tuples.
|
|
40
|
+
*
|
|
41
|
+
* Pass `false` for any header to omit it entirely. Pass a string to override
|
|
42
|
+
* the default value.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} [options] - Security header configuration
|
|
45
|
+
* @param {string|false} [options.contentSecurityPolicy="default-src 'none'"] - Content-Security-Policy header
|
|
46
|
+
* @param {string|false} [options.strictTransportSecurity=false] - Strict-Transport-Security header.
|
|
47
|
+
* Defaults to `false` because this middleware has no request context to verify the connection
|
|
48
|
+
* is HTTPS, and HSTS MUST only be sent over secure transport (RFC 6797 §7.2). Enable explicitly
|
|
49
|
+
* when the app is known to be behind HTTPS, or use ergo-router's transport layer which performs
|
|
50
|
+
* the HTTPS check automatically.
|
|
51
|
+
* @param {string|false} [options.xContentTypeOptions='nosniff'] - X-Content-Type-Options header
|
|
52
|
+
* @param {string|false} [options.xFrameOptions='DENY'] - X-Frame-Options header
|
|
53
|
+
* @param {string|false} [options.referrerPolicy='no-referrer'] - Referrer-Policy header
|
|
54
|
+
* @param {string|false} [options.xXssProtection='0'] - X-XSS-Protection header (0 disables the browser filter)
|
|
55
|
+
* @param {string} [options.permissionsPolicy] - Permissions-Policy header (omitted by default)
|
|
56
|
+
* @returns {function} - Ergo middleware `() => Array<[string, string]>`
|
|
57
|
+
*/
|
|
58
|
+
export default (options = {}) => {
|
|
59
|
+
const headerTuples = buildSecurityHeaderTuples(options);
|
|
60
|
+
const response = {response: {headers: headerTuples}};
|
|
61
|
+
return () => response;
|
|
62
|
+
};
|