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

15
NATS.md Normal file
View file

@ -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.<id>` | node on bloom hit only | aggregator |
Live NATS stays on NS1 loopback. Do not point this UI at a public NATS URL.

30
README.md Normal file
View file

@ -0,0 +1,30 @@
# 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://127.0.0.1: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).
Clone: `ssh://git@git.georgelambert.org:2223/marchon/verae-zapier-simulator.git`

9
SUMMARY.md Normal file
View file

@ -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.

11
package.json Normal file
View file

@ -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" }
}

206
public/index.html Normal file
View file

@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Verae × Zapier simulator</title>
<style>
:root {
--ink: #12202c; --muted: #5b6d78; --line: #d5dee4; --bg: #f3efe8;
--panel: #fff; --accent: #ff4a00; --ok: #0f6e56; --err: #a32020;
--warn: #8a5a00; --silent: #6a7a86; --nats: #1d4f91;
}
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.45 system-ui, sans-serif; color: var(--ink); background: var(--bg); }
header { background: var(--ink); color: #f6f3ee; padding: .9rem 1.1rem; display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap; }
header h1 { font-size: 1.05rem; margin: 0; font-weight: 650; }
header .kicker { letter-spacing: .12em; text-transform: uppercase; font: 700 10px system-ui; opacity: .7; }
header p { margin: 0; color: #c5d0d8; font-size: 13px; }
.layout { display: grid; grid-template-columns: minmax(280px, 360px) 1fr; min-height: calc(100vh - 52px); }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
aside { background: var(--panel); border-right: 1px solid var(--line); padding: 1rem; }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 1.1rem 0 .4rem; }
label { display: block; font-size: 12px; color: var(--muted); margin: .45rem 0 .15rem; }
input, select, textarea { width: 100%; padding: .45rem .5rem; border: 1px solid var(--line); border-radius: 6px; font: 13px ui-monospace, Menlo, monospace; }
textarea { min-height: 72px; }
.row { display: flex; gap: .5rem; flex-wrap: wrap; }
.checks label { display: flex; gap: .4rem; align-items: center; font-size: 13px; color: var(--ink); margin: .2rem 0; }
.checks input { width: auto; }
button { background: var(--accent); color: #fff; border: 0; border-radius: 6px; padding: .55rem .9rem; font: 650 13px system-ui; cursor: pointer; }
button.ghost { background: #e8eeef; color: var(--ink); }
.main { display: grid; grid-template-rows: auto 1fr auto; min-height: 0; }
.response, .monitors { background: var(--panel); border-bottom: 1px solid var(--line); padding: .8rem 1rem; }
.monitors { border-bottom: 0; border-top: 1px solid var(--line); max-height: 34vh; overflow: auto; }
pre { margin: 0; font: 12px/1.4 ui-monospace, Menlo, monospace; white-space: pre-wrap; }
.console { background: #0f171d; color: #d7e2ea; padding: .6rem 0; overflow: auto; min-height: 220px; }
.hop { display: grid; grid-template-columns: 52px 18px 150px 1fr; gap: .45rem; padding: .28rem .8rem; font: 12px/1.35 ui-monospace, Menlo, monospace; border-left: 3px solid transparent; }
.hop:hover { background: #18232c; }
.hop.ok { border-color: var(--ok); }
.hop.error { border-color: var(--err); color: #ffb4b0; }
.hop.silent { border-color: var(--silent); color: #8fa0ab; }
.hop.retry { border-color: #d39b00; }
.hop.delay { border-color: #c27a00; }
.mod { color: #9ad7c2; }
.addr { color: #8cb4ea; }
.pill { display: inline-block; font: 700 10px system-ui; letter-spacing: .06em; text-transform: uppercase; padding: .1rem .35rem; border-radius: 4px; background: #e8eeef; margin-right: .3rem; }
.pill.bad { background: #f8d4d4; color: var(--err); }
.pill.good { background: #d4efe6; color: var(--ok); }
.pill.warn { background: #f5e6c8; color: var(--warn); }
.sug { margin: .35rem 0; padding: .45rem .55rem; background: #faf6ee; border: 1px solid var(--line); border-radius: 6px; }
.find { margin: .25rem 0; font-size: 13px; }
</style>
</head>
<body>
<header>
<div>
<div class="kicker">simulator · not live Zapier</div>
<h1>Verae Time × Zapier trace console</h1>
</div>
<p>Every hop from Zap input → zappier-edge → middleware → splitter → chain / WORM / tree nodes → Zap output. NATS addresses are in-process. Loopback only.</p>
</header>
<div class="layout">
<aside>
<h2>Zapier step</h2>
<label>Action</label>
<select id="action"></select>
<label>Data / items (batch = one per line)</label>
<textarea id="data" placeholder="hello from zapier">hello from zapier</textarea>
<label>SHA256 (lookup; blank = hash Data)</label>
<input id="sha256" placeholder="64 hex chars"/>
<label>Public metadata JSON</label>
<input id="publicMeta" placeholder='{"source":"zap"}'/>
<label>Certificate (verify)</label>
<input id="certificate"/>
<label>Email (signup)</label>
<input id="email" value="ada@example.com"/>
<div class="checks">
<label><input type="checkbox" id="includeAttached"/> includeAttached (query WORM)</label>
<label><input type="checkbox" id="includeTree"/> includeTree (query tree nodes)</label>
</div>
<h2>Inject faults</h2>
<div class="checks">
<label><input type="checkbox" id="failEdge401"/> edge 401</label>
<label><input type="checkbox" id="failEdge402"/> edge 402 quota</label>
<label><input type="checkbox" id="failChainTimeout"/> chain timeout</label>
<label><input type="checkbox" id="failNatsDropWatch"/> drop jobs.watch</label>
<label><input type="checkbox" id="failNatsDropEvents"/> drop jobs.events</label>
<label><input type="checkbox" id="failArchiveAllSilent"/> all archives silent</label>
<label><input type="checkbox" id="failArchiveOneDown"/> one tree node down</label>
<label><input type="checkbox" id="recover"/> recover (retry)</label>
</div>
<label>Delay chain ms</label>
<input id="chainMs" type="number" min="0" value="0"/>
<label>Delay archive ms</label>
<input id="archiveMs" type="number" min="0" value="0"/>
<div class="row" style="margin-top:.8rem">
<button id="run">Run step</button>
<button class="ghost" id="reset">Reset world</button>
</div>
<h2>Scenarios</h2>
<div class="row">
<button class="ghost" data-sc="register">Register</button>
<button class="ghost" data-sc="lookup">Lookup chain</button>
<button class="ghost" data-sc="batch">Batch Merkle</button>
<button class="ghost" data-sc="leaf">Lookup leaf</button>
<button class="ghost" data-sc="tree">Lookup tree</button>
</div>
<p style="color:var(--muted);font-size:12px;margin-top:.8rem">World is sticky: batch first, then lookup a leaf. Central lookup should miss; tree lookup should prove inclusion.</p>
</aside>
<div class="main">
<section class="response">
<h2 style="margin-top:0">Response to Zapier</h2>
<pre id="response">Run a step to see output.</pre>
</section>
<section class="console" id="console" aria-label="trace console"></section>
<section class="monitors">
<h2 style="margin-top:0">Monitors &amp; suggested modifications</h2>
<div id="monitors"></div>
</section>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
async function j(url, opts) {
const r = await fetch(url, opts);
return r.json();
}
function faults() {
const f = {};
for (const id of ['failEdge401','failEdge402','failChainTimeout','failNatsDropWatch','failNatsDropEvents','failArchiveAllSilent','failArchiveOneDown','recover']) {
if ($(id).checked) f[id] = true;
}
const chainMs = Number($('chainMs').value || 0);
const archiveMs = Number($('archiveMs').value || 0);
if (chainMs) f.chainMs = chainMs;
if (archiveMs) f.archiveMs = archiveMs;
return f;
}
function inputFor(action) {
const data = $('data').value;
const sha = $('sha256').value.trim();
let publicMetadata;
try { publicMetadata = $('publicMeta').value ? JSON.parse($('publicMeta').value) : undefined; } catch { publicMetadata = { raw: $('publicMeta').value }; }
const base = { data, sha256: sha || undefined, publicMetadata, certificate: $('certificate').value, email: $('email').value, items: data };
return base;
}
function renderTrace(events) {
const el = $('console');
el.innerHTML = events.map((e) => {
const arrow = e.dir === 'out' ? '←' : e.dir === 'in' ? '→' : '·';
return `<div class="hop ${e.status}">
<span>${String(e.tMs).padStart(4,' ')}ms</span>
<span>${arrow}</span>
<span class="mod">${e.module}</span>
<span><strong>${e.hop}</strong> <span class="addr">${e.address || ''}</span> ${e.note ? '— ' + e.note : ''}</span>
</div>`;
}).join('');
el.scrollTop = el.scrollHeight;
}
function renderMonitor(m) {
const el = $('monitors');
const findings = (m.findings || []).map((f) => {
const pill = f.ok ? 'good' : (f.kind === 'delay' ? 'warn' : 'bad');
return `<div class="find"><span class="pill ${pill}">${f.kind}</span><strong>${f.title}</strong> — ${f.detail}</div>`;
}).join('');
const sug = (m.suggestions || []).map((s) =>
`<div class="sug"><span class="pill ${s.severity === 'high' ? 'bad' : 'warn'}">${s.severity}</span>${s.text}</div>`
).join('');
el.innerHTML = `<div class="find"><span class="pill ${m.ok ? 'good' : 'bad'}">${m.ok ? 'pass' : 'issues'}</span>${m.stats.hops} hops · ${m.stats.errors} errors · ${m.stats.retries} retries · ${m.stats.delays} delays · ${m.stats.natsHops} NATS-address hops</div>` + findings + (sug || '<p style="color:#5b6d78">No modifications suggested for this run.</p>');
}
async function run() {
const action = $('action').value;
const body = {
action,
input: inputFor(action),
includeAttached: $('includeAttached').checked,
includeTree: $('includeTree').checked,
faults: faults(),
};
const out = await j('/api/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
$('response').textContent = JSON.stringify(out.response, null, 2);
if (out.response?.certificate) $('certificate').value = out.response.certificate;
if (out.response?.sha256 && action === 'create_timestamp') $('sha256').value = out.response.sha256;
if (out.response?.leaves) $('sha256').value = out.response.leaves[0];
renderTrace(out.trace);
renderMonitor(out.monitor);
}
async function boot() {
const { actions } = await j('/api/actions');
$('action').innerHTML = actions.map((a) => `<option value="${a.key}">${a.label}</option>`).join('');
$('action').value = 'timestamp_and_wait';
}
$('run').onclick = run;
$('reset').onclick = async () => { await j('/api/reset', { method: 'POST' }); $('console').innerHTML = ''; $('response').textContent = 'World reset.'; $('monitors').textContent = ''; };
document.querySelectorAll('[data-sc]').forEach((b) => b.addEventListener('click', async () => {
const sc = b.dataset.sc;
if (sc === 'register') { $('action').value = 'timestamp_and_wait'; $('includeAttached').checked = true; }
if (sc === 'lookup') { $('action').value = 'hash_lookup'; }
if (sc === 'batch') { $('action').value = 'batch_timestamp'; $('data').value = 'invoice-1001\ninvoice-1002\ninvoice-1003'; }
if (sc === 'leaf') { $('action').value = 'hash_lookup'; $('includeTree').checked = false; }
if (sc === 'tree') { $('action').value = 'tree_lookup'; $('includeTree').checked = true; }
await run();
}));
boot();
</script>
</body>
</html>

220
src/monitors.js Normal file
View file

@ -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,
},
};
}

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

86
src/server.js Normal file
View file

@ -0,0 +1,86 @@
/**
* Local Zapier interface simulator + trace console.
* Default http://127.0.0.1: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 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, '127.0.0.1', () => {
process.stdout.write(`Zapier simulator http://127.0.0.1:${PORT}/\n`);
});

44
src/trace.js Normal file
View file

@ -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;
}
}

134
test/pipeline.test.js Normal file
View file

@ -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);
});
});