@blamejs/core 0.8.76 → 0.8.78
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 +2 -0
- package/index.js +2 -0
- package/lib/acme.js +200 -1
- package/lib/auth/oauth.js +329 -0
- package/lib/compliance-ai-act.js +161 -3
- package/lib/compliance.js +48 -0
- package/lib/config.js +129 -13
- package/lib/content-credentials.js +227 -0
- package/lib/cra-report.js +106 -2
- package/lib/crypto-field.js +5 -0
- package/lib/dsr.js +96 -0
- package/lib/mcp.js +239 -6
- package/lib/middleware/index.js +4 -0
- package/lib/middleware/protected-resource-metadata.js +165 -0
- package/lib/middleware/rate-limit.js +59 -3
- package/lib/middleware/scim-server.js +375 -0
- package/lib/middleware/security-headers.js +12 -0
- package/lib/nist-crosswalk.js +293 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/compliance-ai-act.js
CHANGED
|
@@ -537,6 +537,162 @@ function deployerChecklist(assessment) {
|
|
|
537
537
|
return items;
|
|
538
538
|
}
|
|
539
539
|
|
|
540
|
+
/**
|
|
541
|
+
* @primitive b.complianceAiAct.fundamentalRightsImpactAssessment
|
|
542
|
+
* @signature b.complianceAiAct.fundamentalRightsImpactAssessment(opts)
|
|
543
|
+
* @since 0.8.77
|
|
544
|
+
*
|
|
545
|
+
* EU AI Act Article 27 — Fundamental Rights Impact Assessment (FRIA).
|
|
546
|
+
* Mandatory for deployers of high-risk AI systems listed in Annex III
|
|
547
|
+
* §5 (creditworthiness scoring, life/health insurance risk), §6
|
|
548
|
+
* (law enforcement), §7 (migration/asylum), §8 (justice admin), public
|
|
549
|
+
* authorities, and any private body providing public services. Must
|
|
550
|
+
* be completed BEFORE the first use of the high-risk system, kept
|
|
551
|
+
* updated, and notified to the national market-surveillance authority.
|
|
552
|
+
*
|
|
553
|
+
* Returns the structured FRIA document scaffold — operator fills in
|
|
554
|
+
* the per-deployment specifics; the framework auto-populates the
|
|
555
|
+
* fields it can derive (system identification, GPAI classification
|
|
556
|
+
* if applicable, Annex IV reference, deployment-context audit hooks).
|
|
557
|
+
*
|
|
558
|
+
* @opts
|
|
559
|
+
* {
|
|
560
|
+
* systemId: string, // operator's high-risk system identifier
|
|
561
|
+
* systemDescription: { ... }, // forwarded to classify() for risk-tier verdict
|
|
562
|
+
* deploymentContext: { purpose, sector, geography, scale },
|
|
563
|
+
* affectedPersons: { categories: string[], estimatedCount: number },
|
|
564
|
+
* risksToFundamentalRights: string[], // operator-identified risks
|
|
565
|
+
* mitigations: string[], // mitigations + monitoring per risk
|
|
566
|
+
* humanOversight: { roles: string[], escalationPath: string },
|
|
567
|
+
* residualRisks: string[],
|
|
568
|
+
* reviewCadence: string, // e.g. "quarterly"
|
|
569
|
+
* }
|
|
570
|
+
*
|
|
571
|
+
* @example
|
|
572
|
+
* var fria = b.complianceAiAct.fundamentalRightsImpactAssessment({
|
|
573
|
+
* systemId: "credit-scoring-v3",
|
|
574
|
+
* deploymentContext: { purpose: "loan approval", sector: "financial",
|
|
575
|
+
* geography: "EU", scale: "1M decisions/year" },
|
|
576
|
+
* affectedPersons: { categories: ["EU consumers"], estimatedCount: 1000000 },
|
|
577
|
+
* risksToFundamentalRights: ["discriminatory denial", "right to explanation"],
|
|
578
|
+
* mitigations: ["bias audit every 6 months", "human review threshold"],
|
|
579
|
+
* humanOversight: { roles: ["credit officer"], escalationPath: "ombudsman" },
|
|
580
|
+
* reviewCadence: "semi-annual",
|
|
581
|
+
* });
|
|
582
|
+
*/
|
|
583
|
+
function fundamentalRightsImpactAssessment(opts) {
|
|
584
|
+
if (!opts || typeof opts !== "object") {
|
|
585
|
+
throw new Error("fundamentalRightsImpactAssessment: opts required");
|
|
586
|
+
}
|
|
587
|
+
validateOpts.requireNonEmptyString(opts.systemId, "systemId",
|
|
588
|
+
Error, "compliance-ai-act/no-system-id");
|
|
589
|
+
return {
|
|
590
|
+
"$schema": "https://blamejs.com/schema/ai-act-fria-v1.json",
|
|
591
|
+
regulation: "EU Regulation 2024/1689 — AI Act",
|
|
592
|
+
article: "Article 27 (Fundamental Rights Impact Assessment)",
|
|
593
|
+
generatedAt: new Date().toISOString(),
|
|
594
|
+
systemId: opts.systemId,
|
|
595
|
+
classification: opts.systemDescription ? classify(opts.systemDescription) : null,
|
|
596
|
+
deploymentContext: opts.deploymentContext || {},
|
|
597
|
+
affectedPersons: opts.affectedPersons || { categories: [], estimatedCount: null },
|
|
598
|
+
risks: opts.risksToFundamentalRights || [],
|
|
599
|
+
mitigations: opts.mitigations || [],
|
|
600
|
+
humanOversight: opts.humanOversight || { roles: [], escalationPath: null },
|
|
601
|
+
residualRisks: opts.residualRisks || [],
|
|
602
|
+
reviewCadence: opts.reviewCadence || "annual",
|
|
603
|
+
notificationStatus: "operator-must-notify",
|
|
604
|
+
note: "Notify national market-surveillance authority before first use (Art 27(3))",
|
|
605
|
+
auditHook: "b.audit emission action='aiact.fria.completed' recommended",
|
|
606
|
+
annexIVReference: "see b.complianceAiAct.annexIVScaffold for technical documentation",
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* @primitive b.complianceAiAct.gpai.trainingDataSummary
|
|
612
|
+
* @signature b.complianceAiAct.gpai.trainingDataSummary(opts)
|
|
613
|
+
* @since 0.8.77
|
|
614
|
+
*
|
|
615
|
+
* EU AI Act Article 53(1)(d) — GPAI training-data summary template
|
|
616
|
+
* compliant with the AI Office's template format (published in 2024,
|
|
617
|
+
* mandatory from 2026-08-02). The template requires categories of
|
|
618
|
+
* data, modalities, source provenance, copyright + licensing status,
|
|
619
|
+
* dataset sizes, dates of collection, and steps taken to identify +
|
|
620
|
+
* mitigate biases.
|
|
621
|
+
*
|
|
622
|
+
* Returns the JSON document operators publish under their `/.well-known/
|
|
623
|
+
* ai-training-data-summary` endpoint or attach to model cards.
|
|
624
|
+
*
|
|
625
|
+
* @opts
|
|
626
|
+
* modelId: string, // required
|
|
627
|
+
* modelVersion: string, // optional
|
|
628
|
+
* provider: object, // { name, address, contact }
|
|
629
|
+
* dataCategories: string[], // ["web-crawl", "books", "code", "synthetic", ...]
|
|
630
|
+
* modalities: string[], // ["text", "image", "audio", "video"]
|
|
631
|
+
* sources: object[], // { identifier, url, type, licenseStatus, size, collectedFrom, collectedTo }
|
|
632
|
+
* copyrightStatus: object, // { respectsRightReservations, machineReadableSignalsObserved, tdmExceptionUsed }
|
|
633
|
+
* biasMitigation: object, // { methodsApplied, auditCadence, remediations }
|
|
634
|
+
* contentProvenance: object, // { synthIdEmbed, c2paManifestEmbed, watermarkProvider }
|
|
635
|
+
*
|
|
636
|
+
* @example
|
|
637
|
+
* var summary = b.complianceAiAct.gpai.trainingDataSummary({
|
|
638
|
+
* modelId: "acme-llm-7b",
|
|
639
|
+
* modelVersion: "1.0",
|
|
640
|
+
* provider: { name: "Acme AI", address: "1 St", contact: "ai@acme.example" },
|
|
641
|
+
* dataCategories: ["web-crawl", "books", "code"],
|
|
642
|
+
* modalities: ["text"],
|
|
643
|
+
* sources: [
|
|
644
|
+
* { identifier: "CommonCrawl-2024", type: "web-crawl", licenseStatus: "permitted" },
|
|
645
|
+
* ],
|
|
646
|
+
* biasMitigation: { methodsApplied: ["demographic-balance"], auditCadence: "quarterly" },
|
|
647
|
+
* });
|
|
648
|
+
*/
|
|
649
|
+
function trainingDataSummary(opts) {
|
|
650
|
+
if (!opts || typeof opts !== "object") {
|
|
651
|
+
throw new Error("trainingDataSummary: opts required");
|
|
652
|
+
}
|
|
653
|
+
validateOpts.requireNonEmptyString(opts.modelId, "modelId",
|
|
654
|
+
Error, "compliance-ai-act/no-model-id");
|
|
655
|
+
return {
|
|
656
|
+
"$schema": "https://blamejs.com/schema/ai-act-gpai-training-summary-v1.json",
|
|
657
|
+
regulation: "EU Regulation 2024/1689 — AI Act",
|
|
658
|
+
article: "Article 53(1)(d) (GPAI training data summary)",
|
|
659
|
+
template: "AI Office GPAI Training Data Summary Template",
|
|
660
|
+
generatedAt: new Date().toISOString(),
|
|
661
|
+
modelId: opts.modelId,
|
|
662
|
+
modelVersion: opts.modelVersion || null,
|
|
663
|
+
provider: opts.provider || { name: null, address: null, contact: null },
|
|
664
|
+
dataCategories: opts.dataCategories || [], // ["web-crawl", "books", "code", "synthetic", ...]
|
|
665
|
+
modalities: opts.modalities || [], // ["text", "image", "audio", "video"]
|
|
666
|
+
sources: (opts.sources || []).map(function (s) {
|
|
667
|
+
return {
|
|
668
|
+
identifier: s.identifier,
|
|
669
|
+
url: s.url || null,
|
|
670
|
+
type: s.type || "unknown",
|
|
671
|
+
licenseStatus: s.licenseStatus || "unknown",
|
|
672
|
+
size: s.size || null,
|
|
673
|
+
collectedFrom: s.collectedFrom || null,
|
|
674
|
+
collectedTo: s.collectedTo || null,
|
|
675
|
+
};
|
|
676
|
+
}),
|
|
677
|
+
copyrightStatus: opts.copyrightStatus || {
|
|
678
|
+
respectsRightReservations: null,
|
|
679
|
+
machineReadableSignalsObserved: null,
|
|
680
|
+
tdmExceptionUsed: null,
|
|
681
|
+
},
|
|
682
|
+
biasMitigation: opts.biasMitigation || {
|
|
683
|
+
methodsApplied: [],
|
|
684
|
+
auditCadence: "annual",
|
|
685
|
+
remediations: [],
|
|
686
|
+
},
|
|
687
|
+
contentProvenance: opts.contentProvenance || {
|
|
688
|
+
synthIdEmbed: false,
|
|
689
|
+
c2paManifestEmbed: false,
|
|
690
|
+
watermarkProvider: null,
|
|
691
|
+
},
|
|
692
|
+
note: "Publish at /.well-known/ai-training-data-summary or model card per AI Office template (mandatory 2026-08-02)",
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
540
696
|
module.exports = {
|
|
541
697
|
classify: classify,
|
|
542
698
|
deployerChecklist: deployerChecklist,
|
|
@@ -545,9 +701,10 @@ module.exports = {
|
|
|
545
701
|
transparency: transparency,
|
|
546
702
|
logging: logging,
|
|
547
703
|
gpai: {
|
|
548
|
-
classify:
|
|
549
|
-
listObligations:
|
|
550
|
-
|
|
704
|
+
classify: gpaiClassify,
|
|
705
|
+
listObligations: listGpaiObligations,
|
|
706
|
+
trainingDataSummary: trainingDataSummary,
|
|
707
|
+
OBLIGATIONS: GPAI_OBLIGATIONS,
|
|
551
708
|
},
|
|
552
709
|
articleObligations: articleObligations,
|
|
553
710
|
listArticles: listArticles,
|
|
@@ -555,4 +712,5 @@ module.exports = {
|
|
|
555
712
|
DEADLINES: DEADLINES,
|
|
556
713
|
emitClassificationAudit: emitClassificationAudit,
|
|
557
714
|
annexIVScaffold: annexIVScaffold,
|
|
715
|
+
fundamentalRightsImpactAssessment: fundamentalRightsImpactAssessment,
|
|
558
716
|
};
|
package/lib/compliance.js
CHANGED
|
@@ -110,6 +110,32 @@ var KNOWN_POSTURES = Object.freeze([
|
|
|
110
110
|
"nydfs-500", // NYDFS 23 NYCRR 500 Amendment 2 — financial cybersecurity (multi-factor + asset inventory + governance)
|
|
111
111
|
"hipaa-2026", // HHS HIPAA Security Rule 2026-Q4 final — extends hipaa with mandatory MFA + asset inventory + 72h restoration testing
|
|
112
112
|
"quebec-25", // Quebec Law 25 final phase (effective 2026-09-22) — DPIA + automated-decision opt-out
|
|
113
|
+
// ---- v0.8.77 expansion — US state consumer-privacy postures ----
|
|
114
|
+
// Each posture carries per-state cure-period, profiling opt-out
|
|
115
|
+
// and minor-consent metadata via b.dsr.stateRules(state). The
|
|
116
|
+
// generic DSR primitive (b.dsr.submit) covers ~80% of the surface;
|
|
117
|
+
// these postures fill in the per-state drift.
|
|
118
|
+
"vcdpa", // Virginia Consumer Data Protection Act
|
|
119
|
+
"co-cpa", // Colorado Privacy Act
|
|
120
|
+
"ctdpa", // Connecticut Data Privacy Act
|
|
121
|
+
"ucpa", // Utah Consumer Privacy Act
|
|
122
|
+
"tdpsa", // Texas Data Privacy and Security Act
|
|
123
|
+
"or-cpa", // Oregon Consumer Privacy Act
|
|
124
|
+
"mt-cdpa", // Montana Consumer Data Privacy Act
|
|
125
|
+
"ia-icdpa", // Iowa Consumer Data Protection Act
|
|
126
|
+
"in-indpa", // Indiana Consumer Data Protection Act
|
|
127
|
+
"de-dpdpa", // Delaware Personal Data Privacy Act
|
|
128
|
+
"nh-nhpa", // New Hampshire SB 255 Consumer Privacy Act
|
|
129
|
+
"nj-njdpa", // New Jersey Data Privacy Act
|
|
130
|
+
"ky-kcdpa", // Kentucky Consumer Data Protection Act
|
|
131
|
+
"tn-tipa", // Tennessee Information Protection Act
|
|
132
|
+
"mn-mncdpa", // Minnesota Consumer Data Privacy Act
|
|
133
|
+
"ri-ricpa", // Rhode Island Consumer Privacy Act
|
|
134
|
+
"ne-dpa", // Nebraska Data Privacy Act
|
|
135
|
+
"nv-sb370", // Nevada SB 370 Consumer Health Data Privacy
|
|
136
|
+
"ca-aadc", // California Age-Appropriate Design Code (partial preliminary injunction; track for re-enforcement)
|
|
137
|
+
"ct-sb3", // Connecticut SB 3 Consumer Health Data
|
|
138
|
+
"tx-cubi", // Texas Capture or Use of Biometric Identifier
|
|
113
139
|
]);
|
|
114
140
|
|
|
115
141
|
var STATE = { posture: null, setAt: null };
|
|
@@ -493,6 +519,28 @@ var REGIME_MAP = Object.freeze({
|
|
|
493
519
|
jurisdiction: "CA-QC",
|
|
494
520
|
domain: "privacy",
|
|
495
521
|
},
|
|
522
|
+
// v0.8.77 — US state consumer-privacy postures
|
|
523
|
+
"vcdpa": { name: "Virginia Consumer Data Protection Act", citation: "Va. Code §59.1-575 et seq. (effective 2023-01-01)", jurisdiction: "US-VA", domain: "privacy" },
|
|
524
|
+
"co-cpa": { name: "Colorado Privacy Act", citation: "C.R.S. §6-1-1301 et seq. (effective 2023-07-01)", jurisdiction: "US-CO", domain: "privacy" },
|
|
525
|
+
"ctdpa": { name: "Connecticut Data Privacy Act", citation: "Conn. Gen. Stat. §42-515 et seq. (effective 2023-07-01)", jurisdiction: "US-CT", domain: "privacy" },
|
|
526
|
+
"ucpa": { name: "Utah Consumer Privacy Act", citation: "Utah Code §13-61-101 et seq. (effective 2023-12-31)", jurisdiction: "US-UT", domain: "privacy" },
|
|
527
|
+
"tdpsa": { name: "Texas Data Privacy and Security Act", citation: "Tex. Bus. & Com. Code §541.001 et seq. (effective 2024-07-01)", jurisdiction: "US-TX", domain: "privacy" },
|
|
528
|
+
"or-cpa": { name: "Oregon Consumer Privacy Act", citation: "Or. Rev. Stat. §646A.570 et seq. (effective 2024-07-01)", jurisdiction: "US-OR", domain: "privacy" },
|
|
529
|
+
"mt-cdpa": { name: "Montana Consumer Data Privacy Act", citation: "Mont. Code §30-14-2801 et seq. (effective 2024-10-01)", jurisdiction: "US-MT", domain: "privacy" },
|
|
530
|
+
"ia-icdpa": { name: "Iowa Consumer Data Protection Act", citation: "Iowa Code §715D (effective 2025-01-01)", jurisdiction: "US-IA", domain: "privacy" },
|
|
531
|
+
"in-indpa": { name: "Indiana Consumer Data Protection Act", citation: "Ind. Code §24-15 (effective 2026-01-01)", jurisdiction: "US-IN", domain: "privacy" },
|
|
532
|
+
"de-dpdpa": { name: "Delaware Personal Data Privacy Act", citation: "6 Del. Code Ch. 12D (effective 2026-01-01)", jurisdiction: "US-DE", domain: "privacy" },
|
|
533
|
+
"nh-nhpa": { name: "New Hampshire SB 255 Consumer Privacy Act", citation: "NH RSA Chapter 507-H (effective 2026-01-01)", jurisdiction: "US-NH", domain: "privacy" },
|
|
534
|
+
"nj-njdpa": { name: "New Jersey Data Privacy Act", citation: "N.J. Rev. Stat. §56:8-166.4 et seq. (effective 2026-01-15)", jurisdiction: "US-NJ", domain: "privacy" },
|
|
535
|
+
"ky-kcdpa": { name: "Kentucky Consumer Data Protection Act", citation: "Ky. Rev. Stat. §367.3611 et seq. (effective 2026-01-01)", jurisdiction: "US-KY", domain: "privacy" },
|
|
536
|
+
"tn-tipa": { name: "Tennessee Information Protection Act", citation: "Tenn. Code §47-18-3201 et seq. (effective 2025-07-01)", jurisdiction: "US-TN", domain: "privacy" },
|
|
537
|
+
"mn-mncdpa": { name: "Minnesota Consumer Data Privacy Act", citation: "Minn. Stat. §325O (effective 2026-07-31)", jurisdiction: "US-MN", domain: "privacy" },
|
|
538
|
+
"ri-ricpa": { name: "Rhode Island Consumer Privacy Act", citation: "R.I. Gen. Laws §6-48.1 (effective 2026-01-01)", jurisdiction: "US-RI", domain: "privacy" },
|
|
539
|
+
"ne-dpa": { name: "Nebraska Data Privacy Act", citation: "Neb. Rev. Stat. §87-1101 et seq. (effective 2025-01-01)", jurisdiction: "US-NE", domain: "privacy" },
|
|
540
|
+
"nv-sb370": { name: "Nevada SB 370 Consumer Health Data Privacy", citation: "Nev. Rev. Stat. §603A (consumer-health amendments, effective 2024-03-31)", jurisdiction: "US-NV", domain: "health" },
|
|
541
|
+
"ca-aadc": { name: "California Age-Appropriate Design Code Act", citation: "Cal. Civ. Code §1798.99.28 et seq. (partial preliminary injunction NetChoice v. Bonta)", jurisdiction: "US-CA", domain: "privacy" },
|
|
542
|
+
"ct-sb3": { name: "Connecticut SB 3 Consumer Health Data", citation: "Conn. P.A. 23-56 (effective 2023-07-01)", jurisdiction: "US-CT", domain: "health" },
|
|
543
|
+
"tx-cubi": { name: "Texas Capture or Use of Biometric Identifier", citation: "Tex. Bus. & Com. Code §503.001 (effective 2009-09-01)", jurisdiction: "US-TX", domain: "biometric" },
|
|
496
544
|
});
|
|
497
545
|
|
|
498
546
|
/**
|
package/lib/config.js
CHANGED
|
@@ -185,14 +185,25 @@ function create(opts) {
|
|
|
185
185
|
return value;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
// `.value` is a getter, not a captured property. Without this,
|
|
189
|
+
// `cfg.value.X` reads from the object that was current at create()
|
|
190
|
+
// and never reflects subsequent reload() updates — operators looking
|
|
191
|
+
// at `cfg.value.FEATURE_X` would see stale values forever, while
|
|
192
|
+
// `cfg.get("FEATURE_X")` saw fresh ones. The @primitive docs
|
|
193
|
+
// (loadDbBacked example) promise `cfg.value.X` always works, so the
|
|
194
|
+
// getter is the contract.
|
|
195
|
+
var handle = {
|
|
190
196
|
get: function (key) { return value[key]; },
|
|
191
197
|
has: function (key) { return Object.prototype.hasOwnProperty.call(value, key); },
|
|
192
198
|
redacted: redactedView,
|
|
193
199
|
subscribe: subscribe,
|
|
194
200
|
reload: reload,
|
|
195
201
|
};
|
|
202
|
+
Object.defineProperty(handle, "value", {
|
|
203
|
+
get: function () { return value; },
|
|
204
|
+
enumerable: true,
|
|
205
|
+
});
|
|
206
|
+
return handle;
|
|
196
207
|
}
|
|
197
208
|
|
|
198
209
|
/**
|
|
@@ -209,16 +220,45 @@ function create(opts) {
|
|
|
209
220
|
* via the underlying handle's `reload`, and re-validates. Reload
|
|
210
221
|
* failures emit a `config.reload.failed` audit row but DO NOT
|
|
211
222
|
* clobber the previous value — the running app stays on the
|
|
212
|
-
* last-good config.
|
|
213
|
-
*
|
|
223
|
+
* last-good config.
|
|
224
|
+
*
|
|
225
|
+
* Returns immediately with a synchronous handle, but kicks off one
|
|
226
|
+
* immediate hydration tick on construction so the first DB read
|
|
227
|
+
* happens at t=0 rather than t=intervalMs. Callers that need to wait
|
|
228
|
+
* for first-data-applied can `await handle.hydrated` before the app
|
|
229
|
+
* starts serving traffic; the Promise resolves after the first tick
|
|
230
|
+
* settles (success OR audit-on-failure path) and never rejects, so
|
|
231
|
+
* the boot path never deadlocks on a temporarily-unreachable DB.
|
|
232
|
+
*
|
|
233
|
+
* The returned handle is the same shape as `create()` plus:
|
|
234
|
+
* - `.hydrated` — Promise<void> for the first tick
|
|
235
|
+
* - `.refresh()`— run one tick on demand (save-triggered reload);
|
|
236
|
+
* returns Promise<void> that never rejects
|
|
237
|
+
* - `.stop()` — halts the poller
|
|
238
|
+
*
|
|
239
|
+
* Three tiers of precedence (highest wins): the DB-row overlay
|
|
240
|
+
* resolved at each `_tick` > the `opts.env` baseline > defaults
|
|
241
|
+
* declared on the schema (`s.string().default(...)` and friends).
|
|
242
|
+
* The `.subscribe(fn)` callback registered through `create()` fires
|
|
243
|
+
* synchronously inside every successful reload — operators reach for
|
|
244
|
+
* it to invalidate caches, recompute derived state, or hot-rebuild
|
|
245
|
+
* middleware that closed over the previous config value.
|
|
214
246
|
*
|
|
215
247
|
* @opts
|
|
216
|
-
* schema:
|
|
217
|
-
* env:
|
|
218
|
-
* redactKeys:
|
|
219
|
-
* fetchRows:
|
|
220
|
-
* intervalMs:
|
|
221
|
-
*
|
|
248
|
+
* schema: b.safeSchema instance (required),
|
|
249
|
+
* env: object (env baseline; default process.env),
|
|
250
|
+
* redactKeys: Array<string>,
|
|
251
|
+
* fetchRows: async () => Array<{ key: string, value: string }> (required),
|
|
252
|
+
* intervalMs: number (positive finite poll interval),
|
|
253
|
+
* transformValue: (row) => string | Promise<string> (optional per-row
|
|
254
|
+
* transform — receives `{ key, value, ...rest }` so the
|
|
255
|
+
* row can carry envelope metadata; returns the value
|
|
256
|
+
* that flows into the schema. Common shape: unseal a
|
|
257
|
+
* `b.vault`-sealed ciphertext column before validation.
|
|
258
|
+
* Rows whose transform throws or returns a non-string
|
|
259
|
+
* are skipped with a `config.reload.failed` audit so a
|
|
260
|
+
* single bad row never crashes the poller),
|
|
261
|
+
* audit: boolean (default true; reserved for future per-poll audit),
|
|
222
262
|
*
|
|
223
263
|
* @example
|
|
224
264
|
* var s = b.safeSchema;
|
|
@@ -234,10 +274,42 @@ function create(opts) {
|
|
|
234
274
|
* });
|
|
235
275
|
* cfg.value.FEATURE_X; // → false (until first poll tick lands)
|
|
236
276
|
* cfg.stop(); // halt the poller on shutdown
|
|
277
|
+
*
|
|
278
|
+
* @example
|
|
279
|
+
* // Sealed values — column stores `b.vault.seal(plain)` ciphertext.
|
|
280
|
+
* var cfg = b.config.loadDbBacked({
|
|
281
|
+
* schema: s.object({ STRIPE_SECRET: s.string() }),
|
|
282
|
+
* fetchRows: async function () {
|
|
283
|
+
* return await db.all("SELECT key, sealed FROM _config WHERE sealed IS NOT NULL");
|
|
284
|
+
* },
|
|
285
|
+
* transformValue: function (row) {
|
|
286
|
+
* return b.vault.unseal(row.sealed).toString("utf8");
|
|
287
|
+
* },
|
|
288
|
+
* intervalMs: 30 * 1000,
|
|
289
|
+
* });
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* // Save-triggered reload — admin UI writes a row, fires refresh()
|
|
293
|
+
* // so the new value is active immediately without waiting for
|
|
294
|
+
* // intervalMs. cfg.subscribe(...) sees the change inline.
|
|
295
|
+
* var cfg = b.config.loadDbBacked({
|
|
296
|
+
* schema: s.object({ FEATURE_X: b.config.coerce.boolean().default(false) }),
|
|
297
|
+
* fetchRows: async function () { return await db.all("SELECT key, value FROM _config"); },
|
|
298
|
+
* intervalMs: 5 * 60 * 1000, // safety-net interval
|
|
299
|
+
* });
|
|
300
|
+
* await cfg.hydrated; // boot path waits
|
|
301
|
+
* cfg.subscribe(function (next) { cache.invalidate(); });
|
|
302
|
+
*
|
|
303
|
+
* adminApp.post("/settings", async function (req, res) {
|
|
304
|
+
* await db.run("INSERT OR REPLACE INTO _config(key,value) VALUES (?,?)",
|
|
305
|
+
* req.body.key, req.body.value);
|
|
306
|
+
* await cfg.refresh(); // active immediately
|
|
307
|
+
* res.json({ ok: true });
|
|
308
|
+
* });
|
|
237
309
|
*/
|
|
238
310
|
function loadDbBacked(opts) {
|
|
239
311
|
opts = opts || {};
|
|
240
|
-
validateOpts(opts, ["schema", "env", "redactKeys", "fetchRows", "intervalMs", "audit"],
|
|
312
|
+
validateOpts(opts, ["schema", "env", "redactKeys", "fetchRows", "intervalMs", "transformValue", "audit"],
|
|
241
313
|
"config.loadDbBacked");
|
|
242
314
|
if (typeof opts.fetchRows !== "function") {
|
|
243
315
|
throw new ConfigError("config/bad-fetch-rows",
|
|
@@ -247,6 +319,9 @@ function loadDbBacked(opts) {
|
|
|
247
319
|
throw new ConfigError("config/bad-interval",
|
|
248
320
|
"loadDbBacked: opts.intervalMs must be a positive finite number");
|
|
249
321
|
}
|
|
322
|
+
var transformValue = validateOpts.optionalFunction(
|
|
323
|
+
opts.transformValue, "loadDbBacked: opts.transformValue",
|
|
324
|
+
ConfigError, "config/bad-transform-value") || null;
|
|
250
325
|
var cfg = create({ schema: opts.schema, env: opts.env, redactKeys: opts.redactKeys });
|
|
251
326
|
var stopped = false;
|
|
252
327
|
async function _tick() {
|
|
@@ -265,9 +340,32 @@ function loadDbBacked(opts) {
|
|
|
265
340
|
if (!Array.isArray(rows)) return;
|
|
266
341
|
var overlay = {};
|
|
267
342
|
for (var i = 0; i < rows.length; i++) {
|
|
268
|
-
|
|
269
|
-
|
|
343
|
+
var row = rows[i];
|
|
344
|
+
if (!row || typeof row.key !== "string") continue;
|
|
345
|
+
var value = row.value;
|
|
346
|
+
if (transformValue) {
|
|
347
|
+
try {
|
|
348
|
+
value = await transformValue(row);
|
|
349
|
+
} catch (e) {
|
|
350
|
+
try {
|
|
351
|
+
lazyAudit().safeEmit({
|
|
352
|
+
action: "config.reload.failed", outcome: "failure",
|
|
353
|
+
metadata: { phase: "transform", key: row.key, reason: e && e.message },
|
|
354
|
+
});
|
|
355
|
+
} catch (_e) { /* audit best-effort */ }
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (typeof value !== "string") {
|
|
359
|
+
try {
|
|
360
|
+
lazyAudit().safeEmit({
|
|
361
|
+
action: "config.reload.failed", outcome: "failure",
|
|
362
|
+
metadata: { phase: "transform", key: row.key, reason: "transformValue did not return a string" },
|
|
363
|
+
});
|
|
364
|
+
} catch (_e) { /* audit best-effort */ }
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
270
367
|
}
|
|
368
|
+
overlay[row.key] = value;
|
|
271
369
|
}
|
|
272
370
|
try { cfg.reload(overlay); }
|
|
273
371
|
catch (e) {
|
|
@@ -279,7 +377,25 @@ function loadDbBacked(opts) {
|
|
|
279
377
|
} catch (_e) { /* audit best-effort */ }
|
|
280
378
|
}
|
|
281
379
|
}
|
|
380
|
+
// Fire one immediate hydration before the interval kicks in so
|
|
381
|
+
// callers can `await cfg.hydrated` and not get an empty config window
|
|
382
|
+
// (env defaults only) for the first intervalMs of process lifetime.
|
|
383
|
+
// The interval still fires every intervalMs afterwards for ongoing
|
|
384
|
+
// drift detection. The hydration Promise NEVER rejects — _tick
|
|
385
|
+
// swallows fetch / transform / validate failures via audit, matching
|
|
386
|
+
// the established "last-good config stays in place" contract.
|
|
387
|
+
cfg.hydrated = _tick();
|
|
282
388
|
var handle = safeAsync.repeating(_tick, opts.intervalMs, { name: "config-db-reload" });
|
|
389
|
+
// Save-triggered reload — admin save handlers / settings-management
|
|
390
|
+
// UIs invoke cfg.refresh() right after writing a row to drop the
|
|
391
|
+
// intervalMs-worth of staleness latency between save and active.
|
|
392
|
+
// Returns the same Promise<void> shape as cfg.hydrated: resolves
|
|
393
|
+
// after the tick settles (success OR audit-on-failure), never
|
|
394
|
+
// rejects so the save handler never deadlocks on a flaky DB.
|
|
395
|
+
// Subscribers fire synchronously inside cfg.reload() within the
|
|
396
|
+
// tick, matching the save-then-invalidate-cache pattern operators
|
|
397
|
+
// expect when an admin flips a feature flag.
|
|
398
|
+
cfg.refresh = function () { return _tick(); };
|
|
283
399
|
cfg.stop = function () { stopped = true; if (handle) { handle.stop(); handle = null; } };
|
|
284
400
|
return cfg;
|
|
285
401
|
}
|