@blamejs/core 0.15.7 → 0.15.8

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.8 (2026-06-13) — **Redacts OTLP log-sink attributes to close a secret/PII egress the span fix missed, adds EU DSA and China PIPL cross-border compliance record-builders, ships an SSDF producer self-attestation with every release, and makes the published tarball reproducible.** This release closes a telemetry egress hole, adds two compliance record-builder namespaces, and hardens the release supply chain. The OTLP log sinks (HTTP-JSON and gRPC) shipped a log record's meta attributes and the resource attributes to the collector unredacted — a log line carrying a bearer token, password, or API key reached the wire verbatim (CWE-532). The 0.15.4 fix wired the telemetry redactor into the span and metric exporters but the log sinks were missed; both now run record and resource attributes through the same redactor before serialization. New b.dsa builds the EU Digital Services Act (Reg 2022/2065) records an intermediary or platform must keep — Art. 16 notice-and-action, Art. 17 statement of reasons, and the Art. 15 / 24(3) transparency report. New b.pipl builds the China PIPL cross-border transfer records — an Art. 38/40 assessment that determines whether a CAC security assessment is mandatory (CIIO, important data, or the volume / sensitive-PI thresholds), and an Art. 40 security-assessment certificate. On the supply-chain side, every release now ships ssdf-attestation.json, a machine-readable NIST SP 800-218 / OMB M-22-18 producer self-attestation mapping each secure-development practice to its implementing control, and the published tarball is now packed with SOURCE_DATE_EPOCH so an operator can rebuild it byte-for-byte from the release commit. **Added:** *b.dsa — EU Digital Services Act compliance record-builders* — `b.dsa` builds the dated, frozen records the EU Digital Services Act (Regulation (EU) 2022/2065) requires an online intermediary or platform to keep: `b.dsa.noticeAndAction` (Art. 16) records a notice against a piece of content and computes the action-due window; `b.dsa.statementOfReasons` (Art. 17) records a moderation decision with its legal or contractual ground (exactly one is required), the facts, whether it was automated, and the redress routes offered; `b.dsa.transparencyReport` (Art. 15 / 24(3)) aggregates the period counts into a report with the next-due date. The builders perform no network I/O and emit a best-effort audit event; they map to the `dsa` compliance posture. · *b.pipl — China PIPL cross-border transfer record-builders* — `b.pipl.sccFilingAssessment` builds a PIPL Art. 38/40/55 cross-border transfer assessment and determines the lawful mechanism: it forces a CAC security assessment (over a self-selected standard contract or certification) when the exporter is a critical-information-infrastructure operator, exports important data, handles personal information of more than 1,000,000 individuals, or crosses the cumulative volume / sensitive-PI thresholds. `b.pipl.securityAssessmentCertificate` records an Art. 40 security-assessment self-declaration with a 3-year validity clock. Both return frozen dated records and map to the `pipl-cn` posture. · *SSDF producer self-attestation shipped with every release* — Every release now attaches `ssdf-attestation.json` — a machine-readable NIST SP 800-218 (SSDF v1.1) / OMB M-22-18 producer self-attestation. It maps each secure-software-development practice to its implementing control in the tree (SLSA L3 provenance, SSH-signed tags, vendored zero-runtime-dep supply chain, OSV-Scanner gating, coordinated disclosure) and is deterministic from the release commit. Its sha256 is a subject of the SLSA L3 provenance, so verifying the provenance verifies the attestation has not been tampered with. Downstream consumers who require SSDF supplier-compliance evidence can download it from the release page. **Security:** *OTLP log sinks redact record and resource attributes before export* — `b.logStream`'s OTLP log sinks (HTTP-JSON and gRPC) shipped a log record's `meta` attributes and the sink's resource attributes to the collector UNREDACTED, so a log line whose meta carried a bearer token, password, or API key — or a credential placed in a resource attribute — reached the OTLP wire verbatim (CWE-532). The 0.15.4 change baked the telemetry redactor into the span and metric exporters but its detector was anchored on the span/metric encoder function names, leaving the log sinks uncovered. Both log sinks now run record and resource attributes through `b.observability.redactAttrs` before serialization, the same egress contract the span and metric exporters already hold. · *Reproducible published tarball (SOURCE_DATE_EPOCH)* — The release workflow now exports `SOURCE_DATE_EPOCH` (derived from the tagged commit's author date) before `npm pack`, so the mtime stamped into every tar header is deterministic. An operator can re-pack the package from the same commit and match the published tarball's sha256 byte-for-byte, strengthening the source-to-artifact verification path alongside the existing SLSA L3 provenance and PQC signatures. **Detectors:** *otlp-log-sink-encodes-attrs-without-redactor* — Fires when an OTLP log-sink encoder hands a raw `record.meta` or resource-attribute map to serialization without routing it through `observability.redactAttrs` — the class the span/metric detector could not see because the log sinks carry the OTLP-logs schema function names.
12
+
11
13
  - v0.15.7 (2026-06-13) — **Hardens the new URL canonicalizer against an IPv4-mapped allowlist bypass, enforces the OIDC azp authorized-party check, and closes a set of audited correctness gaps in retention, credential rehashing, the scheduler, and SD-JWT key binding.** This release deepens the URL/host canonicalizer shipped in 0.15.6 and clears a batch of audited correctness gaps. The canonicalizer now folds an IPv4-mapped IPv6 address (::ffff:1.2.3.4) to its embedded IPv4 and strips every trailing dot from a host, so a dual-stack peer can no longer slip past an operator's dotted-IPv4 allowlist and host./host.. no longer evade a host comparison. (NAT64 and 6to4 hosts are deliberately kept as IPv6 so canonicalizing then classifying agrees with the SSRF classifier, which treats them as reserved.) On the OIDC side, verifyIdToken now enforces OIDC Core 3.1.3.7: a multi-audience ID token must carry an azp (authorized party) and a present azp must equal the client_id, closing a confused-deputy hole where a token minted for a different client but listing this RP in its audience array verified clean. The rest are audited fixes: retention.complianceFloor now honors the active posture set via applyPosture (the documented inheritance was unimplemented); credentialHash.needsRehash now drives the advertised SHAKE256 length-rotation (raising the output length now triggers a rehash, upgrade-only); the task scheduler no longer lets a run abandoned by its watchdog clobber the next run's state or emit a stale success when its slow promise settles late; and the SD-JWT key-binding JWT compares its audience and nonce in constant time. **Fixed:** *retention.complianceFloor honors the active compliance posture* — `b.retention.complianceFloor` required an explicit posture and never read the active posture set by `applyPosture` (the `b.compliance.set` cascade), so the documented inheritance was unimplemented dead state. It now inherits the active posture when none is passed — `complianceFloor(candidateTtlMs)` uses the active posture, an explicit posture still overrides it — and `applyPosture(null)` now clears the active posture (it was a silent no-op, so `b.compliance.clear` could not reset it). · *credentialHash.needsRehash drives the SHAKE256 length-rotation* — `b.credentialHash.needsRehash` never compared the stored SHAKE256 digest length against the configured length, so raising the output length never triggered a rehash and the advertised length-rotation was a silent no-op. It now flags a digest shorter than the configured/default length for rehash (upgrade-only — a longer-than-target digest is never shortened, matching the Argon2 convention). · *The scheduler no longer lets a watchdog-abandoned run corrupt the next run* — `b.scheduler`'s watchdog force-clears a task's running flag after `maxJobMs` so a hung handler can't lock out future fires, and the next tick re-fires. The original slow promise then settled late and unconditionally overwrote the task's running / lastFinish / lastError state and emitted a `system.scheduler.task.success`|`failure` for a run the watchdog had already given up on — clobbering the new run and double-counting. Each run is now tagged with a generation that the watchdog and every fire bump; a settle whose tag is stale is ignored. **Security:** *The URL canonicalizer folds IPv4-mapped IPv6 addresses to IPv4* — `b.safeUrl.canonicalize` / `b.ssrfGuard.canonicalizeHost` now fold an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`, the `::ffff:0:0/96` block) to its embedded IPv4 dotted form. Previously it canonicalized to an IPv6 string, so a dual-stack peer never unified with an operator's `1.2.3.4` allow/deny entry — an allowlist bypass. Only the IPv4-mapped block folds, because the SSRF classifier maps it to the embedded v4 verdict directly; NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`) are deliberately kept as IPv6, since the classifier treats a NAT64 literal as reserved and folding it would turn a blocked verdict into an allowed public IPv4. The host canonicalizer also now strips every trailing dot, so `host`, `host.`, and `host..` collapse to one form. · *verifyIdToken enforces the OIDC azp (authorized party) check* — `b.auth.oauth`'s `verifyIdToken` validated only that its `client_id` was present in the token's `aud`, ignoring `azp`. Per OIDC Core 3.1.3.7, a multi-audience ID token must carry an `azp` and a present `azp` must equal the RP's `client_id`. Without it, a token whose authorized party is a different client — but whose `aud` array also lists this RP — verified clean (a confused-deputy / token-substitution hole). The verifier now rejects a multi-audience token with no `azp` (`auth-oauth/azp-required`) and any token whose `azp` is not the `client_id` (`auth-oauth/azp-mismatch`). A single-audience token with no `azp` (the common case) is unaffected. · *SD-JWT key-binding audience and nonce compare in constant time* — `b.auth.sdJwtVc.verify` compared the key-binding JWT's `aud` and `nonce` with a short-circuiting `!==`, while the adjacent `sd_hash` check already used a constant-time compare. The `nonce` is a verifier-issued replay-defense value, so a non-constant-time compare leaks a matching-prefix timing oracle; both checks now use the constant-time helper.
12
14
 
