@blamejs/blamejs-shop 0.3.40 → 0.3.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +14 -3
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +8 -0
- package/lib/vendor/blamejs/README.md +8 -5
- package/lib/vendor/blamejs/SECURITY.md +7 -0
- package/lib/vendor/blamejs/api-snapshot.json +266 -2
- package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +1 -0
- package/lib/vendor/blamejs/index.js +7 -1
- package/lib/vendor/blamejs/lib/agent-idempotency.js +113 -0
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +108 -0
- package/lib/vendor/blamejs/lib/agent-snapshot.js +137 -0
- package/lib/vendor/blamejs/lib/agent-tenant.js +193 -17
- package/lib/vendor/blamejs/lib/ai-input.js +167 -3
- package/lib/vendor/blamejs/lib/ai-output.js +463 -0
- package/lib/vendor/blamejs/lib/ai-prompt.js +304 -0
- package/lib/vendor/blamejs/lib/archive-wrap.js +234 -1
- package/lib/vendor/blamejs/lib/archive.js +1 -0
- package/lib/vendor/blamejs/lib/audit.js +3 -0
- package/lib/vendor/blamejs/lib/auth/oid4vp.js +47 -28
- package/lib/vendor/blamejs/lib/cluster.js +186 -14
- package/lib/vendor/blamejs/lib/codepoint-class.js +18 -0
- package/lib/vendor/blamejs/lib/compliance-ai-act.js +446 -0
- package/lib/vendor/blamejs/lib/consent.js +104 -8
- package/lib/vendor/blamejs/lib/content-credentials.js +851 -41
- package/lib/vendor/blamejs/lib/crypto-field.js +5 -0
- package/lib/vendor/blamejs/lib/db.js +15 -0
- package/lib/vendor/blamejs/lib/framework-error.js +21 -0
- package/lib/vendor/blamejs/lib/mail-srs.js +122 -19
- package/lib/vendor/blamejs/lib/privacy.js +168 -0
- package/lib/vendor/blamejs/lib/safe-archive.js +196 -136
- package/lib/vendor/blamejs/lib/validate-opts.js +24 -0
- package/lib/vendor/blamejs/lib/vault/rotate.js +175 -15
- package/lib/vendor/blamejs/lib/vault-aad.js +84 -33
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.11.json +72 -0
- package/lib/vendor/blamejs/release-notes/v0.14.12.json +95 -0
- package/lib/vendor/blamejs/release-notes/v0.14.13.json +52 -0
- package/lib/vendor/blamejs/release-notes/v0.14.14.json +31 -0
- package/lib/vendor/blamejs/test/00-primitives.js +9 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +103 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +91 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-snapshot.test.js +186 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-tenant.test.js +140 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-input.test.js +59 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-output.test.js +125 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-prompt.test.js +133 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +6 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +94 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-wrap.test.js +176 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cluster-vault-rotation.test.js +243 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +250 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +130 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/consent-purposes.test.js +70 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/content-credentials.test.js +289 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +22 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-srs.test.js +61 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/privacy-vendor-review.test.js +69 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/vault-rotate-aad.test.js +158 -0
- 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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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)`
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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 —
|
|
162
|
-
//
|
|
163
|
-
//
|
|
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;
|
|
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
|
|
187
|
-
// FIRST so an obviously-non-
|
|
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
|
-
|
|
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:
|
|
235
|
-
|
|
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
|
+
};
|