commit 553984c942a17c547f64108eb27baf6487064211 Author: George Lambert Date: Fri Sep 11 13:16:09 2026 -0400 Initial import of verae-zapier-app from zapier monorepo diff --git a/NATS.md b/NATS.md new file mode 100644 index 0000000..3254376 --- /dev/null +++ b/NATS.md @@ -0,0 +1,10 @@ +# NATS — verae-zapier-app + +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_` | + +Job completion is a REST Hook HTTP POST from webhook-deliver, not a NATS client in Zapier. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c62647 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# verae-zapier + +Zapier Platform CLI app for Verae. **Phase 11** in [../TODO.md](../TODO.md). + +## Role + +Runs on **Zapier’s servers**. Calls only the middleware HTTPS API (`MIDDLEWARE_BASE_URL`), never NATS and never `api.veraetime.net` directly. + +## Planned 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 | + +See [../docs/developer/modules/function-reference.md](../docs/developer/modules/function-reference.md) for I/O contracts. + +## Env + +```bash +export MIDDLEWARE_BASE_URL=https://your-middleware.example.com +``` + +## Gate + +```bash +npm run gate:11 # from monorepo root, after Phase 11 implementation +``` diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..94b22b3 --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,9 @@ +# verae-zapier-app + +**Job:** Full Zapier CLI app: timestamp, wait, verify, batch, job status, hash lookup, REST Hook. + +**Expects:** `zmw_` middleware key (today) or zappier key after composition. + +**Sends:** HTTPS to middleware `/zapier/v1/*`. + +**Does not** speak NATS. diff --git a/authentication.js b/authentication.js new file mode 100644 index 0000000..0a9b5e3 --- /dev/null +++ b/authentication.js @@ -0,0 +1,38 @@ +/** + * Custom API key auth against Verae middleware. + * @module authentication + */ + +const middlewareBase = () => + process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; + +/** + * Zapier connection test — validates API key. + * @param {object} z + * @param {object} bundle + */ +const testAuth = async (z, bundle) => { + const response = await z.request({ + url: `${middlewareBase()}/zapier/v1/auth/me`, + headers: { + Authorization: `Bearer ${bundle.authData.api_key}`, + }, + }); + return response.data; +}; + +module.exports = { + type: 'custom', + fields: [ + { + key: 'api_key', + type: 'string', + required: true, + label: 'API Key', + helpText: + 'Your Verae API key (zmw_…) from signup or admin provisioning.', + }, + ], + test: testAuth, + connectionLabel: '{{tenantId}} ({{plan}})', +}; diff --git a/creates/add_numbers.js b/creates/add_numbers.js new file mode 100644 index 0000000..c2dfb5a --- /dev/null +++ b/creates/add_numbers.js @@ -0,0 +1,2 @@ +/** 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_timestamp.js b/creates/batch_timestamp.js new file mode 100644 index 0000000..5b8a279 --- /dev/null +++ b/creates/batch_timestamp.js @@ -0,0 +1,39 @@ +const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; + +const perform = async (z, bundle) => { + const items = (bundle.inputData.items || '') + .split('\n') + .map((s) => s.trim()) + .filter(Boolean) + .map((data) => ({ data })); + + const response = await z.request({ + method: 'POST', + url: `${base()}/zapier/v1/timestamp/batch`, + body: { items }, + }); + return response.data; +}; + +module.exports = { + key: 'batch_timestamp', + noun: 'Timestamp', + display: { + label: 'Create Batch Timestamps', + description: 'Submit multiple data lines for timestamping (paid plans).', + }, + operation: { + inputFields: [ + { + key: 'items', + label: 'Data Lines', + type: 'text', + required: true, + helpText: 'One payload per line.', + }, + ], + perform, + sample: { jobIds: ['id-1', 'id-2'] }, + outputFields: [{ key: 'jobIds', label: 'Job IDs' }], + }, +}; diff --git a/creates/create_timestamp.js b/creates/create_timestamp.js new file mode 100644 index 0000000..c1921c3 --- /dev/null +++ b/creates/create_timestamp.js @@ -0,0 +1,37 @@ +const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; + +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, + }, + }); + return response.data; +}; + +module.exports = { + key: 'create_timestamp', + noun: 'Timestamp', + display: { + label: 'Create Timestamp (Async)', + description: 'Submits data for timestamping and returns a job ID.', + }, + operation: { + inputFields: [ + { key: 'data', label: 'Data', type: 'string', required: true }, + { + key: 'hashAlg', + label: 'Hash Algorithm', + type: 'string', + required: false, + default: 'SHA256', + }, + ], + perform, + sample: { jobId: '550e8400-e29b-41d4-a716-446655440000' }, + outputFields: [{ key: 'jobId', label: 'Job ID' }], + }, +}; diff --git a/creates/timestamp_and_wait.js b/creates/timestamp_and_wait.js new file mode 100644 index 0000000..2fe1f5b --- /dev/null +++ b/creates/timestamp_and_wait.js @@ -0,0 +1,54 @@ +const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; + +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, + }, + }); + return response.data; +}; + +module.exports = { + key: 'timestamp_and_wait', + noun: 'Timestamp', + display: { + label: 'Create Timestamp and Wait', + description: + 'Submits data for blockchain timestamping and waits for the certificate.', + }, + 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', + }, + ], + 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' }, + ], + }, +}; diff --git a/creates/verify_timestamp.js b/creates/verify_timestamp.js new file mode 100644 index 0000000..510926a --- /dev/null +++ b/creates/verify_timestamp.js @@ -0,0 +1,36 @@ +const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; + +const perform = async (z, bundle) => { + const response = await z.request({ + method: 'POST', + url: `${base()}/zapier/v1/verify`, + body: { certificate: bundle.inputData.certificate }, + }); + return response.data; +}; + +module.exports = { + key: 'verify_timestamp', + noun: 'Verification', + display: { + label: 'Verify Timestamp', + description: 'Verify a timestamp certificate.', + }, + operation: { + inputFields: [ + { + key: 'certificate', + label: 'Certificate', + type: 'text', + required: true, + }, + ], + 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' }, + ], + }, +}; diff --git a/index.js b/index.js new file mode 100644 index 0000000..8727990 --- /dev/null +++ b/index.js @@ -0,0 +1,82 @@ +/** + * Verae Zapier Platform app definition. + * @module index + */ + +const authentication = require('./authentication'); +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 jobStatus = require('./searches/job_status'); +const hashLookup = require('./searches/hash_lookup'); +const treeLookup = require('./searches/tree_lookup'); +const timestampCompleted = require('./triggers/timestamp_completed'); + +/** + * Attach middleware API key to every outbound request. + * @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}`; + return request; +}; + +/** + * Map middleware billing errors to Zapier errors. + * @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.status === 403 && response.data?.code === 'PLAN_UPGRADE_REQUIRED') { + throw new z.errors.Error( + response.data?.error ?? 'This action requires a paid plan.', + 'PlanUpgradeRequired', + 403, + ); + } + + return response; +}; + +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, + authentication, + beforeRequest: [addApiKey], + afterResponse: [mapMiddlewareErrors], + triggers: { + [timestampCompleted.key]: timestampCompleted, + }, + creates: { + [addNumbers.key]: addNumbers, + [timestampAndWait.key]: timestampAndWait, + [createTimestamp.key]: createTimestamp, + [verifyTimestamp.key]: verifyTimestamp, + [batchTimestamp.key]: batchTimestamp, + }, + searches: { + [jobStatus.key]: jobStatus, + [hashLookup.key]: hashLookup, + [treeLookup.key]: treeLookup, + }, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..dcabbfb --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "verae-zapier", + "version": "1.0.0", + "description": "Zapier CLI app for Verae Timestamping via middleware", + "main": "index.js", + "scripts": { + "test": "node --test test/**/*.test.js" + }, + "engines": { + "node": ">=18", + "npm": ">=5.6.0" + }, + "dependencies": { + "zapier-platform-core": "15.19.0" + }, + "private": true +} diff --git a/searches/hash_lookup.js b/searches/hash_lookup.js new file mode 100644 index 0000000..cc60ac4 --- /dev/null +++ b/searches/hash_lookup.js @@ -0,0 +1,34 @@ +const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; + +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; + } +}; + +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).', + }, + operation: { + inputFields: [ + { key: 'sha256', label: 'SHA256', type: 'string', required: true }, + ], + perform, + sample: { + sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + exists: true, + jobId: '550e8400-e29b-41d4-a716-446655440000', + }, + }, +}; diff --git a/searches/job_status.js b/searches/job_status.js new file mode 100644 index 0000000..b39fc8c --- /dev/null +++ b/searches/job_status.js @@ -0,0 +1,33 @@ +const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; + +const perform = async (z, bundle) => { + const response = await z.request({ + url: `${base()}/zapier/v1/status/${encodeURIComponent(bundle.inputData.jobId)}`, + }); + return [response.data]; +}; + +module.exports = { + key: 'job_status', + noun: 'Job', + display: { + label: 'Find Job Status', + description: 'Look up a timestamp job by 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' }, + ], + }, +}; diff --git a/searches/tree_lookup.js b/searches/tree_lookup.js new file mode 100644 index 0000000..a1ba223 --- /dev/null +++ b/searches/tree_lookup.js @@ -0,0 +1,45 @@ +const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; + +const perform = async (z, bundle) => { + try { + const sha = encodeURIComponent(bundle.inputData.sha256); + const response = await z.request({ + method: 'GET', + url: `${base()}/zapier/v1/hashes/${sha}?includeAttached=true&includeTree=true`, + }); + return [response.data]; + } catch (err) { + if (err.status === 404) return []; + throw err; + } +}; + +module.exports = { + key: 'tree_lookup', + noun: 'Timestamp', + display: { + label: 'Find Hash (tree nodes + central chain)', + description: + 'Looks up a SHA256 on the main Verae chain, then queries external tree-node archives for leaves that were only sealed as a bulk Merkle summary.', + }, + operation: { + inputFields: [ + { + key: 'sha256', + label: 'SHA256', + type: 'string', + required: true, + helpText: + '64-char hex. If this hash was part of a batch, it may not be itemized on the main chain — this search still finds the Merkle proof on tree nodes.', + }, + ], + perform, + sample: { + sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + exists: true, + itemizedOnMainChain: false, + merkleRoot: 'ab'.repeat(32), + proofOk: true, + }, + }, +}; diff --git a/test/app.test.js b/test/app.test.js new file mode 100644 index 0000000..0c160d1 --- /dev/null +++ b/test/app.test.js @@ -0,0 +1,95 @@ +/** + * GATE 11 — Zapier package shape tests (no live Zapier CLI required) + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const App = require('../index'); +const authentication = require('../authentication'); +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 jobStatus = require('../searches/job_status'); +const timestampCompleted = require('../triggers/timestamp_completed'); + +describe('verae-zapier app definition', () => { + it('exports authentication with api_key field', () => { + assert.equal(authentication.type, 'custom'); + assert.ok(authentication.fields.some((f) => f.key === 'api_key')); + }); + + 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('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('trigger subscribe/unsubscribe shapes', async () => { + process.env.MIDDLEWARE_BASE_URL = 'https://mw.example.com'; + const calls = []; + const z = { + request: async (opts) => { + calls.push(opts); + return { data: { id: 'hook-1' } }; + }, + }; + + const id = await timestampCompleted.operation.performSubscribe(z, { + targetUrl: 'https://hooks.zapier.com/x', + authData: { api_key: 'k' }, + }); + assert.equal(id, 'hook-1'); + assert.equal(calls[0].method, 'POST'); + assert.match(calls[0].url, /webhooks\/subscribe$/); + assert.equal(calls[0].body.event, 'timestamp.completed'); + + await timestampCompleted.operation.performUnsubscribe(z, { + subscribeData: 'hook-1', + authData: { api_key: 'k' }, + }); + assert.equal(calls[1].method, 'DELETE'); + assert.match(calls[1].url, /webhooks\/unsubscribe$/); + }); + + it('beforeRequest adds Authorization bearer', () => { + const req = App.beforeRequest[0]({}, null, { authData: { api_key: 'zmw_abc' } }); + assert.equal(req.headers.Authorization, 'Bearer zmw_abc'); + }); +}); diff --git a/triggers/timestamp_completed.js b/triggers/timestamp_completed.js new file mode 100644 index 0000000..300c36a --- /dev/null +++ b/triggers/timestamp_completed.js @@ -0,0 +1,53 @@ +const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100'; + +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 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 = { + 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' }, + ], + }, +};