@blamejs/blamejs-shop 0.3.40 → 0.3.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/storefront.js +14 -3
  4. package/lib/vendor/MANIFEST.json +2 -2
  5. package/lib/vendor/blamejs/CHANGELOG.md +8 -0
  6. package/lib/vendor/blamejs/README.md +8 -5
  7. package/lib/vendor/blamejs/SECURITY.md +7 -0
  8. package/lib/vendor/blamejs/api-snapshot.json +266 -2
  9. package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +1 -0
  10. package/lib/vendor/blamejs/index.js +7 -1
  11. package/lib/vendor/blamejs/lib/agent-idempotency.js +113 -0
  12. package/lib/vendor/blamejs/lib/agent-orchestrator.js +108 -0
  13. package/lib/vendor/blamejs/lib/agent-snapshot.js +137 -0
  14. package/lib/vendor/blamejs/lib/agent-tenant.js +193 -17
  15. package/lib/vendor/blamejs/lib/ai-input.js +167 -3
  16. package/lib/vendor/blamejs/lib/ai-output.js +463 -0
  17. package/lib/vendor/blamejs/lib/ai-prompt.js +304 -0
  18. package/lib/vendor/blamejs/lib/archive-wrap.js +234 -1
  19. package/lib/vendor/blamejs/lib/archive.js +1 -0
  20. package/lib/vendor/blamejs/lib/audit.js +3 -0
  21. package/lib/vendor/blamejs/lib/auth/oid4vp.js +47 -28
  22. package/lib/vendor/blamejs/lib/cluster.js +186 -14
  23. package/lib/vendor/blamejs/lib/codepoint-class.js +18 -0
  24. package/lib/vendor/blamejs/lib/compliance-ai-act.js +446 -0
  25. package/lib/vendor/blamejs/lib/consent.js +104 -8
  26. package/lib/vendor/blamejs/lib/content-credentials.js +851 -41
  27. package/lib/vendor/blamejs/lib/crypto-field.js +5 -0
  28. package/lib/vendor/blamejs/lib/db.js +15 -0
  29. package/lib/vendor/blamejs/lib/framework-error.js +21 -0
  30. package/lib/vendor/blamejs/lib/mail-srs.js +122 -19
  31. package/lib/vendor/blamejs/lib/privacy.js +168 -0
  32. package/lib/vendor/blamejs/lib/safe-archive.js +196 -136
  33. package/lib/vendor/blamejs/lib/validate-opts.js +24 -0
  34. package/lib/vendor/blamejs/lib/vault/rotate.js +175 -15
  35. package/lib/vendor/blamejs/lib/vault-aad.js +84 -33
  36. package/lib/vendor/blamejs/package.json +1 -1
  37. package/lib/vendor/blamejs/release-notes/v0.14.11.json +72 -0
  38. package/lib/vendor/blamejs/release-notes/v0.14.12.json +95 -0
  39. package/lib/vendor/blamejs/release-notes/v0.14.13.json +52 -0
  40. package/lib/vendor/blamejs/release-notes/v0.14.14.json +31 -0
  41. package/lib/vendor/blamejs/test/00-primitives.js +9 -1
  42. package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +103 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +91 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/agent-snapshot.test.js +186 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/agent-tenant.test.js +140 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/ai-input.test.js +59 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/ai-output.test.js +125 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/ai-prompt.test.js +133 -0
  49. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +6 -1
  50. package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +94 -0
  51. package/lib/vendor/blamejs/test/layer-0-primitives/archive-wrap.test.js +176 -0
  52. package/lib/vendor/blamejs/test/layer-0-primitives/cluster-vault-rotation.test.js +243 -0
  53. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +250 -0
  54. package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +130 -0
  55. package/lib/vendor/blamejs/test/layer-0-primitives/consent-purposes.test.js +70 -0
  56. package/lib/vendor/blamejs/test/layer-0-primitives/content-credentials.test.js +289 -2
  57. package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +22 -0
  58. package/lib/vendor/blamejs/test/layer-0-primitives/mail-srs.test.js +61 -0
  59. package/lib/vendor/blamejs/test/layer-0-primitives/privacy-vendor-review.test.js +69 -0
  60. package/lib/vendor/blamejs/test/layer-0-primitives/vault-rotate-aad.test.js +158 -0
  61. package/package.json +1 -1
