@zero-server/sdk 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +437 -0
  3. package/index.js +412 -0
  4. package/lib/app.js +1172 -0
  5. package/lib/auth/authorize.js +399 -0
  6. package/lib/auth/enrollment.js +367 -0
  7. package/lib/auth/index.js +57 -0
  8. package/lib/auth/jwt.js +731 -0
  9. package/lib/auth/oauth.js +362 -0
  10. package/lib/auth/session.js +588 -0
  11. package/lib/auth/trustedDevice.js +409 -0
  12. package/lib/auth/twoFactor.js +1150 -0
  13. package/lib/auth/webauthn.js +946 -0
  14. package/lib/body/index.js +14 -0
  15. package/lib/body/json.js +109 -0
  16. package/lib/body/multipart.js +440 -0
  17. package/lib/body/raw.js +71 -0
  18. package/lib/body/rawBuffer.js +161 -0
  19. package/lib/body/sendError.js +25 -0
  20. package/lib/body/text.js +75 -0
  21. package/lib/body/typeMatch.js +42 -0
  22. package/lib/body/urlencoded.js +235 -0
  23. package/lib/cli.js +845 -0
  24. package/lib/cluster.js +666 -0
  25. package/lib/debug.js +372 -0
  26. package/lib/env/index.js +465 -0
  27. package/lib/errors.js +683 -0
  28. package/lib/fetch/index.js +256 -0
  29. package/lib/grpc/balancer.js +378 -0
  30. package/lib/grpc/call.js +708 -0
  31. package/lib/grpc/client.js +764 -0
  32. package/lib/grpc/codec.js +1221 -0
  33. package/lib/grpc/credentials.js +398 -0
  34. package/lib/grpc/frame.js +262 -0
  35. package/lib/grpc/health.js +287 -0
  36. package/lib/grpc/index.js +121 -0
  37. package/lib/grpc/metadata.js +461 -0
  38. package/lib/grpc/proto.js +821 -0
  39. package/lib/grpc/reflection.js +590 -0
  40. package/lib/grpc/server.js +445 -0
  41. package/lib/grpc/status.js +118 -0
  42. package/lib/grpc/watch.js +173 -0
  43. package/lib/http/index.js +10 -0
  44. package/lib/http/request.js +727 -0
  45. package/lib/http/response.js +799 -0
  46. package/lib/lifecycle.js +557 -0
  47. package/lib/middleware/compress.js +230 -0
  48. package/lib/middleware/cookieParser.js +237 -0
  49. package/lib/middleware/cors.js +93 -0
  50. package/lib/middleware/csrf.js +137 -0
  51. package/lib/middleware/errorHandler.js +101 -0
  52. package/lib/middleware/helmet.js +176 -0
  53. package/lib/middleware/index.js +17 -0
  54. package/lib/middleware/logger.js +74 -0
  55. package/lib/middleware/rateLimit.js +88 -0
  56. package/lib/middleware/requestId.js +54 -0
  57. package/lib/middleware/static.js +326 -0
  58. package/lib/middleware/timeout.js +72 -0
  59. package/lib/middleware/validator.js +255 -0
  60. package/lib/observe/health.js +326 -0
  61. package/lib/observe/index.js +50 -0
  62. package/lib/observe/logger.js +359 -0
  63. package/lib/observe/metrics.js +805 -0
  64. package/lib/observe/tracing.js +592 -0
  65. package/lib/orm/adapters/json.js +290 -0
  66. package/lib/orm/adapters/memory.js +764 -0
  67. package/lib/orm/adapters/mongo.js +764 -0
  68. package/lib/orm/adapters/mysql.js +933 -0
  69. package/lib/orm/adapters/postgres.js +1144 -0
  70. package/lib/orm/adapters/redis.js +1534 -0
  71. package/lib/orm/adapters/sql-base.js +212 -0
  72. package/lib/orm/adapters/sqlite.js +858 -0
  73. package/lib/orm/audit.js +649 -0
  74. package/lib/orm/cache.js +394 -0
  75. package/lib/orm/geo.js +387 -0
  76. package/lib/orm/index.js +784 -0
  77. package/lib/orm/migrate.js +432 -0
  78. package/lib/orm/model.js +1706 -0
  79. package/lib/orm/plugin.js +375 -0
  80. package/lib/orm/procedures.js +836 -0
  81. package/lib/orm/profiler.js +233 -0
  82. package/lib/orm/query.js +1772 -0
  83. package/lib/orm/replicas.js +241 -0
  84. package/lib/orm/schema.js +307 -0
  85. package/lib/orm/search.js +380 -0
  86. package/lib/orm/seed/data/commerce.js +136 -0
  87. package/lib/orm/seed/data/internet.js +111 -0
  88. package/lib/orm/seed/data/locations.js +204 -0
  89. package/lib/orm/seed/data/names.js +338 -0
  90. package/lib/orm/seed/data/person.js +128 -0
  91. package/lib/orm/seed/data/phone.js +211 -0
  92. package/lib/orm/seed/data/words.js +134 -0
  93. package/lib/orm/seed/factory.js +178 -0
  94. package/lib/orm/seed/fake.js +1186 -0
  95. package/lib/orm/seed/index.js +18 -0
  96. package/lib/orm/seed/rng.js +71 -0
  97. package/lib/orm/seed/seeder.js +125 -0
  98. package/lib/orm/seed/unique.js +68 -0
  99. package/lib/orm/snapshot.js +366 -0
  100. package/lib/orm/tenancy.js +605 -0
  101. package/lib/orm/views.js +350 -0
  102. package/lib/router/index.js +436 -0
  103. package/lib/sse/index.js +8 -0
  104. package/lib/sse/stream.js +349 -0
  105. package/lib/ws/connection.js +451 -0
  106. package/lib/ws/handshake.js +125 -0
  107. package/lib/ws/index.js +14 -0
  108. package/lib/ws/room.js +223 -0
  109. package/package.json +73 -0
  110. package/types/app.d.ts +223 -0
  111. package/types/auth.d.ts +520 -0
  112. package/types/cluster.d.ts +75 -0
  113. package/types/env.d.ts +80 -0
  114. package/types/errors.d.ts +316 -0
  115. package/types/fetch.d.ts +43 -0
  116. package/types/grpc.d.ts +432 -0
  117. package/types/index.d.ts +384 -0
  118. package/types/lifecycle.d.ts +60 -0
  119. package/types/middleware.d.ts +320 -0
  120. package/types/observe.d.ts +304 -0
  121. package/types/orm.d.ts +1887 -0
  122. package/types/request.d.ts +109 -0
  123. package/types/response.d.ts +157 -0
  124. package/types/router.d.ts +78 -0
  125. package/types/sse.d.ts +78 -0
  126. package/types/websocket.d.ts +126 -0
