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

@ -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)