@@ -1105,6 +1105,11 @@ module.exports = {
1105
1105
  getSealedFields: getSealedFields,
1106
1106
  sealRow: sealRow,
1107
1107
  unsealRow: unsealRow,
1108
+ // _aadParts — the column-AAD builder the seal/unseal path uses. Exported
1109
+ // (internal) so the vault-key rotation pipeline reconstructs the IDENTICAL
1110
+ // AAD tuple a cell was sealed under — one source of truth, no drift
1111
+ // between the seal side and the rotate side.
1112
+ _aadParts: _aadParts,
1108
1113
  // Doc-shaped aliases — operators / tests preparing a JS document
1109
1114
  // object (vs. a SQL row) reach for sealDoc / unsealDoc naming. Same
1110
1115
  // function, identical shape, returns a new object (input untouched).
@@ -1318,6 +1318,15 @@ async function init(opts) {
1318
1318
  derivedHashes: t.derivedHashes,
1319
1319
  hashNamespaces: t.hashNamespaces,
1320
1320
  derivedHashMode: t.derivedHashMode,
1321
+ // AAD-binding metadata MUST pass through — without it a schema that
1322
+ // declares { aad: true } registers as a plain table, so its cells
1323
+ // seal under vault: (not vault.aad:) and the vault-key rotation
1324
+ // pipeline cannot reconstruct their AAD. registerTable defaults these
1325
+ // (aad:false / rowIdField:"id" / schemaVersion:"1") so non-AAD tables
1326
+ // are unaffected.
1327
+ aad: t.aad,
1328
+ rowIdField: t.rowIdField,
1329
+ schemaVersion: t.schemaVersion,
1321
1330
  });
