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>
151 lines
5.7 KiB
TypeScript
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 !== ""),
|
|
};
|
|
}
|