/** * `http` — bearer-auth API-client kit. * * Consolidates the ~100-line `client.ts` boilerplate copy-pasted across ~8 MCP * servers (splitwise/tempo/ioffice/app-store-connect/zola): a bearer-auth fetch * wrapper with one-shot 429 retry, 401 mapping, 204 handling, and redacted error * formatting — plus the small URL/JWT/cookie utilities scattered across the * fleet (canvas Link-header parsing, IC/signupgenius cookie jars, zola JWT * decode). * * Security posture (design §"Bearer-token / error-message leakage"): * - {@link formatApiError} runs every upstream body through the shared * {@link truncateErrorMessage} (redaction THEN truncation) so bearer tokens * and JWTs never reach a tool result, even when an upstream echoes the * request back. * - {@link createApiClient} never embeds the token in a thrown message; a 401 * yields a fixed "unauthorized" string, not the credential. */ export * from './throttle.js'; export * from './response-cache.js'; export * from './net-atoms.js'; /** Retry policy for transient responses (HTTP 429 by default). */ export interface RetryPolicy { /** Number of retries after the initial attempt. The fleet default is 1. */ count: number; /** Delay before each retry, in milliseconds. The fleet default is 2000. */ delayMs: number; /** * Statuses that trigger a retry. Defaults to `[429]` — the historical * fleet-wide behavior. Add transient 5xx codes (e.g. `[429, 500, 502, 503, * 504]`) for upstreams that flake; an exhausted non-429 retried status * surfaces through the normal non-2xx path (an {@link ApiError}). */ statuses?: number[]; /** * Honor the response's `Retry-After` header for the retry delay (bounded by * {@link maxRetryAfterMs}), falling back to {@link delayMs} when the header * is absent or unparseable. Off by default — the historical fixed-delay * behavior. Consolidates the hand-rolled Retry-After handling in * getyourguide / musicbrainz / viator / tripadvisor. */ honorRetryAfter?: boolean; /** * Cap on an honored `Retry-After` delay, in ms. Defaults to 30 000 — an * upstream demanding a 10-minute wait shouldn't pin a tool call open. */ maxRetryAfterMs?: number; } /** Options for {@link createApiClient}. */ /** * Minimal structural view of a reactive bearer-token source (e.g. the * `TokenManager` from `@chrischall/mcp-utils/session`). Kept structural so the * core `http` module stays decoupled from the optional `session` module. */ export interface ReactiveTokenSource { /** * Run `call` with a valid access token, refreshing proactively and replaying * once on a 401. Returns the final `Response`. */ withAuth(call: (accessToken: string) => Promise): Promise; } export interface ApiClientOptions { /** Absolute base URL; request paths are appended verbatim. A trailing slash is trimmed. */ baseUrl: string; /** * Resolve the current bearer token. Called per request so the caller can * refresh/rotate transparently. May be sync or async. Return `undefined`/`''` * to send no `Authorization` header (e.g. cookie-authenticated APIs). * Optional when {@link ApiClientOptions.tokenManager} is provided. */ getToken?: () => string | undefined | Promise; /** * Send the token in this named request header instead of * `Authorization: Bearer `. The raw token is sent verbatim — no * `Bearer ` prefix (e.g. `tokenHeader: 'x-goog-api-key'` / * `'x-auth-token'` / `'x-api-key'`). Unset keeps the default * `Authorization: Bearer` behavior. Applies to both * {@link ApiClientOptions.getToken} and * {@link ApiClientOptions.tokenManager} tokens. */ tokenHeader?: string; /** * A reactive token source (e.g. `TokenManager`). When set, every request is * routed through {@link ReactiveTokenSource.withAuth} — proactive refresh + * one reactive 401-replay — instead of {@link ApiClientOptions.getToken}. */ tokenManager?: ReactiveTokenSource; /** * Default headers sent on every request (e.g. an API-version or client-id * header). A per-request `headers` entry of the same name overrides them. */ baseHeaders?: Record; /** * 429 retry policy. Defaults to `{ count: 1, delayMs: 2000 }` — the * fleet-wide "retry once after 2s" behavior. Set `count: 0` to disable. */ retry?: RetryPolicy; /** Human name of the upstream service, used in error messages. Defaults to the host. */ serviceName?: string; /** * Override the error thrown on a 401. Lets a repo surface its own documented * message (e.g. `TEMPO_API_TOKEN is invalid or expired`) without wrapping the * client in a try/catch. The factory receives no arguments — it is never * passed the token, preserving the no-token-in-message guarantee. Defaults to * {@link UnauthorizedError}. */ onUnauthorized?: () => Error; /** * Override the error thrown when a 429 persists past the retry budget. * Defaults to {@link RateLimitedError}. */ onRateLimited?: () => Error; /** Injectable fetch (for tests). Defaults to the global `fetch`. */ fetchImpl?: typeof fetch; /** Injectable sleep (for tests). Defaults to `setTimeout`. */ sleep?: (ms: number) => Promise; /** * Per-attempt request timeout in milliseconds. When set (> 0), each fetch is * bounded by an {@link AbortController}; on expiry it throws a * {@link RequestTimeoutError} instead of hanging until the host kills the * tool call. A 429 retry gets a fresh timeout. Omit/0 to disable (default). */ timeout?: number; } /** A request body and/or extra headers for a single call. */ export interface RequestOptions { /** JSON-serialized into the request body when present. */ body?: unknown; /** * Multipart body (e.g. a file/image upload). Sent verbatim with no * `Content-Type` so `fetch` sets the multipart boundary itself. Takes * precedence over {@link body} when both are present. */ formData?: FormData; /** Extra request headers, merged over the defaults. */ headers?: Record; /** Query params appended via {@link buildQueryString} when present. */ query?: Record; } /** The minimal client surface returned by {@link createApiClient}. */ export interface ApiClient { /** * Authenticated JSON request. Returns the parsed body, or `undefined` for a * 204 / empty body. Throws on 401 (unauthorized), exhausted-429, and other * non-2xx responses (with a redacted, truncated message). */ fetchJson: (method: string, path: string, opts?: RequestOptions) => Promise; /** Authenticated request returning the raw response body as text (e.g. HTML scrapes). */ fetchHtml: (method: string, path: string, opts?: RequestOptions) => Promise; /** * Authenticated request returning the raw bytes plus status/headers — the * binary path `fetchJson` can't express (gzip reports, PNG maps, file * downloads). Same 401/429/non-2xx mapping as `fetchJson`; the error body of * a failure is decoded as text and redacted. Consolidates the hand-rolled * `requestRaw` / `write()` / `requestBinary` / `requestMobileBinary` in * app-store-connect / flightaware / ofw / zola. */ fetchRaw: (method: string, path: string, opts?: RequestOptions) => Promise; } /** Result of {@link ApiClient.fetchRaw}. */ export interface RawApiResponse { /** The 2xx status of the response. */ status: number; /** The `Content-Type` header, when present. */ contentType: string | null; /** The full response headers (e.g. for `Content-Disposition` / `Location`). */ headers: Headers; /** The response body bytes. */ bytes: Uint8Array; } /** Thrown for an upstream 401. Carries the status so callers can trigger a re-auth. */ export declare class UnauthorizedError extends Error { readonly status = 401; constructor(service: string); } /** Thrown when a 429 persists after the retry budget is exhausted. */ export declare class RateLimitedError extends Error { readonly status = 429; constructor(service: string); } /** Thrown when a request exceeds {@link ApiClientOptions.timeout}. */ export declare class RequestTimeoutError extends Error { readonly timeoutMs: number; constructor(service: string, timeoutMs: number); } /** * Thrown by {@link ApiClient.fetchJson} / {@link ApiClient.fetchHtml} for a * non-2xx response that isn't a 401/429 (those map to * {@link UnauthorizedError} / {@link RateLimitedError}). The message is exactly * the redacted, truncated {@link formatApiError} string the client has always * thrown — this class only adds the `status` so callers can branch * (`err instanceof ApiError && err.status === 404`) instead of regexing the * message. */ export declare class ApiError extends Error { readonly status: number; constructor(status: number, message: string); } /** * A status-carrying HTTP error consumers can `throw new` directly — the * directly-constructible parallel to {@link ApiError} (which is only thrown * *inside* {@link createApiClient}). Extends {@link ApiError} so the same * `err instanceof ApiError && err.status === 404` branch works for both, while * its own name/`instanceof UpstreamHttpError` distinguishes the manual throws. * * Consolidates opentable's local `HttpError` and musescore's * `MusescoreHttpError` — structural twins (status-carrying, thrown from a * transport/bridge code path that doesn't go through `createApiClient`, used to * branch on a 404 fallback). Repos that DO route through `createApiClient` * already get {@link ApiError} for free and don't need this. */ export declare class UpstreamHttpError extends ApiError { constructor(status: number, message: string); } /** * Build a bearer-auth fetch client with one-shot 429 retry, 401 mapping, 204 / * empty-body handling, and redacted error formatting. * * Consolidates the structurally-identical `client.ts#doRequest` across * splitwise/tempo/ioffice/app-store-connect/zola. The retry/401/429 behavior is * the hardened superset: 401 → {@link UnauthorizedError} (never echoing the * token), 429 → sleep(`delayMs`) and replay up to `count` times then * {@link RateLimitedError}, 204/empty → `undefined`, other non-2xx → * {@link formatApiError}. */ export declare function createApiClient(opts: ApiClientOptions): ApiClient; /** * Build a URL query string from a params object, returning `''` or * `?k=v&k2=v2`. Skips `undefined`, `null`, and empty-string values; expands * arrays into repeated keys (skipping null/undefined/empty array members); and * percent-encodes keys and values. * * Consolidates the divergent `buildQueryString` / inline `URLSearchParams` * variants across compass/redfin/zillow/homes/opentable/tempo/ioffice into one * superset (array support + empty-string skipping + encoding). */ export declare function buildQueryString(params: Record): string; /** * Build a request body from `args`, including only the `optionalFields` that are * actually present (not `undefined`). `null` is preserved — some APIs use it to * clear a field — only `undefined` (i.e. "not provided") is dropped. * * Consolidates tempo's optional-field body builder: lets a tool forward a wide * args object and emit a minimal PATCH/POST body. */ export declare function buildOptionalBody, K extends keyof T>(args: T, optionalFields: readonly K[]): Partial>; /** Options for {@link formatApiError}. */ export interface FormatApiErrorOptions { /** Service name woven into the prefix. Defaults to `'API'`. */ service?: string; /** Truncation budget for the upstream body. Defaults to the shared 500. */ max?: number; } /** * Format a non-2xx upstream response into a single, client-safe error string: * `"{service} error {status} for {METHOD} {path}: {body}"`. * * SECURITY: the upstream `errorText` is run through the shared * {@link truncateErrorMessage} (redaction of `Bearer ` / JWTs FIRST, * then truncation) so a raw body that echoes the request — or an upstream that * leaks a token — never reaches the caller. The method is upper-cased; an * empty/whitespace body is dropped entirely rather than printing a dangling * colon. */ export declare function formatApiError(status: number, method: string, path: string, errorText: string, opts?: FormatApiErrorOptions): string; /** The RFC 5988 rels callers care about for pagination. */ export interface ParsedLinkHeader { next?: string; prev?: string; first?: string; last?: string; /** Any other rels present, keyed by rel name. */ [rel: string]: string | undefined; } /** * Parse an RFC 5988 `Link` header into a `{ rel: url }` map. Malformed entries * are skipped; `rel="next"` and bare `rel=next` are both accepted. A missing or * empty header yields `{}`. * * Consolidates canvas's pagination Link parser. */ export declare function parseLinkHeader(header: string | null | undefined): ParsedLinkHeader; /** * A parsed Set-Cookie jar: deduplicated name→value plus a ready `Cookie` * header. (Renamed from `CookieJar` to free that name for the stateful * {@link CookieJar} class; no fleet consumer imported the type.) */ export interface ParsedCookieJar { /** Surviving cookies, name → value (last value wins, deletions removed). */ cookies: Record; /** Pre-joined `name=value; name2=value2` string for the `Cookie` request header. */ cookieHeader: string; } /** * Parse `Set-Cookie` headers into a deduplicated {@link ParsedCookieJar}. * * Login responses commonly send deletion markers (`Max-Age=0` or an epoch * `Expires`) alongside real cookies; forwarding both the delete and set form of * a name makes some upstreams (e.g. IC) reject the request. This parser: * - drops deletion markers (`Max-Age=0`, epoch `Expires`), * - drops empty-value cookies (clearing instructions), * - deduplicates by name with **last value wins**, preserving order. * * Accepts the array from `Headers.getSetCookie()` (or a single joined string — * which it splits defensively, though `getSetCookie()` is strongly preferred * since commas inside `Expires` make string-splitting lossy). * * Consolidates the IC/signupgenius cookie-jar logic. */ export declare function parseCookieJar(setCookieHeaders: string[] | string | null | undefined): ParsedCookieJar; /** * Parse a *request* `Cookie:` header (`name=value; name2=value2`) into a * name→value map. This is the inbound counterpart to {@link parseCookieJar}, * which parses *response* `Set-Cookie` headers (with their attributes and * deletion semantics) — a `Cookie` header has no attributes, just pairs. * * Semantics (matching creditkarma's hand-rolled `extractCookieValue` ×3): * - splits on `;`, then on the FIRST `=` only, so a value containing `=` * (e.g. base64 padding `ab==`, or `x=y=z`) is preserved verbatim, * - trims surrounding whitespace from both name and value, * - skips fragments with no `=` (bare attributes) or an empty name (`=v`), * - keeps an empty value when the name is present (`a=` → `{ a: '' }`), * - on a duplicate name, the last value wins. * * A missing / empty / whitespace-only header yields `{}`. * * Consolidates creditkarma's `extractCookieValue` (single-name lookup ×3) and * the cookie-header pattern-matching in two other repos: `extractCookieValue(h, * name)` is `parseCookieHeader(h)[name] ?? null`. */ export declare function parseCookieHeader(header: string): Record; /** * Anything {@link CookieJar.absorb} can read `Set-Cookie`s from: the array from * `Headers.getSetCookie()`, a single (possibly comma-joined) header string, or * a `Headers`-like object (prefers `getSetCookie()`, falls back to splitting * `get('set-cookie')` safely around `Expires` commas). */ export type SetCookieSource = string[] | string | null | undefined | { getSetCookie?: () => string[]; get(name: string): string | null; }; /** * Stateful cookie jar for multi-step session logins (login page → CSRF prime → * credential POST → API calls), built on the {@link parseCookieJar} semantics. * * Consolidates the five drifted hand-rolled jars across * artsonia/canvas-parent/evite/signupgenius/skylight: * - `absorb` merges each response's `Set-Cookie`s into the jar — later values * override earlier ones by name, and deletion markers (`Max-Age <= 0` or a * pre-2000 `Expires`, both comma- and dash-format epochs) **remove** the * name from the jar, * - empty-value non-deletion cookies are ignored (an existing value survives), * - `header()` renders the `Cookie` request-header value in insertion order. */ export declare class CookieJar { private readonly jar; /** Merge `Set-Cookie`s into the jar (later wins; deletion markers remove). */ absorb(setCookies: SetCookieSource): void; /** The current value of a cookie, or `undefined` when absent/deleted. */ get(name: string): string | undefined; /** Render the `Cookie` request-header value: `name=value; name2=value2`. */ header(): string; /** Number of cookies currently in the jar. */ get size(): number; } /** * Decode a JWT's `exp` claim (seconds since epoch). Throws when the structure is * invalid or `exp` is missing/non-numeric — the strict variant zola uses for the * token it depends on. For a lenient probe, use {@link validateJwtExpiry}. */ export declare function decodeJwtExp(token: string): number; /** * Best-effort extraction of a session id from a JWT payload, checking * `session_id` then `sid`. Returns `null` for an undecodable token or absent * claim (never throws) — the lenient variant zola uses for the WAF session * header. */ export declare function decodeJwtSessionId(token: string): string | null; /** * Generic single-claim extractor for a JWT payload. Returns the raw claim * value (`unknown` — cast or narrow at the call site), or `undefined` for an * undecodable token or an absent claim. Never throws. * * Generalizes the hand-rolled per-claim readers across the fleet (e.g. * creditkarma's `extractGlidFromJwt`): `decodeJwtClaim(token, 'glid')` instead * of a bespoke decode + payload-field pluck. Reuses the same base64url payload * decode as {@link decodeJwtExp} / {@link decodeJwtSessionId}. */ export declare function decodeJwtClaim(token: string, claim: string): unknown; /** Result of {@link validateJwtExpiry}. */ export interface JwtExpiryStatus { /** True when the token is past its `exp` (or undecodable / lacks `exp`). */ expired: boolean; /** Seconds until expiry (negative once expired); omitted when undecodable. */ expiresIn?: number; /** Human-readable caution when the token is expired or near expiry. */ warning?: string; } /** * Non-throwing expiry probe for a bearer JWT. Returns `{ expired, expiresIn?, * warning? }`. An undecodable token (or one lacking a numeric `exp`) is treated * as `expired: true` with a warning — failing closed so a malformed token forces * a refresh rather than being sent and bouncing as a 401. * * Near-expiry (within {@link NEAR_EXPIRY_SKEW_SEC}) yields a warning while still * reporting `expired: false`, so callers can refresh proactively. */ export declare function validateJwtExpiry(token: string, nowMs?: number): JwtExpiryStatus; /** Options for {@link runBoundedBatch}. */ export interface RunBoundedBatchOptions { /** * Overall hard deadline for the WHOLE batch, in milliseconds. When it fires, * any item that hasn't settled is filled by {@link onTimeout} and the batch * returns immediately — unsettled workers are abandoned (left to settle in * the background, never awaited), so a permanently-hung item can't wedge the * call past this bound. */ deadlineMs: number; /** * Backfill for an item the deadline cut off before it settled. Receives the * original `item` and its `index` so the placeholder stays identifiable and * re-runnable (the zillow `pending`-row pattern). Called only for unsettled * slots. */ onTimeout: (item: T, index: number) => R; /** * Backfill for an item whose `worker` REJECTED (threw). A worker error is * isolated to its own slot — it never rejects the batch or cascades onto * other items. Defaults to `onTimeout(item, index)` (a rejected worker is * treated like a timeout) so a caller that doesn't care needn't distinguish; * supply `onError` to tell a genuine failure apart from a deadline cutoff. */ onError?: (item: T, index: number, err: unknown) => R; /** * Max workers in flight at once. Defaults to unbounded (all items started * immediately). Use it to bound fan-out against a rate-limited upstream. */ concurrency?: number; /** * Injectable timer (for tests). Defaults to `setTimeout`. Must invoke `cb` * after roughly `ms`, returning a handle passed to {@link clearTimer}. */ setTimer?: (ms: number, cb: () => void) => unknown; /** Injectable clear paired with {@link setTimer}. Defaults to `clearTimeout`. */ clearTimer?: (handle: unknown) => void; } /** * Run `worker` over `items` with an overall hard deadline and a per-item * timeout-backfill, returning a full-length, input-ordered array with exactly * one result per item. * * Each item gets an index-addressable slot. Work is raced against `deadlineMs`; * when the deadline fires first, every still-unsettled slot is filled by * `onTimeout(item, index)` and the batch resolves immediately — the in-flight * workers are abandoned (and signalled via their `AbortSignal`) rather than * awaited, so a single hung row can't keep the whole call (and the MCP request * deadline behind it) pinned open. When everything settles before the deadline, * the timer is cleared and every slot holds its real result. * * Generalises zillow's bulk-tool `runWithDeadline` (`zillow_bulk_get` / * `zillow_resolve_addresses`, issues #98/#78): the slot-array + overall-deadline * + `pending`-backfill shape, now with the worker + concurrency folded in and an * injectable timer for deterministic tests. */ export declare function runBoundedBatch(items: T[], worker: (item: T, signal?: AbortSignal) => Promise, opts: RunBoundedBatchOptions): Promise; //# sourceMappingURL=index.d.ts.map