Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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