@blamejs/core 0.9.46 → 0.10.1

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +951 -893
  2. package/index.js +30 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +67 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/compliance.js +176 -8
  25. package/lib/crypto-field.js +114 -14
  26. package/lib/crypto.js +216 -20
  27. package/lib/db.js +1 -0
  28. package/lib/guard-imap-command.js +335 -0
  29. package/lib/guard-jmap.js +321 -0
  30. package/lib/guard-managesieve-command.js +566 -0
  31. package/lib/guard-pop3-command.js +317 -0
  32. package/lib/guard-smtp-command.js +58 -3
  33. package/lib/mail-agent.js +20 -7
  34. package/lib/mail-arc-sign.js +12 -8
  35. package/lib/mail-auth.js +323 -34
  36. package/lib/mail-crypto-pgp.js +934 -0
  37. package/lib/mail-crypto-smime.js +340 -0
  38. package/lib/mail-crypto.js +108 -0
  39. package/lib/mail-dav.js +1224 -0
  40. package/lib/mail-deploy.js +492 -0
  41. package/lib/mail-dkim.js +431 -26
  42. package/lib/mail-journal.js +435 -0
  43. package/lib/mail-scan.js +502 -0
  44. package/lib/mail-server-imap.js +1102 -0
  45. package/lib/mail-server-jmap.js +488 -0
  46. package/lib/mail-server-managesieve.js +853 -0
  47. package/lib/mail-server-mx.js +164 -34
  48. package/lib/mail-server-pop3.js +836 -0
  49. package/lib/mail-server-rate-limit.js +269 -0
  50. package/lib/mail-server-submission.js +1032 -0
  51. package/lib/mail-server-tls.js +445 -0
  52. package/lib/mail-sieve.js +557 -0
  53. package/lib/mail-spam-score.js +284 -0
  54. package/lib/mail.js +99 -0
  55. package/lib/metrics.js +130 -10
  56. package/lib/middleware/dpop.js +58 -3
  57. package/lib/middleware/idempotency-key.js +255 -42
  58. package/lib/middleware/protected-resource-metadata.js +114 -2
  59. package/lib/network-dns-resolver.js +33 -0
  60. package/lib/network-tls.js +46 -0
  61. package/lib/outbox.js +62 -12
  62. package/lib/pqc-agent.js +13 -5
  63. package/lib/retry.js +23 -9
  64. package/lib/router.js +23 -1
  65. package/lib/safe-ical.js +634 -0
  66. package/lib/safe-icap.js +502 -0
  67. package/lib/safe-mime.js +15 -0
  68. package/lib/safe-sieve.js +684 -0
  69. package/lib/safe-smtp.js +57 -0
  70. package/lib/safe-url.js +37 -0
  71. package/lib/safe-vcard.js +473 -0
  72. package/lib/self-update-standalone-verifier.js +32 -3
  73. package/lib/self-update.js +168 -17
  74. package/lib/vendor/MANIFEST.json +161 -156
  75. package/lib/vendor-data.js +127 -9
  76. package/lib/vex.js +324 -59
  77. package/package.json +1 -1
  78. package/sbom.cdx.json +6 -6
package/lib/agent-saga.js CHANGED
@@ -105,31 +105,119 @@ var SAGA_ID_RAND_BYTES = 8;
105
105
  function create(config) {
106
106
  guardSagaConfig.validate(config);
107
107
  var auditImpl = config.audit || audit();
108
+ // SUBSTRATE-7 — operator wires a stateStore for crash-safe resume.
109
+ // Interface: { saveStep, loadResumePoint, markCompleted, markFailed }.
110
+ // saveStep({sagaId, stepIndex, stepName, state, status}) commits
111
+ // after each step.run; loadResumePoint(sagaId) returns the resume
112
+ // shape `{ stepIndex, state }` on restart. Without a stateStore, the
113
+ // saga still runs end-to-end in-memory but a mid-saga crash loses
114
+ // progress (operator-acknowledged dev mode; the audit emit
115
+ // `agent.saga.no_state_store` surfaces the posture per call).
116
+ var stateStore = config.stateStore || null;
117
+ if (stateStore !== null) {
118
+ if (typeof stateStore.saveStep !== "function" ||
119
+ typeof stateStore.loadResumePoint !== "function") {
120
+ throw new AgentSagaError("agent-saga/bad-state-store",
121
+ "create: stateStore must expose { saveStep, loadResumePoint, markCompleted?, markFailed? }");
122
+ }
123
+ }
108
124
  return {
109
- run: function (ctx, initialState, opts) { return _run(config, auditImpl, ctx, initialState, opts || {}); },
125
+ run: function (ctx, initialState, opts) { return _run(config, auditImpl, stateStore, ctx, initialState, opts || {}); },
126
+ resume: function (sagaId, ctx, opts) { return _resume(config, auditImpl, stateStore, sagaId, ctx, opts || {}); },
110
127
  name: config.name,
111
128
  stepCount: config.steps.length,
112
129
  AgentSagaError: AgentSagaError,
113
130
  };
114
131
  }
115
132
 
