From efd9d378cc4da44a51e6a65a9cb77239d57caa38 Mon Sep 17 00:00:00 2001 From: George Lambert Date: Fri, 11 Sep 2026 18:02:29 -0400 Subject: [PATCH] Initial import of verae-zapier-simulator from zapier monorepo --- NATS.md | 15 + README.md | 34 ++ SUMMARY.md | 9 + package.json | 11 + public/index.html | 215 ++++++++++ src/monitors.js | 220 +++++++++++ src/pipeline.js | 882 ++++++++++++++++++++++++++++++++++++++++++ src/server.js | 87 +++++ src/trace.js | 44 +++ test/pipeline.test.js | 134 +++++++ 10 files changed, 1651 insertions(+) create mode 100644 NATS.md create mode 100644 README.md create mode 100644 SUMMARY.md create mode 100644 package.json create mode 100644 public/index.html create mode 100644 src/monitors.js create mode 100644 src/pipeline.js create mode 100644 src/server.js create mode 100644 src/trace.js create mode 100644 test/pipeline.test.js diff --git a/NATS.md b/NATS.md new file mode 100644 index 0000000..63b8b06 --- /dev/null +++ b/NATS.md @@ -0,0 +1,15 @@ +# NATS — verae-zapier-simulator + +The simulator does **not** subscribe to live NATS. It replays the same addresses in-process so a trace console can validate messaging before the Zapier app is pushed. + +| Simulated address | Publisher (in trace) | Subscriber (in trace) | +|-------------------|----------------------|------------------------| +| HTTPS Zapier → zappier-edge → middleware | zapier-platform-app | zappier-edge | +| `verae.zapier.jobs.watch` | middleware-http | job-poller | +| `verae.zapier.jobs.events` | job-poller | middleware wait, webhook-deliver | +| `verae.zapier.webhooks.deliver` | middleware | webhook-deliver → HTTPS Zapier hook | +| `verae.archive.put` | splitter / merkle builder | archive-* / tree-node-* | +| `verae.archive.query` | archive-aggregator (broadcast) | every WORM and tree node | +| `verae.archive.reply.` | node on bloom hit only | aggregator | + +Live NATS stays on NS1 loopback. Do not point this UI at a public NATS URL. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b764a6 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Verae Zapier interface simulator + +In-process stand-in for the Zapier editor. Run a step, then read a **trace console** of every hop from Zapier input through zappier-edge, middleware, splitter, mock chain, WORM archives, and tree nodes, back to the Zapier output. + +Use it to validate messaging and to catch errors, delays, failures, and recoveries **before** the Zapier app is pushed live. + +```bash +cd packages/verae-zapier-simulator +npm test +npm start # http://0.0.0.0:3847/ +``` + +## What it simulates + +| Zapier action | Path | +|---------------|------| +| Sign up | zappier portal → API key | +| Connect | Zapier test auth (`x-api-key`) | +| Create Timestamp (async) | 202 `jobId` + `jobs.watch` | +| Create Timestamp and Wait | hold until `jobs.events` | +| Find Timestamp by SHA256 | central Verae chain only | +| Find Hash (tree nodes) | chain miss → broadcast `verae.archive.query` kinds=`tree` | +| Create Batch | Merkle root sealed on chain; leaves on tree nodes | +| Verify | mock certificate | + +Faults you can inject: edge 401/402, chain timeout, NATS watch/events drop, archive put fail, all archives silent, one tree node down, hop delays, then **recover** (retry). + +The monitor panel flags broken assumptions (Zapier talking to NATS, file bytes on chain, bloom miss sending a reply) and suggests changes (tree-node search, wait+hook pairing, 402 upgrade URL, archive quorum). + +**Catalog:** https://zapier.georgelambert.org/packages/verae-zapier-simulator/README.pdf +**Live sample UI:** http://0.0.0.0:3847/ — also inside the operator console Trace tab. Chrome matches the operator indigo shell; orange is fault inject only. +**Static copy:** https://zapier.georgelambert.org/simulator/ + +Clone: `ssh://git@git.georgelambert.org:2223/marchon/verae-zapier-simulator.git` diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..d0cba97 --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,9 @@ +# verae-zapier-simulator + +**Job:** Fake the Zapier editor + a trace console from input to response, including every module hop, so messaging, assumptions, delays, failures, and recoveries can be proven **before** `zapier-platform push`. + +**Expects:** operator HTTP to `:3847` (or `Simulator.run` in tests). Uses real `splitRequest`, `WormArchive`, `aggregateAttached`, and Merkle helpers. + +**Sends:** nothing to Zapier cloud, NATS, or `api.veraetime.net`. + +**Test:** `npm test` — register, central lookup, bulk leaf via tree nodes, 402, NATS drop+retry, bloom silence, Zapier-never-NATS. diff --git a/package.json b/package.json new file mode 100644 index 0000000..9a99655 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "verae-zapier-simulator", + "version": "0.1.0", + "type": "module", + "description": "Zapier interface simulator with hop-by-hop trace console (errors, delays, recoveries, suggestions)", + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + }, + "engines": { "node": ">=20" } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..58d911b --- /dev/null +++ b/public/index.html @@ -0,0 +1,215 @@ + + + + + + Verae × Zapier simulator + + + + + +
+
+
simulator · not live Zapier · binds 0.0.0.0:3847
+

