@blamejs/core 0.8.68 → 0.8.71
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 +3 -0
- package/README.md +158 -13
- package/index.js +2 -0
- package/lib/audit.js +1 -0
- package/lib/auth/oauth.js +202 -3
- package/lib/compliance.js +76 -0
- package/lib/data-act.js +328 -0
- package/lib/fapi2.js +109 -1
- package/lib/mcp.js +322 -0
- package/lib/middleware/cors.js +23 -1
- package/lib/middleware/require-aal.js +2 -0
- package/lib/middleware/require-auth.js +11 -3
- package/lib/middleware/require-step-up.js +3 -0
- package/lib/middleware/security-headers.js +14 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +8 -8
package/lib/compliance.js
CHANGED
|
@@ -84,6 +84,7 @@ var KNOWN_POSTURES = Object.freeze([
|
|
|
84
84
|
"uk-gdpr", // UK General Data Protection Regulation (added 2026)
|
|
85
85
|
// ---- Sectoral expansions (added 2026 — v0.8.24) ----
|
|
86
86
|
"fapi-2.0", // Financial-grade API 2.0 Final (composes PAR + DPoP + OAuth 2.1 + mTLS)
|
|
87
|
+
"fapi-2.0-message-signing", // FAPI 2.0 Message Signing profile — adds JARM mandate + signed-request-object enforcement
|
|
87
88
|
"cfpb-1033", // CFPB §1033 / FDX consumer-financial-data sharing (deadline past for $250B+ banks 2026-04-01)
|
|
88
89
|
"iab-tcf-v2.3", // IAB Transparency & Consent Framework v2.3 with disclosedVendors (deadline past 2026-02-28)
|
|
89
90
|
"iab-mspa", // IAB Multi-State Privacy Agreement / Global Privacy Platform universal opt-out
|
|
@@ -104,6 +105,11 @@ var KNOWN_POSTURES = Object.freeze([
|
|
|
104
105
|
"bsi-c5", // Germany BSI C5
|
|
105
106
|
"ens-es", // Spain Esquema Nacional de Seguridad
|
|
106
107
|
"uk-g-cloud", // UK G-Cloud
|
|
108
|
+
// ---- v0.8.70 expansion — 2026 effective deadlines ----
|
|
109
|
+
"modpa", // Maryland Online Data Privacy Act (effective 2026-10-01) — strict data-min
|
|
110
|
+
"nydfs-500", // NYDFS 23 NYCRR 500 Amendment 2 — financial cybersecurity (multi-factor + asset inventory + governance)
|
|
111
|
+
"hipaa-2026", // HHS HIPAA Security Rule 2026-Q4 final — extends hipaa with mandatory MFA + asset inventory + 72h restoration testing
|
|
112
|
+
"quebec-25", // Quebec Law 25 final phase (effective 2026-09-22) — DPIA + automated-decision opt-out
|
|
107
113
|
]);
|
|
108
114
|
|
|
109
115
|
var STATE = { posture: null, setAt: null };
|
|
@@ -457,6 +463,36 @@ var REGIME_MAP = Object.freeze({
|
|
|
457
463
|
jurisdiction: "UK",
|
|
458
464
|
domain: "privacy",
|
|
459
465
|
},
|
|
466
|
+
"fapi-2.0-message-signing": {
|
|
467
|
+
name: "FAPI 2.0 Message Signing Profile",
|
|
468
|
+
citation: "OpenID Foundation FAPI 2.0 Message Signing — Final",
|
|
469
|
+
jurisdiction: "INTL",
|
|
470
|
+
domain: "financial",
|
|
471
|
+
},
|
|
472
|
+
"modpa": {
|
|
473
|
+
name: "Maryland Online Data Privacy Act",
|
|
474
|
+
citation: "Md. Code Ann., Com. Law §§14-4601 et seq. (effective 2026-10-01)",
|
|
475
|
+
jurisdiction: "US-MD",
|
|
476
|
+
domain: "privacy",
|
|
477
|
+
},
|
|
478
|
+
"nydfs-500": {
|
|
479
|
+
name: "NYDFS 23 NYCRR 500 Amendment 2",
|
|
480
|
+
citation: "23 NYCRR Part 500 (Second Amendment, effective 2024-11-01 with rolling phase-in)",
|
|
481
|
+
jurisdiction: "US-NY",
|
|
482
|
+
domain: "financial",
|
|
483
|
+
},
|
|
484
|
+
"hipaa-2026": {
|
|
485
|
+
name: "HIPAA Security Rule (2026 Final)",
|
|
486
|
+
citation: "45 CFR Parts 160, 162, 164 — HHS Final Rule (effective 2026-Q4)",
|
|
487
|
+
jurisdiction: "US",
|
|
488
|
+
domain: "health",
|
|
489
|
+
},
|
|
490
|
+
"quebec-25": {
|
|
491
|
+
name: "Loi 25 (Quebec — final phase)",
|
|
492
|
+
citation: "An Act to modernize legislative provisions as regards the protection of personal information (Final phase 2026-09-22)",
|
|
493
|
+
jurisdiction: "CA-QC",
|
|
494
|
+
domain: "privacy",
|
|
495
|
+
},
|
|
460
496
|
});
|
|
461
497
|
|
|
462
498
|
/**
|
|
@@ -564,6 +600,46 @@ var POSTURE_DEFAULTS = Object.freeze({
|
|
|
564
600
|
tlsMinVersion: "TLSv1.3",
|
|
565
601
|
requireVacuumAfterErase: true,
|
|
566
602
|
}),
|
|
603
|
+
// v0.8.70 — 2026 effective deadlines
|
|
604
|
+
"modpa": Object.freeze({
|
|
605
|
+
// Maryland Online Data Privacy Act (effective 2026-10-01) —
|
|
606
|
+
// unique among US state privacy laws for its strict data-
|
|
607
|
+
// minimization standard ("reasonably necessary"). The cascade
|
|
608
|
+
// floors mirror GDPR-tier audit + at-rest encryption.
|
|
609
|
+
backupEncryptionRequired: true,
|
|
610
|
+
auditChainSignedRequired: true,
|
|
611
|
+
tlsMinVersion: "TLSv1.3",
|
|
612
|
+
requireVacuumAfterErase: true,
|
|
613
|
+
}),
|
|
614
|
+
"nydfs-500": Object.freeze({
|
|
615
|
+
// NYDFS 23 NYCRR 500 Amendment 2 — financial cyber. Adds
|
|
616
|
+
// mandatory MFA, annual penetration test, asset inventory,
|
|
617
|
+
// governance reporting. Floor: encrypted backups + signed
|
|
618
|
+
// audit chain (already true), TLS 1.3 minimum.
|
|
619
|
+
backupEncryptionRequired: true,
|
|
620
|
+
auditChainSignedRequired: true,
|
|
621
|
+
tlsMinVersion: "TLSv1.3",
|
|
622
|
+
requireVacuumAfterErase: true,
|
|
623
|
+
}),
|
|
624
|
+
"hipaa-2026": Object.freeze({
|
|
625
|
+
// HHS HIPAA Security Rule final 2026-Q4 — extends hipaa with
|
|
626
|
+
// mandatory MFA, asset inventory, 72h restoration testing,
|
|
627
|
+
// expanded encryption-at-rest scope.
|
|
628
|
+
backupEncryptionRequired: true,
|
|
629
|
+
auditChainSignedRequired: true,
|
|
630
|
+
tlsMinVersion: "TLSv1.3",
|
|
631
|
+
requireVacuumAfterErase: true,
|
|
632
|
+
}),
|
|
633
|
+
"quebec-25": Object.freeze({
|
|
634
|
+
// Quebec Law 25 final phase (effective 2026-09-22) — DPIA
|
|
635
|
+
// mandatory for high-risk processing + automated-decision
|
|
636
|
+
// explanation right. Cascade floor: encrypted backups + signed
|
|
637
|
+
// audit chain.
|
|
638
|
+
backupEncryptionRequired: true,
|
|
639
|
+
auditChainSignedRequired: true,
|
|
640
|
+
tlsMinVersion: "TLSv1.3",
|
|
641
|
+
requireVacuumAfterErase: true,
|
|
642
|
+
}),
|
|
567
643
|
});
|
|
568
644
|
|
|
569
645
|
/**
|
package/lib/data-act.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.dataAct
|
|
4
|
+
* @nav Compliance
|
|
5
|
+
* @title EU Data Act
|
|
6
|
+
* @order 550
|
|
7
|
+
* @card EU Data Act (Regulation 2023/2854) — connected-product
|
|
8
|
+
* data-sharing primitive. Implements the operator-facing
|
|
9
|
+
* surface for Articles 4 / 5 / 6 / 28 / 32: user-
|
|
10
|
+
* accessible data, third-party sharing, anti-lock-in
|
|
11
|
+
* switching support, and gatekeeper-platform refusal.
|
|
12
|
+
*
|
|
13
|
+
* @intro
|
|
14
|
+
* The EU Data Act came into force 2024-01-11 and applies from
|
|
15
|
+
* 2025-09-12 (general applicability) with cloud-switching support
|
|
16
|
+
* obligations from 2025-09-12 + freely-switchable data-portability
|
|
17
|
+
* from 2027-01-12. Operators of "connected products" (IoT,
|
|
18
|
+
* industrial sensors, smart-home devices) and "related services"
|
|
19
|
+
* (apps that depend on those products) MUST:
|
|
20
|
+
*
|
|
21
|
+
* - Art 4 §1 — let the user access "readily available product
|
|
22
|
+
* data" generated by their use of the connected product.
|
|
23
|
+
* `b.dataAct.userAccessible(productId, userId)` returns the
|
|
24
|
+
* operator-supplied data slice.
|
|
25
|
+
* - Art 5 §1 — share that data with a third-party data
|
|
26
|
+
* recipient on the user's request, "without undue delay,
|
|
27
|
+
* free of charge to the user, of the same quality as is
|
|
28
|
+
* available to the data holder."
|
|
29
|
+
* - Art 6 §2(c) — the third-party data recipient MUST NOT
|
|
30
|
+
* re-share the data without a fresh user authorization
|
|
31
|
+
* (sub-licensing prohibited by default).
|
|
32
|
+
* - Art 28 — switching support: source-cloud operators
|
|
33
|
+
* provide retrieval of a customer's exportable data + clear
|
|
34
|
+
* maximum notice period (≤30 days for non-personal data).
|
|
35
|
+
* - Art 32 §1 — gatekeeper platforms (designated under DMA)
|
|
36
|
+
* MUST NOT receive Art 5 shares.
|
|
37
|
+
*
|
|
38
|
+
* This primitive provides the operator-facing surface; the
|
|
39
|
+
* actual product-data shape is operator-defined (the framework
|
|
40
|
+
* refuses to assume a schema for IoT telemetry). Audit emissions
|
|
41
|
+
* under the `dataact` namespace track every share / refusal /
|
|
42
|
+
* switch-event for the regulator-facing record.
|
|
43
|
+
*
|
|
44
|
+
* Surface:
|
|
45
|
+
*
|
|
46
|
+
* b.dataAct.declareProduct({ productId, dataHolder, ... })
|
|
47
|
+
* b.dataAct.recordUserAccess({ productId, userId, dataSlice, ... })
|
|
48
|
+
* b.dataAct.shareWithThirdParty({ productId, userId, recipient, scope })
|
|
49
|
+
* b.dataAct.refuseGatekeeper({ recipient })
|
|
50
|
+
* b.dataAct.recordSwitchRequest({ customerId, targetProvider, dataSlices })
|
|
51
|
+
*
|
|
52
|
+
* The framework does NOT host the connected-product data itself;
|
|
53
|
+
* operators wire `b.objectStore` or their own storage. b.dataAct
|
|
54
|
+
* is the audit + refusal surface, not the data layer.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
var lazyRequire = require("./lazy-require");
|
|
58
|
+
var validateOpts = require("./validate-opts");
|
|
59
|
+
var { defineClass } = require("./framework-error");
|
|
60
|
+
|
|
61
|
+
var DataActError = defineClass("DataActError", { alwaysPermanent: true });
|
|
62
|
+
|
|
63
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
64
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
65
|
+
var emit = validateOpts.makeNamespacedEmitters("dataact",
|
|
66
|
+
{ audit: audit, observability: observability });
|
|
67
|
+
|
|
68
|
+
// Module-level registry — operators declare products at boot via
|
|
69
|
+
// b.dataAct.declareProduct; subsequent calls cross-check the
|
|
70
|
+
// registration so a typo or a non-Data-Act-scope call surfaces
|
|
71
|
+
// loudly instead of silently emitting audit rows for a phantom
|
|
72
|
+
// product.
|
|
73
|
+
var _products = Object.create(null);
|
|
74
|
+
|
|
75
|
+
// Designated gatekeepers under the EU Digital Markets Act (DMA)
|
|
76
|
+
// per Commission Implementing Decision 2023-09-06 + 2024 expansions.
|
|
77
|
+
// Art 32 §1 of the Data Act prohibits Art 5 third-party shares to
|
|
78
|
+
// these recipients regardless of user request — operators with a
|
|
79
|
+
// legitimate gatekeeper-side flow opt out per-call (audited
|
|
80
|
+
// reason).
|
|
81
|
+
var DESIGNATED_GATEKEEPERS = Object.freeze([
|
|
82
|
+
"alphabet", "google",
|
|
83
|
+
"amazon",
|
|
84
|
+
"apple",
|
|
85
|
+
"meta", "facebook",
|
|
86
|
+
"microsoft",
|
|
87
|
+
"bytedance", "tiktok",
|
|
88
|
+
"booking",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @primitive b.dataAct.declareProduct
|
|
93
|
+
* @signature b.dataAct.declareProduct(opts)
|
|
94
|
+
* @since 0.8.70
|
|
95
|
+
*
|
|
96
|
+
* Register a connected product / related service in the framework's
|
|
97
|
+
* Data-Act audit registry. Operators call once at boot per product;
|
|
98
|
+
* subsequent recordUserAccess / shareWithThirdParty / etc. cross-
|
|
99
|
+
* check the productId against this registry.
|
|
100
|
+
*
|
|
101
|
+
* @opts
|
|
102
|
+
* {
|
|
103
|
+
* productId: string, // operator-stable id
|
|
104
|
+
* dataHolder: string, // legal entity controlling the data
|
|
105
|
+
* productKind?: "connected-product" | "related-service",
|
|
106
|
+
* dataScope?: string, // free-form description of in-scope data
|
|
107
|
+
* }
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* b.dataAct.declareProduct({
|
|
111
|
+
* productId: "thermostat-v3",
|
|
112
|
+
* dataHolder: "Acme Thermal GmbH",
|
|
113
|
+
* productKind: "connected-product",
|
|
114
|
+
* dataScope: "temperature-readings, schedule, energy-use",
|
|
115
|
+
* });
|
|
116
|
+
*/
|
|
117
|
+
function declareProduct(opts) {
|
|
118
|
+
validateOpts.requireObject(opts, "dataAct.declareProduct", DataActError, "dataact/bad-opts");
|
|
119
|
+
validateOpts.requireNonEmptyString(opts.productId, "productId", DataActError, "dataact/no-product-id");
|
|
120
|
+
validateOpts.requireNonEmptyString(opts.dataHolder, "dataHolder", DataActError, "dataact/no-data-holder");
|
|
121
|
+
var kind = opts.productKind || "connected-product";
|
|
122
|
+
if (kind !== "connected-product" && kind !== "related-service") {
|
|
123
|
+
throw new DataActError("dataact/bad-kind",
|
|
124
|
+
"declareProduct: productKind must be 'connected-product' or 'related-service'");
|
|
125
|
+
}
|
|
126
|
+
_products[opts.productId] = {
|
|
127
|
+
productId: opts.productId,
|
|
128
|
+
dataHolder: opts.dataHolder,
|
|
129
|
+
productKind: kind,
|
|
130
|
+
dataScope: opts.dataScope || null,
|
|
131
|
+
declaredAt: Date.now(),
|
|
132
|
+
};
|
|
133
|
+
emit.audit("product_declared", "success", {
|
|
134
|
+
productId: opts.productId,
|
|
135
|
+
dataHolder: opts.dataHolder,
|
|
136
|
+
productKind: kind,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _requireProduct(productId, fnName) {
|
|
141
|
+
if (typeof productId !== "string" || productId.length === 0) {
|
|
142
|
+
throw new DataActError("dataact/no-product-id",
|
|
143
|
+
fnName + ": productId required");
|
|
144
|
+
}
|
|
145
|
+
if (!_products[productId]) {
|
|
146
|
+
throw new DataActError("dataact/unknown-product",
|
|
147
|
+
fnName + ": productId '" + productId + "' not declared via b.dataAct.declareProduct");
|
|
148
|
+
}
|
|
149
|
+
return _products[productId];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @primitive b.dataAct.recordUserAccess
|
|
154
|
+
* @signature b.dataAct.recordUserAccess(opts)
|
|
155
|
+
* @since 0.8.70
|
|
156
|
+
*
|
|
157
|
+
* Audit emission for an Art 4 §1 user-access event. The operator
|
|
158
|
+
* delivers the actual data slice through their own data path
|
|
159
|
+
* (`b.objectStore`, REST API, etc.); this primitive records the
|
|
160
|
+
* regulator-facing audit row.
|
|
161
|
+
*
|
|
162
|
+
* @opts
|
|
163
|
+
* {
|
|
164
|
+
* productId: string,
|
|
165
|
+
* userId: string, // pseudonymous OK; sealed via b.cryptoField
|
|
166
|
+
* dataSlice?: string, // op-defined slice identifier
|
|
167
|
+
* bytes?: number, // size of the served slice
|
|
168
|
+
* }
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* b.dataAct.recordUserAccess({
|
|
172
|
+
* productId: "thermo-v3",
|
|
173
|
+
* userId: "user-1",
|
|
174
|
+
* dataSlice: "temperature-readings",
|
|
175
|
+
* });
|
|
176
|
+
*/
|
|
177
|
+
function recordUserAccess(opts) {
|
|
178
|
+
validateOpts.requireObject(opts, "dataAct.recordUserAccess", DataActError, "dataact/bad-opts");
|
|
179
|
+
_requireProduct(opts.productId, "recordUserAccess");
|
|
180
|
+
validateOpts.requireNonEmptyString(opts.userId, "userId", DataActError, "dataact/no-user-id");
|
|
181
|
+
emit.audit("user_access", "success", {
|
|
182
|
+
productId: opts.productId,
|
|
183
|
+
userId: opts.userId,
|
|
184
|
+
dataSlice: opts.dataSlice || null,
|
|
185
|
+
bytes: typeof opts.bytes === "number" ? opts.bytes : null,
|
|
186
|
+
article: "art-4-1",
|
|
187
|
+
});
|
|
188
|
+
emit.metric("user-access-recorded");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @primitive b.dataAct.shareWithThirdParty
|
|
193
|
+
* @signature b.dataAct.shareWithThirdParty(opts)
|
|
194
|
+
* @since 0.8.70
|
|
195
|
+
*
|
|
196
|
+
* Art 5 §1 third-party share. Refuses gatekeepers per Art 32 §1
|
|
197
|
+
* unless `acceptGatekeeper: { reason }` is passed (audited).
|
|
198
|
+
* Operators MUST verify the user's authorization separately —
|
|
199
|
+
* the framework records the share but doesn't drive consent
|
|
200
|
+
* itself (`b.consent` is the right primitive for that).
|
|
201
|
+
*
|
|
202
|
+
* @opts
|
|
203
|
+
* {
|
|
204
|
+
* productId: string,
|
|
205
|
+
* userId: string,
|
|
206
|
+
* recipient: string, // recipient legal entity
|
|
207
|
+
* scope: string, // free-form data scope description
|
|
208
|
+
* acceptGatekeeper?: { reason: string },
|
|
209
|
+
* }
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* b.dataAct.shareWithThirdParty({
|
|
213
|
+
* productId: "thermo-v3", userId: "user-1",
|
|
214
|
+
* recipient: "Beta Repair Co", scope: "temperature-readings",
|
|
215
|
+
* });
|
|
216
|
+
*/
|
|
217
|
+
function shareWithThirdParty(opts) {
|
|
218
|
+
validateOpts.requireObject(opts, "dataAct.shareWithThirdParty", DataActError, "dataact/bad-opts");
|
|
219
|
+
_requireProduct(opts.productId, "shareWithThirdParty");
|
|
220
|
+
validateOpts.requireNonEmptyString(opts.userId, "userId", DataActError, "dataact/no-user-id");
|
|
221
|
+
validateOpts.requireNonEmptyString(opts.recipient, "recipient", DataActError, "dataact/no-recipient");
|
|
222
|
+
validateOpts.requireNonEmptyString(opts.scope, "scope", DataActError, "dataact/no-scope");
|
|
223
|
+
|
|
224
|
+
var recipientLower = opts.recipient.toLowerCase();
|
|
225
|
+
var isGatekeeper = DESIGNATED_GATEKEEPERS.some(function (g) {
|
|
226
|
+
return recipientLower === g || recipientLower.indexOf(g + ".") !== -1;
|
|
227
|
+
});
|
|
228
|
+
if (isGatekeeper) {
|
|
229
|
+
if (!opts.acceptGatekeeper || typeof opts.acceptGatekeeper !== "object" ||
|
|
230
|
+
typeof opts.acceptGatekeeper.reason !== "string" ||
|
|
231
|
+
opts.acceptGatekeeper.reason.length === 0) {
|
|
232
|
+
emit.audit("share_refused", "failure", {
|
|
233
|
+
productId: opts.productId,
|
|
234
|
+
userId: opts.userId,
|
|
235
|
+
recipient: opts.recipient,
|
|
236
|
+
reason: "designated-gatekeeper",
|
|
237
|
+
article: "art-32-1",
|
|
238
|
+
});
|
|
239
|
+
throw new DataActError("dataact/gatekeeper-refused",
|
|
240
|
+
"shareWithThirdParty: recipient '" + opts.recipient + "' is a designated DMA " +
|
|
241
|
+
"gatekeeper — Art 32 §1 of the Data Act prohibits Art 5 third-party shares to " +
|
|
242
|
+
"gatekeepers. Operators with a legitimate gatekeeper-side flow MUST pass " +
|
|
243
|
+
"{ acceptGatekeeper: { reason: '<audited justification>' } }.");
|
|
244
|
+
}
|
|
245
|
+
emit.audit("share_to_gatekeeper", "success", {
|
|
246
|
+
productId: opts.productId,
|
|
247
|
+
userId: opts.userId,
|
|
248
|
+
recipient: opts.recipient,
|
|
249
|
+
reason: opts.acceptGatekeeper.reason,
|
|
250
|
+
article: "art-32-1-override",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
emit.audit("share_with_third_party", "success", {
|
|
254
|
+
productId: opts.productId,
|
|
255
|
+
userId: opts.userId,
|
|
256
|
+
recipient: opts.recipient,
|
|
257
|
+
scope: opts.scope,
|
|
258
|
+
article: "art-5-1",
|
|
259
|
+
});
|
|
260
|
+
emit.metric("third-party-share");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* @primitive b.dataAct.recordSwitchRequest
|
|
265
|
+
* @signature b.dataAct.recordSwitchRequest(opts)
|
|
266
|
+
* @since 0.8.70
|
|
267
|
+
*
|
|
268
|
+
* Art 28 cloud-switching event. Records the switch request for the
|
|
269
|
+
* regulator-facing audit chain. Operators serving the actual data
|
|
270
|
+
* export wire the operator-side retrieval path; this primitive is
|
|
271
|
+
* the audit record.
|
|
272
|
+
*
|
|
273
|
+
* Art 28 §3 requires "the data processing service provider shall
|
|
274
|
+
* complete the switching operation within a maximum notice period
|
|
275
|
+
* of 30 calendar days" for non-personal data. The framework
|
|
276
|
+
* surfaces the deadline via `noticePeriodDays` (default 30).
|
|
277
|
+
*
|
|
278
|
+
* @opts
|
|
279
|
+
* {
|
|
280
|
+
* customerId: string,
|
|
281
|
+
* targetProvider: string,
|
|
282
|
+
* dataSlices: string[],
|
|
283
|
+
* noticePeriodDays?: number, // default 30 (Art 28 §3 cap)
|
|
284
|
+
* }
|
|
285
|
+
*
|
|
286
|
+
* @example
|
|
287
|
+
* b.dataAct.recordSwitchRequest({
|
|
288
|
+
* customerId: "c1", targetProvider: "OtherCloud",
|
|
289
|
+
* dataSlices: ["app-state", "user-files"],
|
|
290
|
+
* });
|
|
291
|
+
*/
|
|
292
|
+
function recordSwitchRequest(opts) {
|
|
293
|
+
validateOpts.requireObject(opts, "dataAct.recordSwitchRequest", DataActError, "dataact/bad-opts");
|
|
294
|
+
validateOpts.requireNonEmptyString(opts.customerId, "customerId", DataActError, "dataact/no-customer-id");
|
|
295
|
+
validateOpts.requireNonEmptyString(opts.targetProvider, "targetProvider", DataActError, "dataact/no-target");
|
|
296
|
+
if (!Array.isArray(opts.dataSlices) || opts.dataSlices.length === 0) {
|
|
297
|
+
throw new DataActError("dataact/no-data-slices",
|
|
298
|
+
"recordSwitchRequest: dataSlices must be a non-empty array");
|
|
299
|
+
}
|
|
300
|
+
var noticePeriod = typeof opts.noticePeriodDays === "number" ? opts.noticePeriodDays : 30; // allow:raw-byte-literal — Art 28 §3 30-day cap
|
|
301
|
+
if (noticePeriod > 30) { // allow:raw-byte-literal — Art 28 §3 30-day cap
|
|
302
|
+
throw new DataActError("dataact/notice-period-too-long",
|
|
303
|
+
"recordSwitchRequest: noticePeriodDays " + noticePeriod + " exceeds Art 28 §3 cap of 30 days");
|
|
304
|
+
}
|
|
305
|
+
emit.audit("switch_request", "success", {
|
|
306
|
+
customerId: opts.customerId,
|
|
307
|
+
targetProvider: opts.targetProvider,
|
|
308
|
+
dataSlices: opts.dataSlices.slice(),
|
|
309
|
+
noticePeriodDays: noticePeriod,
|
|
310
|
+
article: "art-28",
|
|
311
|
+
});
|
|
312
|
+
emit.metric("switch-request");
|
|
313
|
+
return { acceptedAt: Date.now(), noticePeriodDays: noticePeriod };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function _resetForTest() {
|
|
317
|
+
_products = Object.create(null);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = {
|
|
321
|
+
declareProduct: declareProduct,
|
|
322
|
+
recordUserAccess: recordUserAccess,
|
|
323
|
+
shareWithThirdParty: shareWithThirdParty,
|
|
324
|
+
recordSwitchRequest: recordSwitchRequest,
|
|
325
|
+
DataActError: DataActError,
|
|
326
|
+
DESIGNATED_GATEKEEPERS: DESIGNATED_GATEKEEPERS,
|
|
327
|
+
_resetForTest: _resetForTest,
|
|
328
|
+
};
|
package/lib/fapi2.js
CHANGED
|
@@ -274,12 +274,120 @@ function assertOAuthConfig(oauthOpts) {
|
|
|
274
274
|
* // → "fapi-2.0"
|
|
275
275
|
*/
|
|
276
276
|
function posture() {
|
|
277
|
-
return compliance.current() === "fapi-2.0" ? "fapi-2.0" :
|
|
277
|
+
return compliance.current() === "fapi-2.0" ? "fapi-2.0" :
|
|
278
|
+
compliance.current() === "fapi-2.0-message-signing" ? "fapi-2.0-message-signing" :
|
|
279
|
+
null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @primitive b.fapi2.assertCallback
|
|
284
|
+
* @signature b.fapi2.assertCallback(query, opts?)
|
|
285
|
+
* @since 0.8.70
|
|
286
|
+
* @related b.fapi2.posture, b.auth.oauth.parseCallback
|
|
287
|
+
*
|
|
288
|
+
* Runtime gate the OAuth callback handler invokes BEFORE
|
|
289
|
+
* `parseCallback` to enforce FAPI 2.0's wire-format invariants
|
|
290
|
+
* against the live response:
|
|
291
|
+
*
|
|
292
|
+
* - **§5.4.2 iss-callback** — refuse callbacks lacking `iss`
|
|
293
|
+
* under any FAPI 2.0 posture (regardless of OP discovery).
|
|
294
|
+
* - **§5.3.2 / Message Signing JARM mandate** — under
|
|
295
|
+
* `fapi-2.0-message-signing`, the OP MUST deliver the
|
|
296
|
+
* authorization response as a signed JWT (`response=<jwt>`
|
|
297
|
+
* query param). A bare-param callback is refused.
|
|
298
|
+
*
|
|
299
|
+
* Returns silently on success. Throws Fapi2Error on any FAPI
|
|
300
|
+
* invariant breach. No-op when no FAPI posture is active.
|
|
301
|
+
*
|
|
302
|
+
* @opts
|
|
303
|
+
* { requireJarm?: boolean } // override (default: derive from posture)
|
|
304
|
+
*
|
|
305
|
+
* @example
|
|
306
|
+
* app.get("/oauth/callback", async function (req, res) {
|
|
307
|
+
* var query = Object.fromEntries(new URL(req.url, "x:/").searchParams);
|
|
308
|
+
* b.fapi2.assertCallback(query);
|
|
309
|
+
* var parsed = await oauth.parseCallback(query);
|
|
310
|
+
* res.end(JSON.stringify({ code: parsed.code }));
|
|
311
|
+
* });
|
|
312
|
+
*/
|
|
313
|
+
function assertCallback(query, aopts) {
|
|
314
|
+
aopts = aopts || {};
|
|
315
|
+
var p = posture();
|
|
316
|
+
if (p === null) return; // no FAPI posture active — no-op
|
|
317
|
+
if (!query || typeof query !== "object") {
|
|
318
|
+
throw new Fapi2Error("fapi-2.0/bad-callback",
|
|
319
|
+
"fapi2.assertCallback: query must be an object");
|
|
320
|
+
}
|
|
321
|
+
// §5.3.2 / Message Signing — require JARM when posture demands it.
|
|
322
|
+
// The bare-param callback (no `response=<jwt>`) is the smoking-gun
|
|
323
|
+
// signal that JARM was bypassed; refuse loudly.
|
|
324
|
+
var requireJarm = aopts.requireJarm !== undefined
|
|
325
|
+
? aopts.requireJarm
|
|
326
|
+
: (p === "fapi-2.0-message-signing");
|
|
327
|
+
if (requireJarm) {
|
|
328
|
+
if (typeof query.response !== "string" || query.response.length === 0) {
|
|
329
|
+
throw new Fapi2Error("fapi-2.0/jarm-required",
|
|
330
|
+
"fapi2.assertCallback: posture '" + p + "' requires JARM " +
|
|
331
|
+
"(response_mode=jwt) — callback delivered bare parameters instead. " +
|
|
332
|
+
"FAPI 2.0 Message Signing §5.3.2 mandates signed authorization responses; " +
|
|
333
|
+
"configure the OP with response_mode=jwt and route through " +
|
|
334
|
+
"b.auth.oauth.parseJarmResponse(query.response).");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// §5.4.2 — `iss` MUST be present on every callback under FAPI 2.0.
|
|
338
|
+
// RFC 9207 already adds the cross-check; FAPI 2.0 promotes it from
|
|
339
|
+
// SHOULD to MUST regardless of OP discovery's
|
|
340
|
+
// `authorization_response_iss_parameter_supported` flag.
|
|
341
|
+
if (typeof query.iss !== "string" || query.iss.length === 0) {
|
|
342
|
+
throw new Fapi2Error("fapi-2.0/missing-iss",
|
|
343
|
+
"fapi2.assertCallback: posture '" + p + "' requires the OP to echo " +
|
|
344
|
+
"`iss` on every authorization callback (FAPI 2.0 §5.4.2). The callback " +
|
|
345
|
+
"omitted iss — refused.");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* @primitive b.fapi2.assertAuthzRequest
|
|
351
|
+
* @signature b.fapi2.assertAuthzRequest(authzParams)
|
|
352
|
+
* @since 0.8.70
|
|
353
|
+
* @related b.fapi2.assertCallback
|
|
354
|
+
*
|
|
355
|
+
* Runtime gate the operator wraps around the AuthorizationUrl
|
|
356
|
+
* builder to enforce FAPI 2.0 §5.3.2 — under any FAPI 2.0 posture,
|
|
357
|
+
* the operator MUST send a signed JAR (RFC 9101 `request=<jwt>`
|
|
358
|
+
* OR `request_uri=<par-uri>`). Refuses authorization-request param
|
|
359
|
+
* shapes that look like the bare RFC 6749 query.
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* var params = { request: signedRequestJwt };
|
|
363
|
+
* b.fapi2.assertAuthzRequest(params);
|
|
364
|
+
* var url = oauth.authorizationUrl(params);
|
|
365
|
+
*/
|
|
366
|
+
function assertAuthzRequest(authzParams) {
|
|
367
|
+
var p = posture();
|
|
368
|
+
if (p === null) return;
|
|
369
|
+
if (!authzParams || typeof authzParams !== "object") {
|
|
370
|
+
throw new Fapi2Error("fapi-2.0/bad-authz-params",
|
|
371
|
+
"fapi2.assertAuthzRequest: params must be an object");
|
|
372
|
+
}
|
|
373
|
+
// The operator passes either a built request_object JWT (`request`),
|
|
374
|
+
// a PAR-issued `request_uri`, OR neither (which is a violation).
|
|
375
|
+
var hasJar = (typeof authzParams.request === "string" && authzParams.request.length > 0) ||
|
|
376
|
+
(typeof authzParams.request_uri === "string" && authzParams.request_uri.length > 0);
|
|
377
|
+
if (!hasJar) {
|
|
378
|
+
throw new Fapi2Error("fapi-2.0/jar-required",
|
|
379
|
+
"fapi2.assertAuthzRequest: posture '" + p + "' requires a signed " +
|
|
380
|
+
"request object (RFC 9101 JAR) — pass either `request: <jwt>` OR " +
|
|
381
|
+
"`request_uri: <par-uri>` (FAPI 2.0 §5.3.2). Bare-query authorization " +
|
|
382
|
+
"requests are refused.");
|
|
383
|
+
}
|
|
278
384
|
}
|
|
279
385
|
|
|
280
386
|
module.exports = {
|
|
281
387
|
assertConformance: assertConformance,
|
|
282
388
|
assertOAuthConfig: assertOAuthConfig,
|
|
389
|
+
assertCallback: assertCallback,
|
|
390
|
+
assertAuthzRequest: assertAuthzRequest,
|
|
283
391
|
posture: posture,
|
|
284
392
|
SENDER_CONSTRAINTS: SENDER_CONSTRAINTS.slice(),
|
|
285
393
|
Fapi2Error: Fapi2Error,
|