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
107 changes: 101 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
38 changes: 38 additions & 0 deletions src/enkryptify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
EnkryptifyAuthProvider,
EnkryptifyConfig,
IEnkryptify,
IEnkryptifyProxy,
KubernetesAuthOptions,
Secret,
TokenExchange,
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
});
Comment on lines +113 to +125
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid a preview deployment as the implicit proxy backend.

The fallback chain ends at https://proxy-poc-black.vercel.app, so any consumer who forgets to set proxy.url / ENKRYPTIFY_PROXY_URL is silently pinned to a non-canonical service. That's a brittle production default and can turn a config mistake into routing proxy auth to the wrong host. Prefer an official stable domain here, or fail fast when no explicit proxy URL is configured.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/enkryptify.ts` around lines 113 - 125, The code uses a brittle fallback
proxy URL (DEFAULT_PROXY_URL / `https://proxy-poc-black.vercel.app`) when
neither `config.proxy?.url` nor `process.env.ENKRYPTIFY_PROXY_URL` is set, which
can silently route traffic to a preview deployment; update the initialization in
`proxyUrl`/`EnkryptifyProxy` use to either require an explicit proxy URL (fail
fast with a clear error when `config.proxy?.url` and `ENKRYPTIFY_PROXY_URL` are
both missing) or replace `DEFAULT_PROXY_URL` with an official stable domain, and
ensure any error message references `proxyUrl`/`config.proxy` so consumers know
to set `ENKRYPTIFY_PROXY_URL` instead of relying on the preview host.


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();
}
Expand Down Expand Up @@ -137,6 +162,7 @@ export class Enkryptify implements IEnkryptify {

async get(key: string, options?: { cache?: boolean }): Promise<string> {
this.#guardDestroyed();
this.#guardProxyOnly();

const useCache = this.#cacheEnabled && options?.cache !== false;

Expand All @@ -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(
Expand All @@ -178,6 +205,7 @@ export class Enkryptify implements IEnkryptify {

async preload(): Promise<void> {
this.#guardDestroyed();
this.#guardProxyOnly();

if (!this.#cacheEnabled || !this.#cache) {
throw new EnkryptifyError(
Expand Down Expand Up @@ -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<string> {
this.#logger.debug(
`Fetching secret(s) from API: GET /v1/workspace/${this.#workspace}/project/${this.#project}/secret`,
Expand Down
45 changes: 45 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
15 changes: 14 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading
Loading