Adjacent work: PDF receipts, encrypted blob+share mock, NATS tunnel, SHA256 Zapier action

Around remaining blockers (Zapier login, live Verae): generate PDF receipts
without extra deps; AES-256-GCM object store with tenant isolation and share
tokens; scripts/nats-tunnel.sh to NS1; SHA256 Hash Text local Zapier action;
scripts/test-offline.sh for the no-login suite.

Learned: other-tenant object GET is 403; share token is the mock unwrap path.
This commit is contained in:
George Lambert 2026-09-09 02:59:06 -04:00
parent c8f9d01a04
commit 645a24909a
45 changed files with 1053 additions and 24 deletions

View file

@ -0,0 +1,36 @@
/**
* SHA256 of a string local, no API. Lets you hash a file/text in a Zap.
* @module creates/sha256
*/
const { createHash } = require('node:crypto');
const perform = async (_z, bundle) => {
const text = String(bundle.inputData.text ?? '');
const sha256 = createHash('sha256').update(text, 'utf8').digest('hex');
return { textLength: text.length, sha256, mode: 'local' };
};
module.exports = {
key: 'sha256_hash',
noun: 'Hash',
display: {
label: 'SHA256 Hash Text',
description: 'Computes SHA256 hex of text inside Zapier. No Verae call.',
},
operation: {
cleanInputData: false,
inputFields: [{ key: 'text', label: 'Text', type: 'text', required: true }],
perform,
sample: {
textLength: 5,
sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
mode: 'local',
},
outputFields: [
{ key: 'sha256', type: 'string' },
{ key: 'textLength', type: 'integer' },
{ key: 'mode', type: 'string' },
],
},
};

View file

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

View file

