From 40359cad66631cfa6f38d7908b5eceb753b37a5c Mon Sep 17 00:00:00 2001 From: SiebeBaree Date: Fri, 6 Mar 2026 14:17:10 +0100 Subject: [PATCH 1/3] feat: sdk v0.1 --- README.md | 179 ++++++++++++++++++++--- src/api.ts | 48 +++++++ src/auth.ts | 19 +++ src/cache.ts | 45 ++++++ src/client.ts | 48 ------- src/enkryptify.ts | 254 ++++++++++++++++++++++++++++++++ src/errors.ts | 37 +++++ src/index.ts | 9 +- src/internal/token-store.ts | 18 +++ src/logger.ts | 41 ++++++ src/types.ts | 83 ++++++++++- tests/api.test.ts | 103 +++++++++++++ tests/auth.test.ts | 68 +++++++++ tests/cache.test.ts | 74 ++++++++++ tests/client.test.ts | 44 ------ tests/enkryptify.test.ts | 278 ++++++++++++++++++++++++++++++++++++ tests/logger.test.ts | 62 ++++++++ 17 files changed, 1289 insertions(+), 121 deletions(-) create mode 100644 src/api.ts create mode 100644 src/auth.ts create mode 100644 src/cache.ts delete mode 100644 src/client.ts create mode 100644 src/enkryptify.ts create mode 100644 src/internal/token-store.ts create mode 100644 src/logger.ts create mode 100644 tests/api.test.ts create mode 100644 tests/auth.test.ts create mode 100644 tests/cache.test.ts delete mode 100644 tests/client.test.ts create mode 100644 tests/enkryptify.test.ts create mode 100644 tests/logger.test.ts diff --git a/README.md b/README.md index 95afd25..d8d99e0 100644 --- a/README.md +++ b/README.md @@ -20,42 +20,183 @@ npm install @enkryptify/sdk yarn add @enkryptify/sdk ``` -## Usage +## Quick Start ```typescript import Enkryptify from "@enkryptify/sdk"; const client = new Enkryptify({ - apiKey: "your-api-key", - workspaceId: "your-workspace-id", - projectId: "your-project-id", - environment: "production", + auth: Enkryptify.fromEnv(), + workspace: "my-workspace", + project: "my-project", + environment: "env-id", }); const dbUrl = await client.get("DATABASE_URL"); -console.log(dbUrl); ``` +## Usage + +### Preloading Secrets + +When caching is enabled (the default), you can preload all secrets up front. This makes subsequent `get()` and `getFromCache()` calls instant. + +```typescript +const client = new Enkryptify({ + auth: Enkryptify.fromEnv(), + workspace: "my-workspace", + project: "my-project", + environment: "env-id", +}); + +await client.preload(); + +// Synchronous — no API call needed +const dbHost = client.getFromCache("DB_HOST"); +const dbPort = client.getFromCache("DB_PORT"); +``` + +### Eager Caching + +By default `cache.eager` is `true`. This means the first `get()` call fetches _all_ secrets and caches them, so subsequent calls are served from the cache without additional API requests. + +```typescript +// First call fetches all secrets from the API +const dbHost = await client.get("DB_HOST"); + +// Second call is served from cache — no API call +const dbPort = await client.get("DB_PORT"); +``` + +Set `cache.eager` to `false` to fetch secrets individually: + +```typescript +const client = new Enkryptify({ + auth: Enkryptify.fromEnv(), + workspace: "my-workspace", + project: "my-project", + environment: "env-id", + cache: { eager: false }, +}); + +// Each call fetches only the requested secret +const dbHost = await client.get("DB_HOST"); +const dbPort = await client.get("DB_PORT"); +``` + +### Bypassing the Cache + +Pass `{ cache: false }` to always fetch a fresh value from the API: + +```typescript +const secret = await client.get("ROTATING_KEY", { cache: false }); +``` + +### Strict vs Non-Strict Mode + +By default, `get()` throws a `SecretNotFoundError` when a key doesn't exist. Disable strict mode to return an empty string instead: + +```typescript +const client = new Enkryptify({ + auth: Enkryptify.fromEnv(), + workspace: "my-workspace", + project: "my-project", + environment: "env-id", + options: { strict: false }, +}); + +const value = await client.get("MAYBE_MISSING"); // "" if not found +``` + +### Personal Values + +When `usePersonalValues` is `true` (the default), the SDK prefers your personal override for a secret. If no personal value exists, it falls back to the shared value. + +```typescript +const client = new Enkryptify({ + auth: Enkryptify.fromEnv(), + workspace: "my-workspace", + project: "my-project", + environment: "env-id", + options: { usePersonalValues: false }, // always use shared values +}); +``` + +### Cleanup + +Destroy the client when you're done to clear all cached secrets from memory: + +```typescript +client.destroy(); +``` + +## Configuration + +| Option | Type | Default | Description | +| --------------------------- | ---------------------------------------- | ------------------------------ | ------------------------------------------------ | +| `auth` | `EnkryptifyAuthProvider` | _required_ | Auth provider created via `Enkryptify.fromEnv()` | +| `workspace` | `string` | _required_ | Workspace slug or ID | +| `project` | `string` | _required_ | Project slug or ID | +| `environment` | `string` | _required_ | Environment ID | +| `baseUrl` | `string` | `"https://api.enkryptify.com"` | API base URL | +| `options.strict` | `boolean` | `true` | Throw on missing secrets | +| `options.usePersonalValues` | `boolean` | `true` | Prefer personal secret values | +| `cache.enabled` | `boolean` | `true` | Enable in-memory caching | +| `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 | + ## API Reference -### `new Enkryptify(config)` +### `Enkryptify.fromEnv(): EnkryptifyAuthProvider` + +Creates an auth provider by reading the `ENKRYPTIFY_TOKEN` environment variable. + +### `client.get(key, options?): Promise` + +Fetches a secret by key. Uses the cache when available, otherwise calls the API. -Creates a new Enkryptify client. +- `key` — the secret name +- `options.cache` — set to `false` to bypass the cache (default: `true`) -| Parameter | Type | Description | -| ------------- | -------- | ------------------------------------- | -| `apiKey` | `string` | Your Enkryptify API key | -| `workspaceId` | `string` | The workspace ID | -| `projectId` | `string` | The project ID | -| `environment` | `string` | The environment (e.g. `"production"`) | +### `client.getFromCache(key): string` -### `client.get(key): Promise` +Returns a secret from the cache synchronously. Throws if the key is not cached or caching is disabled. -Fetches a secret by key. Throws `EnkryptifyError` if the secret is not found. +### `client.preload(): Promise` -### `EnkryptifyError` +Fetches all secrets and populates the cache. Throws if caching is disabled. + +### `client.destroy(): void` + +Clears the cache and marks the client as destroyed. All subsequent method calls will throw. + +## Error Handling + +The SDK provides specific error classes so you can handle different failure modes: + +```typescript +import Enkryptify, { SecretNotFoundError, AuthenticationError, ApiError } from "@enkryptify/sdk"; + +try { + const value = await client.get("MY_SECRET"); +} catch (error) { + if (error instanceof SecretNotFoundError) { + // Secret doesn't exist in the project/environment + } else if (error instanceof AuthenticationError) { + // Token is invalid or expired (HTTP 401/403) + } else if (error instanceof ApiError) { + // Other API error (500, network issues, etc.) + } +} +``` -Custom error class for all SDK errors. +| 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 | ## Development @@ -79,7 +220,7 @@ pnpm lint pnpm format # Typecheck -pnpm check +pnpm typecheck ``` ## Contributing diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..a273c61 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,48 @@ +import type { EnkryptifyAuthProvider, Secret } from "@/types"; +import { AuthenticationError, ApiError } from "@/errors"; +import { retrieveToken } from "@/internal/token-store"; + +export class EnkryptifyApi { + #baseUrl: string; + #auth: EnkryptifyAuthProvider; + + constructor(baseUrl: string, auth: EnkryptifyAuthProvider) { + this.#baseUrl = baseUrl; + this.#auth = auth; + } + + async fetchSecret(workspace: string, project: string, secretName: string, environmentId: string): Promise { + const path = `/v1/workspace/${encodeURIComponent(workspace)}/project/${encodeURIComponent(project)}/secret/${encodeURIComponent(secretName)}`; + const params = new URLSearchParams({ environmentId, resolve: "true" }); + return this.#request("GET", `${path}?${params.toString()}`); + } + + async fetchAllSecrets(workspace: string, project: string, environmentId: string): Promise { + const path = `/v1/workspace/${encodeURIComponent(workspace)}/project/${encodeURIComponent(project)}/secret`; + const params = new URLSearchParams({ environmentId, resolve: "true" }); + return this.#request("GET", `${path}?${params.toString()}`); + } + + async #request(method: string, endpoint: string): Promise { + const token = retrieveToken(this.#auth); + const url = `${this.#baseUrl}${endpoint}`; + + const response = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (response.status === 401 || response.status === 403) { + throw new AuthenticationError(response.status); + } + + if (!response.ok) { + throw new ApiError(response.status, response.statusText, method, endpoint); + } + + return response.json() as Promise; + } +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..5bea3fe --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,19 @@ +import type { EnkryptifyAuthProvider } from "@/types"; +import { EnkryptifyError } from "@/errors"; +import { storeToken } from "@/internal/token-store"; + +export class EnvAuthProvider implements EnkryptifyAuthProvider { + readonly _brand = "EnkryptifyAuthProvider" as const; + + constructor() { + const token = process.env.ENKRYPTIFY_TOKEN; + if (!token) { + throw new EnkryptifyError( + "ENKRYPTIFY_TOKEN environment variable is not set. Set it before initializing the SDK:\n" + + ' export ENKRYPTIFY_TOKEN="ek_..."\n' + + "Docs: https://docs.enkryptify.com/sdk/auth#environment-variables", + ); + } + storeToken(this, token); + } +} diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..8500c37 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,45 @@ +interface CacheEntry { + value: string; + expiresAt: number | null; +} + +export class SecretCache { + #store = new Map(); + #ttl: number; + + constructor(ttl: number) { + this.#ttl = ttl; + } + + get(key: string): string | undefined { + const entry = this.#store.get(key); + if (!entry) return undefined; + + if (entry.expiresAt !== null && Date.now() > entry.expiresAt) { + this.#store.delete(key); + return undefined; + } + + return entry.value; + } + + set(key: string, value: string): void { + this.#store.set(key, { + value, + expiresAt: this.#ttl === -1 ? null : Date.now() + this.#ttl, + }); + } + + has(key: string): boolean { + return this.get(key) !== undefined; + } + + clear(): void { + this.#store.clear(); + this.#store = new Map(); + } + + get size(): number { + return this.#store.size; + } +} diff --git a/src/client.ts b/src/client.ts deleted file mode 100644 index f7a1f39..0000000 --- a/src/client.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { EnkryptifyConfig, Secret } from "@/types"; -import { EnkryptifyError } from "@/errors"; - -export class Enkryptify { - private config: EnkryptifyConfig; - - constructor(config: EnkryptifyConfig) { - if (!config.apiKey) { - throw new EnkryptifyError("apiKey is required"); - } - if (!config.workspaceId) { - throw new EnkryptifyError("workspaceId is required"); - } - if (!config.projectId) { - throw new EnkryptifyError("projectId is required"); - } - if (!config.environment) { - throw new EnkryptifyError("environment is required"); - } - - this.config = config; - } - - async get(key: string): Promise { - // TODO: Replace with actual API call - const secrets = await this.fetchSecrets(); - const secret = secrets.find((s) => s.key === key); - - if (!secret) { - throw new EnkryptifyError(`Secret "${key}" not found`); - } - - return secret.value; - } - - private async fetchSecrets(): Promise { - // TODO: Replace with actual API call to Enkryptify - // Stubbed for now — returns fake data - void this.config; - return [ - { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, - { key: "API_KEY", value: "sk-fake-api-key-12345" }, - { key: "JWT_SECRET", value: "super-secret-jwt-value" }, - ]; - } -} - -export default Enkryptify; diff --git a/src/enkryptify.ts b/src/enkryptify.ts new file mode 100644 index 0000000..7b89f29 --- /dev/null +++ b/src/enkryptify.ts @@ -0,0 +1,254 @@ +import type { EnkryptifyAuthProvider, EnkryptifyConfig, IEnkryptify, Secret } from "@/types"; +import { EnkryptifyError, SecretNotFoundError } from "@/errors"; +import { EnvAuthProvider } from "@/auth"; +import { EnkryptifyApi } from "@/api"; +import { SecretCache } from "@/cache"; +import { Logger } from "@/logger"; +import { retrieveToken } from "@/internal/token-store"; + +export class Enkryptify implements IEnkryptify { + #api: EnkryptifyApi; + #cache: SecretCache | null; + #logger: Logger; + #workspace: string; + #project: string; + #environment: string; + #strict: boolean; + #usePersonalValues: boolean; + #cacheEnabled: boolean; + #eagerCache: boolean; + #destroyed = false; + #eagerLoaded = false; + + constructor(config: EnkryptifyConfig) { + if (!config.auth) { + throw new EnkryptifyError( + 'Missing required config field "auth". Provide an auth provider via Enkryptify.fromEnv().\n' + + "Docs: https://docs.enkryptify.com/sdk/configuration", + ); + } + if (!config.workspace) { + throw new EnkryptifyError( + 'Missing required config field "workspace". Provide a workspace slug or ID.\n' + + "Docs: https://docs.enkryptify.com/sdk/configuration", + ); + } + if (!config.project) { + throw new EnkryptifyError( + 'Missing required config field "project". Provide a project slug or ID.\n' + + "Docs: https://docs.enkryptify.com/sdk/configuration", + ); + } + if (!config.environment) { + throw new EnkryptifyError( + 'Missing required config field "environment". Provide an environment ID.\n' + + "Docs: https://docs.enkryptify.com/sdk/configuration", + ); + } + + // Validate auth provider has a token in the store + if (config.auth._brand !== "EnkryptifyAuthProvider") { + throw new EnkryptifyError( + "Invalid auth provider. Use Enkryptify.fromEnv() to create one.\n" + + "Docs: https://docs.enkryptify.com/sdk/auth", + ); + } + retrieveToken(config.auth); + + this.#workspace = config.workspace; + this.#project = config.project; + this.#environment = config.environment; + this.#strict = config.options?.strict ?? true; + this.#usePersonalValues = config.options?.usePersonalValues ?? true; + + this.#cacheEnabled = config.cache?.enabled ?? true; + this.#eagerCache = config.cache?.eager ?? true; + const cacheTtl = config.cache?.ttl ?? -1; + + this.#logger = new Logger(config.logger?.level ?? "info"); + this.#cache = this.#cacheEnabled ? new SecretCache(cacheTtl) : null; + + const baseUrl = config.baseUrl ?? "https://api.enkryptify.com"; + this.#api = new EnkryptifyApi(baseUrl, config.auth); + + this.#logger.info( + `Initialized for workspace "${this.#workspace}", project "${this.#project}", environment "${this.#environment}"`, + ); + } + + static fromEnv(): EnkryptifyAuthProvider { + return new EnvAuthProvider(); + } + + async get(key: string, options?: { cache?: boolean }): Promise { + this.#guardDestroyed(); + + const useCache = this.#cacheEnabled && options?.cache !== false; + + if (useCache && this.#cache) { + const cached = this.#cache.get(key); + if (cached !== undefined) { + this.#logger.debug(`Cache hit for secret "${key}"`); + return cached; + } + this.#logger.debug(`Cache miss for secret "${key}", fetching from API`); + } + + if (useCache && this.#eagerCache && !this.#eagerLoaded) { + return this.#fetchAndCacheAll(key); + } + + return this.#fetchAndCacheSingle(key); + } + + getFromCache(key: string): string { + this.#guardDestroyed(); + + if (!this.#cacheEnabled || !this.#cache) { + throw new EnkryptifyError( + "Cache is disabled. Enable caching in the config or use get() to fetch from the API.\n" + + "Docs: https://docs.enkryptify.com/sdk/configuration#caching", + ); + } + + const value = this.#cache.get(key); + if (value === undefined) { + throw new SecretNotFoundError(key, this.#workspace, this.#environment); + } + + return value; + } + + async preload(): Promise { + this.#guardDestroyed(); + + if (!this.#cacheEnabled || !this.#cache) { + throw new EnkryptifyError( + "Cannot preload: caching is disabled. Enable caching in the config.\n" + + "Docs: https://docs.enkryptify.com/sdk/configuration#caching", + ); + } + + const secrets = await this.#api.fetchAllSecrets(this.#workspace, this.#project, this.#environment); + + let count = 0; + for (const secret of secrets) { + const value = this.#resolveValue(secret); + if (value !== undefined) { + this.#cache.set(secret.name, value); + count++; + } + } + + this.#eagerLoaded = true; + this.#logger.info(`Preloaded ${count} secrets into cache`); + } + + destroy(): void { + if (this.#destroyed) return; + this.#cache?.clear(); + this.#destroyed = true; + this.#logger.info("Client destroyed, all cached secrets cleared"); + } + + #guardDestroyed(): void { + if (this.#destroyed) { + throw new EnkryptifyError( + "This Enkryptify client has been destroyed. Create a new instance to continue.\n" + + "Docs: https://docs.enkryptify.com/sdk/lifecycle", + ); + } + } + + async #fetchAndCacheAll(key: string): Promise { + this.#logger.debug( + `Fetching secret(s) from API: GET /v1/workspace/${this.#workspace}/project/${this.#project}/secret`, + ); + const start = Date.now(); + const secrets = await this.#api.fetchAllSecrets(this.#workspace, this.#project, this.#environment); + this.#logger.debug(`API responded with ${secrets.length} secret(s) in ${Date.now() - start}ms`); + + let found: string | undefined; + + for (const secret of secrets) { + const value = this.#resolveValue(secret); + if (value !== undefined && this.#cache) { + this.#cache.set(secret.name, value); + this.#logger.debug( + `Cached secret "${secret.name}" (${this.#cache ? "TTL: cache configured" : "no expiry"})`, + ); + } + if (secret.name === key) { + found = value; + } + } + + this.#eagerLoaded = true; + + if (found !== undefined) { + return found; + } + + return this.#handleNotFound(key); + } + + async #fetchAndCacheSingle(key: string): Promise { + this.#logger.debug( + `Fetching secret(s) from API: GET /v1/workspace/${this.#workspace}/project/${this.#project}/secret/${key}`, + ); + let secret: Secret; + try { + const start = Date.now(); + secret = await this.#api.fetchSecret(this.#workspace, this.#project, key, this.#environment); + this.#logger.debug(`API responded with 1 secret(s) in ${Date.now() - start}ms`); + } catch (error) { + if (error instanceof EnkryptifyError) { + // If it's a 404-like error (ApiError with status in message) + const msg = error.message; + if (msg.includes("HTTP 404")) { + return this.#handleNotFound(key); + } + } + throw error; + } + + const value = this.#resolveValue(secret); + if (value === undefined) { + return this.#handleNotFound(key); + } + + if (this.#cacheEnabled && this.#cache) { + this.#cache.set(key, value); + } + + return value; + } + + #handleNotFound(key: string): string { + if (this.#strict) { + throw new SecretNotFoundError(key, this.#workspace, this.#environment); + } + this.#logger.warn(`Secret "${key}" not found (strict mode disabled, returning empty string)`); + return ""; + } + + #resolveValue(secret: Secret): string | undefined { + const envValues = secret.values.filter((v) => v.environmentId === this.#environment); + + if (this.#usePersonalValues) { + const personal = envValues.find((v) => v.isPersonal); + if (personal) return personal.value; + + const shared = envValues.find((v) => !v.isPersonal); + if (shared) { + this.#logger.warn(`No personal value for "${secret.name}", falling back to shared value`); + return shared.value; + } + } else { + const shared = envValues.find((v) => !v.isPersonal); + if (shared) return shared.value; + } + + return undefined; + } +} diff --git a/src/errors.ts b/src/errors.ts index 76f6731..2a2d9f9 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -4,3 +4,40 @@ export class EnkryptifyError extends Error { this.name = "EnkryptifyError"; } } + +export class SecretNotFoundError extends EnkryptifyError { + constructor(key: string, workspace: string, environment: string) { + super( + `Secret "${key}" not found in workspace "${workspace}" (environment: "${environment}"). ` + + `Verify the secret exists in your Enkryptify dashboard.\n` + + `Docs: https://docs.enkryptify.com/sdk/troubleshooting#secret-not-found`, + ); + this.name = "SecretNotFoundError"; + } +} + +export class AuthenticationError extends EnkryptifyError { + constructor(status: number) { + super( + `Authentication failed (HTTP ${status}). Your token may be expired or invalid. ` + + `Generate a new token in your Enkryptify dashboard.\n` + + `Docs: https://docs.enkryptify.com/sdk/auth#token-issues`, + ); + this.name = "AuthenticationError"; + } +} + +export class ApiError extends EnkryptifyError { + public readonly status: number; + + constructor(status: number, statusText: string, method: string, endpoint: string) { + super( + `API request failed (HTTP ${status}) for ${method} ${endpoint}. ` + + `${statusText ? statusText + ". " : ""}` + + `This may be a temporary server issue — retry in a few moments.\n` + + `Docs: https://docs.enkryptify.com/sdk/troubleshooting#api-errors`, + ); + this.name = "ApiError"; + this.status = status; + } +} diff --git a/src/index.ts b/src/index.ts index 8a00ac2..c5dc64b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ -export { Enkryptify } from "@/client"; -export { EnkryptifyError } from "@/errors"; -export type { EnkryptifyConfig, Secret } from "@/types"; - -export { Enkryptify as default } from "./client"; +export { Enkryptify } from "@/enkryptify"; +export { Enkryptify as default } from "@/enkryptify"; +export { EnkryptifyError, SecretNotFoundError, AuthenticationError, ApiError } from "@/errors"; +export type { IEnkryptify, EnkryptifyConfig, EnkryptifyAuthProvider } from "@/types"; diff --git a/src/internal/token-store.ts b/src/internal/token-store.ts new file mode 100644 index 0000000..eb719c5 --- /dev/null +++ b/src/internal/token-store.ts @@ -0,0 +1,18 @@ +import { EnkryptifyError } from "@/errors"; + +const store = new WeakMap(); + +export function storeToken(provider: object, token: string): void { + store.set(provider, token); +} + +export function retrieveToken(provider: object): string { + const token = store.get(provider); + if (!token) { + throw new EnkryptifyError( + "Invalid or destroyed auth provider. Create a new one via Enkryptify.fromEnv().\n" + + "Docs: https://docs.enkryptify.com/sdk/auth", + ); + } + return token; +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..fae1814 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,41 @@ +type LogLevel = "debug" | "info" | "warn" | "error"; + +const LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +/* eslint-disable no-console */ +export class Logger { + #level: number; + + constructor(level: LogLevel = "info") { + this.#level = LEVELS[level]; + } + + debug(message: string): void { + if (this.#level <= LEVELS.debug) { + console.debug(`[Enkryptify] ${message}`); + } + } + + info(message: string): void { + if (this.#level <= LEVELS.info) { + console.info(`[Enkryptify] ${message}`); + } + } + + warn(message: string): void { + if (this.#level <= LEVELS.warn) { + console.warn(`[Enkryptify] ${message}`); + } + } + + error(message: string): void { + if (this.#level <= LEVELS.error) { + console.error(`[Enkryptify] ${message}`); + } + } +} diff --git a/src/types.ts b/src/types.ts index 3406d48..867a2f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,84 @@ +export interface IEnkryptify { + /** + * Gets a secret by key. + * + * Uses the cache by default when available. Otherwise, fetches the secret + * from the API. + * + * @param key - The key of the secret to retrieve. + * @param options - Options for the get operation. + * @param options.cache - Whether to use the cache when available. Defaults to `true`. + * @returns The value of the secret. + */ + get( + key: string, + options?: { + cache?: boolean; + }, + ): Promise; + + /** + * Gets a secret by key from the cache. + * + * Throws if the secret is not already cached. + * + * @param key - The key of the secret to retrieve. + * @returns The cached value of the secret. + * @throws {EnkryptifyError} If the secret is not in the cache. + */ + getFromCache(key: string): string; + + /** + * Preloads the cache with all secrets. + * + * @returns A promise that resolves when the cache has been preloaded. + */ + preload(): Promise; + + /** + * Destroys the client, clearing all cached secrets. + */ + destroy(): void; +} + export interface EnkryptifyConfig { - apiKey: string; - workspaceId: string; - projectId: string; + auth: EnkryptifyAuthProvider; + workspace: string; + project: string; environment: string; + baseUrl?: string; + options?: { + strict?: boolean; + usePersonalValues?: boolean; + }; + cache?: { + enabled?: boolean; + ttl?: number; + eager?: boolean; + }; + logger?: { + level?: "debug" | "info" | "warn" | "error"; + }; } -export interface Secret { - key: string; +export interface EnkryptifyAuthProvider { + readonly _brand: "EnkryptifyAuthProvider"; +} + +export interface SecretValue { + environmentId: string; value: string; + isPersonal: boolean; + reminder?: { id: string; type: "one_time" | "recurring"; nextReminderDate: string }; +} + +export interface Secret { + id: string; + name: string; + note: string; + type: string; + dataType: string; + values: SecretValue[]; + createdAt: string; + updatedAt: string; } diff --git a/tests/api.test.ts b/tests/api.test.ts new file mode 100644 index 0000000..9721c05 --- /dev/null +++ b/tests/api.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { EnkryptifyApi } from "@/api"; +import { AuthenticationError, ApiError } from "@/errors"; +import { storeToken } from "@/internal/token-store"; +import type { EnkryptifyAuthProvider } from "@/types"; + +function createAuth(token: string): EnkryptifyAuthProvider { + const auth = { _brand: "EnkryptifyAuthProvider" as const }; + storeToken(auth, token); + return auth; +} + +describe("EnkryptifyApi", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("fetchSecret() constructs correct URL", async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ id: "1", name: "KEY" }), { status: 200 })); + const api = new EnkryptifyApi("https://api.example.com", createAuth("tok")); + + await api.fetchSecret("ws-1", "prj-1", "MY_SECRET", "env-1"); + + const url = fetchMock.mock.calls[0]?.[0] as string; + expect(url).toBe( + "https://api.example.com/v1/workspace/ws-1/project/prj-1/secret/MY_SECRET?environmentId=env-1&resolve=true", + ); + }); + + it("fetchAllSecrets() constructs correct URL", async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify([]), { status: 200 })); + const api = new EnkryptifyApi("https://api.example.com", createAuth("tok")); + + await api.fetchAllSecrets("ws-1", "prj-1", "env-1"); + + const url = fetchMock.mock.calls[0]?.[0] as string; + expect(url).toBe( + "https://api.example.com/v1/workspace/ws-1/project/prj-1/secret?environmentId=env-1&resolve=true", + ); + }); + + it("sends Authorization: Bearer header", async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify([]), { status: 200 })); + const api = new EnkryptifyApi("https://api.example.com", createAuth("my-token")); + + await api.fetchAllSecrets("ws", "prj", "env"); + + const opts = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(opts.headers).toHaveProperty("Authorization", "Bearer my-token"); + }); + + it("parses successful response", async () => { + const data = [{ id: "1", name: "KEY", values: [] }]; + fetchMock.mockResolvedValue(new Response(JSON.stringify(data), { status: 200 })); + const api = new EnkryptifyApi("https://api.example.com", createAuth("tok")); + + const result = await api.fetchAllSecrets("ws", "prj", "env"); + expect(result).toEqual(data); + }); + + it("throws AuthenticationError on 401", async () => { + fetchMock.mockResolvedValue(new Response("Unauthorized", { status: 401 })); + const api = new EnkryptifyApi("https://api.example.com", createAuth("tok")); + + await expect(api.fetchAllSecrets("ws", "prj", "env")).rejects.toThrow(AuthenticationError); + }); + + it("throws AuthenticationError on 403", async () => { + fetchMock.mockResolvedValue(new Response("Forbidden", { status: 403 })); + const api = new EnkryptifyApi("https://api.example.com", createAuth("tok")); + + await expect(api.fetchAllSecrets("ws", "prj", "env")).rejects.toThrow(AuthenticationError); + }); + + it("throws ApiError on 500 with status in message", async () => { + fetchMock.mockResolvedValue( + new Response("Internal Server Error", { status: 500, statusText: "Internal Server Error" }), + ); + const api = new EnkryptifyApi("https://api.example.com", createAuth("tok")); + + await expect(api.fetchAllSecrets("ws", "prj", "env")).rejects.toThrow(ApiError); + await expect(api.fetchAllSecrets("ws", "prj", "env")).rejects.toThrow("HTTP 500"); + }); + + it("URL-encodes special characters in path segments", async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ id: "1" }), { status: 200 })); + const api = new EnkryptifyApi("https://api.example.com", createAuth("tok")); + + await api.fetchSecret("ws/special", "prj&name", "key with spaces", "env-1"); + + const url = fetchMock.mock.calls[0]?.[0] as string; + expect(url).toContain("ws%2Fspecial"); + expect(url).toContain("prj%26name"); + expect(url).toContain("key%20with%20spaces"); + }); +}); diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..1028f23 --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { EnvAuthProvider } from "@/auth"; +import { EnkryptifyError } from "@/errors"; + +describe("EnvAuthProvider", () => { + const originalEnv = process.env.ENKRYPTIFY_TOKEN; + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.ENKRYPTIFY_TOKEN = originalEnv; + } else { + delete process.env.ENKRYPTIFY_TOKEN; + } + }); + + it("creates provider when ENKRYPTIFY_TOKEN is set", () => { + process.env.ENKRYPTIFY_TOKEN = "ek_test_token"; + const provider = new EnvAuthProvider(); + expect(provider._brand).toBe("EnkryptifyAuthProvider"); + }); + + it("throws when ENKRYPTIFY_TOKEN is missing", () => { + delete process.env.ENKRYPTIFY_TOKEN; + expect(() => new EnvAuthProvider()).toThrow(EnkryptifyError); + expect(() => new EnvAuthProvider()).toThrow("ENKRYPTIFY_TOKEN environment variable is not set"); + }); + + it("throws when ENKRYPTIFY_TOKEN is empty string", () => { + process.env.ENKRYPTIFY_TOKEN = ""; + expect(() => new EnvAuthProvider()).toThrow(EnkryptifyError); + }); + + it("token is not accessible via Object.keys()", () => { + process.env.ENKRYPTIFY_TOKEN = "ek_test_token"; + const provider = new EnvAuthProvider(); + const keys = Object.keys(provider); + expect(keys).not.toContain("token"); + for (const key of keys) { + expect((provider as unknown as Record)[key]).not.toBe("ek_test_token"); + } + }); + + it("token is not accessible via JSON.stringify()", () => { + process.env.ENKRYPTIFY_TOKEN = "ek_test_token"; + const provider = new EnvAuthProvider(); + const json = JSON.stringify(provider); + expect(json).not.toContain("ek_test_token"); + }); + + it("token is not accessible via property enumeration", () => { + process.env.ENKRYPTIFY_TOKEN = "ek_test_token"; + const provider = new EnvAuthProvider(); + const allProps = Object.getOwnPropertyNames(provider); + const symbols = Object.getOwnPropertySymbols(provider); + for (const prop of allProps) { + expect((provider as unknown as Record)[prop]).not.toBe("ek_test_token"); + } + for (const sym of symbols) { + expect((provider as unknown as Record)[sym]).not.toBe("ek_test_token"); + } + }); + + it("has the _brand property", () => { + process.env.ENKRYPTIFY_TOKEN = "ek_test_token"; + const provider = new EnvAuthProvider(); + expect(provider._brand).toBe("EnkryptifyAuthProvider"); + }); +}); diff --git a/tests/cache.test.ts b/tests/cache.test.ts new file mode 100644 index 0000000..33e4359 --- /dev/null +++ b/tests/cache.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { SecretCache } from "@/cache"; + +describe("SecretCache", () => { + it("set() and get() round-trip correctly", () => { + const cache = new SecretCache(-1); + cache.set("key1", "value1"); + expect(cache.get("key1")).toBe("value1"); + }); + + it("returns undefined for unknown keys", () => { + const cache = new SecretCache(-1); + expect(cache.get("nonexistent")).toBeUndefined(); + }); + + describe("TTL expiry", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns value before TTL expires", () => { + const cache = new SecretCache(1000); + cache.set("key", "value"); + vi.advanceTimersByTime(500); + expect(cache.get("key")).toBe("value"); + }); + + it("returns undefined after TTL expires", () => { + const cache = new SecretCache(1000); + cache.set("key", "value"); + vi.advanceTimersByTime(1001); + expect(cache.get("key")).toBeUndefined(); + }); + }); + + it("TTL=-1 never expires", () => { + vi.useFakeTimers(); + const cache = new SecretCache(-1); + cache.set("key", "value"); + vi.advanceTimersByTime(999_999_999); + expect(cache.get("key")).toBe("value"); + vi.useRealTimers(); + }); + + it("has() reflects state correctly", () => { + const cache = new SecretCache(-1); + expect(cache.has("key")).toBe(false); + cache.set("key", "value"); + expect(cache.has("key")).toBe(true); + }); + + it("clear() empties cache completely", () => { + const cache = new SecretCache(-1); + cache.set("a", "1"); + cache.set("b", "2"); + cache.clear(); + expect(cache.get("a")).toBeUndefined(); + expect(cache.get("b")).toBeUndefined(); + expect(cache.size).toBe(0); + }); + + it("size returns correct count", () => { + const cache = new SecretCache(-1); + expect(cache.size).toBe(0); + cache.set("a", "1"); + expect(cache.size).toBe(1); + cache.set("b", "2"); + expect(cache.size).toBe(2); + }); +}); diff --git a/tests/client.test.ts b/tests/client.test.ts deleted file mode 100644 index af363f5..0000000 --- a/tests/client.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { Enkryptify, EnkryptifyError } from "@/index"; - -const validConfig = { - apiKey: "test-api-key", - workspaceId: "ws-123", - projectId: "proj-456", - environment: "development", -}; - -describe("Enkryptify", () => { - it("should create an instance with valid config", () => { - const client = new Enkryptify(validConfig); - expect(client).toBeInstanceOf(Enkryptify); - }); - - it("should throw on missing apiKey", () => { - expect(() => new Enkryptify({ ...validConfig, apiKey: "" })).toThrow(EnkryptifyError); - }); - - it("should throw on missing workspaceId", () => { - expect(() => new Enkryptify({ ...validConfig, workspaceId: "" })).toThrow(EnkryptifyError); - }); - - it("should throw on missing projectId", () => { - expect(() => new Enkryptify({ ...validConfig, projectId: "" })).toThrow(EnkryptifyError); - }); - - it("should throw on missing environment", () => { - expect(() => new Enkryptify({ ...validConfig, environment: "" })).toThrow(EnkryptifyError); - }); - - it("should return a string from get()", async () => { - const client = new Enkryptify(validConfig); - const value = await client.get("DATABASE_URL"); - expect(typeof value).toBe("string"); - expect(value.length).toBeGreaterThan(0); - }); - - it("should throw when secret is not found", async () => { - const client = new Enkryptify(validConfig); - await expect(client.get("NONEXISTENT_KEY")).rejects.toThrow(EnkryptifyError); - }); -}); diff --git a/tests/enkryptify.test.ts b/tests/enkryptify.test.ts new file mode 100644 index 0000000..cbfc2df --- /dev/null +++ b/tests/enkryptify.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Enkryptify, EnkryptifyError, SecretNotFoundError } from "@/index"; +import { storeToken } from "@/internal/token-store"; +import type { EnkryptifyAuthProvider, EnkryptifyConfig, Secret } from "@/types"; + +function createAuth(token = "ek_test"): EnkryptifyAuthProvider { + const auth = { _brand: "EnkryptifyAuthProvider" as const }; + storeToken(auth, token); + return auth; +} + +function makeSecret(name: string, value: string, envId: string, isPersonal = false): Secret { + return { + id: `id-${name}`, + name, + note: "", + type: "string", + dataType: "text", + values: [{ environmentId: envId, value, isPersonal }], + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; +} + +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" }, + ...overrides, + }; +} + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Enkryptify — config validation", () => { + it("throws on missing workspace", () => { + expect(() => new Enkryptify(makeConfig({ workspace: "" }))).toThrow( + 'Missing required config field "workspace"', + ); + }); + + it("throws on missing project", () => { + expect(() => new Enkryptify(makeConfig({ project: "" }))).toThrow('Missing required config field "project"'); + }); + + it("throws on missing environment", () => { + expect(() => new Enkryptify(makeConfig({ environment: "" }))).toThrow( + 'Missing required config field "environment"', + ); + }); + + it("throws on missing auth", () => { + expect(() => new Enkryptify({ ...makeConfig(), auth: undefined as unknown as EnkryptifyAuthProvider })).toThrow( + 'Missing required config field "auth"', + ); + }); +}); + +describe("Enkryptify.fromEnv()", () => { + it("returns valid auth provider when env var is set", () => { + process.env.ENKRYPTIFY_TOKEN = "ek_test"; + const auth = Enkryptify.fromEnv(); + expect(auth._brand).toBe("EnkryptifyAuthProvider"); + delete process.env.ENKRYPTIFY_TOKEN; + }); +}); + +describe("Enkryptify.get()", () => { + it("fetches single secret from API and returns value", async () => { + const secret = makeSecret("DB_HOST", "localhost", "env-1"); + fetchMock.mockResolvedValue(new Response(JSON.stringify(secret), { status: 200 })); + + const client = new Enkryptify(makeConfig({ cache: { enabled: false } })); + const value = await client.get("DB_HOST"); + expect(value).toBe("localhost"); + }); + + it("second call hits cache (fetch called only once)", async () => { + const secrets = [makeSecret("DB_HOST", "localhost", "env-1")]; + fetchMock.mockResolvedValue(new Response(JSON.stringify(secrets), { status: 200 })); + + const client = new Enkryptify(makeConfig()); + const v1 = await client.get("DB_HOST"); + const v2 = await client.get("DB_HOST"); + + expect(v1).toBe("localhost"); + expect(v2).toBe("localhost"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("bypasses cache with { cache: false }", async () => { + const secret = makeSecret("DB_HOST", "localhost", "env-1"); + fetchMock.mockImplementation(() => Promise.resolve(new Response(JSON.stringify(secret), { status: 200 }))); + + const client = new Enkryptify(makeConfig({ cache: { enabled: true, eager: false } })); + await client.get("DB_HOST"); + await client.get("DB_HOST", { cache: false }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("strict mode throws SecretNotFoundError for unknown key", async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify([]), { status: 200 })); + + const client = new Enkryptify(makeConfig({ options: { strict: true } })); + await expect(client.get("NONEXISTENT")).rejects.toThrow(SecretNotFoundError); + }); + + it("non-strict mode returns empty string for unknown key", async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify([]), { status: 200 })); + + const client = new Enkryptify(makeConfig({ options: { strict: false } })); + const value = await client.get("NONEXISTENT"); + expect(value).toBe(""); + }); + + it("eager mode fetches ALL secrets on first call", async () => { + const secrets = [makeSecret("KEY_A", "a", "env-1"), makeSecret("KEY_B", "b", "env-1")]; + fetchMock.mockResolvedValue(new Response(JSON.stringify(secrets), { status: 200 })); + + const client = new Enkryptify(makeConfig({ cache: { enabled: true, eager: true } })); + const value = await client.get("KEY_A"); + + expect(value).toBe("a"); + // Should have called the all-secrets endpoint + const url = fetchMock.mock.calls[0]?.[0] as string; + expect(url).not.toContain("/secret/KEY_A"); + expect(url).toMatch(/\/secret\?/); + }); + + it("eager=false fetches only the requested single secret", async () => { + const secret = makeSecret("KEY_A", "a", "env-1"); + fetchMock.mockResolvedValue(new Response(JSON.stringify(secret), { status: 200 })); + + const client = new Enkryptify(makeConfig({ cache: { enabled: true, eager: false } })); + await client.get("KEY_A"); + + const url = fetchMock.mock.calls[0]?.[0] as string; + expect(url).toContain("/secret/KEY_A"); + }); +}); + +describe("Enkryptify.getFromCache()", () => { + it("returns cached value after preload()", async () => { + const secrets = [makeSecret("KEY_A", "val_a", "env-1")]; + fetchMock.mockResolvedValue(new Response(JSON.stringify(secrets), { status: 200 })); + + const client = new Enkryptify(makeConfig()); + await client.preload(); + + expect(client.getFromCache("KEY_A")).toBe("val_a"); + }); + + it("throws when key is not cached", () => { + const client = new Enkryptify(makeConfig()); + expect(() => client.getFromCache("MISSING")).toThrow(SecretNotFoundError); + }); + + it("throws when cache is disabled", () => { + const client = new Enkryptify(makeConfig({ cache: { enabled: false } })); + expect(() => client.getFromCache("KEY")).toThrow(EnkryptifyError); + expect(() => client.getFromCache("KEY")).toThrow("Cache is disabled"); + }); +}); + +describe("Enkryptify.preload()", () => { + it("populates cache, subsequent getFromCache() works", async () => { + const secrets = [makeSecret("A", "1", "env-1"), makeSecret("B", "2", "env-1")]; + fetchMock.mockResolvedValue(new Response(JSON.stringify(secrets), { status: 200 })); + + const client = new Enkryptify(makeConfig()); + await client.preload(); + + expect(client.getFromCache("A")).toBe("1"); + expect(client.getFromCache("B")).toBe("2"); + }); + + it("throws when cache is disabled", async () => { + const client = new Enkryptify(makeConfig({ cache: { enabled: false } })); + await expect(client.preload()).rejects.toThrow("Cannot preload: caching is disabled"); + }); +}); + +describe("Enkryptify — personal value resolution", () => { + it("prefers personal value when usePersonalValues=true", async () => { + const secret: Secret = { + id: "1", + name: "KEY", + note: "", + type: "string", + dataType: "text", + values: [ + { environmentId: "env-1", value: "shared-val", isPersonal: false }, + { environmentId: "env-1", value: "personal-val", isPersonal: true }, + ], + createdAt: "", + updatedAt: "", + }; + fetchMock.mockResolvedValue(new Response(JSON.stringify([secret]), { status: 200 })); + + const client = new Enkryptify(makeConfig({ options: { usePersonalValues: true } })); + const value = await client.get("KEY"); + expect(value).toBe("personal-val"); + }); + + it("falls back to shared when no personal value exists", async () => { + const secret: Secret = { + id: "1", + name: "KEY", + note: "", + type: "string", + dataType: "text", + values: [{ environmentId: "env-1", value: "shared-val", isPersonal: false }], + createdAt: "", + updatedAt: "", + }; + fetchMock.mockResolvedValue(new Response(JSON.stringify([secret]), { status: 200 })); + + const client = new Enkryptify(makeConfig({ options: { usePersonalValues: true } })); + const value = await client.get("KEY"); + expect(value).toBe("shared-val"); + }); + + it("uses shared value when usePersonalValues=false", async () => { + const secret: Secret = { + id: "1", + name: "KEY", + note: "", + type: "string", + dataType: "text", + values: [ + { environmentId: "env-1", value: "shared-val", isPersonal: false }, + { environmentId: "env-1", value: "personal-val", isPersonal: true }, + ], + createdAt: "", + updatedAt: "", + }; + fetchMock.mockResolvedValue(new Response(JSON.stringify([secret]), { status: 200 })); + + const client = new Enkryptify(makeConfig({ options: { usePersonalValues: false } })); + const value = await client.get("KEY"); + expect(value).toBe("shared-val"); + }); +}); + +describe("Enkryptify.destroy()", () => { + it("clears cache and subsequent calls throw", async () => { + const secrets = [makeSecret("KEY", "val", "env-1")]; + fetchMock.mockResolvedValue(new Response(JSON.stringify(secrets), { status: 200 })); + + const client = new Enkryptify(makeConfig()); + await client.preload(); + client.destroy(); + + expect(() => client.getFromCache("KEY")).toThrow("destroyed"); + await expect(client.get("KEY")).rejects.toThrow("destroyed"); + await expect(client.preload()).rejects.toThrow("destroyed"); + }); + + it("double-destroy does not error", () => { + const client = new Enkryptify(makeConfig()); + client.destroy(); + expect(() => client.destroy()).not.toThrow(); + }); +}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 0000000..429ca55 --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Logger } from "@/logger"; + +describe("Logger", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("respects log level filtering — debug hidden at info level", () => { + const spy = vi.spyOn(console, "debug").mockImplementation(() => {}); + const logger = new Logger("info"); + logger.debug("test"); + expect(spy).not.toHaveBeenCalled(); + }); + + it("shows debug messages at debug level", () => { + const spy = vi.spyOn(console, "debug").mockImplementation(() => {}); + const logger = new Logger("debug"); + logger.debug("test"); + expect(spy).toHaveBeenCalledOnce(); + }); + + it("calls correct console method per level", () => { + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}); + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const logger = new Logger("debug"); + logger.debug("d"); + logger.info("i"); + logger.warn("w"); + logger.error("e"); + + expect(debugSpy).toHaveBeenCalledOnce(); + expect(infoSpy).toHaveBeenCalledOnce(); + expect(warnSpy).toHaveBeenCalledOnce(); + expect(errorSpy).toHaveBeenCalledOnce(); + }); + + it("prefixes messages with [Enkryptify]", () => { + const spy = vi.spyOn(console, "info").mockImplementation(() => {}); + const logger = new Logger("info"); + logger.info("hello"); + expect(spy).toHaveBeenCalledWith("[Enkryptify] hello"); + }); + + it("error level hides info and warn messages", () => { + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const logger = new Logger("error"); + logger.info("i"); + logger.warn("w"); + logger.error("e"); + + expect(infoSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledOnce(); + }); +}); From 6989ec586a05cf3941e57abf77f7d59c58996fd3 Mon Sep 17 00:00:00 2001 From: SiebeBaree Date: Fri, 6 Mar 2026 14:18:48 +0100 Subject: [PATCH 2/3] chore: run formatter --- .github/workflows/release.yml | 52 +++++++++++++++++------------------ .oxlintrc.json | 38 ++++++++++++------------- .releaserc.json | 28 +++++++++---------- CONTRIBUTING.md | 20 +++++++------- 4 files changed, 69 insertions(+), 69 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f198f97..7ff1ea8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,39 +1,39 @@ name: Release on: - push: - branches: [main] + push: + branches: [main] permissions: - contents: write - issues: write - pull-requests: write - id-token: write + contents: write + issues: write + pull-requests: write + id-token: write jobs: - release: - runs-on: blacksmith-2vcpu-ubuntu-2404-arm - if: github.repository == 'enkryptify/sdk' + release: + runs-on: blacksmith-2vcpu-ubuntu-2404-arm + if: github.repository == 'enkryptify/sdk' - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 - - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 + - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm - - run: pnpm install --frozen-lockfile + - run: pnpm install --frozen-lockfile - - name: Build - run: pnpm build + - name: Build + run: pnpm build - # - name: Release - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - # run: npx semantic-release + # - name: Release + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + # run: npx semantic-release diff --git a/.oxlintrc.json b/.oxlintrc.json index f536a99..fd486c9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,21 +1,21 @@ { - "plugins": ["typescript", "import", "unicorn"], - "categories": { - "correctness": "error", - "suspicious": "warn" - }, - "rules": { - "no-console": "warn", - "eqeqeq": "error", - "typescript/consistent-type-imports": "error" - }, - "overrides": [ - { - "files": ["tests/**/*.ts"], - "rules": { - "no-console": "off" - } - } - ], - "ignorePatterns": ["dist", "node_modules", "coverage"] + "plugins": ["typescript", "import", "unicorn"], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "rules": { + "no-console": "warn", + "eqeqeq": "error", + "typescript/consistent-type-imports": "error" + }, + "overrides": [ + { + "files": ["tests/**/*.ts"], + "rules": { + "no-console": "off" + } + } + ], + "ignorePatterns": ["dist", "node_modules", "coverage"] } diff --git a/.releaserc.json b/.releaserc.json index ec47b84..142a6ba 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -1,17 +1,17 @@ { - "branches": ["main"], - "plugins": [ - "@semantic-release/commit-analyzer", - "@semantic-release/release-notes-generator", - "@semantic-release/changelog", - "@semantic-release/npm", - "@semantic-release/github", - [ - "@semantic-release/git", - { - "assets": ["CHANGELOG.md", "package.json"], - "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" - } + "branches": ["main"], + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/changelog", + "@semantic-release/npm", + "@semantic-release/github", + [ + "@semantic-release/git", + { + "assets": ["CHANGELOG.md", "package.json"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ] ] - ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d712a6e..47a0996 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,22 +6,22 @@ Thank you for your interest in contributing! Please read our [Code of Conduct](C 1. Clone the repository: - ```bash - git clone https://github.com/enkryptify/sdk.git - cd sdk - ``` + ```bash + git clone https://github.com/enkryptify/sdk.git + cd sdk + ``` 2. Install dependencies: - ```bash - pnpm install - ``` + ```bash + pnpm install + ``` 3. Verify your setup: - ```bash - pnpm check && pnpm lint && pnpm test && pnpm build - ``` + ```bash + pnpm check && pnpm lint && pnpm test && pnpm build + ``` ## Development Workflow From 66cc6f40a8b06ff106c05f7873f9aa6e15e13132 Mon Sep 17 00:00:00 2001 From: SiebeBaree Date: Mon, 9 Mar 2026 08:34:31 +0100 Subject: [PATCH 3/3] feat: update token exchange for API --- src/api.ts | 18 +++- src/auth.ts | 8 ++ src/enkryptify.ts | 84 ++++++++++++----- src/errors.ts | 46 +++++++++- src/index.ts | 10 +- src/token-exchange.ts | 88 ++++++++++++++++++ src/types.ts | 10 +- tests/api.test.ts | 21 ++++- tests/enkryptify.test.ts | 193 ++++++++++++++++++++++++++++++++++++++- 9 files changed, 442 insertions(+), 36 deletions(-) create mode 100644 src/token-exchange.ts diff --git a/src/api.ts b/src/api.ts index a273c61..e243368 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,5 @@ import type { EnkryptifyAuthProvider, Secret } from "@/types"; -import { AuthenticationError, ApiError } from "@/errors"; +import { AuthenticationError, AuthorizationError, NotFoundError, RateLimitError, ApiError } from "@/errors"; import { retrieveToken } from "@/internal/token-store"; export class EnkryptifyApi { @@ -35,8 +35,20 @@ export class EnkryptifyApi { }, }); - if (response.status === 401 || response.status === 403) { - throw new AuthenticationError(response.status); + if (response.status === 401) { + throw new AuthenticationError(); + } + + if (response.status === 403) { + throw new AuthorizationError(); + } + + if (response.status === 404) { + throw new NotFoundError(method, endpoint); + } + + if (response.status === 429) { + throw new RateLimitError(response.headers.get("Retry-After")); } if (!response.ok) { diff --git a/src/auth.ts b/src/auth.ts index 5bea3fe..fb52dfa 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -17,3 +17,11 @@ export class EnvAuthProvider implements EnkryptifyAuthProvider { storeToken(this, token); } } + +export class TokenAuthProvider implements EnkryptifyAuthProvider { + readonly _brand = "EnkryptifyAuthProvider" as const; + + constructor(token: string) { + storeToken(this, token); + } +} diff --git a/src/enkryptify.ts b/src/enkryptify.ts index 7b89f29..c53fedd 100644 --- a/src/enkryptify.ts +++ b/src/enkryptify.ts @@ -1,10 +1,11 @@ import type { EnkryptifyAuthProvider, EnkryptifyConfig, IEnkryptify, Secret } from "@/types"; -import { EnkryptifyError, SecretNotFoundError } from "@/errors"; -import { EnvAuthProvider } from "@/auth"; +import { EnkryptifyError, SecretNotFoundError, NotFoundError } from "@/errors"; +import { EnvAuthProvider, TokenAuthProvider } from "@/auth"; import { EnkryptifyApi } from "@/api"; import { SecretCache } from "@/cache"; import { Logger } from "@/logger"; import { retrieveToken } from "@/internal/token-store"; +import { TokenExchangeManager } from "@/token-exchange"; export class Enkryptify implements IEnkryptify { #api: EnkryptifyApi; @@ -19,14 +20,9 @@ export class Enkryptify implements IEnkryptify { #eagerCache: boolean; #destroyed = false; #eagerLoaded = false; + #tokenExchange: TokenExchangeManager | null = null; constructor(config: EnkryptifyConfig) { - if (!config.auth) { - throw new EnkryptifyError( - 'Missing required config field "auth". Provide an auth provider via Enkryptify.fromEnv().\n' + - "Docs: https://docs.enkryptify.com/sdk/configuration", - ); - } if (!config.workspace) { throw new EnkryptifyError( 'Missing required config field "workspace". Provide a workspace slug or ID.\n' + @@ -46,14 +42,33 @@ export class Enkryptify implements IEnkryptify { ); } - // Validate auth provider has a token in the store - if (config.auth._brand !== "EnkryptifyAuthProvider") { - throw new EnkryptifyError( - "Invalid auth provider. Use Enkryptify.fromEnv() to create one.\n" + - "Docs: https://docs.enkryptify.com/sdk/auth", - ); + // Resolve auth provider: token option → auth option → env var + let auth: EnkryptifyAuthProvider; + if (config.token) { + Enkryptify.#validateTokenFormat(config.token); + auth = new TokenAuthProvider(config.token); + } else if (config.auth) { + if (config.auth._brand !== "EnkryptifyAuthProvider") { + throw new EnkryptifyError( + "Invalid auth provider. Use Enkryptify.fromEnv() or pass a token option.\n" + + "Docs: https://docs.enkryptify.com/sdk/auth", + ); + } + auth = config.auth; + } else { + const envToken = process.env.ENKRYPTIFY_TOKEN; + if (!envToken) { + throw new EnkryptifyError( + "No token provided. Set ENKRYPTIFY_TOKEN or pass token in options.\n" + + "Docs: https://docs.enkryptify.com/sdk/auth", + ); + } + Enkryptify.#validateTokenFormat(envToken); + auth = new TokenAuthProvider(envToken); } - retrieveToken(config.auth); + + // Validate that the auth provider has a token in the store + retrieveToken(auth); this.#workspace = config.workspace; this.#project = config.project; @@ -69,7 +84,12 @@ export class Enkryptify implements IEnkryptify { this.#cache = this.#cacheEnabled ? new SecretCache(cacheTtl) : null; const baseUrl = config.baseUrl ?? "https://api.enkryptify.com"; - this.#api = new EnkryptifyApi(baseUrl, config.auth); + this.#api = new EnkryptifyApi(baseUrl, auth); + + if (config.useTokenExchange) { + const staticToken = retrieveToken(auth); + this.#tokenExchange = new TokenExchangeManager(baseUrl, staticToken, auth, this.#logger); + } this.#logger.info( `Initialized for workspace "${this.#workspace}", project "${this.#project}", environment "${this.#environment}"`, @@ -80,6 +100,24 @@ export class Enkryptify implements IEnkryptify { return new EnvAuthProvider(); } + static #validateTokenFormat(token: string): void { + if (!token) { + throw new EnkryptifyError("Token must be a non-empty string.\nDocs: https://docs.enkryptify.com/sdk/auth"); + } + + // Accept ek_live_ API tokens + if (token.startsWith("ek_live_")) return; + + // Accept JWTs (three base64 segments separated by dots) + const dotCount = token.split(".").length - 1; + if (dotCount === 2) return; + + throw new EnkryptifyError( + "Invalid token format. Expected an ek_live_ token or JWT.\n" + + "Docs: https://docs.enkryptify.com/sdk/auth#token-format", + ); + } + async get(key: string, options?: { cache?: boolean }): Promise { this.#guardDestroyed(); @@ -94,6 +132,8 @@ export class Enkryptify implements IEnkryptify { this.#logger.debug(`Cache miss for secret "${key}", fetching from API`); } + await this.#tokenExchange?.ensureToken(); + if (useCache && this.#eagerCache && !this.#eagerLoaded) { return this.#fetchAndCacheAll(key); } @@ -129,6 +169,8 @@ export class Enkryptify implements IEnkryptify { ); } + await this.#tokenExchange?.ensureToken(); + const secrets = await this.#api.fetchAllSecrets(this.#workspace, this.#project, this.#environment); let count = 0; @@ -146,6 +188,7 @@ export class Enkryptify implements IEnkryptify { destroy(): void { if (this.#destroyed) return; + this.#tokenExchange?.destroy(); this.#cache?.clear(); this.#destroyed = true; this.#logger.info("Client destroyed, all cached secrets cleared"); @@ -202,12 +245,9 @@ export class Enkryptify implements IEnkryptify { secret = await this.#api.fetchSecret(this.#workspace, this.#project, key, this.#environment); this.#logger.debug(`API responded with 1 secret(s) in ${Date.now() - start}ms`); } catch (error) { - if (error instanceof EnkryptifyError) { - // If it's a 404-like error (ApiError with status in message) - const msg = error.message; - if (msg.includes("HTTP 404")) { - return this.#handleNotFound(key); - } + // NotFoundError is imported at the module level via @/errors + if (error instanceof NotFoundError) { + return this.#handleNotFound(key); } throw error; } diff --git a/src/errors.ts b/src/errors.ts index 2a2d9f9..f7f4b98 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -17,16 +17,54 @@ export class SecretNotFoundError extends EnkryptifyError { } export class AuthenticationError extends EnkryptifyError { - constructor(status: number) { + constructor() { super( - `Authentication failed (HTTP ${status}). Your token may be expired or invalid. ` + - `Generate a new token in your Enkryptify dashboard.\n` + - `Docs: https://docs.enkryptify.com/sdk/auth#token-issues`, + "Authentication failed (HTTP 401). Token is invalid, expired, or revoked. " + + "Generate a new token in your Enkryptify dashboard.\n" + + "Docs: https://docs.enkryptify.com/sdk/auth#token-issues", ); this.name = "AuthenticationError"; } } +export class AuthorizationError extends EnkryptifyError { + constructor() { + super( + "Authorization failed (HTTP 403). Token does not have access to this resource. " + + "Check that your token has the required permissions.\n" + + "Docs: https://docs.enkryptify.com/sdk/auth#permissions", + ); + this.name = "AuthorizationError"; + } +} + +export class NotFoundError extends EnkryptifyError { + constructor(method: string, endpoint: string) { + super( + `Resource not found (HTTP 404) for ${method} ${endpoint}. ` + + "Workspace, project, or environment not found. Verify your configuration.\n" + + "Docs: https://docs.enkryptify.com/sdk/troubleshooting#not-found", + ); + this.name = "NotFoundError"; + } +} + +export class RateLimitError extends EnkryptifyError { + public readonly retryAfter: number | null; + + constructor(retryAfter?: string | null) { + const parsed = retryAfter ? parseInt(retryAfter, 10) : null; + const retrySeconds = parsed !== null && !Number.isNaN(parsed) ? parsed : null; + super( + `Rate limited (HTTP 429). ` + + `${retrySeconds ? `Retry after ${retrySeconds} seconds.` : "Please retry later."}\n` + + "Docs: https://docs.enkryptify.com/sdk/troubleshooting#rate-limiting", + ); + this.name = "RateLimitError"; + this.retryAfter = retrySeconds; + } +} + export class ApiError extends EnkryptifyError { public readonly status: number; diff --git a/src/index.ts b/src/index.ts index c5dc64b..96e983a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,12 @@ export { Enkryptify } from "@/enkryptify"; export { Enkryptify as default } from "@/enkryptify"; -export { EnkryptifyError, SecretNotFoundError, AuthenticationError, ApiError } from "@/errors"; +export { + EnkryptifyError, + SecretNotFoundError, + AuthenticationError, + AuthorizationError, + NotFoundError, + RateLimitError, + ApiError, +} from "@/errors"; export type { IEnkryptify, EnkryptifyConfig, EnkryptifyAuthProvider } from "@/types"; diff --git a/src/token-exchange.ts b/src/token-exchange.ts new file mode 100644 index 0000000..d5e4177 --- /dev/null +++ b/src/token-exchange.ts @@ -0,0 +1,88 @@ +import type { EnkryptifyAuthProvider, TokenExchangeResponse } from "@/types"; +import type { Logger } from "@/logger"; +import { storeToken } from "@/internal/token-store"; + +export class TokenExchangeManager { + #baseUrl: string; + #staticToken: string; + #auth: EnkryptifyAuthProvider; + #logger: Logger; + #jwt: string | null = null; + #refreshTimer: ReturnType | null = null; + #exchangePromise: Promise | null = null; + + constructor(baseUrl: string, staticToken: string, auth: EnkryptifyAuthProvider, logger: Logger) { + this.#baseUrl = baseUrl; + this.#staticToken = staticToken; + this.#auth = auth; + this.#logger = logger; + } + + async ensureToken(): Promise { + if (this.#jwt) return; + + // Deduplicate concurrent exchange calls + if (this.#exchangePromise) { + return this.#exchangePromise; + } + + this.#exchangePromise = this.#exchange(); + try { + await this.#exchangePromise; + } finally { + this.#exchangePromise = null; + } + } + + async #exchange(): Promise { + try { + const response = await fetch(`${this.#baseUrl}/v1/auth/exchange`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.#staticToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Token exchange failed with HTTP ${response.status}`); + } + + const data = (await response.json()) as TokenExchangeResponse; + this.#jwt = data.accessToken; + storeToken(this.#auth, this.#jwt); + this.#logger.debug("Token exchanged for short-lived JWT"); + + // Refresh 60 seconds before expiry + const refreshMs = (data.expiresIn - 60) * 1000; + this.#scheduleRefresh(refreshMs); + } catch (error) { + // Fallback to static token + this.#jwt = null; + storeToken(this.#auth, this.#staticToken); + this.#logger.warn( + `Token exchange failed, falling back to static token: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + #scheduleRefresh(ms: number): void { + if (this.#refreshTimer) { + clearTimeout(this.#refreshTimer); + } + this.#refreshTimer = setTimeout(() => { + this.#jwt = null; + this.#exchange(); + }, ms); + // Don't keep the process alive just for token refresh + this.#refreshTimer.unref?.(); + } + + destroy(): void { + if (this.#refreshTimer) { + clearTimeout(this.#refreshTimer); + this.#refreshTimer = null; + } + this.#jwt = null; + } +} diff --git a/src/types.ts b/src/types.ts index 867a2f5..8e17e3f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,11 +42,13 @@ export interface IEnkryptify { } export interface EnkryptifyConfig { - auth: EnkryptifyAuthProvider; + auth?: EnkryptifyAuthProvider; + token?: string; workspace: string; project: string; environment: string; baseUrl?: string; + useTokenExchange?: boolean; options?: { strict?: boolean; usePersonalValues?: boolean; @@ -61,6 +63,12 @@ export interface EnkryptifyConfig { }; } +export interface TokenExchangeResponse { + accessToken: string; + expiresIn: number; + tokenType: string; +} + export interface EnkryptifyAuthProvider { readonly _brand: "EnkryptifyAuthProvider"; } diff --git a/tests/api.test.ts b/tests/api.test.ts index 9721c05..69e9f29 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EnkryptifyApi } from "@/api"; -import { AuthenticationError, ApiError } from "@/errors"; +import { AuthenticationError, AuthorizationError, NotFoundError, RateLimitError, ApiError } from "@/errors"; import { storeToken } from "@/internal/token-store"; import type { EnkryptifyAuthProvider } from "@/types"; @@ -72,11 +72,26 @@ describe("EnkryptifyApi", () => { await expect(api.fetchAllSecrets("ws", "prj", "env")).rejects.toThrow(AuthenticationError); }); - it("throws AuthenticationError on 403", async () => { + it("throws AuthorizationError on 403", async () => { fetchMock.mockResolvedValue(new Response("Forbidden", { status: 403 })); const api = new EnkryptifyApi("https://api.example.com", createAuth("tok")); - await expect(api.fetchAllSecrets("ws", "prj", "env")).rejects.toThrow(AuthenticationError); + await expect(api.fetchAllSecrets("ws", "prj", "env")).rejects.toThrow(AuthorizationError); + }); + + it("throws NotFoundError on 404", async () => { + fetchMock.mockResolvedValue(new Response("Not Found", { status: 404 })); + const api = new EnkryptifyApi("https://api.example.com", createAuth("tok")); + + await expect(api.fetchAllSecrets("ws", "prj", "env")).rejects.toThrow(NotFoundError); + }); + + it("throws RateLimitError on 429", async () => { + const headers = new Headers({ "Retry-After": "30" }); + fetchMock.mockResolvedValue(new Response("Too Many Requests", { status: 429, headers })); + const api = new EnkryptifyApi("https://api.example.com", createAuth("tok")); + + await expect(api.fetchAllSecrets("ws", "prj", "env")).rejects.toThrow(RateLimitError); }); it("throws ApiError on 500 with status in message", async () => { diff --git a/tests/enkryptify.test.ts b/tests/enkryptify.test.ts index cbfc2df..3284cb3 100644 --- a/tests/enkryptify.test.ts +++ b/tests/enkryptify.test.ts @@ -62,13 +62,111 @@ describe("Enkryptify — config validation", () => { ); }); - it("throws on missing auth", () => { + it("throws when no token or auth provided", () => { + delete process.env.ENKRYPTIFY_TOKEN; expect(() => new Enkryptify({ ...makeConfig(), auth: undefined as unknown as EnkryptifyAuthProvider })).toThrow( - 'Missing required config field "auth"', + "No token provided", ); }); }); +describe("Enkryptify — token resolution", () => { + const originalEnv = process.env.ENKRYPTIFY_TOKEN; + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.ENKRYPTIFY_TOKEN = originalEnv; + } else { + delete process.env.ENKRYPTIFY_TOKEN; + } + }); + + it("accepts token option (ek_live_ format)", () => { + const client = new Enkryptify({ + token: "ek_live_abc123", + workspace: "ws-1", + project: "prj-1", + environment: "env-1", + baseUrl: "https://api.test.com", + logger: { level: "error" }, + }); + expect(client).toBeInstanceOf(Enkryptify); + client.destroy(); + }); + + it("accepts token option (JWT format)", () => { + const client = new Enkryptify({ + token: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig", + workspace: "ws-1", + project: "prj-1", + environment: "env-1", + baseUrl: "https://api.test.com", + logger: { level: "error" }, + }); + expect(client).toBeInstanceOf(Enkryptify); + client.destroy(); + }); + + it("rejects invalid token format", () => { + expect( + () => + new Enkryptify({ + token: "not-a-valid-token", + workspace: "ws-1", + project: "prj-1", + environment: "env-1", + logger: { level: "error" }, + }), + ).toThrow("Invalid token format"); + }); + + it("falls back to ENKRYPTIFY_TOKEN env var", () => { + process.env.ENKRYPTIFY_TOKEN = "ek_live_from_env"; + const client = new Enkryptify({ + workspace: "ws-1", + project: "prj-1", + environment: "env-1", + baseUrl: "https://api.test.com", + logger: { level: "error" }, + }); + expect(client).toBeInstanceOf(Enkryptify); + client.destroy(); + }); + + it("token option takes priority over auth option", async () => { + const secrets = [makeSecret("KEY", "val", "env-1")]; + fetchMock.mockResolvedValue(new Response(JSON.stringify(secrets), { status: 200 })); + + const client = new Enkryptify({ + token: "ek_live_priority", + auth: createAuth("ek_live_fallback"), + workspace: "ws-1", + project: "prj-1", + environment: "env-1", + baseUrl: "https://api.test.com", + logger: { level: "error" }, + }); + await client.get("KEY"); + + const opts = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(opts.headers).toHaveProperty("Authorization", "Bearer ek_live_priority"); + client.destroy(); + }); + + it("throws when no token source available", () => { + delete process.env.ENKRYPTIFY_TOKEN; + expect( + () => + new Enkryptify({ + workspace: "ws-1", + project: "prj-1", + environment: "env-1", + logger: { level: "error" }, + }), + ).toThrow("No token provided"); + }); +}); + describe("Enkryptify.fromEnv()", () => { it("returns valid auth provider when env var is set", () => { process.env.ENKRYPTIFY_TOKEN = "ek_test"; @@ -256,6 +354,97 @@ describe("Enkryptify — personal value resolution", () => { }); }); +describe("Enkryptify — token exchange", () => { + it("exchanges token before first API call when useTokenExchange=true", async () => { + const exchangeResponse = { accessToken: "jwt-token", expiresIn: 900, tokenType: "Bearer" }; + const secrets = [makeSecret("KEY", "val", "env-1")]; + + fetchMock.mockImplementation((url: string) => { + if (url.includes("/v1/auth/exchange")) { + return Promise.resolve(new Response(JSON.stringify(exchangeResponse), { status: 200 })); + } + return Promise.resolve(new Response(JSON.stringify(secrets), { status: 200 })); + }); + + const client = new Enkryptify({ + token: "ek_live_static", + workspace: "ws-1", + project: "prj-1", + environment: "env-1", + baseUrl: "https://api.test.com", + useTokenExchange: true, + logger: { level: "error" }, + }); + + await client.get("KEY"); + + // First call should be the exchange + const exchangeUrl = fetchMock.mock.calls[0]?.[0] as string; + expect(exchangeUrl).toBe("https://api.test.com/v1/auth/exchange"); + const exchangeOpts = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(exchangeOpts.headers).toHaveProperty("Authorization", "Bearer ek_live_static"); + + // Second call should use the JWT + const secretOpts = fetchMock.mock.calls[1]?.[1] as RequestInit; + expect(secretOpts.headers).toHaveProperty("Authorization", "Bearer jwt-token"); + + client.destroy(); + }); + + it("falls back to static token if exchange fails", async () => { + const secrets = [makeSecret("KEY", "val", "env-1")]; + + fetchMock.mockImplementation((url: string) => { + if (url.includes("/v1/auth/exchange")) { + return Promise.resolve(new Response("Server Error", { status: 500 })); + } + return Promise.resolve(new Response(JSON.stringify(secrets), { status: 200 })); + }); + + const client = new Enkryptify({ + token: "ek_live_static", + workspace: "ws-1", + project: "prj-1", + environment: "env-1", + baseUrl: "https://api.test.com", + useTokenExchange: true, + logger: { level: "error" }, + }); + + const value = await client.get("KEY"); + expect(value).toBe("val"); + + // Secret request should use static token as fallback + const secretOpts = fetchMock.mock.calls[1]?.[1] as RequestInit; + expect(secretOpts.headers).toHaveProperty("Authorization", "Bearer ek_live_static"); + + client.destroy(); + }); + + it("does not exchange when useTokenExchange is false", async () => { + const secrets = [makeSecret("KEY", "val", "env-1")]; + fetchMock.mockResolvedValue(new Response(JSON.stringify(secrets), { status: 200 })); + + const client = new Enkryptify({ + token: "ek_live_static", + workspace: "ws-1", + project: "prj-1", + environment: "env-1", + baseUrl: "https://api.test.com", + logger: { level: "error" }, + }); + + await client.get("KEY"); + + // Only one call, no exchange + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = fetchMock.mock.calls[0]?.[0] as string; + expect(url).not.toContain("/v1/auth/exchange"); + + client.destroy(); + }); +}); + describe("Enkryptify.destroy()", () => { it("clears cache and subsequent calls throw", async () => { const secrets = [makeSecret("KEY", "val", "env-1")];