13
15
  - v0.15.6 (2026-06-12) — **Closes SAML and OIDC assertion-replay windows, bounds SSE memory under a slow client, restores at-least-once delivery for a crashed outbox publisher, makes sealed-column membership queries work, ships JOSE-conformant SD-JWT signatures, and adds a URL canonicalizer for SSRF-safe comparison.** A security and correctness release. On the identity surface: a SAML Response whose Bearer (or Holder-of-Key) SubjectConfirmation omits NotOnOrAfter is now rejected instead of accepted as fresh-forever, and the OIDC ID-token verifier no longer lets a caller disable expiry validation on a normal token - the exp bypass is restricted to back-channel-logout tokens and bounded by an issued-at freshness floor. SD-JWT-VC ES256/ES384 signatures are now emitted as JOSE raw r||s, so credentials this issuer signs verify in conformant wallets and verifiers. A new b.safeUrl.canonicalize (and b.ssrfGuard.canonicalizeHost) collapses obfuscated host and IP forms to one canonical string so allowlist and SSRF comparisons can't be bypassed by encoding tricks. On the reliability side: server-sent-event channels now cap their per-connection outbound buffer and evict a stalled client instead of growing the heap without bound; the outbox reclaims a job left in-flight by a publisher that crashed mid-delivery; the background worker pool no longer drops a task queued behind one that timed out; a retention preview no longer rewrites the whole database file; an equality / membership query on an encrypted column now hashes each candidate (membership queries previously failed outright); and the on-read re-hash of a legacy lookup digest now runs on Postgres and MySQL handles, not only SQLite. **Fixed:** *SD-JWT-VC ES256 / ES384 signatures are JOSE-conformant* — `b.auth.sdJwtVc` signed and verified ES256 / ES384 credentials with node:crypto's default DER ECDSA encoding instead of the raw r||s (`ieee-p1363`) form JOSE and EUDI wallets require, so a credential this issuer signed was rejected by conformant verifiers and the library rejected conformant holders' key-binding JWTs. The issuer JWT and the holder KB-JWT now both sign and verify with `ieee-p1363`, matching the rest of the framework's JOSE signers. · *Membership queries on an encrypted column now work* — Querying an encrypted (sealed) column with `IN` / `$in` - `b.db.from(table).whereIn("email", [...])` or `b.db.collection(table).find({ email: { $in: [...] } })` - threw, because the sealed-field-to-derived-hash rewrite passed the whole candidate array to the hash lookup as a single value. Each candidate is now hashed individually and matched against both its active keyed digest and its legacy digest, so membership queries on an encrypted column return the right rows, including rows written before the lookup-hash default changed. · *A timing-out background task no longer drops the task queued behind it* — When a `b.workerPool` task timed out or its worker errored, the slot was returned to the idle pool and drained one moment before it was marked for recycling, so a task queued behind it could be dispatched to the worker about to be terminated - and came back as `workerpool/worker-exit` (or hung) instead of running. The slot is now marked recycling before the queue is drained, so the queued task waits for the replacement worker. · *The outbox recovers a job stranded by a crashed publisher* — `b.outbox` claims a row by flipping it to in-flight, but the claim scan only selected pending rows, so a publisher that crashed between claiming a row and recording delivery left that row in-flight forever - the event was silently dropped, breaking at-least-once delivery. The outbox now stamps each claim with a timestamp and, at the start of every poll, returns any in-flight row older than the claim lease (`claimReclaimMs`, default 5 minutes) to the pending pool so it is delivered. An existing outbox table gains the new column automatically. · *A retention preview no longer rewrites the whole database* — Previewing a retention rule with `b.retention` (`run(name, { dryRun: true })` or the `retention preview` command) ran a full database VACUUM for every candidate row under a regulated posture (gdpr / hipaa / and similar), because the per-row erase - which schedules the vacuum - ran before the dry-run check. A preview now computes what it would erase without touching the database; the vacuum runs only on a real erase. · *On-read lookup-digest upgrade runs on Postgres and MySQL* — When `b.cryptoField.unsealRow` re-hashed a legacy lookup digest to the current keyed form and persisted it, the UPDATE was always built for SQLite, so on a Postgres or MySQL handle the durable rewrite quoted identifiers for the wrong dialect and silently no-opped - the legacy digest stayed on disk and the migration never completed off SQLite. The rewrite now uses the handle's own dialect. **Security:** *SAML SubjectConfirmation without NotOnOrAfter is rejected* — `b.auth.saml` SP response verification treated the `NotOnOrAfter` attribute on a Bearer SubjectConfirmationData as optional: a confirmation that omitted it was accepted with no upper bound on the assertion's freshness - a captured assertion replayable indefinitely. SAML 2.0 Web Browser SSO Profile §4.1.4.2 requires Bearer confirmations to carry NotOnOrAfter. `verifyResponse` now rejects a confirmation that is missing NotOnOrAfter, has an unparseable value, or is expired; the Holder-of-Key path (Profile §3.1) is hardened the same way, including the previously-accepted unparseable-value case. · *ID-token expiry can no longer be disabled on a normal token* — `b.auth.oauth`'s `verifyIdToken` honored a `skipExpCheck` option with no constraint, so any caller could verify an expired - or replayed - ID token. That option exists only for OIDC Back-Channel Logout tokens, which carry no `exp`. It is now self-guarding: `verifyIdToken` rejects `skipExpCheck` (`auth-oauth/skip-exp-check-not-allowed`) on any token that lacks the back-channel-logout event claim, and even for a logout token it enforces an `iat` freshness floor (`auth-oauth/logout-token-stale`). The internal logout path is unaffected. · *Server-sent-event channels bound their outbound buffer* — An SSE channel wrote to the response with no regard for backpressure and no cap on buffered bytes, so a single stalled client could make the server buffer events until the heap was exhausted (a memory-exhaustion denial of service). Each channel now tracks its unflushed-byte count and, past a per-channel cap (`maxBufferedBytes`, default 1 MiB), closes the connection and throws `sse/backpressure` - evicting the slow consumer instead of buffering without limit. A client that keeps up is never affected. · *URL and host canonicalizer for SSRF-safe comparison* — New `b.safeUrl.canonicalize(url, opts?)` and `b.ssrfGuard.canonicalizeHost(host)` return the canonical, comparable form of a URL or host: scheme and host lowercased, IDN hosts emitted as their punycode A-label (a confusable / mixed-script host is rejected), every base of an IP literal (decimal, octal, hex, dotted, IPv4-mapped and zero-compressed IPv6) collapsed to one canonical address, default ports stripped, trailing-dot hosts normalized, and path percent-encoding normalized per RFC 3986. Use it to build host allowlists, deduplicate URLs, or compare a fetch target so an allowlist check can't be bypassed by encoding the same address a different way.
package/README.md CHANGED
@@ -194,6 +194,8 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
194
194
  - **AEDT bias audit** — NYC Local Law 144 bias-audit figures (`b.ai.aedtBiasAudit`): selection / scoring rates and EEOC four-fifths-rule impact ratios across sex, race/ethnicity, and their intersection, with the most-selected group and adverse-impact flags (impact ratio < 0.8) for the annual published summary; sub-2% categories excludable per DCWP §5-301
195
195
  - **Frontier AI protocol** — California SB 53 (Transparency in Frontier AI Act) obligations (`b.ai.frontierModelProtocol`): classify the frontier-model (>10²⁶ training FLOPs) and large-frontier-developer (>$500M revenue) thresholds, enumerate the resulting obligations, check a safety framework for required elements, and build a critical-safety-incident report with the 15-day / 24-hour California OES notification deadline (`.incidentReport`)
196
196
  - **GPAI Code-of-Practice adherence** — signed, tamper-evident EU AI Act Art. 53 / 55 adherence declarations with a regulation-derived obligation set (a systemic-risk model omitting the Art. 55 chapter is refused) and SHA3-512 evidence binding, emitted inside an ML-DSA-87-signed CycloneDX 1.6 ML-BOM and replay-checked on verify (`b.compliance.aiAct.gpai.declareAdherence` / `verifyAdherence`)
197
+ - **EU Digital Services Act records** — dated, frozen builders for the content-governance loop the DSA (Reg 2022/2065) requires: Art. 16 notice-and-action with the action-due window, Art. 17 statement of reasons (exactly one legal-or-contractual ground, redress routes, automated-decision flag), and the Art. 15 / 24(3) transparency report (`b.dsa.noticeAndAction` / `statementOfReasons` / `transparencyReport`)
198
+ - **China PIPL cross-border transfer records** — Art. 38/40/55 assessment that forces a CAC security assessment over a self-selected standard contract when a critical-information-infrastructure operator, important data, or the volume / sensitive-PI thresholds are in scope, plus an Art. 40 security-assessment certificate with a 3-year validity clock (`b.pipl.sccFilingAssessment` / `securityAssessmentCertificate`)
197
199
 
198
200
  ### Compliance regimes
199
201
 