@ -14,6 +14,7 @@ describe('verae-activate app', () => {
assert.equal(authentication.type, 'custom');
assert.ok(App.creates.add_numbers);
assert.ok(App.creates.echo);
assert.ok(App.creates.sha256_hash);
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'));
@ -64,6 +65,13 @@ describe('verae-activate app', () => {
assert.equal(result.sum, 5);
});
it('sha256 hashes hello', async () => {
const sha = require('../creates/sha256');
const out = await sha.operation.perform({}, { inputData: { text: 'hello' }, authData: {} });
assert.equal(out.sha256, '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');
assert.equal(out.mode, 'local');
});
it('echo returns text length', async () => {
const echo = require('../creates/echo');
const out = await echo.operation.perform({}, { inputData: { text: 'ab' }, authData: {} });

View file

@ -26,7 +26,7 @@ export function createApp(options = {}) {
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '1mb' }));
app.use(express.json({ limit: '12mb' }));
app.use(traceMiddleware);
/**

View file

@ -121,6 +121,7 @@ async function mockGetStatus(jobId) {
result: job.result,
completedAt: job.completedAt,
metadata: job.metadata,
privateMetadata: job.privateMetadata,
error: job.error,
sha256: job.sha256,
};

View file

@ -0,0 +1,64 @@
/**
* Minimal PDF builder for retrieval receipts (no native deps).
* @module lib/receiptPdf
*/
/**
* @param {string} s
* @returns {string}
*/
function pdfEscape(s) {
return String(s).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
/**
* @param {object} receipt
* @returns {Buffer}
*/
export function buildReceiptPdf(receipt) {
const lines = [
'Verae Time — certified retrieval receipt',
`Type: ${receipt.type}`,
`Job ID: ${receipt.jobId}`,
`SHA256: ${receipt.sha256 ?? ''}`,
`Original timestamp: ${receipt.timestamp ?? ''}`,
`Retrieved at: ${receipt.extraSeal?.retrievedAt ?? ''}`,
`Tenant: ${receipt.extraSeal?.tenantId ?? ''}`,
`Seal event: ${receipt.extraSeal?.event ?? ''}`,
`Certificate: ${String(receipt.certificate ?? '').slice(0, 80)}`,
];
const commands = lines
.map((line, i) => {
const y = 720 - i * 18;
return `BT /F1 11 Tf 50 ${y} Td (${pdfEscape(line)}) Tj ET`;
})
.join('\n');
const stream = `${commands}\n`;
const objects = [
'1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj',
'2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj',
'3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj',
`4 0 obj << /Length ${Buffer.byteLength(stream)} >> stream\n${stream}endstream endobj`,
'5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj',
];
let offset = '%PDF-1.4\n'.length;
const xref = [0];
let body = '%PDF-1.4\n';
for (const obj of objects) {
xref.push(offset);
const chunk = `${obj}\n`;
body += chunk;
offset += Buffer.byteLength(chunk);
}
const xrefStart = offset;
let xrefTable = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i < xref.length; i += 1) {
xrefTable += `${String(xref[i]).padStart(10, '0')} 00000 n \n`;
}
body += xrefTable;
body += `trailer << /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`;
return Buffer.from(body, 'utf8');
}

View file

@ -12,6 +12,7 @@ import { webhookRoutes } from './webhookRoutes.js';
import { publicTenantRoutes, adminTenantRoutes } from './tenantRoutes.js';
import { hashRoutes } from './hashRoutes.js';
import { receiptRoutes } from './receiptRoutes.js';
import { objectRoutes, publicShareRoutes } from './objectRoutes.js';
import { authenticate } from '../middleware/authenticate.js';
import { rateLimit } from '../middleware/rateLimit.js';
@ -21,6 +22,7 @@ export const apiRoutes = Router();
apiRoutes.use('/v1/auth', authRoutes);
apiRoutes.use('/v1', publicTenantRoutes);
apiRoutes.use('/v1/admin', adminTenantRoutes);
apiRoutes.use('/v1/shares', publicShareRoutes);
// Protected
const protectedRoutes = Router();
@ -33,5 +35,6 @@ protectedRoutes.use('/status', statusRoutes);
protectedRoutes.use('/webhooks', webhookRoutes);
protectedRoutes.use('/hashes', hashRoutes);
protectedRoutes.use('/receipts', receiptRoutes);
protectedRoutes.use('/objects', objectRoutes);
apiRoutes.use('/v1', protectedRoutes);

View file

@ -0,0 +1,90 @@
/**
* Encrypted LTS mock + share grants.
* @module routes/objectRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import {
putBlob,
readBlobPlaintext,
createShare,
redeemShare,
} from '../store/blobs.js';
import { createTimestamp } from '../services/timestampService.js';
export const objectRoutes = Router();
objectRoutes.post(
'/',
asyncHandler(async (req, res) => {
const { filename, contentBase64, sha256, publicMetadata, privateMetadata, data } = req.body ?? {};
if (!contentBase64 && !data) {
throw new AppError('contentBase64 or data is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const bytes = Buffer.from(String(contentBase64 ?? data), contentBase64 ? 'base64' : 'utf8');
const stamp = await createTimestamp(req.auth, {
data: data ?? bytes.toString('utf8').slice(0, 1024),
sha256,
publicMetadata,
privateMetadata,
});
const stored = putBlob({
tenantId: req.auth.tenantId,
sha256: stamp.sha256 ?? sha256 ?? null,
filename,
bytes,
publicMetadata,
privateMetadata,
});
res.status(201).json({ ...stored, jobId: stamp.jobId, existing: stamp.existing });
}),
);
objectRoutes.get(
'/:id',
asyncHandler(async (req, res) => {
try {
const rec = readBlobPlaintext(req.params.id, req.auth.tenantId);
if (!rec) throw new AppError('Object not found', { status: 404, code: 'NOT_FOUND' });
res.json({
id: rec.id,
filename: rec.filename,
size: rec.size,
sha256: rec.sha256,
publicMetadata: rec.publicMetadata,
privateMetadata: rec.privateMetadata,
contentBase64: rec.bytes.toString('base64'),
});
} catch (err) {
if (err.code === 'FORBIDDEN') {
throw new AppError('Forbidden', { status: 403, code: 'FORBIDDEN' });
}
throw err;
}
}),
);
objectRoutes.post(
'/:id/share',
asyncHandler(async (req, res) => {
const share = createShare({
blobId: req.params.id,
fromTenantId: req.auth.tenantId,
label: req.body?.label,
});
if (!share) throw new AppError('Object not found', { status: 404, code: 'NOT_FOUND' });
res.status(201).json(share);
}),
);
export const publicShareRoutes = Router();
publicShareRoutes.get(
'/:token',
asyncHandler(async (req, res) => {
const payload = redeemShare(req.params.token);
if (!payload) throw new AppError('Share not found', { status: 404, code: 'NOT_FOUND' });
res.json(payload);
}),
);

View file

@ -6,6 +6,7 @@
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import { getJobStatus } from '../services/timestampService.js';
import { buildReceiptPdf } from '../lib/receiptPdf.js';
export const receiptRoutes = Router();
@ -33,10 +34,11 @@ receiptRoutes.get(
},
};
if (format === 'pdf') {
throw new AppError('PDF receipts not implemented; use format=json', {
status: 501,
code: 'NOT_IMPLEMENTED',
});
const pdf = buildReceiptPdf({ ...receipt, format: 'pdf' });
res.setHeader('content-type', 'application/pdf');
res.setHeader('content-disposition', `attachment; filename="verae-receipt-${jobId}.pdf"`);
res.send(pdf);
return;
}
res.json(receipt);
}),

View file

@ -41,6 +41,11 @@ statusRoutes.get(
throw new AppError('jobId is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const result = await getJobStatus(req.auth, jobId);
if (req.query.scope === 'public' && result.metadata) {
const { publicMetadata, ...restMeta } = result.metadata;
result.metadata = { ...restMeta, publicMetadata };
delete result.privateMetadata;
}
res.json(result);
}),
);

View file

@ -0,0 +1,136 @@
/**
* Mock encrypted long-term object store + share grants.
* @module store/blobs
*/
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
import { getStore, persist } from './db.js';
const ALGO = 'aes-256-gcm';
function ensure() {
const store = getStore();
store.blobs ??= {};
store.shares ??= {};
return store;
}
/**
* @param {Buffer} plaintext
* @param {Buffer} key 32 bytes
*/
function encrypt(plaintext, key) {
const iv = randomBytes(12);
const cipher = createCipheriv(ALGO, key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
return { iv: iv.toString('base64'), tag: tag.toString('base64'), ciphertext: ciphertext.toString('base64') };
}
/**
* @param {{ iv: string, tag: string, ciphertext: string }} enc
* @param {Buffer} key
*/
export function decryptBlob(enc, key) {
const decipher = createDecipheriv(ALGO, key, Buffer.from(enc.iv, 'base64'));
decipher.setAuthTag(Buffer.from(enc.tag, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(enc.ciphertext, 'base64')),
decipher.final(),
]);
}
/**
* @param {object} params
* @param {string} params.tenantId
* @param {string} [params.sha256]
* @param {string} [params.filename]
* @param {Buffer} params.bytes
* @param {object} [params.publicMetadata]
* @param {object} [params.privateMetadata]
*/
export function putBlob({ tenantId, sha256, filename, bytes, publicMetadata = {}, privateMetadata = {} }) {
const store = ensure();
const id = randomUUID();
const key = randomBytes(32);
const enc = encrypt(bytes, key);
store.blobs[id] = {
id,
tenantId,
sha256: sha256 ?? null,
filename: filename ?? 'object.bin',
size: bytes.length,
enc,
dek: key.toString('base64'),
publicMetadata,
privateMetadata,
createdAt: new Date().toISOString(),
};
persist();
return { id, sha256: store.blobs[id].sha256, size: bytes.length, filename: store.blobs[id].filename };
}
export function getBlobRecord(id) {
return ensure().blobs[id] ?? null;
}
/**
* Decrypt for owner tenant only.
* @param {string} id
* @param {string} tenantId
*/
export function readBlobPlaintext(id, tenantId) {
const rec = getBlobRecord(id);
if (!rec) return null;
if (rec.tenantId !== tenantId) {
const err = new Error('Forbidden');
err.code = 'FORBIDDEN';
throw err;
}
return {
...rec,
bytes: decryptBlob(rec.enc, Buffer.from(rec.dek, 'base64')),
};
}
/**
* @param {{ blobId: string, fromTenantId: string, label?: string }} params
*/
export function createShare({ blobId, fromTenantId, label }) {
const rec = getBlobRecord(blobId);
if (!rec || rec.tenantId !== fromTenantId) return null;
const token = `shr_${randomBytes(16).toString('hex')}`;
const store = ensure();
store.shares[token] = {
token,
blobId,
fromTenantId,
label: label ?? rec.filename,
createdAt: new Date().toISOString(),
};
persist();
return { token, blobId, label: store.shares[token].label };
}
/**
* Redeem a share token: ciphertext + wrapped key for the recipient.
* @param {string} token
*/
export function redeemShare(token) {
const store = ensure();
const share = store.shares[token];
if (!share) return null;
const rec = store.blobs[share.blobId];
if (!rec) return null;
return {
token,
blobId: rec.id,
filename: rec.filename,
size: rec.size,
sha256: rec.sha256,
publicMetadata: rec.publicMetadata ?? {},
enc: rec.enc,
dek: rec.dek,
note: 'Mock share: dek is included so the recipient can decrypt. Production must wrap dek to recipient pubkey.',
};
}

View file

@ -33,6 +33,8 @@ export function emptyStore() {
usage: {},
webhooks: [],
jobWatchers: [],
blobs: {},
shares: {},
};
}

View file

@ -0,0 +1,124 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { createApp } from '../../src/app.js';
import { useTempStore, seedProTenant, seedFreeTenant } from '../helpers.js';
import { clearMockJobs } from '../../src/clients/veraeClient.js';
import { config } from '../../src/config.js';
describe('encrypted objects + share + pdf receipt', () => {
let ctx;
let server;
let port;
let ownerKey;
let otherKey;
before(async () => {
config.mockVerae = true;
config.natsEnabled = false;
config.mockJobCompleteMs = 40;
clearMockJobs();
ctx = useTempStore();
ownerKey = seedProTenant().apiKey;
otherKey = seedFreeTenant({ id: 'tenant-other', veraeUsername: 'other', veraePassword: 'otherpass' }).apiKey;
const app = createApp();
await new Promise((resolve) => {
server = app.listen(0, '127.0.0.1', () => {
port = server.address().port;
resolve();
});
});
});
after(async () => {
await new Promise((r) => server.close(r));
ctx.cleanup();
});
it('stores encrypted blob, hides it from other tenants, shares via token', async () => {
const created = await call(port, 'POST', '/zapier/v1/objects', ownerKey, {
filename: 'secret.txt',
contentBase64: Buffer.from('top-secret').toString('base64'),
publicMetadata: { title: 'pub' },
privateMetadata: { ssn: '000' },
});
assert.equal(created.status, 201);
const id = created.body.id;
const mine = await call(port, 'GET', `/zapier/v1/objects/${id}`, ownerKey);
assert.equal(mine.status, 200);
assert.equal(Buffer.from(mine.body.contentBase64, 'base64').toString(), 'top-secret');
assert.equal(mine.body.privateMetadata.ssn, '000');
const theirs = await call(port, 'GET', `/zapier/v1/objects/${id}`, otherKey);
assert.equal(theirs.status, 403);
const share = await call(port, 'POST', `/zapier/v1/objects/${id}/share`, ownerKey, { label: 'for-other' });
assert.equal(share.status, 201);
const redeemed = await call(port, 'GET', `/zapier/v1/shares/${share.body.token}`, otherKey);
assert.equal(redeemed.status, 200);
assert.equal(redeemed.body.filename, 'secret.txt');
assert.ok(redeemed.body.dek);
assert.equal(redeemed.body.publicMetadata.title, 'pub');
});
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;
const rec = await raw(port, 'GET', `/zapier/v1/receipts/${jobId}?format=pdf`, ownerKey);
assert.equal(rec.status, 200);
assert.match(rec.body.toString('latin1').slice(0, 5), /%PDF-/);
});
});
function call(port, method, path, apiKey, body) {
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
path,
method,
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
},
(res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const buf = Buffer.concat(chunks);
let parsed = {};
try {
parsed = buf.length ? JSON.parse(buf.toString()) : {};
} catch {
parsed = { raw: buf.toString() };
}
resolve({ status: res.statusCode, body: parsed });
});
},
);
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
function raw(port, method, path, apiKey) {
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
path,
method,
headers: { authorization: `Bearer ${apiKey}` },
},
(res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks) }));
},
);
req.on('error', reject);
req.end();
});
}