diff --git a/.bumpy/varlock-config-var-aliases.md b/.bumpy/varlock-config-var-aliases.md new file mode 100644 index 000000000..7d0c06fb0 --- /dev/null +++ b/.bumpy/varlock-config-var-aliases.md @@ -0,0 +1,6 @@ +--- +varlock: patch +"@varlock/nextjs-integration": patch +--- + +Accept `VARLOCK_ENV_KEY` (and the other `_VARLOCK_*` config vars without the leading underscore) for platforms like AWS Lambda that reject env var names starting with `_` diff --git a/framework-tests/frameworks/nextjs/nextjs-shared.ts b/framework-tests/frameworks/nextjs/nextjs-shared.ts index 2c6f9fa97..59662ede9 100644 --- a/framework-tests/frameworks/nextjs/nextjs-shared.ts +++ b/framework-tests/frameworks/nextjs/nextjs-shared.ts @@ -696,7 +696,8 @@ export function defineNextjsTests(versionOrCanary: number | 'canary', testDir: s [ 'env -i PATH=/usr/bin:/bin NODE_ENV=production', `HOSTNAME=127.0.0.1 PORT=${standalonePort}`, - '_VARLOCK_ENV_KEY=$_VARLOCK_ENV_KEY', + // the underscore-free alias, as on AWS Lambda (which rejects names starting with `_`) + 'VARLOCK_ENV_KEY=$_VARLOCK_ENV_KEY', 'RUNTIME_BOOT_VAR=runtime-boot-value INSTANCE_ID=i-123', '"$NODE_BIN" server.js', ].join(' '), diff --git a/packages/integrations/nextjs/src/next-env-compat.ts b/packages/integrations/nextjs/src/next-env-compat.ts index 7fac4c634..a37bfe002 100644 --- a/packages/integrations/nextjs/src/next-env-compat.ts +++ b/packages/integrations/nextjs/src/next-env-compat.ts @@ -536,6 +536,7 @@ function bootFromFrozenBuildEnv(frozenEnv: string): LoadedEnvConfig { '', '🚨 [varlock] this build\'s env is frozen and encrypted, but _VARLOCK_ENV_KEY is not set 🚨', 'Set _VARLOCK_ENV_KEY at runtime to the key that was set when building.', + '(On platforms that reject env var names starting with `_`, like AWS Lambda, set VARLOCK_ENV_KEY instead.)', '', ].join('\n')); process.exit(1); diff --git a/packages/varlock-website/src/content/docs/guides/encrypted-deployments.mdx b/packages/varlock-website/src/content/docs/guides/encrypted-deployments.mdx index ba44d116e..fcbfaa3d9 100644 --- a/packages/varlock-website/src/content/docs/guides/encrypted-deployments.mdx +++ b/packages/varlock-website/src/content/docs/guides/encrypted-deployments.mdx @@ -57,6 +57,8 @@ No setup needed. `varlock-wrangler deploy` **auto-generates a key** and uploads You must set `_VARLOCK_ENV_KEY` as an environment variable on your platform. The key must be available at both **build time** (for encryption) and **runtime** (for decryption). It must be a real environment variable in both places: it's a reserved `_VARLOCK_*` name that varlock never populates from `.env` files, and it can't be baked in via vite's `define` (the value has to come from the actual process environment at runtime, not a string substituted at build time). +Some platforms reject environment variable names that start with `_` (AWS Lambda requires a leading letter). On those, set the key as `VARLOCK_ENV_KEY` instead. varlock reads it as `_VARLOCK_ENV_KEY` when that is not set. The same works for the other [`_VARLOCK_*` configuration variables](/reference/reserved-variables/#configuration-variables-_varlock_). + 1. **Generate and set the key** diff --git a/packages/varlock-website/src/content/docs/guides/frozen-env.mdx b/packages/varlock-website/src/content/docs/guides/frozen-env.mdx index e233a04c8..fd4559154 100644 --- a/packages/varlock-website/src/content/docs/guides/frozen-env.mdx +++ b/packages/varlock-website/src/content/docs/guides/frozen-env.mdx @@ -226,6 +226,10 @@ services: Your app boots exactly as it does with a file: `import 'varlock/auto-load'` and read `ENV`. +:::caution[Not for AWS Lambda] +This route does not work on AWS Lambda. Lambda rejects env var names that start with `_`, so `__VARLOCK_ENV` cannot be set, and it caps all of a function's env vars at 4 KB combined, which an encrypted payload usually exceeds. Ship a frozen file in the deploy package instead (or [freeze into the build output](#freezing-in-build-output)), and set `VARLOCK_USE_FROZEN_ENV=1` and `VARLOCK_ENV_KEY`. The `_VARLOCK_*` configuration variables can all be set without the leading underscore; see [Reserved variables](/reference/reserved-variables/#configuration-variables-_varlock_). +::: + The summary goes to stderr in this mode, so stdout carries the payload and nothing else. Everything else is identical to writing a file: the same resolution, the same encryption, the same refusal to emit a payload built from a failed resolution. ### What you give up diff --git a/packages/varlock-website/src/content/docs/integrations/nextjs.mdx b/packages/varlock-website/src/content/docs/integrations/nextjs.mdx index 97fc0e888..32c0d953c 100644 --- a/packages/varlock-website/src/content/docs/integrations/nextjs.mdx +++ b/packages/varlock-website/src/content/docs/integrations/nextjs.mdx @@ -366,7 +366,7 @@ The decision is made at build time and never at boot, so a value never has two p How a frozen build behaves: -- **It must be encrypted.** Generate a key with `varlock generate-key` and set `_VARLOCK_ENV_KEY` both in the build environment and at runtime. See the [encrypted deployments guide](/guides/encrypted-deployments/). A build without the key fails. Set `_VARLOCK_ALLOW_PLAINTEXT_FROZEN_ENV=1` only if you accept every resolved secret sitting unencrypted in your build output. +- **It must be encrypted.** Generate a key with `varlock generate-key` and set `_VARLOCK_ENV_KEY` both in the build environment and at runtime. On platforms that reject env var names starting with `_` (such as AWS Lambda), set it as `VARLOCK_ENV_KEY` instead. See the [encrypted deployments guide](/guides/encrypted-deployments/). A build without the key fails. Set `_VARLOCK_ALLOW_PLAINTEXT_FROZEN_ENV=1` only if you accept every resolved secret sitting unencrypted in your build output. - **Frozen values win over the environment at boot.** If the environment sets a different value for a frozen key, varlock uses the frozen value and prints a warning. A `__VARLOCK_ENV` blob in the environment (for example from `varlock run`) is ignored, also with a warning. - **Values that must vary per instance** go in the schema as [`@dynamic=boot`](/guides/frozen-env/#keys-supplied-at-boot). The frozen value becomes a default, and a value set in the environment at boot replaces it after being checked against the item's type. - Rotating a secret takes effect on your next build, not on the next restart. diff --git a/packages/varlock-website/src/content/docs/reference/reserved-variables.mdx b/packages/varlock-website/src/content/docs/reference/reserved-variables.mdx index 34167959f..287b6b4a0 100644 --- a/packages/varlock-website/src/content/docs/reference/reserved-variables.mdx +++ b/packages/varlock-website/src/content/docs/reference/reserved-variables.mdx @@ -19,6 +19,10 @@ Config items whose key starts with `_VARLOCK_` are reserved for varlock. They ar Set these in your shell, CI, or deploy environment to configure varlock's behavior. +:::note[Platforms that reject a leading underscore] +Some platforms don't allow environment variable names that start with `_`. AWS Lambda, for example, requires names to start with a letter. On those, set any of these variables without the leading underscore instead: `VARLOCK_ENV_KEY` for `_VARLOCK_ENV_KEY`, `VARLOCK_USE_FROZEN_ENV` for `_VARLOCK_USE_FROZEN_ENV`, and so on. If both forms are set, the `_VARLOCK_*` one wins. This applies only to the variables listed in this section, not to `__VARLOCK_*` internals. These alias names are reserved the same way as the `_VARLOCK_` prefix, so don't define items with them in your schema. +::: + ### `_VARLOCK_ENV_KEY` Encryption key used to decrypt the injected env blob at runtime. Typically set in deploy environments. See [Encrypted deployments](/guides/encrypted-deployments/). diff --git a/packages/varlock/src/auto-load.ts b/packages/varlock/src/auto-load.ts index 76ea3e34d..234811b2d 100644 --- a/packages/varlock/src/auto-load.ts +++ b/packages/varlock/src/auto-load.ts @@ -6,6 +6,7 @@ import { createDebug } from './lib/debug'; import { isVarlockCliChild } from './lib/cli-child-marker'; import { initVarlockEnv, getPreInjectionProcessEnv } from './runtime/env'; +import { applyVarlockConfigEnvAliases } from './runtime/lib/config-env-aliases'; import { patchGlobalConsole } from './runtime/patch-console'; import { patchProcessStreams } from './runtime/patch-process-streams'; import { patchGlobalServerResponse } from './runtime/patch-server-response'; @@ -39,6 +40,8 @@ function getPartialResolvedEnv(err: unknown): Record { } function autoLoad() { + // `VARLOCK_ENV_KEY` etc. for platforms that reject a leading underscore (see config-env-aliases) + applyVarlockConfigEnvAliases(); // `@internal` keys stripped from a reused blob - their ambient env values are scrubbed // after init (see the bottom of this module) let strippedInternalKeys: Array = []; diff --git a/packages/varlock/src/cli/helpers/clear-cli-child-marker.ts b/packages/varlock/src/cli/helpers/clear-cli-child-marker.ts index 2fab697ad..abe6f25f4 100644 --- a/packages/varlock/src/cli/helpers/clear-cli-child-marker.ts +++ b/packages/varlock/src/cli/helpers/clear-cli-child-marker.ts @@ -1,4 +1,5 @@ import { CLI_CHILD_MARKER } from '../../lib/cli-child-marker'; +import { applyVarlockConfigEnvAliases } from '../../runtime/lib/config-env-aliases'; /** * Side-effect module: must be the FIRST import of the CLI entrypoint. @@ -20,3 +21,6 @@ export function clearCliChildMarker() { } clearCliChildMarker(); +// also accept `VARLOCK_ENV_KEY` etc. for `_VARLOCK_*` config vars (see config-env-aliases), +// before any command reads them or snapshots process.env for a child +applyVarlockConfigEnvAliases(); diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index e30e23f2c..c6a89a42d 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -639,10 +639,12 @@ export class EnvGraph { for (const itemKey of Object.keys(source.configItemDefs)) { if (isVarlockReservedKey(itemKey)) { source._errors.push(new SchemaError( - `"${itemKey}" uses varlock's reserved _VARLOCK_ prefix`, + itemKey.startsWith('_') + ? `"${itemKey}" uses varlock's reserved _VARLOCK_ prefix` + : `"${itemKey}" is reserved: it is an alias for varlock's _${itemKey} setting`, { isWarning: true, - tip: 'Keys starting with _VARLOCK_ are reserved for configuring varlock itself and are excluded from the injected env and generated types. Rename this item unless that exclusion is intended.', + tip: 'Keys starting with _VARLOCK_ (and their underscore-free aliases like VARLOCK_ENV_KEY) are reserved for configuring varlock itself and are excluded from the injected env and generated types. Rename this item unless that exclusion is intended.', }, )); } diff --git a/packages/varlock/src/env-graph/lib/reserved-vars.ts b/packages/varlock/src/env-graph/lib/reserved-vars.ts index 3707d5b0c..2d02f9fe4 100644 --- a/packages/varlock/src/env-graph/lib/reserved-vars.ts +++ b/packages/varlock/src/env-graph/lib/reserved-vars.ts @@ -1,3 +1,5 @@ +import { VARLOCK_CONFIG_ENV_VAR_ALIAS_NAMES } from '../../runtime/lib/config-env-aliases'; + /** * Single source of truth for varlock's reserved environment variables. * @@ -29,6 +31,10 @@ export const VARLOCK_CONFIG_ENV_VARS: Array = [ name: '_VARLOCK_ENV_KEY', description: 'Encryption key used to decrypt the injected env blob at runtime. Typically set in deploy environments.', }, + { + name: '_VARLOCK_ALLOW_PLAINTEXT_FROZEN_ENV', + description: 'Set to `1` in the build environment to let a Next.js build freeze the env into its output without encrypting it. Without it, a frozen build fails when `_VARLOCK_ENV_KEY` is not set.', + }, { name: '_VARLOCK_CACHE_KEY', description: 'Encryption key for the on-disk resolved-value cache. When set (e.g. as a CI secret), it enables the disk cache in environments without OS keychain access.', @@ -98,10 +104,13 @@ export const VARLOCK_INTERNAL_ENV_VARS: Array = [ ]; /** - * Check if a config item key is reserved for varlock infrastructure. These keys are never - * exposed via the ENV proxy, serialized into the injected env blob, included in generated - * types, or recorded as override provenance — even if a user defines one in their schema. + * Check if a config item key is reserved for varlock infrastructure: any `_VARLOCK_*` key, plus + * the underscore-free aliases of the config vars (`VARLOCK_ENV_KEY`, ...; see config-env-aliases), + * which configure varlock the same way. Computed builtins like `VARLOCK_ENV` are not reserved. + * These keys are never exposed via the ENV proxy, serialized into the injected env blob, + * included in generated types, or recorded as override provenance, even if a user defines one + * in their schema. */ export function isVarlockReservedKey(key: string): boolean { - return key.startsWith(VARLOCK_RESERVED_KEY_PREFIX); + return key.startsWith(VARLOCK_RESERVED_KEY_PREFIX) || VARLOCK_CONFIG_ENV_VAR_ALIAS_NAMES.has(key); } diff --git a/packages/varlock/src/env-graph/test/varlock-reserved-keys.test.ts b/packages/varlock/src/env-graph/test/varlock-reserved-keys.test.ts index 0270ab31e..fa3cc598f 100644 --- a/packages/varlock/src/env-graph/test/varlock-reserved-keys.test.ts +++ b/packages/varlock/src/env-graph/test/varlock-reserved-keys.test.ts @@ -16,6 +16,18 @@ describe('isVarlockReservedKey', () => { expect(isVarlockReservedKey(`${VARLOCK_RESERVED_KEY_PREFIX}ANYTHING_NEW`)).toBe(true); }); + it('matches the underscore-free aliases of config vars', () => { + expect(isVarlockReservedKey('VARLOCK_ENV_KEY')).toBe(true); + expect(isVarlockReservedKey('VARLOCK_CACHE_KEY')).toBe(true); + expect(isVarlockReservedKey('VARLOCK_USE_FROZEN_ENV')).toBe(true); + }); + + it('does not reserve computed builtins', () => { + for (const name of ['VARLOCK_ENV', 'VARLOCK_IS_CI', 'VARLOCK_BRANCH', 'VARLOCK_PLATFORM']) { + expect(isVarlockReservedKey(name), name).toBe(false); + } + }); + it('does not match normal config keys', () => { expect(isVarlockReservedKey('FOO')).toBe(false); expect(isVarlockReservedKey('VARLOCK_ENV')).toBe(false); // no leading underscore @@ -69,6 +81,25 @@ describe('serialized graph excludes reserved _VARLOCK_ keys', () => { expect(Object.keys(config)).not.toContain('_VARLOCK_REDACT_STDOUT'); }); + it('drops schema-defined aliases too, even with an ambient key value', async () => { + const g = await buildGraph( + outdent` + # @defaultSensitive=false @defaultRequired=false + # --- + FOO=bar + VARLOCK_ENV_KEY= + VARLOCK_CACHE_KEY= + _VARLOCK_ENV_KEY= + `, + { VARLOCK_ENV_KEY: 'ambient-key', VARLOCK_CACHE_KEY: 'ambient-cache-key' }, + ); + const serialized = g.getSerializedGraph(); + expect(Object.keys(serialized.config)).toEqual(['FOO']); + expect(JSON.stringify(serialized)).not.toContain('ambient-key'); + expect(JSON.stringify(serialized)).not.toContain('ambient-cache-key'); + expect(serialized.overrideKeys ?? []).not.toContain('VARLOCK_ENV_KEY'); + }); + it('only records schema config keys in override provenance (not arbitrary env vars or reserved keys)', async () => { const g = await buildGraph( outdent` diff --git a/packages/varlock/src/runtime/env.ts b/packages/varlock/src/runtime/env.ts index 9f98f87ff..5c7f35a55 100644 --- a/packages/varlock/src/runtime/env.ts +++ b/packages/varlock/src/runtime/env.ts @@ -5,6 +5,9 @@ import { isBrowser } from '../lib/detect-runtime'; import { debug } from './lib/debug'; import { isStreamRedactionPatched } from './lib/stream-patch-key'; import { envValueMatchesBlobItem } from '../lib/injected-env-provenance'; +import { applyVarlockConfigEnvAliases } from './lib/config-env-aliases'; + +export { applyVarlockConfigEnvAliases }; // TODO: would like to move all of the redaction utils out of this file // but its complicated since it is imported by code that may be run in the backend and frontend @@ -635,6 +638,10 @@ type EnvState = { const processExists = !!globalThis.process; +// `VARLOCK_ENV_KEY` etc. stand in for `_VARLOCK_*` config vars on platforms that reject a +// leading underscore (AWS Lambda). Applied before the snapshot below so it sees them too. +applyVarlockConfigEnvAliases(); + const ENV_STATE_KEY = '__varlockEnvState'; function getEnvState(): EnvState { if (!(globalThis as any)[ENV_STATE_KEY]) { diff --git a/packages/varlock/src/runtime/init.ts b/packages/varlock/src/runtime/init.ts index b392747cb..e329a260b 100644 --- a/packages/varlock/src/runtime/init.ts +++ b/packages/varlock/src/runtime/init.ts @@ -21,6 +21,7 @@ */ import { initVarlockEnv } from './env'; +import { applyVarlockConfigEnvAliases, getVarlockConfigEnvVar } from './lib/config-env-aliases'; import { patchGlobalConsole } from './patch-console'; import { patchProcessStreams } from './patch-process-streams'; import { patchGlobalServerResponse } from './patch-server-response'; @@ -139,10 +140,12 @@ function storeDecryptedEnv(decrypted: string, source: 'global' | 'process') { * as `globalThis.__varlockEnvReady`, and Next.js edge handler invocation is gated on it. */ export function initVarlockRuntime() { + applyVarlockConfigEnvAliases(); const encrypted = getEncryptedEnvBlob(); if (encrypted) { - const key = (globalThis as any).process?.env?._VARLOCK_ENV_KEY; - if (!key) throw new Error('[varlock] env blob is encrypted but _VARLOCK_ENV_KEY is not set'); + // read directly (not just via the alias copy above), which is a no-op on a read-only process.env + const key = getVarlockConfigEnvVar('_VARLOCK_ENV_KEY'); + if (!key) throw new Error('[varlock] env blob is encrypted but _VARLOCK_ENV_KEY (or VARLOCK_ENV_KEY) is not set'); try { storeDecryptedEnv(decryptEnvBlobSync(encrypted.blob, key), encrypted.source); initNow(); diff --git a/packages/varlock/src/runtime/lib/config-env-aliases.ts b/packages/varlock/src/runtime/lib/config-env-aliases.ts new file mode 100644 index 000000000..40cc5d46c --- /dev/null +++ b/packages/varlock/src/runtime/lib/config-env-aliases.ts @@ -0,0 +1,62 @@ +/** + * Some platforms reject env var names that start with an underscore (AWS Lambda requires + * `[a-zA-Z][a-zA-Z0-9_]+`), so the `_VARLOCK_*` config vars cannot be set there directly. + * Each one can also be set without the leading underscore (`VARLOCK_ENV_KEY` for + * `_VARLOCK_ENV_KEY`); the underscore form wins when both are set. + * + * Kept as a plain list of names (rather than importing `VARLOCK_CONFIG_ENV_VARS`) because this + * runs inside the edge-safe init bundles. A test keeps it in sync with reserved-vars.ts. + */ +export const VARLOCK_CONFIG_ENV_VAR_NAMES = [ + '_VARLOCK_ENV_KEY', + '_VARLOCK_ALLOW_PLAINTEXT_FROZEN_ENV', + '_VARLOCK_CACHE_KEY', + '_VARLOCK_REDACT_STDOUT', + '_VARLOCK_FILTER', + '_VARLOCK_THROW_ON_LOAD_ERROR', + '_VARLOCK_DYNAMIC_BUILD_ACCESS_MODE', + '_VARLOCK_USE_INJECTED_ENV', + '_VARLOCK_ALLOW_EMPTY_CONFIG', + '_VARLOCK_USE_FROZEN_ENV', +] as const; + +/** The underscore-free alias names themselves (`VARLOCK_ENV_KEY`, ...) */ +export const VARLOCK_CONFIG_ENV_VAR_ALIAS_NAMES: ReadonlySet = new Set( + VARLOCK_CONFIG_ENV_VAR_NAMES.map((name) => name.slice(1)), +); + +type ConfigEnvVarName = typeof VARLOCK_CONFIG_ENV_VAR_NAMES[number]; + +/** + * Read a `_VARLOCK_*` config var, falling back to its underscore-free alias. Use where the + * value matters even if process.env is read-only (so `applyVarlockConfigEnvAliases` could + * not copy the alias over). + */ +export function getVarlockConfigEnvVar( + name: ConfigEnvVarName, + env: Record | undefined = (globalThis as any).process?.env, +): string | undefined { + return env?.[name] || env?.[name.slice(1)] || undefined; +} + +/** + * Copy each underscore-free alias (`VARLOCK_ENV_KEY`) onto its `_VARLOCK_*` name when that + * name is not already set, so every reader of the `_VARLOCK_*` name sees it. + * Mutates `env` (process.env by default). Safe to call more than once, and a no-op where + * process.env is missing or read-only (some edge runtimes). + */ +export function applyVarlockConfigEnvAliases( + env: Record | undefined = (globalThis as any).process?.env, +) { + if (!env) return; + for (const name of VARLOCK_CONFIG_ENV_VAR_NAMES) { + if (env[name] !== undefined && env[name] !== '') continue; + const aliasValue = env[name.slice(1)]; + if (aliasValue === undefined || aliasValue === '') continue; + try { + env[name] = aliasValue; + } catch { + // read-only process.env + } + } +} diff --git a/packages/varlock/src/runtime/test/config-env-aliases.test.ts b/packages/varlock/src/runtime/test/config-env-aliases.test.ts new file mode 100644 index 000000000..b2bb0d673 --- /dev/null +++ b/packages/varlock/src/runtime/test/config-env-aliases.test.ts @@ -0,0 +1,79 @@ +/* + `_VARLOCK_*` config vars can also be set without the leading underscore, for platforms that + reject env var names starting with `_` (AWS Lambda). +*/ +import { + describe, it, expect, beforeEach, afterEach, vi, +} from 'vitest'; +import { applyVarlockConfigEnvAliases, VARLOCK_CONFIG_ENV_VAR_NAMES } from '../lib/config-env-aliases'; +import { VARLOCK_CONFIG_ENV_VARS } from '../../env-graph/lib/reserved-vars'; +import { encryptEnvBlobSync, generateEncryptionKeyHex } from '../crypto'; + +describe('applyVarlockConfigEnvAliases', () => { + it('covers every user-settable _VARLOCK_* config var', () => { + expect([...VARLOCK_CONFIG_ENV_VAR_NAMES].sort()).toEqual( + VARLOCK_CONFIG_ENV_VARS.filter((v) => !v.internal).map((v) => v.name).sort(), + ); + }); + + it('copies an underscore-free alias onto the _VARLOCK_* name', () => { + const env: Record = { VARLOCK_ENV_KEY: 'abc', VARLOCK_USE_FROZEN_ENV: '1' }; + applyVarlockConfigEnvAliases(env); + expect(env._VARLOCK_ENV_KEY).toBe('abc'); + expect(env._VARLOCK_USE_FROZEN_ENV).toBe('1'); + }); + + it('the _VARLOCK_* name wins when both are set', () => { + const env: Record = { _VARLOCK_ENV_KEY: 'underscore', VARLOCK_ENV_KEY: 'alias' }; + applyVarlockConfigEnvAliases(env); + expect(env._VARLOCK_ENV_KEY).toBe('underscore'); + }); + + it('treats an empty value as unset', () => { + const env: Record = { _VARLOCK_ENV_KEY: '', VARLOCK_ENV_KEY: 'alias', VARLOCK_FILTER: '' }; + applyVarlockConfigEnvAliases(env); + expect(env._VARLOCK_ENV_KEY).toBe('alias'); + expect(env._VARLOCK_FILTER).toBeUndefined(); + }); + + it('leaves other VARLOCK_* vars alone', () => { + const env: Record = { VARLOCK_ENV: 'production', VARLOCK_DEBUG: '1' }; + applyVarlockConfigEnvAliases(env); + expect(env).toEqual({ VARLOCK_ENV: 'production', VARLOCK_DEBUG: '1' }); + }); + + it('does not throw on a read-only env', () => { + const env = Object.freeze({ VARLOCK_ENV_KEY: 'abc' }) as Record; + expect(() => applyVarlockConfigEnvAliases(env)).not.toThrow(); + }); +}); + +describe('initVarlockRuntime with VARLOCK_ENV_KEY', () => { + const cleanup = () => { + delete (globalThis as any).__varlockEnvState; + delete (globalThis as any).__varlockRedactionState; + delete (globalThis as any).__varlockLoadedEnv; + delete process.env.__VARLOCK_ENV; + delete process.env._VARLOCK_ENV_KEY; + delete process.env.VARLOCK_ENV_KEY; + delete process.env.ALIAS_TEST_VAR; + }; + beforeEach(cleanup); + afterEach(cleanup); + + it('decrypts an encrypted blob using only the underscore-free key name', async () => { + const key = generateEncryptionKeyHex(); + process.env.__VARLOCK_ENV = encryptEnvBlobSync(JSON.stringify({ + sources: [], + settings: {}, + config: { ALIAS_TEST_VAR: { value: 'decrypted-value', isSensitive: false } }, + }), key); + process.env.VARLOCK_ENV_KEY = key; + + vi.resetModules(); + const { initVarlockRuntime } = await import('../init'); + initVarlockRuntime(); + const { ENV } = await import('../env'); + expect((ENV as any).ALIAS_TEST_VAR).toBe('decrypted-value'); + }); +}); diff --git a/packages/varlock/src/runtime/test/edge-evaluable.test.ts b/packages/varlock/src/runtime/test/edge-evaluable.test.ts index 6216baa24..d7f772e17 100644 --- a/packages/varlock/src/runtime/test/edge-evaluable.test.ts +++ b/packages/varlock/src/runtime/test/edge-evaluable.test.ts @@ -96,6 +96,8 @@ function evaluateCjsBundleInEdgeLikeContext(bundleFile: string, opts: { env?: Record, /** model Next's dev edge sandbox, whose `process` shim throws when a node API is read off it */ processThrowsOnNodeApis?: boolean, + /** model a runtime whose process.env is read-only (writes silently no-op) */ + freezeEnv?: boolean, probe: string, }) { const bundlePath = path.join(DIST_RUNTIME_DIR, bundleFile); @@ -116,6 +118,7 @@ function evaluateCjsBundleInEdgeLikeContext(bundleFile: string, opts: { } const module = { exports: {} }; const processShim = { env: ${JSON.stringify(opts.env ?? {})} }; + if (${JSON.stringify(!!opts.freezeEnv)}) Object.freeze(processShim.env); if (${JSON.stringify(!!opts.processThrowsOnNodeApis)}) { for (const api of ['getBuiltinModule', 'stdout', 'stderr', 'ppid', 'cwd']) { Object.defineProperty(processShim, api, { @@ -217,6 +220,36 @@ describe('runtime entries evaluate without node builtins', () => { expect(result).toEqual({ publicValue: 'public-value', consolePatched: true, patchConsoleExposed: 'function' }); }); + it('the injected init-bundle decrypts with VARLOCK_ENV_KEY (for platforms that reject a leading `_`)', () => { + const key = 'ce'.repeat(32); + const result = evaluateCjsBundleInEdgeLikeContext('init-bundle.cjs', { + env: { __VARLOCK_ENV: encryptEnvBlobSync(JSON.stringify(FAKE_GRAPH), key), VARLOCK_ENV_KEY: key }, + probe: ` + await sandbox.__varlockEnvReady; + return { publicValue: ns.ENV.PUBLIC_KEY }; + `, + }); + expect(result).toEqual({ publicValue: 'public-value' }); + }); + + it('the injected init-bundle decrypts with VARLOCK_ENV_KEY on a read-only process.env', () => { + const key = 'cf'.repeat(32); + const result = evaluateCjsBundleInEdgeLikeContext('init-bundle.cjs', { + env: { + __VARLOCK_ENV: encryptEnvBlobSync(JSON.stringify({ + ...FAKE_GRAPH, settings: { ...FAKE_GRAPH.settings, disableProcessEnvInjection: true }, + }), key), + VARLOCK_ENV_KEY: key, + }, + freezeEnv: true, + probe: ` + await sandbox.__varlockEnvReady; + return { publicValue: ns.ENV.PUBLIC_KEY }; + `, + }); + expect(result).toEqual({ publicValue: 'public-value' }); + }); + it("the injected init-bundle runs under Next's dev edge sandbox, whose process shim throws on node APIs", () => { // the old edge bundle never touched node builtins; the unified one must survive a // `process` whose node-API properties throw on access rather than being undefined diff --git a/smoke-tests/tests/frozen-env.test.ts b/smoke-tests/tests/frozen-env.test.ts index cdb35dcdb..3a2756e48 100644 --- a/smoke-tests/tests/frozen-env.test.ts +++ b/smoke-tests/tests/frozen-env.test.ts @@ -39,6 +39,9 @@ const ISOLATED_KEYS = [ '_VARLOCK_ENV_KEY', '_VARLOCK_USE_INJECTED_ENV', '_VARLOCK_USE_FROZEN_ENV', + 'VARLOCK_ENV_KEY', + 'VARLOCK_USE_INJECTED_ENV', + 'VARLOCK_USE_FROZEN_ENV', 'APP_ENV', 'PUBLIC_VAR', 'SECRET_TOKEN', @@ -271,6 +274,22 @@ describe('varlock freeze --out -', () => { expect(result.output).toContain('COERCED_FLAG_IS_BOOL=true'); }); + test('the payload boots with only underscore-free config var names set (AWS Lambda)', () => { + const blob = freeze({ args: ['--out', '-'] }).stdout.trim(); + const result = runApp({ + cwd: blobDeployDir, + env: { + __VARLOCK_ENV: blob, + _VARLOCK_USE_FROZEN_ENV: undefined, + VARLOCK_USE_INJECTED_ENV: '1', + VARLOCK_ENV_KEY: encryptionKey, + }, + }); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain('PUBLIC_VAR=public-value-prod'); + expect(result.output).toContain('SECRET_OK=true'); + }); + test('refuses to emit an unencrypted payload without a key', () => { const result = runVarlock(['freeze', '--out', '-'], { cwd: SCENARIO, @@ -315,6 +334,24 @@ describe('booting from a frozen env file', () => { expect(result.output).toContain('COERCED_FLAG_IS_BOOL=true'); }); + // AWS Lambda rejects env var names starting with `_`, so each `_VARLOCK_*` config var can + // also be set without the leading underscore + test('accepts VARLOCK_ENV_KEY / VARLOCK_USE_FROZEN_ENV in place of the _VARLOCK_* names', () => { + const result = runApp({ + env: { _VARLOCK_USE_FROZEN_ENV: undefined, VARLOCK_USE_FROZEN_ENV: '1', VARLOCK_ENV_KEY: encryptionKey }, + }); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain('APP_ENV=production'); + expect(result.output).toContain('SECRET_OK=true'); + }); + + test('the _VARLOCK_* name wins over the underscore-free alias', () => { + const otherKey = runVarlock(['generate-key', '--plain']).stdout.trim(); + const result = runApp({ env: { _VARLOCK_ENV_KEY: encryptionKey, VARLOCK_ENV_KEY: otherKey } }); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain('APP_ENV=production'); + }); + test('varlock run consumes it too', () => { const result = varlockRun(['node', 'app.mjs'], { cwd: SCENARIO,