From 4bb17b57e8824207d00de1c4f2e242f6a8e9eea3 Mon Sep 17 00:00:00 2001 From: SiebeBaree Date: Tue, 21 Apr 2026 13:41:00 +0200 Subject: [PATCH] feat: add interceptor --- package.json | 6 + pnpm-lock.yaml | 50 +++ src/enkryptify.ts | 42 +- src/errors.ts | 7 + src/index.ts | 4 + src/interceptor.ts | 293 ++++++++++++++ src/internal/template.ts | 104 +++++ src/proxy.ts | 160 +++++--- src/types.ts | 97 +++++ tests/interceptor.test.ts | 813 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 1511 insertions(+), 65 deletions(-) create mode 100644 src/interceptor.ts create mode 100644 src/internal/template.ts create mode 100644 tests/interceptor.test.ts diff --git a/package.json b/package.json index 75e2b1a..7158f1f 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,9 @@ "typecheck": "tsc --noEmit", "prepare": "husky" }, + "dependencies": { + "@mswjs/interceptors": "^0.41.4" + }, "devDependencies": { "@commitlint/cli": "^19.8.0", "@commitlint/config-conventional": "^19.8.0", @@ -80,6 +83,9 @@ "oxfmt --write" ] }, + "engines": { + "node": ">=18" + }, "packageManager": "pnpm@10.27.0", "pnpm": { "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 768515f..d9e1ab0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@mswjs/interceptors': + specifier: ^0.41.4 + version: 0.41.4 devDependencies: '@commitlint/cli': specifier: ^19.8.0 @@ -332,6 +336,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mswjs/interceptors@0.41.4': + resolution: {integrity: sha512-3B9EinUkrdOUGYzHRzRWSXunQ4YFGboJnyLNRwEJWEde+j8fNhPUHvrN1E3g1DU/iS/s8JQrMNVe+S7AHHVs0w==} + engines: {node: '>=18'} + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -386,6 +394,15 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@oxfmt/binding-android-arm-eabi@0.36.0': resolution: {integrity: sha512-Z4yVHJWx/swHHjtr0dXrBZb6LxS+qNz1qdza222mWwPTUK4L790+5i3LTgjx3KYGBzcYpjaiZBw4vOx94dH7MQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1415,6 +1432,9 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -1813,6 +1833,9 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + oxfmt@0.36.0: resolution: {integrity: sha512-/ejJ+KoSW6J9bcNT9a9UtJSJNWhJ3yOLSBLbkoFHJs/8CZjmaZVZAJe4YgO1KMJlKpNQasrn/G9JQUEZI3p0EQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2151,6 +2174,9 @@ packages: stream-combiner2@1.1.1: resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -2762,6 +2788,15 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mswjs/interceptors@0.41.4': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.6': @@ -2828,6 +2863,15 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@oxfmt/binding-android-arm-eabi@0.36.0': optional: true @@ -3770,6 +3814,8 @@ snapshots: dependencies: get-east-asian-width: 1.5.0 + is-node-process@1.2.0: {} + is-number@7.0.0: {} is-obj@2.0.0: {} @@ -4076,6 +4122,8 @@ snapshots: dependencies: mimic-function: 5.0.1 + outvariant@1.4.3: {} + oxfmt@0.36.0: dependencies: tinypool: 2.1.0 @@ -4444,6 +4492,8 @@ snapshots: duplexer2: 0.1.4 readable-stream: 2.3.8 + strict-event-emitter@0.5.1: {} + string-argv@0.3.2: {} string-width@4.2.3: diff --git a/src/enkryptify.ts b/src/enkryptify.ts index d74dbaf..83ae7b1 100644 --- a/src/enkryptify.ts +++ b/src/enkryptify.ts @@ -15,7 +15,8 @@ import { Logger } from "@/logger"; import { retrieveToken } from "@/internal/token-store"; import { TokenExchangeManager } from "@/token-exchange"; import { KubernetesExchangeManager } from "@/kubernetes-exchange"; -import { EnkryptifyProxy } from "@/proxy"; +import { EnkryptifyProxy, sendProxyWire } from "@/proxy"; +import { HttpInterceptor } from "@/interceptor"; const DEFAULT_PROXY_URL = "https://proxy-poc-black.vercel.app"; @@ -35,6 +36,7 @@ export class Enkryptify implements IEnkryptify { #tokenExchange: TokenExchange | null = null; #proxy: EnkryptifyProxy; #proxyOnly: boolean; + #interceptor: HttpInterceptor | null = null; constructor(config: EnkryptifyConfig) { if (!config.workspace) { @@ -124,6 +126,32 @@ export class Enkryptify implements IEnkryptify { isDestroyed: () => this.#destroyed, }); + // HTTP interceptor: patches the Node HTTP stack so matching outbound + // requests from third-party SDKs (axios, got, OpenAI, Stripe, etc.) + // are rerouted through the Enkryptify proxy. Enabled when the user + // configures rules; disabled on destroy(). + const interceptorConfig = config.interceptor; + if (interceptorConfig && interceptorConfig.enabled !== false && interceptorConfig.rules.length > 0) { + const proxyCtx = this.#proxy._ctx; + this.#interceptor = new HttpInterceptor({ + config: interceptorConfig, + sendWire: (body, signal) => sendProxyWire(proxyCtx, body, signal), + defaults: { + workspace: this.#workspace, + project: this.#project, + environment: this.#environment, + usePersonalValues: this.#usePersonalValues, + }, + logger: this.#logger, + }); + // Fire-and-forget: dynamic import is async, but we don't want + // construction to be async. Any failure is logged; requests + // issued before `ready` resolves simply aren't intercepted. + this.#interceptor.enable().catch((err: unknown) => { + this.#logger.error(`Interceptor failed to enable: ${(err as Error).message}`); + }); + } + this.#logger.info( `Initialized for workspace "${this.#workspace}", project "${this.#project}", environment "${this.#environment}"`, ); @@ -134,6 +162,16 @@ export class Enkryptify implements IEnkryptify { return this.#proxy; } + /** + * @internal Resolves once the HTTP interceptor has patched the global + * HTTP stack (or immediately if no interceptor was configured). Exposed + * for tests and for users who want to guarantee interception before + * issuing their first request. + */ + _interceptorReady(): Promise { + return this.#interceptor?.ready ?? Promise.resolve(); + } + static fromEnv(): EnkryptifyAuthProvider { return new EnvAuthProvider(); } @@ -233,6 +271,8 @@ export class Enkryptify implements IEnkryptify { destroy(): void { if (this.#destroyed) return; + this.#interceptor?.disable(); + this.#interceptor = null; this.#tokenExchange?.destroy(); this.#cache?.clear(); this.#destroyed = true; diff --git a/src/errors.ts b/src/errors.ts index 5b6f144..c534310 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -131,3 +131,10 @@ export class ProxyValidationError extends EnkryptifyError { this.detail = detail; } } + +export class InterceptorError extends EnkryptifyError { + constructor(message: string) { + super(`${message}\nDocs: https://docs.enkryptify.com/sdk/interceptor`); + this.name = "InterceptorError"; + } +} diff --git a/src/index.ts b/src/index.ts index 33a9029..f308679 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export { KubernetesAuthError, ProxyError, ProxyValidationError, + InterceptorError, } from "@/errors"; export type { IEnkryptify, @@ -23,4 +24,7 @@ export type { ProxyRequestInit, ProxyRequestOptions, JsonValue, + InterceptorConfig, + InterceptorRule, + InterceptorUrlMatcher, } from "@/types"; diff --git a/src/interceptor.ts b/src/interceptor.ts new file mode 100644 index 0000000..19c5ee4 --- /dev/null +++ b/src/interceptor.ts @@ -0,0 +1,293 @@ +import type { InterceptorConfig, InterceptorRule, InterceptorUrlMatcher, JsonValue, ProxyMethod } from "@/types"; +import type { ProxyWireBody } from "@/proxy"; +import { EnkryptifyError, InterceptorError, ProxyError } from "@/errors"; +import type { Logger } from "@/logger"; +import { mergeHeaders, resolveBody, templateUrl } from "@/internal/template"; + +/** + * Minimal shape of the `BatchInterceptor` instance we use. Keeping this + * local avoids a hard top-level import of `@mswjs/interceptors`, which we + * load dynamically so consumers who never configure the interceptor don't + * pay for it at process start. + */ +interface BatchInterceptorLike { + apply(): void; + dispose(): void; + on( + event: "request", + listener: (args: { + request: Request; + requestId: string; + controller: { + respondWith(response: Response): void; + errorWith(reason?: unknown): void; + }; + }) => Promise | void, + ): unknown; +} + +export interface HttpInterceptorInit { + config: InterceptorConfig; + sendWire: (body: ProxyWireBody, signal: AbortSignal | null) => Promise; + defaults: { + workspace: string; + project: string; + environment: string; + usePersonalValues: boolean; + }; + logger: Logger; +} + +const VALID_METHODS: ReadonlySet = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]); + +/** + * Routes outbound HTTP(S) / fetch traffic matching configured rules through + * the Enkryptify proxy so secrets stay server-side. + * + * Uses `@mswjs/interceptors` to patch `http.request`, `https.request`, and + * `globalThis.fetch` — covering every Node HTTP client (axios, got, 3rd-party + * SDKs, etc.). The library is imported dynamically inside `enable()` so the + * cost is only paid when the interceptor is actually in use. + */ +export class HttpInterceptor { + #rules: InterceptorRule[]; + #sendWire: HttpInterceptorInit["sendWire"]; + #defaults: HttpInterceptorInit["defaults"]; + #logger: Logger; + #batch: BatchInterceptorLike | null = null; + #enabled = false; + #ready: Promise | null = null; + + constructor(init: HttpInterceptorInit) { + this.#rules = init.config.rules; + this.#sendWire = init.sendWire; + this.#defaults = init.defaults; + this.#logger = init.logger; + } + + /** + * Resolves once the interceptor has patched the global HTTP stack. + * Requests issued before this promise settles bypass interception. + */ + get ready(): Promise { + return this.#ready ?? Promise.resolve(); + } + + /** + * Patch the global HTTP stack. Async because `@mswjs/interceptors` is + * loaded via dynamic `import()`. Safe to call multiple times — subsequent + * calls are no-ops. + */ + async enable(): Promise { + if (this.#enabled) return; + this.#enabled = true; + + this.#ready = (async () => { + // Dynamic import so `@mswjs/interceptors` isn't pulled into the + // process when the interceptor isn't configured. tsup leaves + // dynamic imports un-bundled in both ESM and CJS output. + const [{ BatchInterceptor }, nodePresets] = await Promise.all([ + import("@mswjs/interceptors"), + import("@mswjs/interceptors/presets/node"), + ]); + + const batch = new BatchInterceptor({ + name: "enkryptify-sdk", + interceptors: nodePresets.default, + }) as unknown as BatchInterceptorLike; + + batch.apply(); + batch.on("request", (args) => this.#onRequest(args)); + + this.#batch = batch; + this.#logger.debug(`Interceptor enabled with ${this.#rules.length} rule(s)`); + })(); + + await this.#ready; + } + + /** + * Restore the global HTTP stack. Safe to call multiple times. + */ + disable(): void { + if (!this.#enabled) return; + this.#enabled = false; + try { + this.#batch?.dispose(); + } catch (error) { + this.#logger.warn(`Interceptor dispose failed: ${(error as Error).message}`); + } + this.#batch = null; + this.#ready = null; + this.#logger.debug("Interceptor disabled"); + } + + async #onRequest(args: { + request: Request; + requestId: string; + controller: { + respondWith(response: Response): void; + errorWith(reason?: unknown): void; + }; + }): Promise { + const { request, controller } = args; + + const rule = this.#matchRule(request); + if (!rule) return; // passthrough — mswjs lets the real request proceed + + try { + const wire = await this.#translate(request, rule); + if (wire === "passthrough") return; + + const response = await this.#sendWire(wire, request.signal); + controller.respondWith(response); + } catch (error) { + if (error instanceof EnkryptifyError) { + controller.errorWith(error); + return; + } + const message = error instanceof Error ? error.message : String(error); + controller.errorWith(new ProxyError(0, "Network error while contacting Enkryptify proxy", message)); + } + } + + #matchRule(request: Request): InterceptorRule | null { + for (const rule of this.#rules) { + try { + if (matches(rule.match, request.url, request)) { + this.#logger.debug( + `Interceptor matched rule ${rule.name ?? "(unnamed)"} for ${request.method} ${request.url}`, + ); + return rule; + } + } catch (error) { + // A broken matcher must not take down the caller's request. + this.#logger.error( + `Interceptor matcher for rule "${rule.name ?? "(unnamed)"}" threw: ${(error as Error).message}`, + ); + } + } + return null; + } + + async #translate(request: Request, rule: InterceptorRule): Promise { + const method = request.method.toUpperCase() as ProxyMethod; + if (!VALID_METHODS.has(method)) { + throw new InterceptorError( + `Intercepted request uses unsupported method "${request.method}". ` + + "The proxy supports GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS.", + ); + } + + // Extract headers as a plain record. Headers iteration gives lowercase + // keys, which is what the proxy and our merge layer both expect. + const interceptedHeaders: Record = {}; + request.headers.forEach((value, key) => { + interceptedHeaders[key] = value; + }); + + // Extract body only when needed. Clone so the underlying stream stays + // consumable for the fallback passthrough path. + // + // We need the intercepted body in two cases: + // 1. No rule body override → forward it verbatim. + // 2. The override is a function → feed it to the function. + // Object/string overrides discard the intercepted body, so skip reading + // (saves the decode and avoids tripping on non-JSON bodies that would + // otherwise force `onUnsupportedBody` handling pointlessly). + let interceptedBody: JsonValue | undefined; + const hasBody = method !== "GET" && method !== "HEAD"; + const needsInterceptedBody = hasBody && (rule.body === undefined || typeof rule.body === "function"); + if (needsInterceptedBody) { + const extraction = await extractJsonBody(request); + if (extraction === "unsupported") { + return this.#handleUnsupportedBody(request, rule); + } + interceptedBody = extraction; + } + + const templatedUrl = templateUrl(request.url, rule.url); + const mergedHeaders = mergeHeaders(interceptedHeaders, rule.headers); + const finalBody = hasBody ? resolveBody(interceptedBody, rule.body) : undefined; + + return { + url: templatedUrl, + method, + headers: mergedHeaders, + body: finalBody, + config: { + workspace: rule.workspace ?? this.#defaults.workspace, + project: rule.project ?? this.#defaults.project, + "environment-id": rule.environment ?? this.#defaults.environment, + "is-personal": rule.usePersonal ?? this.#defaults.usePersonalValues, + }, + }; + } + + #handleUnsupportedBody(request: Request, rule: InterceptorRule): "passthrough" { + const label = rule.name ?? "(unnamed)"; + if (rule.onUnsupportedBody === "error") { + throw new InterceptorError( + `Rule "${label}" matched ${request.method} ${request.url}, but the request body ` + + "is not JSON-serialisable (stream/Blob/FormData/URLSearchParams/binary). " + + 'Set `onUnsupportedBody: "passthrough"` to skip interception for such requests, ' + + "or provide a rule-level `body` override.", + ); + } + this.#logger.warn( + `Rule "${label}" matched ${request.method} ${request.url} but the body is not ` + + "JSON-serialisable — passing the request through uninterrupted.", + ); + return "passthrough"; + } +} + +function matches(matcher: InterceptorUrlMatcher, url: string, request: Request): boolean { + if (typeof matcher === "string") { + return url.startsWith(matcher); + } + if (matcher instanceof RegExp) { + return matcher.test(url); + } + return matcher(url, request); +} + +/** + * Reads the intercepted request body as a JSON value. Returns: + * - the parsed JSON value on success + * - `undefined` when the request has no body + * - the sentinel `"unsupported"` when the body is present but not JSON + * + * We clone the request first so the original body stream is still intact for + * the fallback passthrough path. + */ +async function extractJsonBody(request: Request): Promise { + const clone = request.clone(); + + // No body at all. + if (clone.body === null || clone.body === undefined) { + return undefined; + } + + const contentType = request.headers.get("content-type") ?? ""; + const looksJson = /\bjson\b/i.test(contentType) || contentType === ""; + + if (!looksJson) { + return "unsupported"; + } + + let text: string; + try { + text = await clone.text(); + } catch { + return "unsupported"; + } + + if (text.length === 0) return undefined; + + try { + return JSON.parse(text) as JsonValue; + } catch { + return "unsupported"; + } +} diff --git a/src/internal/template.ts b/src/internal/template.ts new file mode 100644 index 0000000..9a0e1ad --- /dev/null +++ b/src/internal/template.ts @@ -0,0 +1,104 @@ +import type { InterceptorRule, JsonValue } from "@/types"; +import { InterceptorError } from "@/errors"; + +/** + * Apply a rule's URL template to the intercepted URL. The template may contain + * simple tokens resolved from the original URL — `{origin}`, `{host}`, + * `{path}`, `{search}` — plus arbitrary `%VARIABLE%` placeholders which are + * passed through to the proxy verbatim. + * + * When `template` is undefined, the original URL is returned unchanged. + * + * This function performs NO secret resolution. Secrets are resolved + * server-side by the proxy. + */ +export function templateUrl(originalUrl: string, template: string | undefined): string { + if (template === undefined) return originalUrl; + + let parsed: URL; + try { + parsed = new URL(originalUrl); + } catch { + // If the intercepted URL isn't parseable (rare — mswjs gives absolute URLs), + // we can still substitute any non-URL tokens so the rule author gets a + // useful error message downstream. Fall back to empty tokens. + return template.replace(/\{origin\}|\{host\}|\{path\}|\{search\}/g, ""); + } + + return template + .replace(/\{origin\}/g, parsed.origin) + .replace(/\{host\}/g, parsed.host) + .replace(/\{path\}/g, parsed.pathname) + .replace(/\{search\}/g, parsed.search); +} + +/** + * Case-insensitive header merge. Returns a fresh record with the intercepted + * headers as the base and the rule overrides layered on top. Override values + * of `undefined` delete the header entirely. + * + * Hop-by-hop and request-size headers that would be invalid after the proxy + * rewrites the request are dropped: `host`, `connection`, `content-length`, + * `transfer-encoding`. + */ +export function mergeHeaders( + intercepted: Record, + overrides: Record | undefined, +): Record | undefined { + const HOP_BY_HOP = new Set(["host", "connection", "content-length", "transfer-encoding"]); + + // Normalise all keys to lowercase so overrides reliably replace existing + // headers regardless of original casing. + const out: Record = {}; + for (const [key, value] of Object.entries(intercepted)) { + const lower = key.toLowerCase(); + if (HOP_BY_HOP.has(lower)) continue; + out[lower] = value; + } + + if (overrides) { + for (const [key, value] of Object.entries(overrides)) { + const lower = key.toLowerCase(); + if (value === undefined) { + delete out[lower]; + } else { + out[lower] = value; + } + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +/** + * Resolve the body to send to the proxy, based on the rule's `body` override + * and the intercepted body (which may be `undefined` when the request had no + * body or a non-JSON body). + * + * No secret resolution happens here — any `%VARIABLE%` tokens in strings are + * preserved verbatim for the proxy to substitute. + */ +export function resolveBody( + intercepted: JsonValue | undefined, + override: InterceptorRule["body"], +): JsonValue | undefined { + if (override === undefined) return intercepted; + + if (typeof override === "function") { + return override(intercepted); + } + + if (typeof override === "string") { + try { + return JSON.parse(override) as JsonValue; + } catch { + throw new InterceptorError( + "Rule `body` override must be JSON-serialisable. Received a non-JSON string; " + + "pass an object/array/primitive directly or JSON.stringify first.", + ); + } + } + + // Already a JsonValue (object/array/primitive). + return override; +} diff --git a/src/proxy.ts b/src/proxy.ts index 3e94c78..f3d4028 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -25,7 +25,12 @@ export interface EnkryptifyProxyInit { isDestroyed: () => boolean; } -interface ProxyWireBody { +/** + * Internal wire format the SDK POSTs to the Enkryptify proxy service. Exported + * for reuse by the HTTP interceptor (which builds these directly). Not part + * of the public SDK surface — do not import from `@enkryptify/sdk`. + */ +export interface ProxyWireBody { url: string; method: ProxyMethod; headers?: Record; @@ -38,16 +43,85 @@ interface ProxyWireBody { }; } +/** + * Shared context needed to POST a `ProxyWireBody` to the proxy service. + * Passed to `sendProxyWire()` by both `EnkryptifyProxy` and `HttpInterceptor`. + */ +export interface ProxySendContext { + proxyUrl: string; + auth: EnkryptifyAuthProvider; + tokenExchange: TokenExchange | null; + logger: Logger; + isDestroyed: () => boolean; +} + +/** + * Low-level: POSTs a pre-built wire body to the proxy service. Handles token + * exchange, Bearer auth, destroyed-guard, and debug logging. Returns the + * upstream `Response` verbatim (no status-based error mapping — see the notes + * on `EnkryptifyProxy.#send` for why). + */ +export async function sendProxyWire( + ctx: ProxySendContext, + body: ProxyWireBody, + signal: AbortSignal | null, +): Promise { + if (ctx.isDestroyed()) { + throw new EnkryptifyError( + "This Enkryptify client has been destroyed. Create a new instance to continue.\n" + + "Docs: https://docs.enkryptify.com/sdk/lifecycle", + ); + } + + await ctx.tokenExchange?.ensureToken(); + + const token = retrieveToken(ctx.auth); + + // Strip undefined fields from the wire body so we don't send `"body": null` + // when the user didn't specify one. + const wireBody: Record = { + url: body.url, + method: body.method, + config: body.config, + }; + if (body.headers !== undefined) wireBody.headers = body.headers; + if (body.body !== undefined) wireBody.body = body.body; + + ctx.logger.debug(`Proxy request: ${body.method} ${body.url}`); + const start = Date.now(); + + const response = await fetch(ctx.proxyUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(wireBody), + signal, + }); + + ctx.logger.debug(`Proxy responded with HTTP ${response.status} in ${Date.now() - start}ms`); + + // Return the Response verbatim — whatever status, body, and headers it carries. + // + // The Proxy forwards upstream responses unchanged (2xx or not), so mapping status + // codes to typed errors here is fundamentally unsafe: an upstream 401 from the + // caller's target API (e.g. OpenWeatherMap rejecting its own API key) is + // indistinguishable on the wire from a proxy 401 (e.g. Enkryptify token expired), + // and translating both into AuthenticationError produced wrong, misleading errors. + // + // Callers handle non-2xx like native fetch: check `response.ok` / `response.status` + // and read the body. Proxy-layer errors are delivered as `{error: {code, message}}` + // JSON bodies that callers can parse for specifics. + return response; +} + export class EnkryptifyProxy implements IEnkryptifyProxy { - #proxyUrl: string; - #auth: EnkryptifyAuthProvider; - #tokenExchange: TokenExchange | null; + #ctx: ProxySendContext; #workspace: string; #project: string; #environment: string; #usePersonalValues: boolean; - #logger: Logger; - #isDestroyed: () => boolean; // Public-surface methods — rebound in the constructor so that // `const { fetch } = client.proxy` (the pattern users need for wiring into @@ -56,20 +130,27 @@ export class EnkryptifyProxy implements IEnkryptifyProxy { request: (options: ProxyRequestOptions) => Promise; constructor(init: EnkryptifyProxyInit) { - this.#proxyUrl = init.proxyUrl; - this.#auth = init.auth; - this.#tokenExchange = init.tokenExchange; + this.#ctx = { + proxyUrl: init.proxyUrl, + auth: init.auth, + tokenExchange: init.tokenExchange, + logger: init.logger, + isDestroyed: init.isDestroyed, + }; this.#workspace = init.workspace; this.#project = init.project; this.#environment = init.environment; this.#usePersonalValues = init.usePersonalValues; - this.#logger = init.logger; - this.#isDestroyed = init.isDestroyed; this.fetch = this.#fetchImpl.bind(this); this.request = this.#requestImpl.bind(this); } + /** Internal: expose the shared context to the HTTP interceptor. */ + get _ctx(): ProxySendContext { + return this.#ctx; + } + async #fetchImpl(input: string | URL, init?: ProxyRequestInit): Promise { if (typeof Request !== "undefined" && input instanceof Request) { throw new EnkryptifyError( @@ -91,7 +172,8 @@ export class EnkryptifyProxy implements IEnkryptifyProxy { ); } - return this.#send( + return sendProxyWire( + this.#ctx, { url, method, @@ -127,7 +209,8 @@ export class EnkryptifyProxy implements IEnkryptifyProxy { usePersonal: options.usePersonal, }); - return this.#send( + return sendProxyWire( + this.#ctx, { url: options.url, method, @@ -152,57 +235,6 @@ export class EnkryptifyProxy implements IEnkryptifyProxy { "is-personal": overrides?.usePersonal ?? this.#usePersonalValues, }; } - - async #send(body: ProxyWireBody, signal: AbortSignal | null): Promise { - if (this.#isDestroyed()) { - throw new EnkryptifyError( - "This Enkryptify client has been destroyed. Create a new instance to continue.\n" + - "Docs: https://docs.enkryptify.com/sdk/lifecycle", - ); - } - - await this.#tokenExchange?.ensureToken(); - - const token = retrieveToken(this.#auth); - - // Strip undefined fields from the wire body so we don't send `"body": null` - // when the user didn't specify one. - const wireBody: Record = { - url: body.url, - method: body.method, - config: body.config, - }; - if (body.headers !== undefined) wireBody.headers = body.headers; - if (body.body !== undefined) wireBody.body = body.body; - - this.#logger.debug(`Proxy request: ${body.method} ${body.url}`); - const start = Date.now(); - - const response = await fetch(this.#proxyUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(wireBody), - signal, - }); - - this.#logger.debug(`Proxy responded with HTTP ${response.status} in ${Date.now() - start}ms`); - - // Return the Response verbatim — whatever status, body, and headers it carries. - // - // The Proxy forwards upstream responses unchanged (2xx or not), so mapping status - // codes to typed errors here is fundamentally unsafe: an upstream 401 from the - // caller's target API (e.g. OpenWeatherMap rejecting its own API key) is - // indistinguishable on the wire from a proxy 401 (e.g. Enkryptify token expired), - // and translating both into AuthenticationError produced wrong, misleading errors. - // - // Callers handle non-2xx like native fetch: check `response.ok` / `response.status` - // and read the body. Proxy-layer errors are delivered as `{error: {code, message}}` - // JSON bodies that callers can parse for specifics. - return response; - } } function normalizeMethod(method?: string): ProxyMethod { diff --git a/src/types.ts b/src/types.ts index b06451f..3082bf8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,6 +71,7 @@ export interface EnkryptifyConfig { level?: "debug" | "info" | "warn" | "error"; }; proxy?: ProxyConfig; + interceptor?: InterceptorConfig; } export interface TokenExchangeResponse { @@ -186,3 +187,99 @@ export interface IEnkryptifyProxy { */ request(options: ProxyRequestOptions): Promise; } + +/** + * Matches an outbound HTTP request against an interceptor rule. + * + * - `string`: prefix match against the full request URL. Example: + * `"https://api.openai.com/"` matches any URL starting with that. + * - `RegExp`: tested against the full request URL. + * - Predicate: custom escape hatch — receives the URL string and the + * intercepted `Request` object. + */ +export type InterceptorUrlMatcher = string | RegExp | ((url: string, request: Request) => boolean); + +/** + * A single interceptor rule. When `match` fires on an outbound request, the + * request is rewritten to go through the Enkryptify proxy with the provided + * header/URL/body substitutions. `%VARIABLE%` placeholders in header values, + * URL template, and body are passed through to the proxy verbatim; the proxy + * resolves them server-side so secrets never enter the caller's process. + */ +export interface InterceptorRule { + /** Display name used in debug/warn logs. Optional. */ + name?: string; + + /** Which outbound URLs this rule captures. */ + match: InterceptorUrlMatcher; + + /** + * Optional URL rewrite sent to the proxy. May contain `%VARIABLE%` + * placeholders. The following simple template tokens are substituted + * from the intercepted URL when present: + * - `{origin}` — protocol + host (e.g. `https://api.example.com`) + * - `{host}` — host only (e.g. `api.example.com`) + * - `{path}` — pathname (e.g. `/v1/models`) + * - `{search}` — query string including leading `?` (or empty) + * + * When omitted, the intercepted URL is forwarded as-is. + */ + url?: string; + + /** + * Header overrides. Values may contain `%VARIABLE%` placeholders. These + * are merged OVER the intercepted request's headers (case-insensitive). + * Explicit `undefined` deletes the header before the proxy call. + */ + headers?: Record; + + /** + * Body override. Accepts: + * - a `JsonValue` (plain object/array/primitive) — replaces the body wholesale + * - a JSON-encoded string — parsed before being forwarded + * - a function that receives the parsed intercepted body and returns the replacement + * + * Omit to forward the intercepted body verbatim (JSON only — see + * `onUnsupportedBody` for non-JSON handling). + */ + body?: JsonValue | string | ((intercepted: JsonValue | undefined) => JsonValue | undefined); + + /** Override the client's workspace for this rule. */ + workspace?: string; + /** Override the client's project for this rule. */ + project?: string; + /** Override the client's environment for this rule. */ + environment?: string; + /** Override the client's `usePersonalValues` setting for this rule. */ + usePersonal?: boolean; + + /** + * How to handle intercepted requests whose body cannot be represented on + * the proxy wire (streams, Blob, FormData, URLSearchParams, ArrayBuffer, + * typed arrays, non-JSON strings). + * + * - `"passthrough"` (default): let the original request reach the network + * unmodified and log a warning. + * - `"error"`: fail the intercepted request with an `InterceptorError`. + */ + onUnsupportedBody?: "passthrough" | "error"; +} + +/** + * Interceptor configuration. When enabled, the SDK patches the Node HTTP + * stack (via `@mswjs/interceptors`) and reroutes matching outbound requests + * through the Enkryptify proxy. The patching is reversed on `client.destroy()`. + */ +export interface InterceptorConfig { + /** + * Explicitly enable/disable the interceptor. Defaults to `true` when + * `rules` is non-empty. + */ + enabled?: boolean; + + /** + * Ordered list of rules. The first matching rule wins; non-matching + * requests pass through to the network unmodified. + */ + rules: InterceptorRule[]; +} diff --git a/tests/interceptor.test.ts b/tests/interceptor.test.ts new file mode 100644 index 0000000..c1d05ff --- /dev/null +++ b/tests/interceptor.test.ts @@ -0,0 +1,813 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Enkryptify, InterceptorError } from "@/index"; +import { storeToken } from "@/internal/token-store"; +import { mergeHeaders, resolveBody, templateUrl } from "@/internal/template"; +import type { EnkryptifyAuthProvider, EnkryptifyConfig } from "@/types"; + +function createAuth(token = "ek_test"): EnkryptifyAuthProvider { + const auth = { _brand: "EnkryptifyAuthProvider" as const }; + storeToken(auth, token); + return auth; +} + +function makeConfig(overrides?: Partial): EnkryptifyConfig { + return { + auth: createAuth(), + workspace: "ws-1", + project: "prj-1", + environment: "env-1", + baseUrl: "https://api.test.com", + logger: { level: "error" }, + proxy: { url: "https://proxy.test.com" }, + ...overrides, + }; +} + +/** + * Normalise a vitest mock call into `{ url, headers, bodyText }`. + * + * The interceptor uses native fetch internally; when that nested call is + * itself intercepted by @mswjs/interceptors and passed through to the + * stubbed fetch, mswjs invokes the original with a `Request` object rather + * than `(url, init)`. Handle both shapes so tests can inspect either. + */ +async function readCall(call: unknown[]): Promise<{ + url: string; + headers: Record; + bodyText: string | null; +} | null> { + const arg = call[0]; + if (typeof Request !== "undefined" && arg instanceof Request) { + const headers: Record = {}; + arg.headers.forEach((value, key) => { + headers[key] = value; + }); + let bodyText: string | null = null; + try { + bodyText = await arg.clone().text(); + } catch { + bodyText = null; + } + return { url: arg.url, headers, bodyText }; + } + if (typeof arg === "string" || arg instanceof URL) { + const opts = call[1] as RequestInit | undefined; + const rawHeaders = opts?.headers; + const headers: Record = {}; + if (rawHeaders instanceof Headers) { + rawHeaders.forEach((value, key) => { + headers[key] = value; + }); + } else if (Array.isArray(rawHeaders)) { + for (const [k, v] of rawHeaders) headers[k] = v; + } else if (rawHeaders && typeof rawHeaders === "object") { + for (const [k, v] of Object.entries(rawHeaders)) headers[k] = String(v); + } + const bodyText = typeof opts?.body === "string" ? (opts.body as string) : null; + return { url: String(arg), headers, bodyText }; + } + return null; +} + +/** + * Find the call to the proxy URL and return its parsed wire body. + */ +async function findProxyCall(fetchMock: ReturnType): Promise | null> { + for (const call of fetchMock.mock.calls) { + const parsed = await readCall(call); + if (!parsed) continue; + // mswjs sometimes normalises the URL with a trailing slash when it + // runs through URL(); accept both shapes. + if (parsed.url === "https://proxy.test.com" || parsed.url === "https://proxy.test.com/") { + if (parsed.bodyText === null) return null; + try { + return JSON.parse(parsed.bodyText) as Record; + } catch { + return null; + } + } + } + return null; +} + +/** + * Find the call (if any) whose URL starts with `prefix` — regardless of + * whether mswjs passed it through as a Request or (url, init) pair. + */ +async function findCallByUrlPrefix( + fetchMock: ReturnType, + prefix: string, +): Promise<{ url: string; headers: Record; bodyText: string | null } | null> { + for (const call of fetchMock.mock.calls) { + const parsed = await readCall(call); + if (parsed && parsed.url.startsWith(prefix)) return parsed; + } + return null; +} + +// --------------------------------------------------------------------------- +// Pure helper tests: no interceptor state. +// --------------------------------------------------------------------------- + +describe("templateUrl", () => { + it("returns original URL when template is undefined", () => { + expect(templateUrl("https://api.example.com/v1/users?q=1", undefined)).toBe( + "https://api.example.com/v1/users?q=1", + ); + }); + + it("substitutes {origin}/{host}/{path}/{search} tokens", () => { + const url = "https://api.openai.com/v1/chat/completions?model=gpt-4"; + expect(templateUrl(url, "{origin}{path}{search}")).toBe(url); + expect(templateUrl(url, "https://proxy.example.com{path}{search}")).toBe( + "https://proxy.example.com/v1/chat/completions?model=gpt-4", + ); + expect(templateUrl(url, "https://{host}{path}")).toBe("https://api.openai.com/v1/chat/completions"); + }); + + it("preserves %VARIABLE% tokens verbatim", () => { + expect(templateUrl("https://api.example.com/v1/users", "https://api.example.com/v1/%USER_ID%")).toBe( + "https://api.example.com/v1/%USER_ID%", + ); + }); + + it("handles empty query string", () => { + expect(templateUrl("https://api.example.com/v1/users", "https://proxy.com{path}{search}")).toBe( + "https://proxy.com/v1/users", + ); + }); +}); + +describe("mergeHeaders", () => { + it("merges override onto intercepted", () => { + const result = mergeHeaders( + { "content-type": "application/json", "x-foo": "bar" }, + { authorization: "Bearer %KEY%" }, + ); + expect(result).toEqual({ + "content-type": "application/json", + "x-foo": "bar", + authorization: "Bearer %KEY%", + }); + }); + + it("override is case-insensitive against intercepted headers", () => { + const result = mergeHeaders({ "content-type": "text/plain" }, { "Content-Type": "application/json" }); + expect(result).toEqual({ "content-type": "application/json" }); + }); + + it("undefined override deletes the header", () => { + const result = mergeHeaders( + { "content-type": "application/json", authorization: "Bearer old" }, + { authorization: undefined }, + ); + expect(result).toEqual({ "content-type": "application/json" }); + }); + + it("drops hop-by-hop headers", () => { + const result = mergeHeaders( + { + host: "upstream.com", + connection: "keep-alive", + "content-length": "42", + "transfer-encoding": "chunked", + "x-keep": "1", + }, + undefined, + ); + expect(result).toEqual({ "x-keep": "1" }); + }); + + it("returns undefined when result is empty", () => { + expect(mergeHeaders({}, undefined)).toBeUndefined(); + }); +}); + +describe("resolveBody", () => { + it("returns intercepted body when override is undefined", () => { + expect(resolveBody({ a: 1 }, undefined)).toEqual({ a: 1 }); + expect(resolveBody(undefined, undefined)).toBeUndefined(); + }); + + it("returns object override wholesale", () => { + expect(resolveBody({ original: true }, { user: "%USER%" })).toEqual({ user: "%USER%" }); + }); + + it("parses JSON string override", () => { + expect(resolveBody(undefined, '{"key": "%SECRET%"}')).toEqual({ key: "%SECRET%" }); + }); + + it("throws on non-JSON string override", () => { + expect(() => resolveBody(undefined, "not json")).toThrow(InterceptorError); + }); + + it("invokes function override with intercepted body", () => { + const intercepted = { model: "gpt-4", messages: [] }; + const override = vi.fn((body: unknown) => ({ ...(body as object), apiKey: "%KEY%" })); + const result = resolveBody(intercepted, override); + expect(override).toHaveBeenCalledWith(intercepted); + expect(result).toEqual({ model: "gpt-4", messages: [], apiKey: "%KEY%" }); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests against real @mswjs/interceptors. +// Because the interceptor patches globalThis.fetch, these tests must stub +// fetch BEFORE the client is constructed so mswjs captures the stub as its +// passthrough target. Each test tears down via client.destroy(). +// --------------------------------------------------------------------------- + +let fetchMock: ReturnType; +let activeClient: Enkryptify | null = null; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + activeClient?.destroy(); + activeClient = null; + vi.unstubAllGlobals(); +}); + +describe("interceptor — rule matching", () => { + it("string prefix match routes fetch call through the proxy", async () => { + fetchMock.mockResolvedValue(new Response('{"ok":true}', { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [ + { + match: "https://api.openai.com/", + headers: { Authorization: "Bearer %OPENAI_KEY%" }, + }, + ], + }, + }), + ); + await activeClient._interceptorReady(); + + const response = await fetch("https://api.openai.com/v1/models"); + expect(response.status).toBe(200); + + const wire = await findProxyCall(fetchMock); + expect(wire).not.toBeNull(); + expect(wire?.url).toBe("https://api.openai.com/v1/models"); + expect(wire?.method).toBe("GET"); + const openaiHeaders = (wire?.headers ?? {}) as Record; + expect(openaiHeaders.authorization).toBe("Bearer %OPENAI_KEY%"); + }); + + it("regex match routes request through the proxy", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [ + { + match: /^https:\/\/api\.stripe\.com\//, + headers: { Authorization: "Bearer %STRIPE_KEY%" }, + }, + ], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.stripe.com/v1/charges"); + + const wire = await findProxyCall(fetchMock); + expect(wire?.url).toBe("https://api.stripe.com/v1/charges"); + const stripeHeaders = (wire?.headers ?? {}) as Record; + expect(stripeHeaders.authorization).toBe("Bearer %STRIPE_KEY%"); + }); + + it("predicate match routes request through the proxy", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const predicate = vi.fn((url: string) => url.includes("twilio.com")); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [ + { + match: predicate, + headers: { Authorization: "Basic %TWILIO_AUTH%" }, + }, + ], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.twilio.com/2010-04-01/Accounts"); + + expect(predicate).toHaveBeenCalled(); + const wire = await findProxyCall(fetchMock); + expect(wire?.url).toBe("https://api.twilio.com/2010-04-01/Accounts"); + }); + + it("non-matching URL passes through to the real target without proxy involvement", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [ + { + match: "https://api.openai.com/", + headers: { Authorization: "Bearer %OPENAI_KEY%" }, + }, + ], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://example.com/some/path"); + + // The only fetch should have gone directly to example.com — no proxy POST. + expect(await findProxyCall(fetchMock)).toBeNull(); + const exampleCall = await findCallByUrlPrefix(fetchMock, "https://example.com"); + expect(exampleCall).not.toBeNull(); + }); + + it("first matching rule wins when multiple rules overlap", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [ + { name: "first", match: "https://api.example.com/", headers: { "x-source": "first" } }, + { name: "second", match: "https://api.example.com/", headers: { "x-source": "second" } }, + ], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1"); + + const wire = await findProxyCall(fetchMock); + const mergedHeaders = (wire?.headers ?? {}) as Record; + expect(mergedHeaders["x-source"]).toBe("first"); + }); +}); + +describe("interceptor — ProxyWireBody shape", () => { + it("includes config block with client defaults", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + workspace: "ws-x", + project: "prj-y", + environment: "env-z", + options: { usePersonalValues: false }, + interceptor: { + rules: [{ match: "https://api.example.com/", headers: { authorization: "Bearer %K%" } }], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1"); + + const wire = await findProxyCall(fetchMock); + expect(wire?.config).toEqual({ + workspace: "ws-x", + project: "prj-y", + "environment-id": "env-z", + "is-personal": false, + }); + }); + + it("rule-level workspace/project/environment/usePersonal override defaults", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [ + { + match: "https://api.example.com/", + headers: { authorization: "Bearer %K%" }, + workspace: "override-ws", + project: "override-prj", + environment: "override-env", + usePersonal: false, + }, + ], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1"); + + const wire = await findProxyCall(fetchMock); + expect(wire?.config).toEqual({ + workspace: "override-ws", + project: "override-prj", + "environment-id": "override-env", + "is-personal": false, + }); + }); + + it("sends Authorization: Bearer on the proxy call", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + auth: createAuth("my-sdk-token"), + interceptor: { + rules: [{ match: "https://api.example.com/", headers: { authorization: "Bearer %K%" } }], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1"); + + const proxyCall = await findCallByUrlPrefix(fetchMock, "https://proxy.test.com"); + expect(proxyCall).not.toBeNull(); + // Header names come back lowercased when passing through a Request + // object (the passthrough path mswjs uses). Match on lowercase so this + // works regardless of which path vitest's mock received. + const headers = proxyCall?.headers ?? {}; + const lower: Record = {}; + for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v; + expect(lower.authorization).toBe("Bearer my-sdk-token"); + expect(lower["content-type"]).toBe("application/json"); + }); +}); + +describe("interceptor — substitution", () => { + it("URL template rewrites host and preserves path/search + %VAR% tokens", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [ + { + match: "https://api.example.com/", + url: "https://internal.example.com{path}{search}?key=%API_KEY%", + headers: { authorization: "Bearer %K%" }, + }, + ], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1/users?limit=10"); + + const wire = await findProxyCall(fetchMock); + expect(wire?.url).toBe("https://internal.example.com/v1/users?limit=10?key=%API_KEY%"); + }); + + it("header override merges with intercepted headers (case-insensitive)", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [{ match: "https://api.example.com/", headers: { Authorization: "Bearer %K%" } }], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1", { + headers: { "X-Trace": "abc", authorization: "Bearer old" }, + }); + + const wire = await findProxyCall(fetchMock); + const headers = wire?.headers as Record; + expect(headers.authorization).toBe("Bearer %K%"); + expect(headers["x-trace"]).toBe("abc"); + }); + + it("undefined header override deletes the header", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [ + { + match: "https://api.example.com/", + headers: { authorization: "Bearer %K%", "x-drop": undefined }, + }, + ], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1", { + headers: { "X-Drop": "should-go-away", "X-Keep": "stays" }, + }); + + const wire = await findProxyCall(fetchMock); + const headers = wire?.headers as Record; + expect(headers["x-drop"]).toBeUndefined(); + expect(headers["x-keep"]).toBe("stays"); + }); + + it("object body override replaces the intercepted body wholesale", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [ + { + match: "https://api.example.com/", + body: { overridden: true, token: "%T%" }, + }, + ], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ original: true }), + }); + + const wire = await findProxyCall(fetchMock); + expect(wire?.body).toEqual({ overridden: true, token: "%T%" }); + }); + + it("function body override receives the intercepted body", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const bodyOverride = vi.fn((input: unknown) => ({ + ...(input as object), + injected: "%SECRET%", + })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [{ match: "https://api.example.com/", body: bodyOverride }], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ keep: 1 }), + }); + + expect(bodyOverride).toHaveBeenCalledWith({ keep: 1 }); + const wire = await findProxyCall(fetchMock); + expect(wire?.body).toEqual({ keep: 1, injected: "%SECRET%" }); + }); + + it("intercepted JSON body is forwarded verbatim when no body override is set", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [{ match: "https://api.example.com/", headers: { authorization: "Bearer %K%" } }], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query: "%SEARCH%", limit: 5 }), + }); + + const wire = await findProxyCall(fetchMock); + expect(wire?.body).toEqual({ query: "%SEARCH%", limit: 5 }); + }); +}); + +describe("interceptor — response passthrough", () => { + it("returns the proxy's response body to the caller", async () => { + const upstream = { data: [{ id: "1" }] }; + fetchMock.mockResolvedValue( + new Response(JSON.stringify(upstream), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [{ match: "https://api.example.com/", headers: { authorization: "Bearer %K%" } }], + }, + }), + ); + await activeClient._interceptorReady(); + + const response = await fetch("https://api.example.com/v1"); + expect(response.ok).toBe(true); + expect(await response.json()).toEqual(upstream); + }); + + it("non-2xx proxy response is returned as Response without throwing", async () => { + fetchMock.mockResolvedValue(new Response("boom", { status: 500, statusText: "Internal Error" })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [{ match: "https://api.example.com/", headers: { authorization: "Bearer %K%" } }], + }, + }), + ); + await activeClient._interceptorReady(); + + const response = await fetch("https://api.example.com/v1"); + expect(response.ok).toBe(false); + expect(response.status).toBe(500); + expect(await response.text()).toBe("boom"); + }); +}); + +describe("interceptor — unsupported bodies", () => { + it("by default, passes a URLSearchParams body through without interception", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + logger: { level: "error" }, // silence the passthrough warning + interceptor: { + rules: [{ match: "https://api.example.com/", headers: { authorization: "Bearer %K%" } }], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1", { + method: "POST", + body: new URLSearchParams({ k: "v" }), + }); + + // No proxy call should have happened. + expect(await findProxyCall(fetchMock)).toBeNull(); + }); + + it('fails the request when onUnsupportedBody: "error" and body is not JSON', async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [ + { + match: "https://api.example.com/", + headers: { authorization: "Bearer %K%" }, + onUnsupportedBody: "error", + }, + ], + }, + }), + ); + await activeClient._interceptorReady(); + + await expect( + fetch("https://api.example.com/v1", { + method: "POST", + body: new URLSearchParams({ k: "v" }), + }), + ).rejects.toThrow(/not JSON-serialisable/); + }); +}); + +describe("interceptor — lifecycle", () => { + it("destroy() disables interception; subsequent matched URLs hit the real target", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + const client = new Enkryptify( + makeConfig({ + interceptor: { + rules: [{ match: "https://api.example.com/", headers: { authorization: "Bearer %K%" } }], + }, + }), + ); + await client._interceptorReady(); + + // Before destroy: routed through proxy. + await fetch("https://api.example.com/v1"); + expect(await findProxyCall(fetchMock)).not.toBeNull(); + + client.destroy(); + + // After destroy: fresh mock to simplify assertion. + fetchMock.mockClear(); + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + await fetch("https://api.example.com/v1"); + + // No proxy POST; only the direct call to api.example.com. + expect(await findProxyCall(fetchMock)).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("api.example.com"); + }); + + it("no interceptor is attached when rules array is empty", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { rules: [] }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1"); + + expect(await findProxyCall(fetchMock)).toBeNull(); + }); + + it("no interceptor is attached when config.interceptor is omitted", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify(makeConfig()); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1"); + + expect(await findProxyCall(fetchMock)).toBeNull(); + }); + + it("enabled: false disables the interceptor even when rules are present", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + enabled: false, + rules: [{ match: "https://api.example.com/", headers: { authorization: "Bearer %K%" } }], + }, + }), + ); + await activeClient._interceptorReady(); + + await fetch("https://api.example.com/v1"); + expect(await findProxyCall(fetchMock)).toBeNull(); + }); +}); + +describe("interceptor — errors", () => { + it("surfaces a network error when the proxy fetch rejects", async () => { + fetchMock.mockRejectedValue(new TypeError("ECONNREFUSED")); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [{ match: "https://api.example.com/", headers: { authorization: "Bearer %K%" } }], + }, + }), + ); + await activeClient._interceptorReady(); + + await expect(fetch("https://api.example.com/v1")).rejects.toThrow(); + }); + + it("passthrough when a rule matcher throws", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + + activeClient = new Enkryptify( + makeConfig({ + interceptor: { + rules: [ + { + name: "broken", + match: () => { + throw new Error("matcher boom"); + }, + }, + { + name: "fallback", + match: "https://api.example.com/", + headers: { authorization: "Bearer %K%" }, + }, + ], + }, + }), + ); + await activeClient._interceptorReady(); + + // The fallback rule should still match after the first rule's matcher throws. + await fetch("https://api.example.com/v1"); + const wire = await findProxyCall(fetchMock); + expect(wire).not.toBeNull(); + }); +});