@blamejs/blamejs-shop 0.4.85 → 0.4.87
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 +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/cart.js +75 -0
- package/lib/order.js +4 -2
- package/lib/security-middleware.js +1 -0
- package/lib/storefront.js +8 -1
- package/lib/vendor/MANIFEST.json +71 -39
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +11 -3
- package/lib/vendor/blamejs/lib/atomic-file.js +34 -0
- package/lib/vendor/blamejs/lib/auth/fido-mds3.js +10 -0
- package/lib/vendor/blamejs/lib/auth/password.js +1 -0
- package/lib/vendor/blamejs/lib/auth/saml.js +11 -3
- package/lib/vendor/blamejs/lib/daemon.js +4 -1
- package/lib/vendor/blamejs/lib/external-db.js +131 -0
- package/lib/vendor/blamejs/lib/graphql-federation.js +25 -15
- package/lib/vendor/blamejs/lib/log-stream-cloudwatch.js +1 -0
- package/lib/vendor/blamejs/lib/log-stream-local.js +14 -1
- package/lib/vendor/blamejs/lib/log-stream-otlp.js +1 -0
- package/lib/vendor/blamejs/lib/log-stream-webhook.js +1 -0
- package/lib/vendor/blamejs/lib/mail-auth.js +69 -14
- package/lib/vendor/blamejs/lib/mail-bimi.js +6 -0
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +10 -0
- package/lib/vendor/blamejs/lib/mail-dkim.js +68 -15
- package/lib/vendor/blamejs/lib/mail.js +39 -0
- package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +6 -2
- package/lib/vendor/blamejs/lib/network-dns-resolver.js +61 -11
- package/lib/vendor/blamejs/lib/network-dns.js +47 -2
- package/lib/vendor/blamejs/lib/network-nts.js +16 -0
- package/lib/vendor/blamejs/lib/network-proxy.js +55 -2
- package/lib/vendor/blamejs/lib/object-store/azure-blob-bucket-ops.js +1 -0
- package/lib/vendor/blamejs/lib/object-store/gcs-bucket-ops.js +1 -0
- package/lib/vendor/blamejs/lib/object-store/http-request.js +4 -0
- package/lib/vendor/blamejs/lib/outbox.js +29 -0
- package/lib/vendor/blamejs/lib/queue-sqs.js +1 -0
- package/lib/vendor/blamejs/lib/request-helpers.js +25 -6
- package/lib/vendor/blamejs/lib/session-device-binding.js +46 -24
- package/lib/vendor/blamejs/lib/session.js +85 -28
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.15.16.json +94 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +51 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-open-append-nofollow.test.js +87 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/external-db-non-atomic-backend.test.js +200 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/fido-mds3-cert-bad-validity.test.js +159 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/graphql-federation.test.js +49 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth-dmarc-policy-failclosed.test.js +139 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-bimi-cert-validity-unparseable.test.js +137 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime-bad-validity.test.js +134 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-dkim-numericdate-failclosed.test.js +155 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-proxy-framing-bounds.test.js +263 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-dns-lookup-timeout-default.test.js +116 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-dns-resolver-timeout.test.js +126 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-nts-handshake-byte-cap.test.js +127 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/saml-subjectconfirmation-notbefore.test.js +238 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/session-destroy-all-store-backed.test.js +128 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/session-device-binding-ipv6-canonical-and-no-store.test.js +202 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/session-device-binding.test.js +18 -9
- package/lib/vendor/blamejs/test/layer-0-primitives/webhook-verify-nonce-atomic.test.js +169 -0
- package/lib/webhooks.js +38 -9
- package/package.json +1 -1
|
@@ -36,6 +36,10 @@ function request(method, url, headers, body, opts) {
|
|
|
36
36
|
url: url,
|
|
37
37
|
headers: headers,
|
|
38
38
|
body: body,
|
|
39
|
+
// Both caps from the operator's configured timeout: timeoutMs bounds the
|
|
40
|
+
// whole request (no slow-trickle hold-open), idleTimeoutMs the zero-progress
|
|
41
|
+
// window. Undefined leaves httpClient's defaults unchanged.
|
|
42
|
+
timeoutMs: opts.timeoutMs,
|
|
39
43
|
idleTimeoutMs: opts.timeoutMs,
|
|
40
44
|
errorClass: opts.errorClass || ObjectStoreError,
|
|
41
45
|
allowedProtocols: opts.allowedProtocols,
|
|
@@ -214,6 +214,35 @@ function create(opts) {
|
|
|
214
214
|
throw new OutboxError("outbox/bad-externaldb",
|
|
215
215
|
"outbox.create: externalDb must be the b.externalDb namespace (with transaction/query)");
|
|
216
216
|
}
|
|
217
|
+
// The outbox dual-write guarantee (enqueue runs in the SAME
|
|
218
|
+
// transaction as the operator's business write) is VOID on a
|
|
219
|
+
// stateless / autocommit-per-statement backend: BEGIN / the
|
|
220
|
+
// business write / the enqueue INSERT / COMMIT each land on a
|
|
221
|
+
// different session, so a crash or rollback can drop the business
|
|
222
|
+
// row while keeping the event (or vice versa). Refuse at create()
|
|
223
|
+
// when the backend declares it cannot provide interactive
|
|
224
|
+
// transactions, rather than constructing an outbox whose core
|
|
225
|
+
// guarantee silently does not hold. supportsTransactions may be a
|
|
226
|
+
// boolean (custom externalDb object) or a function (the
|
|
227
|
+
// b.externalDb namespace probe); only an explicit false refuses —
|
|
228
|
+
// an absent flag preserves the historical stateful assumption.
|
|
229
|
+
var cap = v.supportsTransactions;
|
|
230
|
+
var atomic = true;
|
|
231
|
+
if (typeof cap === "function") {
|
|
232
|
+
try { atomic = !!cap(); } catch (_e) { atomic = true; }
|
|
233
|
+
} else if (cap === false) {
|
|
234
|
+
atomic = false;
|
|
235
|
+
}
|
|
236
|
+
if (!atomic) {
|
|
237
|
+
throw new OutboxError("outbox/non-atomic-backend",
|
|
238
|
+
"outbox.create: externalDb backend cannot provide interactive " +
|
|
239
|
+
"transactions (supportsTransactions: false — a stateless / " +
|
|
240
|
+
"autocommit-per-statement adapter). The outbox dual-write guarantee " +
|
|
241
|
+
"requires the business write and the enqueue INSERT to commit " +
|
|
242
|
+
"atomically in one transaction; on this backend they would not. " +
|
|
243
|
+
"Supply interactive beginTx / commit / rollback hooks, or a batch " +
|
|
244
|
+
"adapter, on the externalDb backend first.");
|
|
245
|
+
}
|
|
217
246
|
},
|
|
218
247
|
table: function (v) {
|
|
219
248
|
validateOpts.requireNonEmptyString(v,
|
|
@@ -476,7 +476,7 @@ function _maskIpv6(ip, prefix) {
|
|
|
476
476
|
|
|
477
477
|
/**
|
|
478
478
|
* @primitive b.requestHelpers.ipPrefix
|
|
479
|
-
* @signature b.requestHelpers.ipPrefix(ip)
|
|
479
|
+
* @signature b.requestHelpers.ipPrefix(ip, opts?)
|
|
480
480
|
* @since 0.15.15
|
|
481
481
|
* @related b.requestHelpers.clientIp, b.requestHelpers.trustedClientIp
|
|
482
482
|
*
|
|
@@ -493,22 +493,41 @@ function _maskIpv6(ip, prefix) {
|
|
|
493
493
|
* public IP within a subnet don't log a user out). Exposed so an operator who
|
|
494
494
|
* drops to a function-form fingerprint field — for a custom mask width, or to
|
|
495
495
|
* combine the prefix with other signals — reuses this exact algorithm instead
|
|
496
|
-
* of re-deriving the /24 + /64 masking (and silently diverging).
|
|
496
|
+
* of re-deriving the /24 + /64 masking (and silently diverging). Pass
|
|
497
|
+
* <code>opts.v4Bits</code> / <code>opts.v6Bits</code> to override the mask
|
|
498
|
+
* widths (e.g. a device fingerprint that buckets at <code>/48</code> so a
|
|
499
|
+
* client roaming within its allocation but across a <code>/64</code> doesn't
|
|
500
|
+
* drift); an out-of-range or absent value falls back to the /24 + /64 default.
|
|
501
|
+
*
|
|
502
|
+
* @opts
|
|
503
|
+
* v4Bits: number, // IPv4 mask width in bits (default 24; valid 0..32)
|
|
504
|
+
* v6Bits: number, // IPv6 mask width in bits (default 64; valid 0..128)
|
|
497
505
|
*
|
|
498
506
|
* @example
|
|
499
507
|
* b.requestHelpers.ipPrefix("203.0.113.47"); // → "203.0.113.0/24"
|
|
500
508
|
* b.requestHelpers.ipPrefix("2001:db8::1"); // → "2001:db8:0:0/64"
|
|
501
509
|
*/
|
|
502
|
-
function
|
|
510
|
+
function _resolvePrefixBits(bits, def, max) {
|
|
511
|
+
if (typeof bits !== "number" || !isFinite(bits) || bits < 0 || bits > max) return def;
|
|
512
|
+
return bits;
|
|
513
|
+
}
|
|
514
|
+
function ipPrefix(ip, opts) {
|
|
503
515
|
if (typeof ip !== "string" || ip.length === 0) return "";
|
|
516
|
+
opts = opts || {};
|
|
517
|
+
// Configurable mask widths (default /24 + /64); an absent or out-of-range
|
|
518
|
+
// value falls back to the default. This preserves a caller's documented
|
|
519
|
+
// prefix width (e.g. a device fingerprint bucketing at /48) instead of
|
|
520
|
+
// silently forcing /64 — while still canonicalizing the address.
|
|
521
|
+
var v4 = _resolvePrefixBits(opts.v4Bits, IPV4_DEFAULT_PREFIX, 32); // IPv4 max prefix length in bits
|
|
522
|
+
var v6 = _resolvePrefixBits(opts.v6Bits, IPV6_DEFAULT_PREFIX, 128); // IPv6 max prefix length in bits
|
|
504
523
|
// IPv4-mapped IPv6 (::ffff:1.2.3.4) — strip the wrapper so the v4 mask
|
|
505
524
|
// applies. Same bucket regardless of how the proxy reported it.
|
|
506
525
|
var lower = ip.toLowerCase();
|
|
507
526
|
if (lower.indexOf(V4_MAPPED_V6_PREFIX) === 0 && lower.indexOf(".") !== -1) {
|
|
508
|
-
return _maskIpv4(lower.substring(V4_MAPPED_V6_PREFIX.length),
|
|
527
|
+
return _maskIpv4(lower.substring(V4_MAPPED_V6_PREFIX.length), v4) || "";
|
|
509
528
|
}
|
|
510
|
-
if (ip.indexOf(":") !== -1) return _maskIpv6(ip,
|
|
511
|
-
if (ip.indexOf(".") !== -1) return _maskIpv4(ip,
|
|
529
|
+
if (ip.indexOf(":") !== -1) return _maskIpv6(ip, v6) || "";
|
|
530
|
+
if (ip.indexOf(".") !== -1) return _maskIpv4(ip, v4) || "";
|
|
512
531
|
return "";
|
|
513
532
|
}
|
|
514
533
|
|
|
@@ -52,6 +52,14 @@
|
|
|
52
52
|
* primitive falls back to b.session.touch metadata when the operator
|
|
53
53
|
* passes session=b.session AND opts in via storeInSession=true.
|
|
54
54
|
*
|
|
55
|
+
* No store at all: create() with neither bindingStore nor storeInSession
|
|
56
|
+
* still returns an instance — its stateless fingerprint(req) works (it
|
|
57
|
+
* touches no store), while bind/verify/unbind throw a clear "no store
|
|
58
|
+
* configured" error. Operators who only need the soft, store-free digest
|
|
59
|
+
* (sealed inside a self-validating cookie / JWT that compares it itself)
|
|
60
|
+
* can also skip create() entirely and call the static
|
|
61
|
+
* b.sessionDeviceBinding.fingerprint(req, opts).
|
|
62
|
+
*
|
|
55
63
|
* Audit emissions:
|
|
56
64
|
*
|
|
57
65
|
* session.device.bound every successful bind()
|
|
@@ -61,7 +69,9 @@
|
|
|
61
69
|
*
|
|
62
70
|
* Validation policy:
|
|
63
71
|
* - create() opts → throw at config time
|
|
64
|
-
* - bind / verify
|
|
72
|
+
* - bind / verify / unbind → throw on bad token / req shape (operator
|
|
73
|
+
* typo), and throw "no store configured" when called
|
|
74
|
+
* on a store-free instance
|
|
65
75
|
* - storage errors → fail-CLOSED on verify (drift indistinguishable
|
|
66
76
|
* from a wiped store, refuse rather than allow)
|
|
67
77
|
* fail-OPEN on bind (don't lose a fresh session
|
|
@@ -135,28 +145,22 @@ function _normalizeAcceptEncoding(value) {
|
|
|
135
145
|
.join(",");
|
|
136
146
|
}
|
|
137
147
|
|
|
148
|
+
// Mask the client IP to its fingerprint bucket. Routes through the shared
|
|
149
|
+
// canonical masker (requestHelpers.ipPrefix) so a `::`-shorthand address and
|
|
150
|
+
// its fully-expanded equivalent (2001:db8::1 vs 2001:db8:0:0:0:0:0:1), or a
|
|
151
|
+
// leading-zero-folded group, collapse to ONE bucket — the hand-rolled textual
|
|
152
|
+
// ':'-group slice this replaced hashed them differently and logged a roaming
|
|
153
|
+
// user out on a false drift. `bits === 0` is the documented "skip the IP check
|
|
154
|
+
// entirely" escape hatch (mobile clients that switch networks), so it returns
|
|
155
|
+
// "" before any masking. `bits` is the family-resolved configured width (cfg
|
|
156
|
+
// .v4Bits for a v4 client, cfg.v6Bits for v6 — default /24 + /48); pass it as
|
|
157
|
+
// BOTH v4Bits and v6Bits so the canonical masker applies the configured width
|
|
158
|
+
// to whichever family it detects, instead of ipPrefix's bare /24 + /64 default
|
|
159
|
+
// (which would drop the configured width AND silently tighten v6 from /48 to /64).
|
|
138
160
|
function _ipPrefix(ip, bits) {
|
|
139
161
|
if (typeof ip !== "string" || ip.length === 0) return "";
|
|
140
162
|
if (bits === 0) return "";
|
|
141
|
-
|
|
142
|
-
if (ip.indexOf(":") !== -1) {
|
|
143
|
-
var v6Bits = bits;
|
|
144
|
-
var groups = ip.split(":");
|
|
145
|
-
// Naive expansion — keep the first ceil(v6Bits/16) groups intact
|
|
146
|
-
// and zero the rest. Sufficient for fingerprint stability; not a
|
|
147
|
-
// canonical IPv6 representation.
|
|
148
|
-
var keepGroups = Math.ceil(v6Bits / 16); // IPv6 group width in bits
|
|
149
|
-
var kept = groups.slice(0, keepGroups).join(":");
|
|
150
|
-
return "v6:" + kept + "/" + v6Bits;
|
|
151
|
-
}
|
|
152
|
-
// IPv4
|
|
153
|
-
var parts = ip.split(".");
|
|
154
|
-
if (parts.length !== 4) return "v4:" + ip + "/" + bits;
|
|
155
|
-
var v4Bits = bits;
|
|
156
|
-
var keepOctets = Math.floor(v4Bits / 8); // IPv4 octet width in bits
|
|
157
|
-
var maskedOctets = parts.slice(0, keepOctets);
|
|
158
|
-
while (maskedOctets.length < 4) maskedOctets.push("0");
|
|
159
|
-
return "v4:" + maskedOctets.join(".") + "/" + v4Bits;
|
|
163
|
+
return requestHelpers.ipPrefix(ip, { v4Bits: bits, v6Bits: bits });
|
|
160
164
|
}
|
|
161
165
|
|
|
162
166
|
// Resolve operator-supplied fingerprintExtras(req) to a stable string. A
|
|
@@ -195,6 +199,7 @@ function _computeDeviceFingerprint(req, cfg) {
|
|
|
195
199
|
var ae = _normalizeAcceptEncoding(headers["accept-encoding"]);
|
|
196
200
|
var ip = "";
|
|
197
201
|
try { ip = requestHelpers.clientIp(req); } catch (_e) { ip = ""; }
|
|
202
|
+
if (typeof ip !== "string") ip = "";
|
|
198
203
|
var family = ip.indexOf(":") !== -1 ? "v6" : "v4";
|
|
199
204
|
var ipPart = _ipPrefix(ip, family === "v6" ? cfg.v6Bits : cfg.v4Bits);
|
|
200
205
|
var keyPart = "";
|
|
@@ -291,10 +296,15 @@ function create(opts) {
|
|
|
291
296
|
}
|
|
292
297
|
|
|
293
298
|
var storeInSession = !!opts.storeInSession;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
299
|
+
// A no-store instance is still useful: the stateless fingerprint() reads no
|
|
300
|
+
// store and is the soft device-binding building block for self-validating
|
|
301
|
+
// tokens (a sealed cookie / JWT carrying the fingerprint inside). Rather than
|
|
302
|
+
// refuse to construct (issue #330 — fingerprint() unreachable without a
|
|
303
|
+
// store), build the instance and let the persisted bind()/verify() lifecycle
|
|
304
|
+
// throw a clear "no store configured" when actually called. Operators wanting
|
|
305
|
+
// ONLY the stateless digest can also use the static
|
|
306
|
+
// b.sessionDeviceBinding.fingerprint(req, opts) with no create() at all.
|
|
307
|
+
var hasStore = !!(storeInSession || opts.bindingStore);
|
|
298
308
|
if (opts.bindingStore) _requireBindingStore(opts.bindingStore);
|
|
299
309
|
if (storeInSession && (!opts.session || typeof opts.session.touch !== "function")) {
|
|
300
310
|
throw new SessionDeviceBindingError("session-device-binding/bad-opt",
|
|
@@ -351,8 +361,18 @@ function create(opts) {
|
|
|
351
361
|
return { ok: true, fingerprint: r.fingerprint, components: r.components };
|
|
352
362
|
}
|
|
353
363
|
|
|
364
|
+
function _requireStore(stage) {
|
|
365
|
+
if (!hasStore) {
|
|
366
|
+
throw new SessionDeviceBindingError("session-device-binding/no-store",
|
|
367
|
+
stage + ": no store configured — pass bindingStore (b.cache-shaped) or "
|
|
368
|
+
+ "storeInSession=true to create(), or use the stateless "
|
|
369
|
+
+ "b.sessionDeviceBinding.fingerprint(req, opts) for soft binding");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
354
373
|
async function bind(token, req) {
|
|
355
374
|
_requireToken(token);
|
|
375
|
+
_requireStore("bind");
|
|
356
376
|
var fp = _computeFingerprint(req);
|
|
357
377
|
if (!fp.ok) {
|
|
358
378
|
_emitObs("session.device.refused", { reason: fp.reason });
|
|
@@ -408,6 +428,7 @@ function create(opts) {
|
|
|
408
428
|
|
|
409
429
|
async function verify(token, req) {
|
|
410
430
|
_requireToken(token);
|
|
431
|
+
_requireStore("verify");
|
|
411
432
|
var fpResult = _computeFingerprint(req);
|
|
412
433
|
if (!fpResult.ok) {
|
|
413
434
|
_emitObs("session.device.refused", { reason: fpResult.reason });
|
|
@@ -444,6 +465,7 @@ function create(opts) {
|
|
|
444
465
|
|
|
445
466
|
async function unbind(token) {
|
|
446
467
|
_requireToken(token);
|
|
468
|
+
_requireStore("unbind");
|
|
447
469
|
if (bindingStore) {
|
|
448
470
|
try { await bindingStore.del(token); } catch (_e) { /* drop-silent */ }
|
|
449
471
|
}
|
|
@@ -182,6 +182,49 @@ function _validFromConflictRefs(dialect, table) {
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
// CREATE TABLE IF NOT EXISTS for the valid-from boundary, matching the
|
|
186
|
+
// framework schema in db.js (single-node) / framework-schema.js (cluster mode):
|
|
187
|
+
// subjectHash PRIMARY KEY, validFromEpoch + updatedAt NOT NULL. The pluggable
|
|
188
|
+
// store is always a dedicated node:sqlite file (b.session.stores.localDbThin —
|
|
189
|
+
// see session-stores.js), so the dialect is the literal "sqlite". Used to
|
|
190
|
+
// provision the table on demand in a store-backed-only deployment (a
|
|
191
|
+
// b.session.useStore consumer that never ran b.db.init(), so the framework db
|
|
192
|
+
// — the default home of this table — is not initialized).
|
|
193
|
+
function _validFromSchemaSql() {
|
|
194
|
+
return sql.createTable(_validFromSqlTable(), [
|
|
195
|
+
{ name: "subjectHash", type: "text", primaryKey: true },
|
|
196
|
+
{ name: "validFromEpoch", type: "int", notNull: true },
|
|
197
|
+
{ name: "updatedAt", type: "int", notNull: true },
|
|
198
|
+
], { dialect: "sqlite" }).sql;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Run a valid-from boundary operation (bump write / validFrom read / check
|
|
202
|
+
// read) against the correct backend. The boundary lives in the FRAMEWORK db
|
|
203
|
+
// (clusterStorage) — it is a stateless-token revocation primitive shared across
|
|
204
|
+
// every issuer, not per-session data — so that is always the first choice and a
|
|
205
|
+
// present db is never silently bypassed. ONLY when the framework db is not
|
|
206
|
+
// initialized (single-node, b.db.init() never awaited) AND an operator store is
|
|
207
|
+
// configured (b.session.useStore) does the boundary fall back to that store, so
|
|
208
|
+
// a store-backed-only deployment's logout-everywhere still raises (and honors)
|
|
209
|
+
// the stateless boundary instead of 500ing on db/not-initialized (#340). With
|
|
210
|
+
// neither a framework db nor a store, db/not-initialized is a real
|
|
211
|
+
// misconfiguration and propagates unchanged (fail closed — the boundary is
|
|
212
|
+
// never silently dropped). The store provisions the table on demand because a
|
|
213
|
+
// session-data store (localDbThin) does not ship the valid-from DDL.
|
|
214
|
+
async function _runValidFrom(runner) {
|
|
215
|
+
try {
|
|
216
|
+
return await runner(clusterStorage);
|
|
217
|
+
} catch (e) {
|
|
218
|
+
if (e && e.code === "db/not-initialized" && _store) {
|
|
219
|
+
// Framework db absent, operator store present: route through the store,
|
|
220
|
+
// provisioning the boundary table first (idempotent CREATE IF NOT EXISTS).
|
|
221
|
+
await _store.execute(_validFromSchemaSql(), []);
|
|
222
|
+
return await runner(_store);
|
|
223
|
+
}
|
|
224
|
+
throw e;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
185
228
|
// Column order used for INSERT — kept as a constant so the placeholders
|
|
186
229
|
// list and the values list stay in sync. Must match the session table's
|
|
187
230
|
// schema in db.js (single-node) and framework-schema.js (cluster mode).
|
|
@@ -786,21 +829,23 @@ async function destroyAllForUser(userId) {
|
|
|
786
829
|
var result = await _currentStore().execute(built.sql, built.params);
|
|
787
830
|
// Also raise the stateless valid-from boundary so a "logout everywhere"
|
|
788
831
|
// revokes the operator's stateless tokens (sealed cookies / JWTs checked via
|
|
789
|
-
// b.session.check) too, not only the store-backed rows just deleted.
|
|
790
|
-
//
|
|
791
|
-
// (b.session.useStore)
|
|
792
|
-
//
|
|
793
|
-
//
|
|
832
|
+
// b.session.check) too, not only the store-backed rows just deleted. bump()
|
|
833
|
+
// writes to the framework db when one is initialized, otherwise to the
|
|
834
|
+
// configured store (b.session.useStore) — so a store-backed-only consumer who
|
|
835
|
+
// never ran b.db.init() still raises the boundary here instead of 500ing on
|
|
836
|
+
// db/not-initialized (#340). The only state in which bump still surfaces
|
|
837
|
+
// db/not-initialized is the default store (no useStore) with an uninitialized
|
|
838
|
+
// framework db — but the store DELETE above (also via clusterStorage) would
|
|
839
|
+
// have already thrown, so this rewrap is defensive belt-and-suspenders.
|
|
794
840
|
try {
|
|
795
841
|
await bump(userId);
|
|
796
842
|
} catch (e) {
|
|
797
843
|
if (e && e.code === "db/not-initialized") {
|
|
798
844
|
throw _err("MISCONFIGURED",
|
|
799
845
|
"session.destroyAllForUser raises the stateless valid-from boundary (so a " +
|
|
800
|
-
"logout-everywhere also revokes sealed-cookie / JWT sessions)
|
|
801
|
-
"b.db.init()
|
|
802
|
-
"
|
|
803
|
-
"after b.db.init() to also raise the stateless boundary.", true);
|
|
846
|
+
"logout-everywhere also revokes sealed-cookie / JWT sessions). No storage is " +
|
|
847
|
+
"available: call b.db.init() at boot, OR configure a session store via " +
|
|
848
|
+
"b.session.useStore. The store-backed rows were already deleted.", true);
|
|
804
849
|
}
|
|
805
850
|
throw e;
|
|
806
851
|
}
|
|
@@ -1340,21 +1385,27 @@ async function bump(subjectId, opts) {
|
|
|
1340
1385
|
.returning(["validFromEpoch"])
|
|
1341
1386
|
.toSql();
|
|
1342
1387
|
// The valid-from boundary is a FRAMEWORK table (FRAMEWORK_SCHEMA / cluster
|
|
1343
|
-
// DDL), NOT session data — it
|
|
1344
|
-
//
|
|
1345
|
-
//
|
|
1346
|
-
//
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1388
|
+
// DDL), NOT session data — it executes against clusterStorage (the framework
|
|
1389
|
+
// db) whenever one is initialized, never _currentStore(). When the framework
|
|
1390
|
+
// db is NOT initialized but an operator store IS configured (a store-backed-
|
|
1391
|
+
// only b.session.useStore deployment that never ran b.db.init()), the boundary
|
|
1392
|
+
// falls back to that store — provisioned on demand — so logout-everywhere
|
|
1393
|
+
// still raises the stateless boundary instead of throwing db/not-initialized
|
|
1394
|
+
// (#340). _runValidFrom resolves the target; never silently drops the boundary
|
|
1395
|
+
// when a db is present, and propagates db/not-initialized when neither exists.
|
|
1396
|
+
var row = await _runValidFrom(async function (target) {
|
|
1397
|
+
if (built.readbackSql) {
|
|
1398
|
+
// MySQL: ON DUPLICATE KEY UPDATE has no RETURNING — run the upsert, then
|
|
1399
|
+
// the readback SELECT b.sql emits (keyed on subjectHash). MySQL only ever
|
|
1400
|
+
// runs against the framework cluster db; the localDbThin fallback store is
|
|
1401
|
+
// sqlite (RETURNING), so this branch never routes through it.
|
|
1402
|
+
await target.execute(built.sql, built.params);
|
|
1403
|
+
var readback = await target.execute(built.readbackSql.sql, built.readbackSql.params);
|
|
1404
|
+
return readback.rows && readback.rows[0];
|
|
1405
|
+
}
|
|
1406
|
+
var result = await target.execute(built.sql, built.params);
|
|
1407
|
+
return result.rows && result.rows[0];
|
|
1408
|
+
});
|
|
1358
1409
|
var effective = row ? Number(row.validFromEpoch) : epochMs;
|
|
1359
1410
|
|
|
1360
1411
|
// Best-effort audit — matches the file's emit convention (safeEmit is
|
|
@@ -1393,10 +1444,16 @@ async function validFrom(subjectId) {
|
|
|
1393
1444
|
.columns(["validFromEpoch"])
|
|
1394
1445
|
.where("subjectHash", _hashSubjectId(subjectId))
|
|
1395
1446
|
.toSql();
|
|
1396
|
-
// Framework valid-from table — read from clusterStorage (the framework db)
|
|
1397
|
-
//
|
|
1398
|
-
//
|
|
1399
|
-
|
|
1447
|
+
// Framework valid-from table — read from clusterStorage (the framework db)
|
|
1448
|
+
// when one is initialized, falling back to the configured store only when it
|
|
1449
|
+
// is not (the same store bump() wrote the boundary to in a store-backed-only
|
|
1450
|
+
// deployment). _runValidFrom keeps the read on the SAME backend the write
|
|
1451
|
+
// chose, so a boundary raised via destroyAllForUser/bump is the one read back
|
|
1452
|
+
// here. See bump() for the full rationale (#340).
|
|
1453
|
+
var row = await _runValidFrom(async function (target) {
|
|
1454
|
+
var result = await target.execute(built.sql, built.params);
|
|
1455
|
+
return result.rows && result.rows.length > 0 ? result.rows[0] : null;
|
|
1456
|
+
});
|
|
1400
1457
|
return row ? Number(row.validFromEpoch) : 0;
|
|
1401
1458
|
}
|
|
1402
1459
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.15.16",
|
|
4
|
+
"date": "2026-06-22",
|
|
5
|
+
"headline": "A correctness and hardening release: a broad set of credential and certificate verifiers now fail closed on a present-but-unparseable timestamp instead of silently skipping the check, outbound network clients gain an overall wall-clock timeout and a bounded framing buffer, DMARC refuses a record with no usable policy, append-only log sinks refuse a symlinked path, and the interactive-transaction and replay-store contracts refuse a backend that cannot honor them",
|
|
6
|
+
"summary": "This release closes a family of fail-open and resource-exhaustion gaps without adding opt-ins to the request path. A recurring pattern across the verifiers — a timestamp parsed with a lenient parser, then gated by isFinite() so an unparseable value disables the check — is converted to fail-closed everywhere it appears: DKIM x=/t=/l=, ARC AMS/AS t=/x=, SAML Bearer and holder-of-key NotBefore, and the FIDO MDS3 / BIMI / S/MIME certificate validity windows now refuse a malformed value rather than accepting the credential. DMARC now validates and requires a usable p= policy and never recommends delivery for a failing message with an absent or unrecognized policy. Outbound clients that exposed a single timeout but applied it only as an idle (zero-progress) timer now also enforce it as an overall wall-clock cap, so a peer that trickles bytes within the idle window can no longer hold a request open indefinitely: the encrypted HTTP client, the object-store and SQS request paths, the CloudWatch / OTLP / webhook log sinks, and the password breach check. The DNS resolver and the lower-level DNS client gain a per-query wall-clock deadline that also tears the socket down, the SMTP client and the proxy CONNECT tunnel bound their framing buffers and add an overall deadline, and the NTS key-establishment handshake bounds its accumulator. The append-only local log sink and the daemon log open with O_NOFOLLOW so a symlink planted at the path is refused rather than followed. b.externalDb.transaction and b.outbox now refuse a backend that declares it cannot provide an interactive transaction instead of silently running a non-atomic block, and the GraphQL-federation replay store adopts the framework's atomic check-and-insert contract.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Security",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "Certificate and credential verifiers fail closed on an unparseable timestamp",
|
|
13
|
+
"body": "A present-but-unparseable date no longer silently disables a validity check. DKIM x= / t= (signature expiry, future-date, ordering) and l= (body-length cap), ARC AMS/AS t= / x=, SAML Bearer and holder-of-key SubjectConfirmationData NotBefore, and the FIDO MDS3, BIMI, and S/MIME certificate validity windows previously parsed the value, then guarded the comparison with isFinite() — so a value that did not parse (NaN) skipped the check and the credential was accepted. Each now refuses a present-but-unparseable value (a permerror / not-valid result) and only an absent optional field is ignored, matching the existing fail-closed handling of the SAML Conditions block."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "DMARC refuses a record with no usable policy",
|
|
17
|
+
"body": "The DMARC policy tag p= (and the subdomain sp=) were taken verbatim with no validation and p= was never required, so the disposition mapped a null or typo'd policy to the catch-all \"deliver\". A failing, unaligned message against a domain whose record omitted p= or carried an unrecognized value was therefore delivered. p= and sp= are now validated against none|quarantine|reject, p= is required for a record to carry a policy at all, an invalid record is a permerror (not a transient temperror), and the disposition is an explicit fail-closed mapping that never delivers a failing message on an absent or unrecognized policy."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "Outbound clients enforce an overall wall-clock timeout, not only an idle timeout",
|
|
21
|
+
"body": "Several clients exposed a single timeout but forwarded it to the underlying request only as idleTimeoutMs (a zero-progress cap that resets on every received byte), leaving no overall bound — a peer that trickles one byte inside each idle window holds the call open indefinitely. b.httpClient.encrypted (which dropped timeoutMs entirely, and now also forwards the caller's AbortSignal), the object-store request path and Azure/GCS bucket operations, the SQS queue client, the CloudWatch / OTLP / webhook log sinks, and the password HIBP breach check now set the overall wall-clock timeout alongside the idle timeout."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"title": "DNS lookups gain a default wall-clock deadline and tear the socket down",
|
|
25
|
+
"body": "The DNS resolver's DoH transport had no time bound of any kind and the lower-level DNS client defaulted its lookup timeout to zero (no deadline). A non-responsive or slow-trickle upstream — reachable while resolving DKIM/SPF/BIMI records for inbound mail — held the lookup pending forever. The resolver now takes a per-query timeout (default 10s) wrapping every query and CNAME hop, the DNS client defaults its lookup timeout to 10s (operators opt out with a zero timeout), and the raw DoH/DoT request paths arm a socket timeout that destroys the connection on a stall rather than leaking the descriptor."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"title": "SMTP, proxy-CONNECT, and NTS clients bound their read buffers and add deadlines",
|
|
29
|
+
"body": "The SMTP client accumulated the server's reply without a byte cap and relied on an idle timer alone; a hostile or broken MX that never sent a line terminator could exhaust memory, and a slow trickle held the transaction open. It now caps the accumulated reply (refusing once it exceeds the response ceiling) and enforces an overall transaction deadline. The proxy CONNECT-tunnel client, which accumulated the proxy's reply unbounded with no socket timeout, now caps the header buffer and times out the connect. The NTS key-establishment handshake reader caps its record accumulator so a server streaming non-terminating records cannot exhaust memory before the handshake timer fires."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"title": "Append-only log sinks refuse a symlinked path",
|
|
33
|
+
"body": "The local log sink and the daemon log opened the active file in append mode without O_NOFOLLOW, so a symlink planted at the log path (including one re-planted in the race window after a caller's pre-check) was followed, redirecting log writes to an attacker-chosen file. Both now open with O_NOFOLLOW via the new b.atomicFile.openAppendNoFollowSync, refusing a symlinked final path component."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"title": "Transaction and replay-store contracts refuse a backend that cannot honor them",
|
|
37
|
+
"body": "b.externalDb.transaction and b.outbox.create now refuse a backend that declares it cannot provide an interactive transaction (a stateless / autocommit-per-statement adapter) instead of silently running BEGIN, the body, and COMMIT on different sessions — no isolation, no rollback — while reporting success. b.graphqlFederation.guardSdl now consults its replay nonce store through the framework's atomic checkAndInsert(nonce, expireAt) contract (the same one b.webhook and b.nonceStore use) rather than a non-atomic has()-then-remember() check that raced concurrent redeliveries; a store lacking checkAndInsert is refused at construction."
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"heading": "Fixed",
|
|
43
|
+
"items": [
|
|
44
|
+
{
|
|
45
|
+
"title": "b.session.destroyAllForUser works for pluggable-store consumers",
|
|
46
|
+
"body": "A consumer configuring sessions through b.session.useStore without calling b.db.init() got a db/not-initialized error from destroyAllForUser — every \"log out everywhere\" / suspend / delete path rejected with a 500 after the store rows had already been deleted, because the stateless valid-from boundary write was routed only to the framework database. The boundary now prefers the framework database when one is initialized and otherwise falls back to the configured session store (provisioning its table on demand), so store-backed deployments raise the stateless boundary successfully; it still fails closed when neither a framework database nor a store is available."
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"heading": "Added",
|
|
52
|
+
"items": [
|
|
53
|
+
{
|
|
54
|
+
"title": "b.atomicFile.openAppendNoFollowSync — symlink-refusing append open",
|
|
55
|
+
"body": "Opens a long-lived append target (an active log file kept open across appends and reopened on rotation) with O_WRONLY | O_APPEND | O_CREAT | O_NOFOLLOW: the file is created or appended normally, but a symlink at the final path component fails the open closed (ELOOP) rather than redirecting writes. The append-sink counterpart to openNoFollowSync (read) and the exclusive-create temp open."
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"title": "b.externalDb.supportsTransactions() and stateless-adapter declaration",
|
|
59
|
+
"body": "A backend adapter may declare supportsTransactions: false (a stateless / autocommit-per-statement adapter) and/or provide a batch(client, statements) hook for an atomic multi-statement path. b.externalDb.supportsTransactions() reports whether the picked backend can provide an interactive transaction, so a consumer built on the dual-write guarantee can refuse a non-atomic backend up front."
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"title": "Store-free device fingerprint",
|
|
63
|
+
"body": "b.sessionDeviceBinding.fingerprint(req, opts) is now a static entry point returning the request-shape digest with no store, and b.sessionDeviceBinding.create() with neither a bindingStore nor storeInSession returns an instance whose stateless fingerprint() works while bind()/verify() throw a clear \"no store configured\". Soft device binding for self-validating tokens (a sealed cookie or JWT carrying the fingerprint) no longer needs a fabricated no-op store."
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"heading": "Changed",
|
|
69
|
+
"items": [
|
|
70
|
+
{
|
|
71
|
+
"title": "Device fingerprint IP masking is canonical",
|
|
72
|
+
"body": "b.sessionDeviceBinding masked the client IP into its prefix bucket by textual group slicing with no IPv6 normalization, so equivalent forms of one address (a ::-compressed form vs its fully-expanded form) hashed to different fingerprints and a roaming client could be logged out on a false drift. It now masks through the same canonical prefix helper b.session uses, preserving the configured prefix width (the IPv6 default stays /48, and ipPrefixBits is honored). b.requestHelpers.ipPrefix accepts { v4Bits, v6Bits } to override the /24 + /64 default for a function-form fingerprint or other reuse."
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"heading": "Migration",
|
|
78
|
+
"items": [
|
|
79
|
+
{
|
|
80
|
+
"title": "DNS lookups now time out by default",
|
|
81
|
+
"body": "The lower-level DNS client's lookup timeout previously defaulted to zero (no deadline); it now defaults to 10 seconds. A deployment that intentionally ran DNS lookups with no wall-clock bound must set the lookup timeout to zero explicitly to restore the old behavior. The b.network.dns.resolver default timeout is also 10 seconds and is configurable via its timeoutMs option."
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"title": "b.graphqlFederation.guardSdl nonceStore contract",
|
|
85
|
+
"body": "guardSdl's optional replay nonceStore now requires the atomic checkAndInsert(nonce, expireAt) contract (b.nonceStore.create is the reference store) and refuses the previous { has, remember } shape at construction. Operators using the old shape should pass b.nonceStore.create(...) or an adapter exposing checkAndInsert."
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"title": "Interactive transactions on a stateless backend are refused",
|
|
89
|
+
"body": "If an externalDb backend adapter declares supportsTransactions: false, b.externalDb.transaction and b.outbox.create now throw rather than running a silently non-atomic block. Existing stateful adapters (Postgres / MySQL / SQLite, and any adapter that supplies beginTx/commit/rollback or omits the flag) are unaffected. A stateless / autocommit-per-statement adapter should supply interactive transaction hooks or a batch adapter."
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
@@ -1508,6 +1508,7 @@ async function run() {
|
|
|
1508
1508
|
await testApiEncryptDerivedPruneInterval();
|
|
1509
1509
|
await testApiEncryptHttpClientHelperShape();
|
|
1510
1510
|
await testApiEncryptHttpClientRoundTrip();
|
|
1511
|
+
await testApiEncryptHttpClientWallClockTimeout();
|
|
1511
1512
|
await testApiEncryptEncryptedErrorReadback();
|
|
1512
1513
|
|
|
1513
1514
|
// v0.7.3 — per-session keying mode
|
|
@@ -1530,6 +1531,56 @@ async function run() {
|
|
|
1530
1531
|
await testApiEncryptObservabilityCounters();
|
|
1531
1532
|
}
|
|
1532
1533
|
|
|
1534
|
+
// #355 — b.httpClient.encrypted must forward timeoutMs (overall wall-clock cap),
|
|
1535
|
+
// not only idleTimeoutMs. A peer that trickles bytes within the idle window
|
|
1536
|
+
// (never tripping the idle timeout) but never completes the response would
|
|
1537
|
+
// otherwise hold the encrypted call open indefinitely. RED before the fix
|
|
1538
|
+
// (timeoutMs absent from the passable forwarding set): the request never settles
|
|
1539
|
+
// inside the wall-clock bound. GREEN after: it rejects at ~timeoutMs.
|
|
1540
|
+
async function testApiEncryptHttpClientWallClockTimeout() {
|
|
1541
|
+
var http = require("http");
|
|
1542
|
+
var keypair = _serverKeypair();
|
|
1543
|
+
var timer = null;
|
|
1544
|
+
var server = http.createServer(function (req, res) {
|
|
1545
|
+
req.on("data", function () {});
|
|
1546
|
+
req.on("end", function () {
|
|
1547
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1548
|
+
// Trickle one byte every 50ms forever — socket never idle long enough to
|
|
1549
|
+
// trip idleTimeoutMs, response never ends → only a wall-clock cap bounds it.
|
|
1550
|
+
timer = setInterval(function () { try { res.write("x"); } catch (_e) { /* socket gone */ } }, 50);
|
|
1551
|
+
res.on("close", function () { if (timer) { clearInterval(timer); timer = null; } });
|
|
1552
|
+
});
|
|
1553
|
+
});
|
|
1554
|
+
var port = await helpers.listenOnRandomPort(server);
|
|
1555
|
+
try {
|
|
1556
|
+
var enc = b.httpClient.encrypted({
|
|
1557
|
+
pubkey: keypair,
|
|
1558
|
+
baseUrl: "http://127.0.0.1:" + port,
|
|
1559
|
+
method: "POST",
|
|
1560
|
+
});
|
|
1561
|
+
var started = Date.now();
|
|
1562
|
+
var rejected = false;
|
|
1563
|
+
try {
|
|
1564
|
+
await enc.request({
|
|
1565
|
+
path: "/slow",
|
|
1566
|
+
body: { user: "alice" },
|
|
1567
|
+
timeoutMs: 400, // overall wall-clock cap
|
|
1568
|
+
allowedProtocols: b.safeUrl.ALLOW_HTTP_ALL,
|
|
1569
|
+
allowInternal: true,
|
|
1570
|
+
});
|
|
1571
|
+
} catch (_e) {
|
|
1572
|
+
rejected = true;
|
|
1573
|
+
}
|
|
1574
|
+
var elapsed = Date.now() - started;
|
|
1575
|
+
check("httpClient.encrypted: slow-trickle request is bounded (rejects)", rejected === true);
|
|
1576
|
+
check("httpClient.encrypted: bounded by the wall-clock cap (~timeoutMs, not idle)",
|
|
1577
|
+
elapsed < 3000);
|
|
1578
|
+
} finally {
|
|
1579
|
+
if (timer) clearInterval(timer);
|
|
1580
|
+
server.close();
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1533
1584
|
module.exports = { run: run };
|
|
1534
1585
|
|
|
1535
1586
|
if (require.main === module) {
|