From f3dc0e6eeed3369afb72879ca9caeeaa01865884 Mon Sep 17 00:00:00 2001 From: George Lambert Date: Fri, 11 Sep 2026 12:58:03 -0400 Subject: [PATCH] Add Zapier interface simulator, tree-node Merkle lookups, and user docs 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. --- docs/02-architecture/archive-nats.md | 1 + docs/02-architecture/modules-and-nats.md | 3 + docs/02-architecture/tree-nodes.md | 44 + packages/docs-master/MESSAGE-FLOWS.md | 15 + packages/docs-master/README.md | 6 + packages/docs-master/archive-nats.md | 1 + packages/docs-master/modules-and-nats.md | 3 + .../modules/verae-tree-node/NATS.md | 7 + .../modules/verae-tree-node/SUMMARY.md | 9 + .../modules/verae-zapier-simulator/NATS.md | 3 + .../modules/verae-zapier-simulator/SUMMARY.md | 7 + .../modules/zapier-user-docs/NATS.md | 3 + .../modules/zapier-user-docs/SUMMARY.md | 3 + .../verae-archive-aggregator/src/aggregate.js | 21 +- .../test/aggregate.test.js | 16 + packages/verae-tree-node/NATS.md | 13 + packages/verae-tree-node/README.md | 7 + packages/verae-tree-node/SUMMARY.md | 11 + packages/verae-tree-node/package.json | 8 + packages/verae-tree-node/src/merkle.js | 96 ++ packages/verae-tree-node/test/merkle.test.js | 34 + .../src/nats/subjects.js | 4 + .../src/routes/hashRoutes.js | 4 +- .../src/services/timestampService.js | 14 +- packages/verae-zapier-simulator/NATS.md | 15 + packages/verae-zapier-simulator/README.md | 30 + packages/verae-zapier-simulator/SUMMARY.md | 9 + packages/verae-zapier-simulator/package.json | 11 + .../verae-zapier-simulator/public/index.html | 206 ++++ .../verae-zapier-simulator/src/monitors.js | 220 +++++ .../verae-zapier-simulator/src/pipeline.js | 882 ++++++++++++++++++ packages/verae-zapier-simulator/src/server.js | 86 ++ packages/verae-zapier-simulator/src/trace.js | 44 + .../test/pipeline.test.js | 134 +++ packages/verae-zapier/NATS.md | 2 +- packages/verae-zapier/index.js | 2 + packages/verae-zapier/searches/tree_lookup.js | 45 + packages/verae-zapier/test/app.test.js | 1 + packages/zapier-user-docs/01-what-you-get.md | 19 + .../02-signup-zappier-portal.md | 23 + .../zapier-user-docs/03-connect-zapier.md | 19 + .../zapier-user-docs/04-register-a-hash.md | 29 + .../zapier-user-docs/05-wait-async-hooks.md | 22 + .../06-lookup-central-chain.md | 24 + .../07-attachments-and-metadata.md | 16 + .../08-bulk-merkle-summaries.md | 17 + .../zapier-user-docs/09-lookup-tree-nodes.md | 31 + .../zapier-user-docs/10-reading-receipts.md | 28 + .../11-errors-billing-retries.md | 14 + packages/zapier-user-docs/12-security.md | 9 + packages/zapier-user-docs/NATS.md | 5 + packages/zapier-user-docs/README.md | 27 + packages/zapier-user-docs/SUMMARY.md | 9 + packages/zapier-user-docs/package.json | 6 + scripts/build-docs-site.py | 25 + scripts/push-module-repos.sh | 6 + 56 files changed, 2341 insertions(+), 8 deletions(-) create mode 100644 docs/02-architecture/tree-nodes.md create mode 100644 packages/docs-master/modules/verae-tree-node/NATS.md create mode 100644 packages/docs-master/modules/verae-tree-node/SUMMARY.md create mode 100644 packages/docs-master/modules/verae-zapier-simulator/NATS.md create mode 100644 packages/docs-master/modules/verae-zapier-simulator/SUMMARY.md create mode 100644 packages/docs-master/modules/zapier-user-docs/NATS.md create mode 100644 packages/docs-master/modules/zapier-user-docs/SUMMARY.md create mode 100644 packages/verae-tree-node/NATS.md create mode 100644 packages/verae-tree-node/README.md create mode 100644 packages/verae-tree-node/SUMMARY.md create mode 100644 packages/verae-tree-node/package.json create mode 100644 packages/verae-tree-node/src/merkle.js create mode 100644 packages/verae-tree-node/test/merkle.test.js create mode 100644 packages/verae-zapier-simulator/NATS.md create mode 100644 packages/verae-zapier-simulator/README.md create mode 100644 packages/verae-zapier-simulator/SUMMARY.md create mode 100644 packages/verae-zapier-simulator/package.json create mode 100644 packages/verae-zapier-simulator/public/index.html create mode 100644 packages/verae-zapier-simulator/src/monitors.js create mode 100644 packages/verae-zapier-simulator/src/pipeline.js create mode 100644 packages/verae-zapier-simulator/src/server.js create mode 100644 packages/verae-zapier-simulator/src/trace.js create mode 100644 packages/verae-zapier-simulator/test/pipeline.test.js create mode 100644 packages/verae-zapier/searches/tree_lookup.js create mode 100644 packages/zapier-user-docs/01-what-you-get.md create mode 100644 packages/zapier-user-docs/02-signup-zappier-portal.md create mode 100644 packages/zapier-user-docs/03-connect-zapier.md create mode 100644 packages/zapier-user-docs/04-register-a-hash.md create mode 100644 packages/zapier-user-docs/05-wait-async-hooks.md create mode 100644 packages/zapier-user-docs/06-lookup-central-chain.md create mode 100644 packages/zapier-user-docs/07-attachments-and-metadata.md create mode 100644 packages/zapier-user-docs/08-bulk-merkle-summaries.md create mode 100644 packages/zapier-user-docs/09-lookup-tree-nodes.md create mode 100644 packages/zapier-user-docs/10-reading-receipts.md create mode 100644 packages/zapier-user-docs/11-errors-billing-retries.md create mode 100644 packages/zapier-user-docs/12-security.md create mode 100644 packages/zapier-user-docs/NATS.md create mode 100644 packages/zapier-user-docs/README.md create mode 100644 packages/zapier-user-docs/SUMMARY.md create mode 100644 packages/zapier-user-docs/package.json diff --git a/docs/02-architecture/archive-nats.md b/docs/02-architecture/archive-nats.md index a50e0b2..aa1f906 100644 --- a/docs/02-architecture/archive-nats.md +++ b/docs/02-architecture/archive-nats.md @@ -54,6 +54,7 @@ If later attach jobs exist for that hash, `receipts` is an array: seal first, th | `verae.archive.reply.` | 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. diff --git a/docs/02-architecture/modules-and-nats.md b/docs/02-architecture/modules-and-nats.md index cfc3bac..d27d4ff 100644 --- a/docs/02-architecture/modules-and-nats.md +++ b/docs/02-architecture/modules-and-nats.md @@ -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.` | | archive-worm | `verae-archive-worm` | N copies | `verae.archive.query`, `verae.archive.put` | `verae.archive.reply.` if bloom hits | +| tree-node | `verae-tree-node` | N copies (WORM role) | `verae.archive.query`, `verae.archive.put` kind `tree` | `verae.archive.reply.` 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 | diff --git a/docs/02-architecture/tree-nodes.md b/docs/02-architecture/tree-nodes.md new file mode 100644 index 0000000..bd3a69d --- /dev/null +++ b/docs/02-architecture/tree-nodes.md @@ -0,0 +1,44 @@ +# Tree nodes: bulk Merkle summaries vs itemized chain seals + +The main Verae chain stores **hash + time + block + certificate**. A Zap that timestamps one document writes that document’s SHA-256 as its own seal. + +A **batch** of many documents is different: middleware builds a Merkle tree of the item hashes, seals **only the Merkle root** on the chain, and writes each **leaf proof** to external tree-node NATS archives. A later lookup of a member hash will **miss** on the central chain unless the Zap also queries tree nodes. + +```text +Zapier HTTPS zappier-edge HTTPS middleware + | + Merkle root | verae.archive.put kind=tree + (chain seal) | (leaf proof, sharded) + v + tree-node-east/west/central + bloom miss = silence + ^ + hash lookup includeTree | verae.archive.query + | kinds=["tree"] + aggregator merges proof + root seal +``` + +## When to use which Zapier search + +| Search | Hits | +|--------|------| +| Find Timestamp by SHA256 | Hash was registered as its own chain seal | +| Find Hash (tree nodes + central chain) | Chain first, then `includeTree=true` broadcast | + +## Receipt shape for a bulk leaf + +```json +{ + "sha256": "", + "exists": true, + "itemizedOnMainChain": false, + "merkleRoot": "", + "proofOk": true, + "receipts": [ + { "kind": "seal", "of": "merkleRoot", "sha256": "", "certificate": "…" }, + { "kind": "tree-leaf", "archiveId": "tree-node-east", "proof": […], "leafIndex": 1 } + ] +} +``` + +Validate this path in the simulator (`packages/verae-zapier-simulator`) before pushing the Zapier app. diff --git a/packages/docs-master/MESSAGE-FLOWS.md b/packages/docs-master/MESSAGE-FLOWS.md index 66fe0af..5bec06e 100644 --- a/packages/docs-master/MESSAGE-FLOWS.md +++ b/packages/docs-master/MESSAGE-FLOWS.md @@ -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.` 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. diff --git a/packages/docs-master/README.md b/packages/docs-master/README.md index e62ec8d..1f3f5e2 100644 --- a/packages/docs-master/README.md +++ b/packages/docs-master/README.md @@ -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 diff --git a/packages/docs-master/archive-nats.md b/packages/docs-master/archive-nats.md index a50e0b2..aa1f906 100644 --- a/packages/docs-master/archive-nats.md +++ b/packages/docs-master/archive-nats.md @@ -54,6 +54,7 @@ If later attach jobs exist for that hash, `receipts` is an array: seal first, th | `verae.archive.reply.` | 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. diff --git a/packages/docs-master/modules-and-nats.md b/packages/docs-master/modules-and-nats.md index cfc3bac..d27d4ff 100644 --- a/packages/docs-master/modules-and-nats.md +++ b/packages/docs-master/modules-and-nats.md @@ -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.` | | archive-worm | `verae-archive-worm` | N copies | `verae.archive.query`, `verae.archive.put` | `verae.archive.reply.` if bloom hits | +| tree-node | `verae-tree-node` | N copies (WORM role) | `verae.archive.query`, `verae.archive.put` kind `tree` | `verae.archive.reply.` 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 | diff --git a/packages/docs-master/modules/verae-tree-node/NATS.md b/packages/docs-master/modules/verae-tree-node/NATS.md new file mode 100644 index 0000000..f9298ee --- /dev/null +++ b/packages/docs-master/modules/verae-tree-node/NATS.md @@ -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.` | aggregator | only if bloom.mightHave(sha256) | diff --git a/packages/docs-master/modules/verae-tree-node/SUMMARY.md b/packages/docs-master/modules/verae-tree-node/SUMMARY.md new file mode 100644 index 0000000..ab2b882 --- /dev/null +++ b/packages/docs-master/modules/verae-tree-node/SUMMARY.md @@ -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.` on bloom hit. + +**Does not talk to Zapier.** Same bloom-silence rule as `verae-archive-worm`. diff --git a/packages/docs-master/modules/verae-zapier-simulator/NATS.md b/packages/docs-master/modules/verae-zapier-simulator/NATS.md new file mode 100644 index 0000000..bf6e66f --- /dev/null +++ b/packages/docs-master/modules/verae-zapier-simulator/NATS.md @@ -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. diff --git a/packages/docs-master/modules/verae-zapier-simulator/SUMMARY.md b/packages/docs-master/modules/verae-zapier-simulator/SUMMARY.md new file mode 100644 index 0000000..3c186ff --- /dev/null +++ b/packages/docs-master/modules/verae-zapier-simulator/SUMMARY.md @@ -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. diff --git a/packages/docs-master/modules/zapier-user-docs/NATS.md b/packages/docs-master/modules/zapier-user-docs/NATS.md new file mode 100644 index 0000000..2f31dbe --- /dev/null +++ b/packages/docs-master/modules/zapier-user-docs/NATS.md @@ -0,0 +1,3 @@ +# NATS — zapier-user-docs + +Static docs. Users never configure NATS in Zapier. diff --git a/packages/docs-master/modules/zapier-user-docs/SUMMARY.md b/packages/docs-master/modules/zapier-user-docs/SUMMARY.md new file mode 100644 index 0000000..63f9088 --- /dev/null +++ b/packages/docs-master/modules/zapier-user-docs/SUMMARY.md @@ -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. diff --git a/packages/verae-archive-aggregator/src/aggregate.js b/packages/verae-archive-aggregator/src/aggregate.js index bdc308e..cc6c6d0 100644 --- a/packages/verae-archive-aggregator/src/aggregate.js +++ b/packages/verae-archive-aggregator/src/aggregate.js @@ -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]; } diff --git a/packages/verae-archive-aggregator/test/aggregate.test.js b/packages/verae-archive-aggregator/test/aggregate.test.js index 0953521..8027f6e 100644 --- a/packages/verae-archive-aggregator/test/aggregate.test.js +++ b/packages/verae-archive-aggregator/test/aggregate.test.js @@ -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'); + }); }); diff --git a/packages/verae-tree-node/NATS.md b/packages/verae-tree-node/NATS.md new file mode 100644 index 0000000..b531cef --- /dev/null +++ b/packages/verae-tree-node/NATS.md @@ -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.` | 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. diff --git a/packages/verae-tree-node/README.md b/packages/verae-tree-node/README.md new file mode 100644 index 0000000..cca1ec6 --- /dev/null +++ b/packages/verae-tree-node/README.md @@ -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` diff --git a/packages/verae-tree-node/SUMMARY.md b/packages/verae-tree-node/SUMMARY.md new file mode 100644 index 0000000..e31f461 --- /dev/null +++ b/packages/verae-tree-node/SUMMARY.md @@ -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.` 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. diff --git a/packages/verae-tree-node/package.json b/packages/verae-tree-node/package.json new file mode 100644 index 0000000..4b9654d --- /dev/null +++ b/packages/verae-tree-node/package.json @@ -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" } +} diff --git a/packages/verae-tree-node/src/merkle.js b/packages/verae-tree-node/src/merkle.js new file mode 100644 index 0000000..84a96ea --- /dev/null +++ b/packages/verae-tree-node/src/merkle.js @@ -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; +} diff --git a/packages/verae-tree-node/test/merkle.test.js b/packages/verae-tree-node/test/merkle.test.js new file mode 100644 index 0000000..42f793f --- /dev/null +++ b/packages/verae-tree-node/test/merkle.test.js @@ -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); + }); +}); diff --git a/packages/verae-zapier-middleware/src/nats/subjects.js b/packages/verae-zapier-middleware/src/nats/subjects.js index 4e92c08..b308b7f 100644 --- a/packages/verae-zapier-middleware/src/nats/subjects.js +++ b/packages/verae-zapier-middleware/src/nats/subjects.js @@ -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', }); /** diff --git a/packages/verae-zapier-middleware/src/routes/hashRoutes.js b/packages/verae-zapier-middleware/src/routes/hashRoutes.js index a6f8d8a..2ec8a48 100644 --- a/packages/verae-zapier-middleware/src/routes/hashRoutes.js +++ b/packages/verae-zapier-middleware/src/routes/hashRoutes.js @@ -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); }), ); diff --git a/packages/verae-zapier-middleware/src/services/timestampService.js b/packages/verae-zapier-middleware/src/services/timestampService.js index a3b69c6..bf110af 100644 --- a/packages/verae-zapier-middleware/src/services/timestampService.js +++ b/packages/verae-zapier-middleware/src/services/timestampService.js @@ -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, + }; } diff --git a/packages/verae-zapier-simulator/NATS.md b/packages/verae-zapier-simulator/NATS.md new file mode 100644 index 0000000..63b8b06 --- /dev/null +++ b/packages/verae-zapier-simulator/NATS.md @@ -0,0 +1,15 @@ +# NATS — verae-zapier-simulator + +The simulator does **not** subscribe to live NATS. It replays the same addresses in-process so a trace console can validate messaging before the Zapier app is pushed. + +| Simulated address | Publisher (in trace) | Subscriber (in trace) | +|-------------------|----------------------|------------------------| +| HTTPS Zapier → zappier-edge → middleware | zapier-platform-app | zappier-edge | +| `verae.zapier.jobs.watch` | middleware-http | job-poller | +| `verae.zapier.jobs.events` | job-poller | middleware wait, webhook-deliver | +| `verae.zapier.webhooks.deliver` | middleware | webhook-deliver → HTTPS Zapier hook | +| `verae.archive.put` | splitter / merkle builder | archive-* / tree-node-* | +| `verae.archive.query` | archive-aggregator (broadcast) | every WORM and tree node | +| `verae.archive.reply.` | node on bloom hit only | aggregator | + +Live NATS stays on NS1 loopback. Do not point this UI at a public NATS URL. diff --git a/packages/verae-zapier-simulator/README.md b/packages/verae-zapier-simulator/README.md new file mode 100644 index 0000000..eb7faed --- /dev/null +++ b/packages/verae-zapier-simulator/README.md @@ -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` diff --git a/packages/verae-zapier-simulator/SUMMARY.md b/packages/verae-zapier-simulator/SUMMARY.md new file mode 100644 index 0000000..d0cba97 --- /dev/null +++ b/packages/verae-zapier-simulator/SUMMARY.md @@ -0,0 +1,9 @@ +# verae-zapier-simulator + +**Job:** Fake the Zapier editor + a trace console from input to response, including every module hop, so messaging, assumptions, delays, failures, and recoveries can be proven **before** `zapier-platform push`. + +**Expects:** operator HTTP to `:3847` (or `Simulator.run` in tests). Uses real `splitRequest`, `WormArchive`, `aggregateAttached`, and Merkle helpers. + +**Sends:** nothing to Zapier cloud, NATS, or `api.veraetime.net`. + +**Test:** `npm test` — register, central lookup, bulk leaf via tree nodes, 402, NATS drop+retry, bloom silence, Zapier-never-NATS. diff --git a/packages/verae-zapier-simulator/package.json b/packages/verae-zapier-simulator/package.json new file mode 100644 index 0000000..9a99655 --- /dev/null +++ b/packages/verae-zapier-simulator/package.json @@ -0,0 +1,11 @@ +{ + "name": "verae-zapier-simulator", + "version": "0.1.0", + "type": "module", + "description": "Zapier interface simulator with hop-by-hop trace console (errors, delays, recoveries, suggestions)", + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + }, + "engines": { "node": ">=20" } +} diff --git a/packages/verae-zapier-simulator/public/index.html b/packages/verae-zapier-simulator/public/index.html new file mode 100644 index 0000000..12b5961 --- /dev/null +++ b/packages/verae-zapier-simulator/public/index.html @@ -0,0 +1,206 @@ + + + + + + Verae × Zapier simulator + + + +
+
+
simulator · not live Zapier
+

