Initial import of verae-zapier-simulator from zapier monorepo

This commit is contained in:
George Lambert 2026-09-11 14:24:10 -04:00
commit de6065edf3
10 changed files with 1637 additions and 0 deletions

882
src/pipeline.js Normal file
View file

@ -0,0 +1,882 @@
/**
* In-process Zapier zappier-edge middleware splitter chain / WORM / tree-nodes.
* Every hop is traced. Faults inject delay, drop, fail, then optional recovery.
* @module pipeline
*/
import { randomUUID } from 'node:crypto';
import { splitRequest } from '../../verae-request-splitter/src/splitRequest.js';
import { WormArchive } from '../../verae-archive-worm/src/archive.js';
import { aggregateAttached, mergeReceipts } from '../../verae-archive-aggregator/src/aggregate.js';
import {
sha256Hex,
buildMerkle,
merkleProof,
verifyProof,
shardIndex,
} from '../../verae-tree-node/src/merkle.js';
import { TraceBus } from './trace.js';
import { analyze } from './monitors.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const ACTIONS = [
{ key: 'signup', label: 'Sign up (zappier portal)', group: 'account' },
{ key: 'connect_zapier', label: 'Connect Zapier app (API key)', group: 'account' },
{ key: 'create_timestamp', label: 'Create Timestamp (async)', group: 'zap' },
{ key: 'timestamp_and_wait', label: 'Create Timestamp and Wait', group: 'zap' },
{ key: 'hash_lookup', label: 'Find Timestamp by SHA256 (central chain)', group: 'zap' },
{ key: 'tree_lookup', label: 'Find Hash (tree nodes + central)', group: 'zap' },
{ key: 'batch_timestamp', label: 'Create Batch Timestamps (Merkle summary)', group: 'zap' },
{ key: 'verify_timestamp', label: 'Verify Timestamp', group: 'zap' },
];
export class Simulator {
constructor() {
this.reset();
}
reset() {
this.trace = new TraceBus();
this.accounts = [];
this.connected = null;
this.chain = new Map(); // sha256 -> seal
this.jobs = new Map();
this.archives = [new WormArchive('archive-a'), new WormArchive('archive-b'), new WormArchive('archive-c')];
this.treeNodes = [
new WormArchive('tree-node-east'),
new WormArchive('tree-node-west'),
new WormArchive('tree-node-central'),
];
this.hooks = [];
this.meteredCents = 0;
}
snapshot() {
return {
accounts: this.accounts.map((a) => ({ email: a.email, plan: a.plan, apiKey: a.apiKey })),
connected: this.connected,
chainHashes: [...this.chain.keys()],
jobs: [...this.jobs.values()].map((j) => ({ jobId: j.jobId, sha256: j.sha256, status: j.status })),
meteredCents: this.meteredCents,
};
}
/**
* @param {{ action: string, input?: object, faults?: object, includeAttached?: boolean, includeTree?: boolean }} req
*/
async run(req) {
const action = req.action;
const input = req.input || {};
const faults = req.faults || {};
const includeAttached = Boolean(req.includeAttached ?? input.includeAttached);
const includeTree = Boolean(req.includeTree ?? input.includeTree);
this.trace = new TraceBus();
const t = this.trace;
t.emit({
module: 'zapier-platform-app',
hop: 'perform',
dir: 'in',
address: `zapier://action/${action}`,
payload: { action, input: redact(input) },
note: 'Zapier cloud perform(z, bundle). HTTPS only.',
});
try {
if (action === 'signup') {
const out = this.#signup(input);
return this.#finish(action, out, { includeAttached, includeTree });
}
if (action === 'connect_zapier') {
const out = this.#connect(input);
return this.#finish(action, out, { includeAttached, includeTree });
}
await this.#edgeAuth(faults);
await this.#meter(action, faults);
let response;
switch (action) {
case 'create_timestamp':
response = await this.#timestamp(input, { wait: false, includeAttached, faults });
break;
case 'timestamp_and_wait':
response = await this.#timestamp(input, { wait: true, includeAttached, faults });
break;
case 'hash_lookup':
response = await this.#lookup(input, { includeAttached, includeTree: false, faults });
break;
case 'tree_lookup':
response = await this.#lookup(input, { includeAttached: true, includeTree: true, faults });
break;
case 'batch_timestamp':
response = await this.#batch(input, { faults });
break;
case 'verify_timestamp':
response = await this.#verify(input);
break;
default:
throw Object.assign(new Error(`unknown action ${action}`), { status: 400 });
}
t.emit({
module: 'zapier-platform-app',
hop: 'output',
dir: 'out',
address: `zapier://action/${action}`,
payload: redact(response),
note: 'Returned to Zap step / task history',
});
return this.#finish(action, response, { includeAttached, includeTree });
} catch (err) {
const status = err.status || 500;
t.emit({
module: 'zapier-platform-app',
hop: 'error',
dir: 'out',
status: 'error',
payload: { status, error: err.message, code: err.code },
note: err.note || err.message,
});
return this.#finish(action, { error: err.message, status, code: err.code }, { includeAttached, includeTree });
}
}
#finish(action, response, flags) {
const monitor = analyze(this.trace.events, { action, ...flags });
return {
action,
response,
trace: this.trace.events,
monitor,
state: this.snapshot(),
};
}
#signup(input) {
const email = String(input.email || 'user@example.com');
const plan = input.plan || 'free';
const apiKey = `zpk_${randomUUID().replace(/-/g, '').slice(0, 24)}`;
this.accounts.push({ email, plan, apiKey, passwordSet: true });
this.trace.emit({
module: 'zappier-edge',
hop: 'portal_signup',
address: 'HTTPS POST /portal/signup',
payload: { email, plan, apiKey },
note: 'Customer portal creates account + API key. No NATS.',
});
return { email, plan, apiKey, next: 'Paste apiKey into Zapier app authentication (x-api-key).' };
}
#connect(input) {
const key = input.apiKey || this.accounts.at(-1)?.apiKey;
const acct = this.accounts.find((a) => a.apiKey === key);
if (!acct) {
this.trace.emit({
module: 'zappier-edge',
hop: 'auth',
status: 'error',
address: 'HTTPS GET /v1/whoami',
note: 'Unknown API key',
});
const err = new Error('Invalid API key');
err.status = 401;
err.code = 'UNAUTHENTICATED';
throw err;
}
this.connected = { email: acct.email, plan: acct.plan, apiKey: key };
this.trace.emit({
module: 'zapier-platform-app',
hop: 'test_auth',
address: 'HTTPS GET zappier-edge/v1/whoami',
payload: { email: acct.email, plan: acct.plan },
note: 'Zapier “Test authentication” — still HTTPS only.',
});
return { connected: true, email: acct.email, plan: acct.plan };
}
async #edgeAuth(faults) {
if (faults.failEdge401) {
this.trace.emit({
module: 'zappier-edge',
hop: 'auth',
status: 'error',
address: 'HTTPS',
payload: { status: 401 },
note: 'x-api-key rejected',
});
const err = new Error('unauthenticated');
err.status = 401;
throw err;
}
if (!this.connected) {
this.connected = { email: 'sim@local', plan: 'pro', apiKey: 'zpk_sim' };
}
this.trace.emit({
module: 'zappier-edge',
hop: 'auth',
address: 'HTTPS x-api-key',
payload: { plan: this.connected.plan },
});
}
async #meter(action, faults) {
if (faults.failEdge402) {
this.trace.emit({
module: 'zappier-edge',
hop: 'meter',
status: 'error',
address: 'HTTPS 402',
payload: { status: 402, upgradeUrl: 'https://zappier.example/portal/billing' },
note: '402 quota exceeded',
});
const err = new Error('Quota exceeded');
err.status = 402;
err.code = 'QuotaExceeded';
err.note = '402';
throw err;
}
const cents = action === 'batch_timestamp' ? 25 : 8;
this.meteredCents += cents;
this.trace.emit({
module: 'zappier-edge',
hop: 'meter',
address: 'HTTPS → middleware',
payload: { cents, stripeMeter: 'zappier.api_cents' },
note: 'Rate card × tier monthly credit',
});
}
async #timestamp(input, { wait, includeAttached, faults }) {
const t = this.trace;
t.emit({
module: 'middleware-http',
hop: wait ? 'POST /zapier/v1/timestamp/wait' : 'POST /zapier/v1/timestamp',
dir: 'in',
address: wait ? 'HTTPS /zapier/v1/timestamp/wait' : 'HTTPS /zapier/v1/timestamp',
payload: redact(input),
});
const split = splitRequest({
data: input.data,
sha256: input.sha256,
publicMetadata: input.publicMetadata,
privateMetadata: input.privateMetadata,
files: input.files,
includeAttached,
});
t.emit({
module: 'verae-request-splitter',
hop: 'splitRequest',
address: 'in-process',
payload: {
chain: split.chain,
archivePuts: split.archivePuts.map((p) => ({ kind: p.kind, sha256: p.sha256 })),
includeAttached: split.includeAttached,
},
note: 'Chain gets hash only. Files/meta → archive.put',
});
await this.#archivePuts(split.archivePuts, input.tenantId || 'tenant-sim', faults);
const existing = this.chain.get(split.chain.sha256);
let job;
if (existing) {
job = { ...existing, existing: true };
t.emit({
module: 'verae-chain-client',
hop: 'lookup',
address: 'HTTPS api.veraetime.net (MOCK)',
payload: { sha256: split.chain.sha256, jobId: existing.jobId },
note: 'Already sealed — original receipt, no second write',
});
} else {
await this.#maybeDelay(faults, 'chainMs', 'verae-chain-client');
if (faults.failChainTimeout && !faults.recover) {
t.emit({
module: 'verae-chain-client',
hop: 'create',
status: 'error',
address: 'HTTPS api.veraetime.net',
note: 'chain timeout',
});
const err = new Error('chain timeout');
err.status = 504;
throw err;
}
if (faults.failChainTimeout && faults.recover) {
t.emit({
module: 'verae-chain-client',
hop: 'create',
status: 'retry',
address: 'HTTPS api.veraetime.net',
note: 'timeout then retry succeeded',
});
}
job = this.#seal(split.chain.sha256);
t.emit({
module: 'verae-chain-client',
hop: 'create',
address: 'HTTPS api.veraetime.net (MOCK)',
payload: { sha256: split.chain.sha256, jobId: job.jobId, blockIndex: job.blockIndex },
});
}
const watch = {
tenantId: 'tenant-sim',
jobId: job.jobId,
tokenRef: 'tok_sim',
enqueuedAt: new Date().toISOString(),
attempt: 1,
maxAttempts: 8,
intervalMs: 500,
traceId: job.jobId,
};
if (faults.failNatsDropWatch && !faults.recover) {
t.emit({
module: 'middleware-http',
hop: 'publish',
status: 'error',
address: 'verae.zapier.jobs.watch',
note: 'NATS publish dropped',
});
const err = new Error('NATS publish failed');
err.status = 503;
throw err;
}
if (faults.failNatsDropWatch && faults.recover) {
t.emit({
module: 'middleware-http',
hop: 'publish',
status: 'retry',
address: 'verae.zapier.jobs.watch',
note: 'first publish dropped; retried',
});
}
t.emit({
module: 'middleware-http',
hop: 'publish',
dir: 'out',
address: 'verae.zapier.jobs.watch',
payload: watch,
});
t.emit({
module: 'job-poller',
hop: 'consume',
dir: 'in',
address: 'verae.zapier.jobs.watch',
payload: { jobId: job.jobId },
});
t.emit({
module: 'job-poller',
hop: 'GET status',
address: 'HTTPS api.veraetime.net/status',
payload: { jobId: job.jobId, status: 'completed' },
});
const event = {
event: 'timestamp.completed',
tenantId: 'tenant-sim',
jobId: job.jobId,
status: 'completed',
traceId: job.jobId,
emittedAt: new Date().toISOString(),
};
if (faults.failNatsDropEvents && !faults.recover) {
t.emit({
module: 'job-poller',
hop: 'publish',
status: 'error',
address: 'verae.zapier.jobs.events',
note: 'events drop — waiter will time out',
});
} else {
if (faults.failNatsDropEvents && faults.recover) {
t.emit({
module: 'job-poller',
hop: 'publish',
status: 'retry',
address: 'verae.zapier.jobs.events',
note: 'events drop recovered',
});
}
t.emit({
module: 'job-poller',
hop: 'publish',
dir: 'out',
address: 'verae.zapier.jobs.events',
payload: event,
});
}
if (!wait) {
t.emit({
module: 'middleware-http',
hop: 'response',
dir: 'out',
address: 'HTTPS 202',
payload: { jobId: job.jobId, sha256: split.chain.sha256, existing: Boolean(existing) },
});
return { jobId: job.jobId, sha256: split.chain.sha256, existing: Boolean(existing), status: 'accepted' };
}
const droppedEvents = faults.failNatsDropEvents && !faults.recover;
if (droppedEvents) {
t.emit({
module: 'middleware-http',
hop: 'wait',
status: 'delay',
durationMs: 250,
payload: { status: 'pending', jobId: job.jobId },
note: 'WAIT_TIMEOUT_MS — no jobs.events',
});
return { jobId: job.jobId, status: 'pending' };
}
t.emit({
module: 'middleware-http',
hop: 'wait',
dir: 'in',
address: 'verae.zapier.jobs.events',
payload: { jobId: job.jobId, status: 'completed' },
});
let receipts = [
{
kind: 'seal',
timestamp: job.timestamp,
certificate: job.certificate,
blockIndex: job.blockIndex,
jobId: job.jobId,
sha256: split.chain.sha256,
},
];
let files = [];
let archivesQueried = false;
let archiveReplies = 0;
if (includeAttached) {
const agg = await this.#queryArchives(split.chain.sha256, {
kinds: ['publicMeta', 'privateMeta', 'file'],
faults,
archives: this.archives,
});
archivesQueried = true;
archiveReplies = agg.archiveReplies;
receipts = mergeReceipts(receipts, agg.records);
files = agg.records.filter((r) => r.kind === 'file').map((r) => ({
id: r.record?.path,
sha256: r.record?.contentSha256,
archiveId: r.archiveId,
}));
}
const body = {
jobId: job.jobId,
status: 'completed',
sha256: split.chain.sha256,
existing: Boolean(existing),
receipts,
files,
archivesQueried,
archiveReplies,
};
if (this.hooks.length) {
t.emit({
module: 'webhook-deliver',
hop: 'POST',
address: 'verae.zapier.webhooks.deliver',
payload: { targetUrl: this.hooks[0], event: 'timestamp.completed', jobId: job.jobId },
note: 'HTTPS POST Zapier REST Hook — not NATS to Zapier',
});
}
t.emit({
module: 'middleware-http',
hop: 'response',
dir: 'out',
address: 'HTTPS 200',
payload: { jobId: body.jobId, status: body.status, receiptCount: receipts.length },
});
return body;
}
async #lookup(input, { includeAttached, includeTree, faults }) {
const t = this.trace;
const sha = String(input.sha256 || (input.data != null ? sha256Hex(input.data) : '')).toLowerCase();
if (!/^[a-f0-9]{64}$/.test(sha)) {
const err = new Error('sha256 must be 64 hex chars');
err.status = 400;
throw err;
}
t.emit({
module: 'middleware-http',
hop: 'hash_lookup',
address: `HTTPS GET /zapier/v1/hashes/${sha.slice(0, 8)}…?includeAttached=${includeAttached}&includeTree=${includeTree}`,
payload: { sha256: sha, includeAttached, includeTree },
});
const seal = this.chain.get(sha);
if (seal) {
t.emit({
module: 'verae-chain-client',
hop: 'lookup',
address: 'HTTPS api.veraetime.net (MOCK)',
payload: { sha256: sha, jobId: seal.jobId, itemizedOnMainChain: true },
});
let receipts = [
{
kind: 'seal',
timestamp: seal.timestamp,
certificate: seal.certificate,
blockIndex: seal.blockIndex,
jobId: seal.jobId,
sha256: sha,
},
];
let archiveReplies = 0;
if (includeAttached) {
const agg = await this.#queryArchives(sha, {
kinds: ['publicMeta', 'privateMeta', 'file'],
faults,
archives: this.archives,
});
archiveReplies = agg.archiveReplies;
receipts = mergeReceipts(receipts, agg.records);
}
return {
sha256: sha,
exists: true,
itemizedOnMainChain: true,
jobId: seal.jobId,
receipts,
archiveReplies,
};
}
t.emit({
module: 'verae-chain-client',
hop: 'lookup',
status: 'silent',
address: 'HTTPS api.veraetime.net (MOCK)',
payload: { sha256: sha, exists: false },
note: 'Not itemized on the main Verae chain',
});
if (!includeTree) {
t.emit({
module: 'middleware-http',
hop: 'response',
dir: 'out',
address: 'HTTPS 404',
payload: { exists: false },
note: 'Tree-node query not requested — leaf in a bulk summary would be missed',
});
return { sha256: sha, exists: false, itemizedOnMainChain: false, receipts: [] };
}
const agg = await this.#queryArchives(sha, {
kinds: ['tree', 'publicMeta', 'privateMeta', 'file'],
faults,
archives: [...this.treeNodes, ...this.archives],
hop: 'tree_query',
});
const treeRecs = agg.records.filter((r) => r.kind === 'tree');
if (!treeRecs.length) {
t.emit({
module: 'middleware-http',
hop: 'response',
dir: 'out',
address: 'HTTPS 404',
payload: { exists: false, archivesQueried: true, archiveReplies: agg.archiveReplies, silent: agg.silent },
});
return {
sha256: sha,
exists: false,
itemizedOnMainChain: false,
archivesQueried: true,
archiveReplies: agg.archiveReplies,
silent: agg.silent,
receipts: [],
};
}
const rec = treeRecs[0];
const root = rec.record.merkleRoot;
const rootSeal = this.chain.get(root);
const proofOk = verifyProof(sha, rec.record.proof, root);
t.emit({
module: 'verae-tree-node',
hop: 'verify_proof',
payload: { leaf: sha, merkleRoot: root, proofOk, chainSealJobId: rec.record.chainSealJobId },
note: proofOk
? 'Leaf included in bulk summary sealed on main chain'
: 'Proof failed — do not trust this tree record',
});
const receipts = mergeReceipts(
rootSeal
? [
{
kind: 'seal',
of: 'merkleRoot',
sha256: root,
timestamp: rootSeal.timestamp,
certificate: rootSeal.certificate,
blockIndex: rootSeal.blockIndex,
jobId: rootSeal.jobId,
},
]
: [],
treeRecs,
);
return {
sha256: sha,
exists: true,
itemizedOnMainChain: false,
merkleRoot: root,
proofOk,
chainSealJobId: rec.record.chainSealJobId,
archiveId: rec.archiveId,
receipts,
archivesQueried: true,
archiveReplies: agg.archiveReplies,
silent: agg.silent,
};
}
async #batch(input, { faults }) {
const t = this.trace;
const lines = Array.isArray(input.items)
? input.items
: String(input.items || '')
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
if (!lines.length) {
const err = new Error('items required');
err.status = 400;
throw err;
}
t.emit({
module: 'middleware-http',
hop: 'POST /zapier/v1/timestamp/batch',
dir: 'in',
address: 'HTTPS /zapier/v1/timestamp/batch',
payload: { count: lines.length },
});
const leaves = lines.map((d) => sha256Hex(d));
const tree = buildMerkle(leaves);
t.emit({
module: 'verae-tree-node',
hop: 'buildMerkle',
payload: { leaves: leaves.length, root: tree.root },
note: 'Only the root will be itemized on the main Verae chain',
});
await this.#maybeDelay(faults, 'chainMs', 'verae-chain-client');
const job = this.#seal(tree.root);
job.kind = 'merkle-root';
job.leafCount = leaves.length;
t.emit({
module: 'verae-chain-client',
hop: 'create',
address: 'HTTPS api.veraetime.net (MOCK)',
payload: { sha256: tree.root, jobId: job.jobId, kind: 'merkle-root', leafCount: leaves.length },
note: 'Individual item hashes are NOT written to the chain',
});
for (let i = 0; i < leaves.length; i += 1) {
const leaf = leaves[i];
const idx = shardIndex(leaf, this.treeNodes.length);
const node = this.treeNodes[idx];
const record = {
merkleRoot: tree.root,
proof: merkleProof(tree.layers, i),
leafIndex: i,
chainSealJobId: job.jobId,
item: lines[i].length > 80 ? `${lines[i].slice(0, 80)}` : lines[i],
};
node.put({ sha256: leaf, kind: 'tree', record });
t.emit({
module: node.archiveId,
hop: 'put',
dir: 'in',
address: 'verae.archive.put',
payload: { kind: 'tree', sha256: leaf, archiveId: node.archiveId, leafIndex: i },
});
}
t.emit({
module: 'middleware-http',
hop: 'publish',
dir: 'out',
address: 'verae.zapier.jobs.watch',
payload: { jobId: job.jobId },
});
t.emit({
module: 'job-poller',
hop: 'publish',
dir: 'out',
address: 'verae.zapier.jobs.events',
payload: { event: 'timestamp.completed', jobId: job.jobId },
});
t.emit({
module: 'middleware-http',
hop: 'response',
dir: 'out',
address: 'HTTPS 202',
payload: { jobId: job.jobId, merkleRoot: tree.root, leafCount: leaves.length },
});
return {
jobId: job.jobId,
merkleRoot: tree.root,
leafCount: leaves.length,
leaves,
status: 'accepted',
note: 'Look up a member hash with Find Hash (tree nodes), not central-chain lookup alone.',
};
}
#verify(input) {
const cert = String(input.certificate || '');
const found = [...this.chain.values()].find((s) => s.certificate === cert);
this.trace.emit({
module: 'verae-chain-client',
hop: 'verify',
address: 'HTTPS api.veraetime.net/verify (MOCK)',
payload: { valid: Boolean(found), blockIndex: found?.blockIndex },
});
return found
? { valid: true, timestamp: found.timestamp, blockIndex: found.blockIndex, sha256: found.sha256 }
: { valid: false };
}
async #archivePuts(puts, tenantId, faults) {
const t = this.trace;
for (const p of puts) {
if (faults.failArchivePut) {
t.emit({
module: 'archive-b',
hop: 'put',
status: 'error',
address: 'verae.archive.put',
note: 'archive put failed',
});
continue;
}
const idx = shardIndex(p.sha256 + p.kind, this.archives.length);
const node = this.archives[idx];
node.put({ sha256: p.sha256, kind: p.kind, record: p.record });
t.emit({
module: node.archiveId,
hop: 'put',
dir: 'in',
address: 'verae.archive.put',
payload: { kind: p.kind, sha256: p.sha256, tenantId, archiveId: node.archiveId },
});
}
}
async #queryArchives(sha256, { kinds, faults, archives, hop }) {
const t = this.trace;
const correlationId = randomUUID();
await this.#maybeDelay(faults, 'archiveMs', 'archive-aggregator');
t.emit({
module: 'archive-aggregator',
hop: hop || 'query',
dir: 'out',
address: 'verae.archive.query',
payload: { correlationId, sha256, tenantId: 'tenant-sim', kinds, traceId: correlationId },
note: 'Broadcast to every archive/tree node — not a queue group',
});
const wrapped = archives.map((arch) => ({
archiveId: arch.archiveId,
query: (sha) => {
if (faults.failArchiveAllSilent) return null;
if (faults.failArchiveOneDown && arch.archiveId.endsWith('west')) {
t.emit({
module: arch.archiveId,
hop: 'down',
status: 'error',
note: 'node unreachable',
});
return null;
}
const hit = arch.query(sha);
if (hit == null) {
t.emit({
module: arch.archiveId,
hop: 'bloom',
status: 'silent',
payload: { sha256: sha, mightHave: false },
note: 'bloom miss — no packet sent',
});
return null;
}
const filtered = hit.filter((r) => !kinds.length || kinds.includes(r.kind));
t.emit({
module: arch.archiveId,
hop: 'reply',
dir: 'out',
address: `verae.archive.reply.${correlationId}`,
payload: { archiveId: arch.archiveId, sha256: sha, records: filtered.length },
});
return filtered;
},
}));
const agg = aggregateAttached({ sha256, tenantId: 'tenant-sim', kinds, archives: wrapped });
t.emit({
module: 'archive-aggregator',
hop: 'merge',
payload: { archiveReplies: agg.archiveReplies, silent: agg.silent, records: agg.records.length },
});
return agg;
}
#seal(sha256) {
const jobId = randomUUID();
const seal = {
jobId,
sha256,
status: 'completed',
timestamp: new Date().toISOString(),
certificate: `mock-cert-${jobId.slice(0, 8)}`,
blockIndex: this.chain.size + 1,
};
this.chain.set(sha256, seal);
this.jobs.set(jobId, seal);
return seal;
}
async #maybeDelay(faults, key, module) {
const ms = Number(faults[key] || 0);
if (ms > 0) {
const t0 = Date.now();
await sleep(ms);
this.trace.emit({
module,
hop: 'delay',
status: 'delay',
durationMs: Date.now() - t0,
note: `injected ${key}=${ms}ms`,
});
}
}
}
function redact(obj) {
if (!obj || typeof obj !== 'object') return obj;
const out = Array.isArray(obj) ? [...obj] : { ...obj };
if (out.privateMetadata) out.privateMetadata = { _redacted: true };
if (out.files) {
out.files = out.files.map((f) => ({ path: f.path || f.filename, size: f.contentBase64?.length || 0 }));
}
if (out.contentBase64) out.contentBase64 = `[${out.contentBase64.length} b64]`;
if (out.password) out.password = '***';
return out;
}
export { sha256Hex };