Skip to content
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,6 @@ const res = await client.proxy.request({
body: { user: "%USER_ID%" },
// Optional per-call overrides:
// environment: "other-env-id",
// usePersonal: false,
});
```

Expand Down
10 changes: 4 additions & 6 deletions src/interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
180 changes: 154 additions & 26 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -35,12 +42,10 @@ export interface ProxyWireBody {
method: ProxyMethod;
headers?: Record<string, string>;
body?: JsonValue;
config: {
workspace: string;
project: string;
"environment-id": string;
"is-personal": boolean;
};
workspace: string;
project: string;
"environment-id": string;
"is-personal": boolean;
}

/**
Expand Down Expand Up @@ -82,15 +87,16 @@ export async function sendProxyWire(
const wireBody: Record<string, unknown> = {
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}`,
Expand All @@ -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 {
Expand Down Expand Up @@ -179,7 +179,7 @@ export class EnkryptifyProxy implements IEnkryptifyProxy {
method,
headers,
body,
config: this.#buildConfig(),
...this.#buildScope(),
},
init?.signal ?? null,
);
Expand All @@ -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,
Expand All @@ -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<ProxyWireBody, "workspace" | "project" | "environment-id" | "is-personal"> {
return {
workspace: overrides?.workspace ?? this.#workspace,
project: overrides?.project ?? this.#project,
Expand Down Expand Up @@ -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<string, string>;
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<string> = new Set([
"connection",
"content-length",
"transfer-encoding",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"upgrade",
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<Response> {
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": "<message>" }`; 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<unknown> {
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);
}
Loading
Loading