Milestone 3: NATS wait (gate 9), hash lookup, zappier timestamp, NS1 tunnel

Wait-via-NATS returns completed or pending+jobId (GATE 9). Mock SHA256
idempotent register + GET /hashes/{sha256}. Zappier commercial edge has
POST /v1/timestamp and hash-lookup. GATE 12 smoke (signup + wait) passes.

NS1 NATS is 127.0.0.1:4222 on 70.88.205.138; SSH tunnel :14222. Local
nats-server -js used for isolated tests. Activate app adds Echo Text.

Learned: JetStream on NS1 is loopback-only; do not bind 4222 public.
This commit is contained in:
George Lambert 2026-09-09 02:54:02 -04:00
parent 10c663cc0c
commit 51ae79b75f
75 changed files with 1114 additions and 123 deletions

View file

@ -0,0 +1,29 @@
/**
* Echo returns the input string. Second activate-now action (no backend).
* @module creates/echo
*/
const perform = async (_z, bundle) => {
const text = String(bundle.inputData.text ?? '');
return { text, length: text.length, mode: 'local' };
};
module.exports = {
key: 'echo',
noun: 'Echo',
display: {
label: 'Echo Text',
description: 'Returns the text you send. No API required.',
},
operation: {
cleanInputData: false,
inputFields: [{ key: 'text', label: 'Text', type: 'string', required: true }],
perform,
sample: { text: 'hello', length: 5, mode: 'local' },
outputFields: [
{ key: 'text', type: 'string' },
{ key: 'length', type: 'integer' },
{ key: 'mode', type: 'string' },
],
},
};

View file

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

View file

