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 3cb834c..df9821b 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,109 @@ # verae-zapier -[![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme) - -Zapier Platform CLI app for Verae. Runs on **Zapier’s servers**. Calls only middleware HTTPS... +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 @@ -39,24 +112,96 @@ export MIDDLEWARE_BASE_URL=https://your-middleware.example.com npm run gate:11 # from monorepo root, after Phase 11 implementation ``` -## Install +## Status of the API integration (2026-09-15) -```sh -git clone ssh://git@git.georgelambert.org:2223/marchon/verae-zapier-app.git -cd verae-zapier-app -``` +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`). -## Usage +### Done -```sh -# No single entrypoint is declared in this tree. -ls -``` +- 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). -## Contributing +### Open — blocked on people, not code -Questions: open an issue at https://git.georgelambert.org/marchon/verae-zapier-app/issues. Pull requests are accepted. +| 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. | -## License +### Open — code in other repos -UNLICENSED © George Lambert +| 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. + +## 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. 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' } }; + 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); + }); - const id = await timestampCompleted.operation.performSubscribe(z, { - targetUrl: 'https://hooks.zapier.com/x', - authData: { api_key: 'k' }, + 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 } }, + }, + }, }); - 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'); + 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`); + } + }); +}); - await timestampCompleted.operation.performUnsubscribe(z, { - subscribeData: 'hook-1', - authData: { api_key: 'k' }, +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 } }; + }, }); - assert.equal(calls[1].method, 'DELETE'); - assert.match(calls[1].url, /webhooks\/unsubscribe$/); + 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'); }); +}); - it('beforeRequest adds Authorization bearer', () => { - const req = App.beforeRequest[0]({}, null, { authData: { api_key: 'zmw_abc' } }); - assert.equal(req.headers.Authorization, 'Bearer zmw_abc'); +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, + }, +});