Files
agents-marketplace/gateway/src/config.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

151 lines
5.7 KiB
TypeScript

/**
* gateway/src/config.ts — env loading for the OpenAI-compatible gateway.
*
* Mirrors buyer/src/config.ts conventions (requireField, HEX64_RE, LIVE_CHAIN
* semantics) but for a multi-tenant, custodial service. See docs/gateway.md.
*
* Required:
* GATEWAY_MASTER_KEY — 64-char hex (32 bytes). AES-256-GCM key wrapping every
* user's custodial wallet private key at rest. Losing it
* permanently locks all user funds.
* INDEXER_URL — internal indexer base URL.
* OGMIOS_URL — Ogmios HTTP endpoint (required; the gateway submits txs).
*
* Optional:
* LIVE_CHAIN — must be "1" (the gateway always needs a live provider).
* NETWORK_ID — "0" testnet (default) | "1" mainnet.
* GATEWAY_PORT — default 3010.
* GATEWAY_DB_DIR — default "./data/gateway".
* SIGNUP_RATE_MAX / SIGNUP_RATE_WINDOW_MS — per-IP signup limiter.
* KEY_RATE_MAX / KEY_RATE_WINDOW_MS — per-key request limiter.
* SWEEPER_INTERVAL_MS / WALLET_HEALTH_INTERVAL_MS — background tick cadences.
* SDK_REGISTRY_MAX — max cached per-key Marketplace instances (LRU).
* GATEWAY_CORS_ORIGINS — comma-separated allowlist of browser Origins that may
* call the gateway cross-origin (e.g. the marketplace
* frontend's "Generate API key" page). Default empty =
* CORS off (no Access-Control-* headers emitted).
*/
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
const POS_INT_RE = /^[1-9]\d*$/;
export interface GatewayConfig {
masterKeyHex: string;
indexerUrl: string;
ogmiosUrl: string;
networkId: 0 | 1;
liveChain: boolean;
port: number;
dbDir: string;
signupRate: { max: number; windowMs: number };
keyRate: { max: number; windowMs: number };
demoIpRate: { max: number; windowMs: number };
sweeperIntervalMs: number;
/** Close demo chat sessions idle this long (frees the supplier slot).
* Keep below the supplier's CHAT_IDLE_TIMEOUT_MS so the gateway closes
* first and records usage. */
demoSessionIdleMs: number;
/** Chat-session settlement mode this deployment runs. "full" = suppliers
* Claim/Submit and the gateway Accepts; "ticket" = the escrow post at open
* is the only chain op and the sweeper Reclaims it after deliver_by.
* Must agree with the suppliers' CHAT_SETTLE_MODE. */
chatSettleMode: "full" | "ticket";
walletHealthIntervalMs: number;
sdkRegistryMax: number;
corsOrigins: string[];
/** Capability id the /v1/ocr/extract route escrows against. Model-scoped
* per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */
ocrCapabilityId: string;
}
function requireField(env: Record<string, string | undefined>, name: string): string {
const v = env[name];
if (v === undefined || v === null || v === "") {
throw new Error(`loadConfig: missing required env var ${name}`);
}
return v;
}
function chatSettleMode(env: Record<string, string | undefined>): "full" | "ticket" {
const v = env.CHAT_SETTLE_MODE ?? "full";
if (v !== "full" && v !== "ticket") {
throw new Error('loadConfig: CHAT_SETTLE_MODE must be "full" or "ticket"');
}
return v;
}
function posInt(
env: Record<string, string | undefined>,
name: string,
fallback: number,
): number {
const v = env[name];
if (v === undefined || v === "") return fallback;
if (!POS_INT_RE.test(v)) {
throw new Error(`loadConfig: ${name} must be a positive integer`);
}
return Number(v);
}
export function loadConfig(env: Record<string, string | undefined>): GatewayConfig {
const masterKeyHex = requireField(env, "GATEWAY_MASTER_KEY");
if (!HEX64_RE.test(masterKeyHex)) {
throw new Error(
"loadConfig: GATEWAY_MASTER_KEY must be 64 hex chars (32 bytes); generate with: openssl rand -hex 32",
);
}
const indexerUrl = requireField(env, "INDEXER_URL").replace(/\/+$/, "");
// The gateway always posts/settles escrows, so a live provider is mandatory.
const liveChain = env.LIVE_CHAIN === "1";
const ogmiosUrl = env.OGMIOS_URL ?? "";
if (!liveChain) {
throw new Error("loadConfig: LIVE_CHAIN=1 is required (the gateway submits transactions)");
}
if (ogmiosUrl === "") {
throw new Error("loadConfig: OGMIOS_URL is required when LIVE_CHAIN=1");
}
let networkId: 0 | 1 = 0;
const networkIdStr = env.NETWORK_ID;
if (networkIdStr !== undefined && networkIdStr !== "") {
if (networkIdStr !== "0" && networkIdStr !== "1") {
throw new Error('loadConfig: NETWORK_ID must be "0" (testnet) or "1" (mainnet)');
}
networkId = networkIdStr === "1" ? 1 : 0;
}
return {
masterKeyHex,
indexerUrl,
ogmiosUrl,
networkId,
liveChain,
port: posInt(env, "GATEWAY_PORT", 3010),
dbDir: env.GATEWAY_DB_DIR ?? "./data/gateway",
signupRate: {
max: posInt(env, "SIGNUP_RATE_MAX", 5),
windowMs: posInt(env, "SIGNUP_RATE_WINDOW_MS", 60 * 60 * 1000),
},
keyRate: {
max: posInt(env, "KEY_RATE_MAX", 60),
windowMs: posInt(env, "KEY_RATE_WINDOW_MS", 60 * 1000),
},
demoIpRate: {
max: posInt(env, "DEMO_IP_RATE_MAX", 20),
windowMs: posInt(env, "DEMO_IP_RATE_WINDOW_MS", 60 * 1000),
},
sweeperIntervalMs: posInt(env, "SWEEPER_INTERVAL_MS", 60 * 1000),
demoSessionIdleMs: posInt(env, "DEMO_SESSION_IDLE_MS", 3 * 60 * 1000),
chatSettleMode: chatSettleMode(env),
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
.split(",")
.map((o) => o.trim().replace(/\/+$/, ""))
.filter((o) => o !== ""),
};
}