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

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