Keep-going: tree shares, health flags, CI, compose, timestamp Zapier action

Directory-tree encrypted share mock; /health reports mockVerae/nats;
middleware OpenAPI extended; zappier receipt by jobId; activate Create
Timestamp (local mock or hosted); GitHub Actions test:offline; compose
builds zappier image; seed-demo.mjs.

No Zapier login or live Verae required.
This commit is contained in:
George Lambert 2026-09-09 03:02:08 -04:00
parent 645a24909a
commit 5925e76c72
39 changed files with 514 additions and 33 deletions

View file

@ -0,0 +1,54 @@
/**
* Create Timestamp hosted if api_base+api_key, else local mock job.
* @module creates/timestamp
*/
const { randomUUID } = require('node:crypto');
const perform = async (z, bundle) => {
const data = String(bundle.inputData.data ?? '');
const sha256 = bundle.inputData.sha256;
const base = (bundle.authData.api_base || process.env.ZAPPIER_API_BASE || '').replace(/\/$/, '');
const key = bundle.authData.api_key || '';
if (base && key) {
const response = await z.request({
method: 'POST',
url: `${base}/v1/timestamp`,
headers: { 'x-api-key': key, 'content-type': 'application/json' },
body: { data, sha256, hashAlg: bundle.inputData.hashAlg },
});
return { ...(response.data || {}), mode: 'hosted' };
}
return {
jobId: randomUUID(),
sha256: sha256 || null,
existing: false,
mode: 'local-mock',
note: 'No API configured — mock jobId only. Set API base + key to hit zappier /v1/timestamp.',
};
};
module.exports = {
key: 'create_timestamp',
noun: 'Timestamp',
display: {
label: 'Create Timestamp',
description: 'Registers data/SHA256. Local mock if no API; hosted zappier when connected.',
},
operation: {
cleanInputData: false,
inputFields: [
{ key: 'data', label: 'Data', type: 'text', required: false },
{ key: 'sha256', label: 'SHA256 hex', type: 'string', required: false },
{ key: 'hashAlg', label: 'Hash algorithm', type: 'string', required: false, default: 'SHA256' },
],
perform,
sample: { jobId: '00000000-0000-4000-8000-000000000000', existing: false, mode: 'local-mock' },
outputFields: [
{ key: 'jobId', type: 'string' },
{ key: 'sha256', type: 'string' },
{ key: 'existing', type: 'boolean' },
{ key: 'mode', type: 'string' },
],
},
};

View file

@ -11,6 +11,7 @@ const authentication = require('./authentication');
const addNumbers = require('./creates/add_numbers');
const echo = require('./creates/echo');
const sha256Hash = require('./creates/sha256');
const createTimestamp = require('./creates/timestamp');
module.exports = {
version: require('./package.json').version,
@ -20,5 +21,6 @@ module.exports = {
[addNumbers.key]: addNumbers,
[echo.key]: echo,
[sha256Hash.key]: sha256Hash,
[createTimestamp.key]: createTimestamp,
},
};

View file

