Add Zapier interface simulator, tree-node Merkle lookups, and user docs
Some checks are pending
offline / test (push) Waiting to run

In-process trace console validates hops, faults, recoveries, and
suggested changes before zapier-platform push. User guide covers
signup through central-chain and bulk-summary tree-node hash lookup.
This commit is contained in:
George Lambert 2026-09-11 12:58:03 -04:00
parent 3daa88866d
commit f3dc0e6eee
56 changed files with 2341 additions and 8 deletions

View file

@ -27,3 +27,18 @@ After seal is known, aggregator publishes `verae.archive.query`. Each WORM: bloo
## 5. Multipart attachments
Splitter emits one `archive.put` per file (`kind: file`, `contentSha256`). Chain never stores bytes.
## 6. Batch Merkle (bulk summary)
1. Zapier → `POST /v1/timestamp/batch` (one item per line)
2. Middleware hashes each item (leaves), builds Merkle tree
3. Chain seals **only the root**
4. Each leaf proof → `verae.archive.put` `kind: tree` on a sharded tree node
5. Central `GET /hashes/{leaf}` → miss (`itemizedOnMainChain: false`)
6. `GET /hashes/{leaf}?includeTree=true` → aggregator broadcasts `verae.archive.query` `kinds=["tree"]`
7. Tree node bloom hit → `archive.reply.<id>` with proof; others silent
8. Response: root seal + `tree-leaf` receipt
## 7. Simulator
`packages/verae-zapier-simulator` replays flows 16 in-process with a trace console, fault injection, and modification suggestions. It does not connect to live NATS.

View file

@ -17,6 +17,9 @@ Summaries, NATS contracts, and message flows for every Verae Time × Zapier modu
| verae-request-splitter | https://git.georgelambert.org/marchon/verae-request-splitter |
| verae-archive-worm | https://git.georgelambert.org/marchon/verae-archive-worm |
| verae-archive-aggregator | https://git.georgelambert.org/marchon/verae-archive-aggregator |
| verae-tree-node | https://git.georgelambert.org/marchon/verae-tree-node |
| verae-zapier-simulator | https://git.georgelambert.org/marchon/verae-zapier-simulator |
| zapier-user-docs | https://git.georgelambert.org/marchon/zapier-user-docs |
| **zapier-docs-master** (this repo) | https://git.georgelambert.org/marchon/zapier-docs-master |
Clone (SSH port 2223):
@ -34,6 +37,9 @@ Clone (SSH port 2223):
| request-splitter | Hash vs files | — | archive.put |
| archive-worm | Bloom WORM node | archive.query, archive.put | archive.reply.* (hit only) |
| archive-aggregator | Merge archive replies | archive.reply.* | archive.query |
| tree-node | Merkle leaf proofs | archive.query, archive.put | archive.reply.* (hit only) |
| zapier-simulator | Trace console (in-process) | — | — |
| zapier-user-docs | Signup → lookup guide | — | — |
## Documents in this repo

View file

@ -54,6 +54,7 @@ If later attach jobs exist for that hash, `receipts` is an array: seal first, th
| `verae.archive.reply.<correlationId>` | archive on bloom hit | aggregator |
Query payload: `{ correlationId, sha256, tenantId, kinds[] }`.
`kinds` may include `tree` for Merkle leaf proofs (hashes sealed only as a bulk summary root).
Reply payload: `{ archiveId, sha256, records[] }`.
Bloom miss → no reply. Aggregator timeout → complete with whatever arrived.

View file

@ -32,6 +32,9 @@ Zapier cloud **never** connects to NATS. Only middleware workers and archives do
| request-splitter | `verae-request-splitter` | In middleware | HTTP body / multipart | chain hash; `verae.archive.put` |
| archive-aggregator | `verae-archive-aggregator` | Worker / in wait | `includeAttached` on wait | `verae.archive.query`; reads `verae.archive.reply.<id>` |
| archive-worm | `verae-archive-worm` | N copies | `verae.archive.query`, `verae.archive.put` | `verae.archive.reply.<id>` if bloom hits |
| tree-node | `verae-tree-node` | N copies (WORM role) | `verae.archive.query`, `verae.archive.put` kind `tree` | `verae.archive.reply.<id>` if bloom hits |
| zapier-simulator | `verae-zapier-simulator` | Local HTTP :3847 | operator browser | in-process replay of all addresses |
| zapier-user-docs | `zapier-user-docs` | Static | — | catalog `/user-docs/` |
| verae-chain-client | `verae-chain-client` | Library | — | HTTPS `api.veraetime.net` or MOCK |
| docs-master | `zapier-docs-master` | Static | — | published on zapier.georgelambert.org |

