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