@blamejs/blamejs-shop 0.3.11 → 0.3.13
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/admin.js +157 -117
- package/lib/asset-manifest.json +1 -1
- package/lib/currency-rounding.js +2 -14
- package/lib/index.js +1 -0
- package/lib/text-guard.js +227 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +3 -2
- package/lib/vendor/blamejs/SECURITY.md +3 -0
- package/lib/vendor/blamejs/api-snapshot.json +14 -2
- package/lib/vendor/blamejs/lib/agent-event-bus.js +4 -4
- package/lib/vendor/blamejs/lib/agent-idempotency.js +6 -6
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +9 -9
- package/lib/vendor/blamejs/lib/agent-posture-chain.js +10 -10
- package/lib/vendor/blamejs/lib/agent-saga.js +6 -7
- package/lib/vendor/blamejs/lib/agent-snapshot.js +8 -8
- package/lib/vendor/blamejs/lib/agent-stream.js +3 -3
- package/lib/vendor/blamejs/lib/agent-tenant.js +4 -4
- package/lib/vendor/blamejs/lib/agent-trace.js +5 -5
- package/lib/vendor/blamejs/lib/ai-disclosure.js +3 -3
- package/lib/vendor/blamejs/lib/app.js +2 -2
- package/lib/vendor/blamejs/lib/archive-read.js +1 -1
- package/lib/vendor/blamejs/lib/archive-tar-read.js +1 -1
- package/lib/vendor/blamejs/lib/archive-wrap.js +5 -5
- package/lib/vendor/blamejs/lib/audit-tools.js +65 -5
- package/lib/vendor/blamejs/lib/audit.js +2 -2
- package/lib/vendor/blamejs/lib/auth/ciba.js +1 -1
- package/lib/vendor/blamejs/lib/auth/dpop.js +1 -1
- package/lib/vendor/blamejs/lib/auth/fal.js +1 -1
- package/lib/vendor/blamejs/lib/auth/fido-mds3.js +2 -3
- package/lib/vendor/blamejs/lib/auth/jwt-external.js +2 -2
- package/lib/vendor/blamejs/lib/auth/oauth.js +9 -9
- package/lib/vendor/blamejs/lib/auth/oid4vci.js +7 -7
- package/lib/vendor/blamejs/lib/auth/oid4vp.js +1 -1
- package/lib/vendor/blamejs/lib/auth/openid-federation.js +5 -5
- package/lib/vendor/blamejs/lib/auth/passkey.js +6 -6
- package/lib/vendor/blamejs/lib/auth/saml.js +1 -1
- package/lib/vendor/blamejs/lib/auth/sd-jwt-vc.js +3 -6
- package/lib/vendor/blamejs/lib/backup/index.js +18 -18
- package/lib/vendor/blamejs/lib/cache.js +4 -4
- package/lib/vendor/blamejs/lib/calendar.js +5 -5
- package/lib/vendor/blamejs/lib/circuit-breaker.js +1 -1
- package/lib/vendor/blamejs/lib/cms-codec.js +2 -2
- package/lib/vendor/blamejs/lib/compliance.js +14 -14
- package/lib/vendor/blamejs/lib/crypto-field.js +58 -21
- package/lib/vendor/blamejs/lib/crypto.js +5 -6
- package/lib/vendor/blamejs/lib/db-query.js +131 -9
- package/lib/vendor/blamejs/lib/db.js +106 -22
- package/lib/vendor/blamejs/lib/external-db.js +64 -16
- package/lib/vendor/blamejs/lib/framework-schema.js +4 -4
- package/lib/vendor/blamejs/lib/guard-list-id.js +2 -2
- package/lib/vendor/blamejs/lib/guard-list-unsubscribe.js +1 -2
- package/lib/vendor/blamejs/lib/incident-report.js +150 -0
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +1 -1
- package/lib/vendor/blamejs/lib/mail-deploy.js +3 -3
- package/lib/vendor/blamejs/lib/mail-server-managesieve.js +2 -2
- package/lib/vendor/blamejs/lib/mail-server-pop3.js +2 -2
- package/lib/vendor/blamejs/lib/mail-store.js +1 -1
- package/lib/vendor/blamejs/lib/metrics.js +8 -8
- package/lib/vendor/blamejs/lib/middleware/csrf-protect.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/dpop.js +5 -5
- package/lib/vendor/blamejs/lib/middleware/idempotency-key.js +21 -22
- package/lib/vendor/blamejs/lib/middleware/protected-resource-metadata.js +2 -2
- package/lib/vendor/blamejs/lib/network-dns-resolver.js +2 -2
- package/lib/vendor/blamejs/lib/network-dns.js +1 -2
- package/lib/vendor/blamejs/lib/network-tls.js +0 -1
- package/lib/vendor/blamejs/lib/outbox.js +1 -1
- package/lib/vendor/blamejs/lib/pqc-agent.js +1 -1
- package/lib/vendor/blamejs/lib/retention.js +1 -1
- package/lib/vendor/blamejs/lib/retry.js +1 -1
- package/lib/vendor/blamejs/lib/safe-archive.js +2 -2
- package/lib/vendor/blamejs/lib/safe-ical.js +2 -2
- package/lib/vendor/blamejs/lib/safe-mime.js +1 -1
- package/lib/vendor/blamejs/lib/self-update-standalone-verifier.js +1 -1
- package/lib/vendor/blamejs/lib/self-update.js +2 -2
- package/lib/vendor/blamejs/lib/static.js +1 -1
- package/lib/vendor/blamejs/lib/subject.js +2 -2
- package/lib/vendor/blamejs/lib/vault/index.js +64 -1
- package/lib/vendor/blamejs/lib/vault/rotate.js +19 -0
- package/lib/vendor/blamejs/lib/vendor-data.js +1 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.7.json +77 -0
- package/lib/vendor/blamejs/scripts/release.js +28 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/audit-tools-dual-control.test.js +115 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +163 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-derived-hash.test.js +102 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/db-column-gate.test.js +150 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/db-key-aad.test.js +109 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +53 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/incident-report.test.js +65 -0
- package/lib/webhook-subscriptions.js +11 -24
- package/lib/webhooks.js +12 -33
- package/package.json +1 -1
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.textGuard
|
|
4
|
+
* @title Shop input-validation primitives over the framework's codepoint catalog
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* One home for the cross-cutting string validators the shop's
|
|
8
|
+
* handlers reach for at the request boundary: the ISO 4217 currency
|
|
9
|
+
* code, the URL slug label, the outbound host label, and the
|
|
10
|
+
* dangerous-codepoint screen for unconstrained free text. Each was
|
|
11
|
+
* previously open-coded at several call sites with subtly different
|
|
12
|
+
* shapes; centralizing them keeps the discipline identical
|
|
13
|
+
* everywhere and gives the codebase one place to tighten.
|
|
14
|
+
*
|
|
15
|
+
* The dangerous-codepoint surface (bidi overrides, C0 control bytes,
|
|
16
|
+
* null bytes, zero-width / invisible formatting, mixed-script
|
|
17
|
+
* confusables) is NOT reinvented here — it composes the framework's
|
|
18
|
+
* own codepoint catalog, the single source of truth the guard-*
|
|
19
|
+
* family already builds on. The framework does not yet re-export the
|
|
20
|
+
* catalog on its public index; until it does, this module reaches
|
|
21
|
+
* the catalog leaf directly. The leaf has no dependency on any shop
|
|
22
|
+
* module, so requiring it is refresh-safe; an upstream one-line
|
|
23
|
+
* `module.exports` add would let this collapse to `b.codepointClass`
|
|
24
|
+
* with no behavior change.
|
|
25
|
+
*
|
|
26
|
+
* Validators THROW a `TypeError` on bad input. The admin / account
|
|
27
|
+
* request wrappers map a thrown `TypeError` to a clean 400, so a
|
|
28
|
+
* typo or a hostile value surfaces as a bad-request to the caller
|
|
29
|
+
* instead of a 500 — the validators are written to be called at the
|
|
30
|
+
* handler boundary, before the value reaches storage or an outbound
|
|
31
|
+
* dial.
|
|
32
|
+
*
|
|
33
|
+
* Surface:
|
|
34
|
+
* - re-exports of the framework codepoint catalog (BIDI_RE,
|
|
35
|
+
* C0_CTRL_RE, ZERO_WIDTH_RE, NULL_BYTE, scriptFor,
|
|
36
|
+
* detectMixedScripts, detectCharThreats, assertNoCharThreats,
|
|
37
|
+
* applyCharStripPolicies) so consumers grab one import
|
|
38
|
+
* - asciiUpperLetters(s, label, n?) — fixed-length A-Z token
|
|
39
|
+
* - currencyCode(s, label?) — ISO 4217 shape + catalog membership
|
|
40
|
+
* - slugLabel(s, label, opts?) — lowercase URL-slug label
|
|
41
|
+
* - hostLabel(host, label?) — outbound host SSRF + by-name denylist
|
|
42
|
+
* - freeText(s, label, policy?) — dangerous-codepoint screen for
|
|
43
|
+
* unconstrained UTF-8 text fields
|
|
44
|
+
*
|
|
45
|
+
* @primitive textGuard
|
|
46
|
+
* @related b.money, b.ssrfGuard, b.safeUrl, shop.currencyRounding,
|
|
47
|
+
* shop.webhooks, shop.webhookSubscriptions
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
var b = require("./vendor/blamejs");
|
|
51
|
+
|
|
52
|
+
// The framework's codepoint catalog (bidi / control / null / zero-width
|
|
53
|
+
// tables + regexes, the script ranges, and the detect/assert/strip
|
|
54
|
+
// helpers the guard-* family composes). It is not on the framework's
|
|
55
|
+
// public index yet; the catalog leaf has no shop-module dependency, so
|
|
56
|
+
// composing it directly stays refresh-safe and keeps a single source of
|
|
57
|
+
// truth instead of a second copy of the tables.
|
|
58
|
+
// allow:vendor-hand-edit — composing the framework's codepoint catalog leaf (no shop dependency, refresh-safe); collapses to b.codepointClass once upstream re-exports it
|
|
59
|
+
var codepointClass = require("./vendor/blamejs/lib/codepoint-class");
|
|
60
|
+
|
|
61
|
+
// ---- shop validators ----------------------------------------------------
|
|
62
|
+
|
|
63
|
+
// asciiUpperLetters(s, label, n?) — exactly `n` (default 3) ASCII
|
|
64
|
+
// uppercase letters, nothing else. The generalized shape behind every
|
|
65
|
+
// `/^[A-Z]{3}$/` token check. ASCII-allowlist by construction, so bidi
|
|
66
|
+
// / control / zero-width / confusable codepoints are rejected for free
|
|
67
|
+
// (they are not in A-Z). Throws TypeError on a miss.
|
|
68
|
+
function asciiUpperLetters(s, label, n) {
|
|
69
|
+
var name = label || "value";
|
|
70
|
+
var len = n == null ? 3 : n;
|
|
71
|
+
if (typeof len !== "number" || !Number.isInteger(len) || len <= 0) {
|
|
72
|
+
throw new TypeError("textGuard: length must be a positive integer, got " + JSON.stringify(n));
|
|
73
|
+
}
|
|
74
|
+
// allow:dynamic-regex — len is a validated positive integer, not external input
|
|
75
|
+
var re = new RegExp("^[A-Z]{" + len + "}$");
|
|
76
|
+
if (typeof s !== "string" || !re.test(s)) {
|
|
77
|
+
throw new TypeError(
|
|
78
|
+
name + " must be exactly " + len + " uppercase ASCII letter(s), got " + JSON.stringify(s)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return s;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// currencyCode(s, label?) — ISO 4217 currency code: the asciiUpperLetters
|
|
85
|
+
// shape check THEN membership in the framework's catalog (b.money.CURRENCIES,
|
|
86
|
+
// the same surface currency-rounding + currency-display compose). Rejects
|
|
87
|
+
// both malformed codes ("usd", "US") and well-formed-but-nonexistent codes
|
|
88
|
+
// ("ZZZ") that would shape-check past `/^[A-Z]{3}$/` but bind money to a
|
|
89
|
+
// currency the rest of the shop can't price. Throws TypeError on a miss.
|
|
90
|
+
function currencyCode(s, label) {
|
|
91
|
+
var name = label || "currency";
|
|
92
|
+
if (typeof s !== "string" || !/^[A-Z]{3}$/.test(s)) {
|
|
93
|
+
throw new TypeError(
|
|
94
|
+
name + " must be a 3-letter uppercase ISO 4217 code, got " + JSON.stringify(s)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (!Object.prototype.hasOwnProperty.call(b.money.CURRENCIES, s)) {
|
|
98
|
+
throw new TypeError(name + " " + JSON.stringify(s) + " is not in the ISO 4217 catalog");
|
|
99
|
+
}
|
|
100
|
+
return s;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// slugLabel(s, label, opts?) — a lowercase URL-slug label: starts and
|
|
104
|
+
// ends with [a-z0-9], inner chars [a-z0-9-], no leading / trailing /
|
|
105
|
+
// doubled hyphen, length 1..maxLen (default 80). ASCII-allowlist, so
|
|
106
|
+
// dangerous codepoints are rejected by construction. Throws TypeError.
|
|
107
|
+
function slugLabel(s, label, opts) {
|
|
108
|
+
var name = label || "slug";
|
|
109
|
+
opts = opts || {};
|
|
110
|
+
var maxLen = opts.maxLen == null ? 80 : opts.maxLen;
|
|
111
|
+
if (typeof maxLen !== "number" || !Number.isInteger(maxLen) || maxLen <= 0) {
|
|
112
|
+
throw new TypeError("textGuard: maxLen must be a positive integer, got " + JSON.stringify(opts.maxLen));
|
|
113
|
+
}
|
|
114
|
+
if (typeof s !== "string" || s.length < 1 || s.length > maxLen) {
|
|
115
|
+
throw new TypeError(
|
|
116
|
+
name + " must be a 1.." + maxLen + "-character lowercase slug, got " + JSON.stringify(s)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
// Single char must itself be [a-z0-9]; longer must match start/inner/end.
|
|
120
|
+
var ok = s.length === 1
|
|
121
|
+
? /^[a-z0-9]$/.test(s)
|
|
122
|
+
: /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s);
|
|
123
|
+
if (!ok || /--/.test(s)) {
|
|
124
|
+
throw new TypeError(
|
|
125
|
+
name + " must be a lowercase URL slug ([a-z0-9] separated by single hyphens), got " + JSON.stringify(s)
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return s;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Hostnames that name an internal / metadata destination by name rather
|
|
132
|
+
// than by IP literal. The cloud-metadata services answer on a hostname as
|
|
133
|
+
// well as the 169.254.169.254 link-local IP; localhost resolves to
|
|
134
|
+
// loopback. A literal-IP host is classified directly via b.ssrfGuard
|
|
135
|
+
// (no DNS); these names cover the by-name reach of the same targets.
|
|
136
|
+
var BLOCKED_HOSTS = Object.freeze({
|
|
137
|
+
"localhost": 1,
|
|
138
|
+
"metadata.google.internal": 1,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// hostLabel(host, label?) — refuse an outbound host that targets an
|
|
142
|
+
// internal / loopback / link-local / reserved / cloud-metadata
|
|
143
|
+
// destination. A literal-IP host is classified via b.ssrfGuard.classify
|
|
144
|
+
// (IPv4 + IPv6 + IPv4-mapped, no DNS); a hostname is matched against the
|
|
145
|
+
// by-name metadata / loopback denylist plus the *.internal suffix. A
|
|
146
|
+
// trailing FQDN dot and IPv6 brackets are stripped first so neither check
|
|
147
|
+
// is evaded. DNS-rebinding (a public name resolving to a private IP) is
|
|
148
|
+
// out of scope at this gate by design — the resolving guard belongs on
|
|
149
|
+
// the delivery dial. Throws TypeError when the host is not allowed.
|
|
150
|
+
// Returns the normalized (bracket / trailing-dot stripped, lowercased)
|
|
151
|
+
// host on success.
|
|
152
|
+
function hostLabel(host, label) {
|
|
153
|
+
var name = label || "host";
|
|
154
|
+
if (typeof host !== "string" || host.length === 0) {
|
|
155
|
+
throw new TypeError(name + " must be a non-empty string");
|
|
156
|
+
}
|
|
157
|
+
var h = host.replace(/^\[|\]$/g, "").toLowerCase().replace(/\.+$/, "");
|
|
158
|
+
if (b.ssrfGuard.classify(h) ||
|
|
159
|
+
BLOCKED_HOSTS[h] ||
|
|
160
|
+
h === "internal" || /\.internal$/.test(h)) {
|
|
161
|
+
throw new TypeError(name + " is not allowed (internal/loopback/metadata address)");
|
|
162
|
+
}
|
|
163
|
+
return h;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// freeText(s, label, policy?) — the dangerous-codepoint screen for an
|
|
167
|
+
// UNCONSTRAINED UTF-8 text field (customer note, review body, gift
|
|
168
|
+
// message, product Q&A). Unlike the ASCII-allowlist validators above,
|
|
169
|
+
// these fields legitimately carry arbitrary letters, so the codepoint
|
|
170
|
+
// catalog is the right backing: bidi overrides (CVE-2021-42574 Trojan
|
|
171
|
+
// Source), null bytes, and C0 control characters are refused by default;
|
|
172
|
+
// zero-width / invisible formatting is refused when policy.zeroWidth is
|
|
173
|
+
// "reject"; a mixed-script confusable (Cyrillic 'а' inside an otherwise
|
|
174
|
+
// Latin label) is refused when policy.mixedScript is "reject". Throws
|
|
175
|
+
// TypeError on a refused codepoint. Returns the input on success.
|
|
176
|
+
function freeText(s, label, policy) {
|
|
177
|
+
var name = label || "value";
|
|
178
|
+
if (typeof s !== "string") {
|
|
179
|
+
throw new TypeError(name + " must be a string");
|
|
180
|
+
}
|
|
181
|
+
policy = policy || {};
|
|
182
|
+
var bidiP = policy.bidi || "reject";
|
|
183
|
+
var nullP = policy.nullByte || "reject";
|
|
184
|
+
var controlP = policy.control || "reject";
|
|
185
|
+
if (bidiP === "reject" && codepointClass.BIDI_RE.test(s)) {
|
|
186
|
+
throw new TypeError(name + " contains a Unicode bidi override (CVE-2021-42574 Trojan Source)");
|
|
187
|
+
}
|
|
188
|
+
if (nullP === "reject" && s.indexOf(codepointClass.NULL_BYTE) !== -1) {
|
|
189
|
+
throw new TypeError(name + " contains a null byte");
|
|
190
|
+
}
|
|
191
|
+
if (controlP === "reject" && codepointClass.C0_CTRL_RE.test(s)) {
|
|
192
|
+
throw new TypeError(name + " contains a C0 control character");
|
|
193
|
+
}
|
|
194
|
+
if (policy.zeroWidth === "reject" && codepointClass.ZERO_WIDTH_RE.test(s)) {
|
|
195
|
+
throw new TypeError(name + " contains a zero-width / invisible formatting character");
|
|
196
|
+
}
|
|
197
|
+
if (policy.mixedScript === "reject") {
|
|
198
|
+
var scripts = codepointClass.detectMixedScripts(s, policy.allowedScripts);
|
|
199
|
+
if (scripts) {
|
|
200
|
+
throw new TypeError(
|
|
201
|
+
name + " mixes writing systems (" + scripts.join(", ") + ") — possible confusable / homograph"
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return s;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = {
|
|
209
|
+
// Re-exports of the framework codepoint catalog (single source of truth).
|
|
210
|
+
BIDI_RE: codepointClass.BIDI_RE,
|
|
211
|
+
C0_CTRL_RE: codepointClass.C0_CTRL_RE,
|
|
212
|
+
ZERO_WIDTH_RE: codepointClass.ZERO_WIDTH_RE,
|
|
213
|
+
NULL_BYTE: codepointClass.NULL_BYTE,
|
|
214
|
+
BOM_CHAR: codepointClass.BOM_CHAR,
|
|
215
|
+
scriptFor: codepointClass.scriptFor,
|
|
216
|
+
detectMixedScripts: codepointClass.detectMixedScripts,
|
|
217
|
+
detectCharThreats: codepointClass.detectCharThreats,
|
|
218
|
+
assertNoCharThreats: codepointClass.assertNoCharThreats,
|
|
219
|
+
applyCharStripPolicies: codepointClass.applyCharStripPolicies,
|
|
220
|
+
|
|
221
|
+
// Shop validators.
|
|
222
|
+
asciiUpperLetters: asciiUpperLetters,
|
|
223
|
+
currencyCode: currencyCode,
|
|
224
|
+
slugLabel: slugLabel,
|
|
225
|
+
hostLabel: hostLabel,
|
|
226
|
+
freeText: freeText,
|
|
227
|
+
};
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.14.
|
|
7
|
-
"tag": "v0.14.
|
|
6
|
+
"version": "0.14.7",
|
|
7
|
+
"tag": "v0.14.7",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.14.x
|
|
10
10
|
|
|
11
|
+
- v0.14.7 (2026-05-30) — **Storage and audit-trail hardening: queries are gated to declared columns, raw SQL refuses embedded literals, the database key is bound to its location, sealed-column lookup hashes gain a keyed mode, audit-chain purges can require dual control, and breach deadlines ship a running clock.** This release tightens the data and audit layers against a set of failure modes that were previously reachable. Database queries are now checked against the columns a table declared in its schema: a reference to an undeclared column fails closed by default instead of silently matching nothing, and the `whereRaw` escape hatch refuses an embedded string literal so values bind through placeholders. The database encryption key is sealed with its purpose, data directory, and key path as additional authenticated data, so a key file cannot be relocated to another deployment and unsealed there; an older key without that binding upgrades itself on first load. Sealed-column equality-lookup hashes can now be computed as a keyed MAC (HMAC-SHAKE256) off a per-deployment key, making the lookup hash unforgeable without that key, while the salted-SHA3 default is unchanged. Purging the tamper-evident audit chain can be placed under a two-authorizer dual-control grant so one operator cannot erase it alone, and database credential-rejection audits now record which relation the rejected credential tried to reach. Finally, breach-notification deadlines get a running clock that raises approaching and passed alerts as each regime's window elapses. One behavior change to note: the column gate defaults to reject — if a service issues queries against columns it did not declare in its schema, set `db.init({ columnGate: "warn" })` (audited, allowed) or `"off"` while the schema is reconciled. **Added:** *Column-membership gate on every query* — `b.db.from(table)` now checks each referenced column against the table's declared schema. The mode is set with `db.init({ columnGate: "reject" | "warn" | "off" })` (default `reject`), and `query.allowedColumns([...])` narrows a single query to an explicit allowlist that is always enforced. `b.db.getDeclaredColumns(table)` returns a table's declared column names (or `null` for an unknown table). This is defense in depth against typo'd or caller-influenced column names reaching the SQL layer (CWE-89). · *Keyed mode for sealed-column lookup hashes* — Equality-lookup ("derived") hashes for sealed columns can be computed as `hmac-shake256` — a keyed MAC over a per-deployment key — instead of the default `salted-sha3`. Set it per table with `cryptoField.registerTable(name, { derivedHashMode: "hmac-shake256" })` or per column with `{ from, mode: "hmac-shake256" }`. The keyed hash is unforgeable and un-correlatable without the deployment's MAC key, which raises the bar against offline lookup-table attacks on low-entropy sealed values (CWE-916). `b.vault.getDerivedHashMacKey()` exposes the 32-byte per-deployment key; it is created on first use and re-sealed across key rotation automatically. · *Dual-control gate on audit-chain purge* — `b.auditTools.purge` accepts `dualControlGrant`. When `audit_log` is placed under dual control, a verified archive and `confirm: true` are no longer sufficient: the purge additionally requires a consumed m-of-n grant whose action is bound to the purge, so a grant minted for another operation cannot be replayed and one operator cannot erase the tamper-evident chain alone (NIST SP 800-53 AU-9, separation of duties). · *Running clock for breach-notification deadlines* — `b.incident.report.createDeadlineClock({ notify, approachThresholds })` tracks open incidents and raises `deadline_approaching` and `deadline_passed` alerts as each regime's window elapses (GDPR 72h, DORA, NIS2, and the rest of the registry). Alerts are deduplicated per incident and stage, suppressed once a submission stage is acknowledged, and the clock can run on an interval or be ticked manually. **Changed:** *Queries against undeclared columns now fail closed by default* — The column gate defaults to `reject`: a query that references a column the table did not declare throws rather than silently matching nothing. A service that intentionally queries undeclared columns can set `db.init({ columnGate: "warn" })` to audit and allow, or `"off"` to disable the gate, while its schema is reconciled. Framework-declared columns (including `_id` and derived-hash columns) are always members. **Security:** *Database encryption key bound to its location* — `db.key.enc` is sealed with additional authenticated data over its purpose, resolved data directory, and resolved key path. A sealed key copied to a different deployment or path no longer unseals there — the AEAD authentication fails — which prevents silent key relocation. A legacy key sealed without this binding is detected and re-sealed in the bound format on first load, with no operator action required. · *`whereRaw` refuses embedded string literals* — `whereRaw(sql, params)` and `WhereBuilder.raw(sql, params)` reject a raw fragment containing a string literal (`'...'`); values must bind through the `params` array. A static, operator-controlled literal can opt in with `{ allowLiterals: true }`. This closes a path where a value concatenated into a raw fragment would reintroduce SQL injection (CWE-89). · *Credential-rejection audits record the attempted relation* — A `db.auth.failed` audit row (SQLSTATE 28000 / 28P01 / 42501) now carries `attemptedTable`, the relation the rejected credential tried to reach, extracted defensively from the statement. Triage can scope the blast radius of a credential-abuse event without correlating back to the raw SQL log (CWE-778). **Detectors:** *Audit-purge dual-control gate* — A new check fails the build if a call to `purgeAuditChain` appears in a file that does not also route through the dual-control gate, so a future caller cannot physically delete chain rows without two-authorizer enforcement. · *Raw-SQL literal/interpolation guard* — A new check fails the build on a `whereRaw` / `.raw` call whose SQL argument is built by template interpolation or string concatenation, keeping the bound-params discipline enforceable in framework code. · *Hand-rolled lookup-hash guard* — A new check fails the build if a sealed-column lookup hash is derived from the per-deployment salt outside the canonical helper, so call sites cannot bypass the keyed-mode and per-column mode policy. · *Auth-audit attempted-relation guard* — A new check fails the build if a `db.auth.failed` audit is emitted in a file that does not name `attemptedTable`, so the forensic field cannot be dropped from a future emitter.
|
|
12
|
+
|
|
11
13
|
- v0.14.6 (2026-05-30) — **Access-refusal middleware can return RFC 9457 problem+json or a custom response, and several documented-but-uncallable APIs are now reachable.** Every access-refusal middleware — the auth gates (bearer, DPoP, mTLS, AAL, bound-key), CSRF, CORS, rate-limit, bot-guard, age-gate, the host and network allowlists, and the method and content-type gates — now accepts two uniform options: `problemDetails: true` returns an RFC 9457 `application/problem+json` body, and `onDeny(req, res, info)` hands the response to the caller. With neither set the refusal is byte-for-byte what it was, so this is a drop-in change that lets a service standardize one error envelope across its API instead of working around each middleware's hardcoded body. Alongside that: `b.middleware.requireBoundKey` is now exported (it was documented and tested but never wired into the middleware surface), `b.middleware.bearerAuth` accepts `requiredScopes` (previously rejected at construction, which made its scope-enforcement path unreachable), API-key refusals send the RFC 6750 challenge code that matches the failure, two documented call paths that named a missing namespace segment are corrected, and the release flow now flags stale GitHub Actions and vendored bundles — with a ready-to-paste pin — before a dependency PR is needed. **Added:** *Uniform `onDeny` and `problemDetails` options on every access-refusal middleware* — Each request-lifecycle middleware that refuses a request now takes `problemDetails: true` to emit an RFC 9457 `application/problem+json` body (composing `b.problemDetails`) and `onDeny(req, res, info)` to take over the response entirely; `info` carries the status, a machine reason, and the middleware-specific fields. The deny-path response headers (`Allow`, `WWW-Authenticate`, `Retry-After`, `Accept`) survive every mode. When neither option is set the response is unchanged. Covers `requireAuth`, `requireAal`, `requireMethods`, `requireContentType`, `requireMtls`, `requireBoundKey`, `bearerAuth`, `dpop`, `csrfProtect`, `fetchMetadata`, `botGuard`, `ageGate`, `hostAllowlist`, `networkAllowlist`, `cors`, `rateLimit`, and `dailyByteQuota` (whose existing `onExceeded` keeps working as an alias of `onDeny`). **Fixed:** *`b.middleware.requireBoundKey` is now callable* — The Bearer-API-key middleware was documented (with examples and tests) but never exported on `b.middleware`, so `b.middleware.requireBoundKey(...)` threw `undefined is not a function`. It is now wired into the middleware surface. · *`b.middleware.bearerAuth` accepts `requiredScopes`* — The RFC 6750 scope-enforcement path read `opts.requiredScopes`, but the option was rejected at construction with `unknown option`, making the 403 `insufficient_scope` behavior unreachable. `requiredScopes` is now an accepted option. · *RFC 6750 challenge codes on API-key refusals* — `b.middleware.requireBoundKey` now sends the `WWW-Authenticate` error code that matches the failure: `insufficient_scope` on a 403 missing-scope, `invalid_token` on an unknown or revoked token, and no error code on a 401 that presented no credentials (RFC 6750 §3). It previously sent `invalid_request` for every refusal. · *Corrected two documented call paths* — The compliance and network references named a path that dropped a namespace segment: the conformity-assessment scaffold is at `b.cra.report.conformityAssessment` (not `b.cra.conformityAssessment`), and the per-socket tuning helper is at `b.network.socket.applyToSocket` (not `b.network.applyToSocket`). The documented signatures now match the callable paths. · *GitHub Actions pins refreshed* — `github/codeql-action` 4.35.5 to 4.36.0, and `docker/login-action`, `docker/setup-buildx-action`, and `docker/setup-qemu-action` to their latest releases. **Detectors:** *`@primitive` reachability gate* — A new check resolves every documented `b.X.Y` primitive against the actual public surface and fails the build when a documented path is not callable (factory-instance shorthands excluded). This is the gate that would have caught the `requireBoundKey` and call-path issues above. · *Deny-path composition gate* — A new check requires every access-refusal middleware to route its refusal through the shared deny-response writer, so a future middleware cannot reintroduce a hardcoded body that locks callers out of `onDeny` / `problemDetails`. · *Actions and vendor currency in the release flow* — The release flow now fails the cut when a SHA-pinned GitHub Action or a vendored bundle is behind its latest upstream release. The actions report prints a ready-to-paste `owner/repo@<sha> # vX.Y.Z` pin and every file and line that uses it, so the bump is copy-paste rather than an after-the-fact dependency PR. Transient registry or API errors stay advisory so a flaky network response does not block an unrelated release.
|
|
12
14
|
|
|
13
15
|
- v0.14.5 (2026-05-30) — **Finished cleaning up the mislabeled byte-literal lint suppressions, with no API or behavior changes.** A follow-up to the byte-literal lint tightening. The remaining suppression comments that named the byte-literal check on values that are not byte sizes — JSON-RPC error codes, HTTP status codes, octet ranges, day-in-milliseconds constants — are removed, keeping their explanatory text and any correctly-named companion suppression. Every byte-literal suppression that remains is now on genuine 1024-scale byte arithmetic. Source-comment hygiene only. **Changed:** *Remaining mislabeled byte-literal suppressions removed* — The byte-literal lint was previously a check on any multiple-of-8 integer, so suppression comments naming it were scattered across non-byte values. The last of those (in a handful of files, in mixed comment formats) are now removed — their explanatory text is retained as plain comments, and any correctly-named companion suppression is kept. The only byte-literal suppressions that remain are on genuine 1024-scale byte arithmetic. No change to any exported API, error code, wire format, or runtime behavior.
|
|
@@ -61,7 +61,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
61
61
|
### Data layer
|
|
62
62
|
|
|
63
63
|
- **SQLite with sealed-by-default columns** — `b.db`, migrations, seeders, atomic-file writes
|
|
64
|
-
- **Chainable query builder** — atomic `.increment(col, delta)`, closure-form `.whereGroup` / top-level `.orWhere` OR composition, `.search(fields, term)` LIKE-OR with safe `%`/`_` ESCAPE handling, `.paginate(opts)` returning `{ items, total, page, totalPages }`
|
|
64
|
+
- **Chainable query builder** — atomic `.increment(col, delta)`, closure-form `.whereGroup` / top-level `.orWhere` OR composition, `.search(fields, term)` LIKE-OR with safe `%`/`_` ESCAPE handling, `.paginate(opts)` returning `{ items, total, page, totalPages }`; a column-membership gate (`db.init({ columnGate })`, default reject) fails a query closed when it names a column the table never declared, and `whereRaw` refuses an embedded string literal so values bind through placeholders
|
|
65
65
|
- **Mongo-style document-store facade** — `b.db.collection(name, opts?)` with `$set` / `$inc` / `$unset` / `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` / `$in` / `$like`; schemaless-document opts via `overflow: "<col>"` (folds unknown fields into a JSON-text column; rewrites `WHERE` on virtual fields to `JSON_EXTRACT`), `jsonColumns: [...]` (auto-stringify on write + parse via `b.safeJson` on read), `sealedFields: { email: "emailHash" }` (co-locates a `b.cryptoField` sealed-column / derived-hash declaration so plaintext lookups auto-rewrite to hash-column lookups)
|
|
66
66
|
- **DB lifecycle** — in-memory encrypted snapshot via `b.db.snapshot()`; standalone encrypted-DB-file lifecycle (`b.db.fileLifecycle({ dataDir, vault })` — decrypt-to-tmpfs, periodic re-encrypt flush, graceful shutdown — same envelope as `b.db`, no schema/audit-chain coupling); `db.init` opt-outs `frameworkTables: false` / `auditSigning: false` and path overrides `encryptedDbPath` / `encryptedDbName` / `dbKeyPath`
|
|
67
67
|
- **External RDBMS** — bring-your-own Postgres / MySQL with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`)
|
|
@@ -98,7 +98,8 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
98
98
|
- **At-rest envelope** — envelope-versioned PQC (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256); vault sealing (`b.crypto`, `b.vault`)
|
|
99
99
|
- **Power-on self-test** — `b.crypto.selfTest()` runs FIPS 140-3-style integrity checks: NIST FIPS 202 known-answer tests (SHA3-256/512, SHAKE256), AEAD round-trip + tamper-detect, and ML-KEM-1024 / ML-DSA-87 / SLH-DSA-SHAKE-256f pairwise-consistency + negative tests; fails closed (throws) on any mismatch
|
|
100
100
|
- **Field-level + crypto-shred** — `b.cryptoField.eraseRow`; per-column data residency tagging + per-row keys (`K_row = HKDF(K_table, rowId)`) so erasing the per-row key makes WAL / replica residuals undecryptable (`b.cryptoField.declareColumnResidency`, `b.cryptoField.declarePerRowKey`)
|
|
101
|
-
- **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`)
|
|
101
|
+
- **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`). The database encryption key is sealed the same way — bound to its purpose, data directory, and key path — so a relocated key file fails to unseal; an older unbound key upgrades itself on first load
|
|
102
|
+
- **Keyed lookup hashes** — sealed-column equality-lookup hashes default to salted SHA3-512 and can opt into a keyed `hmac-shake256` MAC off a per-deployment key (`cryptoField.registerTable({ derivedHashMode })`, `b.vault.getDerivedHashMacKey`), making the lookup hash unforgeable and un-correlatable across deployments
|
|
102
103
|
- **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
|
|
103
104
|
- **HPKE / HTTP signatures** — RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components and ed25519 / ML-DSA-65 (`b.crypto.httpSig`); RFC 9530 Content-Digest / Repr-Digest body-integrity fields (SHA-256 / SHA-512, legacy algorithms refused — `b.contentDigest`) to sign the digest rather than the whole body
|
|
104
105
|
- **X-Wing hybrid KEM** — `b.crypto.xwing` (draft-connolly-cfrg-xwing-kem, experimental): ML-KEM-768 + X25519 bound by SHA3-256, secure if either component holds — the conservative key-encapsulation shape for migrating off classical ECDH. `keygen` / `encapsulate` / `decapsulate` with a 1216-byte public key, 1120-byte ciphertext, and 32-byte shared secret
|
|
@@ -194,6 +194,8 @@ What blamejs defends against, by design:
|
|
|
194
194
|
- **Disk theft of an offline data dir** — `vault.key.sealed` (wrapped mode) + sealed columns + audit chain mean the data dir alone is opaque without the vault passphrase. Plaintext mode is dev-only and prints a `WARNING:` on every boot.
|
|
195
195
|
- **Future quantum decrypt of currently-stored ciphertext** — every encrypted-at-rest blob uses ML-KEM-1024 + P-384 hybrid KEM and XChaCha20-Poly1305. There's no classical-only fallback to harvest now and decrypt later.
|
|
196
196
|
- **Audit-chain tampering** — every audit row carries `prevHash` + `rowHash` + `nonce` + `fencingToken`; the chain is verified at boot via `auditChain.verifyChain` and any mismatch refuses subsequent appends. Checkpoints are signed with SLH-DSA-SHAKE-256f. An attacker rewriting history needs to rewrite every subsequent hash AND forge the signing key.
|
|
197
|
+
- **Single-operator erasure of the audit chain** — physically purging archived rows already requires a verified archive bundle plus `confirm: true`. Placing `audit_log` under dual control (`b.db.declareRequireDualControl`) additionally requires `b.auditTools.purge({ dualControlGrant })` to carry a consumed m-of-n grant whose action is bound to the purge, so a single compromised operator cannot truncate the tamper-evident chain alone and a grant minted for another operation cannot be replayed.
|
|
198
|
+
- **Stolen / relocated database key file** — `db.key.enc` is sealed with additional authenticated data over its purpose, resolved data directory, and resolved key path. A key file copied into a different deployment or path no longer unseals there (the AEAD authentication fails), so an attacker who exfiltrates the key cannot point it at a different data dir to decrypt. An older key sealed without the binding upgrades to the bound format on first load, with no operator action.
|
|
197
199
|
- **Cross-site request forgery on state-changing routes** — `csrfProtect` cookie-mode (double-submit pattern) + `SameSite=Lax` cookie + `Origin` / `Sec-Fetch-Site` checks in CORS.
|
|
198
200
|
- **Drive-by scrapers / low-effort bots** — `botGuard` middleware fingerprints `User-Agent` + `Sec-Fetch-*` + `Accept-Language`.
|
|
199
201
|
- **Online brute-force against credentials** — `b.auth.lockout` tracks failed attempts per account (or any operator-chosen key) and engages an exponential-backoff lockout (1m → 5m → 15m → 1h → 6h+, clamped). State lives in `b.cache` so it shares across cluster nodes when the cluster backend is wired. Operator-driven `unlock(key, { req, reason })` audits with the admin's 5 W's. Backend errors fail open (the framework's job is to slow attackers, not to lock operators out of their own admin accounts when Redis dies).
|
|
@@ -287,6 +289,7 @@ This is the minimum-viable security posture for a production deployment. The fra
|
|
|
287
289
|
- [ ] Rotate the audit signing key annually (or per compliance schedule)
|
|
288
290
|
- [ ] Archive old audit rows monthly: `blamejs audit archive --before <date> --out ./audit-archives/`
|
|
289
291
|
- [ ] Back up the audit-archive bundles to a separate location with a different passphrase
|
|
292
|
+
- [ ] Place `audit_log` under dual control (`b.db.declareRequireDualControl`) so a physical purge requires a two-authorizer m-of-n grant in addition to a verified archive — one operator cannot erase the chain alone
|
|
290
293
|
|
|
291
294
|
**Backups**
|
|
292
295
|
- [ ] Schedule nightly backups via the framework's `b.backup` primitive (encrypted with `BLAMEJS_BACKUP_PASSPHRASE`, separate from vault passphrase)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"frameworkVersion": "0.14.
|
|
4
|
-
"createdAt": "2026-05-
|
|
3
|
+
"frameworkVersion": "0.14.7",
|
|
4
|
+
"createdAt": "2026-05-30T20:25:42.630Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -14518,6 +14518,10 @@
|
|
|
14518
14518
|
"type": "function",
|
|
14519
14519
|
"arity": 0
|
|
14520
14520
|
},
|
|
14521
|
+
"getDeclaredColumns": {
|
|
14522
|
+
"type": "function",
|
|
14523
|
+
"arity": 1
|
|
14524
|
+
},
|
|
14521
14525
|
"getMode": {
|
|
14522
14526
|
"type": "function",
|
|
14523
14527
|
"arity": 0
|
|
@@ -37103,6 +37107,10 @@
|
|
|
37103
37107
|
"create": {
|
|
37104
37108
|
"type": "function",
|
|
37105
37109
|
"arity": 1
|
|
37110
|
+
},
|
|
37111
|
+
"createDeadlineClock": {
|
|
37112
|
+
"type": "function",
|
|
37113
|
+
"arity": 1
|
|
37106
37114
|
}
|
|
37107
37115
|
}
|
|
37108
37116
|
}
|
|
@@ -49732,6 +49740,10 @@
|
|
|
49732
49740
|
"type": "function",
|
|
49733
49741
|
"arity": 0
|
|
49734
49742
|
},
|
|
49743
|
+
"getDerivedHashMacKey": {
|
|
49744
|
+
"type": "function",
|
|
49745
|
+
"arity": 0
|
|
49746
|
+
},
|
|
49735
49747
|
"getDerivedHashSalt": {
|
|
49736
49748
|
"type": "function",
|
|
49737
49749
|
"arity": 0
|
|
@@ -136,7 +136,7 @@ function _registerTopic(topics, name, topicOpts, auditImpl) {
|
|
|
136
136
|
throw new AgentEventBusError("agent-event-bus/bad-schema",
|
|
137
137
|
"registerTopic: schema required (flat key→type map)");
|
|
138
138
|
}
|
|
139
|
-
//
|
|
139
|
+
// `kind` is now captured on register so listTopics's kind
|
|
140
140
|
// filter actually matches. Prior shape never set entry.kind, so the
|
|
141
141
|
// filter at args.kind was dead. Default value derives from the
|
|
142
142
|
// dotted topic name's first segment ("mail.scan.x" → "mail"), giving
|
|
@@ -164,7 +164,7 @@ function _registerTopic(topics, name, topicOpts, auditImpl) {
|
|
|
164
164
|
});
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
//
|
|
167
|
+
// Operators reloading a module (test runners between
|
|
168
168
|
// runs, hot-reload tools, multi-tenant onboarding flows that
|
|
169
169
|
// register-deregister topics) need a clean unregister path; without
|
|
170
170
|
// it the second register throws topic-duplicate and the operator is
|
|
@@ -183,7 +183,7 @@ function _unregisterTopic(topics, name, auditImpl) {
|
|
|
183
183
|
function _listTopics(topics, args, permissions) {
|
|
184
184
|
// Permission gate: list-topics requires no special scope by default;
|
|
185
185
|
// operator can wrap with their own permissions instance for stricter.
|
|
186
|
-
//
|
|
186
|
+
// Kind filter now matches because register captures kind.
|
|
187
187
|
var out = [];
|
|
188
188
|
topics.forEach(function (entry) {
|
|
189
189
|
if (args.kind && entry.kind !== args.kind) return;
|
|
@@ -229,7 +229,7 @@ async function _publish(topics, pubsub, name, payload, pOpts, permissions, audit
|
|
|
229
229
|
}
|
|
230
230
|
// Schema validation.
|
|
231
231
|
guardEventBusPayload.validate(payload, entry.schema);
|
|
232
|
-
//
|
|
232
|
+
// When a topic is tenant-scoped, require the publisher
|
|
233
233
|
// to declare a tenantId BEFORE the event reaches the durable bus
|
|
234
234
|
// backend. Prior shape allowed `wrapped._tenantId: null` to land on
|
|
235
235
|
// the bus, and the receive-side drop only fired AFTER persistence —
|
|
@@ -158,7 +158,7 @@ function create(opts) {
|
|
|
158
158
|
return {
|
|
159
159
|
get: function (method, actorId, key) { return _get(store, method, actorId, key, auditImpl, ttlMs, maxResultBytes); },
|
|
160
160
|
put: function (method, actorId, key, result, putOpts) { return _put(store, method, actorId, key, result, putOpts || {}, ttlMs, maxResultBytes, fingerprintArgs, auditImpl); },
|
|
161
|
-
//
|
|
161
|
+
// putIfAbsent gates concurrent retries at the cache
|
|
162
162
|
// boundary so only one consumer runs the handler. Operator wraps:
|
|
163
163
|
// var claim = await idem.putIfAbsent(method, actor, key, args);
|
|
164
164
|
// if (claim.alreadyClaimed) return claim.result; // another retry won
|
|
@@ -173,7 +173,7 @@ function create(opts) {
|
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
//
|
|
176
|
+
// Atomic claim/check/run pattern. Returns one of:
|
|
177
177
|
// { alreadyClaimed: false, fingerprint } — caller runs the handler
|
|
178
178
|
// { alreadyClaimed: true, pending: true } — another in-flight claim holds the slot
|
|
179
179
|
// { alreadyClaimed: true, result: <cached> } — prior handler completed; cached result
|
|
@@ -299,7 +299,7 @@ async function _get(store, method, actorId, key, auditImpl, ttlMs, maxResultByte
|
|
|
299
299
|
throw new AgentIdempotencyError("agent-idempotency/corrupt-result",
|
|
300
300
|
"get: cached result failed to parse — " + (e && e.message ? e.message : String(e)));
|
|
301
301
|
}
|
|
302
|
-
//
|
|
302
|
+
// Atomic replayCount increment. The prior shape
|
|
303
303
|
// (read row → mutate → put row) raced two concurrent retries: each
|
|
304
304
|
// saw replayCount=N, both wrote replayCount=N+1, so the counter
|
|
305
305
|
// missed bumps and the put-with-fresh-result race-clobbered prior
|
|
@@ -439,7 +439,7 @@ function _fingerprintArgs(args) {
|
|
|
439
439
|
// unique fingerprint and defeat the args-mismatch defense. Strip
|
|
440
440
|
// _traceContext (varies per-hop, doesn't change result).
|
|
441
441
|
//
|
|
442
|
-
//
|
|
442
|
+
// DO NOT strip _postureChain. The prior shape ignored
|
|
443
443
|
// _postureChain.postureSet, so a request made under
|
|
444
444
|
// postureSet:["hipaa","pci-dss"] cached the same result that a
|
|
445
445
|
// downgrade attempt under postureSet:["pci-dss"] would replay
|
|
@@ -493,7 +493,7 @@ function _inMemoryBackend(maxEntries) {
|
|
|
493
493
|
map.set(_k(method, actorId, hash), row);
|
|
494
494
|
return Promise.resolve();
|
|
495
495
|
},
|
|
496
|
-
//
|
|
496
|
+
// Atomic insert. Map.set is synchronous so the
|
|
497
497
|
// get+set pair below is naturally race-free within the in-memory
|
|
498
498
|
// backend (V8 single-threaded). Returns true when inserted, false
|
|
499
499
|
// when the row already exists.
|
|
@@ -503,7 +503,7 @@ function _inMemoryBackend(maxEntries) {
|
|
|
503
503
|
map.set(k, row);
|
|
504
504
|
return Promise.resolve(true);
|
|
505
505
|
},
|
|
506
|
-
//
|
|
506
|
+
// Atomic replayCount increment. Operators wiring
|
|
507
507
|
// a SQL backend implement this with `UPDATE ... SET
|
|
508
508
|
// replay_count = replay_count + 1 WHERE keyHash = $1 RETURNING *`
|
|
509
509
|
// — read-modify-write race-free. In-memory backend is naturally
|
|
@@ -119,7 +119,7 @@ function _unsealRegistryRow(row) {
|
|
|
119
119
|
var DEFAULT_DRAIN_TIMEOUT_MS = C.TIME.minutes(2);
|
|
120
120
|
var STREAM_ID_RAND_BYTES = 8; // stream-id random-suffix byte length, not a size cap
|
|
121
121
|
var DEFAULT_PER_CONSUMER_STOP_MS = C.TIME.seconds(5);
|
|
122
|
-
//
|
|
122
|
+
// FNV-1a offset basis salted with the first 32 bits of
|
|
123
123
|
// SHA3-512(vault master). Attackers who don't have read access to the
|
|
124
124
|
// vault keypair can't compute the salt, so they can't engineer
|
|
125
125
|
// tenantIds that all map to one shard. Cached per-process; rotation
|
|
@@ -183,7 +183,7 @@ function create(opts) {
|
|
|
183
183
|
// operator-supplied metadata (kind / tenantId / posture / ...);
|
|
184
184
|
// every consuming process holds its own runtime map of name → agent.
|
|
185
185
|
liveAgents: new Map(),
|
|
186
|
-
//
|
|
186
|
+
// Drain quiesce wiring. Operator passes
|
|
187
187
|
// { outbox, sagaInFlightCount, pubsubFlush } via create() so the
|
|
188
188
|
// drain phase can quiesce real in-flight work, not just stop
|
|
189
189
|
// consumers. Optional — operators with no outbox / saga / pubsub
|
|
@@ -192,7 +192,7 @@ function create(opts) {
|
|
|
192
192
|
sagaInFlightCount: typeof opts.sagaInFlightCount === "function" ? opts.sagaInFlightCount : null,
|
|
193
193
|
pubsubFlush: typeof opts.pubsubFlush === "function" ? opts.pubsubFlush : null,
|
|
194
194
|
perConsumerStopMs: typeof opts.perConsumerStopMs === "number" ? opts.perConsumerStopMs : DEFAULT_PER_CONSUMER_STOP_MS,
|
|
195
|
-
//
|
|
195
|
+
// onTransition handler invalidates election cache
|
|
196
196
|
// on lease-lost / acquired / released. Operator opts out via
|
|
197
197
|
// { cacheElections: false } to always re-query b.cluster.
|
|
198
198
|
cacheElections: opts.cacheElections !== false,
|
|
@@ -205,7 +205,7 @@ function create(opts) {
|
|
|
205
205
|
});
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
//
|
|
208
|
+
// Subscribe to cluster lease transitions so cached
|
|
209
209
|
// election state can't go stale after a partition. b.cluster
|
|
210
210
|
// .onTransition fires for every lease-acquired / lease-lost / lease-
|
|
211
211
|
// released event; we invalidate the affected resource's cached
|
|
@@ -254,7 +254,7 @@ function create(opts) {
|
|
|
254
254
|
* @status stable
|
|
255
255
|
* @related b.agent.orchestrator.create
|
|
256
256
|
*
|
|
257
|
-
*
|
|
257
|
+
* Attach an in-process live agent reference to a row
|
|
258
258
|
* that already exists in the persistent registry backend. The
|
|
259
259
|
* canonical boot-phase contract: the *first* process to start a new
|
|
260
260
|
* agent calls `register()` (writes the backend row + holds the live
|
|
@@ -499,7 +499,7 @@ function _spawnSingleConsumer(ctx, agent, queue, topic, maxConcurrency) {
|
|
|
499
499
|
* // → integer in [0, 8)
|
|
500
500
|
*/
|
|
501
501
|
function _saltedFnvBasis() {
|
|
502
|
-
//
|
|
502
|
+
// Salt FNV-1a offset basis with the vault master so
|
|
503
503
|
// an attacker can't engineer tenantIds that all hash to one shard.
|
|
504
504
|
// Vault-less path (single-process tests / dev) falls back to the
|
|
505
505
|
// standard FNV offset basis; production deployments with vault
|
|
@@ -561,7 +561,7 @@ async function _elect(ctx, args) {
|
|
|
561
561
|
});
|
|
562
562
|
return elec;
|
|
563
563
|
}
|
|
564
|
-
//
|
|
564
|
+
// Cluster mode: ALWAYS query truth from b.cluster.
|
|
565
565
|
// The onTransition handler installed in create() invalidates the
|
|
566
566
|
// cache on every lease event, so a cache hit here is safe (it
|
|
567
567
|
// means no lease event has fired since the last query). But the
|
|
@@ -600,10 +600,10 @@ async function _drain(ctx, args) {
|
|
|
600
600
|
var drained = 0;
|
|
601
601
|
var startedAt = Date.now();
|
|
602
602
|
var perConsumerMs = ctx.perConsumerStopMs;
|
|
603
|
-
//
|
|
603
|
+
// Drain phases:
|
|
604
604
|
// 1. set ctx.draining so streams emit drain-markers + new task
|
|
605
605
|
// dispatches refuse (consumers re-check on every envelope).
|
|
606
|
-
// 2. stop each consumer with
|
|
606
|
+
// 2. stop each consumer with a per-consumer timeout race —
|
|
607
607
|
// one hung consumer can't block the full drain budget.
|
|
608
608
|
// 3. quiesce in-flight: poll outbox.pendingCount + sagaInFlightCount
|
|
609
609
|
// until 0 OR remaining-budget-ms elapses.
|
|
@@ -65,7 +65,7 @@ var AgentPostureChainError = defineClass("AgentPostureChainError", { alwaysPerma
|
|
|
65
65
|
|
|
66
66
|
var BUILTIN_REGIMES = Object.freeze(["hipaa", "pci-dss", "gdpr", "soc2"]);
|
|
67
67
|
|
|
68
|
-
//
|
|
68
|
+
// Envelope MAC vocabulary. Cross-process envelope
|
|
69
69
|
// integrity: an attacker with queue / event-bus write access who
|
|
70
70
|
// strips postureSet to [] and re-sends a saga / sub-agent envelope
|
|
71
71
|
// can bypass the downgrade refusal in _validate (which only checks
|
|
@@ -73,7 +73,7 @@ var BUILTIN_REGIMES = Object.freeze(["hipaa", "pci-dss", "gdpr", "soc2"]);
|
|
|
73
73
|
// envelope bytes, computed at appendHop and verified at validate.
|
|
74
74
|
var ENVELOPE_MAC_LABEL = "blamejs.agent.postureChain/v1";
|
|
75
75
|
var ENVELOPE_MAC_KEY_BYTES = 32; // HMAC-SHA3-512 keyed bytes
|
|
76
|
-
//
|
|
76
|
+
// Hop count cap defends infinite recursion across
|
|
77
77
|
// agent delegation. 16 is the spec default; operators can lower via
|
|
78
78
|
// opts.maxHopCount but never raise (audit fan-out without a cap is a
|
|
79
79
|
// DoS class).
|
|
@@ -111,7 +111,7 @@ function _resolveMacKey() {
|
|
|
111
111
|
|
|
112
112
|
function _envelopeMacBytes(envelope) {
|
|
113
113
|
// Sign every field that downstream consumers verify off the wire,
|
|
114
|
-
// except the `_mac` field itself.
|
|
114
|
+
// except the `_mac` field itself. Also includes
|
|
115
115
|
// hopCount + chainTrail so a hostile rewriter can't roll back the
|
|
116
116
|
// trail to evade the cap.
|
|
117
117
|
var payload = {
|
|
@@ -163,8 +163,8 @@ function create(opts) {
|
|
|
163
163
|
opts.maxHopCount <= DEFAULT_MAX_HOP_COUNT
|
|
164
164
|
? Math.floor(opts.maxHopCount)
|
|
165
165
|
: DEFAULT_MAX_HOP_COUNT;
|
|
166
|
-
//
|
|
167
|
-
//
|
|
166
|
+
// Escape hatch — only single-process unit tests should opt out of
|
|
167
|
+
// envelope MAC. Production / multi-
|
|
168
168
|
// process / queue-spanning deployments leave the default on; the
|
|
169
169
|
// gate audit-emits when bypassed so the posture is visible.
|
|
170
170
|
var requireMac = opts.requireMac !== false;
|
|
@@ -257,7 +257,7 @@ function _appendHop(ctx, envelope, hopName) {
|
|
|
257
257
|
"appendHop: hopName must be a non-empty string");
|
|
258
258
|
}
|
|
259
259
|
var trail = Array.isArray(envelope.chainTrail) ? envelope.chainTrail.slice() : [];
|
|
260
|
-
//
|
|
260
|
+
// Cap enforced BEFORE the push so the hop-cap throw
|
|
261
261
|
// fires consistently regardless of whether the operator inspects
|
|
262
262
|
// trail.length first. Cap is a hard refusal (no truncation) because
|
|
263
263
|
// a silently-dropped hop loses audit provenance for the call.
|
|
@@ -279,8 +279,8 @@ function _appendHop(ctx, envelope, hopName) {
|
|
|
279
279
|
hopCount: trail.length,
|
|
280
280
|
});
|
|
281
281
|
guardPostureChain.validate(newEnvelope);
|
|
282
|
-
//
|
|
283
|
-
// ctx.requireMac=false (
|
|
282
|
+
// Sign at every hop. Verify-side enforces requireMac.
|
|
283
|
+
// ctx.requireMac=false (test escape hatch) skips
|
|
284
284
|
// the sign so a vault-less test path still works.
|
|
285
285
|
if (ctx.requireMac) {
|
|
286
286
|
try {
|
|
@@ -301,7 +301,7 @@ function _appendHop(ctx, envelope, hopName) {
|
|
|
301
301
|
|
|
302
302
|
function _validate(ctx, envelope, agentPostureSet) {
|
|
303
303
|
guardPostureChain.validate(envelope);
|
|
304
|
-
//
|
|
304
|
+
// MAC verification BEFORE any field-based decision so
|
|
305
305
|
// the wire-rewrite attack (postureSet:[] downgrade with valid SHAPE
|
|
306
306
|
// but no integrity binding) is refused. ctx.requireMac=false skips
|
|
307
307
|
// verification and emits an audit so the bypass is visible.
|
|
@@ -326,7 +326,7 @@ function _validate(ctx, envelope, agentPostureSet) {
|
|
|
326
326
|
chainTrail: envelope.chainTrail,
|
|
327
327
|
});
|
|
328
328
|
}
|
|
329
|
-
//
|
|
329
|
+
// Hop cap also enforced at validate-time. A hostile
|
|
330
330
|
// envelope might arrive with hopCount > cap if a prior hop's
|
|
331
331
|
// requireMac was off; refuse here regardless.
|
|
332
332
|
if (Array.isArray(envelope.chainTrail) && envelope.chainTrail.length > ctx.maxHopCount) {
|