116
- async function _run(config, auditImpl, ctx, initialState, opts) {
133
+ async function _resume(config, auditImpl, stateStore, sagaId, ctx, opts) {
134
+ if (!stateStore) {
135
+ throw new AgentSagaError("agent-saga/no-state-store",
136
+ "resume: stateStore not wired at create(); cannot resume without persisted state");
137
+ }
138
+ if (typeof sagaId !== "string" || sagaId.length === 0) {
139
+ throw new AgentSagaError("agent-saga/bad-saga-id",
140
+ "resume: sagaId required");
141
+ }
142
+ var resumePoint = await stateStore.loadResumePoint(sagaId);
143
+ if (!resumePoint || typeof resumePoint.stepIndex !== "number") {
144
+ throw new AgentSagaError("agent-saga/not-found",
145
+ "resume: no resume point for saga '" + sagaId + "'");
146
+ }
147
+ return _runFrom(config, auditImpl, stateStore, ctx,
148
+ resumePoint.state || {}, opts, sagaId, resumePoint.stepIndex);
149
+ }
150
+
151
+ async function _run(config, auditImpl, stateStore, ctx, initialState, opts) {
117
152
  var sagaId = opts.sagaId || "saga-" + bCrypto.generateToken(SAGA_ID_RAND_BYTES);
118
- var state = Object.assign({}, initialState || {});
153
+ return _runFrom(config, auditImpl, stateStore, ctx,
154
+ Object.assign({}, initialState || {}), opts, sagaId, 0);
155
+ }
156
+
157
+ async function _runFrom(config, auditImpl, stateStore, ctx, state, opts, sagaId, startIndex) {
158
+ // completedSteps captures index + step reference. On resume we
159
+ // start mid-saga; prior steps are already committed and don't need
160
+ // compensation on a failure in this run (compensation cascades
161
+ // walked persistent state to find the prior completed set).
119
162
  var completedSteps = [];
120
163
 
121
- agentAudit.safeAudit(auditImpl, "agent.saga.started", opts.actor, {
122
- sagaId: sagaId, name: config.name, stepCount: config.steps.length,
123
- });
164
+ if (startIndex === 0) {
165
+ agentAudit.safeAudit(auditImpl, "agent.saga.started", opts.actor, {
166
+ sagaId: sagaId, name: config.name, stepCount: config.steps.length,
167
+ });
168
+ if (!stateStore) {
169
+ agentAudit.safeAudit(auditImpl, "agent.saga.no_state_store", opts.actor, {
170
+ sagaId: sagaId, name: config.name,
171
+ warning: "no stateStore wired; mid-saga crash will lose progress",
172
+ });
173
+ }
174
+ } else {
175
+ agentAudit.safeAudit(auditImpl, "agent.saga.resumed", opts.actor, {
176
+ sagaId: sagaId, name: config.name, fromIndex: startIndex,
177
+ });
178
+ }
124
179
 
125
- for (var i = 0; i < config.steps.length; i += 1) {
180
+ for (var i = startIndex; i < config.steps.length; i += 1) {
126
181
  var step = config.steps[i];
127
182
  try {
128
183
  agentAudit.safeAudit(auditImpl, "agent.saga.step_started", opts.actor, {
129
184
  sagaId: sagaId, name: config.name, stepName: step.name, stepIndex: i,
130
185
  });
131
186
  await step.run(ctx, state);
132
- completedSteps.push(step);
187
+ completedSteps.push({ step: step, index: i });
188
+ // SUBSTRATE-7 — checkpoint after the step.run returns. saveStep
189
+ // commits the post-step state so a crash before the NEXT step
190
+ // resumes from i+1. The audit chain records the checkpoint
191
+ // independently of the operator's stateStore — operator can
192
+ // cross-correlate.
193
+ if (stateStore) {
194
+ try {
195
+ await stateStore.saveStep({
196
+ sagaId: sagaId,
197
+ sagaName: config.name,
198
+ stepIndex: i,
199
+ stepName: step.name,
200
+ state: state,
201
+ status: "completed",
202
+ nextIndex: i + 1,
203
+ checkpointedAt: Date.now(),
204
+ });
205
+ } catch (storeErr) {
206
+ agentAudit.safeAudit(auditImpl, "agent.saga.checkpoint_failed", opts.actor, {
207
+ sagaId: sagaId, name: config.name, stepName: step.name,
208
+ stepIndex: i, reason: (storeErr && storeErr.message) || String(storeErr),
209
+ });
210
+ // saveStep failure is fatal — without the checkpoint the
211
+ // saga cannot resume. Treat as step failure (compensate +
212
+ // throw); the operator's stateStore quota / disk / network
213
+ // outage surfaces here, not silently.
214
+ var ckptErr = new AgentSagaError("agent-saga/checkpoint-failed",
215
+ "saga '" + config.name + "' checkpoint after step '" + step.name +
216
+ "' failed: " + ((storeErr && storeErr.message) || String(storeErr)));
217
+ ckptErr.cause = storeErr;
218
+ throw ckptErr;
219
+ }
220
+ }
133
221
  agentAudit.safeAudit(auditImpl, "agent.saga.step_completed", opts.actor, {
134
222
  sagaId: sagaId, name: config.name, stepName: step.name, stepIndex: i,
135
223
  });
@@ -140,8 +228,15 @@ async function _run(config, auditImpl, ctx, initialState, opts) {
140
228
  message: (stepErr && stepErr.message) || String(stepErr),
141
229
  });
142
230
  var compensationError = null;
231
+ // BUG-5 — capture the compensation step that ACTUALLY failed,
232
+ // not "completedSteps[completedSteps.length-1].name" which
233
+ // names the last-COMPLETED step regardless of which compensation
234
+ // threw. CWE-209-adjacent (information disclosure via wrong
235
+ // error attribution).
236
+ var failedCompStepName = null;
143
237
  for (var c = completedSteps.length - 1; c >= 0; c -= 1) {
144
- var compStep = completedSteps[c];
238
+ var compEntry = completedSteps[c];
239
+ var compStep = compEntry.step;
145
240
  if (typeof compStep.compensate !== "function") continue;
146
241
  try {
147
242
  agentAudit.safeAudit(auditImpl, "agent.saga.compensation_started", opts.actor, {
@@ -156,6 +251,7 @@ async function _run(config, auditImpl, ctx, initialState, opts) {
156
251
  // Halt further compensations; record what failed so audit
157
252
  // pipeline can alert.
158
253
  compensationError = compErr;
254
+ failedCompStepName = compStep.name;
159
255
  agentAudit.safeAudit(auditImpl, "agent.saga.compensation_failed", opts.actor, {
160
256
  sagaId: sagaId, name: config.name, stepName: compStep.name,
161
257
  message: (compErr && compErr.message) || String(compErr),
@@ -166,16 +262,50 @@ async function _run(config, auditImpl, ctx, initialState, opts) {
166
262
  agentAudit.safeAudit(auditImpl, "agent.saga.failed", opts.actor, {
167
263
  sagaId: sagaId, name: config.name, failedStep: step.name,
168
264
  compensationFailed: compensationError !== null,
265
+ compensationFailedAt: failedCompStepName,
169
266
  });
170
- throw new AgentSagaError("agent-saga/failed",
171
- "saga '" + config.name + "' failed at step '" + step.name + "': " +
172
- ((stepErr && stepErr.message) || String(stepErr)) +
173
- (compensationError ? " — and compensation '" + completedSteps[completedSteps.length - 1].name +
174
- "' subsequently failed: " +
175
- (compensationError.message || String(compensationError))
176
- : ""));
267
+ if (stateStore && typeof stateStore.markFailed === "function") {
268
+ try {
269
+ await stateStore.markFailed({
270
+ sagaId: sagaId, sagaName: config.name,
271
+ failedStep: step.name, stepIndex: i,
272
+ compensationFailedAt: failedCompStepName,
273
+ state: state,
274
+ });
275
+ } catch (_e) { /* drop-silent — audit already records */ }
276
+ }
277
+ // SUBSTRATE-15 — attach cause:stepErr so the original step
278
+ // error stack survives. ES2022 Error.cause is the standard
279
+ // mechanism; the framework's defineClass-built AgentSagaError
280
+ // accepts cause via the third arg.
281
+ var detailMsg = "saga '" + config.name + "' failed at step '" + step.name + "': " +
282
+ ((stepErr && stepErr.message) || String(stepErr));
283
+ if (compensationError && failedCompStepName) {
284
+ detailMsg += " — and compensation of step '" + failedCompStepName +
285
+ "' subsequently failed: " +
286
+ ((compensationError.message) || String(compensationError));
287
+ }
288
+ var sagaErr = new AgentSagaError("agent-saga/failed", detailMsg);
289
+ // SUBSTRATE-15 — ES2022 Error.cause attaches the originating
290
+ // stepErr so operator stack-trace tooling can walk the chain.
291
+ // defineClass({alwaysPermanent:true}) doesn't accept cause in
292
+ // its constructor signature; the property assignment after
293
+ // construction is the standard post-instantiation pattern.
294
+ sagaErr.cause = stepErr;
295
+ sagaErr.compensationCause = compensationError || null;
296
+ sagaErr.failedStep = step.name;
297
+ sagaErr.failedCompStepName = failedCompStepName;
298
+ throw sagaErr;
177
299
  }
178
300
  }
301
+ if (stateStore && typeof stateStore.markCompleted === "function") {
302
+ try {
303
+ await stateStore.markCompleted({
304
+ sagaId: sagaId, sagaName: config.name,
305
+ stepCount: config.steps.length, state: state,
306
+ });
307
+ } catch (_e) { /* drop-silent — audit records */ }
308
+ }
179
309
  agentAudit.safeAudit(auditImpl, "agent.saga.completed", opts.actor, {
180
310
  sagaId: sagaId, name: config.name, stepCount: config.steps.length,
181
311
  });
@@ -52,11 +52,21 @@ var { defineClass } = require("./framework-error");
52
52
  var bCrypto = require("./crypto");
53
53
  var guardSnapshotEnvelope = require("./guard-snapshot-envelope");
54
54
  var agentAudit = require("./agent-audit");
55
+ var safeJson = require("./safe-json");
55
56
 
56
57
  var audit = lazyRequire(function () { return require("./audit"); });
58
+ var auditSign = lazyRequire(function () { return require("./audit-sign"); });
59
+ var vault = lazyRequire(function () { return require("./vault"); });
57
60
 
58
61
  var AgentSnapshotError = defineClass("AgentSnapshotError", { alwaysPermanent: true });
59
62
 
63
+ // SUBSTRATE-2 — sealed envelopes start with this prefix on disk; the
64
+ // loader sniffs it and routes through unseal before guardSnapshotEnvelope
65
+ // validation. Compatible with operator backends that store the value
66
+ // as a string (JSON DBs, k/v stores) or wrap it in `{ value: "..." }`.
67
+ var SEALED_PREFIX = "snap-sealed-v1:";
68
+ var SNAPSHOT_TABLE = "agent.snapshot";
69
+
60
70
  var DEFAULT_DRAIN_TIMEOUT_MS = C.TIME.minutes(2);
61
71
  var DEFAULT_SNAPSHOT_INTERVAL_MS = C.TIME.minutes(5);
62
72
  var DEFAULT_MAX_SNAPSHOT_BYTES = C.BYTES.mib(50);
@@ -103,6 +113,28 @@ function create(opts) {
103
113
  var snapshotIntervalMs = typeof policy.snapshotIntervalMs === "number" ? policy.snapshotIntervalMs : DEFAULT_SNAPSHOT_INTERVAL_MS;
104
114
  var maxSnapshotBytes = typeof policy.maxSnapshotBytes === "number" ? policy.maxSnapshotBytes : DEFAULT_MAX_SNAPSHOT_BYTES;
105
115
  var auditImpl = opts.audit || audit();
116
+ // SUBSTRATE-1 — operator may inject `signer` (interface
117
+ // `{ sign(bytes) → Buffer, verify(bytes, sig, pubKey?) → boolean }`)
118
+ // for testing / alternate key custody. Default = b.auditSign when
119
+ // initialized at boot; refuses persist() with a clear error if
120
+ // neither is wired so secure-by-default holds.
121
+ var signer = opts.signer || null;
122
+ // SUBSTRATE-2 — operator may inject `sealer` (interface
123
+ // `{ seal(plaintext, aadParts) → string, unseal(value, aadParts) → string }`)
124
+ // for alternate KMS integration. Default = b.vault.aad. Refused if
125
+ // neither is wired AND opts.allowPlaintext is not explicitly true
126
+ // (operator-justified dev / single-tenant deployments only).
127
+ var sealer = opts.sealer || null;
128
+ // SUBSTRATE-18 — operator-supplied restoreHandlers walk the
129
+ // snapshot inFlight + idempotencyCache + orchestratorState segments
130
+ // and hydrate the corresponding consumer module. Map shape:
131
+ // { streams, sagas, outboxJobs, busSubscribers, pendingDeliveries,
132
+ // idempotencyCache, orchestratorState }
133
+ // Each is an async function(payload, ctx). Missing keys are no-ops.
134
+ var restoreHandlers = opts.restoreHandlers && typeof opts.restoreHandlers === "object"
135
+ ? opts.restoreHandlers : null;
136
+
137
+ var allowPlaintext = opts.allowPlaintext === true;
106
138
 
107
139
  var ctx = {
108
140
  orchestrator: opts.orchestrator,
@@ -111,6 +143,10 @@ function create(opts) {
111
143
  drainTimeoutMs: drainTimeoutMs,
112
144
  snapshotIntervalMs: snapshotIntervalMs,
113
145
  maxSnapshotBytes: maxSnapshotBytes,
146
+ signer: signer,
147
+ sealer: sealer,
148
+ restoreHandlers: restoreHandlers,
149
+ allowPlaintext: allowPlaintext,
114
150
  };
115
151
 
116
152
  return {
@@ -121,11 +157,97 @@ function create(opts) {
121
157
  restore: function (snap, restoreOpts) { return _restore(ctx, snap, restoreOpts || {}); },
122
158
  list: function (listOpts) { return _list(ctx, listOpts || {}); },
123
159
  gc: function (gcOpts) { return _gc(ctx, gcOpts || {}); },
124
- SCHEMA_VERSION: SCHEMA_VERSION,
125
- AgentSnapshotError: AgentSnapshotError,
160
+ SCHEMA_VERSION: SCHEMA_VERSION,
161
+ SEALED_PREFIX: SEALED_PREFIX,
162
+ AgentSnapshotError: AgentSnapshotError,
163
+ };
164
+ }
165
+
166
+ // ---- Signer + sealer resolution -------------------------------------------
167
+
168
+ function _resolveSigner(ctx) {
169
+ if (ctx.signer) return ctx.signer;
170
+ var as;
171
+ try { as = auditSign(); } catch (_e) { as = null; }
172
+ if (as && typeof as.sign === "function" && typeof as.verify === "function") {
173
+ // b.auditSign.sign throws "auditSign/not-initialized" when called
174
+ // pre-init — surface that here as the snapshot's signer-not-wired
175
+ // error so the caller's message is consistent regardless of which
176
+ // dependency landed unwired.
177
+ return {
178
+ sign: function (bytes) {
179
+ try { return as.sign(bytes); }
180
+ catch (e) {
181
+ throw new AgentSnapshotError("agent-snapshot/signer-not-wired",
182
+ "persist: b.auditSign.sign threw (" + (e && e.message ? e.message : String(e)) +
183
+ ") — operator must run b.auditSign.init() at boot OR pass opts.signer to b.agent.snapshot.create");
184
+ }
185
+ },
186
+ verify: function (bytes, sig, pubKey) {
187
+ try { return as.verify(bytes, sig, pubKey); }
188
+ catch (_e) { return false; }
189
+ },
190
+ getPublicKey: function () {
191
+ try { return as.getPublicKey(); } catch (_e) { return null; }
192
+ },
193
+ };
194
+ }
195
+ throw new AgentSnapshotError("agent-snapshot/signer-not-wired",
196
+ "persist: no signer wired — operator must run b.auditSign.init() at boot " +
197
+ "OR pass opts.signer to b.agent.snapshot.create({ signer: { sign, verify } })");
198
+ }
199
+
200
+ function _resolveSealer(ctx) {
201
+ if (ctx.sealer) return ctx.sealer;
202
+ var v;
203
+ try { v = vault(); } catch (_e) { v = null; }
204
+ if (v && v.aad && typeof v.aad.seal === "function" && typeof v.aad.unseal === "function") {
205
+ return v.aad;
206
+ }
207
+ if (ctx.allowPlaintext) return null;
208
+ throw new AgentSnapshotError("agent-snapshot/sealer-not-wired",
209
+ "persist: no sealer wired — operator must run b.vault.init() at boot " +
210
+ "OR pass opts.sealer to b.agent.snapshot.create({ sealer: { seal, unseal } }) " +
211
+ "OR opt out explicitly with { allowPlaintext: true } (refused under hipaa/pci-dss/gdpr/soc2 postures)");
212
+ }
213
+
214
+ function _snapshotAad(snap) {
215
+ return {
216
+ table: SNAPSHOT_TABLE,
217
+ rowId: snap.snapshotId,
218
+ column: "envelope",
219
+ schemaVersion: String(snap.schemaVersion || SCHEMA_VERSION),
126
220
  };
127
221
  }
128
222
 
223
+ // Signable content — every field that operators verify off the wire.
224
+ // Excludes `sig` itself (signatures don't sign themselves) and
225
+ // excludes the schemaless `idempotencyCache` body (size + structure
226
+ // already covered by the seal's AEAD tag).
227
+ function _canonicalSigBytes(snap) {
228
+ var payload = {
229
+ snapshotId: snap.snapshotId,
230
+ takenAt: snap.takenAt,
231
+ frameworkVersion: snap.frameworkVersion,
232
+ schemaVersion: snap.schemaVersion,
233
+ tenantId: snap.tenantId || null,
234
+ contentHash: _contentHash(snap),
235
+ };
236
+ return Buffer.from(safeJson.canonical(payload), "utf8");
237
+ }
238
+
239
+ function _contentHash(snap) {
240
+ // Bind the signature to the in-flight payload via SHA3-512 so the
241
+ // signed bytes stay bounded (the 5 KB SLH-DSA / 3.3 KB ML-DSA-65
242
+ // signature shouldn't have to cover a 50 MiB envelope's payload).
243
+ var body = {
244
+ orchestratorState: snap.orchestratorState || {},
245
+ inFlight: snap.inFlight || {},
246
+ idempotencyCache: snap.idempotencyCache || {},
247
+ };
248
+ return bCrypto.sha3Hash(safeJson.canonical(body));
249
+ }
250
+
129
251
  // ---- Take snapshot --------------------------------------------------------
130
252
 
131
253
  async function _takeSnapshot(ctx, snapshotOpts) {
@@ -150,7 +272,12 @@ async function _takeSnapshot(ctx, snapshotOpts) {
150
272
  pendingDeliveries: snapshotOpts.pendingDeliveries || [],
151
273
  },
152
274
  idempotencyCache: snapshotOpts.idempotencyCache || {},
153
- sig: null, // populated by persist() via b.audit-sign
275
+ // sig + sigPubKey populated by persist() via b.audit-sign. The
276
+ // wire envelope MAY ship with sig:null pre-persist (operator
277
+ // wants to inspect the bytes before commit); guardSnapshotEnvelope
278
+ // doesn't enforce sig presence (loader does).
279
+ sig: null,
280
+ sigPubKey: null,
154
281
  };
155
282
  guardSnapshotEnvelope.validate(envelope, { profile: "strict" });
156
283
  // Enforce per-instance maxSnapshotBytes (separate from guard's
@@ -173,20 +300,150 @@ async function _takeSnapshot(ctx, snapshotOpts) {
173
300
 
174
301
  async function _persist(ctx, snap) {
175
302
  guardSnapshotEnvelope.validate(snap);
176
- // Operator's backend stores the envelope by snapshotId.
177
- await ctx.backend.put(snap.snapshotId, snap);
303
+ // SUBSTRATE-1 — sign first so a backend that mutates on put() (very
304
+ // common for k/v stores adding metadata) doesn't poison the signed
305
+ // bytes downstream readers verify.
306
+ var signer = _resolveSigner(ctx);
307
+ var sigBytes = signer.sign(_canonicalSigBytes(snap));
308
+ snap.sig = sigBytes.toString("base64");
309
+ // Persist a fingerprint alongside the signature so loadLatest can
310
+ // reject stale-key signatures (operator rotated audit-sign keys
311
+ // after the snapshot was taken). _resolveSigner exposes
312
+ // getPublicKey when wired off b.auditSign; operator-supplied signers
313
+ // may set null which we accept (verify falls back to the bound
314
+ // pubkey at verify time).
315
+ snap.sigPubKey = (typeof signer.getPublicKey === "function" && signer.getPublicKey()) || null;
316
+
317
+ // SUBSTRATE-2 — seal the entire envelope under AAD that pins
318
+ // snapshotId + schemaVersion + tenantId. AAD mismatch on unseal (a
319
+ // copy-paste attack from one snapshotId's row into another) fails
320
+ // the Poly1305 tag check; tampered bytes also fail. The sealed
321
+ // string is what reaches durable storage.
322
+ var sealer = _resolveSealer(ctx);
323
+ var serialized = safeJson.stringify(snap);
324
+ if (Buffer.byteLength(serialized, "utf8") > ctx.maxSnapshotBytes) {
325
+ throw new AgentSnapshotError("agent-snapshot/oversize",
326
+ "persist: " + Buffer.byteLength(serialized, "utf8") +
327
+ " bytes exceeds maxSnapshotBytes=" + ctx.maxSnapshotBytes);
328
+ }
329
+ var stored;
330
+ if (sealer) {
331
+ var sealedBlob = sealer.seal(serialized, _snapshotAad(snap));
332
+ // Wrapper keeps the unsealed metadata fields the backend's list()
333
+ // implementation needs to filter by tenantId / takenAt without
334
+ // having to unseal every row. Sealed-blob carries the full
335
+ // envelope; the metadata is decorative + may be tamper-fuzzed by
336
+ // a hostile backend (the AEAD tag still binds via AAD on unseal).
337
+ stored = {
338
+ snapshotId: snap.snapshotId,
339
+ takenAt: snap.takenAt,
340
+ tenantId: snap.tenantId || null,
341
+ sealed: SEALED_PREFIX + sealedBlob,
342
+ };
343
+ } else {
344
+ // ctx.allowPlaintext === true path — operator-acknowledged dev
345
+ // mode. Still emit an audit so the operational posture is visible
346
+ // in the audit chain (operator can grep for plaintext snapshots
347
+ // in production audit feeds and confirm none exist).
348
+ agentAudit.safeAudit(ctx.audit, "agent.snapshot.plaintext_persist", null, {
349
+ snapshotId: snap.snapshotId,
350
+ });
351
+ stored = snap;
352
+ }
353
+ await ctx.backend.put(snap.snapshotId, stored);
178
354
  agentAudit.safeAudit(ctx.audit, "agent.snapshot.persisted", null, {
179
355
  snapshotId: snap.snapshotId, takenAt: snap.takenAt,
356
+ signed: true, sealed: !!sealer,
180
357
  });
181
358
  return { snapshotId: snap.snapshotId };
182
359
  }
183
360
 
184
361
  // ---- Load -----------------------------------------------------------------
185
362
 
363
+ async function _unwrapAndVerify(ctx, raw, expectedId) {
364
+ if (!raw) return null;
365
+ var snap;
366
+ if (raw.sealed && typeof raw.sealed === "string" && raw.sealed.indexOf(SEALED_PREFIX) === 0) {
367
+ var sealer = _resolveSealer(ctx);
368
+ if (!sealer) {
369
+ throw new AgentSnapshotError("agent-snapshot/sealer-not-wired",
370
+ "load: snapshot " + raw.snapshotId + " is sealed but no sealer wired");
371
+ }
372
+ var sealedBlob = raw.sealed.slice(SEALED_PREFIX.length);
373
+ var aad = {
374
+ table: SNAPSHOT_TABLE,
375
+ rowId: raw.snapshotId,
376
+ column: "envelope",
377
+ // schemaVersion is rebuilt at the same point load reads it; the
378
+ // wrapper carries it explicitly so a sealed envelope written
379
+ // under SCHEMA_VERSION=1 still unseals when the framework
380
+ // bumps to 2 later (the restore path then fires the
381
+ // allowSchemaVersionMismatch gate).
382
+ schemaVersion: String(raw.schemaVersion || SCHEMA_VERSION),
383
+ };
384
+ var plaintext;
385
+ try { plaintext = sealer.unseal(sealedBlob, aad); }
386
+ catch (e) {
387
+ agentAudit.safeAudit(ctx.audit, "agent.snapshot.unseal_failed", null, {
388
+ snapshotId: raw.snapshotId, reason: (e && e.message) || String(e),
389
+ });
390
+ throw new AgentSnapshotError("agent-snapshot/unseal-failed",
391
+ "load: snapshot " + raw.snapshotId + " unseal failed — value may be tampered, " +
392
+ "copied from a different snapshotId, or sealed under a different vault keypair");
393
+ }
394
+ snap = safeJson.parse(plaintext, { maxBytes: ctx.maxSnapshotBytes });
395
+ } else {
396
+ snap = raw;
397
+ }
398
+ guardSnapshotEnvelope.validate(snap);
399
+ if (expectedId && snap.snapshotId !== expectedId) {
400
+ // Wrapper carried snapshotId 'A' but the sealed body unsealed to
401
+ // snapshotId 'B' — defends a hostile backend that swaps wrapper
402
+ // metadata while AAD still matches the inner id (the AAD is built
403
+ // from `raw.snapshotId`, so the unseal would fail anyway, but
404
+ // surface explicitly).
405
+ throw new AgentSnapshotError("agent-snapshot/snapshot-id-mismatch",
406
+ "load: wrapper snapshotId='" + expectedId + "' does not match envelope='" + snap.snapshotId + "'");
407
+ }
408
+ // SUBSTRATE-1 — verify the signature before returning the envelope
409
+ // to the caller. Restore-side trust derives from this gate. The
410
+ // allowPlaintext escape hatch (operator-acknowledged dev mode)
411
+ // also waives signature verification because there's no key custody
412
+ // wired to verify against. Audit-emits so the operational posture
413
+ // remains visible to compliance audit.
414
+ if (typeof snap.sig !== "string" || snap.sig.length === 0) {
415
+ if (ctx.allowPlaintext) {
416
+ agentAudit.safeAudit(ctx.audit, "agent.snapshot.unsigned_load", null, {
417
+ snapshotId: snap.snapshotId,
418
+ });
419
+ return snap;
420
+ }
421
+ throw new AgentSnapshotError("agent-snapshot/unsigned",
422
+ "load: snapshot " + snap.snapshotId + " is unsigned — refusing to restore");
423
+ }
424
+ var signer = _resolveSigner(ctx);
425
+ var sigBuf = Buffer.from(snap.sig, "base64");
426
+ var ok = false;
427
+ try {
428
+ ok = signer.verify(_canonicalSigBytes(snap), sigBuf, snap.sigPubKey || undefined);
429
+ } catch (_e) { ok = false; }
430
+ if (!ok) {
431
+ agentAudit.safeAudit(ctx.audit, "agent.snapshot.signature_invalid", null, {
432
+ snapshotId: snap.snapshotId,
433
+ });
434
+ throw new AgentSnapshotError("agent-snapshot/bad-signature",
435
+ "load: snapshot " + snap.snapshotId + " signature verify failed — " +
436
+ "may be tampered or signed under a key the current verifier doesn't trust");
437
+ }
438
+ return snap;
439
+ }
440
+
186
441
  async function _loadLatest(ctx, loadOpts) {
187
442
  var entries = await ctx.backend.list();
188
443
  if (!Array.isArray(entries) || entries.length === 0) return null;
189
- // Filter by tenantId if requested.
444
+ // Filter by tenantId if requested. Sealed entries carry tenantId in
445
+ // the wrapper for cheap-index filtering; the inner sealed body
446
+ // confirms via AAD on unseal.
190
447
  var filtered = entries.filter(function (e) {
191
448
  if (loadOpts.tenantId && e.tenantId !== loadOpts.tenantId) return false;
192
449
  return true;
@@ -194,10 +451,8 @@ async function _loadLatest(ctx, loadOpts) {
194
451
  if (filtered.length === 0) return null;
195
452
  filtered.sort(function (a, b) { return (b.takenAt || 0) - (a.takenAt || 0); });
196
453
  var latestId = filtered[0].snapshotId;
197
- var snap = await ctx.backend.get(latestId);
198
- if (!snap) return null;
199
- guardSnapshotEnvelope.validate(snap);
200
- return snap;
454
+ var raw = await ctx.backend.get(latestId);
455
+ return await _unwrapAndVerify(ctx, raw, latestId);
201
456
  }
202
457
 
203
458
  async function _loadById(ctx, snapshotId) {
@@ -205,10 +460,8 @@ async function _loadById(ctx, snapshotId) {
205
460
  throw new AgentSnapshotError("agent-snapshot/bad-snapshot-id",
206
461
  "loadById: snapshotId required");
207
462
  }
208
- var snap = await ctx.backend.get(snapshotId);
209
- if (!snap) return null;
210
- guardSnapshotEnvelope.validate(snap);
211
- return snap;
463
+ var raw = await ctx.backend.get(snapshotId);
464
+ return await _unwrapAndVerify(ctx, raw, snapshotId);
212
465
  }
213
466
 
214
467
  // ---- Restore --------------------------------------------------------------
@@ -243,17 +496,94 @@ async function _restore(ctx, snap, restoreOpts) {
243
496
  affectedStreams: (snap.inFlight && snap.inFlight.streams || []).length,
244
497
  });
245
498
  }
246
- // Restore is a SIGNAL orchestrator + idempotency + saga + event-
247
- // bus consumers see the envelope and hydrate themselves. v0.9.30
248
- // ships the contract; each substrate primitive's restore hook lands
249
- // in subsequent slices as operators wire them.
499
+ // SUBSTRATE-18invoke operator-supplied restoreHandlers across
500
+ // every segment the snapshot envelope carries. Handlers are
501
+ // declared at create() time; the snapshot primitive owns ordering
502
+ // (orchestratorState first so live agents register before consumers
503
+ // start, idempotencyCache before saga/stream so duplicate-detect
504
+ // works, in-flight saga/stream/outbox/bus last) and the audit
505
+ // emits. Each handler returns a count of restored items.
506
+ //
507
+ // Spec order — 8 documented steps:
508
+ // 1. orchestratorState (re-elect singletons + topology re-register)
509
+ // 2. idempotencyCache (hot subset of the keys)
510
+ // 3. inFlight.sagas (resume from persisted step pointer)
511
+ // 4. inFlight.streams (re-open with lastSeenCursor)
512
+ // 5. inFlight.outboxJobs (re-enqueue any in-flight)
513
+ // 6. inFlight.busSubscribers (re-subscribe + replay pending)
514
+ // 7. inFlight.pendingDeliveries (drain the buffered events)
515
+ // 8. final audit emit + result summary
516
+ var counts = {
517
+ orchestratorState: 0,
518
+ idempotencyCache: 0,
519
+ sagas: 0,
520
+ streams: 0,
521
+ outboxJobs: 0,
522
+ busSubscribers: 0,
523
+ pendingDeliveries: 0,
524
+ };
525
+ var handlers = ctx.restoreHandlers;
526
+ var handlerCtx = { snapshotId: snap.snapshotId, takenAt: snap.takenAt, audit: ctx.audit };
527
+ if (handlers) {
528
+ counts.orchestratorState = await _runHandler(handlers.orchestratorState, snap.orchestratorState, handlerCtx);
529
+ counts.idempotencyCache = await _runHandler(handlers.idempotencyCache, snap.idempotencyCache, handlerCtx);
530
+ if (snap.inFlight) {
531
+ counts.sagas = await _runHandler(handlers.sagas, snap.inFlight.sagas, handlerCtx);
532
+ counts.streams = await _runHandler(handlers.streams, snap.inFlight.streams, handlerCtx);
533
+ counts.outboxJobs = await _runHandler(handlers.outboxJobs, snap.inFlight.outboxJobs, handlerCtx);
534
+ counts.busSubscribers = await _runHandler(handlers.busSubscribers, snap.inFlight.busSubscribers, handlerCtx);
535
+ counts.pendingDeliveries = await _runHandler(handlers.pendingDeliveries, snap.inFlight.pendingDeliveries, handlerCtx);
536
+ }
537
+ } else if (_inFlightCount(snap) > 0) {
538
+ // Per no-MVP rule: if the operator passed restoreOpts.requireHandlers
539
+ // OR the snapshot has in-flight items, refuse the silent no-op so
540
+ // an operator restarting with non-empty inFlight doesn't think
541
+ // restore worked when actually nothing happened.
542
+ if (restoreOpts.requireHandlers || _inFlightCount(snap) > 0) {
543
+ agentAudit.safeAudit(ctx.audit, "agent.snapshot.restore_skipped_no_handlers", null, {
544
+ snapshotId: snap.snapshotId, inFlightCount: _inFlightCount(snap),
545
+ });
546
+ if (restoreOpts.requireHandlers) {
547
+ throw new AgentSnapshotError("agent-snapshot/no-restore-handlers",
548
+ "restore: snapshot " + snap.snapshotId + " carries " + _inFlightCount(snap) +
549
+ " in-flight items but no restoreHandlers wired; pass " +
550
+ "create({ restoreHandlers: { ... } }) or restoreOpts.requireHandlers=false " +
551
+ "to acknowledge data drop");
552
+ }
553
+ }
554
+ }
250
555
  agentAudit.safeAudit(ctx.audit, "agent.snapshot.restored", null, {
251
556
  snapshotId: snap.snapshotId,
252
557
  schemaVersion: snap.schemaVersion,
253
558
  inFlightCount: _inFlightCount(snap),
254
559
  topologyChanged: topologyChanged,
560
+ counts: counts,
255
561
  });
256
- return { snapshotId: snap.snapshotId, topologyChanged: topologyChanged };
562
+ return {
563
+ snapshotId: snap.snapshotId,
564
+ topologyChanged: topologyChanged,
565
+ restored: counts,
566
+ };
567
+ }
568
+
569
+ async function _runHandler(handler, payload, handlerCtx) {
570
+ if (typeof handler !== "function") return 0;
571
+ if (payload === undefined || payload === null) return 0;
572
+ var r;
573
+ try { r = await handler(payload, handlerCtx); }
574
+ catch (e) {
575
+ agentAudit.safeAudit(handlerCtx.audit, "agent.snapshot.restore_handler_failed", null, {
576
+ snapshotId: handlerCtx.snapshotId,
577
+ reason: (e && e.message) || String(e),
578
+ });
579
+ throw new AgentSnapshotError("agent-snapshot/restore-handler-failed",
580
+ "restore: handler threw — " + ((e && e.message) || String(e)));
581
+ }
582
+ if (typeof r === "number" && r >= 0) return r;
583
+ // Handler that returns void is treated as 1-item processed (the
584
+ // payload itself); array payloads return their length.
585
+ if (Array.isArray(payload)) return payload.length;
586
+ return 1;
257
587
  }
258
588
 
259
589
  // ---- List + GC ------------------------------------------------------------