View file

@ -0,0 +1,7 @@
# NATS — verae-tree-node
| Direction | Address | From / to | Body |
|-----------|---------|-----------|------|
| IN | `verae.archive.put` | merkle builder | `{ sha256, kind: "tree", record: { merkleRoot, proof, leafIndex, chainSealJobId } }` |
| IN | `verae.archive.query` | aggregator (broadcast) | `{ correlationId, sha256, kinds: ["tree", …] }` |
| OUT | `verae.archive.reply.<correlationId>` | aggregator | only if bloom.mightHave(sha256) |

View file

@ -0,0 +1,9 @@
# verae-tree-node
**Job:** Hold inclusion proofs for SHA-256 leaves that were registered only as part of a bulk Merkle summary. The main Verae chain itemizes the **root**, not each leaf.
**Expects messages from:** middleware batch path (`verae.archive.put` kind `tree`); aggregator (`verae.archive.query`).
**Sends messages to:** `verae.archive.reply.<correlationId>` on bloom hit.
**Does not talk to Zapier.** Same bloom-silence rule as `verae-archive-worm`.

View file

@ -0,0 +1,3 @@
# NATS — verae-zapier-simulator
In-process replay of `jobs.watch`, `jobs.events`, `archive.put`, `archive.query`, `archive.reply.*`. No live subscribe.

View file

@ -0,0 +1,7 @@
# verae-zapier-simulator
**Job:** Zapier-like UI + hop-by-hop trace console to validate messaging, assumptions, delays, failures, recoveries, and suggested modifications before `zapier-platform push`.
**Expects:** HTTP `:3847` or `Simulator.run` in tests.
**Sends:** nothing to live NATS / Zapier / Verae.

View file

@ -0,0 +1,3 @@
# NATS — zapier-user-docs
Static docs. Users never configure NATS in Zapier.

View file

@ -0,0 +1,3 @@
# zapier-user-docs
**Job:** Customer guide from signup through register, central-chain lookup, and tree-node lookup of bulk-summary leaves.

View file

