@blamejs/core 0.6.13 → 0.6.21

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 (47) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/NOTICE +16 -0
  3. package/README.md +27 -18
  4. package/index.js +12 -0
  5. package/lib/archive.js +8 -7
  6. package/lib/audit.js +4 -0
  7. package/lib/auth/password.js +449 -4
  8. package/lib/bundler.js +8 -8
  9. package/lib/cache.js +105 -20
  10. package/lib/cli.js +598 -4
  11. package/lib/config-drift.js +309 -0
  12. package/lib/crypto-field.js +37 -0
  13. package/lib/crypto.js +8 -0
  14. package/lib/db-query.js +21 -2
  15. package/lib/db.js +32 -2
  16. package/lib/dual-control.js +475 -0
  17. package/lib/file-type.js +265 -0
  18. package/lib/framework-schema.js +38 -6
  19. package/lib/http-client-cookie-jar.js +117 -17
  20. package/lib/http-client.js +81 -3
  21. package/lib/internal-sha1-hibp.js +34 -0
  22. package/lib/mail.js +5 -4
  23. package/lib/middleware/csp-nonce.js +7 -4
  24. package/lib/middleware/index.js +2 -0
  25. package/lib/middleware/network-allowlist.js +199 -0
  26. package/lib/network-dns.js +564 -0
  27. package/lib/network-heartbeat.js +290 -0
  28. package/lib/network-nts.js +552 -0
  29. package/lib/network-proxy.js +246 -0
  30. package/lib/network-tls.js +326 -0
  31. package/lib/network.js +233 -0
  32. package/lib/ntp-check.js +50 -4
  33. package/lib/object-store/azure-blob.js +16 -42
  34. package/lib/pagination.js +136 -76
  35. package/lib/parsers/index.js +16 -2
  36. package/lib/parsers/safe-ini.js +273 -0
  37. package/lib/permissions.js +223 -9
  38. package/lib/pqc-agent.js +4 -4
  39. package/lib/retention.js +439 -0
  40. package/lib/security-assert.js +368 -0
  41. package/lib/session.js +138 -8
  42. package/lib/ssrf-guard.js +9 -0
  43. package/lib/vault/index.js +3 -3
  44. package/lib/vendor/MANIFEST.json +12 -0
  45. package/lib/vendor/common-passwords-top-10000.txt +10000 -0
  46. package/package.json +3 -2
  47. package/sbom.cyclonedx.json +61 -0
package/lib/network.js ADDED
@@ -0,0 +1,233 @@
1
+ "use strict";
2
+
3
+ var ntpCheck = require("./ntp-check");
4
+ var nts = require("./network-nts");
5
+ var dns = require("./network-dns");
6
+ var proxy = require("./network-proxy");
7
+ var trust = require("./network-tls");
8
+ var heartbeat = require("./network-heartbeat");
9
+
10
+ var validateOpts = require("./validate-opts");
11
+ var lazyRequire = require("./lazy-require");
12
+ var { defineClass } = require("./framework-error");
13
+
14
+ var NetworkError = defineClass("NetworkError", { alwaysPermanent: true });
15
+
16
+ var observability = lazyRequire(function () { return require("./observability"); });
17
+ var audit = lazyRequire(function () { return require("./audit"); });
18
+
19
+ var SOCKET_DEFAULTS = {
20
+ noDelay: true,
21
+ keepAlive: true,
22
+ keepAliveInitialDelayMs: 0,
23
+ };
24
+
25
+ function _setSocketNoDelay(value) {
26
+ if (typeof value !== "boolean") {
27
+ throw new NetworkError("socket/bad-no-delay", "socket.setDefaultNoDelay: expected boolean, got " + typeof value);
28
+ }
29
+ SOCKET_DEFAULTS.noDelay = value;
30
+ }
31
+
32
+ function _setSocketKeepAlive(opts) {
33
+ opts = opts || {};
34
+ validateOpts(opts, ["enable", "initialDelayMs"], "socket.setDefaultKeepAlive");
35
+ if (opts.enable !== undefined) {
36
+ if (typeof opts.enable !== "boolean") {
37
+ throw new NetworkError("socket/bad-keepalive", "socket.setDefaultKeepAlive: enable must be boolean");
38
+ }
39
+ SOCKET_DEFAULTS.keepAlive = opts.enable;
40
+ }
41
+ if (opts.initialDelayMs !== undefined) {
42
+ if (typeof opts.initialDelayMs !== "number" || !isFinite(opts.initialDelayMs) || opts.initialDelayMs < 0) {
43
+ throw new NetworkError("socket/bad-keepalive-delay", "socket.setDefaultKeepAlive: initialDelayMs must be non-negative finite number");
44
+ }
45
+ SOCKET_DEFAULTS.keepAliveInitialDelayMs = opts.initialDelayMs;
46
+ }
47
+ }
48
+
49
+ // SO_LINGER is intentionally NOT exposed: Node's net.Socket has no
50
+ // public setLinger() method (the option is tracked at
51
+ // nodejs/node#27293 / rejected for the public surface). Reaching into
52
+ // socket._handle to call setLinger is an unstable internal API. The
53
+ // closest operator-exposed semantic is socket.destroy() (RST = "abort
54
+ // on close") vs socket.end() (graceful FIN). Operators needing true
55
+ // SO_LINGER should use a native binding outside the framework. This
56
+ // stub stays in the export with throw-on-call so a future Node release
57
+ // that exposes setLinger can be wired without an API addition.
58
+ function _setSocketLinger(_opts) {
59
+ throw new NetworkError("socket/linger-not-supported",
60
+ "socket.setDefaultLinger: SO_LINGER is not exposed by Node's public net.Socket API " +
61
+ "(see nodejs/node#27293). Use socket.destroy() (abort) vs socket.end() (graceful) " +
62
+ "to control close semantics, or a native binding if true SO_LINGER is required.");
63
+ }
64
+
65
+ function _socketDefaults() {
66
+ return {
67
+ noDelay: SOCKET_DEFAULTS.noDelay,
68
+ keepAlive: SOCKET_DEFAULTS.keepAlive,
69
+ keepAliveInitialDelayMs: SOCKET_DEFAULTS.keepAliveInitialDelayMs,
70
+ };
71
+ }
72
+
73
+ function applyToSocket(socket) {
74
+ if (!socket) return socket;
75
+ try {
76
+ if (typeof socket.setNoDelay === "function") socket.setNoDelay(SOCKET_DEFAULTS.noDelay);
77
+ if (typeof socket.setKeepAlive === "function") {
78
+ socket.setKeepAlive(SOCKET_DEFAULTS.keepAlive, SOCKET_DEFAULTS.keepAliveInitialDelayMs || 0);
79
+ }
80
+ } catch (_e) {}
81
+ return socket;
82
+ }
83
+
84
+ var ntpFacade = {
85
+ querySingle: ntpCheck.querySingle,
86
+ checkDrift: ntpCheck.checkDrift,
87
+ bootCheck: ntpCheck.bootCheck,
88
+ setThresholds: ntpCheck.setThresholds,
89
+ getThresholds: ntpCheck.getThresholds,
90
+ setServers: function (list) {
91
+ if (!Array.isArray(list) || list.length === 0) {
92
+ throw new NetworkError("ntp/bad-servers", "ntp.setServers: expected non-empty array");
93
+ }
94
+ ntpFacade._defaultServers = list.slice();
95
+ _emitObs("network.ntp.servers.set", { count: list.length });
96
+ },
97
+ getServers: function () {
98
+ return (ntpFacade._defaultServers || ntpCheck.DEFAULT_SERVERS).slice();
99
+ },
100
+ _defaultServers: null,
101
+ nts: nts,
102
+ };
103
+
104
+ function bootFromEnv(opts) {
105
+ opts = opts || {};
106
+ validateOpts(opts, ["env", "audit"], "network.bootFromEnv");
107
+ var env = opts.env || process.env;
108
+ var applied = { ntp: {}, dns: {}, proxy: false, tls: {}, heartbeat: 0, socket: {} };
109
+
110
+ if (env.BLAMEJS_NTP_SERVERS) {
111
+ var list = String(env.BLAMEJS_NTP_SERVERS).split(",").map(function (s) { return s.trim(); }).filter(Boolean);
112
+ if (list.length > 0) { ntpFacade.setServers(list); applied.ntp.servers = list.length; }
113
+ }
114
+ var ntpTimeout = env.BLAMEJS_NTP_TIMEOUT_MS;
115
+ if (ntpTimeout) {
116
+ var t = parseInt(ntpTimeout, 10);
117
+ if (isFinite(t) && t > 0) { ntpFacade._defaultTimeoutMs = t; applied.ntp.timeoutMs = t; }
118
+ }
119
+ var ntpWarn = env.BLAMEJS_NTP_DRIFT_WARN_MS;
120
+ var ntpFatal = env.BLAMEJS_NTP_DRIFT_FATAL_MS;
121
+ if (ntpWarn || ntpFatal) {
122
+ var thr = {};
123
+ if (ntpWarn) { thr.warnMs = parseInt(ntpWarn, 10); applied.ntp.warnMs = thr.warnMs; }
124
+ if (ntpFatal) { thr.fatalMs = parseInt(ntpFatal, 10); applied.ntp.fatalMs = thr.fatalMs; }
125
+ ntpCheck.setThresholds(thr);
126
+ }
127
+
128
+ var dnsServers = env.BLAMEJS_DNS_SERVERS;
129
+ if (dnsServers) {
130
+ var dl = String(dnsServers).split(",").map(function (s) { return s.trim(); }).filter(Boolean);
131
+ if (dl.length > 0) { dns.setServers(dl); applied.dns.servers = dl.length; }
132
+ }
133
+ if (env.BLAMEJS_DNS_RESULT_ORDER) { dns.setResultOrder(env.BLAMEJS_DNS_RESULT_ORDER); applied.dns.resultOrder = env.BLAMEJS_DNS_RESULT_ORDER; }
134
+ if (env.BLAMEJS_DNS_FAMILY) { dns.setFamily(parseInt(env.BLAMEJS_DNS_FAMILY, 10)); applied.dns.family = parseInt(env.BLAMEJS_DNS_FAMILY, 10); }
135
+ if (env.BLAMEJS_DNS_LOOKUP_TIMEOUT_MS) { dns.setLookupTimeoutMs(parseInt(env.BLAMEJS_DNS_LOOKUP_TIMEOUT_MS, 10)); applied.dns.lookupTimeoutMs = parseInt(env.BLAMEJS_DNS_LOOKUP_TIMEOUT_MS, 10); }
136
+ if (env.BLAMEJS_DNS_CACHE_TTL_MS) { dns.setCacheTtlMs(parseInt(env.BLAMEJS_DNS_CACHE_TTL_MS, 10)); applied.dns.cacheTtlMs = parseInt(env.BLAMEJS_DNS_CACHE_TTL_MS, 10); }
137
+ if (env.BLAMEJS_DOH_URL) { dns.useDnsOverHttps({ url: env.BLAMEJS_DOH_URL }); applied.dns.doh = env.BLAMEJS_DOH_URL; }
138
+ else if (env.BLAMEJS_DOH_PROVIDER) { dns.useDnsOverHttps({ provider: env.BLAMEJS_DOH_PROVIDER }); applied.dns.dohProvider = env.BLAMEJS_DOH_PROVIDER; }
139
+ if (env.BLAMEJS_DOT_HOST) { dns.useDnsOverTls({ host: env.BLAMEJS_DOT_HOST, port: env.BLAMEJS_DOT_PORT ? parseInt(env.BLAMEJS_DOT_PORT, 10) : 853 }); applied.dns.dot = env.BLAMEJS_DOT_HOST; }
140
+
141
+ if (env.HTTP_PROXY || env.http_proxy || env.HTTPS_PROXY || env.https_proxy ||
142
+ env.NO_PROXY || env.no_proxy || env.ALL_PROXY || env.all_proxy) {
143
+ applied.proxy = proxy.fromEnv(env);
144
+ }
145
+
146
+ if (env.BLAMEJS_EXTRA_CA_CERTS) {
147
+ trust.addCa(env.BLAMEJS_EXTRA_CA_CERTS, { label: "BLAMEJS_EXTRA_CA_CERTS" });
148
+ applied.tls.fileLoaded = env.BLAMEJS_EXTRA_CA_CERTS;
149
+ }
150
+ if (env.BLAMEJS_EXTRA_CA_CERTS_DIR) {
151
+ trust.addCaBundle(env.BLAMEJS_EXTRA_CA_CERTS_DIR, { label: "BLAMEJS_EXTRA_CA_CERTS_DIR" });
152
+ applied.tls.dirLoaded = env.BLAMEJS_EXTRA_CA_CERTS_DIR;
153
+ }
154
+ if (env.BLAMEJS_USE_SYSTEM_TRUST === "1" || env.BLAMEJS_USE_SYSTEM_TRUST === "true") {
155
+ trust.useSystemTrust(true);
156
+ applied.tls.systemTrust = true;
157
+ }
158
+
159
+ if (env.BLAMEJS_SOCKET_NO_DELAY) _setSocketNoDelay(env.BLAMEJS_SOCKET_NO_DELAY === "1" || env.BLAMEJS_SOCKET_NO_DELAY === "true");
160
+ if (env.BLAMEJS_SOCKET_KEEPALIVE) _setSocketKeepAlive({ enable: env.BLAMEJS_SOCKET_KEEPALIVE === "1" || env.BLAMEJS_SOCKET_KEEPALIVE === "true" });
161
+ if (env.BLAMEJS_SOCKET_KEEPALIVE_DELAY_MS) _setSocketKeepAlive({ initialDelayMs: parseInt(env.BLAMEJS_SOCKET_KEEPALIVE_DELAY_MS, 10) });
162
+ applied.socket = _socketDefaults();
163
+
164
+ var auditOn = opts.audit !== false;
165
+ if (auditOn) {
166
+ var sink;
167
+ try { sink = audit(); } catch (_e) { sink = null; }
168
+ if (sink && typeof sink.safeEmit === "function") {
169
+ try {
170
+ sink.safeEmit({
171
+ action: "network.boot.from_env",
172
+ outcome: "success",
173
+ metadata: applied,
174
+ });
175
+ } catch (_e) {}
176
+ }
177
+ }
178
+ _emitObs("network.boot.from_env", { source: "env" });
179
+ return applied;
180
+ }
181
+
182
+ function snapshot() {
183
+ return {
184
+ ntp: {
185
+ servers: ntpFacade.getServers(),
186
+ thresholds: ntpCheck.getThresholds(),
187
+ },
188
+ dns: dns._stateForTest(),
189
+ proxy: proxy.snapshot(),
190
+ tls: {
191
+ systemTrust: trust.isSystemTrustEnabled(),
192
+ caCount: trust.getTrustStore().length,
193
+ },
194
+ heartbeat: heartbeat.statuses(),
195
+ socket: _socketDefaults(),
196
+ };
197
+ }
198
+
199
+ function _emitObs(name, fields) {
200
+ try { observability().emit(name, fields || {}); } catch (_e) {}
201
+ }
202
+
203
+ function _resetForTest() {
204
+ ntpFacade._defaultServers = null;
205
+ ntpFacade._defaultTimeoutMs = null;
206
+ if (typeof ntpCheck._resetThresholdsForTest === "function") ntpCheck._resetThresholdsForTest();
207
+ dns._resetForTest();
208
+ proxy._resetForTest();
209
+ trust._resetForTest();
210
+ heartbeat._resetForTest();
211
+ SOCKET_DEFAULTS.noDelay = true;
212
+ SOCKET_DEFAULTS.keepAlive = true;
213
+ SOCKET_DEFAULTS.keepAliveInitialDelayMs = 0;
214
+ }
215
+
216
+ module.exports = {
217
+ ntp: ntpFacade,
218
+ dns: dns,
219
+ proxy: proxy,
220
+ tls: trust,
221
+ heartbeat: heartbeat,
222
+ socket: {
223
+ setDefaultNoDelay: _setSocketNoDelay,
224
+ setDefaultKeepAlive: _setSocketKeepAlive,
225
+ setDefaultLinger: _setSocketLinger,
226
+ defaults: _socketDefaults,
227
+ applyToSocket: applyToSocket,
228
+ },
229
+ bootFromEnv: bootFromEnv,
230
+ snapshot: snapshot,
231
+ NetworkError: NetworkError,
232
+ _resetForTest: _resetForTest,
233
+ };
package/lib/ntp-check.js CHANGED
@@ -36,6 +36,43 @@ var DEFAULT_SERVERS = ["pool.ntp.org", "time.cloudflare.com"];
36
36
  var DEFAULT_PORT = 123;
37
37
  var DEFAULT_TIMEOUT_MS = 3000;
38
38
 
39
+ var DEFAULT_DRIFT_WARN_MS = C.TIME.minutes(5);
40
+ var DEFAULT_DRIFT_FATAL_MS = C.TIME.hours(1);
41
+
42
+ var thresholds = {
43
+ warnMs: DEFAULT_DRIFT_WARN_MS,
44
+ fatalMs: DEFAULT_DRIFT_FATAL_MS,
45
+ };
46
+
47
+ function setThresholds(opts) {
48
+ opts = opts || {};
49
+ if (opts.warnMs !== undefined) {
50
+ if (typeof opts.warnMs !== "number" || !isFinite(opts.warnMs) || opts.warnMs < 0) {
51
+ throw new TypeError("ntpCheck.setThresholds: warnMs must be non-negative finite number, got " + JSON.stringify(opts.warnMs));
52
+ }
53
+ thresholds.warnMs = opts.warnMs;
54
+ }
55
+ if (opts.fatalMs !== undefined) {
56
+ if (typeof opts.fatalMs !== "number" || !isFinite(opts.fatalMs) || opts.fatalMs < 0) {
57
+ throw new TypeError("ntpCheck.setThresholds: fatalMs must be non-negative finite number, got " + JSON.stringify(opts.fatalMs));
58
+ }
59
+ thresholds.fatalMs = opts.fatalMs;
60
+ }
61
+ if (thresholds.warnMs > thresholds.fatalMs && thresholds.fatalMs > 0) {
62
+ throw new RangeError("ntpCheck.setThresholds: warnMs (" + thresholds.warnMs +
63
+ ") must be <= fatalMs (" + thresholds.fatalMs + ")");
64
+ }
65
+ }
66
+
67
+ function getThresholds() {
68
+ return { warnMs: thresholds.warnMs, fatalMs: thresholds.fatalMs };
69
+ }
70
+
71
+ function _resetThresholdsForTest() {
72
+ thresholds.warnMs = DEFAULT_DRIFT_WARN_MS;
73
+ thresholds.fatalMs = DEFAULT_DRIFT_FATAL_MS;
74
+ }
75
+
39
76
  /**
40
77
  * Query an NTP server once. Resolves with { driftMs, serverTimeMs } or
41
78
  * rejects with { code, message } where code is one of:
@@ -142,22 +179,26 @@ async function bootCheck(opts) {
142
179
  }
143
180
  var absMs = Math.abs(result.driftMs);
144
181
  var driftStr = (result.driftMs >= 0 ? "+" : "") + result.driftMs + "ms";
145
- if (absMs >= C.TIME.hours(1)) {
182
+ var fatalMs = (opts && typeof opts.driftFatalMs === "number") ? opts.driftFatalMs : thresholds.fatalMs;
183
+ var warnMs = (opts && typeof opts.driftWarnMs === "number") ? opts.driftWarnMs : thresholds.warnMs;
184
+ if (fatalMs > 0 && absMs >= fatalMs) {
146
185
  return {
147
186
  ok: false,
148
187
  severity: "fatal",
149
188
  driftMs: result.driftMs,
150
189
  server: result.server,
151
- message: "clock drift " + driftStr + " from " + result.server + " (>= 1 hour) — refuse to boot",
190
+ message: "clock drift " + driftStr + " from " + result.server +
191
+ " (>= " + fatalMs + "ms) — refuse to boot",
152
192
  };
153
193
  }
154
- if (absMs >= C.TIME.minutes(5)) {
194
+ if (warnMs > 0 && absMs >= warnMs) {
155
195
  return {
156
196
  ok: true,
157
197
  severity: "warning",
158
198
  driftMs: result.driftMs,
159
199
  server: result.server,
160
- message: "clock drift " + driftStr + " from " + result.server + " (>= 5 minutes) — investigate",
200
+ message: "clock drift " + driftStr + " from " + result.server +
201
+ " (>= " + warnMs + "ms) — investigate",
161
202
  };
162
203
  }
163
204
  return {
@@ -173,6 +214,11 @@ module.exports = {
173
214
  querySingle: querySingle,
174
215
  checkDrift: checkDrift,
175
216
  bootCheck: bootCheck,
217
+ setThresholds: setThresholds,
218
+ getThresholds: getThresholds,
176
219
  DEFAULT_SERVERS: DEFAULT_SERVERS,
220
+ DEFAULT_DRIFT_WARN_MS: DEFAULT_DRIFT_WARN_MS,
221
+ DEFAULT_DRIFT_FATAL_MS: DEFAULT_DRIFT_FATAL_MS,
177
222
  NTP_TO_UNIX_OFFSET_SECONDS: NTP_TO_UNIX_OFFSET_SECONDS,
223
+ _resetThresholdsForTest: _resetThresholdsForTest,
178
224
  };
@@ -400,49 +400,23 @@ function create(config) {
400
400
  function presignedUploadUrl(opts) { return _presign("PUT", "cw", opts); }
401
401
  function presignedDownloadUrl(opts) { return _presign("GET", "r", opts); }
402
402
 
403
- // Azure SAS doesn't support an equivalent of S3 / GCS POST policy
404
- // with a content-length-range constraint. The SAS spec carries
405
- // permissions / start / expiry / IP / protocol / resource-content-
406
- // headers but no body-size cap Azure validates the upload at the
407
- // service level only via the operator's optional Block Blob size
408
- // limit applied at storage-account or container scope, not via the
409
- // SAS token itself.
403
+ // Azure SAS has no equivalent of S3 / GCS POST policy with a
404
+ // content-length-range constraint the SAS spec carries permissions
405
+ // / start / expiry / IP / protocol / resource-content-headers but
406
+ // no body-size cap. Returning a PUT URL under the POST-policy name
407
+ // would be a silent shape mismatch (operators wiring an HTML form
408
+ // expecting multipart fields get a PUT URL with no fields), so the
409
+ // azure-blob backend refuses cleanly.
410
410
  //
411
- // Rather than throw NOT_SUPPORTED, this returns the same SAS PUT
412
- // shape as presignedUploadUrl with an `enforcement: 'client-only'`
413
- // marker so operators making cross-vendor decisions know the
414
- // body-size guard is advisory on Azure and must be enforced on the
415
- // client side or via a server-side post-upload check.
416
- //
417
- // For strict server-side body-size enforcement on Azure, the
418
- // canonical pattern is: SAS-authorize the upload, then have the
419
- // operator's app issue a HEAD on the resulting blob and delete +
420
- // 4xx the requester if Content-Length > opts.maxBytes.
421
- function presignedUploadPolicy(opts) {
422
- opts = opts || {};
423
- if (typeof opts.maxBytes !== "number" || !Number.isFinite(opts.maxBytes) ||
424
- opts.maxBytes <= 0) {
425
- throw _err("INVALID_MAX_BYTES",
426
- "presignedUploadPolicy: maxBytes (positive number of bytes) is required " +
427
- "(advisory on Azure — see docstring for server-side enforcement)", true);
428
- }
429
- var underlying = _presign("PUT", "cw", opts);
430
- return {
431
- url: underlying.url,
432
- method: "PUT",
433
- // SAS uploads are PUT, not multipart POST — there are no form
434
- // fields. Operators uploading attach the body directly to the
435
- // returned URL with the headers in `headers`.
436
- fields: null,
437
- headers: underlying.headers,
438
- expiresAt: underlying.expiresAt,
439
- maxBytes: opts.maxBytes,
440
- enforcement: "client-only",
441
- enforcementNote:
442
- "Azure SAS does not natively cap upload size. Operators needing " +
443
- "strict size enforcement must HEAD the blob post-upload and reject " +
444
- "if Content-Length exceeds maxBytes.",
445
- };
411
+ // For strict server-side body-size enforcement on Azure: use
412
+ // presignedUploadUrl + a server-side post-upload HEAD that deletes
413
+ // and 4xxs the requester if Content-Length > limit.
414
+ function presignedUploadPolicy(_opts) {
415
+ throw _err("PRESIGN_NOT_SUPPORTED",
416
+ "azure-blob backend does not support presigned upload policies — " +
417
+ "Azure SAS has no body-size cap. Use presignedUploadUrl + a server-side " +
418
+ "HEAD-and-delete check, or switch to an S3 / GCS-compatible backend.",
419
+ true);
446
420
  }
447
421
 
448
422
  return {
package/lib/pagination.js CHANGED
@@ -75,10 +75,17 @@
75
75
  * module's offset() returns a `total` (from COUNT(*)) and computes
76
76
  * `totalPages` so legacy clients can render numbered nav.
77
77
  *
78
+ * Multi-column ordering:
79
+ * - orderBy accepts a string (single column), an array of strings
80
+ * (multiple columns, all using opts.direction), or an array of
81
+ * { column, direction } objects (mixed directions per column).
82
+ * The keyset WHERE expands to the standard OR cascade
83
+ * (col0 [op0] ? OR (col0 = ? AND col1 [op1] ?) OR ...)
84
+ * so successive pages can't repeat or skip rows when ties on the
85
+ * leading columns are broken by trailing ones. _id is appended as
86
+ * a tiebreaker if not already in the orderBy chain.
87
+ *
78
88
  * Out of scope (with structural reasons documented):
79
- * - Multi-column composite orderBy (orderBy: ["a", "b"]). Use raw
80
- * SQL + encodeCursor / decodeCursor. The Query builder doesn't
81
- * model multi-column ORDER BY today.
82
89
  * - Cursor TTL / expiry. Operators who want time-limited cursors
83
90
  * embed a timestamp in their own state and check at decode-time
84
91
  * before passing to .cursor(). The framework's HMAC tag carries
@@ -202,6 +209,83 @@ function _resolveLimit(opts) {
202
209
 
203
210
  // ---- Cursor pagination ----
204
211
 
212
+ // Normalize opts.orderBy into an array of { column, direction } entries.
213
+ // Accepts:
214
+ // undefined / null → [{ column: "_id", direction: opts.direction || "asc" }]
215
+ // "createdAt" → [{ column: "createdAt", direction: opts.direction || "asc" }]
216
+ // ["createdAt", "_id"] → all entries default to opts.direction || "asc"
217
+ // [{column:"a",direction:"desc"}, {column:"b"}]
218
+ // → mixed; missing direction defaults to opts.direction || "asc"
219
+ // Always appends an _id tiebreaker if not present, so cursor uniqueness
220
+ // is guaranteed regardless of the operator's spec.
221
+ function _normalizeOrderBy(opts) {
222
+ var defaultDir = (opts && opts.direction === "desc") ? "desc" : "asc";
223
+ var raw = opts && opts.orderBy;
224
+ var entries;
225
+ if (raw == null) {
226
+ entries = [{ column: "_id", direction: defaultDir }];
227
+ } else if (typeof raw === "string") {
228
+ entries = [{ column: raw, direction: defaultDir }];
229
+ } else if (Array.isArray(raw)) {
230
+ entries = raw.map(function (e) {
231
+ if (typeof e === "string") return { column: e, direction: defaultDir };
232
+ if (!e || typeof e !== "object" || typeof e.column !== "string") {
233
+ throw new PaginationError("pagination/bad-orderby",
234
+ "orderBy[] entries must be strings or { column, direction } objects, got " +
235
+ JSON.stringify(e));
236
+ }
237
+ var d = (e.direction || defaultDir).toLowerCase();
238
+ if (d !== "asc" && d !== "desc") {
239
+ throw new PaginationError("pagination/bad-orderby",
240
+ "orderBy[].direction must be 'asc' | 'desc', got " + JSON.stringify(e.direction));
241
+ }
242
+ return { column: e.column, direction: d };
243
+ });
244
+ } else {
245
+ throw new PaginationError("pagination/bad-orderby",
246
+ "orderBy must be a string, array, or omitted; got " + typeof raw);
247
+ }
248
+ for (var i = 0; i < entries.length; i++) {
249
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(entries[i].column)) {
250
+ throw new PaginationError("pagination/bad-orderby",
251
+ "orderBy column must match /^[A-Za-z_][A-Za-z0-9_]*$/ (identifier-safe), got " +
252
+ JSON.stringify(entries[i].column));
253
+ }
254
+ }
255
+ // Append _id tiebreaker if not already in the chain. Direction
256
+ // matches the chain's last entry by convention so the tiebreaker
257
+ // doesn't reverse the natural reading order.
258
+ var hasId = entries.some(function (e) { return e.column === "_id"; });
259
+ if (!hasId) {
260
+ entries.push({ column: "_id", direction: entries[entries.length - 1].direction });
261
+ }
262
+ return entries;
263
+ }
264
+
265
+ // Build the keyset WHERE clause for next-page navigation given the
266
+ // cursor's column values. Each (col, dir) entry expands the OR cascade:
267
+ // col0 [op0] ? OR (col0 = ? AND col1 [op1] ?) OR ... OR (col0 = ? AND ... AND coln [opn] ?)
268
+ // The compareOp per column flips by direction × forward (XNOR).
269
+ function _buildKeysetWhere(orderEntries, cursorVals, forward) {
270
+ var clauses = [];
271
+ var params = [];
272
+ for (var i = 0; i < orderEntries.length; i++) {
273
+ var entry = orderEntries[i];
274
+ // Effective direction: asc + forward → ">", desc + forward → "<", and reversed for backward.
275
+ var effectiveAsc = (entry.direction === "asc") === forward;
276
+ var op = effectiveAsc ? ">" : "<";
277
+ var equalChain = [];
278
+ for (var j = 0; j < i; j++) {
279
+ equalChain.push('"' + orderEntries[j].column + '" = ?');
280
+ params.push(cursorVals[j]);
281
+ }
282
+ equalChain.push('"' + entry.column + '" ' + op + ' ?');
283
+ params.push(cursorVals[i]);
284
+ clauses.push("(" + equalChain.join(" AND ") + ")");
285
+ }
286
+ return { sql: clauses.join(" OR "), params: params };
287
+ }
288
+
205
289
  async function cursor(query, opts) {
206
290
  if (!query || typeof query.where !== "function" || typeof query.orderBy !== "function" ||
207
291
  typeof query.limit !== "function" || typeof query.all !== "function") {
@@ -213,110 +297,86 @@ async function cursor(query, opts) {
213
297
  throw new PaginationError("pagination/no-secret",
214
298
  "cursor: opts.secret is required (Buffer or non-empty string for HMAC tagging)");
215
299
  }
216
- var limit = _resolveLimit(opts);
217
- var orderBy = typeof opts.orderBy === "string" && opts.orderBy.length > 0 ? opts.orderBy : "_id";
218
- // Throw at call site on bad orderBy — the value is interpolated into
219
- // a raw SQL fragment for the keyset where-clause. Restrict to
220
- // identifier-safe characters so a careless caller piping
221
- // `req.query.orderBy` through doesn't create an SQL-injection vector.
222
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(orderBy)) {
223
- throw new PaginationError("pagination/bad-orderby",
224
- "cursor: orderBy must match /^[A-Za-z_][A-Za-z0-9_]*$/ (identifier-safe), got " +
225
- JSON.stringify(orderBy));
226
- }
227
- var direction = (opts.direction === "desc") ? "desc" : "asc";
300
+ var limit = _resolveLimit(opts);
301
+ var orderEntries = _normalizeOrderBy(opts);
302
+ // Cursor compatibility key: array of `column:direction` pairs canonicalizes
303
+ // the orderBy spec so a stored cursor must match exactly to be replayed.
304
+ var orderKey = orderEntries.map(function (e) { return e.column + ":" + e.direction; });
228
305
 
229
- // Decode incoming cursor (if any) and override direction from cursor.
230
- // The cursor authoritatively encodes which way we're paging — operators
231
- // shouldn't need to round-trip direction in the URL.
232
306
  var cursorState = null;
233
307
  var forward = (opts.forward !== false);
234
308
  if (opts.cursor) {
235
309
  cursorState = decodeCursor(opts.cursor, opts.secret);
236
- if (cursorState.orderBy !== orderBy || cursorState.dir !== direction) {
310
+ var cursorKey = Array.isArray(cursorState.orderKey) ? cursorState.orderKey :
311
+ // Back-compat with v0.6.20 single-column cursors: synthesize the
312
+ // key from the legacy orderBy/dir fields.
313
+ [cursorState.orderBy + ":" + cursorState.dir];
314
+ if (JSON.stringify(cursorKey) !== JSON.stringify(orderKey)) {
237
315
  throw new PaginationError("pagination/cursor-mismatch",
238
- "cursor was created with orderBy='" + cursorState.orderBy + "' direction='" +
239
- cursorState.dir + "' but call uses orderBy='" + orderBy + "' direction='" +
240
- direction + "' — operator must use the same opts the cursor was issued under");
316
+ "cursor orderKey [" + cursorKey.join(", ") + "] does not match call orderKey [" +
317
+ orderKey.join(", ") + "] operator must use the same orderBy/direction spec");
241
318
  }
242
319
  if (typeof cursorState.forward === "boolean") forward = cursorState.forward;
243
320
  }
244
321
 
245
- // Apply the cursor predicate. Direction interacts with forward/backward:
246
- // asc + forward → strictly greater than (orderByVal, _id)
247
- // asc + backward → strictly less than (orderByVal, _id)
248
- // desc + forward → strictly less than
249
- // desc + backward → strictly greater than
250
- // We always SELECT in the direction that matches forward (so the
251
- // result rows arrive in the right reading order), then if
252
- // backward we reverse client-side at the end.
253
- var effectiveAsc = (direction === "asc") === forward; // XNOR
254
- var compareOp;
322
+ // Apply the keyset WHERE if a cursor is present.
255
323
  if (cursorState) {
256
- compareOp = effectiveAsc ? ">" : "<";
257
- // (orderByVal, _id) <op> (?, ?)
258
- // Express via OR to be portable across SQLite + Postgres.
259
- var oCol = orderBy;
260
- query.whereRaw(
261
- '"' + oCol + '" ' + compareOp + ' ? OR ("' + oCol + '" = ? AND "_id" ' + compareOp + ' ?)',
262
- [cursorState.orderByVal, cursorState.orderByVal, cursorState.id]
263
- );
324
+ var cursorVals;
325
+ if (Array.isArray(cursorState.vals)) {
326
+ cursorVals = cursorState.vals;
327
+ } else {
328
+ // v0.6.20 single-column shape — synthesize the vals array.
329
+ cursorVals = [cursorState.orderByVal, cursorState.id];
330
+ // Drop the synthetic _id append if the legacy cursor already had it
331
+ if (orderEntries.length === 1) cursorVals = [cursorState.orderByVal];
332
+ }
333
+ if (cursorVals.length !== orderEntries.length) {
334
+ throw new PaginationError("pagination/cursor-mismatch",
335
+ "cursor encoded " + cursorVals.length + " column value(s) but orderBy has " +
336
+ orderEntries.length + " — operator changed the orderBy spec mid-flight");
337
+ }
338
+ var where = _buildKeysetWhere(orderEntries, cursorVals, forward);
339
+ query.whereRaw(where.sql, where.params);
264
340
  }
265
- query.orderBy(orderBy, effectiveAsc ? "asc" : "desc");
266
- if (orderBy !== "_id") {
267
- // Tiebreaker by _id in the same direction the framework Query
268
- // only models a single orderBy, so we add the tiebreaker as a
269
- // raw ORDER BY suffix via _orderLimitOffset cooperation. Today
270
- // Query lacks multi-orderBy; we emulate by sorting in-memory
271
- // after the fetch using _id within each orderBy group. Keeps
272
- // pagination correct without expanding the Query API.
273
- // No raw orderBy needed because the WHERE condition above
274
- // strictly disambiguates (orderByVal, _id) tuples — successive
275
- // pages can't repeat or skip a row even with ties on orderBy.
341
+
342
+ // Apply ORDER BY for each entry. When forward=false we reverse direction
343
+ // per entry so the SQL returns rows in the right reading order; we then
344
+ // reverse client-side at the end.
345
+ for (var oi = 0; oi < orderEntries.length; oi++) {
346
+ var entry = orderEntries[oi];
347
+ var effectiveDir = ((entry.direction === "asc") === forward) ? "asc" : "desc";
348
+ query.orderBy(entry.column, effectiveDir);
276
349
  }
277
350
  query.limit(limit + 1);
278
351
 
279
352
  var rows = await Promise.resolve(query.all());
280
353
 
281
- // Tiebreaker stability: when orderBy != _id, the SQL only sorts by
282
- // orderBy. Within an orderByVal cluster, sort by _id in JS so the
283
- // cursor predicate's _id-based tiebreaker stays consistent with the
284
- // returned ordering.
285
- if (orderBy !== "_id") {
286
- rows.sort(function (a, b) {
287
- var av = a[orderBy], bv = b[orderBy];
288
- if (av < bv) return effectiveAsc ? -1 : 1;
289
- if (av > bv) return effectiveAsc ? 1 : -1;
290
- var ai = String(a._id), bi = String(b._id);
291
- if (ai < bi) return effectiveAsc ? -1 : 1;
292
- if (ai > bi) return effectiveAsc ? 1 : -1;
293
- return 0;
294
- });
295
- }
296
-
297
354
  var hasMore = rows.length > limit;
298
355
  var page = hasMore ? rows.slice(0, limit) : rows.slice();
299
356
  if (!forward) page.reverse();
300
357
 
358
+ function _valsForRow(row) {
359
+ return orderEntries.map(function (e) {
360
+ return e.column === "_id" ? String(row._id) : row[e.column];
361
+ });
362
+ }
363
+
301
364
  var nextCursor = null;
302
365
  var prevCursor = null;
303
366
  if (hasMore && page.length > 0) {
304
367
  var last = page[page.length - 1];
305
368
  nextCursor = encodeCursor({
306
- dir: direction, orderBy: orderBy,
307
- orderByVal: last[orderBy], id: String(last._id),
308
- forward: true,
369
+ orderKey: orderKey,
370
+ vals: _valsForRow(last),
371
+ forward: true,
309
372
  }, opts.secret);
310
373
  }
311
- // Always emit a prev cursor when we have a starting position (the
312
- // operator was on a non-first page). Operator UI hides it on the
313
- // first page.
314
374
  if (cursorState && page.length > 0) {
315
375
  var first = page[0];
316
376
  prevCursor = encodeCursor({
317
- dir: direction, orderBy: orderBy,
318
- orderByVal: first[orderBy], id: String(first._id),
319
- forward: false,
377
+ orderKey: orderKey,
378
+ vals: _valsForRow(first),
379
+ forward: false,
320
380
  }, opts.secret);
321
381
  }
322
382