package/index.js CHANGED
@@ -89,6 +89,8 @@ audit.export = function (opts) {
89
89
  var auditChain = require("./lib/audit-chain");
90
90
  var consent = require("./lib/consent");
91
91
  var privacy = require("./lib/privacy");
92
+ var dsa = require("./lib/dsa");
93
+ var pipl = require("./lib/pipl-cn");
92
94
  var subject = require("./lib/subject");
93
95
  var session = require("./lib/session");
94
96
  var storage = require("./lib/storage");
@@ -467,6 +469,8 @@ module.exports = {
467
469
  events: events,
468
470
  consent: consent,
469
471
  privacy: privacy,
472
+ dsa: dsa,
473
+ pipl: pipl,
470
474
  subject: subject,
471
475
  session: session,
472
476
  storage: storage,
package/lib/audit.js CHANGED
@@ -343,6 +343,7 @@ var FRAMEWORK_NAMESPACES = [
343
343
  // (role-switching, RLS-shaped events)
344
344
  "dkim", // b.mail.dkim (DKIM-Signature generation events)
345
345
  "dora", // b.dora (DORA Article 17: dora.incident.classified / reported / draftFinal)
346
+ "dsa", // b.dsa (EU Digital Services Act: dsa.notice.recorded / dsa.sor.recorded / dsa.transparency_report.generated)
346
347
  "dsr", // b.dsr (Data Subject Rights workflow: dsr.ticket.* / dsr.source.*)
347
348
  "dual", // b.dualControl (dual.grant.requested / approved / denied / consumed / expired / self_approval_denied)
348
349
  "mail", // b.mail (b.mail-bounce uses "system.mail.*")
@@ -357,6 +358,7 @@ var FRAMEWORK_NAMESPACES = [
357
358
  "inbox", // b.inbox (inbox.received / handled / handle_failed / swept)
358
359
  "flag", // b.flag (flag.evaluated / flag.evaluation.error / flag.cache.bust)
359
360
  "permissions", // b.permissions
361
+ "pipl", // b.pipl (China PIPL cross-border: pipl.transfer.assessed / pipl.security_assessment.recorded)
360
362
  "pqcagent", // b.pqcAgent (pqcagent.operator_group.accepted)
361
363
  "privacy", // b.privacy (privacy.vendor_review.recorded)
362
364
  "restore", // b.restore
package/lib/dsa.js ADDED
@@ -0,0 +1,482 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.dsa
4
+ * @nav Compliance
5
+ * @title Digital Services Act
6
+ *
7
+ * @intro
8
+ * Record-builders for the operator workflows the EU Digital Services
9
+ * Act (Regulation (EU) 2022/2065) requires an online intermediary or
10
+ * platform to keep on file. Three dated, frozen attestation records
11
+ * cover the regulation's core content-governance loop:
12
+ *
13
+ * - `noticeAndAction` (Art. 16) records a notice a third party
14
+ * submits against a piece of content and computes the window
15
+ * inside which the provider must act on it.
16
+ * - `statementOfReasons` (Art. 17) records the moderation decision
17
+ * taken on a piece of content, its legal or contractual ground,
18
+ * the facts relied on, whether it was automated, and the redress
19
+ * routes offered to the affected recipient.
20
+ * - `transparencyReport` (Art. 15 / Art. 24(3)) aggregates the
21
+ * period counts a provider must publish — notices received,
22
+ * actions taken, automated decisions, appeals — into a report
23
+ * record with the next due date.
24
+ *
25
+ * The builders follow the operator-feeds-metadata pattern: the
26
+ * operator supplies the facts and each function returns a frozen,
27
+ * timestamped record that composes into the operator's own retention /
28
+ * audit / export sink. None of them persist to the framework or touch
29
+ * the network. A best-effort `dsa.*` audit event fires when an audit
30
+ * sink is wired. They map to the `dsa` compliance posture, which
31
+ * cascades ML-DSA-87 audit-chain signing and a TLS 1.3 floor.
32
+ *
33
+ * @card
34
+ * EU Digital Services Act (Reg 2022/2065) record-builders — Art. 16 notice-and-action, Art. 17 statement of reasons, Art. 15/24(3) transparency report.
35
+ */
36
+
37
+ var validateOpts = require("./validate-opts");
38
+ var lazyRequire = require("./lazy-require");
39
+ var C = require("./constants");
40
+ var { DsaError } = require("./framework-error");
41
+
42
+ var audit = lazyRequire(function () { return require("./audit"); });
43
+
44
+ // ---- Art. 16 notice-and-action ----
45
+
46
+ // The notice categories Art. 16(2) expects a notice-and-action
47
+ // mechanism to distinguish. A notice that alleges illegal content
48
+ // (Art. 16(2)(a)-(d)) starts the diligent-and-timely action clock and
49
+ // MUST be answered with an Art. 17 statement of reasons when the
50
+ // provider acts on it; a terms-of-service notice need not be.
51
+ var NOTICE_TYPES = Object.freeze({
52
+ "illegal-content": { statementOfReasonsRequired: true, description: "Notice alleges the content is illegal under Union or member-state law (Art. 16(2))." },
53
+ "terms-violation": { statementOfReasonsRequired: false, description: "Notice alleges the content breaches the provider's terms and conditions." },
54
+ "ip-infringement": { statementOfReasonsRequired: true, description: "Notice alleges intellectual-property infringement (a sub-case of illegal content)." },
55
+ "other": { statementOfReasonsRequired: false, description: "Any other notice category the provider's mechanism accepts." },
56
+ });
57
+ var NOTICE_TYPE_IDS = Object.keys(NOTICE_TYPES);
58
+
59
+ // Who submitted the notice. A trusted flagger (Art. 22) is processed
60
+ // with priority; the field is recorded so the provider can evidence
61
+ // the Art. 22(1) priority-handling obligation.
62
+ var SUBMITTER_TYPES = Object.freeze(["individual", "trusted-flagger", "authority", "rights-holder", "other"]);
63
+
64
+ // Default action window. Art. 16(6) requires action "in a timely,
65
+ // diligent, non-arbitrary and objective manner"; it sets no fixed
66
+ // hour count, so the framework default is a conservative 24h SLA that
67
+ // operators override per their own published policy via actionWindowMs.
68
+ var DEFAULT_ACTION_WINDOW_MS = C.TIME.hours(24);
69
+
70
+ /**
71
+ * @primitive b.dsa.noticeAndAction
72
+ * @signature b.dsa.noticeAndAction(opts)
73
+ * @since 0.15.8
74
+ * @status stable
75
+ * @compliance dsa
76
+ * @related b.dsa.statementOfReasons, b.dsa.transparencyReport, b.compliance.describe
77
+ *
78
+ * Record an Art. 16 notice-and-action notice and compute the window
79
+ * inside which the provider must act on it. The operator supplies the
80
+ * notice facts — the content it targets, the alleged category, the
81
+ * substantiating reason, when it was submitted, and who submitted it —
82
+ * and `noticeAndAction` validates the shape, stamps `recordedAt`,
83
+ * derives `actionDueBy` from the submission time plus the action
84
+ * window, and flags whether acting on the notice will require an
85
+ * Art. 17 statement of reasons (true for illegal-content / IP notices).
86
+ * The returned record is frozen and is NOT framework-persisted —
87
+ * compose it into your retention / audit / export sink. A best-effort
88
+ * `dsa.notice.recorded` audit event fires when an audit sink is wired.
89
+ *
90
+ * @opts
91
+ * contentId: string, // required — the content the notice targets
92
+ * noticeType: string, // required — illegal-content | terms-violation | ip-infringement | other
93
+ * reason: string, // required — the notice's substantiation (Art. 16(2)(a))
94
+ * submittedAt: number, // required — epoch ms the notice was submitted
95
+ * submitterType: string, // required — individual | trusted-flagger | authority | rights-holder | other
96
+ * noticeId: string, // optional — operator notice id; defaults to "dsa-notice-<submittedAt>"
97
+ * actionWindowMs: number, // optional — SLA window; default 24h (Art. 16(6) "timely")
98
+ *
99
+ * @example
100
+ * var n = b.dsa.noticeAndAction({
101
+ * contentId: "post-9931",
102
+ * noticeType: "illegal-content",
103
+ * reason: "Depicts a sale prohibited under national law.",
104
+ * submittedAt: Date.now(),
105
+ * submitterType: "trusted-flagger",
106
+ * });
107
+ * // → { noticeId, contentId, noticeType, status: "recorded",
108
+ * // recordedAt, actionDueBy, statementOfReasonsRequired: true }
109
+ */
110
+ function noticeAndAction(opts) {
111
+ validateOpts.requireObject(opts, "b.dsa.noticeAndAction: opts", DsaError, "dsa/bad-opts");
112
+ validateOpts(opts, [
113
+ "contentId", "noticeType", "reason", "submittedAt", "submitterType",
114
+ "noticeId", "actionWindowMs",
115
+ ], "b.dsa.noticeAndAction");
116
+ validateOpts.requireNonEmptyString(opts.contentId, "b.dsa.noticeAndAction: opts.contentId", DsaError, "dsa/bad-content-id");
117
+ validateOpts.requireNonEmptyString(opts.noticeType, "b.dsa.noticeAndAction: opts.noticeType", DsaError, "dsa/bad-notice-type");
118
+ if (NOTICE_TYPE_IDS.indexOf(opts.noticeType) === -1) {
119
+ throw new DsaError("dsa/unknown-notice-type",
120
+ "b.dsa.noticeAndAction: unknown noticeType '" + opts.noticeType +
121
+ "' (allowed: " + NOTICE_TYPE_IDS.join(", ") + ")");
122
+ }
123
+ validateOpts.requireNonEmptyString(opts.reason, "b.dsa.noticeAndAction: opts.reason", DsaError, "dsa/bad-reason");
124
+ if (typeof opts.submittedAt !== "number" || !isFinite(opts.submittedAt) || opts.submittedAt <= 0) {
125
+ throw new DsaError("dsa/bad-submitted-at",
126
+ "b.dsa.noticeAndAction: opts.submittedAt must be a positive epoch-ms number");
127
+ }
128
+ validateOpts.requireNonEmptyString(opts.submitterType, "b.dsa.noticeAndAction: opts.submitterType", DsaError, "dsa/bad-submitter-type");
129
+ if (SUBMITTER_TYPES.indexOf(opts.submitterType) === -1) {
130
+ throw new DsaError("dsa/unknown-submitter-type",
131
+ "b.dsa.noticeAndAction: unknown submitterType '" + opts.submitterType +
132
+ "' (allowed: " + SUBMITTER_TYPES.join(", ") + ")");
133
+ }
134
+ validateOpts.optionalNonEmptyString(opts.noticeId, "b.dsa.noticeAndAction: opts.noticeId", DsaError, "dsa/bad-notice-id");
135
+ var actionWindowMs = opts.actionWindowMs === undefined
136
+ ? DEFAULT_ACTION_WINDOW_MS
137
+ : validateOpts.optionalPositiveFinite(opts.actionWindowMs, "b.dsa.noticeAndAction: opts.actionWindowMs", DsaError, "dsa/bad-action-window");
138
+
139
+ var recordedAt = Date.now();
140
+ var sorRequired = NOTICE_TYPES[opts.noticeType].statementOfReasonsRequired;
141
+ var record = Object.freeze({
142
+ noticeId: opts.noticeId || ("dsa-notice-" + opts.submittedAt),
143
+ contentId: opts.contentId,
144
+ noticeType: opts.noticeType,
145
+ submitterType: opts.submitterType,
146
+ reason: opts.reason,
147
+ submittedAt: opts.submittedAt,
148
+ status: "recorded",
149
+ recordedAt: recordedAt,
150
+ actionDueBy: opts.submittedAt + actionWindowMs,
151
+ statementOfReasonsRequired: sorRequired,
152
+ });
153
+ try {
154
+ audit().safeEmit({
155
+ action: "dsa.notice.recorded",
156
+ outcome: "success",
157
+ metadata: {
158
+ noticeId: record.noticeId,
159
+ contentId: record.contentId,
160
+ noticeType: record.noticeType,
161
+ submitterType: record.submitterType,
162
+ actionDueBy: record.actionDueBy,
163
+ statementOfReasonsRequired: record.statementOfReasonsRequired,
164
+ },
165
+ });
166
+ } catch (_e) { /* drop-silent — audit is best-effort, never block the builder */ }
167
+ return record;
168
+ }
169
+
170
+ // ---- Art. 17 statement of reasons ----
171
+
172
+ // The moderation decisions Art. 17(1) covers. Each restricts the
173
+ // content or the recipient's account; the statement of reasons must
174
+ // state which (Art. 17(3)(a)).
175
+ var DECISIONS = Object.freeze([
176
+ "content-removed",
177
+ "content-disabled",
178
+ "content-demoted",
179
+ "age-restricted",
180
+ "monetisation-removed",
181
+ "account-suspended",
182
+ "account-terminated",
183
+ "no-action",
184
+ ]);
185
+
186
+ // The redress routes Art. 17(3)(f) requires the statement to point the
187
+ // recipient to. At least one must be offered for a restrictive
188
+ // decision.
189
+ var REDRESS_OPTIONS = Object.freeze([
190
+ "internal-complaint", // Art. 20 internal complaint-handling system
191
+ "out-of-court-settlement", // Art. 21 out-of-court dispute settlement
192
+ "judicial-redress", // Art. 17(3)(f) — judicial remedy
193
+ ]);
194
+
195
+ /**
196
+ * @primitive b.dsa.statementOfReasons
197
+ * @signature b.dsa.statementOfReasons(opts)
198
+ * @since 0.15.8
199
+ * @status stable
200
+ * @compliance dsa
201
+ * @related b.dsa.noticeAndAction, b.dsa.transparencyReport, b.compliance.describe
202
+ *
203
+ * Record an Art. 17 statement of reasons for a content-moderation
204
+ * decision. Whenever a provider restricts content (or a recipient's
205
+ * account) it must give the affected recipient a clear, specific
206
+ * statement of reasons; this builder records that statement as a frozen
207
+ * dated record. The operator supplies the decision, the legal ground
208
+ * (Art. 17(3)(d)) or the contractual ground (Art. 17(3)(e)) it rests
209
+ * on, the facts relied on (Art. 17(3)(c)), whether the decision was
210
+ * taken by automated means (Art. 17(3)(c)), and the redress routes
211
+ * offered (Art. 17(3)(f)). Exactly one of `legalGround` /
212
+ * `contractualGround` is required so the ground is never left implicit.
213
+ * The returned record is frozen and is NOT framework-persisted — also
214
+ * submit it to the Commission's DSA Transparency Database per Art. 24(5)
215
+ * from your own pipeline. A best-effort `dsa.sor.recorded` audit event
216
+ * fires when an audit sink is wired.
217
+ *
218
+ * @opts
219
+ * contentId: string, // required — the content the decision concerns
220
+ * decision: string, // required — content-removed | content-disabled | ... | no-action
221
+ * facts: string, // required — the facts and circumstances relied on (Art. 17(3)(c))
222
+ * automated: boolean, // required — was the decision taken by automated means (Art. 17(3)(c))
223
+ * redressOptions: string[], // required — internal-complaint | out-of-court-settlement | judicial-redress
224
+ * legalGround: string, // one-of-two — the legal ground when the decision rests on illegality (Art. 17(3)(d))
225
+ * contractualGround: string, // one-of-two — the T&C clause when the decision rests on the contract (Art. 17(3)(e))
226
+ * sorId: string, // optional — operator id; defaults to "dsa-sor-<recordedAt>"
227
+ * noticeId: string, // optional — the Art. 16 notice this answers, if any
228
+ * territorialScope: string, // optional — geographic scope of the restriction (Art. 17(3)(b))
229
+ *
230
+ * @example
231
+ * var s = b.dsa.statementOfReasons({
232
+ * contentId: "post-9931",
233
+ * decision: "content-removed",
234
+ * legalGround: "National law prohibiting the depicted sale.",
235
+ * facts: "Listing offered a prohibited item for sale.",
236
+ * automated: false,
237
+ * redressOptions: ["internal-complaint", "judicial-redress"],
238
+ * });
239
+ * // → { sorId, contentId, decision, recordedAt, groundType, automated, ... }
240
+ */
241
+ function statementOfReasons(opts) {
242
+ validateOpts.requireObject(opts, "b.dsa.statementOfReasons: opts", DsaError, "dsa/bad-opts");
243
+ validateOpts(opts, [
244
+ "contentId", "decision", "facts", "automated", "redressOptions",
245
+ "legalGround", "contractualGround", "sorId", "noticeId", "territorialScope",
246
+ ], "b.dsa.statementOfReasons");
247
+ validateOpts.requireNonEmptyString(opts.contentId, "b.dsa.statementOfReasons: opts.contentId", DsaError, "dsa/bad-content-id");
248
+ validateOpts.requireNonEmptyString(opts.decision, "b.dsa.statementOfReasons: opts.decision", DsaError, "dsa/bad-decision");
249
+ if (DECISIONS.indexOf(opts.decision) === -1) {
250
+ throw new DsaError("dsa/unknown-decision",
251
+ "b.dsa.statementOfReasons: unknown decision '" + opts.decision +
252
+ "' (allowed: " + DECISIONS.join(", ") + ")");
253
+ }
254
+ validateOpts.requireNonEmptyString(opts.facts, "b.dsa.statementOfReasons: opts.facts", DsaError, "dsa/bad-facts");
255
+ if (typeof opts.automated !== "boolean") {
256
+ throw new DsaError("dsa/bad-automated",
257
+ "b.dsa.statementOfReasons: opts.automated must be a boolean (Art. 17(3)(c) — was the decision automated)");
258
+ }
259
+ // Exactly one ground — never both, never neither. Art. 17(3)(d)/(e)
260
+ // require the statement to state the specific ground; leaving it
261
+ // implicit or asserting two grounds at once is the compliance-theater
262
+ // shape this refuses.
263
+ validateOpts.optionalNonEmptyString(opts.legalGround, "b.dsa.statementOfReasons: opts.legalGround", DsaError, "dsa/bad-legal-ground");
264
+ validateOpts.optionalNonEmptyString(opts.contractualGround, "b.dsa.statementOfReasons: opts.contractualGround", DsaError, "dsa/bad-contractual-ground");
265
+ var hasLegal = typeof opts.legalGround === "string" && opts.legalGround.length > 0;
266
+ var hasContractual = typeof opts.contractualGround === "string" && opts.contractualGround.length > 0;
267
+ if (hasLegal === hasContractual) {
268
+ throw new DsaError("dsa/ground-required",
269
+ "b.dsa.statementOfReasons: supply exactly one of legalGround (Art. 17(3)(d)) or " +
270
+ "contractualGround (Art. 17(3)(e)) — got " + (hasLegal ? "both" : "neither"));
271
+ }
272
+ if (!Array.isArray(opts.redressOptions) || opts.redressOptions.length === 0) {
273
+ throw new DsaError("dsa/redress-required",
274
+ "b.dsa.statementOfReasons: opts.redressOptions must be a non-empty array (Art. 17(3)(f)) — " +
275
+ "allowed: " + REDRESS_OPTIONS.join(", "));
276
+ }
277
+ opts.redressOptions.forEach(function (r, i) {
278
+ if (typeof r !== "string" || REDRESS_OPTIONS.indexOf(r) === -1) {
279
+ throw new DsaError("dsa/unknown-redress-option",
280
+ "b.dsa.statementOfReasons: redressOptions[" + i + "] '" + r +
281
+ "' is not a recognised redress route (allowed: " + REDRESS_OPTIONS.join(", ") + ")");
282
+ }
283
+ });
284
+ validateOpts.optionalNonEmptyString(opts.sorId, "b.dsa.statementOfReasons: opts.sorId", DsaError, "dsa/bad-sor-id");
285
+ validateOpts.optionalNonEmptyString(opts.noticeId, "b.dsa.statementOfReasons: opts.noticeId", DsaError, "dsa/bad-notice-id");
286
+ validateOpts.optionalNonEmptyString(opts.territorialScope, "b.dsa.statementOfReasons: opts.territorialScope", DsaError, "dsa/bad-territorial-scope");
287
+
288
+ var recordedAt = Date.now();
289
+ var record = Object.freeze({
290
+ sorId: opts.sorId || ("dsa-sor-" + recordedAt),
291
+ contentId: opts.contentId,
292
+ noticeId: opts.noticeId || null,
293
+ decision: opts.decision,
294
+ groundType: hasLegal ? "legal" : "contractual",
295
+ legalGround: hasLegal ? opts.legalGround : null,
296
+ contractualGround: hasContractual ? opts.contractualGround : null,
297
+ facts: opts.facts,
298
+ automated: opts.automated,
299
+ redressOptions: Object.freeze(opts.redressOptions.slice()),
300
+ territorialScope: opts.territorialScope || null,
301
+ recordedAt: recordedAt,
302
+ });
303
+ try {
304
+ audit().safeEmit({
305
+ action: "dsa.sor.recorded",
306
+ outcome: "success",
307
+ metadata: {
308
+ sorId: record.sorId,
309
+ contentId: record.contentId,
310
+ decision: record.decision,
311
+ groundType: record.groundType,
312
+ automated: record.automated,
313
+ noticeId: record.noticeId,
314
+ },
315
+ });
316
+ } catch (_e) { /* drop-silent — audit is best-effort, never block the builder */ }
317
+ return record;
318
+ }
319
+
320
+ // ---- Art. 15 / Art. 24(3) transparency report ----
321
+
322
+ // The metric fields Art. 15(1) + Art. 24 expect a transparency report
323
+ // to carry. Every metric is a non-negative integer count over the
324
+ // reporting period; an omitted metric defaults to 0 so a partial
325
+ // report still produces a complete, comparable shape.
326
+ var METRIC_FIELDS = Object.freeze([
327
+ "noticesReceived", // Art. 15(1)(b) — notices submitted under Art. 16
328
+ "actionsTaken", // Art. 15(1)(b) — actions taken on those notices
329
+ "automatedDecisions", // Art. 15(1)(e) — content moderation by automated means
330
+ "ownInitiativeActions", // Art. 15(1)(c) — own-initiative content moderation
331
+ "statementsOfReasons", // Art. 24(1) — statements of reasons issued
332
+ "appeals", // Art. 24(1)(a) — Art. 20 internal complaints received
333
+ "appealsUpheld", // Art. 24(1)(a) — complaints decided in the recipient's favour
334
+ "outOfCourtDisputes", // Art. 24(1)(b) — Art. 21 out-of-court settlements
335
+ "accountSuspensions", // Art. 23 — suspensions for misuse
336
+ ]);
337
+
338
+ // The annual re-report clock. Art. 15(1) requires reporting "at least
339
+ // once a year"; the next-due default is one year after the period end.
340
+ var REPORT_PERIOD_MS = C.TIME.days(365);
341
+
342
+ /**
343
+ * @primitive b.dsa.transparencyReport
344
+ * @signature b.dsa.transparencyReport(opts)
345
+ * @since 0.15.8
346
+ * @status stable
347
+ * @compliance dsa
348
+ * @related b.dsa.noticeAndAction, b.dsa.statementOfReasons, b.compliance.describe
349
+ *
350
+ * Build an Art. 15 (all intermediary services) / Art. 24(3) (online
351
+ * platforms) transparency report. The operator supplies the reporting
352
+ * period and the period counts — notices received, actions taken,
353
+ * automated decisions, appeals, and so on — and `transparencyReport`
354
+ * validates the shape, normalises every metric to a non-negative
355
+ * integer (omitted metrics default to 0 so a partial report still has a
356
+ * complete, comparable shape), stamps `generatedAt`, and computes
357
+ * `nextReportDueBy` one year after the period end (Art. 15(1) "at least
358
+ * once a year"). The returned report is frozen and is NOT
359
+ * framework-persisted — publish it from your own pipeline. A
360
+ * best-effort `dsa.transparency_report.generated` audit event fires
361
+ * when an audit sink is wired.
362
+ *
363
+ * @opts
364
+ * period: object, // required — { from: number, to: number } epoch-ms window (from < to)
365
+ * metrics: object, // optional — { <metric>: number } period counts; see b.dsa.listTransparencyMetrics()
366
+ * reportId: string, // optional — operator id; defaults to "dsa-transparency-<to>"
367
+ * service: string, // optional — the service the report covers
368
+ *
369
+ * @example
370
+ * var r = b.dsa.transparencyReport({
371
+ * period: { from: Date.UTC(2025, 0, 1), to: Date.UTC(2025, 11, 31) },
372
+ * metrics: { noticesReceived: 1200, actionsTaken: 940, automatedDecisions: 610, appeals: 75 },
373
+ * });
374
+ * // → { reportId, period, metrics: {...all 9 normalised...}, generatedAt, nextReportDueBy }
375
+ */
376
+ function transparencyReport(opts) {
377
+ validateOpts.requireObject(opts, "b.dsa.transparencyReport: opts", DsaError, "dsa/bad-opts");
378
+ validateOpts(opts, ["period", "metrics", "reportId", "service"], "b.dsa.transparencyReport");
379
+ if (!opts.period || typeof opts.period !== "object" || Array.isArray(opts.period)) {
380
+ throw new DsaError("dsa/bad-period",
381
+ "b.dsa.transparencyReport: opts.period must be a { from, to } object of epoch-ms numbers");
382
+ }
383
+ var from = opts.period.from;
384
+ var to = opts.period.to;
385
+ if (typeof from !== "number" || !isFinite(from) || from <= 0 ||
386
+ typeof to !== "number" || !isFinite(to) || to <= 0) {
387
+ throw new DsaError("dsa/bad-period",
388
+ "b.dsa.transparencyReport: opts.period.from and opts.period.to must be positive epoch-ms numbers");
389
+ }
390
+ if (from >= to) {
391
+ throw new DsaError("dsa/bad-period-order",
392
+ "b.dsa.transparencyReport: opts.period.from must be strictly before opts.period.to");
393
+ }
394
+ validateOpts.optionalNonEmptyString(opts.reportId, "b.dsa.transparencyReport: opts.reportId", DsaError, "dsa/bad-report-id");
395
+ validateOpts.optionalNonEmptyString(opts.service, "b.dsa.transparencyReport: opts.service", DsaError, "dsa/bad-service");
396
+
397
+ var supplied = opts.metrics;
398
+ if (supplied !== undefined && supplied !== null &&
399
+ (typeof supplied !== "object" || Array.isArray(supplied))) {
400
+ throw new DsaError("dsa/bad-metrics",
401
+ "b.dsa.transparencyReport: opts.metrics must be a plain object of metric counts");
402
+ }
403
+ supplied = supplied || {};
404
+ // Reject unknown metric keys — a misspelled metric would otherwise
405
+ // silently drop out of the published report.
406
+ Object.keys(supplied).forEach(function (k) {
407
+ if (METRIC_FIELDS.indexOf(k) === -1) {
408
+ throw new DsaError("dsa/unknown-metric",
409
+ "b.dsa.transparencyReport: unknown metric '" + k +
410
+ "' (see b.dsa.listTransparencyMetrics())");
411
+ }
412
+ });
413
+ var metrics = {};
414
+ METRIC_FIELDS.forEach(function (field) {
415
+ var v = supplied[field];
416
+ if (v === undefined || v === null) { metrics[field] = 0; return; }
417
+ if (typeof v !== "number" || !isFinite(v) || v < 0 || Math.floor(v) !== v) {
418
+ throw new DsaError("dsa/bad-metric-value",
419
+ "b.dsa.transparencyReport: metrics." + field +
420
+ " must be a non-negative integer, got " +
421
+ (typeof v === "number" ? String(v) : typeof v));
422
+ }
423
+ metrics[field] = v;
424
+ });
425
+
426
+ var generatedAt = Date.now();
427
+ var report = Object.freeze({
428
+ reportId: opts.reportId || ("dsa-transparency-" + to),
429
+ service: opts.service || null,
430
+ period: Object.freeze({ from: from, to: to }),
431
+ metrics: Object.freeze(metrics),
432
+ generatedAt: generatedAt,
433
+ nextReportDueBy: to + REPORT_PERIOD_MS,
434
+ });
435
+ try {
436
+ audit().safeEmit({
437
+ action: "dsa.transparency_report.generated",
438
+ outcome: "success",
439
+ metadata: {
440
+ reportId: report.reportId,
441
+ service: report.service,
442
+ periodFrom: from,
443
+ periodTo: to,
444
+ noticesReceived: metrics.noticesReceived,
445
+ actionsTaken: metrics.actionsTaken,
446
+ },
447
+ });
448
+ } catch (_e) { /* drop-silent — audit is best-effort, never block the builder */ }
449
+ return report;
450
+ }
451
+
452
+ /**
453
+ * @primitive b.dsa.listTransparencyMetrics
454
+ * @signature b.dsa.listTransparencyMetrics()
455
+ * @since 0.15.8
456
+ * @status stable
457
+ * @related b.dsa.transparencyReport
458
+ *
459
+ * Return the frozen list of metric field names a `transparencyReport`
460
+ * aggregates — each maps to an Art. 15 / Art. 24 reporting obligation.
461
+ * Use it to render a data-entry form or to enumerate the counts the
462
+ * report normalises.
463
+ *
464
+ * @example
465
+ * b.dsa.listTransparencyMetrics();
466
+ * // → ["noticesReceived", "actionsTaken", "automatedDecisions", ...]
467
+ */
468
+ function listTransparencyMetrics() {
469
+ return METRIC_FIELDS;
470
+ }
471
+
472
+ module.exports = {
473
+ noticeAndAction: noticeAndAction,
474
+ statementOfReasons: statementOfReasons,
475
+ transparencyReport: transparencyReport,
476
+ listTransparencyMetrics: listTransparencyMetrics,
477
+ NOTICE_TYPES: NOTICE_TYPES,
478
+ DECISIONS: DECISIONS,
479
+ REDRESS_OPTIONS: REDRESS_OPTIONS,
480
+ METRIC_FIELDS: METRIC_FIELDS,
481
+ DsaError: DsaError,
482
+ };
@@ -389,6 +389,18 @@ var ComplianceError = defineClass("ComplianceError", { alwaysPermane
389
389
  // vendorReview opts object, a non-boolean clause attestation, or an
390
390
  // unknown clause key. Permanent — operator configuration, not transient.
391
391
  var PrivacyError = defineClass("PrivacyError", { alwaysPermanent: true });
392
+ // DsaError covers b.dsa config-time misuse (EU Digital Services Act,
393
+ // Reg 2022/2065): malformed noticeAndAction / statementOfReasons /
394
+ // transparencyReport opts, an unknown notice-type / decision / redress /
395
+ // metric key, a statement of reasons with neither or both grounds, an
396
+ // out-of-order reporting period. Permanent — operator-supplied record shape.
397
+ var DsaError = defineClass("DsaError", { alwaysPermanent: true });
398
+ // PiplError covers b.pipl config-time misuse (China PIPL cross-border
399
+ // transfer): a malformed sccFilingAssessment / securityAssessmentCertificate
400
+ // opts object, an unknown legalBasis / riskRating enum, an empty required
401
+ // array, a bad recordedAt clock, or a malformed injected audit sink.
402
+ // Permanent — operator configuration, not transient.
403
+ var PiplError = defineClass("PiplError", { alwaysPermanent: true });
392
404
  // SmtpPolicyError covers MTA-STS / DANE / TLS-RPT misuse: bad-policy
393
405
  // shape, fetch failures, TLSA-record format errors, missing records.
394
406
  // Permanent — these are policy / DNS configuration errors, not
@@ -722,6 +734,8 @@ module.exports = {
722
734
  DoraError: DoraError,
723
735
  ComplianceError: ComplianceError,
724
736
  PrivacyError: PrivacyError,
737
+ DsaError: DsaError,
738
+ PiplError: PiplError,
725
739
  SmtpPolicyError: SmtpPolicyError,
726
740
  MailAuthError: MailAuthError,
727
741
  MailArfError: MailArfError,
@@ -33,6 +33,11 @@ var safeAsync = require("./safe-async");
33
33
  var safeUrl = require("./safe-url");
34
34
  var { tearDownH2Session } = require("./http2-teardown");
35
35
  var { LogStreamError } = require("./framework-error");
36
+ var lazyRequire = require("./lazy-require");
37
+ // Lazy to break the observability <-> log-stream require cycle. Used only to
38
+ // scrub attribute values through the telemetry redactor before they cross the
39
+ // OTLP egress boundary (CWE-532).
40
+ var observability = lazyRequire(function () { return require("./observability"); });
36
41
 
37
42
  var _err = LogStreamError.factory;
38
43
  var _log = boot("log-stream-otlp-grpc");
@@ -133,7 +138,9 @@ function _encodeLogRecord(record) {
133
138
  // operators emitting > year-2255 timestamps. For ms-resolution
134
139
  // records the LSB nanos are 0; we still send fixed64.
135
140
  var tsNs = BigInt(tsMs) * 1000000n;
136
- var attrPieces = _encodeAttributes(record.meta).map(function (kvBody) {
141
+ // Scrub meta values through the telemetry redactor before the wire (CWE-532),
142
+ // matching the span/metric exporters' egress contract.
143
+ var attrPieces = _encodeAttributes(observability().redactAttrs(record.meta)).map(function (kvBody) {
137
144
  return pb.embeddedMessage(6, kvBody);
138
145
  });
139
146
  var msg = (record.message != null ? String(record.message) : "");
@@ -162,7 +169,7 @@ function _encodeScopeLogs(records, scopeName, scopeVersion) {
162
169
  // ResourceLogs (logs.proto): resource=1 (Resource),
163
170
  // scope_logs=2 (repeated ScopeLogs), schema_url=3
164
171
  function _encodeResourceLogs(records, cfg) {
165
- var resourceBody = _encodeResource(_resourceAttrs(cfg));
172
+ var resourceBody = _encodeResource(observability().redactAttrs(_resourceAttrs(cfg)));
166
173
  var scopeLogsBody = _encodeScopeLogs(records, cfg.scopeName, cfg.scopeVersion);
167
174
  return Buffer.concat([
168
175
  pb.embeddedMessage(1, resourceBody),
@@ -58,6 +58,11 @@ var httpClient = require("./http-client");
58
58
  var safeAsync = require("./safe-async");
59
59
  var safeUrl = require("./safe-url");
60
60
  var authHeader = require("./auth-header");
61
+ var lazyRequire = require("./lazy-require");
62
+ // Lazy to break the observability <-> log-stream require cycle (observability's
63
+ // log path can reach a log-stream sink). Used only to scrub attribute values
64
+ // through the telemetry redactor before they cross the OTLP egress boundary.
65
+ var observability = lazyRequire(function () { return require("./observability"); });
61
66
 
62
67
  var MAX_RESPONSE_BYTES = C.BYTES.mib(1);
63
68
  var FRAMEWORK_VERSION = (pkg && pkg.version) || "unknown";
@@ -141,7 +146,10 @@ function _toLogRecord(record) {
141
146
  var sev = SEVERITY[record.level] || SEVERITY.info;
142
147
  // OTel timeUnixNano is a string (JSON can't safely represent 64-bit ints).
143
148
  var nanos = String(BigInt(record.ts) * 1000000n);
144
- var attrs = record.meta ? _encodeAttrs(record.meta) : [];
149
+ // Telemetry is a first-class EGRESS sink: scrub every meta value through the
150
+ // redactor before it reaches the collector wire (CWE-532), the same contract
151
+ // the span/metric exporters hold.
152
+ var attrs = record.meta ? _encodeAttrs(observability().redactAttrs(record.meta)) : [];
145
153
  return {
146
154
  timeUnixNano: nanos,
147
155
  observedTimeUnixNano: nanos,
@@ -158,7 +166,7 @@ function _serializeBatch(records, cfg, scopeVersion) {
158
166
  resourceLogs: [
159
167
  {
160
168
  resource: {
161
- attributes: _encodeAttrs(resourceAttrs),
169
+ attributes: _encodeAttrs(observability().redactAttrs(resourceAttrs)),
162
170
  },
163
171
  scopeLogs: [
164
172
  {
@@ -291,10 +299,11 @@ function create(config) {
291
299
  }
292
300
 
293
301
  module.exports = {
294
- create: create,
302
+ create: create,
295
303
  // Exposed for tests + advanced operator wiring.
296
- _resolveUrl: _resolveUrl,
297
- _encodeAttrs: _encodeAttrs,
298
- _toLogRecord: _toLogRecord,
299
- SEVERITY: SEVERITY,
304
+ _resolveUrl: _resolveUrl,
305
+ _encodeAttrs: _encodeAttrs,
306
+ _toLogRecord: _toLogRecord,
307
+ _serializeBatch: _serializeBatch,
308
+ SEVERITY: SEVERITY,
300
309
  };
@@ -199,8 +199,9 @@ function getRedactor() {
199
199
  *
200
200
  * Run every value of a telemetry attribute map through the active redactor and
201
201
  * return a NEW `{ key: redactedValue }` object. The OTLP exporters call this on
202
- * span, span-event, metric, and resource attributes before serialization so no
203
- * attribute value crosses the egress boundary unscrubbed (CWE-532: insertion of
202
+ * span, span-event, metric, log-record, and resource attributes before
203
+ * serialization so no attribute value crosses the egress boundary unscrubbed
204
+ * (the HTTP-JSON and gRPC log sinks included) (CWE-532: insertion of
204
205
  * sensitive information into an externally-shipped sink). A key whose redactor
205
206
  * throws is DROPPED — failing toward dropping, never exporting the raw value;
206
207
  * `null` / `undefined` values pass through for the type-encoder to handle.
package/lib/pipl-cn.js ADDED
@@ -0,0 +1,377 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.pipl
4
+ * @nav Compliance
5
+ * @title PIPL (China)
6
+ *
7
+ * @intro
8
+ * China PIPL (Personal Information Protection Law) cross-border
9
+ * transfer record-builders. PIPL Art. 38 sets three lawful bases for
10
+ * transferring personal information outside the PRC: a CAC security
11
+ * assessment (Art. 40), the CAC standard contract (SCC), or
12
+ * certification by a CAC-accredited body. The CAC security assessment
13
+ * is MANDATORY — the operator may not self-select the standard
14
+ * contract — when the exporter is a critical-information-infrastructure
15
+ * operator (CIIO), handles "important data", or crosses the volume /
16
+ * sensitive-PI thresholds in the Measures for Security Assessment of
17
+ * Outbound Data Transfers.
18
+ *
19
+ * These primitives follow the operator-feeds-metadata pattern: the
20
+ * operator supplies the transfer's facts and the builder returns a
21
+ * frozen, dated record (plus a best-effort audit event) that composes
22
+ * into the operator's own retention / export sink. They perform NO
23
+ * network I/O and do NOT file anything with the CAC — they document
24
+ * the legal basis the operator must be able to produce on inspection.
25
+ *
26
+ * @card
27
+ * China PIPL cross-border transfer records — Art. 38/40/55 SCC + CAC security-assessment basis (sccFilingAssessment, securityAssessmentCertificate).
28
+ */
29
+
30
+ var lazyRequire = require("./lazy-require");
31
+ var validateOpts = require("./validate-opts");
32
+ var C = require("./constants");
33
+ var { PiplError } = require("./framework-error");
34
+
35
+ var audit = lazyRequire(function () { return require("./audit"); });
36
+
37
+ // PIPL Art. 38(1) lawful cross-border mechanisms. A standard contract or
38
+ // certification is a self-selectable basis; a security assessment is the
39
+ // mechanism the CAC Measures impose when a mandatory trigger is present.
40
+ var LEGAL_BASES = Object.freeze(["standard-contract", "security-assessment", "certification"]);
41
+
42
+ // Mandatory-security-assessment thresholds from the CAC 2024 Provisions on
43
+ // Promoting and Regulating Cross-Border Data Flows (Art. 7/8), which relaxed
44
+ // the original 2022 Measures. Crossing ANY of these forces the
45
+ // security-assessment mechanism — the operator cannot fall back to the
46
+ // standard contract or certification.
47
+ // - CIIO exporter, or "important data" in scope: always mandatory.
48
+ // - cumulative outbound NON-sensitive PI of MORE THAN 1,000,000 individuals
49
+ // since 1 January of the current year (the 100,000–1,000,000 band is the
50
+ // standard-contract / certification tier, NOT a security-assessment
51
+ // trigger).
52
+ // - cumulative outbound SENSITIVE PI of MORE THAN 10,000 individuals in that
53
+ // window.
54
+ // The thresholds are CUMULATIVE since 1 January and THIS transfer counts toward
55
+ // them — the transfer's own `volume` is sorted into the sensitive or
56
+ // non-sensitive bucket by `sensitivePI` and added to the running cumulative
57
+ // before the comparison.
58
+ var SECURITY_ASSESSMENT_NONSENSITIVE_PI_THRESHOLD = 1000000;
59
+ var SECURITY_ASSESSMENT_SENSITIVE_PI_THRESHOLD = 10000;
60
+
61
+ // Re-assessment / re-filing cadence. The CAC security assessment result
62
+ // is valid for 3 years (Measures Art. 14); the standard-contract +
63
+ // certification bases carry a PIPIA (Art. 55) that should be refreshed
64
+ // at least annually or on any material change. We stamp the longer 3-year
65
+ // clock for a mandated security assessment and a 1-year clock otherwise.
66
+ var SECURITY_ASSESSMENT_VALIDITY_DAYS = 365 * 3;
67
+ var STANDARD_REVIEW_DAYS = 365;
68
+
69
+ var SCC_ASSESSMENT_ALLOWED_KEYS = [
70
+ "assessmentId", "transferType", "recipientJurisdiction", "dataCategories",
71
+ "legalBasis", "volume", "sensitivePI", "ciio", "importantData",
72
+ "cumulativePI", "cumulativeSensitivePI", "recordedAt", "audit",
73
+ ];
74
+
75
+ var RISK_RATINGS = Object.freeze(["low", "medium", "high"]);
76
+
77
+ var SECURITY_CERT_ALLOWED_KEYS = [
78
+ "certId", "assessmentScope", "dataExporter", "overseasRecipient",
79
+ "riskRating", "safeguards", "filingRef", "recordedAt", "audit",
80
+ ];
81
+
82
+ // Resolve the audit sink: an operator-supplied b.audit-shaped object wins
83
+ // (so the call is captured even without a DB-backed global handler);
84
+ // otherwise fall back to the framework's global audit. Validated for shape
85
+ // at the call site so a malformed sink throws rather than silently no-ops.
86
+ function _resolveAudit(optsAudit, label) {
87
+ if (optsAudit === undefined || optsAudit === null) return audit();
88
+ return validateOpts.auditShape(optsAudit, label, PiplError, "pipl/bad-audit");
89
+ }
90
+
91
+ function _requireRecordedAt(value, label) {
92
+ if (typeof value !== "number" || !isFinite(value) || value <= 0) {
93
+ throw new PiplError("pipl/bad-recorded-at",
94
+ label + " must be a positive epoch-ms number");
95
+ }
96
+ return value;
97
+ }
98
+
99
+ /**
100
+ * @primitive b.pipl.sccFilingAssessment
101
+ * @signature b.pipl.sccFilingAssessment(opts)
102
+ * @since 0.15.8
103
+ * @status stable
104
+ * @compliance pipl-cn
105
+ * @related b.pipl.securityAssessmentCertificate, b.compliance.isCrossBorderRegulated, b.privacy.vendorReview
106
+ *
107
+ * Build a dated PIPL Art. 38 / Art. 55 cross-border transfer assessment
108
+ * and determine the lawful mechanism the transfer requires. PIPL Art. 38(1)
109
+ * permits three bases for moving personal information out of the PRC — the
110
+ * CAC standard contract (SCC), a CAC security assessment (Art. 40), or
111
+ * certification by a CAC-accredited body — but the Measures for Security
112
+ * Assessment of Outbound Data Transfers make the security assessment
113
+ * MANDATORY (the operator may NOT self-select the standard contract or
114
+ * certification) when the exporter is a critical-information-infrastructure
115
+ * operator (CIIO), exports "important data", handles personal information
116
+ * of more than 1,000,000 individuals, or has cumulatively exported PI of
117
+ * more than 100,000 individuals or sensitive PI of more than 10,000
118
+ * individuals since 1 January of the preceding year.
119
+ *
120
+ * The builder validates the operator-supplied facts, computes
121
+ * `securityAssessmentRequired` against those thresholds, resolves the
122
+ * `mechanismRequired` (forcing `security-assessment` when any trigger is
123
+ * present, otherwise honoring the operator's declared `legalBasis`), and
124
+ * stamps `recordedAt` plus a `nextReviewDueBy` re-assessment clock (3 years
125
+ * for a mandated security assessment per Measures Art. 14, otherwise the
126
+ * annual PIPIA refresh under Art. 55). The returned record is frozen and
127
+ * is NOT framework-persisted — compose it into your retention / audit /
128
+ * export sink. A best-effort `pipl.transfer.assessed` audit event fires.
129
+ *
130
+ * @opts
131
+ * assessmentId: string, // required — operator's identifier for this assessment
132
+ * transferType: string, // required — e.g. "intra-group", "processor", "controller-to-controller"
133
+ * recipientJurisdiction: string, // required — destination jurisdiction (e.g. "US", "EU", "SG")
134
+ * dataCategories: string[], // required — non-empty list of PI categories transferred
135
+ * legalBasis: string, // required — "standard-contract" | "security-assessment" | "certification"
136
+ * volume: number, // required — count of data subjects in this transfer (>= 0)
137
+ * sensitivePI: boolean, // required — whether the transfer includes sensitive PI (Art. 28)
138
+ * ciio: boolean, // optional — exporter is a CIIO (forces security assessment); default false
139
+ * importantData: boolean, // optional — transfer includes "important data" (forces it); default false
140
+ * cumulativePI: number, // optional — cumulative PI subjects exported since 1 Jan prior year; default 0
141
+ * cumulativeSensitivePI: number, // optional — cumulative sensitive-PI subjects exported in that window; default 0
142
+ * recordedAt: number, // required — epoch ms of this assessment
143
+ * audit: object, // optional — b.audit-shaped sink; default global b.audit
144
+ *
145
+ * @example
146
+ * var rec = b.pipl.sccFilingAssessment({
147
+ * assessmentId: "xfer-2026-001",
148
+ * transferType: "processor",
149
+ * recipientJurisdiction: "US",
150
+ * dataCategories: ["contact", "billing"],
151
+ * legalBasis: "standard-contract",
152
+ * volume: 5000,
153
+ * sensitivePI: false,
154
+ * recordedAt: Date.now(),
155
+ * });
156
+ * // → { assessmentId, mechanismRequired: "standard-contract",
157
+ * // securityAssessmentRequired: false, recordedAt, nextReviewDueBy, ... }
158
+ */
159
+ function sccFilingAssessment(opts) {
160
+ validateOpts.requireObject(opts, "b.pipl.sccFilingAssessment: opts", PiplError, "pipl/bad-opts");
161
+ validateOpts(opts, SCC_ASSESSMENT_ALLOWED_KEYS, "b.pipl.sccFilingAssessment");
162
+ validateOpts.requireNonEmptyString(opts.assessmentId,
163
+ "b.pipl.sccFilingAssessment: opts.assessmentId", PiplError, "pipl/bad-assessment-id");
164
+ validateOpts.requireNonEmptyString(opts.transferType,
165
+ "b.pipl.sccFilingAssessment: opts.transferType", PiplError, "pipl/bad-transfer-type");
166
+ validateOpts.requireNonEmptyString(opts.recipientJurisdiction,
167
+ "b.pipl.sccFilingAssessment: opts.recipientJurisdiction", PiplError, "pipl/bad-recipient");
168
+
169
+ if (!Array.isArray(opts.dataCategories) || opts.dataCategories.length === 0) {
170
+ throw new PiplError("pipl/bad-data-categories",
171
+ "b.pipl.sccFilingAssessment: opts.dataCategories must be a non-empty array of strings");
172
+ }
173
+ validateOpts.optionalNonEmptyStringArray(opts.dataCategories,
174
+ "b.pipl.sccFilingAssessment: opts.dataCategories", PiplError, "pipl/bad-data-categories");
175
+
176
+ if (LEGAL_BASES.indexOf(opts.legalBasis) === -1) {
177
+ throw new PiplError("pipl/bad-legal-basis",
178
+ "b.pipl.sccFilingAssessment: opts.legalBasis must be one of " +
179
+ LEGAL_BASES.join(" | ") + " (PIPL Art. 38(1)) — got " + JSON.stringify(opts.legalBasis));
180
+ }
181
+
182
+ if (typeof opts.volume !== "number" || !isFinite(opts.volume) || opts.volume < 0) {
183
+ throw new PiplError("pipl/bad-volume",
184
+ "b.pipl.sccFilingAssessment: opts.volume must be a non-negative finite number (data-subject count)");
185
+ }
186
+ if (typeof opts.sensitivePI !== "boolean") {
187
+ throw new PiplError("pipl/bad-sensitive-pi",
188
+ "b.pipl.sccFilingAssessment: opts.sensitivePI must be a boolean");
189
+ }
190
+
191
+ var ciio = opts.ciio === undefined ? false
192
+ : validateOpts.optionalBoolean(opts.ciio, "b.pipl.sccFilingAssessment: opts.ciio", PiplError, "pipl/bad-ciio");
193
+ var importantData = opts.importantData === undefined ? false
194
+ : validateOpts.optionalBoolean(opts.importantData, "b.pipl.sccFilingAssessment: opts.importantData", PiplError, "pipl/bad-important-data");
195
+ var cumulativePI = opts.cumulativePI === undefined ? 0
196
+ : validateOpts.optionalFiniteNonNegative(opts.cumulativePI, "b.pipl.sccFilingAssessment: opts.cumulativePI", PiplError, "pipl/bad-cumulative-pi");
197
+ var cumulativeSensitivePI = opts.cumulativeSensitivePI === undefined ? 0
198
+ : validateOpts.optionalFiniteNonNegative(opts.cumulativeSensitivePI, "b.pipl.sccFilingAssessment: opts.cumulativeSensitivePI", PiplError, "pipl/bad-cumulative-sensitive-pi");
199
+
200
+ var recordedAt = _requireRecordedAt(opts.recordedAt, "b.pipl.sccFilingAssessment: opts.recordedAt");
201
+ // Resolve + shape-validate the audit sink at the entry-point tier (THROWS
202
+ // on a malformed sink) — NOT inside the drop-silent emission try/catch
203
+ // below, which would swallow the config error.
204
+ var auditSink = _resolveAudit(opts.audit, "b.pipl.sccFilingAssessment: opts.audit");
205
+
206
+ // Mandatory-security-assessment determination (CAC 2024 Provisions, Art. 7/8).
207
+ // Crossing ANY trigger forces the security-assessment mechanism regardless of
208
+ // the operator's declared legalBasis — the operator cannot self-downgrade to
209
+ // the standard contract or certification once a trigger is present. The
210
+ // thresholds are cumulative since 1 January and THIS transfer counts: sort its
211
+ // own volume into the sensitive or non-sensitive bucket and add it to the
212
+ // running cumulative before comparing, so a first/planned transfer that alone
213
+ // crosses a threshold is classified correctly without the caller having to
214
+ // pre-add it to the cumulative field.
215
+ var effectiveSensitivePI = cumulativeSensitivePI + (opts.sensitivePI ? opts.volume : 0);
216
+ var effectiveNonSensitivePI = cumulativePI + (opts.sensitivePI ? 0 : opts.volume);
217
+ var triggers = [];
218
+ if (ciio) triggers.push("ciio");
219
+ if (importantData) triggers.push("important-data");
220
+ if (effectiveNonSensitivePI > SECURITY_ASSESSMENT_NONSENSITIVE_PI_THRESHOLD) triggers.push("non-sensitive-pi-volume");
221
+ if (effectiveSensitivePI > SECURITY_ASSESSMENT_SENSITIVE_PI_THRESHOLD) triggers.push("sensitive-pi-volume");
222
+
223
+ var securityAssessmentRequired = triggers.length > 0;
224
+ var mechanismRequired = securityAssessmentRequired ? "security-assessment" : opts.legalBasis;
225
+ var nextReviewDays = securityAssessmentRequired ? SECURITY_ASSESSMENT_VALIDITY_DAYS : STANDARD_REVIEW_DAYS;
226
+
227
+ var record = Object.freeze({
228
+ assessmentId: opts.assessmentId,
229
+ transferType: opts.transferType,
230
+ recipientJurisdiction: opts.recipientJurisdiction,
231
+ dataCategories: Object.freeze(opts.dataCategories.slice()),
232
+ legalBasis: opts.legalBasis,
233
+ volume: opts.volume,
234
+ sensitivePI: opts.sensitivePI,
235
+ mechanismRequired: mechanismRequired,
236
+ securityAssessmentRequired: securityAssessmentRequired,
237
+ securityAssessmentTriggers: Object.freeze(triggers),
238
+ legalReference: "PIPL Art. 38 / Art. 40 / Art. 55",
239
+ recordedAt: recordedAt,
240
+ nextReviewDueBy: recordedAt + C.TIME.days(nextReviewDays),
241
+ });
242
+
243
+ try {
244
+ auditSink.safeEmit({
245
+ action: "pipl.transfer.assessed",
246
+ outcome: "success",
247
+ resource: { kind: "pipl-cross-border-transfer", id: opts.assessmentId },
248
+ metadata: {
249
+ transferType: opts.transferType,
250
+ recipientJurisdiction: opts.recipientJurisdiction,
251
+ mechanismRequired: mechanismRequired,
252
+ securityAssessmentRequired: securityAssessmentRequired,
253
+ triggers: triggers,
254
+ recordedAt: recordedAt,
255
+ },
256
+ });
257
+ } catch (_e) { /* drop-silent — audit is best-effort, never block the builder */ }
258
+
259
+ return record;
260
+ }
261
+
262
+ /**
263
+ * @primitive b.pipl.securityAssessmentCertificate
264
+ * @signature b.pipl.securityAssessmentCertificate(opts)
265
+ * @since 0.15.8
266
+ * @status stable
267
+ * @compliance pipl-cn
268
+ * @related b.pipl.sccFilingAssessment, b.compliance.isCrossBorderRegulated
269
+ *
270
+ * Record a dated PIPL Art. 40 / CAC security-assessment self-declaration
271
+ * for an outbound data transfer. PIPL Art. 40 and the Measures for
272
+ * Security Assessment of Outbound Data Transfers require an operator who
273
+ * must pass (or has passed) the CAC security assessment to document the
274
+ * assessment scope, the data exporter, the overseas recipient, a risk
275
+ * rating, and the safeguards relied on — the evidence the operator must be
276
+ * able to produce on CAC inspection. This builder validates the supplied
277
+ * facts and returns a frozen, dated certificate record stamped with a
278
+ * 3-year `validUntil` clock (the CAC security-assessment result validity
279
+ * period, Measures Art. 14). It performs NO network I/O and files nothing
280
+ * with the CAC — it documents the assessment the operator conducted. A
281
+ * best-effort `pipl.security_assessment.recorded` audit event fires.
282
+ *
283
+ * @opts
284
+ * certId: string, // required — operator's identifier for this certificate
285
+ * assessmentScope: string, // required — scope of the security assessment (systems / data flows covered)
286
+ * dataExporter: string, // required — the PRC data exporter (controller / processor)
287
+ * overseasRecipient: string, // required — the overseas recipient receiving the PI
288
+ * riskRating: string, // required — "low" | "medium" | "high"
289
+ * safeguards: string[], // required — non-empty list of safeguards relied on (encryption, DPA, etc.)
290
+ * filingRef: string, // optional — CAC filing / acceptance reference number
291
+ * recordedAt: number, // required — epoch ms of this declaration
292
+ * audit: object, // optional — b.audit-shaped sink; default global b.audit
293
+ *
294
+ * @example
295
+ * var cert = b.pipl.securityAssessmentCertificate({
296
+ * certId: "sa-2026-014",
297
+ * assessmentScope: "CRM outbound replication to US region",
298
+ * dataExporter: "Acme (Shanghai) Co., Ltd.",
299
+ * overseasRecipient: "Acme Inc. (Delaware)",
300
+ * riskRating: "medium",
301
+ * safeguards: ["XChaCha20 at rest", "standard contractual clauses", "data minimization"],
302
+ * recordedAt: Date.now(),
303
+ * });
304
+ * // → { certId, assessmentScope, riskRating, recordedAt, validUntil }
305
+ */
306
+ function securityAssessmentCertificate(opts) {
307
+ validateOpts.requireObject(opts, "b.pipl.securityAssessmentCertificate: opts", PiplError, "pipl/bad-opts");
308
+ validateOpts(opts, SECURITY_CERT_ALLOWED_KEYS, "b.pipl.securityAssessmentCertificate");
309
+ validateOpts.requireNonEmptyString(opts.certId,
310
+ "b.pipl.securityAssessmentCertificate: opts.certId", PiplError, "pipl/bad-cert-id");
311
+ validateOpts.requireNonEmptyString(opts.assessmentScope,
312
+ "b.pipl.securityAssessmentCertificate: opts.assessmentScope", PiplError, "pipl/bad-scope");
313
+ validateOpts.requireNonEmptyString(opts.dataExporter,
314
+ "b.pipl.securityAssessmentCertificate: opts.dataExporter", PiplError, "pipl/bad-exporter");
315
+ validateOpts.requireNonEmptyString(opts.overseasRecipient,
316
+ "b.pipl.securityAssessmentCertificate: opts.overseasRecipient", PiplError, "pipl/bad-recipient");
317
+
318
+ if (RISK_RATINGS.indexOf(opts.riskRating) === -1) {
319
+ throw new PiplError("pipl/bad-risk-rating",
320
+ "b.pipl.securityAssessmentCertificate: opts.riskRating must be one of " +
321
+ RISK_RATINGS.join(" | ") + " — got " + JSON.stringify(opts.riskRating));
322
+ }
323
+
324
+ if (!Array.isArray(opts.safeguards) || opts.safeguards.length === 0) {
325
+ throw new PiplError("pipl/bad-safeguards",
326
+ "b.pipl.securityAssessmentCertificate: opts.safeguards must be a non-empty array of strings");
327
+ }
328
+ validateOpts.optionalNonEmptyStringArray(opts.safeguards,
329
+ "b.pipl.securityAssessmentCertificate: opts.safeguards", PiplError, "pipl/bad-safeguards");
330
+
331
+ var filingRef = validateOpts.optionalNonEmptyString(opts.filingRef,
332
+ "b.pipl.securityAssessmentCertificate: opts.filingRef", PiplError, "pipl/bad-filing-ref");
333
+
334
+ var recordedAt = _requireRecordedAt(opts.recordedAt, "b.pipl.securityAssessmentCertificate: opts.recordedAt");
335
+ // Entry-point shape-validate the audit sink (THROWS) before the drop-silent
336
+ // emission try/catch below.
337
+ var auditSink = _resolveAudit(opts.audit, "b.pipl.securityAssessmentCertificate: opts.audit");
338
+
339
+ var record = Object.freeze({
340
+ certId: opts.certId,
341
+ assessmentScope: opts.assessmentScope,
342
+ dataExporter: opts.dataExporter,
343
+ overseasRecipient: opts.overseasRecipient,
344
+ riskRating: opts.riskRating,
345
+ safeguards: Object.freeze(opts.safeguards.slice()),
346
+ filingRef: filingRef || null,
347
+ legalReference: "PIPL Art. 40 / CAC Measures for Security Assessment of Outbound Data Transfers",
348
+ recordedAt: recordedAt,
349
+ validUntil: recordedAt + C.TIME.days(SECURITY_ASSESSMENT_VALIDITY_DAYS),
350
+ });
351
+
352
+ try {
353
+ auditSink.safeEmit({
354
+ action: "pipl.security_assessment.recorded",
355
+ outcome: "success",
356
+ resource: { kind: "pipl-security-assessment", id: opts.certId },
357
+ metadata: {
358
+ assessmentScope: opts.assessmentScope,
359
+ dataExporter: opts.dataExporter,
360
+ overseasRecipient: opts.overseasRecipient,
361
+ riskRating: opts.riskRating,
362
+ filingRef: filingRef || null,
363
+ recordedAt: recordedAt,
364
+ },
365
+ });
366
+ } catch (_e) { /* drop-silent — audit is best-effort, never block the builder */ }
367
+
368
+ return record;
369
+ }
370
+
371
+ module.exports = {
372
+ sccFilingAssessment: sccFilingAssessment,
373
+ securityAssessmentCertificate: securityAssessmentCertificate,
374
+ LEGAL_BASES: LEGAL_BASES,
375
+ RISK_RATINGS: RISK_RATINGS,
376
+ PiplError: PiplError,
377
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.7",
3
+ "version": "0.15.8",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:af262da2-b0f7-4f93-bb1e-8c6cb863a378",
5
+ "serialNumber": "urn:uuid:d7b8bc62-0d13-40fc-86ee-e6263eb44d87",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-13T17:27:38.127Z",
8
+ "timestamp": "2026-06-13T19:33:49.247Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.15.7",
22
+ "bom-ref": "@blamejs/core@0.15.8",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.7",
25
+ "version": "0.15.8",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.15.7",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.8",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.15.7",
57
+ "ref": "@blamejs/core@0.15.8",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]