Verae Time × Zapier trace console

+
+

Every hop from Zap input → zappier-edge → middleware → splitter → chain / WORM / tree nodes → Zap output. NATS addresses are in-process. Loopback only.

+
+
+ +
+
+

Response to Zapier

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

Monitors & suggested modifications

+
+
+
+
+ + + diff --git a/packages/verae-zapier-simulator/src/monitors.js b/packages/verae-zapier-simulator/src/monitors.js new file mode 100644 index 0000000..8944176 --- /dev/null +++ b/packages/verae-zapier-simulator/src/monitors.js @@ -0,0 +1,220 @@ +/** + * Scan a trace for assumption breaks, delays, failures, recoveries, + * and suggest changes before the Zapier app goes live. + * @module monitors + */ + +const ASSUMPTIONS = [ + { + id: 'zapier-no-nats', + title: 'Zapier never publishes or subscribes to NATS', + check(events) { + const bad = events.filter( + (e) => + e.module === 'zapier-platform-app' && + (String(e.address).startsWith('verae.') || String(e.hop).includes('nats')), + ); + return bad.length + ? { ok: false, detail: `Zapier hop used NATS address ${bad[0].address}` } + : { ok: true, detail: 'Zapier hops are HTTPS only' }; + }, + }, + { + id: 'chain-no-bytes', + title: 'Main chain never receives file bytes or private metadata', + check(events) { + const chain = events.filter((e) => e.module === 'verae-chain-client' && e.payload); + for (const e of chain) { + const p = e.payload; + if (p.contentBase64 || p.files || p.privateMetadata) { + return { ok: false, detail: `chain payload included ${Object.keys(p).join(',')}` }; + } + } + return { ok: true, detail: 'chain payloads are hash/root only' }; + }, + }, + { + id: 'bloom-silence', + title: 'Bloom miss produces no archive.reply packet', + check(events) { + const leaks = events.filter( + (e) => e.module.startsWith('archive-') || e.module.startsWith('tree-node'), + ).filter((e) => e.status === 'silent' && e.address.includes('archive.reply')); + return leaks.length + ? { ok: false, detail: 'silent hop still addressed a reply subject' } + : { ok: true, detail: 'misses logged as silent with no reply address' }; + }, + }, + { + id: 'archive-query-broadcast', + title: 'archive.query is broadcast (not a shared queue group)', + check(events) { + const queries = events.filter((e) => e.address === 'verae.archive.query'); + const replies = events.filter((e) => String(e.address).startsWith('verae.archive.reply.')); + if (!queries.length) return { ok: true, detail: 'no archive query this run' }; + return { ok: true, detail: `${queries.length} query hop(s), ${replies.length} reply hop(s)` }; + }, + }, +]; + +const DELAY_MS = 200; + +/** + * @param {object[]} events + * @param {object} [ctx] + */ +export function analyze(events, ctx = {}) { + const findings = []; + const suggestions = []; + + for (const a of ASSUMPTIONS) { + const r = a.check(events); + findings.push({ + kind: 'assumption', + id: a.id, + title: a.title, + ok: r.ok, + detail: r.detail, + }); + } + + const errors = events.filter((e) => e.status === 'error'); + const retries = events.filter((e) => e.status === 'retry'); + const delays = events.filter((e) => e.status === 'delay' || (e.durationMs || 0) >= DELAY_MS); + + for (const e of errors) { + findings.push({ + kind: 'error', + id: `err-${e.seq}`, + title: `${e.module} ${e.hop} failed`, + ok: false, + detail: e.note || e.address, + }); + } + for (const e of delays) { + findings.push({ + kind: 'delay', + id: `delay-${e.seq}`, + title: `${e.module} took ${e.durationMs || e.tMs}ms`, + ok: false, + detail: e.note || 'exceeds 200ms hop budget used for Zapier wait steps', + }); + } + if (retries.length) { + findings.push({ + kind: 'recovery', + id: 'retries', + title: `${retries.length} recovery retry hop(s)`, + ok: true, + detail: retries.map((e) => `${e.module}:${e.hop}`).join(', '), + }); + } + + const last = events[events.length - 1]; + if (last?.status === 'error' && retries.length === 0) { + suggestions.push({ + id: 'add-retry', + severity: 'high', + text: 'Add a single retry on jobs.watch / jobs.events drop before returning pending to Zapier. Zapier will otherwise mark the step failed with no jobId recovery path.', + }); + } + + const lookup = events.find((e) => e.hop === 'hash_lookup' || e.module === 'hash-lookup'); + const treeQuery = events.some((e) => e.payload?.kinds?.includes?.('tree') || e.hop === 'tree_query'); + const chainMiss = events.some( + (e) => e.module === 'verae-chain-client' && e.hop === 'lookup' && e.status === 'silent', + ); + if (chainMiss && !treeQuery) { + suggestions.push({ + id: 'tree-lookup-action', + severity: 'high', + text: 'Central chain miss for a SHA-256 that may live only in a bulk Merkle summary. Add Zapier search “Find Hash (tree nodes)” that sets includeTree=true so middleware broadcasts verae.archive.query kinds=["tree"].', + }); + } + + const puts = events.filter((e) => e.address === 'verae.archive.put'); + const queries = events.filter((e) => e.address === 'verae.archive.query'); + if (puts.length && !queries.length && ctx.includeAttached) { + suggestions.push({ + id: 'query-after-put', + severity: 'high', + text: 'Archive puts ran but wait path never published verae.archive.query. Wire aggregator into createTimestampAndWait when includeAttached is true.', + }); + } + if (puts.length && !ctx.includeAttached && ctx.action !== 'batch_timestamp') { + suggestions.push({ + id: 'include-attached-default', + severity: 'medium', + text: 'Metadata/files were stored off-chain but the Zapier step did not request includeAttached. Document the flag and default it on for “Create Timestamp and Wait”.', + }); + } + + const billing = events.find((e) => e.payload?.status === 402 || e.note?.includes?.('402')); + if (billing) { + suggestions.push({ + id: '402-upgrade-url', + severity: 'medium', + text: 'Map HTTP 402 to Zapier QuotaExceeded and always include details.upgradeUrl from zappier-edge so the user can reload credit without leaving the Zap editor.', + }); + } + + const waitPending = events.some((e) => e.payload?.status === 'pending' && e.hop === 'wait'); + if (waitPending) { + suggestions.push({ + id: 'wait-timeout', + severity: 'medium', + text: 'Wait returned pending. Pair every Create Timestamp (async) Zap with the Timestamp Completed REST Hook, or raise WAIT_TIMEOUT_MS only after measuring chain poll interval.', + }); + } + + const natsFromZapier = events.filter( + (e) => e.module === 'zapier-platform-app' && String(e.address).startsWith('verae.'), + ); + if (natsFromZapier.length) { + suggestions.push({ + id: 'never-bind-nats', + severity: 'high', + text: 'Zapier cloud must not reach NATS. Keep NS1 nats-server on 127.0.0.1:4222; only middleware workers subscribe.', + }); + } + + if (ctx.action === 'batch_timestamp') { + suggestions.push({ + id: 'batch-docs', + severity: 'low', + text: 'User docs must say batch items are not individually itemized on the main chain. Lookup of a member hash requires the tree-node search, not Find Timestamp by SHA256 alone.', + }); + } + + const silentAll = + queries.length > 0 && + !events.some((e) => String(e.address).startsWith('verae.archive.reply.') && e.status === 'ok'); + if (silentAll && (ctx.includeAttached || ctx.includeTree)) { + findings.push({ + kind: 'error', + id: 'all-archives-silent', + title: 'All archives/tree nodes silent during query', + ok: false, + detail: 'Aggregator completed with zero replies. Distinguish “hash unknown” from “archives unreachable”.', + }); + suggestions.push({ + id: 'archive-quorum', + severity: 'high', + text: 'Treat “zero replies + known puts” as archive outage, not a miss. Require at least one reply OR an explicit bloom-miss count from a live node heartbeat.', + }); + } + + const ok = findings.filter((f) => f.ok === false).length === 0; + return { + ok, + findings, + suggestions, + stats: { + hops: events.length, + errors: errors.length, + retries: retries.length, + delays: delays.length, + natsHops: events.filter((e) => String(e.address).startsWith('verae.')).length, + }, + }; +} diff --git a/packages/verae-zapier-simulator/src/pipeline.js b/packages/verae-zapier-simulator/src/pipeline.js new file mode 100644 index 0000000..adc8c14 --- /dev/null +++ b/packages/verae-zapier-simulator/src/pipeline.js @@ -0,0 +1,882 @@ +/** + * In-process Zapier → zappier-edge → middleware → splitter → chain / WORM / tree-nodes. + * Every hop is traced. Faults inject delay, drop, fail, then optional recovery. + * @module pipeline + */ + +import { randomUUID } from 'node:crypto'; +import { splitRequest } from '../../verae-request-splitter/src/splitRequest.js'; +import { WormArchive } from '../../verae-archive-worm/src/archive.js'; +import { aggregateAttached, mergeReceipts } from '../../verae-archive-aggregator/src/aggregate.js'; +import { + sha256Hex, + buildMerkle, + merkleProof, + verifyProof, + shardIndex, +} from '../../verae-tree-node/src/merkle.js'; +import { TraceBus } from './trace.js'; +import { analyze } from './monitors.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export const ACTIONS = [ + { key: 'signup', label: 'Sign up (zappier portal)', group: 'account' }, + { key: 'connect_zapier', label: 'Connect Zapier app (API key)', group: 'account' }, + { key: 'create_timestamp', label: 'Create Timestamp (async)', group: 'zap' }, + { key: 'timestamp_and_wait', label: 'Create Timestamp and Wait', group: 'zap' }, + { key: 'hash_lookup', label: 'Find Timestamp by SHA256 (central chain)', group: 'zap' }, + { key: 'tree_lookup', label: 'Find Hash (tree nodes + central)', group: 'zap' }, + { key: 'batch_timestamp', label: 'Create Batch Timestamps (Merkle summary)', group: 'zap' }, + { key: 'verify_timestamp', label: 'Verify Timestamp', group: 'zap' }, +]; + +export class Simulator { + constructor() { + this.reset(); + } + + reset() { + this.trace = new TraceBus(); + this.accounts = []; + this.connected = null; + this.chain = new Map(); // sha256 -> seal + this.jobs = new Map(); + this.archives = [new WormArchive('archive-a'), new WormArchive('archive-b'), new WormArchive('archive-c')]; + this.treeNodes = [ + new WormArchive('tree-node-east'), + new WormArchive('tree-node-west'), + new WormArchive('tree-node-central'), + ]; + this.hooks = []; + this.meteredCents = 0; + } + + snapshot() { + return { + accounts: this.accounts.map((a) => ({ email: a.email, plan: a.plan, apiKey: a.apiKey })), + connected: this.connected, + chainHashes: [...this.chain.keys()], + jobs: [...this.jobs.values()].map((j) => ({ jobId: j.jobId, sha256: j.sha256, status: j.status })), + meteredCents: this.meteredCents, + }; + } + + /** + * @param {{ action: string, input?: object, faults?: object, includeAttached?: boolean, includeTree?: boolean }} req + */ + async run(req) { + const action = req.action; + const input = req.input || {}; + const faults = req.faults || {}; + const includeAttached = Boolean(req.includeAttached ?? input.includeAttached); + const includeTree = Boolean(req.includeTree ?? input.includeTree); + this.trace = new TraceBus(); + const t = this.trace; + + t.emit({ + module: 'zapier-platform-app', + hop: 'perform', + dir: 'in', + address: `zapier://action/${action}`, + payload: { action, input: redact(input) }, + note: 'Zapier cloud perform(z, bundle). HTTPS only.', + }); + + try { + if (action === 'signup') { + const out = this.#signup(input); + return this.#finish(action, out, { includeAttached, includeTree }); + } + if (action === 'connect_zapier') { + const out = this.#connect(input); + return this.#finish(action, out, { includeAttached, includeTree }); + } + + await this.#edgeAuth(faults); + await this.#meter(action, faults); + + let response; + switch (action) { + case 'create_timestamp': + response = await this.#timestamp(input, { wait: false, includeAttached, faults }); + break; + case 'timestamp_and_wait': + response = await this.#timestamp(input, { wait: true, includeAttached, faults }); + break; + case 'hash_lookup': + response = await this.#lookup(input, { includeAttached, includeTree: false, faults }); + break; + case 'tree_lookup': + response = await this.#lookup(input, { includeAttached: true, includeTree: true, faults }); + break; + case 'batch_timestamp': + response = await this.#batch(input, { faults }); + break; + case 'verify_timestamp': + response = await this.#verify(input); + break; + default: + throw Object.assign(new Error(`unknown action ${action}`), { status: 400 }); + } + + t.emit({ + module: 'zapier-platform-app', + hop: 'output', + dir: 'out', + address: `zapier://action/${action}`, + payload: redact(response), + note: 'Returned to Zap step / task history', + }); + return this.#finish(action, response, { includeAttached, includeTree }); + } catch (err) { + const status = err.status || 500; + t.emit({ + module: 'zapier-platform-app', + hop: 'error', + dir: 'out', + status: 'error', + payload: { status, error: err.message, code: err.code }, + note: err.note || err.message, + }); + return this.#finish(action, { error: err.message, status, code: err.code }, { includeAttached, includeTree }); + } + } + + #finish(action, response, flags) { + const monitor = analyze(this.trace.events, { action, ...flags }); + return { + action, + response, + trace: this.trace.events, + monitor, + state: this.snapshot(), + }; + } + + #signup(input) { + const email = String(input.email || 'user@example.com'); + const plan = input.plan || 'free'; + const apiKey = `zpk_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + this.accounts.push({ email, plan, apiKey, passwordSet: true }); + this.trace.emit({ + module: 'zappier-edge', + hop: 'portal_signup', + address: 'HTTPS POST /portal/signup', + payload: { email, plan, apiKey }, + note: 'Customer portal creates account + API key. No NATS.', + }); + return { email, plan, apiKey, next: 'Paste apiKey into Zapier app authentication (x-api-key).' }; + } + + #connect(input) { + const key = input.apiKey || this.accounts.at(-1)?.apiKey; + const acct = this.accounts.find((a) => a.apiKey === key); + if (!acct) { + this.trace.emit({ + module: 'zappier-edge', + hop: 'auth', + status: 'error', + address: 'HTTPS GET /v1/whoami', + note: 'Unknown API key', + }); + const err = new Error('Invalid API key'); + err.status = 401; + err.code = 'UNAUTHENTICATED'; + throw err; + } + this.connected = { email: acct.email, plan: acct.plan, apiKey: key }; + this.trace.emit({ + module: 'zapier-platform-app', + hop: 'test_auth', + address: 'HTTPS GET zappier-edge/v1/whoami', + payload: { email: acct.email, plan: acct.plan }, + note: 'Zapier “Test authentication” — still HTTPS only.', + }); + return { connected: true, email: acct.email, plan: acct.plan }; + } + + async #edgeAuth(faults) { + if (faults.failEdge401) { + this.trace.emit({ + module: 'zappier-edge', + hop: 'auth', + status: 'error', + address: 'HTTPS', + payload: { status: 401 }, + note: 'x-api-key rejected', + }); + const err = new Error('unauthenticated'); + err.status = 401; + throw err; + } + if (!this.connected) { + this.connected = { email: 'sim@local', plan: 'pro', apiKey: 'zpk_sim' }; + } + this.trace.emit({ + module: 'zappier-edge', + hop: 'auth', + address: 'HTTPS x-api-key', + payload: { plan: this.connected.plan }, + }); + } + + async #meter(action, faults) { + if (faults.failEdge402) { + this.trace.emit({ + module: 'zappier-edge', + hop: 'meter', + status: 'error', + address: 'HTTPS 402', + payload: { status: 402, upgradeUrl: 'https://zappier.example/portal/billing' }, + note: '402 quota exceeded', + }); + const err = new Error('Quota exceeded'); + err.status = 402; + err.code = 'QuotaExceeded'; + err.note = '402'; + throw err; + } + const cents = action === 'batch_timestamp' ? 25 : 8; + this.meteredCents += cents; + this.trace.emit({ + module: 'zappier-edge', + hop: 'meter', + address: 'HTTPS → middleware', + payload: { cents, stripeMeter: 'zappier.api_cents' }, + note: 'Rate card × tier − monthly credit', + }); + } + + async #timestamp(input, { wait, includeAttached, faults }) { + const t = this.trace; + t.emit({ + module: 'middleware-http', + hop: wait ? 'POST /zapier/v1/timestamp/wait' : 'POST /zapier/v1/timestamp', + dir: 'in', + address: wait ? 'HTTPS /zapier/v1/timestamp/wait' : 'HTTPS /zapier/v1/timestamp', + payload: redact(input), + }); + + const split = splitRequest({ + data: input.data, + sha256: input.sha256, + publicMetadata: input.publicMetadata, + privateMetadata: input.privateMetadata, + files: input.files, + includeAttached, + }); + t.emit({ + module: 'verae-request-splitter', + hop: 'splitRequest', + address: 'in-process', + payload: { + chain: split.chain, + archivePuts: split.archivePuts.map((p) => ({ kind: p.kind, sha256: p.sha256 })), + includeAttached: split.includeAttached, + }, + note: 'Chain gets hash only. Files/meta → archive.put', + }); + + await this.#archivePuts(split.archivePuts, input.tenantId || 'tenant-sim', faults); + + const existing = this.chain.get(split.chain.sha256); + let job; + if (existing) { + job = { ...existing, existing: true }; + t.emit({ + module: 'verae-chain-client', + hop: 'lookup', + address: 'HTTPS api.veraetime.net (MOCK)', + payload: { sha256: split.chain.sha256, jobId: existing.jobId }, + note: 'Already sealed — original receipt, no second write', + }); + } else { + await this.#maybeDelay(faults, 'chainMs', 'verae-chain-client'); + if (faults.failChainTimeout && !faults.recover) { + t.emit({ + module: 'verae-chain-client', + hop: 'create', + status: 'error', + address: 'HTTPS api.veraetime.net', + note: 'chain timeout', + }); + const err = new Error('chain timeout'); + err.status = 504; + throw err; + } + if (faults.failChainTimeout && faults.recover) { + t.emit({ + module: 'verae-chain-client', + hop: 'create', + status: 'retry', + address: 'HTTPS api.veraetime.net', + note: 'timeout then retry succeeded', + }); + } + job = this.#seal(split.chain.sha256); + t.emit({ + module: 'verae-chain-client', + hop: 'create', + address: 'HTTPS api.veraetime.net (MOCK)', + payload: { sha256: split.chain.sha256, jobId: job.jobId, blockIndex: job.blockIndex }, + }); + } + + const watch = { + tenantId: 'tenant-sim', + jobId: job.jobId, + tokenRef: 'tok_sim', + enqueuedAt: new Date().toISOString(), + attempt: 1, + maxAttempts: 8, + intervalMs: 500, + traceId: job.jobId, + }; + + if (faults.failNatsDropWatch && !faults.recover) { + t.emit({ + module: 'middleware-http', + hop: 'publish', + status: 'error', + address: 'verae.zapier.jobs.watch', + note: 'NATS publish dropped', + }); + const err = new Error('NATS publish failed'); + err.status = 503; + throw err; + } + if (faults.failNatsDropWatch && faults.recover) { + t.emit({ + module: 'middleware-http', + hop: 'publish', + status: 'retry', + address: 'verae.zapier.jobs.watch', + note: 'first publish dropped; retried', + }); + } + t.emit({ + module: 'middleware-http', + hop: 'publish', + dir: 'out', + address: 'verae.zapier.jobs.watch', + payload: watch, + }); + t.emit({ + module: 'job-poller', + hop: 'consume', + dir: 'in', + address: 'verae.zapier.jobs.watch', + payload: { jobId: job.jobId }, + }); + t.emit({ + module: 'job-poller', + hop: 'GET status', + address: 'HTTPS api.veraetime.net/status', + payload: { jobId: job.jobId, status: 'completed' }, + }); + + const event = { + event: 'timestamp.completed', + tenantId: 'tenant-sim', + jobId: job.jobId, + status: 'completed', + traceId: job.jobId, + emittedAt: new Date().toISOString(), + }; + if (faults.failNatsDropEvents && !faults.recover) { + t.emit({ + module: 'job-poller', + hop: 'publish', + status: 'error', + address: 'verae.zapier.jobs.events', + note: 'events drop — waiter will time out', + }); + } else { + if (faults.failNatsDropEvents && faults.recover) { + t.emit({ + module: 'job-poller', + hop: 'publish', + status: 'retry', + address: 'verae.zapier.jobs.events', + note: 'events drop recovered', + }); + } + t.emit({ + module: 'job-poller', + hop: 'publish', + dir: 'out', + address: 'verae.zapier.jobs.events', + payload: event, + }); + } + + if (!wait) { + t.emit({ + module: 'middleware-http', + hop: 'response', + dir: 'out', + address: 'HTTPS 202', + payload: { jobId: job.jobId, sha256: split.chain.sha256, existing: Boolean(existing) }, + }); + return { jobId: job.jobId, sha256: split.chain.sha256, existing: Boolean(existing), status: 'accepted' }; + } + + const droppedEvents = faults.failNatsDropEvents && !faults.recover; + if (droppedEvents) { + t.emit({ + module: 'middleware-http', + hop: 'wait', + status: 'delay', + durationMs: 250, + payload: { status: 'pending', jobId: job.jobId }, + note: 'WAIT_TIMEOUT_MS — no jobs.events', + }); + return { jobId: job.jobId, status: 'pending' }; + } + + t.emit({ + module: 'middleware-http', + hop: 'wait', + dir: 'in', + address: 'verae.zapier.jobs.events', + payload: { jobId: job.jobId, status: 'completed' }, + }); + + let receipts = [ + { + kind: 'seal', + timestamp: job.timestamp, + certificate: job.certificate, + blockIndex: job.blockIndex, + jobId: job.jobId, + sha256: split.chain.sha256, + }, + ]; + let files = []; + let archivesQueried = false; + let archiveReplies = 0; + + if (includeAttached) { + const agg = await this.#queryArchives(split.chain.sha256, { + kinds: ['publicMeta', 'privateMeta', 'file'], + faults, + archives: this.archives, + }); + archivesQueried = true; + archiveReplies = agg.archiveReplies; + receipts = mergeReceipts(receipts, agg.records); + files = agg.records.filter((r) => r.kind === 'file').map((r) => ({ + id: r.record?.path, + sha256: r.record?.contentSha256, + archiveId: r.archiveId, + })); + } + + const body = { + jobId: job.jobId, + status: 'completed', + sha256: split.chain.sha256, + existing: Boolean(existing), + receipts, + files, + archivesQueried, + archiveReplies, + }; + if (this.hooks.length) { + t.emit({ + module: 'webhook-deliver', + hop: 'POST', + address: 'verae.zapier.webhooks.deliver', + payload: { targetUrl: this.hooks[0], event: 'timestamp.completed', jobId: job.jobId }, + note: 'HTTPS POST Zapier REST Hook — not NATS to Zapier', + }); + } + t.emit({ + module: 'middleware-http', + hop: 'response', + dir: 'out', + address: 'HTTPS 200', + payload: { jobId: body.jobId, status: body.status, receiptCount: receipts.length }, + }); + return body; + } + + async #lookup(input, { includeAttached, includeTree, faults }) { + const t = this.trace; + const sha = String(input.sha256 || (input.data != null ? sha256Hex(input.data) : '')).toLowerCase(); + if (!/^[a-f0-9]{64}$/.test(sha)) { + const err = new Error('sha256 must be 64 hex chars'); + err.status = 400; + throw err; + } + t.emit({ + module: 'middleware-http', + hop: 'hash_lookup', + address: `HTTPS GET /zapier/v1/hashes/${sha.slice(0, 8)}…?includeAttached=${includeAttached}&includeTree=${includeTree}`, + payload: { sha256: sha, includeAttached, includeTree }, + }); + + const seal = this.chain.get(sha); + if (seal) { + t.emit({ + module: 'verae-chain-client', + hop: 'lookup', + address: 'HTTPS api.veraetime.net (MOCK)', + payload: { sha256: sha, jobId: seal.jobId, itemizedOnMainChain: true }, + }); + let receipts = [ + { + kind: 'seal', + timestamp: seal.timestamp, + certificate: seal.certificate, + blockIndex: seal.blockIndex, + jobId: seal.jobId, + sha256: sha, + }, + ]; + let archiveReplies = 0; + if (includeAttached) { + const agg = await this.#queryArchives(sha, { + kinds: ['publicMeta', 'privateMeta', 'file'], + faults, + archives: this.archives, + }); + archiveReplies = agg.archiveReplies; + receipts = mergeReceipts(receipts, agg.records); + } + return { + sha256: sha, + exists: true, + itemizedOnMainChain: true, + jobId: seal.jobId, + receipts, + archiveReplies, + }; + } + + t.emit({ + module: 'verae-chain-client', + hop: 'lookup', + status: 'silent', + address: 'HTTPS api.veraetime.net (MOCK)', + payload: { sha256: sha, exists: false }, + note: 'Not itemized on the main Verae chain', + }); + + if (!includeTree) { + t.emit({ + module: 'middleware-http', + hop: 'response', + dir: 'out', + address: 'HTTPS 404', + payload: { exists: false }, + note: 'Tree-node query not requested — leaf in a bulk summary would be missed', + }); + return { sha256: sha, exists: false, itemizedOnMainChain: false, receipts: [] }; + } + + const agg = await this.#queryArchives(sha, { + kinds: ['tree', 'publicMeta', 'privateMeta', 'file'], + faults, + archives: [...this.treeNodes, ...this.archives], + hop: 'tree_query', + }); + const treeRecs = agg.records.filter((r) => r.kind === 'tree'); + if (!treeRecs.length) { + t.emit({ + module: 'middleware-http', + hop: 'response', + dir: 'out', + address: 'HTTPS 404', + payload: { exists: false, archivesQueried: true, archiveReplies: agg.archiveReplies, silent: agg.silent }, + }); + return { + sha256: sha, + exists: false, + itemizedOnMainChain: false, + archivesQueried: true, + archiveReplies: agg.archiveReplies, + silent: agg.silent, + receipts: [], + }; + } + + const rec = treeRecs[0]; + const root = rec.record.merkleRoot; + const rootSeal = this.chain.get(root); + const proofOk = verifyProof(sha, rec.record.proof, root); + t.emit({ + module: 'verae-tree-node', + hop: 'verify_proof', + payload: { leaf: sha, merkleRoot: root, proofOk, chainSealJobId: rec.record.chainSealJobId }, + note: proofOk + ? 'Leaf included in bulk summary sealed on main chain' + : 'Proof failed — do not trust this tree record', + }); + const receipts = mergeReceipts( + rootSeal + ? [ + { + kind: 'seal', + of: 'merkleRoot', + sha256: root, + timestamp: rootSeal.timestamp, + certificate: rootSeal.certificate, + blockIndex: rootSeal.blockIndex, + jobId: rootSeal.jobId, + }, + ] + : [], + treeRecs, + ); + return { + sha256: sha, + exists: true, + itemizedOnMainChain: false, + merkleRoot: root, + proofOk, + chainSealJobId: rec.record.chainSealJobId, + archiveId: rec.archiveId, + receipts, + archivesQueried: true, + archiveReplies: agg.archiveReplies, + silent: agg.silent, + }; + } + + async #batch(input, { faults }) { + const t = this.trace; + const lines = Array.isArray(input.items) + ? input.items + : String(input.items || '') + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + if (!lines.length) { + const err = new Error('items required'); + err.status = 400; + throw err; + } + t.emit({ + module: 'middleware-http', + hop: 'POST /zapier/v1/timestamp/batch', + dir: 'in', + address: 'HTTPS /zapier/v1/timestamp/batch', + payload: { count: lines.length }, + }); + const leaves = lines.map((d) => sha256Hex(d)); + const tree = buildMerkle(leaves); + t.emit({ + module: 'verae-tree-node', + hop: 'buildMerkle', + payload: { leaves: leaves.length, root: tree.root }, + note: 'Only the root will be itemized on the main Verae chain', + }); + + await this.#maybeDelay(faults, 'chainMs', 'verae-chain-client'); + const job = this.#seal(tree.root); + job.kind = 'merkle-root'; + job.leafCount = leaves.length; + t.emit({ + module: 'verae-chain-client', + hop: 'create', + address: 'HTTPS api.veraetime.net (MOCK)', + payload: { sha256: tree.root, jobId: job.jobId, kind: 'merkle-root', leafCount: leaves.length }, + note: 'Individual item hashes are NOT written to the chain', + }); + + for (let i = 0; i < leaves.length; i += 1) { + const leaf = leaves[i]; + const idx = shardIndex(leaf, this.treeNodes.length); + const node = this.treeNodes[idx]; + const record = { + merkleRoot: tree.root, + proof: merkleProof(tree.layers, i), + leafIndex: i, + chainSealJobId: job.jobId, + item: lines[i].length > 80 ? `${lines[i].slice(0, 80)}…` : lines[i], + }; + node.put({ sha256: leaf, kind: 'tree', record }); + t.emit({ + module: node.archiveId, + hop: 'put', + dir: 'in', + address: 'verae.archive.put', + payload: { kind: 'tree', sha256: leaf, archiveId: node.archiveId, leafIndex: i }, + }); + } + + t.emit({ + module: 'middleware-http', + hop: 'publish', + dir: 'out', + address: 'verae.zapier.jobs.watch', + payload: { jobId: job.jobId }, + }); + t.emit({ + module: 'job-poller', + hop: 'publish', + dir: 'out', + address: 'verae.zapier.jobs.events', + payload: { event: 'timestamp.completed', jobId: job.jobId }, + }); + t.emit({ + module: 'middleware-http', + hop: 'response', + dir: 'out', + address: 'HTTPS 202', + payload: { jobId: job.jobId, merkleRoot: tree.root, leafCount: leaves.length }, + }); + return { + jobId: job.jobId, + merkleRoot: tree.root, + leafCount: leaves.length, + leaves, + status: 'accepted', + note: 'Look up a member hash with Find Hash (tree nodes), not central-chain lookup alone.', + }; + } + + #verify(input) { + const cert = String(input.certificate || ''); + const found = [...this.chain.values()].find((s) => s.certificate === cert); + this.trace.emit({ + module: 'verae-chain-client', + hop: 'verify', + address: 'HTTPS api.veraetime.net/verify (MOCK)', + payload: { valid: Boolean(found), blockIndex: found?.blockIndex }, + }); + return found + ? { valid: true, timestamp: found.timestamp, blockIndex: found.blockIndex, sha256: found.sha256 } + : { valid: false }; + } + + async #archivePuts(puts, tenantId, faults) { + const t = this.trace; + for (const p of puts) { + if (faults.failArchivePut) { + t.emit({ + module: 'archive-b', + hop: 'put', + status: 'error', + address: 'verae.archive.put', + note: 'archive put failed', + }); + continue; + } + const idx = shardIndex(p.sha256 + p.kind, this.archives.length); + const node = this.archives[idx]; + node.put({ sha256: p.sha256, kind: p.kind, record: p.record }); + t.emit({ + module: node.archiveId, + hop: 'put', + dir: 'in', + address: 'verae.archive.put', + payload: { kind: p.kind, sha256: p.sha256, tenantId, archiveId: node.archiveId }, + }); + } + } + + async #queryArchives(sha256, { kinds, faults, archives, hop }) { + const t = this.trace; + const correlationId = randomUUID(); + await this.#maybeDelay(faults, 'archiveMs', 'archive-aggregator'); + t.emit({ + module: 'archive-aggregator', + hop: hop || 'query', + dir: 'out', + address: 'verae.archive.query', + payload: { correlationId, sha256, tenantId: 'tenant-sim', kinds, traceId: correlationId }, + note: 'Broadcast to every archive/tree node — not a queue group', + }); + + const wrapped = archives.map((arch) => ({ + archiveId: arch.archiveId, + query: (sha) => { + if (faults.failArchiveAllSilent) return null; + if (faults.failArchiveOneDown && arch.archiveId.endsWith('west')) { + t.emit({ + module: arch.archiveId, + hop: 'down', + status: 'error', + note: 'node unreachable', + }); + return null; + } + const hit = arch.query(sha); + if (hit == null) { + t.emit({ + module: arch.archiveId, + hop: 'bloom', + status: 'silent', + payload: { sha256: sha, mightHave: false }, + note: 'bloom miss — no packet sent', + }); + return null; + } + const filtered = hit.filter((r) => !kinds.length || kinds.includes(r.kind)); + t.emit({ + module: arch.archiveId, + hop: 'reply', + dir: 'out', + address: `verae.archive.reply.${correlationId}`, + payload: { archiveId: arch.archiveId, sha256: sha, records: filtered.length }, + }); + return filtered; + }, + })); + + const agg = aggregateAttached({ sha256, tenantId: 'tenant-sim', kinds, archives: wrapped }); + t.emit({ + module: 'archive-aggregator', + hop: 'merge', + payload: { archiveReplies: agg.archiveReplies, silent: agg.silent, records: agg.records.length }, + }); + return agg; + } + + #seal(sha256) { + const jobId = randomUUID(); + const seal = { + jobId, + sha256, + status: 'completed', + timestamp: new Date().toISOString(), + certificate: `mock-cert-${jobId.slice(0, 8)}`, + blockIndex: this.chain.size + 1, + }; + this.chain.set(sha256, seal); + this.jobs.set(jobId, seal); + return seal; + } + + async #maybeDelay(faults, key, module) { + const ms = Number(faults[key] || 0); + if (ms > 0) { + const t0 = Date.now(); + await sleep(ms); + this.trace.emit({ + module, + hop: 'delay', + status: 'delay', + durationMs: Date.now() - t0, + note: `injected ${key}=${ms}ms`, + }); + } + } +} + +function redact(obj) { + if (!obj || typeof obj !== 'object') return obj; + const out = Array.isArray(obj) ? [...obj] : { ...obj }; + if (out.privateMetadata) out.privateMetadata = { _redacted: true }; + if (out.files) { + out.files = out.files.map((f) => ({ path: f.path || f.filename, size: f.contentBase64?.length || 0 })); + } + if (out.contentBase64) out.contentBase64 = `[${out.contentBase64.length} b64]`; + if (out.password) out.password = '***'; + return out; +} + +export { sha256Hex }; diff --git a/packages/verae-zapier-simulator/src/server.js b/packages/verae-zapier-simulator/src/server.js new file mode 100644 index 0000000..e9c0a43 --- /dev/null +++ b/packages/verae-zapier-simulator/src/server.js @@ -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`); +}); diff --git a/packages/verae-zapier-simulator/src/trace.js b/packages/verae-zapier-simulator/src/trace.js new file mode 100644 index 0000000..6bf633b --- /dev/null +++ b/packages/verae-zapier-simulator/src/trace.js @@ -0,0 +1,44 @@ +/** + * Ordered hop log from Zapier input through every module to the response. + * @module trace + */ + +export class TraceBus { + constructor() { + this.events = []; + this.t0 = Date.now(); + } + + now() { + return Date.now() - this.t0; + } + + /** + * @param {{ + * module: string, + * hop: string, + * dir?: 'in'|'out'|'internal', + * address?: string, + * status?: 'ok'|'silent'|'error'|'retry'|'delay'|'skip', + * payload?: object, + * note?: string, + * durationMs?: number + * }} e + */ + emit(e) { + const ev = { + seq: this.events.length + 1, + tMs: this.now(), + module: e.module, + hop: e.hop, + dir: e.dir || 'internal', + address: e.address || '', + status: e.status || 'ok', + payload: e.payload ?? null, + note: e.note || '', + durationMs: e.durationMs ?? 0, + }; + this.events.push(ev); + return ev; + } +} diff --git a/packages/verae-zapier-simulator/test/pipeline.test.js b/packages/verae-zapier-simulator/test/pipeline.test.js new file mode 100644 index 0000000..9dcc727 --- /dev/null +++ b/packages/verae-zapier-simulator/test/pipeline.test.js @@ -0,0 +1,134 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { Simulator, sha256Hex } from '../src/pipeline.js'; + +describe('Zapier simulator pipeline', () => { + it('signup then connect then register+wait with metadata', async () => { + const s = new Simulator(); + const sign = await s.run({ action: 'signup', input: { email: 'ada@example.com', plan: 'pro' } }); + assert.ok(sign.response.apiKey.startsWith('zpk_')); + const conn = await s.run({ action: 'connect_zapier', input: { apiKey: sign.response.apiKey } }); + assert.equal(conn.response.connected, true); + const wait = await s.run({ + action: 'timestamp_and_wait', + includeAttached: true, + input: { data: 'hello from zapier', publicMetadata: { source: 'zap' } }, + }); + assert.equal(wait.response.status, 'completed'); + assert.equal(wait.response.receipts[0].kind, 'seal'); + assert.ok(wait.response.receipts.some((r) => r.kind === 'metadata-attach')); + assert.ok(wait.trace.some((e) => e.address === 'verae.zapier.jobs.watch')); + assert.ok(wait.trace.some((e) => e.address === 'verae.zapier.jobs.events')); + assert.ok(wait.trace.some((e) => e.address === 'verae.archive.put')); + assert.ok(wait.trace.some((e) => e.address === 'verae.archive.query')); + const zapNats = wait.trace.filter( + (e) => e.module === 'zapier-platform-app' && String(e.address).startsWith('verae.'), + ); + assert.equal(zapNats.length, 0); + assert.equal(wait.monitor.findings.find((f) => f.id === 'zapier-no-nats').ok, true); + assert.equal(wait.monitor.findings.find((f) => f.id === 'chain-no-bytes').ok, true); + }); + + it('async create returns jobId; second register returns original seal', async () => { + const s = new Simulator(); + const a = await s.run({ action: 'create_timestamp', input: { data: 'once' } }); + assert.equal(a.response.status, 'accepted'); + const b = await s.run({ action: 'create_timestamp', input: { data: 'once' } }); + assert.equal(b.response.existing, true); + assert.equal(b.response.jobId, a.response.jobId); + }); + + it('central lookup finds itemized hash and misses a bulk leaf', async () => { + const s = new Simulator(); + const reg = await s.run({ action: 'timestamp_and_wait', input: { data: 'solo' } }); + const hit = await s.run({ action: 'hash_lookup', input: { sha256: reg.response.sha256 } }); + assert.equal(hit.response.exists, true); + assert.equal(hit.response.itemizedOnMainChain, true); + + const batch = await s.run({ + action: 'batch_timestamp', + input: { items: 'invoice-1001\ninvoice-1002\ninvoice-1003' }, + }); + assert.equal(batch.response.leafCount, 3); + const leaf = batch.response.leaves[0]; + assert.equal(leaf, sha256Hex('invoice-1001')); + const miss = await s.run({ action: 'hash_lookup', input: { sha256: leaf } }); + assert.equal(miss.response.exists, false); + assert.ok(miss.monitor.suggestions.some((x) => x.id === 'tree-lookup-action')); + }); + + it('tree lookup proves a leaf that is only in the bulk summary', async () => { + const s = new Simulator(); + const batch = await s.run({ + action: 'batch_timestamp', + input: { items: ['alpha-doc', 'beta-doc', 'gamma-doc'] }, + }); + const leaf = batch.response.leaves[1]; + const found = await s.run({ action: 'tree_lookup', input: { sha256: leaf } }); + assert.equal(found.response.exists, true); + assert.equal(found.response.itemizedOnMainChain, false); + assert.equal(found.response.proofOk, true); + assert.equal(found.response.merkleRoot, batch.response.merkleRoot); + assert.ok(found.response.receipts.some((r) => r.kind === 'tree-leaf')); + assert.ok(found.response.receipts.some((r) => r.kind === 'seal' && r.of === 'merkleRoot')); + assert.ok(found.trace.some((e) => e.status === 'silent')); + assert.ok(found.trace.some((e) => String(e.address).startsWith('verae.archive.reply.'))); + const bloomLeak = found.trace.filter((e) => e.status === 'silent' && String(e.address).includes('archive.reply')); + assert.equal(bloomLeak.length, 0); + }); + + it('402 quota is traced and suggested', async () => { + const s = new Simulator(); + const r = await s.run({ action: 'create_timestamp', input: { data: 'x' }, faults: { failEdge402: true } }); + assert.equal(r.response.status, 402); + assert.ok(r.monitor.suggestions.some((x) => x.id === '402-upgrade-url')); + }); + + it('NATS events drop then recovery retry', async () => { + const s = new Simulator(); + const fail = await s.run({ + action: 'timestamp_and_wait', + input: { data: 'drop' }, + faults: { failNatsDropEvents: true }, + }); + assert.equal(fail.response.status, 'pending'); + assert.ok(fail.monitor.suggestions.some((x) => x.id === 'wait-timeout' || x.id === 'add-retry')); + + const ok = await s.run({ + action: 'timestamp_and_wait', + input: { data: 'drop-recover' }, + faults: { failNatsDropEvents: true, recover: true }, + }); + assert.equal(ok.response.status, 'completed'); + assert.ok(ok.trace.some((e) => e.status === 'retry')); + }); + + it('chain delay is monitored', async () => { + const s = new Simulator(); + const r = await s.run({ + action: 'create_timestamp', + input: { data: 'slow' }, + faults: { chainMs: 220 }, + }); + assert.equal(r.response.status, 'accepted'); + assert.ok(r.monitor.findings.some((f) => f.kind === 'delay' && f.ok === false)); + }); + + it('files never appear on the chain hop payload', async () => { + const s = new Simulator(); + const r = await s.run({ + action: 'timestamp_and_wait', + includeAttached: true, + input: { + data: 'with-file', + files: [{ path: 'a.txt', data: 'bytes' }], + }, + }); + const chain = r.trace.filter((e) => e.module === 'verae-chain-client'); + for (const e of chain) { + assert.equal(e.payload?.files, undefined); + assert.equal(e.payload?.contentBase64, undefined); + } + assert.ok(r.response.files.length >= 1); + }); +}); diff --git a/packages/verae-zapier/NATS.md b/packages/verae-zapier/NATS.md index b04ff77..3254376 100644 --- a/packages/verae-zapier/NATS.md +++ b/packages/verae-zapier/NATS.md @@ -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. diff --git a/packages/verae-zapier/index.js b/packages/verae-zapier/index.js index c73a3f6..8727990 100644 --- a/packages/verae-zapier/index.js +++ b/packages/verae-zapier/index.js @@ -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, }, }; diff --git a/packages/verae-zapier/searches/tree_lookup.js b/packages/verae-zapier/searches/tree_lookup.js new file mode 100644 index 0000000..a1ba223 --- /dev/null +++ b/packages/verae-zapier/searches/tree_lookup.js @@ -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, + }, + }, +}; diff --git a/packages/verae-zapier/test/app.test.js b/packages/verae-zapier/test/app.test.js index 558d738..0c160d1 100644 --- a/packages/verae-zapier/test/app.test.js +++ b/packages/verae-zapier/test/app.test.js @@ -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); }); diff --git a/packages/zapier-user-docs/01-what-you-get.md b/packages/zapier-user-docs/01-what-you-get.md new file mode 100644 index 0000000..f7ddf8c --- /dev/null +++ b/packages/zapier-user-docs/01-what-you-get.md @@ -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. diff --git a/packages/zapier-user-docs/02-signup-zappier-portal.md b/packages/zapier-user-docs/02-signup-zappier-portal.md new file mode 100644 index 0000000..dff70fd --- /dev/null +++ b/packages/zapier-user-docs/02-signup-zappier-portal.md @@ -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**. diff --git a/packages/zapier-user-docs/03-connect-zapier.md b/packages/zapier-user-docs/03-connect-zapier.md new file mode 100644 index 0000000..e94e74b --- /dev/null +++ b/packages/zapier-user-docs/03-connect-zapier.md @@ -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). diff --git a/packages/zapier-user-docs/04-register-a-hash.md b/packages/zapier-user-docs/04-register-a-hash.md new file mode 100644 index 0000000..7910de7 --- /dev/null +++ b/packages/zapier-user-docs/04-register-a-hash.md @@ -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`). diff --git a/packages/zapier-user-docs/05-wait-async-hooks.md b/packages/zapier-user-docs/05-wait-async-hooks.md new file mode 100644 index 0000000..10a0081 --- /dev/null +++ b/packages/zapier-user-docs/05-wait-async-hooks.md @@ -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). diff --git a/packages/zapier-user-docs/06-lookup-central-chain.md b/packages/zapier-user-docs/06-lookup-central-chain.md new file mode 100644 index 0000000..0cff10b --- /dev/null +++ b/packages/zapier-user-docs/06-lookup-central-chain.md @@ -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. diff --git a/packages/zapier-user-docs/07-attachments-and-metadata.md b/packages/zapier-user-docs/07-attachments-and-metadata.md new file mode 100644 index 0000000..c4608e5 --- /dev/null +++ b/packages/zapier-user-docs/07-attachments-and-metadata.md @@ -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. diff --git a/packages/zapier-user-docs/08-bulk-merkle-summaries.md b/packages/zapier-user-docs/08-bulk-merkle-summaries.md new file mode 100644 index 0000000..83710e9 --- /dev/null +++ b/packages/zapier-user-docs/08-bulk-merkle-summaries.md @@ -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. diff --git a/packages/zapier-user-docs/09-lookup-tree-nodes.md b/packages/zapier-user-docs/09-lookup-tree-nodes.md new file mode 100644 index 0000000..61695ce --- /dev/null +++ b/packages/zapier-user-docs/09-lookup-tree-nodes.md @@ -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.` 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. diff --git a/packages/zapier-user-docs/10-reading-receipts.md b/packages/zapier-user-docs/10-reading-receipts.md new file mode 100644 index 0000000..6ea16ec --- /dev/null +++ b/packages/zapier-user-docs/10-reading-receipts.md @@ -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. diff --git a/packages/zapier-user-docs/11-errors-billing-retries.md b/packages/zapier-user-docs/11-errors-billing-retries.md new file mode 100644 index 0000000..bf9e1f2 --- /dev/null +++ b/packages/zapier-user-docs/11-errors-billing-retries.md @@ -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. diff --git a/packages/zapier-user-docs/12-security.md b/packages/zapier-user-docs/12-security.md new file mode 100644 index 0000000..fe3af6d --- /dev/null +++ b/packages/zapier-user-docs/12-security.md @@ -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. diff --git a/packages/zapier-user-docs/NATS.md b/packages/zapier-user-docs/NATS.md new file mode 100644 index 0000000..e9f2d63 --- /dev/null +++ b/packages/zapier-user-docs/NATS.md @@ -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. diff --git a/packages/zapier-user-docs/README.md b/packages/zapier-user-docs/README.md new file mode 100644 index 0000000..5e84f0c --- /dev/null +++ b/packages/zapier-user-docs/README.md @@ -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). diff --git a/packages/zapier-user-docs/SUMMARY.md b/packages/zapier-user-docs/SUMMARY.md new file mode 100644 index 0000000..5868f33 --- /dev/null +++ b/packages/zapier-user-docs/SUMMARY.md @@ -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/ diff --git a/packages/zapier-user-docs/package.json b/packages/zapier-user-docs/package.json new file mode 100644 index 0000000..4764bec --- /dev/null +++ b/packages/zapier-user-docs/package.json @@ -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" +} diff --git a/scripts/build-docs-site.py b/scripts/build-docs-site.py index aa9bcba..51b8b81 100755 --- a/scripts/build-docs-site.py +++ b/scripts/build-docs-site.py @@ -31,6 +31,17 @@ SECTIONS = [ ("docs/02-architecture/nats-subjects.md", "NATS subjects"), ("docs/02-architecture/archive-nats.md", "Archive NATS, bloom, multi-receipt"), ("docs/02-architecture/modules-and-nats.md", "Module catalog and NATS addresses"), + ("docs/02-architecture/tree-nodes.md", "Tree nodes and bulk Merkle summaries"), + ], + ), + ( + "User guide (signup → lookup)", + [ + ("packages/zapier-user-docs/README.md", "User guide index"), + ("packages/zapier-user-docs/02-signup-zappier-portal.md", "Sign up"), + ("packages/zapier-user-docs/04-register-a-hash.md", "Register a SHA-256"), + ("packages/zapier-user-docs/06-lookup-central-chain.md", "Central chain lookup"), + ("packages/zapier-user-docs/09-lookup-tree-nodes.md", "Tree-node lookup"), ], ), ( @@ -125,6 +136,9 @@ def main() -> None: "verae-request-splitter", "verae-archive-worm", "verae-archive-aggregator", + "verae-tree-node", + "verae-zapier-simulator", + "zapier-user-docs", "docs-master", ): readme = ROOT / "packages" / pkg / "README.md" @@ -141,6 +155,10 @@ def main() -> None: copy_tree(src, SITE / src.name if src.parent == ROOT else SITE / "docs" / "OPEN.md") copy_tree(ROOT / "docs" / "WORK-LOG.md", SITE / "docs" / "WORK-LOG.md") copy_tree(ROOT / "packages" / "docs-master", SITE / "docs-master") + copy_tree(ROOT / "packages" / "zapier-user-docs", SITE / "user-docs", ignore=shutil.ignore_patterns("node_modules")) + sim_pub = ROOT / "packages" / "verae-zapier-simulator" / "public" / "index.html" + if sim_pub.exists(): + copy_tree(sim_pub, SITE / "simulator" / "index.html") # research markdown + diagrams only r = ROOT / "research" / "zapier" @@ -197,6 +215,8 @@ def main() -> None: ("docs-master/README.md", "Master summaries (docs-master)"), ("docs-master/MESSAGE-FLOWS.md", "Numbered message flows"), ("docs-master/modules-and-nats.md", "NATS address table"), + ("user-docs/README.md", "User guide index"), + ("packages/verae-zapier-simulator/README.md", "Simulator README"), ] git_repos = [ @@ -209,6 +229,9 @@ def main() -> None: ("verae-archive-worm", "Bloom-filtered WORM archive node"), ("verae-archive-aggregator", "Archive reply aggregator"), ("zapier-docs-master", "Master summaries and NATS contracts"), + ("verae-tree-node", "Merkle leaf proofs (bulk summaries)"), + ("verae-zapier-simulator", "Zapier interface + trace console"), + ("zapier-user-docs", "Signup-to-usage user guide"), ] git_lis = "".join( f'
  • {name} ' @@ -264,6 +287,8 @@ def main() -> None:
  • archive-nats.md
  • composition.md
  • Per-module SUMMARY.md and NATS.md
  • +
  • User guide (signup → tree-node lookup)
  • +
  • Simulator README
  • diff --git a/scripts/push-module-repos.sh b/scripts/push-module-repos.sh index 42b2317..204d9cf 100755 --- a/scripts/push-module-repos.sh +++ b/scripts/push-module-repos.sh @@ -43,6 +43,9 @@ create verae-request-splitter "Split hash vs attachments for chain vs WORM" create verae-archive-worm "Bloom-filtered WORM archive node" create verae-archive-aggregator "Aggregate archive replies into job JSON" create zapier-docs-master "Master summaries, NATS contracts, message flows" +create verae-tree-node "Merkle leaf proofs for hashes only sealed as a bulk summary" +create verae-zapier-simulator "Zapier interface simulator with hop-by-hop trace console" +create zapier-user-docs "User docs from signup through hash register, chain lookup, tree-node query" push_dir "$ROOT/packages/zappier" zappier-edge push_dir "$ROOT/packages/verae-zapier-middleware" verae-middleware @@ -52,5 +55,8 @@ push_dir "$ROOT/packages/verae-request-splitter" verae-request-splitter push_dir "$ROOT/packages/verae-archive-worm" verae-archive-worm push_dir "$ROOT/packages/verae-archive-aggregator" verae-archive-aggregator push_dir "$ROOT/packages/docs-master" zapier-docs-master +push_dir "$ROOT/packages/verae-tree-node" verae-tree-node +push_dir "$ROOT/packages/verae-zapier-simulator" verae-zapier-simulator +push_dir "$ROOT/packages/zapier-user-docs" zapier-user-docs echo ALL_MODULE_REPOS_PUSHED