Initial import of verae-middleware from zapier monorepo

This commit is contained in:
George Lambert 2026-09-11 14:23:39 -04:00
commit cc2ab54161
66 changed files with 6590 additions and 0 deletions

55
src/app.js Normal file
View file

@ -0,0 +1,55 @@
/**
* @fileoverview Express application factory for the Zapier-facing HTTP edge.
* @module app
*/
import express from 'express';
import { sendError } from './errors.js';
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');
/**
* Create the Express app (does not listen).
*
* @param {{ load?: boolean }} [options]
* @returns {import('express').Express}
*/
export function createApp(options = {}) {
if (options.load !== false) {
loadStore();
}
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '12mb' }));
app.use(traceMiddleware);
/**
* Liveness probe.
*/
app.get('/health', (_req, res) => {
res.json({
status: 'ok',
service: 'verae-zapier-middleware',
mockVerae: config.mockVerae,
natsEnabled: config.natsEnabled,
natsConnected: isNatsConnected(),
});
});
app.use('/zapier', apiRoutes);
app.use((err, _req, res, _next) => {
sendError(res, err);
});
log.info('express app created');
return app;
}

351
src/clients/veraeClient.js Normal file
View file

@ -0,0 +1,351 @@
/**
* @fileoverview HTTP client for api.veraetime.net (with mock mode).
* @module clients/veraeClient
*/
import { createHash, randomUUID } from 'node:crypto';
import { config } from '../config.js';
import { AppError } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('http');
const mockJobs = new Map();
/** @type {Map<string, { jobId: string, sha256: string, timestamp?: string, publicMetadata?: object, privateMetadata?: object }>} */
const mockHashes = new Map();
/**
* @param {string} data
* @returns {string}
*/
export function sha256Hex(data) {
return createHash('sha256').update(String(data), 'utf8').digest('hex');
}
/**
* @param {number} ms
* @returns {Promise<void>}
*/
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function mockLogin({ username, password }) {
if (!username || !password) {
throw new AppError('Invalid credentials', { status: 401, code: 'UNAUTHORIZED' });
}
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
return {
token: `mock-jwt-${username}`,
expiresAt,
user: {
id: randomUUID(),
username,
role: username.includes('admin') ? 'admin' : 'user',
},
};
}
async function mockValidate(token) {
if (!token?.startsWith('mock-jwt-')) {
throw new AppError('Invalid or expired token', { status: 401, code: 'UNAUTHORIZED' });
}
const username = token.replace('mock-jwt-', '');
return {
valid: true,
userId: randomUUID(),
username,
role: 'user',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
};
}
async function mockCreateTimestamp({ data, hashAlg, sha256, publicMetadata, privateMetadata }) {
if (!data && !sha256) {
throw new AppError('Invalid input data', { status: 400, code: 'VALIDATION_ERROR' });
}
const key = (sha256 || sha256Hex(data)).toLowerCase();
const existing = mockHashes.get(key);
if (existing) {
const job = mockJobs.get(existing.jobId);
return {
jobId: existing.jobId,
sha256: key,
existing: true,
timestamp: job?.completedAt ?? existing.timestamp,
};
}
const jobId = randomUUID();
mockJobs.set(jobId, {
id: jobId,
status: 'pending',
createdAt: Date.now(),
data: data ?? key,
hashAlg: hashAlg ?? 'SHA256',
sha256: key,
publicMetadata: publicMetadata ?? {},
privateMetadata: privateMetadata ?? {},
});
mockHashes.set(key, { jobId, sha256: key, publicMetadata, privateMetadata });
const delayMs = config.mockJobCompleteMs ?? 150;
setTimeout(() => {
const job = mockJobs.get(jobId);
if (!job) return;
job.status = 'completed';
job.result = `mock-cert-${jobId}`;
job.completedAt = new Date().toISOString();
job.metadata = {
blockIndex: 42,
timestamp: job.completedAt,
certificate: job.result,
sha256: key,
publicMetadata: job.publicMetadata,
};
const rec = mockHashes.get(key);
if (rec) rec.timestamp = job.completedAt;
}, delayMs);
return { jobId, sha256: key, existing: false };
}
async function mockGetStatus(jobId) {
const job = mockJobs.get(jobId);
if (!job) {
throw new AppError('Job not found', { status: 404, code: 'NOT_FOUND' });
}
return {
id: job.id,
status: job.status,
result: job.result,
completedAt: job.completedAt,
metadata: job.metadata,
privateMetadata: job.privateMetadata,
error: job.error,
sha256: job.sha256,
};
}
async function mockLookupHash(sha256) {
const key = String(sha256 || '').toLowerCase();
const rec = mockHashes.get(key);
if (!rec) {
throw new AppError('Hash not found', { status: 404, code: 'NOT_FOUND' });
}
const job = mockJobs.get(rec.jobId);
return {
sha256: key,
exists: true,
jobId: rec.jobId,
timestamp: rec.timestamp ?? job?.completedAt,
status: job?.status,
publicMetadata: job?.publicMetadata ?? rec.publicMetadata ?? {},
};
}
async function mockVerify({ certificate }) {
if (!certificate) {
throw new AppError('Invalid input data', { status: 400, code: 'VALIDATION_ERROR' });
}
const valid = certificate.startsWith('mock-cert-') || certificate.startsWith('eyJ');
return valid
? { valid: true, timestamp: new Date().toISOString(), blockIndex: 42 }
: { valid: false };
}
/**
* Low-level fetch to Verae API.
* @param {string} path
* @param {{ method?: string, token?: string, body?: unknown }} [options]
* @returns {Promise<any>}
*/
async function request(path, { method = 'GET', token, body } = {}) {
const url = `${config.veraeApiBaseUrl}${path}`;
const headers = { Accept: 'application/json' };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
const started = Date.now();
log.debug('verae request', { method, path, hasToken: Boolean(token) });
const response = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
let payload = null;
const text = await response.text();
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = { error: text };
}
}
log.debug('verae response', {
method,
path,
status: response.status,
durationMs: Date.now() - started,
});
if (!response.ok) {
throw new AppError(payload?.error ?? `Verae API error (${response.status})`, {
status: response.status,
code: payload?.code ?? 'VERAE_API_ERROR',
details: payload,
});
}
return payload;
}
/**
* Verae API client (mock when config.mockVerae is true).
*/
export const veraeClient = {
/**
* @param {{ username: string, password: string }} credentials
*/
async login(credentials) {
if (config.mockVerae) return mockLogin(credentials);
return request('/auth/login', { method: 'POST', body: credentials });
},
/**
* @param {string} token
*/
async validate(token) {
if (config.mockVerae) return mockValidate(token);
return request('/auth/validate', { token });
},
/**
* @param {string} token
* @param {{ data: string, hashAlg?: string }} body
*/
async createTimestamp(token, body) {
if (config.mockVerae) return mockCreateTimestamp(body);
const liveBody = { data: body.data, hashAlg: body.hashAlg };
return request('/api/timestamp', { method: 'POST', token, body: liveBody });
},
/**
* @param {string} token
* @param {string} sha256
*/
async lookupHash(token, sha256) {
if (config.mockVerae) return mockLookupHash(sha256);
throw new AppError('Hash lookup is not on the live Verae OpenAPI', {
status: 501,
code: 'NOT_IMPLEMENTED',
});
},
/**
* @param {string} token
* @param {{ items: Array<{ data: string, hashAlg?: string }> }} body
*/
async createBatchTimestamp(token, body) {
if (config.mockVerae) {
const jobIds = [];
for (const item of body.items ?? []) {
const res = await mockCreateTimestamp(item);
jobIds.push(res.jobId);
}
return { jobIds };
}
return request('/api/batch/timestamp', { method: 'POST', token, body });
},
/**
* @param {string} token
* @param {string} jobId
*/
async getStatus(token, jobId) {
if (config.mockVerae) return mockGetStatus(jobId);
return request(`/api/status/${encodeURIComponent(jobId)}`, { token });
},
/**
* @param {string} token
* @param {{ jobIds: string[] }} body
*/
async getBatchStatus(token, body) {
if (config.mockVerae) {
const results = {};
for (const jobId of body.jobIds ?? []) {
results[jobId] = await mockGetStatus(jobId);
}
return { results };
}
return request('/api/batch/status', { method: 'POST', token, body });
},
/**
* @param {string} token
* @param {{ certificate: string }} body
*/
async verify(token, body) {
if (config.mockVerae) return mockVerify(body);
return request('/api/verify', { method: 'POST', token, body });
},
/**
* @param {string} token
* @param {{ certificates: string[] }} body
*/
async verifyBatch(token, body) {
if (config.mockVerae) {
const results = [];
for (const certificate of body.certificates ?? []) {
results.push(await mockVerify({ certificate }));
}
return { results };
}
return request('/api/batch/verify', { method: 'POST', token, body });
},
/**
* @param {string} token
* @param {string} jobId
*/
async getJobVerification(token, jobId) {
if (config.mockVerae) return mockGetStatus(jobId);
return request(`/api/verify/${encodeURIComponent(jobId)}`, { token });
},
/**
* Poll until completed/failed or timeout.
* @param {string} token
* @param {string} jobId
* @param {{ maxAttempts: number, intervalMs: number }} options
*/
async waitForJob(token, jobId, { maxAttempts, intervalMs }) {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const status = await this.getStatus(token, jobId);
if (status.status === 'completed' || status.status === 'failed') {
return status;
}
await delay(intervalMs);
}
throw new AppError(`Job ${jobId} timed out`, { status: 504, code: 'GATEWAY_TIMEOUT' });
},
};
/**
* Clear mock jobs (tests only).
* @returns {void}
*/
export function clearMockJobs() {
mockJobs.clear();
mockHashes.clear();
}

144
src/config.js Normal file
View file

@ -0,0 +1,144 @@
/**
* @fileoverview Process configuration for the Verae Zapier middleware.
* @module config
*
* Loads optional `.env` then exports typed settings used by HTTP, NATS, and workers.
*/
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDebugger } from './debug/logger.js';
const log = createDebugger('app');
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = resolve(__dirname, '..');
/**
* Load KEY=VALUE pairs from `.env` without overriding existing process.env keys.
* @returns {void}
*/
export function loadEnvFile() {
const envPath = resolve(rootDir, '.env');
if (!existsSync(envPath)) return;
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!(key in process.env)) {
process.env[key] = value;
}
}
}
loadEnvFile();
/**
* @param {string|undefined} value
* @param {boolean} [fallback=false]
* @returns {boolean}
*/
function bool(value, fallback = false) {
if (value === undefined) return fallback;
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
}
/**
* @param {string|undefined} value
* @param {number} fallback
* @returns {number}
*/
function int(value, fallback) {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
/**
* Runtime configuration object.
* @type {{
* port: number,
* host: string,
* veraeApiBaseUrl: string,
* mockVerae: boolean,
* natsEnabled: boolean,
* natsUrl: string,
* tokenSecret: string,
* jobPollIntervalMs: number,
* jobPollMaxAttempts: number,
* storePath: string,
* upgradeUrl: string,
* adminSecret: string,
* }}
*/
export const config = {
port: int(process.env.PORT, 3100),
host: process.env.HOST ?? '0.0.0.0',
veraeApiBaseUrl: (process.env.VERAE_API_BASE_URL ?? 'http://localhost:8080').replace(
/\/$/,
'',
),
mockVerae: bool(process.env.MOCK_VERAE, false),
natsEnabled: bool(process.env.NATS_ENABLED, false),
natsUrl: process.env.NATS_URL ?? 'nats://127.0.0.1:4222',
tokenSecret: process.env.TOKEN_SECRET ?? 'dev-secret-change-me',
jobPollIntervalMs: int(process.env.JOB_POLL_INTERVAL_MS, 2000),
jobPollMaxAttempts: int(process.env.JOB_POLL_MAX_ATTEMPTS, 60),
waitTimeoutMs: int(process.env.WAIT_TIMEOUT_MS, 25000),
mockJobCompleteMs: int(process.env.MOCK_JOB_COMPLETE_MS, 150),
storePath: resolve(rootDir, process.env.STORE_PATH ?? './data/store.json'),
upgradeUrl: process.env.UPGRADE_URL ?? 'https://veraetime.net/billing',
adminSecret: process.env.ADMIN_SECRET ?? 'change-me-admin',
};
/**
* Default plan limits. `null` numeric fields mean unlimited.
* @type {Record<string, {
* timestamps: number|null,
* verifications: number|null,
* batch: boolean,
* batchMaxItems: number|null,
* requestsPerMinute: number
* }>}
*/
export const PLAN_LIMITS = {
free: {
timestamps: 50,
verifications: 50,
batch: false,
batchMaxItems: 0,
requestsPerMinute: 30,
},
starter: {
timestamps: 500,
verifications: 500,
batch: true,
batchMaxItems: 10,
requestsPerMinute: 120,
},
pro: {
timestamps: 5000,
verifications: 5000,
batch: true,
batchMaxItems: 100,
requestsPerMinute: 600,
},
enterprise: {
timestamps: null,
verifications: null,
batch: true,
batchMaxItems: null,
requestsPerMinute: 3000,
},
};
log.info('config loaded', {
veraeApiBaseUrl: config.veraeApiBaseUrl,
mockVerae: config.mockVerae,
natsEnabled: config.natsEnabled,
natsUrl: config.natsUrl,
port: config.port,
});

106
src/debug/config.js Normal file
View file

@ -0,0 +1,106 @@
/**
* @fileoverview Runtime debug configuration.
*
* Reads process environment at call time of {@link loadDebugConfig} so tests can
* mutate `process.env` between cases. Production servers typically load once at boot.
*
* @module debug/config
*/
/**
* @typedef {Object} DebugConfig
* @property {boolean} enabled - True when any debug output should be produced.
* @property {Set<string>|null} namespaces - Allowed namespaces; `null` means all.
* @property {'debug'|'info'|'warn'|'error'} level - Minimum severity to emit.
* @property {string|null} filePath - Optional secondary log file path.
*/
const LEVEL_ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
/**
* Parse `DEBUG_VERAE` into an enabled flag and optional namespace allow-list.
*
* @param {string|undefined} raw - Raw env value (e.g. `"1"`, `"*"`, `"auth,nats"`).
* @returns {{ enabled: boolean, namespaces: Set<string>|null }}
*
* @example
* parseDebugVeraeEnv('auth,jobs'); // { enabled: true, namespaces: Set{'auth','jobs'} }
* parseDebugVeraeEnv(undefined); // { enabled: false, namespaces: null }
*/
export function parseDebugVeraeEnv(raw) {
if (raw === undefined || raw === null) {
return { enabled: false, namespaces: null };
}
const trimmed = String(raw).trim();
if (!trimmed || trimmed === '0' || trimmed.toLowerCase() === 'false' || trimmed.toLowerCase() === 'off') {
return { enabled: false, namespaces: null };
}
if (trimmed === '1' || trimmed === '*' || trimmed.toLowerCase() === 'true' || trimmed.toLowerCase() === 'all') {
return { enabled: true, namespaces: null };
}
const parts = trimmed
.split(',')
.map((p) => p.trim().toLowerCase())
.filter(Boolean);
if (parts.length === 0) {
return { enabled: false, namespaces: null };
}
return { enabled: true, namespaces: new Set(parts) };
}
/**
* Normalize a level string to a known level.
*
* @param {string|undefined} raw - Env level value.
* @param {'debug'|'info'|'warn'|'error'} [fallback='debug'] - Default level.
* @returns {'debug'|'info'|'warn'|'error'}
*/
export function parseLevel(raw, fallback = 'debug') {
const value = String(raw ?? fallback).toLowerCase();
if (value in LEVEL_ORDER) return /** @type {'debug'|'info'|'warn'|'error'} */ (value);
return fallback;
}
/**
* Load full debug configuration from `process.env`.
*
* Environment variables:
* - `DEBUG_VERAE` enable + namespaces (see {@link parseDebugVeraeEnv})
* - `DEBUG_VERAE_LEVEL` minimum level
* - `DEBUG_VERAE_FILE` optional log file path
*
* @param {NodeJS.ProcessEnv} [env=process.env] - Environment map (injectable for tests).
* @returns {DebugConfig}
*/
export function loadDebugConfig(env = process.env) {
const { enabled, namespaces } = parseDebugVeraeEnv(env.DEBUG_VERAE);
return {
enabled,
namespaces,
level: parseLevel(env.DEBUG_VERAE_LEVEL, 'debug'),
filePath: env.DEBUG_VERAE_FILE ? String(env.DEBUG_VERAE_FILE) : null,
};
}
/**
* Whether a message at `messageLevel` in `namespace` should be emitted.
*
* @param {DebugConfig} config - Active config.
* @param {string} namespace - Logger namespace (e.g. `auth`).
* @param {'debug'|'info'|'warn'|'error'} messageLevel - Message severity.
* @returns {boolean}
*/
export function shouldLog(config, namespace, messageLevel) {
if (!config.enabled) return false;
if (config.namespaces && !config.namespaces.has(String(namespace).toLowerCase())) {
return false;
}
return LEVEL_ORDER[messageLevel] >= LEVEL_ORDER[config.level];
}
export { LEVEL_ORDER };

24
src/debug/index.js Normal file
View file

@ -0,0 +1,24 @@
/**
* @fileoverview Public exports for the runtime debug facility.
* @module debug
*/
export {
loadDebugConfig,
parseDebugVeraeEnv,
parseLevel,
shouldLog,
LEVEL_ORDER,
} from './config.js';
export { createDebugger, formatLogLine, setDebugTestSink } from './logger.js';
export { redact, redactString } from './redact.js';
export {
generateTraceId,
getTraceId,
getTraceContext,
withTrace,
traceMiddleware,
} from './trace.js';

121
src/debug/logger.js Normal file
View file

@ -0,0 +1,121 @@
/**
* @fileoverview Namespaced debug logger with runtime enable/disable.
* @module debug/logger
*/
import { appendFileSync } from 'node:fs';
import { loadDebugConfig, shouldLog } from './config.js';
import { redact } from './redact.js';
import { getTraceId } from './trace-context.js';
/**
* @typedef {Object} Debugger
* @property {(msg: string, meta?: Record<string, unknown>) => void} debug
* @property {(msg: string, meta?: Record<string, unknown>) => void} info
* @property {(msg: string, meta?: Record<string, unknown>) => void} warn
* @property {(msg: string, meta?: Record<string, unknown>) => void} error
* @property {string} namespace
*/
/**
* Optional sink for tests when set, lines go here instead of/in addition to stderr.
* @type {null|((line: string) => void)}
*/
let testSink = null;
/**
* Install a test sink that captures formatted log lines.
* Used only by unit tests; do not use in production code.
*
* @param {null|((line: string) => void)} sink - Callback or null to clear.
* @returns {void}
*/
export function setDebugTestSink(sink) {
testSink = sink;
}
/**
* Format a single log line.
*
* @param {object} parts
* @param {string} parts.level
* @param {string} parts.namespace
* @param {string} parts.message
* @param {Record<string, unknown>|undefined} parts.meta
* @param {string|null} parts.traceId
* @returns {string}
*/
export function formatLogLine({ level, namespace, message, meta, traceId }) {
const ts = new Date().toISOString();
const tracePart = traceId ? ` [trace=${traceId}]` : '';
const metaPart =
meta && Object.keys(meta).length > 0 ? ` ${JSON.stringify(redact(meta))}` : '';
return `${ts} ${level.toUpperCase()} ${namespace}${tracePart} ${message}${metaPart}`;
}
/**
* Emit a log line to stderr, optional file, and optional test sink.
*
* @param {string} line - Full formatted line.
* @param {string|null} filePath - Optional file from config.
* @returns {void}
*/
function emit(line, filePath) {
if (testSink) {
testSink(line);
} else {
// eslint-disable-next-line no-console
console.error(line);
}
if (filePath) {
try {
appendFileSync(filePath, `${line}\n`, 'utf8');
} catch {
// ignore file errors in debug path
}
}
}
/**
* Create a namespaced debugger bound to current env configuration.
*
* Configuration is re-read on every log call so tests can toggle `DEBUG_VERAE`
* without reloading modules. Slight cost is acceptable for debug-only paths.
*
* @param {string} namespace - Namespace such as `auth`, `nats`, `jobs`.
* @returns {Debugger} Logger with debug/info/warn/error methods.
*
* @example
* const log = createDebugger('jobs');
* log.debug('poll start', { jobId: 'abc' });
*/
export function createDebugger(namespace) {
const ns = String(namespace || 'app').toLowerCase();
/**
* @param {'debug'|'info'|'warn'|'error'} level
* @param {string} message
* @param {Record<string, unknown>} [meta]
*/
function write(level, message, meta) {
const config = loadDebugConfig();
if (!shouldLog(config, ns, level)) return;
const line = formatLogLine({
level,
namespace: ns,
message: String(message),
meta,
traceId: getTraceId(),
});
emit(line, config.filePath);
}
return {
namespace: ns,
debug: (message, meta) => write('debug', message, meta),
info: (message, meta) => write('info', message, meta),
warn: (message, meta) => write('warn', message, meta),
error: (message, meta) => write('error', message, meta),
};
}

93
src/debug/redact.js Normal file
View file

@ -0,0 +1,93 @@
/**
* @fileoverview Secret redaction for debug logs.
* @module debug/redact
*/
const SENSITIVE_KEYS = new Set([
'password',
'veraepassword',
'token',
'veraetoken',
'accesstoken',
'apikey',
'api_key',
'secret',
'authorization',
'x-api-key',
'cookie',
'sessionkey',
]);
const BEARER_RE = /^Bearer\s+.+/i;
const API_KEY_RE = /^zmw_[A-Za-z0-9_-]+/;
const SESSION_RE = /^zmt_[A-Za-z0-9_.-]+/;
const JWT_RE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/;
/**
* Redact a single string value if it looks like a secret.
*
* @param {string} value - Raw string.
* @returns {string} Original or `[REDACTED]`.
*/
export function redactString(value) {
if (typeof value !== 'string') return value;
if (
BEARER_RE.test(value) ||
API_KEY_RE.test(value) ||
SESSION_RE.test(value) ||
JWT_RE.test(value)
) {
return '[REDACTED]';
}
return value;
}
/**
* Deep-clone plain data structures while redacting sensitive keys and values.
*
* @param {unknown} input - Any JSON-like value.
* @param {number} [depth=0] - Recursion depth guard.
* @returns {unknown} Redacted structure safe for logging.
*
* @example
* redact({ password: 'x', jobId: '1' }); // { password: '[REDACTED]', jobId: '1' }
*/
export function redact(input, depth = 0) {
if (depth > 8) return '[MaxDepth]';
if (input === null || input === undefined) return input;
if (typeof input === 'string') return redactString(input);
if (typeof input === 'number' || typeof input === 'boolean') return input;
if (input instanceof Error) {
return { name: input.name, message: redactString(input.message), stack: undefined };
}
if (Array.isArray(input)) {
return input.map((item) => redact(item, depth + 1));
}
if (typeof input === 'object') {
/** @type {Record<string, unknown>} */
const out = {};
for (const [key, value] of Object.entries(input)) {
if (SENSITIVE_KEYS.has(key.toLowerCase())) {
out[key] = '[REDACTED]';
} else if (key.toLowerCase() === 'targeturl' && typeof value === 'string') {
try {
const u = new URL(value);
out[key] = `${u.origin}${u.pathname}`;
} catch {
out[key] = '[REDACTED_URL]';
}
} else {
out[key] = redact(value, depth + 1);
}
}
return out;
}
return String(input);
}

View file

@ -0,0 +1,39 @@
/**
* @fileoverview AsyncLocalStorage-backed trace context (no logger dependency).
* Split from trace.js to avoid circular imports with logger.js.
* @module debug/trace-context
*/
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomBytes } from 'node:crypto';
/**
* @typedef {Object} TraceContext
* @property {string} traceId
* @property {string} [span]
*/
/** @type {AsyncLocalStorage<TraceContext>} */
export const traceStorage = new AsyncLocalStorage();
/**
* Generate a short opaque trace id (16 hex chars).
* @returns {string}
*/
export function generateTraceId() {
return randomBytes(8).toString('hex');
}
/**
* @returns {string|null}
*/
export function getTraceId() {
return traceStorage.getStore()?.traceId ?? null;
}
/**
* @returns {TraceContext|null}
*/
export function getTraceContext() {
return traceStorage.getStore() ?? null;
}

95
src/debug/trace.js Normal file
View file

@ -0,0 +1,95 @@
/**
* @fileoverview Correlation / trace IDs for failure tracing across HTTP and NATS.
* @module debug/trace
*/
import { createDebugger } from './logger.js';
import {
traceStorage,
generateTraceId,
getTraceId,
getTraceContext,
} from './trace-context.js';
export { generateTraceId, getTraceId, getTraceContext };
const log = createDebugger('trace');
/**
* Run `fn` within a trace context. Nested calls inherit the same `traceId`
* unless `traceId` is explicitly overridden.
*
* @template T
* @param {object|(() => T|Promise<T>)} options - Options or the callback itself.
* @param {string} [options.traceId] - Existing id (e.g. from `X-Trace-Id` header).
* @param {string} [options.span] - Human label for this span (logged when debug on).
* @param {() => T|Promise<T>} [fn] - Work to execute inside the context when options is an object.
* @returns {T|Promise<T>} Return value of `fn`.
*
* @example
* await withTrace({ span: 'timestamp.wait', traceId: req.headers['x-trace-id'] }, async () => {
* // getTraceId() is stable here and in awaited children
* });
*/
export function withTrace(options, fn) {
const opts = typeof options === 'function' ? {} : options ?? {};
const callback = typeof options === 'function' ? options : fn;
if (typeof callback !== 'function') {
throw new TypeError('withTrace requires a function to execute');
}
const parent = traceStorage.getStore();
const traceId = opts.traceId || parent?.traceId || generateTraceId();
const span = opts.span || parent?.span || 'root';
const ctx = { traceId, span };
return traceStorage.run(ctx, () => {
log.debug('span enter', { span });
try {
const result = callback();
if (result && typeof result.then === 'function') {
return result.then(
(value) => {
log.debug('span exit', { span, ok: true });
return value;
},
(err) => {
log.debug('span exit', { span, ok: false, error: err?.message });
throw err;
},
);
}
log.debug('span exit', { span, ok: true });
return result;
} catch (err) {
log.debug('span exit', { span, ok: false, error: err?.message });
throw err;
}
});
}
/**
* Express middleware that establishes a trace context per request.
*
* Reads `X-Trace-Id` when present; otherwise generates a new id.
* Sets `req.traceId` and response header `X-Trace-Id`.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
* @returns {void}
*/
export function traceMiddleware(req, res, next) {
const incoming = req.headers['x-trace-id'];
const traceId =
typeof incoming === 'string' && incoming.trim() ? incoming.trim() : generateTraceId();
req.traceId = traceId;
res.setHeader('X-Trace-Id', traceId);
withTrace({ traceId, span: `${req.method} ${req.path}` }, () => {
next();
});
}

75
src/errors.js Normal file
View file

@ -0,0 +1,75 @@
/**
* @fileoverview Structured errors and Express helpers.
* @module errors
*/
import { createDebugger } from './debug/logger.js';
import { getTraceId } from './debug/trace-context.js';
const log = createDebugger('app');
/**
* Operational error with HTTP status and machine-readable code.
*/
export class AppError extends Error {
/**
* @param {string} message - Human-readable error.
* @param {object} [options]
* @param {number} [options.status=500] - HTTP status code.
* @param {string} [options.code='INTERNAL_ERROR'] - Machine code.
* @param {unknown} [options.details] - Optional structured details (safe for clients).
*/
constructor(message, { status = 500, code = 'INTERNAL_ERROR', details } = {}) {
super(message);
this.name = 'AppError';
this.status = status;
this.code = code;
this.details = details;
}
}
/**
* Wrap an async Express handler so rejected promises reach the error middleware.
*
* @param {(req: import('express').Request, res: import('express').Response, next: import('express').NextFunction) => Promise<unknown>} fn
* @returns {import('express').RequestHandler}
*/
export function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
/**
* Send a JSON error response. Logs server errors when debug is enabled.
*
* @param {import('express').Response} res
* @param {Error|AppError} err
* @returns {void}
*/
export function sendError(res, err) {
const status = err instanceof AppError ? err.status : 500;
const code = err instanceof AppError ? err.code : 'INTERNAL_ERROR';
const message = err?.message || 'Internal server error';
const details = err instanceof AppError ? err.details : undefined;
const traceId = getTraceId();
if (status >= 500) {
log.error('request failed', {
status,
code,
message,
traceId,
stack: err?.stack,
});
} else {
log.debug('client error', { status, code, message, traceId });
}
res.status(status).json({
error: message,
code,
...(details !== undefined ? { details } : {}),
...(traceId ? { traceId } : {}),
});
}

51
src/index.js Normal file
View file

@ -0,0 +1,51 @@
/**
* @fileoverview Process entrypoint: HTTP listen + optional in-process poller.
* @module index
*/
import { createApp } from './app.js';
import { config } from './config.js';
import { createDebugger } from './debug/logger.js';
import { startInProcessJobPoller } from './workers/inProcessJobPoller.js';
import { connectNats, ensureStreams } from './nats/connection.js';
import { startJobPollerWorker } from './workers/jobPollerWorker.js';
import { startWebhookWorker } from './workers/webhookWorker.js';
const log = createDebugger('app');
/**
* Start the HTTP server and background workers.
* @returns {import('http').Server}
*/
export function startServer() {
const app = createApp();
if (config.natsEnabled) {
connectNats()
.then(({ jsm }) => ensureStreams(jsm))
.then(() => Promise.all([startJobPollerWorker(), startWebhookWorker()]))
.then(() => log.info('NATS workers started'))
.catch((err) => log.error('NATS worker start failed', { error: err.message }));
} else {
startInProcessJobPoller();
}
const server = app.listen(config.port, config.host, () => {
log.info('middleware listening', {
host: config.host,
port: config.port,
veraeApiBaseUrl: config.veraeApiBaseUrl,
mockVerae: config.mockVerae,
natsEnabled: config.natsEnabled,
});
// eslint-disable-next-line no-console
console.log(
`Verae Zapier middleware listening on http://${config.host}:${config.port}`,
);
});
return server;
}
if (process.argv[1]?.includes('index.js')) {
startServer();
}

64
src/lib/receiptPdf.js Normal file
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');
}

97
src/lib/tokens.js Normal file
View file

@ -0,0 +1,97 @@
/**
* @fileoverview API keys and HMAC session tokens for the middleware auth bridge.
* @module lib/tokens
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { config } from '../config.js';
const TOKEN_PREFIX = 'zmt_';
const API_KEY_PREFIX = 'zmw_';
/**
* @param {string} payload
* @returns {string}
*/
function sign(payload) {
return createHmac('sha256', config.tokenSecret).update(payload).digest('base64url');
}
/**
* Issue a signed middleware session token embedding tenant + Verae JWT.
*
* @param {object} params
* @param {string} params.tenantId
* @param {string} params.veraeToken
* @param {string} [params.expiresAt]
* @returns {string} Token string starting with `zmt_`
*/
export function issueSessionToken({ tenantId, veraeToken, expiresAt }) {
const payload = Buffer.from(
JSON.stringify({
tenantId,
veraeToken,
expiresAt,
nonce: randomBytes(8).toString('hex'),
}),
).toString('base64url');
const signature = sign(payload);
return `${TOKEN_PREFIX}${payload}.${signature}`;
}
/**
* Parse and verify a session token.
*
* @param {string} token
* @returns {{ tenantId: string, veraeToken: string, expiresAt?: string, nonce?: string }|null}
*/
export function parseSessionToken(token) {
if (!token?.startsWith(TOKEN_PREFIX)) return null;
const raw = token.slice(TOKEN_PREFIX.length);
const dot = raw.lastIndexOf('.');
if (dot === -1) return null;
const payload = raw.slice(0, dot);
const signature = raw.slice(dot + 1);
const expected = sign(payload);
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
return null;
}
try {
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
} catch {
return null;
}
}
/**
* Generate a new public API key (`zmw_…`).
* @returns {string}
*/
export function generateApiKey() {
return `${API_KEY_PREFIX}${randomBytes(24).toString('base64url')}`;
}
/**
* @param {unknown} value
* @returns {boolean}
*/
export function isApiKey(value) {
return typeof value === 'string' && value.startsWith(API_KEY_PREFIX);
}
/**
* Extract bearer credential from an Authorization header.
* @param {string|undefined} header
* @returns {string|null}
*/
export function extractBearerToken(header) {
if (!header) return null;
const match = String(header).match(/^Bearer\s+(.+)$/i);
return match?.[1] ?? null;
}

View file

@ -0,0 +1,27 @@
/**
* @fileoverview Express auth middleware populates req.auth.
* @module middleware/authenticate
*/
import { extractBearerToken } from '../lib/tokens.js';
import { resolveAuthContext } from '../services/authService.js';
import { asyncHandler } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('auth');
/**
* Resolve Bearer or x-api-key into `req.auth`.
*/
export const authenticate = asyncHandler(async (req, res, next) => {
const rawToken =
extractBearerToken(req.headers.authorization) ?? req.headers['x-api-key'] ?? null;
req.auth = await resolveAuthContext(rawToken);
log.debug('authenticated', {
tenantId: req.auth.tenantId,
method: req.auth.authMethod,
plan: req.auth.tenant?.plan,
});
next();
});

View file

@ -0,0 +1,56 @@
/**
* @fileoverview Simple in-memory per-tenant rate limiter.
* @module middleware/rateLimit
*/
import { AppError, asyncHandler } from '../errors.js';
import { getTenant, resolveLimits } from '../store/tenants.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('billing');
/** @type {Map<string, { windowStart: number, count: number }>} */
const windows = new Map();
/**
* Reset rate limit windows (tests).
* @returns {void}
*/
export function resetRateLimitWindows() {
windows.clear();
}
/**
* Enforce plan requestsPerMinute using a 60s sliding fixed window.
*/
export const rateLimit = asyncHandler(async (req, res, next) => {
const tenantId = req.auth?.tenantId;
if (!tenantId) return next();
const tenant = getTenant(tenantId);
const limits = tenant
? resolveLimits(tenant)
: { requestsPerMinute: 30 };
const rpm = limits.requestsPerMinute ?? 30;
const now = Date.now();
const windowMs = 60_000;
let entry = windows.get(tenantId);
if (!entry || now - entry.windowStart >= windowMs) {
entry = { windowStart: now, count: 0 };
windows.set(tenantId, entry);
}
entry.count += 1;
if (entry.count > rpm) {
log.debug('rate limited', { tenantId, count: entry.count, rpm });
throw new AppError('Rate limit exceeded', {
status: 429,
code: 'RATE_LIMITED',
details: { requestsPerMinute: rpm },
});
}
next();
});

116
src/nats/connection.js Normal file
View file

@ -0,0 +1,116 @@
/**
* @fileoverview NATS + JetStream connection lifecycle.
* @module nats/connection
*/
import { createDebugger } from '../debug/logger.js';
import { config } from '../config.js';
import { SUBJECTS, STREAMS } from './subjects.js';
const log = createDebugger('nats');
/** @type {import('nats').NatsConnection|null} */
let nc = null;
/** @type {import('nats').JetStreamClient|null} */
let js = null;
/** @type {import('nats').JetStreamManager|null} */
let jsm = null;
/**
* Connect to NATS and return JetStream handles.
*
* @param {string} [url=config.natsUrl]
* @returns {Promise<{ nc: import('nats').NatsConnection, js: import('nats').JetStreamClient, jsm: import('nats').JetStreamManager }>}
*/
export async function connectNats(url = config.natsUrl) {
if (!config.natsEnabled && process.env.NATS_FORCE_CONNECT !== '1') {
log.debug('connect skipped — NATS_ENABLED=false');
throw new Error('NATS is disabled (NATS_ENABLED=false)');
}
if (nc && js && jsm) {
return { nc, js, jsm };
}
log.info('connecting to NATS', { url });
const { connect } = await import('nats');
nc = await connect({ servers: url, name: 'verae-zapier-middleware' });
js = nc.jetstream();
jsm = await nc.jetstreamManager();
log.info('NATS connected', { url });
return { nc, js, jsm };
}
/** @returns {import('nats').NatsConnection|null} */
export function getNatsConnection() {
return nc;
}
/**
* Idempotently create JetStream streams required by this middleware.
*
* @param {import('nats').JetStreamManager} [manager]
* @returns {Promise<void>}
*/
export async function ensureStreams(manager) {
const m = manager ?? jsm;
if (!m) {
throw new Error('JetStream manager not available — call connectNats first');
}
/** @type {Array<{ name: string, subjects: string[] }>} */
const defs = [
{ name: STREAMS.ZAPIER_JOBS, subjects: [SUBJECTS.JOBS_WATCH] },
{ name: STREAMS.ZAPIER_EVENTS, subjects: [SUBJECTS.JOBS_EVENTS] },
{ name: STREAMS.ZAPIER_WEBHOOKS, subjects: [SUBJECTS.WEBHOOKS_DELIVER] },
];
for (const def of defs) {
try {
await m.streams.info(def.name);
log.debug('stream exists', { stream: def.name });
} catch {
await m.streams.add({
name: def.name,
subjects: def.subjects,
retention: 'limits',
storage: 'file',
max_age: 24 * 60 * 60 * 1e9, // 24h in ns
num_replicas: 1,
});
log.info('stream created', { stream: def.name, subjects: def.subjects });
}
}
}
/**
* Close the shared NATS connection if open.
* @returns {Promise<void>}
*/
export async function closeNats() {
if (!nc) {
log.debug('closeNats: no active connection');
return;
}
log.info('closing NATS connection');
await nc.drain();
nc = null;
js = null;
jsm = null;
}
/**
* @returns {import('nats').JetStreamClient|null}
*/
export function getJetStream() {
return js;
}
/**
* @returns {boolean}
*/
export function isNatsConnected() {
return Boolean(nc && !nc.isClosed());
}