Verae Time × Zapier trace console

+
+

Every hop from Zap input → zappier-edge → middleware → splitter → chain / WORM / tree nodes → Zap output. NATS addresses are in-process. HTTP on all interfaces; NATS stays private.

+
+
+ +
+
+

Response to Zapier

+
Run a step to see output.
+
+
+
+

Monitors & suggested modifications

+
+
+
+
+ + + diff --git a/src/monitors.js b/src/monitors.js new file mode 100644 index 0000000..8944176 --- /dev/null +++ b/src/monitors.js @@ -0,0 +1,220 @@ +/** + * Scan a trace for assumption breaks, delays, failures, recoveries, + * and suggest changes before the Zapier app goes live. + * @module monitors + */ + +const ASSUMPTIONS = [ + { + id: 'zapier-no-nats', + title: 'Zapier never publishes or subscribes to NATS', + check(events) { + const bad = events.filter( + (e) => + e.module === 'zapier-platform-app' && + (String(e.address).startsWith('verae.') || String(e.hop).includes('nats')), + ); + return bad.length + ? { ok: false, detail: `Zapier hop used NATS address ${bad[0].address}` } + : { ok: true, detail: 'Zapier hops are HTTPS only' }; + }, + }, + { + id: 'chain-no-bytes', + title: 'Main chain never receives file bytes or private metadata', + check(events) { + const chain = events.filter((e) => e.module === 'verae-chain-client' && e.payload); + for (const e of chain) { + const p = e.payload; + if (p.contentBase64 || p.files || p.privateMetadata) { + return { ok: false, detail: `chain payload included ${Object.keys(p).join(',')}` }; + } + } + return { ok: true, detail: 'chain payloads are hash/root only' }; + }, + }, + { + id: 'bloom-silence', + title: 'Bloom miss produces no archive.reply packet', + check(events) { + const leaks = events.filter( + (e) => e.module.startsWith('archive-') || e.module.startsWith('tree-node'), + ).filter((e) => e.status === 'silent' && e.address.includes('archive.reply')); + return leaks.length + ? { ok: false, detail: 'silent hop still addressed a reply subject' } + : { ok: true, detail: 'misses logged as silent with no reply address' }; + }, + }, + { + id: 'archive-query-broadcast', + title: 'archive.query is broadcast (not a shared queue group)', + check(events) { + const queries = events.filter((e) => e.address === 'verae.archive.query'); + const replies = events.filter((e) => String(e.address).startsWith('verae.archive.reply.')); + if (!queries.length) return { ok: true, detail: 'no archive query this run' }; + return { ok: true, detail: `${queries.length} query hop(s), ${replies.length} reply hop(s)` }; + }, + }, +]; + +const DELAY_MS = 200; + +/** + * @param {object[]} events + * @param {object} [ctx] + */ +export function analyze(events, ctx = {}) { + const findings = []; + const suggestions = []; + + for (const a of ASSUMPTIONS) { + const r = a.check(events); + findings.push({ + kind: 'assumption', + id: a.id, + title: a.title, + ok: r.ok, + detail: r.detail, + }); + } + + const errors = events.filter((e) => e.status === 'error'); + const retries = events.filter((e) => e.status === 'retry'); + const delays = events.filter((e) => e.status === 'delay' || (e.durationMs || 0) >= DELAY_MS); + + for (const e of errors) { + findings.push({ + kind: 'error', + id: `err-${e.seq}`, + title: `${e.module} ${e.hop} failed`, + ok: false, + detail: e.note || e.address, + }); + } + for (const e of delays) { + findings.push({ + kind: 'delay', + id: `delay-${e.seq}`, + title: `${e.module} took ${e.durationMs || e.tMs}ms`, + ok: false, + detail: e.note || 'exceeds 200ms hop budget used for Zapier wait steps', + }); + } + if (retries.length) { + findings.push({ + kind: 'recovery', + id: 'retries', + title: `${retries.length} recovery retry hop(s)`, + ok: true, + detail: retries.map((e) => `${e.module}:${e.hop}`).join(', '), + }); + } + + const last = events[events.length - 1]; + if (last?.status === 'error' && retries.length === 0) { + suggestions.push({ + id: 'add-retry', + severity: 'high', + text: 'Add a single retry on jobs.watch / jobs.events drop before returning pending to Zapier. Zapier will otherwise mark the step failed with no jobId recovery path.', + }); + } + + const lookup = events.find((e) => e.hop === 'hash_lookup' || e.module === 'hash-lookup'); + const treeQuery = events.some((e) => e.payload?.kinds?.includes?.('tree') || e.hop === 'tree_query'); + const chainMiss = events.some( + (e) => e.module === 'verae-chain-client' && e.hop === 'lookup' && e.status === 'silent', + ); + if (chainMiss && !treeQuery) { + suggestions.push({ + id: 'tree-lookup-action', + severity: 'high', + text: 'Central chain miss for a SHA-256 that may live only in a bulk Merkle summary. Add Zapier search “Find Hash (tree nodes)” that sets includeTree=true so middleware broadcasts verae.archive.query kinds=["tree"].', + }); + } + + const puts = events.filter((e) => e.address === 'verae.archive.put'); + const queries = events.filter((e) => e.address === 'verae.archive.query'); + if (puts.length && !queries.length && ctx.includeAttached) { + suggestions.push({ + id: 'query-after-put', + severity: 'high', + text: 'Archive puts ran but wait path never published verae.archive.query. Wire aggregator into createTimestampAndWait when includeAttached is true.', + }); + } + if (puts.length && !ctx.includeAttached && ctx.action !== 'batch_timestamp') { + suggestions.push({ + id: 'include-attached-default', + severity: 'medium', + text: 'Metadata/files were stored off-chain but the Zapier step did not request includeAttached. Document the flag and default it on for “Create Timestamp and Wait”.', + }); + } + + const billing = events.find((e) => e.payload?.status === 402 || e.note?.includes?.('402')); + if (billing) { + suggestions.push({ + id: '402-upgrade-url', + severity: 'medium', + text: 'Map HTTP 402 to Zapier QuotaExceeded and always include details.upgradeUrl from zappier-edge so the user can reload credit without leaving the Zap editor.', + }); + } + + const waitPending = events.some((e) => e.payload?.status === 'pending' && e.hop === 'wait'); + if (waitPending) { + suggestions.push({ + id: 'wait-timeout', + severity: 'medium', + text: 'Wait returned pending. Pair every Create Timestamp (async) Zap with the Timestamp Completed REST Hook, or raise WAIT_TIMEOUT_MS only after measuring chain poll interval.', + }); + } + + const natsFromZapier = events.filter( + (e) => e.module === 'zapier-platform-app' && String(e.address).startsWith('verae.'), + ); + if (natsFromZapier.length) { + suggestions.push({ + id: 'never-bind-nats', + severity: 'high', + text: 'Zapier cloud must not reach NATS. Keep NS1 nats-server on 127.0.0.1:4222; only middleware workers subscribe.', + }); + } + + if (ctx.action === 'batch_timestamp') { + suggestions.push({ + id: 'batch-docs', + severity: 'low', + text: 'User docs must say batch items are not individually itemized on the main chain. Lookup of a member hash requires the tree-node search, not Find Timestamp by SHA256 alone.', + }); + } + + const silentAll = + queries.length > 0 && + !events.some((e) => String(e.address).startsWith('verae.archive.reply.') && e.status === 'ok'); + if (silentAll && (ctx.includeAttached || ctx.includeTree)) { + findings.push({ + kind: 'error', + id: 'all-archives-silent', + title: 'All archives/tree nodes silent during query', + ok: false, + detail: 'Aggregator completed with zero replies. Distinguish “hash unknown” from “archives unreachable”.', + }); + suggestions.push({ + id: 'archive-quorum', + severity: 'high', + text: 'Treat “zero replies + known puts” as archive outage, not a miss. Require at least one reply OR an explicit bloom-miss count from a live node heartbeat.', + }); + } + + const ok = findings.filter((f) => f.ok === false).length === 0; + return { + ok, + findings, + suggestions, + stats: { + hops: events.length, + errors: errors.length, + retries: retries.length, + delays: delays.length, + natsHops: events.filter((e) => String(e.address).startsWith('verae.')).length, + }, + }; +} diff --git a/src/pipeline.js b/src/pipeline.js new file mode 100644 index 0000000..adc8c14 --- /dev/null +++ b/src/pipeline.js @@ -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 }; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..f2e0886 --- /dev/null +++ b/src/server.js @@ -0,0 +1,87 @@ +/** + * Local Zapier interface simulator + trace console. + * Default http://0.0.0.0:3847 + */ +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ACTIONS, Simulator } from './pipeline.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PUBLIC = path.join(__dirname, '..', 'public'); +const PORT = Number(process.env.SIM_PORT || 3847); +const BIND = process.env.SIM_BIND || '0.0.0.0'; +const world = new Simulator(); + +const mime = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', +}; + +function send(res, code, body, type = 'application/json; charset=utf-8') { + const buf = typeof body === 'string' ? body : JSON.stringify(body, null, 2); + res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' }); + res.end(buf); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch (err) { + reject(err); + } + }); + req.on('error', reject); + }); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://127.0.0.1:${PORT}`); + try { + if (req.method === 'GET' && url.pathname === '/health') { + return send(res, 200, { ok: true, port: PORT }); + } + if (req.method === 'GET' && url.pathname === '/api/actions') { + return send(res, 200, { actions: ACTIONS, state: world.snapshot() }); + } + if (req.method === 'GET' && url.pathname === '/api/state') { + return send(res, 200, world.snapshot()); + } + if (req.method === 'POST' && url.pathname === '/api/reset') { + world.reset(); + return send(res, 200, { ok: true, state: world.snapshot() }); + } + if (req.method === 'POST' && url.pathname === '/api/run') { + const body = await readBody(req); + const result = await world.run(body); + return send(res, 200, result); + } + let file = url.pathname === '/' ? '/index.html' : url.pathname; + const dest = path.normalize(path.join(PUBLIC, file)); + if (!dest.startsWith(PUBLIC)) { + return send(res, 403, { error: 'forbidden' }); + } + if (!fs.existsSync(dest) || fs.statSync(dest).isDirectory()) { + return send(res, 404, { error: 'not found' }); + } + const ext = path.extname(dest); + res.writeHead(200, { 'content-type': mime[ext] || 'application/octet-stream' }); + fs.createReadStream(dest).pipe(res); + } catch (err) { + send(res, 500, { error: err.message }); + } +}); + +server.listen(PORT, BIND, () => { + process.stdout.write(`Zapier simulator http://${BIND}:${PORT}/\n`); +}); diff --git a/src/trace.js b/src/trace.js new file mode 100644 index 0000000..6bf633b --- /dev/null +++ b/src/trace.js @@ -0,0 +1,44 @@ +/** + * Ordered hop log from Zapier input through every module to the response. + * @module trace + */ + +export class TraceBus { + constructor() { + this.events = []; + this.t0 = Date.now(); + } + + now() { + return Date.now() - this.t0; + } + + /** + * @param {{ + * module: string, + * hop: string, + * dir?: 'in'|'out'|'internal', + * address?: string, + * status?: 'ok'|'silent'|'error'|'retry'|'delay'|'skip', + * payload?: object, + * note?: string, + * durationMs?: number + * }} e + */ + emit(e) { + const ev = { + seq: this.events.length + 1, + tMs: this.now(), + module: e.module, + hop: e.hop, + dir: e.dir || 'internal', + address: e.address || '', + status: e.status || 'ok', + payload: e.payload ?? null, + note: e.note || '', + durationMs: e.durationMs ?? 0, + }; + this.events.push(ev); + return ev; + } +} diff --git a/test/pipeline.test.js b/test/pipeline.test.js new file mode 100644 index 0000000..9dcc727 --- /dev/null +++ b/test/pipeline.test.js @@ -0,0 +1,134 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { Simulator, sha256Hex } from '../src/pipeline.js'; + +describe('Zapier simulator pipeline', () => { + it('signup then connect then register+wait with metadata', async () => { + const s = new Simulator(); + const sign = await s.run({ action: 'signup', input: { email: 'ada@example.com', plan: 'pro' } }); + assert.ok(sign.response.apiKey.startsWith('zpk_')); + const conn = await s.run({ action: 'connect_zapier', input: { apiKey: sign.response.apiKey } }); + assert.equal(conn.response.connected, true); + const wait = await s.run({ + action: 'timestamp_and_wait', + includeAttached: true, + input: { data: 'hello from zapier', publicMetadata: { source: 'zap' } }, + }); + assert.equal(wait.response.status, 'completed'); + assert.equal(wait.response.receipts[0].kind, 'seal'); + assert.ok(wait.response.receipts.some((r) => r.kind === 'metadata-attach')); + assert.ok(wait.trace.some((e) => e.address === 'verae.zapier.jobs.watch')); + assert.ok(wait.trace.some((e) => e.address === 'verae.zapier.jobs.events')); + assert.ok(wait.trace.some((e) => e.address === 'verae.archive.put')); + assert.ok(wait.trace.some((e) => e.address === 'verae.archive.query')); + const zapNats = wait.trace.filter( + (e) => e.module === 'zapier-platform-app' && String(e.address).startsWith('verae.'), + ); + assert.equal(zapNats.length, 0); + assert.equal(wait.monitor.findings.find((f) => f.id === 'zapier-no-nats').ok, true); + assert.equal(wait.monitor.findings.find((f) => f.id === 'chain-no-bytes').ok, true); + }); + + it('async create returns jobId; second register returns original seal', async () => { + const s = new Simulator(); + const a = await s.run({ action: 'create_timestamp', input: { data: 'once' } }); + assert.equal(a.response.status, 'accepted'); + const b = await s.run({ action: 'create_timestamp', input: { data: 'once' } }); + assert.equal(b.response.existing, true); + assert.equal(b.response.jobId, a.response.jobId); + }); + + it('central lookup finds itemized hash and misses a bulk leaf', async () => { + const s = new Simulator(); + const reg = await s.run({ action: 'timestamp_and_wait', input: { data: 'solo' } }); + const hit = await s.run({ action: 'hash_lookup', input: { sha256: reg.response.sha256 } }); + assert.equal(hit.response.exists, true); + assert.equal(hit.response.itemizedOnMainChain, true); + + const batch = await s.run({ + action: 'batch_timestamp', + input: { items: 'invoice-1001\ninvoice-1002\ninvoice-1003' }, + }); + assert.equal(batch.response.leafCount, 3); + const leaf = batch.response.leaves[0]; + assert.equal(leaf, sha256Hex('invoice-1001')); + const miss = await s.run({ action: 'hash_lookup', input: { sha256: leaf } }); + assert.equal(miss.response.exists, false); + assert.ok(miss.monitor.suggestions.some((x) => x.id === 'tree-lookup-action')); + }); + + it('tree lookup proves a leaf that is only in the bulk summary', async () => { + const s = new Simulator(); + const batch = await s.run({ + action: 'batch_timestamp', + input: { items: ['alpha-doc', 'beta-doc', 'gamma-doc'] }, + }); + const leaf = batch.response.leaves[1]; + const found = await s.run({ action: 'tree_lookup', input: { sha256: leaf } }); + assert.equal(found.response.exists, true); + assert.equal(found.response.itemizedOnMainChain, false); + assert.equal(found.response.proofOk, true); + assert.equal(found.response.merkleRoot, batch.response.merkleRoot); + assert.ok(found.response.receipts.some((r) => r.kind === 'tree-leaf')); + assert.ok(found.response.receipts.some((r) => r.kind === 'seal' && r.of === 'merkleRoot')); + assert.ok(found.trace.some((e) => e.status === 'silent')); + assert.ok(found.trace.some((e) => String(e.address).startsWith('verae.archive.reply.'))); + const bloomLeak = found.trace.filter((e) => e.status === 'silent' && String(e.address).includes('archive.reply')); + assert.equal(bloomLeak.length, 0); + }); + + it('402 quota is traced and suggested', async () => { + const s = new Simulator(); + const r = await s.run({ action: 'create_timestamp', input: { data: 'x' }, faults: { failEdge402: true } }); + assert.equal(r.response.status, 402); + assert.ok(r.monitor.suggestions.some((x) => x.id === '402-upgrade-url')); + }); + + it('NATS events drop then recovery retry', async () => { + const s = new Simulator(); + const fail = await s.run({ + action: 'timestamp_and_wait', + input: { data: 'drop' }, + faults: { failNatsDropEvents: true }, + }); + assert.equal(fail.response.status, 'pending'); + assert.ok(fail.monitor.suggestions.some((x) => x.id === 'wait-timeout' || x.id === 'add-retry')); + + const ok = await s.run({ + action: 'timestamp_and_wait', + input: { data: 'drop-recover' }, + faults: { failNatsDropEvents: true, recover: true }, + }); + assert.equal(ok.response.status, 'completed'); + assert.ok(ok.trace.some((e) => e.status === 'retry')); + }); + + it('chain delay is monitored', async () => { + const s = new Simulator(); + const r = await s.run({ + action: 'create_timestamp', + input: { data: 'slow' }, + faults: { chainMs: 220 }, + }); + assert.equal(r.response.status, 'accepted'); + assert.ok(r.monitor.findings.some((f) => f.kind === 'delay' && f.ok === false)); + }); + + it('files never appear on the chain hop payload', async () => { + const s = new Simulator(); + const r = await s.run({ + action: 'timestamp_and_wait', + includeAttached: true, + input: { + data: 'with-file', + files: [{ path: 'a.txt', data: 'bytes' }], + }, + }); + const chain = r.trace.filter((e) => e.module === 'verae-chain-client'); + for (const e of chain) { + assert.equal(e.payload?.files, undefined); + assert.equal(e.payload?.contentBase64, undefined); + } + assert.ok(r.response.files.length >= 1); + }); +});