Initial import of verae-zapier-simulator from zapier monorepo
This commit is contained in:
commit
de6065edf3
10 changed files with 1637 additions and 0 deletions
15
NATS.md
Normal file
15
NATS.md
Normal 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
30
README.md
Normal 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
9
SUMMARY.md
Normal 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
11
package.json
Normal 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
206
public/index.html
Normal 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 & 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
220
src/monitors.js
Normal 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
882
src/pipeline.js
Normal 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
86
src/server.js
Normal 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
44
src/trace.js
Normal 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
134
test/pipeline.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue