Add Zapier interface simulator, tree-node Merkle lookups, and user docs
Some checks are pending
offline / test (push) Waiting to run
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:
parent
3daa88866d
commit
f3dc0e6eee
56 changed files with 2341 additions and 8 deletions
|
|
@ -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 1–6 in-process with a trace console, fault injection, and modification suggestions. It does not connect to live NATS.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
7
packages/docs-master/modules/verae-tree-node/NATS.md
Normal file
7
packages/docs-master/modules/verae-tree-node/NATS.md
Normal 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) |
|
||||
9
packages/docs-master/modules/verae-tree-node/SUMMARY.md
Normal file
9
packages/docs-master/modules/verae-tree-node/SUMMARY.md
Normal 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`.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
3
packages/docs-master/modules/zapier-user-docs/NATS.md
Normal file
3
packages/docs-master/modules/zapier-user-docs/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS — zapier-user-docs
|
||||
|
||||
Static docs. Users never configure NATS in Zapier.
|
||||
3
packages/docs-master/modules/zapier-user-docs/SUMMARY.md
Normal file
3
packages/docs-master/modules/zapier-user-docs/SUMMARY.md
Normal 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.
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
13
packages/verae-tree-node/NATS.md
Normal file
13
packages/verae-tree-node/NATS.md
Normal 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.
|
||||
7
packages/verae-tree-node/README.md
Normal file
7
packages/verae-tree-node/README.md
Normal 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`
|
||||
11
packages/verae-tree-node/SUMMARY.md
Normal file
11
packages/verae-tree-node/SUMMARY.md
Normal 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.
|
||||
8
packages/verae-tree-node/package.json
Normal file
8
packages/verae-tree-node/package.json
Normal 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" }
|
||||
}
|
||||
96
packages/verae-tree-node/src/merkle.js
Normal file
96
packages/verae-tree-node/src/merkle.js
Normal 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;
|
||||
}
|
||||
34
packages/verae-tree-node/test/merkle.test.js
Normal file
34
packages/verae-tree-node/test/merkle.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
15
packages/verae-zapier-simulator/NATS.md
Normal file
15
packages/verae-zapier-simulator/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
packages/verae-zapier-simulator/README.md
Normal file
30
packages/verae-zapier-simulator/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
packages/verae-zapier-simulator/SUMMARY.md
Normal file
9
packages/verae-zapier-simulator/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
packages/verae-zapier-simulator/package.json
Normal file
11
packages/verae-zapier-simulator/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
packages/verae-zapier-simulator/public/index.html
Normal file
206
packages/verae-zapier-simulator/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
packages/verae-zapier-simulator/src/monitors.js
Normal file
220
packages/verae-zapier-simulator/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
packages/verae-zapier-simulator/src/pipeline.js
Normal file
882
packages/verae-zapier-simulator/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
packages/verae-zapier-simulator/src/server.js
Normal file
86
packages/verae-zapier-simulator/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
packages/verae-zapier-simulator/src/trace.js
Normal file
44
packages/verae-zapier-simulator/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
packages/verae-zapier-simulator/test/pipeline.test.js
Normal file
134
packages/verae-zapier-simulator/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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
45
packages/verae-zapier/searches/tree_lookup.js
Normal file
45
packages/verae-zapier/searches/tree_lookup.js
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
19
packages/zapier-user-docs/01-what-you-get.md
Normal file
19
packages/zapier-user-docs/01-what-you-get.md
Normal 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.
|
||||
23
packages/zapier-user-docs/02-signup-zappier-portal.md
Normal file
23
packages/zapier-user-docs/02-signup-zappier-portal.md
Normal 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**.
|
||||
19
packages/zapier-user-docs/03-connect-zapier.md
Normal file
19
packages/zapier-user-docs/03-connect-zapier.md
Normal 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).
|
||||
29
packages/zapier-user-docs/04-register-a-hash.md
Normal file
29
packages/zapier-user-docs/04-register-a-hash.md
Normal 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`).
|
||||
22
packages/zapier-user-docs/05-wait-async-hooks.md
Normal file
22
packages/zapier-user-docs/05-wait-async-hooks.md
Normal 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).
|
||||
24
packages/zapier-user-docs/06-lookup-central-chain.md
Normal file
24
packages/zapier-user-docs/06-lookup-central-chain.md
Normal 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 else’s **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.
|
||||
16
packages/zapier-user-docs/07-attachments-and-metadata.md
Normal file
16
packages/zapier-user-docs/07-attachments-and-metadata.md
Normal 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.
|
||||
17
packages/zapier-user-docs/08-bulk-merkle-summaries.md
Normal file
17
packages/zapier-user-docs/08-bulk-merkle-summaries.md
Normal 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 leaf’s **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.
|
||||
31
packages/zapier-user-docs/09-lookup-tree-nodes.md
Normal file
31
packages/zapier-user-docs/09-lookup-tree-nodes.md
Normal 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 **root’s** 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.
|
||||
28
packages/zapier-user-docs/10-reading-receipts.md
Normal file
28
packages/zapier-user-docs/10-reading-receipts.md
Normal 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.
|
||||
14
packages/zapier-user-docs/11-errors-billing-retries.md
Normal file
14
packages/zapier-user-docs/11-errors-billing-retries.md
Normal 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 Zapier’s step timeout until chain poll interval and archive query window (`WAIT_ARCHIVE_MS`) are measured on NS1.
|
||||
9
packages/zapier-user-docs/12-security.md
Normal file
9
packages/zapier-user-docs/12-security.md
Normal 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.
|
||||
5
packages/zapier-user-docs/NATS.md
Normal file
5
packages/zapier-user-docs/NATS.md
Normal 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.
|
||||
27
packages/zapier-user-docs/README.md
Normal file
27
packages/zapier-user-docs/README.md
Normal 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).
|
||||
9
packages/zapier-user-docs/SUMMARY.md
Normal file
9
packages/zapier-user-docs/SUMMARY.md
Normal 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/
|
||||
6
packages/zapier-user-docs/package.json
Normal file
6
packages/zapier-user-docs/package.json
Normal 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"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue