Initial import of verae-zapier-simulator from zapier monorepo
This commit is contained in:
commit
de6065edf3
10 changed files with 1637 additions and 0 deletions
882
src/pipeline.js
Normal file
882
src/pipeline.js
Normal file
|
|
@ -0,0 +1,882 @@
|
|||
/**
|
||||
* In-process Zapier → zappier-edge → middleware → splitter → chain / WORM / tree-nodes.
|
||||
* Every hop is traced. Faults inject delay, drop, fail, then optional recovery.
|
||||
* @module pipeline
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { splitRequest } from '../../verae-request-splitter/src/splitRequest.js';
|
||||
import { WormArchive } from '../../verae-archive-worm/src/archive.js';
|
||||
import { aggregateAttached, mergeReceipts } from '../../verae-archive-aggregator/src/aggregate.js';
|
||||
import {
|
||||
sha256Hex,
|
||||
buildMerkle,
|
||||
merkleProof,
|
||||
verifyProof,
|
||||
shardIndex,
|
||||
} from '../../verae-tree-node/src/merkle.js';
|
||||
import { TraceBus } from './trace.js';
|
||||
import { analyze } from './monitors.js';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export const ACTIONS = [
|
||||
{ key: 'signup', label: 'Sign up (zappier portal)', group: 'account' },
|
||||
{ key: 'connect_zapier', label: 'Connect Zapier app (API key)', group: 'account' },
|
||||
{ key: 'create_timestamp', label: 'Create Timestamp (async)', group: 'zap' },
|
||||
{ key: 'timestamp_and_wait', label: 'Create Timestamp and Wait', group: 'zap' },
|
||||
{ key: 'hash_lookup', label: 'Find Timestamp by SHA256 (central chain)', group: 'zap' },
|
||||
{ key: 'tree_lookup', label: 'Find Hash (tree nodes + central)', group: 'zap' },
|
||||
{ key: 'batch_timestamp', label: 'Create Batch Timestamps (Merkle summary)', group: 'zap' },
|
||||
{ key: 'verify_timestamp', label: 'Verify Timestamp', group: 'zap' },
|
||||
];
|
||||
|
||||
export class Simulator {
|
||||
constructor() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.trace = new TraceBus();
|
||||
this.accounts = [];
|
||||
this.connected = null;
|
||||
this.chain = new Map(); // sha256 -> seal
|
||||
this.jobs = new Map();
|
||||
this.archives = [new WormArchive('archive-a'), new WormArchive('archive-b'), new WormArchive('archive-c')];
|
||||
this.treeNodes = [
|
||||
new WormArchive('tree-node-east'),
|
||||
new WormArchive('tree-node-west'),
|
||||
new WormArchive('tree-node-central'),
|
||||
];
|
||||
this.hooks = [];
|
||||
this.meteredCents = 0;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return {
|
||||
accounts: this.accounts.map((a) => ({ email: a.email, plan: a.plan, apiKey: a.apiKey })),
|
||||
connected: this.connected,
|
||||
chainHashes: [...this.chain.keys()],
|
||||
jobs: [...this.jobs.values()].map((j) => ({ jobId: j.jobId, sha256: j.sha256, status: j.status })),
|
||||
meteredCents: this.meteredCents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ action: string, input?: object, faults?: object, includeAttached?: boolean, includeTree?: boolean }} req
|
||||
*/
|
||||
async run(req) {
|
||||
const action = req.action;
|
||||
const input = req.input || {};
|
||||
const faults = req.faults || {};
|
||||
const includeAttached = Boolean(req.includeAttached ?? input.includeAttached);
|
||||
const includeTree = Boolean(req.includeTree ?? input.includeTree);
|
||||
this.trace = new TraceBus();
|
||||
const t = this.trace;
|
||||
|
||||
t.emit({
|
||||
module: 'zapier-platform-app',
|
||||
hop: 'perform',
|
||||
dir: 'in',
|
||||
address: `zapier://action/${action}`,
|
||||
payload: { action, input: redact(input) },
|
||||
note: 'Zapier cloud perform(z, bundle). HTTPS only.',
|
||||
});
|
||||
|
||||
try {
|
||||
if (action === 'signup') {
|
||||
const out = this.#signup(input);
|
||||
return this.#finish(action, out, { includeAttached, includeTree });
|
||||
}
|
||||
if (action === 'connect_zapier') {
|
||||
const out = this.#connect(input);
|
||||
return this.#finish(action, out, { includeAttached, includeTree });
|
||||
}
|
||||
|
||||
await this.#edgeAuth(faults);
|
||||
await this.#meter(action, faults);
|
||||
|
||||
let response;
|
||||
switch (action) {
|
||||
case 'create_timestamp':
|
||||
response = await this.#timestamp(input, { wait: false, includeAttached, faults });
|
||||
break;
|
||||
case 'timestamp_and_wait':
|
||||
response = await this.#timestamp(input, { wait: true, includeAttached, faults });
|
||||
break;
|
||||
case 'hash_lookup':
|
||||
response = await this.#lookup(input, { includeAttached, includeTree: false, faults });
|
||||
break;
|
||||
case 'tree_lookup':
|
||||
response = await this.#lookup(input, { includeAttached: true, includeTree: true, faults });
|
||||
break;
|
||||
case 'batch_timestamp':
|
||||
response = await this.#batch(input, { faults });
|
||||
break;
|
||||
case 'verify_timestamp':
|
||||
response = await this.#verify(input);
|
||||
break;
|
||||
default:
|
||||
throw Object.assign(new Error(`unknown action ${action}`), { status: 400 });
|
||||
}
|
||||
|
||||
t.emit({
|
||||
module: 'zapier-platform-app',
|
||||
hop: 'output',
|
||||
dir: 'out',
|
||||
address: `zapier://action/${action}`,
|
||||
payload: redact(response),
|
||||
note: 'Returned to Zap step / task history',
|
||||
});
|
||||
return this.#finish(action, response, { includeAttached, includeTree });
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
t.emit({
|
||||
module: 'zapier-platform-app',
|
||||
hop: 'error',
|
||||
dir: 'out',
|
||||
status: 'error',
|
||||
payload: { status, error: err.message, code: err.code },
|
||||
note: err.note || err.message,
|
||||
});
|
||||
return this.#finish(action, { error: err.message, status, code: err.code }, { includeAttached, includeTree });
|
||||
}
|
||||
}
|
||||
|
||||
#finish(action, response, flags) {
|
||||
const monitor = analyze(this.trace.events, { action, ...flags });
|
||||
return {
|
||||
action,
|
||||
response,
|
||||
trace: this.trace.events,
|
||||
monitor,
|
||||
state: this.snapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
#signup(input) {
|
||||
const email = String(input.email || 'user@example.com');
|
||||
const plan = input.plan || 'free';
|
||||
const apiKey = `zpk_${randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
||||
this.accounts.push({ email, plan, apiKey, passwordSet: true });
|
||||
this.trace.emit({
|
||||
module: 'zappier-edge',
|
||||
hop: 'portal_signup',
|
||||
address: 'HTTPS POST /portal/signup',
|
||||
payload: { email, plan, apiKey },
|
||||
note: 'Customer portal creates account + API key. No NATS.',
|
||||
});
|
||||
return { email, plan, apiKey, next: 'Paste apiKey into Zapier app authentication (x-api-key).' };
|
||||
}
|
||||
|
||||
#connect(input) {
|
||||
const key = input.apiKey || this.accounts.at(-1)?.apiKey;
|
||||
const acct = this.accounts.find((a) => a.apiKey === key);
|
||||
if (!acct) {
|
||||
this.trace.emit({
|
||||
module: 'zappier-edge',
|
||||
hop: 'auth',
|
||||
status: 'error',
|
||||
address: 'HTTPS GET /v1/whoami',
|
||||
note: 'Unknown API key',
|
||||
});
|
||||
const err = new Error('Invalid API key');
|
||||
err.status = 401;
|
||||
err.code = 'UNAUTHENTICATED';
|
||||
throw err;
|
||||
}
|
||||
this.connected = { email: acct.email, plan: acct.plan, apiKey: key };
|
||||
this.trace.emit({
|
||||
module: 'zapier-platform-app',
|
||||
hop: 'test_auth',
|
||||
address: 'HTTPS GET zappier-edge/v1/whoami',
|
||||
payload: { email: acct.email, plan: acct.plan },
|
||||
note: 'Zapier “Test authentication” — still HTTPS only.',
|
||||
});
|
||||
return { connected: true, email: acct.email, plan: acct.plan };
|
||||
}
|
||||
|
||||
async #edgeAuth(faults) {
|
||||
if (faults.failEdge401) {
|
||||
this.trace.emit({
|
||||
module: 'zappier-edge',
|
||||
hop: 'auth',
|
||||
status: 'error',
|
||||
address: 'HTTPS',
|
||||
payload: { status: 401 },
|
||||
note: 'x-api-key rejected',
|
||||
});
|
||||
const err = new Error('unauthenticated');
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
if (!this.connected) {
|
||||
this.connected = { email: 'sim@local', plan: 'pro', apiKey: 'zpk_sim' };
|
||||
}
|
||||
this.trace.emit({
|
||||
module: 'zappier-edge',
|
||||
hop: 'auth',
|
||||
address: 'HTTPS x-api-key',
|
||||
payload: { plan: this.connected.plan },
|
||||
});
|
||||
}
|
||||
|
||||
async #meter(action, faults) {
|
||||
if (faults.failEdge402) {
|
||||
this.trace.emit({
|
||||
module: 'zappier-edge',
|
||||
hop: 'meter',
|
||||
status: 'error',
|
||||
address: 'HTTPS 402',
|
||||
payload: { status: 402, upgradeUrl: 'https://zappier.example/portal/billing' },
|
||||
note: '402 quota exceeded',
|
||||
});
|
||||
const err = new Error('Quota exceeded');
|
||||
err.status = 402;
|
||||
err.code = 'QuotaExceeded';
|
||||
err.note = '402';
|
||||
throw err;
|
||||
}
|
||||
const cents = action === 'batch_timestamp' ? 25 : 8;
|
||||
this.meteredCents += cents;
|
||||
this.trace.emit({
|
||||
module: 'zappier-edge',
|
||||
hop: 'meter',
|
||||
address: 'HTTPS → middleware',
|
||||
payload: { cents, stripeMeter: 'zappier.api_cents' },
|
||||
note: 'Rate card × tier − monthly credit',
|
||||
});
|
||||
}
|
||||
|
||||
async #timestamp(input, { wait, includeAttached, faults }) {
|
||||
const t = this.trace;
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: wait ? 'POST /zapier/v1/timestamp/wait' : 'POST /zapier/v1/timestamp',
|
||||
dir: 'in',
|
||||
address: wait ? 'HTTPS /zapier/v1/timestamp/wait' : 'HTTPS /zapier/v1/timestamp',
|
||||
payload: redact(input),
|
||||
});
|
||||
|
||||
const split = splitRequest({
|
||||
data: input.data,
|
||||
sha256: input.sha256,
|
||||
publicMetadata: input.publicMetadata,
|
||||
privateMetadata: input.privateMetadata,
|
||||
files: input.files,
|
||||
includeAttached,
|
||||
});
|
||||
t.emit({
|
||||
module: 'verae-request-splitter',
|
||||
hop: 'splitRequest',
|
||||
address: 'in-process',
|
||||
payload: {
|
||||
chain: split.chain,
|
||||
archivePuts: split.archivePuts.map((p) => ({ kind: p.kind, sha256: p.sha256 })),
|
||||
includeAttached: split.includeAttached,
|
||||
},
|
||||
note: 'Chain gets hash only. Files/meta → archive.put',
|
||||
});
|
||||
|
||||
await this.#archivePuts(split.archivePuts, input.tenantId || 'tenant-sim', faults);
|
||||
|
||||
const existing = this.chain.get(split.chain.sha256);
|
||||
let job;
|
||||
if (existing) {
|
||||
job = { ...existing, existing: true };
|
||||
t.emit({
|
||||
module: 'verae-chain-client',
|
||||
hop: 'lookup',
|
||||
address: 'HTTPS api.veraetime.net (MOCK)',
|
||||
payload: { sha256: split.chain.sha256, jobId: existing.jobId },
|
||||
note: 'Already sealed — original receipt, no second write',
|
||||
});
|
||||
} else {
|
||||
await this.#maybeDelay(faults, 'chainMs', 'verae-chain-client');
|
||||
if (faults.failChainTimeout && !faults.recover) {
|
||||
t.emit({
|
||||
module: 'verae-chain-client',
|
||||
hop: 'create',
|
||||
status: 'error',
|
||||
address: 'HTTPS api.veraetime.net',
|
||||
note: 'chain timeout',
|
||||
});
|
||||
const err = new Error('chain timeout');
|
||||
err.status = 504;
|
||||
throw err;
|
||||
}
|
||||
if (faults.failChainTimeout && faults.recover) {
|
||||
t.emit({
|
||||
module: 'verae-chain-client',
|
||||
hop: 'create',
|
||||
status: 'retry',
|
||||
address: 'HTTPS api.veraetime.net',
|
||||
note: 'timeout then retry succeeded',
|
||||
});
|
||||
}
|
||||
job = this.#seal(split.chain.sha256);
|
||||
t.emit({
|
||||
module: 'verae-chain-client',
|
||||
hop: 'create',
|
||||
address: 'HTTPS api.veraetime.net (MOCK)',
|
||||
payload: { sha256: split.chain.sha256, jobId: job.jobId, blockIndex: job.blockIndex },
|
||||
});
|
||||
}
|
||||
|
||||
const watch = {
|
||||
tenantId: 'tenant-sim',
|
||||
jobId: job.jobId,
|
||||
tokenRef: 'tok_sim',
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
attempt: 1,
|
||||
maxAttempts: 8,
|
||||
intervalMs: 500,
|
||||
traceId: job.jobId,
|
||||
};
|
||||
|
||||
if (faults.failNatsDropWatch && !faults.recover) {
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: 'publish',
|
||||
status: 'error',
|
||||
address: 'verae.zapier.jobs.watch',
|
||||
note: 'NATS publish dropped',
|
||||
});
|
||||
const err = new Error('NATS publish failed');
|
||||
err.status = 503;
|
||||
throw err;
|
||||
}
|
||||
if (faults.failNatsDropWatch && faults.recover) {
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: 'publish',
|
||||
status: 'retry',
|
||||
address: 'verae.zapier.jobs.watch',
|
||||
note: 'first publish dropped; retried',
|
||||
});
|
||||
}
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: 'publish',
|
||||
dir: 'out',
|
||||
address: 'verae.zapier.jobs.watch',
|
||||
payload: watch,
|
||||
});
|
||||
t.emit({
|
||||
module: 'job-poller',
|
||||
hop: 'consume',
|
||||
dir: 'in',
|
||||
address: 'verae.zapier.jobs.watch',
|
||||
payload: { jobId: job.jobId },
|
||||
});
|
||||
t.emit({
|
||||
module: 'job-poller',
|
||||
hop: 'GET status',
|
||||
address: 'HTTPS api.veraetime.net/status',
|
||||
payload: { jobId: job.jobId, status: 'completed' },
|
||||
});
|
||||
|
||||
const event = {
|
||||
event: 'timestamp.completed',
|
||||
tenantId: 'tenant-sim',
|
||||
jobId: job.jobId,
|
||||
status: 'completed',
|
||||
traceId: job.jobId,
|
||||
emittedAt: new Date().toISOString(),
|
||||
};
|
||||
if (faults.failNatsDropEvents && !faults.recover) {
|
||||
t.emit({
|
||||
module: 'job-poller',
|
||||
hop: 'publish',
|
||||
status: 'error',
|
||||
address: 'verae.zapier.jobs.events',
|
||||
note: 'events drop — waiter will time out',
|
||||
});
|
||||
} else {
|
||||
if (faults.failNatsDropEvents && faults.recover) {
|
||||
t.emit({
|
||||
module: 'job-poller',
|
||||
hop: 'publish',
|
||||
status: 'retry',
|
||||
address: 'verae.zapier.jobs.events',
|
||||
note: 'events drop recovered',
|
||||
});
|
||||
}
|
||||
t.emit({
|
||||
module: 'job-poller',
|
||||
hop: 'publish',
|
||||
dir: 'out',
|
||||
address: 'verae.zapier.jobs.events',
|
||||
payload: event,
|
||||
});
|
||||
}
|
||||
|
||||
if (!wait) {
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: 'response',
|
||||
dir: 'out',
|
||||
address: 'HTTPS 202',
|
||||
payload: { jobId: job.jobId, sha256: split.chain.sha256, existing: Boolean(existing) },
|
||||
});
|
||||
return { jobId: job.jobId, sha256: split.chain.sha256, existing: Boolean(existing), status: 'accepted' };
|
||||
}
|
||||
|
||||
const droppedEvents = faults.failNatsDropEvents && !faults.recover;
|
||||
if (droppedEvents) {
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: 'wait',
|
||||
status: 'delay',
|
||||
durationMs: 250,
|
||||
payload: { status: 'pending', jobId: job.jobId },
|
||||
note: 'WAIT_TIMEOUT_MS — no jobs.events',
|
||||
});
|
||||
return { jobId: job.jobId, status: 'pending' };
|
||||
}
|
||||
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: 'wait',
|
||||
dir: 'in',
|
||||
address: 'verae.zapier.jobs.events',
|
||||
payload: { jobId: job.jobId, status: 'completed' },
|
||||
});
|
||||
|
||||
let receipts = [
|
||||
{
|
||||
kind: 'seal',
|
||||
timestamp: job.timestamp,
|
||||
certificate: job.certificate,
|
||||
blockIndex: job.blockIndex,
|
||||
jobId: job.jobId,
|
||||
sha256: split.chain.sha256,
|
||||
},
|
||||
];
|
||||
let files = [];
|
||||
let archivesQueried = false;
|
||||
let archiveReplies = 0;
|
||||
|
||||
if (includeAttached) {
|
||||
const agg = await this.#queryArchives(split.chain.sha256, {
|
||||
kinds: ['publicMeta', 'privateMeta', 'file'],
|
||||
faults,
|
||||
archives: this.archives,
|
||||
});
|
||||
archivesQueried = true;
|
||||
archiveReplies = agg.archiveReplies;
|
||||
receipts = mergeReceipts(receipts, agg.records);
|
||||
files = agg.records.filter((r) => r.kind === 'file').map((r) => ({
|
||||
id: r.record?.path,
|
||||
sha256: r.record?.contentSha256,
|
||||
archiveId: r.archiveId,
|
||||
}));
|
||||
}
|
||||
|
||||
const body = {
|
||||
jobId: job.jobId,
|
||||
status: 'completed',
|
||||
sha256: split.chain.sha256,
|
||||
existing: Boolean(existing),
|
||||
receipts,
|
||||
files,
|
||||
archivesQueried,
|
||||
archiveReplies,
|
||||
};
|
||||
if (this.hooks.length) {
|
||||
t.emit({
|
||||
module: 'webhook-deliver',
|
||||
hop: 'POST',
|
||||
address: 'verae.zapier.webhooks.deliver',
|
||||
payload: { targetUrl: this.hooks[0], event: 'timestamp.completed', jobId: job.jobId },
|
||||
note: 'HTTPS POST Zapier REST Hook — not NATS to Zapier',
|
||||
});
|
||||
}
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: 'response',
|
||||
dir: 'out',
|
||||
address: 'HTTPS 200',
|
||||
payload: { jobId: body.jobId, status: body.status, receiptCount: receipts.length },
|
||||
});
|
||||
return body;
|
||||
}
|
||||
|
||||
async #lookup(input, { includeAttached, includeTree, faults }) {
|
||||
const t = this.trace;
|
||||
const sha = String(input.sha256 || (input.data != null ? sha256Hex(input.data) : '')).toLowerCase();
|
||||
if (!/^[a-f0-9]{64}$/.test(sha)) {
|
||||
const err = new Error('sha256 must be 64 hex chars');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: 'hash_lookup',
|
||||
address: `HTTPS GET /zapier/v1/hashes/${sha.slice(0, 8)}…?includeAttached=${includeAttached}&includeTree=${includeTree}`,
|
||||
payload: { sha256: sha, includeAttached, includeTree },
|
||||
});
|
||||
|
||||
const seal = this.chain.get(sha);
|
||||
if (seal) {
|
||||
t.emit({
|
||||
module: 'verae-chain-client',
|
||||
hop: 'lookup',
|
||||
address: 'HTTPS api.veraetime.net (MOCK)',
|
||||
payload: { sha256: sha, jobId: seal.jobId, itemizedOnMainChain: true },
|
||||
});
|
||||
let receipts = [
|
||||
{
|
||||
kind: 'seal',
|
||||
timestamp: seal.timestamp,
|
||||
certificate: seal.certificate,
|
||||
blockIndex: seal.blockIndex,
|
||||
jobId: seal.jobId,
|
||||
sha256: sha,
|
||||
},
|
||||
];
|
||||
let archiveReplies = 0;
|
||||
if (includeAttached) {
|
||||
const agg = await this.#queryArchives(sha, {
|
||||
kinds: ['publicMeta', 'privateMeta', 'file'],
|
||||
faults,
|
||||
archives: this.archives,
|
||||
});
|
||||
archiveReplies = agg.archiveReplies;
|
||||
receipts = mergeReceipts(receipts, agg.records);
|
||||
}
|
||||
return {
|
||||
sha256: sha,
|
||||
exists: true,
|
||||
itemizedOnMainChain: true,
|
||||
jobId: seal.jobId,
|
||||
receipts,
|
||||
archiveReplies,
|
||||
};
|
||||
}
|
||||
|
||||
t.emit({
|
||||
module: 'verae-chain-client',
|
||||
hop: 'lookup',
|
||||
status: 'silent',
|
||||
address: 'HTTPS api.veraetime.net (MOCK)',
|
||||
payload: { sha256: sha, exists: false },
|
||||
note: 'Not itemized on the main Verae chain',
|
||||
});
|
||||
|
||||
if (!includeTree) {
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: 'response',
|
||||
dir: 'out',
|
||||
address: 'HTTPS 404',
|
||||
payload: { exists: false },
|
||||
note: 'Tree-node query not requested — leaf in a bulk summary would be missed',
|
||||
});
|
||||
return { sha256: sha, exists: false, itemizedOnMainChain: false, receipts: [] };
|
||||
}
|
||||
|
||||
const agg = await this.#queryArchives(sha, {
|
||||
kinds: ['tree', 'publicMeta', 'privateMeta', 'file'],
|
||||
faults,
|
||||
archives: [...this.treeNodes, ...this.archives],
|
||||
hop: 'tree_query',
|
||||
});
|
||||
const treeRecs = agg.records.filter((r) => r.kind === 'tree');
|
||||
if (!treeRecs.length) {
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: 'response',
|
||||
dir: 'out',
|
||||
address: 'HTTPS 404',
|
||||
payload: { exists: false, archivesQueried: true, archiveReplies: agg.archiveReplies, silent: agg.silent },
|
||||
});
|
||||
return {
|
||||
sha256: sha,
|
||||
exists: false,
|
||||
itemizedOnMainChain: false,
|
||||
archivesQueried: true,
|
||||
archiveReplies: agg.archiveReplies,
|
||||
silent: agg.silent,
|
||||
receipts: [],
|
||||
};
|
||||
}
|
||||
|
||||
const rec = treeRecs[0];
|
||||
const root = rec.record.merkleRoot;
|
||||
const rootSeal = this.chain.get(root);
|
||||
const proofOk = verifyProof(sha, rec.record.proof, root);
|
||||
t.emit({
|
||||
module: 'verae-tree-node',
|
||||
hop: 'verify_proof',
|
||||
payload: { leaf: sha, merkleRoot: root, proofOk, chainSealJobId: rec.record.chainSealJobId },
|
||||
note: proofOk
|
||||
? 'Leaf included in bulk summary sealed on main chain'
|
||||
: 'Proof failed — do not trust this tree record',
|
||||
});
|
||||
const receipts = mergeReceipts(
|
||||
rootSeal
|
||||
? [
|
||||
{
|
||||
kind: 'seal',
|
||||
of: 'merkleRoot',
|
||||
sha256: root,
|
||||
timestamp: rootSeal.timestamp,
|
||||
certificate: rootSeal.certificate,
|
||||
blockIndex: rootSeal.blockIndex,
|
||||
jobId: rootSeal.jobId,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
treeRecs,
|
||||
);
|
||||
return {
|
||||
sha256: sha,
|
||||
exists: true,
|
||||
itemizedOnMainChain: false,
|
||||
merkleRoot: root,
|
||||
proofOk,
|
||||
chainSealJobId: rec.record.chainSealJobId,
|
||||
archiveId: rec.archiveId,
|
||||
receipts,
|
||||
archivesQueried: true,
|
||||
archiveReplies: agg.archiveReplies,
|
||||
silent: agg.silent,
|
||||
};
|
||||
}
|
||||
|
||||
async #batch(input, { faults }) {
|
||||
const t = this.trace;
|
||||
const lines = Array.isArray(input.items)
|
||||
? input.items
|
||||
: String(input.items || '')
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!lines.length) {
|
||||
const err = new Error('items required');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: 'POST /zapier/v1/timestamp/batch',
|
||||
dir: 'in',
|
||||
address: 'HTTPS /zapier/v1/timestamp/batch',
|
||||
payload: { count: lines.length },
|
||||
});
|
||||
const leaves = lines.map((d) => sha256Hex(d));
|
||||
const tree = buildMerkle(leaves);
|
||||
t.emit({
|
||||
module: 'verae-tree-node',
|
||||
hop: 'buildMerkle',
|
||||
payload: { leaves: leaves.length, root: tree.root },
|
||||
note: 'Only the root will be itemized on the main Verae chain',
|
||||
});
|
||||
|
||||
await this.#maybeDelay(faults, 'chainMs', 'verae-chain-client');
|
||||
const job = this.#seal(tree.root);
|
||||
job.kind = 'merkle-root';
|
||||
job.leafCount = leaves.length;
|
||||
t.emit({
|
||||
module: 'verae-chain-client',
|
||||
hop: 'create',
|
||||
address: 'HTTPS api.veraetime.net (MOCK)',
|
||||
payload: { sha256: tree.root, jobId: job.jobId, kind: 'merkle-root', leafCount: leaves.length },
|
||||
note: 'Individual item hashes are NOT written to the chain',
|
||||
});
|
||||
|
||||
for (let i = 0; i < leaves.length; i += 1) {
|
||||
const leaf = leaves[i];
|
||||
const idx = shardIndex(leaf, this.treeNodes.length);
|
||||
const node = this.treeNodes[idx];
|
||||
const record = {
|
||||
merkleRoot: tree.root,
|
||||
proof: merkleProof(tree.layers, i),
|
||||
leafIndex: i,
|
||||
chainSealJobId: job.jobId,
|
||||
item: lines[i].length > 80 ? `${lines[i].slice(0, 80)}…` : lines[i],
|
||||
};
|
||||
node.put({ sha256: leaf, kind: 'tree', record });
|
||||
t.emit({
|
||||
module: node.archiveId,
|
||||
hop: 'put',
|
||||
dir: 'in',
|
||||
address: 'verae.archive.put',
|
||||
payload: { kind: 'tree', sha256: leaf, archiveId: node.archiveId, leafIndex: i },
|
||||
});
|
||||
}
|
||||
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: 'publish',
|
||||
dir: 'out',
|
||||
address: 'verae.zapier.jobs.watch',
|
||||
payload: { jobId: job.jobId },
|
||||
});
|
||||
t.emit({
|
||||
module: 'job-poller',
|
||||
hop: 'publish',
|
||||
dir: 'out',
|
||||
address: 'verae.zapier.jobs.events',
|
||||
payload: { event: 'timestamp.completed', jobId: job.jobId },
|
||||
});
|
||||
t.emit({
|
||||
module: 'middleware-http',
|
||||
hop: 'response',
|
||||
dir: 'out',
|
||||
address: 'HTTPS 202',
|
||||
payload: { jobId: job.jobId, merkleRoot: tree.root, leafCount: leaves.length },
|
||||
});
|
||||
return {
|
||||
jobId: job.jobId,
|
||||
merkleRoot: tree.root,
|
||||
leafCount: leaves.length,
|
||||
leaves,
|
||||
status: 'accepted',
|
||||
note: 'Look up a member hash with Find Hash (tree nodes), not central-chain lookup alone.',
|
||||
};
|
||||
}
|
||||
|
||||
#verify(input) {
|
||||
const cert = String(input.certificate || '');
|
||||
const found = [...this.chain.values()].find((s) => s.certificate === cert);
|
||||
this.trace.emit({
|
||||
module: 'verae-chain-client',
|
||||
hop: 'verify',
|
||||
address: 'HTTPS api.veraetime.net/verify (MOCK)',
|
||||
payload: { valid: Boolean(found), blockIndex: found?.blockIndex },
|
||||
});
|
||||
return found
|
||||
? { valid: true, timestamp: found.timestamp, blockIndex: found.blockIndex, sha256: found.sha256 }
|
||||
: { valid: false };
|
||||
}
|
||||
|
||||
async #archivePuts(puts, tenantId, faults) {
|
||||
const t = this.trace;
|
||||
for (const p of puts) {
|
||||
if (faults.failArchivePut) {
|
||||
t.emit({
|
||||
module: 'archive-b',
|
||||
hop: 'put',
|
||||
status: 'error',
|
||||
address: 'verae.archive.put',
|
||||
note: 'archive put failed',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const idx = shardIndex(p.sha256 + p.kind, this.archives.length);
|
||||
const node = this.archives[idx];
|
||||
node.put({ sha256: p.sha256, kind: p.kind, record: p.record });
|
||||
t.emit({
|
||||
module: node.archiveId,
|
||||
hop: 'put',
|
||||
dir: 'in',
|
||||
address: 'verae.archive.put',
|
||||
payload: { kind: p.kind, sha256: p.sha256, tenantId, archiveId: node.archiveId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async #queryArchives(sha256, { kinds, faults, archives, hop }) {
|
||||
const t = this.trace;
|
||||
const correlationId = randomUUID();
|
||||
await this.#maybeDelay(faults, 'archiveMs', 'archive-aggregator');
|
||||
t.emit({
|
||||
module: 'archive-aggregator',
|
||||
hop: hop || 'query',
|
||||
dir: 'out',
|
||||
address: 'verae.archive.query',
|
||||
payload: { correlationId, sha256, tenantId: 'tenant-sim', kinds, traceId: correlationId },
|
||||
note: 'Broadcast to every archive/tree node — not a queue group',
|
||||
});
|
||||
|
||||
const wrapped = archives.map((arch) => ({
|
||||
archiveId: arch.archiveId,
|
||||
query: (sha) => {
|
||||
if (faults.failArchiveAllSilent) return null;
|
||||
if (faults.failArchiveOneDown && arch.archiveId.endsWith('west')) {
|
||||
t.emit({
|
||||
module: arch.archiveId,
|
||||
hop: 'down',
|
||||
status: 'error',
|
||||
note: 'node unreachable',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const hit = arch.query(sha);
|
||||
if (hit == null) {
|
||||
t.emit({
|
||||
module: arch.archiveId,
|
||||
hop: 'bloom',
|
||||
status: 'silent',
|
||||
payload: { sha256: sha, mightHave: false },
|
||||
note: 'bloom miss — no packet sent',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const filtered = hit.filter((r) => !kinds.length || kinds.includes(r.kind));
|
||||
t.emit({
|
||||
module: arch.archiveId,
|
||||
hop: 'reply',
|
||||
dir: 'out',
|
||||
address: `verae.archive.reply.${correlationId}`,
|
||||
payload: { archiveId: arch.archiveId, sha256: sha, records: filtered.length },
|
||||
});
|
||||
return filtered;
|
||||
},
|
||||
}));
|
||||
|
||||
const agg = aggregateAttached({ sha256, tenantId: 'tenant-sim', kinds, archives: wrapped });
|
||||
t.emit({
|
||||
module: 'archive-aggregator',
|
||||
hop: 'merge',
|
||||
payload: { archiveReplies: agg.archiveReplies, silent: agg.silent, records: agg.records.length },
|
||||
});
|
||||
return agg;
|
||||
}
|
||||
|
||||
#seal(sha256) {
|
||||
const jobId = randomUUID();
|
||||
const seal = {
|
||||
jobId,
|
||||
sha256,
|
||||
status: 'completed',
|
||||
timestamp: new Date().toISOString(),
|
||||
certificate: `mock-cert-${jobId.slice(0, 8)}`,
|
||||
blockIndex: this.chain.size + 1,
|
||||
};
|
||||
this.chain.set(sha256, seal);
|
||||
this.jobs.set(jobId, seal);
|
||||
return seal;
|
||||
}
|
||||
|
||||
async #maybeDelay(faults, key, module) {
|
||||
const ms = Number(faults[key] || 0);
|
||||
if (ms > 0) {
|
||||
const t0 = Date.now();
|
||||
await sleep(ms);
|
||||
this.trace.emit({
|
||||
module,
|
||||
hop: 'delay',
|
||||
status: 'delay',
|
||||
durationMs: Date.now() - t0,
|
||||
note: `injected ${key}=${ms}ms`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function redact(obj) {
|
||||
if (!obj || typeof obj !== 'object') return obj;
|
||||
const out = Array.isArray(obj) ? [...obj] : { ...obj };
|
||||
if (out.privateMetadata) out.privateMetadata = { _redacted: true };
|
||||
if (out.files) {
|
||||
out.files = out.files.map((f) => ({ path: f.path || f.filename, size: f.contentBase64?.length || 0 }));
|
||||
}
|
||||
if (out.contentBase64) out.contentBase64 = `[${out.contentBase64.length} b64]`;
|
||||
if (out.password) out.password = '***';
|
||||
return out;
|
||||
}
|
||||
|
||||
export { sha256Hex };
|
||||
Loading…
Add table
Add a link
Reference in a new issue