import { Effect } from 'effect'; /** * SSRF guard for an ARBITRARY (user-supplied) outbound URL — webhook targets, * ingest URLs. Throws when the URL is non-http(s), or its host is an IP LITERAL * in a loopback / private / link-local (incl. `169.254.169.254` cloud-metadata) * / reserved range, or `localhost` / `*.internal` / `*.local`. Fails CLOSED on a * malformed URL. * * SYNC + node-free by design (this is a leaf package — no `node:dns`): it does * NOT resolve DNS, so a HOSTNAME that resolves to a private IP (DNS rebinding) * is not caught here — pair it with network-layer egress control for that. It * DOES block the high-severity direct-IP vector (metadata endpoint, loopback, * RFC-1918), which is the common webhook-SSRF exploit. */ export declare const assertPublicUrl: (rawUrl: string) => void; /** * SSRF guard: the resolved URL's host MUST equal the configured base host. * Used by every request so a crafted ref can't pivot the server-side fetch to * an internal address. Fails CLOSED — a malformed URL returns `false`. */ export declare const assertSameHost: (baseUrl: string, targetUrl: string) => boolean; /** * How each request is authenticated. Given the (optional) per-call context the * caller passes to a request method, produce the outgoing headers. Keeping this * a function means bearer tokens, tracking headers, or per-subject credentials * are all expressible without the core knowing any auth scheme. Return the FULL * header set for the request (the client does not merge — you own it). */ export declare type AuthHeaders = (ctx: Ctx) => Record; /** Exponential backoff with a cap; attempt is 1-based. */ export declare const backoffMs: (attempt: number, policy: HttpPolicy) => number; declare type CallOptions = [Ctx] extends [void] ? { readonly ctx?: void; readonly body?: unknown; } | undefined : HttpRequestOptions; export declare const DEFAULT_POLICY: HttpPolicy; export declare const DEFAULT_RETRY_BUDGET: RetryBudget; /** * The methods a transient failure is retried for, absent a per-call `retry`. * The idempotent verbs. `POST` is NOT among them, and the reason is the * request this transport was first built for: `createIssue` is a POST, a proxy * between the app and Jira answered 502 after Jira had already committed the * issue, and the retry created a second one. A retry is only safe when the * server cannot tell it from the first attempt — which is what idempotency * means, and what a bare POST does not promise. */ export declare const DEFAULT_RETRY_METHODS: ReadonlyArray; /** Minimal fetch surface so tests can inject a stub — and so the core stays * transport-agnostic (a `Response` structurally satisfies this shape). */ export declare type FetchLike = (url: string, init: { method: string; headers: Record; body?: string; signal?: AbortSignal; }) => Promise<{ status: number; headers: { get: (name: string) => string | null; }; text: () => Promise; arrayBuffer: () => Promise; }>; /** * A bound HTTP client: typed methods over one base URL + auth + policy. Each * method returns an `Effect` that fails with the caller's typed error `E`. */ export declare interface HttpClient { /** GET `path` → raw bytes/status. */ readonly get: (path: string, options?: CallOptions) => Effect.Effect; /** GET `path` → JSON (`null` for an empty body). */ readonly getJson: (path: string, options?: CallOptions) => Effect.Effect; /** POST `path` (JSON body) → JSON. */ readonly postJson: (path: string, options?: CallOptions) => Effect.Effect; /** PUT `path` (JSON body) → JSON. */ readonly putJson: (path: string, options?: CallOptions) => Effect.Effect; /** DELETE `path` → JSON. */ readonly deleteJson: (path: string, options?: CallOptions) => Effect.Effect; /** Escape hatch: a raw request with a caller-chosen method → raw bytes. */ readonly request: (method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, options?: CallOptions) => Effect.Effect; /** The resolved policy in effect (defaults merged). */ readonly policy: HttpPolicy; } export declare interface HttpClientConfig { /** Absolute base URL. Every request path is joined beneath it; the SSRF * guard pins outbound requests to this host. */ readonly baseUrl: string; /** Build the request headers (auth + any per-call headers). */ readonly auth: AuthHeaders; /** Map a transport failure to the caller's typed error. */ readonly makeError: MakeError; /** The fetch implementation. Pass the global `fetch` in production or a stub * in tests. */ readonly fetchImpl: FetchLike; /** Retry/backoff/timeout overrides, merged over {@link DEFAULT_POLICY}. */ readonly policy?: Partial; /** Injected clock for Retry-After math (tests). */ readonly now?: () => number; } export declare type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; export declare interface HttpPolicy { /** Max attempts INCLUDING the first. Default 4. */ readonly maxAttempts: number; /** First-retry delay (ms). Default 500. */ readonly initialDelayMs: number; /** Cap on any single backoff (ms). Default 10_000. */ readonly maxDelayMs: number; /** Per-request timeout (ms). Default 20_000. */ readonly timeoutMs: number; /** Honour a `Retry-After` response header over computed backoff. Default true. */ readonly honourRetryAfter: boolean; /** Which methods a transient failure is retried for — see * {@link DEFAULT_RETRY_METHODS}. A per-call `retry` overrides it. */ readonly retryMethods?: ReadonlyArray; /** The per-host retry budget, or `false` for none — see {@link RetryBudget}. */ readonly retryBudget?: RetryBudget | false; } export declare interface HttpRequestOptions { /** Per-call auth context passed to `auth` (e.g. a resolved token / subject). */ readonly ctx: Ctx; /** Extra JSON body for POST/PUT — serialized by the client. */ readonly body?: unknown; } /** * Maps a transport-level failure into the CALLER's typed error. Supplying this * is how each integration keeps ONE core but its OWN `Schema.TaggedError` * (`JiraError`, `GithubError`, …). * * `code: 'unauthorized'` marks a 401 — non-transient, so re-auth rather than * retry. It used to be `'session_expired'`, and the rename is the whole point: * **a 401 says the credential was not accepted and says nothing about why.** * Expired, revoked, wrong scope, wrong host, or — the case that produced this * change — a MALFORMED token that never was a credential. * * A deployment's plugin read an `.encrypted()` column through a seam that skipped * the storage codec, so it sent `enc:v1:…` as a bearer token. Ciphertext is a * syntactically valid one, the upstream answered 401, this line named that * `session_expired`, and their PAT health check did the reasonable thing with * that name: deleted the session. Login → dashboard → login, forever, with * every symptom pointing at a revoked credential — the one explanation that was * wrong. A configuration error in the costume of an authentication refusal. * * The name asserted a CAUSE the status cannot support, and consumers act on * names. `status` is attached so a caller that genuinely knows more about its * upstream can still decide; deciding for them is what this rename gives up. */ export declare interface MakeError { (args: { readonly status?: number; readonly message: string; readonly transient: boolean; readonly code?: 'unauthorized'; }): E; } /** * Build a bound {@link HttpClient}. The SSRF host-allowlist, transient retry * with Retry-After, per-request timeout, and 401 → `unauthorized` all come * from the shared core — this only binds the config once. * * @example * ```ts no-check * const github = makeHttpClient({ * baseUrl: 'https://api.github.com', * auth: () => ({ authorization: `Bearer ${token}`, accept: 'application/vnd.github+json' }), * makeError: (a) => new GithubError(a), * fetchImpl: fetch as unknown as FetchLike, * }) * const repo = yield* github.getJson('/repos/acme/widgets') * ``` */ export declare const makeHttpClient: (config: HttpClientConfig) => HttpClient; /** Parse a `Retry-After` header — either delta-seconds or an HTTP date. */ export declare const parseRetryAfter: (value: string | null, nowMs: number) => number | null; export declare interface RawResult { readonly status: number; readonly bytes: Uint8Array; readonly contentType: string | null; readonly text: () => string; } /** * Perform one request with retry/backoff/timeout. Succeeds with the raw * response (bytes + status); fails with the caller's typed error. */ export declare const request: (args: RequestArgs) => Effect.Effect; export declare interface RequestArgs { readonly fetchImpl: FetchLike; readonly baseUrl: string; readonly method: HttpMethod; readonly path: string; readonly headers: Record; readonly body?: string; readonly policy: HttpPolicy; readonly makeError: MakeError; /** * Per-call override of `policy.retryMethods`. `true` retries this call * whatever its method — for a POST the caller has made idempotent (an * `Idempotency-Key` header, a client-generated id in the body). `false` * never retries it, whatever the method. Omitted ⇒ the policy decides. */ readonly retry?: boolean; /** Injected clock for Retry-After date math and the retry budget (tests). */ readonly now?: () => number; } /** Convenience: `request` + JSON-parse the body (empty body → `null`). */ export declare const requestJson: (args: RequestArgs) => Effect.Effect; /** Test seam — the ledger is process-wide, and two suites must not share one. */ export declare const resetRetryBudgets: () => void; /** Status codes worth retrying — the conservative transient set. */ export declare const RETRYABLE_STATUS: Set; /** * A per-HOST retry budget — the transport-level answer to "an upstream outage * is `maxAttempts × timeoutMs` per fiber, times every fiber". Retries are * allowed while, over the trailing window, retries ≤ `ratio × requests` plus a * floor of `minRetriesPerWindow`, so a single slow call still gets its retries * and a thousand concurrent ones cannot quadruple the load on a host that is * already failing. Per process: each replica keeps its own ledger. * * A budget rather than a circuit breaker on purpose. A breaker needs a notion * of "open" and a probe that half-opens it, and both are policy a plugin * cannot set for an app — how long to stay open, what counts as recovered. A * budget makes no such decision: every FIRST attempt still goes out, so the * host's recovery is observed by the traffic itself, and only the amplification * is bounded. */ export declare interface RetryBudget { /** Retries permitted per request in the window. Default 0.2. */ readonly ratio: number; /** Retries always permitted per window regardless of `ratio`. Default 10. */ readonly minRetriesPerWindow: number; /** The trailing window (ms). Default 10_000. */ readonly windowMs: number; } /** What the ledger holds for `host` right now — for a dashboard or a test. */ export declare const retryBudgetSnapshot: (host: string, now?: number, windowMs?: number) => { readonly requests: number; readonly retries: number; }; export { }