verae-zapier-simulator/src/pipeline.js

882 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 };