95
src/nats/publishers.js Normal file
View file

@ -0,0 +1,95 @@
/**
* @fileoverview JetStream publishers for jobs, events, and webhooks.
* @module nats/publishers
*/
import { createDebugger } from '../debug/logger.js';
import { getTraceId } from '../debug/trace-context.js';
import { SUBJECTS } from './subjects.js';
import { getJetStream, connectNats } from './connection.js';
const log = createDebugger('nats');
/**
* @returns {Promise<import('nats').JetStreamClient>}
*/
async function requireJs() {
let js = getJetStream();
if (!js) {
const handles = await connectNats();
js = handles.js;
}
return js;
}
/**
* @param {object} partial
* @returns {Promise<{ seq: number }>}
*/
export async function enqueueWatch(partial) {
const msg = {
attempt: 0,
enqueuedAt: new Date().toISOString(),
traceId: getTraceId() || 'no-trace',
...partial,
};
log.debug('enqueueWatch', {
subject: SUBJECTS.JOBS_WATCH,
tenantId: msg.tenantId,
jobId: msg.jobId,
attempt: msg.attempt,
traceId: msg.traceId,
});
const js = await requireJs();
const ack = await js.publish(SUBJECTS.JOBS_WATCH, JSON.stringify(msg));
return { seq: Number(ack.seq) };
}
/**
* @param {object} partial
* @returns {Promise<{ seq: number }>}
*/
export async function publishJobEvent(partial) {
const msg = {
emittedAt: new Date().toISOString(),
traceId: getTraceId() || 'no-trace',
...partial,
};
log.debug('publishJobEvent', {
subject: SUBJECTS.JOBS_EVENTS,
event: msg.event,
jobId: msg.jobId,
tenantId: msg.tenantId,
});
const js = await requireJs();
const ack = await js.publish(SUBJECTS.JOBS_EVENTS, JSON.stringify(msg));
return { seq: Number(ack.seq) };
}
/**
* @param {object} partial
* @returns {Promise<{ seq: number }>}
*/
export async function enqueueWebhook(partial) {
const msg = {
attempt: 1,
traceId: getTraceId() || 'no-trace',
...partial,
};
log.debug('enqueueWebhook', {
subject: SUBJECTS.WEBHOOKS_DELIVER,
hookId: msg.hookId,
tenantId: msg.tenantId,
event: msg.event,
targetUrl: msg.targetUrl,
});
const js = await requireJs();
const ack = await js.publish(SUBJECTS.WEBHOOKS_DELIVER, JSON.stringify(msg));
return { seq: Number(ack.seq) };
}

47
src/nats/subjects.js Normal file
View file

@ -0,0 +1,47 @@
/**
* @fileoverview NATS subject and stream name constants.
* @module nats/subjects
*
* See docs/architecture/nats-subjects.md for payload schemas.
*/
/**
* Subject strings used by publishers and consumers.
* @readonly
*/
export const SUBJECTS = Object.freeze({
/** Work queue: poll Verae job status */
JOBS_WATCH: 'verae.zapier.jobs.watch',
/** Terminal job outcomes */
JOBS_EVENTS: 'verae.zapier.jobs.events',
/** Work queue: HTTP POST to Zapier REST Hooks */
WEBHOOKS_DELIVER: 'verae.zapier.webhooks.deliver',
/** Optional metering stream */
USAGE: 'verae.zapier.usage',
/** Off-chain WORM / tree-node puts */
ARCHIVE_PUT: 'verae.archive.put',
/** Broadcast hash lookup to every WORM and tree node (no queue group) */
ARCHIVE_QUERY: 'verae.archive.query',
});
/**
* JetStream stream names.
* @readonly
*/
export const STREAMS = Object.freeze({
ZAPIER_JOBS: 'ZAPIER_JOBS',
ZAPIER_EVENTS: 'ZAPIER_EVENTS',
ZAPIER_WEBHOOKS: 'ZAPIER_WEBHOOKS',
ZAPIER_USAGE: 'ZAPIER_USAGE',
});
/**
* Durable consumer names (queue groups).
* @readonly
*/
export const CONSUMERS = Object.freeze({
JOB_POLLER: 'job-poller',
EVENT_WEBHOOK_ROUTER: 'event-webhook-router',
WEBHOOK_DELIVER: 'webhook-deliver',
USAGE_WRITER: 'usage-writer',
});

59
src/nats/wait.js Normal file
View file

@ -0,0 +1,59 @@
/**
* Wait for a terminal job event on NATS JOBS_EVENTS.
* @module nats/wait
*/
import { createDebugger } from '../debug/logger.js';
import { getTraceId } from '../debug/trace-context.js';
import { SUBJECTS } from './subjects.js';
import { connectNats, getNatsConnection } from './connection.js';
const log = createDebugger('jobs');
/**
* @param {string} jobId
* @param {number} timeoutMs
* @returns {Promise<object|null>} event payload or null on timeout
*/
export async function waitForJobEvent(jobId, timeoutMs) {
const handles = getNatsConnection() ? { nc: getNatsConnection() } : await connectNats();
const nc = handles.nc;
const sub = nc.subscribe(SUBJECTS.JOBS_EVENTS);
const traceId = getTraceId();
log.debug('waitForJobEvent subscribe', { jobId, timeoutMs, traceId });
const timeout = new Promise((resolve) => {
setTimeout(() => resolve(null), timeoutMs);
});
const firstMatch = (async () => {
for await (const msg of sub) {
let data;
try {
data = JSON.parse(typeof msg.string === 'function' ? msg.string() : msg.data);
} catch {
continue;
}
if (data.jobId !== jobId) continue;
const event = data.event;
if (
event === 'timestamp.completed' ||
event === 'timestamp.failed' ||
event === 'timestamp.timeout'
) {
return data;
}
}
return null;
})();
try {
return await Promise.race([firstMatch, timeout]);
} finally {
try {
sub.unsubscribe();
} catch {
/* ignore */
}
}
}

57
src/routes/authRoutes.js Normal file
View file

@ -0,0 +1,57 @@
/**
* @fileoverview Auth routes under /zapier/v1/auth
* @module routes/authRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import { loginWithCredentials, loginWithApiKey, validateSession } from '../services/authService.js';
import { getUsageSummary } from '../store/usage.js';
import { getTenant } from '../store/tenants.js';
import { extractBearerToken } from '../lib/tokens.js';
export const authRoutes = Router();
authRoutes.post(
'/login',
asyncHandler(async (req, res) => {
const { username, password, api_key: apiKey } = req.body ?? {};
if (apiKey) {
const session = await loginWithApiKey(apiKey);
return res.json(session);
}
if (!username || !password) {
throw new AppError('username and password are required', {
status: 400,
code: 'VALIDATION_ERROR',
});
}
const session = await loginWithCredentials({ username, password });
res.json(session);
}),
);
authRoutes.get(
'/me',
asyncHandler(async (req, res) => {
const rawToken =
extractBearerToken(req.headers.authorization) ?? req.headers['x-api-key'] ?? null;
if (!rawToken) {
throw new AppError('Missing authorization token', { status: 401, code: 'UNAUTHORIZED' });
}
const validation = await validateSession(rawToken);
const tenant = getTenant(validation.tenantId);
const usage = getUsageSummary(validation.tenantId);
res.json({
...validation,
plan: tenant?.plan ?? validation.plan ?? 'free',
usage,
});
}),
);

23
src/routes/hashRoutes.js Normal file
View file

@ -0,0 +1,23 @@
/**
* @module routes/hashRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import { lookupHash } from '../services/timestampService.js';
export const hashRoutes = Router();
hashRoutes.get(
'/:sha256',
asyncHandler(async (req, res) => {
const { sha256 } = req.params;
if (!sha256 || !/^[a-fA-F0-9]{64}$/.test(sha256)) {
throw new AppError('sha256 must be 64 hex chars', { status: 400, code: 'VALIDATION_ERROR' });
}
const includeAttached = req.query.includeAttached === 'true' || req.query.includeAttached === '1';
const includeTree = req.query.includeTree === 'true' || req.query.includeTree === '1';
const result = await lookupHash(req.auth, sha256, { includeAttached, includeTree });
res.json(result);
}),
);

40
src/routes/index.js Normal file
View file

@ -0,0 +1,40 @@
/**
* @fileoverview Mount all /zapier routes.
* @module routes/index
*/
import { Router } from 'express';
import { authRoutes } from './authRoutes.js';
import { timestampRoutes } from './timestampRoutes.js';
import { verifyRoutes } from './verifyRoutes.js';
import { statusRoutes } from './statusRoutes.js';
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';
export const apiRoutes = Router();
// Public
apiRoutes.use('/v1/auth', authRoutes);
apiRoutes.use('/v1', publicTenantRoutes);
apiRoutes.use('/v1/admin', adminTenantRoutes);
apiRoutes.use('/v1/shares', publicShareRoutes);
// Protected
const protectedRoutes = Router();
protectedRoutes.use(authenticate);
protectedRoutes.use(rateLimit);
protectedRoutes.use('/timestamp', timestampRoutes);
protectedRoutes.use('/verify', verifyRoutes);
protectedRoutes.use('/status', statusRoutes);
protectedRoutes.use('/webhooks', webhookRoutes);
protectedRoutes.use('/hashes', hashRoutes);
protectedRoutes.use('/receipts', receiptRoutes);
protectedRoutes.use('/objects', objectRoutes);
apiRoutes.use('/v1', protectedRoutes);

125
src/routes/objectRoutes.js Normal file
View file

@ -0,0 +1,125 @@
/**
* Encrypted LTS mock + share grants.
* @module routes/objectRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import {
putBlob,
readBlobPlaintext,
createShare,
redeemShare,
putTree,
createTreeShare,
} 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(
'/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) => {
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

@ -0,0 +1,45 @@
/**
* Mock certified retrieval receipt (JSON). Not a payment invoice.
* @module routes/receiptRoutes
*/
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();
receiptRoutes.get(
'/:jobId',
asyncHandler(async (req, res) => {
const { jobId } = req.params;
const format = String(req.query.format || 'json').toLowerCase();
const status = await getJobStatus(req.auth, jobId);
if (status.status !== 'completed') {
throw new AppError('Receipt only for completed jobs', { status: 409, code: 'NOT_COMPLETE' });
}
const retrievedAt = new Date().toISOString();
const receipt = {
type: 'verae.retrieval-receipt',
format: 'json',
jobId,
sha256: status.sha256 ?? status.metadata?.sha256,
timestamp: status.completedAt ?? status.metadata?.timestamp,
certificate: status.result,
extraSeal: {
event: 'document.retrieved',
retrievedAt,
tenantId: req.auth.tenantId,
},
};
if (format === 'pdf') {
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

@ -0,0 +1,51 @@
/**
* @module routes/statusRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import {
getJobStatus,
getBatchJobStatus,
getJobVerification,
} from '../services/timestampService.js';
export const statusRoutes = Router();
// Static/more-specific routes first
statusRoutes.post(
'/batch',
asyncHandler(async (req, res) => {
const { jobIds } = req.body ?? {};
if (!Array.isArray(jobIds) || jobIds.length === 0) {
throw new AppError('jobIds array is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const result = await getBatchJobStatus(req.auth, { jobIds });
res.json(result);
}),
);
statusRoutes.get(
'/:jobId/verification',
asyncHandler(async (req, res) => {
const result = await getJobVerification(req.auth, req.params.jobId);
res.json(result);
}),
);
statusRoutes.get(
'/:jobId',
asyncHandler(async (req, res) => {
const { jobId } = req.params;
if (!jobId) {
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,46 @@
/**
* @module routes/tenantRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import {
selfServeSignup,
provisionTenant,
listProvisionedTenants,
} from '../services/tenantService.js';
import { config } from '../config.js';
export const publicTenantRoutes = Router();
export const adminTenantRoutes = Router();
publicTenantRoutes.post(
'/signup',
asyncHandler(async (req, res) => {
const result = await selfServeSignup(req.body ?? {});
res.status(201).json(result);
}),
);
adminTenantRoutes.use((req, _res, next) => {
const secret = req.headers['x-admin-secret'];
if (secret !== config.adminSecret) {
return next(new AppError('Invalid admin secret', { status: 403, code: 'FORBIDDEN' }));
}
next();
});
adminTenantRoutes.post(
'/tenants',
asyncHandler(async (req, res) => {
const result = await provisionTenant(req.body ?? {});
res.status(201).json(result);
}),
);
adminTenantRoutes.get(
'/tenants',
asyncHandler(async (_req, res) => {
res.json({ tenants: listProvisionedTenants() });
}),
);

View file

@ -0,0 +1,61 @@
/**
* @module routes/timestampRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import {
createTimestamp,
createTimestampAndWait,
createBatchTimestamp,
} from '../services/timestampService.js';
export const timestampRoutes = Router();
timestampRoutes.post(
'/',
asyncHandler(async (req, res) => {
const { data, hashAlg, sha256, publicMetadata, privateMetadata } = req.body ?? {};
if (!data && !sha256) {
throw new AppError('data or sha256 is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const result = await createTimestamp(req.auth, {
data,
hashAlg,
sha256,
publicMetadata,
privateMetadata,
});
res.status(202).json(result);
}),
);
timestampRoutes.post(
'/wait',
asyncHandler(async (req, res) => {
const { data, hashAlg, sha256, publicMetadata, privateMetadata } = req.body ?? {};
if (!data && !sha256) {
throw new AppError('data or sha256 is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const result = await createTimestampAndWait(req.auth, {
data,
hashAlg,
sha256,
publicMetadata,
privateMetadata,
});
res.json(result);
}),
);
timestampRoutes.post(
'/batch',
asyncHandler(async (req, res) => {
const { items } = req.body ?? {};
if (!Array.isArray(items) || items.length === 0) {
throw new AppError('items array is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const result = await createBatchTimestamp(req.auth, { items });
res.status(202).json(result);
}),
);

View file

@ -0,0 +1,36 @@
/**
* @module routes/verifyRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import { verifyTimestamp, verifyBatch } from '../services/verifyService.js';
export const verifyRoutes = Router();
verifyRoutes.post(
'/',
asyncHandler(async (req, res) => {
const { certificate } = req.body ?? {};
if (!certificate) {
throw new AppError('certificate is required', { status: 400, code: 'VALIDATION_ERROR' });
}
const result = await verifyTimestamp(req.auth, { certificate });
res.json(result);
}),
);
verifyRoutes.post(
'/batch',
asyncHandler(async (req, res) => {
const { certificates } = req.body ?? {};
if (!Array.isArray(certificates) || certificates.length === 0) {
throw new AppError('certificates array is required', {
status: 400,
code: 'VALIDATION_ERROR',
});
}
const result = await verifyBatch(req.auth, { certificates });
res.json(result);
}),
);

View file

@ -0,0 +1,44 @@
/**
* @module routes/webhookRoutes
*/
import { Router } from 'express';
import { asyncHandler, AppError } from '../errors.js';
import { subscribe, unsubscribe } from '../services/webhookService.js';
import { listWebhooksForTenant } from '../store/webhooks.js';
export const webhookRoutes = Router();
webhookRoutes.post(
'/subscribe',
asyncHandler(async (req, res) => {
const { targetUrl, event = 'timestamp.completed' } = req.body ?? {};
try {
const hook = subscribe(req.auth, { targetUrl, event });
res.status(201).json(hook);
} catch (err) {
throw new AppError(err.message, { status: 400, code: 'VALIDATION_ERROR' });
}
}),
);
webhookRoutes.delete(
'/unsubscribe',
asyncHandler(async (req, res) => {
const { hookId, targetUrl } = req.body ?? req.query ?? {};
try {
const result = unsubscribe(req.auth, { hookId, targetUrl });
res.json(result);
} catch (err) {
throw new AppError(err.message, { status: 404, code: 'NOT_FOUND' });
}
}),
);
webhookRoutes.get(
'/',
asyncHandler(async (req, res) => {
const hooks = listWebhooksForTenant(req.auth.tenantId);
res.json({ webhooks: hooks });
}),
);

141
src/services/authService.js Normal file
View file

@ -0,0 +1,141 @@
/**
* @fileoverview Auth bridge: API keys / sessions Verae JWT context.
* @module services/authService
*/
import { veraeClient } from '../clients/veraeClient.js';
import { getTenantByApiKey, getTenant } from '../store/tenants.js';
import { issueSessionToken, parseSessionToken, isApiKey } from '../lib/tokens.js';
import { AppError } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('auth');
/**
* Login with Verae username/password and issue middleware session token.
*
* @param {object} params
* @param {string} params.username
* @param {string} params.password
* @param {object} [params.tenant]
* @returns {Promise<{ accessToken: string, expiresAt: string, tenant: object, user: object }>}
*/
export async function loginWithCredentials({ username, password, tenant }) {
log.debug('loginWithCredentials', { username, tenantId: tenant?.id });
const verae = await veraeClient.login({ username, password });
if (tenant?.veraeUsername && tenant.veraeUsername !== username) {
throw new AppError('Credentials do not match tenant account', {
status: 403,
code: 'FORBIDDEN',
});
}
const tenantId = tenant?.id ?? `user:${verae.user.username}`;
const accessToken = issueSessionToken({
tenantId,
veraeToken: verae.token,
expiresAt: verae.expiresAt,
});
return {
accessToken,
expiresAt: verae.expiresAt,
tenant: tenant
? { id: tenant.id, name: tenant.name, plan: tenant.plan }
: { id: tenantId, name: verae.user.username, plan: 'free' },
user: verae.user,
};
}
/**
* Resolve API key to tenant and login with stored Verae credentials.
* @param {string} apiKey
* @returns {Promise<Awaited<ReturnType<typeof loginWithCredentials>>>}
*/
export async function loginWithApiKey(apiKey) {
const tenant = getTenantByApiKey(apiKey);
if (!tenant) {
log.debug('invalid api key');
throw new AppError('Invalid API key', { status: 401, code: 'UNAUTHORIZED' });
}
if (!tenant.veraeUsername || !tenant.veraePassword) {
throw new AppError('Tenant is missing Verae credentials', {
status: 500,
code: 'TENANT_MISCONFIGURED',
});
}
log.debug('loginWithApiKey', { tenantId: tenant.id, plan: tenant.plan });
return loginWithCredentials({
username: tenant.veraeUsername,
password: tenant.veraePassword,
tenant,
});
}
/**
* Resolve bearer credential into request auth context.
*
* @param {string|null} rawToken
* @returns {Promise<{ tenantId: string, tenant: object, veraeToken: string, authMethod: string }>}
*/
export async function resolveAuthContext(rawToken) {
if (!rawToken) {
throw new AppError('Missing authorization token', { status: 401, code: 'UNAUTHORIZED' });
}
if (isApiKey(rawToken)) {
const session = await loginWithApiKey(rawToken);
const parsed = parseSessionToken(session.accessToken);
const tenant = getTenant(session.tenant.id) ?? session.tenant;
return {
tenantId: session.tenant.id,
tenant: { id: session.tenant.id, name: session.tenant.name, plan: session.tenant.plan },
veraeToken: parsed.veraeToken,
authMethod: 'api_key',
fullTenant: tenant,
};
}
const parsed = parseSessionToken(rawToken);
if (!parsed?.veraeToken) {
throw new AppError('Invalid or expired session token', { status: 401, code: 'UNAUTHORIZED' });
}
if (parsed.expiresAt && Date.parse(parsed.expiresAt) < Date.now()) {
throw new AppError('Session token expired', { status: 401, code: 'TOKEN_EXPIRED' });
}
const tenant = getTenant(parsed.tenantId);
log.debug('session auth', { tenantId: parsed.tenantId });
return {
tenantId: parsed.tenantId,
tenant: tenant
? { id: tenant.id, name: tenant.name, plan: tenant.plan }
: { id: parsed.tenantId, plan: 'free' },
veraeToken: parsed.veraeToken,
authMethod: 'session',
fullTenant: tenant,
};
}
/**
* Validate auth and optionally ping Verae /auth/validate.
* @param {string} rawToken
* @returns {Promise<object>}
*/
export async function validateSession(rawToken) {
const context = await resolveAuthContext(rawToken);
const validation = await veraeClient.validate(context.veraeToken);
return {
valid: true,
tenantId: context.tenantId,
plan: context.tenant.plan,
authMethod: context.authMethod,
user: validation,
};
}

View file

@ -0,0 +1,160 @@
/**
* @fileoverview Plan quotas and usage recording.
* @module services/entitlementService
*/
import { config } from '../config.js';
import { AppError } from '../errors.js';
import { getTenant, resolveLimits } from '../store/tenants.js';
import { getUsage, incrementUsage } from '../store/usage.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('billing');
function isUnlimited(value) {
return value === null || value === undefined;
}
function quotaExceeded({ limit, used, allowOverage }) {
if (isUnlimited(limit)) return false;
if (used < limit) return false;
return !allowOverage;
}
/**
* Ensure tenant may perform an action under plan limits.
*
* @param {string} tenantId
* @param {'timestamp'|'verify'|'batch_timestamp'} action
* @param {{ amount?: number }} [options]
* @returns {{ tenant: object, limits: object, usage: object }}
*/
export function checkEntitlement(tenantId, action, { amount = 1 } = {}) {
const tenant = getTenant(tenantId);
if (!tenant) {
throw new AppError('Unknown tenant', { status: 401, code: 'UNAUTHORIZED' });
}
const limits = resolveLimits(tenant);
const usage = getUsage(tenantId);
log.debug('checkEntitlement', {
tenantId,
action,
plan: tenant.plan,
amount,
timestampsUsed: usage.timestamps,
timestampsLimit: limits.timestamps,
});
if (action === 'batch_timestamp') {
if (!limits.batch) {
throw new AppError('Batch timestamps require a paid plan', {
status: 403,
code: 'PLAN_UPGRADE_REQUIRED',
details: { upgradeUrl: config.upgradeUrl },
});
}
if (!isUnlimited(limits.batchMaxItems) && amount > limits.batchMaxItems) {
throw new AppError(`Batch size exceeds plan limit of ${limits.batchMaxItems}`, {
status: 403,
code: 'BATCH_LIMIT_EXCEEDED',
details: { upgradeUrl: config.upgradeUrl, limit: limits.batchMaxItems },
});
}
}
if (action === 'timestamp') {
if (
quotaExceeded({
limit: limits.timestamps,
used: usage.timestamps,
allowOverage: limits.allowOverage,
})
) {
const err = new AppError('Monthly timestamp quota exceeded', {
status: 402,
code: 'QUOTA_EXCEEDED',
details: {
limit: limits.timestamps,
used: usage.timestamps,
period: 'monthly',
upgradeUrl: config.upgradeUrl,
},
});
throw err;
}
}
if (action === 'verify') {
if (
quotaExceeded({
limit: limits.verifications,
used: usage.verifications,
allowOverage: limits.allowOverage,
})
) {
throw new AppError('Monthly verification quota exceeded', {
status: 402,
code: 'QUOTA_EXCEEDED',
details: {
limit: limits.verifications,
used: usage.verifications,
period: 'monthly',
upgradeUrl: config.upgradeUrl,
},
});
}
}
return { tenant, limits, usage };
}
/**
* Record usage after a successful action.
*
* @param {string} tenantId
* @param {string} action
* @param {{ amount?: number }} [options]
* @returns {void}
*/
export function recordUsage(tenantId, action, { amount = 1 } = {}) {
const tenant = getTenant(tenantId);
if (!tenant) return;
const limits = resolveLimits(tenant);
const usage = getUsage(tenantId);
if (action === 'timestamp') {
if (
!isUnlimited(limits.timestamps) &&
usage.timestamps >= limits.timestamps &&
limits.allowOverage
) {
incrementUsage(tenantId, 'overage.timestamps', amount);
}
incrementUsage(tenantId, 'timestamps', amount);
}
if (action === 'verify') {
if (
!isUnlimited(limits.verifications) &&
usage.verifications >= limits.verifications &&
limits.allowOverage
) {
incrementUsage(tenantId, 'overage.verifications', amount);
}
incrementUsage(tenantId, 'verifications', amount);
}
if (action === 'status') {
incrementUsage(tenantId, 'statusChecks', amount);
}
if (action === 'batch_timestamp') {
incrementUsage(tenantId, 'batchTimestamps', amount);
incrementUsage(tenantId, 'timestamps', amount);
}
log.debug('recordUsage', { tenantId, action, amount });
}

View file

@ -0,0 +1,137 @@
/**
* @fileoverview Self-serve signup and admin tenant provisioning.
* @module services/tenantService
*/
import { randomUUID } from 'node:crypto';
import { createTenant, getTenant, listTenants } from '../store/tenants.js';
import { veraeClient } from '../clients/veraeClient.js';
import { AppError } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('auth');
const ALLOWED_PLANS = new Set(['free', 'starter', 'pro', 'enterprise']);
function slugify(value) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 48);
}
async function validateVeraeCredentials(username, password) {
try {
await veraeClient.login({ username, password });
return true;
} catch (err) {
throw new AppError('Invalid Verae credentials', {
status: 400,
code: 'INVALID_VERAE_CREDENTIALS',
details: { message: err.message },
});
}
}
/**
* Public free-tier signup.
* @param {{ email: string, name: string, veraeUsername: string, veraePassword: string }} params
*/
export async function selfServeSignup({ email, name, veraeUsername, veraePassword }) {
if (!email || !name || !veraeUsername || !veraePassword) {
throw new AppError('email, name, veraeUsername, and veraePassword are required', {
status: 400,
code: 'VALIDATION_ERROR',
});
}
await validateVeraeCredentials(veraeUsername, veraePassword);
const id = `tenant-${slugify(email)}-${randomUUID().slice(0, 8)}`;
const { tenant, apiKey } = createTenant({
id,
name,
plan: 'free',
veraeUsername,
veraePassword,
contract: null,
metadata: { email, audience: 'self-serve', createdVia: 'signup' },
});
log.info('self-serve signup', { tenantId: tenant.id });
return {
tenant: { id: tenant.id, name: tenant.name, plan: tenant.plan, email },
apiKey,
zapierSetup: {
authType: 'custom',
field: 'api_key',
middlewareUrl: '/zapier/v1/auth/me',
},
};
}
/**
* Admin provision (internal / enterprise).
*/
export async function provisionTenant({
id,
name,
plan,
veraeUsername,
veraePassword,
contract = null,
metadata = {},
audience = 'admin',
}) {
if (!name || !plan || !veraeUsername || !veraePassword) {
throw new AppError('name, plan, veraeUsername, and veraePassword are required', {
status: 400,
code: 'VALIDATION_ERROR',
});
}
if (!ALLOWED_PLANS.has(plan)) {
throw new AppError(`Invalid plan: ${plan}`, { status: 400, code: 'VALIDATION_ERROR' });
}
if (plan === 'enterprise' && !contract) {
throw new AppError('enterprise tenants require a contract object', {
status: 400,
code: 'VALIDATION_ERROR',
});
}
await validateVeraeCredentials(veraeUsername, veraePassword);
const tenantId = id ?? `tenant-${slugify(name)}-${randomUUID().slice(0, 8)}`;
if (getTenant(tenantId)) {
throw new AppError('Tenant already exists', { status: 409, code: 'CONFLICT' });
}
const { tenant, apiKey } = createTenant({
id: tenantId,
name,
plan,
veraeUsername,
veraePassword,
contract,
metadata: { ...metadata, audience, createdVia: 'provision' },
});
log.info('tenant provisioned', { tenantId: tenant.id, plan, audience });
return { tenant: { id: tenant.id, name: tenant.name, plan: tenant.plan }, apiKey };
}
/**
* @returns {Array<object>}
*/
export function listProvisionedTenants() {
return listTenants().map((tenant) => ({
id: tenant.id,
name: tenant.name,
plan: tenant.plan,
audience: tenant.metadata?.audience ?? 'unknown',
createdAt: tenant.createdAt,
}));
}

View file

@ -0,0 +1,169 @@
/**
* @fileoverview Timestamp create/status operations with billing + job enqueue.
* @module services/timestampService
*/
import { config } from '../config.js';
import { veraeClient } from '../clients/veraeClient.js';
import { enqueueJob } from '../store/jobWatchers.js';
import { checkEntitlement, recordUsage } from './entitlementService.js';
import { createDebugger } from '../debug/logger.js';
import { getTraceId } from '../debug/trace-context.js';
const log = createDebugger('jobs');
/**
* Enqueue async job watch via NATS or in-process store.
* @param {object} ctx
* @param {string} jobId
*/
async function enqueueWatchForJob(ctx, jobId) {
const traceId = getTraceId() ?? undefined;
if (config.natsEnabled) {
const { enqueueWatch } = await import('../nats/publishers.js');
await enqueueWatch({
tenantId: ctx.tenantId,
jobId,
// Prefer re-login in worker; include token for MVP simplicity when mock
veraeToken: ctx.veraeToken,
maxAttempts: config.jobPollMaxAttempts,
intervalMs: config.jobPollIntervalMs,
traceId,
});
log.debug('watch enqueued on NATS', { jobId, tenantId: ctx.tenantId });
return;
}
enqueueJob({
tenantId: ctx.tenantId,
jobId,
veraeToken: ctx.veraeToken,
traceId,
});
}
/**
* @param {object} ctx - Auth context with tenantId, veraeToken
* @param {{ data?: string, hashAlg?: string, sha256?: string, publicMetadata?: object, privateMetadata?: object }} body
* @returns {Promise<{ jobId: string, sha256?: string, existing?: boolean }>}
*/
export async function createTimestamp(ctx, body) {
checkEntitlement(ctx.tenantId, 'timestamp');
const result = await veraeClient.createTimestamp(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'timestamp');
await enqueueWatchForJob(ctx, result.jobId);
log.debug('timestamp created', { jobId: result.jobId, tenantId: ctx.tenantId });
return result;
}
/**
* @param {object} ctx
* @param {{ data: string, hashAlg?: string }} body
* @returns {Promise<object>} StatusResponse
*/
export async function createTimestampAndWait(ctx, body) {
const created = await createTimestamp(ctx, body);
if (config.natsEnabled) {
const { waitForJobEvent } = await import('../nats/wait.js');
const event = await waitForJobEvent(created.jobId, config.waitTimeoutMs);
if (event?.status) {
recordUsage(ctx.tenantId, 'status');
log.debug('wait via NATS event', {
jobId: created.jobId,
event: event.event,
traceId: event.traceId,
});
return event.status;
}
const status = await veraeClient.getStatus(ctx.veraeToken, created.jobId);
recordUsage(ctx.tenantId, 'status');
if (status.status === 'pending') {
return { id: created.jobId, jobId: created.jobId, status: 'pending' };
}
return status;
}
try {
const status = await veraeClient.waitForJob(ctx.veraeToken, created.jobId, {
maxAttempts: config.jobPollMaxAttempts,
intervalMs: config.jobPollIntervalMs,
});
recordUsage(ctx.tenantId, 'status');
return status;
} catch {
const status = await veraeClient.getStatus(ctx.veraeToken, created.jobId);
recordUsage(ctx.tenantId, 'status');
return { id: created.jobId, jobId: created.jobId, status: status.status ?? 'pending' };
}
}
/**
* @param {object} ctx
* @param {{ items: Array<{ data: string, hashAlg?: string }> }} body
*/
export async function createBatchTimestamp(ctx, body) {
const itemCount = body.items?.length ?? 0;
checkEntitlement(ctx.tenantId, 'batch_timestamp', { amount: itemCount });
const result = await veraeClient.createBatchTimestamp(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'batch_timestamp', { amount: itemCount });
for (const jobId of result.jobIds ?? []) {
await enqueueWatchForJob(ctx, jobId);
}
return result;
}
/**
* @param {object} ctx
* @param {string} jobId
*/
export async function getJobStatus(ctx, jobId) {
const status = await veraeClient.getStatus(ctx.veraeToken, jobId);
recordUsage(ctx.tenantId, 'status');
return status;
}
/**
* @param {object} ctx
* @param {{ jobIds: string[] }} body
*/
export async function getBatchJobStatus(ctx, body) {
const status = await veraeClient.getBatchStatus(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'status', { amount: body.jobIds?.length ?? 1 });
return status;
}
/**
* @param {object} ctx
* @param {string} jobId
*/
export async function getJobVerification(ctx, jobId) {
const status = await veraeClient.getJobVerification(ctx.veraeToken, jobId);
recordUsage(ctx.tenantId, 'status');
return status;
}
/**
* @param {object} ctx
* @param {string} sha256
* @param {{ includeAttached?: boolean, includeTree?: boolean }} [opts]
*/
export async function lookupHash(ctx, sha256, opts = {}) {
const result = await veraeClient.lookupHash(ctx.veraeToken, sha256);
recordUsage(ctx.tenantId, 'status');
if (!opts.includeAttached && !opts.includeTree) return result;
return {
...result,
includeAttached: Boolean(opts.includeAttached),
includeTree: Boolean(opts.includeTree),
note:
opts.includeTree && !result?.exists
? 'Chain miss: middleware should broadcast verae.archive.query kinds=["tree"] (wired in simulator; live aggregator hook TBD).'
: undefined,
};
}

View file

@ -0,0 +1,34 @@
/**
* @fileoverview Certificate verification with usage metering.
* @module services/verifyService
*/
import { veraeClient } from '../clients/veraeClient.js';
import { checkEntitlement, recordUsage } from './entitlementService.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('jobs');
/**
* @param {object} ctx
* @param {{ certificate: string }} body
*/
export async function verifyTimestamp(ctx, body) {
checkEntitlement(ctx.tenantId, 'verify');
const result = await veraeClient.verify(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'verify');
log.debug('verify result', { tenantId: ctx.tenantId, valid: result.valid });
return result;
}
/**
* @param {object} ctx
* @param {{ certificates: string[] }} body
*/
export async function verifyBatch(ctx, body) {
const amount = body.certificates?.length ?? 1;
checkEntitlement(ctx.tenantId, 'verify', { amount });
const result = await veraeClient.verifyBatch(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'verify', { amount });
return result;
}

View file

@ -0,0 +1,70 @@
/**
* @fileoverview REST Hook subscribe/unsubscribe and HTTP delivery.
* @module services/webhookService
*/
import { createWebhook, deleteWebhook } from '../store/webhooks.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('webhooks');
const ALLOWED_EVENTS = new Set(['timestamp.completed', 'timestamp.failed']);
/**
* @param {object} ctx - auth context
* @param {{ targetUrl: string, event: string }} params
*/
export function subscribe(ctx, { targetUrl, event }) {
if (!targetUrl) {
throw new Error('targetUrl is required');
}
if (!ALLOWED_EVENTS.has(event)) {
throw new Error(`Unsupported event: ${event}`);
}
return createWebhook({
tenantId: ctx.tenantId,
targetUrl,
event,
});
}
/**
* @param {object} ctx
* @param {{ hookId?: string, targetUrl?: string }} params
*/
export function unsubscribe(ctx, { hookId, targetUrl }) {
const removed = deleteWebhook({
tenantId: ctx.tenantId,
hookId,
targetUrl,
});
if (!removed) {
throw new Error('Webhook subscription not found');
}
return { removed: true };
}
/**
* POST payload to Zapier target URL.
* @param {string} targetUrl
* @param {object} payload
* @returns {Promise<{ ok: boolean, status: number }>}
*/
export async function deliverWebhook(targetUrl, payload) {
log.debug('deliverWebhook', { targetUrl, event: payload?.event });
const response = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Verae-Zapier-Middleware/1.0',
},
body: JSON.stringify(payload),
});
log.debug('deliverWebhook result', { status: response.status, ok: response.ok });
return { ok: response.ok, status: response.status };
}

208
src/store/blobs.js Normal file
View file

@ -0,0 +1,208 @@
/**
* 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 ??= {};
store.trees ??= {};
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,
kind: 'file',
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;
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,
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.',
};
}
/**
* @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 };
}

117
src/store/db.js Normal file
View file

@ -0,0 +1,117 @@
/**
* @fileoverview JSON file-backed in-memory store (MVP persistence).
* @module store/db
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { dirname } from 'node:path';
import { config } from '../config.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('app');
/**
* @typedef {Object} StoreShape
* @property {Record<string, object>} tenants
* @property {Record<string, string>} apiKeys - apiKey tenantId
* @property {Record<string, object>} usage - tenantId counters
* @property {object[]} webhooks
* @property {object[]} jobWatchers
*/
/** @type {StoreShape|null} */
let store = null;
/**
* Create an empty store document.
* @returns {StoreShape}
*/
export function emptyStore() {
return {
tenants: {},
apiKeys: {},
usage: {},
webhooks: [],
jobWatchers: [],
blobs: {},
shares: {},
trees: {},
};
}
/**
* Load store from disk into memory (or create empty if missing).
* @param {string} [path=config.storePath]
* @returns {StoreShape}
*/
export function loadStore(path = config.storePath) {
if (store) return store;
if (existsSync(path)) {
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON.parse(raw);
store = {
...emptyStore(),
...parsed,
tenants: parsed.tenants ?? {},
apiKeys: parsed.apiKeys ?? {},
usage: parsed.usage ?? {},
webhooks: Array.isArray(parsed.webhooks) ? parsed.webhooks : [],
jobWatchers: Array.isArray(parsed.jobWatchers) ? parsed.jobWatchers : [],
};
log.debug('store loaded', { path, tenants: Object.keys(store.tenants).length });
} catch (err) {
log.error('store load failed, using empty', { path, error: err.message });
store = emptyStore();
}
} else {
store = emptyStore();
log.debug('store initialized empty', { path });
}
return store;
}
/**
* Access the in-memory store (loads if needed).
* @returns {StoreShape}
*/
export function getStore() {
if (!store) return loadStore();
return store;
}
/**
* Replace the in-memory store (tests only).
* @param {StoreShape|null} next
* @returns {void}
*/
export function setStoreForTests(next) {
store = next;
}
/**
* Persist the in-memory store to disk.
* @param {string} [path=config.storePath]
* @returns {void}
*/
export function persist(path = config.storePath) {
const data = getStore();
const dir = dirname(path);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8');
log.debug('store persisted', { path });
}
/**
* Force reload from disk (drops memory).
* @param {string} [path=config.storePath]
* @returns {StoreShape}
*/
export function reloadStore(path = config.storePath) {
store = null;
return loadStore(path);
}

89
src/store/jobWatchers.js Normal file
View file

@ -0,0 +1,89 @@
/**
* @fileoverview In-process job watch queue (NATS_ENABLED=false path).
* @module store/jobWatchers
*/
import { randomUUID } from 'node:crypto';
import { getStore, persist } from './db.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('jobs');
/**
* @typedef {Object} JobWatcher
* @property {string} id
* @property {string} tenantId
* @property {string} jobId
* @property {string} veraeToken
* @property {string} status
* @property {number} attempts
* @property {string} createdAt
* @property {string} updatedAt
* @property {string} [traceId]
*/
/**
* @param {object} params
* @param {string} params.tenantId
* @param {string} params.jobId
* @param {string} params.veraeToken
* @param {string} [params.traceId]
* @returns {JobWatcher}
*/
export function enqueueJob({ tenantId, jobId, veraeToken, traceId }) {
/** @type {JobWatcher} */
const watcher = {
id: randomUUID(),
tenantId,
jobId,
veraeToken,
status: 'pending',
attempts: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
traceId,
};
const store = getStore();
store.jobWatchers.push(watcher);
persist();
log.debug('job enqueued', { watcherId: watcher.id, jobId, tenantId });
return watcher;
}
/**
* @returns {JobWatcher[]}
*/
export function listPendingJobs() {
return getStore().jobWatchers.filter((job) => job.status === 'pending');
}
/**
* @param {string} id
* @param {Partial<JobWatcher>} patch
* @returns {JobWatcher|null}
*/
export function updateJobWatcher(id, patch) {
const store = getStore();
const index = store.jobWatchers.findIndex((job) => job.id === id);
if (index === -1) return null;
store.jobWatchers[index] = {
...store.jobWatchers[index],
...patch,
updatedAt: new Date().toISOString(),
};
persist();
return store.jobWatchers[index];
}
/**
* @param {string} id
* @returns {void}
*/
export function removeJobWatcher(id) {
const store = getStore();
store.jobWatchers = store.jobWatchers.filter((job) => job.id !== id);
persist();
log.debug('job watcher removed', { id });
}

137
src/store/tenants.js Normal file
View file

@ -0,0 +1,137 @@
/**
* @fileoverview Tenant and API key persistence.
* @module store/tenants
*/
import { getStore, persist } from './db.js';
import { generateApiKey } from '../lib/tokens.js';
import { PLAN_LIMITS } from '../config.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('billing');
/**
* @typedef {Object} Tenant
* @property {string} id
* @property {string} name
* @property {string} plan
* @property {string} veraeUsername
* @property {string} veraePassword
* @property {object|null} contract
* @property {object} [metadata]
* @property {string} createdAt
*/
/**
* @param {string} tenantId
* @returns {Tenant|null}
*/
export function getTenant(tenantId) {
return getStore().tenants[tenantId] ?? null;
}
/**
* @param {string} apiKey
* @returns {Tenant|null}
*/
export function getTenantByApiKey(apiKey) {
const store = getStore();
const tenantId = store.apiKeys[apiKey];
return tenantId ? store.tenants[tenantId] ?? null : null;
}
/**
* @returns {Tenant[]}
*/
export function listTenants() {
return Object.values(getStore().tenants);
}
/**
* @param {Tenant} tenant
* @returns {Tenant}
*/
export function upsertTenant(tenant) {
const store = getStore();
store.tenants[tenant.id] = tenant;
persist();
log.debug('tenant upserted', { tenantId: tenant.id, plan: tenant.plan });
return tenant;
}
/**
* Create a tenant and bind a new API key.
*
* @param {object} params
* @param {string} params.id
* @param {string} params.name
* @param {string} [params.plan='free']
* @param {string} params.veraeUsername
* @param {string} params.veraePassword
* @param {object|null} [params.contract=null]
* @param {string} [params.apiKey]
* @param {object} [params.metadata]
* @returns {{ tenant: Tenant, apiKey: string }}
*/
export function createTenant({
id,
name,
plan = 'free',
veraeUsername,
veraePassword,
contract = null,
apiKey = generateApiKey(),
metadata = {},
}) {
const store = getStore();
/** @type {Tenant} */
const tenant = {
id,
name,
plan,
veraeUsername,
veraePassword,
contract,
metadata,
createdAt: new Date().toISOString(),
};
store.tenants[id] = tenant;
store.apiKeys[apiKey] = id;
persist();
log.info('tenant created', { tenantId: id, plan, audience: metadata.audience });
return { tenant, apiKey };
}
/**
* Resolve effective limits for a tenant (plan defaults or enterprise contract).
*
* @param {Tenant} tenant
* @returns {{
* timestamps: number|null,
* verifications: number|null,
* batch: boolean,
* batchMaxItems: number|null,
* requestsPerMinute: number,
* allowOverage: boolean,
* overageRates: object
* }}
*/
export function resolveLimits(tenant) {
if (tenant.plan === 'enterprise' && tenant.contract) {
return {
timestamps: tenant.contract.includedTimestamps ?? null,
verifications: tenant.contract.includedVerifications ?? null,
batch: tenant.contract.batch !== false,
batchMaxItems: tenant.contract.batchMaxItems ?? null,
requestsPerMinute:
tenant.contract.requestsPerMinute ?? PLAN_LIMITS.enterprise.requestsPerMinute,
allowOverage: tenant.contract.allowOverage ?? false,
overageRates: tenant.contract.overageRates ?? {},
};
}
const base = PLAN_LIMITS[tenant.plan] ?? PLAN_LIMITS.free;
return { ...base, allowOverage: false, overageRates: {} };
}

102
src/store/usage.js Normal file
View file

@ -0,0 +1,102 @@
/**
* @fileoverview Per-tenant usage metering counters.
* @module store/usage
*/
import { getStore, persist } from './db.js';
import { getTenant, resolveLimits } from './tenants.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('billing');
/**
* @returns {string} YYYY-MM period key
*/
function currentPeriod() {
const d = new Date();
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
}
/**
* @param {string} tenantId
* @returns {{ period: string, timestamps: number, verifications: number, statusChecks: number, batchTimestamps: number, overage: object }}
*/
function emptyUsage(tenantId) {
return {
tenantId,
period: currentPeriod(),
timestamps: 0,
verifications: 0,
statusChecks: 0,
batchTimestamps: 0,
overage: { timestamps: 0, verifications: 0 },
};
}
/**
* Get usage for tenant, rolling period if month changed.
* @param {string} tenantId
* @returns {ReturnType<typeof emptyUsage>}
*/
export function getUsage(tenantId) {
const store = getStore();
let usage = store.usage[tenantId];
const period = currentPeriod();
if (!usage || usage.period !== period) {
usage = emptyUsage(tenantId);
store.usage[tenantId] = usage;
}
return usage;
}
/**
* Public summary for /auth/me.
* @param {string} tenantId
* @returns {object}
*/
export function getUsageSummary(tenantId) {
const usage = getUsage(tenantId);
const tenant = getTenant(tenantId);
const limits = tenant ? resolveLimits(tenant) : null;
return {
period: usage.period,
timestamps: usage.timestamps,
verifications: usage.verifications,
statusChecks: usage.statusChecks,
batchTimestamps: usage.batchTimestamps,
limits: limits
? {
timestamps: limits.timestamps,
verifications: limits.verifications,
batch: limits.batch,
batchMaxItems: limits.batchMaxItems,
}
: null,
};
}
/**
* Increment a usage metric.
*
* @param {string} tenantId
* @param {string} metric - e.g. `timestamps`, `verifications`, `statusChecks`, `batchTimestamps`, `overage.timestamps`
* @param {number} [amount=1]
* @returns {void}
*/
export function incrementUsage(tenantId, metric, amount = 1) {
const usage = getUsage(tenantId);
if (metric.startsWith('overage.')) {
const key = metric.slice('overage.'.length);
usage.overage[key] = (usage.overage[key] ?? 0) + amount;
} else {
usage[metric] = (usage[metric] ?? 0) + amount;
}
getStore().usage[tenantId] = usage;
persist();
log.debug('usage incremented', { tenantId, metric, amount, value: usage[metric] ?? usage.overage });
}

91
src/store/webhooks.js Normal file
View file

