@blamejs/blamejs-shop 0.2.13 → 0.2.14
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 +2 -0
- package/README.md +1 -1
- package/lib/admin.js +616 -21
- package/lib/asset-manifest.json +3 -3
- package/lib/catalog.js +19 -0
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +16 -0
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/lib/agent-idempotency.js +50 -10
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +58 -5
- package/lib/vendor/blamejs/lib/agent-saga.js +8 -4
- package/lib/vendor/blamejs/lib/agent-tenant.js +56 -4
- package/lib/vendor/blamejs/lib/agent-trace.js +4 -3
- package/lib/vendor/blamejs/lib/ai-adverse-decision.js +1 -1
- package/lib/vendor/blamejs/lib/audit-daily-review.js +3 -3
- package/lib/vendor/blamejs/lib/audit.js +11 -8
- package/lib/vendor/blamejs/lib/calendar.js +14 -10
- package/lib/vendor/blamejs/lib/circuit-breaker.js +6 -4
- package/lib/vendor/blamejs/lib/compliance-ai-act-transparency.js +2 -2
- package/lib/vendor/blamejs/lib/compliance.js +7 -9
- package/lib/vendor/blamejs/lib/cookies.js +2 -2
- package/lib/vendor/blamejs/lib/crypto-field.js +8 -3
- package/lib/vendor/blamejs/lib/data-act.js +4 -3
- package/lib/vendor/blamejs/lib/file-upload.js +18 -1
- package/lib/vendor/blamejs/lib/mail-bimi.js +3 -2
- package/lib/vendor/blamejs/lib/mail-crypto-pgp.js +7 -11
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +6 -5
- package/lib/vendor/blamejs/lib/mail-crypto.js +5 -5
- package/lib/vendor/blamejs/lib/mail-store.js +3 -2
- package/lib/vendor/blamejs/lib/mcp.js +2 -3
- package/lib/vendor/blamejs/lib/middleware/age-gate.js +10 -6
- package/lib/vendor/blamejs/lib/middleware/ai-act-disclosure.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +3 -3
- package/lib/vendor/blamejs/lib/middleware/compose-pipeline.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/csp-nonce.js +2 -2
- package/lib/vendor/blamejs/lib/middleware/flag-context.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/require-auth.js +2 -1
- package/lib/vendor/blamejs/lib/queue-redis.js +9 -0
- package/lib/vendor/blamejs/lib/queue.js +17 -1
- package/lib/vendor/blamejs/lib/retry.js +26 -0
- package/lib/vendor/blamejs/lib/router.js +4 -1
- package/lib/vendor/blamejs/lib/safe-decompress.js +3 -2
- package/lib/vendor/blamejs/lib/safe-icap.js +3 -2
- package/lib/vendor/blamejs/lib/safe-mime.js +1 -1
- package/lib/vendor/blamejs/lib/safe-smtp.js +1 -1
- package/lib/vendor/blamejs/lib/sec-cyber.js +1 -1
- package/lib/vendor/blamejs/lib/storage.js +20 -16
- package/lib/vendor/blamejs/lib/vault/index.js +1 -0
- package/lib/vendor/blamejs/lib/vault-aad.js +2 -2
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.25.json +39 -0
- package/lib/vendor/blamejs/release-notes/v0.13.26.json +31 -0
- package/lib/vendor/blamejs/release-notes/v0.13.27.json +34 -0
- package/lib/vendor/blamejs/release-notes/v0.13.28.json +30 -0
- package/lib/vendor/blamejs/release-notes/v0.13.29.json +30 -0
- package/lib/vendor/blamejs/release-notes/v0.13.30.json +30 -0
- package/lib/vendor/blamejs/release-notes/v0.13.31.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.13.32.json +34 -0
- package/lib/vendor/blamejs/test/20-db.js +30 -0
- package/lib/vendor/blamejs/test/integration/queue-redis.test.js +14 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +37 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +37 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-tenant.test.js +26 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/audit-daily-review.test.js +9 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/retry.test.js +25 -0
- package/package.json +1 -1
|
@@ -833,10 +833,10 @@ function _requireInit() {
|
|
|
833
833
|
//
|
|
834
834
|
// Resumable-chunked-upload primitive. Operators handling large file
|
|
835
835
|
// uploads (multipart form / tus / S3-multipart-style flow) need to
|
|
836
|
-
// persist incoming chunks during the upload window, then
|
|
837
|
-
// them
|
|
838
|
-
//
|
|
839
|
-
//
|
|
836
|
+
// persist incoming chunks during the upload window, then concatenate
|
|
837
|
+
// them in order when the upload completes. Without a framework
|
|
838
|
+
// primitive every consumer ended up reinventing the per-assembly
|
|
839
|
+
// directory layout + ordered gap-checked assembly + GC of partial
|
|
840
840
|
// assemblies that never completed.
|
|
841
841
|
//
|
|
842
842
|
// chunkScratch owns:
|
|
@@ -844,8 +844,8 @@ function _requireInit() {
|
|
|
844
844
|
// storage backend just like saveFile)
|
|
845
845
|
// - chunk persistence + retrieval with the framework envelope
|
|
846
846
|
// - assembly metadata tracking createdAt/totalChunks/chunkHashes
|
|
847
|
-
// -
|
|
848
|
-
//
|
|
847
|
+
// - ordered, gap-checked concat returning the assembled bytes for
|
|
848
|
+
// the caller to persist (the primitive does not write a final file)
|
|
849
849
|
// - GC of stale partial assemblies (operator opts in via gc())
|
|
850
850
|
//
|
|
851
851
|
// Backend is the same `b.storage` backend the operator already
|
|
@@ -919,10 +919,11 @@ function _validateChunkIndex(idx) {
|
|
|
919
919
|
* @related b.storage.saveFile, b.storage.getFileBuffer
|
|
920
920
|
*
|
|
921
921
|
* Resumable-chunked-upload primitive. Persists incoming upload chunks
|
|
922
|
-
* during the upload window
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
922
|
+
* during the upload window, then concatenates them in order on
|
|
923
|
+
* completion and returns the assembled bytes for the caller to persist
|
|
924
|
+
* (the primitive does not itself write a final file). Owns per-assembly
|
|
925
|
+
* directory layout, envelope-encrypted chunk persistence, ordered
|
|
926
|
+
* gap-checked assembly, and GC of partial assemblies.
|
|
926
927
|
*
|
|
927
928
|
* Composes existing primitives: each chunk routes through
|
|
928
929
|
* `b.storage.saveFile` (same XChaCha20-Poly1305 envelope as the
|
|
@@ -974,13 +975,16 @@ function _validateChunkIndex(idx) {
|
|
|
974
975
|
* b.storage.init({ backend: "local", uploadDir: "./data/uploads" });
|
|
975
976
|
* var cs = b.storage.chunkScratch({ rootKeyPrefix: "uploads/scratch" });
|
|
976
977
|
*
|
|
977
|
-
* // During upload — each PUT lands one chunk
|
|
978
|
-
*
|
|
979
|
-
*
|
|
980
|
-
* await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex:
|
|
978
|
+
* // During upload — each PUT lands one chunk. saveChunk returns the
|
|
979
|
+
* // chunk's sealed encryptionKey; collect them in order for assemble.
|
|
980
|
+
* var keys = [];
|
|
981
|
+
* keys[0] = (await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 0, data: chunk0 })).encryptionKey;
|
|
982
|
+
* keys[1] = (await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 1, data: chunk1 })).encryptionKey;
|
|
983
|
+
* keys[2] = (await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 2, data: chunk2 })).encryptionKey;
|
|
981
984
|
*
|
|
982
|
-
* // On completion —
|
|
983
|
-
*
|
|
985
|
+
* // On completion — concat the chunks (in order) into the assembled
|
|
986
|
+
* // buffer, then clean up. chunkEncryptionKeys is one key per chunk.
|
|
987
|
+
* var assembled = await cs.assemble({ assemblyId: "upload-abc", expectedTotal: 3, chunkEncryptionKeys: keys });
|
|
984
988
|
* await cs.removeAssembly("upload-abc");
|
|
985
989
|
*
|
|
986
990
|
* // Periodic GC of partial uploads abandoned mid-stream
|
|
@@ -625,6 +625,7 @@ module.exports = {
|
|
|
625
625
|
getKeysJson: getKeysJson,
|
|
626
626
|
getCurrentPassphrase: getCurrentPassphrase,
|
|
627
627
|
getMode: getMode,
|
|
628
|
+
isInitialized: function () { return initialized; },
|
|
628
629
|
VaultError: VaultError,
|
|
629
630
|
sealPemFile: sealPemFileModule.sealPemFile,
|
|
630
631
|
SealPemFileError: sealPemFileModule.SealPemFileError,
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
* .isAadSealed(value) → boolean
|
|
36
36
|
*
|
|
37
37
|
* Per the framework's security-first stance:
|
|
38
|
-
* - Symmetric key derivation uses
|
|
39
|
-
*
|
|
38
|
+
* - Symmetric key derivation uses SHAKE256 (matching the vault's
|
|
39
|
+
* KDF) over the vault root key concatenated with the
|
|
40
40
|
* canonicalized AAD.
|
|
41
41
|
* - AEAD: XChaCha20-Poly1305 with the AAD threaded into the tag.
|
|
42
42
|
* - 24-byte nonce, generated fresh per-seal via
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.25",
|
|
4
|
+
"date": "2026-05-28",
|
|
5
|
+
"headline": "Agent idempotency results and orchestrator/tenant registry rows are sealed at rest",
|
|
6
|
+
"summary": "The b.agent.idempotency, b.agent.orchestrator, and b.agent.tenant primitives documented their stored rows as sealed at rest, but the values were written as plaintext JSON — a database dump could expose cached result payloads (which can carry mail-move / search data), the tenant ids that own each agent, and operator-supplied endpoint metadata. Those values are now sealed via b.cryptoField (XChaCha20-Poly1305 through the vault) before they reach the backing store and unsealed on read, when a vault is configured — which is the default in a booted app. Each ciphertext is AAD-bound to its row identity so a database-write attacker cannot copy a sealed value between rows. Reads of rows written before this release (plain JSON) continue to work unchanged, and a vault-less deployment stores rows as before. No API or call-site changes are required.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Security",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "Agent idempotency / orchestrator / tenant rows sealed at rest",
|
|
13
|
+
"body": "`b.agent.idempotency` cached result blobs, `b.agent.orchestrator` registry rows (owning tenant id + endpoint metadata), and `b.agent.tenant` registry-row metadata are now sealed via `b.cryptoField` when a vault is configured (the default in a booted app via `b.start`). The fields were previously stored as plaintext despite the docs describing them as sealed, so a DB dump exposed cached payloads, agent↔tenant ownership, and endpoint detail. Each sealed value is AAD-bound to the row identity (the idempotency key hash / the agent name / the tenant id), so a sealed value cannot be copied between rows. Rows written before this release remain readable (a non-sealed value passes through unseal), and a vault-less deployment behaves as before. No call-site changes."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"heading": "Fixed",
|
|
19
|
+
"items": [
|
|
20
|
+
{
|
|
21
|
+
"title": "`b.agent.saga` run() return/throw shape documented correctly",
|
|
22
|
+
"body": "The doc said `run()` resolves to `{ status: \"failed\", failedStep, lastCompensationError }` on failure and to a bare final state. In fact it resolves to `{ status: \"completed\", sagaId, state }` on success and rejects (throws) on step failure with an error carrying `failedStep`, `cause`, `compensationCause`, and `failedCompStepName`. The docs now describe the actual contract."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"title": "`b.agent.idempotency` put() example uses the real option name",
|
|
26
|
+
"body": "The example passed `{ argsFingerprint: ... }`, which the function does not read; the fingerprint option is `requestFingerprint` (or `args`). The example now uses `requestFingerprint`."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"title": "`b.agent.tenant.derivedKey` @since corrected to 0.9.26",
|
|
30
|
+
"body": "It was tagged `@since 0.9.25`, a version before the tenant module existed; it ships in 0.9.26 alongside `b.agent.tenant.create`."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"title": "`b.agent.trace.injectIntoEnvelope` documented as single-argument",
|
|
34
|
+
"body": "The doc listed a second `currentSpan` argument the function ignores — it always injects the currently-active span's trace context. The doc now shows `injectIntoEnvelope(envelope)` and notes it should be called while the intended span is active."
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.26",
|
|
4
|
+
"date": "2026-05-28",
|
|
5
|
+
"headline": "`b.cryptoField.unsealRow` nulls a sealed column on unseal failure instead of returning the forged ciphertext",
|
|
6
|
+
"summary": "When a sealed column failed to unseal — a DB-write attacker's forged `vault:<…>` payload, or a valid ciphertext copied into a different row so the AAD no longer matches — unsealRow recorded the failure on the audit chain but then kept the original attacker-crafted string in the field rather than nulling it, despite the documented contract that downstream sees 'no value'. A write-back guard discarded the intended null on the failure path. The column is now nulled on any unseal failure, so a forged or cross-row-copied value never reaches downstream code as if it were a real plaintext. Valid values round-trip unchanged and genuinely-unsealed pass-through values are still kept. This hardens every sealed-column reader, including the agent idempotency / orchestrator / tenant rows sealed in 0.13.25.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Security",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "Sealed columns are nulled on unseal failure (forged / cross-row ciphertext)",
|
|
13
|
+
"body": "`b.cryptoField.unsealRow` now nulls a sealed field when its value fails to unseal — a crafted `vault:`/`vault.aad:` payload written by a DB-write attacker, or a valid ciphertext copied to a different row (AAD mismatch). Previously the field kept the attacker-controlled string, so downstream code could read the forged ciphertext as if it were the plaintext. The audit emit (`system.crypto.unseal_failed`) is unchanged. Valid round-trips and not-actually-sealed pass-through values are unaffected. A regression test pins the forged-value, cross-row-copy, and pass-through cases."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"heading": "Fixed",
|
|
19
|
+
"items": [
|
|
20
|
+
{
|
|
21
|
+
"title": "Audit checkpoint docs name the actual signature algorithm",
|
|
22
|
+
"body": "`b.audit.checkpoint` / `b.audit.verifyCheckpoints` described the anchor signature as ML-DSA-87, but the checkpoint is signed with the configured `b.auditSign` algorithm — SLH-DSA-SHAKE-256f by default (ML-DSA-87 / ML-DSA-65 are opt-in). The docs and the verify-failure reason now refer to the post-quantum signature without naming a specific algorithm the operator may not be using."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"title": "`b.storage.chunkScratch` example and assembly description corrected",
|
|
26
|
+
"body": "The `assemble()` example omitted the mandatory `chunkEncryptionKeys` argument (one sealed key per chunk, returned by `saveChunk`), so it would have thrown as written; it now collects and passes the keys. The prose no longer claims the primitive writes a final file with an 'atomic finalize' — `assemble()` concatenates the chunks in order and returns the assembled bytes for the caller to persist."
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.27",
|
|
4
|
+
"date": "2026-05-28",
|
|
5
|
+
"headline": "Documentation corrected across api-encrypt, mail-crypto, mail-store, and calendar",
|
|
6
|
+
"summary": "A set of JSDoc corrections where the documented contract had drifted from the code. The most actionable: b.middleware.apiEncrypt's @opts named the keypair fields secretKey / ecSecretKey, but the middleware requires privateKey / ecPrivateKey (the shape b.crypto.generateEncryptionKeyPair returns), so a keypair built from the docs threw INVALID_KEYPAIR at construction; the same block documented a wrong custom-nonceStore interface and an example calling a non-existent b.crypto.keypair(). Also corrected: the b.mail.crypto facade and the PGP module described S/MIME sign/verify and PGP encrypt/decrypt/WKD as deferred when they ship and are live; b.mail.crypto.smime.checkCert documented a return shape whose field names did not match what it returns; and b.mailStore.create listed a destroy method it does not expose. No code behavior changed — only the docs were wrong.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Fixed",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.middleware.apiEncrypt` options documented with the correct field names",
|
|
13
|
+
"body": "The `@opts` listed the keypair as `{ publicKey, secretKey, ecPublicKey, ecSecretKey }`, but the middleware requires `{ publicKey, privateKey, ecPublicKey, ecPrivateKey }` — the shape `b.crypto.generateEncryptionKeyPair()` returns — and threw `INVALID_KEYPAIR` for the documented shape. The custom `nonceStore` interface was documented as `{ has, add, prune }` but the middleware calls `{ checkAndInsert, purgeExpired, close }`, and the example called a non-existent `b.crypto.keypair()`. All three now match the implementation (`b.crypto.generateEncryptionKeyPair()`)."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "`b.mail.crypto` S/MIME and PGP availability described accurately",
|
|
17
|
+
"body": "The `b.mail.crypto` facade said S/MIME `sign()` / `verify()` were deferred, and the PGP module's intro said in-process encrypt / decrypt and WKD discovery would 'ship in v0.10.14'. All of these are implemented and live (PGP encrypt/decrypt/WKD were promoted to the stable surface in v0.11.32; S/MIME sign/verify/verifyAll run on the `b.cms` substrate). The docs now describe them as available; the genuinely-deferred PGP v6-signature-packet support remains noted as deferred."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "`b.mail.crypto.smime.checkCert` return shape documented correctly",
|
|
21
|
+
"body": "The doc and example described the result as `{ subjectCN, issuerCN, validFrom, validTo, keyAlg, keyBits, sigAlg }`; the function returns `{ subject, issuer, validFrom, validTo, sigAlgName, sigAlgOid, keyType, fingerprint256 }` (full DN strings, no key-size field). The documented shape now matches."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"title": "`b.mailStore.create` method list matches the returned handle",
|
|
25
|
+
"body": "The doc listed a `destroy` method the handle does not expose and omitted `search` / `moveMessages` / `hardExpunge` that it does. The list now reflects the actual methods."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"title": "Smaller doc corrections",
|
|
29
|
+
"body": "`b.calendar` fromIcal / toIcal / validate now document that they also handle Task (VTODO), Note (VJOURNAL), and Group, not just Event. `b.middleware.requireAuth`'s `prefersJson` default is documented as Accept / X-Requested-With only (Content-Type is intentionally not a signal, as the module already noted). The default CSP nonce is 24 base64 chars (16 bytes), not 22. `b.mail.bimi`'s returned `evidenceDocument` is noted as echoed from the operator-supplied option rather than pulled from the certificate."
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.28",
|
|
4
|
+
"date": "2026-05-28",
|
|
5
|
+
"headline": "Queue retry backoff now applies on the Redis backend; static-serve path-containment edge closed",
|
|
6
|
+
"summary": "Two behavioral fixes plus doc corrections. The Redis queue backend silently discarded the documented retry backoff: b.queue.consume passes the delay as `{ retryDelayMs }` (the shape the local backend reads), but the Redis backend's fail() accepted only a bare-number third argument, so the object failed its numeric check and the delay was forced to 0 — a failing job re-leased immediately instead of waiting 1s/2s/4s/…, a retry storm under failure. The Redis backend now accepts the object form, so the exponential backoff applies as documented (verified by an integration test against real Redis). Separately, b.router.serveStatic's path-containment check used a bare string prefix, so a sibling directory whose name extends the root (root `/srv/public` vs `/srv/public-evil`) could pass; it now anchors on a path separator. Also: b.fileUpload now surfaces (via an observability counter) when a configured content-safety gate is skipped because an upload streamed past the reassembly cap, and documents that boundary; and b.cookies.parse's example output is corrected.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Fixed",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "Redis queue backend honors the documented retry backoff",
|
|
13
|
+
"body": "`b.queue.consume` re-pends a failed job with deterministic exponential backoff (1s base, 5min cap) by calling the backend's `fail()` with `{ retryDelayMs }`. The Redis backend's `fail()` accepted only a bare-number third argument, so the object failed its `typeof === \"number\"` check and the delay was reset to 0 — a failing job became immediately re-leasable, hot-looping instead of backing off. `fail()` now accepts both the object form (as the local backend does) and a bare number, so the backoff applies on Redis. An integration test against real Redis pins it."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "`b.router.serveStatic` path-containment anchors on a separator",
|
|
17
|
+
"body": "The containment check was `resolvedPath.startsWith(root)`, which a sibling directory sharing the root's name as a prefix (root `/srv/public` vs `/srv/public-evil`) could satisfy. It now requires the resolved path to equal `root` or start with `root + path.sep`, closing the sibling-prefix edge (`b.staticServe.create` remains the hardened serving path, with realpath + filename gating)."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "`b.fileUpload` surfaces content-safety gate skips on oversized streamed uploads",
|
|
21
|
+
"body": "The byte-level content-safety gate inspects the reassembled buffer, so it runs on uploads up to `maxStreamReassemblyBytes` (default 64 MiB); a larger upload is handed to `onFinalize` as a stream and the byte-content gate is skipped (MIME-sniff and filename gates still run). That skip now emits a `fileUpload.content_safety_skipped_streamed` observability counter instead of passing silently, and the limit is documented. To guarantee content-gating of a type, cap `maxFileBytes` at or below `maxStreamReassemblyBytes`."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"title": "`b.cookies.parse` example output corrected",
|
|
25
|
+
"body": "The example claimed `theme=%22dark%22` parses to `theme: \"dark\"`, but quote-stripping runs before percent-decoding, so the literal quotes survive. The example now uses `theme=dark%20mode` → `theme: \"dark mode\"`, which demonstrates percent-decoding without the quote-strip-ordering quirk."
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.29",
|
|
4
|
+
"date": "2026-05-28",
|
|
5
|
+
"headline": "Doc corrections: AI Act disclosure kind values, SQS queue model, age-gate coupling",
|
|
6
|
+
"summary": "Documentation corrections. The most actionable: b.middleware.aiActDisclosure's @opts listed two EU AI Act transparency `kind` values (`deepfake` and `synthetic-content`) that the middleware does not accept, so they threw at construction; the accepted values use the hyphenated Art. 50 spellings (e.g. `deep-fake`) and include a text-public-interest variant the enum omitted — an operator copying the documented values crashed a compliance middleware at boot. The b.queue docs implied the SQS backend is driven by the generic b.queue.consume loop like local/redis; SQS is actually an SQS-native adapter (complete/fail by message receipt handle, server-side redrive) driven directly, and the docs now say so. b.middleware.ageGate's `requireAge` 451 floor is documented as taking effect only alongside `consentRequired` (it was silently inert without it). Plus a compose-pipeline @since and a flag-context @related correction. No code behavior changed.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Fixed",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.middleware.aiActDisclosure` documents the accepted `kind` values",
|
|
13
|
+
"body": "The `@opts` listed `kind` as `ai-interaction | deepfake | emotion-recognition | biometric-categorisation | synthetic-content`. Two of those — `deepfake` and `synthetic-content` — are not accepted and threw at construction; the EU AI Act Art. 50 values use hyphenated spellings (e.g. `deep-fake`, the generated-content variant) and include `ai-text-public-interest`, which the documented enum omitted. The `@opts` now lists the full set the middleware accepts."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "`b.queue` SQS backend documented as SQS-native, not consume-driven",
|
|
17
|
+
"body": "The module docs implied the `sqs` backend is interchangeable with `local`/`redis` under the generic `b.queue.consume` loop. SQS is an SQS-native adapter: `complete` / `fail` act on the message's `receiptHandle` (returned by `lease()`, threaded back by the caller), and DLQ + visibility-expiry are handled server-side by the queue's RedrivePolicy. The docs now state that `sqs` is driven directly (lease → handle → complete/fail) rather than by `b.queue.consume`, and does not use the framework DLQ / sweep."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "`b.middleware.ageGate` documents the `requireAge` / `consentRequired` coupling",
|
|
21
|
+
"body": "`requireAge` (the HTTP 451 legal floor) is evaluated within the consent classification, so it takes effect only when `consentRequired` is also set — `requireAge` alone, with `consentRequired: null`, never classifies a request as below-threshold and the 451 never fires. The `@opts` and prose now state this coupling instead of presenting `requireAge` as a standalone threshold."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"title": "Smaller doc corrections",
|
|
25
|
+
"body": "`b.middleware.composePipeline`'s `@since` is corrected to 0.9.43 (its actual ship version). `b.middleware.flagContext`'s `@related` pointed at a non-existent `b.flagClient.getBoolean`; it now references `b.flag.create`."
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.30",
|
|
4
|
+
"date": "2026-05-28",
|
|
5
|
+
"headline": "Doc corrections in the safe-* parsers (defaults, an error code, an example, a status list)",
|
|
6
|
+
"summary": "Four documentation corrections in the safe-* input parsers; no code behavior changed. The parsers' enforced limits and controls are unchanged — these align the docs with what the code already does. b.safeMime.parse's documented default transfer-encoding allowlist listed `binary`, which is excluded by default (opt-in per RFC 3030 BINARYMIME). b.safeDecompress documented a refusal code (`output-too-large`) it never emits — an absolute-size bomb surfaces under `decompress-failed`. b.safeSmtp.findDotTerminator's example output was off by one. b.safeIcap's intro status-code summary omitted 404 / 405 / 408 (the detailed block already listed them).",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Fixed",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.safeMime.parse` documents the actual default transfer-encoding allowlist",
|
|
13
|
+
"body": "The `@opts` default listed `7bit/8bit/binary/qp/base64`, but `binary` is deliberately excluded by default (RFC 3030 BINARYMIME is opt-in); the default is `7bit/8bit/quoted-printable/base64`. The doc now matches, so operators don't expect inbound `Content-Transfer-Encoding: binary` parts to pass without opting in."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "`b.safeDecompress` names the real absolute-size-bomb refusal code",
|
|
17
|
+
"body": "The refusal-posture list documented `safe-decompress/output-too-large` for a bomb-by-absolute-size, but that code is never emitted — zlib's `maxOutputLength` throws before allocation and the failure surfaces as `safe-decompress/decompress-failed`. The doc now names the code an operator branching on the result will actually see (the ratio, output-byte, and compressed-input caps are unchanged and enforced)."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "`b.safeSmtp.findDotTerminator` example output corrected",
|
|
21
|
+
"body": "The example claimed the `\\r\\n.\\r\\n` terminator in `\"Hello world.\\r\\n.\\r\\n\"` is at index 13; it is at index 12. The example now shows 12 (the implementation was already correct)."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"title": "`b.safeIcap` intro status-code summary lists 404 / 405 / 408",
|
|
25
|
+
"body": "The intro summary said only `100 / 200 / 204 / 400 / 403 / 5xx` are honored, but the parser also accepts `404 / 405 / 408` (legitimate RFC 3507 §4.3.3 codes, already listed in the detailed `parse` block). The intro summary now matches."
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.31",
|
|
4
|
+
"date": "2026-05-28",
|
|
5
|
+
"headline": "Circuit-breaker onStateChange callback now fires; mcp / vault-aad doc corrections",
|
|
6
|
+
"summary": "b.circuitBreaker documented an `onStateChange` callback (both an option and an `onStateChange(handler)` registration method) plus a state-change payload, but the callback was never invoked — only an observability event fired. The callback is now implemented: it fires on every transition with `{ name, from, to, at }`, the registration method works, and a non-function handler is rejected at construction. The same primitive's docs are corrected to name the real accessor (`getState()`, not `state()`) and drop a never-read `audit` option. Plus two doc-only corrections: b.mcp.toolResult.sanitize described composing b.guardHtml / b.ai.input.classify (it uses built-in detection) and documented a `classifyInput` option it never read; and b.vault.aad's prose said HKDF-SHAKE256 where the derivation is SHAKE256 (the AEAD AAD-binding itself is unchanged and sound).",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Fixed",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.circuitBreaker` onStateChange callback is invoked on every transition",
|
|
13
|
+
"body": "The `onStateChange` option and the `onStateChange(handler)` registration method are now wired: each registered handler is called with `{ name, from, to, at }` on every state transition (closed→open, open→half, half→closed/open), alongside the existing `breaker.state.change` observability event. A non-function `onStateChange` is rejected at construction. Previously the documented callback never fired. The docs are also corrected to name the real state accessor `getState()` (there is a `state` property, so `state()` was never a method) and to drop a never-read `audit` option."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "`b.mcp.toolResult.sanitize` documents its actual detection and options",
|
|
17
|
+
"body": "The prose said the sanitizer composes `b.guardHtml`'s strict profile and `b.ai.input.classify`; it uses built-in dangerous-HTML and prompt-injection-marker detection. The `@opts` also listed a `classifyInput` override the function never read. The prose now describes the built-in detection and the unwired `classifyInput` option is removed. The fail-closed refusal behavior (default `posture: \"refuse\"`) is unchanged."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "`b.vault.aad` derivation named correctly (SHAKE256)",
|
|
21
|
+
"body": "The module prose described the per-binding key derivation as HKDF-SHAKE256; it is SHAKE256 over the vault root concatenated with the binding inputs (no HKDF extract/expand). The AEAD AAD-binding to (table, row, column, schema version) — the file's actual security guarantee — is unchanged and sound; only the KDF name in the doc was wrong."
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.32",
|
|
4
|
+
"date": "2026-05-28",
|
|
5
|
+
"headline": "`b.auditDailyReview` enforces notify under the sox-404 posture; compliance doc corrections",
|
|
6
|
+
"summary": "b.auditDailyReview documented `sox-404` (SOX §404 ICFR — the internal-controls regime this primitive serves) as one of the postures that make a `notify` callback mandatory at construction, but the enforcement set used only `sox`, so pinning `posture: \"sox-404\"` without a notify channel was silently accepted. `sox-404` is now in the mandatory-notify set, so the advertised guarantee holds (a regression test pins it). The rest are documentation corrections with no behavior change: b.compliance.posturesByDomain / posturesByJurisdiction examples showed small fixed arrays where the functions return every matching posture (the catalog has grown); b.dataAct's surface list named two methods that do not exist (the real surface is declareProduct / recordUserAccess / shareWithThirdParty / recordSwitchRequest, with gatekeeper refusal folded into shareWithThirdParty); b.secCyber.eightKArtifact's documented return key `audit` is actually `deadlineBusinessDays`; and b.compliance.aiAct.transparency's helper summary named `cspMetaTag` / a `watermark({ kind })` argument that are really `metaTags` / `watermark({ mediaKind })`.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Fixed",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.auditDailyReview` requires a notify channel under the `sox-404` posture",
|
|
13
|
+
"body": "The docs listed `sox-404` among the postures that make a `notify` callback mandatory at create-time, but the enforcement set contained only `sox` — so `posture: \"sox-404\"` without `notify` was accepted instead of refused. `sox-404` (SOX §404 ICFR) is now in the mandatory-notify set, matching the documented guarantee; constructing without a notify channel under it throws `auditDailyReview/notify-required-under-posture`."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "`b.compliance` jurisdiction/domain lister examples no longer enumerate a stale fixed set",
|
|
17
|
+
"body": "`posturesByDomain` and `posturesByJurisdiction` return every posture matching the domain/jurisdiction, but their `@example`s showed small fixed arrays from before the posture catalog grew. The examples now show a representative prefix with `...` and note they return the full matching set."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "`b.dataAct` surface list matches the real methods",
|
|
21
|
+
"body": "The module surface listed `userAccessible(...)` and `refuseGatekeeper(...)`, neither of which exists. The real surface is `declareProduct` / `recordUserAccess` / `shareWithThirdParty` / `recordSwitchRequest`, and DMA-gatekeeper refusal (Art 32 §1) is enforced inside `shareWithThirdParty`. The doc now reflects that."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"title": "`b.secCyber.eightKArtifact` documented return shape corrected",
|
|
25
|
+
"body": "The signature line showed `{ artifact, deadline, audit }`; the function returns `{ artifact, deadline, deadlineBusinessDays }` (there is no `audit` key). The doc now matches."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"title": "`b.compliance.aiAct.transparency` helper names corrected",
|
|
29
|
+
"body": "The helper summary named a `cspMetaTag(...)` function and a `watermark({ kind })` argument; the real names are `metaTags(...)` and `watermark({ mediaKind })`. Calling the documented names threw. Also corrected: a `b.aiAdverseDecision` illustration showed an ECOA `statutoryDeadlines` shape that didn't match the regime's actual deadlines."
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -427,11 +427,41 @@ async function testCrossSchemaAttach() {
|
|
|
427
427
|
}
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
+
async function testUnsealRowNullsForgedValue() {
|
|
431
|
+
// Security regression (CRYPTO-1): an unseal failure — a DB-write
|
|
432
|
+
// attacker's forged `vault:<…>` payload, or a valid ciphertext copied
|
|
433
|
+
// to a different row (AAD mismatch) — must NULL the column so
|
|
434
|
+
// downstream sees "no value", not the attacker-crafted string. A prior
|
|
435
|
+
// `unsealed ? unsealed : out[field]` write-back silently kept the
|
|
436
|
+
// forged value on failure, defeating the documented defense.
|
|
437
|
+
var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-cf-forged-"));
|
|
438
|
+
try {
|
|
439
|
+
await setupTestDb(tmpDir);
|
|
440
|
+
b.cryptoField.registerTable("cf_forged_regress", {
|
|
441
|
+
sealedFields: ["secret"], aad: true, rowIdField: "id",
|
|
442
|
+
});
|
|
443
|
+
var sealed = b.cryptoField.sealRow("cf_forged_regress", { id: "r1", secret: "hello" });
|
|
444
|
+
check("cryptoField valid round-trip",
|
|
445
|
+
b.cryptoField.unsealRow("cf_forged_regress", sealed).secret === "hello");
|
|
446
|
+
var forged = b.cryptoField.unsealRow("cf_forged_regress",
|
|
447
|
+
{ id: "r1", secret: "vault.aad:Zm9yZ2VkLWdhcmJhZ2U=" });
|
|
448
|
+
check("forged sealed value nulls the field (not kept)", forged.secret === null);
|
|
449
|
+
var crossRow = b.cryptoField.unsealRow("cf_forged_regress",
|
|
450
|
+
{ id: "DIFFERENT-ROW", secret: sealed.secret });
|
|
451
|
+
check("cross-row-copied ciphertext nulls the field (AAD mismatch)", crossRow.secret === null);
|
|
452
|
+
var plain = b.cryptoField.unsealRow("cf_forged_regress", { id: "r2", secret: "not-sealed" });
|
|
453
|
+
check("non-sealed pass-through value is kept", plain.secret === "not-sealed");
|
|
454
|
+
} finally {
|
|
455
|
+
await teardownTestDb(tmpDir);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
430
459
|
// ---- run() ----
|
|
431
460
|
|
|
432
461
|
async function run() {
|
|
433
462
|
// db basic
|
|
434
463
|
await testDbBasic();
|
|
464
|
+
await testUnsealRowNullsForgedValue();
|
|
435
465
|
await testDbWriteOps();
|
|
436
466
|
await testDbSealedWithoutDerived();
|
|
437
467
|
await testDbTransactions();
|
|
@@ -152,6 +152,20 @@ async function run() {
|
|
|
152
152
|
lease3b[0].attempts === 2); // sweep doesn't re-increment; lease does
|
|
153
153
|
await qr.complete(enq3.jobId);
|
|
154
154
|
|
|
155
|
+
// ---- fail() honors the object-form { retryDelayMs } b.queue.consume
|
|
156
|
+
// passes (regression: the redis backend previously accepted only
|
|
157
|
+
// a bare-number 3rd arg, so the object failed the typeof check
|
|
158
|
+
// and the delay was forced to 0 — the documented exponential
|
|
159
|
+
// backoff was silently discarded and the job re-leased at once) ----
|
|
160
|
+
var QB = "retry-backoff";
|
|
161
|
+
var enqB = await qr.enqueue(QB, { go: 1 }, { maxAttempts: 5 });
|
|
162
|
+
var leaseB = await qr.lease(QB, 5000, 1);
|
|
163
|
+
check("lease: backoff-regression job leased", leaseB.length === 1);
|
|
164
|
+
await qr.fail(enqB.jobId, "boom", { retryDelayMs: 60000 }); // object form, 60s delay
|
|
165
|
+
var leaseBNow = await qr.lease(QB, 5000, 1);
|
|
166
|
+
check("fail({retryDelayMs}) delays re-lease — backoff honored, not forced to 0",
|
|
167
|
+
leaseBNow.length === 0);
|
|
168
|
+
|
|
155
169
|
// ---- fail + retry path ----
|
|
156
170
|
var Q4 = "retry";
|
|
157
171
|
var enq4 = await qr.enqueue(Q4, { go: 1 }, { maxAttempts: 2 });
|
|
@@ -235,8 +235,45 @@ async function testReplayCountAtomic() {
|
|
|
235
235
|
(r1.replayCount === 2 && r2.replayCount === 1));
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
async function testAtRestSealingWithVault() {
|
|
239
|
+
// The cached result is sealed at rest via b.cryptoField when a vault
|
|
240
|
+
// is configured. Init a vault, capture what actually lands in the
|
|
241
|
+
// backend, and assert the sensitive payload is not stored in the
|
|
242
|
+
// clear — then confirm it round-trips. Reset the vault afterwards so
|
|
243
|
+
// the remaining (vault-less) tests run in their expected mode.
|
|
244
|
+
var os = require("node:os");
|
|
245
|
+
var path = require("node:path");
|
|
246
|
+
var fs = require("node:fs");
|
|
247
|
+
var dir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-idem-seal-"));
|
|
248
|
+
check("vault.isInitialized() is false before init", b.vault.isInitialized() === false);
|
|
249
|
+
await b.vault.init({ mode: "plaintext", dataDir: dir });
|
|
250
|
+
try {
|
|
251
|
+
check("vault.isInitialized() is true after init", b.vault.isInitialized() === true);
|
|
252
|
+
var SECRET = "patient-PII-9007199254740993";
|
|
253
|
+
var captured = null;
|
|
254
|
+
var backend = {
|
|
255
|
+
_m: Object.create(null),
|
|
256
|
+
async get(m, a, h) { return this._m[m + a + h] || null; },
|
|
257
|
+
async put(m, a, h, row) { this._m[m + a + h] = row; captured = row; },
|
|
258
|
+
async delete(m, a, h) { delete this._m[m + a + h]; },
|
|
259
|
+
};
|
|
260
|
+
var idem = b.agent.idempotency.create({ store: backend });
|
|
261
|
+
await idem.put("move", "u-seal", "jmap-seal-1", { payload: SECRET });
|
|
262
|
+
check("at-rest: result blob is sealed (no plaintext leak)",
|
|
263
|
+
typeof captured.resultBlob === "string" && captured.resultBlob.indexOf(SECRET) === -1);
|
|
264
|
+
check("at-rest: sealed blob carries a vault prefix",
|
|
265
|
+
/^vault(\.aad)?:/.test(String(captured.resultBlob)));
|
|
266
|
+
var hit = await idem.get("move", "u-seal", "jmap-seal-1");
|
|
267
|
+
check("at-rest: sealed result round-trips to plaintext",
|
|
268
|
+
hit && hit.result && hit.result.payload === SECRET);
|
|
269
|
+
} finally {
|
|
270
|
+
b.vault._resetForTest();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
238
274
|
async function run() {
|
|
239
275
|
testSurface();
|
|
276
|
+
await testAtRestSealingWithVault();
|
|
240
277
|
await testBasicGetPut();
|
|
241
278
|
await testCrossActorIsolation();
|
|
242
279
|
await testCrossMethodIsolation();
|
|
@@ -361,6 +361,42 @@ async function testShardForSaltedFnv() {
|
|
|
361
361
|
Object.keys(spread).length >= 4);
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
async function testRegistryRowSealedAtRest() {
|
|
365
|
+
// Registry rows seal tenantId + endpoint metadata at rest via
|
|
366
|
+
// b.cryptoField when a vault is configured. Scope a vault around this
|
|
367
|
+
// one test (the other tests intentionally run vault-less to exercise
|
|
368
|
+
// the salted-FNV fallback), capture what lands in the backend, and
|
|
369
|
+
// assert the sensitive fields are not stored in the clear — then
|
|
370
|
+
// confirm the tenantId filter still resolves through unseal.
|
|
371
|
+
var os = require("node:os");
|
|
372
|
+
var path = require("node:path");
|
|
373
|
+
var fs = require("node:fs");
|
|
374
|
+
var dir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-orch-seal-"));
|
|
375
|
+
await helpers.setupVaultOnly(dir);
|
|
376
|
+
try {
|
|
377
|
+
var captured = null;
|
|
378
|
+
var backend = {
|
|
379
|
+
_m: Object.create(null),
|
|
380
|
+
async get(n) { return this._m[n] || null; },
|
|
381
|
+
async set(n, row) { this._m[n] = row; if (n === "svc-seal") captured = row; },
|
|
382
|
+
async delete(n) { delete this._m[n]; },
|
|
383
|
+
async list() { return Object.values(this._m); },
|
|
384
|
+
};
|
|
385
|
+
var orch = b.agent.orchestrator.create({ backend: backend });
|
|
386
|
+
await orch.register("svc-seal", { kind: "mail", handle: function () {} },
|
|
387
|
+
{ agentKind: "mail", tenantId: "acme-corp", metadata: { endpoint: "https://internal-host:9000" } });
|
|
388
|
+
check("orch at-rest: tenantId sealed (no plaintext leak)",
|
|
389
|
+
typeof captured.tenantId === "string" && captured.tenantId.indexOf("acme-corp") === -1);
|
|
390
|
+
check("orch at-rest: metadata sealed (no endpoint leak)",
|
|
391
|
+
typeof captured.metadata === "string" && captured.metadata.indexOf("internal-host") === -1);
|
|
392
|
+
var listed = await orch.list({ tenantId: "acme-corp" });
|
|
393
|
+
check("orch at-rest: tenantId filter resolves through unseal",
|
|
394
|
+
listed.length === 1 && listed[0].name === "svc-seal" && listed[0].tenantId === "acme-corp");
|
|
395
|
+
} finally {
|
|
396
|
+
helpers.teardownVaultOnly(dir);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
364
400
|
async function run() {
|
|
365
401
|
testSurface();
|
|
366
402
|
await testRegisterLookupUnregister();
|
|
@@ -381,6 +417,7 @@ async function run() {
|
|
|
381
417
|
await testHealth();
|
|
382
418
|
await testStreamRegistry();
|
|
383
419
|
await testPermissions();
|
|
420
|
+
await testRegistryRowSealedAtRest();
|
|
384
421
|
}
|
|
385
422
|
|
|
386
423
|
module.exports = { run: run };
|
|
@@ -268,12 +268,38 @@ async function testUnsealRowAuditsOnDecryptRefusal() {
|
|
|
268
268
|
check("BUG-4: audit emitted on cross-tenant null", saw);
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
async function testRegistryMetadataSealedAtRest() {
|
|
272
|
+
// The registry row's metadata is sealed at rest via b.cryptoField when
|
|
273
|
+
// a vault is configured (it is, in this file's run()). Capture what
|
|
274
|
+
// lands in the backend and assert the sensitive metadata is not stored
|
|
275
|
+
// in the clear, then confirm lookup round-trips it back to an object.
|
|
276
|
+
var SECRET = "tenant-billing-id-XQ42";
|
|
277
|
+
var captured = null;
|
|
278
|
+
var backend = {
|
|
279
|
+
_m: Object.create(null),
|
|
280
|
+
async get(k) { return this._m[k] || null; },
|
|
281
|
+
async set(k, row) { this._m[k] = row; if (k === "acme-seal") captured = row; },
|
|
282
|
+
async delete(k) { delete this._m[k]; },
|
|
283
|
+
async list() { return Object.values(this._m); },
|
|
284
|
+
};
|
|
285
|
+
var tn = b.agent.tenant.create({ backend: backend });
|
|
286
|
+
await tn.register("acme-seal", { posture: "hipaa", metadata: { billingId: SECRET } });
|
|
287
|
+
check("tenant at-rest: metadata sealed (no plaintext leak)",
|
|
288
|
+
typeof captured.metadata === "string" && captured.metadata.indexOf(SECRET) === -1);
|
|
289
|
+
check("tenant at-rest: sealed metadata carries a vault prefix",
|
|
290
|
+
/^vault(\.aad)?:/.test(String(captured.metadata)));
|
|
291
|
+
var cfg = await tn.lookup("acme-seal");
|
|
292
|
+
check("tenant at-rest: metadata round-trips to an object",
|
|
293
|
+
cfg && cfg.metadata && cfg.metadata.billingId === SECRET);
|
|
294
|
+
}
|
|
295
|
+
|
|
271
296
|
async function run() {
|
|
272
297
|
var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-vault-"));
|
|
273
298
|
global._testVaultDir = tmpDir;
|
|
274
299
|
await helpers.setupVaultOnly(tmpDir);
|
|
275
300
|
try {
|
|
276
301
|
testSurface();
|
|
302
|
+
await testRegistryMetadataSealedAtRest();
|
|
277
303
|
await testRegisterLookupUnregister();
|
|
278
304
|
await testCheckCrossTenant();
|
|
279
305
|
await testCrossTenantAdminScope();
|
|
@@ -82,6 +82,15 @@ function testPostureRequiresNotify() {
|
|
|
82
82
|
} catch (e) { threw = e; }
|
|
83
83
|
check("pci-dss posture without notify throws",
|
|
84
84
|
threw && /notify-required-under-posture/.test(threw.code || ""));
|
|
85
|
+
|
|
86
|
+
// sox-404 (SOX §404 ICFR — the audit/internal-controls regime this
|
|
87
|
+
// primitive serves) is an advertised notify-required posture too.
|
|
88
|
+
var threw404 = null;
|
|
89
|
+
try {
|
|
90
|
+
b.auditDailyReview.create({ audit: _fakeAudit([]), posture: "sox-404" });
|
|
91
|
+
} catch (e) { threw404 = e; }
|
|
92
|
+
check("sox-404 posture without notify throws",
|
|
93
|
+
threw404 && /notify-required-under-posture/.test(threw404.code || ""));
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
function testBadSeverity() {
|