@ -13,6 +13,7 @@ describe('verae-activate app', () => {
it('exports Add Numbers create and custom auth', () => {
assert.equal(authentication.type, 'custom');
assert.ok(App.creates.add_numbers);
assert.ok(App.creates.echo);
assert.equal(addNumbers.key, 'add_numbers');
assert.ok(addNumbers.operation.inputFields.some((f) => f.key === 'number1'));
assert.ok(addNumbers.operation.inputFields.some((f) => f.key === 'number2'));
@ -63,6 +64,13 @@ describe('verae-activate app', () => {
assert.equal(result.sum, 5);
});
it('echo returns text length', async () => {
const echo = require('../creates/echo');
const out = await echo.operation.perform({}, { inputData: { text: 'ab' }, authData: {} });
assert.equal(out.length, 2);
assert.equal(out.mode, 'local');
});
it('auth test is local when fields empty', async () => {
const out = await authentication.test({ request: async () => ({ data: {} }) }, { authData: {} });
assert.equal(out.ok, true);

View file

@ -7,8 +7,13 @@ VERAE_API_BASE_URL=https://api.veraetime.net
MOCK_VERAE=false
# NATS (Phase 7+)
# NS1 JetStream is 127.0.0.1:4222 on 70.88.205.138 (not public).
# Tunnel: ssh -fN -L 14222:127.0.0.1:4222 marchon@70.88.205.138
# then NATS_URL=nats://127.0.0.1:14222
NATS_ENABLED=false
NATS_URL=nats://127.0.0.1:4222
WAIT_TIMEOUT_MS=25000
MOCK_JOB_COMPLETE_MS=150
# Security
TOKEN_SECRET=change-me-in-production

View file

@ -17,7 +17,9 @@
"test:gate6": "MOCK_VERAE=true NATS_ENABLED=false JOB_POLL_INTERVAL_MS=20 JOB_POLL_MAX_ATTEMPTS=50 node --test test/integration/http-api.test.js",
"test:gate7": "MOCK_VERAE=true NATS_ENABLED=true NATS_FORCE_CONNECT=1 NATS_URL=nats://127.0.0.1:4222 node --test test/integration/nats.test.js",
"test:gate8": "MOCK_VERAE=true NATS_ENABLED=true NATS_FORCE_CONNECT=1 NATS_URL=nats://127.0.0.1:4222 JOB_POLL_INTERVAL_MS=50 node --test test/integration/nats-workers.test.js",
"test:gate10": "MOCK_VERAE=true NATS_ENABLED=false node --test test/integration/tenants.test.js"
"test:gate9": "MOCK_VERAE=true NATS_ENABLED=true NATS_FORCE_CONNECT=1 NATS_URL=nats://127.0.0.1:4222 JOB_POLL_INTERVAL_MS=40 WAIT_TIMEOUT_MS=8000 node --test test/integration/wait-nats.test.js",
"test:gate10": "MOCK_VERAE=true NATS_ENABLED=false node --test test/integration/tenants.test.js",
"test:hash": "MOCK_VERAE=true NATS_ENABLED=false node --test test/integration/hash.test.js"
},
"engines": {
"node": ">=22.0.0"

View file

@ -3,7 +3,7 @@
* @module clients/veraeClient
*/
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { config } from '../config.js';
import { AppError } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
@ -11,6 +11,16 @@ 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
@ -50,19 +60,36 @@ async function mockValidate(token) {
};
}
async function mockCreateTimestamp({ data, hashAlg }) {
if (!data) {
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: 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;
@ -73,10 +100,14 @@ async function mockCreateTimestamp({ data, hashAlg }) {
blockIndex: 42,
timestamp: job.completedAt,
certificate: job.result,
sha256: key,
publicMetadata: job.publicMetadata,
};
}, 150);
const rec = mockHashes.get(key);
if (rec) rec.timestamp = job.completedAt;
}, delayMs);
return { jobId };
return { jobId, sha256: key, existing: false };
}
async function mockGetStatus(jobId) {
@ -91,6 +122,24 @@ async function mockGetStatus(jobId) {
completedAt: job.completedAt,
metadata: job.metadata,
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 ?? {},
};
}
@ -184,7 +233,20 @@ export const veraeClient = {
*/
async createTimestamp(token, body) {
if (config.mockVerae) return mockCreateTimestamp(body);
return request('/api/timestamp', { method: 'POST', token, 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',
});
},
/**
@ -284,4 +346,5 @@ export const veraeClient = {
*/
export function clearMockJobs() {
mockJobs.clear();
mockHashes.clear();
}

View file

@ -87,6 +87,8 @@ export const config = {
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',

View file

@ -43,6 +43,11 @@ export async function connectNats(url = config.natsUrl) {
return { nc, js, jsm };
}
/** @returns {import('nats').NatsConnection|null} */
export function getNatsConnection() {
return nc;
}
/**
* Idempotently create JetStream streams required by this middleware.
*

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 */
}
}
}

View file

@ -0,0 +1,21 @@
/**
* @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 result = await lookupHash(req.auth, sha256);
res.json(result);
}),
);

View file

@ -10,6 +10,7 @@ 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 { authenticate } from '../middleware/authenticate.js';
import { rateLimit } from '../middleware/rateLimit.js';
@ -29,5 +30,6 @@ protectedRoutes.use('/timestamp', timestampRoutes);
protectedRoutes.use('/verify', verifyRoutes);
protectedRoutes.use('/status', statusRoutes);
protectedRoutes.use('/webhooks', webhookRoutes);
protectedRoutes.use('/hashes', hashRoutes);
apiRoutes.use('/v1', protectedRoutes);

View file

@ -15,11 +15,17 @@ export const timestampRoutes = Router();
timestampRoutes.post(
'/',
asyncHandler(async (req, res) => {
const { data, hashAlg } = req.body ?? {};
if (!data) {
throw new AppError('data is required', { status: 400, code: 'VALIDATION_ERROR' });
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 });
const result = await createTimestamp(req.auth, {
data,
hashAlg,
sha256,
publicMetadata,
privateMetadata,
});
res.status(202).json(result);
}),
);
@ -27,11 +33,17 @@ timestampRoutes.post(
timestampRoutes.post(
'/wait',
asyncHandler(async (req, res) => {
const { data, hashAlg } = req.body ?? {};
if (!data) {
throw new AppError('data is required', { status: 400, code: 'VALIDATION_ERROR' });
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 });
const result = await createTimestampAndWait(req.auth, {
data,
hashAlg,
sha256,
publicMetadata,
privateMetadata,
});
res.json(result);
}),
);

View file

@ -45,8 +45,8 @@ async function enqueueWatchForJob(ctx, jobId) {
/**
* @param {object} ctx - Auth context with tenantId, veraeToken
* @param {{ data: string, hashAlg?: string }} body
* @returns {Promise<{ jobId: string }>}
* @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');
@ -66,12 +66,39 @@ export async function createTimestamp(ctx, body) {
*/
export async function createTimestampAndWait(ctx, body) {
const created = await createTimestamp(ctx, body);
const status = await veraeClient.waitForJob(ctx.veraeToken, created.jobId, {
maxAttempts: config.jobPollMaxAttempts,
intervalMs: config.jobPollIntervalMs,
});
recordUsage(ctx.tenantId, 'status');
return status;
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' };
}
}
/**
@ -120,3 +147,13 @@ export async function getJobVerification(ctx, jobId) {
recordUsage(ctx.tenantId, 'status');
return status;
}
/**
* @param {object} ctx
* @param {string} sha256
*/
export async function lookupHash(ctx, sha256) {
const result = await veraeClient.lookupHash(ctx.veraeToken, sha256);
recordUsage(ctx.tenantId, 'status');
return result;
}

View file

@ -0,0 +1,97 @@
/**
* Hash idempotent register + lookup (mock Verae)
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { createApp } from '../../src/app.js';
import { useTempStore, seedProTenant } from '../helpers.js';
import { clearMockJobs, sha256Hex } from '../../src/clients/veraeClient.js';
import { config } from '../../src/config.js';
describe('hash register + lookup', () => {
let ctx;
/** @type {import('http').Server} */
let server;
let port;
let apiKey;
before(async () => {
config.mockVerae = true;
config.natsEnabled = false;
clearMockJobs();
ctx = useTempStore();
const seeded = seedProTenant();
apiKey = seeded.apiKey;
const app = createApp();
await new Promise((resolve) => {
server = app.listen(0, '127.0.0.1', () => {
port = server.address().port;
resolve();
});
});
});
after(async () => {
await new Promise((r) => server.close(r));
ctx.cleanup();
});
it('registers sha256, second create returns original jobId', async () => {
const data = 'hello-hash';
const sha = sha256Hex(data);
const r1 = await json(port, 'POST', '/zapier/v1/timestamp', apiKey, { data });
assert.equal(r1.status, 202);
const r2 = await json(port, 'POST', '/zapier/v1/timestamp', apiKey, { sha256: sha });
assert.equal(r2.status, 202);
assert.equal(r2.body.jobId, r1.body.jobId);
assert.equal(r2.body.existing, true);
const look = await json(port, 'GET', `/zapier/v1/hashes/${sha}`, apiKey);
assert.equal(look.status, 200);
assert.equal(look.body.exists, true);
assert.equal(look.body.jobId, r1.body.jobId);
});
it('lookup miss is 404', async () => {
const miss = 'a'.repeat(64);
const look = await json(port, 'GET', `/zapier/v1/hashes/${miss}`, apiKey);
assert.equal(look.status, 404);
});
});
function json(port, method, path, apiKey, body) {
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
path,
method,
headers: {
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json',
},
},
(res) => {
let data = '';
res.on('data', (c) => {
data += c;
});
res.on('end', () => {
let parsed = {};
try {
parsed = data ? JSON.parse(data) : {};
} catch {
parsed = { raw: data };
}
resolve({ status: res.statusCode, body: parsed });
});
},
);
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}

View file

@ -0,0 +1,67 @@
/**
* GATE 9 /timestamp/wait via NATS events (timeout returns pending + jobId)
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { config } from '../../src/config.js';
import { useTempStore, seedProTenant } from '../helpers.js';
import { connectNats, ensureStreams, closeNats } from '../../src/nats/connection.js';
import { startJobPollerWorker, stopJobPollerWorker } from '../../src/workers/jobPollerWorker.js';
import { createTimestampAndWait } from '../../src/services/timestampService.js';
import { resolveAuthContext } from '../../src/services/authService.js';
import { clearMockJobs } from '../../src/clients/veraeClient.js';
describe('GATE 9 wait via NATS', () => {
/** @type {ReturnType<typeof useTempStore>} */
let ctx;
let authCtx;
before(async () => {
assert.equal(config.mockVerae, true);
config.natsEnabled = true;
process.env.NATS_FORCE_CONNECT = '1';
config.natsUrl = process.env.NATS_URL || 'nats://127.0.0.1:4222';
config.jobPollIntervalMs = 40;
config.jobPollMaxAttempts = 40;
config.waitTimeoutMs = 8000;
config.mockJobCompleteMs = 80;
clearMockJobs();
ctx = useTempStore();
const seeded = seedProTenant();
authCtx = await resolveAuthContext(seeded.apiKey);
await connectNats(config.natsUrl);
await ensureStreams();
await startJobPollerWorker();
});
after(async () => {
await stopJobPollerWorker();
await closeNats();
ctx.cleanup();
process.env.NATS_FORCE_CONNECT = '';
});
it('wait returns completed when worker finishes before timeout', async () => {
const status = await createTimestampAndWait(authCtx, { data: 'wait-fast' });
assert.equal(status.status, 'completed');
assert.ok(status.id || status.jobId);
});
it('wait returns pending + jobId on timeout', async () => {
const prevWait = config.waitTimeoutMs;
const prevDelay = config.mockJobCompleteMs;
config.waitTimeoutMs = 60;
config.mockJobCompleteMs = 30_000;
try {
const status = await createTimestampAndWait(authCtx, { data: 'wait-slow' });
assert.equal(status.status, 'pending');
assert.ok(status.jobId || status.id);
} finally {
config.waitTimeoutMs = prevWait;
config.mockJobCompleteMs = prevDelay;
}
});
});

View file

@ -18,6 +18,7 @@ describe('config', () => {
'tokenSecret',
'jobPollIntervalMs',
'jobPollMaxAttempts',
'waitTimeoutMs',
'storePath',
]) {
assert.notEqual(config[key], undefined, `missing config.${key}`);

View file

@ -10,6 +10,7 @@ const verifyTimestamp = require('./creates/verify_timestamp');
const batchTimestamp = require('./creates/batch_timestamp');
const addNumbers = require('./creates/add_numbers');
const jobStatus = require('./searches/job_status');
const hashLookup = require('./searches/hash_lookup');
const timestampCompleted = require('./triggers/timestamp_completed');
/**
@ -74,5 +75,6 @@ module.exports = {
},
searches: {
[jobStatus.key]: jobStatus,
[hashLookup.key]: hashLookup,
},
};

View file

@ -0,0 +1,34 @@
const base = () => process.env.MIDDLEWARE_BASE_URL || 'http://127.0.0.1:3100';
const perform = async (z, bundle) => {
try {
const response = await z.request({
method: 'GET',
url: `${base()}/zapier/v1/hashes/${encodeURIComponent(bundle.inputData.sha256)}`,
});
return [response.data];
} catch (err) {
if (err.status === 404) return [];
throw err;
}
};
module.exports = {
key: 'hash_lookup',
noun: 'Timestamp',
display: {
label: 'Find Timestamp by SHA256',
description: 'Looks up an existing timestamp for a SHA256 hex digest (mock/middleware).',
},
operation: {
inputFields: [
{ key: 'sha256', label: 'SHA256', type: 'string', required: true },
],
perform,
sample: {
sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
exists: true,
jobId: '550e8400-e29b-41d4-a716-446655440000',
},
},
};

View file

@ -27,6 +27,7 @@ describe('verae-zapier app definition', () => {
assert.ok(App.creates.verify_timestamp);
assert.ok(App.creates.batch_timestamp);
assert.ok(App.searches.job_status);
assert.ok(App.searches.hash_lookup);
assert.ok(App.triggers.timestamp_completed);
});

View file

@ -58,6 +58,41 @@ paths:
responses:
'200':
description: OK
/v1/timestamp:
post:
operationId: timestamp
summary: Register a timestamp (proxies middleware or in-process mock)
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
data:
type: string
sha256:
type: string
hashAlg:
type: string
responses:
'202':
description: Accepted
/v1/hashes/{sha256}:
get:
operationId: hash-lookup
summary: Lookup a SHA256 timestamp (mock)
parameters:
- name: sha256
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
'404':
description: Missing
/v1/add:
post:
operationId: add

View file

@ -1,5 +1,5 @@
import path from 'path';
import { randomUUID } from 'crypto';
import { createHash, randomUUID } from 'crypto';
import express, { Express, NextFunction, Request, RequestHandler, Response } from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
import swaggerUi from 'swagger-ui-express';
@ -97,6 +97,10 @@ export function buildApp(deps: AppDeps = {}): {
},
};
const items: StoredItem[] = [];
const hashIndex = new Map<
string,
{ jobId: string; sha256: string; data?: string; timestamp: string }
>();
const app = express();
app.use(express.json());
@ -157,6 +161,36 @@ export function buildApp(deps: AppDeps = {}): {
res.json({ output: text.toUpperCase(), quote: res.locals.quote });
});
app.post('/v1/timestamp', meter('timestamp', usage, pricing), (req, res) => {
const data = req.body?.data != null ? String(req.body.data) : '';
const sha256 =
(req.body?.sha256 && String(req.body.sha256).toLowerCase()) ||
(data ? createHash('sha256').update(data, 'utf8').digest('hex') : '');
if (!sha256) {
res.status(400).json({ error: 'data or sha256 is required' });
return;
}
const existing = hashIndex.get(sha256);
if (existing) {
res.status(202).json({ jobId: existing.jobId, sha256, existing: true, timestamp: existing.timestamp });
return;
}
const jobId = randomUUID();
const timestamp = new Date().toISOString();
hashIndex.set(sha256, { jobId, sha256, data, timestamp });
res.status(202).json({ jobId, sha256, existing: false, timestamp });
});
app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing), (req, res) => {
const sha256 = String(req.params.sha256 || '').toLowerCase();
const rec = hashIndex.get(sha256);
if (!rec) {
res.status(404).json({ error: 'Hash not found' });
return;
}
res.json({ exists: true, ...rec });
});
app.post('/v1/add', meter('add', usage, pricing), (req, res) => {
const number1 = Number(req.body?.number1);
const number2 = Number(req.body?.number2);

View file

@ -64,6 +64,8 @@ export const DEFAULT_RATE_CARD: RateCard = {
'storage-list': { kind: 'free' },
transform: { kind: 'fixed', fixedCents: 4 },
add: { kind: 'free' },
timestamp: { kind: 'fixed', fixedCents: 4 },
'hash-lookup': { kind: 'free' },
storage: { kind: 'variable', baseCents: 10, perKbCents: 1, perMbCents: 50 },
},
};

View file

@ -18,6 +18,26 @@ describe('Zappier API', () => {
expect(res.body.quote.totalCents).toBe(0);
});
it('POST /v1/timestamp is idempotent by sha256', async () => {
const { app } = buildApp();
const a = await request(app)
.post('/v1/timestamp')
.set('x-api-key', KEY)
.send({ data: 'abc' });
expect(a.status).toBe(202);
const b = await request(app)
.post('/v1/timestamp')
.set('x-api-key', KEY)
.send({ data: 'abc' });
expect(b.body.jobId).toBe(a.body.jobId);
expect(b.body.existing).toBe(true);
const look = await request(app)
.get(`/v1/hashes/${a.body.sha256}`)
.set('x-api-key', KEY);
expect(look.status).toBe(200);
expect(look.body.jobId).toBe(a.body.jobId);
});
it('POST /v1/add returns the sum and is free', async () => {
const { app } = buildApp();
const res = await request(app)