@ -0,0 +1,91 @@
/**
* @fileoverview REST Hook subscription storage.
* @module store/webhooks
*/
import { randomUUID } from 'node:crypto';
import { getStore, persist } from './db.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('webhooks');
/**
* @typedef {Object} Webhook
* @property {string} id
* @property {string} tenantId
* @property {string} targetUrl
* @property {string} event
* @property {string} createdAt
*/
/**
* @param {object} params
* @param {string} params.tenantId
* @param {string} params.targetUrl
* @param {string} params.event
* @returns {Webhook}
*/
export function createWebhook({ tenantId, targetUrl, event }) {
/** @type {Webhook} */
const hook = {
id: randomUUID(),
tenantId,
targetUrl,
event,
createdAt: new Date().toISOString(),
};
const store = getStore();
store.webhooks.push(hook);
persist();
log.info('webhook created', { hookId: hook.id, tenantId, event });
return hook;
}
/**
* Delete a webhook for a tenant by id and/or targetUrl.
*
* @param {object} params
* @param {string} params.tenantId
* @param {string} [params.hookId]
* @param {string} [params.targetUrl]
* @returns {boolean} True if at least one webhook was removed.
*/
export function deleteWebhook({ tenantId, hookId, targetUrl }) {
if (!hookId && !targetUrl) return false;
const store = getStore();
const before = store.webhooks.length;
store.webhooks = store.webhooks.filter((hook) => {
if (hook.tenantId !== tenantId) return true;
if (hookId && hook.id === hookId) return false;
if (!hookId && targetUrl && hook.targetUrl === targetUrl) return false;
return true;
});
const removedCount = before - store.webhooks.length;
if (removedCount > 0) {
persist();
log.info('webhook deleted', { tenantId, hookId, removedCount });
return true;
}
return false;
}
/**
* @param {string} tenantId
* @param {string} event
* @returns {Webhook[]}
*/
export function getActiveWebhooks(tenantId, event) {
return getStore().webhooks.filter((h) => h.tenantId === tenantId && h.event === event);
}
/**
* @param {string} tenantId
* @returns {Webhook[]}
*/
export function listWebhooksForTenant(tenantId) {
return getStore().webhooks.filter((h) => h.tenantId === tenantId);
}

View file

@ -0,0 +1,108 @@
/**
* @fileoverview In-process job poller when NATS_ENABLED=false.
* @module workers/inProcessJobPoller
*/
import { config } from '../config.js';
import { veraeClient } from '../clients/veraeClient.js';
import {
listPendingJobs,
updateJobWatcher,
removeJobWatcher,
} from '../store/jobWatchers.js';
import { getActiveWebhooks } from '../store/webhooks.js';
import { deliverWebhook } from '../services/webhookService.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('jobs');
let timer = null;
let running = false;
/**
* @param {import('../store/jobWatchers.js').JobWatcher} job
*/
async function processJob(job) {
const attempts = job.attempts + 1;
updateJobWatcher(job.id, { attempts });
if (attempts > config.jobPollMaxAttempts) {
updateJobWatcher(job.id, { status: 'timeout' });
removeJobWatcher(job.id);
log.warn('job timeout', { jobId: job.jobId });
return;
}
let status;
try {
status = await veraeClient.getStatus(job.veraeToken, job.jobId);
} catch (err) {
log.debug('poll error', { jobId: job.jobId, error: err.message });
return;
}
if (status.status === 'pending') {
return;
}
const event = status.status === 'completed' ? 'timestamp.completed' : 'timestamp.failed';
const hooks = getActiveWebhooks(job.tenantId, event);
for (const hook of hooks) {
try {
await deliverWebhook(hook.targetUrl, {
event,
jobId: job.jobId,
tenantId: job.tenantId,
status,
});
} catch (err) {
log.error('webhook deliver failed', { hookId: hook.id, error: err.message });
}
}
updateJobWatcher(job.id, { status: status.status });
removeJobWatcher(job.id);
log.debug('job terminal', { jobId: job.jobId, status: status.status, hooks: hooks.length });
}
async function tick() {
if (running) return;
running = true;
try {
const jobs = listPendingJobs();
await Promise.all(jobs.map((job) => processJob(job)));
} finally {
running = false;
}
}
/**
* Start interval poller (no-op if already started or NATS enabled).
* @returns {void}
*/
export function startInProcessJobPoller() {
if (config.natsEnabled) {
log.info('in-process poller skipped (NATS_ENABLED=true)');
return;
}
if (timer) return;
const interval = config.jobPollIntervalMs;
timer = setInterval(() => {
tick().catch((err) => log.error('poller tick failed', { error: err.message }));
}, interval);
log.info('in-process job poller started', { intervalMs: interval });
}
/**
* Stop interval poller.
* @returns {void}
*/
export function stopInProcessJobPoller() {
if (!timer) return;
clearInterval(timer);
timer = null;
log.info('in-process job poller stopped');
}

View file

@ -0,0 +1,158 @@
/**
* @fileoverview JetStream consumer that polls Verae job status.
* @module workers/jobPollerWorker
*/
import { createDebugger } from '../debug/logger.js';
import { config } from '../config.js';
import { SUBJECTS, CONSUMERS, STREAMS } from '../nats/subjects.js';
import { connectNats, ensureStreams } from '../nats/connection.js';
import { publishJobEvent } from '../nats/publishers.js';
import { veraeClient } from '../clients/veraeClient.js';
import { getTenant } from '../store/tenants.js';
import { withTrace } from '../debug/trace.js';
const log = createDebugger('jobs');
let running = false;
/** @type {AbortController|null} */
let abort = null;
/**
* Resolve a Verae token for polling (re-login via tenant if needed).
* @param {object} msg
* @returns {Promise<string>}
*/
async function resolveVeraeToken(msg) {
if (msg.veraeToken) return msg.veraeToken;
const tenant = getTenant(msg.tenantId);
if (!tenant?.veraeUsername) {
throw new Error(`Cannot resolve token for tenant ${msg.tenantId}`);
}
const login = await veraeClient.login({
username: tenant.veraeUsername,
password: tenant.veraePassword,
});
return login.token;
}
/**
* Process one watch message.
* @param {object} data
* @param {{ ack: () => Promise<void>, nak: (delay?: number) => Promise<void> }} ctrl
*/
async function handleWatch(data, ctrl) {
await withTrace({ traceId: data.traceId, span: 'job-poll' }, async () => {
const attempt = (data.attempt ?? 0) + 1;
const maxAttempts = data.maxAttempts ?? config.jobPollMaxAttempts;
if (attempt > maxAttempts) {
await publishJobEvent({
event: 'timestamp.timeout',
tenantId: data.tenantId,
jobId: data.jobId,
status: { id: data.jobId, status: 'timeout' },
traceId: data.traceId,
});
await ctrl.ack();
return;
}
const token = await resolveVeraeToken(data);
let status;
try {
status = await veraeClient.getStatus(token, data.jobId);
} catch (err) {
log.debug('poll error, nak', { jobId: data.jobId, error: err.message });
await ctrl.nak(config.jobPollIntervalMs);
return;
}
log.debug('poll status', { jobId: data.jobId, status: status.status, attempt });
if (status.status === 'pending') {
await ctrl.nak(config.jobPollIntervalMs);
return;
}
const event =
status.status === 'completed' ? 'timestamp.completed' : 'timestamp.failed';
await publishJobEvent({
event,
tenantId: data.tenantId,
jobId: data.jobId,
status,
traceId: data.traceId,
});
await ctrl.ack();
});
}
/**
* Start the durable job-poller worker loop.
* @returns {Promise<{ stop: () => Promise<void> }>}
*/
export async function startJobPollerWorker() {
if (running) {
return { stop: async () => stopJobPollerWorker() };
}
const { nc, js, jsm } = await connectNats();
await ensureStreams(jsm);
// Ensure durable consumer (workqueue-style via filter + durable name)
try {
await jsm.consumers.add(STREAMS.ZAPIER_JOBS, {
durable_name: CONSUMERS.JOB_POLLER,
ack_policy: 'explicit',
filter_subject: SUBJECTS.JOBS_WATCH,
max_deliver: config.jobPollMaxAttempts + 5,
ack_wait: 30_000_000_000, // 30s ns
});
} catch (err) {
// already exists
log.debug('consumer may exist', { error: err.message });
}
const consumer = await js.consumers.get(STREAMS.ZAPIER_JOBS, CONSUMERS.JOB_POLLER);
abort = new AbortController();
running = true;
log.info('job poller worker started', { consumer: CONSUMERS.JOB_POLLER });
(async () => {
const messages = await consumer.consume({ max_messages: 10 });
for await (const msg of messages) {
if (abort?.signal.aborted) break;
try {
const data = JSON.parse(msg.string());
await handleWatch(data, {
ack: () => msg.ack(),
nak: (delayMs = 1000) => msg.nak(delayMs),
});
} catch (err) {
log.error('job poller handle failed', { error: err.message });
try {
msg.nak(1000);
} catch {
/* ignore */
}
}
}
})().catch((err) => log.error('job poller loop failed', { error: err.message }));
return {
stop: async () => stopJobPollerWorker(),
};
}
/**
* @returns {Promise<void>}
*/
export async function stopJobPollerWorker() {
abort?.abort();
abort = null;
running = false;
log.info('job poller worker stopped');
}

View file

@ -0,0 +1,138 @@
/**
* @fileoverview JetStream consumer that POSTs Zapier REST Hook payloads.
* @module workers/webhookWorker
*/
import { createDebugger } from '../debug/logger.js';
import { SUBJECTS, CONSUMERS, STREAMS } from '../nats/subjects.js';
import { connectNats, ensureStreams } from '../nats/connection.js';
import { deliverWebhook } from '../services/webhookService.js';
import { getActiveWebhooks } from '../store/webhooks.js';
import { withTrace } from '../debug/trace.js';
const log = createDebugger('webhooks');
let running = false;
/** @type {AbortController|null} */
let abort = null;
/**
* Route job events per-hook deliver messages (inline or via re-publish).
* Also handles direct deliver subjects.
*
* @param {object} data
* @param {{ ack: () => Promise<void>, nak: (d?: number) => Promise<void> }} ctrl
*/
async function handleDeliver(data, ctrl) {
await withTrace({ traceId: data.traceId, span: 'webhook-deliver' }, async () => {
// Event router path: expand tenant hooks
if (data.event && data.jobId && !data.targetUrl) {
const hooks = getActiveWebhooks(data.tenantId, data.event);
for (const hook of hooks) {
await deliverWebhook(hook.targetUrl, {
event: data.event,
jobId: data.jobId,
tenantId: data.tenantId,
status: data.status,
});
}
await ctrl.ack();
return;
}
if (!data.targetUrl) {
log.warn('deliver missing targetUrl', { dataKeys: Object.keys(data) });
await ctrl.ack();
return;
}
const result = await deliverWebhook(data.targetUrl, data.payload ?? data);
if (result.ok) {
await ctrl.ack();
} else {
log.debug('deliver non-2xx, nak', { status: result.status });
await ctrl.nak(2000);
}
});
}
/**
* Start webhook delivery worker (consumes WEBHOOKS stream + optional events).
* @returns {Promise<{ stop: () => Promise<void> }>}
*/
export async function startWebhookWorker() {
if (running) {
return { stop: async () => stopWebhookWorker() };
}
const { js, jsm } = await connectNats();
await ensureStreams(jsm);
// Events consumer → deliver
try {
await jsm.consumers.add(STREAMS.ZAPIER_EVENTS, {
durable_name: CONSUMERS.EVENT_WEBHOOK_ROUTER,
ack_policy: 'explicit',
filter_subject: SUBJECTS.JOBS_EVENTS,
max_deliver: 10,
});
} catch (err) {
log.debug('events consumer may exist', { error: err.message });
}
try {
await jsm.consumers.add(STREAMS.ZAPIER_WEBHOOKS, {
durable_name: CONSUMERS.WEBHOOK_DELIVER,
ack_policy: 'explicit',
filter_subject: SUBJECTS.WEBHOOKS_DELIVER,
max_deliver: 10,
});
} catch (err) {
log.debug('webhook consumer may exist', { error: err.message });
}
abort = new AbortController();
running = true;
log.info('webhook worker started');
const runConsumer = async (stream, durable) => {
const consumer = await js.consumers.get(stream, durable);
const messages = await consumer.consume({ max_messages: 10 });
for await (const msg of messages) {
if (abort?.signal.aborted) break;
try {
const data = JSON.parse(msg.string());
await handleDeliver(data, {
ack: () => msg.ack(),
nak: (d = 1000) => msg.nak(d),
});
} catch (err) {
log.error('webhook handle failed', { error: err.message });
try {
msg.nak(1000);
} catch {
/* ignore */
}
}
}
};
runConsumer(STREAMS.ZAPIER_EVENTS, CONSUMERS.EVENT_WEBHOOK_ROUTER).catch((err) =>
log.error('events consumer failed', { error: err.message }),
);
runConsumer(STREAMS.ZAPIER_WEBHOOKS, CONSUMERS.WEBHOOK_DELIVER).catch((err) =>
log.error('webhooks consumer failed', { error: err.message }),
);
return { stop: async () => stopWebhookWorker() };
}
/**
* @returns {Promise<void>}
*/
export async function stopWebhookWorker() {
abort?.abort();
abort = null;
running = false;
log.info('webhook worker stopped');
}