@blamejs/blamejs-shop 0.4.32 → 0.4.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -1
  3. package/lib/asset-manifest.json +1 -1
  4. package/lib/vendor/MANIFEST.json +72 -52
  5. package/lib/vendor/blamejs/.github/workflows/ci.yml +12 -12
  6. package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +37 -5
  7. package/lib/vendor/blamejs/.github/workflows/release-container.yml +2 -2
  8. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  9. package/lib/vendor/blamejs/MIGRATING.md +12 -0
  10. package/lib/vendor/blamejs/README.md +5 -2
  11. package/lib/vendor/blamejs/SECURITY.md +4 -2
  12. package/lib/vendor/blamejs/api-snapshot.json +137 -2
  13. package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +1 -0
  14. package/lib/vendor/blamejs/index.js +4 -0
  15. package/lib/vendor/blamejs/lib/archive-read.js +2 -1
  16. package/lib/vendor/blamejs/lib/archive-tar-read.js +2 -1
  17. package/lib/vendor/blamejs/lib/atomic-file.js +5 -0
  18. package/lib/vendor/blamejs/lib/audit.js +2 -0
  19. package/lib/vendor/blamejs/lib/auth/elevation-grant.js +6 -2
  20. package/lib/vendor/blamejs/lib/auth/oauth.js +13 -0
  21. package/lib/vendor/blamejs/lib/auth/sd-jwt-vc.js +5 -2
  22. package/lib/vendor/blamejs/lib/cli.js +8 -1
  23. package/lib/vendor/blamejs/lib/compliance.js +4 -0
  24. package/lib/vendor/blamejs/lib/config-drift.js +2 -1
  25. package/lib/vendor/blamejs/lib/credential-hash.js +9 -0
  26. package/lib/vendor/blamejs/lib/db.js +15 -2
  27. package/lib/vendor/blamejs/lib/dsa.js +482 -0
  28. package/lib/vendor/blamejs/lib/framework-error.js +14 -0
  29. package/lib/vendor/blamejs/lib/http-client.js +5 -2
  30. package/lib/vendor/blamejs/lib/local-db-thin.js +3 -2
  31. package/lib/vendor/blamejs/lib/log-stream-local.js +1 -1
  32. package/lib/vendor/blamejs/lib/log-stream-otlp-grpc.js +9 -2
  33. package/lib/vendor/blamejs/lib/log-stream-otlp.js +16 -7
  34. package/lib/vendor/blamejs/lib/middleware/clear-site-data.js +36 -11
  35. package/lib/vendor/blamejs/lib/mtls-ca.js +2 -2
  36. package/lib/vendor/blamejs/lib/observability.js +3 -2
  37. package/lib/vendor/blamejs/lib/pipl-cn.js +377 -0
  38. package/lib/vendor/blamejs/lib/restore-rollback.js +5 -5
  39. package/lib/vendor/blamejs/lib/retention.js +16 -2
  40. package/lib/vendor/blamejs/lib/scheduler.js +12 -0
  41. package/lib/vendor/blamejs/lib/self-update.js +1 -1
  42. package/lib/vendor/blamejs/lib/session.js +64 -0
  43. package/lib/vendor/blamejs/lib/ssrf-guard.js +25 -7
  44. package/lib/vendor/blamejs/lib/vault/passphrase-ops.js +3 -3
  45. package/lib/vendor/blamejs/lib/watcher.js +8 -0
  46. package/lib/vendor/blamejs/package.json +2 -2
  47. package/lib/vendor/blamejs/release-notes/v0.15.7.json +43 -0
  48. package/lib/vendor/blamejs/release-notes/v0.15.8.json +48 -0
  49. package/lib/vendor/blamejs/release-notes/v0.15.9.json +58 -0
  50. package/lib/vendor/blamejs/scripts/gen-migrating.js +16 -0
  51. package/lib/vendor/blamejs/scripts/generate-ssdf-attestation.js +338 -0
  52. package/lib/vendor/blamejs/test/00-primitives.js +51 -0
  53. package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-rename-retry.test.js +70 -0
  54. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +250 -3
  55. package/lib/vendor/blamejs/test/layer-0-primitives/credential-hash.test.js +18 -0
  56. package/lib/vendor/blamejs/test/layer-0-primitives/db-init-extensions.test.js +32 -0
  57. package/lib/vendor/blamejs/test/layer-0-primitives/dsa.test.js +169 -0
  58. package/lib/vendor/blamejs/test/layer-0-primitives/otlp-attr-redaction.test.js +40 -1
  59. package/lib/vendor/blamejs/test/layer-0-primitives/pipl-cn.test.js +172 -0
  60. package/lib/vendor/blamejs/test/layer-0-primitives/retention-floor.test.js +59 -0
  61. package/lib/vendor/blamejs/test/layer-0-primitives/safe-url-canonicalize.test.js +64 -11
  62. package/lib/vendor/blamejs/test/layer-0-primitives/scheduler-watchdog-stale-settle.test.js +71 -0
  63. package/lib/vendor/blamejs/test/layer-0-primitives/session-extensions.test.js +57 -0
  64. package/lib/vendor/blamejs/test/layer-0-primitives/watcher.test.js +7 -3
  65. package/package.json +2 -2
