@blamejs/core 0.8.8 → 0.8.10

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.
@@ -0,0 +1,261 @@
1
+ "use strict";
2
+ /**
3
+ * b.gdpr.ropa — GDPR Article 30 Records of Processing Activities.
4
+ *
5
+ * Article 30 §1 (controller) + §2 (processor) require a written
6
+ * record of processing activities. The framework's existing audit
7
+ * chain captures *what happened* on each request; the RoPA captures
8
+ * *what processing the operator does in general* — purposes, data
9
+ * categories, retention periods, recipients, transfers outside the
10
+ * EEA, security measures.
11
+ *
12
+ * The primitive is a registry + exporter:
13
+ * - register(activity) — add a processing-activity record
14
+ * - update(id, patch) — modify an existing record
15
+ * - remove(id) — soft-delete (operator audit trail)
16
+ * - export({ format }) — emit RoPA as JSON / CSV / Markdown
17
+ *
18
+ * var ropa = b.gdpr.ropa.create({
19
+ * audit: b.audit,
20
+ * controller: { name: "Acme Co", contact: "dpo@acme.example" },
21
+ * });
22
+ * ropa.register({
23
+ * id: "sales-funnel-tracking",
24
+ * name: "Sales-funnel CRM tracking",
25
+ * purposes: ["lead-tracking", "sales-attribution"],
26
+ * legalBasis: "legitimate-interests",
27
+ * dataCategories: ["contact-info", "engagement-history"],
28
+ * dataSubjectCategories: ["prospects", "customers"],
29
+ * recipients: ["analytics-vendor", "sales-team"],
30
+ * thirdCountryTransfers: [{ country: "US", safeguard: "scc-2021" }],
31
+ * retentionPeriod: "5 years post-relationship",
32
+ * securityMeasures: ["encrypted-at-rest", "tls-13", "access-control"],
33
+ * });
34
+ * var json = ropa.export({ format: "json" });
35
+ */
36
+
37
+ var defineClass = require("./framework-error").defineClass;
38
+ var lazyRequire = require("./lazy-require");
39
+ var validateOpts = require("./validate-opts");
40
+
41
+ var audit = lazyRequire(function () { return require("./audit"); });
42
+
43
+ var GdprRopaError = defineClass("GdprRopaError", { alwaysPermanent: true });
44
+
45
+ var REQUIRED_ACTIVITY_FIELDS = Object.freeze([
46
+ "id", "name", "purposes", "legalBasis", "dataCategories",
47
+ ]);
48
+
49
+ var VALID_LEGAL_BASES = Object.freeze({
50
+ "consent": 1, // Art 6(1)(a)
51
+ "contract": 1, // Art 6(1)(b)
52
+ "legal-obligation": 1, // Art 6(1)(c)
53
+ "vital-interests": 1, // Art 6(1)(d)
54
+ "public-task": 1, // Art 6(1)(e)
55
+ "legitimate-interests": 1, // Art 6(1)(f)
56
+ });
57
+
58
+ function create(opts) {
59
+ opts = opts || {};
60
+ validateOpts(opts, [
61
+ "audit", "controller", "dpo", "supervisoryAuthority", "now",
62
+ ], "gdpr.ropa");
63
+
64
+ if (!opts.controller || typeof opts.controller !== "object") {
65
+ throw new GdprRopaError("gdpr-ropa/bad-controller",
66
+ "gdpr.ropa.create: opts.controller is required (Article 30 §1(a) requires controller name + contact)");
67
+ }
68
+ var controller = opts.controller;
69
+ var dpo = opts.dpo || null;
70
+ var supervisoryAuthority = opts.supervisoryAuthority || null;
71
+ var auditOn = opts.audit !== false;
72
+ var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
73
+
74
+ var activities = new Map();
75
+
76
+ function _emitAudit(action, outcome, metadata) {
77
+ if (!auditOn) return;
78
+ try {
79
+ audit().safeEmit({
80
+ action: "gdpr.ropa." + action,
81
+ outcome: outcome,
82
+ metadata: metadata || {},
83
+ });
84
+ } catch (_e) { /* drop-silent */ }
85
+ }
86
+
87
+ function _validateActivity(activity, op) {
88
+ if (!activity || typeof activity !== "object") {
89
+ throw new GdprRopaError("gdpr-ropa/bad-activity",
90
+ "gdpr.ropa." + op + ": activity must be an object");
91
+ }
92
+ for (var i = 0; i < REQUIRED_ACTIVITY_FIELDS.length; i++) {
93
+ var f = REQUIRED_ACTIVITY_FIELDS[i];
94
+ if (activity[f] === undefined) {
95
+ throw new GdprRopaError("gdpr-ropa/missing-field",
96
+ "gdpr.ropa." + op + ": activity is missing required field '" + f + "' (per Article 30 §1)");
97
+ }
98
+ }
99
+ if (typeof activity.id !== "string" || activity.id.length === 0) {
100
+ throw new GdprRopaError("gdpr-ropa/bad-id",
101
+ "gdpr.ropa." + op + ": activity.id must be a non-empty string");
102
+ }
103
+ if (!VALID_LEGAL_BASES[activity.legalBasis]) {
104
+ throw new GdprRopaError("gdpr-ropa/bad-legal-basis",
105
+ "gdpr.ropa." + op + ": activity.legalBasis must be one of " + Object.keys(VALID_LEGAL_BASES).join(", "));
106
+ }
107
+ }
108
+
109
+ function register(activity) {
110
+ _validateActivity(activity, "register");
111
+ if (activities.has(activity.id)) {
112
+ throw new GdprRopaError("gdpr-ropa/duplicate-id",
113
+ "gdpr.ropa.register: activity '" + activity.id + "' already registered");
114
+ }
115
+ var rec = Object.assign({}, activity, {
116
+ registeredAt: now(),
117
+ lastUpdatedAt: now(),
118
+ });
119
+ activities.set(activity.id, rec);
120
+ _emitAudit("registered", "success", { id: activity.id, purposes: activity.purposes });
121
+ return rec;
122
+ }
123
+
124
+ function update(id, patch) {
125
+ var existing = activities.get(id);
126
+ if (!existing) {
127
+ throw new GdprRopaError("gdpr-ropa/not-found",
128
+ "gdpr.ropa.update: no activity with id '" + id + "'");
129
+ }
130
+ if (!patch || typeof patch !== "object") {
131
+ throw new GdprRopaError("gdpr-ropa/bad-patch",
132
+ "gdpr.ropa.update: patch must be an object");
133
+ }
134
+ var merged = Object.assign({}, existing, patch, {
135
+ id: id, // id is immutable on update
136
+ registeredAt: existing.registeredAt,
137
+ lastUpdatedAt: now(),
138
+ });
139
+ if (patch.legalBasis && !VALID_LEGAL_BASES[merged.legalBasis]) {
140
+ throw new GdprRopaError("gdpr-ropa/bad-legal-basis",
141
+ "gdpr.ropa.update: legalBasis must be one of " + Object.keys(VALID_LEGAL_BASES).join(", "));
142
+ }
143
+ activities.set(id, merged);
144
+ _emitAudit("updated", "success", { id: id, fields: Object.keys(patch) });
145
+ return merged;
146
+ }
147
+
148
+ function remove(id, info) {
149
+ var existing = activities.get(id);
150
+ if (!existing) {
151
+ throw new GdprRopaError("gdpr-ropa/not-found",
152
+ "gdpr.ropa.remove: no activity with id '" + id + "'");
153
+ }
154
+ activities.delete(id);
155
+ _emitAudit("removed", "success", {
156
+ id: id,
157
+ reason: (info && info.reason) || null,
158
+ actor: (info && info.actor) || null,
159
+ });
160
+ return { removed: true, id: id };
161
+ }
162
+
163
+ function get(id) { return activities.get(id) || null; }
164
+ function list() {
165
+ var out = [];
166
+ activities.forEach(function (rec) { out.push(rec); });
167
+ return out;
168
+ }
169
+
170
+ // Operator-facing exporter — JSON for API integrations / SCC, CSV
171
+ // for spreadsheet handoff to legal, Markdown for human-readable
172
+ // operator documentation. Each shape carries the same fields per
173
+ // Article 30 §1(a-h) / §2(a-d).
174
+ function _exportJson() {
175
+ return {
176
+ controller: controller,
177
+ dpo: dpo,
178
+ supervisoryAuthority: supervisoryAuthority,
179
+ generatedAt: new Date(now()).toISOString(),
180
+ article: "30",
181
+ regulation: "(EU) 2016/679 (GDPR)",
182
+ activities: list(),
183
+ };
184
+ }
185
+ function _exportCsv() {
186
+ var headers = [
187
+ "id", "name", "purposes", "legalBasis", "dataCategories",
188
+ "dataSubjectCategories", "recipients", "thirdCountryTransfers",
189
+ "retentionPeriod", "securityMeasures",
190
+ ];
191
+ var rows = [headers.join(",")];
192
+ var entries = list();
193
+ for (var i = 0; i < entries.length; i++) {
194
+ var e = entries[i];
195
+ var row = headers.map(function (h) {
196
+ var v = e[h];
197
+ if (v === undefined || v === null) return "";
198
+ if (Array.isArray(v)) return JSON.stringify(v).replace(/"/g, "\"\"");
199
+ return String(v).replace(/"/g, "\"\"");
200
+ });
201
+ rows.push(row.map(function (c) { return '"' + c + '"'; }).join(","));
202
+ }
203
+ return rows.join("\n");
204
+ }
205
+ function _exportMarkdown() {
206
+ var entries = list();
207
+ var md = "# GDPR Article 30 Records of Processing Activities\n\n";
208
+ md += "Generated: " + new Date(now()).toISOString() + "\n\n";
209
+ md += "Controller: " + (controller.name || "(unspecified)") + "\n";
210
+ md += "Contact: " + (controller.contact || "(unspecified)") + "\n\n";
211
+ if (dpo) md += "DPO: " + (dpo.name || "(unspecified)") + " (" + (dpo.contact || "") + ")\n\n";
212
+ md += "## Activities (" + entries.length + ")\n\n";
213
+ for (var i = 0; i < entries.length; i++) {
214
+ var e = entries[i];
215
+ md += "### " + (e.name || e.id) + " (`" + e.id + "`)\n\n";
216
+ md += "- Purposes: " + (e.purposes || []).join(", ") + "\n";
217
+ md += "- Legal basis: " + e.legalBasis + "\n";
218
+ md += "- Data categories: " + (e.dataCategories || []).join(", ") + "\n";
219
+ if (e.dataSubjectCategories) md += "- Data subjects: " + e.dataSubjectCategories.join(", ") + "\n";
220
+ if (e.recipients) md += "- Recipients: " + e.recipients.join(", ") + "\n";
221
+ if (e.retentionPeriod) md += "- Retention: " + e.retentionPeriod + "\n";
222
+ if (e.securityMeasures) md += "- Security: " + e.securityMeasures.join(", ") + "\n";
223
+ if (e.thirdCountryTransfers && e.thirdCountryTransfers.length > 0) {
224
+ md += "- Third-country transfers:\n";
225
+ for (var ti = 0; ti < e.thirdCountryTransfers.length; ti++) {
226
+ var t = e.thirdCountryTransfers[ti];
227
+ md += " - " + t.country + " (safeguard: " + (t.safeguard || "n/a") + ")\n";
228
+ }
229
+ }
230
+ md += "\n";
231
+ }
232
+ return md;
233
+ }
234
+ function exportRopa(eopts) {
235
+ eopts = eopts || {};
236
+ var format = (eopts.format || "json").toLowerCase();
237
+ _emitAudit("exported", "success", { format: format, count: activities.size });
238
+ if (format === "csv") return _exportCsv();
239
+ if (format === "markdown") return _exportMarkdown();
240
+ if (format === "json") return _exportJson();
241
+ throw new GdprRopaError("gdpr-ropa/bad-format",
242
+ "gdpr.ropa.export: format must be 'json' / 'csv' / 'markdown'");
243
+ }
244
+
245
+ return {
246
+ register: register,
247
+ update: update,
248
+ remove: remove,
249
+ get: get,
250
+ list: list,
251
+ "export": exportRopa, // explicit string-key — `export` is reserved
252
+ VALID_LEGAL_BASES: Object.keys(VALID_LEGAL_BASES),
253
+ };
254
+ }
255
+
256
+ module.exports = {
257
+ create: create,
258
+ GdprRopaError: GdprRopaError,
259
+ VALID_LEGAL_BASES: Object.keys(VALID_LEGAL_BASES),
260
+ REQUIRED_ACTIVITY_FIELDS: REQUIRED_ACTIVITY_FIELDS,
261
+ };
@@ -0,0 +1,314 @@
1
+ "use strict";
2
+ /**
3
+ * b.incident.report — generic 3-stage incident-reporting primitive.
4
+ *
5
+ * The three stages mirror the deadline pattern that recurs across
6
+ * regulatory regimes (GDPR Article 33 / NIS2 Article 23 / DORA
7
+ * Article 19 / CRA Article 14 / HIPAA Breach Notification Rule):
8
+ *
9
+ * 1. Initial / early-warning notification — within 24 hours of
10
+ * detection. Operators report what they know: scope, suspected
11
+ * cause, immediate-mitigation plan.
12
+ * 2. Intermediate / status update — within 72 hours of detection.
13
+ * Updated impact assessment, root-cause analysis progress,
14
+ * operator's response posture.
15
+ * 3. Final report — within 30 days of detection (or per-regime
16
+ * deadline). Full incident narrative, affected-data-subject
17
+ * count, remediation, lessons learned.
18
+ *
19
+ * var ir = b.incident.report.create({
20
+ * audit: b.audit,
21
+ * persist: async function (record) { await db.run("INSERT ..."); },
22
+ * onStage: function (event) {
23
+ * // event = { incidentId, stage: "initial"|"intermediate"|"final",
24
+ * // dueBy, regime, fields }
25
+ * },
26
+ * deadlines: { // operator-overridable per regime
27
+ * initial: C.TIME.hours(24),
28
+ * intermediate: C.TIME.hours(72),
29
+ * final: C.TIME.days(30),
30
+ * },
31
+ * });
32
+ * var incident = await ir.open({
33
+ * regime: "gdpr", // identifies the regulatory regime
34
+ * detectedAt: Date.now(),
35
+ * scope: "data-confidentiality-breach",
36
+ * summary: "...",
37
+ * impact: { dataSubjects: 1200, categories: ["pii", "phi"] },
38
+ * });
39
+ * await ir.recordInitial(incident.id, { ... });
40
+ * await ir.recordIntermediate(incident.id, { ... });
41
+ * await ir.recordFinal(incident.id, { ... });
42
+ *
43
+ * Each stage records a tamper-evident audit event with the regime,
44
+ * incident ID, stage, due-by timestamp, and operator-supplied
45
+ * payload. The `persist` hook writes to the operator's incident
46
+ * registry (DB / SIEM / SOAR system); audits give regulator-friendly
47
+ * proof-of-process when the primitive is queried later.
48
+ *
49
+ * Late-stage detection (filing past the due-by) is recorded with a
50
+ * `lateBy` field on the audit metadata and `outcome: "late"`, so
51
+ * regulator audits can distinguish "filed on time" from "filed late
52
+ * but eventually". Refusing to record at all would lose the data;
53
+ * the framework's posture is "always record, always flag late".
54
+ */
55
+
56
+ var C = require("./constants");
57
+ var defineClass = require("./framework-error").defineClass;
58
+ var lazyRequire = require("./lazy-require");
59
+ var validateOpts = require("./validate-opts");
60
+
61
+ var audit = lazyRequire(function () { return require("./audit"); });
62
+ var observability = lazyRequire(function () { return require("./observability"); });
63
+
64
+ var IncidentReportError = defineClass("IncidentReportError", { alwaysPermanent: true });
65
+
66
+ var DEFAULT_DEADLINES = Object.freeze({
67
+ initial: C.TIME.hours(24),
68
+ intermediate: C.TIME.hours(72),
69
+ final: C.TIME.days(30),
70
+ });
71
+
72
+ var VALID_STAGES = Object.freeze({ initial: 1, intermediate: 1, final: 1 });
73
+
74
+ // Regime defaults — operators select these via opts.regime so the
75
+ // per-regime deadline shape is the framework default, not something
76
+ // the operator has to redefine each time. Operators with mixed
77
+ // regimes per incident pick the shortest deadline (the "first wall
78
+ // to hit" rule). Per-regime overrides go on opts.deadlines and
79
+ // override the regime defaults.
80
+ var REGIME_DEADLINES = Object.freeze({
81
+ // GDPR Article 33 §1: notify within 72 hours of awareness.
82
+ // No formal "initial" stage — the framework adds an internal
83
+ // 24-hour initial-warning checkpoint as a practical posture.
84
+ gdpr: Object.freeze({
85
+ initial: C.TIME.hours(24),
86
+ intermediate: C.TIME.hours(72),
87
+ final: C.TIME.days(30),
88
+ }),
89
+ // NIS2 Directive Article 23 §4: early warning within 24h, full
90
+ // notification within 72h, final report within 1 month.
91
+ nis2: Object.freeze({
92
+ initial: C.TIME.hours(24),
93
+ intermediate: C.TIME.hours(72),
94
+ final: C.TIME.days(30),
95
+ }),
96
+ // DORA (EU Digital Operational Resilience Act) Article 19: initial
97
+ // within 4h of classification, intermediate within 24h, final
98
+ // within 1 month.
99
+ dora: Object.freeze({
100
+ initial: C.TIME.hours(4),
101
+ intermediate: C.TIME.hours(24),
102
+ final: C.TIME.days(30),
103
+ }),
104
+ // CRA (EU Cyber Resilience Act) Article 14: early warning within
105
+ // 24h, vulnerability/incident notification within 72h, final
106
+ // report within 14 days.
107
+ cra: Object.freeze({
108
+ initial: C.TIME.hours(24),
109
+ intermediate: C.TIME.hours(72),
110
+ final: C.TIME.days(14),
111
+ }),
112
+ // HIPAA Breach Notification Rule (45 CFR §164.404 etc.): notify
113
+ // affected individuals within 60 days; HHS within 60 days for
114
+ // breaches affecting 500+ individuals (annually otherwise). The
115
+ // initial / intermediate stages have no statutory deadline; we
116
+ // adopt the EU 24/72-hour internal checkpoints as good operator
117
+ // practice.
118
+ hipaa: Object.freeze({
119
+ initial: C.TIME.hours(24),
120
+ intermediate: C.TIME.hours(72),
121
+ final: C.TIME.days(60),
122
+ }),
123
+ });
124
+
125
+ function _resolveDeadlines(regime, override) {
126
+ var base = (regime && REGIME_DEADLINES[regime]) || DEFAULT_DEADLINES;
127
+ if (!override || typeof override !== "object") return base;
128
+ return Object.freeze({
129
+ initial: typeof override.initial === "number" ? override.initial : base.initial,
130
+ intermediate: typeof override.intermediate === "number" ? override.intermediate : base.intermediate,
131
+ final: typeof override.final === "number" ? override.final : base.final,
132
+ });
133
+ }
134
+
135
+ function create(opts) {
136
+ opts = opts || {};
137
+ validateOpts(opts, [
138
+ "audit", "persist", "onStage", "deadlines", "now",
139
+ ], "incident.report");
140
+
141
+ var auditOn = opts.audit !== false;
142
+ var persist = typeof opts.persist === "function" ? opts.persist : null;
143
+ var onStage = typeof opts.onStage === "function" ? opts.onStage : null;
144
+ var deadlinesOverride = opts.deadlines || null;
145
+ var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
146
+
147
+ // In-memory registry — operators wire `persist` for durable
148
+ // storage. The in-memory map is for unit tests + small operator
149
+ // deployments that don't yet wire a DB-backed registry.
150
+ var incidents = new Map();
151
+ var seq = 0;
152
+
153
+ function _emitAudit(action, outcome, metadata) {
154
+ if (!auditOn) return;
155
+ try {
156
+ audit().safeEmit({
157
+ action: "incident.report." + action,
158
+ outcome: outcome,
159
+ metadata: metadata || {},
160
+ });
161
+ } catch (_e) { /* drop-silent */ }
162
+ }
163
+ function _emitMetric(verb, n, labels) {
164
+ try { observability().safeEvent("incident.report." + verb, n || 1, labels || {}); }
165
+ catch (_e) { /* drop-silent */ }
166
+ }
167
+
168
+ function _genIncidentId(regime, detectedAt) {
169
+ seq += 1;
170
+ var ts = new Date(detectedAt).toISOString().replace(/[:.]/g, "-");
171
+ return "incident-" + (regime || "generic") + "-" + ts + "-" + seq;
172
+ }
173
+
174
+ async function open(spec) {
175
+ if (!spec || typeof spec !== "object") {
176
+ throw new IncidentReportError("incident-report/bad-spec",
177
+ "incident.report.open: spec must be an object with { regime, detectedAt, scope, summary, impact }");
178
+ }
179
+ if (typeof spec.regime !== "string" || spec.regime.length === 0) {
180
+ throw new IncidentReportError("incident-report/bad-regime",
181
+ "incident.report.open: spec.regime must be a non-empty string (gdpr / nis2 / dora / cra / hipaa or operator-defined)");
182
+ }
183
+ if (typeof spec.detectedAt !== "number" || !isFinite(spec.detectedAt)) {
184
+ throw new IncidentReportError("incident-report/bad-detected-at",
185
+ "incident.report.open: spec.detectedAt must be a finite Unix-ms timestamp");
186
+ }
187
+ var deadlines = _resolveDeadlines(spec.regime, deadlinesOverride);
188
+ var id = _genIncidentId(spec.regime, spec.detectedAt);
189
+ var record = {
190
+ id: id,
191
+ regime: spec.regime,
192
+ detectedAt: spec.detectedAt,
193
+ scope: spec.scope || null,
194
+ summary: spec.summary || null,
195
+ impact: spec.impact || null,
196
+ deadlines: deadlines,
197
+ dueBy: {
198
+ initial: spec.detectedAt + deadlines.initial,
199
+ intermediate: spec.detectedAt + deadlines.intermediate,
200
+ final: spec.detectedAt + deadlines.final,
201
+ },
202
+ stages: {}, // populated by recordInitial / recordIntermediate / recordFinal
203
+ openedAt: now(),
204
+ closedAt: null,
205
+ };
206
+ incidents.set(id, record);
207
+ _emitAudit("opened", "success", {
208
+ incidentId: id, regime: spec.regime, detectedAt: spec.detectedAt,
209
+ dueByInitial: record.dueBy.initial,
210
+ dueByIntermediate: record.dueBy.intermediate,
211
+ dueByFinal: record.dueBy.final,
212
+ });
213
+ _emitMetric("opened", 1, { regime: spec.regime });
214
+ if (persist) {
215
+ try { await persist(record); }
216
+ catch (e) { _emitAudit("persist_failed", "failure", { incidentId: id, error: (e && e.message) || String(e) }); }
217
+ }
218
+ return record;
219
+ }
220
+
221
+ async function _recordStage(incidentId, stage, payload) {
222
+ if (!VALID_STAGES[stage]) {
223
+ throw new IncidentReportError("incident-report/bad-stage",
224
+ "incident.report._recordStage: stage must be one of " + Object.keys(VALID_STAGES).join(", "));
225
+ }
226
+ var rec = incidents.get(incidentId);
227
+ if (!rec) {
228
+ throw new IncidentReportError("incident-report/unknown-incident",
229
+ "incident.report: no incident with id '" + incidentId + "'");
230
+ }
231
+ if (rec.stages[stage]) {
232
+ throw new IncidentReportError("incident-report/stage-already-filed",
233
+ "incident.report: incident '" + incidentId + "' already has a '" + stage + "' stage filing");
234
+ }
235
+ var nowMs = now();
236
+ var dueBy = rec.dueBy[stage];
237
+ var late = nowMs > dueBy;
238
+ var lateBy = late ? (nowMs - dueBy) : 0;
239
+ rec.stages[stage] = {
240
+ filedAt: nowMs,
241
+ dueBy: dueBy,
242
+ late: late,
243
+ lateBy: lateBy,
244
+ payload: payload || {},
245
+ };
246
+ if (stage === "final") rec.closedAt = nowMs;
247
+
248
+ _emitAudit("stage_recorded", late ? "late" : "success", {
249
+ incidentId: incidentId, regime: rec.regime, stage: stage,
250
+ dueBy: dueBy, filedAt: nowMs, late: late, lateBy: lateBy,
251
+ });
252
+ _emitMetric("stage_recorded", 1, { regime: rec.regime, stage: stage, late: String(late) });
253
+ if (onStage) {
254
+ try { onStage({ incidentId: incidentId, stage: stage, dueBy: dueBy, late: late, regime: rec.regime, fields: payload }); }
255
+ catch (_e) { /* drop-silent — operator hook */ }
256
+ }
257
+ if (persist) {
258
+ try { await persist(rec); }
259
+ catch (e) { _emitAudit("persist_failed", "failure", { incidentId: incidentId, stage: stage, error: (e && e.message) || String(e) }); }
260
+ }
261
+ return rec;
262
+ }
263
+
264
+ function recordInitial(incidentId, payload) { return _recordStage(incidentId, "initial", payload); }
265
+ function recordIntermediate(incidentId, payload) { return _recordStage(incidentId, "intermediate", payload); }
266
+ function recordFinal(incidentId, payload) { return _recordStage(incidentId, "final", payload); }
267
+
268
+ function get(incidentId) { return incidents.get(incidentId) || null; }
269
+ function list() {
270
+ var out = [];
271
+ incidents.forEach(function (rec) { out.push(rec); });
272
+ return out;
273
+ }
274
+
275
+ // Operator-facing summary for dashboards / regulator-prep — counts
276
+ // open / late / closed across all tracked regimes.
277
+ function status() {
278
+ var nowMs = now();
279
+ var summary = {
280
+ total: incidents.size,
281
+ open: 0,
282
+ closed: 0,
283
+ late: { initial: 0, intermediate: 0, final: 0 },
284
+ };
285
+ incidents.forEach(function (rec) {
286
+ if (rec.closedAt) summary.closed += 1; else summary.open += 1;
287
+ ["initial", "intermediate", "final"].forEach(function (s) {
288
+ if (!rec.stages[s] && nowMs > rec.dueBy[s]) summary.late[s] += 1;
289
+ else if (rec.stages[s] && rec.stages[s].late) summary.late[s] += 1;
290
+ });
291
+ });
292
+ return summary;
293
+ }
294
+
295
+ return {
296
+ open: open,
297
+ recordInitial: recordInitial,
298
+ recordIntermediate: recordIntermediate,
299
+ recordFinal: recordFinal,
300
+ get: get,
301
+ list: list,
302
+ status: status,
303
+ REGIME_DEADLINES: REGIME_DEADLINES,
304
+ DEFAULT_DEADLINES: DEFAULT_DEADLINES,
305
+ };
306
+ }
307
+
308
+ module.exports = {
309
+ create: create,
310
+ IncidentReportError: IncidentReportError,
311
+ REGIME_DEADLINES: REGIME_DEADLINES,
312
+ DEFAULT_DEADLINES: DEFAULT_DEADLINES,
313
+ VALID_STAGES: Object.keys(VALID_STAGES),
314
+ };