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; /** 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 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; } 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: 'GET' | 'POST' | 'PUT' | 'DELETE'; readonly path: string; readonly headers: Record; readonly body?: string; readonly policy: HttpPolicy; readonly makeError: MakeError; /** Injected clock for Retry-After date math (tests). */ readonly now?: () => number; } /** Convenience: `request` + JSON-parse the body (empty body → `null`). */ export declare const requestJson: (args: RequestArgs) => Effect.Effect; /** Status codes worth retrying — the conservative transient set. */ export declare const RETRYABLE_STATUS: Set; export { }