@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/lib/dsr.js
ADDED
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.dsr — Data Subject Rights workflow primitive.
|
|
4
|
+
*
|
|
5
|
+
* Coordinates the operator's response to GDPR Articles 15-22 / CCPA /
|
|
6
|
+
* CPRA / LGPD / PIPEDA / UK-GDPR data-subject requests. The framework
|
|
7
|
+
* owns the ticket state machine, deadline computation, audit emission,
|
|
8
|
+
* and source orchestration. The operator owns the storage backend
|
|
9
|
+
* (declares a `ticketStore` that satisfies the `{ insert, get, list,
|
|
10
|
+
* update }` shape) and the per-source `query` / `erase` callbacks.
|
|
11
|
+
*
|
|
12
|
+
* var dsr = b.dsr.create({
|
|
13
|
+
* ticketStore: dsrTickets, // operator-supplied storage
|
|
14
|
+
* posture: "gdpr", // sets default deadline (1mo)
|
|
15
|
+
* identityResolver: async function (input) {
|
|
16
|
+
* // takes operator-form input; returns canonical subject
|
|
17
|
+
* return { subjectId, email, phone, aliases };
|
|
18
|
+
* },
|
|
19
|
+
* sources: [
|
|
20
|
+
* {
|
|
21
|
+
* name: "users",
|
|
22
|
+
* query: async function (subj) { return rowsAboutSubj; },
|
|
23
|
+
* erase: async function (subj) { return { deletedIds: [...] }; },
|
|
24
|
+
* },
|
|
25
|
+
* {
|
|
26
|
+
* name: "orders",
|
|
27
|
+
* query: async function (subj) { ... },
|
|
28
|
+
* erase: async function (subj) { ... },
|
|
29
|
+
* // CCPA §1798.105(d) — sale records may be retained for legal
|
|
30
|
+
* // dispute purposes; flag the source so erasure produces a
|
|
31
|
+
* // partial-success outcome.
|
|
32
|
+
* eraseExclusions: ["legal-hold"],
|
|
33
|
+
* },
|
|
34
|
+
* ],
|
|
35
|
+
* audit: true,
|
|
36
|
+
* retentionFloorMs: C.TIME.days(30), // export TTL
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* // Operator route: subject submits a request
|
|
40
|
+
* var ticket = await dsr.submit({
|
|
41
|
+
* type: "access", // | "erasure" | "portability" |
|
|
42
|
+
* // "rectification" | "restriction" |
|
|
43
|
+
* // "object" | "automated-decision"
|
|
44
|
+
* subject: { email: "alice@example.com" },
|
|
45
|
+
* reason: "user-initiated via web form",
|
|
46
|
+
* // optional — operator-side workflow ID for cross-ref
|
|
47
|
+
* externalRef: "case-ZD-12345",
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* // Operator route: admin processes a queued ticket
|
|
51
|
+
* var result = await dsr.process(ticket.id, {
|
|
52
|
+
* actor: "compliance@example.com",
|
|
53
|
+
* verifyContext: { mfaVerified: true, attestation: "..." },
|
|
54
|
+
* });
|
|
55
|
+
*
|
|
56
|
+
* // Cancel before processing (operator chooses; subject withdraws)
|
|
57
|
+
* await dsr.cancel(ticket.id, {
|
|
58
|
+
* actor: "compliance@example.com",
|
|
59
|
+
* reason: "subject withdrew on phone call",
|
|
60
|
+
* });
|
|
61
|
+
*
|
|
62
|
+
* Ticket state machine:
|
|
63
|
+
* pending → in_progress → (completed | partially_completed | cancelled | rejected)
|
|
64
|
+
*
|
|
65
|
+
* Audit emissions (audit namespace `dsr`):
|
|
66
|
+
* dsr.ticket.submitted — every submit()
|
|
67
|
+
* dsr.ticket.in_progress — every process() entry
|
|
68
|
+
* dsr.ticket.completed — every successful process() exit
|
|
69
|
+
* dsr.ticket.partial — process() with at least one source failure
|
|
70
|
+
* dsr.ticket.cancelled — every cancel()
|
|
71
|
+
* dsr.ticket.rejected — process() refuses (verify-context fail / unsupported)
|
|
72
|
+
* dsr.ticket.expired — ticket past deadline without completion
|
|
73
|
+
* dsr.source.queried — per-source successful query
|
|
74
|
+
* dsr.source.erased — per-source successful erase
|
|
75
|
+
* dsr.source.failed — per-source failure
|
|
76
|
+
*
|
|
77
|
+
* Posture-aware deadline (operator may override per-ticket):
|
|
78
|
+
* gdpr — 1 month (Art. 12(3)); extendable +2 months for complexity
|
|
79
|
+
* ccpa — 45 calendar days; extendable +45 days
|
|
80
|
+
* lgpd-br — 15 days for data subjects' requests (LGPD Art. 19)
|
|
81
|
+
* pipeda-ca — 30 days
|
|
82
|
+
* uk-gdpr — 1 month (mirrors GDPR)
|
|
83
|
+
* default — 30 days
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
var C = require("./constants");
|
|
87
|
+
var bCrypto = require("./crypto");
|
|
88
|
+
var lazyRequire = require("./lazy-require");
|
|
89
|
+
var validateOpts = require("./validate-opts");
|
|
90
|
+
var { defineClass } = require("./framework-error");
|
|
91
|
+
|
|
92
|
+
var DsrError = defineClass("DsrError", { alwaysPermanent: true });
|
|
93
|
+
|
|
94
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
95
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
96
|
+
|
|
97
|
+
var VALID_REQUEST_TYPES = Object.freeze([
|
|
98
|
+
"access", // GDPR Art. 15 / CCPA §1798.110
|
|
99
|
+
"erasure", // GDPR Art. 17 / CCPA §1798.105
|
|
100
|
+
"portability", // GDPR Art. 20 / CCPA §1798.130
|
|
101
|
+
"rectification", // GDPR Art. sixteen
|
|
102
|
+
"restriction", // GDPR Art. 18
|
|
103
|
+
"object", // GDPR Art. 21
|
|
104
|
+
"automated-decision", // GDPR Art. 22 — review of automated decision
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
var VALID_STATES = Object.freeze([
|
|
108
|
+
"pending",
|
|
109
|
+
"in_progress",
|
|
110
|
+
"completed",
|
|
111
|
+
"partially_completed",
|
|
112
|
+
"cancelled",
|
|
113
|
+
"rejected",
|
|
114
|
+
"expired",
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
var TERMINAL_STATES = Object.freeze({
|
|
118
|
+
completed: true,
|
|
119
|
+
partially_completed: true,
|
|
120
|
+
cancelled: true,
|
|
121
|
+
rejected: true,
|
|
122
|
+
expired: true,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Per-posture default deadline. Operators with ambiguity (multi-region
|
|
126
|
+
// deployments) pass an explicit `deadlineMs` to submit().
|
|
127
|
+
// Verification level — operator-side controls for the identity ladder
|
|
128
|
+
// per GDPR Art. 12(6) ("the controller may request the provision of
|
|
129
|
+
// additional information necessary to confirm the identity of the
|
|
130
|
+
// data subject").
|
|
131
|
+
//
|
|
132
|
+
// "minimal" — controller relies on operator's identity-resolver
|
|
133
|
+
// (e.g. session-bound user lookup); no extra step.
|
|
134
|
+
// "secondary" — controller requires a second factor (email-link
|
|
135
|
+
// challenge, phone OTP, MFA recheck) verified at
|
|
136
|
+
// request submission time.
|
|
137
|
+
// "strong" — controller requires a notarised attestation /
|
|
138
|
+
// in-person verification step (typically required
|
|
139
|
+
// for healthcare / minor's data).
|
|
140
|
+
var VALID_VERIFICATION_LEVELS = Object.freeze([
|
|
141
|
+
"minimal",
|
|
142
|
+
"secondary",
|
|
143
|
+
"strong",
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
// For each request type, the minimum verification level the framework
|
|
147
|
+
// recommends. Operators override per-ticket via opts.verificationLevel
|
|
148
|
+
// but cannot drop BELOW the matrix.
|
|
149
|
+
var TYPE_MIN_VERIFICATION = Object.freeze({
|
|
150
|
+
"access": "minimal", // GDPR Recital sixty-four — provide info if identity confirmed
|
|
151
|
+
"erasure": "secondary", // irreversible — second factor recommended
|
|
152
|
+
"portability": "secondary", // mass export — second factor recommended
|
|
153
|
+
"rectification": "secondary", // data integrity impact
|
|
154
|
+
"restriction": "minimal",
|
|
155
|
+
"object": "minimal",
|
|
156
|
+
"automated-decision": "minimal",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
var POSTURE_DEADLINE_MS = Object.freeze({
|
|
160
|
+
"gdpr": C.TIME.days(30), // GDPR Art. 12(3) — 1 month
|
|
161
|
+
"uk-gdpr": C.TIME.days(30), // UK ICO — mirrors GDPR
|
|
162
|
+
"ccpa": C.TIME.days(45), // CCPA — 45 calendar days
|
|
163
|
+
"lgpd-br": C.TIME.days(15), // LGPD Art. 19 — 15 days
|
|
164
|
+
"pipeda-ca": C.TIME.days(30), // PIPEDA — 30 days
|
|
165
|
+
"appi-jp": C.TIME.days(30), // APPI — typical 30-day handling
|
|
166
|
+
"pdpa-sg": C.TIME.days(30), // PDPA — 30 days
|
|
167
|
+
"pipl-cn": C.TIME.days(15), // PIPL — 15 days
|
|
168
|
+
"default": C.TIME.days(30),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Operator extends without modifying the table (exported for tests
|
|
172
|
+
// + extension). Read-only at module scope.
|
|
173
|
+
function _deadlineForPosture(posture) {
|
|
174
|
+
if (typeof posture !== "string") return POSTURE_DEADLINE_MS["default"];
|
|
175
|
+
return POSTURE_DEADLINE_MS[posture] || POSTURE_DEADLINE_MS["default"];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _now() { return Date.now(); }
|
|
179
|
+
|
|
180
|
+
function _isTerminal(state) { return TERMINAL_STATES[state] === true; }
|
|
181
|
+
|
|
182
|
+
function _validateTicketStore(store) {
|
|
183
|
+
if (!store || typeof store !== "object") return false;
|
|
184
|
+
return ["insert", "get", "list", "update"].every(function (m) {
|
|
185
|
+
return typeof store[m] === "function";
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _validateSource(s) {
|
|
190
|
+
if (!s || typeof s !== "object") return false;
|
|
191
|
+
if (typeof s.name !== "string" || s.name.length === 0) return false;
|
|
192
|
+
if (typeof s.query !== "function" && typeof s.erase !== "function") return false;
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function create(opts) {
|
|
197
|
+
validateOpts.requireObject(opts, "dsr", DsrError);
|
|
198
|
+
validateOpts(opts, [
|
|
199
|
+
"ticketStore", "posture", "identityResolver",
|
|
200
|
+
"sources", "audit", "retentionFloorMs",
|
|
201
|
+
"deadlineMs", "observability",
|
|
202
|
+
"verificationLevel", "verifyContext",
|
|
203
|
+
"receiptSigner", "minVerificationByType",
|
|
204
|
+
], "dsr.create");
|
|
205
|
+
|
|
206
|
+
if (!_validateTicketStore(opts.ticketStore)) {
|
|
207
|
+
throw new DsrError("dsr/bad-store",
|
|
208
|
+
"dsr.create: ticketStore must implement { insert, get, list, update }");
|
|
209
|
+
}
|
|
210
|
+
if (typeof opts.identityResolver !== "function") {
|
|
211
|
+
throw new DsrError("dsr/bad-identity",
|
|
212
|
+
"dsr.create: identityResolver must be an async function");
|
|
213
|
+
}
|
|
214
|
+
if (!Array.isArray(opts.sources) || opts.sources.length === 0) {
|
|
215
|
+
throw new DsrError("dsr/no-sources",
|
|
216
|
+
"dsr.create: sources must be a non-empty array");
|
|
217
|
+
}
|
|
218
|
+
for (var i = 0; i < opts.sources.length; i++) {
|
|
219
|
+
if (!_validateSource(opts.sources[i])) {
|
|
220
|
+
throw new DsrError("dsr/bad-source",
|
|
221
|
+
"dsr.create: sources[" + i + "] missing name or query/erase function");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (opts.posture !== undefined && typeof opts.posture !== "string") {
|
|
225
|
+
throw new DsrError("dsr/bad-posture",
|
|
226
|
+
"dsr.create: posture must be a string");
|
|
227
|
+
}
|
|
228
|
+
validateOpts.optionalPositiveFinite(opts.retentionFloorMs,
|
|
229
|
+
"dsr.create: retentionFloorMs", DsrError, "dsr/bad-opts");
|
|
230
|
+
validateOpts.optionalPositiveFinite(opts.deadlineMs,
|
|
231
|
+
"dsr.create: deadlineMs", DsrError, "dsr/bad-opts");
|
|
232
|
+
|
|
233
|
+
var store = opts.ticketStore;
|
|
234
|
+
var posture = opts.posture || "default";
|
|
235
|
+
var auditOn = opts.audit !== false;
|
|
236
|
+
var defaultDeadlineMs = opts.deadlineMs || _deadlineForPosture(posture);
|
|
237
|
+
var retentionFloorMs = opts.retentionFloorMs || C.TIME.days(30);
|
|
238
|
+
|
|
239
|
+
var defaultVerificationLevel = opts.verificationLevel || null;
|
|
240
|
+
if (defaultVerificationLevel !== null &&
|
|
241
|
+
VALID_VERIFICATION_LEVELS.indexOf(defaultVerificationLevel) === -1) {
|
|
242
|
+
throw new DsrError("dsr/bad-verification-level",
|
|
243
|
+
"dsr.create: verificationLevel must be one of " +
|
|
244
|
+
VALID_VERIFICATION_LEVELS.join(", "));
|
|
245
|
+
}
|
|
246
|
+
var minVerificationByType = Object.assign({}, TYPE_MIN_VERIFICATION,
|
|
247
|
+
opts.minVerificationByType || {});
|
|
248
|
+
// Validate operator override values
|
|
249
|
+
var overrideKeys = Object.keys(minVerificationByType);
|
|
250
|
+
for (var ki = 0; ki < overrideKeys.length; ki++) {
|
|
251
|
+
if (VALID_VERIFICATION_LEVELS.indexOf(minVerificationByType[overrideKeys[ki]]) === -1) {
|
|
252
|
+
throw new DsrError("dsr/bad-min-verification",
|
|
253
|
+
"dsr.create: minVerificationByType[" + overrideKeys[ki] +
|
|
254
|
+
"] must be one of " + VALID_VERIFICATION_LEVELS.join(", "));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
validateOpts.optionalFunction(opts.receiptSigner,
|
|
258
|
+
"dsr.create: receiptSigner", DsrError, "dsr/bad-opts");
|
|
259
|
+
|
|
260
|
+
function _levelOrdinal(lvl) {
|
|
261
|
+
return VALID_VERIFICATION_LEVELS.indexOf(lvl);
|
|
262
|
+
}
|
|
263
|
+
function _isVerificationOk(actualLevel, requiredLevel) {
|
|
264
|
+
return _levelOrdinal(actualLevel) >= _levelOrdinal(requiredLevel);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Source registry — keyed by name for O(1) lookup
|
|
268
|
+
var sources = Object.create(null);
|
|
269
|
+
for (var s = 0; s < opts.sources.length; s++) {
|
|
270
|
+
sources[opts.sources[s].name] = opts.sources[s];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function _emitAudit(action, outcome, metadata) {
|
|
274
|
+
if (!auditOn) return;
|
|
275
|
+
try {
|
|
276
|
+
audit().safeEmit({
|
|
277
|
+
action: action,
|
|
278
|
+
outcome: outcome === "ok" ? "success" : outcome === "fail" ? "failure" : outcome === "warn" ? "success" : outcome,
|
|
279
|
+
metadata: metadata || {},
|
|
280
|
+
});
|
|
281
|
+
} catch (_e) { /* drop-silent — audit sink */ }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function _emitMetric(verb, n, labels) {
|
|
285
|
+
try { observability().safeEvent("dsr." + verb, n || 1, labels || {}); }
|
|
286
|
+
catch (_e) { /* drop-silent */ }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function _newTicketId() {
|
|
290
|
+
var ts = String(Date.now()).slice(-7); // allow:raw-byte-literal — last 7 chars of unix-ms timestamp; collision-resistant when paired with the random suffix
|
|
291
|
+
var rnd = bCrypto.generateBytes(C.BYTES.bytes(6)).toString("hex").toUpperCase();
|
|
292
|
+
return "DSR-" + ts + "-" + rnd;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function submit(input) {
|
|
296
|
+
if (!input || typeof input !== "object") {
|
|
297
|
+
throw new DsrError("dsr/bad-submit", "submit: input must be an object");
|
|
298
|
+
}
|
|
299
|
+
if (VALID_REQUEST_TYPES.indexOf(input.type) === -1) {
|
|
300
|
+
throw new DsrError("dsr/bad-type",
|
|
301
|
+
"submit: type must be one of " + VALID_REQUEST_TYPES.join(", ") +
|
|
302
|
+
" (got " + JSON.stringify(input.type) + ")");
|
|
303
|
+
}
|
|
304
|
+
if (!input.subject || typeof input.subject !== "object") {
|
|
305
|
+
throw new DsrError("dsr/bad-subject",
|
|
306
|
+
"submit: subject must be an object");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Resolve canonical subject identity
|
|
310
|
+
var resolved;
|
|
311
|
+
try { resolved = await opts.identityResolver(input.subject); }
|
|
312
|
+
catch (e) {
|
|
313
|
+
_emitAudit("dsr.ticket.rejected", "fail", {
|
|
314
|
+
reason: "identity-resolver-failed",
|
|
315
|
+
error: (e && e.message) || String(e),
|
|
316
|
+
});
|
|
317
|
+
throw new DsrError("dsr/identity-resolver-failed",
|
|
318
|
+
"submit: identityResolver threw: " + ((e && e.message) || String(e)));
|
|
319
|
+
}
|
|
320
|
+
if (!resolved || typeof resolved !== "object") {
|
|
321
|
+
throw new DsrError("dsr/identity-not-resolved",
|
|
322
|
+
"submit: identityResolver returned non-object (subject not found?)");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
var deadlineMs = (typeof input.deadlineMs === "number" && isFinite(input.deadlineMs))
|
|
326
|
+
? input.deadlineMs
|
|
327
|
+
: defaultDeadlineMs;
|
|
328
|
+
|
|
329
|
+
var now = _now();
|
|
330
|
+
var submitVerificationLevel = input.verificationLevel || defaultVerificationLevel || null;
|
|
331
|
+
if (submitVerificationLevel !== null &&
|
|
332
|
+
VALID_VERIFICATION_LEVELS.indexOf(submitVerificationLevel) === -1) {
|
|
333
|
+
throw new DsrError("dsr/bad-verification-level",
|
|
334
|
+
"submit: verificationLevel must be one of " +
|
|
335
|
+
VALID_VERIFICATION_LEVELS.join(", "));
|
|
336
|
+
}
|
|
337
|
+
var ticket = {
|
|
338
|
+
id: _newTicketId(),
|
|
339
|
+
type: input.type,
|
|
340
|
+
subject: resolved,
|
|
341
|
+
submittedBy: input.submittedBy || null,
|
|
342
|
+
reason: input.reason || null,
|
|
343
|
+
externalRef: input.externalRef || null,
|
|
344
|
+
status: "pending",
|
|
345
|
+
submittedAt: now,
|
|
346
|
+
deadlineAt: now + deadlineMs,
|
|
347
|
+
processedAt: null,
|
|
348
|
+
result: null,
|
|
349
|
+
sourceResults: [],
|
|
350
|
+
posture: posture,
|
|
351
|
+
retentionUntil: now + retentionFloorMs,
|
|
352
|
+
verificationLevel: submitVerificationLevel,
|
|
353
|
+
verifyContext: null,
|
|
354
|
+
};
|
|
355
|
+
await store.insert(ticket);
|
|
356
|
+
_emitAudit("dsr.ticket.submitted", "ok", {
|
|
357
|
+
id: ticket.id,
|
|
358
|
+
type: ticket.type,
|
|
359
|
+
posture: posture,
|
|
360
|
+
deadlineAt: ticket.deadlineAt,
|
|
361
|
+
});
|
|
362
|
+
_emitMetric("submitted", 1, { type: ticket.type, posture: posture });
|
|
363
|
+
return ticket;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function process(ticketId, opts2) {
|
|
367
|
+
opts2 = opts2 || {};
|
|
368
|
+
var ticket = await store.get(ticketId);
|
|
369
|
+
if (!ticket) {
|
|
370
|
+
throw new DsrError("dsr/not-found", "process: ticket " + ticketId + " not found");
|
|
371
|
+
}
|
|
372
|
+
if (_isTerminal(ticket.status)) {
|
|
373
|
+
throw new DsrError("dsr/terminal-state",
|
|
374
|
+
"process: ticket " + ticketId + " is in terminal state " + ticket.status);
|
|
375
|
+
}
|
|
376
|
+
if (ticket.status === "in_progress") {
|
|
377
|
+
throw new DsrError("dsr/already-in-progress",
|
|
378
|
+
"process: ticket " + ticketId + " is already in progress");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Identity-verification ladder per GDPR Article 12(6) / CCPA
|
|
382
|
+
// §1798.140(y). The operator passes the verification level
|
|
383
|
+
// they completed at submission/process time; the framework
|
|
384
|
+
// refuses processing if it's below the per-type floor.
|
|
385
|
+
var requiredLevel = minVerificationByType[ticket.type] || "minimal";
|
|
386
|
+
var actualLevel = (opts2 && opts2.verificationLevel) ||
|
|
387
|
+
ticket.verificationLevel ||
|
|
388
|
+
defaultVerificationLevel ||
|
|
389
|
+
"minimal";
|
|
390
|
+
if (VALID_VERIFICATION_LEVELS.indexOf(actualLevel) === -1) {
|
|
391
|
+
throw new DsrError("dsr/bad-verification-level",
|
|
392
|
+
"process: verificationLevel must be one of " +
|
|
393
|
+
VALID_VERIFICATION_LEVELS.join(", "));
|
|
394
|
+
}
|
|
395
|
+
if (!_isVerificationOk(actualLevel, requiredLevel)) {
|
|
396
|
+
_emitAudit("dsr.ticket.rejected", "fail", {
|
|
397
|
+
id: ticket.id, type: ticket.type,
|
|
398
|
+
reason: "insufficient-verification",
|
|
399
|
+
required: requiredLevel, actual: actualLevel,
|
|
400
|
+
});
|
|
401
|
+
throw new DsrError("dsr/insufficient-verification",
|
|
402
|
+
"process: ticket " + ticketId + " requires verification level " +
|
|
403
|
+
requiredLevel + " but actual is " + actualLevel +
|
|
404
|
+
" (operator must complete the additional verification step before re-processing)");
|
|
405
|
+
}
|
|
406
|
+
ticket.verificationLevel = actualLevel;
|
|
407
|
+
ticket.verifyContext = opts2.verifyContext || null;
|
|
408
|
+
|
|
409
|
+
// Mark in_progress before any source dispatch — protects against
|
|
410
|
+
// concurrent processors picking the same ticket.
|
|
411
|
+
ticket.status = "in_progress";
|
|
412
|
+
ticket.startedAt = _now();
|
|
413
|
+
ticket.processor = opts2.actor || null;
|
|
414
|
+
await store.update(ticket.id, ticket);
|
|
415
|
+
_emitAudit("dsr.ticket.in_progress", "ok", {
|
|
416
|
+
id: ticket.id,
|
|
417
|
+
type: ticket.type,
|
|
418
|
+
actor: opts2.actor || null,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
var sourceResults = [];
|
|
422
|
+
var anyFailed = false;
|
|
423
|
+
var totalRows = 0;
|
|
424
|
+
var deletedTotal = 0;
|
|
425
|
+
|
|
426
|
+
var sourceNames = Object.keys(sources);
|
|
427
|
+
for (var i = 0; i < sourceNames.length; i++) {
|
|
428
|
+
var src = sources[sourceNames[i]];
|
|
429
|
+
var sourceResult = {
|
|
430
|
+
name: src.name,
|
|
431
|
+
outcome: "skipped",
|
|
432
|
+
rows: null,
|
|
433
|
+
deleted: null,
|
|
434
|
+
error: null,
|
|
435
|
+
};
|
|
436
|
+
try {
|
|
437
|
+
if (ticket.type === "access" || ticket.type === "portability" ||
|
|
438
|
+
ticket.type === "rectification") {
|
|
439
|
+
if (typeof src.query === "function") {
|
|
440
|
+
var rows = await src.query(ticket.subject);
|
|
441
|
+
sourceResult.outcome = "queried";
|
|
442
|
+
sourceResult.rows = Array.isArray(rows) ? rows.length : (rows ? 1 : 0);
|
|
443
|
+
sourceResult.data = rows; // operator-side responsibility to redact
|
|
444
|
+
totalRows += sourceResult.rows;
|
|
445
|
+
_emitAudit("dsr.source.queried", "ok", {
|
|
446
|
+
ticketId: ticket.id, source: src.name, rows: sourceResult.rows,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
} else if (ticket.type === "erasure") {
|
|
450
|
+
if (typeof src.erase === "function") {
|
|
451
|
+
var eraseResult = await src.erase(ticket.subject);
|
|
452
|
+
var deleted = (eraseResult && Array.isArray(eraseResult.deletedIds))
|
|
453
|
+
? eraseResult.deletedIds.length
|
|
454
|
+
: (typeof (eraseResult && eraseResult.deleted) === "number"
|
|
455
|
+
? eraseResult.deleted : 0);
|
|
456
|
+
sourceResult.outcome = "erased";
|
|
457
|
+
sourceResult.deleted = deleted;
|
|
458
|
+
sourceResult.deletedIds = (eraseResult && eraseResult.deletedIds) || null;
|
|
459
|
+
sourceResult.exclusions = (eraseResult && eraseResult.exclusions) ||
|
|
460
|
+
src.eraseExclusions || null;
|
|
461
|
+
deletedTotal += deleted;
|
|
462
|
+
_emitAudit("dsr.source.erased", "ok", {
|
|
463
|
+
ticketId: ticket.id, source: src.name, deleted: deleted,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
} else if (ticket.type === "restriction") {
|
|
467
|
+
// Restriction is operator-side: we mark the source as "noted"
|
|
468
|
+
// so the operator's downstream code skips processing for the
|
|
469
|
+
// subject. The framework records the restriction; enforcement
|
|
470
|
+
// is operator code that reads sourceResults.
|
|
471
|
+
sourceResult.outcome = "marked-restricted";
|
|
472
|
+
} else if (ticket.type === "object") {
|
|
473
|
+
// Object to processing — same shape as restriction but different
|
|
474
|
+
// outcome label so audits read correctly.
|
|
475
|
+
sourceResult.outcome = "marked-objection";
|
|
476
|
+
} else if (ticket.type === "automated-decision") {
|
|
477
|
+
// Operator-side: log a review-required marker
|
|
478
|
+
sourceResult.outcome = "marked-automated-decision-review";
|
|
479
|
+
}
|
|
480
|
+
} catch (e) {
|
|
481
|
+
anyFailed = true;
|
|
482
|
+
sourceResult.outcome = "failed";
|
|
483
|
+
sourceResult.error = (e && e.message) || String(e);
|
|
484
|
+
_emitAudit("dsr.source.failed", "fail", {
|
|
485
|
+
ticketId: ticket.id, source: src.name,
|
|
486
|
+
error: sourceResult.error,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
sourceResults.push(sourceResult);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
var finalStatus = anyFailed ? "partially_completed" : "completed";
|
|
493
|
+
ticket.status = finalStatus;
|
|
494
|
+
ticket.processedAt = _now();
|
|
495
|
+
ticket.sourceResults = sourceResults;
|
|
496
|
+
ticket.result = {
|
|
497
|
+
type: ticket.type,
|
|
498
|
+
anyFailed: anyFailed,
|
|
499
|
+
totalRowsFound: totalRows,
|
|
500
|
+
totalDeleted: deletedTotal,
|
|
501
|
+
sources: sourceResults.map(function (r) {
|
|
502
|
+
return {
|
|
503
|
+
name: r.name, outcome: r.outcome,
|
|
504
|
+
rows: r.rows, deleted: r.deleted,
|
|
505
|
+
error: r.error,
|
|
506
|
+
};
|
|
507
|
+
}),
|
|
508
|
+
};
|
|
509
|
+
await store.update(ticket.id, ticket);
|
|
510
|
+
_emitAudit(anyFailed ? "dsr.ticket.partial" : "dsr.ticket.completed",
|
|
511
|
+
anyFailed ? "warn" : "ok",
|
|
512
|
+
{ id: ticket.id, type: ticket.type, totalRows: totalRows,
|
|
513
|
+
totalDeleted: deletedTotal, anyFailed: anyFailed });
|
|
514
|
+
_emitMetric(anyFailed ? "partial" : "completed", 1, { type: ticket.type });
|
|
515
|
+
return ticket;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function cancel(ticketId, opts2) {
|
|
519
|
+
opts2 = opts2 || {};
|
|
520
|
+
var ticket = await store.get(ticketId);
|
|
521
|
+
if (!ticket) {
|
|
522
|
+
throw new DsrError("dsr/not-found", "cancel: ticket " + ticketId + " not found");
|
|
523
|
+
}
|
|
524
|
+
if (_isTerminal(ticket.status)) {
|
|
525
|
+
throw new DsrError("dsr/terminal-state",
|
|
526
|
+
"cancel: ticket " + ticketId + " is in terminal state " + ticket.status);
|
|
527
|
+
}
|
|
528
|
+
ticket.status = "cancelled";
|
|
529
|
+
ticket.cancelledAt = _now();
|
|
530
|
+
ticket.cancelledBy = opts2.actor || null;
|
|
531
|
+
ticket.cancelReason = opts2.reason || null;
|
|
532
|
+
await store.update(ticket.id, ticket);
|
|
533
|
+
_emitAudit("dsr.ticket.cancelled", "ok", {
|
|
534
|
+
id: ticket.id, type: ticket.type, actor: opts2.actor || null,
|
|
535
|
+
reason: opts2.reason || null,
|
|
536
|
+
});
|
|
537
|
+
_emitMetric("cancelled", 1, { type: ticket.type });
|
|
538
|
+
return ticket;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function reject(ticketId, opts2) {
|
|
542
|
+
opts2 = opts2 || {};
|
|
543
|
+
if (typeof opts2.reason !== "string" || opts2.reason.length === 0) {
|
|
544
|
+
throw new DsrError("dsr/bad-reject",
|
|
545
|
+
"reject: opts.reason is required (operator must record the rejection rationale)");
|
|
546
|
+
}
|
|
547
|
+
var ticket = await store.get(ticketId);
|
|
548
|
+
if (!ticket) {
|
|
549
|
+
throw new DsrError("dsr/not-found", "reject: ticket " + ticketId + " not found");
|
|
550
|
+
}
|
|
551
|
+
if (_isTerminal(ticket.status)) {
|
|
552
|
+
throw new DsrError("dsr/terminal-state",
|
|
553
|
+
"reject: ticket " + ticketId + " is in terminal state " + ticket.status);
|
|
554
|
+
}
|
|
555
|
+
ticket.status = "rejected";
|
|
556
|
+
ticket.rejectedAt = _now();
|
|
557
|
+
ticket.rejectedBy = opts2.actor || null;
|
|
558
|
+
ticket.rejectReason = opts2.reason;
|
|
559
|
+
await store.update(ticket.id, ticket);
|
|
560
|
+
_emitAudit("dsr.ticket.rejected", "ok", {
|
|
561
|
+
id: ticket.id, type: ticket.type, actor: opts2.actor || null,
|
|
562
|
+
reason: opts2.reason,
|
|
563
|
+
});
|
|
564
|
+
_emitMetric("rejected", 1, { type: ticket.type });
|
|
565
|
+
return ticket;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function get(ticketId) {
|
|
569
|
+
return await store.get(ticketId);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function listBySubject(subject) {
|
|
573
|
+
if (!subject || typeof subject !== "object") return [];
|
|
574
|
+
return await store.list({ subject: subject });
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function listByStatus(status) {
|
|
578
|
+
if (VALID_STATES.indexOf(status) === -1) {
|
|
579
|
+
throw new DsrError("dsr/bad-status",
|
|
580
|
+
"listByStatus: status must be one of " + VALID_STATES.join(", "));
|
|
581
|
+
}
|
|
582
|
+
return await store.list({ status: status });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function expireOverdue() {
|
|
586
|
+
// Sweep tickets whose deadline has passed without terminal state.
|
|
587
|
+
// Operator runs this on a schedule (e.g. via b.scheduler).
|
|
588
|
+
var now = _now();
|
|
589
|
+
var pending = await store.list({ status: "pending" });
|
|
590
|
+
var inFlight = await store.list({ status: "in_progress" });
|
|
591
|
+
var candidates = [].concat(pending || [], inFlight || []);
|
|
592
|
+
var expired = [];
|
|
593
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
594
|
+
var t = candidates[i];
|
|
595
|
+
if (typeof t.deadlineAt === "number" && t.deadlineAt < now) {
|
|
596
|
+
t.status = "expired";
|
|
597
|
+
t.expiredAt = now;
|
|
598
|
+
await store.update(t.id, t);
|
|
599
|
+
_emitAudit("dsr.ticket.expired", "warn", {
|
|
600
|
+
id: t.id, type: t.type,
|
|
601
|
+
deadlineAt: t.deadlineAt,
|
|
602
|
+
});
|
|
603
|
+
expired.push(t);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return expired;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Build an operator-signed receipt for a completed/cancelled/rejected
|
|
610
|
+
// ticket. The receipt is the canonical "I did the thing" artifact
|
|
611
|
+
// the operator gives to the subject + retains for compliance audit.
|
|
612
|
+
// Receipt shape:
|
|
613
|
+
// {
|
|
614
|
+
// schema: "blamejs.dsr.receipt/1",
|
|
615
|
+
// ticketId, type, status,
|
|
616
|
+
// subject: { subjectId, email, phone },
|
|
617
|
+
// posture, verificationLevel,
|
|
618
|
+
// submittedAt, processedAt | cancelledAt | rejectedAt,
|
|
619
|
+
// deadlineAt,
|
|
620
|
+
// summary: { totalRowsFound?, totalDeleted?, sources?[],
|
|
621
|
+
// cancelReason?, rejectReason? },
|
|
622
|
+
// issuedAt, issuer (from receiptSigner.issuer),
|
|
623
|
+
// signature: base64url-encoded operator signature when
|
|
624
|
+
// receiptSigner is provided
|
|
625
|
+
// }
|
|
626
|
+
async function buildReceipt(ticketId) {
|
|
627
|
+
var ticket = await store.get(ticketId);
|
|
628
|
+
if (!ticket) {
|
|
629
|
+
throw new DsrError("dsr/not-found",
|
|
630
|
+
"buildReceipt: ticket " + ticketId + " not found");
|
|
631
|
+
}
|
|
632
|
+
if (!_isTerminal(ticket.status)) {
|
|
633
|
+
throw new DsrError("dsr/not-terminal",
|
|
634
|
+
"buildReceipt: ticket must be in terminal state (got " +
|
|
635
|
+
ticket.status + ")");
|
|
636
|
+
}
|
|
637
|
+
var summary = {};
|
|
638
|
+
if (ticket.status === "completed" || ticket.status === "partially_completed") {
|
|
639
|
+
summary.totalRowsFound = (ticket.result && ticket.result.totalRowsFound) || 0;
|
|
640
|
+
summary.totalDeleted = (ticket.result && ticket.result.totalDeleted) || 0;
|
|
641
|
+
summary.sources = (ticket.result && ticket.result.sources) || [];
|
|
642
|
+
} else if (ticket.status === "cancelled") {
|
|
643
|
+
summary.cancelReason = ticket.cancelReason || null;
|
|
644
|
+
} else if (ticket.status === "rejected") {
|
|
645
|
+
summary.rejectReason = ticket.rejectReason || null;
|
|
646
|
+
} else if (ticket.status === "expired") {
|
|
647
|
+
summary.deadlineAt = ticket.deadlineAt;
|
|
648
|
+
}
|
|
649
|
+
var receipt = {
|
|
650
|
+
schema: "blamejs.dsr.receipt/1",
|
|
651
|
+
ticketId: ticket.id,
|
|
652
|
+
type: ticket.type,
|
|
653
|
+
status: ticket.status,
|
|
654
|
+
subject: {
|
|
655
|
+
subjectId: ticket.subject.subjectId || null,
|
|
656
|
+
email: ticket.subject.email || null,
|
|
657
|
+
phone: ticket.subject.phone || null,
|
|
658
|
+
},
|
|
659
|
+
posture: ticket.posture,
|
|
660
|
+
verificationLevel: ticket.verificationLevel || "minimal",
|
|
661
|
+
submittedAt: ticket.submittedAt,
|
|
662
|
+
processedAt: ticket.processedAt || null,
|
|
663
|
+
cancelledAt: ticket.cancelledAt || null,
|
|
664
|
+
rejectedAt: ticket.rejectedAt || null,
|
|
665
|
+
expiredAt: ticket.expiredAt || null,
|
|
666
|
+
deadlineAt: ticket.deadlineAt,
|
|
667
|
+
summary: summary,
|
|
668
|
+
issuedAt: _now(),
|
|
669
|
+
};
|
|
670
|
+
if (typeof opts.receiptSigner === "function") {
|
|
671
|
+
try {
|
|
672
|
+
var sigResult = await opts.receiptSigner(receipt);
|
|
673
|
+
receipt.issuer = (sigResult && sigResult.issuer) || null;
|
|
674
|
+
receipt.algorithm = (sigResult && sigResult.algorithm) || null;
|
|
675
|
+
receipt.signature = (sigResult && sigResult.signature) || null;
|
|
676
|
+
} catch (e) {
|
|
677
|
+
// Signer failure is operator-side; return unsigned receipt
|
|
678
|
+
// with a marker so the caller can decide how to handle.
|
|
679
|
+
receipt.signatureError = (e && e.message) || String(e);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return receipt;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Build a portability bundle from a completed access/portability
|
|
686
|
+
// ticket. Operators wire this into their export endpoint; the
|
|
687
|
+
// framework structures the output as a JSON envelope.
|
|
688
|
+
function buildPortabilityBundle(ticket) {
|
|
689
|
+
if (!ticket || ticket.type === undefined) {
|
|
690
|
+
throw new DsrError("dsr/bad-ticket", "buildPortabilityBundle: ticket required");
|
|
691
|
+
}
|
|
692
|
+
if (ticket.type !== "access" && ticket.type !== "portability") {
|
|
693
|
+
throw new DsrError("dsr/wrong-type",
|
|
694
|
+
"buildPortabilityBundle: ticket.type must be 'access' or 'portability'");
|
|
695
|
+
}
|
|
696
|
+
if (!_isTerminal(ticket.status) || ticket.status === "cancelled" ||
|
|
697
|
+
ticket.status === "rejected" || ticket.status === "expired") {
|
|
698
|
+
throw new DsrError("dsr/not-completed",
|
|
699
|
+
"buildPortabilityBundle: ticket must be in completed/partially_completed state");
|
|
700
|
+
}
|
|
701
|
+
var bundle = {
|
|
702
|
+
schema: "blamejs.dsr.portability/1",
|
|
703
|
+
ticketId: ticket.id,
|
|
704
|
+
type: ticket.type,
|
|
705
|
+
subject: {
|
|
706
|
+
subjectId: ticket.subject.subjectId || null,
|
|
707
|
+
email: ticket.subject.email || null,
|
|
708
|
+
phone: ticket.subject.phone || null,
|
|
709
|
+
},
|
|
710
|
+
generatedAt: _now(),
|
|
711
|
+
retentionUntil: ticket.retentionUntil,
|
|
712
|
+
data: {},
|
|
713
|
+
};
|
|
714
|
+
var results = ticket.sourceResults || [];
|
|
715
|
+
for (var i = 0; i < results.length; i++) {
|
|
716
|
+
var r = results[i];
|
|
717
|
+
if (r.outcome === "queried" && r.data !== undefined) {
|
|
718
|
+
bundle.data[r.name] = r.data;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return bundle;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return {
|
|
725
|
+
submit: submit,
|
|
726
|
+
process: process,
|
|
727
|
+
cancel: cancel,
|
|
728
|
+
reject: reject,
|
|
729
|
+
get: get,
|
|
730
|
+
listBySubject: listBySubject,
|
|
731
|
+
listByStatus: listByStatus,
|
|
732
|
+
expireOverdue: expireOverdue,
|
|
733
|
+
buildReceipt: buildReceipt,
|
|
734
|
+
buildPortabilityBundle: buildPortabilityBundle,
|
|
735
|
+
// Test hooks
|
|
736
|
+
_deadlineForPosture: _deadlineForPosture,
|
|
737
|
+
_isTerminal: _isTerminal,
|
|
738
|
+
_isVerificationOk: _isVerificationOk,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// In-memory ticket store — operator dev / test scaffold. Production
|
|
743
|
+
// operators wire their own b.externalDb-backed store. The shape is
|
|
744
|
+
// the contract: { insert(ticket), get(id), list(filter), update(id,
|
|
745
|
+
// patch) }; this implementation satisfies it.
|
|
746
|
+
function memoryTicketStore() {
|
|
747
|
+
var byId = new Map();
|
|
748
|
+
return {
|
|
749
|
+
insert: async function (ticket) {
|
|
750
|
+
if (byId.has(ticket.id)) {
|
|
751
|
+
throw new DsrError("dsr/duplicate-ticket-id",
|
|
752
|
+
"memoryTicketStore: duplicate ticket id " + ticket.id);
|
|
753
|
+
}
|
|
754
|
+
byId.set(ticket.id, Object.assign({}, ticket));
|
|
755
|
+
},
|
|
756
|
+
get: async function (id) {
|
|
757
|
+
var t = byId.get(id);
|
|
758
|
+
return t ? Object.assign({}, t) : null;
|
|
759
|
+
},
|
|
760
|
+
list: async function (filter) {
|
|
761
|
+
filter = filter || {};
|
|
762
|
+
var out = [];
|
|
763
|
+
for (var entry of byId) {
|
|
764
|
+
var t = entry[1];
|
|
765
|
+
if (filter.status && t.status !== filter.status) continue;
|
|
766
|
+
if (filter.subject) {
|
|
767
|
+
if (filter.subject.email && t.subject.email !== filter.subject.email) continue;
|
|
768
|
+
if (filter.subject.subjectId && t.subject.subjectId !== filter.subject.subjectId) continue;
|
|
769
|
+
}
|
|
770
|
+
out.push(Object.assign({}, t));
|
|
771
|
+
}
|
|
772
|
+
return out;
|
|
773
|
+
},
|
|
774
|
+
update: async function (id, ticket) {
|
|
775
|
+
if (!byId.has(id)) {
|
|
776
|
+
throw new DsrError("dsr/ticket-not-found",
|
|
777
|
+
"memoryTicketStore: ticket " + id + " not found for update");
|
|
778
|
+
}
|
|
779
|
+
byId.set(id, Object.assign({}, ticket));
|
|
780
|
+
},
|
|
781
|
+
_size: function () { return byId.size; },
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// b.db-backed ticket store — production operators wire this against
|
|
786
|
+
// the framework's SQLite engine. The store auto-provisions a single
|
|
787
|
+
// table (default name `dsr_tickets`) with the canonical column set:
|
|
788
|
+
//
|
|
789
|
+
// id TEXT PRIMARY KEY
|
|
790
|
+
// type TEXT NOT NULL
|
|
791
|
+
// status TEXT NOT NULL
|
|
792
|
+
// subject_id TEXT
|
|
793
|
+
// subject_email TEXT
|
|
794
|
+
// subject_phone TEXT
|
|
795
|
+
// submitted_at INTEGER NOT NULL
|
|
796
|
+
// deadline_at INTEGER NOT NULL
|
|
797
|
+
// processed_at INTEGER
|
|
798
|
+
// verification_level TEXT
|
|
799
|
+
// posture TEXT
|
|
800
|
+
// payload TEXT -- full JSON for the ticket
|
|
801
|
+
//
|
|
802
|
+
// Indexed on subject_email and status for the common list-by-subject
|
|
803
|
+
// and list-by-status queries.
|
|
804
|
+
function dbTicketStore(opts) {
|
|
805
|
+
opts = opts || {};
|
|
806
|
+
var db = opts.db;
|
|
807
|
+
if (!db || typeof db.runSql !== "function" || typeof db.prepare !== "function") {
|
|
808
|
+
throw new DsrError("dsr/bad-db",
|
|
809
|
+
"dbTicketStore: opts.db must be a b.db-shaped handle (with runSql + prepare)");
|
|
810
|
+
}
|
|
811
|
+
var table = opts.table || "dsr_tickets";
|
|
812
|
+
if (typeof table !== "string" || !/^[A-Za-z][A-Za-z0-9_]*$/.test(table)) {
|
|
813
|
+
throw new DsrError("dsr/bad-table",
|
|
814
|
+
"dbTicketStore: table must be a SQL identifier ([A-Za-z][A-Za-z0-9_]*)");
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Auto-provision schema if not already present. Idempotent.
|
|
818
|
+
function ensureSchema() {
|
|
819
|
+
db.runSql("CREATE TABLE IF NOT EXISTS " + table + " (" +
|
|
820
|
+
"id TEXT PRIMARY KEY, " +
|
|
821
|
+
"type TEXT NOT NULL, " +
|
|
822
|
+
"status TEXT NOT NULL, " +
|
|
823
|
+
"subject_id TEXT, " +
|
|
824
|
+
"subject_email TEXT, " +
|
|
825
|
+
"subject_phone TEXT, " +
|
|
826
|
+
"submitted_at INTEGER NOT NULL, " +
|
|
827
|
+
"deadline_at INTEGER NOT NULL, " +
|
|
828
|
+
"processed_at INTEGER, " +
|
|
829
|
+
"verification_level TEXT, " +
|
|
830
|
+
"posture TEXT, " +
|
|
831
|
+
"payload TEXT NOT NULL" +
|
|
832
|
+
")");
|
|
833
|
+
db.runSql("CREATE INDEX IF NOT EXISTS " + table + "_email_idx ON " +
|
|
834
|
+
table + " (subject_email)");
|
|
835
|
+
db.runSql("CREATE INDEX IF NOT EXISTS " + table + "_status_idx ON " +
|
|
836
|
+
table + " (status)");
|
|
837
|
+
}
|
|
838
|
+
ensureSchema();
|
|
839
|
+
|
|
840
|
+
return {
|
|
841
|
+
insert: async function (ticket) {
|
|
842
|
+
var stmt = db.prepare("INSERT INTO " + table +
|
|
843
|
+
" (id, type, status, subject_id, subject_email, subject_phone, " +
|
|
844
|
+
" submitted_at, deadline_at, processed_at, verification_level, posture, payload) " +
|
|
845
|
+
" VALUES ($id, $type, $status, $sid, $email, $phone, $submittedAt, " +
|
|
846
|
+
" $deadlineAt, $processedAt, $verLevel, $posture, $payload)");
|
|
847
|
+
stmt.run({
|
|
848
|
+
$id: ticket.id,
|
|
849
|
+
$type: ticket.type,
|
|
850
|
+
$status: ticket.status,
|
|
851
|
+
$sid: (ticket.subject && ticket.subject.subjectId) || null,
|
|
852
|
+
$email: (ticket.subject && ticket.subject.email) || null,
|
|
853
|
+
$phone: (ticket.subject && ticket.subject.phone) || null,
|
|
854
|
+
$submittedAt: ticket.submittedAt,
|
|
855
|
+
$deadlineAt: ticket.deadlineAt,
|
|
856
|
+
$processedAt: ticket.processedAt || null,
|
|
857
|
+
$verLevel: ticket.verificationLevel || null,
|
|
858
|
+
$posture: ticket.posture || null,
|
|
859
|
+
$payload: JSON.stringify(ticket),
|
|
860
|
+
});
|
|
861
|
+
},
|
|
862
|
+
get: async function (id) {
|
|
863
|
+
var rows = db.prepare("SELECT payload FROM " + table + " WHERE id = $id")
|
|
864
|
+
.all({ $id: id });
|
|
865
|
+
if (!rows || rows.length === 0) return null;
|
|
866
|
+
return JSON.parse(rows[0].payload); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store, never from operator/network input
|
|
867
|
+
},
|
|
868
|
+
list: async function (filter) {
|
|
869
|
+
filter = filter || {};
|
|
870
|
+
var sql = "SELECT payload FROM " + table;
|
|
871
|
+
var conds = [];
|
|
872
|
+
var params = {};
|
|
873
|
+
if (filter.status) {
|
|
874
|
+
conds.push("status = $status");
|
|
875
|
+
params.$status = filter.status;
|
|
876
|
+
}
|
|
877
|
+
if (filter.subject) {
|
|
878
|
+
if (filter.subject.email) {
|
|
879
|
+
conds.push("subject_email = $email");
|
|
880
|
+
params.$email = filter.subject.email;
|
|
881
|
+
}
|
|
882
|
+
if (filter.subject.subjectId) {
|
|
883
|
+
conds.push("subject_id = $sid");
|
|
884
|
+
params.$sid = filter.subject.subjectId;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (conds.length > 0) sql += " WHERE " + conds.join(" AND ");
|
|
888
|
+
sql += " ORDER BY submitted_at DESC";
|
|
889
|
+
var rows = db.prepare(sql).all(params);
|
|
890
|
+
return rows.map(function (r) { return JSON.parse(r.payload); }); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store, never from operator/network input
|
|
891
|
+
},
|
|
892
|
+
update: async function (id, ticket) {
|
|
893
|
+
var stmt = db.prepare("UPDATE " + table + " SET " +
|
|
894
|
+
" type = $type, status = $status, subject_id = $sid, " +
|
|
895
|
+
" subject_email = $email, subject_phone = $phone, " +
|
|
896
|
+
" submitted_at = $submittedAt, deadline_at = $deadlineAt, " +
|
|
897
|
+
" processed_at = $processedAt, verification_level = $verLevel, " +
|
|
898
|
+
" posture = $posture, payload = $payload " +
|
|
899
|
+
" WHERE id = $id");
|
|
900
|
+
var info = stmt.run({
|
|
901
|
+
$id: id,
|
|
902
|
+
$type: ticket.type,
|
|
903
|
+
$status: ticket.status,
|
|
904
|
+
$sid: (ticket.subject && ticket.subject.subjectId) || null,
|
|
905
|
+
$email: (ticket.subject && ticket.subject.email) || null,
|
|
906
|
+
$phone: (ticket.subject && ticket.subject.phone) || null,
|
|
907
|
+
$submittedAt: ticket.submittedAt,
|
|
908
|
+
$deadlineAt: ticket.deadlineAt,
|
|
909
|
+
$processedAt: ticket.processedAt || null,
|
|
910
|
+
$verLevel: ticket.verificationLevel || null,
|
|
911
|
+
$posture: ticket.posture || null,
|
|
912
|
+
$payload: JSON.stringify(ticket),
|
|
913
|
+
});
|
|
914
|
+
if (info && info.changes === 0) {
|
|
915
|
+
throw new DsrError("dsr/ticket-not-found",
|
|
916
|
+
"dbTicketStore: ticket " + id + " not found for update");
|
|
917
|
+
}
|
|
918
|
+
},
|
|
919
|
+
purgeExpired: async function (asOfMs) {
|
|
920
|
+
// Bulk-delete tickets in terminal states whose retentionUntil
|
|
921
|
+
// is in the past. Returns the number of rows removed.
|
|
922
|
+
var asOf = (typeof asOfMs === "number" && isFinite(asOfMs)) ? asOfMs : Date.now();
|
|
923
|
+
var rows = db.prepare("SELECT id, payload FROM " + table +
|
|
924
|
+
" WHERE status IN ('completed','partially_completed','cancelled','rejected','expired')").all({});
|
|
925
|
+
var purged = 0;
|
|
926
|
+
var del = db.prepare("DELETE FROM " + table + " WHERE id = $id");
|
|
927
|
+
for (var i = 0; i < rows.length; i++) {
|
|
928
|
+
try {
|
|
929
|
+
var t = JSON.parse(rows[i].payload); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store, never from operator/network input
|
|
930
|
+
if (t.retentionUntil && t.retentionUntil < asOf) {
|
|
931
|
+
del.run({ $id: rows[i].id });
|
|
932
|
+
purged += 1;
|
|
933
|
+
}
|
|
934
|
+
} catch (_e) { /* malformed payload — leave it */ }
|
|
935
|
+
}
|
|
936
|
+
return purged;
|
|
937
|
+
},
|
|
938
|
+
_table: table,
|
|
939
|
+
_ensureSchema: ensureSchema,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
module.exports = {
|
|
944
|
+
create: create,
|
|
945
|
+
memoryTicketStore: memoryTicketStore,
|
|
946
|
+
dbTicketStore: dbTicketStore,
|
|
947
|
+
VALID_REQUEST_TYPES: VALID_REQUEST_TYPES,
|
|
948
|
+
VALID_STATES: VALID_STATES,
|
|
949
|
+
VALID_VERIFICATION_LEVELS: VALID_VERIFICATION_LEVELS,
|
|
950
|
+
TYPE_MIN_VERIFICATION: TYPE_MIN_VERIFICATION,
|
|
951
|
+
POSTURE_DEADLINE_MS: POSTURE_DEADLINE_MS,
|
|
952
|
+
DsrError: DsrError,
|
|
953
|
+
};
|