@ -41,13 +41,26 @@ export function aggregateAttached({ sha256, tenantId, kinds = ['publicMeta', 'pr
* @param {object[]} archiveRecords
*/
export function mergeReceipts(chainReceipts, archiveRecords) {
const extra = archiveRecords
.filter((r) => r.kind === 'publicMeta' || r.kind === 'privateMeta' || r.kind === 'file')
.map((r) => ({
const extra = archiveRecords.map((r) => {
if (r.kind === 'tree') {
return {
kind: 'tree-leaf',
archiveId: r.archiveId,
attachedAt: r.storedAt,
itemizedOnMainChain: false,
merkleRoot: r.record?.merkleRoot,
proof: r.record?.proof,
leafIndex: r.record?.leafIndex,
chainSealJobId: r.record?.chainSealJobId,
...r.record,
};
}
return {
kind: r.kind === 'file' ? 'file-attach' : 'metadata-attach',
archiveId: r.archiveId,
attachedAt: r.storedAt,
...r.record,
}));
};
});
return [...chainReceipts, ...extra];
}

View file

@ -25,4 +25,20 @@ describe('aggregateAttached', () => {
assert.equal(merged[0].kind, 'seal');
assert.equal(merged[1].kind, 'metadata-attach');
});
it('merges tree-leaf receipts from bulk summaries', () => {
const seal = [{ kind: 'seal', of: 'merkleRoot', sha256: 'aa'.repeat(32) }];
const recs = [
{
kind: 'tree',
archiveId: 'tree-node-east',
storedAt: '2026-01-02T00:00:00Z',
record: { merkleRoot: 'aa'.repeat(32), leafIndex: 1, proof: [] },
},
];
const merged = mergeReceipts(seal, recs);
assert.equal(merged[1].kind, 'tree-leaf');
assert.equal(merged[1].itemizedOnMainChain, false);
assert.equal(merged[1].archiveId, 'tree-node-east');
});
});

View file

@ -0,0 +1,13 @@
# NATS — verae-tree-node
Tree nodes are WORM archives that store **Merkle leaf proofs**. They listen on the same archive subjects so a leaf that was never itemized on the main chain can still be found.
Zapier never connects here. Middleware aggregator broadcasts; a tree node that does not hold the leaf stays silent.
| Direction | Address | From / to | Body |
|-----------|---------|-----------|------|
| IN | `verae.archive.put` | batch splitter / merkle builder | `{ sha256, tenantId, kind: "tree", record: { merkleRoot, proof, leafIndex, chainSealJobId }, traceId }` |
| IN | `verae.archive.query` | aggregator (broadcast, **no queue group**) | `{ correlationId, sha256, tenantId, kinds: ["tree", …], traceId }` |
| OUT | `verae.archive.reply.<correlationId>` | aggregator | `{ archiveId, sha256, records[], traceId }` **only if bloom.mightHave(sha256)** |
`record.merkleRoot` is the hash sealed on the main Verae chain. `proof` lets a verifier recompute the root from this leaf.

View file

@ -0,0 +1,7 @@
# verae-tree-node
External tree-node role: Bloom-filtered WORM store for Merkle **leaf** receipts.
When Zapier submits a **batch**, middleware builds a Merkle tree of item SHA-256s, seals **only the root** on `api.veraetime.net`, and puts each leaf proof on a sharded tree node. A later lookup of a leaf that is not on the main chain fans out `verae.archive.query` with `kinds: ["tree"]`. Nodes that do not hold the leaf send nothing.
Clone: `ssh://git@git.georgelambert.org:2223/marchon/verae-tree-node.git`

View file

@ -0,0 +1,11 @@
# verae-tree-node
**Job:** Hold inclusion proofs for SHA-256 leaves that were registered only as part of a bulk Merkle summary. The main Verae chain itemizes the **root**, not each leaf.
**Expects messages from:** middleware batch path (`verae.archive.put` kind `tree`); aggregator (`verae.archive.query`).
**Sends messages to:** `verae.archive.reply.<correlationId>` on bloom hit.
**Does not talk to Zapier.** Same bloom-silence rule as `verae-archive-worm`.
**Test:** `npm test` — three leaves, root sealed, each leaf verifies; missing leaf has no proof.

View file

@ -0,0 +1,8 @@
{
"name": "verae-tree-node",
"version": "0.1.0",
"type": "module",
"description": "Merkle leaf proofs for hashes only sealed as a bulk summary root on the main Verae chain",
"scripts": { "test": "node --test test/*.test.js" },
"engines": { "node": ">=20" }
}

View file

@ -0,0 +1,96 @@
/**
* Merkle tree over SHA-256 leaf hex digests.
* Only the root is sealed on the main Verae chain; leaves live on tree nodes.
* @module merkle
*/
import { createHash } from 'node:crypto';
/**
* @param {string|Buffer|Uint8Array} data
* @returns {string} lowercase hex
*/
export function sha256Hex(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8');
return createHash('sha256').update(buf).digest('hex');
}
/**
* Parent of two 32-byte hex children (left || right).
* Odd last child is duplicated (Bitcoin-style).
* @param {string} left
* @param {string} right
*/
export function parentHash(left, right) {
return createHash('sha256')
.update(Buffer.from(left, 'hex'))
.update(Buffer.from(right, 'hex'))
.digest('hex');
}
/**
* @param {string[]} leaves hex SHA-256 digests
* @returns {{ root: string, layers: string[][], leaves: string[] }}
*/
export function buildMerkle(leaves) {
if (!leaves.length) {
throw new Error('at least one leaf is required');
}
const layer0 = leaves.map((h) => String(h).toLowerCase());
const layers = [layer0];
let layer = layer0;
while (layer.length > 1) {
const next = [];
for (let i = 0; i < layer.length; i += 2) {
const left = layer[i];
const right = layer[i + 1] ?? layer[i];
next.push(parentHash(left, right));
}
layer = next;
layers.push(layer);
}
return { root: layer[0], layers, leaves: layer0 };
}
/**
* Inclusion proof for leaf index.
* @param {string[][]} layers
* @param {number} index
* @returns {Array<{ sibling: string, position: 'left'|'right' }>}
*/
export function merkleProof(layers, index) {
const proof = [];
let i = index;
for (let level = 0; level < layers.length - 1; level += 1) {
const layer = layers[level];
const isRight = i % 2 === 1;
const sib = isRight ? i - 1 : i + 1;
const sibling = layer[sib] ?? layer[i];
proof.push({ sibling, position: isRight ? 'left' : 'right' });
i = Math.floor(i / 2);
}
return proof;
}
/**
* @param {string} leaf
* @param {Array<{ sibling: string, position: 'left'|'right' }>} proof
* @param {string} root
*/
export function verifyProof(leaf, proof, root) {
let h = String(leaf).toLowerCase();
for (const step of proof) {
h = step.position === 'left' ? parentHash(step.sibling, h) : parentHash(h, step.sibling);
}
return h === String(root).toLowerCase();
}
/**
* Shard a leaf onto one of N tree nodes (others stay silent on bloom miss).
* @param {string} sha256
* @param {number} n
*/
export function shardIndex(sha256, n) {
const b = Buffer.from(String(sha256).slice(0, 8), 'hex');
return n <= 0 ? 0 : b.readUInt32BE(0) % n;
}

View file

@ -0,0 +1,34 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
sha256Hex,
buildMerkle,
merkleProof,
verifyProof,
shardIndex,
} from '../src/merkle.js';
describe('merkle', () => {
it('one leaf is its own root', () => {
const leaf = sha256Hex('only');
const t = buildMerkle([leaf]);
assert.equal(t.root, leaf);
assert.equal(verifyProof(leaf, merkleProof(t.layers, 0), t.root), true);
});
it('three leaves: each proof verifies, unknown leaf does not', () => {
const leaves = ['a', 'b', 'c'].map((s) => sha256Hex(s));
const t = buildMerkle(leaves);
for (let i = 0; i < leaves.length; i += 1) {
const proof = merkleProof(t.layers, i);
assert.equal(verifyProof(leaves[i], proof, t.root), true);
}
assert.equal(verifyProof(sha256Hex('nope'), merkleProof(t.layers, 0), t.root), false);
});
it('shards stably into three nodes', () => {
const h = sha256Hex('x');
assert.equal(shardIndex(h, 3), shardIndex(h, 3));
assert.ok(shardIndex(h, 3) >= 0 && shardIndex(h, 3) < 3);
});
});

View file

@ -18,6 +18,10 @@ export const SUBJECTS = Object.freeze({
WEBHOOKS_DELIVER: 'verae.zapier.webhooks.deliver',
/** Optional metering stream */
USAGE: 'verae.zapier.usage',
/** Off-chain WORM / tree-node puts */
ARCHIVE_PUT: 'verae.archive.put',
/** Broadcast hash lookup to every WORM and tree node (no queue group) */
ARCHIVE_QUERY: 'verae.archive.query',
});
/**

View file

@ -15,7 +15,9 @@ hashRoutes.get(
if (!sha256 || !/^[a-fA-F0-9]{64}$/.test(sha256)) {
throw new AppError('sha256 must be 64 hex chars', { status: 400, code: 'VALIDATION_ERROR' });
}
const result = await lookupHash(req.auth, sha256);
const includeAttached = req.query.includeAttached === 'true' || req.query.includeAttached === '1';
const includeTree = req.query.includeTree === 'true' || req.query.includeTree === '1';
const result = await lookupHash(req.auth, sha256, { includeAttached, includeTree });
res.json(result);
}),
);

View file

@ -151,9 +151,19 @@ export async function getJobVerification(ctx, jobId) {
/**
* @param {object} ctx
* @param {string} sha256
* @param {{ includeAttached?: boolean, includeTree?: boolean }} [opts]
*/
export async function lookupHash(ctx, sha256) {
export async function lookupHash(ctx, sha256, opts = {}) {
const result = await veraeClient.lookupHash(ctx.veraeToken, sha256);
recordUsage(ctx.tenantId, 'status');
return result;
if (!opts.includeAttached && !opts.includeTree) return result;
return {
...result,
includeAttached: Boolean(opts.includeAttached),
includeTree: Boolean(opts.includeTree),
note:
opts.includeTree && !result?.exists
? 'Chain miss: middleware should broadcast verae.archive.query kinds=["tree"] (wired in simulator; live aggregator hook TBD).'
: undefined,
};
}

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.

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`

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.

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

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>

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

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

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

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

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

View file

@ -5,6 +5,6 @@ This Zapier Platform app **does not use NATS**.
| Direction | Address | Peer | Body |
|-----------|---------|------|------|
| IN | Zapier runtime | user Zap | create/search/trigger fields |
| OUT HTTPS | middleware `/zapier/v1/timestamp`, `/wait`, `/verify`, `/status/{id}`, `/hashes/{sha256}`, REST Hook subscribe | verae-middleware | Bearer `zmw_` |
| OUT HTTPS | middleware `/zapier/v1/timestamp`, `/wait`, `/verify`, `/status/{id}`, `/hashes/{sha256}`, `/hashes/{sha256}?includeTree=true`, REST Hook subscribe | verae-middleware | Bearer `zmw_` |
Job completion is a REST Hook HTTP POST from webhook-deliver, not a NATS client in Zapier.

View file

@ -11,6 +11,7 @@ const batchTimestamp = require('./creates/batch_timestamp');
const addNumbers = require('./creates/add_numbers');
const jobStatus = require('./searches/job_status');
const hashLookup = require('./searches/hash_lookup');
const treeLookup = require('./searches/tree_lookup');
const timestampCompleted = require('./triggers/timestamp_completed');
/**
@ -76,5 +77,6 @@ module.exports = {
searches: {
[jobStatus.key]: jobStatus,
[hashLookup.key]: hashLookup,
[treeLookup.key]: treeLookup,
},
};

View file

@ -0,0 +1,45 @@
const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100';
const perform = async (z, bundle) => {
try {
const sha = encodeURIComponent(bundle.inputData.sha256);
const response = await z.request({
method: 'GET',
url: `${base()}/zapier/v1/hashes/${sha}?includeAttached=true&includeTree=true`,
});
return [response.data];
} catch (err) {
if (err.status === 404) return [];
throw err;
}
};
module.exports = {
key: 'tree_lookup',
noun: 'Timestamp',
display: {
label: 'Find Hash (tree nodes + central chain)',
description:
'Looks up a SHA256 on the main Verae chain, then queries external tree-node archives for leaves that were only sealed as a bulk Merkle summary.',
},
operation: {
inputFields: [
{
key: 'sha256',
label: 'SHA256',
type: 'string',
required: true,
helpText:
'64-char hex. If this hash was part of a batch, it may not be itemized on the main chain — this search still finds the Merkle proof on tree nodes.',
},
],
perform,
sample: {
sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
exists: true,
itemizedOnMainChain: false,
merkleRoot: 'ab'.repeat(32),
proofOk: true,
},
},
};

View file

@ -28,6 +28,7 @@ describe('verae-zapier app definition', () => {
assert.ok(App.creates.batch_timestamp);
assert.ok(App.searches.job_status);
assert.ok(App.searches.hash_lookup);
assert.ok(App.searches.tree_lookup);
assert.ok(App.triggers.timestamp_completed);
});

View file

@ -0,0 +1,19 @@
# 1. What you get
Verae Time proves that a SHA-256 existed at a given time. Zapier lets you do that from the tools you already use (Drive, email, CRM, forms) without writing HTTP clients.
Three kinds of proof:
| Proof | Where it lives | Zapier search |
|-------|----------------|---------------|
| **Itemized seal** | Main Verae chain (`api.veraetime.net`) | Find Timestamp by SHA256 |
| **Bulk Merkle root** | Main chain, one seal for many items | Create Batch Timestamps |
| **Leaf inclusion** | External tree-node archives (NATS, never Zapier) | Find Hash (tree nodes + central chain) |
You also get:
- A **zappier** customer portal: account, API key, usage, invoices, prepaid balance.
- A **Zapier** integration: creates, searches, and a “timestamp completed” trigger.
- Optional **public metadata**, **encrypted private metadata**, and **file attachments** stored on WORM archives — not on the blockchain.
Zapier talks **HTTPS only** to zappier-edge. zappier meters the call and forwards to Verae middleware. Middleware talks to the chain and, when asked, to archive/tree-node NATS servers. You never configure NATS in Zapier.

View file

@ -0,0 +1,23 @@
# 2. Sign up for a Verae / zappier account
You need an API key **before** you can connect Zapier.
1. Open the customer portal (`/portal` on the zappier host your operator gave you).
2. Choose **Create an account**.
3. Enter name, email, and a password of at least 8 characters.
4. You start on the **Free** plan and receive an **API key immediately**.
5. Copy the key. It is sent as `x-api-key` on every Zapier request.
If your company already created an account (you received a key by email), sign up with the **same email**. The existing plan and key are kept; your password is attached to that account.
## After signup
- **Dashboard** — usage this month, included credit, billable overage, prepaid balance.
- **API & pricing** — live rate card (free / fixed / variable per operation, including batch).
- **Invoices** — draft / issued / paid.
- **Billing** — reload $1$10,000; prepaid is drawn down automatically.
- **Security** — optional 2FA.
Regenerating the API key **invalidates the old one immediately**. Update the Zapier authentication field or every Zap step will 401.
Simulator: action **Sign up (zappier portal)** then **Connect Zapier app**.

View file

@ -0,0 +1,19 @@
# 3. Connect the Zapier app
1. Sign in at [zapier.com](https://zapier.com) (or your Zapier workspace).
2. Create a Zap → add **Verae Time** (or **Verae Activate** while the full app is in private invite).
3. When Zapier asks for authentication, paste:
- **API key** from the zappier portal.
- **API base URL** if the operator asked you to (hosted edge). Leave blank only for local-mock Activate steps that do not hit the chain.
4. Click **Test**. Zapier calls zappier-edge over HTTPS (`whoami` / a cheap GET). You should see your email and plan. A 401 means the key was regenerated or mistyped.
You do **not** enter NATS URLs, Verae JWT, or `api.veraetime.net` credentials. Those stay on middleware.
## Plans
| Plan | Typical access |
|------|----------------|
| Free | Single timestamp, lookup, Add Numbers / Echo / SHA256 helpers |
| Paid | Batch timestamps, wait-for-certificate, attached metadata, tree-node lookup |
A 403 `PLAN_UPGRADE_REQUIRED` means that Zap step is paid-only. A 402 means you exhausted included credit — reload in the portal (the error should include an upgrade URL).

View file

@ -0,0 +1,29 @@
# 4. Register a SHA-256 (create timestamp)
Two Zapier **creates**:
| Action | What happens | What you get back |
|--------|----------------|-------------------|
| **Create Timestamp (async)** | Submit hash, return immediately | `jobId`, `sha256`, `existing` |
| **Create Timestamp and Wait** | Submit hash, hold the Zap step until the certificate exists (or timeout) | `status`, `receipts[]`, `completedAt` |
## Inputs
- **Data** — raw text/bytes. Middleware hashes it with SHA-256. Prefer this when the previous Zap step has the document contents.
- **SHA256** — 64 hex characters if you already hashed the file outside Zapier (recommended for large files: hash locally, send only the digest).
- **Hash algorithm**`SHA256` (default).
Do not send the whole PDF to the blockchain. Only the digest is sealed.
## Already registered
If that SHA-256 was sealed before, you get the **original** `jobId` and certificate. A second chain write is not created. `existing: true`.
## Example Zap
1. Trigger: “New file in Google Drive”.
2. Action (optional): compute SHA-256 in a Code step, or pass file contents as **Data**.
3. Action: **Create Timestamp and Wait**.
4. Action: store `jobId`, `sha256`, and certificate in your sheet/CRM.
Simulator: scenario **Register** (wait + `includeAttached`).

View file

@ -0,0 +1,22 @@
# 5. Wait, async, and completed hooks
Timestamping is not always instant. Middleware publishes `verae.zapier.jobs.watch`; a poller waits on the chain; then `verae.zapier.jobs.events` fires. Zapier never sees those subjects.
## Choose a pattern
| Pattern | Use when |
|---------|----------|
| **Wait** | The next Zap step needs the certificate now (Slack message, PDF receipt). |
| **Async + Find Job Status** | You stored `jobId` and will poll later. |
| **Timestamp Completed trigger** | You want a second Zap to run when the seal exists (REST Hook). |
If **Wait** returns `status: pending`, the chain or NATS event did not finish inside `WAIT_TIMEOUT_MS`. Pair async creates with the **Timestamp Completed** trigger instead of raising the timeout blindly.
## Trigger setup
1. New Zap → Verae Time → **Timestamp Completed**.
2. Zapier registers a REST Hook URL with middleware (`/zapier/v1/webhooks/subscribe`).
3. When `jobs.events` says `timestamp.completed`, middleware HTTPS POSTs that URL.
4. Turning the Zap off unsubscribes the hook.
The hook payload is the same completed-job JSON as wait (seal receipt, optional attached metadata).

View file

@ -0,0 +1,24 @@
# 6. Look up a hash on the central Verae chain
Zapier search: **Find Timestamp by SHA256**.
Middleware: `GET /zapier/v1/hashes/{sha256}`.
This asks the **main Verae chain** (or its mock) whether that digest was **itemized** as its own seal.
| Result | Meaning |
|--------|---------|
| One record, `exists: true`, `itemizedOnMainChain: true` | This hash has its own certificate. Use `jobId` / `receipts[0]`. |
| Empty / `exists: false` | Not itemized on the main chain. It may still exist as a **leaf** of a bulk summary — use [tree-node lookup](09-lookup-tree-nodes.md). |
Searches in Zapier must return a list. Zero hits is a miss (the Zap can follow the “not found” path). Errors other than 404 fail the step.
## When central lookup is the right tool
- You registered with **Create Timestamp** (single item).
- You stored the `sha256` from that Zap and want to confirm the seal later.
- You are verifying someone elses **itemized** hash (they gave you 64 hex chars that they sealed individually).
## When it is the wrong tool
- The hash was submitted in **Create Batch Timestamps**. The chain stored the Merkle **root**, not this leaf. Central lookup will miss. Continue to tree nodes.

View file

@ -0,0 +1,16 @@
# 7. Attachments and metadata (not on chain)
The blockchain stores hash + time + certificate. Everything else is **off-chain WORM**:
| Field | Chain | Archive (`verae.archive.put`) |
|-------|-------|-------------------------------|
| SHA-256 | yes | key |
| Public metadata JSON | no | `kind: publicMeta` |
| Encrypted private metadata | no | `kind: privateMeta` |
| File bytes | no | `kind: file` + `contentSha256` |
Set **includeAttached** on wait/lookup when you need those records back. Middleware then broadcasts `verae.archive.query`. Each archive uses a bloom filter: **if it does not have the hash, it sends nothing**. Archives that have data reply; the aggregator merges them into `receipts[]` and `files[]`.
Bloom false positives are possible (empty reply after a maybe). False negatives must be rare.
Private metadata is only returned on authenticated archive replies. Zapier still never talks to NATS; it only sees the JSON middleware built.

View file

@ -0,0 +1,17 @@
# 8. Bulk Merkle summaries
**Create Batch Timestamps** takes one payload per line (or an `items[]` array).
Middleware:
1. SHA-256 each item (leaf).
2. Build a Merkle tree.
3. Seal **only the root** on the main Verae chain (one certificate, one `jobId`).
4. Put each leafs **inclusion proof** on a sharded **tree-node** archive (`kind: tree`).
5. Return `jobId`, `merkleRoot`, `leafCount`, and the leaf hashes.
Those member hashes are **not individually itemized** on the main chain. Looking them up with **Find Timestamp by SHA256** returns empty. That is expected.
Why batch: one chain write for thousands of documents, cheaper and faster, while each document can still prove inclusion later via its proof + the root certificate.
Simulator: scenario **Batch Merkle**, then copy the first leaf into the SHA256 field.

View file

@ -0,0 +1,31 @@
# 9. Look up a hash on tree-node archives
Zapier search: **Find Hash (tree nodes + central chain)**.
Middleware: `GET /zapier/v1/hashes/{sha256}?includeAttached=true&includeTree=true`.
## Sequence (you never configure this)
1. Central chain lookup (same as chapter 6).
2. If itemized, return that seal (and attached metadata if requested).
3. If miss, middleware broadcasts `verae.archive.query` with `kinds: ["tree", …]` to **every** tree-node NATS server.
4. A node whose bloom filter does not contain the hash **stays silent**.
5. A node that holds the leaf replies on `verae.archive.reply.<correlationId>` with `{ merkleRoot, proof, leafIndex, chainSealJobId }`.
6. Middleware checks the proof against the root, loads the **roots** chain seal, and returns both receipts.
## Reading the result in a Zap
- `exists: true` and `itemizedOnMainChain: false` — this hash was in a bulk summary.
- `proofOk: true` — the leaf really is under `merkleRoot`.
- `receipts` includes `kind: seal` (of the root) and `kind: tree-leaf` (the proof).
- `archiveId` tells you which tree node answered (for support).
If every node is silent, the hash is unknown **or** the archive fleet is down. The simulator flags “all archives silent” when puts were known to exist; operators should treat that as an outage, not a miss.
## Zap pattern
1. Search: Find Hash (tree nodes + central chain).
2. Filter / Paths:
- Found + itemized → treat as a first-class seal.
- Found + not itemized → store Merkle proof + root certificate.
- Not found → optionally **Create Timestamp** to itemize it now.

View file

@ -0,0 +1,28 @@
# 10. Reading receipts
Completed JSON (wait, hook, or lookup):
```json
{
"jobId": "…",
"status": "completed",
"sha256": "…",
"receipts": [
{ "kind": "seal", "timestamp": "…", "certificate": "…", "blockIndex": 42 },
{ "kind": "metadata-attach", "attachedAt": "…", "publicMetadata": {} },
{ "kind": "tree-leaf", "merkleRoot": "…", "proof": [], "itemizedOnMainChain": false }
],
"files": [{ "id": "invoice.pdf", "sha256": "…", "archiveId": "archive-b" }],
"archivesQueried": true,
"archiveReplies": 2
}
```
| `kind` | Meaning |
|--------|---------|
| `seal` | Chain certificate. If `of: merkleRoot`, this seal covers a batch, not the leaf itself. |
| `metadata-attach` | Public or private metadata added later. |
| `file-attach` | Off-chain file (content hash in the record). |
| `tree-leaf` | Inclusion proof for a hash that is not itemized on the main chain. |
**Verify Timestamp** takes a `certificate` string and returns `{ valid, timestamp, blockIndex }`. For a bulk leaf, verify the **root** certificate, then trust the leaf only if `proofOk` is true.

View file

@ -0,0 +1,14 @@
# 11. Errors, billing, retries
| HTTP | Zapier mapping | What you should do |
|------|----------------|--------------------|
| 400 | validation | SHA-256 must be 64 hex chars; supply Data or SHA256. |
| 401 | auth | Re-paste API key; it may have been regenerated. |
| 402 | QuotaExceeded | Reload prepaid credit in the portal (`upgradeUrl` in the error). |
| 403 | PlanUpgradeRequired | Batch / wait / tree lookup may be paid-only. |
| 404 | empty search | Not itemized — try tree-node search before creating a new seal. |
| 504 / pending | timeout | Use the Completed trigger; do not loop wait in the same step. |
Middleware retries `jobs.watch` / `jobs.events` once when NATS drops (simulator: enable **recover**). Zapier itself retries failed HTTP according to your Zap settings — keep creates **idempotent** (re-registering the same hash returns the original seal).
Delays: if the simulator flags hops over 200ms, do not raise Zapiers step timeout until chain poll interval and archive query window (`WAIT_ARCHIVE_MS`) are measured on NS1.

View file

@ -0,0 +1,9 @@
# 12. Security: what Zapier never sees
- Zapier **never** connects to NATS, tree nodes, WORM archives, or `api.veraetime.net`.
- NS1 `nats-server` stays on **127.0.0.1:4222**. Operators use `scripts/nats-tunnel.sh`; it is not a public bind.
- File bytes and private metadata never go on chain. Private metadata is only on authenticated archive replies.
- API keys are `x-api-key` / Bearer tokens on HTTPS. Treat them like passwords; regenerating kills old Zaps.
- Bloom filters are **not** an access-control list. On a hit, middleware still checks tenant/share before returning private records.
If a trace (simulator or `DEBUG_VERAE`) ever shows a `zapier-platform-app` hop with a `verae.*` subject, that is a bug — do not push the app.

View file

@ -0,0 +1,5 @@
# NATS — zapier-user-docs
This documentation repo does **not** subscribe to NATS. It describes what middleware and tree nodes do so users can choose the right Zapier search.
Users never set NATS URLs in Zapier.

View file

@ -0,0 +1,27 @@
# Verae Time for Zapier — user guide
How to go from **zero account** to **registering SHA-256 hashes**, **looking them up on the central Verae chain**, and **finding hashes that were only sealed as part of a bulk Merkle summary** on external tree-node archives.
This is the customer-facing guide (signup → Zaps). Developers: see [zapier-docs-master](https://git.georgelambert.org/marchon/zapier-docs-master) and the simulator at [verae-zapier-simulator](https://git.georgelambert.org/marchon/verae-zapier-simulator).
**Live catalog:** https://zapier.georgelambert.org/
**Clone:** `ssh://git@git.georgelambert.org:2223/marchon/zapier-user-docs.git`
## Contents
1. [What you get](01-what-you-get.md)
2. [Sign up for a Verae / zappier account](02-signup-zappier-portal.md)
3. [Connect the Zapier app](03-connect-zapier.md)
4. [Register a SHA-256 (create timestamp)](04-register-a-hash.md)
5. [Wait, async, and completed hooks](05-wait-async-hooks.md)
6. [Look up a hash on the central Verae chain](06-lookup-central-chain.md)
7. [Attachments and metadata (not on chain)](07-attachments-and-metadata.md)
8. [Bulk Merkle summaries](08-bulk-merkle-summaries.md)
9. [Look up a hash on tree-node archives](09-lookup-tree-nodes.md)
10. [Reading receipts](10-reading-receipts.md)
11. [Errors, billing, retries](11-errors-billing-retries.md)
12. [Security: what Zapier never sees](12-security.md)
## Try it without going live
The [interface simulator](https://git.georgelambert.org/marchon/verae-zapier-simulator) is a Zapier-like form plus a trace console. Run **Batch Merkle**, copy a leaf SHA-256, then **Find Timestamp by SHA256** (miss) vs **Find Hash (tree nodes)** (proof).

View file

@ -0,0 +1,9 @@
# zapier-user-docs
**Job:** Teach a customer to sign up, connect Zapier, register SHA-256s, look them up on the central Verae chain, and query tree-node archives for hashes only present in a bulk Merkle summary.
**Expects messages from:** none (static docs).
**Sends messages to:** none.
**Published on:** https://zapier.georgelambert.org/user-docs/

View file

@ -0,0 +1,6 @@
{
"name": "zapier-user-docs",
"version": "0.1.0",
"private": true,
"description": "Verae Time × Zapier user documentation from signup through hash register, central lookup, and tree-node queries"
}