@blamejs/core 0.7.103 → 0.7.105
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/index.js +2 -0
- package/lib/audit.js +1 -0
- package/lib/compliance-sanctions-aliases.js +167 -0
- package/lib/compliance-sanctions-fetcher.js +206 -0
- package/lib/compliance-sanctions-fuzzy.js +297 -0
- package/lib/compliance-sanctions.js +569 -0
- package/lib/compliance.js +2 -0
- package/lib/dsr.js +953 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **0.7.105** (2026-05-06) — `b.compliance.sanctions` — sanctions-list screening primitive. Operators handling KYC / payment / customer-onboarding flows screen names against the U.S. Treasury OFAC SDN list, EU CSL, UK HMT consolidated list, UN 1267 list, or operator-defined lists. The framework owns indexing + match algorithm; the operator owns the daily fetch + format-specific parsing (the framework intentionally does not vendor the list — it changes daily and has legal-distribution implications). **`b.compliance.sanctions.create({ entries, algorithm, fuzzy, ... })`** returns a screener with `screen(input)` (single record), `screenBulk(inputs)` (batch), `snapshot()` (rule-version digest for audit trails), `reload(newEntries)` (atomic index swap with diff), `entryById(id)` (lookup), and `size()`. Three match strategies: `exact` (fastest, no fuzz), `jaro-winkler` (default, threshold 0.85), `levenshtein` (edit-distance with cap). Match output: `{ match: bool, hits: [{ entryId, name, matchedOn, score, reason, listed, programs }], algorithm, ruleVersion, screenedAt }`. **`b.compliance.sanctions.fuzzy`** — pure algorithmic core: `normalize` (Unicode diacritic strip + lowercase + whitespace collapse), `tokenize`, `levenshtein` (cap + early-exit), `jaro` / `jaroWinkler`, `tokenSetSimilarity` (order-invariant bag-of-tokens), `substringContains` (token-bounded), `initialsMatch`. **`b.compliance.sanctions.aliases.expand(name, opts)`** — alias-expansion helper covering nicknames (Bill ↔ William, Mike ↔ Michael), transliteration variants (Mohamed ↔ Mohammed), reverse-order forms (Smith John / Smith, John), and initials (J. Smith). 32 built-in name pairs plus operator-extensible `extraPairs`. **`b.compliance.sanctions.fetcher.create({ screener, fetch, intervalMs, onRefreshed, onError })`** — periodic refresh worker that runs the operator's `fetch` callback, validates a non-empty result, and atomically reloads the screener via `screener.reload`. Audit emissions on every refresh state (`compliance.sanctions.refresh.started` / `completed` / `skipped` / `failed`). **Parser shims** for the canonical public list formats: `parseOfacCsvRow` / `parseOfacAliasRow` / `mergeAliases` (OFAC SDN), `parseEuCslEntry` (EU Consolidated Sanctions List XML), `parseUn1267Entry` (UN Security Council XML). Audit emissions: `compliance.sanctions.screened` (every screen call), `compliance.sanctions.matched` (when hits > 0). Test coverage: 39 cases across normalize / tokenize / Levenshtein / Jaro-Winkler / token-set / substring / initials / screen exact + jw + levenshtein / type filter / bulk / snapshot / reload / alias expansion / fetcher tick + failure modes.
|
|
12
|
+
|
|
13
|
+
- **0.7.104** (2026-05-06) — `b.dsr` Data Subject Rights workflow primitive (~2000 LoC). End-to-end coordinator for GDPR Article 15-22 / CCPA / CPRA / LGPD / PIPEDA / UK-GDPR data-subject requests. **`b.dsr.create({ ticketStore, posture, identityResolver, sources, ... })`** returns a workflow instance with full ticket lifecycle: `submit(input)` resolves subject identity via the operator-supplied `identityResolver`, computes a posture-aware deadline (gdpr 30d / ccpa 45d / lgpd-br 15d / pipl-cn 15d / pipeda-ca 30d / appi-jp 30d / pdpa-sg 30d / uk-gdpr 30d), and persists a pending ticket. `process(ticketId, opts)` orchestrates per-source `query` (for access / portability / rectification) or `erase` (for erasure) callbacks; partial source failures land the ticket in `partially_completed` state with per-source error capture. `cancel` / `reject` (with required reason per GDPR) advance to terminal states. `expireOverdue()` sweep marks deadline-overdue tickets as `expired`. Seven request types: `access` / `erasure` / `portability` / `rectification` / `restriction` / `object` / `automated-decision`. **Verification ladder** (`minimal` / `secondary` / `strong`) per GDPR Art. 12(6) — minimum required level by request type with operator override; erasure / portability / rectification require `secondary` by default. **Receipt builder** (`buildReceipt(ticketId)`) — emits a canonical `blamejs.dsr.receipt/1` JSON envelope for completed/cancelled/rejected/expired tickets with optional operator-side `receiptSigner` hook for cryptographic attestation. **Portability bundle builder** (`buildPortabilityBundle(ticket)`) — `blamejs.dsr.portability/1` JSON shape with per-source data for access / portability requests. **Two ticket-store backends** ship: `memoryTicketStore()` for development / tests, `dbTicketStore({ db, table })` for production (auto-provisions a SQLite table with subject_email + status indexes, includes a `purgeExpired()` retention sweep). Audit emissions on every state transition (`dsr.ticket.submitted` / `in_progress` / `completed` / `partial` / `cancelled` / `rejected` / `expired` plus per-source `dsr.source.queried` / `erased` / `failed`). Test coverage: 38 cases across submit / process / cancel / reject / list / expire / portability / verification ladder / receipt / store backends.
|
|
14
|
+
|
|
11
15
|
- **0.7.103** (2026-05-06) — W3C distributed tracing suite. End-to-end OTel-shaped tracing without a vendored OTel SDK: tracestate + Baggage parsers, span builder, OTLP/JSON exporter, HTTP-server span middleware, log correlation. **`b.observability.traceContext.parseTracestate / buildTracestate`** — W3C Trace Context §3.3 vendor data: enforces vendor-key shape (lcase-alnum + `_-*/`, optional `<tenant>@<system>`), value charset (printable ASCII excluding `,` and `=`), 32-entry cap, 512-char total cap, dup-key-keep-first per §3.3.1.5. **`b.observability.baggage.parse / build`** — W3C Baggage spec parser + builder for operator-supplied context (tenantId, region, experimentId, etc.) propagated across service boundaries. RFC 7230 tchar key grammar, percent-encoded UTF-8 values, optional per-entry properties (`key=value;property=value`), 64-entry / 8192-char caps. **`b.observability.tracer.create({ service, resource, onEnd })`** — OTel-shaped span builder. `tracer.start(name, opts)` returns a span with `setAttribute` / `setAttributes` / `addEvent` / `recordException` / `setStatus` / `end` / `isRecording` / `toJSON`. OTLP/JSON-compatible output (Trace v1) with `traceId` / `spanId` / `parentSpanId` / `name` / `kind` / `startTimeUnixNano` / `endTimeUnixNano` / `attributes` / `events` / `status` / `resource` / `scope` / `droppedAttributesCount` / `droppedEventsCount`. Attribute caps (128 keys, 1024-char values), event cap (128) per OTLP defaults. `tracer.startChildOf(parent, name)` derives child spans sharing the trace context. **`b.observability.tracer.spanToTraceparent(span)`** — emits the canonical W3C `traceparent` for outbound propagation. **`b.observability.otlpExporter.create({ endpoint, ... })`** — buffered OTLP/HTTP JSON span exporter. Batches spans (default 200), flushes on size + interval (default 5s), retries 5xx + 408/429 with exponential backoff, drops oldest on queue overflow (default 4096). Custom `fetchImpl` opt for testing or non-default HTTP transports; `allowedProtocols` opt for cleartext dev collectors. **`b.middleware.tracePropagate`** extended to also read inbound `tracestate` and stamp `req.trace.tracestate` as the parsed entries array (or `[]` when missing); when `setResponseHeader: true`, echoes both `traceparent` and `tracestate` on the response. **`b.middleware.spanHttpServer({ tracer, ... })`** — auto-creates a root server span per HTTP request, populates OTel `SEMCONV.HTTP_*` / `URL_*` / `SERVER_*` / `CLIENT_*` attributes, attaches the span to `req.span`, ends on response close, fires `onEnd(span.toJSON())` for export. `ignorePaths` (string + RegExp) keeps healthz / static-asset routes out of span volume; `captureRequestHeaders` / `captureResponseHeaders` lift named headers into the span as `http.request.header.*` / `http.response.header.*` attributes. **`b.middleware.traceLogCorrelation({ logger })`** — wraps a `b.log` instance for the request lifetime so every `info()` / `warn()` / `error()` / etc. emission inside the handler auto-includes `trace_id` + `span_id` from the active context (via `req.trace` + `req.span`). Pass-through when no trace context present. Internal sweep: `safeBuffer.TRACE_ID_HEX_RE` / `SPAN_ID_HEX_RE` / `RFC7230_TCHAR_RE` extracted as shared regex constants; `guard-mime` / `middleware/headers` / `observability` consolidated against the new shared constants.
|
|
12
16
|
|
|
13
17
|
- **0.7.102** (2026-05-06) — `b.middleware.tracePropagate({ generateIfMissing, ... })` — middleware that consumes the inbound `traceparent` header per W3C Trace Context and stamps `req.trace = { traceId, parentId, sampled, hadUpstream }` for downstream handlers + propagation into outbound HTTP calls. `generateIfMissing: true` (default) synthesises a fresh trace when the inbound header is missing/malformed and stamps `hadUpstream: false`. `auditOnMissing: true` emits `system.trace.synthesised` audit events on every locally-originated trace. `setResponseHeader: true` echoes the resolved traceparent on the response (useful when the framework is the back-end of an L7 router that wants to log it). Composes with `b.observability.traceContext` (v0.7.101) for outbound propagation: downstream handlers read `req.trace.traceId` and pass it to `traceContext.build({ traceId: req.trace.traceId, parentId: traceContext.newParentId(), sampled: req.trace.sampled })` for the `traceparent` header on upstream calls.
|
package/index.js
CHANGED
|
@@ -213,6 +213,7 @@ var dualControl = require("./lib/dual-control");
|
|
|
213
213
|
var retention = require("./lib/retention");
|
|
214
214
|
var network = require("./lib/network");
|
|
215
215
|
var cloudEvents = require("./lib/cloud-events");
|
|
216
|
+
var dsr = require("./lib/dsr");
|
|
216
217
|
var outbox = require("./lib/outbox");
|
|
217
218
|
|
|
218
219
|
module.exports = {
|
|
@@ -361,6 +362,7 @@ module.exports = {
|
|
|
361
362
|
retention: retention,
|
|
362
363
|
network: network,
|
|
363
364
|
cloudEvents: cloudEvents,
|
|
365
|
+
dsr: dsr,
|
|
364
366
|
outbox: outbox,
|
|
365
367
|
ntpCheck: ntpCheck,
|
|
366
368
|
version: constants.version,
|
package/lib/audit.js
CHANGED
|
@@ -210,6 +210,7 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
210
210
|
// (role-switching, RLS-shaped events)
|
|
211
211
|
"dkim", // b.mail.dkim (DKIM-Signature generation events)
|
|
212
212
|
"dora", // b.dora (DORA Article 17: dora.incident.classified / reported / draftFinal)
|
|
213
|
+
"dsr", // b.dsr (Data Subject Rights workflow: dsr.ticket.* / dsr.source.*)
|
|
213
214
|
"dual", // b.dualControl (dual.grant.requested / approved / denied / consumed / expired / self_approval_denied)
|
|
214
215
|
"mail", // b.mail (b.mail-bounce uses "system.mail.*")
|
|
215
216
|
"network", // b.middleware.networkAllowlist (network.gate.denied)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Alias-expansion helpers for sanctions screening.
|
|
4
|
+
*
|
|
5
|
+
* The OFAC SDN list / EU CSL / UK HMT consolidated list publish a
|
|
6
|
+
* primary name + a small set of formal aliases per entry. Real-world
|
|
7
|
+
* input doesn't match those forms exactly: people use nicknames
|
|
8
|
+
* (Bill / William, Mike / Michael), transliteration variants
|
|
9
|
+
* (Mohamed / Mohammed / Muhammad), and initials (J. Smith).
|
|
10
|
+
*
|
|
11
|
+
* This module expands a candidate name into the set of plausible
|
|
12
|
+
* forms that should screen-match against the same SDN entry. Operators
|
|
13
|
+
* call expand() before screen() to broaden the match scope:
|
|
14
|
+
*
|
|
15
|
+
* var aliases = b.compliance.sanctions.aliases.expand("Bill J. Smith");
|
|
16
|
+
* var result = screener.screen({
|
|
17
|
+
* name: "Bill J. Smith",
|
|
18
|
+
* aliases: aliases,
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* The expansion is deterministic + idempotent. Operators with
|
|
22
|
+
* domain-specific names (Cyrillic / Arabic) extend via opts.extra.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
var fuzzy = require("./compliance-sanctions-fuzzy");
|
|
26
|
+
|
|
27
|
+
// Common nickname → formal-name pairs. The framework ships a focused
|
|
28
|
+
// table for English/European names; operators with non-Western lists
|
|
29
|
+
// extend via opts.extraPairs at expand() time.
|
|
30
|
+
var NICKNAME_PAIRS = Object.freeze([
|
|
31
|
+
["bill", "william"],
|
|
32
|
+
["bob", "robert"],
|
|
33
|
+
["dick", "richard"],
|
|
34
|
+
["mike", "michael"],
|
|
35
|
+
["nick", "nicholas"],
|
|
36
|
+
["tom", "thomas"],
|
|
37
|
+
["jim", "james"],
|
|
38
|
+
["jack", "john"],
|
|
39
|
+
["chris", "christopher"],
|
|
40
|
+
["dan", "daniel"],
|
|
41
|
+
["dave", "david"],
|
|
42
|
+
["matt", "matthew"],
|
|
43
|
+
["alex", "alexander"],
|
|
44
|
+
["sam", "samuel"],
|
|
45
|
+
["pat", "patrick"],
|
|
46
|
+
["tony", "anthony"],
|
|
47
|
+
["ben", "benjamin"],
|
|
48
|
+
["joe", "joseph"],
|
|
49
|
+
["ed", "edward"],
|
|
50
|
+
["fred", "frederick"],
|
|
51
|
+
["greg", "gregory"],
|
|
52
|
+
["liz", "elizabeth"],
|
|
53
|
+
["beth", "elizabeth"],
|
|
54
|
+
["meg", "margaret"],
|
|
55
|
+
["maggie", "margaret"],
|
|
56
|
+
["kate", "katherine"],
|
|
57
|
+
["kathy", "katherine"],
|
|
58
|
+
["sue", "susan"],
|
|
59
|
+
["jen", "jennifer"],
|
|
60
|
+
["jenny", "jennifer"],
|
|
61
|
+
["nat", "natalie"],
|
|
62
|
+
["mohamed", "mohammed"],
|
|
63
|
+
["muhammad", "mohammed"],
|
|
64
|
+
["abd", "abdul"],
|
|
65
|
+
["abu", "abou"],
|
|
66
|
+
["yusuf", "yousef"],
|
|
67
|
+
["yasin", "yaseen"],
|
|
68
|
+
["hussein", "hussain"],
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
function _expandNickname(token) {
|
|
72
|
+
var alts = [];
|
|
73
|
+
var lower = token.toLowerCase();
|
|
74
|
+
for (var i = 0; i < NICKNAME_PAIRS.length; i++) {
|
|
75
|
+
var pair = NICKNAME_PAIRS[i];
|
|
76
|
+
if (lower === pair[0]) alts.push(pair[1]);
|
|
77
|
+
else if (lower === pair[1]) alts.push(pair[0]);
|
|
78
|
+
}
|
|
79
|
+
return alts;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _expandInitials(tokens) {
|
|
83
|
+
// Build "J. Smith" / "JS" forms
|
|
84
|
+
var alts = [];
|
|
85
|
+
if (tokens.length >= 2) {
|
|
86
|
+
var first = tokens[0];
|
|
87
|
+
var rest = tokens.slice(1).join(" ");
|
|
88
|
+
if (first.length > 1) {
|
|
89
|
+
// J Smith / J. Smith
|
|
90
|
+
alts.push(first.charAt(0) + " " + rest);
|
|
91
|
+
alts.push(first.charAt(0) + ". " + rest);
|
|
92
|
+
}
|
|
93
|
+
// Last + first
|
|
94
|
+
alts.push(tokens[tokens.length - 1] + " " + tokens.slice(0, -1).join(" "));
|
|
95
|
+
// Last, First
|
|
96
|
+
alts.push(tokens[tokens.length - 1] + ", " + tokens.slice(0, -1).join(" "));
|
|
97
|
+
}
|
|
98
|
+
if (tokens.length === 2) {
|
|
99
|
+
// Initials-only "JS"
|
|
100
|
+
alts.push(tokens[0].charAt(0) + tokens[1].charAt(0));
|
|
101
|
+
}
|
|
102
|
+
return alts;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function _expandTokenLevel(tokens) {
|
|
106
|
+
// For each token, swap with each plausible nickname/transliteration,
|
|
107
|
+
// emit the resulting full name.
|
|
108
|
+
var alts = [];
|
|
109
|
+
for (var i = 0; i < tokens.length; i++) {
|
|
110
|
+
var swaps = _expandNickname(tokens[i]);
|
|
111
|
+
for (var j = 0; j < swaps.length; j++) {
|
|
112
|
+
var newTokens = tokens.slice();
|
|
113
|
+
newTokens[i] = swaps[j];
|
|
114
|
+
alts.push(newTokens.join(" "));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return alts;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function expand(name, opts) {
|
|
121
|
+
opts = opts || {};
|
|
122
|
+
if (typeof name !== "string" || name.length === 0) return [];
|
|
123
|
+
var tokens = fuzzy.tokenize(name);
|
|
124
|
+
if (tokens.length === 0) return [];
|
|
125
|
+
var seen = Object.create(null);
|
|
126
|
+
var out = [];
|
|
127
|
+
function _add(s) {
|
|
128
|
+
if (typeof s !== "string" || s.length === 0) return;
|
|
129
|
+
var key = fuzzy.normalize(s);
|
|
130
|
+
if (key.length === 0) return;
|
|
131
|
+
if (seen[key]) return;
|
|
132
|
+
seen[key] = true;
|
|
133
|
+
out.push(s);
|
|
134
|
+
}
|
|
135
|
+
// 1. The original (normalised)
|
|
136
|
+
_add(tokens.join(" "));
|
|
137
|
+
// 2. Initial-form variants
|
|
138
|
+
var initials = _expandInitials(tokens);
|
|
139
|
+
for (var i = 0; i < initials.length; i++) _add(initials[i]);
|
|
140
|
+
// 3. Token-level nickname/transliteration swaps
|
|
141
|
+
var swaps = _expandTokenLevel(tokens);
|
|
142
|
+
for (var j = 0; j < swaps.length; j++) _add(swaps[j]);
|
|
143
|
+
// 4. Operator-supplied extras
|
|
144
|
+
if (Array.isArray(opts.extra)) {
|
|
145
|
+
for (var k = 0; k < opts.extra.length; k++) _add(opts.extra[k]);
|
|
146
|
+
}
|
|
147
|
+
if (Array.isArray(opts.extraPairs)) {
|
|
148
|
+
for (var p = 0; p < opts.extraPairs.length; p++) {
|
|
149
|
+
var pair = opts.extraPairs[p];
|
|
150
|
+
if (!Array.isArray(pair) || pair.length !== 2) continue;
|
|
151
|
+
for (var ti = 0; ti < tokens.length; ti++) {
|
|
152
|
+
var lower = tokens[ti].toLowerCase();
|
|
153
|
+
if (lower === pair[0]) {
|
|
154
|
+
var nt1 = tokens.slice(); nt1[ti] = pair[1]; _add(nt1.join(" "));
|
|
155
|
+
} else if (lower === pair[1]) {
|
|
156
|
+
var nt2 = tokens.slice(); nt2[ti] = pair[0]; _add(nt2.join(" "));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
expand: expand,
|
|
166
|
+
NICKNAME_PAIRS: NICKNAME_PAIRS,
|
|
167
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.compliance.sanctions.fetcher — periodic sanctions-list refresh
|
|
4
|
+
* helper.
|
|
5
|
+
*
|
|
6
|
+
* The framework intentionally does NOT vendor the sanctions list (it
|
|
7
|
+
* changes daily and has legal-distribution implications). Operators
|
|
8
|
+
* fetch from the canonical source on a schedule + reload the screener.
|
|
9
|
+
* This module wraps the schedule + comparison + reload-trigger logic
|
|
10
|
+
* so operators write one fetch callback instead of orchestrating it.
|
|
11
|
+
*
|
|
12
|
+
* var fetcher = b.compliance.sanctions.fetcher.create({
|
|
13
|
+
* screener: sdnScreener, // from sanctions.create
|
|
14
|
+
* intervalMs: C.TIME.hours(24),
|
|
15
|
+
* fetch: async function () {
|
|
16
|
+
* // Operator-supplied: hits treasury.gov, parses CSV, returns
|
|
17
|
+
* // canonical entry array.
|
|
18
|
+
* var rows = await downloadSdnCsv();
|
|
19
|
+
* return rows.map(b.compliance.sanctions.parseOfacCsvRow);
|
|
20
|
+
* },
|
|
21
|
+
* onRefreshed: function (diff) {
|
|
22
|
+
* log.info("SDN list refreshed", diff);
|
|
23
|
+
* },
|
|
24
|
+
* onError: function (err) {
|
|
25
|
+
* pagerDuty.alert("SDN list fetch failed", err);
|
|
26
|
+
* },
|
|
27
|
+
* });
|
|
28
|
+
* fetcher.start();
|
|
29
|
+
* ...
|
|
30
|
+
* await fetcher.shutdown();
|
|
31
|
+
*
|
|
32
|
+
* Behavior:
|
|
33
|
+
* - On each tick, run fetch(); if it returns a non-empty array,
|
|
34
|
+
* swap the screener's index via screener.reload(entries).
|
|
35
|
+
* - If fetch() throws or returns empty, skip the swap and emit an
|
|
36
|
+
* audit event; the screener keeps the previous index. Operators
|
|
37
|
+
* can configure onError for paging.
|
|
38
|
+
* - Initial run is opt-in via opts.fetchOnStart (default true);
|
|
39
|
+
* operators that prefer to seed the screener from a cached file
|
|
40
|
+
* at boot pass false.
|
|
41
|
+
*
|
|
42
|
+
* Audit emissions:
|
|
43
|
+
* compliance.sanctions.refresh.started — every tick
|
|
44
|
+
* compliance.sanctions.refresh.completed — successful refresh + diff
|
|
45
|
+
* compliance.sanctions.refresh.skipped — tick returned empty
|
|
46
|
+
* compliance.sanctions.refresh.failed — fetch threw
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
var C = require("./constants");
|
|
50
|
+
var lazyRequire = require("./lazy-require");
|
|
51
|
+
var safeAsync = require("./safe-async");
|
|
52
|
+
var validateOpts = require("./validate-opts");
|
|
53
|
+
var { defineClass } = require("./framework-error");
|
|
54
|
+
|
|
55
|
+
var SanctionsFetcherError = defineClass("SanctionsFetcherError", { alwaysPermanent: true });
|
|
56
|
+
|
|
57
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
58
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
59
|
+
|
|
60
|
+
function create(opts) {
|
|
61
|
+
validateOpts.requireObject(opts, "compliance.sanctions.fetcher", SanctionsFetcherError);
|
|
62
|
+
validateOpts(opts, [
|
|
63
|
+
"screener", "intervalMs", "fetch",
|
|
64
|
+
"onRefreshed", "onError",
|
|
65
|
+
"fetchOnStart", "audit",
|
|
66
|
+
], "compliance.sanctions.fetcher.create");
|
|
67
|
+
|
|
68
|
+
if (!opts.screener || typeof opts.screener.reload !== "function") {
|
|
69
|
+
throw new SanctionsFetcherError("sanctions-fetcher/bad-screener",
|
|
70
|
+
"fetcher.create: screener must be a sanctions.create() instance");
|
|
71
|
+
}
|
|
72
|
+
if (typeof opts.fetch !== "function") {
|
|
73
|
+
throw new SanctionsFetcherError("sanctions-fetcher/bad-fetch",
|
|
74
|
+
"fetcher.create: fetch must be an async function returning entry[]");
|
|
75
|
+
}
|
|
76
|
+
validateOpts.optionalPositiveFinite(opts.intervalMs,
|
|
77
|
+
"fetcher.create: intervalMs", SanctionsFetcherError, "sanctions-fetcher/bad-opts");
|
|
78
|
+
validateOpts.optionalFunction(opts.onRefreshed,
|
|
79
|
+
"fetcher.create: onRefreshed", SanctionsFetcherError, "sanctions-fetcher/bad-opts");
|
|
80
|
+
validateOpts.optionalFunction(opts.onError,
|
|
81
|
+
"fetcher.create: onError", SanctionsFetcherError, "sanctions-fetcher/bad-opts");
|
|
82
|
+
|
|
83
|
+
var intervalMs = opts.intervalMs || C.TIME.hours(24);
|
|
84
|
+
var fetchOnStart = opts.fetchOnStart !== false;
|
|
85
|
+
var auditOn = opts.audit !== false;
|
|
86
|
+
var screener = opts.screener;
|
|
87
|
+
var fetchFn = opts.fetch;
|
|
88
|
+
|
|
89
|
+
var handle = null;
|
|
90
|
+
var stopping = false;
|
|
91
|
+
var lastSuccess = null;
|
|
92
|
+
var lastError = null;
|
|
93
|
+
var refreshCount = 0;
|
|
94
|
+
var failureCount = 0;
|
|
95
|
+
|
|
96
|
+
function _emitAudit(action, outcome, metadata) {
|
|
97
|
+
if (!auditOn) return;
|
|
98
|
+
try {
|
|
99
|
+
audit().safeEmit({
|
|
100
|
+
action: action,
|
|
101
|
+
outcome: outcome,
|
|
102
|
+
metadata: metadata || {},
|
|
103
|
+
});
|
|
104
|
+
} catch (_e) { /* drop-silent */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function _emitMetric(verb, n) {
|
|
108
|
+
try { observability().safeEvent("compliance.sanctions.fetcher." + verb, n || 1, {}); }
|
|
109
|
+
catch (_e) { /* drop-silent */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function _tick() {
|
|
113
|
+
if (stopping) return;
|
|
114
|
+
_emitAudit("compliance.sanctions.refresh.started", "success", {
|
|
115
|
+
algorithm: screener.algorithm,
|
|
116
|
+
});
|
|
117
|
+
var entries;
|
|
118
|
+
try {
|
|
119
|
+
entries = await fetchFn();
|
|
120
|
+
} catch (e) {
|
|
121
|
+
failureCount += 1;
|
|
122
|
+
lastError = (e && e.message) || String(e);
|
|
123
|
+
_emitAudit("compliance.sanctions.refresh.failed", "failure", {
|
|
124
|
+
error: lastError, algorithm: screener.algorithm,
|
|
125
|
+
});
|
|
126
|
+
_emitMetric("failed", 1);
|
|
127
|
+
if (typeof opts.onError === "function") {
|
|
128
|
+
try { opts.onError(e); } catch (_e2) { /* operator hook */ }
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
133
|
+
_emitAudit("compliance.sanctions.refresh.skipped", "success", {
|
|
134
|
+
reason: "fetch-returned-empty", algorithm: screener.algorithm,
|
|
135
|
+
});
|
|
136
|
+
_emitMetric("skipped", 1);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
var diff;
|
|
140
|
+
try { diff = screener.reload(entries); }
|
|
141
|
+
catch (e) {
|
|
142
|
+
failureCount += 1;
|
|
143
|
+
lastError = (e && e.message) || String(e);
|
|
144
|
+
_emitAudit("compliance.sanctions.refresh.failed", "failure", {
|
|
145
|
+
error: lastError, phase: "reload", algorithm: screener.algorithm,
|
|
146
|
+
});
|
|
147
|
+
_emitMetric("failed", 1);
|
|
148
|
+
if (typeof opts.onError === "function") {
|
|
149
|
+
try { opts.onError(e); } catch (_e2) { /* operator hook */ }
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
refreshCount += 1;
|
|
154
|
+
lastSuccess = Date.now();
|
|
155
|
+
_emitAudit("compliance.sanctions.refresh.completed", "success", {
|
|
156
|
+
algorithm: screener.algorithm,
|
|
157
|
+
added: diff.addedIds.length,
|
|
158
|
+
removed: diff.removedIds.length,
|
|
159
|
+
newSize: diff.newSize,
|
|
160
|
+
});
|
|
161
|
+
_emitMetric("completed", 1);
|
|
162
|
+
if (typeof opts.onRefreshed === "function") {
|
|
163
|
+
try { opts.onRefreshed(diff); } catch (_e2) { /* operator hook */ }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function start() {
|
|
168
|
+
if (handle) return;
|
|
169
|
+
stopping = false;
|
|
170
|
+
if (fetchOnStart) {
|
|
171
|
+
// Fire-and-forget; the periodic ticker handles the rest.
|
|
172
|
+
_tick().catch(function () { /* drop-silent — see _tick */ });
|
|
173
|
+
}
|
|
174
|
+
handle = safeAsync.repeating(function () {
|
|
175
|
+
_tick().catch(function () { /* drop-silent */ });
|
|
176
|
+
}, intervalMs, { name: "sanctions-fetcher" });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function shutdown() {
|
|
180
|
+
stopping = true;
|
|
181
|
+
if (handle) { handle.stop(); handle = null; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function stats() {
|
|
185
|
+
return {
|
|
186
|
+
lastSuccess: lastSuccess,
|
|
187
|
+
lastError: lastError,
|
|
188
|
+
refreshCount: refreshCount,
|
|
189
|
+
failureCount: failureCount,
|
|
190
|
+
running: handle !== null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
start: start,
|
|
196
|
+
shutdown: shutdown,
|
|
197
|
+
stats: stats,
|
|
198
|
+
// Test hook
|
|
199
|
+
_tickOnce: _tick,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = {
|
|
204
|
+
create: create,
|
|
205
|
+
SanctionsFetcherError: SanctionsFetcherError,
|
|
206
|
+
};
|