@blamejs/core 0.9.49 → 0.10.2
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 +952 -908
- package/index.js +25 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +78 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/cli.js +13 -0
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-graphql.js +37 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-regex.js +138 -1
- package/lib/guard-smtp-command.js +58 -3
- package/lib/guard-xml.js +39 -1
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +64 -26
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +40 -30
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +13 -0
- package/lib/mail-server-submission.js +70 -24
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +80 -3
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/otel-export.js +13 -4
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +153 -33
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
});
|
package/lib/agent-snapshot.js
CHANGED
|
@@ -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:
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
177
|
-
|
|
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
|
|
198
|
-
|
|
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
|
|
209
|
-
|
|
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
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
499
|
+
// SUBSTRATE-18 — invoke 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 {
|
|
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 ------------------------------------------------------------
|