@blamejs/blamejs-shop 0.0.111 → 0.0.112
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/lib/api-keys.js +5 -7
- package/lib/carrier-accounts.js +4 -6
- package/lib/customer-surveys.js +4 -5
- package/lib/stock-alerts.js +1 -10
- package/lib/stock-receipts.js +1 -4
- package/lib/storefront.js +3 -5
- package/lib/subscription-gifts.js +1 -6
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +31 -2
- package/lib/vendor/blamejs/index.js +1 -0
- package/lib/vendor/blamejs/lib/ai-disclosure.js +349 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.12.json +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-disclosure.test.js +192 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +42 -1
- package/lib/webhook-receiver.js +1 -4
- package/lib/wishlist-sharing.js +3 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.0.x
|
|
10
10
|
|
|
11
|
+
- v0.0.112 (2026-05-23) — **Compose `b.crypto.toBase64Url` instead of reinventing — nine primitives + new detector.** Nine framework primitives (`api-keys`, `carrier-accounts`, `customer-surveys`, `stock-alerts`, `stock-receipts`, `subscription-gifts`, `webhook-receiver`, `wishlist-sharing`, `storefront`'s server-side `_b64u`) shipped with hand-rolled base64url encoding — the canonical four-line `.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")` chain. The trailing-padding `.replace(/=+$/, "")` strip is polynomial-ReDoS-shaped per CodeQL js/polynomial-redos. The framework's `b.crypto.toBase64Url(buf)` helper routes through Node's built-in `base64url` encoding which is linear-time and produces the same RFC 4648 §5 output. Every server-side callsite now composes the framework primitive. Two browser-side helpers in `lib/storefront.js`'s WebAuthn UI string-template (no `b.crypto` available in the page runtime) carry inline `allow:inline-base64url-three-replace` markers — `window.btoa` plus the three-replace shim is the browser-built-in equivalent. New `inline-base64url-three-replace` codebase-patterns detector enforces the composition shop-wide. **Added:** *`inline-base64url-three-replace` codebase-patterns detector* — Flags `.replace(/=+$/, ...)` anywhere under `lib/` or `worker/`. Catches the canonical four-line base64url reinvention chain at the trailing-padding-strip line. Ported verbatim (with shop-scope) from blamejs's catalog. Browser-side string-template emissions in `lib/storefront.js` carry inline allow markers documenting that the browser has no `b.crypto` runtime — `window.btoa` plus the three-replace shim is the built-in. **Changed:** *Nine primitives compose `b.crypto.toBase64Url`* — `api-keys` `_generateToken`, `carrier-accounts` `_generateSecret`, `customer-surveys` `_generateToken`, `stock-alerts` `_mintToken` (inlined the redundant `_b64url` helper), `stock-receipts` `_generateToken`, `subscription-gifts` `_generateToken`, `webhook-receiver` `_generateSecret`, `wishlist-sharing` `_generateToken`, and `storefront`'s server-side `_b64u` helper all now route through `_b().crypto.toBase64Url(buf)`. Linear-time output, no polynomial-ReDoS surface, no Node-minor-version Buffer-flag-rename risk.
|
|
12
|
+
|
|
11
13
|
- v0.0.111 (2026-05-23) — **Restore /feed.xml + /sitemap.xml — `b.safeUrl.parse` returns a URL instance, not a string.** `/feed.xml` and `/sitemap.xml` have been responding 503 "temporarily unavailable" since they shipped. Root cause: three edge handlers (sitemap, feed, scheduled cache-warmer) chained `.replace(/\/$/, "")` directly off `b.safeUrl.parse(...)`. The framework primitive returns a WHATWG `URL` instance, not a string — `URL.prototype.replace` doesn't exist, so every request threw `TypeError: ... .replace is not a function`. The handler's catch swallowed the throw and returned the canonical 503, making the routes appear transiently unavailable when they were in fact permanently broken on that callsite. The bug went undetected because production deploys were frozen at v0.0.101 for a separate reason (now fixed in v0.0.110), so the broken handlers never reached live traffic until today. Fix: chain `.href.replace(/\/$/, "")` so the string method operates on the URL's href string. A new `safeurl-parse-string-method` detector catches the shape repo-wide (47 detectors → adds 1, total 47 → 48). **Added:** *`safeurl-parse-string-method` codebase-patterns detector* — Flags `b.safeUrl.parse(...).<strMethod>(` anywhere under `worker/` — covers `replace` / `startsWith` / `endsWith` / `split` / `includes` / `slice` / `indexOf` / `toLowerCase` / `toUpperCase`. Property access (`.href`) is intentionally NOT flagged — that's the correct shape. The detector exists because three handlers shipped this anti-pattern without anyone noticing while production was frozen; pre-merge detection prevents the next instance from regressing through review the same way. **Fixed:** *Restore `/feed.xml`, `/sitemap.xml`, and the scheduled cache-warmer* — Three callsites in `worker/index.js` shipped `b.safeUrl.parse(env.D1_BRIDGE_URL || "https://blamejs.shop").replace(/\/$/, "")` — `URL.prototype.replace` doesn't exist, so the worker threw TypeError on every request and the edge handler's catch returned the canonical 503. Replaced with `.href.replace(...)` so the trailing-slash strip runs against the URL's serialized form. The semantic contract (operator-configured origin with no trailing slash) is preserved across operator inputs with or without a path prefix.
|
|
12
14
|
|
|
13
15
|
- v0.0.110 (2026-05-23) — **Unblock Cloudflare production deploys + vendor blamejs v0.12.11 + vendor-drift demoted to warning.** Production Cloudflare Workers deploys have been failing on every push since v0.0.102. Root cause: the container image excludes `worker/` via `.dockerignore` (the worker is deployed separately by `wrangler deploy`, the container only ships the long-running backend), but `test/smoke.js` runs inside the container build's `RUN node test/smoke.js` step and calls the `worker-syntax` gate, which tries to read `worker/index.js` and crashes with `ENOENT`. The Docker build exits 1, `wrangler deploy` aborts, and the production worker is left frozen at the last successful build. The fix: `scripts/check-worker-syntax.js` now skips with a no-op stderr message and exits 0 when its entry file isn't present in the current build context — outside the container (host smoke / CI / npm-publish) the entry is always present and the strict scan runs unchanged. Vendor refresh: blamejs v0.12.11 (carries upstream's matching disciplines). Vendor-drift gate demoted from failure to warning: drift surfaces on stderr (still visible in CI logs + operator terminal) but no longer blocks `npm test` — the committed vendor tree is the source of truth, and operators don't need to refresh on every blamejs release before they can ship an unrelated patch. **Changed:** *`vendor-update.sh --check` warns instead of failing on drift* — Drift between vendored blamejs and the latest upstream tag now surfaces on stderr as `[vendor-check] WARNING — vendored vA.B.C, latest vX.Y.Z` and exits 0. The committed vendor tree is the source of truth — there's no integrity loss from an older-but-still-supported vendor version, and forcing a refresh on every minor blamejs release before any unrelated patch could ship was friction without a corresponding safety win. CI logs and the operator's terminal still display the warning prominently. · *Vendor refresh: blamejs v0.12.10 → v0.12.11* — Standard shallow-clone refresh via `scripts/vendor-update.sh blamejs v0.12.11`. No code changes outside `lib/vendor/blamejs/`. **Fixed:** *Worker-syntax gate skips gracefully when `worker/index.js` is absent* — `scripts/check-worker-syntax.js` now checks for the existence of its entry file before scanning. When the file is missing (Cloudflare container build context, where `worker/` is excluded by `.dockerignore`), the gate logs `[worker-syntax] SKIPPED — <entry> not present in this build context (no worker tree to scan)` to stderr and exits 0. When the file IS present (host smoke runs, CI runners, npm-publish workflow, edge-render checks) the strict balance-walker scan runs unchanged and a missing trailing `}` still fails the build. Unblocks production deploys that have been failing since v0.0.102.
|
package/lib/api-keys.js
CHANGED
|
@@ -252,15 +252,13 @@ function _now() { return Date.now(); }
|
|
|
252
252
|
|
|
253
253
|
// ---- token generation + hashing -----------------------------------------
|
|
254
254
|
|
|
255
|
-
// 32 bytes -> 43 chars base64url (no padding).
|
|
256
|
-
//
|
|
257
|
-
//
|
|
255
|
+
// 32 bytes -> 43 chars base64url (no padding). Routes through the
|
|
256
|
+
// framework's `b.crypto.toBase64Url` so the linear-time Node
|
|
257
|
+
// built-in `base64url` encoding runs in place of a polynomial-ReDoS-
|
|
258
|
+
// shaped `.replace(/=+$/, "")` strip (CodeQL js/polynomial-redos).
|
|
258
259
|
function _generateToken() {
|
|
259
260
|
var buf = _b().crypto.generateBytes(TOKEN_BYTE_LEN);
|
|
260
|
-
return
|
|
261
|
-
.replace(/\+/g, "-")
|
|
262
|
-
.replace(/\//g, "_")
|
|
263
|
-
.replace(/=+$/, "");
|
|
261
|
+
return _b().crypto.toBase64Url(buf);
|
|
264
262
|
}
|
|
265
263
|
|
|
266
264
|
function _canonicalToken(input) {
|
package/lib/carrier-accounts.js
CHANGED
|
@@ -324,14 +324,12 @@ function _shipFromAddress(a) {
|
|
|
324
324
|
// ---- secret generation + hashing ---------------------------------------
|
|
325
325
|
|
|
326
326
|
// 32 bytes -> 43 chars base64url (no padding). Used for rotated api_key
|
|
327
|
-
// / api_secret.
|
|
328
|
-
//
|
|
327
|
+
// / api_secret. Routes through `b.crypto.toBase64Url` (linear-time
|
|
328
|
+
// Node built-in `base64url` encoding) instead of the polynomial-
|
|
329
|
+
// ReDoS-shaped `.replace(/=+$/, "")` strip.
|
|
329
330
|
function _generateSecret() {
|
|
330
331
|
var buf = _b().crypto.generateBytes(SECRET_BYTE_LEN);
|
|
331
|
-
return
|
|
332
|
-
.replace(/\+/g, "-")
|
|
333
|
-
.replace(/\//g, "_")
|
|
334
|
-
.replace(/=+$/, "");
|
|
332
|
+
return _b().crypto.toBase64Url(buf);
|
|
335
333
|
}
|
|
336
334
|
|
|
337
335
|
function _hashAccountNumber(plain) {
|
package/lib/customer-surveys.js
CHANGED
|
@@ -409,13 +409,12 @@ function _validateAnswers(answers, questions) {
|
|
|
409
409
|
|
|
410
410
|
// 32 random bytes -> 43-char base64url (no padding). Rendered
|
|
411
411
|
// manually so the primitive doesn't depend on a Buffer-side flag
|
|
412
|
-
// rename across Node minors.
|
|
412
|
+
// rename across Node minors. Routes through `b.crypto.toBase64Url`
|
|
413
|
+
// (linear-time built-in encoding) instead of the polynomial-ReDoS-
|
|
414
|
+
// shaped `.replace(/=+$/, "")` strip.
|
|
413
415
|
function _generateToken() {
|
|
414
416
|
var buf = _b().crypto.generateBytes(TOKEN_BYTE_LEN);
|
|
415
|
-
return
|
|
416
|
-
.replace(/\+/g, "-")
|
|
417
|
-
.replace(/\//g, "_")
|
|
418
|
-
.replace(/=+$/, "");
|
|
417
|
+
return _b().crypto.toBase64Url(buf);
|
|
419
418
|
}
|
|
420
419
|
|
|
421
420
|
function _canonicalToken(input) {
|
package/lib/stock-alerts.js
CHANGED
|
@@ -142,18 +142,9 @@ function _validateNow(n) {
|
|
|
142
142
|
return n;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
// Base64url with no padding — matches RFC 4648 §5. 24 bytes → 32 chars.
|
|
146
|
-
function _b64url(buf) {
|
|
147
|
-
return buf
|
|
148
|
-
.toString("base64")
|
|
149
|
-
.replace(/\+/g, "-")
|
|
150
|
-
.replace(/\//g, "_")
|
|
151
|
-
.replace(/=+$/, "");
|
|
152
|
-
}
|
|
153
|
-
|
|
154
145
|
function _mintToken() {
|
|
155
146
|
var raw = _b().crypto.generateBytes(TOKEN_BYTES);
|
|
156
|
-
return
|
|
147
|
+
return _b().crypto.toBase64Url(raw);
|
|
157
148
|
}
|
|
158
149
|
|
|
159
150
|
function _hashEmail(normalised) {
|
package/lib/stock-receipts.js
CHANGED
|
@@ -241,10 +241,7 @@ function _linesArray(arr) {
|
|
|
241
241
|
|
|
242
242
|
function _generateToken() {
|
|
243
243
|
var buf = _b().crypto.generateBytes(TOKEN_BYTE_LEN);
|
|
244
|
-
return
|
|
245
|
-
.replace(/\+/g, "-")
|
|
246
|
-
.replace(/\//g, "_")
|
|
247
|
-
.replace(/=+$/, "");
|
|
244
|
+
return _b().crypto.toBase64Url(buf);
|
|
248
245
|
}
|
|
249
246
|
|
|
250
247
|
function _hashToken(canonical) {
|
package/lib/storefront.js
CHANGED
|
@@ -1458,7 +1458,7 @@ var ACCOUNT_LOGIN_PAGE =
|
|
|
1458
1458
|
" <script>\n" +
|
|
1459
1459
|
" (function () {\n" +
|
|
1460
1460
|
" function _b64uToBuf(s){ s = s.replace(/-/g,'+').replace(/_/g,'/'); while(s.length%4)s+='='; var raw=atob(s); var arr=new Uint8Array(raw.length); for(var i=0;i<raw.length;i++)arr[i]=raw.charCodeAt(i); return arr.buffer; }\n" +
|
|
1461
|
-
" function _bufToB64u(buf){ var b=new Uint8Array(buf), s=''; for(var i=0;i<b.length;i++)s+=String.fromCharCode(b[i]); return btoa(s).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''); }\n" +
|
|
1461
|
+
" function _bufToB64u(buf){ var b=new Uint8Array(buf), s=''; for(var i=0;i<b.length;i++)s+=String.fromCharCode(b[i]); return btoa(s).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''); }\n" + // allow:inline-base64url-three-replace — browser-side helper; the page has no `b.crypto` to call, btoa is the runtime-built-in equivalent
|
|
1462
1462
|
" var form=document.getElementById('login-form');\n" +
|
|
1463
1463
|
" var msg=document.getElementById('login-message');\n" +
|
|
1464
1464
|
" form.addEventListener('submit', async function(ev){\n" +
|
|
@@ -1507,7 +1507,7 @@ var ACCOUNT_REGISTER_PAGE =
|
|
|
1507
1507
|
" <script>\n" +
|
|
1508
1508
|
" (function () {\n" +
|
|
1509
1509
|
" function _b64uToBuf(s){ s = s.replace(/-/g,'+').replace(/_/g,'/'); while(s.length%4)s+='='; var raw=atob(s); var arr=new Uint8Array(raw.length); for(var i=0;i<raw.length;i++)arr[i]=raw.charCodeAt(i); return arr.buffer; }\n" +
|
|
1510
|
-
" function _bufToB64u(buf){ var b=new Uint8Array(buf), s=''; for(var i=0;i<b.length;i++)s+=String.fromCharCode(b[i]); return btoa(s).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''); }\n" +
|
|
1510
|
+
" function _bufToB64u(buf){ var b=new Uint8Array(buf), s=''; for(var i=0;i<b.length;i++)s+=String.fromCharCode(b[i]); return btoa(s).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''); }\n" + // allow:inline-base64url-three-replace — browser-side helper; the page has no `b.crypto` to call, btoa is the runtime-built-in equivalent
|
|
1511
1511
|
" var form=document.getElementById('reg-form');\n" +
|
|
1512
1512
|
" var msg=document.getElementById('reg-message');\n" +
|
|
1513
1513
|
" form.addEventListener('submit', async function(ev){\n" +
|
|
@@ -2015,9 +2015,7 @@ function mount(router, deps) {
|
|
|
2015
2015
|
var expectedOrigin = deps.shop_origin || ("https://" + rpId);
|
|
2016
2016
|
|
|
2017
2017
|
function _b64u(buf) {
|
|
2018
|
-
|
|
2019
|
-
var b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
|
|
2020
|
-
return b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2018
|
+
return _b().crypto.toBase64Url(buf);
|
|
2021
2019
|
}
|
|
2022
2020
|
|
|
2023
2021
|
function _sealEnvelope(obj) {
|
|
@@ -229,12 +229,7 @@ function _hashEmail(canonical) {
|
|
|
229
229
|
|
|
230
230
|
function _generateToken() {
|
|
231
231
|
var buf = _b().crypto.generateBytes(TOKEN_BYTE_LEN);
|
|
232
|
-
|
|
233
|
-
// depend on a Buffer-side flag rename across Node minors.
|
|
234
|
-
return buf.toString("base64")
|
|
235
|
-
.replace(/\+/g, "-")
|
|
236
|
-
.replace(/\//g, "_")
|
|
237
|
-
.replace(/=+$/, "");
|
|
232
|
+
return _b().crypto.toBase64Url(buf);
|
|
238
233
|
}
|
|
239
234
|
|
|
240
235
|
function _canonicalToken(input) {
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.12.
|
|
7
|
-
"tag": "v0.12.
|
|
6
|
+
"version": "0.12.12",
|
|
7
|
+
"tag": "v0.12.12",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"server": "lib/vendor/blamejs/"
|
|
14
14
|
},
|
|
15
15
|
"bundler": "shallow git clone of release tag from github.com/blamejs/blamejs",
|
|
16
|
-
"bundledAt": "2026-05-
|
|
16
|
+
"bundledAt": "2026-05-24"
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.12.x
|
|
10
10
|
|
|
11
|
+
- v0.12.12 (2026-05-23) — **`b.ai.disclosure.chatbot` + `b.ai.disclosure.deepfake` + `b.ai.disclosure.emotion` — EU AI Act Art. 50 transparency obligations (calendar-locked 2026-08-02) with US-CA AB-853 + China CAC GenAI cross-walk.** EU AI Act Art. 50 transparency primitives land ahead of the 2026-08-02 enforcement deadline. `b.ai.disclosure.chatbot(session, opts)` emits the Art. 50(1) first-contact "you are interacting with an AI system" disclosure with placement control (`first-message` / `always` / `on-request`). `b.ai.disclosure.deepfake(content, { contentType, placement, jurisdiction })` emits the Art. 50(4) synthetic-content label + machine-readable metadata payload for image / audio / video / text. `b.ai.disclosure.emotion({ systemType })` emits the Art. 50(3) emotion-recognition / biometric-categorisation notice. Each primitive emits a tamper-evident `ai-act/*-disclosure-applied` audit event so the compliance trail backs the user-facing notice. Cross-jurisdiction cross-walk lives in `opts.jurisdiction`: `"eu"` (default), `"us-ca"` adds AB-853 §22949.91 to the cross-walk array, `"cn"` adds CAC GenAI Measures Art. 12. The deepfake primitive returns a `schema: "c2pa-v1.4-ready"` metadata field that the v0.12.21 `b.contentCredentials` C2PA adapter will consume when it lands — this patch ships the label markup + schema; the C2PA manifest emission is the next composition. **Added:** *`b.ai.disclosure.chatbot(session, opts)` — Art. 50(1) first-contact disclosure* — Operators interacting with natural persons via an AI system get a primitive that emits the "you are interacting with an AI system" notice + audits the emission. `placement` opts: `"first-message"` (default — emit on first contact only, tracked via `session.aiDisclosureEmitted`), `"always"` (every response), `"on-request"` (operator wires their own trigger). Returns `{ text, language, jurisdiction, placement, shouldEmit, article, regulation }` — `shouldEmit` is the operator-consumable boolean for response-wire-up logic. · *`b.ai.disclosure.deepfake(content, opts)` — Art. 50(4) synthetic-content label* — Operators emitting model-generated or model-manipulated content get a primitive that returns both the visible label markup AND the machine-readable metadata payload. `contentType: "image" | "audio" | "video" | "text"` is required; `placement: "label" | "metadata" | "both"` (default `"both"`) controls what the primitive populates. The metadata payload includes `schema: "c2pa-v1.4-ready"` — the v0.12.21 `b.contentCredentials` C2PA adapter will consume this schema field when it lands. `crossWalk` array carries `["eu-ai-act/Art. 50(4)"]` plus the per-jurisdiction reference (AB-853 §22949.91 / CAC GenAI Art. 12). · *`b.ai.disclosure.emotion(opts)` — Art. 50(3) emotion-recognition / biometric-categorisation notice* — Operators deploying emotion-recognition or biometric-categorisation systems get the consent-flow notice primitive. `systemType: "emotion" | "biometric-categorisation"` (default `"emotion"`) selects which Art. 50(3) sub-obligation applies. Returns the notice payload + emits an `ai-act/emotion-disclosure-applied` audit event. · *Cross-jurisdiction cross-walk: EU + US-CA + China in a single primitive* — The `opts.jurisdiction` opt accepts `"eu"` (default — Regulation (EU) 2024/1689), `"us-ca"` (California AB-853 effective 2026), or `"cn"` (China CAC GenAI Measures). The chatbot + deepfake primitives both honour the cross-walk: the deepfake response's `crossWalk` array carries every jurisdiction-specific legal reference the same emission satisfies, so operators serving multi-region traffic emit one notice + audit one event + reference all applicable regimes. **Security:** *Drop-silent audit emission preserves the disclosure path under audit-bus failure* — If `opts.audit` is supplied but its `safeEmit` throws (network bus down, audit-sign chain malformed), the disclosure primitive still returns the user-facing notice payload. The Art. 50 obligation is the user-facing notice itself; the audit emission is a parallel best-effort chain-of-custody record. Refusing the disclosure to defend the audit chain would fail the wrong direction — the regulatory contract is satisfied by emitting the notice. Matches the framework's `audit.safeEmit` drop-silent contract for hot-path observability sinks. **Migration:** *C2PA manifest emission lands in v0.12.21* — The deepfake primitive's metadata payload includes a `schema: "c2pa-v1.4-ready"` field that the v0.12.21 `b.contentCredentials` adapter will consume. Operators emitting image / audio / video for v0.12.12-0.12.20 get the label markup + structured metadata; the actual C2PA manifest (signed JUMBF assertion chain) is the next composition layer.
|
|
12
|
+
|
|
11
13
|
- v0.12.11 (2026-05-23) — **`b.archive.wrapWithPassphrase` + `b.archive.unwrapWithPassphrase` — Argon2id + XChaCha20-Poly1305 archive envelope + `b.backup` `cryptoStrategy: "passphrase"` with HIPAA / PCI-DSS 128-bit entropy floor.** Passphrase wrap lands as the second `b.archive` envelope strategy alongside v0.12.10's recipient wrap. `b.archive.wrapWithPassphrase(bytes, { passphrase, minEntropyBits })` produces a `BAWPP`-prefixed envelope under Argon2id (RFC 9106; framework-default 64 MiB / 3 iterations / 4 parallelism) key derivation with XChaCha20-Poly1305 AEAD; each envelope carries its own fresh salt in the wire format (5-byte magic + 1-byte version + 1-byte saltLen + salt + 24-byte nonce + ciphertext+tag) so KDF parameters can rotate in future minors without per-envelope version bumps. `b.archive.unwrapWithPassphrase(sealed, { passphrase })` verifies the `BAWPP` header before any Argon2id compute so non-envelope inputs fail with `archive-wrap/bad-magic` rather than burning the KDF on bad bytes. `b.backup.bundleAdapterStorage({ cryptoStrategy: "passphrase", passphrase })` composes the wrap layer transparently — bundle bytes hitting the adapter's `writeFile` are an opaque passphrase-derived envelope. Default `passphraseMinEntropyBits: 80` matches OWASP strong-password guidance; HIPAA + PCI-DSS postures raise the floor to 128 bits automatically (matching the framework's existing crypto-grade-password discipline for sealed-storage). The recipient strategy from v0.12.10 + passphrase strategy from v0.12.11 + plaintext strategy from v0.12.7 cover the operator's posture matrix: HIPAA / PCI-DSS pick recipient or passphrase; non-regulated deployments may stay on `"none"` when the storage layer is itself the protective boundary. **Added:** *`b.archive.wrapWithPassphrase(bytes, { passphrase, minEntropyBits })` — Argon2id-derived archive envelope* — Composes `b.backupCrypto.encryptWithFreshSalt(bytes, passphrase)` (Argon2id KDF + XChaCha20-Poly1305 AEAD, fresh per-envelope salt) and prepends a 7-byte `BAWPP` envelope header (5-byte magic + 1-byte version + 1-byte saltLen) so format sniffers can identify passphrase wrap output without trial KDF work. Entropy estimate uses observed-alphabet bit-count (the standard NIST/OWASP character-class approximation). `minEntropyBits` defaults to 80; the gate refuses upfront with `archive-wrap/weak-passphrase` when the estimate falls short. · *`b.archive.unwrapWithPassphrase(sealed, { passphrase })` — inverse with magic-check upfront* — Verifies the 7-byte `BAWPP` header (magic + version + saltLen) before any cryptographic work so non-envelope inputs (raw archives, recipient-wrap envelopes, truncated buffers) fail with `archive-wrap/bad-magic` / `archive-wrap/bad-version` / `archive-wrap/truncated-envelope` rather than wasting Argon2id compute. Routes through `b.backupCrypto.decryptWithPassphrase(encrypted, passphrase, saltHex)` so the framework's locked Argon2id parameters apply. · *`b.backup.bundleAdapterStorage({ cryptoStrategy: "passphrase", passphrase })` — Argon2id-keyed bundle storage* — Composes `b.archive.wrapWithPassphrase` transparently — every `writeBundle` payload is wrapped before `adapter.writeFile`; every `readBundle` payload is unwrapped after `adapter.readFile`. The `passphraseMinEntropyBits` opt defaults to 80 (OWASP strong-password floor); HIPAA + PCI-DSS postures raise the floor to 128 bits automatically. Passphrase + directory format combination refused upfront (same contract as recipient + directory). Wire-format envelope on disk is opaque ciphertext — no information leakage about archive contents through the storage adapter. · *HIPAA + PCI-DSS postures raise entropy floor to 128 bits under passphrase strategy* — `bundleAdapterStorage({ posture: "hipaa", cryptoStrategy: "passphrase", passphrase })` enforces `passphraseMinEntropyBits >= 128` regardless of the operator-supplied opt. The 128-bit floor matches the framework's existing crypto-grade-password discipline for sealed-storage cells. Operators sourcing passphrases from a CSPRNG (`b.crypto.generateBytes(16).toString("base64url")` → ~128 bits) pass without issue; operators typing dictionary phrases trip the gate. **Security:** *Magic-check before KDF work — non-envelope inputs can't burn Argon2id compute* — Adversarial inputs that look like passphrase envelopes but aren't (random bytes, recipient envelopes, raw archives) fail at byte 0-4 (magic check) rather than after a 64 MiB Argon2id round. Operators handing user-supplied bundles to readBundle on a server with concurrent load get bounded refusal latency rather than worst-case KDF compute under a chosen-bytes attack.
|
|
12
14
|
|
|
13
15
|
- v0.12.10 (2026-05-23) — **`b.archive.wrap` + `b.archive.unwrap` — recipient-encrypted archive envelopes (Flavor 1) + `b.backup` `cryptoStrategy: "recipient"` + HIPAA/PCI-DSS posture refusal.** Flavor 1 lands as the whole-archive recipient-wrap substrate. `b.archive.wrap(bytes, { recipient })` produces a sealed envelope under the framework's hybrid PQC seal (ML-KEM-1024 + P-384 ECDH + SHAKE256 + XChaCha20-Poly1305) prefixed with a 6-byte `BAWRP` archive-wrap header so format sniffers can identify wrap envelopes without trial decryption. `b.archive.unwrap(sealed, { recipient })` is the inverse with magic-check upfront so non-envelope inputs throw `archive-wrap/bad-magic` rather than a crypto-level error. Recipient strategies: static keypair (`{ publicKey, ecPublicKey }`) and peer-cert (`{ peerCertDer, peerKemPubkey }`); the tenant strategy lands in v0.12.11 alongside the backup-crypto refactor + per-tenant key resolution. `b.backup.bundleAdapterStorage({ cryptoStrategy: "recipient", recipient })` composes the wrap/unwrap layer transparently: the bytes hitting the adapter's `writeFile` are a `BAWRP`-prefixed envelope, never the raw tar / tar.gz / directory bundle. HIPAA + PCI-DSS postures refuse `cryptoStrategy: "none"` upfront with `backup/posture-requires-encryption` — the storage adapter cannot itself satisfy the encryption-at-rest requirement; the recipient envelope is the framework-side gate. Flavor 2 (per-entry ZIP wrap with the 0xBADC extra-field marker) and the backup-crypto refactor into `lib/_crypto-base.js` ship in v0.12.11. **Added:** *`b.archive.wrap(bytes, { recipient })` — recipient-encrypted archive envelope* — Composes `b.crypto.encrypt` (or `b.crypto.encryptEnvelopeAsCertPeer` for the peer-cert strategy) under the framework's hybrid PQC seal. The output is a Buffer carrying a 6-byte `BAWRP` archive-wrap header (5-byte magic + 1-byte version) followed by the base64-encoded envelope bytes. Recipient strategies: `{ publicKey, ecPublicKey }` for the static-keypair path (ML-KEM-1024 PEM + P-384 ECDH PEM); `{ peerCertDer, peerKemPubkey }` for the peer-cert path (extracts the P-384 half from the cert per `b.crypto.encryptEnvelopeAsCertPeer`). `"tenant"` returns `archive-wrap/tenant-strategy-deferred` upfront — that strategy lands in v0.12.11 with the per-tenant key resolution. · *`b.archive.unwrap(sealed, { recipient })` — inverse with upfront magic check* — Verifies the 6-byte `BAWRP` header before any cryptographic work so non-envelope inputs (raw archives, other-magic envelopes, truncated buffers) fail with `archive-wrap/bad-magic` / `archive-wrap/bad-version` rather than a downstream `crypto/*` error. Routes through `b.crypto.decrypt(envelope, recipient, { raw: true })` so binary archive payloads (gzip, ZIP, tar) round-trip losslessly — `raw: true` is the contract that preserves bytes vs the default utf-8 decoding. · *`b.backup.bundleAdapterStorage({ cryptoStrategy: "recipient", recipient })` — opt-in envelope storage* — `cryptoStrategy: "none"` (default, v0.12.7-9 behaviour) writes plaintext bundle bytes to the adapter — safe for storage layers that are themselves the protective boundary (S3 SSE, disk-encrypted hosts). `cryptoStrategy: "recipient"` requires `opts.recipient` and wraps every `writeBundle` payload through `b.archive.wrap` before `adapter.writeFile`; `readBundle` unwraps after `adapter.readFile`. The wrap layer sits OUTSIDE the gz / tar layers so the bundle on disk is opaque ciphertext under the operator-controlled recipient key. Passphrase strategy is deferred to v0.12.11 alongside the `_crypto-base.js` refactor. · *HIPAA + PCI-DSS posture refuses plaintext bundles* — `bundleAdapterStorage({ posture: "hipaa" })` (or `"pci-dss"`) refuses `cryptoStrategy: "none"` upfront with `backup/posture-requires-encryption` — adapter-storage's plaintext default cannot itself satisfy encryption-at-rest requirements. Operators under these postures pass `cryptoStrategy: "recipient"` + a recipient key. The refusal message includes the posture name + the strategy that fails so audit-trail operators see exactly which gate blocked the call. **Security:** *Wrap envelope is the framework's hybrid PQC seal — ML-KEM-1024 + P-384 ECDH + SHAKE256 + XChaCha20-Poly1305* — Defence-in-depth posture: a CRQC against ML-KEM-1024 alone still has to defeat the classical P-384 ECDH leg; a future ECDH break alone still has to defeat ML-KEM-1024. The 4-byte envelope header (magic + KEM ID + cipher ID + KDF ID) is bound as AEAD AAD so a header-substitution attack fails Poly1305 verification. `b.archive.wrap` prepends a separate 6-byte archive-wrap header BEFORE the base64 envelope so format sniffing can identify wrap output without trial decryption — non-envelope inputs are refused at byte 0-4 (magic check) instead of after fruitless decapsulation work. **Detectors:** *`backup-adapter-storage-without-posture-check` — postures that mandate encryption must propagate to `cryptoStrategy`* — When a primitive that wires `b.backup.bundleAdapterStorage` carries a `posture:` opt drawn from the HIPAA / PCI-DSS / etc. set, the same code path must propagate `cryptoStrategy: "recipient"` (or refuse before reaching writeBundle). The detector matches `bundleAdapterStorage({ ... posture: ... })` invocations in `lib/` and requires a matching `cryptoStrategy` opt; missing it surfaces during the codebase-patterns gate so a future caller can't silently drop the contract. **Migration:** *Flavor 2 — per-entry ZIP recipient wrap with 0xBADC extra-field* — Per-entry encryption inside the carrier ZIP (method=STORE with the encrypted bytes as the stored payload + a 0xBADC user-defined-range extra-field marker carrying the recipient hint). Inspect-without-decrypt is the operator value: entry list + name-safety gating happens BEFORE any key resolution. Lands in v0.12.11 alongside the backup-crypto refactor. · *`lib/_crypto-base.js` refactor — backup-crypto, Flavor 1, Flavor 2 share substrate* — The legacy per-file Argon2id + XChaCha20-Poly1305 path in `lib/backup/crypto.js` gets factored into a private `_crypto-base.js` helper so all three encryption flavors compose the same primitive set. No operator-visible API change; closes the each-feature-rolls-its-own-crypto smell. · *`cryptoStrategy: "passphrase"` + tenant strategy* — Passphrase strategy on `bundleAdapterStorage` (Argon2id-derived key + XChaCha20-Poly1305) and the `"tenant"` recipient string (composes `b.vault.derivedKey({ tenant, purpose: "archive-wrap" })`) both ship in v0.12.11. The v0.12.10 surface is the recipient substrate; v0.12.11 lights up the per-tenant + passphrase strategies that consume it.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"frameworkVersion": "0.12.
|
|
4
|
-
"createdAt": "2026-05-
|
|
3
|
+
"frameworkVersion": "0.12.12",
|
|
4
|
+
"createdAt": "2026-05-23T21:15:03.328Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -1498,6 +1498,35 @@
|
|
|
1498
1498
|
}
|
|
1499
1499
|
}
|
|
1500
1500
|
},
|
|
1501
|
+
"disclosure": {
|
|
1502
|
+
"type": "object",
|
|
1503
|
+
"members": {
|
|
1504
|
+
"AiDisclosureError": {
|
|
1505
|
+
"type": "function",
|
|
1506
|
+
"arity": 4
|
|
1507
|
+
},
|
|
1508
|
+
"DEEPFAKE_CONTENT_TYPES": {
|
|
1509
|
+
"type": "instance",
|
|
1510
|
+
"ctorName": "Array"
|
|
1511
|
+
},
|
|
1512
|
+
"SUPPORTED_JURISDICTIONS": {
|
|
1513
|
+
"type": "instance",
|
|
1514
|
+
"ctorName": "Array"
|
|
1515
|
+
},
|
|
1516
|
+
"chatbot": {
|
|
1517
|
+
"type": "function",
|
|
1518
|
+
"arity": 2
|
|
1519
|
+
},
|
|
1520
|
+
"deepfake": {
|
|
1521
|
+
"type": "function",
|
|
1522
|
+
"arity": 2
|
|
1523
|
+
},
|
|
1524
|
+
"emotion": {
|
|
1525
|
+
"type": "function",
|
|
1526
|
+
"arity": 1
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
},
|
|
1501
1530
|
"input": {
|
|
1502
1531
|
"type": "object",
|
|
1503
1532
|
"members": {
|
|
@@ -442,6 +442,7 @@ module.exports = {
|
|
|
442
442
|
input: aiInput,
|
|
443
443
|
aiContentDetect: require("./lib/ai-content-detect"),
|
|
444
444
|
modelManifest: require("./lib/ai-model-manifest"),
|
|
445
|
+
disclosure: require("./lib/ai-disclosure"),
|
|
445
446
|
},
|
|
446
447
|
promisePool: require("./lib/promise-pool"),
|
|
447
448
|
sdNotify: require("./lib/sd-notify"),
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.ai.disclosure
|
|
4
|
+
* @nav Compliance
|
|
5
|
+
* @title AI Act Art. 50 disclosures
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* EU AI Act Regulation (EU) 2024/1689 Article 50 transparency
|
|
9
|
+
* obligations enter force 2026-08-02. This module ships the active
|
|
10
|
+
* runtime primitives that emit disclosure markup at request time:
|
|
11
|
+
*
|
|
12
|
+
* - `b.ai.disclosure.chatbot(session, opts)` — Art. 50(1).
|
|
13
|
+
* Operators interacting with natural persons must disclose the
|
|
14
|
+
* AI nature of the interaction. Returns the disclosure payload
|
|
15
|
+
* (visible text / structured metadata) to wire into the response.
|
|
16
|
+
*
|
|
17
|
+
* - `b.ai.disclosure.deepfake(content, opts)` — Art. 50(4).
|
|
18
|
+
* Operators emitting AI-generated / AI-manipulated content
|
|
19
|
+
* (image / audio / video / text) must label the output as
|
|
20
|
+
* synthetic. Returns the disclosure payload + suggested
|
|
21
|
+
* embedding points (visible label / C2PA metadata / both).
|
|
22
|
+
*
|
|
23
|
+
* - `b.ai.disclosure.emotion(opts)` — Art. 50(3). Emotion-
|
|
24
|
+
* recognition / biometric-categorisation systems must inform
|
|
25
|
+
* the natural person of operation. Returns the notice payload.
|
|
26
|
+
*
|
|
27
|
+
* Cross-jurisdiction:
|
|
28
|
+
* - California AB-853 (effective 2026) — watermarking on
|
|
29
|
+
* AI-generated content. The deepfake primitive emits both
|
|
30
|
+
* AI Act Art. 50(4) AND AB-853 markup when `jurisdiction:
|
|
31
|
+
* "us-ca"` is requested.
|
|
32
|
+
* - China CAC GenAI Measures — content review marker. Same
|
|
33
|
+
* primitive handles the cross-walk via the `jurisdiction:
|
|
34
|
+
* "cn"` opt.
|
|
35
|
+
*
|
|
36
|
+
* Composition:
|
|
37
|
+
* - `b.audit-sign` chains every disclosure emission so the
|
|
38
|
+
* Art. 50 compliance trail is tamper-evident.
|
|
39
|
+
* - `b.agent.idempotency` (v0.9.22) ensures the chatbot
|
|
40
|
+
* first-contact disclosure isn't double-emitted across
|
|
41
|
+
* retry / reconnect.
|
|
42
|
+
* - `b.contentCredentials` (v0.12.21 — deferred) will wire
|
|
43
|
+
* C2PA manifest emission alongside the visible label.
|
|
44
|
+
*
|
|
45
|
+
* Out of scope (this patch):
|
|
46
|
+
* - C2PA manifest emission — defers to v0.12.21 b.contentCredentials.
|
|
47
|
+
* - Watermark frame embedding into image/audio/video bytes —
|
|
48
|
+
* operator's encoder pipeline (this primitive supplies the
|
|
49
|
+
* label markup; the operator chooses the embed point).
|
|
50
|
+
* - Real-time prohibited-content moderation — orthogonal,
|
|
51
|
+
* composes with b.ai.input.refuseIfMalicious.
|
|
52
|
+
*
|
|
53
|
+
* @card
|
|
54
|
+
* EU AI Act Art. 50 transparency obligation primitives — chatbot
|
|
55
|
+
* disclosure, deepfake / synthetic-content labels, emotion-
|
|
56
|
+
* recognition notices. Calendar-locked 2026-08-02.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
var { defineClass } = require("./framework-error");
|
|
60
|
+
|
|
61
|
+
var AiDisclosureError = defineClass("AiDisclosureError", { alwaysPermanent: true });
|
|
62
|
+
|
|
63
|
+
// Audit emissions route through opts.audit (operator-supplied
|
|
64
|
+
// instance) — see _emitAudit below. No framework-side audit
|
|
65
|
+
// require needed; the primitive is a pure value-returning function
|
|
66
|
+
// with the optional safeEmit-via-opts side-effect.
|
|
67
|
+
|
|
68
|
+
var DEFAULT_CHATBOT_TEXT = "You are interacting with an AI system.";
|
|
69
|
+
var DEFAULT_DEEPFAKE_TEXT = "This content has been generated or manipulated using artificial intelligence.";
|
|
70
|
+
var DEFAULT_EMOTION_TEXT = "This system uses AI to recognise emotions or biometrically categorise individuals.";
|
|
71
|
+
|
|
72
|
+
// Recognised jurisdiction codes. EU is implicit (the AI Act applies
|
|
73
|
+
// throughout the Union); US-CA layers in AB-853; CN layers in the
|
|
74
|
+
// CAC GenAI Measures.
|
|
75
|
+
var SUPPORTED_JURISDICTIONS = ["eu", "us-ca", "cn"];
|
|
76
|
+
|
|
77
|
+
// Content types eligible for a deepfake notice per Art. 50(4):
|
|
78
|
+
// image / audio / video / text. Each carries different recommended
|
|
79
|
+
// placement defaults (image+video → both label & metadata; audio →
|
|
80
|
+
// audible preamble or metadata; text → visible disclaimer).
|
|
81
|
+
var DEEPFAKE_CONTENT_TYPES = ["image", "audio", "video", "text"];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @primitive b.ai.disclosure.chatbot
|
|
85
|
+
* @signature b.ai.disclosure.chatbot(session, opts)
|
|
86
|
+
* @since 0.12.12
|
|
87
|
+
* @status stable
|
|
88
|
+
* @compliance eu-ai-act, ca-ab-853, cac-genai-label
|
|
89
|
+
* @related b.ai.disclosure.deepfake, b.ai.disclosure.emotion, b.audit
|
|
90
|
+
*
|
|
91
|
+
* EU AI Act Art. 50(1) first-contact disclosure. Operators
|
|
92
|
+
* interacting with natural persons via an AI system must inform
|
|
93
|
+
* the person they are interacting with AI unless it is obvious from
|
|
94
|
+
* the circumstances (Art. 50(1) carve-out). This primitive returns
|
|
95
|
+
* the disclosure payload + emits an audit event per emission so
|
|
96
|
+
* the compliance trail is tamper-evident under `b.audit-sign`.
|
|
97
|
+
*
|
|
98
|
+
* @opts
|
|
99
|
+
* placement: "first-message" | "always" | "on-request", // default "first-message"
|
|
100
|
+
* language: string, // BCP 47 tag; defaults to en
|
|
101
|
+
* text: string, // override the default disclosure text
|
|
102
|
+
* jurisdiction: string, // "eu" (default) | "us-ca" | "cn"
|
|
103
|
+
* audit: object, // b.audit instance for tamper-evident logging
|
|
104
|
+
* correlationId: string, // audit chain correlation
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* var disclosure = b.ai.disclosure.chatbot({ id: "session-42" }, {
|
|
108
|
+
* placement: "first-message",
|
|
109
|
+
* language: "en",
|
|
110
|
+
* });
|
|
111
|
+
* // disclosure.text → "You are interacting with an AI system."
|
|
112
|
+
* // disclosure.shouldEmit → true (first contact)
|
|
113
|
+
* // operator wires disclosure.text into the response payload
|
|
114
|
+
*/
|
|
115
|
+
function chatbot(session, opts) {
|
|
116
|
+
opts = opts || {};
|
|
117
|
+
if (!session || typeof session !== "object") {
|
|
118
|
+
throw new AiDisclosureError("ai-disclosure/bad-session",
|
|
119
|
+
"chatbot: session must be an object carrying at minimum an id");
|
|
120
|
+
}
|
|
121
|
+
if (typeof session.id !== "string" || session.id.length === 0) {
|
|
122
|
+
throw new AiDisclosureError("ai-disclosure/bad-session",
|
|
123
|
+
"chatbot: session.id must be a non-empty string");
|
|
124
|
+
}
|
|
125
|
+
var placement = opts.placement || "first-message";
|
|
126
|
+
if (placement !== "first-message" && placement !== "always" && placement !== "on-request") {
|
|
127
|
+
throw new AiDisclosureError("ai-disclosure/bad-arg",
|
|
128
|
+
"chatbot: opts.placement must be \"first-message\" (default) | \"always\" | \"on-request\"; got " +
|
|
129
|
+
JSON.stringify(placement));
|
|
130
|
+
}
|
|
131
|
+
var jurisdiction = opts.jurisdiction || "eu";
|
|
132
|
+
_validateJurisdiction(jurisdiction, "chatbot");
|
|
133
|
+
var text = typeof opts.text === "string" && opts.text.length > 0
|
|
134
|
+
? opts.text
|
|
135
|
+
: DEFAULT_CHATBOT_TEXT;
|
|
136
|
+
// Codex P1A on v0.12.12 PR #163 — "on-request" placement gates on
|
|
137
|
+
// the operator's explicit `opts.requested: true` signal. Without
|
|
138
|
+
// it, "on-request" collapsed into "always" semantics and emitted
|
|
139
|
+
// every call. The operator wires this from an explicit user
|
|
140
|
+
// gesture ("show me what AI features are in use") or an admin
|
|
141
|
+
// toggle. Default false so the gate stays closed when the opt
|
|
142
|
+
// isn't passed.
|
|
143
|
+
var requested = opts.requested === true;
|
|
144
|
+
var firstSeen = !session.aiDisclosureEmitted;
|
|
145
|
+
var shouldEmit = placement === "always" ||
|
|
146
|
+
(placement === "first-message" && firstSeen) ||
|
|
147
|
+
(placement === "on-request" && requested);
|
|
148
|
+
var emission = {
|
|
149
|
+
text: text,
|
|
150
|
+
language: opts.language || "en",
|
|
151
|
+
jurisdiction: jurisdiction,
|
|
152
|
+
placement: placement,
|
|
153
|
+
shouldEmit: shouldEmit,
|
|
154
|
+
article: "Art. 50(1)",
|
|
155
|
+
regulation: "Regulation (EU) 2024/1689",
|
|
156
|
+
};
|
|
157
|
+
if (shouldEmit) {
|
|
158
|
+
// Codex P1B on v0.12.12 PR #163 — mark the session so subsequent
|
|
159
|
+
// calls with the same session under "first-message" placement
|
|
160
|
+
// see `aiDisclosureEmitted: true` and return shouldEmit=false.
|
|
161
|
+
// Without this mutation operators had to remember to flip the
|
|
162
|
+
// flag themselves; the default would re-emit on every call.
|
|
163
|
+
if (placement === "first-message") {
|
|
164
|
+
session.aiDisclosureEmitted = true;
|
|
165
|
+
}
|
|
166
|
+
_emitAudit(opts, "ai-act/chatbot-disclosure-applied", "success", {
|
|
167
|
+
sessionId: session.id,
|
|
168
|
+
placement: placement,
|
|
169
|
+
jurisdiction: jurisdiction,
|
|
170
|
+
correlationId: opts.correlationId || null,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return emission;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @primitive b.ai.disclosure.deepfake
|
|
178
|
+
* @signature b.ai.disclosure.deepfake(content, opts)
|
|
179
|
+
* @since 0.12.12
|
|
180
|
+
* @status stable
|
|
181
|
+
* @compliance eu-ai-act, ca-ab-853, cac-genai-label
|
|
182
|
+
* @related b.ai.disclosure.chatbot, b.contentCredentials
|
|
183
|
+
*
|
|
184
|
+
* EU AI Act Art. 50(4) synthetic-content disclosure. Operators
|
|
185
|
+
* emitting AI-generated or AI-manipulated content (image / audio /
|
|
186
|
+
* video / text) must label the output as synthetic in a clear and
|
|
187
|
+
* machine-readable manner. This primitive returns the disclosure
|
|
188
|
+
* payload (visible label + structured metadata) the operator wires
|
|
189
|
+
* into the encoder / response pipeline. C2PA manifest emission is
|
|
190
|
+
* deferred to v0.12.21 `b.contentCredentials` — this primitive
|
|
191
|
+
* supplies the label markup and the metadata schema that the C2PA
|
|
192
|
+
* adapter consumes when it lands.
|
|
193
|
+
*
|
|
194
|
+
* @opts
|
|
195
|
+
* contentType: "image" | "audio" | "video" | "text", // required
|
|
196
|
+
* placement: "label" | "metadata" | "both", // default "both"
|
|
197
|
+
* jurisdiction: string, // "eu" (default) | "us-ca" | "cn"
|
|
198
|
+
* language: string, // BCP 47 tag; defaults to en
|
|
199
|
+
* text: string, // override the default disclosure text
|
|
200
|
+
* audit: object,
|
|
201
|
+
* correlationId: string,
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* var disclosure = b.ai.disclosure.deepfake(imageBytes, {
|
|
205
|
+
* contentType: "image",
|
|
206
|
+
* placement: "both",
|
|
207
|
+
* jurisdiction: "us-ca",
|
|
208
|
+
* });
|
|
209
|
+
* // disclosure.label → "This content has been generated ..."
|
|
210
|
+
* // disclosure.metadata → { ai_generated: true, schema: "c2pa-v1.4-ready" }
|
|
211
|
+
* // disclosure.crossWalk → ["eu-ai-act/Art. 50(4)", "us-ca/AB-853 §22949.91"]
|
|
212
|
+
*/
|
|
213
|
+
function deepfake(content, opts) {
|
|
214
|
+
opts = opts || {};
|
|
215
|
+
if (content === undefined || content === null) {
|
|
216
|
+
throw new AiDisclosureError("ai-disclosure/bad-content",
|
|
217
|
+
"deepfake: content is required (Buffer | string | { type, bytes })");
|
|
218
|
+
}
|
|
219
|
+
if (typeof opts.contentType !== "string" ||
|
|
220
|
+
DEEPFAKE_CONTENT_TYPES.indexOf(opts.contentType) === -1) {
|
|
221
|
+
throw new AiDisclosureError("ai-disclosure/bad-arg",
|
|
222
|
+
"deepfake: opts.contentType must be one of " +
|
|
223
|
+
DEEPFAKE_CONTENT_TYPES.join(" | ") + "; got " + JSON.stringify(opts.contentType));
|
|
224
|
+
}
|
|
225
|
+
var placement = opts.placement || "both";
|
|
226
|
+
if (placement !== "label" && placement !== "metadata" && placement !== "both") {
|
|
227
|
+
throw new AiDisclosureError("ai-disclosure/bad-arg",
|
|
228
|
+
"deepfake: opts.placement must be \"label\" | \"metadata\" | \"both\" (default); got " +
|
|
229
|
+
JSON.stringify(placement));
|
|
230
|
+
}
|
|
231
|
+
var jurisdiction = opts.jurisdiction || "eu";
|
|
232
|
+
_validateJurisdiction(jurisdiction, "deepfake");
|
|
233
|
+
var text = typeof opts.text === "string" && opts.text.length > 0
|
|
234
|
+
? opts.text
|
|
235
|
+
: DEFAULT_DEEPFAKE_TEXT;
|
|
236
|
+
var crossWalk = ["eu-ai-act/Art. 50(4)"];
|
|
237
|
+
if (jurisdiction === "us-ca") crossWalk.push("us-ca/AB-853 §22949.91");
|
|
238
|
+
if (jurisdiction === "cn") crossWalk.push("cn/CAC-GenAI Measures Art. 12");
|
|
239
|
+
var emission = {
|
|
240
|
+
label: placement === "metadata" ? null : text,
|
|
241
|
+
metadata: placement === "label" ? null : {
|
|
242
|
+
ai_generated: true,
|
|
243
|
+
content_type: opts.contentType,
|
|
244
|
+
schema: "c2pa-v1.4-ready", // v0.12.21 b.contentCredentials lights this up
|
|
245
|
+
jurisdiction: jurisdiction,
|
|
246
|
+
regulation: "Regulation (EU) 2024/1689",
|
|
247
|
+
article: "Art. 50(4)",
|
|
248
|
+
},
|
|
249
|
+
language: opts.language || "en",
|
|
250
|
+
contentType: opts.contentType,
|
|
251
|
+
placement: placement,
|
|
252
|
+
crossWalk: crossWalk,
|
|
253
|
+
};
|
|
254
|
+
_emitAudit(opts, "ai-act/deepfake-disclosure-applied", "success", {
|
|
255
|
+
contentType: opts.contentType,
|
|
256
|
+
placement: placement,
|
|
257
|
+
jurisdiction: jurisdiction,
|
|
258
|
+
correlationId: opts.correlationId || null,
|
|
259
|
+
});
|
|
260
|
+
return emission;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* @primitive b.ai.disclosure.emotion
|
|
265
|
+
* @signature b.ai.disclosure.emotion(opts)
|
|
266
|
+
* @since 0.12.12
|
|
267
|
+
* @status stable
|
|
268
|
+
* @compliance eu-ai-act
|
|
269
|
+
* @related b.ai.disclosure.chatbot, b.ai.disclosure.deepfake
|
|
270
|
+
*
|
|
271
|
+
* EU AI Act Art. 50(3) emotion-recognition / biometric-
|
|
272
|
+
* categorisation disclosure. Operators deploying these systems
|
|
273
|
+
* must inform the natural person of operation. Returns the notice
|
|
274
|
+
* payload the operator wires into the consent / pre-interaction
|
|
275
|
+
* flow.
|
|
276
|
+
*
|
|
277
|
+
* @opts
|
|
278
|
+
* language: string,
|
|
279
|
+
* text: string,
|
|
280
|
+
* systemType: "emotion" | "biometric-categorisation", // default "emotion"
|
|
281
|
+
* audit: object,
|
|
282
|
+
* correlationId: string,
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* var notice = b.ai.disclosure.emotion({ systemType: "emotion" });
|
|
286
|
+
* // notice.text → "This system uses AI to recognise emotions ..."
|
|
287
|
+
* // notice.article → "Art. 50(3)"
|
|
288
|
+
*/
|
|
289
|
+
function emotion(opts) {
|
|
290
|
+
opts = opts || {};
|
|
291
|
+
var systemType = opts.systemType || "emotion";
|
|
292
|
+
if (systemType !== "emotion" && systemType !== "biometric-categorisation") {
|
|
293
|
+
throw new AiDisclosureError("ai-disclosure/bad-arg",
|
|
294
|
+
"emotion: opts.systemType must be \"emotion\" (default) | \"biometric-categorisation\"; got " +
|
|
295
|
+
JSON.stringify(systemType));
|
|
296
|
+
}
|
|
297
|
+
var text = typeof opts.text === "string" && opts.text.length > 0
|
|
298
|
+
? opts.text
|
|
299
|
+
: DEFAULT_EMOTION_TEXT;
|
|
300
|
+
var emission = {
|
|
301
|
+
text: text,
|
|
302
|
+
language: opts.language || "en",
|
|
303
|
+
systemType: systemType,
|
|
304
|
+
article: "Art. 50(3)",
|
|
305
|
+
regulation: "Regulation (EU) 2024/1689",
|
|
306
|
+
};
|
|
307
|
+
_emitAudit(opts, "ai-act/emotion-disclosure-applied", "success", {
|
|
308
|
+
systemType: systemType,
|
|
309
|
+
correlationId: opts.correlationId || null,
|
|
310
|
+
});
|
|
311
|
+
return emission;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function _validateJurisdiction(jurisdiction, primitive) {
|
|
315
|
+
if (SUPPORTED_JURISDICTIONS.indexOf(jurisdiction) === -1) {
|
|
316
|
+
throw new AiDisclosureError("ai-disclosure/bad-jurisdiction",
|
|
317
|
+
primitive + ": opts.jurisdiction must be one of " +
|
|
318
|
+
SUPPORTED_JURISDICTIONS.join(" | ") + " (eu = default; us-ca = California AB-853; " +
|
|
319
|
+
"cn = China CAC GenAI Measures); got " + JSON.stringify(jurisdiction));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function _emitAudit(opts, action, outcome, metadata) {
|
|
324
|
+
if (!opts.audit || typeof opts.audit.safeEmit !== "function") return;
|
|
325
|
+
try {
|
|
326
|
+
opts.audit.safeEmit({
|
|
327
|
+
action: action,
|
|
328
|
+
outcome: outcome,
|
|
329
|
+
metadata: metadata || {},
|
|
330
|
+
});
|
|
331
|
+
} catch (_e) {
|
|
332
|
+
// drop-silent — audit emit failure cannot crash the disclosure
|
|
333
|
+
// path. The Art. 50 obligation is the user-facing notice the
|
|
334
|
+
// primitive returns; the audit emission is a parallel best-
|
|
335
|
+
// effort chain-of-custody record. Throwing here would refuse
|
|
336
|
+
// the disclosure to defend the audit chain, which fails the
|
|
337
|
+
// wrong direction (the regulatory contract is satisfied by
|
|
338
|
+
// emitting the notice; the audit trail backs it up).
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
module.exports = {
|
|
343
|
+
chatbot: chatbot,
|
|
344
|
+
deepfake: deepfake,
|
|
345
|
+
emotion: emotion,
|
|
346
|
+
AiDisclosureError: AiDisclosureError,
|
|
347
|
+
SUPPORTED_JURISDICTIONS: Object.freeze(SUPPORTED_JURISDICTIONS.slice()),
|
|
348
|
+
DEEPFAKE_CONTENT_TYPES: Object.freeze(DEEPFAKE_CONTENT_TYPES.slice()),
|
|
349
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.12.12",
|
|
4
|
+
"date": "2026-05-23",
|
|
5
|
+
"headline": "`b.ai.disclosure.chatbot` + `b.ai.disclosure.deepfake` + `b.ai.disclosure.emotion` — EU AI Act Art. 50 transparency obligations (calendar-locked 2026-08-02) with US-CA AB-853 + China CAC GenAI cross-walk",
|
|
6
|
+
"summary": "EU AI Act Art. 50 transparency primitives land ahead of the 2026-08-02 enforcement deadline. `b.ai.disclosure.chatbot(session, opts)` emits the Art. 50(1) first-contact \"you are interacting with an AI system\" disclosure with placement control (`first-message` / `always` / `on-request`). `b.ai.disclosure.deepfake(content, { contentType, placement, jurisdiction })` emits the Art. 50(4) synthetic-content label + machine-readable metadata payload for image / audio / video / text. `b.ai.disclosure.emotion({ systemType })` emits the Art. 50(3) emotion-recognition / biometric-categorisation notice. Each primitive emits a tamper-evident `ai-act/*-disclosure-applied` audit event so the compliance trail backs the user-facing notice. Cross-jurisdiction cross-walk lives in `opts.jurisdiction`: `\"eu\"` (default), `\"us-ca\"` adds AB-853 §22949.91 to the cross-walk array, `\"cn\"` adds CAC GenAI Measures Art. 12. The deepfake primitive returns a `schema: \"c2pa-v1.4-ready\"` metadata field that the v0.12.21 `b.contentCredentials` C2PA adapter will consume when it lands — this patch ships the label markup + schema; the C2PA manifest emission is the next composition.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.ai.disclosure.chatbot(session, opts)` — Art. 50(1) first-contact disclosure",
|
|
13
|
+
"body": "Operators interacting with natural persons via an AI system get a primitive that emits the \"you are interacting with an AI system\" notice + audits the emission. `placement` opts: `\"first-message\"` (default — emit on first contact only, tracked via `session.aiDisclosureEmitted`), `\"always\"` (every response), `\"on-request\"` (operator wires their own trigger). Returns `{ text, language, jurisdiction, placement, shouldEmit, article, regulation }` — `shouldEmit` is the operator-consumable boolean for response-wire-up logic."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "`b.ai.disclosure.deepfake(content, opts)` — Art. 50(4) synthetic-content label",
|
|
17
|
+
"body": "Operators emitting model-generated or model-manipulated content get a primitive that returns both the visible label markup AND the machine-readable metadata payload. `contentType: \"image\" | \"audio\" | \"video\" | \"text\"` is required; `placement: \"label\" | \"metadata\" | \"both\"` (default `\"both\"`) controls what the primitive populates. The metadata payload includes `schema: \"c2pa-v1.4-ready\"` — the v0.12.21 `b.contentCredentials` C2PA adapter will consume this schema field when it lands. `crossWalk` array carries `[\"eu-ai-act/Art. 50(4)\"]` plus the per-jurisdiction reference (AB-853 §22949.91 / CAC GenAI Art. 12)."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "`b.ai.disclosure.emotion(opts)` — Art. 50(3) emotion-recognition / biometric-categorisation notice",
|
|
21
|
+
"body": "Operators deploying emotion-recognition or biometric-categorisation systems get the consent-flow notice primitive. `systemType: \"emotion\" | \"biometric-categorisation\"` (default `\"emotion\"`) selects which Art. 50(3) sub-obligation applies. Returns the notice payload + emits an `ai-act/emotion-disclosure-applied` audit event."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"title": "Cross-jurisdiction cross-walk: EU + US-CA + China in a single primitive",
|
|
25
|
+
"body": "The `opts.jurisdiction` opt accepts `\"eu\"` (default — Regulation (EU) 2024/1689), `\"us-ca\"` (California AB-853 effective 2026), or `\"cn\"` (China CAC GenAI Measures). The chatbot + deepfake primitives both honour the cross-walk: the deepfake response's `crossWalk` array carries every jurisdiction-specific legal reference the same emission satisfies, so operators serving multi-region traffic emit one notice + audit one event + reference all applicable regimes."
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"heading": "Security",
|
|
31
|
+
"items": [
|
|
32
|
+
{
|
|
33
|
+
"title": "Drop-silent audit emission preserves the disclosure path under audit-bus failure",
|
|
34
|
+
"body": "If `opts.audit` is supplied but its `safeEmit` throws (network bus down, audit-sign chain malformed), the disclosure primitive still returns the user-facing notice payload. The Art. 50 obligation is the user-facing notice itself; the audit emission is a parallel best-effort chain-of-custody record. Refusing the disclosure to defend the audit chain would fail the wrong direction — the regulatory contract is satisfied by emitting the notice. Matches the framework's `audit.safeEmit` drop-silent contract for hot-path observability sinks."
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"heading": "Migration",
|
|
40
|
+
"items": [
|
|
41
|
+
{
|
|
42
|
+
"title": "C2PA manifest emission lands in v0.12.21",
|
|
43
|
+
"body": "The deepfake primitive's metadata payload includes a `schema: \"c2pa-v1.4-ready\"` field that the v0.12.21 `b.contentCredentials` adapter will consume. Operators emitting image / audio / video for v0.12.12-0.12.20 get the label markup + structured metadata; the actual C2PA manifest (signed JUMBF assertion chain) is the next composition layer."
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Layer 0 — b.ai.disclosure.chatbot + .deepfake + .emotion
|
|
4
|
+
* (EU AI Act Art. 50 transparency obligations).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
var b = require("../../index");
|
|
8
|
+
var helpers = require("../helpers");
|
|
9
|
+
var check = helpers.check;
|
|
10
|
+
|
|
11
|
+
function _makeFakeAudit() {
|
|
12
|
+
var events = [];
|
|
13
|
+
return {
|
|
14
|
+
safeEmit: function (e) { events.push(e); },
|
|
15
|
+
events: events,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function testChatbotFirstContact() {
|
|
20
|
+
var audit = _makeFakeAudit();
|
|
21
|
+
var d = b.ai.disclosure.chatbot({ id: "s1" }, {
|
|
22
|
+
placement: "first-message",
|
|
23
|
+
audit: audit,
|
|
24
|
+
});
|
|
25
|
+
check("chatbot: shouldEmit true on first contact (placement first-message)", d.shouldEmit);
|
|
26
|
+
check("chatbot: carries Art. 50(1) reference", d.article === "Art. 50(1)");
|
|
27
|
+
check("chatbot: default text is generic AI interaction notice",
|
|
28
|
+
d.text === "You are interacting with an AI system.");
|
|
29
|
+
check("chatbot: audit event emitted",
|
|
30
|
+
audit.events.length === 1 && audit.events[0].action === "ai-act/chatbot-disclosure-applied");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function testChatbotPostFirstContact() {
|
|
34
|
+
var d = b.ai.disclosure.chatbot({ id: "s2", aiDisclosureEmitted: true }, {
|
|
35
|
+
placement: "first-message",
|
|
36
|
+
});
|
|
37
|
+
check("chatbot: shouldEmit false after first-message already emitted", !d.shouldEmit);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function testChatbotFirstMessageMutatesSession() {
|
|
41
|
+
// Codex P1B on v0.12.12 PR #163 — first-message must mutate
|
|
42
|
+
// session.aiDisclosureEmitted so the next call sees it.
|
|
43
|
+
var session = { id: "s-codex-b" };
|
|
44
|
+
var d1 = b.ai.disclosure.chatbot(session, { placement: "first-message" });
|
|
45
|
+
check("chatbot: first call emits", d1.shouldEmit === true);
|
|
46
|
+
check("chatbot: session.aiDisclosureEmitted mutated after first emit",
|
|
47
|
+
session.aiDisclosureEmitted === true);
|
|
48
|
+
var d2 = b.ai.disclosure.chatbot(session, { placement: "first-message" });
|
|
49
|
+
check("chatbot: second call on same session no longer emits", d2.shouldEmit === false);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function testChatbotOnRequestGatesOnRequested() {
|
|
53
|
+
// Codex P1A on v0.12.12 PR #163 — "on-request" without
|
|
54
|
+
// opts.requested must NOT emit (otherwise on-request collapses
|
|
55
|
+
// to always-on).
|
|
56
|
+
var d1 = b.ai.disclosure.chatbot({ id: "s-or-1" }, { placement: "on-request" });
|
|
57
|
+
check("chatbot: on-request without opts.requested does not emit", d1.shouldEmit === false);
|
|
58
|
+
var d2 = b.ai.disclosure.chatbot({ id: "s-or-2" }, {
|
|
59
|
+
placement: "on-request",
|
|
60
|
+
requested: true,
|
|
61
|
+
});
|
|
62
|
+
check("chatbot: on-request with opts.requested:true emits", d2.shouldEmit === true);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function testChatbotAlwaysPlacement() {
|
|
66
|
+
var d = b.ai.disclosure.chatbot({ id: "s3", aiDisclosureEmitted: true }, {
|
|
67
|
+
placement: "always",
|
|
68
|
+
});
|
|
69
|
+
check("chatbot: shouldEmit true under placement: always regardless of prior emission",
|
|
70
|
+
d.shouldEmit);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function testChatbotRefusesBadJurisdiction() {
|
|
74
|
+
var refused = null;
|
|
75
|
+
try {
|
|
76
|
+
b.ai.disclosure.chatbot({ id: "s4" }, { jurisdiction: "not-a-jurisdiction" });
|
|
77
|
+
} catch (e) { refused = e; }
|
|
78
|
+
check("chatbot: bad jurisdiction refused with typed error",
|
|
79
|
+
refused && /bad-jurisdiction/.test(refused.code || refused.message));
|
|
80
|
+
check("chatbot: refusal is a b.ai.disclosure.AiDisclosureError",
|
|
81
|
+
refused instanceof b.ai.disclosure.AiDisclosureError);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function testDeepfakeImage() {
|
|
85
|
+
var audit = _makeFakeAudit();
|
|
86
|
+
var d = b.ai.disclosure.deepfake("imageBytes", {
|
|
87
|
+
contentType: "image",
|
|
88
|
+
placement: "both",
|
|
89
|
+
audit: audit,
|
|
90
|
+
});
|
|
91
|
+
check("deepfake: image carries visible label", typeof d.label === "string" && d.label.length > 0);
|
|
92
|
+
check("deepfake: image carries machine-readable metadata", d.metadata !== null && d.metadata.ai_generated === true);
|
|
93
|
+
check("deepfake: schema reserved for C2PA v0.12.21 hand-off",
|
|
94
|
+
d.metadata.schema === "c2pa-v1.4-ready");
|
|
95
|
+
check("deepfake: default jurisdiction is eu (cross-walk single-entry)",
|
|
96
|
+
d.crossWalk.length === 1 && d.crossWalk[0] === "eu-ai-act/Art. 50(4)");
|
|
97
|
+
check("deepfake: audit event emitted",
|
|
98
|
+
audit.events.length === 1 && audit.events[0].action === "ai-act/deepfake-disclosure-applied");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function testDeepfakeCrossWalkCalifornia() {
|
|
102
|
+
var d = b.ai.disclosure.deepfake("imageBytes", {
|
|
103
|
+
contentType: "image",
|
|
104
|
+
jurisdiction: "us-ca",
|
|
105
|
+
});
|
|
106
|
+
check("deepfake: us-ca jurisdiction adds AB-853 cross-walk entry",
|
|
107
|
+
d.crossWalk.length === 2 && d.crossWalk.indexOf("us-ca/AB-853 §22949.91") !== -1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function testDeepfakeCrossWalkChina() {
|
|
111
|
+
var d = b.ai.disclosure.deepfake("imageBytes", {
|
|
112
|
+
contentType: "image",
|
|
113
|
+
jurisdiction: "cn",
|
|
114
|
+
});
|
|
115
|
+
check("deepfake: cn jurisdiction adds CAC GenAI Art. 12 cross-walk entry",
|
|
116
|
+
d.crossWalk.length === 2 && d.crossWalk.indexOf("cn/CAC-GenAI Measures Art. 12") !== -1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function testDeepfakeRefusesBadContentType() {
|
|
120
|
+
var refused = null;
|
|
121
|
+
try {
|
|
122
|
+
b.ai.disclosure.deepfake("bytes", { contentType: "spreadsheet" });
|
|
123
|
+
} catch (e) { refused = e; }
|
|
124
|
+
check("deepfake: invalid contentType refused upfront",
|
|
125
|
+
refused && /bad-arg/.test(refused.code || refused.message));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function testDeepfakeLabelOnlyPlacement() {
|
|
129
|
+
var d = b.ai.disclosure.deepfake("bytes", { contentType: "text", placement: "label" });
|
|
130
|
+
check("deepfake: placement: label yields no metadata payload", d.metadata === null);
|
|
131
|
+
check("deepfake: placement: label yields visible label", typeof d.label === "string");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function testDeepfakeMetadataOnlyPlacement() {
|
|
135
|
+
var d = b.ai.disclosure.deepfake("bytes", { contentType: "video", placement: "metadata" });
|
|
136
|
+
check("deepfake: placement: metadata yields no visible label", d.label === null);
|
|
137
|
+
check("deepfake: placement: metadata yields structured payload",
|
|
138
|
+
d.metadata !== null && d.metadata.content_type === "video");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function testEmotionDisclosure() {
|
|
142
|
+
var audit = _makeFakeAudit();
|
|
143
|
+
var d = b.ai.disclosure.emotion({ systemType: "emotion", audit: audit });
|
|
144
|
+
check("emotion: carries Art. 50(3) reference", d.article === "Art. 50(3)");
|
|
145
|
+
check("emotion: audit event emitted",
|
|
146
|
+
audit.events.length === 1 && audit.events[0].action === "ai-act/emotion-disclosure-applied");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function testEmotionBiometricCategorisation() {
|
|
150
|
+
var d = b.ai.disclosure.emotion({ systemType: "biometric-categorisation" });
|
|
151
|
+
check("emotion: biometric-categorisation systemType accepted",
|
|
152
|
+
d.systemType === "biometric-categorisation");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function testAuditDropSilent() {
|
|
156
|
+
// Per rule §5 — hot-path observability sinks drop silent. Pass an
|
|
157
|
+
// audit whose safeEmit throws + verify the primitive doesn't fail.
|
|
158
|
+
var throwingAudit = { safeEmit: function () { throw new Error("audit-bus-down"); } };
|
|
159
|
+
var d = b.ai.disclosure.chatbot({ id: "s5" }, {
|
|
160
|
+
placement: "first-message",
|
|
161
|
+
audit: throwingAudit,
|
|
162
|
+
});
|
|
163
|
+
check("chatbot: audit emit failure does not crash the disclosure path",
|
|
164
|
+
d.shouldEmit === true);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function run() {
|
|
168
|
+
await testChatbotFirstContact();
|
|
169
|
+
await testChatbotFirstMessageMutatesSession();
|
|
170
|
+
await testChatbotOnRequestGatesOnRequested();
|
|
171
|
+
await testChatbotPostFirstContact();
|
|
172
|
+
await testChatbotAlwaysPlacement();
|
|
173
|
+
await testChatbotRefusesBadJurisdiction();
|
|
174
|
+
await testDeepfakeImage();
|
|
175
|
+
await testDeepfakeCrossWalkCalifornia();
|
|
176
|
+
await testDeepfakeCrossWalkChina();
|
|
177
|
+
await testDeepfakeRefusesBadContentType();
|
|
178
|
+
await testDeepfakeLabelOnlyPlacement();
|
|
179
|
+
await testDeepfakeMetadataOnlyPlacement();
|
|
180
|
+
await testEmotionDisclosure();
|
|
181
|
+
await testEmotionBiometricCategorisation();
|
|
182
|
+
await testAuditDropSilent();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { run: run };
|
|
186
|
+
|
|
187
|
+
if (require.main === module) {
|
|
188
|
+
run().then(
|
|
189
|
+
function () { console.log("[ai-disclosure] OK — " + helpers.getChecks() + " checks passed"); },
|
|
190
|
+
function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -2242,11 +2242,33 @@ async function testNoDuplicateCodeBlocks() {
|
|
|
2242
2242
|
{
|
|
2243
2243
|
mode: "family-subset",
|
|
2244
2244
|
files: [
|
|
2245
|
+
"lib/ai-disclosure.js:chatbot",
|
|
2245
2246
|
"lib/backup/index.js:bundleAdapterStorage",
|
|
2246
2247
|
"lib/importmap-integrity.js:build",
|
|
2247
2248
|
"lib/metrics.js:shadowRegistry",
|
|
2248
2249
|
],
|
|
2249
|
-
reason: "v0.12.11 —
|
|
2250
|
+
reason: "v0.12.11/v0.12.12 — opts-validation cascade shape (chained `if (typeof opts.X !== \"...\") throw ...`) reaches 50-token duplication across primitives that each carry distinct semantic vocabulary. bundleAdapterStorage validates cryptoStrategy / recipient / passphrase / posture; importmap-integrity.build validates SRI hash list / nonce policy; metrics.shadowRegistry validates collector config; ai-disclosure.chatbot validates session / placement / jurisdiction per EU AI Act Art. 50(1). Extracting would require a generic options-cascade helper that loses per-primitive error codes operators grep for in audit logs.",
|
|
2251
|
+
},
|
|
2252
|
+
{
|
|
2253
|
+
mode: "family-subset",
|
|
2254
|
+
files: [
|
|
2255
|
+
"lib/ai-disclosure.js:chatbot",
|
|
2256
|
+
"lib/auth/dpop.js:_canonicalJwk",
|
|
2257
|
+
"lib/auth/sd-jwt-vc-holder.js:store",
|
|
2258
|
+
"lib/compliance-sanctions.js:screen",
|
|
2259
|
+
"lib/dora.js:_validateReportInput",
|
|
2260
|
+
"lib/fda-21cfr11.js:_validateSignatureInput",
|
|
2261
|
+
"lib/guard-envelope.js:check",
|
|
2262
|
+
"lib/guard-list-unsubscribe.js:validate",
|
|
2263
|
+
"lib/guard-mail-query.js:validateActor",
|
|
2264
|
+
"lib/guard-mail-reply.js:validate",
|
|
2265
|
+
"lib/guard-saga-config.js:validate",
|
|
2266
|
+
"lib/guard-trace-context.js:validate",
|
|
2267
|
+
"lib/incident-report.js:open",
|
|
2268
|
+
"lib/mail-greylist.js:check",
|
|
2269
|
+
"lib/mail-helo.js:evaluate",
|
|
2270
|
+
],
|
|
2271
|
+
reason: "v0.12.12 — `if (!opts || typeof opts !== \"object\") throw Error(...) ; if (typeof opts.X !== \"string\") throw Error(...)` argument-shape preamble is the framework's standard primitive boundary check. Every guard family member + every compliance / mail / auth primitive that takes an opts object shares this shingle. Extracting would require a generic argShape helper, but the throw-on-bad-shape carries primitive-specific Error subclasses (AiDisclosureError, GuardEnvelopeError, etc.) that operators grep for. Family is wide and stays inline by design.",
|
|
2250
2272
|
},
|
|
2251
2273
|
{
|
|
2252
2274
|
mode: "family-subset",
|
|
@@ -5624,6 +5646,25 @@ var KNOWN_ANTIPATTERNS = [
|
|
|
5624
5646
|
reason: "Codex P1 on v0.12.7 PR #158 — archive-read.extract used renameSync to atomically place each decompressed entry at its canonical destination + tracked written[].path for catch-block cleanup. When the destination directory was non-empty, the rename silently overwrote operator files; on extract abort, the cleanup deleted them. Fix: refuse upfront if destination path exists, force operators to use a fresh / empty subtree. Detector locks the shape: any extract code that tracks resolvedPath for catch-block cleanup MUST carry a `destination-exists` refusal in the same file.",
|
|
5625
5647
|
},
|
|
5626
5648
|
|
|
5649
|
+
{
|
|
5650
|
+
// Codex P1A on v0.12.12 PR #163 — "on-request" placement
|
|
5651
|
+
// semantics collapsed into "always" when shouldEmit didn't
|
|
5652
|
+
// gate on an explicit `opts.requested` signal. Detector locks
|
|
5653
|
+
// the contract: any compliance-disclosure primitive in
|
|
5654
|
+
// lib/ai-disclosure.js with a placement-mode dispatch MUST
|
|
5655
|
+
// gate the "on-request" branch on an explicit opt rather than
|
|
5656
|
+
// unconditionally returning true. The pattern is narrow
|
|
5657
|
+
// (file-scoped to ai-disclosure.js) because the bug shape was
|
|
5658
|
+
// specific to the Art. 50 placement enum.
|
|
5659
|
+
id: "ai-disclosure-on-request-without-requested-gate",
|
|
5660
|
+
primitive: "in lib/ai-disclosure.js, placement === \"on-request\" branches must gate on an explicit opt (e.g. opts.requested === true) so the disclosure doesn't fire on every call. Without the gate, on-request collapses into always-on semantics and the operator's three placement modes become two.",
|
|
5661
|
+
regex: /placement\s*===\s*["']on-request["']/,
|
|
5662
|
+
requires: /requested|allow:ai-disclosure-on-request-without-requested-gate/,
|
|
5663
|
+
skipCommentLines: true,
|
|
5664
|
+
allowlist: [],
|
|
5665
|
+
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.",
|
|
5666
|
+
},
|
|
5667
|
+
|
|
5627
5668
|
// Codex P1 on v0.12.11 PR #162 — surfaced the NaN/Infinity bypass
|
|
5628
5669
|
// through `typeof X === "number" ? X : default` gating. The
|
|
5629
5670
|
// pattern exists widely in the codebase (~29 call sites at the
|
package/lib/webhook-receiver.js
CHANGED
|
@@ -293,10 +293,7 @@ function _coerceBodyBytes(body) {
|
|
|
293
293
|
|
|
294
294
|
function _generateSecret() {
|
|
295
295
|
var buf = _b().crypto.generateBytes(SECRET_BYTE_LEN);
|
|
296
|
-
return
|
|
297
|
-
.replace(/\+/g, "-")
|
|
298
|
-
.replace(/\//g, "_")
|
|
299
|
-
.replace(/=+$/, "");
|
|
296
|
+
return _b().crypto.toBase64Url(buf);
|
|
300
297
|
}
|
|
301
298
|
|
|
302
299
|
function _canonicalSecret(input) {
|
package/lib/wishlist-sharing.js
CHANGED
|
@@ -216,15 +216,11 @@ function _viewerSessionId(s) {
|
|
|
216
216
|
|
|
217
217
|
// ---- token generation + hashing -----------------------------------------
|
|
218
218
|
|
|
219
|
-
// 32 random bytes -> 43-char base64url (no padding)
|
|
220
|
-
//
|
|
221
|
-
// across Node minors.
|
|
219
|
+
// 32 random bytes -> 43-char base64url (no padding) via
|
|
220
|
+
// `b.crypto.toBase64Url`.
|
|
222
221
|
function _generateToken() {
|
|
223
222
|
var buf = _b().crypto.generateBytes(TOKEN_BYTE_LEN);
|
|
224
|
-
return
|
|
225
|
-
.replace(/\+/g, "-")
|
|
226
|
-
.replace(/\//g, "_")
|
|
227
|
-
.replace(/=+$/, "");
|
|
223
|
+
return _b().crypto.toBase64Url(buf);
|
|
228
224
|
}
|
|
229
225
|
|
|
230
226
|
function _canonicalToken(input) {
|
package/package.json
CHANGED