diff --git a/README.md b/README.md index 18a77c1..78d5f81 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,6 @@ const res = await client.proxy.request({ body: { user: "%USER_ID%" }, // Optional per-call overrides: // environment: "other-env-id", - // usePersonal: false, }); ``` diff --git a/src/interceptor.ts b/src/interceptor.ts index 19c5ee4..a8a9fa5 100644 --- a/src/interceptor.ts +++ b/src/interceptor.ts @@ -215,12 +215,10 @@ export class HttpInterceptor { 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, - }, + 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, }; } diff --git a/src/proxy.ts b/src/proxy.ts index f3d4028..d56f09f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -7,7 +7,14 @@ import type { ProxyRequestOptions, TokenExchange, } from "@/types"; -import { EnkryptifyError } from "@/errors"; +import { + AuthenticationError, + AuthorizationError, + EnkryptifyError, + ProxyError, + ProxyValidationError, + RateLimitError, +} from "@/errors"; import type { Logger } from "@/logger"; import { retrieveToken } from "@/internal/token-store"; @@ -35,12 +42,10 @@ export interface ProxyWireBody { method: ProxyMethod; headers?: Record; body?: JsonValue; - config: { - workspace: string; - project: string; - "environment-id": string; - "is-personal": boolean; - }; + workspace: string; + project: string; + "environment-id": string; + "is-personal": boolean; } /** @@ -82,15 +87,16 @@ export async function sendProxyWire( const wireBody: Record = { url: body.url, method: body.method, - config: body.config, + "is-personal": body["is-personal"], }; if (body.headers !== undefined) wireBody.headers = body.headers; if (body.body !== undefined) wireBody.body = body.body; + const proxyRequestUrl = buildProxyRequestUrl(ctx.proxyUrl, body.workspace, body.project, body["environment-id"]); ctx.logger.debug(`Proxy request: ${body.method} ${body.url}`); const start = Date.now(); - const response = await fetch(ctx.proxyUrl, { + const response = await fetch(proxyRequestUrl, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -102,18 +108,12 @@ export async function sendProxyWire( 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; + if (!response.ok) { + const detail = await readProxyErrorDetail(response); + throw mapProxyError(response.status, response.statusText, response.headers.get("retry-after"), detail); + } + + return unwrapUpstreamResponse(response); } export class EnkryptifyProxy implements IEnkryptifyProxy { @@ -179,7 +179,7 @@ export class EnkryptifyProxy implements IEnkryptifyProxy { method, headers, body, - config: this.#buildConfig(), + ...this.#buildScope(), }, init?.signal ?? null, ); @@ -202,7 +202,7 @@ export class EnkryptifyProxy implements IEnkryptifyProxy { ); } - const config = this.#buildConfig({ + const scope = this.#buildScope({ workspace: options.workspace, project: options.project, environment: options.environment, @@ -216,18 +216,18 @@ export class EnkryptifyProxy implements IEnkryptifyProxy { method, headers: options.headers, body: options.body, - config, + ...scope, }, null, ); } - #buildConfig(overrides?: { + #buildScope(overrides?: { workspace?: string; project?: string; environment?: string; usePersonal?: boolean; - }): ProxyWireBody["config"] { + }): Pick { return { workspace: overrides?.workspace ?? this.#workspace, project: overrides?.project ?? this.#project, @@ -309,3 +309,131 @@ function bodyTypeError(typeName: string): EnkryptifyError { "Docs: https://docs.enkryptify.com/sdk/proxy", ); } + +function buildProxyRequestUrl(baseUrl: string, workspace: string, project: string, environmentId: string): string { + const normalizedBaseUrl = baseUrl.replace(/\/+$/, ""); + return `${normalizedBaseUrl}/${encodeURIComponent(workspace)}/${encodeURIComponent(project)}/${encodeURIComponent(environmentId)}`; +} + +/** + * Response envelope produced by the Enkryptify proxy service when it + * successfully forwards a request upstream. The proxy always returns HTTP 200 + * on success and conveys the upstream status / headers / body via this object. + */ +interface ProxyResponseEnvelope { + status: number; + headers?: Record; + body?: unknown; +} + +/** + * Hop-by-hop and length-related headers that would be incorrect after we + * re-encode the body. RFC 7230 §6.1 names these as connection-specific. + */ +const HOP_BY_HOP_RESPONSE_HEADERS: ReadonlySet = new Set([ + "connection", + "content-length", + "transfer-encoding", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "upgrade", +]); + +function isProxyResponseEnvelope(value: unknown): value is ProxyResponseEnvelope { + return ( + typeof value === "object" && + value !== null && + "status" in value && + typeof (value as { status: unknown }).status === "number" + ); +} + +/** + * Convert the proxy's `{ status, headers, body }` envelope into a real upstream + * `Response`. The returned object behaves exactly like the upstream API's own + * response would have, so callers can use `await res.json()`, `res.ok`, etc. + */ +async function unwrapUpstreamResponse(proxyResponse: Response): Promise { + let envelope: ProxyResponseEnvelope; + try { + const parsed = (await proxyResponse.json()) as unknown; + if (!isProxyResponseEnvelope(parsed)) { + throw new ProxyError( + proxyResponse.status, + proxyResponse.statusText, + "Proxy returned a 2xx response without the expected `{ status, headers, body }` envelope.", + ); + } + envelope = parsed; + } catch (err) { + if (err instanceof EnkryptifyError) throw err; + throw new ProxyError( + proxyResponse.status, + proxyResponse.statusText, + `Failed to parse proxy response: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const headers = new Headers(); + for (const [key, value] of Object.entries(envelope.headers ?? {})) { + if (HOP_BY_HOP_RESPONSE_HEADERS.has(key.toLowerCase())) continue; + headers.set(key, value); + } + + // 204 / 205 / 304 must not have a body per the Fetch spec — passing one to + // the Response constructor throws TypeError. + const status = envelope.status; + const noBodyStatus = status === 204 || status === 205 || status === 304; + const body = envelope.body; + + let bodyInit: BodyInit | null = null; + if (!noBodyStatus && body !== null && body !== undefined) { + if (typeof body === "string") { + bodyInit = body; + } else { + // Object / array / number / boolean → JSON. + bodyInit = JSON.stringify(body); + if (!headers.has("content-type")) { + headers.set("content-type", "application/json; charset=utf-8"); + } + } + } + + return new Response(bodyInit, { status, headers }); +} + +/** + * Best-effort decode of the proxy's own error body. The proxy's error handler + * returns `{ "error": "" }`; surface that string directly so error + * messages stay short. Fall back to the raw text or parsed JSON otherwise. + */ +async function readProxyErrorDetail(proxyResponse: Response): Promise { + const text = await proxyResponse.text().catch(() => ""); + if (!text) return undefined; + try { + const parsed = JSON.parse(text) as unknown; + if (typeof parsed === "object" && parsed !== null && "error" in parsed) { + const err = (parsed as { error: unknown }).error; + if (typeof err === "string") return err; + } + return parsed; + } catch { + return text; + } +} + +function mapProxyError( + status: number, + statusText: string, + retryAfter: string | null, + detail: unknown, +): EnkryptifyError { + if (status === 401) return new AuthenticationError(); + if (status === 403) return new AuthorizationError(); + if (status === 429) return new RateLimitError(retryAfter ?? undefined); + if (status === 400) return new ProxyValidationError(detail); + return new ProxyError(status, statusText, detail); +} diff --git a/tests/interceptor.test.ts b/tests/interceptor.test.ts index c1d05ff..c09b0bb 100644 --- a/tests/interceptor.test.ts +++ b/tests/interceptor.test.ts @@ -1,9 +1,25 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { Enkryptify, InterceptorError } from "@/index"; +import { Enkryptify, InterceptorError, ProxyError } from "@/index"; import { storeToken } from "@/internal/token-store"; import { mergeHeaders, resolveBody, templateUrl } from "@/internal/template"; import type { EnkryptifyAuthProvider, EnkryptifyConfig } from "@/types"; +/** + * Build the response shape the real Enkryptify proxy returns on success: + * HTTP 200 wrapping the upstream status/headers/body in a JSON envelope. + */ +function envelope(body: unknown = {}, upstreamStatus = 200, upstreamHeaders: Record = {}): Response { + return new Response(JSON.stringify({ status: upstreamStatus, headers: upstreamHeaders, body }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +/** Default no-op proxy success response used by tests that only care about the wire body. */ +function okEnvelope(): Response { + return envelope({}, 200, {}); +} + function createAuth(token = "ek_test"): EnkryptifyAuthProvider { const auth = { _brand: "EnkryptifyAuthProvider" as const }; storeToken(auth, token); @@ -78,7 +94,7 @@ async function findProxyCall(fetchMock: ReturnType): Promise; @@ -233,7 +249,7 @@ afterEach(() => { describe("interceptor — rule matching", () => { it("string prefix match routes fetch call through the proxy", async () => { - fetchMock.mockResolvedValue(new Response('{"ok":true}', { status: 200 })); + fetchMock.mockResolvedValue(envelope({ ok: true }, 200)); activeClient = new Enkryptify( makeConfig({ @@ -261,7 +277,7 @@ describe("interceptor — rule matching", () => { }); it("regex match routes request through the proxy", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -286,7 +302,7 @@ describe("interceptor — rule matching", () => { }); it("predicate match routes request through the proxy", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const predicate = vi.fn((url: string) => url.includes("twilio.com")); activeClient = new Enkryptify( @@ -311,7 +327,7 @@ describe("interceptor — rule matching", () => { }); it("non-matching URL passes through to the real target without proxy involvement", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -336,7 +352,7 @@ describe("interceptor — rule matching", () => { }); it("first matching rule wins when multiple rules overlap", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -359,8 +375,8 @@ describe("interceptor — rule matching", () => { }); describe("interceptor — ProxyWireBody shape", () => { - it("includes config block with client defaults", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + it("puts client default context in the proxy URL path", async () => { + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -378,16 +394,13 @@ describe("interceptor — ProxyWireBody shape", () => { 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, - }); + expect(wire?.["is-personal"]).toBe(false); + const proxyCall = await findCallByUrlPrefix(fetchMock, "https://proxy.test.com/"); + expect(proxyCall?.url).toBe("https://proxy.test.com/ws-x/prj-y/env-z"); }); it("rule-level workspace/project/environment/usePersonal override defaults", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -410,16 +423,13 @@ describe("interceptor — ProxyWireBody shape", () => { 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, - }); + expect(wire?.["is-personal"]).toBe(false); + const proxyCall = await findCallByUrlPrefix(fetchMock, "https://proxy.test.com/"); + expect(proxyCall?.url).toBe("https://proxy.test.com/override-ws/override-prj/override-env"); }); it("sends Authorization: Bearer on the proxy call", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -448,7 +458,7 @@ describe("interceptor — ProxyWireBody shape", () => { describe("interceptor — substitution", () => { it("URL template rewrites host and preserves path/search + %VAR% tokens", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -472,7 +482,7 @@ describe("interceptor — substitution", () => { }); it("header override merges with intercepted headers (case-insensitive)", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -494,7 +504,7 @@ describe("interceptor — substitution", () => { }); it("undefined header override deletes the header", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -521,7 +531,7 @@ describe("interceptor — substitution", () => { }); it("object body override replaces the intercepted body wholesale", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -548,7 +558,7 @@ describe("interceptor — substitution", () => { }); it("function body override receives the intercepted body", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const bodyOverride = vi.fn((input: unknown) => ({ ...(input as object), injected: "%SECRET%", @@ -575,7 +585,7 @@ describe("interceptor — substitution", () => { }); it("intercepted JSON body is forwarded verbatim when no body override is set", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -597,15 +607,10 @@ describe("interceptor — substitution", () => { }); }); -describe("interceptor — response passthrough", () => { - it("returns the proxy's response body to the caller", async () => { +describe("interceptor — upstream response delivery", () => { + it("delivers the unwrapped upstream body to the caller", async () => { const upstream = { data: [{ id: "1" }] }; - fetchMock.mockResolvedValue( - new Response(JSON.stringify(upstream), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); + fetchMock.mockResolvedValue(envelope(upstream, 200, { "content-type": "application/json" })); activeClient = new Enkryptify( makeConfig({ @@ -621,8 +626,8 @@ describe("interceptor — response passthrough", () => { 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" })); + it("preserves upstream non-2xx status (e.g. 401 from the target API) without throwing", async () => { + fetchMock.mockResolvedValue(envelope({ cod: 401, message: "Invalid API key" }, 401)); activeClient = new Enkryptify( makeConfig({ @@ -635,14 +640,34 @@ describe("interceptor — response passthrough", () => { 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"); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ cod: 401, message: "Invalid API key" }); + }); + + it("proxy-layer 5xx surfaces as a thrown ProxyError on the intercepted fetch", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: "proxy down" }), { + status: 502, + headers: { "content-type": "application/json" }, + }), + ); + + 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.toBeInstanceOf(ProxyError); }); }); describe("interceptor — unsupported bodies", () => { it("by default, passes a URLSearchParams body through without interception", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -664,7 +689,7 @@ describe("interceptor — unsupported bodies", () => { }); it('fails the request when onUnsupportedBody: "error" and body is not JSON', async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -692,7 +717,7 @@ describe("interceptor — unsupported bodies", () => { describe("interceptor — lifecycle", () => { it("destroy() disables interception; subsequent matched URLs hit the real target", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify( makeConfig({ @@ -711,7 +736,7 @@ describe("interceptor — lifecycle", () => { // After destroy: fresh mock to simplify assertion. fetchMock.mockClear(); - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); await fetch("https://api.example.com/v1"); @@ -722,7 +747,7 @@ describe("interceptor — lifecycle", () => { }); it("no interceptor is attached when rules array is empty", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -737,7 +762,7 @@ describe("interceptor — lifecycle", () => { }); it("no interceptor is attached when config.interceptor is omitted", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify(makeConfig()); await activeClient._interceptorReady(); @@ -748,7 +773,7 @@ describe("interceptor — lifecycle", () => { }); it("enabled: false disables the interceptor even when rules are present", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ @@ -782,7 +807,7 @@ describe("interceptor — errors", () => { }); it("passthrough when a rule matcher throws", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); activeClient = new Enkryptify( makeConfig({ diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts index be377f0..45cfbc9 100644 --- a/tests/proxy.test.ts +++ b/tests/proxy.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { Enkryptify, EnkryptifyError } from "@/index"; +import { + AuthenticationError, + AuthorizationError, + Enkryptify, + EnkryptifyError, + ProxyError, + ProxyValidationError, + RateLimitError, +} from "@/index"; import { storeToken } from "@/internal/token-store"; import type { EnkryptifyAuthProvider, EnkryptifyConfig } from "@/types"; @@ -9,6 +17,22 @@ function createAuth(token = "ek_test"): EnkryptifyAuthProvider { return auth; } +/** + * Build the response shape the real Enkryptify proxy returns on success: + * HTTP 200 wrapping the upstream status/headers/body in a JSON envelope. + */ +function envelope(body: unknown = {}, upstreamStatus = 200, upstreamHeaders: Record = {}): Response { + return new Response(JSON.stringify({ status: upstreamStatus, headers: upstreamHeaders, body }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +/** Default no-op proxy success response used by tests that only care about the wire body. */ +function okEnvelope(): Response { + return envelope({}, 200, {}); +} + function makeConfig(overrides?: Partial): EnkryptifyConfig { return { auth: createAuth(), @@ -40,14 +64,14 @@ afterEach(() => { describe("client.proxy.fetch — body translation", () => { it("GET without body sends correct wire body", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig()); await client.proxy.fetch("https://upstream/x?k=%K%"); expect(fetchMock).toHaveBeenCalledTimes(1); const url = fetchMock.mock.calls[0]?.[0] as string; - expect(url).toBe("https://proxy.test.com"); + expect(url).toBe("https://proxy.test.com/ws-1/prj-1/env-1"); const opts = fetchMock.mock.calls[0]?.[1] as RequestInit; expect(opts.method).toBe("POST"); @@ -55,19 +79,14 @@ describe("client.proxy.fetch — body translation", () => { expect(body).toMatchObject({ url: "https://upstream/x?k=%K%", method: "GET", - config: { - workspace: "ws-1", - project: "prj-1", - "environment-id": "env-1", - "is-personal": true, - }, + "is-personal": true, }); expect(body.body).toBeUndefined(); expect(body.headers).toBeUndefined(); }); it("POST with JSON string body parses to object in wire body", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig()); await client.proxy.fetch("https://upstream/x", { @@ -80,7 +99,7 @@ describe("client.proxy.fetch — body translation", () => { }); it("POST with plain object body passes through", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig()); // Cast needed because RequestInit.body doesn't include plain objects @@ -163,7 +182,7 @@ describe("client.proxy.fetch — body translation", () => { }); it("coerces URL object input to string", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig()); await client.proxy.fetch(new URL("https://upstream/x?a=1")); @@ -173,7 +192,7 @@ describe("client.proxy.fetch — body translation", () => { }); it("normalizes headers from Headers object", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig()); await client.proxy.fetch("https://upstream/x", { @@ -190,7 +209,7 @@ describe("client.proxy.fetch — body translation", () => { }); it("defaults method to GET when init omitted", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig()); await client.proxy.fetch("https://upstream/x"); @@ -200,7 +219,7 @@ describe("client.proxy.fetch — body translation", () => { }); it("uppercases lowercase method", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig()); await client.proxy.fetch("https://upstream/x", { method: "post", body: "{}" }); @@ -211,8 +230,8 @@ describe("client.proxy.fetch — body translation", () => { }); describe("client.proxy.request — low-level API", () => { - it("sends exact config in kebab-case", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + it("sends wire body and routes context in URL path", async () => { + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig()); await client.proxy.request({ @@ -226,17 +245,13 @@ describe("client.proxy.request — low-level API", () => { url: "https://upstream/x", method: "POST", body: { foo: "%BAR%" }, - config: { - workspace: "ws-1", - project: "prj-1", - "environment-id": "env-1", - "is-personal": true, - }, + "is-personal": true, }); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://proxy.test.com/ws-1/prj-1/env-1"); }); it("applies per-call environment override", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig()); await client.proxy.request({ @@ -245,12 +260,11 @@ describe("client.proxy.request — low-level API", () => { environment: "other-env", }); - const body = getCallBody(fetchMock.mock.calls[0] as unknown[]); - expect((body.config as Record)["environment-id"]).toBe("other-env"); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://proxy.test.com/ws-1/prj-1/other-env"); }); it("applies per-call workspace/project/usePersonal overrides", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig()); await client.proxy.request({ @@ -262,12 +276,8 @@ describe("client.proxy.request — low-level API", () => { }); const body = getCallBody(fetchMock.mock.calls[0] as unknown[]); - expect(body.config).toEqual({ - workspace: "other-ws", - project: "other-prj", - "environment-id": "env-1", - "is-personal": false, - }); + expect(body["is-personal"]).toBe(false); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://proxy.test.com/other-ws/other-prj/env-1"); }); it("rejects GET with body", async () => { @@ -291,7 +301,7 @@ describe("client.proxy.request — low-level API", () => { describe("client.proxy — authorization", () => { it("sends Authorization: Bearer ", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig({ auth: createAuth("my-proxy-token") })); await client.proxy.fetch("https://upstream/x"); @@ -312,7 +322,7 @@ describe("client.proxy — authorization", () => { }), ); } - return Promise.resolve(new Response("{}", { status: 200 })); + return Promise.resolve(okEnvelope()); }); const client = new Enkryptify( @@ -337,44 +347,33 @@ describe("client.proxy — authorization", () => { }); }); -describe("client.proxy — response passthrough", () => { - // client.proxy.fetch is a fetch-style API: it returns whatever the Proxy returned - // (which for success is the upstream's verbatim Response, and for proxy-layer - // errors is a `{error: {code, message}}` JSON envelope). No status-based throwing — - // that produced wrong errors when the upstream itself returned 401/403/etc. +describe("client.proxy — envelope unwrap (upstream response)", () => { + // The proxy returns `{ status, headers, body }` wrapped in an HTTP 200. + // The SDK unwraps that envelope into a `Response` whose status/headers/body + // mirror what the upstream API itself produced. - it("returns the upstream Response on 2xx and body is readable", async () => { + it("returns the upstream body on 2xx and body is readable", async () => { const payload = { hello: "world" }; - fetchMock.mockResolvedValue( - new Response(JSON.stringify(payload), { - status: 200, - headers: new Headers({ "Content-Type": "application/json" }), - }), - ); + fetchMock.mockResolvedValue(envelope(payload, 200, { "content-type": "application/json" })); const client = new Enkryptify(makeConfig()); const res = await client.proxy.fetch("https://upstream/x"); expect(res.ok).toBe(true); - const json = await res.json(); - expect(json).toEqual(payload); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(payload); }); - it("preserves upstream status code on 201/204/etc.", async () => { - fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + it("preserves upstream status on 201/204/etc.", async () => { + fetchMock.mockResolvedValue(envelope(null, 204, {})); const client = new Enkryptify(makeConfig()); const res = await client.proxy.fetch("https://upstream/x", { method: "DELETE" }); expect(res.status).toBe(204); }); - it("returns upstream 401 as Response without throwing (critical: distinguish upstream auth from proxy auth)", async () => { + it("returns upstream 401 without throwing — distinguishes upstream auth from proxy auth", async () => { const body = { cod: 401, message: "Invalid API key" }; - fetchMock.mockResolvedValue( - new Response(JSON.stringify(body), { - status: 401, - headers: new Headers({ "Content-Type": "application/json" }), - }), - ); + fetchMock.mockResolvedValue(envelope(body, 401, { "content-type": "application/json" })); const client = new Enkryptify(makeConfig()); const res = await client.proxy.fetch("https://upstream/x"); @@ -384,51 +383,144 @@ describe("client.proxy — response passthrough", () => { }); it.each([ - [400, "Bad Request"], - [403, "Forbidden"], - [404, "Not Found"], - [429, "Too Many Requests"], - [500, "Internal Server Error"], - [502, "Bad Gateway"], - [503, "Service Unavailable"], - ])("returns upstream %i as Response without throwing", async (status, statusText) => { - fetchMock.mockResolvedValue(new Response(statusText, { status, statusText })); + [400, { error: "bad" }], + [403, "forbidden"], + [404, { message: "not found" }], + [500, "boom"], + [502, "bad gateway"], + [503, "down"], + ])("returns upstream %i as Response without throwing", async (upstreamStatus, body) => { + fetchMock.mockResolvedValue(envelope(body, upstreamStatus)); const client = new Enkryptify(makeConfig()); const res = await client.proxy.fetch("https://upstream/x"); expect(res.ok).toBe(false); - expect(res.status).toBe(status); - expect(await res.text()).toBe(statusText); + expect(res.status).toBe(upstreamStatus); }); - it("returns proxy-layer error envelope as Response (caller reads body.error.code)", async () => { - // Simulates the Proxy's own error response for missing_authorization / - // secrets_unauthorized / invalid_request / etc. — same JSON shape the - // Proxy produces today. - const body = { error: { code: "secrets_unauthorized", message: "Unauthorized to load secrets" } }; + it("forwards upstream string body verbatim with explicit content-type", async () => { + fetchMock.mockResolvedValue(envelope("hi", 200, { "content-type": "application/xml" })); + const client = new Enkryptify(makeConfig()); + + const res = await client.proxy.fetch("https://upstream/x"); + expect(res.headers.get("content-type")).toBe("application/xml"); + expect(await res.text()).toBe("hi"); + }); + + it("preserves upstream Retry-After header on 429", async () => { + fetchMock.mockResolvedValue(envelope("rate limited", 429, { "Retry-After": "42" })); + const client = new Enkryptify(makeConfig()); + + const res = await client.proxy.fetch("https://upstream/x"); + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("42"); + }); + + it("strips hop-by-hop headers (content-length, transfer-encoding) from the synthesized Response", async () => { fetchMock.mockResolvedValue( - new Response(JSON.stringify(body), { - status: 401, - headers: new Headers({ "Content-Type": "application/json" }), + envelope({ ok: true }, 200, { + "content-length": "999", + "transfer-encoding": "chunked", + "x-custom": "keep", }), ); const client = new Enkryptify(makeConfig()); const res = await client.proxy.fetch("https://upstream/x"); - expect(res.status).toBe(401); - expect(await res.json()).toEqual(body); + expect(res.headers.get("transfer-encoding")).toBeNull(); + expect(res.headers.get("content-length")).toBeNull(); + expect(res.headers.get("x-custom")).toBe("keep"); }); +}); + +describe("client.proxy — proxy-layer errors map to typed exceptions", () => { + // Non-2xx from the proxy itself (auth failed, validation failed, missing + // secret, rate limit, etc.) must NOT look like an upstream Response — + // surface a typed error so callers can branch on the cause. - it("preserves upstream Retry-After header on 429 passthrough", async () => { + function errorBody(message: string): Response { + return new Response(JSON.stringify({ error: message }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + + it("401 from the proxy throws AuthenticationError", async () => { fetchMock.mockResolvedValue( - new Response("rate limited", { status: 429, headers: new Headers({ "Retry-After": "42" }) }), + new Response(JSON.stringify({ error: "Invalid token" }), { + status: 401, + headers: { "content-type": "application/json" }, + }), ); const client = new Enkryptify(makeConfig()); - const res = await client.proxy.fetch("https://upstream/x"); - expect(res.status).toBe(429); - expect(res.headers.get("Retry-After")).toBe("42"); + await expect(client.proxy.fetch("https://upstream/x")).rejects.toBeInstanceOf(AuthenticationError); + }); + + it("403 from the proxy throws AuthorizationError", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: { "content-type": "application/json" }, + }), + ); + const client = new Enkryptify(makeConfig()); + + await expect(client.proxy.fetch("https://upstream/x")).rejects.toBeInstanceOf(AuthorizationError); + }); + + it("400 from the proxy throws ProxyValidationError with the proxy's detail", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: 'Secret "X" missing' }), { + status: 400, + headers: { "content-type": "application/json" }, + }), + ); + const client = new Enkryptify(makeConfig()); + + const err = (await client.proxy.fetch("https://upstream/x").catch((e) => e)) as ProxyValidationError; + expect(err).toBeInstanceOf(ProxyValidationError); + expect(err.detail).toBe('Secret "X" missing'); + }); + + it("429 from the proxy throws RateLimitError with Retry-After", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: "Slow down" }), { + status: 429, + headers: { "content-type": "application/json", "Retry-After": "30" }, + }), + ); + const client = new Enkryptify(makeConfig()); + + const err = (await client.proxy.fetch("https://upstream/x").catch((e) => e)) as RateLimitError; + expect(err).toBeInstanceOf(RateLimitError); + expect(err.retryAfter).toBe(30); + }); + + it("5xx from the proxy throws ProxyError carrying status and detail", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: "down" }), { + status: 502, + headers: { "content-type": "application/json" }, + }), + ); + const client = new Enkryptify(makeConfig()); + + const err = (await client.proxy.fetch("https://upstream/x").catch((e) => e)) as ProxyError; + expect(err).toBeInstanceOf(ProxyError); + expect(err.status).toBe(502); + expect(err.detail).toBe("down"); }); + + it("HTTP 200 with a non-envelope body throws ProxyError (proxy contract violation)", async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ not: "an envelope" }), { status: 200 })); + const client = new Enkryptify(makeConfig()); + + await expect(client.proxy.fetch("https://upstream/x")).rejects.toBeInstanceOf(ProxyError); + }); + + // Reference unused helper to silence the linter (kept for future tests). + void errorBody; }); describe("client.proxy — URL resolution", () => { @@ -444,32 +536,32 @@ describe("client.proxy — URL resolution", () => { it("config.proxy.url takes priority over env var", async () => { process.env.ENKRYPTIFY_PROXY_URL = "https://env.test.com"; - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig({ proxy: { url: "https://config.test.com" } })); await client.proxy.fetch("https://upstream/x"); - expect(fetchMock.mock.calls[0]?.[0]).toBe("https://config.test.com"); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://config.test.com/ws-1/prj-1/env-1"); }); it("falls back to ENKRYPTIFY_PROXY_URL env var", async () => { process.env.ENKRYPTIFY_PROXY_URL = "https://env.test.com"; - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig({ proxy: undefined })); await client.proxy.fetch("https://upstream/x"); - expect(fetchMock.mock.calls[0]?.[0]).toBe("https://env.test.com"); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://env.test.com/ws-1/prj-1/env-1"); }); it("falls back to default POC URL when nothing else is set", async () => { delete process.env.ENKRYPTIFY_PROXY_URL; - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig({ proxy: undefined })); await client.proxy.fetch("https://upstream/x"); - expect(fetchMock.mock.calls[0]?.[0]).toBe("https://proxy.enkryptify.com"); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://proxy.enkryptify.com/ws-1/prj-1/env-1"); }); }); @@ -482,7 +574,7 @@ describe("client.proxy — lifecycle", () => { }); it("throws when destroyed between getting proxy and calling fetch", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig()); const proxy = client.proxy; client.destroy(); @@ -493,7 +585,7 @@ describe("client.proxy — lifecycle", () => { describe("client.proxy — destructured fetch (axios/ky wiring)", () => { it("works when fetch is destructured from proxy", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig()); const { fetch: proxyFetch } = client.proxy; @@ -526,7 +618,7 @@ describe("proxyOnly mode", () => { }); it(".proxy.fetch() still works when proxyOnly=true", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + fetchMock.mockResolvedValue(okEnvelope()); const client = new Enkryptify(makeConfig({ proxy: { url: "https://proxy.test.com", proxyOnly: true } })); await client.proxy.fetch("https://upstream/x");