UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

182 lines (181 loc) 9.81 kB
/** * # client/platform-wire — the ONE platform HTTP/SSE transport dialect (kestrel-z473.2). * * The four HTTP faces of the managed service — the CLI's {@link ../cli/backend/remote.ts * RemoteBackend}, the CLI's {@link ../cli/backend/hosted.ts HostedFunnel}, the SDK's * {@link ../sdk/remote.ts remoteTransport} (via the 109 client), and the library-safe * {@link ./index.ts KestrelClient} — used to each hand-decode the same platform wire: * the single {@link DEFAULT_API}, the deterministic Idempotency-Key, the JSON/auth * headers, the structured 402/Offer surfaced as DATA, the trial-capability mint, and the * Operation SSE stream (framing lives in {@link ./sse-transport.ts}; the OperationEvent * decode/accumulate loop was copied verbatim into two faces). This module owns that * dialect ONCE so a change is a single edit and no face can silently re-fabricate it. * * The wire SHAPES + pure decoders stay in {@link ../protocol/wire.ts} (OSS-ADR-0046: the * protocol package owns the shapes and ships zero runtime deps). This is the TRANSPORT * layer over those shapes: the fetch plumbing, header construction, and stream handling * every face shares. * * ERROR-AGNOSTIC (like {@link ./sse-transport.ts}): the CLI faces fail closed as * `CliError`, the library faces as `KestrelClientError`, so every helper that can fail * takes the caller's error factory INJECTED (`httpError` for a synchronous status→error, * `onHttpError` for a `Response`→error that may read a problem+json body). This module * holds no error type of its own and imports NOTHING from `src/cli` — it stays inside the * `types:[]` light-build closure the published `./client` / `./sdk` faces depend on * (pinned by tests/package.boundary.test.ts). * * Determinism (ADR-0009): the Idempotency-Key is a pure sha256 of the canonical request, * so a replay is effect-once with no wall clock / RNG. node ≥18 + bun + Workers: web * `fetch`/`ReadableStream`/`TextDecoder` only. */ import { asOfferResponse, decodeTrialCapability, isSseEventType, isTerminalSseEvent, } from "../protocol/wire.js"; import { consumeSseStream } from "./sse-transport.js"; import { sha256 } from "../crypto/sha256.js"; /** The single canonical managed API base. A bare/empty base resolves here. Every face * re-exports THIS constant rather than defining its own copy. */ export const DEFAULT_API = "https://api.kestrel.markets"; /* ─────────────────────────────── headers ───────────────────────────────── */ /** The JSON request headers every control-plane POST/GET carries. */ export function jsonHeaders() { return { "content-type": "application/json", accept: "application/json" }; } /** The `Authorization` header for a static bearer (a REGISTERED credential, a pre-seeded * capability, or a lazily-minted anon trial). Absent bearer ⇒ `{}` (no header), never a * fabricated token. The keypair-JWT path is a per-face concern (it mints a fresh token * per call) and is layered on top by the caller. */ export function bearerHeader(bearer) { return bearer !== undefined ? { authorization: `Bearer ${bearer}` } : {}; } /** * A DETERMINISTIC Idempotency-Key: a sha256 of the canonical request (method, path, * body). The identical request yields the identical key → effect-once on replay * (ADR-0009), with no wall clock / RNG on the runtime path. ≥8 chars (openapi). This is * the ONE definition — the CLI and library faces were byte-identical copies of it, so a * cross-face replay is effect-once interchangeable. */ export function idemKey(method, path, body) { return "idem_" + sha256(`${method} ${path}\n${JSON.stringify(body)}`).slice(0, 40); } /* ─────────────────────── 402 / validate response decode ─────────────────── */ /** * Decode a structured 402 body → {@link WireOfferResponse}, fail-closed via the injected * `httpError`. NEVER opens a browser / follows `human_action.url` — the Offer is returned * as DATA (ADR-0004). A body that is not JSON, or is JSON that is not a real OfferResponse, * is a loud error, never a silent default. */ export async function decodeOfferResponse(res, httpError) { let body; try { body = await res.json(); } catch { throw httpError(402, "402 body was not a structured OfferResponse"); } const offer = asOfferResponse(body); if (offer === null) throw httpError(402, "402 body missing operation_id/offer{offer_id,scope,amount}/settlement_methods"); return offer; } /** * Read the `diagnostics[]` off a 422 validate response into human strings. Fail-safe: a * non-JSON / absent body degrades to `["validation_failed"]`, never a throw (a validate * failure must surface `ok:false` + diagnostics, never a 200 hiding a silent default). */ export async function parseValidateDiagnostics(res) { try { const body = (await res.json()); return (body.diagnostics ?? []).map((d) => `${d.severity ?? "error"} ${d.code ?? ""}: ${d.message ?? ""}`.trim()); } catch { return ["validation_failed"]; } } /** * `POST /capabilities/trial` — mint an anonymous trial capability (proof-before-account, * ADR-0009). Effectful → carries a deterministic Idempotency-Key. The bearer token * (openapi `capability`) authorizes every subsequent call. The caller owns caching (it * sets `this.cap`/`this.bearer`) and the error type (`onHttpError` builds it). No trial is * ever minted here except by an explicit call — a face short-circuits when it already * holds a bearer. */ export async function mintTrialCapability(opts) { const body = {}; const res = await opts.fetch(`${opts.base}/capabilities/trial`, { method: "POST", headers: { ...jsonHeaders(), "idempotency-key": idemKey("POST", "/capabilities/trial", body) }, body: JSON.stringify(body), }); if (!res.ok) throw await opts.onHttpError(res, "trial capability mint failed"); const wire = (await res.json()); return { capability: decodeTrialCapability(wire), bearer: wire.capability }; } /* ──────────────────────── effectful control-plane POST ──────────────────── */ /** * An effectful control-plane POST (`/sim`, `/grade`) with the deterministic * Idempotency-Key and structured-402 gating. The caller pre-resolves its `authHeaders` * (so the jwtSigner / anon-mint timing stays a per-face concern) and injects both error * factories: `httpError` for the 402 Offer decode, `onHttpError` for a non-2xx. A 402 is * surfaced as DATA on {@link Gated402}; anything else off the happy path is a loud throw. */ export async function postEffectful(opts) { const res = await opts.fetch(`${opts.base}${opts.path}`, { method: "POST", headers: { ...jsonHeaders(), ...opts.authHeaders, "idempotency-key": idemKey("POST", opts.path, opts.body) }, body: JSON.stringify(opts.body), }); if (res.status === 402) return { gated: true, payment: await decodeOfferResponse(res, opts.httpError) }; if (!res.ok) throw await opts.onHttpError(res, `POST ${opts.path} failed`); return { gated: false, value: (await res.json()) }; } /** * Read an Operation's canonical event stream (`GET /operations/{id}/events`) to a terminal * event, accumulating the domain payload from `operation.artifact` / `operation.receipt` * frames. The framing + opaque-cursor resume loop is the shared leaf * ({@link consumeSseStream}); this supplies the OperationEvent vocabulary, the per-frame * decode/accumulate, and the optional observer. An SSE event whose `type` is outside the * OperationEvent enum is a protocol violation → FAIL CLOSED (via `unknownEventError`); * `operation.failed`, a non-JSON `data:`, a bad status, or budget exhaustion are loud * errors (via `httpError`), never a silent empty payload. */ export async function consumeOperationStream(opts) { const payload = {}; await consumeSseStream({ fetch: opts.fetch, endpoint: `${opts.base}/operations/${encodeURIComponent(opts.operationId)}/events`, cursor: opts.cursor, // opaque STRING, handed back verbatim (never `.token` of a JSON object) ...(opts.headers !== undefined ? { headers: opts.headers } : {}), isEventType: isSseEventType, httpError: opts.httpError, unknownEventError: opts.unknownEventError, onFrame: (type, data, cursor) => { let env; if (data !== "") { try { env = JSON.parse(data); } catch { throw opts.httpError(502, `SSE ${type} carried non-JSON data`); } } if (opts.onEvent) opts.onEvent({ type: type, operationId: env?.operation_id ?? opts.operationId, cursor: env?.cursor ?? cursor, // the resume bookmark at this frame, envelope override wins ...(env?.data !== undefined ? { data: env.data } : {}), }); if ((type === "operation.artifact" || type === "operation.receipt") && env?.data) Object.assign(payload, env.data); if (isTerminalSseEvent(type)) { if (type === "operation.failed") throw opts.httpError(502, "operation failed"); return { done: true }; } return env?.cursor ? { cursor: env.cursor } : {}; }, }); return { payload }; }