1322
1331
  tableMetadata[t.name] = {
1323
1332
  primaryKey: _normalizePk(t),
@@ -3161,6 +3170,12 @@ module.exports = {
3161
3170
  // (plain mode) or when the plaintext DB doesn't exist.
3162
3171
  flushToDisk: encryptToDisk,
3163
3172
  snapshot: snapshot,
3173
+ // Internal AAD constructors, exported so the vault-key rotation
3174
+ // pipeline (lib/vault/rotate.js) re-seals db.enc / db.key.enc under the
3175
+ // SAME deployment-bound AAD this module writes them with — single source
3176
+ // of truth for the wire-format literals (no duplicated constants).
3177
+ _dbEncAad: _dbEncAad,
3178
+ _dbKeyAad: _dbKeyAad,
3164
3179
  // integrityCheck — runs PRAGMA integrity_check against the live db
3165
3180
  // and returns "ok" on success, an array of corruption lines
3166
3181
  // otherwise. Operators wire this into a periodic monitor or a
@@ -370,6 +370,10 @@ var DoraError = defineClass("DoraError", { alwaysPermane
370
370
  // posture name, runtime-switch refusal, assertion failures.
371
371
  // Permanent — these are configuration errors, not transient.
372
372
  var ComplianceError = defineClass("ComplianceError", { alwaysPermanent: true });
373
+ // PrivacyError covers b.privacy config-time misuse: a malformed
374
+ // vendorReview opts object, a non-boolean clause attestation, or an
375
+ // unknown clause key. Permanent — operator configuration, not transient.
376
+ var PrivacyError = defineClass("PrivacyError", { alwaysPermanent: true });
373
377
  // SmtpPolicyError covers MTA-STS / DANE / TLS-RPT misuse: bad-policy
374
378
  // shape, fetch failures, TLSA-record format errors, missing records.
375
379
  // Permanent — these are policy / DNS configuration errors, not
@@ -412,6 +416,20 @@ var McpError = defineClass("McpError", { alwaysPermane
412
416
  // input shape, classifier-result-shape errors, oversized input bypass.
413
417
  // Permanent — caller-shape errors.
414
418
  var AiInputError = defineClass("AiInputError", { alwaysPermanent: true });
419
+ // AiOutputError covers LLM output-handling violations raised by
420
+ // b.ai.output.sanitize / b.ai.output.redact: malformed input shape
421
+ // (non-string), oversized output bypass (exceeds maxBytes cap), bad
422
+ // maxBytes opt, unknown redaction entity. Permanent — caller-shape
423
+ // errors that retry will not recover. OWASP LLM05:2025 (Improper
424
+ // Output Handling) + LLM02:2025 (Sensitive Information Disclosure).
425
+ var AiOutputError = defineClass("AiOutputError", { alwaysPermanent: true });
426
+ // AiPromptError covers LLM prompt-assembly violations raised by
427
+ // b.ai.prompt.template: malformed segment shape (non-string system /
428
+ // context / user), bad maxBytes / nonceBytes opt, oversized assembled
429
+ // prompt. Permanent — caller-shape errors that retry will not recover.
430
+ // OWASP LLM01:2025 (Prompt Injection — indirect / data-plane injection
431
+ // from untrusted context).
432
+ var AiPromptError = defineClass("AiPromptError", { alwaysPermanent: true });
415
433
  // A2aError covers A2A (Agent-to-Agent) protocol violations: signed-
416
434
  // agent-card signature mismatch, expired card, unknown card id,
417
435
  // malformed card shape, signature-algorithm allowlist drift.
@@ -684,6 +702,7 @@ module.exports = {
684
702
  GuardAuthError: GuardAuthError,
685
703
  DoraError: DoraError,
686
704
  ComplianceError: ComplianceError,
705
+ PrivacyError: PrivacyError,
687
706
  SmtpPolicyError: SmtpPolicyError,
688
707
  MailAuthError: MailAuthError,
689
708
  MailArfError: MailArfError,
@@ -691,6 +710,8 @@ module.exports = {
691
710
  SseError: SseError,
692
711
  McpError: McpError,
693
712
  AiInputError: AiInputError,
713
+ AiOutputError: AiOutputError,
714
+ AiPromptError: AiPromptError,
694
715
  A2aError: A2aError,
695
716
  GraphqlFederationError: GraphqlFederationError,
696
717
  Fda21Cfr11Error: Fda21Cfr11Error,
@@ -28,15 +28,24 @@
28
28
  * - `local` is the original sender's local-part
29
29
  * - `forwarder.example` is the rewriting forwarder's domain
30
30
  *
31
- * SRS1 (double-forward case): when an already-SRS0-encoded address
32
- * gets forwarded a second time, SRS1 wraps the SRS0 envelope
33
- * instead of re-encoding from scratch, preserving the original
34
- * sender chain.
31
+ * Wire format (SRS1 — the multi-hop chain case):
32
+ *
33
+ * SRS1=HHH=priorForwarder==<SRS0-body>@thisForwarder
34
+ *
35
+ * When an already-SRS0 (or SRS1) address is forwarded again,
36
+ * `srs1Rewrite(srsAddress)` wraps it: it keeps the original SRS0
37
+ * body verbatim, prepends the preceding forwarder's domain, and
38
+ * binds the pair with this forwarder's own HMAC tag — no new
39
+ * timestamp, no repeated original local-part. `reverse()` detects
40
+ * SRS1, verifies this hop's tag, and unwraps exactly one hop back to
41
+ * the prior forwarder's SRS0 address so the bounce re-routes to it.
35
42
  *
36
43
  * `b.mail.srs.create({ secret, forwarderDomain })` returns
37
- * `{ rewrite, reverse }`. `rewrite(originalSender)` produces the
38
- * SRS-encoded address; `reverse(srsAddress)` decodes back to the
39
- * original sender + verifies the HMAC.
44
+ * `{ rewrite, srs1Rewrite, reverse }`. `rewrite(originalSender)`
45
+ * produces the SRS0 address; `srs1Rewrite(srsAddress)` chains a
46
+ * further hop as SRS1; `reverse(srsAddress)` decodes an SRS0 back to
47
+ * the original sender (verifying HMAC + expiry) or unwraps an SRS1
48
+ * one hop back to the prior forwarder.
40
49
  *
41
50
  * @card
42
51
  * SRS Sender Rewriting Scheme — forwarder envelope-from rewriting with HMAC-bound day-rotated tags so the next-hop SPF check passes and bounces route correctly back to the original sender.
@@ -94,6 +103,34 @@ function _dayDiff(stamp, nowMs) {
94
103
  return diff;
95
104
  }
96
105
 
106
+ // Parse an SRS1 local-part "SRS1=<tag>=<priorForwarder>==<srs0Body>"
107
+ // into its three fields. The 4-char base32 tag and the prior-forwarder
108
+ // domain both carry no "=", so the FIRST "=" ends the tag and the FIRST
109
+ // "==" (which can only fall immediately after the "="-free prior-forwarder
110
+ // domain) ends the prior forwarder — even when the inner SRS0 body carries
111
+ // its own single "=" separators.
112
+ function _parseSrs1(localPart) {
113
+ var rest = localPart.slice(5); // strip "SRS1="
114
+ var firstEq = rest.indexOf("=");
115
+ if (firstEq <= 0) {
116
+ throw new SrsError("srs/malformed",
117
+ "srs.reverse: SRS1 must be SRS1=tag=priorForwarder==<srs0body>");
118
+ }
119
+ var tag = rest.slice(0, firstEq);
120
+ var afterTag = rest.slice(firstEq + 1);
121
+ var sep = afterTag.indexOf("==");
122
+ if (sep <= 0) {
123
+ throw new SrsError("srs/malformed",
124
+ "srs.reverse: SRS1 missing the '==' prior-forwarder separator");
125
+ }
126
+ var srs0Body = afterTag.slice(sep + 2);
127
+ if (!srs0Body) {
128
+ throw new SrsError("srs/malformed",
129
+ "srs.reverse: SRS1 carries an empty inner SRS0 body");
130
+ }
131
+ return { tag: tag, priorForwarder: afterTag.slice(0, sep), srs0Body: srs0Body };
132
+ }
133
+
97
134
  /**
98
135
  * @primitive b.mail.srs.create
99
136
  * @signature b.mail.srs.create(opts)
@@ -101,7 +138,11 @@ function _dayDiff(stamp, nowMs) {
101
138
  * @status stable
102
139
  *
103
140
  * Build an SRS rewriter bound to the operator's forwarder domain +
104
- * HMAC signing secret. Returns `{ rewrite, reverse }`.
141
+ * HMAC signing secret. Returns `{ rewrite, srs1Rewrite, reverse }` —
142
+ * `rewrite` produces an SRS0 origin address, `srs1Rewrite` chains an
143
+ * already-SRS0/SRS1 address as SRS1 for a further forwarding hop, and
144
+ * `reverse` decodes either form (SRS0 → original sender with HMAC +
145
+ * expiry checks; SRS1 → the prior forwarder's address, one hop back).
105
146
  *
106
147
  * @opts
107
148
  * secret: string, // operator's HMAC-SHA-256 signing secret (>=32 bytes recommended)
@@ -121,6 +162,11 @@ function _dayDiff(stamp, nowMs) {
121
162
  * // Bounce arrives back at SRS0=...; decode to deliver
122
163
  * var original = srs.reverse(rewritten);
123
164
  * // → "alice@bob.com"
165
+ *
166
+ * // A further forwarding hop chains the already-SRS0 address as SRS1
167
+ * var hop2 = srs.srs1Rewrite(rewritten);
168
+ * // → "SRS1=HHHH=forwarder.example==HHHH=TT=bob.com=alice@forwarder.example"
169
+ * srs.reverse(hop2); // → the prior-hop SRS0 address, re-routed one hop back
124
170
  */
125
171
  function create(opts) {
126
172
  if (!opts || typeof opts !== "object") {
@@ -158,13 +204,12 @@ function create(opts) {
158
204
  throw new SrsError("srs/bad-address",
159
205
  "srs.rewrite: localPart / domain exceeds RFC 5321 length cap");
160
206
  }
161
- // Refuse SRS double-encoding from this primitive — operator must
162
- // use srs1Rewrite() for already-SRS0 inputs (deferred per the
163
- // v1-defensible decision: SRS1 wrapping is rare in operator
164
- // deployments and adds substantial spec surface).
207
+ // Refuse SRS double-encoding from this primitive — already-SRS0 (or
208
+ // SRS1) inputs chain through srs1Rewrite(), which keeps the original
209
+ // SRS0 body verbatim rather than re-stamping it as a fresh origin.
165
210
  if (/^SRS[01]=/i.test(localPart)) {
166
211
  throw new SrsError("srs/already-rewritten",
167
- "srs.rewrite: address already SRS-encoded; chain forwarding through SRS1 is not yet supported (operator demand TBD)");
212
+ "srs.rewrite: address already SRS-encoded; use srs1Rewrite() to chain a further forwarding hop");
168
213
  }
169
214
  var now = typeof nowMs === "number" ? nowMs : Date.now();
170
215
  var ts = _dayStamp(now);
@@ -173,6 +218,49 @@ function create(opts) {
173
218
  return "SRS0=" + tag + "=" + ts + "=" + domain + "=" + localPart + "@" + forwarderDomain;
174
219
  }
175
220
 
221
+ function srs1Rewrite(srsAddress) {
222
+ validateOpts.requireNonEmptyString(
223
+ srsAddress, "srs.srs1Rewrite.address", SrsError, "srs/bad-address");
224
+ var at = srsAddress.lastIndexOf("@");
225
+ if (at <= 0 || at === srsAddress.length - 1) {
226
+ throw new SrsError("srs/bad-address",
227
+ "srs.srs1Rewrite: address must be in localPart@domain form");
228
+ }
229
+ var localPart = srsAddress.slice(0, at);
230
+ // The SRS0 body is kept verbatim across the whole chain (the SRS1
231
+ // optimization: no new timestamp, no repeated original local-part).
232
+ // `priorForwarder` is the domain the bounce must ultimately reach to
233
+ // recover the original sender — i.e. the forwarder that MINTED the
234
+ // inner SRS0. From an SRS0 input that is its own @domain; from an
235
+ // SRS1 input (a third or later hop) it is the originator already
236
+ // recorded in the SRS1, NOT the immediately-preceding forwarder, so
237
+ // every hop's bounce routes straight back to the SRS0 originator.
238
+ var priorForwarder, srs0Body;
239
+ if (/^SRS0=/i.test(localPart)) {
240
+ priorForwarder = srsAddress.slice(at + 1);
241
+ srs0Body = localPart.slice(5);
242
+ } else if (/^SRS1=/i.test(localPart)) {
243
+ var inner = _parseSrs1(localPart);
244
+ priorForwarder = inner.priorForwarder;
245
+ srs0Body = inner.srs0Body;
246
+ } else {
247
+ throw new SrsError("srs/not-srs0",
248
+ "srs.srs1Rewrite: input must be an SRS0 or SRS1 address (use rewrite() for a plain address)");
249
+ }
250
+ if (!priorForwarder || priorForwarder.indexOf("=") !== -1) {
251
+ throw new SrsError("srs/bad-address",
252
+ "srs.srs1Rewrite: prior forwarder domain must be a non-empty domain without '=' (would corrupt SRS1 field parsing)");
253
+ }
254
+ var opaque = priorForwarder + "==" + srs0Body;
255
+ var tag = _hashTag(secret, opaque);
256
+ var result = "SRS1=" + tag + "=" + priorForwarder + "==" + srs0Body + "@" + forwarderDomain;
257
+ if (result.length > 256) { // RFC 5321 §4.5.3.1.3 path-length cap
258
+ throw new SrsError("srs/too-long",
259
+ "srs.srs1Rewrite: rewritten address exceeds the RFC 5321 256-octet path limit (forwarding chain too deep)");
260
+ }
261
+ return result;
262
+ }
263
+
176
264
  function reverse(srsAddress, nowMs) {
177
265
  validateOpts.requireNonEmptyString(
178
266
  srsAddress, "srs.reverse.address", SrsError, "srs/bad-address");
@@ -183,13 +271,15 @@ function create(opts) {
183
271
  }
184
272
  var localPart = srsAddress.slice(0, at);
185
273
  var rcptDomain = srsAddress.slice(at + 1);
186
- // Allow case-insensitive SRS0 prefix per the spec. Check this
187
- // FIRST so an obviously-non-SRS0 input (`plain@example.com`)
274
+ // Allow case-insensitive SRS0 / SRS1 prefixes per the spec. Check
275
+ // this FIRST so an obviously-non-SRS input (`plain@example.com`)
188
276
  // gets the specific not-srs0 verdict instead of the more general
189
277
  // wrong-forwarder verdict.
190
- if (!/^SRS0=/i.test(localPart)) {
278
+ var isSrs0 = /^SRS0=/i.test(localPart);
279
+ var isSrs1 = /^SRS1=/i.test(localPart);
280
+ if (!isSrs0 && !isSrs1) {
191
281
  throw new SrsError("srs/not-srs0",
192
- "srs.reverse: address local-part does not start with SRS0=");
282
+ "srs.reverse: address local-part does not start with SRS0= or SRS1=");
193
283
  }
194
284
  // Domain binding — the rewriter is scoped to a specific forwarder
195
285
  // domain, and reverse() must verify the bounce arrived at THAT
@@ -203,6 +293,18 @@ function create(opts) {
203
293
  "srs.reverse: bounce addressed to '" + rcptDomain + "' but rewriter " +
204
294
  "is bound to forwarderDomain '" + forwarderDomain + "'");
205
295
  }
296
+ if (isSrs1) {
297
+ var s1 = _parseSrs1(localPart);
298
+ if (!_timingSafeStringEqual(s1.tag, _hashTag(secret, s1.priorForwarder + "==" + s1.srs0Body))) {
299
+ throw new SrsError("srs/bad-tag",
300
+ "srs.reverse: SRS1 HMAC tag does not verify (wrong secret or tampered envelope-from)");
301
+ }
302
+ // Unwrap exactly one hop: re-address the bounce to the prior
303
+ // forwarder's SRS0. That forwarder owns the inner SRS0's tag +
304
+ // expiry, so we do NOT re-check them here — per the SRS spec each
305
+ // hop verifies only its OWN hash.
306
+ return "SRS0=" + s1.srs0Body + "@" + s1.priorForwarder;
307
+ }
206
308
  var rest = localPart.slice(5);
207
309
  var parts = rest.split("=");
208
310
  if (parts.length < 4) {
@@ -231,8 +333,9 @@ function create(opts) {
231
333
  }
232
334
 
233
335
  return Object.freeze({
234
- rewrite: rewrite,
235
- reverse: reverse,
336
+ rewrite: rewrite,
337
+ srs1Rewrite: srs1Rewrite,
338
+ reverse: reverse,
236
339
  forwarderDomain: forwarderDomain,
237
340
  });
238
341
  }
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.privacy
4
+ * @nav Compliance
5
+ * @title Privacy
6
+ *
7
+ * @intro
8
+ * Privacy-program operational helpers. The first primitive,
9
+ * `vendorReview`, builds the annual third-party / EdTech vendor-review
10
+ * attestation that FERPA's school-official exception and California's
11
+ * SOPIPA expect a school or district to keep on file for every
12
+ * processor that touches student data: a dated, clause-by-clause
13
+ * record that the vendor uses the data only for the authorized
14
+ * educational purpose, runs no targeted advertising or commercial
15
+ * profiling, sells nothing, keeps reasonable security safeguards,
16
+ * deletes on request, and so on.
17
+ *
18
+ * The builder follows the operator-feeds-metadata pattern: the
19
+ * operator supplies the vendor's attested answers and `vendorReview`
20
+ * returns a frozen report — `{ attested, gaps, reviewedAt,
21
+ * nextReviewDueAt, ... }` — that composes into the operator's own
22
+ * retention / audit / export sink. It is not framework-persisted.
23
+ *
24
+ * @card
25
+ * Privacy-program helpers — annual FERPA / SOPIPA EdTech vendor-review attestation reports (`vendorReview`).
26
+ */
27
+
28
+ var lazyRequire = require("./lazy-require");
29
+ var validateOpts = require("./validate-opts");
30
+ var C = require("./constants");
31
+ var { PrivacyError } = require("./framework-error");
32
+
33
+ var audit = lazyRequire(function () { return require("./audit"); });
34
+
35
+ // The clause set a FERPA school-official / SOPIPA vendor review attests.
36
+ // Each entry: { id, required, citation, description }. Every `required`
37
+ // clause must be attested true for the review to pass (attested:true).
38
+ var VENDOR_REVIEW_CLAUSES = Object.freeze([
39
+ Object.freeze({ id: "educationalPurposeOnly", required: true, citation: "FERPA 34 CFR 99.31(a)(1)(i)(B)", description: "Vendor uses student data only for the authorized educational purpose under direct school control; no redisclosure." }),
40
+ Object.freeze({ id: "noTargetedAdvertising", required: true, citation: "SOPIPA Cal. B&P 22584(b)(1)", description: "No targeted advertising to students based on covered information." }),
41
+ Object.freeze({ id: "noCommercialProfiling", required: true, citation: "SOPIPA Cal. B&P 22584(b)(2)", description: "No amassing of a student profile except in furtherance of K-12 purposes." }),
42
+ Object.freeze({ id: "noSaleOfStudentData", required: true, citation: "SOPIPA Cal. B&P 22584(b)(3)", description: "No sale or rental of student information." }),
43
+ Object.freeze({ id: "securitySafeguards", required: true, citation: "SOPIPA Cal. B&P 22584(d)(1)", description: "Reasonable security procedures and practices appropriate to the data's sensitivity." }),
44
+ Object.freeze({ id: "deletionOnRequest", required: true, citation: "SOPIPA Cal. B&P 22584(d)(2)", description: "Deletes student PII within a reasonable time at the school's or district's request." }),
45
+ Object.freeze({ id: "subProcessorsCurrent", required: true, citation: "FERPA 34 CFR 99.33 (redisclosure)", description: "Sub-processor list is current and each is bound to the same restrictions." }),
46
+ Object.freeze({ id: "breachNotification", required: true, citation: "FERPA 34 CFR 99.31(a)(1) control + state breach law", description: "Notifies the school / district of any security breach without undue delay." }),
47
+ Object.freeze({ id: "schoolOfficialDesignation", required: true, citation: "FERPA 34 CFR 99.31(a)(1)(i)(B)", description: "Vendor is designated a school official with a legitimate educational interest." }),
48
+ Object.freeze({ id: "directoryInformationHandling", required: false, citation: "FERPA 34 CFR 99.37", description: "Handles directory information per the school's opt-out notice (only when applicable)." }),
49
+ ]);
50
+
51
+ var CLAUSE_IDS = VENDOR_REVIEW_CLAUSES.map(function (c) { return c.id; });
52
+
53
+ /**
54
+ * @primitive b.privacy.vendorReview
55
+ * @signature b.privacy.vendorReview(opts)
56
+ * @since 0.14.14
57
+ * @status stable
58
+ * @compliance ferpa, ca-sopipa, coppa
59
+ * @related b.consent.recognizedPurpose, b.compliance.describe, b.retention
60
+ *
61
+ * Build a dated annual third-party / EdTech vendor-review attestation —
62
+ * the record a FERPA school-official arrangement and California SOPIPA
63
+ * expect a school or district to keep for every processor of student
64
+ * data. The operator supplies the vendor's attested answer (a boolean)
65
+ * per clause; `vendorReview` validates the shape, computes whether every
66
+ * REQUIRED clause is attested (`attested`) and which are not (`gaps`),
67
+ * and stamps the review date plus a 365-day `nextReviewDueAt` re-review
68
+ * clock. Operator-feeds-metadata: the returned report is frozen and is
69
+ * NOT framework-persisted — compose it into your retention / audit /
70
+ * export sink. A best-effort `privacy.vendor_review.recorded` audit event
71
+ * fires when an audit sink is wired.
72
+ *
73
+ * @opts
74
+ * vendorName: string, // required — the processor under review
75
+ * reviewedAt: number, // required — epoch ms of this review
76
+ * clauses: { <clauseId>: boolean }, // attested answer per clause (see listVendorReviewClauses)
77
+ * reviewer: string, // optional — who performed the review
78
+ * notes: string, // optional — free-text reviewer notes
79
+ *
80
+ * @example
81
+ * var report = b.privacy.vendorReview({
82
+ * vendorName: "Acme LMS",
83
+ * reviewedAt: Date.now(),
84
+ * clauses: {
85
+ * educationalPurposeOnly: true, noTargetedAdvertising: true,
86
+ * noCommercialProfiling: true, noSaleOfStudentData: true,
87
+ * securitySafeguards: true, deletionOnRequest: true,
88
+ * subProcessorsCurrent: true, breachNotification: true,
89
+ * schoolOfficialDesignation: true,
90
+ * },
91
+ * });
92
+ * // → { vendorName, reviewedAt, nextReviewDueAt, attested: true, gaps: [], clauses: {...} }
93
+ */
94
+ function vendorReview(opts) {
95
+ validateOpts.requireObject(opts, "b.privacy.vendorReview: opts", PrivacyError, "privacy/bad-opts");
96
+ validateOpts.requireNonEmptyString(opts.vendorName, "b.privacy.vendorReview: opts.vendorName", PrivacyError, "privacy/bad-vendor");
97
+ if (typeof opts.reviewedAt !== "number" || !isFinite(opts.reviewedAt) || opts.reviewedAt <= 0) {
98
+ throw new PrivacyError("privacy/bad-reviewed-at",
99
+ "b.privacy.vendorReview: opts.reviewedAt must be a positive epoch-ms number");
100
+ }
101
+ var clauses = opts.clauses || {};
102
+ validateOpts.requireObject(clauses, "b.privacy.vendorReview: opts.clauses", PrivacyError, "privacy/bad-clauses");
103
+ // Reject unknown clause keys — a misspelled clause would otherwise
104
+ // silently never gate.
105
+ Object.keys(clauses).forEach(function (k) {
106
+ if (CLAUSE_IDS.indexOf(k) === -1) {
107
+ throw new PrivacyError("privacy/unknown-clause",
108
+ "b.privacy.vendorReview: unknown clause '" + k + "' (see b.privacy.listVendorReviewClauses())");
109
+ }
110
+ });
111
+ var resolved = {};
112
+ var gaps = [];
113
+ VENDOR_REVIEW_CLAUSES.forEach(function (clause) {
114
+ var v = clauses[clause.id];
115
+ // A supplied clause answer must be a boolean (config-time THROW); an
116
+ // omitted one defaults to not-attested.
117
+ validateOpts.optionalBoolean(v, "b.privacy.vendorReview: clauses." + clause.id, PrivacyError, "privacy/bad-clause-value");
118
+ var attestedTrue = v === true;
119
+ resolved[clause.id] = attestedTrue;
120
+ if (clause.required && !attestedTrue) gaps.push(clause.id);
121
+ });
122
+ var attested = gaps.length === 0;
123
+ var report = Object.freeze({
124
+ vendorName: opts.vendorName,
125
+ reviewedAt: opts.reviewedAt,
126
+ nextReviewDueAt: opts.reviewedAt + C.TIME.days(365),
127
+ coversPeriod: Object.freeze({ from: opts.reviewedAt - C.TIME.days(365), to: opts.reviewedAt }),
128
+ reviewer: opts.reviewer || null,
129
+ notes: opts.notes || null,
130
+ attested: attested,
131
+ gaps: Object.freeze(gaps),
132
+ clauses: Object.freeze(resolved),
133
+ });
134
+ try {
135
+ audit().safeEmit({
136
+ action: "privacy.vendor_review.recorded",
137
+ outcome: attested ? "success" : "denied",
138
+ metadata: { vendorName: opts.vendorName, attested: attested, gaps: gaps, reviewedAt: opts.reviewedAt },
139
+ });
140
+ } catch (_e) { /* drop-silent — audit is best-effort, never block the builder */ }
141
+ return report;
142
+ }
143
+
144
+ /**
145
+ * @primitive b.privacy.listVendorReviewClauses
146
+ * @signature b.privacy.listVendorReviewClauses()
147
+ * @since 0.14.14
148
+ * @status stable
149
+ * @related b.privacy.vendorReview
150
+ *
151
+ * Return the frozen FERPA / SOPIPA vendor-review clause set — each entry
152
+ * is `{ id, required, citation, description }`. Use it to render a review
153
+ * form or to enumerate the clauses `vendorReview` evaluates.
154
+ *
155
+ * @example
156
+ * b.privacy.listVendorReviewClauses().map(function (c) { return c.id; });
157
+ * // → ["educationalPurposeOnly", "noTargetedAdvertising", ...]
158
+ */
159
+ function listVendorReviewClauses() {
160
+ return VENDOR_REVIEW_CLAUSES;
161
+ }
162
+
163
+ module.exports = {
164
+ vendorReview: vendorReview,
165
+ listVendorReviewClauses: listVendorReviewClauses,
166
+ VENDOR_REVIEW_CLAUSES: VENDOR_REVIEW_CLAUSES,
167
+ PrivacyError: PrivacyError,
168
+ };