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.
274 lines (251 loc) • 13.4 kB
text/typescript
/**
* # 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 type { TrialCapability } from "../protocol/index.ts";
import {
type WireTrialCapability,
type WireOperation,
type WireOfferResponse,
type WireOperationEvent,
type SseEventType,
asOfferResponse,
decodeTrialCapability,
isSseEventType,
isTerminalSseEvent,
} from "../protocol/wire.ts";
import { consumeSseStream } from "./sse-transport.ts";
import type { FetchLike } from "./sse-transport.ts";
import { sha256 } from "../crypto/sha256.ts";
export type { FetchLike };
/** 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";
/** A synchronous status→error factory (fail-closed). The CLI supplies one building
* `CliError`; the library faces one building `KestrelClientError`. */
export type HttpErrorFactory = (status: number, message: string) => Error;
/** An async `Response`→error factory: the non-2xx path some faces enrich with the
* server's `application/problem+json` diagnostics (the library client) or leave bare
* (the CLI). It never throws inside error handling — it returns the error to throw. */
export type ResponseErrorFactory = (res: Response, message: string) => Promise<Error>;
/** A control-plane result that may be gated behind a structured 402/Offer, surfaced as
* DATA (ADR-0004) — never a thrown error, never a browser redirect. Structurally the
* `Gated<T>` both the CLI backend and the library client define, over the ONE
* {@link WireOfferResponse}. */
export type Gated402<T> =
| { readonly gated: false; readonly value: T }
| { readonly gated: true; readonly payment: WireOfferResponse };
/* ─────────────────────────────── headers ───────────────────────────────── */
/** The JSON request headers every control-plane POST/GET carries. */
export function jsonHeaders(): Record<string, string> {
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: string | undefined): Record<string, string> {
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: string, path: string, body: unknown): string {
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: Response, httpError: HttpErrorFactory): Promise<WireOfferResponse> {
let body: unknown;
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: Response): Promise<string[]> {
try {
const body = (await res.json()) as {
diagnostics?: readonly { severity?: string; code?: string; message?: string }[];
};
return (body.diagnostics ?? []).map((d) =>
`${d.severity ?? "error"} ${d.code ?? ""}: ${d.message ?? ""}`.trim(),
);
} catch {
return ["validation_failed"];
}
}
/* ──────────────────────── trial-capability mint ─────────────────────────── */
/** The decoded result of a trial mint: the OSS {@link TrialCapability} plus the raw
* bearer token (`capability`) the caller caches for its `Authorization` header. */
export interface MintedTrial {
readonly capability: TrialCapability;
readonly bearer: string;
}
/**
* `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: {
readonly fetch: FetchLike;
readonly base: string;
readonly onHttpError: ResponseErrorFactory;
}): Promise<MintedTrial> {
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()) as WireTrialCapability;
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: {
readonly fetch: FetchLike;
readonly base: string;
readonly path: string;
readonly body: unknown;
readonly authHeaders: Record<string, string>;
readonly httpError: HttpErrorFactory;
readonly onHttpError: ResponseErrorFactory;
}): Promise<Gated402<WireOperation>> {
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()) as WireOperation };
}
/* ─────────────────────────── operation SSE stream ───────────────────────── */
/** A single SSE frame surfaced to an {@link ConsumeOperationOptions.onEvent} observer. */
export interface OperationEventView {
readonly type: SseEventType;
readonly operationId: string;
/** The opaque resume cursor at this frame. */
readonly cursor: string;
/** The opaque domain payload carried on artifact/receipt frames. */
readonly data?: Record<string, unknown>;
}
export interface ConsumeOperationOptions {
readonly fetch: FetchLike;
/** The API base (trailing slashes already trimmed). */
readonly base: string;
/** The Operation whose canonical event stream is read to its terminal event. */
readonly operationId: string;
/** The initial opaque cursor (the Operation's `cursor`), handed back verbatim. */
readonly cursor: string;
/** Auth (and any other) headers for the events GET. */
readonly headers?: Record<string, string>;
readonly httpError: HttpErrorFactory;
readonly unknownEventError: (type: string) => Error;
/** Observe each in-vocabulary frame as it arrives (progress/artifact/…). Optional. */
readonly onEvent?: (event: OperationEventView) => void;
}
/**
* 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: ConsumeOperationOptions,
): Promise<{ payload: Record<string, unknown> }> {
const payload: Record<string, unknown> = {};
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: WireOperationEvent | undefined;
if (data !== "") {
try {
env = JSON.parse(data) as WireOperationEvent;
} catch {
throw opts.httpError(502, `SSE ${type} carried non-JSON data`);
}
}
if (opts.onEvent)
opts.onEvent({
type: type as SseEventType,
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 };
}