@blamejs/core 0.7.103 → 0.7.104

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.7.x
10
10
 
11
+ - **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.
12
+
11
13
  - **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
14
 
13
15
  - **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)
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.7.103",
3
+ "version": "0.7.104",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:dcca667d-36b2-435f-9aaa-00c532f93b2c",
5
+ "serialNumber": "urn:uuid:5afd6c98-92aa-4383-a13c-7d4aed07fdbc",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-06T10:33:07.703Z",
8
+ "timestamp": "2026-05-06T11:00:18.264Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.7.103",
22
+ "bom-ref": "@blamejs/core@0.7.104",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.7.103",
25
+ "version": "0.7.104",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.7.103",
29
+ "purl": "pkg:npm/%40blamejs/core@0.7.104",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.7.103",
57
+ "ref": "@blamejs/core@0.7.104",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]