@@ -19,8 +19,12 @@
19
19
  * iat / exp / jti
20
20
  *
21
21
  * Tokens are revocable: revoke(jti) adds the jti to an in-process
22
- * deny-set checked by verify(). Operators with multi-node clusters
23
- * pass `revokedSet` opt to back the deny-set with their own KV.
22
+ * deny-set checked by verify(). Revocation is in-process only and does
23
+ * NOT propagate across cluster nodes a jti revoked on one node is not
24
+ * denied on another. On a multi-node deployment, keep grant TTLs short
25
+ * (the iat/exp window is the cross-node bound) and route revocation
26
+ * through a shared, externally-checked store of your own; this module
27
+ * does not yet accept a backing revoked-set.
24
28
  *
25
29
  * Token format: base64url(JSON-payload) + "." + base64url(HMAC).
26
30
  *
@@ -1896,6 +1896,19 @@ function create(opts) {
1896
1896
  throw new OAuthError("auth-oauth/aud-mismatch",
1897
1897
  "ID token aud does not contain clientId '" + clientId + "'");
1898
1898
  }
1899
+ // OIDC Core §3.1.3.7: a multi-audience ID token MUST carry an azp
1900
+ // (authorized party), and a present azp MUST equal our client_id.
1901
+ // Without this, a token whose authorized party is a DIFFERENT client but
1902
+ // whose aud array also lists this RP would verify clean — a confused-deputy
1903
+ // / token-substitution hole.
1904
+ if (aud.length > 1 && typeof payload.azp !== "string") {
1905
+ throw new OAuthError("auth-oauth/azp-required",
1906
+ "ID token has multiple audiences but no azp (authorized party) claim");
1907
+ }
1908
+ if (payload.azp !== undefined && payload.azp !== clientId) {
1909
+ throw new OAuthError("auth-oauth/azp-mismatch",
1910
+ "ID token azp '" + payload.azp + "' is not clientId '" + clientId + "'");
1911
+ }
1899
1912
  if (vopts.nonce && !vopts.skipNonceCheck) {
1900
1913
  // Constant-time nonce compare — secret-shaped value matched
1901
1914
  // against attacker-controlled payload.
@@ -638,11 +638,14 @@ async function verify(presentation, opts) {
638
638
  jwtExternal._assertAlgKtyMatch(kbAlg, holderKey);
639
639
  var holderKeyObj = nodeCrypto.createPublicKey({ key: holderKey, format: "jwk" });
640
640
  var kbParsed = _verifyJwt(maybeKbJwt, holderKeyObj, kbAlg);
641
- if (opts.audience && kbParsed.payload.aud !== opts.audience) {
641
+ // Constant-time compares: the nonce is a verifier-issued replay-defense
642
+ // value, so a short-circuiting !== leaks a matching-prefix timing oracle.
643
+ // Matches the sd_hash check below (the framework's hash/token discipline).
644
+ if (opts.audience && !_timingSafeEqStr(kbParsed.payload.aud, opts.audience)) {
642
645
  throw new AuthError("auth-sd-jwt-vc/wrong-audience",
643
646
  "verify: KB-JWT aud mismatch");
644
647
  }
645
- if (opts.nonce && kbParsed.payload.nonce !== opts.nonce) {
648
+ if (opts.nonce && !_timingSafeEqStr(kbParsed.payload.nonce, opts.nonce)) {
646
649
  throw new AuthError("auth-sd-jwt-vc/wrong-nonce",
647
650
  "verify: KB-JWT nonce mismatch (replay defense)");
648
651
  }
@@ -94,7 +94,14 @@ function _openSqlite(dbPath) {
94
94
  // Lazy-required so the CLI doesn't crash on `blamejs version` or
95
95
  // `blamejs help` if node:sqlite isn't usable for some reason.
96
96
  var { DatabaseSync } = require("node:sqlite");
97
- return new DatabaseSync(dbPath);
97
+ // Same SQLITE_LIMIT_ sqlLength cap as db.init's main handle — the CLI opens
98
+ // the operator's real database for migrate / inspect, so the parse-time DoS
99
+ // floor applies here too.
100
+ return new DatabaseSync(dbPath, {
101
+ limits: {
102
+ sqlLength: C.BYTES.mib(1),
103
+ },
104
+ });
98
105
  }
99
106
 
100
107
  // ---- Subcommand: migrate ----
@@ -594,6 +594,10 @@ function clear() {
594
594
  }
595
595
  STATE.posture = null;
596
596
  STATE.setAt = null;
597
+ // Cascade the reset the same way set() cascades the posture — otherwise a
598
+ // primitive that inherits the active posture (e.g. retention.complianceFloor)
599
+ // keeps applying the stale floor after the global posture was cleared.
600
+ _applyPostureCascade(null);
597
601
  }
598
602
 
599
603
  function _resetForTest() {
@@ -44,6 +44,7 @@ var lazyRequire = require("./lazy-require");
44
44
  var safeJson = require("./safe-json");
45
45
  var validateOpts = require("./validate-opts");
46
46
  var { defineClass } = require("./framework-error");
47
+ var atomicFile = require("./atomic-file");
47
48
 
48
49
  var audit = lazyRequire(function () { return require("./audit"); });
49
50
 
@@ -201,7 +202,7 @@ function create(opts) {
201
202
  };
202
203
  var tmp = sidecarPath + ".tmp";
203
204
  nodeFs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
204
- nodeFs.renameSync(tmp, sidecarPath);
205
+ atomicFile.renameWithRetry(tmp, sidecarPath);
205
206
  }
206
207
 
207
208
  function _verifySidecar(parsed) {
@@ -380,6 +380,15 @@ function needsRehash(envelope, opts) {
380
380
  try { return passwordModule().needsRehash(phc, opts && opts.params); }
381
381
  catch (_e) { return true; }
382
382
  }
383
+ if (decoded.algoId === C.CRED_HASH_IDS.SHAKE256) {
384
+ // Length-rotation: rehash when the stored digest is SHORTER than the
385
+ // configured/default output length. Upgrade-only (`<`, matching the Argon2
386
+ // needsRehash convention) — a longer-than-target digest is not actively
387
+ // shortened. Without this compare, raising the SHAKE256 length never
388
+ // triggered a rehash and the advertised rotation was a silent no-op.
389
+ var targetLength = (opts && opts.params && opts.params.length) || SHAKE256_DEFAULT_LENGTH;
390
+ if (decoded.payload.length < targetLength) return true;
391
+ }
383
392
  return false;
384
393
  }
385
394
 
@@ -1156,8 +1156,21 @@ async function init(opts) {
1156
1156
  encKey = null;
1157
1157
  }
1158
1158
 
1159
- // Open the database
1160
- database = new DatabaseSync(dbPath);
1159
+ // Open the database. The node:sqlite `limits` option sets SQLITE_LIMIT_*
1160
+ // caps at construction — a parse-time DoS floor complementary to the
1161
+ // streamLimit row-count gate (one bounds statement size, the other bounds
1162
+ // result cardinality). sqlLength rejects a megaquery (>1 MiB) before the
1163
+ // parser chews CPU/memory on it; the framework never legitimately emits a
1164
+ // statement anywhere near 1 MiB, and a 1 GB attacker-influenced statement
1165
+ // would otherwise be parsed. The limits option is part of node:sqlite from
1166
+ // Node 24.10+, comfortably under the engines floor. (SQLITE_LIMIT_ATTACHED is
1167
+ // left at the SQLite default — the snapshot / backup path relies on the
1168
+ // attach mechanism.)
1169
+ database = new DatabaseSync(dbPath, {
1170
+ limits: {
1171
+ sqlLength: C.BYTES.mib(1),
1172
+ },
1173
+ });
1161
1174
 
1162
1175
  // Performance pragmas
1163
1176
  runSql(database, "PRAGMA journal_mode=WAL");
@@ -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,