@blamejs/core 0.8.69 → 0.8.72

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/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
  /**
@@ -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" : null;
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,