Initial import of verae-zapier-app from zapier monorepo

This commit is contained in:
George Lambert 2026-09-11 13:50:57 -04:00
commit 41b763bc94
16 changed files with 618 additions and 0 deletions

10
NATS.md Normal file
View file

@ -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.

34
README.md Normal file
View file

@ -0,0 +1,34 @@
# verae-zapier
Zapier Platform CLI app for Verae. **Phase 11** in [../TODO.md](../TODO.md).
## Role
Runs on **Zapiers 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
```

9
SUMMARY.md Normal file
View file

@ -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.

38
authentication.js Normal file
View file

@ -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}})',
};

2
creates/add_numbers.js Normal file
View file

@ -0,0 +1,2 @@
/** Re-export activate-now Add Numbers so this package stays self-contained. */
module.exports = require('../../verae-activate/creates/add_numbers');

View file

@ -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' }],
},
};

View file

@ -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' }],
},
};

View file

@ -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' },
],
},
};

View file

@ -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' },
],
},
};

82
index.js Normal file
View file

@ -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,
},
};

17
package.json Normal file
View file

@ -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
}

34
searches/hash_lookup.js Normal file
View file

@ -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',
},
},
};

33
searches/job_status.js Normal file
View file

@ -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' },
],
},
};

45
searches/tree_lookup.js Normal file
View file

@ -0,0 +1,45 @@
const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100';
const perform = async (z, bundle) => {
try {
const sha = encodeURIComponent(bundle.inputData.sha256);
const response = await z.request({
method: 'GET',
url: `${base()}/zapier/v1/hashes/${sha}?includeAttached=true&includeTree=true`,
});
return [response.data];
} catch (err) {
if (err.status === 404) return [];
throw err;
}
};
module.exports = {
key: 'tree_lookup',
noun: 'Timestamp',
display: {
label: 'Find Hash (tree nodes + central chain)',
description:
'Looks up a SHA256 on the main Verae chain, then queries external tree-node archives for leaves that were only sealed as a bulk Merkle summary.',
},
operation: {
inputFields: [
{
key: 'sha256',
label: 'SHA256',
type: 'string',
required: true,
helpText:
'64-char hex. If this hash was part of a batch, it may not be itemized on the main chain — this search still finds the Merkle proof on tree nodes.',
},
],
perform,
sample: {
sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
exists: true,
itemizedOnMainChain: false,
merkleRoot: 'ab'.repeat(32),
proofOk: true,
},
},
};

95
test/app.test.js Normal file
View file

@ -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');
});
});

View file

@ -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' },
],
},
};