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:
parent
645a24909a
commit
5925e76c72
39 changed files with 514 additions and 33 deletions
|
|
@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export function emptyStore() {
|
|||
jobWatchers: [],
|
||||
blobs: {},
|
||||
shares: {},
|
||||
trees: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue