From 220559d257d501dcecdafad8a4d3cafd24d9629c Mon Sep 17 00:00:00 2001 From: Andrew Birkholz Date: Tue, 15 Sep 2026 12:16:47 -0600 Subject: [PATCH 1/2] Bring the Zapier app to parity with the middleware /zapier/v1 contract Zapier cloud runs this app against verae-middleware over HTTPS with a customer zmw_ key; the middleware owns keys, plans, job watching, webhooks, and the api.veraetime.net client. This commit makes the app cover the whole middleware surface and match its documented behaviour. New operations - creates: batch_verify (POST /verify/batch), batch_status (POST /status/batch), get_receipt (GET /receipts/{jobId}, optional PDF via z.stashFile) - searches: job_verification (GET /status/{jobId}/verification), usage (GET /auth/me -> plan and month-to-date usage vs limits) - triggers: timestamp_failed REST Hook alongside timestamp_completed Behaviour changes - Create Timestamp / Create Timestamp and Wait accept Data or a 64-hex SHA256, plus optional public/private metadata dicts, per the user guide. - Searches return [] on 404 instead of failing the Zap; other errors carry the middleware message. - Hook items get id = jobId so Zapier can dedupe; subscribeData is { id, event }; performUnsubscribe accepts the legacy bare id. - The API key is sent as Authorization: Bearer and as x-api-key so the app works through the middleware today and the access planes / billing edge that forward only x-api-key. - afterResponse maps every middleware error body; QUOTA_EXCEEDED, PLAN_UPGRADE_REQUIRED and BATCH_LIMIT_EXCEEDED include the upgrade URL; 429 becomes a ThrottledError. - MIDDLEWARE_PATH_PREFIX (default /zapier/v1) for a fronting edge. Shared code - lib/middleware.js: base URL, error parsing, 404-as-no-match search helper, timestamp input/body builder, shared samples and output fields. - lib/hookTrigger.js: REST Hook factory. Repo health - Remove creates/add_numbers.js, which required a file from the sibling verae-activate package and prevented the app from loading. - Fix the npm test glob for Node 22; add .gitignore and package-lock.json. - Version 1.1.0. Tests and docs - test/app.test.js rewritten: 25 unit tests with a fake z, no network. - test/e2e.test.js: opt-in run through zapier-platform-core against a live middleware (MIDDLEWARE_BASE_URL + MIDDLEWARE_API_KEY); plan-aware and E2E_SKIP_HOOK_DELIVERY for the lab. Passes on pro and free tenants. - README: module table, live-lab origins and which are usable, env, and an "open items" status section; SUMMARY and NATS updated. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 7 + NATS.md | 11 +- README.md | 148 +++++- SUMMARY.md | 8 +- authentication.js | 17 +- creates/add_numbers.js | 2 - creates/batch_status.js | 73 +++ creates/batch_timestamp.js | 40 +- creates/batch_verify.js | 67 +++ creates/create_timestamp.js | 41 +- creates/get_receipt.js | 76 +++ creates/timestamp_and_wait.js | 59 +-- creates/verify_timestamp.js | 20 +- index.js | 74 +-- lib/hookTrigger.js | 87 ++++ lib/middleware.js | 233 +++++++++ package-lock.json | 819 ++++++++++++++++++++++++++++++++ package.json | 5 +- searches/hash_lookup.js | 41 +- searches/job_status.js | 29 +- searches/job_verification.js | 29 ++ searches/tree_lookup.js | 38 +- searches/usage.js | 73 +++ test/app.test.js | 441 ++++++++++++++--- test/e2e.test.js | 202 ++++++++ triggers/timestamp_completed.js | 60 +-- triggers/timestamp_failed.js | 21 + 27 files changed, 2416 insertions(+), 305 deletions(-) create mode 100644 .gitignore delete mode 100644 creates/add_numbers.js create mode 100644 creates/batch_status.js create mode 100644 creates/batch_verify.js create mode 100644 creates/get_receipt.js create mode 100644 lib/hookTrigger.js create mode 100644 lib/middleware.js create mode 100644 package-lock.json create mode 100644 searches/job_verification.js create mode 100644 searches/usage.js create mode 100644 test/e2e.test.js create mode 100644 triggers/timestamp_failed.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f49048 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +build/ +.env +.env.* +.zapierapprc +*.log +.DS_Store diff --git a/NATS.md b/NATS.md index 3254376..38749c8 100644 --- a/NATS.md +++ b/NATS.md @@ -5,6 +5,13 @@ 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}`, `/hashes/{sha256}?includeTree=true`, REST Hook subscribe | verae-middleware | Bearer `zmw_` | +| OUT HTTPS | middleware `/zapier/v1/auth/me` | verae-middleware | Bearer `zmw_` | +| OUT HTTPS | `/zapier/v1/timestamp`, `/timestamp/wait`, `/timestamp/batch` | verae-middleware | Bearer `zmw_` | +| OUT HTTPS | `/zapier/v1/verify`, `/verify/batch` | verae-middleware | Bearer `zmw_` | +| OUT HTTPS | `/zapier/v1/status/{id}`, `/status/{id}/verification`, `/status/batch` | verae-middleware | Bearer `zmw_` | +| OUT HTTPS | `/zapier/v1/hashes/{sha256}`, `/hashes/{sha256}?includeAttached=true&includeTree=true` | verae-middleware | Bearer `zmw_` | +| OUT HTTPS | `/zapier/v1/receipts/{id}` | verae-middleware | Bearer `zmw_` | +| OUT HTTPS | `/zapier/v1/webhooks/subscribe`, `/webhooks/unsubscribe` | verae-middleware | Bearer `zmw_` | +| IN HTTPS | Zapier hook URL | webhook-deliver / in-process poller | `{ event, jobId, tenantId, status }` | -Job completion is a REST Hook HTTP POST from webhook-deliver, not a NATS client in Zapier. +Job completion is a REST Hook HTTP POST from the middleware's webhook path, not a NATS client in Zapier. diff --git a/README.md b/README.md index cf1f196..b6ff75f 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,109 @@ # verae-zapier -Zapier Platform CLI app for Verae. Runs on **Zapier’s servers**. Calls only middleware HTTPS (`MIDDLEWARE_BASE_URL`), never NATS and never `api.veraetime.net`. +Zapier Platform CLI app for Verae. Runs on **Zapier's servers**. Calls only middleware HTTPS (`MIDDLEWARE_BASE_URL`, `/zapier/v1/*`), never NATS and never `api.veraetime.net`. **Forgejo:** https://git.georgelambert.org/marchon/verae-zapier-app +**Middleware:** https://git.georgelambert.org/marchon/verae-middleware **Catalog:** https://zapier.georgelambert.org/packages/verae-zapier/README.pdf **Sphinx API sheets:** https://zapier.georgelambert.org/sphinx/index.html ## Role -Runs on **Zapier’s servers**. Calls only the middleware HTTPS API (`MIDDLEWARE_BASE_URL`), never NATS and never `api.veraetime.net` directly. +Runs on **Zapier's servers**. Calls only the middleware HTTPS API (`MIDDLEWARE_BASE_URL`), never +NATS and never `api.veraetime.net` directly. The middleware owns customer API keys (`zmw_…`), +plans and quotas, job watching, REST Hook delivery, and the upstream Verae chain client +(`POST /auth/login`, `/api/timestamp`, `/api/status`, `/api/verify`, … on api.veraetime.net). -## Planned modules +## Modules -| File | Purpose | -|------|---------| -| `authentication.js` | Custom API key auth → `GET /zapier/v1/auth/me` | -| `index.js` | App definition, beforeRequest, afterResponse error mapping | -| `creates/timestamp_and_wait.js` | Primary action | -| `creates/create_timestamp.js` | Async jobId action | -| `creates/verify_timestamp.js` | Verify certificate | -| `creates/batch_timestamp.js` | Batch create | -| `searches/job_status.js` | Lookup by jobId | -| `triggers/timestamp_completed.js` | REST Hook | +| File | Zapier type | Middleware call | +|------|-------------|-----------------| +| `authentication.js` | custom API key | `GET /zapier/v1/auth/me` → connection label `tenantId (plan)` | +| `index.js` | app definition | `beforeRequest` sends the key as `Authorization: Bearer` **and** `x-api-key`; `afterResponse` maps `{ error, code, details }` (402/403 billing → upgrade URL, 429 → ThrottledError) | +| `lib/middleware.js` | helpers | base URL, error parsing, 404-as-no-match search helper, shared samples | +| `lib/hookTrigger.js` | helpers | REST Hook factory (`/webhooks/subscribe`, `/webhooks/unsubscribe`) | +| `creates/timestamp_and_wait.js` | action | `POST /timestamp/wait` (primary) | +| `creates/create_timestamp.js` | action | `POST /timestamp` → 202 `{ jobId }` | +| `creates/batch_timestamp.js` | action | `POST /timestamp/batch` (paid plans) | +| `creates/verify_timestamp.js` | action | `POST /verify` | +| `creates/batch_verify.js` | action | `POST /verify/batch` | +| `creates/batch_status.js` | action | `POST /status/batch` | +| `creates/get_receipt.js` | action | `GET /receipts/{jobId}` (JSON; optional PDF via `z.stashFile`) | +| `searches/job_status.js` | search | `GET /status/{jobId}` (404 → no match) | +| `searches/job_verification.js` | search | `GET /status/{jobId}/verification` (404 → no match) | +| `searches/hash_lookup.js` | search | `GET /hashes/{sha256}` (404 → no match) | +| `searches/tree_lookup.js` | search | `GET /hashes/{sha256}?includeAttached=true&includeTree=true` | +| `searches/usage.js` | search | `GET /auth/me` → plan + month-to-date usage vs limits (Find Usage) | +| `triggers/timestamp_completed.js` | REST Hook | event `timestamp.completed` | +| `triggers/timestamp_failed.js` | REST Hook | event `timestamp.failed` | + +Hook payload delivered by the middleware: `{ event, jobId, tenantId, status }`; the trigger +adds `id = jobId` so Zapier can dedupe. + +Create Timestamp / Create Timestamp and Wait take **Data or SHA256** (64 hex; hash large files +outside Zapier and send only the digest, per the user guide), plus optional public / private +metadata dictionaries. A digest that was sealed before returns the original job with +`existing: true` and no second chain write. I/O contracts: https://zapier.georgelambert.org/docs/developer/modules/function-reference.pdf +### Known gaps upstream (middleware / chain), not in this app + +- `GET /hashes/{sha256}` returns **501 NOT_IMPLEMENTED** when the middleware runs against the + live chain (`MOCK_VERAE=false`); the Verae OpenAPI has no hash lookup. The two hash searches + surface that message verbatim. +- `publicMetadata` / `privateMetadata` and `sha256`-only creates are accepted by the middleware + but only honoured by its mock chain, so this app does not expose them. +- The middleware has no "list recent events" endpoint, so `performList` on the hook triggers + returns nothing and Zapier uses the static `sample`. + ## Env +| Variable | Default | Purpose | +|----------|---------|---------| +| `MIDDLEWARE_BASE_URL` | `http://127.0.0.1:3100` | Origin the app calls. Set per Zapier version: `zapier env:set 1.1.0 MIDDLEWARE_BASE_URL=https://mw.zapier.georgelambert.org` | +| `MIDDLEWARE_PATH_PREFIX` | `/zapier/v1` | Route prefix at that origin. Leave the default for the middleware or the access planes; change it only if a fronting edge mounts the same routes elsewhere. | + +## Where to point it (lab, 2026-09-14) + +The catalog at https://zapier.georgelambert.org/ documents the live lab on NS1: + +| Origin | What it is | Usable by this app today? | +|--------|------------|---------------------------| +| `https://mw.zapier.georgelambert.org` | verae-middleware (`MOCK_VERAE=true`, NATS on) | **Yes** — full `/zapier/v1/*`; accepts `Bearer` or `x-api-key`. Use this until the Zap plane proxies every route. | +| `https://zap.zapier.georgelambert.org` | verae-access-zapier, the intended Zapier door (STATUS.md / dev-overview) | **Not yet** — it only proxies `POST /v1/timestamp` and does not forward the API key, so `auth/me`, wait, verify, status, hooks all 404/401. | +| `https://api.zapier.georgelambert.org` | verae-access-api (direct customer API plane) | Forwards `x-api-key` for `POST /v1/timestamp` only; not for Zapier. | +| zappier-edge `/v1/*` | billing edge (composition target; loopback :13000, tunnel only) | Not yet — covers timestamp/hashes/receipts/usage only, no `/zapier/v1` prefix. | + +Keys: connect with a middleware **`zmw_…`** key (portal / `POST /zapier/v1/signup` / +admin provisioning). Never a Verae username, password, or JWT. After the zappier +composition lands, the same field takes the portal `x-api-key`; the app already sends that +header. + +## Develop + ```bash -export MIDDLEWARE_BASE_URL=https://your-middleware.example.com +npm install +npm test # unit tests, no network +npx zapier-platform-cli validate --without-style +``` + +End-to-end against a running middleware (mock chain shown; a live-chain middleware works the same). +Against the lab middleware add `E2E_SKIP_HOOK_DELIVERY=1` (it cannot POST back to your laptop): + +```bash +# in verae-middleware +PORT=3100 MOCK_VERAE=true NATS_ENABLED=false ADMIN_SECRET=dev-admin npm start +curl -s -X POST http://127.0.0.1:3100/zapier/v1/admin/tenants \ + -H 'x-admin-secret: dev-admin' -H 'content-type: application/json' \ + -d '{"name":"E2E","plan":"pro","veraeUsername":"e2e","veraePassword":"x"}' # → apiKey + +# in verae-zapier-app +MIDDLEWARE_BASE_URL=http://127.0.0.1:3100 MIDDLEWARE_API_KEY=zmw_... npm run test:e2e + +# against the live lab (key from dev-overview/05-developer-credentials or /zapier/v1/signup) +MIDDLEWARE_BASE_URL=https://mw.zapier.georgelambert.org MIDDLEWARE_API_KEY=zmw_... \ + E2E_SKIP_HOOK_DELIVERY=1 npm run test:e2e ``` ## Gate @@ -36,3 +111,48 @@ export MIDDLEWARE_BASE_URL=https://your-middleware.example.com ```bash npm run gate:11 # from monorepo root, after Phase 11 implementation ``` + +## Status of the API integration (2026-09-15) + +Where the Zapier ↔ Verae path stands, and what is still open. Sources: this repo, +`verae-middleware`, and the catalog (`zapier-decisions/STATUS`, `dev-overview/03…05`). + +### Done + +- This app covers the whole middleware `/zapier/v1` surface that Zapier needs: async / wait / + batch create, single and batch verify, job status / verification / batch status, hash and + tree-node lookup, usage, retrieval receipt, and REST Hooks for `timestamp.completed` and + `timestamp.failed`. +- The middleware already has the live chain client (`src/clients/veraeClient.js`) for + `POST /api/timestamp`, `/api/batch/timestamp`, `/api/verify`, `/api/batch/verify`, + `GET /api/status/{jobId}`, `GET /api/verify/{jobId}`, `POST /api/batch/status`, and + `POST /auth/login` on https://api.veraetime.net. No Zapier-app change is needed to go live. +- Verified end to end through `zapier-platform-core` against a mock-chain middleware + (`npm run test:e2e`), on both a pro tenant (batch, hook delivery) and a free tenant + (402/403 upgrade-URL mapping). + +### Open — blocked on people, not code + +| Item | Owner | What is needed | +|------|-------|----------------| +| Zapier Platform push | operator | `zapier login`, `zapier register` (or link), `zapier env:set MIDDLEWARE_BASE_URL=…`, `zapier push` as a private app, then test Create Timestamp and Wait, Create Timestamp, Find Job Status, Timestamp Completed. | +| Live chain (`MOCK_VERAE=false`) | Verae / operator | A Verae username + password for the middleware (`VERAE_API_BASE_URL=https://api.veraetime.net`). Then prove one real seal, confirm `202 { jobId }` → status → verify, and map the live status JSON into the receipt shape this app samples (`certificate`, `timestamp`, `blockIndex`, `sha256`). | +| Live-lab e2e run | anyone with the lab key | `MIDDLEWARE_BASE_URL=https://mw.zapier.georgelambert.org MIDDLEWARE_API_KEY=zmw_… E2E_SKIP_HOOK_DELIVERY=1 npm run test:e2e`. Exercises the NATS-enabled wait / poller path that the local run does not. | + +### Open — code in other repos + +| Item | Repo | Notes | +|------|------|-------| +| Zapier plane does not proxy the app | `verae-access-zapier` | `zap.zapier.georgelambert.org` handles only `POST /v1/timestamp` and drops the API key. STATUS says to point the app there; until it proxies all `/zapier/v1/*` with the key intact, use `mw.zapier.georgelambert.org`. | +| Hash lookup on live chain | Verae API or middleware store | `GET /hashes/{sha256}` is mock-only; live returns `501 NOT_IMPLEMENTED`. Needs a Verae API gap fill or a middleware-side index. | +| `sha256`-only create and metadata on live chain | `verae-middleware` | Accepted by the middleware and honoured by the mock; the live client sends only `data` + `hashAlg` (adapter rule). Decide how the live path handles digest-only registration and public/private metadata. | +| Tree-leaf receipts on wait/status | `verae-middleware` / `verae-archive-aggregator` | The user guide describes `receipts[]`, `itemizedOnMainChain`, `merkleRoot`, `proofOk`; the live wait path does not call the aggregator yet, so those fields are absent. This app's `tree_lookup` sample already names them. | +| Billing-edge composition | `zappier-edge` | Target is Zapier → zappier `/v1/*` (`x-api-key`, metered) → middleware. The edge is loopback-only today and covers only timestamp / hashes / receipts / usage without the `/zapier/v1` prefix. This app already sends `x-api-key` and has `MIDDLEWARE_PATH_PREFIX` for when it lands. | +| Webhook `performList` | `verae-middleware` | No "recent events" endpoint, so the hook triggers return `[]` from `performList` and Zapier uses the static sample. A small list route would give real sample data in the editor. | + +### Not planned in this app + +- Verae admin HTML, user CRUD, and get-block-by-hash (`/admin/*`, `/auth/users*`). +- Encrypted object storage and shares (`/objects*`, `/shares/*`) — mock-only today; add creates + here once the middleware contract is final. +- Anything that talks to NATS or api.veraetime.net directly. diff --git a/SUMMARY.md b/SUMMARY.md index 94b22b3..a3420d8 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -1,9 +1,9 @@ # verae-zapier-app -**Job:** Full Zapier CLI app: timestamp, wait, verify, batch, job status, hash lookup, REST Hook. +**Job:** Full Zapier CLI app: timestamp (async, wait, batch), verify (single, batch), job status (single, batch, verification details), hash / tree-node lookup, usage, retrieval receipt, REST Hooks for `timestamp.completed` and `timestamp.failed`. -**Expects:** `zmw_` middleware key (today) or zappier key after composition. +**Expects:** `zmw_` middleware key (today) or zappier portal key after composition; sent as `Authorization: Bearer` and `x-api-key`. -**Sends:** HTTPS to middleware `/zapier/v1/*`. +**Sends:** HTTPS to middleware `/zapier/v1/*` only. -**Does not** speak NATS. +**Does not** speak NATS or call `api.veraetime.net`. diff --git a/authentication.js b/authentication.js index 0a9b5e3..47a5afd 100644 --- a/authentication.js +++ b/authentication.js @@ -1,21 +1,25 @@ /** - * Custom API key auth against Verae middleware. + * Custom API key auth against the Verae middleware. + * + * Customers get a `zmw_…` key from signup / provisioning. The middleware + * resolves it to a tenant (plan, quotas) and logs in to Verae on their behalf. * @module authentication */ -const middlewareBase = () => - process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; +const { url } = require('./lib/middleware'); /** - * Zapier connection test — validates API key. + * Zapier connection test — `GET /zapier/v1/auth/me`. Its fields feed + * `connectionLabel`. * @param {object} z * @param {object} bundle */ const testAuth = async (z, bundle) => { const response = await z.request({ - url: `${middlewareBase()}/zapier/v1/auth/me`, + url: url('/auth/me'), headers: { Authorization: `Bearer ${bundle.authData.api_key}`, + 'x-api-key': bundle.authData.api_key, }, }); return response.data; @@ -29,8 +33,7 @@ module.exports = { type: 'string', required: true, label: 'API Key', - helpText: - 'Your Verae API key (zmw_…) from signup or admin provisioning.', + helpText: 'Your Verae API key (zmw_…) from portal signup or admin provisioning. Never a Verae username, password, or JWT.', }, ], test: testAuth, diff --git a/creates/add_numbers.js b/creates/add_numbers.js deleted file mode 100644 index c2dfb5a..0000000 --- a/creates/add_numbers.js +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export activate-now Add Numbers so this package stays self-contained. */ -module.exports = require('../../verae-activate/creates/add_numbers'); diff --git a/creates/batch_status.js b/creates/batch_status.js new file mode 100644 index 0000000..b3d34d7 --- /dev/null +++ b/creates/batch_status.js @@ -0,0 +1,73 @@ +/** + * `POST /zapier/v1/status/batch` — status of several jobs at once. + * Modelled as an action because it returns one aggregate result. + * @module creates/batch_status + */ + +const { url, statusSample, toStringList } = require('../lib/middleware'); + +const perform = async (z, bundle) => { + const jobIds = toStringList(bundle.inputData.jobIds); + if (jobIds.length === 0) { + throw new z.errors.Error('Provide at least one job ID.', 'VALIDATION_ERROR', 400); + } + + const response = await z.request({ + method: 'POST', + url: url('/status/batch'), + body: { jobIds }, + }); + const map = (response.data && response.data.results) || {}; + const results = jobIds.map((id) => ({ id, ...(map[id] || { status: 'unknown' }) })); + const count = (s) => results.filter((r) => r.status === s).length; + + return { + jobIds, + results, + total: results.length, + completedCount: count('completed'), + pendingCount: count('pending'), + failedCount: count('failed'), + allCompleted: results.length > 0 && count('completed') === results.length, + }; +}; + +module.exports = { + key: 'batch_status', + noun: 'Job', + display: { + label: 'Get Batch Job Status', + description: 'Retrieves the current status of multiple timestamp jobs in one request.', + }, + operation: { + inputFields: [ + { + key: 'jobIds', + label: 'Job IDs', + type: 'string', + required: true, + list: true, + helpText: 'One job ID per entry (or one per line), e.g. the Job IDs from "Create Batch Timestamps".', + }, + ], + perform, + sample: { + jobIds: [statusSample.id, '650e8400-e29b-41d4-a716-446655440001'], + results: [statusSample, { id: '650e8400-e29b-41d4-a716-446655440001', status: 'pending' }], + total: 2, + completedCount: 1, + pendingCount: 1, + failedCount: 0, + allCompleted: false, + }, + outputFields: [ + { key: 'jobIds', label: 'Job IDs' }, + { key: 'results', label: 'Results' }, + { key: 'total', label: 'Total', type: 'integer' }, + { key: 'completedCount', label: 'Completed Count', type: 'integer' }, + { key: 'pendingCount', label: 'Pending Count', type: 'integer' }, + { key: 'failedCount', label: 'Failed Count', type: 'integer' }, + { key: 'allCompleted', label: 'All Completed', type: 'boolean' }, + ], + }, +}; diff --git a/creates/batch_timestamp.js b/creates/batch_timestamp.js index 5b8a279..9899371 100644 --- a/creates/batch_timestamp.js +++ b/creates/batch_timestamp.js @@ -1,18 +1,24 @@ -const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; +/** + * `POST /zapier/v1/timestamp/batch` — several items in one request (paid plans). + * @module creates/batch_timestamp + */ + +const { url, hashAlgField, toStringList } = require('../lib/middleware'); const perform = async (z, bundle) => { - const items = (bundle.inputData.items || '') - .split('\n') - .map((s) => s.trim()) - .filter(Boolean) - .map((data) => ({ data })); + const hashAlg = bundle.inputData.hashAlg || undefined; + const items = toStringList(bundle.inputData.items).map((data) => ({ data, hashAlg })); + if (items.length === 0) { + throw new z.errors.Error('Provide at least one data item to timestamp.', 'VALIDATION_ERROR', 400); + } const response = await z.request({ method: 'POST', - url: `${base()}/zapier/v1/timestamp/batch`, + url: url('/timestamp/batch'), body: { items }, }); - return response.data; + const jobIds = (response.data && response.data.jobIds) || []; + return { ...response.data, jobIds, count: jobIds.length }; }; module.exports = { @@ -20,20 +26,28 @@ module.exports = { noun: 'Timestamp', display: { label: 'Create Batch Timestamps', - description: 'Submit multiple data lines for timestamping (paid plans).', + description: 'Submits multiple data items for timestamping in one request (paid plans).', }, operation: { inputFields: [ { key: 'items', - label: 'Data Lines', + label: 'Data Items', type: 'text', required: true, - helpText: 'One payload per line.', + list: true, + helpText: 'One item per entry (or one per line). Each item becomes its own timestamp job.', }, + hashAlgField, ], perform, - sample: { jobIds: ['id-1', 'id-2'] }, - outputFields: [{ key: 'jobIds', label: 'Job IDs' }], + sample: { + jobIds: ['550e8400-e29b-41d4-a716-446655440000', '650e8400-e29b-41d4-a716-446655440001'], + count: 2, + }, + outputFields: [ + { key: 'jobIds', label: 'Job IDs' }, + { key: 'count', label: 'Job Count', type: 'integer' }, + ], }, }; diff --git a/creates/batch_verify.js b/creates/batch_verify.js new file mode 100644 index 0000000..637e582 --- /dev/null +++ b/creates/batch_verify.js @@ -0,0 +1,67 @@ +/** + * `POST /zapier/v1/verify/batch` — verify several certificates at once. + * @module creates/batch_verify + */ + +const { url, toStringList } = require('../lib/middleware'); + +const perform = async (z, bundle) => { + const certificates = toStringList(bundle.inputData.certificates); + if (certificates.length === 0) { + throw new z.errors.Error('Provide at least one certificate to verify.', 'VALIDATION_ERROR', 400); + } + + const response = await z.request({ + method: 'POST', + url: url('/verify/batch'), + body: { certificates }, + }); + const results = (response.data && response.data.results) || []; + const validCount = results.filter((r) => r && r.valid === true).length; + return { + results, + total: results.length, + validCount, + invalidCount: results.length - validCount, + allValid: results.length > 0 && validCount === results.length, + }; +}; + +module.exports = { + key: 'batch_verify', + noun: 'Verification', + display: { + label: 'Verify Batch Timestamps', + description: 'Verifies multiple timestamp certificates in one request.', + }, + operation: { + inputFields: [ + { + key: 'certificates', + label: 'Certificates', + type: 'text', + required: true, + list: true, + helpText: 'One certificate per entry (or one per line). Results come back in the same order.', + }, + ], + perform, + sample: { + results: [ + { valid: true, timestamp: '2023-01-01T12:00:00Z', blockIndex: 42 }, + { valid: false }, + ], + total: 2, + validCount: 1, + invalidCount: 1, + allValid: false, + }, + outputFields: [ + { key: 'results', label: 'Results' }, + { key: 'total', label: 'Total', type: 'integer' }, + { key: 'validCount', label: 'Valid Count', type: 'integer' }, + { key: 'invalidCount', label: 'Invalid Count', type: 'integer' }, + { key: 'allValid', label: 'All Valid', type: 'boolean' }, + ], + }, +}; diff --git a/creates/create_timestamp.js b/creates/create_timestamp.js index c1921c3..5db8347 100644 --- a/creates/create_timestamp.js +++ b/creates/create_timestamp.js @@ -1,13 +1,16 @@ -const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; +/** + * `POST /zapier/v1/timestamp` — submit data or a SHA-256, get a job ID (202). + * A digest that was sealed before returns the original job with `existing: true`. + * @module creates/create_timestamp + */ + +const { url, timestampBody, timestampInputFields } = require('../lib/middleware'); const perform = async (z, bundle) => { const response = await z.request({ method: 'POST', - url: `${base()}/zapier/v1/timestamp`, - body: { - data: bundle.inputData.data, - hashAlg: bundle.inputData.hashAlg || undefined, - }, + url: url('/timestamp'), + body: timestampBody(z, bundle.inputData), }); return response.data; }; @@ -17,21 +20,21 @@ module.exports = { noun: 'Timestamp', display: { label: 'Create Timestamp (Async)', - description: 'Submits data for timestamping and returns a job ID.', + description: + 'Registers data or a SHA-256 digest for blockchain timestamping and returns a job ID right away. Pair with "Timestamp Completed" or "Find Job Status" to get the certificate.', }, operation: { - inputFields: [ - { key: 'data', label: 'Data', type: 'string', required: true }, - { - key: 'hashAlg', - label: 'Hash Algorithm', - type: 'string', - required: false, - default: 'SHA256', - }, - ], + inputFields: timestampInputFields(), perform, - sample: { jobId: '550e8400-e29b-41d4-a716-446655440000' }, - outputFields: [{ key: 'jobId', label: 'Job ID' }], + sample: { + jobId: '550e8400-e29b-41d4-a716-446655440000', + sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + existing: false, + }, + outputFields: [ + { key: 'jobId', label: 'Job ID' }, + { key: 'sha256', label: 'SHA256' }, + { key: 'existing', label: 'Already Timestamped', type: 'boolean' }, + ], }, }; diff --git a/creates/get_receipt.js b/creates/get_receipt.js new file mode 100644 index 0000000..2ce11ce --- /dev/null +++ b/creates/get_receipt.js @@ -0,0 +1,76 @@ +/** + * `GET /zapier/v1/receipts/{jobId}` — certified retrieval receipt for a + * completed job (JSON, optionally the PDF as a Zapier file). The middleware + * seals a `document.retrieved` event on each call, so this is an action. + * @module creates/get_receipt + */ + +const { url } = require('../lib/middleware'); + +const perform = async (z, bundle) => { + const jobId = bundle.inputData.jobId; + const receiptUrl = url(`/receipts/${encodeURIComponent(jobId)}`); + + const response = await z.request({ url: receiptUrl, params: { format: 'json' } }); + const receipt = { id: jobId, ...(response.data || {}) }; + + if (bundle.inputData.includePdf) { + const pdfRequest = z.request({ url: receiptUrl, params: { format: 'pdf' }, raw: true }); + receipt.pdf = await z.stashFile( + pdfRequest, + undefined, + `verae-receipt-${jobId}.pdf`, + 'application/pdf', + ); + } + return receipt; +}; + +module.exports = { + key: 'get_receipt', + noun: 'Receipt', + display: { + label: 'Get Retrieval Receipt', + description: + 'Fetches the certified retrieval receipt for a completed timestamp job. Fails with NOT_COMPLETE while the job is still pending.', + }, + operation: { + inputFields: [ + { key: 'jobId', label: 'Job ID', type: 'string', required: true }, + { + key: 'includePdf', + label: 'Include PDF', + type: 'boolean', + required: false, + default: 'false', + helpText: 'Also attach the receipt as a PDF file.', + }, + ], + perform, + sample: { + id: '550e8400-e29b-41d4-a716-446655440000', + type: 'verae.retrieval-receipt', + format: 'json', + jobId: '550e8400-e29b-41d4-a716-446655440000', + sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + timestamp: '2023-01-01T12:05:00Z', + certificate: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + extraSeal: { + event: 'document.retrieved', + retrievedAt: '2023-01-02T09:00:00Z', + tenantId: 'tenant-example', + }, + }, + outputFields: [ + { key: 'jobId', label: 'Job ID' }, + { key: 'type', label: 'Receipt Type' }, + { key: 'sha256', label: 'SHA256' }, + { key: 'timestamp', label: 'Timestamp', type: 'datetime' }, + { key: 'certificate', label: 'Certificate' }, + { key: 'extraSeal__event', label: 'Seal Event' }, + { key: 'extraSeal__retrievedAt', label: 'Retrieved At', type: 'datetime' }, + { key: 'extraSeal__tenantId', label: 'Tenant ID' }, + { key: 'pdf', label: 'Receipt PDF', type: 'file' }, + ], + }, +}; diff --git a/creates/timestamp_and_wait.js b/creates/timestamp_and_wait.js index 2fe1f5b..046900c 100644 --- a/creates/timestamp_and_wait.js +++ b/creates/timestamp_and_wait.js @@ -1,15 +1,27 @@ -const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; +/** + * `POST /zapier/v1/timestamp/wait` — submit data or a SHA-256 and hold until + * the job is terminal or the middleware's WAIT_TIMEOUT_MS elapses + * (then `status: pending` with the job ID). + * @module creates/timestamp_and_wait + */ + +const { + url, + statusOutputFields, + statusSample, + timestampBody, + timestampInputFields, +} = require('../lib/middleware'); const perform = async (z, bundle) => { const response = await z.request({ method: 'POST', - url: `${base()}/zapier/v1/timestamp/wait`, - body: { - data: bundle.inputData.data, - hashAlg: bundle.inputData.hashAlg || undefined, - }, + url: url('/timestamp/wait'), + body: timestampBody(z, bundle.inputData), }); - return response.data; + const status = response.data || {}; + const id = status.id || status.jobId; + return { ...status, id, jobId: status.jobId || id }; }; module.exports = { @@ -18,37 +30,12 @@ module.exports = { display: { label: 'Create Timestamp and Wait', description: - 'Submits data for blockchain timestamping and waits for the certificate.', + 'Registers data or a SHA-256 digest and holds the step until the certificate exists. If the chain is slow the step returns status "pending" with the job ID so a later step (or the Timestamp Completed trigger) can finish the Zap.', }, operation: { - inputFields: [ - { - key: 'data', - label: 'Data', - type: 'string', - required: true, - helpText: 'Content to timestamp on the blockchain.', - }, - { - key: 'hashAlg', - label: 'Hash Algorithm', - type: 'string', - required: false, - default: 'SHA256', - }, - ], + inputFields: timestampInputFields(), perform, - sample: { - id: '550e8400-e29b-41d4-a716-446655440000', - status: 'completed', - result: 'mock-cert-example', - completedAt: '2023-01-01T12:05:00Z', - }, - outputFields: [ - { key: 'id', label: 'Job ID' }, - { key: 'status', label: 'Status' }, - { key: 'result', label: 'Certificate' }, - { key: 'completedAt', label: 'Completed At', type: 'datetime' }, - ], + sample: statusSample, + outputFields: [{ key: 'jobId', label: 'Job ID' }, ...statusOutputFields.slice(1)], }, }; diff --git a/creates/verify_timestamp.js b/creates/verify_timestamp.js index 510926a..360afbc 100644 --- a/creates/verify_timestamp.js +++ b/creates/verify_timestamp.js @@ -1,9 +1,14 @@ -const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; +/** + * `POST /zapier/v1/verify` — verify one certificate. + * @module creates/verify_timestamp + */ + +const { url, verifyOutputFields, verifySample } = require('../lib/middleware'); const perform = async (z, bundle) => { const response = await z.request({ method: 'POST', - url: `${base()}/zapier/v1/verify`, + url: url('/verify'), body: { certificate: bundle.inputData.certificate }, }); return response.data; @@ -14,7 +19,7 @@ module.exports = { noun: 'Verification', display: { label: 'Verify Timestamp', - description: 'Verify a timestamp certificate.', + description: 'Verifies the authenticity of a timestamp certificate.', }, operation: { inputFields: [ @@ -23,14 +28,11 @@ module.exports = { label: 'Certificate', type: 'text', required: true, + helpText: 'The certificate returned when a timestamp job completed.', }, ], perform, - sample: { valid: true, timestamp: '2023-01-01T12:00:00Z', blockIndex: 42 }, - outputFields: [ - { key: 'valid', label: 'Valid', type: 'boolean' }, - { key: 'timestamp', label: 'Timestamp', type: 'datetime' }, - { key: 'blockIndex', label: 'Block Index', type: 'integer' }, - ], + sample: verifySample, + outputFields: verifyOutputFields, }, }; diff --git a/index.js b/index.js index 8727990..dfdc196 100644 --- a/index.js +++ b/index.js @@ -1,82 +1,100 @@ /** * Verae Zapier Platform app definition. + * + * Every operation calls the Verae middleware (`MIDDLEWARE_BASE_URL`, + * `/zapier/v1/*`). The middleware talks to api.veraetime.net; this app never does. * @module index */ const authentication = require('./authentication'); +const { describeError } = require('./lib/middleware'); + const timestampAndWait = require('./creates/timestamp_and_wait'); const createTimestamp = require('./creates/create_timestamp'); -const verifyTimestamp = require('./creates/verify_timestamp'); const batchTimestamp = require('./creates/batch_timestamp'); -const addNumbers = require('./creates/add_numbers'); +const verifyTimestamp = require('./creates/verify_timestamp'); +const batchVerify = require('./creates/batch_verify'); +const batchStatus = require('./creates/batch_status'); +const getReceipt = require('./creates/get_receipt'); + const jobStatus = require('./searches/job_status'); +const jobVerification = require('./searches/job_verification'); const hashLookup = require('./searches/hash_lookup'); const treeLookup = require('./searches/tree_lookup'); +const findUsage = require('./searches/usage'); + const timestampCompleted = require('./triggers/timestamp_completed'); +const timestampFailed = require('./triggers/timestamp_failed'); /** - * Attach middleware API key to every outbound request. + * Attach the API key to every outbound request, both ways the stack accepts + * it: the middleware reads `Authorization: Bearer` or `x-api-key`; the + * access planes and the zappier billing edge forward only `x-api-key`. * @param {object} request * @param {object} _z * @param {object} bundle */ const addApiKey = (request, _z, bundle) => { request.headers = request.headers || {}; - request.headers.Authorization = `Bearer ${bundle.authData.api_key}`; + const key = bundle && bundle.authData && bundle.authData.api_key; + if (key) { + if (!request.headers.Authorization) request.headers.Authorization = `Bearer ${key}`; + if (!request.headers['x-api-key']) request.headers['x-api-key'] = key; + } return request; }; /** - * Map middleware billing errors to Zapier errors. + * Map middleware errors (`{ error, code, details }`) to Zapier errors. + * Billing errors carry the upgrade URL; 429 becomes a ThrottledError so + * Zapier retries later. Requests made with `skipThrowForStatus` are left alone. * @param {object} response * @param {object} z */ const mapMiddlewareErrors = (response, z) => { - if (response.status === 402) { - throw new z.errors.Error( - `${response.data?.error ?? 'Quota exceeded'}. Upgrade at ${response.data?.details?.upgradeUrl ?? response.data?.upgradeUrl ?? 'your billing portal'}.`, - 'QuotaExceeded', - 402, - ); + if (response.skipThrowForStatus || response.status < 400) { + return response; } - if (response.status === 403 && response.data?.code === 'PLAN_UPGRADE_REQUIRED') { - throw new z.errors.Error( - response.data?.error ?? 'This action requires a paid plan.', - 'PlanUpgradeRequired', - 403, - ); + const { message, code, upgradeUrl } = describeError(response); + + if (response.status === 429) { + throw new z.errors.ThrottledError(message); } - return response; + const billingCodes = new Set(['QUOTA_EXCEEDED', 'PLAN_UPGRADE_REQUIRED', 'BATCH_LIMIT_EXCEEDED']); + if (response.status === 402 || billingCodes.has(code)) { + const where = upgradeUrl || 'your billing portal'; + throw new z.errors.Error(`${message}. Upgrade at ${where}.`, code, response.status); + } + + throw new z.errors.Error(message, code, response.status); }; -let platformVersion = '15.19.0'; -try { - platformVersion = require('zapier-platform-core').version; -} catch { - // optional for unit tests without full install -} - module.exports = { version: require('./package.json').version, - platformVersion, + platformVersion: require('zapier-platform-core').version, authentication, beforeRequest: [addApiKey], afterResponse: [mapMiddlewareErrors], triggers: { [timestampCompleted.key]: timestampCompleted, + [timestampFailed.key]: timestampFailed, }, creates: { - [addNumbers.key]: addNumbers, [timestampAndWait.key]: timestampAndWait, [createTimestamp.key]: createTimestamp, - [verifyTimestamp.key]: verifyTimestamp, [batchTimestamp.key]: batchTimestamp, + [verifyTimestamp.key]: verifyTimestamp, + [batchVerify.key]: batchVerify, + [batchStatus.key]: batchStatus, + [getReceipt.key]: getReceipt, }, searches: { [jobStatus.key]: jobStatus, + [jobVerification.key]: jobVerification, [hashLookup.key]: hashLookup, [treeLookup.key]: treeLookup, + [findUsage.key]: findUsage, }, }; diff --git a/lib/hookTrigger.js b/lib/hookTrigger.js new file mode 100644 index 0000000..0650e8b --- /dev/null +++ b/lib/hookTrigger.js @@ -0,0 +1,87 @@ +/** + * Factory for REST Hook triggers backed by the middleware's + * `/webhooks/subscribe` and `/webhooks/unsubscribe` endpoints. + * + * Delivered payload (see verae-middleware inProcessJobPoller / webhookWorker): + * `{ event, jobId, tenantId, status: StatusResponse }`. + * @module lib/hookTrigger + */ + +const { url, statusSample } = require('./middleware'); + +/** + * @param {object} spec + * @param {string} spec.key Zapier trigger key + * @param {string} spec.event middleware event name (timestamp.completed | timestamp.failed) + * @param {string} spec.label + * @param {string} spec.description + * @param {object} [spec.sampleStatus] StatusResponse used in the sample payload + * @returns {object} Zapier trigger definition + */ +const hookTrigger = ({ key, event, label, description, sampleStatus = statusSample }) => { + const performSubscribe = async (z, bundle) => { + const response = await z.request({ + method: 'POST', + url: url('/webhooks/subscribe'), + body: { targetUrl: bundle.targetUrl, event }, + }); + return { id: response.data.id, event }; + }; + + const performUnsubscribe = async (z, bundle) => { + const hookId = + bundle.subscribeData && typeof bundle.subscribeData === 'object' + ? bundle.subscribeData.id + : bundle.subscribeData; + await z.request({ + method: 'DELETE', + url: url('/webhooks/unsubscribe'), + body: { hookId, targetUrl: bundle.targetUrl }, + }); + return {}; + }; + + /** Zapier dedupes hook items on `id`; the payload only carries `jobId`. */ + const perform = async (_z, bundle) => { + const payload = bundle.cleanedRequest || {}; + const id = payload.jobId || (payload.status && payload.status.id); + return [{ id, ...payload }]; + }; + + /** The middleware has no "list recent events" endpoint; Zapier falls back to `sample`. */ + const performList = async () => []; + + return { + key, + noun: 'Timestamp', + display: { label, description }, + operation: { + type: 'hook', + perform, + performList, + performSubscribe, + performUnsubscribe, + sample: { + id: sampleStatus.id, + event, + jobId: sampleStatus.id, + tenantId: 'tenant-example', + status: sampleStatus, + }, + outputFields: [ + { key: 'id', label: 'Job ID' }, + { key: 'event', label: 'Event' }, + { key: 'jobId', label: 'Job ID' }, + { key: 'tenantId', label: 'Tenant ID' }, + { key: 'status__status', label: 'Status' }, + { key: 'status__result', label: 'Certificate' }, + { key: 'status__error', label: 'Error' }, + { key: 'status__completedAt', label: 'Completed At', type: 'datetime' }, + { key: 'status__sha256', label: 'SHA256' }, + { key: 'status__metadata__blockIndex', label: 'Block Index', type: 'integer' }, + ], + }, + }; +}; + +module.exports = hookTrigger; diff --git a/lib/middleware.js b/lib/middleware.js new file mode 100644 index 0000000..ee5bd72 --- /dev/null +++ b/lib/middleware.js @@ -0,0 +1,233 @@ +/** + * Shared helpers for calling the Verae middleware (`/zapier/v1/*`). + * + * The Zapier app never talks to api.veraetime.net or NATS; the middleware + * (verae-middleware) owns API keys, plans/quotas, job watching, webhooks, + * and the upstream Verae chain client. + * @module lib/middleware + */ + +const DEFAULT_BASE_URL = 'http://127.0.0.1:3100'; +const DEFAULT_PATH_PREFIX = '/zapier/v1'; + +/** + * Middleware origin, from MIDDLEWARE_BASE_URL (set per Zapier version with + * `zapier env:set MIDDLEWARE_BASE_URL=https://...`). + * @returns {string} origin without a trailing slash + */ +const baseUrl = () => + (process.env.MIDDLEWARE_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, ''); + +/** + * Path prefix in front of every route. The middleware serves `/zapier/v1`; + * a fronting edge may mount the same routes elsewhere, so it is overridable + * with MIDDLEWARE_PATH_PREFIX. + * @returns {string} prefix starting with "/" and without a trailing slash + */ +const pathPrefix = () => { + const raw = process.env.MIDDLEWARE_PATH_PREFIX; + if (raw === undefined || raw === null) return DEFAULT_PATH_PREFIX; + const trimmed = String(raw).trim().replace(/\/+$/, ''); + if (!trimmed) return ''; + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; +}; + +/** + * Build a middleware URL. + * @param {string} path path starting with "/", e.g. "/timestamp/wait" + * @returns {string} + */ +const url = (path) => `${baseUrl()}${pathPrefix()}${path}`; + +/** 64 lowercase/uppercase hex characters. */ +const SHA256_RE = /^[A-Fa-f0-9]{64}$/; + +/** + * Build the body for `/timestamp` and `/timestamp/wait` from Zapier input. + * Either `data` or a 64-hex `sha256` is required (the middleware hashes data + * itself; sending only the digest is recommended for large files). + * Optional public/private metadata dictionaries are passed through. + * @param {object} z + * @param {object} inputData + * @returns {{ data?: string, sha256?: string, hashAlg?: string, publicMetadata?: object, privateMetadata?: object }} + */ +const timestampBody = (z, inputData) => { + const data = inputData.data === undefined || inputData.data === null ? '' : String(inputData.data); + const sha256 = String(inputData.sha256 || '').trim().toLowerCase(); + if (!data && !sha256) { + throw new z.errors.Error('Provide either Data or a SHA256 digest.', 'VALIDATION_ERROR', 400); + } + if (sha256 && !SHA256_RE.test(sha256)) { + throw new z.errors.Error('SHA256 must be 64 hexadecimal characters.', 'VALIDATION_ERROR', 400); + } + const body = { hashAlg: inputData.hashAlg || undefined }; + if (data) body.data = data; + if (sha256) body.sha256 = sha256; + const isDict = (v) => v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length > 0; + if (isDict(inputData.publicMetadata)) body.publicMetadata = inputData.publicMetadata; + if (isDict(inputData.privateMetadata)) body.privateMetadata = inputData.privateMetadata; + return body; +}; + +/** Input fields shared by Create Timestamp and Create Timestamp and Wait. */ +const timestampInputFields = () => [ + { + key: 'data', + label: 'Data', + type: 'text', + required: false, + helpText: 'Content to timestamp. The middleware hashes it with SHA-256; only the digest is sealed on chain. Leave blank if you supply a SHA256 below.', + }, + { + key: 'sha256', + label: 'SHA256', + type: 'string', + required: false, + helpText: '64 hex characters if you already hashed the file outside Zapier (recommended for large files). Either Data or SHA256 is required.', + }, + hashAlgField, + { + key: 'publicMetadata', + label: 'Public Metadata', + dict: true, + required: false, + helpText: 'Key/value pairs stored with the seal and returned on lookups.', + }, + { + key: 'privateMetadata', + label: 'Private Metadata', + dict: true, + required: false, + helpText: 'Key/value pairs stored privately with the job (only visible to your account).', + }, +]; + +/** + * Read the middleware's structured error body (`{ error, code, details }`), + * falling back to plain text or a generic message. + * @param {object} response zapier-platform-core response + * @returns {{ message: string, code: string, upgradeUrl?: string, details?: object }} + */ +const describeError = (response) => { + const data = response.data; + if (data && typeof data === 'object' && typeof data.error === 'string') { + const details = data.details && typeof data.details === 'object' ? data.details : undefined; + return { + message: data.error, + code: typeof data.code === 'string' ? data.code : `HTTP_${response.status}`, + upgradeUrl: details?.upgradeUrl ?? data.upgradeUrl, + details, + }; + } + + let text = ''; + try { + text = typeof response.content === 'string' ? response.content.trim() : ''; + } catch { + text = ''; + } + if (text && text.length <= 500 && !text.startsWith('<')) { + return { message: text, code: `HTTP_${response.status}` }; + } + return { + message: `Verae middleware request failed with HTTP ${response.status}`, + code: `HTTP_${response.status}`, + }; +}; + +/** + * Run a lookup request for a Zapier search: 404 means "no match" (empty + * array), other errors are raised with the middleware's message, and a hit + * is returned as a one-element array. + * @param {object} z + * @param {object} requestOptions passed to z.request + * @returns {Promise} + */ +const searchOne = async (z, requestOptions) => { + const response = await z.request({ ...requestOptions, skipThrowForStatus: true }); + if (response.status === 404) return []; + if (response.status >= 400) { + const { message, code } = describeError(response); + throw new z.errors.Error(message, code, response.status); + } + return [response.data]; +}; + +/** + * Normalise a Zapier `list: true` field or newline-separated text into an + * array of non-empty trimmed strings. + * @param {string[]|string|undefined} value + * @returns {string[]} + */ +const toStringList = (value) => { + const raw = Array.isArray(value) ? value : String(value ?? '').split('\n'); + return raw.map((s) => String(s).trim()).filter(Boolean); +}; + +/** Sample job status as returned by `/status/{jobId}` and `/timestamp/wait`. */ +const statusSample = { + id: '550e8400-e29b-41d4-a716-446655440000', + jobId: '550e8400-e29b-41d4-a716-446655440000', + status: 'completed', + result: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + completedAt: '2023-01-01T12:05:00Z', + sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + metadata: { + blockIndex: 42, + timestamp: '2023-01-01T12:00:00Z', + certificate: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + }, +}; + +/** Output fields for a job status (nested keys use Zapier's `__` notation). */ +const statusOutputFields = [ + { key: 'id', label: 'Job ID' }, + { key: 'status', label: 'Status' }, + { key: 'error', label: 'Error' }, + { key: 'result', label: 'Certificate' }, + { key: 'completedAt', label: 'Completed At', type: 'datetime' }, + { key: 'sha256', label: 'SHA256' }, + { key: 'metadata__blockIndex', label: 'Block Index', type: 'integer' }, + { key: 'metadata__timestamp', label: 'Block Timestamp', type: 'datetime' }, + { key: 'metadata__certificate', label: 'Certificate (metadata)' }, +]; + +/** Sample verification result from `/verify`. */ +const verifySample = { valid: true, timestamp: '2023-01-01T12:00:00Z', blockIndex: 42 }; + +/** Output fields for a verification result. */ +const verifyOutputFields = [ + { key: 'valid', label: 'Valid', type: 'boolean' }, + { key: 'timestamp', label: 'Timestamp', type: 'datetime' }, + { key: 'blockIndex', label: 'Block Index', type: 'integer' }, +]; + +/** Hash-algorithm input shared by the timestamp actions. */ +const hashAlgField = { + key: 'hashAlg', + label: 'Hash Algorithm', + type: 'string', + required: false, + default: 'SHA256', + helpText: 'Hash algorithm for the chain. Defaults to SHA256.', +}; + +module.exports = { + DEFAULT_BASE_URL, + DEFAULT_PATH_PREFIX, + SHA256_RE, + baseUrl, + describeError, + hashAlgField, + pathPrefix, + searchOne, + statusOutputFields, + statusSample, + timestampBody, + timestampInputFields, + toStringList, + url, + verifyOutputFields, + verifySample, +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fe11a63 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,819 @@ +{ + "name": "verae-zapier", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "verae-zapier", + "version": "1.0.0", + "dependencies": { + "zapier-platform-core": "15.19.0" + }, + "engines": { + "node": ">=18", + "npm": ">=5.6.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@zapier/secret-scrubber": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@zapier/secret-scrubber/-/secret-scrubber-1.1.6.tgz", + "integrity": "sha512-ETblIxh6lnkVHjVqQ8XVB2Ymeo7RudR7kIiZylXtYXAfpmGLXIzbk1h9fjF/SuO21k7jdxaMBaBdqUCBHv8+og==", + "license": "ISC", + "dependencies": { + "create-hash": "1.2.0", + "lodash.isplainobject": "4.0.6" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cipher-base": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dotenv": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-12.0.4.tgz", + "integrity": "sha512-oWdqbSywffzH1l4WXKPHWA0TWYpqp7IyLfqjipT4upoIFS0HPMqtNotykQpD4iIg0BqtNmdgPCh2WMvMt7yTiw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jsonschema": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.2.2.tgz", + "integrity": "sha512-iX5OFQ6yx9NgbHCwse51ohhKgLuLL7Z5cNOeZOPIlDUtAMrxlruHLzVZxbltdHE5mEDXN+75oFOwq6Gn0MZwsA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/ripemd160": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz", + "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT", + "optional": true + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/zapier-platform-core": { + "version": "15.19.0", + "resolved": "https://registry.npmjs.org/zapier-platform-core/-/zapier-platform-core-15.19.0.tgz", + "integrity": "sha512-KWyvuZ+QnooOknFl3AW6HObbXdz1FZYclN46EwUeQEd1xIjHwr+WdQwOYPJJM6jwSBkh0Fb7MMVhZQQ0VmPlCg==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@zapier/secret-scrubber": "^1.1.1", + "bluebird": "3.7.2", + "content-disposition": "0.5.4", + "dotenv": "12.0.4 ", + "form-data": "4.0.0", + "lodash": "4.17.21", + "mime-types": "2.1.35", + "node-abort-controller": "3.1.1", + "node-fetch": "2.6.7", + "oauth-sign": "0.9.0", + "semver": "7.5.2", + "zapier-platform-schema": "15.19.0" + }, + "engines": { + "node": ">=16", + "npm": ">=5.6.0" + }, + "optionalDependencies": { + "@types/node": "^20.3.1" + } + }, + "node_modules/zapier-platform-schema": { + "version": "15.19.0", + "resolved": "https://registry.npmjs.org/zapier-platform-schema/-/zapier-platform-schema-15.19.0.tgz", + "integrity": "sha512-c3lkrIm3/KaAOXGgayhQtXiH7ODtn1lxBTK5marU30yuyO+7WMCpxW+H79yVrfeiDyR7Dhy7LyKtoKw7pEWq+Q==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "jsonschema": "1.2.2", + "lodash": "4.17.21" + } + } + } +} diff --git a/package.json b/package.json index dcabbfb..1acf24f 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "verae-zapier", - "version": "1.0.0", + "version": "1.1.0", "description": "Zapier CLI app for Verae Timestamping via middleware", "main": "index.js", "scripts": { - "test": "node --test test/**/*.test.js" + "test": "node --test test/*.test.js", + "test:e2e": "node --test test/e2e.test.js" }, "engines": { "node": ">=18", diff --git a/searches/hash_lookup.js b/searches/hash_lookup.js index cc60ac4..d41974d 100644 --- a/searches/hash_lookup.js +++ b/searches/hash_lookup.js @@ -1,34 +1,45 @@ -const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; +/** + * `GET /zapier/v1/hashes/{sha256}` — find an existing timestamp by the + * SHA-256 of its data. 404 means no match. + * @module searches/hash_lookup + */ -const perform = async (z, bundle) => { - try { - const response = await z.request({ - method: 'GET', - url: `${base()}/zapier/v1/hashes/${encodeURIComponent(bundle.inputData.sha256)}`, - }); - return [response.data]; - } catch (err) { - if (err.status === 404) return []; - throw err; - } -}; +const { url, searchOne } = require('../lib/middleware'); + +const perform = (z, bundle) => + searchOne(z, { url: url(`/hashes/${encodeURIComponent(bundle.inputData.sha256)}`) }); module.exports = { key: 'hash_lookup', noun: 'Timestamp', display: { label: 'Find Timestamp by SHA256', - description: 'Looks up an existing timestamp for a SHA256 hex digest (mock/middleware).', + description: 'Looks up an existing timestamp on the main Verae chain by the SHA256 hex digest of its data.', }, operation: { inputFields: [ - { key: 'sha256', label: 'SHA256', type: 'string', required: true }, + { + key: 'sha256', + label: 'SHA256', + type: 'string', + required: true, + helpText: '64 hex characters.', + }, ], perform, sample: { sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', exists: true, jobId: '550e8400-e29b-41d4-a716-446655440000', + timestamp: '2023-01-01T12:05:00Z', + status: 'completed', }, + outputFields: [ + { key: 'sha256', label: 'SHA256' }, + { key: 'exists', label: 'Exists', type: 'boolean' }, + { key: 'jobId', label: 'Job ID' }, + { key: 'timestamp', label: 'Timestamp', type: 'datetime' }, + { key: 'status', label: 'Status' }, + ], }, }; diff --git a/searches/job_status.js b/searches/job_status.js index b39fc8c..294ecb7 100644 --- a/searches/job_status.js +++ b/searches/job_status.js @@ -1,33 +1,26 @@ -const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; +/** + * `GET /zapier/v1/status/{jobId}` — 404 means no such job (empty result). + * @module searches/job_status + */ -const perform = async (z, bundle) => { - const response = await z.request({ - url: `${base()}/zapier/v1/status/${encodeURIComponent(bundle.inputData.jobId)}`, - }); - return [response.data]; -}; +const { url, searchOne, statusOutputFields, statusSample } = require('../lib/middleware'); + +const perform = (z, bundle) => + searchOne(z, { url: url(`/status/${encodeURIComponent(bundle.inputData.jobId)}`) }); module.exports = { key: 'job_status', noun: 'Job', display: { label: 'Find Job Status', - description: 'Look up a timestamp job by ID.', + description: 'Looks up a timestamp job by its job ID.', }, operation: { inputFields: [ { key: 'jobId', label: 'Job ID', type: 'string', required: true }, ], perform, - sample: { - id: '550e8400-e29b-41d4-a716-446655440000', - status: 'completed', - result: 'mock-cert', - }, - outputFields: [ - { key: 'id', label: 'Job ID' }, - { key: 'status', label: 'Status' }, - { key: 'result', label: 'Result' }, - ], + sample: statusSample, + outputFields: statusOutputFields, }, }; diff --git a/searches/job_verification.js b/searches/job_verification.js new file mode 100644 index 0000000..a9cff83 --- /dev/null +++ b/searches/job_verification.js @@ -0,0 +1,29 @@ +/** + * `GET /zapier/v1/status/{jobId}/verification` — block details and + * verification info for a job. 404 means no such job. + * @module searches/job_verification + */ + +const { url, searchOne, statusOutputFields, statusSample } = require('../lib/middleware'); + +const perform = (z, bundle) => + searchOne(z, { + url: url(`/status/${encodeURIComponent(bundle.inputData.jobId)}/verification`), + }); + +module.exports = { + key: 'job_verification', + noun: 'Verification', + display: { + label: 'Find Job Verification Details', + description: 'Retrieves block details and verification information for a timestamp job.', + }, + operation: { + inputFields: [ + { key: 'jobId', label: 'Job ID', type: 'string', required: true }, + ], + perform, + sample: statusSample, + outputFields: statusOutputFields, + }, +}; diff --git a/searches/tree_lookup.js b/searches/tree_lookup.js index a1ba223..7b4c9bb 100644 --- a/searches/tree_lookup.js +++ b/searches/tree_lookup.js @@ -1,18 +1,17 @@ -const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; +/** + * `GET /zapier/v1/hashes/{sha256}?includeAttached=true&includeTree=true` — + * main chain first, then tree-node archives for leaves sealed in a batch + * Merkle summary. 404 means no match anywhere. + * @module searches/tree_lookup + */ -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; - } -}; +const { url, searchOne } = require('../lib/middleware'); + +const perform = (z, bundle) => + searchOne(z, { + url: url(`/hashes/${encodeURIComponent(bundle.inputData.sha256)}`), + params: { includeAttached: 'true', includeTree: 'true' }, + }); module.exports = { key: 'tree_lookup', @@ -37,9 +36,20 @@ module.exports = { sample: { sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', exists: true, + jobId: '550e8400-e29b-41d4-a716-446655440000', + includeAttached: true, + includeTree: true, itemizedOnMainChain: false, merkleRoot: 'ab'.repeat(32), proofOk: true, }, + outputFields: [ + { key: 'sha256', label: 'SHA256' }, + { key: 'exists', label: 'Exists', type: 'boolean' }, + { key: 'jobId', label: 'Job ID' }, + { key: 'itemizedOnMainChain', label: 'Itemized On Main Chain', type: 'boolean' }, + { key: 'merkleRoot', label: 'Merkle Root' }, + { key: 'proofOk', label: 'Proof OK', type: 'boolean' }, + ], }, }; diff --git a/searches/usage.js b/searches/usage.js new file mode 100644 index 0000000..7b2b24f --- /dev/null +++ b/searches/usage.js @@ -0,0 +1,73 @@ +/** + * `GET /zapier/v1/auth/me` — the connection's tenant, plan, and month-to-date + * usage against plan limits. Lets a Zap branch before it hits a quota. + * @module searches/usage + */ + +const { url } = require('../lib/middleware'); + +const perform = async (z) => { + const response = await z.request({ url: url('/auth/me') }); + const me = response.data || {}; + const usage = me.usage || {}; + const limits = usage.limits || {}; + return [ + { + id: me.tenantId, + tenantId: me.tenantId, + plan: me.plan, + period: usage.period, + timestamps: usage.timestamps, + verifications: usage.verifications, + statusChecks: usage.statusChecks, + batchTimestamps: usage.batchTimestamps, + timestampsLimit: limits.timestamps ?? null, + verificationsLimit: limits.verifications ?? null, + batchAllowed: Boolean(limits.batch), + batchMaxItems: limits.batchMaxItems ?? null, + username: me.user && me.user.username, + }, + ]; +}; + +module.exports = { + key: 'usage', + noun: 'Usage', + display: { + label: 'Find Usage', + description: 'Returns your plan and month-to-date usage against its limits.', + }, + operation: { + inputFields: [], + perform, + sample: { + id: 'tenant-example', + tenantId: 'tenant-example', + plan: 'pro', + period: '2026-09', + timestamps: 12, + verifications: 3, + statusChecks: 20, + batchTimestamps: 0, + timestampsLimit: 5000, + verificationsLimit: 5000, + batchAllowed: true, + batchMaxItems: 100, + username: 'alice', + }, + outputFields: [ + { key: 'tenantId', label: 'Tenant ID' }, + { key: 'plan', label: 'Plan' }, + { key: 'period', label: 'Period' }, + { key: 'timestamps', label: 'Timestamps Used', type: 'integer' }, + { key: 'verifications', label: 'Verifications Used', type: 'integer' }, + { key: 'statusChecks', label: 'Status Checks', type: 'integer' }, + { key: 'batchTimestamps', label: 'Batch Timestamps', type: 'integer' }, + { key: 'timestampsLimit', label: 'Timestamp Limit', type: 'integer' }, + { key: 'verificationsLimit', label: 'Verification Limit', type: 'integer' }, + { key: 'batchAllowed', label: 'Batch Allowed', type: 'boolean' }, + { key: 'batchMaxItems', label: 'Batch Max Items', type: 'integer' }, + { key: 'username', label: 'Verae Username' }, + ], + }, +}; diff --git a/test/app.test.js b/test/app.test.js index 0c160d1..4359963 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -1,95 +1,392 @@ /** - * GATE 11 — Zapier package shape tests (no live Zapier CLI required) + * Unit tests for the Verae Zapier app — no network, no Zapier CLI. + * Every operation is exercised with a fake `z` whose request() answers from + * a route table keyed "METHOD /zapier/v1/path". */ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); +process.env.MIDDLEWARE_BASE_URL = 'https://mw.example.com/'; + const App = require('../index'); const authentication = require('../authentication'); -const timestampAndWait = require('../creates/timestamp_and_wait'); +const mw = require('../lib/middleware'); const createTimestamp = require('../creates/create_timestamp'); -const verifyTimestamp = require('../creates/verify_timestamp'); +const timestampAndWait = require('../creates/timestamp_and_wait'); const batchTimestamp = require('../creates/batch_timestamp'); +const verifyTimestamp = require('../creates/verify_timestamp'); +const batchVerify = require('../creates/batch_verify'); +const batchStatus = require('../creates/batch_status'); +const getReceipt = require('../creates/get_receipt'); const jobStatus = require('../searches/job_status'); +const jobVerification = require('../searches/job_verification'); +const hashLookup = require('../searches/hash_lookup'); +const treeLookup = require('../searches/tree_lookup'); const timestampCompleted = require('../triggers/timestamp_completed'); +const timestampFailed = require('../triggers/timestamp_failed'); +const findUsage = require('../searches/usage'); -describe('verae-zapier app definition', () => { - it('exports authentication with api_key field', () => { +const BASE = 'https://mw.example.com/zapier/v1'; + +class ZError extends Error { + constructor(message, code, status) { + super(message); + this.name = 'Error'; + this.code = code; + this.status = status; + } +} +class ThrottledError extends Error { + constructor(message) { + super(message); + this.name = 'ThrottledError'; + } +} + +/** + * @param {Record} routes "METHOD /path" -> response or (opts) => response + */ +const makeZ = (routes) => { + const calls = []; + return { + calls, + errors: { Error: ZError, ThrottledError }, + stashFile: async (_stream, _len, filename) => `https://zapier-stash.example/${filename}`, + request: async (opts) => { + const method = (opts.method || 'GET').toUpperCase(); + assert.ok(opts.url.startsWith(BASE), `unexpected base in ${opts.url}`); + const path = opts.url.slice(BASE.length); + calls.push({ ...opts, method, path }); + const handler = routes[`${method} ${path}`]; + if (!handler) throw new Error(`unexpected request ${method} ${path}`); + const res = typeof handler === 'function' ? handler(opts) : handler; + return { status: 200, data: {}, ...res }; + }, + }; +}; + +const bundle = (inputData = {}, extra = {}) => ({ + authData: { api_key: 'zmw_test' }, + inputData, + ...extra, +}); + +describe('app definition', () => { + it('uses custom API key auth against /auth/me', async () => { assert.equal(authentication.type, 'custom'); assert.ok(authentication.fields.some((f) => f.key === 'api_key')); + const z = makeZ({ 'GET /auth/me': { data: { valid: true, tenantId: 't1', plan: 'pro' } } }); + const me = await authentication.test(z, bundle()); + assert.equal(me.tenantId, 't1'); + assert.equal(authentication.connectionLabel, '{{tenantId}} ({{plan}})'); }); - it('wires creates, searches, triggers', () => { - assert.ok(App.creates.add_numbers); - assert.ok(App.creates.timestamp_and_wait); - assert.ok(App.creates.create_timestamp); - assert.ok(App.creates.verify_timestamp); - 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); + it('wires every trigger, create, and search', () => { + assert.deepEqual(Object.keys(App.triggers).sort(), ['timestamp_completed', 'timestamp_failed']); + assert.deepEqual( + Object.keys(App.creates).sort(), + ['batch_status', 'batch_timestamp', 'batch_verify', 'create_timestamp', 'get_receipt', 'timestamp_and_wait', 'verify_timestamp'], + ); + assert.deepEqual(Object.keys(App.searches).sort(), ['hash_lookup', 'job_status', 'job_verification', 'tree_lookup', 'usage']); }); - it('create actions target middleware paths', () => { - process.env.MIDDLEWARE_BASE_URL = 'https://mw.example.com'; - const urls = []; - const z = { - request: async (opts) => { - urls.push(opts.url); - return { data: { ok: true } }; - }, - }; - const bundle = { - authData: { api_key: 'zmw_test' }, - inputData: { data: 'x', certificate: 'c', items: 'a\nb', jobId: 'j1' }, - }; - - return Promise.all([ - timestampAndWait.operation.perform(z, bundle), - createTimestamp.operation.perform(z, bundle), - verifyTimestamp.operation.perform(z, bundle), - batchTimestamp.operation.perform(z, bundle), - jobStatus.operation.perform(z, bundle), - ]).then(() => { - assert.ok(urls.some((u) => u.endsWith('/zapier/v1/timestamp/wait'))); - assert.ok(urls.some((u) => u.endsWith('/zapier/v1/timestamp'))); - assert.ok(urls.some((u) => u.endsWith('/zapier/v1/verify'))); - assert.ok(urls.some((u) => u.endsWith('/zapier/v1/timestamp/batch'))); - assert.ok(urls.some((u) => u.includes('/zapier/v1/status/'))); - }); + it('builds /zapier/v1 URLs from MIDDLEWARE_BASE_URL (trailing slash tolerated)', () => { + assert.equal(mw.url('/timestamp/wait'), 'https://mw.example.com/zapier/v1/timestamp/wait'); }); - it('trigger subscribe/unsubscribe shapes', async () => { - process.env.MIDDLEWARE_BASE_URL = 'https://mw.example.com'; - const calls = []; - const z = { - request: async (opts) => { - calls.push(opts); - return { data: { id: 'hook-1' } }; - }, - }; - - const id = await timestampCompleted.operation.performSubscribe(z, { - targetUrl: 'https://hooks.zapier.com/x', - authData: { api_key: 'k' }, - }); - assert.equal(id, 'hook-1'); - assert.equal(calls[0].method, 'POST'); - assert.match(calls[0].url, /webhooks\/subscribe$/); - assert.equal(calls[0].body.event, 'timestamp.completed'); - - await timestampCompleted.operation.performUnsubscribe(z, { - subscribeData: 'hook-1', - authData: { api_key: 'k' }, - }); - assert.equal(calls[1].method, 'DELETE'); - assert.match(calls[1].url, /webhooks\/unsubscribe$/); - }); - - it('beforeRequest adds Authorization bearer', () => { - const req = App.beforeRequest[0]({}, null, { authData: { api_key: 'zmw_abc' } }); - assert.equal(req.headers.Authorization, 'Bearer zmw_abc'); + it('honours MIDDLEWARE_PATH_PREFIX for a fronting edge', () => { + const saved = process.env.MIDDLEWARE_PATH_PREFIX; + try { + process.env.MIDDLEWARE_PATH_PREFIX = 'v1/'; + assert.equal(mw.url('/timestamp'), 'https://mw.example.com/v1/timestamp'); + process.env.MIDDLEWARE_PATH_PREFIX = ''; + assert.equal(mw.url('/timestamp'), 'https://mw.example.com/timestamp'); + } finally { + if (saved === undefined) delete process.env.MIDDLEWARE_PATH_PREFIX; + else process.env.MIDDLEWARE_PATH_PREFIX = saved; + } + }); +}); + +describe('request middleware', () => { + const addApiKey = App.beforeRequest[0]; + const mapErrors = App.afterResponse[0]; + const z = makeZ({}); + + it('adds the API key as both a bearer token and x-api-key', () => { + const req = addApiKey({}, z, { authData: { api_key: 'zmw_abc' } }); + assert.equal(req.headers.Authorization, 'Bearer zmw_abc'); + assert.equal(req.headers['x-api-key'], 'zmw_abc'); + const untouched = addApiKey({ headers: { Authorization: 'Bearer other' } }, z, { authData: { api_key: 'zmw_abc' } }); + assert.equal(untouched.headers.Authorization, 'Bearer other'); + assert.equal(addApiKey({}, z, { authData: {} }).headers['x-api-key'], undefined); + }); + + it('maps 402 quota errors with the upgrade URL from details', () => { + const response = { + status: 402, + data: { error: 'Monthly timestamp quota exceeded', code: 'QUOTA_EXCEEDED', details: { upgradeUrl: 'https://veraetime.net/billing' } }, + }; + assert.throws( + () => mapErrors(response, z), + (err) => err instanceof ZError && err.code === 'QUOTA_EXCEEDED' && err.status === 402 + && err.message === 'Monthly timestamp quota exceeded. Upgrade at https://veraetime.net/billing.', + ); + }); + + it('maps 403 plan and batch-limit errors as billing errors', () => { + for (const code of ['PLAN_UPGRADE_REQUIRED', 'BATCH_LIMIT_EXCEEDED']) { + assert.throws( + () => mapErrors({ status: 403, data: { error: 'nope', code, details: { upgradeUrl: 'https://u' } } }, z), + (err) => err instanceof ZError && err.code === code && /Upgrade at https:\/\/u\./.test(err.message), + ); + } + }); + + it('maps other JSON errors, plain-text errors, 429, and leaves skipThrowForStatus alone', () => { + assert.throws( + () => mapErrors({ status: 401, data: { error: 'Invalid API key', code: 'UNAUTHORIZED' } }, z), + (err) => err instanceof ZError && err.message === 'Invalid API key' && err.code === 'UNAUTHORIZED' && err.status === 401, + ); + assert.throws( + () => mapErrors({ status: 502, data: 'Bad Gateway', content: 'Bad Gateway' }, z), + (err) => err instanceof ZError && err.message === 'Bad Gateway' && err.code === 'HTTP_502', + ); + assert.throws(() => mapErrors({ status: 429, data: { error: 'Rate limit exceeded', code: 'RATE_LIMITED' } }, z), ThrottledError); + const skipped = { status: 404, skipThrowForStatus: true }; + assert.equal(mapErrors(skipped, z), skipped); + const ok = { status: 202, data: {} }; + assert.equal(mapErrors(ok, z), ok); + }); +}); + +describe('creates', () => { + it('create_timestamp posts data/hashAlg to /timestamp', async () => { + const z = makeZ({ + 'POST /timestamp': (opts) => { + assert.deepEqual(opts.body, { data: 'hello', hashAlg: 'SHA256' }); + return { status: 202, data: { jobId: 'job-1', sha256: 'abc', existing: false } }; + }, + }); + const out = await createTimestamp.operation.perform(z, bundle({ data: 'hello', hashAlg: 'SHA256' })); + assert.equal(out.jobId, 'job-1'); + }); + + it('create_timestamp accepts a SHA256 instead of data, plus metadata, and validates input', async () => { + const sha = 'AB'.repeat(32); + const z = makeZ({ + 'POST /timestamp': (opts) => { + assert.deepEqual(opts.body, { + sha256: sha.toLowerCase(), + hashAlg: 'SHA256', + publicMetadata: { title: 'Doc' }, + privateMetadata: { owner: 'me' }, + }); + return { status: 202, data: { jobId: 'job-5', sha256: sha.toLowerCase(), existing: true } }; + }, + }); + const out = await createTimestamp.operation.perform(z, bundle({ + sha256: ` ${sha} `, hashAlg: 'SHA256', publicMetadata: { title: 'Doc' }, privateMetadata: { owner: 'me' }, + })); + assert.equal(out.existing, true); + + await assert.rejects(createTimestamp.operation.perform(z, bundle({})), (err) => /Data or a SHA256/.test(err.message) && err.status === 400); + await assert.rejects(createTimestamp.operation.perform(z, bundle({ sha256: 'not-hex' })), (err) => /64 hexadecimal/.test(err.message)); + // Empty metadata dicts are dropped, not sent. + const z2 = makeZ({ 'POST /timestamp': (opts) => { assert.deepEqual(opts.body, { data: 'x', hashAlg: undefined }); return { status: 202, data: { jobId: 'j' } }; } }); + await createTimestamp.operation.perform(z2, bundle({ data: 'x', publicMetadata: {}, privateMetadata: null })); + }); + + it('timestamp_and_wait posts to /timestamp/wait and normalises id/jobId', async () => { + const z = makeZ({ + 'POST /timestamp/wait': { data: { id: 'job-2', status: 'completed', result: 'cert' } }, + }); + const out = await timestampAndWait.operation.perform(z, bundle({ data: 'x' })); + assert.equal(out.id, 'job-2'); + assert.equal(out.jobId, 'job-2'); + assert.equal(out.result, 'cert'); + + const pending = makeZ({ 'POST /timestamp/wait': { data: { id: 'job-3', jobId: 'job-3', status: 'pending' } } }); + const late = await timestampAndWait.operation.perform(pending, bundle({ data: 'x' })); + assert.equal(late.status, 'pending'); + assert.equal(late.id, 'job-3'); + }); + + it('batch_timestamp accepts list input or newline text and rejects empty input', async () => { + const z = makeZ({ + 'POST /timestamp/batch': (opts) => { + assert.deepEqual(opts.body.items, [{ data: 'a', hashAlg: 'SHA256' }, { data: 'b', hashAlg: 'SHA256' }]); + return { status: 202, data: { jobIds: ['j1', 'j2'] } }; + }, + }); + assert.deepEqual(await batchTimestamp.operation.perform(z, bundle({ items: ['a', 'b'], hashAlg: 'SHA256' })), { jobIds: ['j1', 'j2'], count: 2 }); + assert.deepEqual(await batchTimestamp.operation.perform(z, bundle({ items: 'a\n b \n\n', hashAlg: 'SHA256' })), { jobIds: ['j1', 'j2'], count: 2 }); + await assert.rejects(batchTimestamp.operation.perform(z, bundle({ items: '' })), ZError); + }); + + it('verify_timestamp posts the certificate to /verify', async () => { + const z = makeZ({ + 'POST /verify': (opts) => { + assert.deepEqual(opts.body, { certificate: 'cert' }); + return { data: { valid: true, timestamp: '2023-01-01T12:00:00Z', blockIndex: 42 } }; + }, + }); + const out = await verifyTimestamp.operation.perform(z, bundle({ certificate: 'cert' })); + assert.equal(out.valid, true); + }); + + it('batch_verify posts to /verify/batch and summarises', async () => { + const z = makeZ({ + 'POST /verify/batch': (opts) => { + assert.deepEqual(opts.body, { certificates: ['c1', 'c2'] }); + return { data: { results: [{ valid: true, blockIndex: 1 }, { valid: false }] } }; + }, + }); + const out = await batchVerify.operation.perform(z, bundle({ certificates: ['c1', 'c2'] })); + assert.deepEqual([out.total, out.validCount, out.invalidCount, out.allValid], [2, 1, 1, false]); + }); + + it('batch_status posts to /status/batch and preserves input order', async () => { + const z = makeZ({ + 'POST /status/batch': (opts) => { + assert.deepEqual(opts.body, { jobIds: ['j1', 'j2', 'j3'] }); + return { data: { results: { j2: { id: 'j2', status: 'pending' }, j1: { id: 'j1', status: 'completed' } } } }; + }, + }); + const out = await batchStatus.operation.perform(z, bundle({ jobIds: ['j1', 'j2', 'j3'] })); + assert.deepEqual(out.results.map((r) => [r.id, r.status]), [['j1', 'completed'], ['j2', 'pending'], ['j3', 'unknown']]); + assert.deepEqual([out.completedCount, out.pendingCount, out.failedCount, out.allCompleted], [1, 1, 0, false]); + }); + + it('get_receipt fetches JSON and optionally stashes the PDF', async () => { + const z = makeZ({ + 'GET /receipts/job-1': (opts) => ( + opts.params.format === 'pdf' + ? { data: 'pdf-bytes' } + : { data: { type: 'verae.retrieval-receipt', jobId: 'job-1', certificate: 'cert' } } + ), + }); + const plain = await getReceipt.operation.perform(z, bundle({ jobId: 'job-1' })); + assert.equal(plain.id, 'job-1'); + assert.equal(plain.certificate, 'cert'); + assert.equal(plain.pdf, undefined); + + const withPdf = await getReceipt.operation.perform(z, bundle({ jobId: 'job-1', includePdf: true })); + assert.equal(withPdf.pdf, 'https://zapier-stash.example/verae-receipt-job-1.pdf'); + const pdfCall = z.calls.find((c) => c.params && c.params.format === 'pdf'); + assert.equal(pdfCall.raw, true); + }); +}); + +describe('searches', () => { + it('job_status wraps a hit, returns [] on 404, and raises the middleware message otherwise', async () => { + const z = makeZ({ + 'GET /status/job-1': { data: { id: 'job-1', status: 'completed' } }, + 'GET /status/missing': { status: 404, data: { error: 'Job not found', code: 'NOT_FOUND' } }, + 'GET /status/broken': { status: 500, data: { error: 'chain down', code: 'INTERNAL_ERROR' } }, + }); + assert.deepEqual(await jobStatus.operation.perform(z, bundle({ jobId: 'job-1' })), [{ id: 'job-1', status: 'completed' }]); + assert.deepEqual(await jobStatus.operation.perform(z, bundle({ jobId: 'missing' })), []); + await assert.rejects(jobStatus.operation.perform(z, bundle({ jobId: 'broken' })), (err) => err.message === 'chain down' && err.status === 500); + assert.ok(z.calls.every((c) => c.skipThrowForStatus === true), 'searches must opt out of throwForStatus'); + }); + + it('job_verification hits /status/{jobId}/verification', async () => { + const z = makeZ({ 'GET /status/job-1/verification': { data: { id: 'job-1', metadata: { blockIndex: 42 } } } }); + const [out] = await jobVerification.operation.perform(z, bundle({ jobId: 'job-1' })); + assert.equal(out.metadata.blockIndex, 42); + }); + + it('hash_lookup and tree_lookup hit /hashes/{sha256}, tree adds the include flags, 501 is surfaced', async () => { + const sha = 'a'.repeat(64); + const z = makeZ({ + [`GET /hashes/${sha}`]: (opts) => ( + opts.params + ? { data: { sha256: sha, exists: true, includeTree: true } } + : { data: { sha256: sha, exists: true, jobId: 'job-1' } } + ), + [`GET /hashes/${'b'.repeat(64)}`]: { status: 501, data: { error: 'Hash lookup is not on the live Verae OpenAPI', code: 'NOT_IMPLEMENTED' } }, + }); + const [hit] = await hashLookup.operation.perform(z, bundle({ sha256: sha })); + assert.equal(hit.jobId, 'job-1'); + const [tree] = await treeLookup.operation.perform(z, bundle({ sha256: sha })); + assert.equal(tree.includeTree, true); + assert.deepEqual(z.calls[1].params, { includeAttached: 'true', includeTree: 'true' }); + await assert.rejects(hashLookup.operation.perform(z, bundle({ sha256: 'b'.repeat(64) })), (err) => err.code === 'NOT_IMPLEMENTED' && err.status === 501); + }); + + it('usage search maps /auth/me into one flat record', async () => { + const z = makeZ({ + 'GET /auth/me': { + data: { + valid: true, tenantId: 't1', plan: 'free', user: { username: 'ada' }, + usage: { period: '2026-09', timestamps: 3, verifications: 1, statusChecks: 4, batchTimestamps: 0, limits: { timestamps: 50, verifications: 50, batch: false, batchMaxItems: 0 } }, + }, + }, + }); + const [u] = await findUsage.operation.perform(z, bundle()); + assert.equal(u.id, 't1'); + assert.equal(u.plan, 'free'); + assert.equal(u.timestamps, 3); + assert.equal(u.timestampsLimit, 50); + assert.equal(u.batchAllowed, false); + assert.equal(u.username, 'ada'); + }); + + it('every search sample has fields Zapier can key on', () => { + for (const s of [jobStatus, jobVerification, hashLookup, treeLookup, findUsage]) { + assert.ok(s.operation.sample && Object.keys(s.operation.sample).length > 0, `${s.key} needs a sample`); + } + }); +}); + +describe('triggers', () => { + it('subscribe stores hook id + event; unsubscribe sends hookId', async () => { + const z = makeZ({ + 'POST /webhooks/subscribe': (opts) => { + assert.equal(opts.body.targetUrl, 'https://hooks.zapier.com/x'); + return { status: 201, data: { id: 'hook-1', event: opts.body.event } }; + }, + 'DELETE /webhooks/unsubscribe': (opts) => { + assert.equal(opts.body.hookId, 'hook-1'); + return { data: { removed: true } }; + }, + }); + const sub = await timestampCompleted.operation.performSubscribe(z, bundle({}, { targetUrl: 'https://hooks.zapier.com/x' })); + assert.deepEqual(sub, { id: 'hook-1', event: 'timestamp.completed' }); + assert.equal(z.calls[0].body.event, 'timestamp.completed'); + + await timestampCompleted.operation.performUnsubscribe(z, bundle({}, { subscribeData: sub, targetUrl: 'https://hooks.zapier.com/x' })); + // Legacy subscribeData (a bare string) still works. + await timestampCompleted.operation.performUnsubscribe(z, bundle({}, { subscribeData: 'hook-1' })); + assert.equal(z.calls.filter((c) => c.method === 'DELETE').length, 2); + }); + + it('timestamp_failed subscribes to timestamp.failed', async () => { + const z = makeZ({ 'POST /webhooks/subscribe': { status: 201, data: { id: 'hook-2' } } }); + const sub = await timestampFailed.operation.performSubscribe(z, bundle({}, { targetUrl: 'https://hooks.zapier.com/y' })); + assert.equal(sub.event, 'timestamp.failed'); + assert.equal(z.calls[0].body.event, 'timestamp.failed'); + }); + + it('perform gives each delivered payload an id (the jobId) for dedupe', async () => { + const payload = { event: 'timestamp.completed', jobId: 'job-9', tenantId: 't', status: { id: 'job-9', status: 'completed' } }; + const [item] = await timestampCompleted.operation.perform(makeZ({}), bundle({}, { cleanedRequest: payload })); + assert.equal(item.id, 'job-9'); + assert.equal(item.status.status, 'completed'); + assert.deepEqual(await timestampCompleted.operation.performList(makeZ({}), bundle()), []); + assert.equal(timestampFailed.operation.sample.status.status, 'failed'); + }); +}); + +describe('lib/middleware.describeError', () => { + it('prefers JSON bodies (with upgradeUrl), then text, then a generic message', () => { + assert.deepEqual( + mw.describeError({ status: 402, data: { error: 'quota', code: 'QUOTA_EXCEEDED', details: { upgradeUrl: 'https://u', limit: 50 } } }), + { message: 'quota', code: 'QUOTA_EXCEEDED', upgradeUrl: 'https://u', details: { upgradeUrl: 'https://u', limit: 50 } }, + ); + assert.deepEqual(mw.describeError({ status: 504, data: 'upstream timeout', content: 'upstream timeout' }), { message: 'upstream timeout', code: 'HTTP_504' }); + assert.deepEqual(mw.describeError({ status: 502, content: 'bad gateway' }), { message: 'Verae middleware request failed with HTTP 502', code: 'HTTP_502' }); }); }); diff --git a/test/e2e.test.js b/test/e2e.test.js new file mode 100644 index 0000000..0880f7a --- /dev/null +++ b/test/e2e.test.js @@ -0,0 +1,202 @@ +/** + * End-to-end test: drives the real app definition through zapier-platform-core + * (beforeRequest / afterResponse / throwForStatus) against a running + * verae-middleware. + * + * Opt in with an API key for a tenant on that middleware: + * + * MIDDLEWARE_BASE_URL=http://127.0.0.1:3100 MIDDLEWARE_API_KEY=zmw_... npm run test:e2e + * + * Works for any plan: on a free tenant the batch step asserts the 403 + * PLAN_UPGRADE_REQUIRED mapping instead of a successful batch. The hook + * delivery test needs the middleware to reach this machine; set + * E2E_SKIP_HOOK_DELIVERY=1 when it cannot (e.g. the live lab middleware). + * The middleware may run in mock-chain mode or against api.veraetime.net. + */ + +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); +const { createHash } = require('node:crypto'); + +const apiKey = process.env.MIDDLEWARE_API_KEY; +const enabled = Boolean(apiKey); + +describe('e2e against verae-middleware', { skip: !enabled && 'set MIDDLEWARE_API_KEY (and MIDDLEWARE_BASE_URL) to run' }, () => { + const zapier = require('zapier-platform-core'); + const App = require('../index'); + const appTester = zapier.createAppTester(App); + const authData = { api_key: apiKey }; + const run = (op, inputData = {}, extra = {}) => appTester(op, { authData, inputData, ...extra }); + + /** Local HTTP sink that plays the role of hooks.zapier.com. */ + const deliveries = []; + let hookServer; + let hookUrl; + + before(async () => { + await new Promise((resolve) => { + hookServer = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + deliveries.push(JSON.parse(body || '{}')); + res.writeHead(200); + res.end('ok'); + }); + }); + hookServer.listen(0, '127.0.0.1', () => { + hookUrl = `http://127.0.0.1:${hookServer.address().port}/hook`; + resolve(); + }); + }); + }); + + after(async () => { + await new Promise((resolve) => hookServer.close(resolve)); + }); + + const waitFor = async (predicate, ms = 15000) => { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const value = await predicate(); + if (value) return value; + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error('timed out waiting'); + }; + + let tenantId; + let plan; + let completed; // StatusResponse of a completed job + const skipHookDelivery = process.env.E2E_SKIP_HOOK_DELIVERY === '1'; + const data = `zapier-e2e-${Date.now()}`; + const sha256 = createHash('sha256').update(data).digest('hex'); + + it('authenticates and labels the connection', async () => { + const me = await run(App.authentication.test); + assert.equal(me.valid, true); + assert.ok(me.tenantId); + assert.ok(me.plan); + tenantId = me.tenantId; + plan = me.plan; + }); + + it('rejects a bad API key with the middleware message', async () => { + await assert.rejects( + appTester(App.authentication.test, { authData: { api_key: 'zmw_definitely-not-a-key' } }), + (err) => /Invalid API key/.test(err.message), + ); + }); + + it('subscribes and unsubscribes a hook, creates a timestamp, and sees it complete', async () => { + const sub = await run(App.triggers.timestamp_completed.operation.performSubscribe, {}, { targetUrl: hookUrl }); + assert.ok(sub.id); + + const created = await run(App.creates.create_timestamp.operation.perform, { data }); + assert.ok(created.jobId); + + let status; + if (skipHookDelivery) { + status = await waitFor(async () => { + const [s] = await run(App.searches.job_status.operation.perform, { jobId: created.jobId }); + return s && s.status === 'completed' ? s : null; + }); + } else { + const delivered = await waitFor(async () => deliveries.find((d) => d.jobId === created.jobId)); + assert.equal(delivered.event, 'timestamp.completed'); + assert.equal(delivered.tenantId, tenantId); + assert.equal(delivered.status.status, 'completed'); + const [item] = await run(App.triggers.timestamp_completed.operation.perform, {}, { cleanedRequest: delivered }); + assert.equal(item.id, created.jobId); + status = delivered.status; + } + + await run(App.triggers.timestamp_completed.operation.performUnsubscribe, {}, { subscribeData: sub, targetUrl: hookUrl }); + completed = { id: created.jobId, ...status }; + }); + + it('re-registering the same SHA-256 is idempotent (existing: true)', async () => { + const again = await run(App.creates.create_timestamp.operation.perform, { sha256 }); + assert.equal(again.jobId, completed.id); + assert.equal(again.existing, true); + }); + + it('reports usage for the connection', async () => { + const [usage] = await run(App.searches.usage.operation.perform); + assert.equal(usage.tenantId, tenantId); + assert.equal(usage.plan, plan); + assert.ok(usage.timestamps >= 1); + }); + + it('finds the job by id and by hash; a missing job is no match', async () => { + const [status] = await run(App.searches.job_status.operation.perform, { jobId: completed.id }); + assert.equal(status.status, 'completed'); + assert.ok(status.result); + + const [details] = await run(App.searches.job_verification.operation.perform, { jobId: completed.id }); + assert.equal(details.id, completed.id); + + const [byHash] = await run(App.searches.hash_lookup.operation.perform, { sha256 }); + assert.equal(byHash.exists, true); + assert.equal(byHash.jobId, completed.id); + + const [tree] = await run(App.searches.tree_lookup.operation.perform, { sha256 }); + assert.equal(tree.includeTree, true); + + assert.deepEqual(await run(App.searches.job_status.operation.perform, { jobId: '00000000-0000-0000-0000-000000000000' }), []); + assert.deepEqual(await run(App.searches.hash_lookup.operation.perform, { sha256: 'f'.repeat(64) }), []); + }); + + it('verifies the certificate, singly and in batch', async () => { + const single = await run(App.creates.verify_timestamp.operation.perform, { certificate: completed.result }); + assert.equal(single.valid, true); + + const batch = await run(App.creates.batch_verify.operation.perform, { certificates: [completed.result, 'not-a-certificate'] }); + assert.equal(batch.total, 2); + assert.equal(batch.results[0].valid, true); + assert.equal(batch.results[1].valid, false); + assert.equal(batch.allValid, false); + }); + + it('timestamp-and-wait returns a terminal or pending status with a job id', async () => { + const out = await run(App.creates.timestamp_and_wait.operation.perform, { data: `${data}-wait` }); + assert.ok(out.id); + assert.ok(['completed', 'pending'].includes(out.status), out.status); + }); + + it('batch timestamps then batch status (paid plan), or a clear upgrade error (free plan)', async () => { + const items = [`${data}-b1`, `${data}-b2`]; + if (plan === 'free') { + await assert.rejects( + run(App.creates.batch_timestamp.operation.perform, { items }), + (err) => /paid plan/i.test(err.message) && /Upgrade at https?:\/\//.test(err.message), + ); + return; + } + const batch = await run(App.creates.batch_timestamp.operation.perform, { items }); + assert.equal(batch.count, 2); + + const statuses = await waitFor(async () => { + const out = await run(App.creates.batch_status.operation.perform, { jobIds: batch.jobIds }); + return out.allCompleted ? out : null; + }); + assert.equal(statuses.completedCount, 2); + assert.deepEqual(statuses.results.map((r) => r.id), batch.jobIds); + }); + + it('fetches a retrieval receipt for the completed job', async () => { + const receipt = await run(App.creates.get_receipt.operation.perform, { jobId: completed.id }); + assert.equal(receipt.type, 'verae.retrieval-receipt'); + assert.equal(receipt.jobId, completed.id); + assert.equal(receipt.certificate, completed.result); + assert.equal(receipt.extraSeal.event, 'document.retrieved'); + }); + + it('refuses a receipt for a job that is not complete', async () => { + await assert.rejects( + run(App.creates.get_receipt.operation.perform, { jobId: '00000000-0000-0000-0000-000000000000' }), + (err) => /not found|NOT_COMPLETE|Receipt only/i.test(`${err.message} ${err.code || ''}`), + ); + }); +}); diff --git a/triggers/timestamp_completed.js b/triggers/timestamp_completed.js index 300c36a..70a5a10 100644 --- a/triggers/timestamp_completed.js +++ b/triggers/timestamp_completed.js @@ -1,53 +1,13 @@ -const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; +/** + * REST Hook: fires when a timestamp job completes (`timestamp.completed`). + * @module triggers/timestamp_completed + */ -const subscribeHook = async (z, bundle) => { - const response = await z.request({ - method: 'POST', - url: `${base()}/zapier/v1/webhooks/subscribe`, - body: { - targetUrl: bundle.targetUrl, - event: 'timestamp.completed', - }, - }); - return response.data.id; -}; +const hookTrigger = require('../lib/hookTrigger'); -const unsubscribeHook = async (z, bundle) => { - await z.request({ - method: 'DELETE', - url: `${base()}/zapier/v1/webhooks/unsubscribe`, - body: { - hookId: bundle.subscribeData, - }, - }); - return {}; -}; - -const perform = async (z, bundle) => [bundle.cleanedRequest]; - -const performList = async () => []; - -module.exports = { +module.exports = hookTrigger({ key: 'timestamp_completed', - noun: 'Timestamp', - display: { - label: 'Timestamp Completed', - description: 'Triggers when a blockchain timestamp job completes.', - }, - operation: { - type: 'hook', - perform, - performList, - performSubscribe: subscribeHook, - performUnsubscribe: unsubscribeHook, - sample: { - event: 'timestamp.completed', - jobId: '550e8400-e29b-41d4-a716-446655440000', - status: { id: '550e8400-e29b-41d4-a716-446655440000', status: 'completed' }, - }, - outputFields: [ - { key: 'event', label: 'Event' }, - { key: 'jobId', label: 'Job ID' }, - ], - }, -}; + event: 'timestamp.completed', + label: 'Timestamp Completed', + description: 'Triggers when a blockchain timestamp job completes and its certificate is ready.', +}); diff --git a/triggers/timestamp_failed.js b/triggers/timestamp_failed.js new file mode 100644 index 0000000..66277fb --- /dev/null +++ b/triggers/timestamp_failed.js @@ -0,0 +1,21 @@ +/** + * REST Hook: fires when a timestamp job fails (`timestamp.failed`). + * @module triggers/timestamp_failed + */ + +const hookTrigger = require('../lib/hookTrigger'); +const { statusSample } = require('../lib/middleware'); + +module.exports = hookTrigger({ + key: 'timestamp_failed', + event: 'timestamp.failed', + label: 'Timestamp Failed', + description: 'Triggers when a blockchain timestamp job fails, so you can alert or retry.', + sampleStatus: { + id: statusSample.id, + status: 'failed', + error: 'chain rejected payload', + completedAt: statusSample.completedAt, + sha256: statusSample.sha256, + }, +}); From d42aa4e4551dd9d74057a346bdafc31b31680973 Mon Sep 17 00:00:00 2001 From: Andrew Birkholz Date: Tue, 15 Sep 2026 12:34:00 -0600 Subject: [PATCH 2/2] README: proposed Verae chain API changes for hash lookup, receipts, metadata Answers the developer question "how do I query my original hash, get a receipt on submission and on lookup, see all timestamp metadata, and store metadata or links in a timestamp". Lists the required additions to api.veraetime.net (hash lookup, idempotent create with digest input, full status on lookup, metadata field), recommended extras (certified retrieval receipt, JSON errors, completion callback), spec rules for public vs private metadata, and what the middleware can cover without API changes. Co-Authored-By: Claude Fable 5.1 --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/README.md b/README.md index b6ff75f..df9821b 100644 --- a/README.md +++ b/README.md @@ -156,3 +156,52 @@ Where the Zapier ↔ Verae path stands, and what is still open. Sources: this re - Encrypted object storage and shares (`/objects*`, `/shares/*`) — mock-only today; add creates here once the middleware contract is final. - Anything that talks to NATS or api.veraetime.net directly. + +## Proposed changes to the Verae chain API + +Context: a developer asked how to query an original hash, get a receipt back from both the +original submission and later lookups, see every timestamp field and its metadata, and store +metadata (or links to it) in a blockchain timestamp. Against the live OpenAPI +(`docs/00-sources/veraetime-openapi.yaml`) none of that is possible today: lookups are by job ID +only, `POST /api/timestamp` returns just `{ jobId }`, and the request has no metadata field. +The middleware mocks all of it (`MOCK_VERAE=true`) but its live client may only call paths in +the snapshot spec. + +### Required on api.veraetime.net + +| Ask | Change to the Verae API | Why the chain, not the middleware | +|-----|-------------------------|-----------------------------------| +| Query by original hash | New `GET /api/hashes/{sha256}` → `{ exists, jobId, sha256, timestamp, blockIndex, certificate }`. The chain already records `dataHash` per transaction; it needs an index on it. | Only the chain can find hashes sealed by any submitter. A middleware index only knows what passed through it. | +| Receipt on original submission | Make `POST /api/timestamp` idempotent on the digest: if the hash already exists return `200 { jobId, existing: true, certificate, timestamp, blockIndex }` instead of `202`. Accept a pre-hashed input `{ sha256 }` alongside `{ data }` so clients never ship the document. | Idempotency has to live where the seal lives. Accepting a digest also stops raw data landing in blocks. | +| Receipt on lookup | Have the hash lookup and `GET /api/verify/{jobId}` return the full `StatusResponse` (certificate + block metadata). The shape exists; the lookup just needs to reuse it. | No new data, a consistent response. | +| Metadata in the timestamp | Optional `metadata` object on `TimestampRequest` with a documented size cap, stored with the transaction and returned on status, verify, and hash lookup. Add `metadataHash` as an alternative for callers who want to commit to metadata without publishing it. | Anything the seal must prove existed at that time has to be inside what the chain hashes. Everything else stays off chain. | + +### Strongly recommended + +- **Certified retrieval receipt** — `GET /api/receipts/{jobId}?format=json|pdf`, where the chain + seals a `document.retrieved` event referencing the original job and returns both. The + middleware mocks this; a chain-signed receipt is what makes it certified. +- **JSON error bodies everywhere** — the spec promises `{ error, code }` but the live service + returns plain text on 401 (`Unauthorized: token is missing`). Every client special-cases it. +- **Completion callback or job list** — an optional `callbackUrl` on create, or + `GET /api/jobs?since=…`. Today every consumer polls `GET /api/status/{jobId}` per job. + +### Rules to write into the spec + +- Public metadata on chain is immutable and readable by anyone. Private metadata never goes on + chain; only a hash commitment does. +- Define `data` as "bytes to hash" and state whether blocks retain it. The `/admin/*` examples + show blocks carrying a base64 `data` field that decodes to the sample input, which suggests + they do — that matters for privacy and should be confirmed on a live seal. + +### What can be done without touching the API + +The middleware can index SHA-256 → job ID at submission, store public/private metadata, and +merge both back into `/status/{jobId}` and `/hashes/{sha256}` on the live path (it already does +so in mock). That covers the developer's flow for anything submitted through Verae's own +middleware. It cannot deliver chain-wide lookup, chain-level idempotency, or a chain-signed +receipt — those are the real API asks in the table above. + +This Zapier app already sends `sha256`, `publicMetadata`, and `privateMetadata` to the +middleware and exposes Find Timestamp by SHA256, Find Job Verification Details, and Get +Retrieval Receipt, so no app change is needed once either side lands.