diff --git a/README.md b/README.md index d8d99e0..18a77c1 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,84 @@ Destroy the client when you're done to clear all cached secrets from memory: client.destroy(); ``` +## Proxy + +The Enkryptify proxy lets your code call upstream APIs without the secret values ever touching your process. Write `%VARIABLE_NAME%` placeholders in the URL, headers, or body, and Enkryptify substitutes them server-side before forwarding the request. + +### Basic Usage (fetch-compatible) + +```typescript +const res = await client.proxy.fetch("https://api.stripe.com/v1/charges", { + method: "POST", + headers: { Authorization: "Bearer %STRIPE_SECRET%" }, + body: JSON.stringify({ amount: 1000, currency: "usd" }), +}); +const charge = await res.json(); +``` + +`client.proxy.fetch` returns a standard `Response` — call `.json()`, `.text()`, `.arrayBuffer()`, etc. as needed. + +### Wiring into axios, ky, or other HTTP clients + +Because `client.proxy.fetch` matches the native `fetch` signature, any client that accepts a custom `fetch` implementation or fetch adapter will work: + +```typescript +// axios (1.7+) +import axios from "axios"; +const api = axios.create({ adapter: "fetch", fetch: client.proxy.fetch }); +const res = await api.get("https://api.upstream.com/x?key=%API_KEY%"); + +// ky +import ky from "ky"; +const api = ky.extend({ fetch: client.proxy.fetch }); +const data = await api.get("https://api.upstream.com/x?key=%API_KEY%").json(); +``` + +### Low-level Request API + +Prefer `client.proxy.request` when writing new code — it takes a typed JSON body directly and skips the fetch-init translation: + +```typescript +const res = await client.proxy.request({ + url: "https://api.upstream.com/x", + method: "POST", + headers: { "X-Auth": "Bearer %TOKEN%" }, + body: { user: "%USER_ID%" }, + // Optional per-call overrides: + // environment: "other-env-id", + // usePersonal: false, +}); +``` + +### Proxy-Only Clients + +If your token has no permission to read secrets directly (proxy-gated access), set `proxy.proxyOnly` so that `.get()` / `.preload()` / `.getFromCache()` throw a clear error pointing users at the proxy: + +```typescript +const client = new Enkryptify({ + token: process.env.ENKRYPTIFY_TOKEN, + workspace: "demo", + project: "proxy", + environment: "env-id", + proxy: { proxyOnly: true }, +}); + +// Throws: "This client is configured as proxy-only. Use client.proxy.fetch() or client.proxy.request() instead." +await client.get("SOME_KEY"); + +// Works as expected: +await client.proxy.fetch("https://api.upstream.com/x?key=%SOME_KEY%"); +``` + +### Body Restrictions + +The proxy operates on JSON payloads (so it can template `%VARIABLE%` placeholders). Non-JSON bodies throw with a clear message: + +- ✅ plain objects / arrays / primitives / `JSON.stringify`-ed strings +- ❌ `FormData`, `Blob`, `ArrayBuffer`, typed arrays, `ReadableStream`, `URLSearchParams` + +GET and HEAD requests cannot include a body (matches the proxy contract). + ## Configuration | Option | Type | Default | Description | @@ -145,6 +223,17 @@ client.destroy(); | `cache.ttl` | `number` | `-1` | Cache TTL in ms (`-1` = never expire) | | `cache.eager` | `boolean` | `true` | Fetch all secrets on first `get()` | | `logger.level` | `"debug" \| "info" \| "warn" \| "error"` | `"info"` | Minimum log level | +| `proxy.url` | `string` | POC URL (overridable) | Proxy service URL | +| `proxy.proxyOnly` | `boolean` | `false` | Disable direct secret access; require proxy | + +### Environment Variables + +| Variable | Description | +| ----------------------- | --------------------------------------------------- | +| `ENKRYPTIFY_TOKEN` | Auth token, used when no `token` / `auth` is passed | +| `ENKRYPTIFY_API_URL` | Overrides the default `baseUrl` | +| `ENKRYPTIFY_PROXY_URL` | Overrides the default proxy URL | +| `ENKRYPTIFY_TOKEN_PATH` | Kubernetes service account token path | ## API Reference @@ -191,12 +280,18 @@ try { } ``` -| Error Class | When | -| --------------------- | ----------------------------------------------- | -| `EnkryptifyError` | Base class for all SDK errors | -| `SecretNotFoundError` | Secret key not found in the project/environment | -| `AuthenticationError` | HTTP 401 or 403 from the API | -| `ApiError` | Any other non-OK HTTP response | +| Error Class | When | +| ---------------------- | ------------------------------------------------------- | +| `EnkryptifyError` | Base class for all SDK errors | +| `SecretNotFoundError` | Secret key not found in the project/environment | +| `AuthenticationError` | HTTP 401 from the API or proxy | +| `AuthorizationError` | HTTP 403 from the API or proxy | +| `NotFoundError` | HTTP 404 from the API (wrong workspace/project/env) | +| `RateLimitError` | HTTP 429 (includes `retryAfter` in seconds) | +| `ApiError` | Any other non-OK response from the secrets API | +| `ProxyError` | Any non-OK, non-400 response from the proxy service | +| `ProxyValidationError` | HTTP 400 from the proxy (bad URL, method, body, config) | +| `KubernetesAuthError` | Kubernetes service account token read failed | ## Development diff --git a/src/enkryptify.ts b/src/enkryptify.ts index 168f7e4..d74dbaf 100644 --- a/src/enkryptify.ts +++ b/src/enkryptify.ts @@ -2,6 +2,7 @@ import type { EnkryptifyAuthProvider, EnkryptifyConfig, IEnkryptify, + IEnkryptifyProxy, KubernetesAuthOptions, Secret, TokenExchange, @@ -14,6 +15,9 @@ import { Logger } from "@/logger"; import { retrieveToken } from "@/internal/token-store"; import { TokenExchangeManager } from "@/token-exchange"; import { KubernetesExchangeManager } from "@/kubernetes-exchange"; +import { EnkryptifyProxy } from "@/proxy"; + +const DEFAULT_PROXY_URL = "https://proxy-poc-black.vercel.app"; export class Enkryptify implements IEnkryptify { #api: EnkryptifyApi; @@ -29,6 +33,8 @@ export class Enkryptify implements IEnkryptify { #destroyed = false; #eagerLoaded = false; #tokenExchange: TokenExchange | null = null; + #proxy: EnkryptifyProxy; + #proxyOnly: boolean; constructor(config: EnkryptifyConfig) { if (!config.workspace) { @@ -104,11 +110,30 @@ export class Enkryptify implements IEnkryptify { this.#tokenExchange = new TokenExchangeManager(baseUrl, staticToken, auth, this.#logger); } + const proxyUrl = config.proxy?.url ?? process.env.ENKRYPTIFY_PROXY_URL ?? DEFAULT_PROXY_URL; + this.#proxyOnly = config.proxy?.proxyOnly ?? false; + this.#proxy = new EnkryptifyProxy({ + proxyUrl, + auth, + tokenExchange: this.#tokenExchange, + workspace: this.#workspace, + project: this.#project, + environment: this.#environment, + usePersonalValues: this.#usePersonalValues, + logger: this.#logger, + isDestroyed: () => this.#destroyed, + }); + this.#logger.info( `Initialized for workspace "${this.#workspace}", project "${this.#project}", environment "${this.#environment}"`, ); } + get proxy(): IEnkryptifyProxy { + this.#guardDestroyed(); + return this.#proxy; + } + static fromEnv(): EnkryptifyAuthProvider { return new EnvAuthProvider(); } @@ -137,6 +162,7 @@ export class Enkryptify implements IEnkryptify { async get(key: string, options?: { cache?: boolean }): Promise { this.#guardDestroyed(); + this.#guardProxyOnly(); const useCache = this.#cacheEnabled && options?.cache !== false; @@ -160,6 +186,7 @@ export class Enkryptify implements IEnkryptify { getFromCache(key: string): string { this.#guardDestroyed(); + this.#guardProxyOnly(); if (!this.#cacheEnabled || !this.#cache) { throw new EnkryptifyError( @@ -178,6 +205,7 @@ export class Enkryptify implements IEnkryptify { async preload(): Promise { this.#guardDestroyed(); + this.#guardProxyOnly(); if (!this.#cacheEnabled || !this.#cache) { throw new EnkryptifyError( @@ -220,6 +248,16 @@ export class Enkryptify implements IEnkryptify { } } + #guardProxyOnly(): void { + if (this.#proxyOnly) { + throw new EnkryptifyError( + "This client is configured as proxy-only. Direct secret access is disabled. " + + "Use client.proxy.fetch() or client.proxy.request() instead.\n" + + "Docs: https://docs.enkryptify.com/sdk/proxy", + ); + } + } + async #fetchAndCacheAll(key: string): Promise { this.#logger.debug( `Fetching secret(s) from API: GET /v1/workspace/${this.#workspace}/project/${this.#project}/secret`, diff --git a/src/errors.ts b/src/errors.ts index 99adb7e..5b6f144 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -86,3 +86,48 @@ export class ApiError extends EnkryptifyError { this.status = status; } } + +function formatDetail(detail: unknown): string { + if (detail === undefined || detail === null) return ""; + if (typeof detail === "string") return detail; + try { + return JSON.stringify(detail); + } catch { + return String(detail); + } +} + +export class ProxyError extends EnkryptifyError { + public readonly status: number; + public readonly detail?: unknown; + + constructor(status: number, statusText: string, detail?: unknown) { + const detailStr = formatDetail(detail); + super( + `Proxy request failed (HTTP ${status}). ` + + `${statusText ? statusText + ". " : ""}` + + `${detailStr ? `Detail: ${detailStr}. ` : ""}` + + `This may be a temporary proxy issue — retry in a few moments.\n` + + `Docs: https://docs.enkryptify.com/sdk/proxy#errors`, + ); + this.name = "ProxyError"; + this.status = status; + this.detail = detail; + } +} + +export class ProxyValidationError extends EnkryptifyError { + public readonly detail?: unknown; + + constructor(detail?: unknown) { + const detailStr = formatDetail(detail); + super( + `Proxy rejected the request (HTTP 400). ` + + `${detailStr ? `Detail: ${detailStr}. ` : ""}` + + `Check your URL, method, headers, body, and proxy config.\n` + + `Docs: https://docs.enkryptify.com/sdk/proxy#errors`, + ); + this.name = "ProxyValidationError"; + this.detail = detail; + } +} diff --git a/src/index.ts b/src/index.ts index b330409..33a9029 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,5 +9,18 @@ export { RateLimitError, ApiError, KubernetesAuthError, + ProxyError, + ProxyValidationError, } from "@/errors"; -export type { IEnkryptify, EnkryptifyConfig, EnkryptifyAuthProvider, KubernetesAuthOptions } from "@/types"; +export type { + IEnkryptify, + IEnkryptifyProxy, + EnkryptifyConfig, + EnkryptifyAuthProvider, + KubernetesAuthOptions, + ProxyConfig, + ProxyMethod, + ProxyRequestInit, + ProxyRequestOptions, + JsonValue, +} from "@/types"; diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..3e94c78 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,279 @@ +import type { + EnkryptifyAuthProvider, + IEnkryptifyProxy, + JsonValue, + ProxyMethod, + ProxyRequestInit, + ProxyRequestOptions, + TokenExchange, +} from "@/types"; +import { EnkryptifyError } from "@/errors"; +import type { Logger } from "@/logger"; +import { retrieveToken } from "@/internal/token-store"; + +const ALLOWED_METHODS: readonly ProxyMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; + +export interface EnkryptifyProxyInit { + proxyUrl: string; + auth: EnkryptifyAuthProvider; + tokenExchange: TokenExchange | null; + workspace: string; + project: string; + environment: string; + usePersonalValues: boolean; + logger: Logger; + isDestroyed: () => boolean; +} + +interface ProxyWireBody { + url: string; + method: ProxyMethod; + headers?: Record; + body?: JsonValue; + config: { + workspace: string; + project: string; + "environment-id": string; + "is-personal": boolean; + }; +} + +export class EnkryptifyProxy implements IEnkryptifyProxy { + #proxyUrl: string; + #auth: EnkryptifyAuthProvider; + #tokenExchange: TokenExchange | null; + #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 + // axios's `fetch` option or ky's `fetch`) keeps working. + fetch: (input: string | URL, init?: ProxyRequestInit) => Promise; + request: (options: ProxyRequestOptions) => Promise; + + constructor(init: EnkryptifyProxyInit) { + this.#proxyUrl = init.proxyUrl; + this.#auth = init.auth; + this.#tokenExchange = init.tokenExchange; + 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); + } + + async #fetchImpl(input: string | URL, init?: ProxyRequestInit): Promise { + if (typeof Request !== "undefined" && input instanceof Request) { + throw new EnkryptifyError( + "client.proxy.fetch does not accept Request objects. Pass a URL string or URL instance instead.\n" + + "Docs: https://docs.enkryptify.com/sdk/proxy", + ); + } + + const url = typeof input === "string" ? input : String(input); + + const method = normalizeMethod(init?.method); + const headers = normalizeHeaders(init?.headers); + const body = parseFetchBody(init?.body); + + if ((method === "GET" || method === "HEAD") && body !== undefined) { + throw new EnkryptifyError( + `${method} requests cannot include a body. Remove the body or change the method.\n` + + "Docs: https://docs.enkryptify.com/sdk/proxy", + ); + } + + return this.#send( + { + url, + method, + headers, + body, + config: this.#buildConfig(), + }, + init?.signal ?? null, + ); + } + + async #requestImpl(options: ProxyRequestOptions): Promise { + if (!options.url) { + throw new EnkryptifyError("Proxy request requires a non-empty `url`."); + } + if (!options.method) { + throw new EnkryptifyError("Proxy request requires a `method`."); + } + + const method = normalizeMethod(options.method); + + if ((method === "GET" || method === "HEAD") && options.body !== undefined) { + throw new EnkryptifyError( + `${method} requests cannot include a body. Remove the body or change the method.\n` + + "Docs: https://docs.enkryptify.com/sdk/proxy", + ); + } + + const config = this.#buildConfig({ + workspace: options.workspace, + project: options.project, + environment: options.environment, + usePersonal: options.usePersonal, + }); + + return this.#send( + { + url: options.url, + method, + headers: options.headers, + body: options.body, + config, + }, + null, + ); + } + + #buildConfig(overrides?: { + workspace?: string; + project?: string; + environment?: string; + usePersonal?: boolean; + }): ProxyWireBody["config"] { + return { + workspace: overrides?.workspace ?? this.#workspace, + project: overrides?.project ?? this.#project, + "environment-id": overrides?.environment ?? this.#environment, + "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 { + const upper = (method ?? "GET").toUpperCase(); + if (!ALLOWED_METHODS.includes(upper as ProxyMethod)) { + throw new EnkryptifyError( + `Unsupported HTTP method "${method}". Supported methods: ${ALLOWED_METHODS.join(", ")}.`, + ); + } + return upper as ProxyMethod; +} + +function normalizeHeaders(input: HeadersInit | undefined): Record | undefined { + if (input === undefined) return undefined; + + const out: Record = {}; + const headers = new Headers(input); + headers.forEach((value, key) => { + out[key] = value; + }); + + // If the input was empty, return undefined to avoid sending `"headers": {}`. + return Object.keys(out).length > 0 ? out : undefined; +} + +function parseFetchBody(body: BodyInit | JsonValue | null | undefined): JsonValue | undefined { + if (body === undefined || body === null) return undefined; + + // Reject binary / form / stream body types — the proxy substitutes + // %VARIABLE% placeholders which only makes sense for JSON-encoded payloads. + if (typeof Blob !== "undefined" && body instanceof Blob) { + throw bodyTypeError("Blob"); + } + if (typeof FormData !== "undefined" && body instanceof FormData) { + throw bodyTypeError("FormData"); + } + if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) { + throw bodyTypeError("URLSearchParams"); + } + if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) { + throw bodyTypeError("ReadableStream"); + } + if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) { + throw bodyTypeError("ArrayBuffer"); + } + if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(body as ArrayBufferView)) { + throw bodyTypeError("typed array"); + } + + // Treat strings as JSON — this is what axios/ky/etc. produce after their + // internal JSON.stringify step. + if (typeof body === "string") { + try { + return JSON.parse(body) as JsonValue; + } catch { + throw new EnkryptifyError( + "Proxy body must be JSON-serializable. Received a non-JSON string; " + + "pass an object/array/primitive directly or JSON.stringify first.\n" + + "Docs: https://docs.enkryptify.com/sdk/proxy", + ); + } + } + + // Already a JSON-serializable value (plain object, array, number, boolean) + return body as JsonValue; +} + +function bodyTypeError(typeName: string): EnkryptifyError { + return new EnkryptifyError( + `Proxy only accepts JSON-compatible bodies. Received a ${typeName}. ` + + "Convert the value to a JSON-serializable object/string before calling the proxy.\n" + + "Docs: https://docs.enkryptify.com/sdk/proxy", + ); +} diff --git a/src/types.ts b/src/types.ts index e73e50b..b06451f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,15 @@ export interface IEnkryptify { * Destroys the client, clearing all cached secrets. */ destroy(): void; + + /** + * Proxy namespace for sending requests through the Enkryptify proxy. + * + * The proxy substitutes `%VARIABLE_NAME%` placeholders in the URL, headers, + * and body with the corresponding secret values server-side, then forwards + * the request upstream. Secrets never touch the caller's process. + */ + readonly proxy: IEnkryptifyProxy; } export interface EnkryptifyConfig { @@ -61,6 +70,7 @@ export interface EnkryptifyConfig { logger?: { level?: "debug" | "info" | "warn" | "error"; }; + proxy?: ProxyConfig; } export interface TokenExchangeResponse { @@ -99,3 +109,80 @@ export interface Secret { createdAt: string; updatedAt: string; } + +/** + * A JSON-serializable value. Matches the `body` accepted by the proxy service. + */ +export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +/** + * HTTP methods supported by the proxy. + */ +export type ProxyMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; + +/** + * Proxy-related client configuration. + */ +export interface ProxyConfig { + /** + * Override the proxy service URL. Takes priority over the `ENKRYPTIFY_PROXY_URL` + * environment variable. + */ + url?: string; + + /** + * When `true`, direct secret-fetching methods (`get`, `getFromCache`, `preload`) + * throw a helpful error pointing users at `client.proxy.fetch()` / `.request()`. + * + * Use this for consumers whose tokens have no direct-secret-read permission. + */ + proxyOnly?: boolean; +} + +/** + * Fetch-compatible init for `client.proxy.fetch`. Widens `body` to accept a + * plain JSON-serializable value in addition to the standard `BodyInit`. + */ +export interface ProxyRequestInit extends Omit { + body?: BodyInit | JsonValue | null; +} + +/** + * Low-level options for `client.proxy.request`. Mirrors the proxy service's + * request shape, but in the SDK's idiomatic camelCase. + */ +export interface ProxyRequestOptions { + url: string; + method: ProxyMethod; + headers?: Record; + body?: JsonValue; + /** Override the client's workspace for this request. */ + workspace?: string; + /** Override the client's project for this request. */ + project?: string; + /** Override the client's environment for this request. */ + environment?: string; + /** Override the client's `usePersonalValues` setting for this request. */ + usePersonal?: boolean; +} + +export interface IEnkryptifyProxy { + /** + * Fetch-compatible proxy call. Drop-in replacement for `globalThis.fetch`. + * + * Accepts a URL (string or `URL`) and a standard `RequestInit`. The SDK + * translates the call into a POST against the proxy service with the + * URL/method/headers/body and the workspace/project/environment context. + * + * Returns the upstream `Response` verbatim on success. On proxy-side + * failures, throws `AuthenticationError` / `AuthorizationError` / + * `RateLimitError` / `ProxyValidationError` / `ProxyError`. + */ + fetch(input: string | URL, init?: ProxyRequestInit): Promise; + + /** + * Low-level proxy call. Takes the proxy's request shape directly with + * typed JSON body. + */ + request(options: ProxyRequestOptions): Promise; +} diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts new file mode 100644 index 0000000..07601ce --- /dev/null +++ b/tests/proxy.test.ts @@ -0,0 +1,540 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Enkryptify, EnkryptifyError } from "@/index"; +import { storeToken } from "@/internal/token-store"; +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, + }; +} + +function getCallBody(call: unknown[]): Record { + const opts = call[1] as RequestInit; + return JSON.parse(opts.body as string) as Record; +} + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("client.proxy.fetch — body translation", () => { + it("GET without body sends correct wire body", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + 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"); + const opts = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(opts.method).toBe("POST"); + + const body = getCallBody(fetchMock.mock.calls[0] as unknown[]); + 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, + }, + }); + 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 })); + const client = new Enkryptify(makeConfig()); + + await client.proxy.fetch("https://upstream/x", { + method: "POST", + body: JSON.stringify({ user: "%USER%", count: 5 }), + }); + + const body = getCallBody(fetchMock.mock.calls[0] as unknown[]); + expect(body.body).toEqual({ user: "%USER%", count: 5 }); + }); + + it("POST with plain object body passes through", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const client = new Enkryptify(makeConfig()); + + // Cast needed because RequestInit.body doesn't include plain objects + await client.proxy.fetch("https://upstream/x", { + method: "POST", + body: { user: "%USER%" } as unknown as BodyInit, + }); + + const body = getCallBody(fetchMock.mock.calls[0] as unknown[]); + expect(body.body).toEqual({ user: "%USER%" }); + }); + + it("rejects GET with body synchronously", async () => { + const client = new Enkryptify(makeConfig()); + + await expect(client.proxy.fetch("https://upstream/x", { method: "GET", body: '"x"' })).rejects.toThrow( + "GET requests cannot include a body", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects HEAD with body synchronously", async () => { + const client = new Enkryptify(makeConfig()); + + await expect(client.proxy.fetch("https://upstream/x", { method: "HEAD", body: '"x"' })).rejects.toThrow( + "HEAD requests cannot include a body", + ); + }); + + it("rejects Blob body", async () => { + const client = new Enkryptify(makeConfig()); + const blob = new Blob(["hello"]); + + await expect(client.proxy.fetch("https://upstream/x", { method: "POST", body: blob })).rejects.toThrow( + /JSON-compatible.*Blob/, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects FormData body", async () => { + const client = new Enkryptify(makeConfig()); + const form = new FormData(); + form.append("key", "value"); + + await expect(client.proxy.fetch("https://upstream/x", { method: "POST", body: form })).rejects.toThrow( + /JSON-compatible.*FormData/, + ); + }); + + it("rejects URLSearchParams body", async () => { + const client = new Enkryptify(makeConfig()); + const params = new URLSearchParams({ k: "v" }); + + await expect(client.proxy.fetch("https://upstream/x", { method: "POST", body: params })).rejects.toThrow( + /JSON-compatible.*URLSearchParams/, + ); + }); + + it("rejects non-JSON string body with helpful error", async () => { + const client = new Enkryptify(makeConfig()); + + await expect( + client.proxy.fetch("https://upstream/x", { method: "POST", body: "not json at all" }), + ).rejects.toThrow("Proxy body must be JSON-serializable"); + }); + + it("rejects unsupported HTTP method", async () => { + const client = new Enkryptify(makeConfig()); + + await expect(client.proxy.fetch("https://upstream/x", { method: "TRACE" })).rejects.toThrow( + /Unsupported HTTP method/, + ); + }); + + it("rejects Request input", async () => { + const client = new Enkryptify(makeConfig()); + const req = new Request("https://upstream/x"); + + await expect(client.proxy.fetch(req as unknown as string)).rejects.toThrow(/does not accept Request objects/); + }); + + it("coerces URL object input to string", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const client = new Enkryptify(makeConfig()); + + await client.proxy.fetch(new URL("https://upstream/x?a=1")); + + const body = getCallBody(fetchMock.mock.calls[0] as unknown[]); + expect(body.url).toBe("https://upstream/x?a=1"); + }); + + it("normalizes headers from Headers object", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const client = new Enkryptify(makeConfig()); + + await client.proxy.fetch("https://upstream/x", { + method: "POST", + headers: new Headers({ "X-Foo": "bar", Authorization: "Bearer %T%" }), + body: "{}", + }); + + const body = getCallBody(fetchMock.mock.calls[0] as unknown[]); + expect(body.headers).toMatchObject({ + "x-foo": "bar", + authorization: "Bearer %T%", + }); + }); + + it("defaults method to GET when init omitted", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const client = new Enkryptify(makeConfig()); + + await client.proxy.fetch("https://upstream/x"); + + const body = getCallBody(fetchMock.mock.calls[0] as unknown[]); + expect(body.method).toBe("GET"); + }); + + it("uppercases lowercase method", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const client = new Enkryptify(makeConfig()); + + await client.proxy.fetch("https://upstream/x", { method: "post", body: "{}" }); + + const body = getCallBody(fetchMock.mock.calls[0] as unknown[]); + expect(body.method).toBe("POST"); + }); +}); + +describe("client.proxy.request — low-level API", () => { + it("sends exact config in kebab-case", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const client = new Enkryptify(makeConfig()); + + await client.proxy.request({ + url: "https://upstream/x", + method: "POST", + body: { foo: "%BAR%" }, + }); + + const body = getCallBody(fetchMock.mock.calls[0] as unknown[]); + expect(body).toMatchObject({ + url: "https://upstream/x", + method: "POST", + body: { foo: "%BAR%" }, + config: { + workspace: "ws-1", + project: "prj-1", + "environment-id": "env-1", + "is-personal": true, + }, + }); + }); + + it("applies per-call environment override", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const client = new Enkryptify(makeConfig()); + + await client.proxy.request({ + url: "https://upstream/x", + method: "GET", + environment: "other-env", + }); + + const body = getCallBody(fetchMock.mock.calls[0] as unknown[]); + expect((body.config as Record)["environment-id"]).toBe("other-env"); + }); + + it("applies per-call workspace/project/usePersonal overrides", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const client = new Enkryptify(makeConfig()); + + await client.proxy.request({ + url: "https://upstream/x", + method: "GET", + workspace: "other-ws", + project: "other-prj", + usePersonal: false, + }); + + 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, + }); + }); + + it("rejects GET with body", async () => { + const client = new Enkryptify(makeConfig()); + + await expect( + client.proxy.request({ + url: "https://upstream/x", + method: "GET", + body: { x: 1 }, + }), + ).rejects.toThrow("GET requests cannot include a body"); + }); + + it("rejects empty url", async () => { + const client = new Enkryptify(makeConfig()); + + await expect(client.proxy.request({ url: "", method: "GET" })).rejects.toThrow("non-empty `url`"); + }); +}); + +describe("client.proxy — authorization", () => { + it("sends Authorization: Bearer ", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const client = new Enkryptify(makeConfig({ auth: createAuth("my-proxy-token") })); + + await client.proxy.fetch("https://upstream/x"); + + const opts = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(opts.headers).toMatchObject({ + Authorization: "Bearer my-proxy-token", + "Content-Type": "application/json", + }); + }); + + it("uses exchanged JWT when useTokenExchange=true", async () => { + fetchMock.mockImplementation((url: string) => { + if (url.includes("/v1/auth/exchange")) { + return Promise.resolve( + new Response(JSON.stringify({ accessToken: "jwt-abc", expiresIn: 900, tokenType: "Bearer" }), { + status: 200, + }), + ); + } + return Promise.resolve(new Response("{}", { status: 200 })); + }); + + const client = new Enkryptify( + makeConfig({ + token: "ek_live_static", + auth: undefined, + useTokenExchange: true, + }), + ); + + await client.proxy.fetch("https://upstream/x"); + + // First call is the exchange + const exchangeUrl = fetchMock.mock.calls[0]?.[0] as string; + expect(exchangeUrl).toBe("https://api.test.com/v1/auth/exchange"); + + // Second call is the proxy, with JWT + const proxyOpts = fetchMock.mock.calls[1]?.[1] as RequestInit; + expect(proxyOpts.headers).toMatchObject({ Authorization: "Bearer jwt-abc" }); + + client.destroy(); + }); +}); + +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. + + it("returns the upstream Response 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" }), + }), + ); + 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); + }); + + it("preserves upstream status code on 201/204/etc.", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 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 () => { + const body = { cod: 401, message: "Invalid API key" }; + fetchMock.mockResolvedValue( + new Response(JSON.stringify(body), { + status: 401, + headers: new Headers({ "Content-Type": "application/json" }), + }), + ); + const client = new Enkryptify(makeConfig()); + + const res = await client.proxy.fetch("https://upstream/x"); + expect(res.ok).toBe(false); + expect(res.status).toBe(401); + expect(await res.json()).toEqual(body); + }); + + 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 })); + 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); + }); + + 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" } }; + fetchMock.mockResolvedValue( + new Response(JSON.stringify(body), { + status: 401, + headers: new Headers({ "Content-Type": "application/json" }), + }), + ); + 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); + }); + + it("preserves upstream Retry-After header on 429 passthrough", async () => { + fetchMock.mockResolvedValue( + new Response("rate limited", { status: 429, headers: new Headers({ "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"); + }); +}); + +describe("client.proxy — URL resolution", () => { + const originalEnv = process.env.ENKRYPTIFY_PROXY_URL; + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.ENKRYPTIFY_PROXY_URL = originalEnv; + } else { + delete process.env.ENKRYPTIFY_PROXY_URL; + } + }); + + 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 })); + + 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"); + }); + + 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 })); + + 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"); + }); + + 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 })); + + const client = new Enkryptify(makeConfig({ proxy: undefined })); + await client.proxy.fetch("https://upstream/x"); + + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://proxy-poc-black.vercel.app"); + }); +}); + +describe("client.proxy — lifecycle", () => { + it("throws when parent is destroyed", async () => { + const client = new Enkryptify(makeConfig()); + client.destroy(); + + expect(() => client.proxy).toThrow(/destroyed/); + }); + + it("throws when destroyed between getting proxy and calling fetch", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const client = new Enkryptify(makeConfig()); + const proxy = client.proxy; + client.destroy(); + + await expect(proxy.fetch("https://upstream/x")).rejects.toThrow(/destroyed/); + }); +}); + +describe("client.proxy — destructured fetch (axios/ky wiring)", () => { + it("works when fetch is destructured from proxy", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const client = new Enkryptify(makeConfig()); + + const { fetch: proxyFetch } = client.proxy; + await proxyFetch("https://upstream/x"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = getCallBody(fetchMock.mock.calls[0] as unknown[]); + expect(body.url).toBe("https://upstream/x"); + }); +}); + +describe("proxyOnly mode", () => { + it(".get() throws with pointer to proxy when proxyOnly=true", async () => { + const client = new Enkryptify(makeConfig({ proxy: { url: "https://proxy.test.com", proxyOnly: true } })); + + await expect(client.get("ANY_KEY")).rejects.toThrow(/proxy-only/); + await expect(client.get("ANY_KEY")).rejects.toThrow(/client\.proxy\.fetch/); + }); + + it(".preload() throws when proxyOnly=true", async () => { + const client = new Enkryptify(makeConfig({ proxy: { url: "https://proxy.test.com", proxyOnly: true } })); + + await expect(client.preload()).rejects.toThrow(/proxy-only/); + }); + + it(".getFromCache() throws when proxyOnly=true", () => { + const client = new Enkryptify(makeConfig({ proxy: { url: "https://proxy.test.com", proxyOnly: true } })); + + expect(() => client.getFromCache("X")).toThrow(/proxy-only/); + }); + + it(".proxy.fetch() still works when proxyOnly=true", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + const client = new Enkryptify(makeConfig({ proxy: { url: "https://proxy.test.com", proxyOnly: true } })); + + await client.proxy.fetch("https://upstream/x"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws EnkryptifyError (not AuthenticationError) for clarity", async () => { + const client = new Enkryptify(makeConfig({ proxy: { url: "https://proxy.test.com", proxyOnly: true } })); + await expect(client.get("X")).rejects.toThrow(EnkryptifyError); + }); +});