@ -15,6 +15,7 @@ describe('verae-activate app', () => {
assert.ok(App.creates.add_numbers);
assert.ok(App.creates.echo);
assert.ok(App.creates.sha256_hash);
assert.ok(App.creates.create_timestamp);
assert.equal(addNumbers.key, 'add_numbers');
assert.ok(addNumbers.operation.inputFields.some((f) => f.key === 'number1'));
assert.ok(addNumbers.operation.inputFields.some((f) => f.key === 'number2'));
@ -65,6 +66,16 @@ describe('verae-activate app', () => {
assert.equal(result.sum, 5);
});
it('create timestamp local mock without API', async () => {
const ts = require('../creates/timestamp');
const out = await ts.operation.perform(
{ request: async () => { throw new Error('no http'); } },
{ inputData: { data: 'x' }, authData: {} },
);
assert.equal(out.mode, 'local-mock');
assert.ok(out.jobId);
});
it('sha256 hashes hello', async () => {
const sha = require('../creates/sha256');
const out = await sha.operation.perform({}, { inputData: { text: 'hello' }, authData: {} });

View file

@ -9,6 +9,8 @@ import { createDebugger } from './debug/logger.js';
import { traceMiddleware } from './debug/trace.js';
import { apiRoutes } from './routes/index.js';
import { loadStore } from './store/db.js';
import { config } from './config.js';
import { isNatsConnected } from './nats/connection.js';
const log = createDebugger('app');
@ -36,6 +38,9 @@ export function createApp(options = {}) {
res.json({
status: 'ok',
service: 'verae-zapier-middleware',
mockVerae: config.mockVerae,
natsEnabled: config.natsEnabled,
natsConnected: isNatsConnected(),
});
});

View file

@ -10,6 +10,8 @@ import {
readBlobPlaintext,
createShare,
redeemShare,
putTree,
createTreeShare,
} from '../store/blobs.js';
import { createTimestamp } from '../services/timestampService.js';
@ -65,6 +67,39 @@ objectRoutes.get(
}),
);
objectRoutes.post(
'/trees',
asyncHandler(async (req, res) => {
const { prefix, files } = req.body ?? {};
if (!Array.isArray(files) || files.length === 0) {
throw new AppError('files array is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const tree = putTree({
tenantId: req.auth.tenantId,
prefix: prefix || '/',
files: files.map((f) => ({
path: String(f.path || f.filename || 'file.bin'),
bytes: Buffer.from(String(f.contentBase64 || f.data || ''), f.contentBase64 ? 'base64' : 'utf8'),
sha256: f.sha256,
})),
});
res.status(201).json(tree);
}),
);
objectRoutes.post(
'/trees/:id/share',
asyncHandler(async (req, res) => {
const share = createTreeShare({
treeId: req.params.id,
fromTenantId: req.auth.tenantId,
label: req.body?.label,
});
if (!share) throw new AppError('Tree not found', { status: 404, code: 'NOT_FOUND' });
res.status(201).json(share);
}),
);
objectRoutes.post(
'/:id/share',
asyncHandler(async (req, res) => {

View file

@ -12,6 +12,7 @@ function ensure() {
const store = getStore();
store.blobs ??= {};
store.shares ??= {};
store.trees ??= {};
return store;
}
@ -103,6 +104,7 @@ export function createShare({ blobId, fromTenantId, label }) {
const store = ensure();
store.shares[token] = {
token,
kind: 'file',
blobId,
fromTenantId,
label: label ?? rec.filename,
@ -120,10 +122,35 @@ export function redeemShare(token) {
const store = ensure();
const share = store.shares[token];
if (!share) return null;
if (share.kind === 'tree') {
const tree = store.trees[share.treeId];
if (!tree) return null;
return {
token,
kind: 'tree',
prefix: tree.prefix,
files: tree.entries.map((e) => {
const rec = store.blobs[e.blobId];
return rec
? {
path: e.path,
blobId: rec.id,
filename: rec.filename,
size: rec.size,
sha256: rec.sha256,
enc: rec.enc,
dek: rec.dek,
}
: { path: e.path, missing: true };
}),
note: 'Mock tree share: each file includes dek. Production must wrap per-file deks to recipient pubkey.',
};
}
const rec = store.blobs[share.blobId];
if (!rec) return null;
return {
token,
kind: 'file',
blobId: rec.id,
filename: rec.filename,
size: rec.size,
@ -134,3 +161,48 @@ export function redeemShare(token) {
note: 'Mock share: dek is included so the recipient can decrypt. Production must wrap dek to recipient pubkey.',
};
}
/**
* @param {{ tenantId: string, prefix: string, files: Array<{ path: string, bytes: Buffer, sha256?: string }> }} params
*/
export function putTree({ tenantId, prefix, files }) {
const store = ensure();
const id = randomUUID();
const entries = [];
for (const file of files) {
const stored = putBlob({
tenantId,
sha256: file.sha256,
filename: file.path.split('/').pop(),
bytes: file.bytes,
});
entries.push({ path: file.path, blobId: stored.id });
}
store.trees[id] = { id, tenantId, prefix: prefix || '/', entries, createdAt: new Date().toISOString() };
persist();
return { id, prefix: store.trees[id].prefix, fileCount: entries.length };
}
export function getTree(id) {
return ensure().trees[id] ?? null;
}
/**
* @param {{ treeId: string, fromTenantId: string, label?: string }} params
*/
export function createTreeShare({ treeId, fromTenantId, label }) {
const tree = getTree(treeId);
if (!tree || tree.tenantId !== fromTenantId) return null;
const token = `shr_${randomBytes(16).toString('hex')}`;
const store = ensure();
store.shares[token] = {
token,
kind: 'tree',
treeId,
fromTenantId,
label: label ?? tree.prefix,
createdAt: new Date().toISOString(),
};
persist();
return { token, kind: 'tree', treeId, label: store.shares[token].label, fileCount: tree.entries.length };
}

View file

@ -35,6 +35,7 @@ export function emptyStore() {
jobWatchers: [],
blobs: {},
shares: {},
trees: {},
};
}

View file

@ -62,6 +62,41 @@ describe('encrypted objects + share + pdf receipt', () => {
assert.equal(redeemed.body.publicMetadata.title, 'pub');
});
it('shares a directory tree of encrypted files', async () => {
const tree = await call(port, 'POST', '/zapier/v1/objects/trees', ownerKey, {
prefix: 'docs/',
files: [
{ path: 'docs/a.txt', data: 'alpha' },
{ path: 'docs/b.txt', data: 'beta' },
],
});
assert.equal(tree.status, 201);
assert.equal(tree.body.fileCount, 2);
const share = await call(port, 'POST', `/zapier/v1/objects/trees/${tree.body.id}/share`, ownerKey, {});
assert.equal(share.status, 201);
assert.equal(share.body.kind, 'tree');
const redeemed = await call(port, 'GET', `/zapier/v1/shares/${share.body.token}`, otherKey);
assert.equal(redeemed.status, 200);
assert.equal(redeemed.body.kind, 'tree');
assert.equal(redeemed.body.files.length, 2);
assert.ok(redeemed.body.files[0].dek);
});
it('hash lookup never includes privateMetadata', async () => {
const created = await call(port, 'POST', '/zapier/v1/timestamp', ownerKey, {
data: 'meta-private',
publicMetadata: { vis: 'yes' },
privateMetadata: { secret: 'nope' },
});
const sha = created.body.sha256;
const look = await call(port, 'GET', `/zapier/v1/hashes/${sha}`, ownerKey);
assert.equal(look.status, 200);
assert.equal(look.body.publicMetadata.vis, 'yes');
assert.equal(look.body.privateMetadata, undefined);
const st = await call(port, 'GET', `/zapier/v1/status/${created.body.jobId}`, ownerKey);
assert.equal(st.body.privateMetadata.secret, 'nope');
});
it('returns a PDF receipt starting with %PDF', async () => {
const wait = await call(port, 'POST', '/zapier/v1/timestamp/wait', ownerKey, { data: 'pdf-receipt' });
const jobId = wait.body.id;

View file

@ -30,6 +30,9 @@ describe('createApp', () => {
const body = await res.json();
assert.equal(body.status, 'ok');
assert.equal(body.service, 'verae-zapier-middleware');
assert.equal(typeof body.mockVerae, 'boolean');
assert.equal(typeof body.natsEnabled, 'boolean');
assert.equal(typeof body.natsConnected, 'boolean');
assert.ok(res.headers.get('x-trace-id'));
});

View file

@ -0,0 +1,12 @@
FROM node:20-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
COPY admin ./admin
COPY portal ./portal
COPY openapi.yaml ./
ENV PORT=3000
EXPOSE 3000
CMD ["npx", "ts-node", "src/index.ts"]

View file

@ -93,6 +93,21 @@ paths:
description: OK
'404':
description: Missing
/v1/receipts/{jobId}:
get:
operationId: receipt
summary: Retrieval receipt JSON (mock job ids from /v1/timestamp)
parameters:
- name: jobId
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
'404':
description: Missing
/v1/add:
post:
operationId: add

View file

@ -101,6 +101,7 @@ export function buildApp(deps: AppDeps = {}): {
string,
{ jobId: string; sha256: string; data?: string; timestamp: string }
>();
const jobIndex = new Map<string, { jobId: string; sha256: string; data?: string; timestamp: string }>();
const app = express();
app.use(express.json());
@ -177,10 +178,29 @@ export function buildApp(deps: AppDeps = {}): {
}
const jobId = randomUUID();
const timestamp = new Date().toISOString();
hashIndex.set(sha256, { jobId, sha256, data, timestamp });
const rec = { jobId, sha256, data, timestamp };
hashIndex.set(sha256, rec);
jobIndex.set(jobId, rec);
res.status(202).json({ jobId, sha256, existing: false, timestamp });
});
app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing), (req, res) => {
const rec = jobIndex.get(String(req.params.jobId));
if (!rec) {
res.status(404).json({ error: 'Job not found' });
return;
}
res.json({
type: 'verae.retrieval-receipt',
format: 'json',
jobId: rec.jobId,
sha256: rec.sha256,
timestamp: rec.timestamp,
extraSeal: { event: 'document.retrieved', retrievedAt: new Date().toISOString() },
quote: res.locals.quote,
});
});
app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing), (req, res) => {
const sha256 = String(req.params.sha256 || '').toLowerCase();
const rec = hashIndex.get(sha256);

View file

@ -66,6 +66,7 @@ export const DEFAULT_RATE_CARD: RateCard = {
add: { kind: 'free' },
timestamp: { kind: 'fixed', fixedCents: 4 },
'hash-lookup': { kind: 'free' },
receipt: { kind: 'free' },
storage: { kind: 'variable', baseCents: 10, perKbCents: 1, perMbCents: 50 },
},
};

View file

@ -36,6 +36,11 @@ describe('Zappier API', () => {
.set('x-api-key', KEY);
expect(look.status).toBe(200);
expect(look.body.jobId).toBe(a.body.jobId);
const receipt = await request(app)
.get(`/v1/receipts/${a.body.jobId}`)
.set('x-api-key', KEY);
expect(receipt.status).toBe(200);
expect(receipt.body.type).toBe('verae.retrieval-receipt');
});
it('POST /v1/add returns the sum and is free', async () => {