@@ -0,0 +1,230 @@
1
+ /**
2
+ * @module compress
3
+ * @description Response compression middleware using Node's built-in `zlib`.
4
+ * Supports gzip, deflate, and brotli (Node >= 11.7).
5
+ * Zero external dependencies.
6
+ */
7
+ const zlib = require('zlib');
8
+ const log = require('../debug')('zero:compress');
9
+
10
+ /**
11
+ * Default minimum response size (in bytes) to bother compressing.
12
+ * Responses smaller than this are sent uncompressed.
13
+ * @type {number}
14
+ */
15
+ const DEFAULT_THRESHOLD = 1024;
16
+
17
+ /**
18
+ * MIME types that are worth compressing.
19
+ * Binary formats (images, video, zip) are already compressed and gain little.
20
+ * @type {RegExp}
21
+ */
22
+ const COMPRESSIBLE = /^text\/|^application\/(json|javascript|xml|x-www-form-urlencoded|ld\+json|graphql|wasm)|^image\/svg\+xml/;
23
+
24
+ /**
25
+ * Create a compression middleware.
26
+ *
27
+ * @param {object} [opts] - Configuration options.
28
+ * @param {number} [opts.threshold=1024] - Minimum body size in bytes to compress.
29
+ * @param {number} [opts.level] - Compression level (zlib.constants.Z_DEFAULT_COMPRESSION).
30
+ * @param {string|string[]} [opts.encoding] - Force specific encoding(s). Default: auto-negotiate.
31
+ * @param {Function} [opts.filter] - `(req, res) => boolean` — return false to skip compression.
32
+ * @returns {Function} Middleware `(req, res, next) => void`.
33
+ *
34
+ * @example
35
+ * const { createApp, compress } = require('@zero-server/sdk');
36
+ * const app = createApp();
37
+ * app.use(compress()); // gzip/deflate/br auto-negotiated
38
+ * app.use(compress({ threshold: 0 })) // compress everything
39
+ */
40
+ function compress(opts = {})
41
+ {
42
+ const threshold = opts.threshold !== undefined ? opts.threshold : DEFAULT_THRESHOLD;
43
+ const level = opts.level !== undefined ? opts.level : undefined;
44
+ const filterFn = typeof opts.filter === 'function' ? opts.filter : null;
45
+ const hasBrotli = typeof zlib.createBrotliCompress === 'function';
46
+
47
+ /**
48
+ * Choose the best encoding from the Accept-Encoding header.
49
+ * Parses quality values (RFC 7231) and picks the highest-priority match.
50
+ * Priority when equal quality: br > gzip > deflate.
51
+ * @private
52
+ * @param {string} header - HTTP header value.
53
+ * @returns {string|null} Best encoding name, or `null` if none acceptable.
54
+ */
55
+ function negotiate(header)
56
+ {
57
+ if (!header) return null;
58
+ const encodings = { br: 0, gzip: 0, deflate: 0 };
59
+ const parts = header.toLowerCase().split(',');
60
+ for (let i = 0; i < parts.length; i++)
61
+ {
62
+ const part = parts[i].trim();
63
+ const semi = part.indexOf(';');
64
+ const name = (semi !== -1 ? part.substring(0, semi).trim() : part);
65
+ let q = 1;
66
+ if (semi !== -1)
67
+ {
68
+ const qMatch = /q\s*=\s*([0-9.]+)/.exec(part.substring(semi));
69
+ if (qMatch) q = parseFloat(qMatch[1]);
70
+ }
71
+ if (name in encodings) encodings[name] = q;
72
+ }
73
+ // Filter available encodings
74
+ if (!hasBrotli) encodings.br = 0;
75
+ // Pick highest quality; break ties with priority order
76
+ let best = null;
77
+ let bestQ = 0;
78
+ const order = hasBrotli ? ['br', 'gzip', 'deflate'] : ['gzip', 'deflate'];
79
+ for (let i = 0; i < order.length; i++)
80
+ {
81
+ if (encodings[order[i]] > bestQ)
82
+ {
83
+ bestQ = encodings[order[i]];
84
+ best = order[i];
85
+ }
86
+ }
87
+ return best;
88
+ }
89
+
90
+ /**
91
+ * Create a compression stream for the chosen encoding.
92
+ * @private
93
+ * @param {string} encoding - Content encoding.
94
+ * @returns {import('stream').Transform} Compression transform stream.
95
+ */
96
+ function createStream(encoding)
97
+ {
98
+ const zlibOpts = {};
99
+ if (level !== undefined) zlibOpts.level = level;
100
+ switch (encoding)
101
+ {
102
+ case 'br':
103
+ return zlib.createBrotliCompress(level !== undefined
104
+ ? { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: level } }
105
+ : undefined);
106
+ case 'gzip':
107
+ return zlib.createGzip(zlibOpts);
108
+ case 'deflate':
109
+ return zlib.createDeflate(zlibOpts);
110
+ default:
111
+ return null;
112
+ }
113
+ }
114
+
115
+ return (req, res, next) =>
116
+ {
117
+ // Skip if client doesn't accept encoding
118
+ const acceptEncoding = req.headers['accept-encoding'] || '';
119
+ const encoding = negotiate(acceptEncoding);
120
+ if (!encoding) return next();
121
+
122
+ // Allow user to skip compression
123
+ if (filterFn && !filterFn(req, res)) return next();
124
+
125
+ // Monkey-patch the raw response's write/end to pipe through compression
126
+ const raw = res.raw;
127
+ const origWrite = raw.write.bind(raw);
128
+ const origEnd = raw.end.bind(raw);
129
+ let compressStream = null;
130
+ let headersWritten = false;
131
+ const chunks = [];
132
+
133
+ /** @private */
134
+ function initCompress()
135
+ {
136
+ if (compressStream) return true;
137
+
138
+ // If headers were already committed (e.g. res.sse() calls
139
+ // writeHead before write), we can no longer modify them.
140
+ if (raw.headersSent) return false;
141
+
142
+ // Check Content-Type — skip non-compressible types
143
+ const ct = raw.getHeader('content-type') || '';
144
+ if (ct && !COMPRESSIBLE.test(ct))
145
+ {
146
+ return false;
147
+ }
148
+
149
+ // Never compress SSE streams — compression buffers
150
+ // the small frames and prevents real-time delivery.
151
+ if (ct.includes('text/event-stream'))
152
+ {
153
+ return false;
154
+ }
155
+
156
+ compressStream = createStream(encoding);
157
+ if (!compressStream) return false;
158
+
159
+ // Remove Content-Length (we don't know compressed size ahead of time)
160
+ raw.removeHeader('content-length');
161
+ raw.removeHeader('Content-Length');
162
+ raw.setHeader('Content-Encoding', encoding);
163
+ raw.setHeader('Vary', 'Accept-Encoding');
164
+ log.debug('compressing with %s', encoding);
165
+
166
+ compressStream.on('data', (chunk) => origWrite(chunk));
167
+ compressStream.on('end', () => origEnd());
168
+ compressStream.on('error', (err) =>
169
+ { log.error('compression error: %s', err.message); // On compression error, remove encoding header and end raw stream
170
+ try { raw.removeHeader('Content-Encoding'); } catch (e) { }
171
+ try { origEnd(); } catch (e) { }
172
+ });
173
+ return true;
174
+ }
175
+
176
+ raw.write = function (chunk, enc, callback)
177
+ {
178
+ if (!headersWritten)
179
+ {
180
+ headersWritten = true;
181
+ const ct = raw.getHeader('content-type') || '';
182
+ initCompress();
183
+ if (compressStream)
184
+ {
185
+ compressStream.write(chunk, enc, callback);
186
+ return true;
187
+ }
188
+ }
189
+ if (compressStream)
190
+ {
191
+ compressStream.write(chunk, enc, callback);
192
+ return true;
193
+ }
194
+ return origWrite(chunk, enc, callback);
195
+ };
196
+
197
+ raw.end = function (chunk, encoding, callback)
198
+ {
199
+ if (!headersWritten)
200
+ {
201
+ headersWritten = true;
202
+
203
+ // Check threshold — if the total body is small, skip compression
204
+ const totalChunk = chunk ? (Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) : null;
205
+ if (totalChunk && totalChunk.length < threshold)
206
+ {
207
+ return origEnd(chunk, encoding, callback);
208
+ }
209
+
210
+ if (initCompress())
211
+ {
212
+ if (chunk) compressStream.end(chunk, encoding, callback);
213
+ else compressStream.end(callback);
214
+ return;
215
+ }
216
+ }
217
+ if (compressStream)
218
+ {
219
+ if (chunk) compressStream.end(chunk, encoding, callback);
220
+ else compressStream.end(callback);
221
+ return;
222
+ }
223
+ return origEnd(chunk, encoding, callback);
224
+ };
225
+
226
+ next();
227
+ };
228
+ }
229
+
230
+ module.exports = compress;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * @module cookieParser
3
+ * @description Cookie parsing middleware.
4
+ * Parses the `Cookie` header and populates `req.cookies`.
5
+ * Supports signed cookies, JSON cookies, secret rotation,
6
+ * and timing-safe signature verification.
7
+ */
8
+ const crypto = require('crypto');
9
+
10
+ // -- Internal helpers ------------------------------------
11
+
12
+ /**
13
+ * Timing-safe HMAC-SHA256 signature comparison.
14
+ * Prevents timing-based side-channel attacks on cookie signatures.
15
+ *
16
+ * @param {string} data - The cookie payload.
17
+ * @param {string} sig - The provided signature (base64, no padding).
18
+ * @param {string} secret - Secret to verify against.
19
+ * @returns {boolean} `true` if the signature is valid.
20
+ * @private
21
+ */
22
+ function _timingSafeVerify(data, sig, secret)
23
+ {
24
+ try
25
+ {
26
+ const expected = crypto
27
+ .createHmac('sha256', secret)
28
+ .update(data)
29
+ .digest('base64')
30
+ .replace(/=+$/, '');
31
+ if (expected.length !== sig.length) return false;
32
+ return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
33
+ }
34
+ catch (e) { return false; }
35
+ }
36
+
37
+ /**
38
+ * Verify and unsign a signed cookie value.
39
+ * Signed cookies have the format: `s:<value>.<signature>`.
40
+ * All secret(s) are attempted to support key rotation.
41
+ *
42
+ * @param {string} val - Raw cookie value.
43
+ * @param {string[]} secrets - Array of secrets to try.
44
+ * @returns {string|false} Unsigned value on success, `false` on failure.
45
+ * @private
46
+ */
47
+ function _unsign(val, secrets)
48
+ {
49
+ if (typeof val !== 'string' || !val.startsWith('s:')) return val;
50
+ const payload = val.slice(2);
51
+ const dotIdx = payload.lastIndexOf('.');
52
+ if (dotIdx === -1) return false;
53
+
54
+ const data = payload.slice(0, dotIdx);
55
+ const sig = payload.slice(dotIdx + 1);
56
+
57
+ for (const s of secrets)
58
+ {
59
+ if (_timingSafeVerify(data, sig, s)) return data;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * Try to parse a value as a JSON cookie (prefixed with `j:`).
66
+ *
67
+ * @param {string} val - Cookie value.
68
+ * @returns {*} Parsed value or original string.
69
+ * @private
70
+ */
71
+ function _parseJSONCookie(val)
72
+ {
73
+ if (typeof val !== 'string' || !val.startsWith('j:')) return val;
74
+ try { return JSON.parse(val.slice(2)); }
75
+ catch (e) { return val; }
76
+ }
77
+
78
+ // -- Middleware factory ----------------------------------
79
+
80
+ /**
81
+ * Create a cookie parsing middleware.
82
+ *
83
+ * Features:
84
+ * - Signed cookies with HMAC-SHA256 and timing-safe verification
85
+ * - Secret rotation (array of secrets, newest first)
86
+ * - JSON cookies (`j:` prefix, auto-parsed)
87
+ * - `req.secret` / `req.secrets` exposed for downstream middleware
88
+ * - URI-decode toggle
89
+ *
90
+ * @param {string|string[]} [secret] - Secret(s) for signing / verifying cookies.
91
+ * @param {object} [opts] - Configuration options.
92
+ * @param {boolean} [opts.decode=true] - URI-decode cookie values.
93
+ * @returns {Function} Middleware `(req, res, next) => void`.
94
+ *
95
+ * @example
96
+ * app.use(cookieParser());
97
+ * app.use(cookieParser('my-secret'));
98
+ * app.use(cookieParser(['new-secret', 'old-secret'])); // key rotation
99
+ */
100
+ function cookieParser(secret, opts = {})
101
+ {
102
+ const secrets = secret
103
+ ? (Array.isArray(secret) ? secret : [secret])
104
+ : [];
105
+ const decode = opts.decode !== false;
106
+
107
+ return (req, res, next) =>
108
+ {
109
+ const header = req.headers.cookie;
110
+ req.cookies = {};
111
+ req.signedCookies = {};
112
+
113
+ // Expose secret(s) for downstream use (res.cookie signed:true, csrf, etc.)
114
+ if (secrets.length)
115
+ {
116
+ req.secret = secrets[0];
117
+ req.secrets = secrets;
118
+ }
119
+
120
+ if (!header)
121
+ {
122
+ return next();
123
+ }
124
+
125
+ const pairs = header.split(';');
126
+ for (const pair of pairs)
127
+ {
128
+ const eqIdx = pair.indexOf('=');
129
+ if (eqIdx === -1) continue;
130
+
131
+ const name = pair.slice(0, eqIdx).trim();
132
+ let val = pair.slice(eqIdx + 1).trim();
133
+
134
+ // Remove surrounding quotes if any
135
+ if (val.length >= 2 && val[0] === '"' && val[val.length - 1] === '"')
136
+ {
137
+ val = val.slice(1, -1);
138
+ }
139
+
140
+ // URI decode
141
+ if (decode)
142
+ {
143
+ try { val = decodeURIComponent(val); } catch (e) { /* keep raw */ }
144
+ }
145
+
146
+ // Signed cookies → verify, then JSON-parse if j: prefixed
147
+ if (secrets.length > 0 && val.startsWith('s:'))
148
+ {
149
+ const unsigned = _unsign(val, secrets);
150
+ if (unsigned !== false)
151
+ {
152
+ req.signedCookies[name] = _parseJSONCookie(unsigned);
153
+ }
154
+ // Failed-verification signed cookies are silently dropped
155
+ }
156
+ // JSON cookies → auto-parse
157
+ else if (val.startsWith('j:'))
158
+ {
159
+ req.cookies[name] = _parseJSONCookie(val);
160
+ }
161
+ else
162
+ {
163
+ req.cookies[name] = val;
164
+ }
165
+ }
166
+
167
+ next();
168
+ };
169
+ }
170
+
171
+ // -- Static helpers --------------------------------------
172
+
173
+ /**
174
+ * Sign a cookie value with the given secret.
175
+ *
176
+ * @param {string} val - Cookie value to sign.
177
+ * @param {string} secret - Signing secret.
178
+ * @returns {string} Signed value in format `s:<value>.<signature>`.
179
+ *
180
+ * @example
181
+ * const signed = cookieParser.sign('hello', 'my-secret');
182
+ * // => 's:hello.DGDyS...'
183
+ */
184
+ cookieParser.sign = function sign(val, secret)
185
+ {
186
+ const sig = crypto
187
+ .createHmac('sha256', secret)
188
+ .update(String(val))
189
+ .digest('base64')
190
+ .replace(/=+$/, '');
191
+ return `s:${val}.${sig}`;
192
+ };
193
+
194
+ /**
195
+ * Verify and unsign a signed cookie value.
196
+ *
197
+ * @param {string} val - Signed cookie value (`s:data.sig`).
198
+ * @param {string|string[]} secret - Secret or array of secrets (for rotation).
199
+ * @returns {string|false} Unsigned value on success, `false` on failure.
200
+ *
201
+ * @example
202
+ * const value = cookieParser.unsign('s:hello.DGDyS...', 'my-secret');
203
+ * // => 'hello' or false
204
+ */
205
+ cookieParser.unsign = function unsign(val, secret)
206
+ {
207
+ const secrets = Array.isArray(secret) ? secret : [secret];
208
+ return _unsign(val, secrets);
209
+ };
210
+
211
+ /**
212
+ * Serialize a value as a JSON cookie string (prefixed with `j:`).
213
+ *
214
+ * @param {*} val - Value to serialize (object, array, etc.).
215
+ * @returns {string} JSON cookie string.
216
+ *
217
+ * @example
218
+ * const jcookie = cookieParser.jsonCookie({ cart: [1,2,3] });
219
+ * // => 'j:{"cart":[1,2,3]}'
220
+ */
221
+ cookieParser.jsonCookie = function jsonCookie(val)
222
+ {
223
+ return 'j:' + JSON.stringify(val);
224
+ };
225
+
226
+ /**
227
+ * Parse a JSON cookie string (must start with `j:`).
228
+ *
229
+ * @param {string} str - JSON cookie string.
230
+ * @returns {*} Parsed value, or the original string if not a valid JSON cookie.
231
+ */
232
+ cookieParser.parseJSON = function parseJSON(str)
233
+ {
234
+ return _parseJSONCookie(str);
235
+ };
236
+
237
+ module.exports = cookieParser;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @module cors
3
+ * @description CORS middleware. Supports exact origins, wildcard `'*'`,
4
+ * arrays of allowed origins, and suffix matching with a leading dot
5
+ * (e.g. `'.example.com'` matches `sub.example.com`).
6
+ */
7
+
8
+ /**
9
+ * Create a CORS middleware.
10
+ *
11
+ * @param {object} [options] - Configuration options.
12
+ * @param {string|string[]} [options.origin='*'] - Allowed origin(s). Use `'*'` for any,
13
+ * an array for a whitelist, or a string
14
+ * starting with `'.'` for suffix matching.
15
+ * @param {string} [options.methods='GET,POST,PUT,DELETE,OPTIONS'] - Allowed HTTP methods.
16
+ * @param {string} [options.allowedHeaders='Content-Type,Authorization'] - Allowed request headers.
17
+ * @param {string} [options.exposedHeaders] - Headers the browser is allowed to read.
18
+ * @param {boolean} [options.credentials=false] - Whether to set `Access-Control-Allow-Credentials`.
19
+ * @param {number} [options.maxAge] - Preflight cache duration in seconds.
20
+ * @returns {Function} Middleware `(req, res, next) => void`.
21
+ *
22
+ * @example
23
+ * app.use(cors()); // allow all origins
24
+ * app.use(cors({ origin: 'https://example.com' })); // single origin
25
+ * app.use(cors({ // fine-grained
26
+ * origin: ['https://my.example.com', '.example.com'],
27
+ * credentials: true,
28
+ * maxAge: 86400,
29
+ * }));
30
+ */
31
+ function cors(options = {})
32
+ {
33
+ const allowOrigin = (options.hasOwnProperty('origin')) ? options.origin : '*';
34
+ const allowMethods = (options.methods || 'GET,POST,PUT,DELETE,OPTIONS');
35
+ const allowHeaders = (options.allowedHeaders || 'Content-Type,Authorization');
36
+
37
+ // RFC 6454: credentials cannot be used with wildcard origin
38
+ if (options.credentials && allowOrigin === '*')
39
+ {
40
+ throw new Error('CORS credentials cannot be used with wildcard origin "*". Specify explicit origins instead.');
41
+ }
42
+
43
+ /**
44
+ * Resolve the Origin header value to echo back based on the configured
45
+ * allow-list. Returns `null` when the origin should not be allowed.
46
+ *
47
+ * @private
48
+ * @param {string|undefined} reqOrigin - The request's `Origin` header.
49
+ * @returns {string|null} Origin value to set, or `null`.
50
+ */
51
+ function matchOrigin(reqOrigin)
52
+ {
53
+ if (!allowOrigin) return null; // origin explicitly disabled
54
+ if (typeof allowOrigin === 'string') return allowOrigin === '*' ? '*' : allowOrigin;
55
+ if (Array.isArray(allowOrigin))
56
+ {
57
+ if (!reqOrigin) return null;
58
+ for (const o of allowOrigin)
59
+ {
60
+ if (!o) continue;
61
+ if (o === reqOrigin) return reqOrigin;
62
+ // allow suffix match with leading dot (e.g. .example.com)
63
+ if (o[0] === '.' && reqOrigin.endsWith(o)) return reqOrigin;
64
+ }
65
+ return null;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ return (req, res, next) =>
71
+ {
72
+ const reqOrigin = req.headers && (req.headers.origin || req.headers.Origin);
73
+ const originValue = matchOrigin(reqOrigin);
74
+
75
+ if (originValue)
76
+ {
77
+ res.set('Access-Control-Allow-Origin', originValue);
78
+ // Set Vary: Origin when not using wildcard (important for caching proxies)
79
+ if (originValue !== '*') res.vary('Origin');
80
+ if (options.credentials) res.set('Access-Control-Allow-Credentials', 'true');
81
+ }
82
+
83
+ if (allowMethods) res.set('Access-Control-Allow-Methods', allowMethods);
84
+ if (allowHeaders) res.set('Access-Control-Allow-Headers', allowHeaders);
85
+ if (options.exposedHeaders) res.set('Access-Control-Expose-Headers', options.exposedHeaders);
86
+ if (options.maxAge !== undefined) res.set('Access-Control-Max-Age', String(options.maxAge));
87
+
88
+ if (req.method === 'OPTIONS') return res.status(204).send();
89
+ next();
90
+ };
91
+ }
92
+
93
+ module.exports = cors;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @module middleware/csrf
3
+ * @description CSRF (Cross-Site Request Forgery) protection middleware.
4
+ * Uses the double-submit cookie + header/body token pattern.
5
+ *
6
+ * Safe methods (GET, HEAD, OPTIONS) are skipped automatically.
7
+ * For state-changing requests (POST, PUT, PATCH, DELETE), the
8
+ * middleware checks for a matching token in:
9
+ * 1. `req.headers['x-csrf-token']`
10
+ * 2. `req.body._csrf` (if body parsed)
11
+ * 3. `req.query._csrf`
12
+ *
13
+ * @example
14
+ * const { createApp, csrf } = require('@zero-server/sdk');
15
+ * const app = createApp();
16
+ *
17
+ * app.use(csrf()); // default options
18
+ * app.use(csrf({ cookie: 'tok' })); // custom cookie name
19
+ *
20
+ * // In a route, read the token for forms / SPA:
21
+ * app.get('/form', (req, res) => {
22
+ * res.json({ csrfToken: req.csrfToken });
23
+ * });
24
+ */
25
+ const crypto = require('crypto');
26
+ const log = require('../debug')('zero:csrf');
27
+
28
+ /**
29
+ * @param {object} [options] - Configuration options.
30
+ * @param {string} [options.cookie='_csrf'] - Name of the double-submit cookie.
31
+ * @param {string} [options.header='x-csrf-token'] - Request header that carries the token.
32
+ * @param {number} [options.saltLength=18] - Bytes of randomness for token generation.
33
+ * @param {string} [options.secret] - HMAC secret. Auto-generated per process if omitted.
34
+ * @param {string[]} [options.ignoreMethods] - HTTP methods to skip. Default: GET, HEAD, OPTIONS.
35
+ * @param {string[]} [options.ignorePaths] - Path prefixes to skip (e.g. ['/api/webhooks']).
36
+ * @param {Function} [options.onError] - Custom error handler `(req, res) => {}`.
37
+ * @returns {Function} Middleware function.
38
+ */
39
+ function csrf(options = {})
40
+ {
41
+ const cookieName = options.cookie || '_csrf';
42
+ const headerName = (options.header || 'x-csrf-token').toLowerCase();
43
+ const saltLen = options.saltLength || 18;
44
+ const secret = options.secret || crypto.randomBytes(32).toString('hex');
45
+ const ignoreMethods = new Set((options.ignoreMethods || ['GET', 'HEAD', 'OPTIONS']).map(m => m.toUpperCase()));
46
+ const ignorePaths = options.ignorePaths || [];
47
+
48
+ /** @private */
49
+ function generateToken()
50
+ {
51
+ try
52
+ {
53
+ const salt = crypto.randomBytes(saltLen).toString('hex');
54
+ const hash = crypto.createHmac('sha256', secret).update(salt).digest('hex');
55
+ return `${salt}.${hash}`;
56
+ }
57
+ catch (e) { return null; }
58
+ }
59
+
60
+ /** @private */
61
+ function verifyToken(token)
62
+ {
63
+ if (!token || typeof token !== 'string') return false;
64
+ const parts = token.split('.');
65
+ if (parts.length !== 2) return false;
66
+ const [salt, hash] = parts;
67
+ try
68
+ {
69
+ const expected = crypto.createHmac('sha256', secret).update(salt).digest('hex');
70
+ // Constant-time comparison
71
+ if (expected.length !== hash.length) return false;
72
+ return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(hash));
73
+ }
74
+ catch (e) { return false; }
75
+ }
76
+
77
+ return function csrfMiddleware(req, res, next)
78
+ {
79
+ // Skip safe methods
80
+ if (ignoreMethods.has(req.method))
81
+ {
82
+ // Ensure a token exists in the cookie for the client to read
83
+ const existing = req.cookies && req.cookies[cookieName];
84
+ if (!existing || !verifyToken(existing))
85
+ {
86
+ const token = generateToken();
87
+ const secure = req.secure ? '; Secure' : '';
88
+ res.set('Set-Cookie',
89
+ `${cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict${secure}`
90
+ );
91
+ req.csrfToken = token;
92
+ }
93
+ else
94
+ {
95
+ req.csrfToken = existing;
96
+ }
97
+ return next();
98
+ }
99
+
100
+ // Skip ignored paths
101
+ const pathname = req.url.split('?')[0];
102
+ for (const prefix of ignorePaths)
103
+ {
104
+ if (pathname.startsWith(prefix)) return next();
105
+ }
106
+
107
+ // Extract the token the client sent
108
+ const clientToken =
109
+ req.headers[headerName] ||
110
+ (req.body && req.body._csrf) ||
111
+ (req.query && req.query._csrf) ||
112
+ null;
113
+
114
+ // Extract the cookie token
115
+ const cookieToken = req.cookies && req.cookies[cookieName];
116
+
117
+ // Both must exist and be valid, and must match
118
+ if (!clientToken || !cookieToken || clientToken !== cookieToken || !verifyToken(clientToken))
119
+ {
120
+ log.warn('CSRF validation failed for %s %s', req.method, pathname);
121
+ if (options.onError) return options.onError(req, res);
122
+ res.status(403).json({ error: 'CSRF token missing or invalid' });
123
+ return;
124
+ }
125
+
126
+ // Rotate token on each state-changing request
127
+ const newToken = generateToken();
128
+ const secure = req.secure ? '; Secure' : '';
129
+ res.set('Set-Cookie',
130
+ `${cookieName}=${newToken}; Path=/; HttpOnly; SameSite=Strict${secure}`
131
+ );
132
+ req.csrfToken = newToken;
133
+ next();
134
+ };
135
+ }
136
+
137
+ module.exports = csrf;