diff --git a/README.md b/README.md index f45efba..970caf4 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ The Hyphen Node.js SDK is a JavaScript library that allows developers to easily - [Toggle Environment Variables](#toggle-environment-variables) - [Toggle Self-Hosted](#toggle-self-hosted) - [ENV - Secret Management Service](#env---secret-management-service) - - [Loading Environment Variables](#loading-environment-variables) + - [Loading Environment Variables from Hyphen](#loading-environment-variables-from-hyphen) + - [Loading Local Environment Files](#loading-local-environment-files) - [Net Info - Geo Information Service](#net-info---geo-information-service) - [Execution Context - API Key Validation](#execution-context---api-key-validation) - [Link - Short Code Service](#link---short-code-service) @@ -600,7 +601,66 @@ console.log('Boolean toggle value:', result); // true Hyphens secret management service known as [ENV](https://hyphen.ai/env) allows you to manage your environment variables in a secure way. The Hyphen Node.js SDK provides a simple way to access your environment variables. -## Loading Environment Variables +## Loading Environment Variables from Hyphen + +Use the authenticated `Env` service to fetch encrypted variables from Hyphen, +decrypt them in memory, and add them to `process.env`: + +```javascript +import { Hyphen } from '@hyphen/sdk'; + +const hyphen = new Hyphen({ + apiKey: 'your_api_key', + env: { + organizationId: 'your_organization_id', + projectId: 'your_project_id', + applicationId: 'your_application_id', + }, +}); + +const variables = await hyphen.env.load('production'); +``` + +Loading a named environment fetches both `default` and the named environment. +Named values take precedence, and remote values overwrite matching values in +`process.env`. The returned object contains the merged remote values. + +You can change those defaults per load: + +```javascript +await hyphen.env.load('production', { + includeDefault: false, + override: false, +}); +``` + +The service reads missing configuration from `HYPHEN_API_KEY`, +`HYPHEN_ORGANIZATION_ID`, `HYPHEN_PROJECT_ID`, and +`HYPHEN_APPLICATION_ID`, with `HYPHEN_APP_ID` supported as a compatibility +fallback. When running with Hyphen Deploy, `HYPHEN_PROJECT_NAME` and +`HYPHEN_APP_NAME` are used if their corresponding IDs are unavailable, and an +omitted load environment uses `HYPHEN_APP_ENVIRONMENT` before falling back to +`default`. Projects that do not use Hyphen's secure key store can provide their +key directly with `decryptionKey: { secretKeyId, secretKey }`. Decryption keys +and plaintext ENV data are never written to disk by this API. + +The `Env` class can also be used directly: + +```javascript +import { Env } from '@hyphen/sdk'; + +const remoteEnv = new Env({ + apiKey: 'your_api_key', + organizationId: 'your_organization_id', + projectId: 'your_project_id', + applicationId: 'your_application_id', +}); + +await remoteEnv.load('production'); +``` + +## Loading Local Environment Files + To load your environment variables, you can use the `env()` function from the SDK. This function will automatically load your environment variables from the `.env` file and then override them with the environment based environment file if it exists (ex: `.env.development`). This is useful for managing different environments such as development, staging, and production. The following override path is: @@ -1069,4 +1129,4 @@ pnpm i && pnpm test # License and Copyright This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. -The copyright for this project is held by Hyphen, Inc. All rights reserved. \ No newline at end of file +The copyright for this project is held by Hyphen, Inc. All rights reserved. diff --git a/src/base-service.ts b/src/base-service.ts index 861c102..c6ecdb3 100644 --- a/src/base-service.ts +++ b/src/base-service.ts @@ -1,5 +1,9 @@ // biome-ignore-all lint/suspicious/noExplicitAny: this is for http client and helpers -import { CacheableNet, type FetchRequestInit } from "@cacheable/net"; +import { + CacheableNet, + type FetchRequestInit, + type NetFetchOptions, +} from "@cacheable/net"; import { Cacheable } from "cacheable"; import { Hookified, type HookifiedOptions } from "hookified"; import pino from "pino"; @@ -68,7 +72,7 @@ export class BaseService extends Hookified { public async get( url: string, - config?: FetchRequestInit & { params?: any }, + config?: NetFetchOptions & { params?: any }, ): Promise> { // Handle query parameters if provided let finalUrl = url; diff --git a/src/env-service.ts b/src/env-service.ts new file mode 100644 index 0000000..523545d --- /dev/null +++ b/src/env-service.ts @@ -0,0 +1,298 @@ +import { Buffer } from "node:buffer"; +import { createDecipheriv, createHash } from "node:crypto"; +import process from "node:process"; +import { parseEnv, TextDecoder } from "node:util"; +import { BaseService, type BaseServiceOptions } from "./base-service.js"; + +const defaultEnvHorizonUri = "https://toggle.hyphen.cloud"; +const defaultEnvKeyStoreUri = "https://vinz.hyphen.ai"; + +export type EnvKey = { + secretKeyId: number; + secretKey: string; +}; + +export type EnvLoadOptions = { + includeDefault?: boolean; + override?: boolean; +}; + +export type EnvServiceOptions = { + apiKey?: string; + organizationId?: string; + projectId?: string; + applicationId?: string; + decryptionKey?: EnvKey; + horizonUri?: string; + keyStoreUri?: string; +} & BaseServiceOptions; + +const environmentNamePattern = /^[a-z0-9_-]+$/; +const base64UrlPattern = /^[A-Za-z0-9_-]+={0,2}$/; +const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); + +class SafeEnvError extends Error {} + +export class Env extends BaseService { + private _apiKey: string | undefined; + private readonly _organizationId: string | undefined; + private readonly _projectId: string | undefined; + private readonly _applicationId: string | undefined; + private readonly _decryptionKey: EnvKey | undefined; + private readonly _horizonUri: string; + private readonly _keyStoreUri: string; + + constructor(options?: EnvServiceOptions) { + super(options); + this._organizationId = + options?.organizationId ?? process.env.HYPHEN_ORGANIZATION_ID; + this._projectId = + options?.projectId ?? + process.env.HYPHEN_PROJECT_ID ?? + process.env.HYPHEN_PROJECT_NAME; + this._applicationId = + options?.applicationId ?? + process.env.HYPHEN_APPLICATION_ID ?? + process.env.HYPHEN_APP_ID ?? + process.env.HYPHEN_APP_NAME; + this._decryptionKey = options?.decryptionKey; + this._horizonUri = stripTrailingSlash( + options?.horizonUri ?? defaultEnvHorizonUri, + ); + this._keyStoreUri = stripTrailingSlash( + options?.keyStoreUri ?? defaultEnvKeyStoreUri, + ); + this.apiKey = options?.apiKey ?? process.env.HYPHEN_API_KEY; + } + + public get apiKey(): string | undefined { + return this._apiKey; + } + + public set apiKey(value: string | undefined) { + this._apiKey = value; + } + + public async load( + environment?: string, + options?: EnvLoadOptions, + ): Promise> { + const environmentName = normalizeEnvironmentName( + environment ?? process.env.HYPHEN_APP_ENVIRONMENT ?? "default", + ); + this.validateConfiguration(); + + const key = await this.getDecryptionKey(); + const includeDefault = options?.includeDefault ?? true; + const environmentNames = + environmentName !== "default" && includeDefault + ? ["default", environmentName] + : [environmentName]; + const layers = await Promise.all( + environmentNames.map((name) => this.loadEnvironment(name, key)), + ); + const variables = Object.assign({}, ...layers) as Record; + const entries = Object.entries(variables); + if ( + entries.some( + ([name, value]) => name.includes("\0") || value.includes("\0"), + ) + ) { + throw new Error( + "Hyphen ENV contains a variable that cannot be added to process.env.", + ); + } + + for (const [name, value] of entries) { + if ((options?.override ?? true) || process.env[name] === undefined) { + process.env[name] = value; + } + } + + return variables; + } + + private validateConfiguration(): void { + if (!this._apiKey) { + throw new Error("An API key is required to load Hyphen ENV data."); + } + if (this._apiKey.startsWith("public_")) { + throw new Error("A public API key cannot load Hyphen ENV data."); + } + if (!this._organizationId) { + throw new Error( + "An organization ID is required to load Hyphen ENV data.", + ); + } + if (!this._applicationId) { + throw new Error("An application ID is required to load Hyphen ENV data."); + } + if (!this._decryptionKey && !this._projectId) { + throw new Error( + "A project ID or decryption key is required to load Hyphen ENV data.", + ); + } + } + + private async getDecryptionKey(): Promise { + if (this._decryptionKey) { + return validateKey(this._decryptionKey); + } + + try { + const url = `${this._keyStoreUri}/${encodeURIComponent(this._organizationId as string)}/${encodeURIComponent(this._projectId as string)}/key`; + const response = await this.get(url, { + caching: false, + headers: this.createHeaders(this._apiKey), + }); + if (response.status !== 200) { + throw new SafeEnvError(`HTTP ${response.status}`); + } + return readKeyResponse(response.data); + } catch (error) { + throw contextualError("Failed to fetch the Hyphen ENV key", error); + } + } + + private async loadEnvironment( + environment: string, + key: EnvKey, + ): Promise> { + let encryptedData: string; + try { + const url = `${this._horizonUri}/api/organizations/${encodeURIComponent(this._organizationId as string)}/apps/${encodeURIComponent(this._applicationId as string)}/dot-env/`; + const params: Record = { + secretKeyId: key.secretKeyId, + }; + if (environment !== "default") { + params.environmentId = environment; + } + + const response = await this.get(url, { + caching: false, + headers: this.createHeaders(this._apiKey), + params, + }); + if (response.status !== 200) { + throw new SafeEnvError(`HTTP ${response.status}`); + } + encryptedData = readEnvironmentResponse(response.data); + } catch (error) { + throw contextualError( + `Failed to fetch Hyphen ENV "${environment}"`, + error, + ); + } + + try { + return parseEnv(decrypt(encryptedData, key.secretKey)); + } catch { + throw new Error( + `Failed to decrypt or parse Hyphen ENV "${environment}".`, + ); + } + } +} + +function normalizeEnvironmentName(environment: string): string { + const name = environment === "" ? "default" : environment.toLowerCase(); + if (!environmentNamePattern.test(name)) { + throw new Error( + "Environment must contain only lowercase letters, numbers, hyphens, and underscores.", + ); + } + return name; +} + +function validateKey(key: { + secretKeyId: unknown; + secretKey: unknown; +}): EnvKey { + if ( + typeof key.secretKeyId !== "number" || + !Number.isSafeInteger(key.secretKeyId) || + key.secretKeyId < 1 || + typeof key.secretKey !== "string" || + key.secretKey.length === 0 + ) { + throw new SafeEnvError("The Hyphen ENV key is invalid."); + } + return { + secretKeyId: key.secretKeyId, + secretKey: key.secretKey, + }; +} + +function readKeyResponse(value: unknown): EnvKey { + if (!isRecord(value) || !isRecord(value.key)) { + throw new SafeEnvError("The key-store response is invalid."); + } + return validateKey({ + secretKeyId: value.key.secret_key_id, + secretKey: value.key.secret_key, + }); +} + +function readEnvironmentResponse(value: unknown): string { + if (!isRecord(value) || typeof value.data !== "string") { + throw new SafeEnvError("The response did not contain encrypted ENV data."); + } + return value.data; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function decrypt(encryptedData: string, secretKey: string): string { + const ciphertext = decodeBase64Url(encryptedData); + if (ciphertext.length < 16) { + throw new Error( + "Encrypted ENV data is shorter than its initialization vector.", + ); + } + + const keyHash = createHash("sha256").update(secretKey).digest("hex"); + const key = Buffer.from(keyHash.slice(0, 32), "utf8"); + const decipher = createDecipheriv( + "aes-256-cfb", + key, + ciphertext.subarray(0, 16), + ); + const plaintext = Buffer.concat([ + decipher.update(ciphertext.subarray(16)), + decipher.final(), + ]); + return utf8Decoder.decode(plaintext); +} + +function decodeBase64Url(value: string): Buffer { + if (!base64UrlPattern.test(value) || value.length % 4 === 1) { + throw new Error("Encrypted ENV data is not valid base64url."); + } + + const unpadded = value.replace(/=+$/, ""); + const padding = value.length - unpadded.length; + const requiredPadding = (4 - (unpadded.length % 4)) % 4; + if ( + padding !== requiredPadding || + Buffer.from(unpadded, "base64url").toString("base64url") !== unpadded + ) { + throw new Error("Encrypted ENV data is not valid base64url."); + } + return Buffer.from(unpadded, "base64url"); +} + +function stripTrailingSlash(uri: string): string { + let end = uri.length; + while (end > 0 && uri[end - 1] === "/") { + end -= 1; + } + return uri.slice(0, end); +} + +function contextualError(message: string, error: unknown): Error { + const detail = + error instanceof SafeEnvError ? error.message : "Request failed."; + return new Error(`${message}: ${detail}`); +} diff --git a/src/hyphen.ts b/src/hyphen.ts index c407faa..39732c3 100644 --- a/src/hyphen.ts +++ b/src/hyphen.ts @@ -1,4 +1,5 @@ import { Hookified, type HookifiedOptions } from "hookified"; +import { Env, type EnvServiceOptions } from "./env-service.js"; import { Link, type LinkOptions } from "./link.js"; import { NetInfo, type NetInfoOptions } from "./net-info.js"; import { Toggle, type ToggleOptions } from "./toggle.js"; @@ -35,9 +36,17 @@ export type HyphenOptions = { * @default {Link} */ link?: Omit; + /** + * Options for the ENV service. + * Excludes `apiKey` as it's provided at the top level. + * @see EnvServiceOptions + * @default {Env} + */ + env?: Omit; } & HookifiedOptions; export class Hyphen extends Hookified { + private readonly _env: Env; private readonly _netInfo: NetInfo; private readonly _toggle: Toggle; private readonly _link: Link; @@ -51,6 +60,7 @@ export class Hyphen extends Hookified { const toggleOptions: ToggleOptions = options?.toggle ?? {}; const netInfoOptions: NetInfoOptions = options?.netInfo ?? {}; const linkOptions: LinkOptions = options?.link ?? {}; + const envOptions: EnvServiceOptions = { ...options?.env }; if (options?.publicApiKey) { this._publicApiKey = options.publicApiKey; @@ -61,8 +71,23 @@ export class Hyphen extends Hookified { this._apiKey = options.apiKey; netInfoOptions.apiKey = options.apiKey; linkOptions.apiKey = options.apiKey; + envOptions.apiKey = options.apiKey; } + this._env = new Env(envOptions); + + this._env.on("error", (message, ...args) => + this.emit("error", message, ...args), + ); + + this._env.on("info", (message, ...args) => + this.emit("info", message, ...args), + ); + + this._env.on("warn", (message, ...args) => + this.emit("warn", message, ...args), + ); + this._netInfo = new NetInfo(netInfoOptions); // Set error, info, warn emitters for netInfo @@ -109,6 +134,14 @@ export class Hyphen extends Hookified { ); } + /** + * Get the ENV service instance. + * @returns {Env} The ENV service instance. + */ + public get env(): Env { + return this._env; + } + /** * Get the NetInfo service instance. * @returns {NetInfo} The NetInfo service instance. @@ -154,7 +187,7 @@ export class Hyphen extends Hookified { /** * Get the API key for the Hyphen service. - * This is used for authenticated endpoints that require an API key such as the NetInfo and Link services. + * This is used for authenticated endpoints that require an API key such as the ENV, NetInfo, and Link services. * @returns {string | undefined} The API key. */ public get apiKey(): string | undefined { @@ -163,11 +196,12 @@ export class Hyphen extends Hookified { /** * Set the API key for the Hyphen service. If set, this will also update the underlying services. - * This is used for authenticated endpoints that require an API key such as the NetInfo and Link services. + * This is used for authenticated endpoints that require an API key such as the ENV, NetInfo, and Link services. * @param {string | undefined} value - The API key to set. */ public set apiKey(value: string | undefined) { this._apiKey = value; + this._env.apiKey = value; this._netInfo.apiKey = value; this._link.apiKey = value; } diff --git a/src/index.ts b/src/index.ts index 78eb641..e919847 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,10 @@ export { type EnvOptions, env, type LoadEnvOptions, loadEnv } from "./env.js"; +export { + Env, + type EnvKey, + type EnvLoadOptions, + type EnvServiceOptions, +} from "./env-service.js"; export { type ExecutionContext, type ExecutionContextLocation, diff --git a/test/env-service.test.ts b/test/env-service.test.ts new file mode 100644 index 0000000..36a580e --- /dev/null +++ b/test/env-service.test.ts @@ -0,0 +1,767 @@ +import { Buffer } from "node:buffer"; +import { createCipheriv, createHash } from "node:crypto"; +import process from "node:process"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { HttpResponse } from "../src/base-service.js"; +import { + Env, + type EnvKey, + type EnvServiceOptions, +} from "../src/env-service.js"; + +const apiKey = "private-test-api-key"; +const organizationId = "org_test"; +const projectId = "proj_test"; +const applicationId = "app_test"; +const productionEnvironment = "production"; +const defaultEnvHorizonUri = "https://toggle.hyphen.cloud"; +const defaultEnvKeyStoreUri = "https://vinz.hyphen.ai"; +const testVariableNames = [ + "CUSTOM_URI", + "DEFAULT_ONLY", + "DEFAULT_VALUE", + "DEPLOY_FALLBACK", + "DIRECT_KEY", + "EXPLICIT", + "FROM_ENV", + "ID_PRECEDENCE", + "LEGACY_APP_ID", + "NAMED_ONLY", + "NEW_VALUE", + "ONLY_NAMED", + "QUOTED", + "SETTER_KEY", + "SHARED", + "UNCHANGED", +]; + +const decryptionKey: EnvKey = { + secretKeyId: 1_725_000_000, + secretKey: "dGVzdC1zZWNyZXQ=", +}; + +const authHeaders = { + accept: "application/json", + "content-type": "application/json", + "x-api-key": apiKey, +}; + +const keyStoreUrl = `${defaultEnvKeyStoreUri}/${organizationId}/${projectId}/key`; +const horizonUrl = `${defaultEnvHorizonUri}/api/organizations/${organizationId}/apps/${applicationId}/dot-env/`; + +function response( + data: T, + status = 200, + statusText = status === 200 ? "OK" : "Request failed", +): HttpResponse { + return { + data, + status, + statusText, + headers: {}, + config: {}, + }; +} + +function padBase64(value: string): string { + return value.padEnd(value.length + ((4 - (value.length % 4)) % 4), "="); +} + +/** + * Produces the wire format used by hx: SHA-256 hex characters as the AES key, + * followed by AES-256-CFB with the IV prefixed to padded base64url ciphertext. + */ +function encryptBytesLikeHx( + plaintext: Uint8Array, + secretKey = decryptionKey.secretKey, + ivByte = 1, +): string { + const hash = createHash("sha256").update(secretKey).digest("hex"); + const key = Buffer.from(hash.slice(0, 32), "utf8"); + const iv = Buffer.alloc(16, ivByte); + const cipher = createCipheriv("aes-256-cfb", key, iv); + const encrypted = Buffer.concat([ + iv, + cipher.update(plaintext), + cipher.final(), + ]); + + return padBase64(encrypted.toString("base64url")); +} + +function encryptLikeHx( + plaintext: string, + secretKey = decryptionKey.secretKey, + ivByte = 1, +): string { + return encryptBytesLikeHx(Buffer.from(plaintext), secretKey, ivByte); +} + +function createService(overrides: EnvServiceOptions = {}): Env { + return new Env({ + apiKey, + organizationId, + projectId, + applicationId, + ...overrides, + }); +} + +function mockKeyResponse(key = decryptionKey): HttpResponse { + return response({ + key: { + secret_key_id: key.secretKeyId, + secret_key: key.secretKey, + }, + }); +} + +function mockEnvResponse(plaintext: string, ivByte = 1): HttpResponse { + return response({ + data: encryptLikeHx(plaintext, decryptionKey.secretKey, ivByte), + }); +} + +let originalEnvironment: NodeJS.ProcessEnv; + +beforeEach(() => { + originalEnvironment = { ...process.env }; + delete process.env.HYPHEN_API_KEY; + delete process.env.HYPHEN_ORGANIZATION_ID; + delete process.env.HYPHEN_PROJECT_ID; + delete process.env.HYPHEN_PROJECT_NAME; + delete process.env.HYPHEN_APPLICATION_ID; + delete process.env.HYPHEN_APP_ID; + delete process.env.HYPHEN_APP_NAME; + delete process.env.HYPHEN_APP_ENVIRONMENT; + for (const name of testVariableNames) { + delete process.env[name]; + } +}); + +afterEach(() => { + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + Object.assign(process.env, originalEnvironment); + vi.restoreAllMocks(); +}); + +describe("Env", () => { + test("uses the production ENV service endpoints", () => { + expect(defaultEnvHorizonUri).toBe("https://toggle.hyphen.cloud"); + expect(defaultEnvKeyStoreUri).toBe("https://vinz.hyphen.ai"); + }); + + test("decrypts a fixed ciphertext produced by the Go hx algorithm", async () => { + const service = createService({ + decryptionKey: { + secretKeyId: 1, + secretKey: "c2VjcmV0LWtleQ==", + }, + }); + const getSpy = vi.spyOn(service, "get").mockResolvedValue( + response({ + data: "AAECAwQFBgcICQoLDA0OD3bIZNW5zB1r27HjMfDRJ2Xghuc=", + }), + ); + + const variables = await service.load("default"); + + expect(variables).toEqual({ FOO: "bar", NESTED: "a=b" }); + expect(process.env.FOO).toBe("bar"); + expect(process.env.NESTED).toBe("a=b"); + expect(getSpy).toHaveBeenCalledOnce(); + }); + + test("fetches the key, merges default and named ENV, and applies named values last", async () => { + const service = createService(); + const getSpy = vi + .spyOn(service, "get") + .mockResolvedValueOnce(mockKeyResponse()) + .mockResolvedValueOnce( + mockEnvResponse( + 'DEFAULT_ONLY=base\nSHARED=default\nQUOTED="hello world"\n', + 2, + ), + ) + .mockResolvedValueOnce( + mockEnvResponse("SHARED=named\nNAMED_ONLY=value=with=equals\n", 3), + ); + + const variables = await service.load(productionEnvironment); + + expect(variables).toEqual({ + DEFAULT_ONLY: "base", + NAMED_ONLY: "value=with=equals", + QUOTED: "hello world", + SHARED: "named", + }); + expect(process.env.DEFAULT_ONLY).toBe("base"); + expect(process.env.NAMED_ONLY).toBe("value=with=equals"); + expect(process.env.QUOTED).toBe("hello world"); + expect(process.env.SHARED).toBe("named"); + + expect(getSpy).toHaveBeenNthCalledWith(1, keyStoreUrl, { + caching: false, + headers: authHeaders, + }); + expect(getSpy).toHaveBeenNthCalledWith(2, horizonUrl, { + caching: false, + headers: authHeaders, + params: { secretKeyId: decryptionKey.secretKeyId }, + }); + expect(getSpy).toHaveBeenNthCalledWith(3, horizonUrl, { + caching: false, + headers: authHeaders, + params: { + environmentId: productionEnvironment, + secretKeyId: decryptionKey.secretKeyId, + }, + }); + }); + + test("fetches only the named ENV when includeDefault is false", async () => { + const service = createService(); + const getSpy = vi + .spyOn(service, "get") + .mockResolvedValueOnce(mockKeyResponse()) + .mockResolvedValueOnce(mockEnvResponse("ONLY_NAMED=yes\n")); + + await expect( + service.load(productionEnvironment, { includeDefault: false }), + ).resolves.toEqual({ ONLY_NAMED: "yes" }); + + expect(getSpy).toHaveBeenCalledTimes(2); + expect(getSpy).toHaveBeenLastCalledWith(horizonUrl, { + caching: false, + headers: authHeaders, + params: { + environmentId: productionEnvironment, + secretKeyId: decryptionKey.secretKeyId, + }, + }); + }); + + test("loads the default ENV only once", async () => { + const service = createService(); + const getSpy = vi + .spyOn(service, "get") + .mockResolvedValueOnce(mockKeyResponse()) + .mockResolvedValueOnce(mockEnvResponse("DEFAULT_VALUE=yes\n")); + + await expect(service.load()).resolves.toEqual({ DEFAULT_VALUE: "yes" }); + + expect(getSpy).toHaveBeenCalledTimes(2); + expect(getSpy).toHaveBeenLastCalledWith(horizonUrl, { + caching: false, + headers: authHeaders, + params: { secretKeyId: decryptionKey.secretKeyId }, + }); + }); + + test("returns remote values but preserves existing process values when override is false", async () => { + process.env.SHARED = "existing"; + const service = createService({ decryptionKey }); + vi.spyOn(service, "get").mockResolvedValue( + mockEnvResponse("SHARED=remote\nNEW_VALUE=added\n"), + ); + + await expect(service.load("default", { override: false })).resolves.toEqual( + { NEW_VALUE: "added", SHARED: "remote" }, + ); + + expect(process.env.SHARED).toBe("existing"); + expect(process.env.NEW_VALUE).toBe("added"); + }); + + test("overwrites existing process values by default", async () => { + process.env.SHARED = "existing"; + const service = createService({ decryptionKey }); + vi.spyOn(service, "get").mockResolvedValue( + mockEnvResponse("SHARED=remote\n"), + ); + + await service.load(); + + expect(process.env.SHARED).toBe("remote"); + }); + + test("uses an explicit key without requiring a project or requesting the key store", async () => { + const service = new Env({ + apiKey, + organizationId, + applicationId, + decryptionKey, + }); + const getSpy = vi + .spyOn(service, "get") + .mockResolvedValue(mockEnvResponse("DIRECT_KEY=yes\n")); + + await expect( + service.load(productionEnvironment, { includeDefault: false }), + ).resolves.toEqual({ DIRECT_KEY: "yes" }); + + expect(getSpy).toHaveBeenCalledOnce(); + expect(getSpy).toHaveBeenCalledWith(horizonUrl, { + caching: false, + headers: authHeaders, + params: { + environmentId: productionEnvironment, + secretKeyId: decryptionKey.secretKeyId, + }, + }); + }); + + test("falls back to the documented environment variables", async () => { + process.env.HYPHEN_API_KEY = apiKey; + process.env.HYPHEN_ORGANIZATION_ID = organizationId; + process.env.HYPHEN_PROJECT_ID = projectId; + process.env.HYPHEN_APPLICATION_ID = applicationId; + const service = new Env(); + const getSpy = vi + .spyOn(service, "get") + .mockResolvedValueOnce(mockKeyResponse()) + .mockResolvedValueOnce(mockEnvResponse("FROM_ENV=yes\n")); + + await expect(service.load()).resolves.toEqual({ FROM_ENV: "yes" }); + + expect(service.apiKey).toBe(apiKey); + expect(getSpy).toHaveBeenNthCalledWith(1, keyStoreUrl, { + caching: false, + headers: authHeaders, + }); + expect(getSpy).toHaveBeenNthCalledWith(2, horizonUrl, { + caching: false, + headers: authHeaders, + params: { secretKeyId: decryptionKey.secretKeyId }, + }); + }); + + test("supports HYPHEN_APP_ID as a legacy application fallback", async () => { + process.env.HYPHEN_API_KEY = apiKey; + process.env.HYPHEN_ORGANIZATION_ID = organizationId; + process.env.HYPHEN_PROJECT_ID = projectId; + process.env.HYPHEN_APP_ID = applicationId; + const service = new Env({ decryptionKey }); + const getSpy = vi + .spyOn(service, "get") + .mockResolvedValue(mockEnvResponse("LEGACY_APP_ID=yes\n")); + + await service.load(); + + expect(getSpy).toHaveBeenCalledWith(horizonUrl, { + caching: false, + headers: authHeaders, + params: { secretKeyId: decryptionKey.secretKeyId }, + }); + }); + + test("uses Hyphen Deploy names and environment as final fallbacks", async () => { + const deployProjectName = "project-from-deploy"; + const deployApplicationName = "app-from-deploy"; + const deployEnvironment = "staging"; + process.env.HYPHEN_API_KEY = apiKey; + process.env.HYPHEN_ORGANIZATION_ID = organizationId; + process.env.HYPHEN_PROJECT_NAME = deployProjectName; + process.env.HYPHEN_APP_NAME = deployApplicationName; + process.env.HYPHEN_APP_ENVIRONMENT = deployEnvironment; + const service = new Env(); + const getSpy = vi + .spyOn(service, "get") + .mockResolvedValueOnce(mockKeyResponse()) + .mockResolvedValueOnce(mockEnvResponse("DEFAULT_VALUE=yes\n")) + .mockResolvedValueOnce(mockEnvResponse("DEPLOY_FALLBACK=yes\n")); + + await expect(service.load()).resolves.toEqual({ + DEFAULT_VALUE: "yes", + DEPLOY_FALLBACK: "yes", + }); + + expect(getSpy).toHaveBeenNthCalledWith( + 1, + `${defaultEnvKeyStoreUri}/${organizationId}/${deployProjectName}/key`, + { + caching: false, + headers: authHeaders, + }, + ); + expect(getSpy).toHaveBeenNthCalledWith( + 2, + `${defaultEnvHorizonUri}/api/organizations/${organizationId}/apps/${deployApplicationName}/dot-env/`, + { + caching: false, + headers: authHeaders, + params: { secretKeyId: decryptionKey.secretKeyId }, + }, + ); + expect(getSpy).toHaveBeenNthCalledWith( + 3, + `${defaultEnvHorizonUri}/api/organizations/${organizationId}/apps/${deployApplicationName}/dot-env/`, + { + caching: false, + headers: authHeaders, + params: { + environmentId: deployEnvironment, + secretKeyId: decryptionKey.secretKeyId, + }, + }, + ); + }); + + test("prefers ID variables and an explicit environment over deploy fallbacks", async () => { + process.env.HYPHEN_API_KEY = apiKey; + process.env.HYPHEN_ORGANIZATION_ID = organizationId; + process.env.HYPHEN_PROJECT_ID = projectId; + process.env.HYPHEN_PROJECT_NAME = "ignored-project-name"; + process.env.HYPHEN_APPLICATION_ID = applicationId; + process.env.HYPHEN_APP_ID = "ignored-legacy-app-id"; + process.env.HYPHEN_APP_NAME = "ignored-app-name"; + process.env.HYPHEN_APP_ENVIRONMENT = "ignored-environment"; + const service = new Env(); + const getSpy = vi + .spyOn(service, "get") + .mockResolvedValueOnce(mockKeyResponse()) + .mockResolvedValueOnce(mockEnvResponse("ID_PRECEDENCE=yes\n")); + + await expect( + service.load("PRODUCTION", { includeDefault: false }), + ).resolves.toEqual({ ID_PRECEDENCE: "yes" }); + + expect(getSpy).toHaveBeenNthCalledWith(1, keyStoreUrl, { + caching: false, + headers: authHeaders, + }); + expect(getSpy).toHaveBeenNthCalledWith(2, horizonUrl, { + caching: false, + headers: authHeaders, + params: { + environmentId: productionEnvironment, + secretKeyId: decryptionKey.secretKeyId, + }, + }); + }); + + test("prefers explicit options over environment variable fallbacks", async () => { + process.env.HYPHEN_API_KEY = "environment-api-key"; + process.env.HYPHEN_ORGANIZATION_ID = "org_environment"; + process.env.HYPHEN_PROJECT_ID = "proj_environment"; + process.env.HYPHEN_APPLICATION_ID = "app_environment"; + const service = createService({ decryptionKey }); + const getSpy = vi + .spyOn(service, "get") + .mockResolvedValue(mockEnvResponse("EXPLICIT=yes\n")); + + await service.load(); + + expect(getSpy).toHaveBeenCalledWith(horizonUrl, { + caching: false, + headers: authHeaders, + params: { secretKeyId: decryptionKey.secretKeyId }, + }); + }); + + test("uses custom service URIs without duplicate trailing slashes", async () => { + const customKeyStoreUri = "https://keys.example.test///"; + const customHorizonUri = "https://env.example.test///"; + const service = createService({ + keyStoreUri: customKeyStoreUri, + horizonUri: customHorizonUri, + }); + const getSpy = vi + .spyOn(service, "get") + .mockResolvedValueOnce(mockKeyResponse()) + .mockResolvedValueOnce(mockEnvResponse("CUSTOM_URI=yes\n")); + + await service.load(); + + expect(getSpy).toHaveBeenNthCalledWith( + 1, + `https://keys.example.test/${organizationId}/${projectId}/key`, + expect.any(Object), + ); + expect(getSpy).toHaveBeenNthCalledWith( + 2, + `https://env.example.test/api/organizations/${organizationId}/apps/${applicationId}/dot-env/`, + expect.any(Object), + ); + }); + + test("allows the API key to be supplied after construction", async () => { + const service = new Env({ + organizationId, + applicationId, + decryptionKey, + }); + service.apiKey = apiKey; + vi.spyOn(service, "get").mockResolvedValue( + mockEnvResponse("SETTER_KEY=yes\n"), + ); + + expect(service.apiKey).toBe(apiKey); + await expect(service.load()).resolves.toEqual({ SETTER_KEY: "yes" }); + }); + + test("rejects a public API key on load before making a request", async () => { + const constructorService = createService({ apiKey: "public_test-key" }); + const constructorGetSpy = vi.spyOn(constructorService, "get"); + await expect(constructorService.load()).rejects.toThrow( + "A public API key cannot load Hyphen ENV data.", + ); + expect(constructorGetSpy).not.toHaveBeenCalled(); + + const setterService = createService(); + setterService.apiKey = "public_test-key"; + const setterGetSpy = vi.spyOn(setterService, "get"); + await expect(setterService.load()).rejects.toThrow( + "A public API key cannot load Hyphen ENV data.", + ); + expect(setterGetSpy).not.toHaveBeenCalled(); + }); + + test("normalizes uppercase and empty environment names", async () => { + const service = createService({ decryptionKey }); + const getSpy = vi + .spyOn(service, "get") + .mockResolvedValueOnce(mockEnvResponse("ONLY_NAMED=yes\n")) + .mockResolvedValueOnce(mockEnvResponse("DEFAULT_VALUE=yes\n")); + + await service.load("PRODUCTION", { includeDefault: false }); + await service.load(""); + + expect(getSpy).toHaveBeenNthCalledWith(1, horizonUrl, { + caching: false, + headers: authHeaders, + params: { + environmentId: "production", + secretKeyId: decryptionKey.secretKeyId, + }, + }); + expect(getSpy).toHaveBeenNthCalledWith(2, horizonUrl, { + caching: false, + headers: authHeaders, + params: { secretKeyId: decryptionKey.secretKeyId }, + }); + }); + + test("rejects an invalid environment name before making a request", async () => { + const service = createService({ decryptionKey }); + const getSpy = vi.spyOn(service, "get"); + + await expect(service.load("invalid environment!")).rejects.toThrow( + "Environment must contain only lowercase letters, numbers, hyphens, and underscores.", + ); + expect(getSpy).not.toHaveBeenCalled(); + }); + + describe("validation", () => { + test.each([ + { + label: "API key", + options: { organizationId, projectId, applicationId }, + error: "An API key is required to load Hyphen ENV data.", + }, + { + label: "organization ID", + options: { apiKey, projectId, applicationId }, + error: "An organization ID is required to load Hyphen ENV data.", + }, + { + label: "application ID", + options: { apiKey, organizationId, projectId }, + error: "An application ID is required to load Hyphen ENV data.", + }, + { + label: "project ID", + options: { apiKey, organizationId, applicationId }, + error: + "A project ID or decryption key is required to load Hyphen ENV data.", + }, + ])("rejects a missing $label before making a request", async ({ + options, + error, + }) => { + const service = new Env(options); + const getSpy = vi.spyOn(service, "get"); + + await expect(service.load()).rejects.toThrow(error); + expect(getSpy).not.toHaveBeenCalled(); + }); + + test.each([ + { secretKeyId: 0, secretKey: decryptionKey.secretKey }, + { secretKeyId: -1, secretKey: decryptionKey.secretKey }, + { secretKeyId: Number.NaN, secretKey: decryptionKey.secretKey }, + { secretKeyId: decryptionKey.secretKeyId, secretKey: "" }, + ])("rejects an invalid explicit decryption key %#", async (invalidKey) => { + const service = createService({ decryptionKey: invalidKey }); + const getSpy = vi.spyOn(service, "get"); + + await expect(service.load()).rejects.toThrow( + "The Hyphen ENV key is invalid.", + ); + expect(getSpy).not.toHaveBeenCalled(); + }); + }); + + describe("failures are atomic", () => { + test("rejects a failed key-store response without mutating process.env", async () => { + process.env.UNCHANGED = "before"; + const service = createService(); + vi.spyOn(service, "get").mockResolvedValue( + response({ message: "unauthorized" }, 401, "Unauthorized"), + ); + + await expect(service.load()).rejects.toThrow( + "Failed to fetch the Hyphen ENV key: HTTP 401", + ); + + expect(process.env.UNCHANGED).toBe("before"); + expect(process.env.DEFAULT_ONLY).toBeUndefined(); + }); + + test.each([ + null, + {}, + { key: null }, + { key: {} }, + { key: { secret_key_id: "not-a-number", secret_key: "secret" } }, + { key: { secret_key_id: 1, secret_key: "" } }, + { key: { secret_key_id: 1, secret_key: 123 } }, + ])("rejects a malformed key-store payload %#", async (payload) => { + const service = createService(); + vi.spyOn(service, "get").mockResolvedValue(response(payload)); + + await expect(service.load()).rejects.toThrow( + "Failed to fetch the Hyphen ENV key:", + ); + expect(process.env.DEFAULT_ONLY).toBeUndefined(); + }); + + test("rejects a failed Horizon response without mutating process.env", async () => { + const service = createService({ decryptionKey }); + vi.spyOn(service, "get").mockResolvedValue( + response({ message: "unavailable" }, 503, "Service Unavailable"), + ); + + await expect(service.load()).rejects.toThrow( + 'Failed to fetch Hyphen ENV "default": HTTP 503', + ); + expect(process.env.DEFAULT_ONLY).toBeUndefined(); + }); + + test.each([ + null, + {}, + { data: null }, + { data: 123 }, + ])("rejects a malformed Horizon payload %#", async (payload) => { + const service = createService({ decryptionKey }); + vi.spyOn(service, "get").mockResolvedValue(response(payload)); + + await expect(service.load()).rejects.toThrow( + 'Failed to fetch Hyphen ENV "default":', + ); + expect(process.env.DEFAULT_ONLY).toBeUndefined(); + }); + + test.each([ + "not-base64!", + "A", + "AA=", + "AB", + "AAECAwQFBgcICQ==", + ])("rejects malformed or truncated ciphertext %s", async (data) => { + const service = createService({ decryptionKey }); + vi.spyOn(service, "get").mockResolvedValue(response({ data })); + + await expect(service.load()).rejects.toThrow( + 'Failed to decrypt or parse Hyphen ENV "default".', + ); + expect(process.env.DEFAULT_ONLY).toBeUndefined(); + }); + + test("rejects decrypted bytes that are not valid UTF-8", async () => { + const service = createService({ decryptionKey }); + vi.spyOn(service, "get").mockResolvedValue( + response({ data: encryptBytesLikeHx(Uint8Array.of(0xff)) }), + ); + + await expect(service.load()).rejects.toThrow( + 'Failed to decrypt or parse Hyphen ENV "default".', + ); + expect(process.env.DEFAULT_ONLY).toBeUndefined(); + }); + + test.each([ + "SAFE_VALUE=before\nBAD\0NAME=value\n", + "SAFE_VALUE=before\nBAD_VALUE=value\0suffix\n", + ])("rejects process-incompatible variables atomically", async (contents) => { + const service = createService({ decryptionKey }); + vi.spyOn(service, "get").mockResolvedValue(mockEnvResponse(contents)); + + await expect(service.load()).rejects.toThrow( + "Hyphen ENV contains a variable that cannot be added to process.env.", + ); + expect(process.env.SAFE_VALUE).toBeUndefined(); + }); + + test("accepts empty and comment-only ENV documents", async () => { + const service = createService({ decryptionKey }); + vi.spyOn(service, "get").mockResolvedValue( + mockEnvResponse("# This environment intentionally has no variables.\n"), + ); + + await expect(service.load()).resolves.toEqual({}); + }); + + test("rejects ciphertext when required base64url padding is missing", async () => { + const service = createService({ decryptionKey }); + const data = encryptLikeHx("UNPADDED=yes\n").replace(/=+$/, ""); + vi.spyOn(service, "get").mockResolvedValue(response({ data })); + + await expect(service.load()).rejects.toThrow( + 'Failed to decrypt or parse Hyphen ENV "default".', + ); + }); + + test("does not apply a valid default ENV when the named ENV fails", async () => { + process.env.UNCHANGED = "before"; + const service = createService({ decryptionKey }); + vi.spyOn(service, "get") + .mockResolvedValueOnce(mockEnvResponse("DEFAULT_ONLY=base\n")) + .mockResolvedValueOnce( + response({ message: "not found" }, 404, "Not Found"), + ); + + await expect(service.load(productionEnvironment)).rejects.toThrow( + 'Failed to fetch Hyphen ENV "production": HTTP 404', + ); + + expect(process.env.DEFAULT_ONLY).toBeUndefined(); + expect(process.env.UNCHANGED).toBe("before"); + }); + + test("sanitizes request failures without mutating process.env", async () => { + const service = createService({ decryptionKey }); + const sensitiveDetail = `network down: ${decryptionKey.secretKey}`; + vi.spyOn(service, "get").mockRejectedValue(new Error(sensitiveDetail)); + + const error = await service.load().catch((caught) => caught as Error); + expect(error.message).toBe( + 'Failed to fetch Hyphen ENV "default": Request failed.', + ); + expect(error.message).not.toContain(decryptionKey.secretKey); + expect(process.env.DEFAULT_ONLY).toBeUndefined(); + }); + + test("uses a stable message for a non-Error request rejection", async () => { + const service = createService({ decryptionKey }); + vi.spyOn(service, "get").mockRejectedValue("network down"); + + await expect(service.load()).rejects.toThrow( + 'Failed to fetch Hyphen ENV "default": Request failed.', + ); + }); + }); +}); diff --git a/test/hyphen.test.ts b/test/hyphen.test.ts index e4a2cdb..574dd94 100644 --- a/test/hyphen.test.ts +++ b/test/hyphen.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test, vi } from "vitest"; +import { Env } from "../src/env-service.js"; import { Hyphen } from "../src/hyphen.js"; import { Link } from "../src/link.js"; import { NetInfo } from "../src/net-info.js"; @@ -34,6 +35,24 @@ describe("Hyphen", () => { expect(hyphen.link).toBeInstanceOf(Link); }); + test("should have an env property", () => { + const hyphen = new Hyphen(); + expect(hyphen.env).toBeDefined(); + expect(hyphen.env).toBeInstanceOf(Env); + }); + + test("should accept nested env service options", () => { + const hyphen = new Hyphen({ + env: { + applicationId: "app-id", + organizationId: "org-id", + projectId: "project-id", + }, + }); + + expect(hyphen.env).toBeInstanceOf(Env); + }); + test("should allow setting and getting publicApiKey", () => { const publicApiKey = "public_api_key"; const hyphen = new Hyphen({ publicApiKey }); @@ -48,14 +67,38 @@ describe("Hyphen", () => { const apiKey = "api_key"; const hyphen = new Hyphen({ apiKey }); expect(hyphen.apiKey).toBe(apiKey); + expect(hyphen.env.apiKey).toBe(apiKey); const newApiKey = "new_api_key"; hyphen.apiKey = newApiKey; expect(hyphen.apiKey).toBe(newApiKey); + expect(hyphen.env.apiKey).toBe(newApiKey); }); }); describe("Hyphen Emitters", () => { + test("should emit env error events", () => { + const hyphen = new Hyphen(); + hyphen.on("error", () => {}); + const errorSpy = vi.spyOn(hyphen, "emit"); + hyphen.env.error("Test error"); + expect(errorSpy).toHaveBeenCalledWith("error", "Test error"); + }); + + test("should emit env warn events", () => { + const hyphen = new Hyphen(); + const warnSpy = vi.spyOn(hyphen, "emit"); + hyphen.env.warn("Test warn"); + expect(warnSpy).toHaveBeenCalledWith("warn", "Test warn"); + }); + + test("should emit env info events", () => { + const hyphen = new Hyphen(); + const infoSpy = vi.spyOn(hyphen, "emit"); + hyphen.env.info("Test info"); + expect(infoSpy).toHaveBeenCalledWith("info", "Test info"); + }); + test("should emit link error events", () => { const hyphen = new Hyphen(); hyphen.on("error", () => {}); diff --git a/test/index.test.ts b/test/index.test.ts index eb11b57..a33b329 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { Toggle } from "../src/index.js"; +import { Env, Toggle } from "../src/index.js"; describe("Hyphen sdk", () => { test("should create an instance of Toggle", () => { @@ -10,4 +10,9 @@ describe("Hyphen sdk", () => { }); expect(toggle).toBeInstanceOf(Toggle); }); + + test("should export the ENV service", () => { + const env = new Env({ apiKey: "test-api-key" }); + expect(env).toBeInstanceOf(Env); + }); });