Files
agents-marketplace/supplier/src/server.ts
FilipBl4gojevic 069edcb076 feat(ocr): model-scoped OCR capability + gateway /v1/ocr/extract route
Adds the ocr.page.extract.chandra-ocr-2.v1 capability end to end: the
postOcrEscrow builder (locking the min-ada floor for the Submitted-state
datum, required since the min-ada fix), the gateway OCR route that selects
an OCR supplier by capability, posts a per-page escrow, calls the supplier,
verifies and settles, and returns content plus the signed receipt labelled
first-party-brokered. Supplier OCR handler with a hosted document-API
upstream and buyer-SDK OCR support included.

Squashed from the 2026-08-06 working subset; the deploy pack is
intentionally excluded (it lives in a private operator repo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 16:40:00 +02:00

1436 lines
62 KiB
TypeScript

/**
* supplier/src/server.ts — Express app factory for the supplier node.
*
* createApp(deps) returns an Express Application with all routes wired.
* deps is injected so tests can swap in mock chain providers, state, etc.
*
* Routes:
* GET /capability — ARCHITECTURE.md §5.1
* GET /status — ARCHITECTURE.md §5.1
* POST /v1/chat/completions — async claim+enqueue (M1-F-async-chat)
* GET /v1/chat/completions/:jobId — poll job status
*
* Async chat flow (M1-F-async-chat-green):
* POST validates → acquires lock → buildClaimTx → awaitTx Claim
* → jobs.create → runChatJob (fire-and-forget) → 202 {job_id, status, escrow_ref}
* GET dispatches by record.status:
* - 404 job_not_found / 400 invalid_job_id
* - 202 {status: "accepted"|"running", escrow_ref}
* - 200 with full payload for "done"
* - failure.httpStatus with {status: "failed", reason, message, escrow_ref}
*
* Error envelope (every non-2xx response):
* { reason, message, error: { reason, message } }
*
* Tests assert on `res.body.reason ?? res.body.error` against a regex —
* we set both top-level and nested fields so either pathway matches.
*
* The chat handler reaches for `fetch` and `chain.submitTx` through live
* references (`ollama.callOllama` via the imported namespace; `deps.chain`
* via the closure) rather than top-level destructuring. That keeps
* vi.stubGlobal("fetch") and vi.spyOn(chain, "submitTx") effective.
*/
import express, { type Application, type Request, type Response, type NextFunction } from "express";
import { createHash } from "crypto";
import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
import { decodeAdvertDatum, decodeEscrowDatum, canonicalize } from "@marketplace/shared/cbor";
import type { AdvertDatum, EscrowDatum } from "@marketplace/shared/cbor";
import type { ChatMessage, WalletKey } from "@marketplace/shared/tx";
import {
buildClaimTx,
chatSessionPromptHash,
mockSlotToWallclockMs,
detectCborBackend,
normalizeChatMessage,
ocrPromptHash,
ALLOWED_OCR_MIMES,
ALLOWED_OCR_OUTPUT_FORMATS,
MAX_OCR_IMAGE_B64_CHARS,
} from "@marketplace/shared/tx";
import type { OcrRequest } from "@marketplace/shared/tx";
import type { SupplierState } from "./state.js";
import type { SupplierConfig } from "./config.js";
import { JobStore } from "./jobs.js";
import { runChatJob, runTtsJob, runOcrJob } from "./jobRunner.js";
import { probeRevision } from "./ocrVision.js";
import { probeDatalabHealth } from "./datalabOcr.js";
import { ChatSessionStore, type ChatSessionRecord } from "./chatSession.js";
import { endChatSession, type EndChatSessionDeps } from "./chatSessionRunner.js";
import { callOpenAiStream } from "./openai.js";
import { healthzRouter } from "./routes/healthz.js";
import { triggerOnFailureConsolidate } from "./walletHealth.js";
export interface SupplierDeps {
chain: ChainProvider;
state: SupplierState;
config: SupplierConfig;
supplierKey: WalletKey;
/** Optional: defaults to a fresh JobStore. M1-F-async-chat. */
jobs?: JobStore;
/** Optional: defaults to a fresh ChatSessionStore (chat-session capability). */
chatSessions?: ChatSessionStore;
}
const ESCROW_REF_RE = /^[0-9a-fA-F]{64}#(?:0|[1-9]\d*)$/;
const UUID_V4_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/;
function sha256Hex(s: string): string {
return createHash("sha256").update(s, "utf8").digest("hex");
}
function parseEscrowRef(ref: string): OutputReference | null {
if (!ESCROW_REF_RE.test(ref)) return null;
const idx = ref.indexOf("#");
return { txHash: ref.slice(0, idx), index: Number(ref.slice(idx + 1)) };
}
function jsonError(res: Response, status: number, reason: string, message: string): Response {
return res
.status(status)
.json({ reason, message, error: { reason, message } });
}
// ─── /capability ───────────────────────────────────────────────────────────
interface ResolvedDeps {
chain: ChainProvider;
state: SupplierState;
config: SupplierConfig;
supplierKey: WalletKey;
jobs: JobStore;
chatSessions: ChatSessionStore;
}
function makeCapabilityHandler(deps: ResolvedDeps) {
return async (_req: Request, res: Response, next: NextFunction) => {
try {
res.setHeader("Cache-Control", "no-store");
const utxo = await deps.chain.queryUtxo(deps.config.advertRef);
if (utxo === null || !utxo.datumHex) {
return jsonError(res, 503, "advert_unavailable", "advert UTxO not found on chain");
}
let datum: AdvertDatum;
try {
datum = decodeAdvertDatum(utxo.datumHex);
} catch (err) {
return jsonError(res, 503, "advert_decode_failed",
`unable to decode advert datum: ${(err as Error).message}`);
}
if (datum.status !== "Active") {
return jsonError(res, 503, "advert_not_active",
`advert is retired (status=${datum.status})`);
}
const advertRefStr = `${deps.config.advertRef.txHash}#${deps.config.advertRef.index}`;
return res.status(200).json({
capability_id: datum.capability_id,
model: datum.model,
max_output_tokens: datum.max_output_tokens,
max_processing_ms: datum.max_processing_ms,
price_lovelace: datum.price_lovelace.toString(),
advert_ref: advertRefStr,
supplier_pkh: datum.supplier_pkh,
// SPEC FIX 2026-04-25: pub_key_hex required for buyer-side receipt verification
pub_key_hex: deps.supplierKey.pubKeyHex,
});
} catch (err) {
next(err);
return;
}
};
}
// ─── /status ───────────────────────────────────────────────────────────────
function makeStatusHandler(deps: ResolvedDeps) {
return (_req: Request, res: Response) => {
const snap = deps.state.snapshot();
const payload: Record<string, unknown> = {
status: snap.status,
last_seen: snap.lastSeenIso,
active_sessions: snap.activeSessions,
max_sessions: snap.maxSessions,
};
if (snap.status === "working" && snap.currentEscrowRef) {
payload.current_escrow_ref = snap.currentEscrowRef;
}
return res.status(200).json(payload);
};
}
// ─── /v1/chat/completions ──────────────────────────────────────────────────
interface ChatBody {
model?: unknown;
messages?: unknown;
max_tokens?: unknown;
stream?: unknown;
tools?: unknown;
tool_choice?: unknown;
functions?: unknown;
}
type AdvertResult =
| { datum: AdvertDatum }
| { error: { status: number; reason: string; message: string } };
async function fetchActiveAdvert(deps: ResolvedDeps): Promise<AdvertResult> {
const utxo = await deps.chain.queryUtxo(deps.config.advertRef);
if (utxo === null || !utxo.datumHex) {
return { error: { status: 503, reason: "advert_unavailable", message: "advert UTxO missing on chain" } };
}
let datum: AdvertDatum;
try {
datum = decodeAdvertDatum(utxo.datumHex);
} catch (err) {
return { error: { status: 503, reason: "advert_decode_failed", message: (err as Error).message } };
}
if (datum.status !== "Active") {
return { error: { status: 503, reason: "advert_not_active", message: `advert status=${datum.status}` } };
}
return { datum };
}
function makeChatHandler(deps: ResolvedDeps) {
return async (req: Request, res: Response, next: NextFunction) => {
// Slot held by THIS request (null until acquired / after hand-off to the
// job runner) — the catch-all must only release its own acquisition.
let acquiredRef: string | null = null;
try {
// ── 1. Header validation ──────────────────────────────────────────
const headerVal = req.header("X-Escrow-Ref");
if (!headerVal) {
return jsonError(res, 400, "escrow_ref_required", "X-Escrow-Ref header is required");
}
const escrowRef = parseEscrowRef(headerVal);
if (escrowRef === null) {
return jsonError(res, 400, "escrow_ref_malformed",
'X-Escrow-Ref must match "<64-hex>#<int>"');
}
const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`;
// ── 2. Body shape validation ─────────────────────────────────────
const body = (req.body ?? {}) as ChatBody;
if (body.stream === true) {
return jsonError(res, 400, "streaming_not_supported", "stream:true is not supported");
}
if (body.tools !== undefined) {
return jsonError(res, 400, "tools_not_supported", "tools[] is not supported");
}
if (body.tool_choice !== undefined) {
return jsonError(res, 400, "tools_not_supported", "tool_choice is not supported");
}
if (body.functions !== undefined) {
return jsonError(res, 400, "tools_not_supported", "functions[] is not supported");
}
if (!Array.isArray(body.messages) || body.messages.length === 0) {
return jsonError(res, 400, "messages_required", "messages must be a non-empty array");
}
const messages = body.messages as Array<{ role: unknown; content: unknown }>;
for (const m of messages) {
if (!m || typeof m !== "object" || typeof m.role !== "string" || typeof m.content !== "string") {
return jsonError(res, 400, "messages_required",
"each message must have string role and string content");
}
}
const validatedMessages = messages as Array<{ role: "system" | "user" | "assistant"; content: string }>;
// ── 3. Resolve advert ────────────────────────────────────────────
const advertResult = await fetchActiveAdvert(deps);
if ("error" in advertResult) {
const e = advertResult.error;
return jsonError(res, e.status, e.reason, e.message);
}
const advert = advertResult.datum;
const maxTokensRaw = body.max_tokens;
if (maxTokensRaw !== undefined) {
if (typeof maxTokensRaw !== "number" || !Number.isFinite(maxTokensRaw) || maxTokensRaw < 0) {
return jsonError(res, 400, "max_tokens_invalid",
"max_tokens must be a non-negative number");
}
if (maxTokensRaw > advert.max_output_tokens) {
return jsonError(res, 400, "output_cap_exceeded",
`max_tokens ${maxTokensRaw} exceeds advertised cap ${advert.max_output_tokens}`);
}
}
// ── 4. Resolve escrow UTxO ───────────────────────────────────────
const escrowUtxo = await deps.chain.queryUtxo(escrowRef);
if (escrowUtxo === null || !escrowUtxo.datumHex) {
return jsonError(res, 404, "escrow_not_found",
`escrow UTxO ${escrowRefStr} not found on chain`);
}
let escrowDatum: EscrowDatum;
try {
escrowDatum = decodeEscrowDatum(escrowUtxo.datumHex);
} catch (err) {
return jsonError(res, 404, "escrow_decode_failed", (err as Error).message);
}
// ── 5. State / identity / capability ─────────────────────────────
if (escrowDatum.state !== "Open") {
return jsonError(res, 409, "escrow_not_claimable",
`escrow state is ${escrowDatum.state}, expected Open`);
}
if (escrowDatum.supplier_pkh !== deps.supplierKey.pubKeyHash) {
return jsonError(res, 403, "wrong_supplier",
"escrow supplier_pkh does not match this node");
}
if (escrowDatum.capability_id !== advert.capability_id) {
return jsonError(res, 409, "capability_mismatch",
`escrow capability ${escrowDatum.capability_id} != advert ${advert.capability_id}`);
}
// ── 6. Hash checks ───────────────────────────────────────────────
const expectedRequestSpecHash = sha256Hex(canonicalize({
capability_id: advert.capability_id,
max_output_tokens: advert.max_output_tokens,
model: advert.model,
}));
if (escrowDatum.request_spec_hash !== expectedRequestSpecHash) {
return jsonError(res, 409, "request_spec_mismatch",
"request_spec_hash in escrow does not match advert spec");
}
const expectedPromptHash = sha256Hex(canonicalize(validatedMessages));
if (escrowDatum.prompt_hash !== expectedPromptHash) {
return jsonError(res, 409, "prompt_mismatch",
"prompt_hash in escrow does not match request body messages");
}
// ── 7. Deadline ──────────────────────────────────────────────────
const tipSlot = await deps.chain.tip();
const isLive = detectCborBackend(deps.chain) === "live";
const nowMs = isLive
? Date.now()
: Math.max(mockSlotToWallclockMs(tipSlot), escrowDatum.posted_at);
if (nowMs >= escrowDatum.deliver_by) {
return jsonError(res, 408, "past_deliver_by",
`now ${nowMs} >= deliver_by ${escrowDatum.deliver_by}`);
}
// ── 8. Acquire session slot ──────────────────────────────────────
if (!deps.state.tryAcquire(escrowRefStr)) {
return jsonError(res, 409, "supplier_busy", "supplier is already working another job");
}
acquiredRef = escrowRefStr;
// From here on, every error path MUST release the slot until the
// runner takes ownership of it.
// ── 9+10. Claim tx: build + confirm as ONE wallet critical section ─
// (splitting them would let another wallet spend coin-select mid-flight)
const claimOutcome = await deps.state.walletMutex.run(async () => {
let built;
try {
built = await buildClaimTx({
chain: deps.chain,
supplierKey: deps.supplierKey,
escrowRef,
});
} catch (err) {
return { kind: "build_failed" as const, err };
}
try {
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
} catch (err) {
return { kind: "await_failed" as const, err };
}
return { kind: "ok" as const, built };
});
if (claimOutcome.kind === "build_failed") {
deps.state.release(escrowRefStr);
acquiredRef = null;
// Backstop the periodic wallet-health ticker: most Claim build failures
// we see in practice are wallet-shape problems (fragmentation, dust).
// Fire-and-forget a debounced consolidate so the NEXT buyer retry
// finds a clean 2-UTxO wallet. Safe no-op if already healthy.
triggerOnFailureConsolidate({
chain: deps.chain,
state: deps.state,
supplierKey: deps.supplierKey,
});
return jsonError(res, 503, "chain_submit_failed",
`Claim tx submit failed: ${(claimOutcome.err as Error).message}`);
}
if (claimOutcome.kind === "await_failed") {
deps.state.release(escrowRefStr);
acquiredRef = null;
return jsonError(res, 504, "claim_timeout",
`Claim awaitTx failed: ${(claimOutcome.err as Error).message}`);
}
const claimResult = claimOutcome.built;
// ── 11. Continuing-output ref + create job + fire-and-forget ─────
const claimedRef: OutputReference = {
txHash: claimResult.expectedTxHash,
index: 0,
};
const jobId = deps.jobs.create(escrowRefStr);
// Slot release now happens inside runChatJob's finally.
acquiredRef = null;
void runChatJob({
deps: {
chain: deps.chain,
state: deps.state,
config: deps.config,
supplierKey: deps.supplierKey,
jobs: deps.jobs,
},
jobId,
escrowRef: escrowRefStr,
claimedRef,
advert,
escrowDatum,
requestBody: { messages: validatedMessages },
});
// ── 12. 202 Accepted ─────────────────────────────────────────────
return res.status(202).json({
job_id: jobId,
status: "accepted",
escrow_ref: escrowRefStr,
});
} catch (err) {
if (acquiredRef) {
try { deps.state.release(acquiredRef); } catch { /* ignore */ }
}
next(err);
return;
}
};
}
// ─── GET /v1/chat/completions/:jobId ───────────────────────────────────────
function makeGetJobHandler(deps: ResolvedDeps) {
return (req: Request, res: Response) => {
const rawJobId = req.params.jobId;
const jobId = typeof rawJobId === "string" ? rawJobId : "";
if (!UUID_V4_RE.test(jobId)) {
return jsonError(res, 400, "invalid_job_id",
"jobId must be a UUIDv4 string");
}
const record = deps.jobs.get(jobId);
if (!record) {
return jsonError(res, 404, "job_not_found",
`no job found with id ${jobId}`);
}
res.setHeader("Content-Type", "application/json");
if (record.status === "accepted" || record.status === "running") {
return res.status(202).json({
status: record.status,
escrow_ref: record.escrowRef,
});
}
if (record.status === "done") {
const payload = record.responsePayload!;
// Defensive narrowing: this poll route only knows how to render the
// chat shape. A future shared JobStore could mix capabilities; we'd
// want each kind's poll to be sure it's reading its own.
if (payload.kind === "ocr" || "audio_b64" in payload) {
return jsonError(res, 500, "wrong_payload_kind",
"non-chat payload returned to chat poll route");
}
return res.status(200).json({
choices: payload.choices,
usage: payload.usage,
receipt: payload.receipt,
receipt_signature: payload.receipt_signature,
escrow_ref: record.escrowRef,
});
}
// failed
const f = record.failure!;
return res.status(f.httpStatus).json({
status: "failed",
reason: f.reason,
message: f.message,
escrow_ref: record.escrowRef,
});
};
}
// ─── POST /v1/audio/synthesize ──────────────────────────────────────────────
//
// Mirror of makeChatHandler for the audio.synthesize.piper.v1 capability.
// Same lifecycle (validate → resolve advert → resolve escrow → hash check →
// claim → fire-and-forget runner → 202). Differences:
// - body shape: { text, voice, format, speed } (no messages array)
// - prompt_hash is sha256(canonical({text, voice, format, speed})), so the
// escrow datum the buyer commits MUST hash the same object shape — see
// packages/shared/src/tx/escrow/postEscrow*.ts (TTS variant).
const ALLOWED_TTS_VOICES = new Set([
"alloy", "echo", "fable", "onyx", "nova", "shimmer", "lessac",
]);
const ALLOWED_TTS_FORMATS = new Set(["mp3", "wav", "opus", "aac", "flac"]);
function makeTtsHandler(deps: ResolvedDeps) {
return async (req: Request, res: Response, next: NextFunction) => {
// Slot held by THIS request (null until acquired / after hand-off).
let acquiredRef: string | null = null;
try {
// ── 1. Header ───────────────────────────────────────────────────
const headerVal = req.header("X-Escrow-Ref");
if (!headerVal) {
return jsonError(res, 400, "escrow_ref_required", "X-Escrow-Ref header is required");
}
const escrowRef = parseEscrowRef(headerVal);
if (escrowRef === null) {
return jsonError(res, 400, "escrow_ref_malformed",
'X-Escrow-Ref must match "<64-hex>#<int>"');
}
const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`;
// ── 2. Body ─────────────────────────────────────────────────────
const body = (req.body ?? {}) as Record<string, unknown>;
const text = typeof body.text === "string" ? body.text : "";
if (text.length === 0) {
return jsonError(res, 400, "text_required", "body.text must be a non-empty string");
}
const voice = typeof body.voice === "string" ? body.voice : "";
if (!ALLOWED_TTS_VOICES.has(voice)) {
return jsonError(res, 400, "voice_invalid",
`voice must be one of: ${[...ALLOWED_TTS_VOICES].join(", ")}`);
}
const format = typeof body.format === "string" ? body.format : "";
if (!ALLOWED_TTS_FORMATS.has(format)) {
return jsonError(res, 400, "format_invalid",
`format must be one of: ${[...ALLOWED_TTS_FORMATS].join(", ")}`);
}
const speedRaw = body.speed;
const speed = typeof speedRaw === "number" ? speedRaw : Number(speedRaw);
if (!Number.isFinite(speed) || speed < 0.5 || speed > 1.5) {
return jsonError(res, 400, "speed_out_of_range",
"speed must be a finite number in [0.5, 1.5]");
}
// ── 3. Advert ───────────────────────────────────────────────────
const advertResult = await fetchActiveAdvert(deps);
if ("error" in advertResult) {
const e = advertResult.error;
return jsonError(res, e.status, e.reason, e.message);
}
const advert = advertResult.datum;
// ── 4. Escrow ───────────────────────────────────────────────────
const escrowUtxo = await deps.chain.queryUtxo(escrowRef);
if (escrowUtxo === null || !escrowUtxo.datumHex) {
return jsonError(res, 404, "escrow_not_found",
`escrow UTxO ${escrowRefStr} not found on chain`);
}
let escrowDatum: EscrowDatum;
try {
escrowDatum = decodeEscrowDatum(escrowUtxo.datumHex);
} catch (err) {
return jsonError(res, 404, "escrow_decode_failed", (err as Error).message);
}
// ── 5. State / identity / capability ────────────────────────────
if (escrowDatum.state !== "Open") {
return jsonError(res, 409, "escrow_not_claimable",
`escrow state is ${escrowDatum.state}, expected Open`);
}
if (escrowDatum.supplier_pkh !== deps.supplierKey.pubKeyHash) {
return jsonError(res, 403, "wrong_supplier",
"escrow supplier_pkh does not match this node");
}
if (escrowDatum.capability_id !== advert.capability_id) {
return jsonError(res, 409, "capability_mismatch",
`escrow capability ${escrowDatum.capability_id} != advert ${advert.capability_id}`);
}
// ── 6. Hash checks ──────────────────────────────────────────────
const expectedRequestSpecHash = sha256Hex(canonicalize({
capability_id: advert.capability_id,
max_output_tokens: advert.max_output_tokens,
model: advert.model,
}));
if (escrowDatum.request_spec_hash !== expectedRequestSpecHash) {
return jsonError(res, 409, "request_spec_mismatch",
"request_spec_hash in escrow does not match advert spec");
}
// The TTS prompt commitment is the canonicalisation of the full
// request envelope (text + voice + format + speed). The buyer SDK
// computes the same shape when building the escrow datum.
const expectedPromptHash = sha256Hex(canonicalize({
text, voice, format, speed,
}));
if (escrowDatum.prompt_hash !== expectedPromptHash) {
return jsonError(res, 409, "prompt_mismatch",
"prompt_hash in escrow does not match request body");
}
// ── 7. Deadline ─────────────────────────────────────────────────
const tipSlot = await deps.chain.tip();
const isLive = detectCborBackend(deps.chain) === "live";
const nowMs = isLive
? Date.now()
: Math.max(mockSlotToWallclockMs(tipSlot), escrowDatum.posted_at);
if (nowMs >= escrowDatum.deliver_by) {
return jsonError(res, 408, "past_deliver_by",
`now ${nowMs} >= deliver_by ${escrowDatum.deliver_by}`);
}
// ── 8. Acquire session slot ─────────────────────────────────────
if (!deps.state.tryAcquire(escrowRefStr)) {
return jsonError(res, 409, "supplier_busy", "supplier is already working another job");
}
acquiredRef = escrowRefStr;
// ── 9. Claim tx: build + confirm as ONE wallet critical section ──
const claimOutcome = await deps.state.walletMutex.run(async () => {
let built;
try {
built = await buildClaimTx({
chain: deps.chain,
supplierKey: deps.supplierKey,
escrowRef,
});
} catch (err) {
return { kind: "build_failed" as const, err };
}
try {
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
} catch (err) {
return { kind: "await_failed" as const, err };
}
return { kind: "ok" as const, built };
});
if (claimOutcome.kind === "build_failed") {
deps.state.release(escrowRefStr);
acquiredRef = null;
return jsonError(res, 503, "chain_submit_failed",
`Claim tx submit failed: ${(claimOutcome.err as Error).message}`);
}
if (claimOutcome.kind === "await_failed") {
deps.state.release(escrowRefStr);
acquiredRef = null;
return jsonError(res, 504, "claim_timeout",
`Claim awaitTx failed: ${(claimOutcome.err as Error).message}`);
}
// ── 10. Spawn runner + 202 ──────────────────────────────────────
const claimedRef: OutputReference = {
txHash: claimOutcome.built.expectedTxHash,
index: 0,
};
const jobId = deps.jobs.create(escrowRefStr);
// Slot release now happens inside runTtsJob's finally.
acquiredRef = null;
void runTtsJob({
deps: {
chain: deps.chain,
state: deps.state,
config: deps.config,
supplierKey: deps.supplierKey,
jobs: deps.jobs,
},
jobId,
escrowRef: escrowRefStr,
claimedRef,
advert,
escrowDatum,
requestBody: { text, voice, format, speed },
});
return res.status(202).json({
job_id: jobId,
status: "accepted",
escrow_ref: escrowRefStr,
});
} catch (err) {
if (acquiredRef) {
try { deps.state.release(acquiredRef); } catch { /* ignore */ }
}
next(err);
return;
}
};
}
// ─── GET /v1/audio/synthesize/:jobId ────────────────────────────────────────
function makeGetTtsJobHandler(deps: ResolvedDeps) {
return (req: Request, res: Response) => {
const rawJobId = req.params.jobId;
const jobId = typeof rawJobId === "string" ? rawJobId : "";
if (!UUID_V4_RE.test(jobId)) {
return jsonError(res, 400, "invalid_job_id", "jobId must be a UUIDv4 string");
}
const record = deps.jobs.get(jobId);
if (!record) {
return jsonError(res, 404, "job_not_found", `no job found with id ${jobId}`);
}
res.setHeader("Content-Type", "application/json");
if (record.status === "accepted" || record.status === "running") {
return res.status(202).json({
status: record.status,
escrow_ref: record.escrowRef,
});
}
if (record.status === "done") {
const payload = record.responsePayload!;
if (!("audio_b64" in payload)) {
return jsonError(res, 500, "wrong_payload_kind",
"chat payload returned to tts poll route");
}
return res.status(200).json({
audio_b64: payload.audio_b64,
format: payload.format,
content_type: payload.content_type,
byte_length: payload.byte_length,
receipt: payload.receipt,
receipt_signature: payload.receipt_signature,
escrow_ref: record.escrowRef,
});
}
// failed
const f = record.failure!;
return res.status(f.httpStatus).json({
status: "failed",
reason: f.reason,
message: f.message,
escrow_ref: record.escrowRef,
});
};
}
// ─── POST /v1/ocr/extract ───────────────────────────────────────────────────
//
// Mirror of makeTtsHandler for model-scoped `ocr.page.extract.<model-slug>.v1`
// capabilities. Same lifecycle (validate → resolve advert → resolve escrow →
// hash check → claim → fire-and-forget runner → 202). Differences:
// - body shape: { image_b64, mime, output_format } (one page per job)
// - prompt_hash is ocrPromptHash(envelope) — shared with the buyer-side
// builder in packages/shared/src/tx/escrow/postOcrEscrow.ts
// - optional served-revision probe BEFORE Claim: a mispinned or
// unreachable upstream refuses the job instead of stranding the escrow
// in Claimed.
/** Plain base64 (standard alphabet, optional padding). Shared shape with the
* buyer-side validator; kept local so route validation stays dependency-light. */
const OCR_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
function makeOcrHandler(deps: ResolvedDeps) {
return async (req: Request, res: Response, next: NextFunction) => {
// Slot held by THIS request (null until acquired / after hand-off).
let acquiredRef: string | null = null;
try {
// ── 1. Header ───────────────────────────────────────────────────
const headerVal = req.header("X-Escrow-Ref");
if (!headerVal) {
return jsonError(res, 400, "escrow_ref_required", "X-Escrow-Ref header is required");
}
const escrowRef = parseEscrowRef(headerVal);
if (escrowRef === null) {
return jsonError(res, 400, "escrow_ref_malformed",
'X-Escrow-Ref must match "<64-hex>#<int>"');
}
const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`;
// ── 2. Body ─────────────────────────────────────────────────────
const body = (req.body ?? {}) as Record<string, unknown>;
const imageB64 = typeof body.image_b64 === "string" ? body.image_b64 : "";
if (imageB64.length === 0) {
return jsonError(res, 400, "image_required",
"body.image_b64 must be a non-empty base64 string");
}
if (imageB64.length > MAX_OCR_IMAGE_B64_CHARS) {
return jsonError(res, 413, "image_too_large",
`body.image_b64 exceeds ${MAX_OCR_IMAGE_B64_CHARS} chars`);
}
if (!OCR_BASE64_RE.test(imageB64)) {
return jsonError(res, 400, "image_not_base64",
"body.image_b64 must be plain base64 (no data: prefix, no whitespace)");
}
const mime = typeof body.mime === "string" ? body.mime : "";
if (!ALLOWED_OCR_MIMES.has(mime)) {
return jsonError(res, 400, "mime_invalid",
`mime must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
}
const outputFormat = typeof body.output_format === "string" ? body.output_format : "";
if (!ALLOWED_OCR_OUTPUT_FORMATS.has(outputFormat)) {
return jsonError(res, 400, "output_format_invalid",
`output_format must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`);
}
const ocrRequest: OcrRequest = { image_b64: imageB64, mime, output_format: outputFormat };
// ── 3. Advert ───────────────────────────────────────────────────
const advertResult = await fetchActiveAdvert(deps);
if ("error" in advertResult) {
const e = advertResult.error;
return jsonError(res, e.status, e.reason, e.message);
}
const advert = advertResult.datum;
// ── 4. Escrow ───────────────────────────────────────────────────
const escrowUtxo = await deps.chain.queryUtxo(escrowRef);
if (escrowUtxo === null || !escrowUtxo.datumHex) {
return jsonError(res, 404, "escrow_not_found",
`escrow UTxO ${escrowRefStr} not found on chain`);
}
let escrowDatum: EscrowDatum;
try {
escrowDatum = decodeEscrowDatum(escrowUtxo.datumHex);
} catch (err) {
return jsonError(res, 404, "escrow_decode_failed", (err as Error).message);
}
// ── 5. State / identity / capability ────────────────────────────
if (escrowDatum.state !== "Open") {
return jsonError(res, 409, "escrow_not_claimable",
`escrow state is ${escrowDatum.state}, expected Open`);
}
if (escrowDatum.supplier_pkh !== deps.supplierKey.pubKeyHash) {
return jsonError(res, 403, "wrong_supplier",
"escrow supplier_pkh does not match this node");
}
if (escrowDatum.capability_id !== advert.capability_id) {
return jsonError(res, 409, "capability_mismatch",
`escrow capability ${escrowDatum.capability_id} != advert ${advert.capability_id}`);
}
// ── 6. Hash checks ──────────────────────────────────────────────
const expectedRequestSpecHash = sha256Hex(canonicalize({
capability_id: advert.capability_id,
max_output_tokens: advert.max_output_tokens,
model: advert.model,
}));
if (escrowDatum.request_spec_hash !== expectedRequestSpecHash) {
return jsonError(res, 409, "request_spec_mismatch",
"request_spec_hash in escrow does not match advert spec");
}
// The OCR prompt commitment is the canonicalisation of the full
// request envelope. The buyer SDK computes the same shape (shared
// ocrPromptHash) when building the escrow datum.
const expectedPromptHash = ocrPromptHash(ocrRequest);
if (escrowDatum.prompt_hash !== expectedPromptHash) {
return jsonError(res, 409, "prompt_mismatch",
"prompt_hash in escrow does not match request body");
}
// ── 7. Deadline ─────────────────────────────────────────────────
const tipSlot = await deps.chain.tip();
const isLive = detectCborBackend(deps.chain) === "live";
const nowMs = isLive
? Date.now()
: Math.max(mockSlotToWallclockMs(tipSlot), escrowDatum.posted_at);
if (nowMs >= escrowDatum.deliver_by) {
return jsonError(res, 408, "past_deliver_by",
`now ${nowMs} >= deliver_by ${escrowDatum.deliver_by}`);
}
// ── 7.5. Upstream probe (before Claim, when configured) ─────────
// A failure REFUSES the job here — claiming with a dead or mispinned
// upstream would strand the escrow (and, for self-hosted supply,
// corrupt the attestation story). 503 tells the buyer to retry/route
// elsewhere. Datalab upstream: liveness only (hosted supply has no
// checkpoint to pin). openai-vision upstream: liveness + optional
// served-revision match against HF_MODEL_REVISION.
if (deps.config.revisionProbeMode === "per_job") {
if (deps.config.ocrUpstream === "datalab") {
const probe = await probeDatalabHealth({
baseUrl: deps.config.datalabBaseUrl,
apiKey: deps.config.datalabApiKey,
timeoutMs: 10_000,
});
if (!probe.ok) {
console.warn(`[upstream_probe_failed] ${probe.detail}`);
return jsonError(res, 503, "upstream_probe_failed",
`upstream health probe failed: ${probe.detail}`);
}
} else if (deps.config.revisionProbeUrl !== "") {
const probe = await probeRevision({
probeUrl: deps.config.revisionProbeUrl,
timeoutMs: 10_000,
apiKey: deps.config.openaiApiKey,
});
if (!probe.ok) {
console.warn(`[revision_probe_failed] ${probe.detail}`);
return jsonError(res, 503, "revision_probe_failed",
`served-revision probe failed: ${probe.detail}`);
}
if (
deps.config.hfModelRevision !== "" &&
probe.servedSha !== "" &&
probe.servedSha !== deps.config.hfModelRevision.toLowerCase()
) {
console.warn(
`[revision_mismatch] expected=${deps.config.hfModelRevision} served=${probe.servedSha}`,
);
return jsonError(res, 503, "revision_mismatch",
"served checkpoint does not match the pinned revision");
}
}
}
// ── 8. Acquire session slot ─────────────────────────────────────
if (!deps.state.tryAcquire(escrowRefStr)) {
return jsonError(res, 409, "supplier_busy", "supplier is already working another job");
}
acquiredRef = escrowRefStr;
// ── 9. Claim tx: build + confirm as ONE wallet critical section ──
const claimOutcome = await deps.state.walletMutex.run(async () => {
let built;
try {
built = await buildClaimTx({
chain: deps.chain,
supplierKey: deps.supplierKey,
escrowRef,
});
} catch (err) {
return { kind: "build_failed" as const, err };
}
try {
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
} catch (err) {
return { kind: "await_failed" as const, err };
}
return { kind: "ok" as const, built };
});
if (claimOutcome.kind === "build_failed") {
deps.state.release(escrowRefStr);
acquiredRef = null;
triggerOnFailureConsolidate({
chain: deps.chain,
state: deps.state,
supplierKey: deps.supplierKey,
});
return jsonError(res, 503, "chain_submit_failed",
`Claim tx submit failed: ${(claimOutcome.err as Error).message}`);
}
if (claimOutcome.kind === "await_failed") {
deps.state.release(escrowRefStr);
acquiredRef = null;
return jsonError(res, 504, "claim_timeout",
`Claim awaitTx failed: ${(claimOutcome.err as Error).message}`);
}
// ── 10. Spawn runner + 202 ──────────────────────────────────────
const claimedRef: OutputReference = {
txHash: claimOutcome.built.expectedTxHash,
index: 0,
};
const jobId = deps.jobs.create(escrowRefStr);
// Slot release now happens inside runOcrJob's finally.
acquiredRef = null;
void runOcrJob({
deps: {
chain: deps.chain,
state: deps.state,
config: deps.config,
supplierKey: deps.supplierKey,
jobs: deps.jobs,
},
jobId,
escrowRef: escrowRefStr,
claimedRef,
advert,
escrowDatum,
requestBody: ocrRequest,
});
return res.status(202).json({
job_id: jobId,
status: "accepted",
escrow_ref: escrowRefStr,
});
} catch (err) {
if (acquiredRef) {
try { deps.state.release(acquiredRef); } catch { /* ignore */ }
}
next(err);
return;
}
};
}
// ─── GET /v1/ocr/extract/:jobId ─────────────────────────────────────────────
function makeGetOcrJobHandler(deps: ResolvedDeps) {
return (req: Request, res: Response) => {
const rawJobId = req.params.jobId;
const jobId = typeof rawJobId === "string" ? rawJobId : "";
if (!UUID_V4_RE.test(jobId)) {
return jsonError(res, 400, "invalid_job_id", "jobId must be a UUIDv4 string");
}
const record = deps.jobs.get(jobId);
if (!record) {
return jsonError(res, 404, "job_not_found", `no job found with id ${jobId}`);
}
res.setHeader("Content-Type", "application/json");
if (record.status === "accepted" || record.status === "running") {
return res.status(202).json({
status: record.status,
escrow_ref: record.escrowRef,
});
}
if (record.status === "done") {
const payload = record.responsePayload!;
if (!("kind" in payload) || payload.kind !== "ocr") {
return jsonError(res, 500, "wrong_payload_kind",
"non-ocr payload returned to ocr poll route");
}
return res.status(200).json({
output_format: payload.output_format,
content: payload.content,
usage: payload.usage,
receipt: payload.receipt,
receipt_signature: payload.receipt_signature,
escrow_ref: record.escrowRef,
});
}
// failed
const f = record.failure!;
return res.status(f.httpStatus).json({
status: "failed",
reason: f.reason,
message: f.message,
escrow_ref: record.escrowRef,
});
};
}
// ─── /v1/chat/{start,message,end} — multi-turn chat session (llm.chat.v1) ───
//
// Escrow bookends with off-chain turns:
// start → validate (mirror makeChatHandler 1-7, but no messages) → Claim
// (Open→Claimed) → create session + arm idle/hard-cap watchdog → 200
// message → SSE stream a turn via callOpenAiStream; zero chain interaction
// end → endChatSession (Submit transcript receipt; Claimed→Submitted) → 200
//
// The buyer then Accepts off this route (server-side, in the buyer-app),
// which is when the user is actually charged. Single-slot: the SupplierState
// mutex is held from Claim (start) to Submit (end), so one paid chat at a time.
interface ChatStartBody {
model?: unknown;
session_nonce?: unknown;
}
function makeChatSessionHandlers(deps: ResolvedDeps) {
const endDeps: EndChatSessionDeps = {
chain: deps.chain,
state: deps.state,
config: deps.config,
supplierKey: deps.supplierKey,
jobs: deps.jobs,
sessions: deps.chatSessions,
};
function armIdleTimer(record: ChatSessionRecord): void {
if (record.idleTimer) clearTimeout(record.idleTimer);
record.idleTimer = setTimeout(() => {
void endChatSession({ deps: endDeps, escrowRef: record.escrowRef, trigger: "idle" });
}, deps.config.chatIdleTimeoutMs);
record.idleTimer.unref?.();
}
function armHardCapTimer(record: ChatSessionRecord, nowMs: number): void {
// Force-end before deliver_by so the Submit tx (60s awaitTx budget) lands
// in time. 90s margin = Submit awaitTx + buffer.
const margin = 90_000;
const capMs = Math.max(1_000, record.escrowDatum.deliver_by - nowMs - margin);
record.hardCapTimer = setTimeout(() => {
void endChatSession({ deps: endDeps, escrowRef: record.escrowRef, trigger: "hard-cap" });
}, capMs);
record.hardCapTimer.unref?.();
}
const start = async (req: Request, res: Response, next: NextFunction) => {
// Slot held by THIS request (null until acquired / after the session
// record takes ownership) — the catch-all only releases its own.
let acquiredRef: string | null = null;
try {
// ── 1. Header ───────────────────────────────────────────────────
const headerVal = req.header("X-Escrow-Ref");
if (!headerVal) {
return jsonError(res, 400, "escrow_ref_required", "X-Escrow-Ref header is required");
}
const escrowRef = parseEscrowRef(headerVal);
if (escrowRef === null) {
return jsonError(res, 400, "escrow_ref_malformed", 'X-Escrow-Ref must match "<64-hex>#<int>"');
}
const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`;
// ── 1.5 Duplicate-session guard ───────────────────────────────────
// Ended records are retained, so this also blocks replaying a used
// ticket-mode escrow (which stays Open on-chain — the state check below
// wouldn't catch it). In full mode the on-chain Claim already prevents
// reuse; this check just fails faster.
if (deps.chatSessions.get(escrowRefStr)) {
return jsonError(res, 409, "session_exists", "a chat session already exists for this escrow ref");
}
// ── 2. Body (session_nonce; no messages) ─────────────────────────
const body = (req.body ?? {}) as ChatStartBody;
const sessionNonce = typeof body.session_nonce === "string" ? body.session_nonce : "";
if (sessionNonce.length === 0) {
return jsonError(res, 400, "session_nonce_required", "body.session_nonce must be a non-empty string");
}
// ── 3. Advert ───────────────────────────────────────────────────
const advertResult = await fetchActiveAdvert(deps);
if ("error" in advertResult) {
const e = advertResult.error;
return jsonError(res, e.status, e.reason, e.message);
}
const advert = advertResult.datum;
// ── 4. Escrow ───────────────────────────────────────────────────
const escrowUtxo = await deps.chain.queryUtxo(escrowRef);
if (escrowUtxo === null || !escrowUtxo.datumHex) {
return jsonError(res, 404, "escrow_not_found", `escrow UTxO ${escrowRefStr} not found on chain`);
}
let escrowDatum: EscrowDatum;
try {
escrowDatum = decodeEscrowDatum(escrowUtxo.datumHex);
} catch (err) {
return jsonError(res, 404, "escrow_decode_failed", (err as Error).message);
}
// ── 5. State / identity / capability ─────────────────────────────
if (escrowDatum.state !== "Open") {
return jsonError(res, 409, "escrow_not_claimable", `escrow state is ${escrowDatum.state}, expected Open`);
}
if (escrowDatum.supplier_pkh !== deps.supplierKey.pubKeyHash) {
return jsonError(res, 403, "wrong_supplier", "escrow supplier_pkh does not match this node");
}
if (escrowDatum.capability_id !== advert.capability_id) {
return jsonError(res, 409, "capability_mismatch",
`escrow capability ${escrowDatum.capability_id} != advert ${advert.capability_id}`);
}
// ── 6. Hash checks (request spec + session-init prompt) ──────────
const expectedRequestSpecHash = sha256Hex(canonicalize({
capability_id: advert.capability_id,
max_output_tokens: advert.max_output_tokens,
model: advert.model,
}));
if (escrowDatum.request_spec_hash !== expectedRequestSpecHash) {
return jsonError(res, 409, "request_spec_mismatch", "request_spec_hash in escrow does not match advert spec");
}
const expectedPromptHash = chatSessionPromptHash({ session_nonce: sessionNonce });
if (escrowDatum.prompt_hash !== expectedPromptHash) {
return jsonError(res, 409, "prompt_mismatch", "prompt_hash in escrow does not match session_nonce");
}
// ── 7. Deadline ──────────────────────────────────────────────────
const tipSlot = await deps.chain.tip();
const isLive = detectCborBackend(deps.chain) === "live";
const nowMs = isLive
? Date.now()
: Math.max(mockSlotToWallclockMs(tipSlot), escrowDatum.posted_at);
if (nowMs >= escrowDatum.deliver_by) {
return jsonError(res, 408, "past_deliver_by", `now ${nowMs} >= deliver_by ${escrowDatum.deliver_by}`);
}
// ── 8. Acquire session slot ──────────────────────────────────────
if (!deps.state.tryAcquire(escrowRefStr)) {
return jsonError(res, 409, "supplier_busy", "supplier is already working another chat");
}
acquiredRef = escrowRefStr;
if (deps.config.chatSettleMode === "ticket") {
// ── 9-ticket. No Claim: the verified Open escrow IS the entry
// ticket. No hard-cap timer either — there is no Submit deadline, so
// the session may outlive deliver_by (the buyer reclaims the escrow
// independently). The idle timer is the only lifetime bound.
const record = deps.chatSessions.create({
escrowRef: escrowRefStr,
settleMode: "ticket",
advert,
escrowDatum,
});
acquiredRef = null; // record owns the slot; endChatSession releases it
armIdleTimer(record);
return res.status(200).json({ status: "ticket", escrow_ref: escrowRefStr, settle_mode: "ticket" });
}
// ── 9-full. Claim (Open → Claimed): build + confirm as ONE wallet
// critical section — hold the slot on success through Submit at end.
const claimOutcome = await deps.state.walletMutex.run(async () => {
let built;
try {
built = await buildClaimTx({ chain: deps.chain, supplierKey: deps.supplierKey, escrowRef });
} catch (err) {
return { kind: "build_failed" as const, err };
}
try {
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
} catch (err) {
return { kind: "await_failed" as const, err };
}
return { kind: "ok" as const, built };
});
if (claimOutcome.kind === "build_failed") {
deps.state.release(escrowRefStr);
acquiredRef = null;
triggerOnFailureConsolidate({ chain: deps.chain, state: deps.state, supplierKey: deps.supplierKey });
return jsonError(res, 503, "chain_submit_failed", `Claim tx submit failed: ${(claimOutcome.err as Error).message}`);
}
if (claimOutcome.kind === "await_failed") {
deps.state.release(escrowRefStr);
acquiredRef = null;
return jsonError(res, 504, "claim_timeout", `Claim awaitTx failed: ${(claimOutcome.err as Error).message}`);
}
// ── 10. Create session + arm watchdog ────────────────────────────
const claimedRef: OutputReference = { txHash: claimOutcome.built.expectedTxHash, index: 0 };
const record = deps.chatSessions.create({ escrowRef: escrowRefStr, claimedRef, settleMode: "full", advert, escrowDatum });
acquiredRef = null; // record owns the slot; endChatSession releases it
armIdleTimer(record);
armHardCapTimer(record, nowMs);
return res.status(200).json({ status: "claimed", escrow_ref: escrowRefStr });
} catch (err) {
if (acquiredRef) {
try { deps.state.release(acquiredRef); } catch { /* ignore */ }
}
next(err);
return;
}
};
const message = async (req: Request, res: Response, next: NextFunction) => {
try {
const headerVal = req.header("X-Escrow-Ref");
if (!headerVal) {
return jsonError(res, 400, "escrow_ref_required", "X-Escrow-Ref header is required");
}
const escrowRef = parseEscrowRef(headerVal);
if (escrowRef === null) {
return jsonError(res, 400, "escrow_ref_malformed", 'X-Escrow-Ref must match "<64-hex>#<int>"');
}
const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`;
const record = deps.chatSessions.get(escrowRefStr);
if (!record || record.status !== "active") {
return jsonError(res, 404, "chat_session_not_found",
`no active chat session for ${escrowRefStr}`);
}
// Two body shapes: legacy {content} (single user turn) and
// {messages, tools?, tool_choice?} (multi-message delta — e.g. tool
// results — appended to the transcript as one turn).
const body = (req.body ?? {}) as {
content?: unknown;
messages?: unknown;
tools?: unknown;
tool_choice?: unknown;
};
let delta: ChatMessage[];
if (Array.isArray(body.messages)) {
const normalized: ChatMessage[] = [];
for (const raw of body.messages) {
const msg = normalizeChatMessage(raw);
if (!msg) {
return jsonError(res, 400, "invalid_messages",
"body.messages must be OpenAI-shaped {role, content, tool_calls?, tool_call_id?} objects");
}
normalized.push(msg);
}
const last = normalized[normalized.length - 1];
if (!last || (last.role !== "user" && last.role !== "tool")) {
return jsonError(res, 400, "invalid_messages",
"body.messages must end with a user or tool message");
}
delta = normalized;
} else {
const content = typeof body.content === "string" ? body.content : "";
if (content.length === 0) {
return jsonError(res, 400, "content_required", "body.content must be a non-empty string");
}
delta = [{ role: "user", content }];
}
const tools = Array.isArray(body.tools) && body.tools.length > 0 ? body.tools : undefined;
const toolChoice = tools !== undefined ? body.tool_choice : undefined;
// Pause the idle timer while a turn is in flight; re-arm when it ends so
// a long generation never trips the auto-end mid-stream.
if (record.idleTimer) { clearTimeout(record.idleTimer); record.idleTimer = undefined; }
const transcriptLenBefore = record.transcript.length;
deps.chatSessions.appendMessages(escrowRefStr, delta);
res.status(200);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
if (typeof (res as Response & { flushHeaders?: () => void }).flushHeaders === "function") {
(res as Response & { flushHeaders: () => void }).flushHeaders();
}
const sse = (frame: Record<string, unknown>): void => {
res.write(`data: ${JSON.stringify(frame)}\n\n`);
};
try {
const result = await callOpenAiStream(
{
baseUrl: deps.config.openaiBaseUrl,
model: record.advert.model,
messages: record.transcript,
timeoutMs: deps.config.openaiTimeoutMs,
apiKey: deps.config.openaiApiKey,
maxTokens: deps.config.openaiMaxTokens,
disableReasoning: deps.config.openaiReasoningDisabled,
tools,
toolChoice,
},
(tok) => sse({ type: "token", value: tok }),
);
// Build the assistant message once and send it VERBATIM in the done
// frame: the gateway mirrors this object so both transcripts stay
// hash-identical for the receipt (field presence matters).
const assistantMsg: ChatMessage = { role: "assistant", content: result.content };
if (result.tool_calls && result.tool_calls.length > 0) assistantMsg.tool_calls = result.tool_calls;
deps.chatSessions.appendAssistant(escrowRefStr, assistantMsg, {
prompt_tokens: result.prompt_tokens,
completion_tokens: result.completion_tokens,
});
const finishReason =
result.finish_reason === "tool_calls" || (result.tool_calls?.length ?? 0) > 0
? "tool_calls"
: "stop";
sse({
type: "done",
message: assistantMsg,
finish_reason: finishReason,
usage: { prompt_tokens: result.prompt_tokens, completion_tokens: result.completion_tokens },
});
} catch (err) {
// Failed turn: the gateway mirror never saw this delta, so drop it here
// too — otherwise a retry re-sends it and the transcripts diverge.
deps.chatSessions.truncateTranscript(escrowRefStr, transcriptLenBefore);
const errMsg = err instanceof Error ? err.message : String(err);
sse({ type: "error", message: errMsg });
} finally {
if (record.status === "active") armIdleTimer(record);
res.end();
}
return;
} catch (err) {
next(err);
return;
}
};
const end = async (req: Request, res: Response, next: NextFunction) => {
try {
const headerVal = req.header("X-Escrow-Ref");
if (!headerVal) {
return jsonError(res, 400, "escrow_ref_required", "X-Escrow-Ref header is required");
}
const escrowRef = parseEscrowRef(headerVal);
if (escrowRef === null) {
return jsonError(res, 400, "escrow_ref_malformed", 'X-Escrow-Ref must match "<64-hex>#<int>"');
}
const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`;
if (!deps.chatSessions.get(escrowRefStr)) {
return jsonError(res, 404, "chat_session_not_found", `no chat session for ${escrowRefStr}`);
}
const record = await endChatSession({ deps: endDeps, escrowRef: escrowRefStr, trigger: "end" });
if (!record) {
return jsonError(res, 404, "chat_session_not_found", `no chat session for ${escrowRefStr}`);
}
if (record.settleMode === "ticket") {
// No receipt/Submit in ticket mode — the buyer reclaims the escrow.
// Idempotent: a re-end returns the same shape.
return res.status(200).json({ status: "ended", escrow_ref: escrowRefStr, settle_mode: "ticket" });
}
if (record.endFailure) {
return jsonError(res, 502, record.endFailure.reason, record.endFailure.message);
}
if (!record.endResult) {
return jsonError(res, 500, "chat_end_incomplete", "session ended without a receipt");
}
return res.status(200).json({
status: "submitted",
escrow_ref: escrowRefStr,
submitted_ref: record.endResult.submitted_ref,
receipt: record.endResult.receipt,
receipt_signature: record.endResult.receipt_signature,
});
} catch (err) {
next(err);
return;
}
};
return { start, message, end };
}
// ─── App factory ───────────────────────────────────────────────────────────
export function createApp(deps: SupplierDeps): Application {
const resolved: ResolvedDeps = {
chain: deps.chain,
state: deps.state,
config: deps.config,
supplierKey: deps.supplierKey,
jobs: deps.jobs ?? new JobStore(),
chatSessions: deps.chatSessions ?? new ChatSessionStore(),
};
const app = express();
app.disable("x-powered-by");
// /v1/ocr carries a base64 page image and mounts its own larger parser
// below; every other route keeps the 1mb ceiling.
const jsonBody = express.json({ limit: "1mb" });
app.use((req, res, next) =>
req.path.startsWith("/v1/ocr") ? next() : jsonBody(req, res, next));
// /healthz is mounted FIRST and uses no deps — it must succeed regardless
// of chain/state/config (independent of /status free/working/offline).
app.use(healthzRouter());
app.get("/capability", makeCapabilityHandler(resolved));
app.get("/status", makeStatusHandler(resolved));
// Dispatch by configured capability. The supplier is a single-capability
// process — it advertises one on-chain capability_id and serves one route
// shape. Hard-mounting the wrong route would let a misconfigured client
// hang on a request that's guaranteed to fail capability validation; we'd
// rather it 404 immediately. (The on-chain capability_id check still runs
// on every request, so this is belt-and-braces.)
if (resolved.config.capabilityKind === "tts") {
app.post("/v1/audio/synthesize", makeTtsHandler(resolved));
app.get("/v1/audio/synthesize/:jobId", makeGetTtsJobHandler(resolved));
} else if (resolved.config.capabilityKind === "ocr") {
// MAX_OCR_IMAGE_B64_CHARS (~12M chars ≈ 9 MB binary) + JSON envelope
// fits comfortably under 16mb.
app.post("/v1/ocr/extract", express.json({ limit: "16mb" }), makeOcrHandler(resolved));
app.get("/v1/ocr/extract/:jobId", makeGetOcrJobHandler(resolved));
} else if (resolved.config.capabilityKind === "chat-session") {
const chat = makeChatSessionHandlers(resolved);
app.post("/v1/chat/start", chat.start);
app.post("/v1/chat/message", chat.message);
app.post("/v1/chat/end", chat.end);
} else {
app.post("/v1/chat/completions", makeChatHandler(resolved));
app.get("/v1/chat/completions/:jobId", makeGetJobHandler(resolved));
}
// Centralised error handler (4 args required by Express).
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
void _next;
const message = err instanceof Error ? err.message : String(err);
if (!res.headersSent) {
res.status(500).json({
reason: "internal_error",
message,
error: { reason: "internal_error", message },
});
}
});
return app;
}