From 110ff6dcbf8915dbfe1c192f4db4e1266144fcd9 Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Fri, 11 Sep 2026 01:14:14 +0200 Subject: [PATCH 1/3] feat(env): simplify local and production configuration --- .gitignore | 1 + packages/deploy-cloudflare/src/framework.ts | 21 + packages/deploy-cloudflare/src/version.ts | 116 +++- .../test/unit/framework.test.ts | 25 + .../test/unit/version.test.ts | 91 +++- packages/void/src/auth/node.ts | 4 +- packages/void/src/cli/cli.ts | 4 +- packages/void/src/cli/db.ts | 47 +- packages/void/src/cli/deploy.ts | 458 ++-------------- packages/void/src/cli/env-args.ts | 31 +- packages/void/src/cli/env-example.ts | 338 ------------ packages/void/src/cli/env-scaffold.ts | 26 +- packages/void/src/cli/env-schema-probe.ts | 124 +---- packages/void/src/cli/env-validation.ts | 222 +++----- packages/void/src/cli/env.ts | 49 +- packages/void/src/cli/help.ts | 14 +- packages/void/src/cli/init.ts | 8 +- packages/void/src/cli/secret.ts | 6 + packages/void/src/cli/template.ts | 3 +- packages/void/src/codegen/prepare.ts | 14 +- packages/void/src/index.ts | 189 ++----- packages/void/src/plugin-drizzle.ts | 4 +- packages/void/src/plugin-env-client-fold.ts | 33 +- packages/void/src/plugin-env-client-guard.ts | 25 +- packages/void/src/plugin-env-schema.ts | 169 ++++-- packages/void/src/plugin-migration.ts | 36 +- packages/void/src/plugin-routing.ts | 6 +- packages/void/src/prerender.ts | 22 +- packages/void/src/runtime/env-helpers.ts | 49 -- .../void/src/runtime/env-public-client.ts | 9 +- packages/void/src/runtime/env-public.ts | 83 +-- packages/void/src/shared/dotenv.ts | 17 +- packages/void/src/shared/env-mask.ts | 27 +- packages/void/src/shared/env.ts | 60 +- .../void/test/integration/env-cli.test.ts | 16 +- .../integration/env-prod-validation.test.ts | 104 ++-- .../integration/kitchen-sink-build.test.ts | 47 +- .../void/test/integration/pg-test.test.ts | 9 +- .../integration/plugin-env-schema.test.ts | 82 ++- .../void/test/unit/cloudflare-deploy.test.ts | 514 +++++++----------- .../void/test/unit/database-provider.test.ts | 4 +- packages/void/test/unit/db-commands.test.ts | 6 +- .../void/test/unit/db-studio-remote.test.ts | 2 +- .../void/test/unit/deploy-env-expand.test.ts | 45 -- .../test/unit/deploy-prerender-skip.test.ts | 11 +- .../void/test/unit/env-client-fold.test.ts | 9 +- .../void/test/unit/env-client-guard.test.ts | 16 +- .../void/test/unit/env-command-target.test.ts | 94 ++++ packages/void/test/unit/env-example.test.ts | 245 --------- packages/void/test/unit/env-mask.test.ts | 24 +- packages/void/test/unit/env-scaffold.test.ts | 5 +- packages/void/test/unit/env-schema.test.ts | 33 -- .../test/unit/env-validation-selfhost.test.ts | 144 +---- packages/void/test/unit/init.test.ts | 6 +- packages/void/test/unit/prerender.test.ts | 4 +- .../unit/secret-cloudflare-validation.test.ts | 87 +++ packages/void/vite.config.ts | 4 +- playground/kitchen-sink/.env | 1 + playground/kitchen-sink/env.ts | 1 + 59 files changed, 1313 insertions(+), 2531 deletions(-) delete mode 100644 packages/void/src/cli/env-example.ts delete mode 100644 packages/void/test/unit/deploy-env-expand.test.ts create mode 100644 packages/void/test/unit/env-command-target.test.ts delete mode 100644 packages/void/test/unit/env-example.test.ts create mode 100644 packages/void/test/unit/secret-cloudflare-validation.test.ts diff --git a/.gitignore b/.gitignore index 0655191bb..64d20e85a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist *.tsbuildinfo .DS_Store .wrangler +.env .claude plan.md TODOs.md diff --git a/packages/deploy-cloudflare/src/framework.ts b/packages/deploy-cloudflare/src/framework.ts index 3b7c2c240..2f683dc3f 100644 --- a/packages/deploy-cloudflare/src/framework.ts +++ b/packages/deploy-cloudflare/src/framework.ts @@ -13,6 +13,7 @@ export type CloudflareFrameworkDeployConfig = Readonly<{ compatibilityFlags?: ReadonlyArray; assetsIgnore?: ReadonlyArray; crons?: ReadonlyArray; + requiredSecrets?: ReadonlyArray; }>; /** @@ -57,6 +58,26 @@ export function writeCloudflareFrameworkDeployConfig( if (options.crons !== undefined) { upsert(['triggers', 'crons'], [...options.crons]); } + if (options.requiredSecrets && options.requiredSecrets.length > 0) { + const config = parse(content) as { secrets?: unknown }; + if ( + config.secrets !== undefined && + (typeof config.secrets !== 'object' || + config.secrets === null || + Array.isArray(config.secrets)) + ) { + throw new Error('Cloudflare framework config has a malformed secrets field.'); + } + const required = (config.secrets as { required?: unknown } | undefined)?.required; + if ( + required !== undefined && + (!Array.isArray(required) || !required.every((name) => typeof name === 'string')) + ) { + throw new Error('Cloudflare framework config has a malformed secrets.required field.'); + } + const current = (required as Array | undefined) ?? []; + upsert(['secrets', 'required'], [...new Set([...current, ...options.requiredSecrets])]); + } let changed = content !== initial; if (changed) { diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index a93ac491b..500c72a6e 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -694,6 +694,7 @@ export async function activateCloudflareVersion(options: { if (!options.verifyUploader()) { return { ok: false, reason: 'uploader-changed' }; } + const previousVersionId = previous.versionId; let inheritanceBaseline: Array | undefined; if (options.expectedPreviousVersionId !== undefined) { const recent = await readRecentCloudflareVersionIds(options, commonArgs); @@ -726,10 +727,22 @@ export async function activateCloudflareVersion(options: { : options.configPath ? readCloudflareTriggerConfig(options.configPath) : triggerConfigFromWrangler({}); - const previousMetadata = - previous.versionId && desiredMetadata?.schema === 2 - ? await readCloudflareVersionMetadata(options, commonArgs, previous.versionId) + let previousVersion = + previousVersionId && desiredMetadata?.schema === 2 + ? await readCloudflareVersionInspection(options, commonArgs, previousVersionId) : null; + async function previousIsSecretOnlyDraft(): Promise { + if (!previousVersionId) { + return false; + } + previousVersion ??= await readCloudflareVersionInspection( + options, + commonArgs, + previousVersionId, + ); + return previousVersion?.isSecretOnlyDraft === true; + } + const previousMetadata = desiredMetadata?.schema === 2 ? previousVersion?.metadata : null; const continuesOpaqueTriggerPreservation = !options.preserveTriggers && !options.previousTrafficConfig && @@ -763,7 +776,7 @@ export async function activateCloudflareVersion(options: { const resolved = resolvePreservedTraffic( desiredTriggerConfig, previousTrafficConfig, - previous.versionId !== null, + previous.versionId !== null && !(await previousIsSecretOnlyDraft()), ); if (!resolved) { return { @@ -814,11 +827,15 @@ export async function activateCloudflareVersion(options: { if (!isCleanCommandExit(upload)) { const output = `${upload.stdout}\n${upload.stderr}`; const cloudflareErrorCode = parseCloudflareErrorCode(output); + const isBootstrapError = + cloudflareErrorCode === DURABLE_MIGRATION_VERSION_ERROR || + /cannot upload a new version of a worker that does not yet exist/i.test(output); if ( !options.preserveTriggers && - (previous.versionId === null || options.allowFirstDeploymentBootstrap === true) && - (cloudflareErrorCode === DURABLE_MIGRATION_VERSION_ERROR || - /cannot upload a new version of a worker that does not yet exist/i.test(output)) + isBootstrapError && + (previous.versionId === null || + options.allowFirstDeploymentBootstrap === true || + (await previousIsSecretOnlyDraft())) ) { return bootstrapFirstDeployment({ ...options, message }, commonArgs, previous.versionId); } @@ -886,7 +903,9 @@ export async function activateCloudflareVersion(options: { if (!readinessUrl) { if ( !options.preserveTriggers && - (previous.versionId === null || options.allowFirstDeploymentBootstrap === true) + (previous.versionId === null || + options.allowFirstDeploymentBootstrap === true || + (await previousIsSecretOnlyDraft())) ) { return bootstrapFirstDeployment({ ...options, message }, commonArgs, previous.versionId); } @@ -1284,6 +1303,19 @@ async function readCloudflareVersionMetadata( commonArgs: Array, versionId: string, ): Promise { + return (await readCloudflareVersionInspection(options, commonArgs, versionId))?.metadata ?? null; +} + +type CloudflareVersionInspection = Readonly<{ + metadata: CloudflareVersionMetadata | null; + isSecretOnlyDraft: boolean; +}>; + +async function readCloudflareVersionInspection( + options: Pick[0], 'runWrangler' | 'workerName'>, + commonArgs: Array, + versionId: string, +): Promise { const result = await Promise.resolve( options.runWrangler( ['versions', 'list', ...commonArgs, '--name', options.workerName, '--json'], @@ -1299,11 +1331,15 @@ async function readCloudflareVersionMetadata( return null; } for (const entry of versions) { - if (!isRecord(entry) || entry.id !== versionId || !isRecord(entry.annotations)) { + if (!isRecord(entry) || entry.id !== versionId) { continue; } - const message = entry.annotations['workers/message']; - return typeof message === 'string' ? parseCloudflareVersionMessage(message) : null; + const annotations = isRecord(entry.annotations) ? entry.annotations : undefined; + const message = annotations?.['workers/message']; + return { + metadata: typeof message === 'string' ? parseCloudflareVersionMessage(message) : null, + isSecretOnlyDraft: isWranglerSecretOnlyDraft(entry), + }; } } catch { return null; @@ -1351,6 +1387,64 @@ function arraysEqual(left: ReadonlyArray, right: ReadonlyArray): return left.length === right.length && left.every((value, index) => value === right[index]); } +/** + * `wrangler secret put` creates a fetch-only draft before attaching a first secret to a Worker. + * Accept that draft as first-deployment proof only when its complete deployable shape contains no + * application code configuration or non-secret resource that an ordinary deploy could own. + */ +function isWranglerSecretOnlyDraft(entry: Record): boolean { + if (!isRecord(entry.annotations)) { + return false; + } + const annotationKeys = Object.keys(entry.annotations); + if ( + entry.annotations['workers/triggered_by'] !== 'secret' || + annotationKeys.some((key) => key !== 'workers/triggered_by') + ) { + return false; + } + + if (!isRecord(entry.resources)) { + return false; + } + const { bindings, script, script_runtime: scriptRuntime } = entry.resources; + if ( + !Array.isArray(bindings) || + bindings.length === 0 || + !bindings.every( + (binding) => + isRecord(binding) && + typeof binding.name === 'string' && + binding.name.length > 0 && + binding.type === 'secret_text' && + Object.keys(binding).every((key) => key === 'name' || key === 'type'), + ) + ) { + return false; + } + + if ( + !isRecord(script) || + !Array.isArray(script.handlers) || + script.handlers.length !== 1 || + script.handlers[0] !== 'fetch' || + script.placement_mode !== undefined + ) { + return false; + } + + if (!isRecord(scriptRuntime)) { + return false; + } + const runtimeKeys = Object.keys(scriptRuntime); + return ( + scriptRuntime.usage_model === 'standard' && + isRecord(scriptRuntime.limits) && + Object.keys(scriptRuntime.limits).length === 0 && + runtimeKeys.every((key) => key === 'usage_model' || key === 'limits') + ); +} + /** * The Versions API cannot create a Worker or its initial Durable Object migration history, and * some accounts do not expose a version preview until their first ordinary deployment. A diff --git a/packages/deploy-cloudflare/test/unit/framework.test.ts b/packages/deploy-cloudflare/test/unit/framework.test.ts index 660745c1b..34fdded4d 100644 --- a/packages/deploy-cloudflare/test/unit/framework.test.ts +++ b/packages/deploy-cloudflare/test/unit/framework.test.ts @@ -17,6 +17,7 @@ describe('Cloudflare framework deployment', () => { `{ // retain operator context "compatibility_flags": ["custom-flag"], + "secrets": { "required": ["MANUAL_SECRET"] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }], "kv_namespaces": [{ "binding": "KV", "id": "abc" }] }\n`, @@ -33,6 +34,7 @@ describe('Cloudflare framework deployment', () => { compatibilityFlags: ['nodejs_als'], assetsIgnore: ['_worker.js', '_routes.json'], crons: ['0 * * * *'], + requiredSecrets: ['API_TOKEN', 'MANUAL_SECRET'], }; expect(writeCloudflareFrameworkDeployConfig(input)).toBe(true); expect(writeCloudflareFrameworkDeployConfig(input)).toBe(false); @@ -55,6 +57,7 @@ describe('Cloudflare framework deployment', () => { not_found_handling: 'none', }, triggers: { crons: ['0 * * * *'] }, + secrets: { required: ['MANUAL_SECRET', 'API_TOKEN'] }, }); expect(JSON.parse(readFileSync(pointerPath, 'utf-8'))).toEqual({ configPath: '../../wrangler.jsonc', @@ -88,4 +91,26 @@ describe('Cloudflare framework deployment', () => { expect(parseRootWranglerConfig(configPath)).toMatchObject({ triggers: { crons: [] } }); }); + + it('rejects a malformed existing secrets.required field without writing', () => { + const root = mkdtempSync(join(tmpdir(), 'void-framework-config-')); + const configPath = join(root, 'wrangler.jsonc'); + const pointerPath = join(root, '.wrangler/deploy/config.json'); + const original = '{ "secrets": { "required": "API_TOKEN" } }\n'; + writeFileSync(configPath, original); + + expect(() => + writeCloudflareFrameworkDeployConfig({ + configPath, + deployPointerPath: pointerPath, + name: 'framework-app', + accountId: 'account-id', + main: 'dist/worker.js', + assetsDirectory: 'dist/assets', + compatibilityDate: '2026-09-11', + requiredSecrets: ['API_TOKEN'], + }), + ).toThrow('malformed secrets.required'); + expect(readFileSync(configPath, 'utf-8')).toBe(original); + }); }); diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index 334fb6b2c..bca399f8e 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -17,6 +17,22 @@ const CURRENT = '11111111-1111-4111-8111-111111111111'; const NEXT = '22222222-2222-4222-8222-222222222222'; const CONCURRENT = '33333333-3333-4333-8333-333333333333'; +function secretOnlyDraftVersion() { + return { + id: CURRENT, + annotations: { 'workers/triggered_by': 'secret' }, + resources: { + bindings: [{ name: 'API_TOKEN', type: 'secret_text' }], + script: { + etag: 'draft', + handlers: ['fetch'], + last_deployed_from: 'wrangler', + }, + script_runtime: { usage_model: 'standard', limits: {} }, + }, + }; +} + function ok(stdout = ''): CloudflareCommandResult { return { stdout, stderr: '', status: 0 }; } @@ -1400,6 +1416,7 @@ describe('Cloudflare version activation', () => { expect(runWrangler.mock.calls.map(([args]) => args.slice(0, 2))).toEqual([ ['deployments', 'status'], ['versions', 'upload'], + ['versions', 'list'], ['versions', 'deploy'], ['deployments', 'status'], ['versions', 'deploy'], @@ -1407,10 +1424,10 @@ describe('Cloudflare version activation', () => { ['triggers', 'deploy'], ['deployments', 'status'], ]); - expect(runWrangler.mock.calls[2]![0]).toEqual( + expect(runWrangler.mock.calls[3]![0]).toEqual( expect.arrayContaining([`${CURRENT}@100`, `${NEXT}@0`]), ); - expect(runWrangler.mock.calls[4]![0]).toContain(`${NEXT}@100`); + expect(runWrangler.mock.calls[5]![0]).toContain(`${NEXT}@100`); expect(fetcher).toHaveBeenCalledWith( 'https://my-worker.developer-platform-experience.workers.dev/__void/ready', expect.objectContaining({ @@ -1441,10 +1458,10 @@ describe('Cloudflare version activation', () => { rollback: 'succeeded', }); - expect(runWrangler.mock.calls[2]![0]).toEqual( + expect(runWrangler.mock.calls[3]![0]).toEqual( expect.arrayContaining([`${CURRENT}@100`, `${NEXT}@0`]), ); - expect(runWrangler.mock.calls[3]![0]).toContain(`${CURRENT}@100`); + expect(runWrangler.mock.calls[4]![0]).toContain(`${CURRENT}@100`); expect(runWrangler.mock.calls.some(([args]) => args[0] === 'triggers')).toBe(false); }); @@ -1473,6 +1490,7 @@ describe('Cloudflare version activation', () => { 'deployments', 'versions', 'versions', + 'versions', ]); }); @@ -1888,4 +1906,69 @@ describe('Cloudflare version activation', () => { ).resolves.toMatchObject({ ok: true, previousVersionId: CURRENT }); expect(runWrangler.mock.calls.some(([args]) => args[0] === 'deploy')).toBe(true); }); + + it("recognizes Wrangler's active secret-only draft without same-process provisioning proof", async () => { + const runWrangler = runner({ + versions: ok(JSON.stringify([secretOnlyDraftVersion()])), + upload: { + stdout: '', + stderr: 'Durable Object migrations require deploy. [code: 10211]', + status: 1, + }, + bootstrap: ok(`Current Version ID: ${NEXT}\nhttps://first.example.workers.dev`), + }); + + await expect( + activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'first-with-existing-secret', + message: encodeCloudflareVersionMessage(['0001_state.sql']), + fetch: async () => Response.json({ ready: true }), + }), + ).resolves.toMatchObject({ ok: true, previousVersionId: CURRENT }); + expect(runWrangler.mock.calls.some(([args]) => args[0] === 'deploy')).toBe(true); + }); + + it('rejects a secret-triggered version that also contains application configuration', async () => { + const draft = secretOnlyDraftVersion(); + const version = { + ...draft, + annotations: { + ...draft.annotations, + 'workers/message': 'deployed application', + }, + resources: { + ...draft.resources, + bindings: [...draft.resources.bindings, { name: 'PUBLIC_VALUE', type: 'plain_text' }], + script_runtime: { + ...draft.resources.script_runtime, + compatibility_date: '2026-09-01', + }, + }, + }; + const runWrangler = runner({ + versions: ok(JSON.stringify([version])), + upload: { + stdout: '', + stderr: 'Durable Object migrations require deploy. [code: 10211]', + status: 1, + }, + }); + + await expect( + activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'existing-with-secret', + message: encodeCloudflareVersionMessage(['0002_state.sql']), + }), + ).resolves.toEqual({ + ok: false, + reason: 'upload-failed', + status: 1, + cloudflareErrorCode: 10211, + }); + expect(runWrangler.mock.calls.some(([args]) => args[0] === 'deploy')).toBe(false); + }); }); diff --git a/packages/void/src/auth/node.ts b/packages/void/src/auth/node.ts index ef13f90e5..4a8e05117 100644 --- a/packages/void/src/auth/node.ts +++ b/packages/void/src/auth/node.ts @@ -1,5 +1,4 @@ import { join } from 'pathslash'; -import { loadEnv } from 'vite'; import { readConfig } from '../config.ts'; import { getMiniflareDatabasePath } from '../migrations/runner.ts'; import { @@ -8,6 +7,7 @@ import { applySqliteAuthMigrations, } from '../runtime/auth-migrations.ts'; import type { VoidAuthDialect, VoidAuthEnv } from '../runtime/better-auth-shared.ts'; +import { loadLocalEnv } from '../shared/env.ts'; import { findVoidAuthConfig, loadVoidAuthConfig } from './config.ts'; import { resolveVoidAuthMigrationSchema } from './drizzle.ts'; @@ -24,7 +24,7 @@ export async function runLocalVoidAuthMigrations( providers: readConfig(root).auth?.providers ?? null, userConfig: authConfig, }; - const env = loadEnv('development', root, '') as unknown as VoidAuthEnv; + const env = { ...loadLocalEnv(root), ...process.env } as unknown as VoidAuthEnv; const migrationSchema = resolveVoidAuthMigrationSchema(runtime, env); if (dialect === 'sqlite') { diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index bb1ba90ef..443fe19e8 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -280,8 +280,8 @@ if (helpPath) { }, { name: 'env', - hint: 'check | types | example', - description: 'Validate env.ts, generate types, or scaffold .env.example', + hint: 'check | types', + description: 'Validate env.ts or generate types', }, { name: 'gen', diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index d3b44a832..6ae4bb284 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -26,6 +26,7 @@ import { detectFramework } from '../plugin-inference.ts'; import { readProjectPaths } from '../project-paths.ts'; import type { SeedFn } from '../runtime/seed.ts'; import { cfAccessHeaders } from '../shared/cf-access.ts'; +import { loadLocalEnv } from '../shared/env.ts'; import { getProxyUrl, getToken } from './auth.ts'; import { PlatformApiError, PlatformClient } from './client.ts'; import type { DbArgs } from './db-args.ts'; @@ -84,51 +85,22 @@ function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function readEnvLocal(root: string): Record { - const envPath = join(root, '.env.local'); - if (!existsSync(envPath)) { - return {}; - } - const content = readFileSync(envPath, 'utf-8'); - const vars: Record = {}; - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { - continue; - } - const eqIndex = trimmed.indexOf('='); - if (eqIndex === -1) { - continue; - } - const key = trimmed.slice(0, eqIndex).trim(); - let value = trimmed.slice(eqIndex + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - vars[key] = value; - } - return vars; -} - function getPgUrl(root: string): string { - const vars = readEnvLocal(root); + const vars = loadLocalEnv(root); const url = vars.DATABASE_URL; if (!url) { throw new Error( - 'DATABASE_URL not found in .env.local. Add it to connect to your PostgreSQL database.', + 'DATABASE_URL not found in .env. Add it to connect to your local PostgreSQL database.', ); } return url; } function getMySqlUrl(root: string): string { - const url = readEnvLocal(root).DATABASE_URL; + const url = loadLocalEnv(root).DATABASE_URL; if (!url) { throw new Error( - 'DATABASE_URL not found in .env.local. Add it to connect to your MySQL database.', + 'DATABASE_URL not found in .env. Add it to connect to your local MySQL database.', ); } return url; @@ -222,7 +194,7 @@ export async function runDbCommand(root: string, args: DbArgs): Promise { } export function writeLocalDatabaseUrl(root: string, connectionString: string): void { - const envPath = join(root, '.env.local'); + const envPath = join(root, '.env'); const original = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : ''; const lines = original ? original.replace(/\r\n/g, '\n').split('\n') : []; const assignment = `DATABASE_URL=${JSON.stringify(connectionString)}`; @@ -351,7 +323,7 @@ async function runConnect( } writeLocalDatabaseUrl(root, connectionString); - log.success('Saved DATABASE_URL to .env.local.'); + log.success('Saved DATABASE_URL to .env.'); if (!args.localOnly) { const token = getToken(root); @@ -397,11 +369,10 @@ async function runGenerate(root: string, dialect: DatabaseDialect): Promise, -): string { - if (reject.reason === 'custom-envdir') { - return [ - 'deploy: --platform cloudflare does not support a custom Vite `envDir` (including `envDir: false`).', - 'v1 loads env from the project root only. The build reads `.env*` from your envDir but', - 'generates the auth schema from the project root, so a custom envDir could validate a', - 'different table set than the worker actually ships. Remove `envDir` from your Vite config', - '(or point it back at the project root), then rerun — or deploy this app with `void deploy`', - '(the managed platform).', - ].join('\n'); - } - return [ - 'deploy: --platform cloudflare builds in production mode only (your build resolves to a non-production mode).', - 'A non-production build mode selects a different `.env.` set than this backend validates.', - 'Remove the custom `mode` from your Vite config / build command so the build runs in production,', - 'then rerun — or deploy this app with `void deploy` (the managed platform).', - ].join('\n'); -} - /** Re-export so existing importers (and tests) keep reading the type from deploy.ts. */ export type { EmittedWorkerVarsResult }; @@ -1520,230 +1485,31 @@ function readEmittedWorkerVarsDefault(root: string): EmittedWorkerVarsResult { return readEmittedWorkerVars(root); } -type CloudflareSchemaSecretFailureReason = - | 'invalid-value' - | 'config-invalid' - | 'uploader-changed' - | 'list-failed' - | 'bulk-failed' - | 'verification-failed'; - -/** Values-free failure from promotion of schema-marked Cloudflare secrets. */ -export class CloudflareSchemaSecretError extends Error { - override name = 'CloudflareSchemaSecretError'; - - constructor( - message: string, - readonly reason: CloudflareSchemaSecretFailureReason, - ) { - super(message); +function resolveGeneratedDeployConfigPathDefault(root: string): string { + const pointerPath = deployPointerPath(root); + const pointer = JSON.parse(readFileSync(pointerPath, 'utf-8')) as { configPath?: unknown }; + if (typeof pointer.configPath !== 'string' || pointer.configPath.length === 0) { + throw new Error(`Generated Cloudflare deploy pointer ${pointerPath} has no configPath.`); } + return resolve(dirname(pointerPath), pointer.configPath); } function readGeneratedDeployConfig(root: string): { path: string; config: Record; } { - const pointerPath = deployPointerPath(root); - let redirectedPath: string; - try { - const pointer = JSON.parse(readFileSync(pointerPath, 'utf-8')) as { configPath?: unknown }; - if (typeof pointer.configPath !== 'string' || pointer.configPath.length === 0) { - throw new Error('missing pointer'); - } - redirectedPath = resolve(dirname(pointerPath), pointer.configPath); - } catch { - throw new CloudflareSchemaSecretError( - 'deploy: could not resolve the generated Cloudflare config while preparing schema secrets.', - 'config-invalid', - ); - } + const redirectedPath = resolveGeneratedDeployConfigPathDefault(root); try { const config = JSON.parse(readFileSync(redirectedPath, 'utf-8')) as unknown; if (config === null || typeof config !== 'object' || Array.isArray(config)) { throw new Error('malformed config'); } return { path: redirectedPath, config: config as Record }; - } catch (error) { - if (error instanceof CloudflareSchemaSecretError) { - throw error; - } - throw new CloudflareSchemaSecretError( - 'deploy: the generated Cloudflare config is not valid JSON; no schema secrets were uploaded.', - 'config-invalid', - ); - } -} - -/** - * Promote explicit `env.ts` `.secret()` values from generated plaintext vars to - * Cloudflare Worker secrets. Values are streamed as JSON over stdin, remote-only - * marked secrets are inherited, and no remote secret is ever deleted. - */ -export async function syncCloudflareSchemaSecrets(options: { - root: string; - configPath: string; - accountId?: string; - workerName: string; - emittedVars: Record; - secretKeys: Array; - excludeKeys?: Array; - runWrangler: DeployWranglerRun; - beforeWranglerRun?: () => boolean; -}): Promise<{ - requiredSecretNames: Array; - uploaded: number; - workerWasMissing?: true; -}> { - const excluded = new Set(options.excludeKeys ?? []); - const secretKeys = [...new Set(options.secretKeys)].filter((key) => !excluded.has(key)); - if (secretKeys.length === 0) { - return { requiredSecretNames: [], uploaded: 0 }; - } - - const uploadValues: Record = {}; - for (const key of secretKeys) { - if (!Object.prototype.hasOwnProperty.call(options.emittedVars, key)) { - continue; - } - const value = options.emittedVars[key]; - if (value === '') { - continue; - } - if (typeof value !== 'string') { - throw new CloudflareSchemaSecretError( - `deploy: schema secret ${key} must resolve to a string before it can be uploaded.`, - 'invalid-value', - ); - } - uploadValues[key] = value; - } - - const beforeRun = (): void => { - if (options.beforeWranglerRun && !options.beforeWranglerRun()) { - throw new CloudflareSchemaSecretError('', 'uploader-changed'); - } - }; - let workerWasMissing = false; - const list = async (allowMissingWorker: boolean): Promise> => { - beforeRun(); - try { - return await listSelfHostSecretNames( - { - root: options.root, - configPath: options.configPath, - accountId: options.accountId, - workerName: options.workerName, - runWrangler: (args) => options.runWrangler(args, { capture: true }), - }, - secretKeys, - ); - } catch (error) { - if (allowMissingWorker && error instanceof SelfHostFirstDeployError) { - workerWasMissing = true; - return []; - } - if (error instanceof SelfHostFirstDeployError || error instanceof SelfHostSecretListError) { - throw new CloudflareSchemaSecretError( - 'deploy: could not confirm the remote schema secrets. No Worker was uploaded; run `void secret list` and retry.', - allowMissingWorker ? 'list-failed' : 'verification-failed', - ); - } - throw error; - } - }; - - const existing = await list(true); - const marked = new Set(secretKeys); - const requiredSecretNames = [ - ...new Set([...Object.keys(uploadValues), ...existing.filter((name) => marked.has(name))]), - ].sort(); - - const generated = readGeneratedDeployConfig(options.root); - const rawVars = generated.config.vars; - if ( - rawVars !== undefined && - (rawVars === null || typeof rawVars !== 'object' || Array.isArray(rawVars)) - ) { - throw new CloudflareSchemaSecretError( - 'deploy: the generated Cloudflare config has a malformed vars field; no schema secrets were uploaded.', - 'config-invalid', - ); - } - const vars = { ...((rawVars as Record | undefined) ?? {}) }; - for (const key of secretKeys) { - delete vars[key]; - } - generated.config.vars = vars; - - const rawSecrets = generated.config.secrets; - if ( - rawSecrets !== undefined && - (rawSecrets === null || typeof rawSecrets !== 'object' || Array.isArray(rawSecrets)) - ) { - throw new CloudflareSchemaSecretError( - 'deploy: the generated Cloudflare config has a malformed secrets field; no schema secrets were uploaded.', - 'config-invalid', - ); - } - const secrets = { ...((rawSecrets as Record | undefined) ?? {}) }; - const rawRequired = secrets.required; - if ( - rawRequired !== undefined && - (!Array.isArray(rawRequired) || !rawRequired.every((name) => typeof name === 'string')) - ) { - throw new CloudflareSchemaSecretError( - 'deploy: the generated Cloudflare config has a malformed secrets.required field; no schema secrets were uploaded.', - 'config-invalid', + } catch { + throw new Error( + `Generated Cloudflare deploy config ${redirectedPath} is missing or invalid JSON.`, ); } - const existingRequired = (rawRequired as Array | undefined) ?? []; - secrets.required = [...new Set([...existingRequired, ...requiredSecretNames])].sort(); - generated.config.secrets = secrets; - writeFileSync(generated.path, `${JSON.stringify(generated.config, null, 2)}\n`); - - const entries = Object.entries(uploadValues); - for (let offset = 0; offset < entries.length; offset += 100) { - const chunk = Object.fromEntries(entries.slice(offset, offset + 100)); - beforeRun(); - let result: DeployWranglerRunResult; - try { - result = await Promise.resolve( - options.runWrangler(['secret', 'bulk', '-c', options.configPath, '--env='], { - capture: true, - input: `${JSON.stringify(chunk)}\n`, - }), - ); - } catch { - throw new CloudflareSchemaSecretError( - 'deploy: Cloudflare schema-secret upload failed. No secret value was logged; retry the deploy.', - 'bulk-failed', - ); - } - if (!isCleanWranglerExit(result)) { - throw new CloudflareSchemaSecretError( - `deploy: Cloudflare schema-secret upload failed (wrangler exit ${String(result.status)}). No secret value was logged; retry the deploy.`, - 'bulk-failed', - ); - } - } - - if (entries.length > 0) { - const verified = new Set(await list(false)); - const missing = Object.keys(uploadValues).filter((key) => !verified.has(key)); - if (missing.length > 0) { - throw new CloudflareSchemaSecretError( - `deploy: Cloudflare did not confirm these uploaded schema secrets: ${missing.join(', ')}. No Worker was uploaded; retry once secret storage is available.`, - 'verification-failed', - ); - } - } - - return { - requiredSecretNames, - uploaded: entries.length, - ...(workerWasMissing ? { workerWasMissing: true as const } : {}), - }; } /** @@ -2374,8 +2140,6 @@ export interface CloudflareDeployDeps { dischargeSelfHostEnv?: typeof dischargeSelfHostEnvContext; /** Persistent Better Auth secret seam (default: {@link ensureCloudflareAuthSecret}). */ ensureAuthSecret?: typeof ensureCloudflareAuthSecret; - /** Schema-marked Worker secret sync seam (default: {@link syncCloudflareSchemaSecrets}). */ - syncSchemaSecrets?: typeof syncCloudflareSchemaSecrets; /** Generated-deploy-config verification seam (default: {@link verifyGeneratedDeployConfig}). */ verifyDeployConfig?: typeof verifyGeneratedDeployConfig; /** Resolve the generated config selected by the fresh deploy pointer. */ @@ -2397,7 +2161,7 @@ export interface CloudflareDeployDeps { /** * Isolated env-schema probe seam (default: the {@link EnvSchemaProbeInput} * subprocess). Receives a scrubbed env and returns the env-INSENSITIVE schema facts - * (required-secret NAMES + `.secret()` flags), derived by importing env.ts in the + * (server and required key names), derived by importing env.ts in the * child, so the parent runs no project code. A `schema-error` (env.ts failed to * evaluate, or a validator threw) fails the deploy closed. */ @@ -2419,7 +2183,7 @@ export interface CloudflareDeployDeps { /** * Emitted-worker-vars read seam (default: {@link readEmittedWorkerVarsDefault}). * Runs POST-build: reads the REAL `vars` map the build baked into - * `dist/ssr/wrangler.json` — the ground truth the plaintext warnings + secret gate + * `dist/ssr/wrangler.json` — the ground truth the plaintext-server-key + secret gate * validate. */ readEmittedWorkerVars?: (root: string) => EmittedWorkerVarsResult; @@ -2435,17 +2199,6 @@ const CLOUDFLARE_RESOURCE_LABELS: Record = hyperdrive: 'Hyperdrive config', }; -/** - * Why a plaintext worker var was flagged, rendered next to its key name. These - * are VALUES-FREE by construction — the classifier inspects a value to pick the - * reason, but only ever returns the key, so nothing here can echo a secret. - */ -const PLAINTEXT_WARNING_REASONS: Record = { - 'schema-secret': ' (env.ts .secret())', - heuristic: '', - 'value-shape': ' (value looks like a connection URL with embedded credentials)', -}; - const BETTER_AUTH_SECRET_NAME = 'BETTER_AUTH_SECRET'; type CloudflareAuthSecretFailureReason = @@ -4040,20 +3793,10 @@ export async function runCloudflareDeploy( return exit(1); } - // 3c. Derive the env-INSENSITIVE schema facts — the required-secret NAMES + the keys - // flagged `.secret()` — AND enforce the v1 envDir/mode narrowing, in an ISOLATED - // SUBPROCESS under a scrubbed env. Importing the user's env.ts + resolving the Vite - // config is arbitrary project code, so it must not run in this credential-bearing - // parent; the child does it safely (it `process.exit`s). The child resolves the config - // purely to read the effective `{ envDir, mode }` and REJECTS a custom envDir / - // non-production mode: the build generates the auth artifact under the project ROOT - // while the worker `vars` come from the build's envDir, so a custom envDir could - // validate a different table set than the worker ships. The REAL worker vars the gate - // validates come from the emitted `dist/ssr/wrangler.json` AFTER the build (ground - // truth), not from here. The child env scrubs the FULL wrangler-auth credential set and - // pins `NODE_ENV=production` (see {@link buildSchemaProbeEnv}). Run pre-build so a custom - // envDir / non-production mode / broken env.ts fails fast, before any account op - // (whoami / provision / build). + // 3c. Derive the env-INSENSITIVE server and required key names in an ISOLATED + // SUBPROCESS under a scrubbed env. Importing the user's env.ts is arbitrary project + // code, so it must not run in this credential-bearing parent. Run pre-build so a + // broken env.ts fails fast before any account operation. const schemaProbeEnv = buildSchemaProbeEnv(accountId, apiBaseUrl); let schemaResult: EnvSchemaProbeResult; try { @@ -4062,13 +3805,7 @@ export async function runCloudflareDeploy( schemaProbeEnv, ); } catch (err) { - log.error( - `deploy: could not derive the env schema (required secrets / .secret() flags) — ${getErrorMessage(err)}`, - ); - return exit(1); - } - if (schemaResult.kind === 'reject') { - log.error(cloudflareEnvDirModeRejectMessage(schemaResult)); + log.error(`deploy: could not derive the env schema — ${getErrorMessage(err)}`); return exit(1); } if (schemaResult.kind === 'schema-error') { @@ -4502,6 +4239,7 @@ export async function runCloudflareDeploy( voidConfig, detectedFramework, frameworkPreset, + schemaFacts.requiredKeys, ); } catch (error) { log.error( @@ -4562,31 +4300,6 @@ export async function runCloudflareDeploy( return exit(1); } const emittedVars = emitted.vars; - const schemaSecretKeys = schemaFacts.hasSchema - ? schemaFacts.secretKeys.filter((key) => !(authEnabled && key === BETTER_AUTH_SECRET_NAME)) - : []; - if (existingWorker && schemaSecretKeys.some((key) => Object.hasOwn(emittedVars, key))) { - throw new Error( - 'Linking preserves existing encrypted secrets. Remove local secret overrides and make secret changes after the handoff.', - ); - } - - // Explicit schema secrets are about to be promoted, so classify warnings - // against the plaintext vars that will actually remain in the artifact. - // Validate the transport shape early and without printing any value. - const remainingPlaintextVars = { ...emittedVars }; - for (const key of schemaSecretKeys) { - if (!Object.prototype.hasOwnProperty.call(emittedVars, key)) { - continue; - } - const value = emittedVars[key]; - if (value !== '' && typeof value !== 'string') { - log.error(`deploy: schema secret ${key} must resolve to a string before it can be uploaded.`); - return exit(1); - } - delete remainingPlaintextVars[key]; - } - // Better Auth's signing secret must never be baked into the generated config. // Match the managed Void deploy contract: the deploy target owns this secret // and stores it through its encrypted secret API, not through plaintext vars. @@ -4594,26 +4307,23 @@ export async function runCloudflareDeploy( log.error( [ `deploy: ${BETTER_AUTH_SECRET_NAME} must be stored as a Cloudflare Worker secret, not a plaintext worker var.`, - 'Remove it from .env / .env.production and rerun. This deploy command preserves an', + 'Remove it from Worker vars and rerun. This deploy command preserves an', 'existing remote value or creates a persistent one automatically when it is missing.', ].join('\n'), ); return exit(1); } - // 8a. Plaintext-var warnings — classify the secret-looking keys among the REAL emitted vars - // (`.secret()`-flagged from the schema facts, OR the shared name heuristic). Ground truth — - // no `loadEnv`, no facsimile. - const warnings = collectPlaintextVarWarningsFromVars(remainingPlaintextVars, []); - if (warnings.secretKeys.length > 0) { - log.warn( + const plaintextServerKeys = schemaFacts.serverKeys.filter((key) => key in emittedVars); + if (plaintextServerKeys.length > 0) { + log.error( [ - 'deploy: these worker vars ship as PLAINTEXT in the deployed config:', - // Key names + why they were flagged only — never a value. - ...warnings.secretKeys.map((k) => ` • ${k.key}${PLAINTEXT_WARNING_REASONS[k.reason]}`), - 'Move real secrets to `void secret put ` so they are not baked into wrangler.json.', + 'deploy: server env keys cannot ship as plaintext Worker vars:', + ...plaintextServerKeys.map((key) => ` • ${key}`), + 'Remove them from Worker vars and set them with `void secret put `.', ].join('\n'), ); + return exit(1); } // 8b. Required-secret gate — build the values-free context from the REAL emitted vars + the @@ -5027,40 +4737,7 @@ export async function runCloudflareDeploy( } } - // 10. Promote explicit `.secret()` schema values only after every migration - // gate/apply has succeeded, immediately before final verification + upload. - // The sync rewrites the fresh generated artifact to remove plaintext values, - // bulk uploads over stdin, and inherits both uploaded and remote-only marked - // secrets without pruning anything from Cloudflare. - let requiredSchemaSecretNames: Array = []; - if (schemaSecretKeys.length > 0) { - try { - const synced = await (deps.syncSchemaSecrets ?? syncCloudflareSchemaSecrets)({ - root, - configPath, - accountId, - workerName, - emittedVars, - secretKeys: schemaSecretKeys, - runWrangler, - beforeWranglerRun: assertUploaderStillPinned, - }); - workerWasMissingBeforeSecretProvisioning ||= synced.workerWasMissing === true; - requiredSchemaSecretNames = synced.requiredSecretNames; - cliLog.info('cloudflare_schema_secrets_ready', { uploaded: synced.uploaded }); - } catch (error) { - if (error instanceof CloudflareSchemaSecretError && error.reason === 'uploader-changed') { - return exit(1); - } - if (error instanceof CloudflareSchemaSecretError) { - log.error(error.message); - return exit(1); - } - throw error; - } - } - - // 10b. Verify the generated deploy config targets the pinned account + expected worker + // 10. Verify the generated deploy config targets the pinned account + expected worker // AND the exact provisioned binding IDENTITIES BEFORE uploading. A `CLOUDFLARE_ENV` / // `CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH` the CF plugin loaded could have baked a // different account/name — or the SAME account/name with a flattened, DIFFERENT binding @@ -5080,7 +4757,7 @@ export async function runCloudflareDeploy( passthroughRawFields, needsAI, authEnabled, - requiredSecretNames: requiredSchemaSecretNames, + requiredSecretNames: schemaFacts.requiredKeys, expectedContainers, expectedCrons, }); @@ -5121,8 +4798,7 @@ export async function runCloudflareDeploy( // Final uploader re-verification immediately before the deploy — the last and most privileged // credentialed run of the hashed bundle. Against the same pre-build baseline; fail closed. const generatedConfigPath = ( - deps.resolveGeneratedConfigPath ?? - ((projectRoot) => readGeneratedDeployConfig(projectRoot).path) + deps.resolveGeneratedConfigPath ?? resolveGeneratedDeployConfigPathDefault )(root); const queueConsumerCleanup = readWranglerManagedQueueCleanupCandidates(configPath); if (existingWorker) { @@ -5379,6 +5055,7 @@ export async function prepareCloudflareFrameworkArtifact( voidConfig: VoidConfig, framework: DetectedFramework, preset: FrameworkPreset, + requiredSecretNames: ReadonlyArray = [], ): Promise { const workerMain = join(root, preset.workerDir, preset.workerMain); const assetsDirectory = join(root, preset.assetsDir); @@ -5422,6 +5099,7 @@ export async function prepareCloudflareFrameworkArtifact( '2026-09-11', compatibilityFlags: preset.cloudflareCompatibilityFlags, crons: crons.flatMap((job) => job.crons), + requiredSecrets: requiredSecretNames, ...(preset.workerDir === preset.assetsDir ? { assetsIgnore: [ @@ -6245,13 +5923,9 @@ export type PrerenderSourceInputs = { * returns `[...new Set([])]`, so skipping is result-identical. * * Skipping matters beyond speed. `collectPrerenderPaths` boots the worker in - * Miniflare with bindings from `wranglerVars + loadEnv('production', root, '')` - * only, and the generated fetch handler validates the `env.ts` schema at boot. - * An app whose secrets live on the platform (never in a committed `.env`) gets - * an HTTP 500 there and the deploy dies — for an app that had nothing to - * prerender in the first place. The only workaround was moving the secret into - * `.env.production`, which `loadDeployEnv` then publishes as a plaintext - * manifest var. So this guard also removes a real incentive to leak secrets. + * Miniflare without remote production secrets, and the generated fetch handler + * validates `env.ts` at boot. An app with nothing to prerender should not fail + * merely because that unnecessary worker boot cannot access the remote store. */ export function hasPrerenderSources(inputs: PrerenderSourceInputs): boolean { return inputs.hasPages || inputs.hasSsrEntry || (inputs.explicitPrerenderPaths?.length ?? 0) > 0; @@ -7060,17 +6734,13 @@ async function runFrameworkDeploy( ...(q.maxBatchTimeout != null && { maxBatchTimeout: q.maxBatchTimeout }), })); - // 10. Load .env vars for production (excludes .local files) - const envVars = loadDeployEnv(root); + // Production env values are remote secrets. Client values are compiled + // from schema defaults or the build shell; no dotenv value enters the manifest. + const envVars: Record = {}; // 10a. Validate against env.ts schema if present. await assertEnvSchemaValid(root, client, config.projectId); - // 10b. Overlay schema `.default(value)` entries for keys not set in - // .env files so the deployed worker sees the same fallback as the - // typed `env.X` proxy. - await applySchemaDefaultsToVars(root, envVars); - // 11. Package & deploy const s = createSpinner(); const onProgress = (msg: string) => s.message(msg); @@ -7573,17 +7243,12 @@ async function runFullDeploy( ); const workerMain = resolveDeployWorkerMain(deployConfig, join(distDir, workerDirName)); - // 9. Load .env vars for production (excludes .local files) - const envVars = loadDeployEnv(root); + // Production env values are remote secrets; dotenv is local-only. + const envVars: Record = {}; // 9a. Validate against env.ts schema if present. await assertEnvSchemaValid(root, client, config.projectId); - // 9b. Overlay schema `.default(value)` entries for keys not set in - // .env files so the deployed worker sees the same fallback as the - // typed `env.X` proxy. - await applySchemaDefaultsToVars(root, envVars); - // 10. Package & Upload const s = createSpinner(); const onProgress = (msg: string) => s.message(msg); @@ -8022,24 +7687,6 @@ function migrationSnapshotsEqual( }); } -/** - * Overlay schema `.default(value)` entries onto the deploy manifest vars so - * the deployed worker sees the same fallback as the typed `env.X` proxy at - * runtime. User-provided `.env*` values always win — defaults only fill - * gaps. No-op when the project has no `env.ts`. - */ -async function applySchemaDefaultsToVars( - root: string, - envVars: Record, -): Promise { - const defaults = await getDeployEnvDefaults(root); - for (const [key, value] of Object.entries(defaults)) { - if (envVars[key] === undefined) { - envVars[key] = value; - } - } -} - /** * If the project has an `env.ts`, validate its schema against the union of * production env files and remote secret names. Hard-error before upload so @@ -8068,31 +7715,12 @@ async function assertEnvSchemaValid( log.error(`Env validation failed:\n${formatted}`); if (report.missing.length > 0) { log.info( - `Add the missing values to .env / .env.production, or upload them with \`void secret put \`.`, + 'Upload missing server values with `void secret put `; set VITE_* values in the build shell or schema defaults.', ); } throw new DeployEventError('deploy: Aborting because env.ts schema is not satisfied.'); } -/** - * Load env vars for deploy — only .env and .env.production (not .local files). - * Later files win on conflict. - */ -export function loadDeployEnv(root: string): Record { - // Same parse + merge + expand path env validation uses, so the shipped - // values match what was validated. - const files = ['.env', '.env.production']; - const parsed = files.map((file) => parseDotenvFile(join(root, file), { expand: false })); - const env = mergeAndExpand(parsed, { onWarn: (msg) => voidWarn(msg) }); - const { sanitized, stripped } = stripInternalEnvKeys(env); - if (stripped.length > 0) { - voidWarn( - `Ignoring internal env keys from .env files: ${stripped.join(', ')}. These are reserved for Void runtime use and will not be shipped in the deploy manifest.`, - ); - } - return sanitized; -} - /** * Resolve the top-level build output directory. * diff --git a/packages/void/src/cli/env-args.ts b/packages/void/src/cli/env-args.ts index 32a6fb31b..8d88d1153 100644 --- a/packages/void/src/cli/env-args.ts +++ b/packages/void/src/cli/env-args.ts @@ -4,7 +4,7 @@ import type { SubcommandDef } from './subcommand-prompt.ts'; export const envSubcommands: Array = [ { name: 'check', - description: 'Validate env.ts schema against .env files (and optionally remote secrets)', + description: 'Validate local .env or required remote secret names', flags: [ { name: '--remote', @@ -16,22 +16,9 @@ export const envSubcommands: Array = [ name: 'types', description: 'Regenerate .void/env.d.ts from env.ts', }, - { - name: 'example', - description: 'Refresh the void-managed block in .env.example', - flags: [ - { - name: '--force', - description: 'Suppress the "appended block" notice (for scripted runs)', - }, - ], - }, ]; -export type EnvArgs = - | { subcommand: 'check'; useRemoteSecrets: boolean } - | { subcommand: 'types' } - | { subcommand: 'example'; force: boolean }; +export type EnvArgs = { subcommand: 'check'; useRemoteSecrets: boolean } | { subcommand: 'types' }; export function parseEnvArgs(args: Array): EnvArgs { const [subcommand, ...rest] = args; @@ -53,17 +40,5 @@ export function parseEnvArgs(args: Array): EnvArgs { } return { subcommand: 'types' }; } - if (subcommand === 'example') { - const parsed = parseArgs({ - args: rest, - options: { force: { type: 'boolean' } }, - allowPositionals: true, - strict: true, - }); - if (parsed.positionals.length > 0) { - throw new Error(`env: Unexpected argument '${parsed.positionals[0]}'.`); - } - return { subcommand: 'example', force: !!parsed.values.force }; - } - throw new Error('env: Usage: void env .'); + throw new Error('env: Usage: void env .'); } diff --git a/packages/void/src/cli/env-example.ts b/packages/void/src/cli/env-example.ts deleted file mode 100644 index 339fc1380..000000000 --- a/packages/void/src/cli/env-example.ts +++ /dev/null @@ -1,338 +0,0 @@ -/** - * `void env example` — generate a `.env.example` block from the registered - * env schema, managed as a marker-delimited section so any user-managed - * entries (CI tokens, build flags, etc.) above or below the block survive - * a refresh. - * - * Marker convention mirrors the one used by `init.ts` for CLAUDE.md / - * AGENTS.md instructions: a literal start marker, a body, and a literal - * end marker. The markers are hash-prefixed so they remain valid dotenv - * comments and don't trip up tools that parse `.env` files. - * - * Schema introspection strategy: - * - Call `schema['~standard'].validate(undefined)`: - * • `{ value: undefined }` → optional (no default) - * • `{ value: }` → has a default (value is ``) - * • `{ issues: [...] }` → required - * - For enum hints, probe with a sentinel that is unlikely to match and - * parse the built-in `oneOf` error message ("Expected one of A, B, got ..."), - * yielding the valid values. This only works for Void's built-in - * `oneOf()` helper — third-party validators silently degrade to no hint, - * which is fine because `.env.example` is purely informational. - * - * This keeps the introspection layer Standard Schema-friendly (no private - * fields, no type-widening) while still giving a good DX for the common - * Void helpers. - */ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { log, outro } from '@clack/prompts'; -import { join } from 'pathslash'; -import { findEnvFile } from '../codegen/env-types.ts'; -import { loadUserEnvFile } from '../host/load-user-env.ts'; -import type { StandardSchemaV1 } from '../standard-schema.ts'; - -type EnvPublicModule = { - _getRegisteredSchema(): Record> | null; -}; - -/** Load the user's env.ts so `defineEnv` registers the schema. */ -async function loadRegisteredSchema( - envFile: string, -): Promise> | null> { - await loadUserEnvFile(envFile); - const mod = (await import('../runtime/env-public.ts')) as unknown as EnvPublicModule; - return mod._getRegisteredSchema(); -} - -/** Classification of a single env key for display. */ -export type EntryKind = - | { kind: 'required'; enumValues?: Array } - | { kind: 'default'; defaultValue: unknown; enumValues?: Array } - | { kind: 'optional'; enumValues?: Array }; - -export type EnvExampleEntry = { - key: string; - classification: EntryKind; -}; - -/** - * Introspect a single schema into an {@link EntryKind}. - * - * The probing logic is pure: it only calls the public `~standard.validate` - * contract, so it works for any Standard Schema-compliant validator. - */ -export function classifySchema(schema: StandardSchemaV1): EntryKind { - const enumValues = tryExtractEnumValues(schema); - - const probe = schema['~standard'].validate(undefined); - if (probe instanceof Promise) { - // Async validators can't be introspected synchronously; treat as required - // (safest default — users must supply a value). - return { kind: 'required', enumValues }; - } - - if ('issues' in probe && probe.issues) { - return { kind: 'required', enumValues }; - } - // Success path: distinguish optional (value === undefined) vs default. - if (probe.value === undefined) { - return { kind: 'optional', enumValues }; - } - return { kind: 'default', defaultValue: probe.value, enumValues }; -} - -/** - * Attempt to pull enum values out of a Void `oneOf()` schema by probing with - * an invalid sentinel and parsing the error message. Returns undefined for - * any schema that isn't a built-in `oneOf`. - */ -function tryExtractEnumValues( - schema: StandardSchemaV1, -): Array | undefined { - const sentinel = '__void_env_example_probe__'; - const result = schema['~standard'].validate(sentinel); - if (result instanceof Promise) { - return undefined; - } - if (!('issues' in result) || !result.issues || result.issues.length === 0) { - return undefined; - } - const message = result.issues[0].message; - // Built-in oneOf message: `Expected one of A, B, C, got "..."` - const match = /^Expected one of (.+), got "/.exec(message); - if (!match) { - return undefined; - } - return match[1].split(',').map((s) => s.trim()); -} - -/** Serialize a default value into a dotenv-friendly string. */ -function serializeDefault(value: unknown): string { - if (typeof value === 'string') { - return value; - } - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - // Objects / arrays from json().default(...): stringify. - try { - return JSON.stringify(value); - } catch { - return ''; - } -} - -/** - * Build the body of the managed env block (no markers) from a registered - * schema map. - * - * Grouped by: - * 1. required (no default, not optional) - * 2. required with default (prefilled value) - * 3. optional (empty value) - * - * Enum-valued keys get a leading `# enum: A | B | C` comment. - */ -export function formatEnvExample( - schema: Record>, -): string { - const entries: Array = Object.entries(schema).map(([key, s]) => ({ - key, - classification: classifySchema(s), - })); - - const required = entries.filter((e) => e.classification.kind === 'required'); - const withDefault = entries.filter((e) => e.classification.kind === 'default'); - const optional = entries.filter((e) => e.classification.kind === 'optional'); - - const lines: Array = []; - - const writeGroup = (label: string, group: Array) => { - if (group.length === 0) { - return; - } - if (lines.length > 0) { - lines.push(''); - } - lines.push(`# ${label}`); - for (const entry of group) { - const enumValues = entry.classification.enumValues; - if (enumValues && enumValues.length > 0) { - lines.push(`# enum: ${enumValues.join(' | ')}`); - } - if (entry.classification.kind === 'default') { - lines.push(`${entry.key}=${serializeDefault(entry.classification.defaultValue)}`); - } else { - lines.push(`${entry.key}=`); - } - } - }; - - writeGroup('required', required); - writeGroup('with defaults', withDefault); - writeGroup('optional', optional); - - // Trailing newline so the body terminates cleanly before the end marker. - return lines.join('\n') + (lines.length > 0 ? '\n' : ''); -} - -/** - * Literal start/end markers for the void-managed block. They are valid - * dotenv comments (hash-prefixed) so any tool that parses `.env.example` - * silently ignores them, and they are matched as whole lines (with - * defensive trailing-whitespace tolerance) when locating the block. - */ -export const ENV_EXAMPLE_START_MARKER = - '# >>> void env: managed block — do not edit between markers <<<'; -export const ENV_EXAMPLE_END_MARKER = '# >>> end void env <<<'; -const ENV_EXAMPLE_MARKER_HINT = '# Run `void env example` to refresh.'; - -/** Regex matchers for the markers — full line, ignore trailing whitespace. */ -const START_MARKER_RE = new RegExp( - `^${ENV_EXAMPLE_START_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, -); -const END_MARKER_RE = new RegExp( - `^${ENV_EXAMPLE_END_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, -); - -function buildMarkerBlock(body: string): string { - // Body already terminates with `\n` when non-empty (see `formatEnvExample`). - // Drop the trailing newline so the end marker sits flush, then re-add a - // single newline after the end marker for POSIX-friendly files. - const trimmedBody = body.endsWith('\n') ? body.slice(0, -1) : body; - const parts = [ENV_EXAMPLE_START_MARKER, ENV_EXAMPLE_MARKER_HINT]; - if (trimmedBody.length > 0) { - parts.push(trimmedBody); - } - parts.push(ENV_EXAMPLE_END_MARKER); - return parts.join('\n') + '\n'; -} - -export type ApplyMarkerAction = 'create' | 'replace' | 'append' | 'error'; - -export type ApplyMarkerResult = { - content: string; - action: ApplyMarkerAction; - reason?: string; -}; - -/** - * Pure helper that produces the new `.env.example` content given the - * existing file contents (or `null` when the file does not exist) and the - * formatted env body (without marker lines). - * - * Behavior matrix: - * - existing == null → `create` a fresh marker-only file - * - both markers present → `replace` the lines between them - * - no markers present → `append` the block at the end - * - exactly one marker present → `error` (mismatched markers) - */ -export function applyMarkerBlock(existing: string | null, body: string): ApplyMarkerResult { - const block = buildMarkerBlock(body); - - if (existing === null) { - return { content: block, action: 'create' }; - } - - const lines = existing.split('\n'); - let startIdx = -1; - let endIdx = -1; - for (let i = 0; i < lines.length; i++) { - if (startIdx === -1 && START_MARKER_RE.test(lines[i])) { - startIdx = i; - continue; - } - if (startIdx !== -1 && END_MARKER_RE.test(lines[i])) { - endIdx = i; - break; - } - } - // Independently locate an end marker even if no start marker was found, - // so we can detect the "end without start" mismatch case. - const hasStart = lines.some((l) => START_MARKER_RE.test(l)); - const hasEnd = lines.some((l) => END_MARKER_RE.test(l)); - - if (hasStart && !hasEnd) { - return { - content: existing, - action: 'error', - reason: - 'Found start marker but no matching end marker in .env.example. Restore `# >>> end void env <<<` or delete the file and re-run `void env example`.', - }; - } - if (!hasStart && hasEnd) { - return { - content: existing, - action: 'error', - reason: - 'Found end marker but no matching start marker in .env.example. Restore `# >>> void env: ... <<<` or delete the file and re-run `void env example`.', - }; - } - - if (startIdx !== -1 && endIdx !== -1) { - const before = lines.slice(0, startIdx); - const after = lines.slice(endIdx + 1); - // `block` already has its own trailing newline. Splice as text: - // join before + block + after, taking care to preserve the file's - // original trailing-newline shape from `after`. - const beforeText = before.length > 0 ? before.join('\n') + '\n' : ''; - const afterText = after.join('\n'); - return { content: beforeText + block + afterText, action: 'replace' }; - } - - // Append: separate from existing content with a single blank line, and - // tolerate files that don't end in a newline. - const trimmed = existing.replace(/\s+$/, ''); - const separator = trimmed.length > 0 ? '\n\n' : ''; - return { content: trimmed + separator + block, action: 'append' }; -} - -export type RunEnvExampleOptions = { - /** Suppress informational notes (e.g. the "appended block" message) for scripts. */ - force: boolean; -}; - -/** CLI entrypoint: load schema, manage marker block, write `.env.example`. */ -export async function runEnvExample(root: string, options: RunEnvExampleOptions): Promise { - const envFile = findEnvFile(root); - if (!envFile) { - outro('No env.ts found at project root — nothing to generate.'); - return; - } - const schema = await loadRegisteredSchema(envFile); - if (!schema || Object.keys(schema).length === 0) { - outro('env.ts registered no keys — nothing to generate.'); - return; - } - - const outPath = join(root, '.env.example'); - const existing = existsSync(outPath) ? readFileSync(outPath, 'utf-8') : null; - const body = formatEnvExample(schema); - const result = applyMarkerBlock(existing, body); - - if (result.action === 'error') { - log.error(result.reason ?? 'Failed to update .env.example.'); - process.exit(1); - } - - // Don't rewrite if content is identical — keeps mtime stable for CI/caching. - if (existing !== null && existing === result.content) { - outro('.env.example is already up to date.'); - return; - } - - writeFileSync(outPath, result.content); - const count = Object.keys(schema).length; - const keyCount = `${count} key${count === 1 ? '' : 's'}`; - if (result.action === 'create') { - log.success(`Wrote .env.example (${keyCount}).`); - } else if (result.action === 'replace') { - log.success(`Refreshed void env block in .env.example (${keyCount}).`); - } else if (result.action === 'append') { - log.success(`Refreshed void env block in .env.example (${keyCount}).`); - if (!options.force) { - log.info('appended void env block to existing .env.example'); - } - } - outro('Done.'); -} diff --git a/packages/void/src/cli/env-scaffold.ts b/packages/void/src/cli/env-scaffold.ts index ecd8026c0..2fbb74319 100644 --- a/packages/void/src/cli/env-scaffold.ts +++ b/packages/void/src/cli/env-scaffold.ts @@ -13,14 +13,8 @@ import { existsSync, writeFileSync } from 'node:fs'; import { join } from 'pathslash'; import { expandDotenv, parseDotenvFile as sharedParseDotenvFile } from '../shared/dotenv.ts'; -/** Dotenv files we harvest keys from, in trust order (first wins per key). */ -export const SCAFFOLD_SOURCE_FILES = [ - '.env', - '.env.local', - '.env.development', - '.env.development.local', - '.env.example', -] as const; +/** Void has one local dotenv source. */ +export const SCAFFOLD_SOURCE_FILES = ['.env'] as const; export type InferredHelper = 'string' | 'number' | 'boolean' | 'url'; @@ -75,12 +69,8 @@ export function parseDotenvFile(filePath: string): Record { /** Collect a deduped list of inferred entries from the dotenv files we find. */ export function collectInferredEntries(root: string): Array { const seen = new Map(); - // Walk files in trust order (first wins). At each step we expand the - // current file's values against the cumulative "earliest-wins" record so - // that a value like `API_URL=${BASE_URL}/v1` resolves even when - // `BASE_URL` lives in a previously-seen file. A per-file expanded view is - // what feeds type inference, so an unresolved literal isn't mis-classified - // as a plain string when it would have been a URL after expansion. + // Keep this loop-shaped so the parser remains easy to extend, while the + // supported source set intentionally contains only `.env`. const cumulative: Record = {}; for (const file of SCAFFOLD_SOURCE_FILES) { const parsed = sharedParseDotenvFile(join(root, file), { expand: false }); @@ -97,9 +87,7 @@ export function collectInferredEntries(root: string): Array { } const existing = seen.get(key); // First file to mention the key wins for the sample value, but a - // later file with a non-empty value can still upgrade inference if - // the earlier value was empty (e.g. `.env.example` had `FOO=` and - // `.env.local` has `FOO=3000`). + // later source with a non-empty value can still upgrade inference. if (!existing) { seen.set(key, { key, @@ -144,7 +132,7 @@ export function renderEnvTs(entries: Array): string { const lines: Array = []; lines.push(`import { defineEnv, ${importList.join(', ')} } from 'void/env';`); lines.push(''); - lines.push(`// Scaffolded from your .env files. Inference is conservative — review each`); + lines.push(`// Scaffolded from .env. Inference is conservative — review each`); lines.push(`// entry and tighten types as needed (e.g. oneOf([...]), url(), .optional(),`); lines.push(`// .default(value), or a Standard Schema validator from valibot/zod/arktype).`); lines.push(`export default defineEnv({`); @@ -165,7 +153,7 @@ export type ScaffoldResult = { }; /** - * If the project has no `env.ts`, scaffold one based on `.env*` files the + * If the project has no `env.ts`, scaffold one based on the local `.env` * user already has. Safe to call unconditionally during `void init` — it * no-ops when `env.ts` is present. Never overwrites an existing file. */ diff --git a/packages/void/src/cli/env-schema-probe.ts b/packages/void/src/cli/env-schema-probe.ts index df06a5663..d5c265576 100644 --- a/packages/void/src/cli/env-schema-probe.ts +++ b/packages/void/src/cli/env-schema-probe.ts @@ -1,7 +1,6 @@ import { spawnSync } from 'node:child_process'; import { writeSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { join } from 'pathslash'; import type { SelfHostSchemaFacts, SelfHostSchemaFailureReason } from './env-validation.ts'; /** @@ -12,15 +11,8 @@ import type { SelfHostSchemaFacts, SelfHostSchemaFailureReason } from './env-val * token / `CLOUDFLARE_API_TOKEN` in `process.env`) and must NOT evaluate arbitrary * project code in-process: * - * 1. `facts` — the env-INSENSITIVE schema facts (required-secret NAMES + the - * `.secret()` flags), fixed by env.ts's structure. The child ALSO resolves the - * Vite config here purely to read the final `{ envDir, mode }` and reject a - * custom `envDir` or a non-`production` build mode (v1 supports neither): the - * build generates the auth artifact under the project root while the worker - * `vars` come from the build's `envDir`, so a custom `envDir` could validate a - * different table set than the worker ships. Resolving the config runs the full - * plugin pipeline (arbitrary code, timers/`beforeExit` risk) — safe HERE in the - * child (which `process.exit`s), never in the credentialed parent. + * 1. `facts` — the env-INSENSITIVE server and required key names, fixed by + * env.ts's structure. * 2. `validate-emitted` — validate the REAL emitted worker `vars` (post-build * ground truth) against the schema's Standard-Schema validators and return the * FAILING key NAMES only. A required var shipped with an INVALID value (fails @@ -54,23 +46,12 @@ export type EnvSchemaProbeInput = { root: string }; /** Full child input (adds the operation discriminant to {@link EnvSchemaProbeInput}). */ type ChildInput = { op: EnvSchemaProbeOp; root: string }; -/** The reason the `facts` probe rejected the app as unsupported (values-free). */ -export type EnvSchemaProbeRejectReason = 'custom-envdir' | 'non-production-mode'; - /** * The structured result of the `facts` operation. `ok` carries the env-insensitive - * schema facts; `reject` fails the deploy closed pre-account with clear v1-scope - * guidance; `schema-error` surfaces a values-free env.ts/validator failure. - * - * `reject` crosses the process boundary as a FIXED reason CODE only — never the raw - * resolved `mode` / `envDir` strings. Those are arbitrary Vite-config-derived values - * (a config could set `envDir`/`mode` from any inherited secret), so echoing them - * over fd 3 or into a parent log would break the values-free boundary; the parent - * renders fixed guidance from the code alone. + * schema facts; `schema-error` surfaces a values-free env.ts/validator failure. */ export type EnvSchemaProbeResult = | { kind: 'ok'; facts: SelfHostSchemaFacts } - | { kind: 'reject'; reason: EnvSchemaProbeRejectReason } | { kind: 'schema-error'; reason: SelfHostSchemaFailureReason }; /** @@ -168,9 +149,8 @@ async function spawnEnvSchemaProbe( } /** - * PARENT side (`facts`): return the env-insensitive schema facts + the v1 envDir/mode - * narrowing. Runs pre-build so a custom envDir / non-production mode / broken env.ts - * fails fast, before any account op. + * PARENT side (`facts`): return the env-insensitive schema facts. Runs pre-build so + * a broken env.ts fails fast, before any account operation. */ export async function resolveEnvSchemaFactsViaSubprocess( input: EnvSchemaProbeInput, @@ -196,8 +176,8 @@ export async function validateEmittedVarsViaSubprocess( spawn: EnvSchemaProbeSpawn = defaultSpawn, ): Promise { const result = await spawnEnvSchemaProbe('validate-emitted', input, env, spawn); - if (result.kind === 'ok' || result.kind === 'reject') { - // The `validate-emitted` operation never emits ok/reject; treat it as infra. + if (result.kind === 'ok') { + // The `validate-emitted` operation never emits ok; treat it as infra. throw new Error( 'the env-schema probe returned an unexpected result for the validate-emitted operation.', ); @@ -235,12 +215,6 @@ function parseProbeResult(fd3: string): EnvSchemaProbeRawResult | undefined { if (record.kind === 'ok' && isSchemaFacts(record.facts)) { return { kind: 'ok', facts: record.facts }; } - if ( - record.kind === 'reject' && - (record.reason === 'custom-envdir' || record.reason === 'non-production-mode') - ) { - return { kind: 'reject', reason: record.reason }; - } if ( record.kind === 'invalid-keys' && Array.isArray(record.invalidKeys) && @@ -262,13 +236,13 @@ function isSchemaFacts(value: unknown): value is SelfHostSchemaFacts { if (!value || typeof value !== 'object') { return false; } - const f = value as { hasSchema?: unknown; requiredKeys?: unknown; secretKeys?: unknown }; + const f = value as { hasSchema?: unknown; serverKeys?: unknown; requiredKeys?: unknown }; return ( typeof f.hasSchema === 'boolean' && Array.isArray(f.requiredKeys) && f.requiredKeys.every((k) => typeof k === 'string') && - Array.isArray(f.secretKeys) && - f.secretKeys.every((k) => typeof k === 'string') + Array.isArray(f.serverKeys) && + f.serverKeys.every((k) => typeof k === 'string') ); } @@ -278,84 +252,10 @@ function emitResult(result: EnvSchemaProbeRawResult): void { } /** - * Read the RAW (root-relative, unnormalized) envDir the build's worker-vars loader - * actually uses, straight from the `void:env` config hook's captured value (exposed on - * that plugin's `api.getVoidDeployBuildConfig`). This is the only faithful input to the - * build's OWN `join(root, rawEnvDir)` computation: Vite's `resolved.envDir` is already - * normalized (`path.resolve`), which loses the distinction an absolute-root envDir needs. - * Returns `undefined` when the hook is absent (a non-void config), which the caller - * treats as "no custom raw envDir" and falls back to Vite's normalized view. - */ -function readBuildRawEnvDir( - plugins: ReadonlyArray<{ name: string; api?: unknown }>, -): string | undefined { - const plugin = plugins.find((p) => p.name === 'void:env'); - const api = plugin?.api as - | { getVoidDeployBuildConfig?: () => { mode: string; envDir: string | undefined } | undefined } - | undefined; - return api?.getVoidDeployBuildConfig?.()?.envDir; -} - -/** - * CHILD side (`facts`): resolve the Vite config HERE (the FULL plugin pipeline the - * real `vite build` runs, under the scrubbed env — never in the parent) purely to - * read the effective `{ envDir, mode }` and reject a custom envDir / non-production - * mode; then, when accepted, import the user's env.ts and derive the env-insensitive - * schema facts. All heavy imports are DYNAMIC so a plain library import of this - * module (by the CLI, for the parent-side helper) never pulls Vite / env-validation / - * the user's env.ts in. + * CHILD side (`facts`): import the user's env.ts and derive its structural schema + * facts. The import stays isolated because env.ts is arbitrary project code. */ async function runFactsChild(root: string): Promise { - const { resolveConfig } = await import('vite'); - // The FULL config-hook pipeline (exactly what `vite build` runs) so a top-level - // `mode`/`envDir` a config file or plugin sets is observed. `resolved.envDir` is - // `false` (env loading disabled) or the absolute normalized dir; the default - // equals `resolved.root`. - const resolved = await resolveConfig({ root }, 'build', 'production', 'production'); - // Reject a custom envDir by the SAME computation the BUILD uses. Vite normalizes - // `resolved.envDir` via `path.resolve(root, envDir)` (absolute-safe), so an ABSOLUTE - // envDir equal to the project root collapses to `resolved.root` and would ACCEPT — but - // the build's worker-vars loader (`index.ts`) uses `join(root, rawEnvDir)`, and - // `join(root, )` DUPLICATES the path (`/proj/proj`), so the build loads - // `vars` from the WRONG dir while the auth artifact is generated under the root: the - // exact vars-vs-auth divergence this guard exists to reject. So derive the build's - // ACTUAL vars dir from the RAW envDir the `void:env` hook captured and reject unless it - // equals the root — unset / `'.'` → root → accept; a relative subdir → non-root → - // reject; an absolute-root → `join` duplicates → non-root → reject. Also keep rejecting - // Vite's own `envDir === false` (env loading disabled) and its normalized-non-root view - // (a robust fallback that still catches a custom subdir if the `void:env` hook is absent). - // NOTE: `index.ts` uses `join` (not Vite's `resolve`) for the raw envDir, so the build - // itself mishandles an absolute envDir; this guard fails such configs CLOSED here. - // Switching `index.ts` `join`→`resolve` is a separate build-side follow-up (not done here). - const rawEnvDir = readBuildRawEnvDir(resolved.plugins); - const buildVarsDir = rawEnvDir ? join(resolved.root, rawEnvDir) : resolved.root; - if ( - resolved.envDir === false || - resolved.envDir !== resolved.root || - buildVarsDir !== resolved.root - ) { - // v1 supports only a root-relative envDir. Only the fixed reason code crosses the - // boundary (never the raw envDir string). - emitResult({ kind: 'reject', reason: 'custom-envdir' }); - return; - } - if (resolved.mode !== 'production') { - // A non-production build mode selects a different `.env.` set than the - // deploy validates — v1 supports only the production build. Fixed reason code only. - emitResult({ kind: 'reject', reason: 'non-production-mode' }); - return; - } - - // `resolveConfig` above already ran `void:env-schema`'s config hook, which imports the - // user's `env.ts` — so deriving the facts imports it a SECOND time, through the - // cache-busting loader. That is deliberate, and reusing the registration from config - // resolution instead would be unsafe: `importUserEnv` SWALLOWS a load error (it warns - // and returns), so a throwing `env.ts` would leave nothing registered, report - // `hasSchema: false`, and the required-secret gate would then validate ZERO keys and let - // the deploy through. Re-importing surfaces that failure as `schema-error`, which the - // orchestrator treats as fatal. Re-evaluation is a supported path — `defineEnv` is - // last-write-wins and resets its caches for exactly this (HMR re-imports do the same), - // and the build itself evaluates `env.ts` several times per process. const { deriveSelfHostSchemaFacts, SelfHostSchemaError } = await import('./env-validation.ts'); try { const facts = await deriveSelfHostSchemaFacts(root); diff --git a/packages/void/src/cli/env-validation.ts b/packages/void/src/cli/env-validation.ts index a31f2212e..327aca4ba 100644 --- a/packages/void/src/cli/env-validation.ts +++ b/packages/void/src/cli/env-validation.ts @@ -2,7 +2,7 @@ * Shared env-schema validation for CLI commands. * * Loads the user's `env.ts` (which calls `defineEnv` as a side effect), - * collects sources of env values (`.env*` files, remote secrets), and + * validates the local `.env` or remote production-secret state, and * validates each declared key against the registered schema. * * Used by both `void deploy` (hard error on missing prod env) and @@ -15,36 +15,53 @@ import { dirname, join, resolve } from 'pathslash'; import { findEnvFile } from '../codegen/env-types.ts'; import { loadUserEnvFile } from '../host/load-user-env.ts'; import { voidWarn } from '../log.ts'; -import { getSecretOverride } from '../runtime/env-helpers.ts'; import type { EnvValidationReport } from '../runtime/env-public.ts'; -import { mergeAndExpand, parseDotenvFile } from '../shared/dotenv.ts'; -import { isLikelySecretKey } from '../shared/env-mask.ts'; +import { findLegacyEnvFiles, isClientEnvKey, loadLocalEnv } from '../shared/env.ts'; import type { StandardSchemaV1 } from '../standard-schema.ts'; import type { PlatformClient } from './client.ts'; import { resolveWranglerArgv } from './preset.ts'; type EnvPublicModule = { - _getRegisteredSchema(): Record | null; + _getRegisteredSchema(): Record> | null; _hasRegisteredSchema(): boolean; _getSchemaDefaults(): Record; validateAllEnv(values: Record): Promise; }; /** - * After the user's env.ts has been imported (via `validateProdEnv` or - * `loadSchema`), read the registered schema's `.default(value)` entries. - * Returns `{}` if no schema was registered. + * Validate plaintext while it is still available to `void secret put/sync`. + * Existing remote values remain unreadable and are validated again at worker boot. */ -export async function getDeployEnvDefaults(root: string): Promise> { +export async function validateSecretValues( + root: string, + entries: Array<{ name: string; value: string }>, +): Promise { const envFile = findEnvFile(root); if (!envFile) { - return {}; + throw new Error('secret: env.ts is required before setting production values.'); } const mod = await loadSchema(envFile); - if (!mod) { - return {}; + const schema = mod?._getRegisteredSchema(); + if (!schema) { + throw new Error('secret: env.ts did not register a schema with defineEnv().'); + } + + for (const { name, value } of entries) { + if (isClientEnvKey(name)) { + throw new Error( + `secret: ${name} is client-exposed. Set it in the build shell or give it a schema default.`, + ); + } + const validator = schema[name]; + if (!validator) { + throw new Error(`secret: ${name} is not declared in env.ts.`); + } + const result = validator['~standard'].validate(value); + const resolved = result instanceof Promise ? await result : result; + if (resolved.issues) { + throw new Error(`secret: ${name} failed env.ts validation.`); + } } - return mod._getSchemaDefaults(); } /** Load the registered schema by importing the user's env.ts. */ @@ -58,7 +75,7 @@ async function loadSchema(envFile: string): Promise { } export type ValidateProdEnvOptions = { - /** When true (deploy mode), only `.env` + `.env.production` are read. */ + /** When true, validate build-shell + remote state and never read `.env`. */ productionOnly: boolean; /** Optional remote secret list (names only). Treated as "value present". */ remoteSecrets?: Array; @@ -74,14 +91,20 @@ function emptyReport(): EnvValidationReport { } /** - * Validate the user's env schema against the union of .env files and - * remote secret names. Remote secrets are treated as "present" (we can't - * fetch their values), which is sufficient for the missing-key check. + * Validate local development against `.env` + the shell, or production + * against the build shell + remote secret names. `.env` is never a + * production source. */ export async function validateProdEnv( root: string, options: ValidateProdEnvOptions, ): Promise { + const legacyFiles = findLegacyEnvFiles(root); + if (legacyFiles.length > 0) { + throw new Error( + `env: only .env is supported for local development. Remove ${legacyFiles.join(', ')} and follow the env migration guide.`, + ); + } const envFile = findEnvFile(root); if (!envFile) { return { hasSchema: false, report: emptyReport() }; @@ -91,24 +114,22 @@ export async function validateProdEnv( return { hasSchema: false, report: emptyReport() }; } - const files = options.productionOnly - ? ['.env', '.env.production'] - : ['.env', '.env.local', '.env.production', '.env.production.local']; - // Parse each file with expansion disabled, then merge + expand across the - // union so that (e.g.) `.env.local` can reference a key declared in `.env`. - const parsedFiles = files.map((file) => parseDotenvFile(join(root, file), { expand: false })); - const merged: Record = mergeAndExpand(parsedFiles, { - onWarn: (msg) => voidWarn(msg), - }); + const local = options.productionOnly + ? {} + : loadLocalEnv(root, { onWarn: (message) => voidWarn(message) }); + const shell = options.productionOnly + ? Object.fromEntries(Object.entries(process.env).filter(([key]) => isClientEnvKey(key))) + : process.env; + const merged: Record = { ...local, ...shell }; const report = await mod.validateAllEnv(merged); // Remote secrets are present on the deployed worker but not readable here, // so we can't run the user's validator against the real value. Treat any - // schema key that is only "missing locally" but "present remotely" as OK — + // server schema key that is only missing but present remotely as OK — // the boot-time `_validateEnvOnce` will validate the real value per isolate. const remoteSet = new Set(options.remoteSecrets ?? []); if (remoteSet.size > 0 && report.missing.length > 0) { - report.missing = report.missing.filter((key) => !remoteSet.has(key)); + report.missing = report.missing.filter((key) => isClientEnvKey(key) || !remoteSet.has(key)); if (report.missing.length === 0 && report.invalid.length === 0) { report.valid = true; } @@ -157,68 +178,12 @@ export async function fetchRemoteSecretNames( // against the platform's secret store. The self-host path bypasses the platform // entirely, so it needs its own safety units over the REAL build artifact // (`dist/ssr/wrangler.json` `vars`), defined further below: -// 1. `collectPlaintextVarWarningsFromVars` — WARN: the build bakes worker -// `vars` verbatim into the uploaded config (plaintext); report which of the -// emitted keys look like secrets so the caller can warn. -// 2. `deriveSelfHostEnvContextFromVars` + `dischargeSelfHostEnvContext` — GATE: +// `deriveSelfHostEnvContextFromVars` + `dischargeSelfHostEnvContext` — GATE: // the platform-free analogue of `assertEnvSchemaValid`, sourcing remote // secret names from `wrangler secret list` instead of the platform API. // Both consume ground truth (the emitted map), never a pre-build facsimile. // --------------------------------------------------------------------------- -/** One worker-vars key that looks like a secret, with why it was flagged. */ -export type PlaintextVarWarning = { - key: string; - /** - * `schema-secret` — the user's env.ts flags this key `.secret()` (explicit, - * always wins). `heuristic` — the key NAME matches the shared - * `isLikelySecretKey` credential heuristic (`KEY|TOKEN|SECRET|…`). - * `value-shape` — the key name looks benign but the VALUE is a - * credential-bearing connection URL ({@link isCredentialBearingUrlValue}). - * An explicit `.public()` does NOT suppress the heuristics here (it only means - * "not a schema secret"): the value still ships as PLAINTEXT in the worker - * config, so a secret-looking public key is still worth flagging. - */ - reason: 'schema-secret' | 'heuristic' | 'value-shape'; -}; - -/** - * A `scheme://user:password@host…` connection URL — the shape that carries a - * live credential in a key nothing in the NAME heuristic would catch. The - * motivating case is `DATABASE_URL`: `SECRET_KEY_RE` has no `URL`/`DATABASE` - * branch, and the shared `isLikelySecretValue` cannot help either — its - * `HIGH_ENTROPY_RE` character class excludes `@`, so a connection URL never - * matches it. Both halves of the userinfo must be non-empty, so a bare - * `postgres://host/db` or a password-less `https://user@host` is NOT flagged. - * - * Deliberately local to the DEPLOY-side warning rather than added to - * `shared/env-mask.ts`: that module is also the runtime/managed redaction - * heuristic, and widening it would change masking behaviour well outside this - * warning. - */ -const CREDENTIAL_URL_VALUE_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/; - -/** - * `true` when a var's VALUE is a connection URL carrying inline credentials. - * Inspecting the value is safe here: only the KEY name ever leaves - * {@link collectPlaintextVarWarningsFromVars}. - */ -function isCredentialBearingUrlValue(value: unknown): boolean { - return typeof value === 'string' && CREDENTIAL_URL_VALUE_RE.test(value); -} - -/** - * Result of {@link collectPlaintextVarWarningsFromVars}. Carries KEY NAMES ONLY — - * never values — so nothing here can leak a secret into logs, no matter how - * the caller formats it. - */ -export type PlaintextVarWarningsResult = { - /** Secret-looking keys the build will bake into worker `vars`, sorted by key. */ - secretKeys: Array; - /** Total number of keys the build will merge into worker `vars`. */ - totalVarKeys: number; -}; - /** Minimal structural view of a Standard Schema validator's probe entrypoint. */ type SchemaProbe = StandardSchemaV1['~standard']; @@ -392,8 +357,8 @@ export class SelfHostFirstDeployError extends SelfHostSecretListError { /** * The env schema is not satisfied for a self-host production deploy: required - * keys are missing (not in `.env`/`.env.production` and not present as remote - * worker secrets), local values are invalid, or an EMPTY local assignment would + * keys are missing from remote worker secrets, values are invalid, or an EMPTY + * plaintext assignment would * shadow a remote secret. The message and the attached `report` carry the * offending key NAMES plus stable, values-free reason codes only — never raw * input, never resolved values — so nothing on this path can echo a secret @@ -536,30 +501,24 @@ function emptyEnvContext(): SelfHostEnvContext { // The redesigned `void deploy --backend cloudflare` gate validates the REAL // build artifact the build emits — `dist/ssr/wrangler.json` `vars` (the plaintext // worker vars) — instead of a pre-build `loadEnv` facsimile. It needs only ONE -// piece of project-derived data: the env-INSENSITIVE schema facts (required key -// names + `.secret()` flags), which do NOT depend on which `.env` file the build -// reads. Those are derived once in an isolated subprocess; everything else is pure +// piece of project-derived data: the env-INSENSITIVE server and required key +// names. Those are derived once in an isolated subprocess; everything else is pure // parent work over the emitted map. // --------------------------------------------------------------------------- /** * The env-INSENSITIVE structural facts the self-host secret gate needs from the - * project's env schema: whether a schema is registered, the REQUIRED key names (a - * validator that rejects the ABSENT value — {@link requiredSchemaKeys}), and the - * keys the schema flags `.secret()` ({@link getSecretOverride} === 'secret'). - * NONE of these depend on which `.env` file the build reads — they are fixed by - * env.ts's structure — so this needs no `loadEnv` and no build env, and the result - * is the SAME regardless of the build's mode/envDir. That is precisely why the old - * probe-vs-build divergence class is gone: nothing derived here can diverge from - * the build over which `.env*` set was loaded. + * project's env schema: whether a schema is registered, every server key, and the + * REQUIRED key names (validators that reject the absent value). These are fixed by + * env.ts's structure and require no dotenv loading. */ export type SelfHostSchemaFacts = { /** Whether the project registered an env schema at all (no schema → nothing to gate). */ hasSchema: boolean; + /** Every server-only schema key. These may never be emitted as plaintext vars. */ + serverKeys: Array; /** Required key NAMES — validator rejects `undefined`. Values-free. */ requiredKeys: Array; - /** Keys the schema flags `.secret()`. Values-free. */ - secretKeys: Array; }; /** @@ -573,22 +532,19 @@ export type SelfHostSchemaFacts = { export async function deriveSelfHostSchemaFacts(root: string): Promise { const envFile = findEnvFile(root); if (envFile == null) { - return { hasSchema: false, requiredKeys: [], secretKeys: [] }; + return { hasSchema: false, serverKeys: [], requiredKeys: [] }; } const mod = await withSchemaBoundary('schema-load-failed', () => loadSchema(envFile)); if (!mod) { - return { hasSchema: false, requiredKeys: [], secretKeys: [] }; + return { hasSchema: false, serverKeys: [], requiredKeys: [] }; } const schema = mod._getRegisteredSchema() ?? {}; - const requiredKeys = await withSchemaBoundary('schema-validation-error', () => + const serverKeys = Object.keys(schema).filter((key) => !isClientEnvKey(key)); + const required = await withSchemaBoundary('schema-validation-error', () => requiredSchemaKeys(schema), ); - // `.secret()` is a STATIC flag on the validator (getSecretOverride) — no value - // read, no env dependency. - const secretKeys = Object.keys(schema).filter( - (key) => getSecretOverride(schema[key]) === 'secret', - ); - return { hasSchema: true, requiredKeys: [...requiredKeys], secretKeys }; + const requiredKeys = [...required].filter((key) => !isClientEnvKey(key)); + return { hasSchema: true, serverKeys, requiredKeys }; } /** @@ -757,44 +713,6 @@ export async function computeInvalidEmittedKeys( return Array.from(invalid).sort((a, b) => a.localeCompare(b)); } -/** - * Classify the secret-looking keys among the REAL worker `vars` the build baked - * into `dist/ssr/wrangler.json` (ground truth). PURE: for each emitted var key, a - * `.secret()`-flagged key ({@link SelfHostSchemaFacts.secretKeys}) is - * `schema-secret`; otherwise the shared {@link isLikelySecretKey} name heuristic - * applies (so a secret-looking key ships plaintext regardless of any schema, and - * heuristic classification still works with no env.ts at all); failing both, a - * VALUE shaped like a credential-bearing connection URL - * ({@link isCredentialBearingUrlValue}) flags the key as `value-shape`, catching - * the `DATABASE_URL`-class leak no name heuristic sees. - * - * KEYS ONLY leave here — a value may be INSPECTED to classify it, but is never - * copied into the result, so no caller can format a value into a log. - */ -export function collectPlaintextVarWarningsFromVars( - emittedVars: Record, - secretFlaggedKeys: Array, -): PlaintextVarWarningsResult { - const flagged = new Set(secretFlaggedKeys); - const keys = Object.keys(emittedVars); - const secretKeys: Array = []; - for (const key of keys) { - if (flagged.has(key)) { - secretKeys.push({ key, reason: 'schema-secret' }); - continue; - } - if (isLikelySecretKey(key)) { - secretKeys.push({ key, reason: 'heuristic' }); - continue; - } - if (isCredentialBearingUrlValue(emittedVars[key])) { - secretKeys.push({ key, reason: 'value-shape' }); - } - } - secretKeys.sort((a, b) => a.key.localeCompare(b.key)); - return { secretKeys, totalVarKeys: keys.length }; -} - /** Build a values-free invalid {@link EnvValidationReport} from key names only. */ function invalidEnvReport(missing: Array, invalidKeys: Array): EnvValidationReport { return { @@ -1038,11 +956,7 @@ function selfHostEnvError(report: EnvValidationReport): SelfHostEnvValidationErr } } if (missing.length > 0) { - lines.push( - '', - 'Set each missing production secret with `void secret put `, ' + - 'or add a value to .env / .env.production.', - ); + lines.push('', 'Set each missing production secret with `void secret put `.'); } // Sanitized report: key names + the fixed reason code, no messages, no values. const safeReport: EnvValidationReport = { @@ -1130,9 +1044,7 @@ function firstDeployMessage(missingKeys: Array): string { ...sorted.map((key) => ` • ${key}`), '', 'Bootstrap them on the draft Worker before deploying:', - ...names.map((key) => ` wrangler secret put ${key}`), - '', - 'or add a value to .env / .env.production, then run the deploy again.', + ...names.map((key) => ` void secret put ${key}`), ].join('\n'); } diff --git a/packages/void/src/cli/env.ts b/packages/void/src/cli/env.ts index 20001643f..ffd6b9f03 100644 --- a/packages/void/src/cli/env.ts +++ b/packages/void/src/cli/env.ts @@ -5,10 +5,9 @@ import { findEnvFile, generateEnvTypes } from '../codegen/env-types.ts'; import { getToken } from './auth.ts'; import { PlatformClient } from './client.ts'; import type { EnvArgs } from './env-args.ts'; -import { runEnvExample } from './env-example.ts'; import { fetchRemoteSecretNames, formatEnvReport, validateProdEnv } from './env-validation.ts'; import { cliTitle } from './output.ts'; -import { readProjectConfig } from './project.ts'; +import { readDeploymentPlatform, readProjectConfig } from './project.ts'; export async function runEnvCommand(root: string, args: EnvArgs): Promise { console.log(); @@ -22,31 +21,37 @@ export async function runEnvCommand(root: string, args: EnvArgs): Promise runTypes(root); return; } - if (args.subcommand === 'example') { - await runEnvExample(root, { force: args.force }); - return; - } } async function runCheck(root: string, useRemoteSecrets: boolean): Promise { let remoteSecrets: Array | undefined; if (useRemoteSecrets) { - const token = getToken(root); - if (!token) { - log.error('--remote requires authentication. Run `void auth login` first.'); - process.exit(1); + const platform = readDeploymentPlatform(root); + if (platform === 'none') { + throw new Error('env: Deployment is disabled. Run `void init` to choose a platform.'); } - const config = readProjectConfig(root); - if (!config) { - log.error('--remote requires a linked project. Run `void project link` first.'); - process.exit(1); + if (platform === 'cloudflare') { + const { listCloudflareSecrets, resolveCloudflareOperationContext } = + await import('./cloudflare-operations.ts'); + remoteSecrets = await listCloudflareSecrets(resolveCloudflareOperationContext(root)); + } else { + const token = getToken(root); + if (!token) { + log.error('--remote requires authentication. Run `void auth login` first.'); + process.exit(1); + } + const config = readProjectConfig(root); + if (!config) { + log.error('--remote requires a linked project. Run `void project link` first.'); + process.exit(1); + } + const client = new PlatformClient(token, { root }); + remoteSecrets = await fetchRemoteSecretNames(client, config.projectId); } - const client = new PlatformClient(token, { root }); - remoteSecrets = await fetchRemoteSecretNames(client, config.projectId); } const { hasSchema, report } = await validateProdEnv(root, { - productionOnly: true, + productionOnly: useRemoteSecrets, remoteSecrets, }); @@ -55,14 +60,20 @@ async function runCheck(root: string, useRemoteSecrets: boolean): Promise return; } if (report.valid) { - outro('All required env vars are present and valid.'); + outro( + useRemoteSecrets + ? 'All required remote env names are present; values validate at runtime.' + : 'Local .env values are valid.', + ); return; } note(formatEnvReport(report), 'Env validation failed'); if (report.missing.length > 0) { log.info( - `Set missing values via .env / .env.production, or upload as secrets with \`void secret put \`.`, + useRemoteSecrets + ? 'Upload missing server values with `void secret put `; set VITE_* values in the build shell or schema defaults.' + : 'Add the missing values to .env or set them in your shell.', ); } process.exit(1); diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index dea979a66..900f5a23e 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -727,27 +727,21 @@ const HELP_TREE: HelpTree = { }, }, env: { - summary: 'Validate env.ts and generate environment artifacts.', + summary: 'Validate env.ts and generate environment types.', usage: ['void env '], commands: [ - { command: 'check', description: 'Validate env.ts against .env files' }, + { command: 'check', description: 'Validate local .env or remote secret names' }, { command: 'types', description: 'Regenerate .void/env.d.ts from env.ts' }, - { command: 'example', description: 'Generate .env.example from env.ts schema' }, ], children: { check: { - summary: 'Validate env.ts against .env files and optionally remote secrets.', + summary: 'Validate local .env or required remote secret names.', usage: ['void env check [options]'], options: [ - { command: '--remote', description: 'Also check required keys as remote secrets' }, + { command: '--remote', description: 'Check required server keys as remote secrets' }, ], }, types: basicSubcommand('Regenerate .void/env.d.ts from env.ts.', 'void env types'), - example: { - summary: 'Refresh the Void-managed block in .env.example.', - usage: ['void env example [options]'], - options: [{ command: '--force', description: 'Suppress the appended block notice' }], - }, }, }, gen: { diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index e12e07cf7..b588212f9 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -2580,14 +2580,14 @@ function scaffoldPagesApp( if (starterMode === 'postgresql') { const devCommand = getDevCommand(root); note( - `Add DATABASE_URL to .env.local before running \`${devCommand}\`.\n` + + `Add DATABASE_URL to .env before running \`${devCommand}\`.\n` + 'Void uses that direct connection for local PostgreSQL development.', 'PostgreSQL local dev', ); } else if (starterMode === 'mysql') { const devCommand = getDevCommand(root); note( - `Add DATABASE_URL to .env.local before running \`${devCommand}\`.\n` + + `Add DATABASE_URL to .env before running \`${devCommand}\`.\n` + 'Void uses that direct connection for local MySQL development.', 'MySQL local dev', ); @@ -3804,9 +3804,7 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise if (scaffoldResult.written) { const rel = relative(root, scaffoldResult.written) || 'env.ts'; if (scaffoldResult.entries.length > 0) { - log.success( - `Scaffolded ${rel} from your .env files — review inferred types before running dev.`, - ); + log.success(`Scaffolded ${rel} from .env — review inferred types before running dev.`); } else { log.success(`Created empty ${rel} — add your env keys with \`defineEnv({...})\`.`); } diff --git a/packages/void/src/cli/secret.ts b/packages/void/src/cli/secret.ts index 475b21364..fd5eb38c6 100644 --- a/packages/void/src/cli/secret.ts +++ b/packages/void/src/cli/secret.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import { intro, outro, log, note, cancel, isCancel, password, confirm } from '@clack/prompts'; import { getToken } from './auth.ts'; import { PlatformClient } from './client.ts'; +import { validateSecretValues } from './env-validation.ts'; import { cliTitle } from './output.ts'; import { readDeploymentPlatform, readProjectConfig } from './project.ts'; import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts'; @@ -58,6 +59,7 @@ export async function runSecretCommand(root: string, args: SecretArgs): Promise< if (args.action === 'put') { const value = args.value ?? (await readSecretValue()); + await validateSecretValues(root, [{ name: args.name, value }]); await client.setSecret(projectId, args.name, value); outro(`Secret ${args.name} set.`); return; @@ -71,6 +73,8 @@ export async function runSecretCommand(root: string, args: SecretArgs): Promise< return; } + await validateSecretValues(root, secrets); + log.info(`Found ${secrets.length} secret(s):`); for (const s of secrets) { log.message(` ${s.name}`); @@ -123,6 +127,7 @@ async function runCloudflareSecretCommand(root: string, args: SecretArgs): Promi } if (args.action === 'put') { const value = args.value ?? (await readSecretValue()); + await validateSecretValues(root, [{ name: args.name, value }]); await putCloudflareSecret(context, args.name, value); outro(`Secret ${args.name} set.`); return; @@ -134,6 +139,7 @@ async function runCloudflareSecretCommand(root: string, args: SecretArgs): Promi outro('Nothing to sync.'); return; } + await validateSecretValues(root, secrets); log.info(`Found ${secrets.length} secret(s):`); for (const secret of secrets) { log.message(` ${secret.name}`); diff --git a/packages/void/src/cli/template.ts b/packages/void/src/cli/template.ts index b753416bf..2edc4ee0c 100644 --- a/packages/void/src/cli/template.ts +++ b/packages/void/src/cli/template.ts @@ -3,8 +3,7 @@ export const SCAFFOLD_GITIGNORE_LINES = [ 'dist', '.void', '.wrangler', - '.env.local', - '.env.*.local', + '.env', '*.tsbuildinfo', '.DS_Store', ] as const; diff --git a/packages/void/src/codegen/prepare.ts b/packages/void/src/codegen/prepare.ts index 4edc8c739..12a70e402 100644 --- a/packages/void/src/codegen/prepare.ts +++ b/packages/void/src/codegen/prepare.ts @@ -1,7 +1,6 @@ import { existsSync, mkdirSync, rmSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { join } from 'pathslash'; -import { loadEnv } from 'vite'; import { findVoidAuthConfig, loadVoidAuthConfig } from '../auth/config.ts'; import { generateVoidAuthDatabaseModule, generateVoidAuthDrizzleSchema } from '../auth/drizzle.ts'; import { getDatabaseDialect, readConfig } from '../config.ts'; @@ -9,6 +8,7 @@ import { discoverSchema } from '../drizzle/discover.ts'; import { FRAMEWORK_SCAN_DIRS, detectFramework, inferProjectBindings } from '../plugin-inference.ts'; import { resolveProjectPaths } from '../project-paths.ts'; import { scanRoutes } from '../router/scan.ts'; +import { loadLocalEnv } from '../shared/env.ts'; import { generateDbDts } from './db-types.ts'; import { findEnvFile, generateEnvTypes } from './env-types.ts'; import { ensureProjectTsconfig } from './project-tsconfig.ts'; @@ -21,11 +21,6 @@ export type PrepareProjectResult = { removed: Array; }; -export type PrepareProjectOptions = { - /** Vite env mode used while resolving a function-based auth config. */ - mode?: string; -}; - function writeGeneratedFile( outDir: string, fileName: string, @@ -47,10 +42,7 @@ function removeGeneratedFile(outDir: string, fileName: string, removed: Array { +export async function prepareProject(root: string): Promise { const config = readConfig(root); const paths = resolveProjectPaths(root, config); const outDir = join(root, '.void'); @@ -111,7 +103,7 @@ export async function prepareProject( providers: config.auth?.providers ?? null, userConfig: await loadVoidAuthConfig(authConfigPath), }; - const env = loadEnv(options?.mode ?? 'development', root, '') as Record; + const env = { ...loadLocalEnv(root), ...process.env } as Record; const schemaPath = join(outDir, 'better-auth-schema.ts'); const databasePath = join(outDir, 'better-auth-db.ts'); const drizzleAdapterPath = fileURLToPath(import.meta.resolve('better-auth/adapters/drizzle')); diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 8f332d5bf..41de86e96 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -4,7 +4,7 @@ import { cloudflare, type PluginConfig, type WorkerConfig } from '@cloudflare/vi import { appendSqliteClassMigration } from '@void/deploy-core'; import { join } from 'pathslash'; import pc from 'picocolors'; -import { loadEnv, type Plugin } from 'vite'; +import { type Plugin } from 'vite'; import { hasApiRoutes as routeScanHasApiRoutes, hasDocumentRoutes as routeScanHasDocumentRoutes, @@ -48,7 +48,7 @@ import { devTriggersHintPlugin } from './plugin-dev-triggers-hint.ts'; import { drizzlePlugin } from './plugin-drizzle.ts'; import { envClientFoldPlugin } from './plugin-env-client-fold.ts'; import { envClientGuardPlugin } from './plugin-env-client-guard.ts'; -import { envSchemaPlugin } from './plugin-env-schema.ts'; +import { envSchemaPlugin, type EnvSchemaFacts } from './plugin-env-schema.ts'; import { analogDevTriggerInjectPlugin, astroDevTriggerInjectPlugin, @@ -76,10 +76,9 @@ import { isRouteEnabledForEnvironment, type RouteEnvironment } from './router/pa import { scanMiddlewareSync, scanRoutePatternsSync } from './router/scan.ts'; import { scanWebSocketRoutesSync } from './router/websocket.ts'; import { SANDBOX_MIGRATION_TAG, isSandboxEnabled, resolveSandboxConfig } from './sandbox/config.ts'; -import { mergeAndExpand, parseDotenvFile } from './shared/dotenv.ts'; import { CLOUDFLARE_DEPLOY_BUILD_ENV, - filterLoadedEnv, + loadLocalEnv, resolveRemoteMode, stripInternalEnvKeys, } from './shared/env.ts'; @@ -1015,6 +1014,7 @@ export function voidPlugin(options?: { crons: jobs.flatMap((job) => job.crons), }; } + let envSchemaFacts: EnvSchemaFacts | undefined; let assetRouting: AssetRoutingPolicy; if (config.worker?.vars) { workerConfig.vars = { ...config.worker.vars }; @@ -1070,16 +1070,32 @@ export function voidPlugin(options?: { ); const result: Record = { ...workerConfig }; - // Direct Cloudflare deploys create BETTER_AUTH_SECRET before upload. Tell - // Wrangler to inherit that remote secret into the new Worker version; - // otherwise a normal deploy can replace the version without carrying the - // secret forward. Scope this to the Void CLI's Cloudflare build marker so - // local dev, managed Void deploys, and bare user builds keep their existing - // behavior. Preserve and dedupe any user-declared required secrets. - if (authEnabled && process.env[CLOUDFLARE_DEPLOY_BUILD_ENV] === '1') { - const resolvedSecrets = (resolved as { secrets?: { required?: Array } }).secrets; - if (!resolvedSecrets?.required?.includes('BETTER_AUTH_SECRET')) { - result.secrets = { required: ['BETTER_AUTH_SECRET'] }; + if (envSchemaFacts || (authEnabled && process.env[CLOUDFLARE_DEPLOY_BUILD_ENV] === '1')) { + const plaintextVars = { + ...((resolved.vars as Record | undefined) ?? {}), + ...((result.vars as Record | undefined) ?? {}), + }; + const plaintextServerKeys = (envSchemaFacts?.serverKeys ?? []).filter( + (key) => key in plaintextVars, + ); + if (viteCommand === 'build' && plaintextServerKeys.length > 0) { + throw new Error( + `Server env keys cannot be Worker plaintext vars: ${plaintextServerKeys.join(', ')}. ` + + 'Remove them from worker.vars/wrangler vars and set them with the remote secret store.', + ); + } + const configuredRequired = resolved.secrets?.required ?? []; + const required = [ + ...new Set([ + ...configuredRequired, + ...(envSchemaFacts?.requiredServerKeys ?? []), + ...(authEnabled && process.env[CLOUDFLARE_DEPLOY_BUILD_ENV] === '1' + ? ['BETTER_AUTH_SECRET'] + : []), + ]), + ]; + if (required.length > 0) { + result.secrets = { required }; } } @@ -1450,77 +1466,16 @@ export function voidPlugin(options?: { return result as Partial; } - // Merge schema defaults from env.ts into workerConfig.vars without - // shadowing values already provided by .env files / shell. Wired into - // envSchemaPlugin's onDefaults callback for CF targets only (the node - // target reads from process.env at runtime). - function applySchemaDefaultsToWorkerVars(defaults: Record): void { - const vars = (workerConfig.vars as Record | undefined) ?? {}; - for (const [key, value] of Object.entries(defaults)) { - if (vars[key] === undefined) { - vars[key] = value; - } - } - workerConfig.vars = vars; - } - - // A direct Cloudflare deploy promotes only explicitly `.secret()`-marked - // schema values to Worker secrets. Make production-local and CI shell values - // available to the post-build validator/uploader without exposing unrelated - // machine env or changing ordinary Vite build behaviour. - function applySchemaSecretsToWorkerVars(secretKeys: Array): void { - if (process.env[CLOUDFLARE_DEPLOY_BUILD_ENV] !== '1' || secretKeys.length === 0) { - return; - } - const mode = voidDeployBuildConfig?.mode ?? 'production'; - const rawEnvDir = voidDeployBuildConfig?.envDir; - const envDir = rawEnvDir ? join(root, rawEnvDir) : root; - const local = mergeAndExpand( - [parseDotenvFile(join(envDir, `.env.${mode}.local`), { expand: false })], - { onWarn: (message) => voidWarn(message) }, - ); - const selected: Record = {}; - for (const key of secretKeys) { - if (authEnabled && key === 'BETTER_AUTH_SECRET') { - continue; - } - if (Object.prototype.hasOwnProperty.call(local, key)) { - selected[key] = local[key]!; - } - if (process.env[key] !== undefined) { - selected[key] = process.env[key]!; - } - } - const { sanitized, stripped } = stripInternalEnvKeys(selected); - if (stripped.length > 0) { - voidWarn( - `Ignoring schema secret keys reserved for Void runtime use: ${stripped.join(', ')}.`, - ); - } - workerConfig.vars = { - ...(workerConfig.vars as Record | undefined), - ...sanitized, - }; + function applyEnvSchema(facts: EnvSchemaFacts): void { + envSchemaFacts = facts; } - // Plugin that loads .env files and merges them into workerConfig.vars. - // Runs before the Cloudflare plugin so vars are available as worker bindings. - // - // The `{ mode, envDir }` this hook OBSERVES (raw, root-relative) is exposed via `api` so the - // self-host `void deploy --backend cloudflare` secret preflight can validate the SAME `.env*` - // set the build ships. It must be captured HERE, at this hook's position, not read off the - // final `resolved.envDir`: config hooks run in sequence, and a LATER plugin's `config` hook - // that returns a different `envDir` would change `resolved.envDir` while this hook already - // loaded env from the value it saw — the preflight reading the final value would then diverge - // from what the build baked. - let voidDeployBuildConfig: { mode: string; envDir: string | undefined } | undefined; + // Plugin that loads the single local `.env` file into worker bindings for + // local dev and preview only. Production values come from remote secrets; + // builds never turn local dotenv entries into deployable Worker vars. const envPlugin: Plugin = { name: 'void:env', - api: { - getVoidDeployBuildConfig: (): { mode: string; envDir: string | undefined } | undefined => - voidDeployBuildConfig, - }, - config(userConfig, { mode, command, isPreview }) { + config(_userConfig, { command, isPreview }) { viteCommand = command; // `dev` is the Vite dev server only. Every build, `void deploy`, and // `vite preview` (which reports `command: 'serve'`) is `prod`. @@ -1542,50 +1497,28 @@ export function voidPlugin(options?: { // Exclude the .void cache directory from the file watcher so that // miniflare SQLite writes don't trigger infinite HMR refreshes. const ignored = ['**/.void/**']; - // Honour the user's `envDir` config — projects that keep .env files - // in `config/` or similar would otherwise be silently ignored. Falls - // back to the project root, matching Vite's own default. - const rawEnvDir = - typeof userConfig.envDir === 'string' && userConfig.envDir.length > 0 - ? userConfig.envDir - : undefined; - voidDeployBuildConfig = { mode, envDir: rawEnvDir }; - const envDir = rawEnvDir ? join(root, rawEnvDir) : root; - // Direct Cloudflare deploys must never turn machine-local dotenv files - // into plaintext Worker vars. Rebuild the deploy env from the two - // portable files instead of trying to delete `.local` keys from Vite's - // already-merged result: a key overridden by `.env.production.local` - // must fall back to its `.env.production` value, not disappear or retain - // the local override. Development and ordinary Vite builds keep Vite's - // standard four-file loading for compatibility. - let filtered: Record; - if (process.env[CLOUDFLARE_DEPLOY_BUILD_ENV] === '1') { - const deployFiles = ['.env', `.env.${mode}`]; - filtered = mergeAndExpand( - deployFiles.map((file) => parseDotenvFile(join(envDir, file), { expand: false })), - { onWarn: (message) => voidWarn(message) }, - ); - // Vite gives the shell precedence over dotenv, after which - // filterLoadedEnv removes those shell-sourced keys. Preserve that - // invariant so credentials exported for the CLI never become vars. - for (const key of Object.keys(process.env)) { - delete filtered[key]; + if (command === 'serve') { + const local = loadLocalEnv(root, { onWarn: (message) => voidWarn(message) }); + const { sanitized: env, stripped } = stripInternalEnvKeys(local); + if (stripped.length > 0) { + voidWarn( + `Ignoring internal env keys from .env: ${stripped.join(', ')}. These are reserved for Void runtime use.`, + ); + } + workerConfig.vars = { + ...(workerConfig.vars as Record | undefined), + ...env, + }; + // The Cloudflare preview server runs the already-built wrangler + // artifact and sources its declared secrets from process.env instead + // of the in-memory worker config. Populate only missing entries so the + // shell still wins and `.env` remains a local runtime input. + if (isPreview) { + for (const [key, value] of Object.entries(env)) { + process.env[key] ??= value; + } } - } else { - // loadEnv with prefix "" returns .env file vars AND all process.env - // vars. filterLoadedEnv strips shell pollution (PATH, HOME, etc.) so - // only .env-file vars reach worker bindings. - filtered = filterLoadedEnv(loadEnv(mode, envDir, '')); - } - // Remove any internal Void keys a user may have set in portable env - // files so they cannot hijack credential-shaped Worker bindings. - const { sanitized: env, stripped } = stripInternalEnvKeys(filtered); - if (stripped.length > 0) { - voidWarn( - `Ignoring internal env keys from .env files: ${stripped.join(', ')}. These are reserved for Void runtime use.`, - ); } - workerConfig.vars = { ...(workerConfig.vars as Record | undefined), ...env }; // In dev, keep document requests on the user worker. If this is deleted, // the CF router may route a page request to the user worker, the dev @@ -1874,10 +1807,7 @@ export function voidPlugin(options?: { restartPlugin(), bindingWatchPlugin, envPlugin, - envSchemaPlugin(root, { - onDefaults: applySchemaDefaultsToWorkerVars, - onSecrets: applySchemaSecretsToWorkerVars, - }), + envSchemaPlugin(root, { onSchema: applyEnvSchema }), envClientGuardPlugin(root), envClientFoldPlugin(), clientStubsPlugin, @@ -2046,10 +1976,7 @@ export function voidPlugin(options?: { restartPlugin(), bindingWatchPlugin, envPlugin, - envSchemaPlugin(root, { - onDefaults: applySchemaDefaultsToWorkerVars, - onSecrets: applySchemaSecretsToWorkerVars, - }), + envSchemaPlugin(root, { onSchema: applyEnvSchema }), envClientGuardPlugin(root), envClientFoldPlugin(), clientStubsPlugin, diff --git a/packages/void/src/plugin-drizzle.ts b/packages/void/src/plugin-drizzle.ts index db843215f..7ccadac48 100644 --- a/packages/void/src/plugin-drizzle.ts +++ b/packages/void/src/plugin-drizzle.ts @@ -166,7 +166,7 @@ function getConnectionString() { if (url) return url; const hd = getRuntimeBinding("HYPERDRIVE"); if (hd?.connectionString) return hd.connectionString; - throw new Error("db: MySQL is not configured. Add DATABASE_URL to .env.local for local dev, or configure Hyperdrive for production."); + throw new Error("db: MySQL is not configured. Add DATABASE_URL to .env for local dev, or configure Hyperdrive for production."); } function getInstance() { @@ -204,7 +204,7 @@ function getConnectionString() { if (url) return url; const hd = getRuntimeBinding("HYPERDRIVE"); if (hd?.connectionString) return hd.connectionString; - throw new Error("db: PostgreSQL is not configured. Add DATABASE_URL to .env.local for local dev, or configure Hyperdrive for production."); + throw new Error("db: PostgreSQL is not configured. Add DATABASE_URL to .env for local dev, or configure Hyperdrive for production."); } function getInstance() { diff --git a/packages/void/src/plugin-env-client-fold.ts b/packages/void/src/plugin-env-client-fold.ts index f592ee241..ca5a14cbe 100644 --- a/packages/void/src/plugin-env-client-fold.ts +++ b/packages/void/src/plugin-env-client-fold.ts @@ -142,8 +142,7 @@ function pushHit(node: AstMemberExpression, hits: Array): void { // to the runtime Proxy — not our problem. } -/** Default Vite `envPrefix`. */ -const DEFAULT_ENV_PREFIXES: ReadonlyArray = ['VITE_']; +const CLIENT_ENV_PREFIX = 'VITE_'; /** * Build-time constant folding for client-only `env.FOO` reads. @@ -159,21 +158,19 @@ const DEFAULT_ENV_PREFIXES: ReadonlyArray = ['VITE_']; * • Runs only when `this.environment.name === 'client'`. Server / SSR / * worker builds keep the full runtime Proxy (secrets, runtime-injected * vars, HMR remain live). - * • Only folds keys that match the configured `envPrefix` — the exact - * set Vite is already willing to ship to the browser bundle. A + * • Only folds keys that match Void's fixed `VITE_` client prefix. A * server-only key is never folded (and the env-client-guard plugin * would have errored out earlier anyway). * • Only folds static member access: `env.FOO` and `env["FOO"]`. * Dynamic access, destructuring, and reassignment fall through to the * runtime Proxy. - * • Only folds keys with a known build-time value (loaded .env* merged - * with schema `.default(...)` entries). Unknown keys are left as-is + * • Only folds keys with a known build-time value (build shell or schema + * `.default(...)`). Unknown keys are left as-is * and the Proxy handles them at runtime — no "undefined" shoved in. - * • Build-only (`apply: 'build'`). Dev runs through the Proxy so HMR - * and editing `.env.local` take effect without a full rebuild. + * • Build-only (`apply: 'build'`). Dev runs through the Proxy so local + * `.env` edits take effect without a full rebuild. */ export function envClientFoldPlugin(): Plugin { - let envPrefixes: ReadonlyArray = DEFAULT_ENV_PREFIXES; let loadedEnv: Record = {}; const schemaDefaults: Record = {}; @@ -189,7 +186,7 @@ export function envClientFoldPlugin(): Plugin { } function isExposedToClient(key: string): boolean { - return envPrefixes.some((p) => key.startsWith(p)); + return key.startsWith(CLIENT_ENV_PREFIX); } return { @@ -202,21 +199,11 @@ export function envClientFoldPlugin(): Plugin { // about for folding correctness. enforce: 'post', async configResolved(config: ResolvedConfig) { - const prefix = config.envPrefix; - if (typeof prefix === 'string') { - envPrefixes = [prefix]; - } else if (Array.isArray(prefix) && prefix.length > 0) { - envPrefixes = prefix; - } else { - envPrefixes = DEFAULT_ENV_PREFIXES; - } - - // Vite has already resolved `.env*` + process.env into config.env, - // filtered by the same envPrefix list. Reuse it so we match exactly - // what Vite injects into `import.meta.env`. + // The env-schema plugin has already removed local `.env` values on + // builds. Reuse the remaining build-shell client values from config.env. loadedEnv = { ...(config.env ?? {}) } as Record; - // Schema `.default(...)` values back-fill keys missing from .env*. + // Schema `.default(...)` values back-fill missing client keys. // We only surface client-exposed ones so server-only defaults never // leak into the client bundle. try { diff --git a/packages/void/src/plugin-env-client-guard.ts b/packages/void/src/plugin-env-client-guard.ts index ccb5ecfec..89c38d444 100644 --- a/packages/void/src/plugin-env-client-guard.ts +++ b/packages/void/src/plugin-env-client-guard.ts @@ -192,12 +192,9 @@ function recordHit(node: AstMemberExpression, hits: Array): void { * No-op when no `env.ts` is registered. Server / SSR / worker environments * are skipped — only the `client` environment is checked. */ -/** Default value matches Vite's default `envPrefix`. */ -const DEFAULT_ENV_PREFIXES: ReadonlyArray = ['VITE_']; +const CLIENT_ENV_PREFIX = 'VITE_'; export function envClientGuardPlugin(root: string): Plugin { - let envPrefixes: ReadonlyArray = DEFAULT_ENV_PREFIXES; - return { name: 'void:env-client-guard', // Run after framework SFC compilers (svelte, vue, astro) so that when we @@ -206,17 +203,6 @@ export function envClientGuardPlugin(root: string): Plugin { // SFC source and `parseSync` bails — the server-only leak then slips // through to runtime. `.ts`/`.tsx` files don't care about order. enforce: 'post', - configResolved(config) { - // Honour the user's `envPrefix` (string or string[]) so projects that - // customise it — e.g. `envPrefix: 'PUBLIC_'` for SvelteKit-like setups - // — don't get false-positive errors on their public keys. - const prefix = config.envPrefix; - if (typeof prefix === 'string') { - envPrefixes = [prefix]; - } else if (Array.isArray(prefix) && prefix.length > 0) { - envPrefixes = prefix; - } - }, async transform(code, id) { // Only run on the client environment. SSR, worker, RSC etc. legitimately // read server keys via the same `void/env` import. @@ -309,19 +295,14 @@ export function envClientGuardPlugin(root: string): Plugin { if (!(key in schema)) { continue; } - // Already exposed to the client by Vite's own envPrefix filter. - if (envPrefixes.some((p) => key.startsWith(p))) { + if (key.startsWith(CLIENT_ENV_PREFIX)) { continue; } - const prefixHint = - envPrefixes.length === 1 - ? `${envPrefixes[0]}* prefix` - : `one of these prefixes: ${envPrefixes.join(', ')}`; this.error({ message: `Server-only env "${key}" referenced from client code at ${relPath}:${line}:${column}.\n` + - `If this should be available in the browser, rename it with the ${prefixHint} in env.ts.\n` + + `If this should be available in the browser, rename it with the VITE_* prefix in env.ts.\n` + `If it must stay server-only, remove this reference and read the value via a server route or loader.`, loc: { line, column, file: id }, }); diff --git a/packages/void/src/plugin-env-schema.ts b/packages/void/src/plugin-env-schema.ts index d0e6c71c2..8030786f0 100644 --- a/packages/void/src/plugin-env-schema.ts +++ b/packages/void/src/plugin-env-schema.ts @@ -5,8 +5,7 @@ import { findEnvFile, generateEnvTypes } from './codegen/env-types.ts'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; import { loadUserEnvFile } from './host/load-user-env.ts'; import { voidLog, voidWarn } from './log.ts'; -import { getSecretOverride } from './runtime/env-helpers.ts'; -import { filterLoadedEnv } from './shared/env.ts'; +import { CLIENT_ENV_PREFIX, findLegacyEnvFiles, loadLocalEnv } from './shared/env.ts'; type RegisteredSchemaModule = { _getRegisteredSchema(): Record< @@ -24,16 +23,17 @@ type RegisteredSchemaModule = { export type EnvSchemaPluginOptions = { /** - * Called with the schema's `.default(value)` entries (stringified) so the - * caller can seed worker bindings, deploy manifest vars, or any other env - * sink. Invoked after the user's `env.ts` has been imported so the - * registered schema is up-to-date. User-provided values in `.env*` / - * shell env should take precedence — the caller is responsible for - * "skip if already set" semantics. + * Called after env.ts is imported with the structural facts needed to + * configure production bindings. Values never cross this callback. */ - onDefaults?: (defaults: Record) => void; - /** Called with keys explicitly marked `.secret()` in the registered schema. */ - onSecrets?: (secretKeys: Array) => void; + onSchema?: (facts: EnvSchemaFacts) => void; +}; + +export type EnvSchemaFacts = { + keys: Array; + clientKeys: Array; + serverKeys: Array; + requiredServerKeys: Array; }; let cachedSchemaModule: RegisteredSchemaModule | null = null; @@ -51,49 +51,46 @@ async function loadSchemaModule(): Promise { * Vite plugin that auto-discovers `env.ts` at the project root and: * - imports it (registering the user's schema via `defineEnv`) * - generates `.void/env.d.ts` so `env.X` and `c.env.X` are typed - * - validates the merged dev env (.env files + process.env) and warns + * - validates the local dev env (`.env` + process.env) and warns * on missing/invalid required keys * - regenerates types + re-validates when env.ts changes (HMR) * * Hard errors are reserved for `void deploy`; dev keeps moving with warnings - * so unrelated work isn't blocked by a stale .env.local. + * so unrelated work isn't blocked by a stale local value. */ /** Patterns Vite's `define` typically uses for env-shaped constants. */ const DEFINE_ENV_PATTERN = /^(?:globalThis\.)?(?:process\.env|import\.meta\.env)\.([A-Z_][A-Z0-9_]*)$/; -const DEFAULT_ENV_PREFIXES: ReadonlyArray = ['VITE_']; - -function resolveEnvPrefixes(prefix: ResolvedConfig['envPrefix']): ReadonlyArray { - if (typeof prefix === 'string') { - return [prefix]; - } - if (Array.isArray(prefix) && prefix.length > 0) { - return prefix; - } - return DEFAULT_ENV_PREFIXES; -} +const CLIENT_ENV_PREFIXES: ReadonlyArray = [CLIENT_ENV_PREFIX]; export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = {}): Plugin { const outDir = join(root, '.void'); let envFile: string | null = null; - let viteMode = 'development'; - let envDir = root; + let command: 'serve' | 'build' = 'serve'; const appliedClientDefaults = new Map(); - async function emitSchemaMetadata() { - if (!envFile || (!options.onDefaults && !options.onSecrets)) { + async function emitSchemaFacts(prefixes: ReadonlyArray) { + if (!envFile || !options.onSchema) { return; } - const { _hasRegisteredSchema, _getRegisteredSchema, _getSchemaDefaults } = - await loadSchemaModule(); + const { _hasRegisteredSchema, _getRegisteredSchema } = await loadSchemaModule(); if (!_hasRegisteredSchema()) { return; } - options.onDefaults?.(_getSchemaDefaults()); const schema = _getRegisteredSchema() ?? {}; - options.onSecrets?.( - Object.keys(schema).filter((key) => getSecretOverride(schema[key]) === 'secret'), - ); + const keys = Object.keys(schema); + const clientKeys = keys.filter((key) => prefixes.some((prefix) => key.startsWith(prefix))); + const clientKeySet = new Set(clientKeys); + const serverKeys = keys.filter((key) => !clientKeySet.has(key)); + const requiredServerKeys: Array = []; + for (const key of serverKeys) { + const result = schema[key]!['~standard'].validate(undefined); + const resolved = result instanceof Promise ? await result : result; + if (resolved && typeof resolved === 'object' && 'issues' in resolved && resolved.issues) { + requiredServerKeys.push(key); + } + } + options.onSchema({ keys, clientKeys, serverKeys, requiredServerKeys }); } function regenerateTypes() { @@ -105,13 +102,16 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = writeFileIfChanged(join(outDir, 'env.d.ts'), dts); } - async function importUserEnv() { + async function importUserEnv(strict = false) { if (!envFile) { return; } try { await loadUserEnvFile(envFile); } catch (err) { + if (strict) { + throw err; + } voidWarn((err as Error).message); } } @@ -157,15 +157,14 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = if (!_hasRegisteredSchema()) { return; } - const { loadEnv } = await import('vite'); - const merged = filterLoadedEnv(loadEnv(viteMode, envDir, '')); - const report = await validateAllEnv(merged as Record); + const local = loadLocalEnv(root, { onWarn: (message) => voidWarn(message) }); + const report = await validateAllEnv({ ...local, ...process.env }); if (report.valid) { return; } const lines: Array = ['Env validation issues:']; for (const key of report.missing) { - lines.push(` • ${key} is required but missing — add it to .env.local or set in your shell`); + lines.push(` • ${key} is required but missing — add it to .env or set it in your shell`); } for (const { key, messages } of report.invalid) { lines.push(` • ${key}: ${messages.join('; ')}`); @@ -173,55 +172,107 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = voidWarn(lines.join('\n')); } - async function applyClientDefaultsToViteEnv(config: ResolvedConfig) { + async function applyClientValuesToViteEnv(config: ResolvedConfig) { if (!envFile) { return; } + const configEnv = config.env ?? {}; for (const [key, value] of appliedClientDefaults) { - if (config.env[key] === value) { - delete config.env[key]; + if (configEnv[key] === value) { + delete configEnv[key]; } } appliedClientDefaults.clear(); - const { _hasRegisteredSchema, _getSchemaDefaults } = await loadSchemaModule(); + const { _hasRegisteredSchema, _getRegisteredSchema, _getSchemaDefaults } = + await loadSchemaModule(); if (!_hasRegisteredSchema()) { return; } - const prefixes = resolveEnvPrefixes(config.envPrefix); + const schema = _getRegisteredSchema() ?? {}; + const local = command === 'serve' ? loadLocalEnv(root) : {}; + const readable = { ...local, ...process.env }; + for (const key of Object.keys(schema)) { + if (!key.startsWith(CLIENT_ENV_PREFIX) || configEnv[key] !== undefined) { + continue; + } + const value = readable[key]; + if (value !== undefined) { + configEnv[key] = value; + } + } + for (const [key, value] of Object.entries(_getSchemaDefaults())) { - if (!prefixes.some((prefix) => key.startsWith(prefix))) { + if (!key.startsWith(CLIENT_ENV_PREFIX)) { continue; } - if (config.env[key] !== undefined) { + if (configEnv[key] !== undefined) { continue; } - config.env[key] = value; + configEnv[key] = value; appliedClientDefaults.set(key, value); } } return { name: 'void:env-schema', - async config() { + enforce: 'pre', + async config(_config, environment) { + const legacyFiles = findLegacyEnvFiles(root); + if (legacyFiles.length > 0) { + throw new Error( + `void/env supports one local file: .env. Remove ${legacyFiles.join(', ')} and follow the env migration guide.`, + ); + } envFile = findEnvFile(root); if (!envFile) { return; } regenerateTypes(); - // Import env.ts at config time so onDefaults can fire before the - // Cloudflare plugin's mergeBindings reads workerConfig.vars. - await importUserEnv(); - await emitSchemaMetadata(); + // Import env.ts at config time so onSchema can configure required + // secrets before the Cloudflare plugin resolves worker bindings. + await importUserEnv(environment.command === 'build'); + await emitSchemaFacts(CLIENT_ENV_PREFIXES); }, async configResolved(config) { - viteMode = config.mode; - // `envDir: false` disables Vite's .env loading entirely — fall back to - // root so our own validate-against-merged-env still picks up shell vars. - envDir = typeof config.envDir === 'string' ? config.envDir : root; - await applyClientDefaultsToViteEnv(config); + command = config.command; + await emitSchemaFacts(CLIENT_ENV_PREFIXES); + + // `.env` is local-only. Vite loads it during production builds by + // default, so remove file-sourced client values before its replacement + // pass. CI/shell values remain available, and schema defaults are added + // immediately below. + if (command === 'build') { + const local = loadLocalEnv(root, { onWarn: (message) => voidWarn(message) }); + for (const [key, value] of Object.entries(local)) { + if (process.env[key] === undefined && config.env[key] === value) { + delete config.env[key]; + } + } + } + await applyClientValuesToViteEnv(config); + + if (command === 'build' && envFile) { + const { validateAllEnv } = await loadSchemaModule(); + const report = await validateAllEnv({ ...config.env, ...process.env }); + const clientKey = (key: string) => key.startsWith(CLIENT_ENV_PREFIX); + const missing = report.missing.filter(clientKey); + const invalid = report.invalid.filter(({ key }) => clientKey(key)); + if (missing.length > 0 || invalid.length > 0) { + const lines = ['Client env validation failed:']; + for (const key of missing) { + lines.push( + ` • ${key} is required — set it in the build shell or add a schema default`, + ); + } + for (const { key, messages } of invalid) { + lines.push(` • ${key}: ${messages.join('; ')}`); + } + throw new Error(lines.join('\n')); + } + } // Warn when vite `define` shadows a schema key. The two systems coexist // — define replaces source literals at build time; defineEnv types and // validates the runtime env — but using both for the same name causes @@ -250,8 +301,8 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = voidLog('env.ts changed — regenerating types'); regenerateTypes(); await importUserEnv(); - await emitSchemaMetadata(); - await applyClientDefaultsToViteEnv(server.config); + await emitSchemaFacts(CLIENT_ENV_PREFIXES); + await applyClientValuesToViteEnv(server.config); await validateAndWarn(); server.ws.send({ type: 'full-reload' }); } diff --git a/packages/void/src/plugin-migration.ts b/packages/void/src/plugin-migration.ts index 17feba887..46b230986 100644 --- a/packages/void/src/plugin-migration.ts +++ b/packages/void/src/plugin-migration.ts @@ -1,45 +1,19 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { join } from 'pathslash'; import type { Plugin } from 'vite'; import type { DatabaseDialect } from './config.ts'; import { voidLog, voidWarn, voidError } from './log.ts'; import { readProjectPaths } from './project-paths.ts'; +import { loadLocalEnv } from './shared/env.ts'; function readDatabaseUrl(root: string): string | undefined { - const envPath = join(root, '.env.local'); - if (!existsSync(envPath)) { - return undefined; - } - const content = readFileSync(envPath, 'utf-8'); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { - continue; - } - const eqIndex = trimmed.indexOf('='); - if (eqIndex === -1) { - continue; - } - const key = trimmed.slice(0, eqIndex).trim(); - if (key !== 'DATABASE_URL') { - continue; - } - let value = trimmed.slice(eqIndex + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - return value; - } - return undefined; + return loadLocalEnv(root).DATABASE_URL; } /** * Vite plugin that runs local migrations on dev server startup. * For SQLite/D1: applies migrations to the local miniflare database. - * For PostgreSQL/MySQL: connects via DATABASE_URL from .env.local. + * For PostgreSQL/MySQL: connects via DATABASE_URL from .env. */ export function migrationPlugin( root: string, @@ -68,7 +42,7 @@ export function migrationPlugin( const warn = () => { console.log(); voidError( - `DATABASE_URL not found in .env.local — skipping ${dialect === 'mysql' ? 'MySQL' : 'PostgreSQL'} migrations.`, + `DATABASE_URL not found in .env — skipping ${dialect === 'mysql' ? 'MySQL' : 'PostgreSQL'} migrations.`, ); }; if (server.httpServer) { diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index ececdd7be..83cc78691 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { dirname, join, relative } from 'pathslash'; -import { loadEnv, normalizePath, type Plugin } from 'vite'; +import { normalizePath, type Plugin } from 'vite'; import { hasApiRoutes, hasDocumentRoutes, @@ -34,6 +34,7 @@ import { import type { RouteEnvironment } from './router/parse-filename.ts'; import { scanRoutes, type ScanResult } from './router/scan.ts'; import type { VoidAuthRuntimeOptions } from './runtime/better-auth-shared.ts'; +import { loadLocalEnv } from './shared/env.ts'; import { validateSsrEntries, SSR_SERVER_ENTRY_RELATIVE_PATHS, @@ -282,7 +283,8 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug providers: compileOptions.auth.providers, userConfig: await loadVoidAuthConfig(compileOptions.auth.authConfigPath), }; - const env = loadEnv(resolvedConfig?.mode ?? 'development', root, '') as Record; + const local = resolvedConfig?.command === 'serve' ? loadLocalEnv(root) : {}; + const env = { ...local, ...process.env } as Record; const schemaPath = join(outDir, 'better-auth-schema.ts'); const databasePath = join(outDir, 'better-auth-db.ts'); diff --git a/packages/void/src/prerender.ts b/packages/void/src/prerender.ts index c4eca67df..3182af837 100644 --- a/packages/void/src/prerender.ts +++ b/packages/void/src/prerender.ts @@ -10,12 +10,11 @@ import { import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import { dirname, join, relative } from 'pathslash'; -import { loadEnv } from 'vite'; import { getProxyUrl, getToken, isStagingMode } from './cli/auth.ts'; import { ensureWranglerCompat } from './cli/wrangler.ts'; import { readConfig } from './config.ts'; import { voidLog, voidWarn } from './log.ts'; -import { filterLoadedEnv, stripInternalEnvKeys } from './shared/env.ts'; +import { stripInternalEnvKeys } from './shared/env.ts'; import { DEFAULT_PROXY_URL, STAGING_PROXY_URL } from './shared/proxy.ts'; import { buildEnv } from './shared/untrusted-env.ts'; @@ -256,7 +255,9 @@ async function createCloudflarePrerenderRunner(options: { const wranglerCompat = ensureWranglerCompat(root, readConfig(root), workerDir); const workerMain = wranglerConfig.main ?? 'index.js'; - const env = filterLoadedEnv(loadEnv('production', root, '')); + // Local `.env` values never enter a production prerender. Code that needs + // production server configuration must read it at runtime from remote secrets. + const env: Record = {}; // Resolve Void projectId via a three-level chain: // 1. explicit options.projectId — passed from runFullDeploy so the @@ -447,19 +448,6 @@ type NodeRunnerConfig = | { mode: 'collect-and-render'; ssrDir: string; clientDir: string }; async function runNodePrerenderRunner(root: string, config: NodeRunnerConfig): Promise { - // Mirror the sanitization pipeline used by envPlugin.config (index.ts), - // buildPrerenderBindings (CF runner), and loadDeployEnv (deploy.ts): strip - // any internal Void keys a user may have placed in .env.production so they - // can't shadow or pollute the child process env via the `...env` spread - // below. Consistency matters — all sites that ingest user .env files must - // run the same sanitizer, or drift will reintroduce the leak. - const filtered = filterLoadedEnv(loadEnv('production', root, '')); - const { sanitized: env, stripped } = stripInternalEnvKeys(filtered); - if (stripped.length > 0) { - voidWarn( - `Ignoring internal env keys from .env files in Node prerender runner: ${stripped.join(', ')}. These are reserved for Void runtime use.`, - ); - } const runnerPath = join(root, '.void', 'prerender-runner.mjs'); mkdirSync(dirname(runnerPath), { recursive: true }); writeFileSync( @@ -555,7 +543,7 @@ if (mode === "collect") { execFile( process.execPath, [runnerPath, JSON.stringify(config)], - { env: buildEnv(env), cwd: root, maxBuffer: 10 * 1024 * 1024 }, + { env: buildEnv(), cwd: root, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { if (stderr) { process.stderr.write(stderr); diff --git a/packages/void/src/runtime/env-helpers.ts b/packages/void/src/runtime/env-helpers.ts index b986b55d8..c3d3474c3 100644 --- a/packages/void/src/runtime/env-helpers.ts +++ b/packages/void/src/runtime/env-helpers.ts @@ -1,4 +1,3 @@ -import type { SecretOverride } from '../shared/env-mask.ts'; /** * Built-in env schema helpers — Standard Schema-conformant. * @@ -25,44 +24,11 @@ type Parser = (raw: string) => ParseResult | ParseError; type BuilderState = { optional: boolean; defaultValue: T | undefined; - secretOverride: SecretOverride; }; -/** - * Marker property read by the mask helpers. Present only on built-in - * schema builders; third-party Standard Schema validators fall back to the - * name/value heuristic (documented in `docs/guide/env-vars.md`). - */ -export const SECRET_OVERRIDE_SYMBOL: unique symbol = Symbol.for('void.env.secretOverride'); - -/** - * Pull the explicit `.secret()` / `.public()` flag off a Standard Schema - * validator, if it was authored through our built-in helpers. Returns - * `undefined` for third-party validators (valibot, zod, arktype, …) — - * they are redacted purely via the heuristic. - */ -export function getSecretOverride( - validator: { [SECRET_OVERRIDE_SYMBOL]?: SecretOverride } | unknown, -): SecretOverride { - if (!validator || typeof validator !== 'object') { - return undefined; - } - return (validator as { [SECRET_OVERRIDE_SYMBOL]?: SecretOverride })[SECRET_OVERRIDE_SYMBOL]; -} - export type EnvSchema = StandardSchemaV1 & { optional(): EnvSchema; default(value: NonNullable): EnvSchema>; - /** - * Force redaction of this key's value in every validation/error surface, - * regardless of the key-name / value-content heuristics. - */ - secret(): EnvSchema; - /** - * Opt this key out of redaction — useful for public config (URLs, app - * titles) that happens to match the heuristic (`PUBLIC_KEY`, `…_TOKEN_URL`). - */ - public(): EnvSchema; }; function build(parse: Parser, state: BuilderState): EnvSchema { @@ -93,36 +59,21 @@ function build(parse: Parser, state: BuilderState): EnvSchema { return build(parse as Parser, { optional: true, defaultValue: state.defaultValue, - secretOverride: state.secretOverride, }); }, default(value: NonNullable): EnvSchema> { return build>(parse as Parser>, { optional: false, defaultValue: value, - secretOverride: state.secretOverride, }); }, - secret() { - return build(parse, { ...state, secretOverride: 'secret' }); - }, - public() { - return build(parse, { ...state, secretOverride: 'public' }); - }, }; - // Non-enumerable so it doesn't pollute `Object.keys` on user schemas. - Object.defineProperty(schema, SECRET_OVERRIDE_SYMBOL, { - value: state.secretOverride, - enumerable: false, - configurable: true, - }); return schema; } const empty = (): BuilderState => ({ optional: false, defaultValue: undefined, - secretOverride: undefined, }); /** Required string env value. */ diff --git a/packages/void/src/runtime/env-public-client.ts b/packages/void/src/runtime/env-public-client.ts index cc1e3e445..8ce23da24 100644 --- a/packages/void/src/runtime/env-public-client.ts +++ b/packages/void/src/runtime/env-public-client.ts @@ -1,9 +1,8 @@ /** * Client-side variant of `void/env`. * - * Selected via the `browser` export condition. Vite already strips - * non-prefixed env keys from the client bundle (the prefix is configurable - * via `envPrefix`, defaulting to `VITE_*`). If a server-only key is read + * Selected via the `browser` export condition. Void exposes only fixed-prefix + * `VITE_*` schema keys to the client bundle. If a server-only key is read * here it isn't on `import.meta.env`, so we throw a clear error rather than * silently returning `undefined`. * @@ -27,8 +26,8 @@ function readClientKey(key: string): unknown { } throw new Error( `env: Key '${key}' is not exposed to the client. ` + - `Vite only injects keys matching the configured envPrefix (default: VITE_*). ` + - `Either rename the key in env.ts to match envPrefix, or move this access to a server module.`, + `Void only exposes VITE_* schema keys to browser code. ` + + `Either rename the key in env.ts with that prefix, or move this access to a server module.`, ); } diff --git a/packages/void/src/runtime/env-public.ts b/packages/void/src/runtime/env-public.ts index b37dfb952..e392fe5ab 100644 --- a/packages/void/src/runtime/env-public.ts +++ b/packages/void/src/runtime/env-public.ts @@ -28,17 +28,33 @@ */ /// -import { isUnmaskActive, redactMessage, type SecretOverride } from '../shared/env-mask.ts'; +import { isUnmaskActive, redactMessage } from '../shared/env-mask.ts'; import type { StandardSchemaV1 } from '../standard-schema.ts'; -import { getSecretOverride } from './env-helpers.ts'; import { getRawRuntimeEnv, type RuntimeEnv } from './env-raw.ts'; import { getRuntimeEnv, isInternalBinding } from './env.ts'; type AnyValidator = StandardSchemaV1; type AnySchema = Record; -let registeredSchema: AnySchema | null = null; -let hasDefineEnvBeenCalled = false; +type BootValidationState = { ok: true } | { ok: false; error: EnvValidationError }; +type EnvRegistryState = { + schema: AnySchema | null; + hasDefineEnvBeenCalled: boolean; + bootValidation: BootValidationState | null; +}; + +// Tooling can load `void/env` through a different module instance than the Vite +// plugin (source workspace, built package, or cache-busted HMR import). Keep the +// registry process-global so every instance observes the same schema. +const ENV_REGISTRY_SYMBOL = Symbol.for('void.env.registry'); +const registryGlobal = globalThis as typeof globalThis & { + [ENV_REGISTRY_SYMBOL]?: EnvRegistryState; +}; +const registry = (registryGlobal[ENV_REGISTRY_SYMBOL] ??= { + schema: null, + hasDefineEnvBeenCalled: false, + bootValidation: null, +}); const validatedCache = new WeakMap>(); /** @@ -67,22 +83,19 @@ type InferEnv = { * `import { env } from "~/env"` pattern. */ export function defineEnv(schema: S): InferEnv { - registeredSchema = schema; + registry.schema = schema; // Flip the presence flag once and leave it on — even if a future call // swaps in an empty schema, we still know defineEnv was declared. - hasDefineEnvBeenCalled = true; + registry.hasDefineEnvBeenCalled = true; // Drop any cached entries from a prior schema (HMR / re-import). for (const map of validatedCacheValues()) { map.clear(); } // Reset boot-time validation state so a re-imported schema is re-checked. - bootValidationState = null; + registry.bootValidation = null; return env as unknown as InferEnv; } -type BootValidationState = { ok: true } | { ok: false; error: EnvValidationError }; -let bootValidationState: BootValidationState | null = null; - /** * Internal: validate the entire schema against the worker's actual env on * the first request per isolate. Catches bad remote-secret values and @@ -101,18 +114,18 @@ let bootValidationState: BootValidationState | null = null; * throw with guidance pointing back to this boot-time pre-validation. */ export async function _validateEnvOnce(envValues: unknown): Promise { - if (bootValidationState) { - if (!bootValidationState.ok) { - throw bootValidationState.error; + if (registry.bootValidation) { + if (!registry.bootValidation.ok) { + throw registry.bootValidation.error; } return; } - if (!registeredSchema) { - bootValidationState = { ok: true }; + if (!registry.schema) { + registry.bootValidation = { ok: true }; return; } const values = (envValues ?? {}) as Record; - const report = await validateAllEnvWithValues(registeredSchema, values); + const report = await validateAllEnvWithValues(registry.schema, values); if (report.valid) { // Seed the sync cache with already-validated values so subsequent // `env.X` reads (including ones with async validators) hit the cache @@ -147,7 +160,7 @@ export async function _validateEnvOnce(envValues: unknown): Promise { } catch { // No runtime env available — no proxy reads will happen, so no seeding needed. } - bootValidationState = { ok: true }; + registry.bootValidation = { ok: true }; return; } const lines: Array = ['Env validation failed at worker boot:']; @@ -159,7 +172,7 @@ export async function _validateEnvOnce(envValues: unknown): Promise { lines.push(` • ${key}: ${messages.join('; ')}`); } const error = new EnvValidationError('__init__', [{ message: lines.join('\n') }]); - bootValidationState = { ok: false, error }; + registry.bootValidation = { ok: false, error }; throw error; } @@ -171,7 +184,7 @@ const cacheList: Array> = []; /** Internal: framework-only access to the registered schema (for plugin/CLI). */ export function _getRegisteredSchema(): AnySchema | null { - return registeredSchema; + return registry.schema; } /** @@ -181,14 +194,12 @@ export function _getRegisteredSchema(): AnySchema | null { * `defineEnv({})` (an intentional empty schema) from "never called". */ export function _hasRegisteredSchema(): boolean { - return hasDefineEnvBeenCalled; + return registry.hasDefineEnvBeenCalled; } /** * Internal: extract `.default(value)` values from the registered schema as - * stringified env values. Used by the plugin and deploy CLI to seed worker - * `vars` so `c.env.X` and `process.env.X` see the same fallback that the - * typed `env.X` proxy returns. + * stringified env values. Used for explicit build-time client fallbacks. * * Detection is schema-agnostic: probe each validator with `undefined` and, * if it succeeds with a non-undefined value, that value is the default. @@ -196,16 +207,14 @@ export function _hasRegisteredSchema(): boolean { * Schema validator with intrinsic default behaviour. Async validators are * skipped (we can't get a sync default). * - * Keys with no default — required keys, plain `.optional()` — are omitted - * from the result so they don't shadow user `.env*` entries with empty - * strings. + * Keys with no default — required keys and plain `.optional()` keys — are omitted. */ export function _getSchemaDefaults(): Record { const defaults: Record = {}; - if (!registeredSchema) { + if (!registry.schema) { return defaults; } - for (const [key, validator] of Object.entries(registeredSchema)) { + for (const [key, validator] of Object.entries(registry.schema)) { let result: unknown; try { result = validator['~standard'].validate(undefined); @@ -258,10 +267,10 @@ function readKey(key: string): unknown { // Filtered env hides internal __VOID_*/__PROJECT_* bindings from user code. const filtered = getRuntimeEnv() as Record; const rawValue = filtered[key]; - if (!registeredSchema) { + if (!registry.schema) { return rawValue; } - const validator = registeredSchema[key]; + const validator = registry.schema[key]; if (!validator) { return rawValue; } @@ -290,10 +299,9 @@ function readKey(key: string): unknown { } if (result.issues) { const unmask = isUnmaskActive(); - const override = getSecretOverride(validator); const redactedIssues = result.issues.map((i) => ({ ...i, - message: redactMessage(key, rawValue, i.message, { override, unmask }), + message: redactMessage(key, rawValue, i.message, { unmask }), })); throw new EnvValidationError(key, redactedIssues); } @@ -354,7 +362,7 @@ export type EnvValidationReport = { }; /** - * Validate an env record (e.g. merged `.env*` + remote secret list) against + * Validate an env record (local dotenv, build inputs, or runtime bindings) against * the registered schema. Used by the dev plugin (warnings), the deploy CLI * (hard error), and `_validateEnvOnce` at worker boot. * @@ -364,10 +372,10 @@ export type EnvValidationReport = { export async function validateAllEnv( envValues: Record, ): Promise { - if (!registeredSchema) { + if (!registry.schema) { return { valid: true, missing: [], invalid: [], values: new Map() }; } - return await validateAllEnvWithValues(registeredSchema, envValues); + return await validateAllEnvWithValues(registry.schema, envValues); } /** @@ -395,12 +403,9 @@ async function validateAllEnvWithValues( if (isMissing) { report.missing.push(key); } else { - const override: SecretOverride = getSecretOverride(validator); report.invalid.push({ key, - messages: result.issues.map((i) => - redactMessage(key, raw, i.message, { override, unmask }), - ), + messages: result.issues.map((i) => redactMessage(key, raw, i.message, { unmask })), }); } } else { diff --git a/packages/void/src/shared/dotenv.ts b/packages/void/src/shared/dotenv.ts index bada835a2..8802640ba 100644 --- a/packages/void/src/shared/dotenv.ts +++ b/packages/void/src/shared/dotenv.ts @@ -1,7 +1,6 @@ /** - * Shared dotenv parser + variable expander used by every CLI path that reads - * `.env*` files (`void env check`, `void deploy`, `void init` scaffold - * inference). A single source of truth avoids the drift we hit when two + * Shared dotenv parser + variable expander used by local `.env` consumers and + * explicit `void secret sync ` input. A single source of truth avoids drift when two * hand-rolled parsers diverged on quoting / expansion semantics. * * Expansion supports `${VAR}` and `$VAR`, a `\$VAR` literal-escape, and @@ -10,14 +9,8 @@ * * Security note: expansion is deliberately scoped to the in-file record. * We do NOT consult `process.env` the way `dotenv-expand` does — a shell - * env leaking into an `.env.example` via expansion would silently reveal - * developer machine state in committed output, and `void env check` / - * `void deploy` would then pass with values that aren't present in CI. - * - * The runtime path (Vite's own `loadEnv`) DOES consult `process.env` for - * expansion. That's a deliberate Vite choice; `filterLoadedEnv` strips - * shell pollution from the final worker vars so the two sides agree on - * the observable surface even when expansion sources differ. + * env leaking into a parsed dotenv file would silently change its meaning + * across machines. * * This module is a leaf — it must not import from `../log` or any non-leaf * module so that the prerender subprocess's worker bundle doesn't pick up @@ -226,7 +219,7 @@ function expandValue(value: string, resolve: (key: string) => string): string { /** * Merge multiple parsed dotenv records with later-entries-win semantics, * then expand across the combined set. This is the recommended path for - * callers reading multiple `.env*` files (Vite-style precedence), since + * callers that explicitly combine multiple dotenv records, since * expansion sees every earlier file's keys at once. */ export function mergeAndExpand( diff --git a/packages/void/src/shared/env-mask.ts b/packages/void/src/shared/env-mask.ts index 7922588de..63666e8d5 100644 --- a/packages/void/src/shared/env-mask.ts +++ b/packages/void/src/shared/env-mask.ts @@ -6,13 +6,9 @@ * dev-server warning block. The key name is never masked (the user needs * it to locate the offending entry). * - * Redaction is belt-and-braces: - * 1. An explicit `.secret()` / `.public()` flag on a schema entry wins. - * 2. Otherwise, a key-name regex (`KEY|TOKEN|SECRET|…`) redacts anything - * that looks like credentials. - * 3. Even on non-secret keys, a value that itself looks high-entropy - * (`sk_…`, `ghp_…`, ≥24 characters of mixed alphanumerics) is redacted - * — catches wrong-file typos like `PORT=sk_live_abc123`. + * Server env values are production secrets by definition, so validator + * messages redact the raw input unconditionally. The old `.secret()` / + * `.public()` masking axis was removed because it did not control storage. * * Users can opt out of the guard for local debugging via * `VOID_ENV_UNMASK=1` in the environment; that path prints a one-shot @@ -58,12 +54,7 @@ export function isLikelySecretValue(value: unknown): boolean { return SECRET_VALUE_PREFIX_RE.test(value) || HIGH_ENTROPY_RE.test(value); } -/** Per-key override set by `.secret()` / `.public()` on built-in helpers. */ -export type SecretOverride = 'secret' | 'public' | undefined; - export type MaskOptions = { - /** Explicit per-key override; wins over every heuristic. */ - override?: SecretOverride; /** When true, the env unmask escape hatch has been honoured. */ unmask?: boolean; }; @@ -77,12 +68,6 @@ export function maskValue(key: string, value: unknown, options: MaskOptions = {} if (options.unmask) { return stringifyForDisplay(value); } - if (options.override === 'public') { - return stringifyForDisplay(value); - } - if (options.override === 'secret') { - return REDACTED; - } if (isLikelySecretKey(key)) { return REDACTED; } @@ -109,12 +94,6 @@ export function redactMessage( if (options.unmask) { return message; } - const shouldMask = - options.override === 'secret' || - (options.override !== 'public' && (isLikelySecretKey(key) || isLikelySecretValue(rawValue))); - if (!shouldMask) { - return message; - } if (typeof rawValue !== 'string' || rawValue.length === 0) { return message; } diff --git a/packages/void/src/shared/env.ts b/packages/void/src/shared/env.ts index 08d55aa90..b76bae1e7 100644 --- a/packages/void/src/shared/env.ts +++ b/packages/void/src/shared/env.ts @@ -1,12 +1,34 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'pathslash'; +import { parseDotenvFile } from './dotenv.ts'; + // Shared helpers for environment resolution. Imported by the Vite plugin // (index.ts), the prerender runner (prerender.ts), and the prerender Vite // plugin (plugin-prerender.ts). Keep these as leaf helpers — they must not // import from any non-leaf module, otherwise the prerender subprocess's // worker bundle acquires unwanted transitive dependencies. +/** Void's single local dotenv file. It is never a production configuration source. */ +export const LOCAL_ENV_FILE = '.env'; + +/** The fixed browser-exposure convention supported by `void/env`. */ +export const CLIENT_ENV_PREFIX = 'VITE_'; + +export function isClientEnvKey(key: string): boolean { + return key.startsWith(CLIENT_ENV_PREFIX); +} + +/** Read the one local-development dotenv file. */ +export function loadLocalEnv( + root: string, + options: { onWarn?: (message: string) => void } = {}, +): Record { + return parseDotenvFile(join(root, LOCAL_ENV_FILE), { onWarn: options.onWarn }); +} + /** * Marks the isolated probes and Vite build run by - * `void deploy --backend cloudflare`. + * `void deploy --platform cloudflare`. * * Kept here so the deploy orchestrator and Vite plugin share one spelling. * The marker is passed through child-process env only; it is never a Worker @@ -15,24 +37,23 @@ export const CLOUDFLARE_DEPLOY_BUILD_ENV = 'VOID_CLOUDFLARE_DEPLOY_BUILD'; /** - * Strip shell environment pollution from a Vite `loadEnv(mode, root, '')` - * result. Vite returns `.env`-file entries AND every `process.env` entry - * when called with an empty prefix; we want only the keys that came from - * `.env` files (either new keys or keys that override a shell value). - * - * Used anywhere we need to populate worker vars from `.env` without - * shipping the developer's shell environment (PATH, HOME, etc.) into - * `wrangler.json` or the prerender Miniflare instance. + * Find legacy Vite dotenv variants. Void intentionally supports only `.env`; + * production configuration is project-scoped remote state instead of a file. */ -export function filterLoadedEnv(loaded: Record): Record { - const shellKeys = new Set(Object.keys(process.env)); - const env: Record = {}; - for (const [key, value] of Object.entries(loaded)) { - if (!shellKeys.has(key) || process.env[key] !== value) { - env[key] = value; - } +export function findLegacyEnvFiles(root: string): Array { + try { + return readdirSync(root) + .filter( + (name) => + name.startsWith('.env.') || + name === '.env.example' || + name === '.dev.vars' || + name.startsWith('.dev.vars.'), + ) + .sort((a, b) => a.localeCompare(b)); + } catch { + return []; } - return env; } /** @@ -67,10 +88,9 @@ export function resolveRemoteMode(configRemote: boolean | undefined): boolean { * of them should ever originate from user-authored `.env` files, shell env, * or CI config — if they do, we strip them and warn at the call site. * - * Callers: `index.ts` envPlugin.config (dev/build wrangler.json vars), + * Callers: `index.ts` envPlugin.config (dev wrangler.json vars), * `prerender.ts` buildPrerenderBindings (CF prerender subprocess bindings), - * `prerender.ts` runNodePrerenderRunner (Node prerender child process env), - * `cli/deploy.ts` loadDeployEnv (deploy manifest vars). + * `prerender.ts` runNodePrerenderRunner (Node prerender child process env). */ export const INTERNAL_ENV_KEYS = [ '__VOID_TOKEN', diff --git a/packages/void/test/integration/env-cli.test.ts b/packages/void/test/integration/env-cli.test.ts index de7160c51..c7a4705c8 100644 --- a/packages/void/test/integration/env-cli.test.ts +++ b/packages/void/test/integration/env-cli.test.ts @@ -90,19 +90,19 @@ describe('void env check (subprocess)', () => { expect(combined).toContain('STRIPE_KEY'); }); - it('exits 0 when .env.production satisfies every required key', () => { + it('exits 0 when .env satisfies every required key locally', () => { const root = createProject('{ STRIPE_KEY: string(), PORT: number() }'); - writeFileSync(join(root, '.env.production'), 'STRIPE_KEY=sk_live\nPORT=8080\n'); + writeFileSync(join(root, '.env'), 'STRIPE_KEY=sk_dev\nPORT=8080\n'); const { stdout, status } = runVoid(root, ['env', 'check']); expect(status).toBe(0); - expect(stdout).toContain('All required env vars'); + expect(stdout).toContain('Local .env values are valid'); }); it('redacts secret-looking values from invalid-env report output', () => { // PORT expects a number; the user accidentally pasted an API key. // The CLI output must NOT contain the raw token. const root = createProject('{ PORT: number() }'); - writeFileSync(join(root, '.env.production'), 'PORT=sk_live_abc123456\n'); + writeFileSync(join(root, '.env'), 'PORT=sk_live_abc123456\n'); const { stdout, status } = runVoid(root, ['env', 'check']); expect(status).toBe(1); expect(stdout).toContain('PORT'); @@ -110,15 +110,13 @@ describe('void env check (subprocess)', () => { expect(stdout).toContain(''); }); - it('ignores .env.local in the deploy-style productionOnly check', () => { - // This is the guardrail behaviour: `void env check` defaults to the - // same productionOnly filter deploy uses. A dev-only .env.local must - // not mask a missing prod secret. + it('rejects legacy .env.local files', () => { const root = createProject('{ STRIPE_KEY: string() }'); writeFileSync(join(root, '.env.local'), 'STRIPE_KEY=sk_dev\n'); const { stdout, status } = runVoid(root, ['env', 'check']); expect(status).toBe(1); - expect(stdout).toContain('STRIPE_KEY'); + expect(stdout).toContain('only .env is supported'); + expect(stdout).toContain('.env.local'); }); }); diff --git a/packages/void/test/integration/env-prod-validation.test.ts b/packages/void/test/integration/env-prod-validation.test.ts index 0818be4bb..0fc2117ab 100644 --- a/packages/void/test/integration/env-prod-validation.test.ts +++ b/packages/void/test/integration/env-prod-validation.test.ts @@ -1,8 +1,12 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'pathslash'; -import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; -import { formatEnvReport, validateProdEnv } from '../../src/cli/env-validation.ts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + formatEnvReport, + validateProdEnv, + validateSecretValues, +} from '../../src/cli/env-validation.ts'; import { number, string } from '../../src/runtime/env-helpers.ts'; import { defineEnv } from '../../src/runtime/env-public.ts'; @@ -11,8 +15,8 @@ import { defineEnv } from '../../src/runtime/env-public.ts'; * `assertEnvSchemaValid` wrapper inside `deploy.ts` is a thin layer over * `validateProdEnv` — it resolves the remote secret list, calls * `validateProdEnv`, and throws when the report is invalid. Exercising - * `validateProdEnv` across the realistic source combinations (.env, - * .env.production, .env.local, remote secrets) is what catches the + * `validateProdEnv` across the local `.env`, build shell, and remote + * secret sources is what catches the * regressions that silently break `void deploy`. * * Schema registration is done directly via `defineEnv` rather than via an @@ -44,7 +48,40 @@ beforeEach(() => { defineEnv({}); }); +describe('validateSecretValues — upload-time validation', () => { + it('accepts declared server values that pass env.ts validation', async () => { + const root = createTempProject(); + defineEnv({ PORT: number() }); + + await expect( + validateSecretValues(root, [{ name: 'PORT', value: '8080' }]), + ).resolves.toBeUndefined(); + }); + + it('rejects invalid values without echoing the plaintext', async () => { + const root = createTempProject(); + defineEnv({ PORT: number() }); + + await expect( + validateSecretValues(root, [{ name: 'PORT', value: 'not-a-number' }]), + ).rejects.toThrow('PORT failed env.ts validation'); + }); + + it('rejects undeclared and client-exposed names', async () => { + const root = createTempProject(); + defineEnv({ SERVER_URL: string(), VITE_API_URL: string() }); + + await expect(validateSecretValues(root, [{ name: 'OTHER', value: 'x' }])).rejects.toThrow( + 'OTHER is not declared in env.ts', + ); + await expect( + validateSecretValues(root, [{ name: 'VITE_API_URL', value: 'https://example.com' }]), + ).rejects.toThrow('VITE_API_URL is client-exposed'); + }); +}); + afterEach(() => { + vi.unstubAllEnvs(); for (const root of roots.splice(0, roots.length)) { rmSync(root, { recursive: true, force: true }); } @@ -58,7 +95,7 @@ describe('validateProdEnv — deploy-time schema enforcement', () => { expect(result.report.valid).toBe(true); }); - it('reports missing required keys when neither .env* files nor remote secrets supply them', async () => { + it('reports missing required keys when no remote secrets supply them', async () => { const root = createTempProject(); defineEnv({ STRIPE_KEY: string(), DATABASE_URL: string() }); @@ -101,78 +138,63 @@ describe('validateProdEnv — deploy-time schema enforcement', () => { expect(report.invalid).toEqual([]); }); - it('merges .env and .env.production values, with later files winning', async () => { + it('never reads .env during production validation', async () => { const root = createTempProject(); defineEnv({ STRIPE_KEY: string(), PORT: number() }); writeDotenv(root, '.env', 'STRIPE_KEY=base\nPORT=3000\n'); - // .env.production overrides PORT — deploy-safe. - writeDotenv(root, '.env.production', 'PORT=8080\n'); const { report } = await validateProdEnv(root, { productionOnly: true }); - expect(report.valid).toBe(true); + expect(report.valid).toBe(false); + expect(report.missing.sort()).toEqual(['PORT', 'STRIPE_KEY']); }); - it('does NOT read .env.local in productionOnly mode — .env.local must not plug prod gaps', async () => { + it('reads only .env in local mode', async () => { const root = createTempProject(); defineEnv({ STRIPE_KEY: string() }); - // .env.local has the key — but productionOnly excludes it. This is the - // guardrail that stops local-only secrets from masking a missing prod secret. - writeDotenv(root, '.env.local', 'STRIPE_KEY=sk_dev\n'); + writeDotenv(root, '.env', 'STRIPE_KEY=sk_dev\n'); - const { report } = await validateProdEnv(root, { productionOnly: true }); + const { report } = await validateProdEnv(root, { productionOnly: false }); - expect(report.valid).toBe(false); - expect(report.missing).toEqual(['STRIPE_KEY']); + expect(report.valid).toBe(true); }); - it('reads .env.local in non-productionOnly mode (the `void env check` local path)', async () => { + it('ignores server values from the production shell', async () => { const root = createTempProject(); defineEnv({ STRIPE_KEY: string() }); - writeDotenv(root, '.env.local', 'STRIPE_KEY=sk_dev\n'); + vi.stubEnv('STRIPE_KEY', 'sk_live'); - const { report } = await validateProdEnv(root, { productionOnly: false }); + const { report } = await validateProdEnv(root, { productionOnly: true }); - expect(report.valid).toBe(true); + expect(report.valid).toBe(false); + expect(report.missing).toEqual(['STRIPE_KEY']); }); - it('surfaces invalid (not missing) values when a .env value fails schema validation', async () => { + it('validates client-prefixed values from the production shell', async () => { const root = createTempProject(); - defineEnv({ PORT: number() }); - writeDotenv(root, '.env.production', 'PORT=not-a-number\n'); + defineEnv({ VITE_PORT: number() }); + vi.stubEnv('VITE_PORT', 'not-a-number'); const { report } = await validateProdEnv(root, { productionOnly: true }); expect(report.valid).toBe(false); expect(report.missing).toEqual([]); - expect(report.invalid.map((i) => i.key)).toEqual(['PORT']); + expect(report.invalid.map((i) => i.key)).toEqual(['VITE_PORT']); const formatted = formatEnvReport(report); expect(formatted).toContain('Invalid env vars'); - expect(formatted).toContain('PORT'); + expect(formatted).toContain('VITE_PORT'); }); - it('redacts invalid values in formatEnvReport when the value or key looks secret', async () => { + it('redacts invalid client values in formatEnvReport', async () => { const root = createTempProject(); - defineEnv({ PORT: number() }); - // `PORT=sk_live_abc123` is a "wrong-file typo" — the value itself is - // secret-looking, so it must not surface in the CLI report. - writeDotenv(root, '.env.production', 'PORT=sk_live_abc123\n'); + defineEnv({ VITE_PORT: number() }); + vi.stubEnv('VITE_PORT', 'sk_live_abc123'); const { report } = await validateProdEnv(root, { productionOnly: true }); const formatted = formatEnvReport(report); expect(formatted).not.toContain('sk_live_abc123'); expect(formatted).toContain(''); // Key name is still there so the user can find it. - expect(formatted).toContain('PORT'); - }); - - it('strips surrounding quotes in .env values so quoted prod secrets validate correctly', async () => { - const root = createTempProject(); - defineEnv({ STRIPE_KEY: string() }); - writeDotenv(root, '.env.production', 'STRIPE_KEY="sk_live_abc"\n'); - - const { report } = await validateProdEnv(root, { productionOnly: true }); - - expect(report.valid).toBe(true); + expect(formatted).toContain('VITE_PORT'); }); }); diff --git a/packages/void/test/integration/kitchen-sink-build.test.ts b/packages/void/test/integration/kitchen-sink-build.test.ts index 1221b0751..1917bd803 100644 --- a/packages/void/test/integration/kitchen-sink-build.test.ts +++ b/packages/void/test/integration/kitchen-sink-build.test.ts @@ -24,9 +24,6 @@ describe('playground/kitchen-sink build', () => { playground = createIsolatedPlayground(ROOT, SOURCE_PLAYGROUND); const pg = playground.root; dist = join(pg, 'dist'); - const envProductionPath = join(pg, '.env.production'); - const envLocalPath = join(pg, '.env.local'); - const envProductionLocalPath = join(pg, '.env.production.local'); rmSync(join(pg, 'dist'), { recursive: true, force: true }); rmSync(join(pg, '.void'), { recursive: true, force: true }); @@ -37,26 +34,6 @@ describe('playground/kitchen-sink build', () => { JSON.stringify({ projectId: 'test-leak-project-id', slug: 'test-leak' }), ); - writeFileSync( - envProductionPath, - [ - '__VOID_TOKEN=polluted-from-env-file-do-not-use', - '__VOID_PROJECT_ID=polluted-project-id', - '__VOID_REMOTE=1', - 'LEGIT_USER_VAR=kept', - ].join('\n') + '\n', - ); - writeFileSync( - envLocalPath, - ['LOCAL_ONLY_VAR=must-not-ship', 'LEGIT_USER_VAR=local-override'].join('\n') + '\n', - ); - writeFileSync( - envProductionLocalPath, - ['PRODUCTION_LOCAL_ONLY_VAR=must-not-ship', 'LEGIT_USER_VAR=production-local-override'].join( - '\n', - ) + '\n', - ); - const result = spawn.sync('vp', ['run', '--filter', `./${playground.workspacePath}`, 'build'], { cwd: ROOT, encoding: 'utf8', @@ -65,6 +42,7 @@ describe('playground/kitchen-sink build', () => { ...process.env, VOID_TOKEN: 'test-leak-token-do-not-use', VOID_CLOUDFLARE_DEPLOY_BUILD: '1', + VITE_APP_TITLE: 'Kitchen Sink Production', }, }); @@ -75,7 +53,6 @@ describe('playground/kitchen-sink build', () => { afterAll(() => { playground?.cleanup(); }); - it('emits the SSR worker entry', () => { expect(existsSync(join(dist, 'ssr/index.js'))).toBe(true); }); @@ -120,10 +97,19 @@ describe('playground/kitchen-sink build', () => { expect(vars.__VOID_AI_GATEWAY).toBe('kitchen-sink-gw'); }); - it('keeps non-internal .env.production vars in dist/ssr/wrangler.json', () => { + it('does not ship local .env values and declares required server secrets', () => { const wranglerConfig = JSON.parse(readFileSync(join(dist, 'ssr', 'wrangler.json'), 'utf-8')); const vars = (wranglerConfig.vars ?? {}) as Record; - expect(vars.LEGIT_USER_VAR).toBe('kept'); + expect(vars.APP_NAME).toBeUndefined(); + expect(vars.SECRET_KEY).toBeUndefined(); + expect(vars.API_URL).toBeUndefined(); + expect(vars.VITE_APP_TITLE).toBeUndefined(); + expect(wranglerConfig.secrets?.required).toEqual([ + 'APP_NAME', + 'SECRET_KEY', + 'API_URL', + 'BETTER_AUTH_SECRET', + ]); }); it('requires auth deployments to inherit BETTER_AUTH_SECRET without emitting plaintext', () => { @@ -133,15 +119,6 @@ describe('playground/kitchen-sink build', () => { expect(vars.BETTER_AUTH_SECRET).toBeUndefined(); }); - it('excludes local dotenv files from Cloudflare deploy worker vars', () => { - const wranglerConfig = JSON.parse(readFileSync(join(dist, 'ssr', 'wrangler.json'), 'utf-8')); - const vars = (wranglerConfig.vars ?? {}) as Record; - expect(vars.LOCAL_ONLY_VAR).toBeUndefined(); - expect(vars.PRODUCTION_LOCAL_ONLY_VAR).toBeUndefined(); - // A local override must not replace the portable production value. - expect(vars.LEGIT_USER_VAR).toBe('kept'); - }); - it('emits manifest.pages for the Void pages app (scan order, RAW patterns)', async () => { // The deploy manifest (carrying `pages`) is a `void deploy` artifact, not a // `vite build` output — the `vp build` above never writes it to `dist`. Drive diff --git a/packages/void/test/integration/pg-test.test.ts b/packages/void/test/integration/pg-test.test.ts index 79cde9387..940216677 100644 --- a/packages/void/test/integration/pg-test.test.ts +++ b/packages/void/test/integration/pg-test.test.ts @@ -8,7 +8,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); -const ENV_LOCAL_PATH = join(ROOT, 'playground/pg-test/.env.local'); +const ENV_PATH = join(ROOT, 'playground/pg-test/.env'); type User = { id: number; name: string; email: string }; type Post = { id: number; title: string; author: string }; type PageData = { @@ -45,10 +45,9 @@ describe('playground/pg-test', () => { await once(pgServer, 'listening'); pgPort = (pgServer.address() as import('node:net').AddressInfo).port; - // 2. Write .env.local with dynamic DATABASE_URL - // Vite's loadEnv reads .env.local and passes vars to the workerd runtime. + // 2. Write the single local .env with the dynamic DATABASE_URL. const databaseUrl = `postgresql://postgres@127.0.0.1:${pgPort}/postgres`; - writeFileSync(ENV_LOCAL_PATH, `DATABASE_URL=${databaseUrl}\n`); + writeFileSync(ENV_PATH, `DATABASE_URL=${databaseUrl}\n`); // 3. Start vite dev server devPort = await getPort(); @@ -97,7 +96,7 @@ describe('playground/pg-test', () => { await pglite.close(); } try { - unlinkSync(ENV_LOCAL_PATH); + unlinkSync(ENV_PATH); } catch { // Ignore missing cleanup files between test runs. } diff --git a/packages/void/test/integration/plugin-env-schema.test.ts b/packages/void/test/integration/plugin-env-schema.test.ts index 17d81ae7d..d973efa7c 100644 --- a/packages/void/test/integration/plugin-env-schema.test.ts +++ b/packages/void/test/integration/plugin-env-schema.test.ts @@ -113,6 +113,61 @@ describe('envSchemaPlugin auto-discovery', () => { expect(existsSync(join(root, '.void', 'env.d.ts'))).toBe(false); }); + it('rejects legacy dotenv variants when env.ts exists', async () => { + const root = createTempRoot(); + writeFileSync(join(root, 'env.ts'), 'export default {};\n'); + writeFileSync(join(root, '.env.production'), 'API_URL=https://example.com\n'); + + await expect(runConfig(envSchemaPlugin(root))).rejects.toThrow('supports one local file: .env'); + }); + + it('removes local .env client values from production builds', async () => { + const { defineEnv } = await import('../../src/runtime/env-public.ts'); + const { string } = await import('../../src/runtime/env-helpers.ts'); + const root = createTempRoot(); + writeFileSync(join(root, 'env.ts'), 'export default {};\n'); + writeFileSync(join(root, '.env'), 'VITE_TITLE=Local only\n'); + + const plugin = envSchemaPlugin(root); + await runConfig(plugin); + defineEnv({ VITE_TITLE: string() }); + const resolved = { + command: 'build', + mode: 'production', + envDir: root, + envPrefix: 'VITE_', + env: { VITE_TITLE: 'Local only' }, + define: {}, + }; + + await expect(runConfigResolved(plugin, resolved)).rejects.toThrow('VITE_TITLE is required'); + expect(resolved.env.VITE_TITLE).toBeUndefined(); + }); + + it('reports server and required names without values', async () => { + const { defineEnv } = await import('../../src/runtime/env-public.ts'); + const { string } = await import('../../src/runtime/env-helpers.ts'); + defineEnv({ + API_TOKEN: string(), + OPTIONAL_REGION: string().optional(), + VITE_TITLE: string(), + }); + const root = createTempRoot(); + writeFileSync(join(root, 'env.ts'), 'export default {};\n'); + const facts: Array = []; + + const plugin = envSchemaPlugin(root, { onSchema: (value) => facts.push(value) }); + const configHook = plugin.config as (config: Record, env: unknown) => unknown; + await configHook.call({}, { envPrefix: 'PUBLIC_' }, { mode: 'production' }); + + expect(facts.at(-1)).toEqual({ + keys: ['API_TOKEN', 'OPTIONAL_REGION', 'VITE_TITLE'], + clientKeys: ['VITE_TITLE'], + serverKeys: ['API_TOKEN', 'OPTIONAL_REGION'], + requiredServerKeys: ['API_TOKEN'], + }); + }); + // The define-overlap warning runs in `configResolved`. We register the // schema directly via `defineEnv` (rather than through a temp env.ts loaded // by jiti) so the singleton the plugin reads matches the one the test @@ -232,7 +287,7 @@ describe('envSchemaPlugin auto-discovery', () => { expect(resolved.env.VITE_TITLE).toBe('FromEnv'); }); - it('honours custom envPrefix values when exposing schema defaults', async () => { + it('uses VITE_ even when Vite has a custom envPrefix', async () => { const { defineEnv } = await import('../../src/runtime/env-public.ts'); const { string } = await import('../../src/runtime/env-helpers.ts'); const root = createTempRoot(); @@ -254,29 +309,6 @@ describe('envSchemaPlugin auto-discovery', () => { }; await runConfigResolved(plugin, resolved); - expect(resolved.env).toEqual({ PUBLIC_TITLE: 'Public' }); - }); - - it('reports only keys explicitly marked secret', async () => { - const { defineEnv } = await import('../../src/runtime/env-public.ts'); - const { string } = await import('../../src/runtime/env-helpers.ts'); - defineEnv({ - API_TOKEN: string().secret(), - PUBLIC_KEY: string().public(), - ORDINARY: string(), - }); - const root = createTempRoot(); - writeFileSync(join(root, 'env.ts'), 'export default {};\n'); - let secretKeys: Array = []; - - await runConfig( - envSchemaPlugin(root, { - onSecrets(keys) { - secretKeys = keys; - }, - }), - ); - - expect(secretKeys).toEqual(['API_TOKEN']); + expect(resolved.env).toEqual({ VITE_TITLE: 'Vite' }); }); }); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index aa766cc7f..11a610c7f 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -3,6 +3,7 @@ import { pathToFileURL } from 'node:url'; import { log } from '@clack/prompts'; import { dirname, join } from 'pathslash'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { activateCloudflareVersion as activateCloudflareVersionPrimitive } from '../../../deploy-cloudflare/src/index.ts'; import { readCloudflareLink } from '../../src/cli/cloudflare-link.ts'; import { assertMigrationsProduceAuthSchema, @@ -21,7 +22,6 @@ import { parseRootWranglerConfig, prepareCloudflareFrameworkArtifact, runCloudflareDeploy, - syncCloudflareSchemaSecrets, snapshotBindingIdentities, snapshotPassthroughIdentities, snapshotPassthroughRawFields, @@ -151,7 +151,7 @@ describe('Cloudflare activation diagnostics', () => { }); /** No-schema (nothing-to-gate) env-insensitive facts. */ -const NO_SCHEMA_FACTS: SelfHostSchemaFacts = { hasSchema: false, requiredKeys: [], secretKeys: [] }; +const NO_SCHEMA_FACTS: SelfHostSchemaFacts = { hasSchema: false, serverKeys: [], requiredKeys: [] }; /** A default-passing env-schema probe result (no schema → nothing to gate). */ function okSchemaFacts(overrides: Partial = {}): EnvSchemaProbeResult { @@ -355,7 +355,7 @@ function harness(overrides: Partial = {}): { return { resources: [], configPath, accountIdWritten: false }; }) as CloudflareDeployDeps['provision'], // Stub the isolated env-schema probe so orchestration tests never spawn a child; the - // env-insensitive facts (required names + `.secret()` flags) are returned inline. Silent + // env-insensitive facts (server and required key names) are returned inline. Silent // (no trace) — it runs pre-build and is not a step these tests order around. resolveSchemaFacts: (() => okSchemaFacts()) as CloudflareDeployDeps['resolveSchemaFacts'], // Stub the isolated emitted-var validation probe (F3) so orchestration tests never spawn a @@ -380,14 +380,6 @@ function harness(overrides: Partial = {}): { trace.push('auth-secret'); return { created: false }; }) as CloudflareDeployDeps['ensureAuthSecret'], - syncSchemaSecrets: (async ({ emittedVars, secretKeys }) => ({ - requiredSecretNames: secretKeys.filter( - (key) => typeof emittedVars[key] === 'string' && emittedVars[key] !== '', - ), - uploaded: secretKeys.filter( - (key) => typeof emittedVars[key] === 'string' && emittedVars[key] !== '', - ).length, - })) as CloudflareDeployDeps['syncSchemaSecrets'], // The stubbed build never writes dist/ssr/wrangler.json, so the real config // verification would have nothing to read — pass it by default; dedicated tests // exercise the real verifier and the fail-closed branch. @@ -747,123 +739,6 @@ describe('ensureCloudflareAuthSecret', () => { }); }); -describe('syncCloudflareSchemaSecrets', () => { - function schemaSecretProject(): string { - return makeProject({ - 'package.json': JSON.stringify({ name: 'app' }), - 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'app' }), - '.wrangler/deploy/config.json': JSON.stringify({ - configPath: '../../dist/ssr/wrangler.json', - }), - 'dist/ssr/wrangler.json': JSON.stringify({ - account_id: 'acc123', - name: 'app', - vars: { API_TOKEN: 'local-token', ORDINARY: 'visible' }, - secrets: { required: ['MANUAL_SECRET'] }, - }), - }); - } - - it('uploads marked values through stdin and removes them from plaintext vars', async () => { - const root = schemaSecretProject(); - const calls: Array<{ args: Array; input?: string }> = []; - let listCount = 0; - const result = await syncCloudflareSchemaSecrets({ - root, - configPath: join(root, 'wrangler.jsonc'), - accountId: 'acc123', - workerName: 'app', - emittedVars: { API_TOKEN: 'local-token', ORDINARY: 'visible' }, - secretKeys: ['API_TOKEN', 'REMOTE_ONLY'], - runWrangler: (args, opts) => { - calls.push({ args, input: opts.input }); - if (args[1] === 'list') { - listCount++; - return { - ...cleanExit, - stdout: JSON.stringify( - listCount === 1 - ? [{ name: 'REMOTE_ONLY' }, { name: 'UNMARKED_REMOTE' }] - : [{ name: 'API_TOKEN' }, { name: 'REMOTE_ONLY' }], - ), - }; - } - return cleanExit; - }, - }); - - expect(result).toEqual({ - requiredSecretNames: ['API_TOKEN', 'REMOTE_ONLY'], - uploaded: 1, - }); - const bulk = calls.find((call) => call.args[1] === 'bulk')!; - expect(bulk.args).toEqual(['secret', 'bulk', '-c', join(root, 'wrangler.jsonc'), '--env=']); - expect(JSON.parse(bulk.input!)).toEqual({ API_TOKEN: 'local-token' }); - expect(bulk.args.join(' ')).not.toContain('local-token'); - const generated = JSON.parse(readFileSync(join(root, 'dist/ssr/wrangler.json'), 'utf-8')); - expect(generated.vars).toEqual({ ORDINARY: 'visible' }); - expect(generated.secrets.required).toEqual(['API_TOKEN', 'MANUAL_SECRET', 'REMOTE_ONLY']); - }); - - it('bootstraps a first deploy and skips an absent optional marked secret', async () => { - const root = schemaSecretProject(); - let call = 0; - const result = await syncCloudflareSchemaSecrets({ - root, - configPath: join(root, 'wrangler.jsonc'), - accountId: 'acc123', - workerName: 'app', - emittedVars: { API_TOKEN: 'local-token' }, - secretKeys: ['API_TOKEN', 'OPTIONAL_TOKEN'], - runWrangler: (args) => { - call++; - if (call === 1) { - return { - ...cleanExit, - status: 1, - stderr: [ - 'Worker "app" not found.', - 'If this is a new Worker, run `wrangler deploy` first to create it.', - "Otherwise, check that the Worker name is correct and you're logged into the right account.", - ].join('\n'), - }; - } - if (args[1] === 'list') { - return { ...cleanExit, stdout: JSON.stringify([{ name: 'API_TOKEN' }]) }; - } - return cleanExit; - }, - }); - - expect(result).toEqual({ - requiredSecretNames: ['API_TOKEN'], - uploaded: 1, - workerWasMissing: true, - }); - expect(call).toBe(3); - }); - - it('fails without echoing a value when bulk upload fails', async () => { - const root = schemaSecretProject(); - const secret = 'never-print-this'; - const promise = syncCloudflareSchemaSecrets({ - root, - configPath: join(root, 'wrangler.jsonc'), - accountId: 'acc123', - workerName: 'app', - emittedVars: { API_TOKEN: secret }, - secretKeys: ['API_TOKEN'], - runWrangler: (args) => - args[1] === 'list' - ? { ...cleanExit, stdout: '[]' } - : { ...cleanExit, status: 1, stderr: secret }, - }); - - await expect(promise).rejects.toMatchObject({ reason: 'bulk-failed' }); - await expect(promise).rejects.not.toThrow(secret); - }); -}); - /** A worker-bearing D1 app fixture with a fully-provisioned wrangler.jsonc. */ function d1Project( wrangler: Record, @@ -891,35 +766,6 @@ function sandboxProject(): string { }); } -/** - * A minimal `vite.config.ts` carrying a `void:env` plugin STUB that mirrors how the real - * plugin (index.ts) captures the RAW (unnormalized) envDir and exposes it via - * `api.getVoidDeployBuildConfig`. `envDirExpr` is spliced verbatim as the config's `envDir` - * (e.g. `"'.'"`, `'import.meta.dirname'`), so the real probe exercises the build's own - * `join(root, rawEnvDir)` guard without pulling the full void pipeline into the child. - */ -function viteConfigWithVoidEnvStub(envDirExpr: string): string { - return [ - `let captured;`, - `export default {`, - ` envDir: ${envDirExpr},`, - ` plugins: [`, - ` {`, - ` name: 'void:env',`, - ` api: { getVoidDeployBuildConfig: () => captured },`, - ` config(userConfig, { mode }) {`, - ` const raw =`, - ` typeof userConfig.envDir === 'string' && userConfig.envDir.length > 0`, - ` ? userConfig.envDir`, - ` : undefined;`, - ` captured = { mode, envDir: raw };`, - ` },`, - ` },`, - ` ],`, - `};`, - ].join('\n'); -} - /** One non-destructive checked-in migration so the remote migrate path runs. */ const INIT_MIGRATION = { 'db/migrations/0000_init.sql': `CREATE TABLE notes (id integer primary key);`, @@ -2393,19 +2239,44 @@ describe('runCloudflareDeploy — backwards-compatible provision flag', () => { }); describe('runCloudflareDeploy — secrets gate', () => { - it('warns about a plaintext secret-looking key in the REAL emitted vars without blocking', async () => { + it('blocks when a schema-declared server key is present in the REAL emitted vars', async () => { const root = d1Project(PROVISIONED_D1); - // The warning classifies the REAL vars the build baked into dist/ssr/wrangler.json (ground - // truth). A secret-looking key is non-fatal — the deploy still proceeds. - const { deps, trace } = harness({ + const { deps, trace, exitCalls } = harness({ + resolveSchemaFacts: (() => + okSchemaFacts({ + hasSchema: true, + serverKeys: ['API_TOKEN'], + requiredKeys: ['API_TOKEN'], + })) as CloudflareDeployDeps['resolveSchemaFacts'], readEmittedWorkerVars: (() => emittedVars({ API_TOKEN: 'sk-live-xyz', APP_NAME: 'demo', })) as CloudflareDeployDeps['readEmittedWorkerVars'], }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).not.toContain('wrangler:deploy'); + }); + + it('allows emitted client-prefixed vars', async () => { + const root = d1Project(PROVISIONED_D1); + const { deps, trace } = harness({ + resolveSchemaFacts: (() => + okSchemaFacts({ + hasSchema: true, + serverKeys: [], + requiredKeys: [], + })) as CloudflareDeployDeps['resolveSchemaFacts'], + readEmittedWorkerVars: (() => + emittedVars({ + VITE_API_ORIGIN: 'https://example.com', + })) as CloudflareDeployDeps['readEmittedWorkerVars'], + }); await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); - expect(trace).toContain('wrangler:deploy'); // warning is non-fatal + expect(trace).toContain('wrangler:deploy'); }); it('blocks with exit(1) when the gate discharge throws (missing required secret); never deploys or migrates remote', async () => { @@ -2446,6 +2317,98 @@ describe('runCloudflareDeploy — secrets gate', () => { ).rejects.toBeInstanceOf(ExitError); expect(exitCalls).toEqual([1]); }); + + it("bootstraps an initial Durable Object migration after a required secret created Wrangler's draft", async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'secret-do-app' }), + 'void.json': '{}', + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'wrangler.jsonc': JSON.stringify({ + account_id: 'acc123', + name: 'secret-do-app', + durable_objects: { bindings: [{ name: 'ROOM', class_name: 'Room' }] }, + migrations: [{ tag: 'v1', new_sqlite_classes: ['Room'] }], + }), + }); + const current = '11111111-1111-4111-8111-111111111111'; + const next = '22222222-2222-4222-8222-222222222222'; + let active = current; + const commands: Array> = []; + const runWrangler: DeployWranglerRun = (args) => { + commands.push(args); + if (args[0] === 'whoami') { + return { ...cleanExit, stdout: '{"loggedIn":true}' }; + } + if (args[0] === 'deployments') { + return { + ...cleanExit, + stdout: JSON.stringify({ versions: [{ version_id: active, percentage: 100 }] }), + }; + } + if (args[0] === 'versions' && args[1] === 'list') { + return { + ...cleanExit, + stdout: JSON.stringify([ + { + id: current, + annotations: { 'workers/triggered_by': 'secret' }, + resources: { + bindings: [{ name: 'API_TOKEN', type: 'secret_text' }], + script: { + etag: 'draft', + handlers: ['fetch'], + last_deployed_from: 'wrangler', + }, + script_runtime: { usage_model: 'standard', limits: {} }, + }, + }, + ]), + }; + } + if (args[0] === 'versions' && args[1] === 'upload') { + return { + ...cleanExit, + status: 1, + stderr: 'Durable Object migrations require deploy. [code: 10211]', + }; + } + if (args[0] === 'deploy') { + active = next; + return { + ...cleanExit, + stdout: `Current Version ID: ${next}\nhttps://secret-do-app.example.workers.dev`, + }; + } + return cleanExit; + }; + let explicitBootstrapProof: boolean | undefined; + const { deps } = harness({ + runWrangler, + resolveSchemaFacts: (() => + okSchemaFacts({ + hasSchema: true, + serverKeys: ['API_TOKEN'], + requiredKeys: ['API_TOKEN'], + })) as CloudflareDeployDeps['resolveSchemaFacts'], + dischargeSelfHostEnv: async () => ({ + hasSchema: true, + remoteSecretNames: ['API_TOKEN'], + }), + activateVersion: async (options) => { + explicitBootstrapProof = options.allowFirstDeploymentBootstrap; + return activateCloudflareVersionPrimitive({ + ...options, + fetch: async () => Response.json({ ready: true }), + }); + }, + }); + + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + + expect(explicitBootstrapProof).not.toBe(true); + expect(commands.some((args) => args[0] === 'versions' && args[1] === 'upload')).toBe(true); + expect(commands.some((args) => args[0] === 'deploy')).toBe(true); + }); }); describe('runCloudflareDeploy — pg/Hyperdrive app skips D1 migrations', () => { @@ -3199,7 +3162,7 @@ describe('verifyGeneratedDeployConfig', () => { ); }); - it('requires every promoted schema secret to be inherited', () => { + it('requires every schema-declared server secret to be inherited', () => { const root = deployConfigProject({ account_id: 'acc123', name: 'myapp', @@ -5450,7 +5413,11 @@ describe('runCloudflareDeploy — config laundering by a build-adjacent probe', // Schema facts declaring one required secret that is ABSENT from the (empty) emitted vars, so the // real gate reaches the worker-name reconcile + the (stubbed) remote secret list. function requiredSecretFacts(): EnvSchemaProbeResult { - return okSchemaFacts({ hasSchema: true, requiredKeys: ['STRIPE_KEY'], secretKeys: [] }); + return okSchemaFacts({ + hasSchema: true, + serverKeys: ['STRIPE_KEY'], + requiredKeys: ['STRIPE_KEY'], + }); } // The real gate, but with the remote `wrangler secret list` stubbed to report the secret EXISTS. @@ -5592,11 +5559,24 @@ describe('runCloudflareDeploy — framework artifact guard', () => { 'routes/index.ts': `export const GET = () => new Response('ok');`, 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'skapp' }, null, 2), }); - const { deps, trace, exitCalls } = harness(); + let requiredSecretNames: ReadonlyArray | undefined; + const { deps, trace, exitCalls } = harness({ + resolveSchemaFacts: (() => + okSchemaFacts({ + hasSchema: true, + serverKeys: ['API_TOKEN'], + requiredKeys: ['API_TOKEN'], + })) as CloudflareDeployDeps['resolveSchemaFacts'], + prepareFrameworkArtifact: (async (...args) => { + trace.push('prepare-framework'); + requiredSecretNames = args[7]; + }) as CloudflareDeployDeps['prepareFrameworkArtifact'], + }); await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); expect(exitCalls).toEqual([]); expect(trace).toContain('build'); expect(trace).toContain('prepare-framework'); + expect(requiredSecretNames).toEqual(['API_TOKEN']); expect(trace).toContain('wrangler:deploy'); }); @@ -5733,6 +5713,7 @@ describe('runCloudflareDeploy — framework artifact guard', () => { readConfig(root), { name: 'sveltekit', class: 'b' }, FRAMEWORK_PRESETS.sveltekit, + ['API_TOKEN'], ); const wrapper = readFileSync(join(root, '.svelte-kit/cloudflare/_worker.js'), 'utf-8'); @@ -5755,6 +5736,7 @@ describe('runCloudflareDeploy — framework artifact guard', () => { run_worker_first: true, }, triggers: { crons: ['0 * * * *'] }, + secrets: { required: ['API_TOKEN'] }, }); expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf-8')).toContain('// keep this comment'); }); @@ -7473,26 +7455,64 @@ describe('compareWranglerMigrationPaths (wrangler 4.107 numeric-prefix comparato // --------------------------------------------------------------------------- describe('runCloudflareDeploy — post-build ground-truth secret gate', () => { - it('promotes marked secrets after migration gates and before Worker upload', async () => { + it('rejects a schema-declared server key emitted as a plaintext Worker var', async () => { const root = d1Project(PROVISIONED_D1); - const { deps, trace } = harness({ + const { deps, trace, exitCalls } = harness({ resolveSchemaFacts: (() => okSchemaFacts({ hasSchema: true, requiredKeys: ['API_TOKEN'], - secretKeys: ['API_TOKEN'], + serverKeys: ['API_TOKEN'], })) as CloudflareDeployDeps['resolveSchemaFacts'], readEmittedWorkerVars: (() => emittedVars({ API_TOKEN: 'secret' })) as CloudflareDeployDeps['readEmittedWorkerVars'], - syncSchemaSecrets: (async () => { - trace.push('schema-secrets'); - return { requiredSecretNames: ['API_TOKEN'], uploaded: 1 }; - }) as CloudflareDeployDeps['syncSchemaSecrets'], + }); + + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toContain('build'); + expect(trace).not.toContain('wrangler:deploy'); + }); + + it('allows an emitted VITE_* client value', async () => { + const root = d1Project(PROVISIONED_D1); + const { deps, trace } = harness({ + resolveSchemaFacts: (() => + okSchemaFacts({ + hasSchema: true, + serverKeys: [], + requiredKeys: [], + })) as CloudflareDeployDeps['resolveSchemaFacts'], + readEmittedWorkerVars: (() => + emittedVars({ + VITE_APP_TITLE: 'Public title', + })) as CloudflareDeployDeps['readEmittedWorkerVars'], }); await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); - expect(trace.indexOf('migrate-gate')).toBeLessThan(trace.indexOf('schema-secrets')); - expect(trace.indexOf('schema-secrets')).toBeLessThan(trace.indexOf('wrangler:deploy')); + expect(trace).toContain('wrangler:deploy'); + }); + + it('passes schema-required keys to generated config verification', async () => { + const root = d1Project(PROVISIONED_D1); + let requiredSecretNames: Array | undefined; + const { deps } = harness({ + resolveSchemaFacts: (() => + okSchemaFacts({ + hasSchema: true, + serverKeys: ['API_TOKEN'], + requiredKeys: ['API_TOKEN'], + })) as CloudflareDeployDeps['resolveSchemaFacts'], + verifyDeployConfig: ((_root, options) => { + requiredSecretNames = options.requiredSecretNames; + return { ok: true }; + }) as CloudflareDeployDeps['verifyDeployConfig'], + }); + + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(requiredSecretNames).toEqual(['API_TOKEN']); }); it('rejects a plaintext BETTER_AUTH_SECRET before provisioning or upload', async () => { @@ -7526,7 +7546,6 @@ describe('runCloudflareDeploy — post-build ground-truth secret gate', () => { okSchemaFacts({ hasSchema: true, requiredKeys: ['BETTER_AUTH_SECRET'], - secretKeys: ['BETTER_AUTH_SECRET'], })) as CloudflareDeployDeps['resolveSchemaFacts'], validateEmittedVars: (async () => ({ kind: 'invalid-keys', @@ -7553,7 +7572,7 @@ describe('runCloudflareDeploy — post-build ground-truth secret gate', () => { okSchemaFacts({ hasSchema: true, requiredKeys: ['STRIPE_KEY'], - secretKeys: ['STRIPE_KEY'], + serverKeys: ['STRIPE_KEY'], })) as CloudflareDeployDeps['resolveSchemaFacts'], readEmittedWorkerVars: (() => emittedVars({ APP_NAME: 'demo' })) as CloudflareDeployDeps['readEmittedWorkerVars'], @@ -7584,7 +7603,7 @@ describe('runCloudflareDeploy — post-build ground-truth secret gate', () => { okSchemaFacts({ hasSchema: true, requiredKeys: ['STRIPE_KEY'], - secretKeys: ['STRIPE_KEY'], + serverKeys: ['STRIPE_KEY'], })) as CloudflareDeployDeps['resolveSchemaFacts'], readEmittedWorkerVars: (() => emittedVars({ STRIPE_KEY: '' })) as CloudflareDeployDeps['readEmittedWorkerVars'], @@ -7611,7 +7630,7 @@ describe('runCloudflareDeploy — post-build ground-truth secret gate', () => { okSchemaFacts({ hasSchema: true, requiredKeys: ['STRIPE_KEY'], - secretKeys: ['STRIPE_KEY'], + serverKeys: ['STRIPE_KEY'], })) as CloudflareDeployDeps['resolveSchemaFacts'], readEmittedWorkerVars: (() => emittedVars({ STRIPE_KEY: null })) as CloudflareDeployDeps['readEmittedWorkerVars'], @@ -7673,72 +7692,6 @@ describe('runCloudflareDeploy — post-build ground-truth secret gate', () => { }); }); -// --------------------------------------------------------------------------- -// envDir / mode narrowing — a custom Vite envDir (the build reads `.env*` from a -// non-root dir but generates the auth schema from root) or a non-production build -// mode is unsupported in v1, REJECTED pre-account by the facts probe. -// --------------------------------------------------------------------------- - -describe('runCloudflareDeploy — envDir / mode narrowing (rejected pre-account)', () => { - it('rejects a custom Vite envDir pre-account — no whoami / provision / build (REAL probe)', async () => { - // A REAL vite.config sets a custom envDir; the facts probe resolves the config in the - // isolated subprocess, reads the effective envDir, and rejects before any account op. - const root = d1Project(PROVISIONED_D1, { - 'package.json': JSON.stringify({ name: 'myapp', type: 'module' }), - 'vite.config.ts': `export default { envDir: './cfg' };`, - 'cfg/.gitkeep': '', - }); - const { deps, trace, wranglerArgs, exitCalls } = harness({ resolveSchemaFacts: undefined }); - await expect( - runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), - ).rejects.toBeInstanceOf(ExitError); - expect(exitCalls).toEqual([1]); - // Pre-account: nothing ran — no whoami, no provision, no build, no deploy. - expect(trace).toEqual([]); - expect(wranglerArgs).toEqual([]); - }); - - it('rejects an ABSOLUTE envDir equal to the project root pre-account — the build mis-loads it (REAL probe)', async () => { - // Vite normalizes `envDir` via `path.resolve(root, envDir)`, so an ABSOLUTE envDir equal - // to the root collapses to `resolved.root` and Vite's own view would ACCEPT. But the build - // loads worker vars with `join(root, rawEnvDir)`, which DUPLICATES an absolute path - // (`/proj/proj`) → the build reads vars from the wrong dir. The probe now reads the RAW - // envDir the `void:env` hook captured and rejects via the build's own computation, before - // any account op. `import.meta.dirname` in the config === the project root (absolute). - const root = d1Project(PROVISIONED_D1, { - 'package.json': JSON.stringify({ name: 'myapp', type: 'module' }), - 'vite.config.ts': viteConfigWithVoidEnvStub('import.meta.dirname'), - }); - const { deps, trace, wranglerArgs, exitCalls } = harness({ resolveSchemaFacts: undefined }); - await expect( - runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), - ).rejects.toBeInstanceOf(ExitError); - expect(exitCalls).toEqual([1]); - // Pre-account: nothing ran — no whoami, no provision, no build, no deploy. - expect(trace).toEqual([]); - expect(wranglerArgs).toEqual([]); - }); - - it('rejects a non-production build mode pre-account — no whoami / provision / build', async () => { - const root = d1Project(PROVISIONED_D1); - const { deps, trace, wranglerArgs, exitCalls } = harness({ - // The probe resolves mode from the config; a non-production mode selects a different - // `.env.` set than this backend validates → reject before any account op. Only the - // fixed reason code crosses the boundary (no raw mode/envDir string). - resolveSchemaFacts: (() => ({ - kind: 'reject', - reason: 'non-production-mode', - })) as CloudflareDeployDeps['resolveSchemaFacts'], - }); - await expect( - runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), - ).rejects.toBeInstanceOf(ExitError); - expect(exitCalls).toEqual([1]); - expect(trace).toEqual([]); - expect(wranglerArgs).toEqual([]); - }); -}); - // --------------------------------------------------------------------------- // NODE_ENV pin — the facts probe evaluates env.ts under the build's environment // (NODE_ENV=production), so a required set conditioned on NODE_ENV matches the @@ -7881,11 +7834,13 @@ describe('runCloudflareDeploy — invalid emitted value fails closed pre-remote' resolveSchemaFacts: (() => okSchemaFacts({ hasSchema: true, - requiredKeys: ['PORT'], - secretKeys: [], + serverKeys: [], + requiredKeys: [], })) as CloudflareDeployDeps['resolveSchemaFacts'], readEmittedWorkerVars: (() => - emittedVars({ PORT: 'not-a-number' })) as CloudflareDeployDeps['readEmittedWorkerVars'], + emittedVars({ + VITE_PORT: 'not-a-number', + })) as CloudflareDeployDeps['readEmittedWorkerVars'], // Use the REAL discharge so the invalidKeys folded in by the probe actually fail closed. dischargeSelfHostEnv: undefined, ...overrides, @@ -7894,10 +7849,10 @@ describe('runCloudflareDeploy — invalid emitted value fails closed pre-remote' it('fails closed (no remote apply / deploy) when a required emitted value is schema-invalid', async () => { const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); const { deps, trace, wranglerArgs, exitCalls } = invalidPortHarness({ - // The probe reports PORT invalid — it ships a present, non-empty, malformed value. + // The probe reports the client build value invalid. validateEmittedVars: (async () => ({ kind: 'invalid-keys', - invalidKeys: ['PORT'], + invalidKeys: ['VITE_PORT'], })) as CloudflareDeployDeps['validateEmittedVars'], }); await expect( @@ -7915,8 +7870,8 @@ describe('runCloudflareDeploy — invalid emitted value fails closed pre-remote' const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); const { deps, trace } = invalidPortHarness({ readEmittedWorkerVars: (() => - emittedVars({ PORT: '8080' })) as CloudflareDeployDeps['readEmittedWorkerVars'], - // No key is invalid; PORT is present & non-empty so the gate makes no wrangler call. + emittedVars({ VITE_PORT: '8080' })) as CloudflareDeployDeps['readEmittedWorkerVars'], + // No key is invalid, so the gate makes no wrangler call. validateEmittedVars: (async () => ({ kind: 'invalid-keys', invalidKeys: [], @@ -8022,7 +7977,7 @@ describe('env-schema probe (real subprocess)', () => { // The probe spawns a plain `node` on this repo's source `.ts`, so its runtime imports must use // type-strippable syntax supported by Node without extra NODE_OPTIONS flags. - it('derives required + `.secret()` facts and the fd-3 result survives noisy stdout', async () => { + it('derives server + required facts and the fd-3 result survives noisy stdout', async () => { const root = makeProject({ 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), 'env.ts': [ @@ -8030,7 +7985,7 @@ describe('env-schema probe (real subprocess)', () => { `import { string } from ${JSON.stringify(srcEnvHelpers)};`, // A huge stdout write proves the fd-3 result channel is not truncated by env.ts noise. `console.log('x'.repeat(200000));`, - `export default defineEnv({ STRIPE_KEY: string().secret(), API_URL: string() });`, + `export default defineEnv({ STRIPE_KEY: string(), API_URL: string(), VITE_TITLE: string() });`, ].join('\n'), }); const r = await resolveEnvSchemaFactsViaSubprocess({ root }, { ...process.env }); @@ -8039,7 +7994,7 @@ describe('env-schema probe (real subprocess)', () => { expect(r.facts.hasSchema).toBe(true); expect(r.facts.requiredKeys).toContain('STRIPE_KEY'); expect(r.facts.requiredKeys).toContain('API_URL'); - expect(r.facts.secretKeys).toEqual(['STRIPE_KEY']); + expect(r.facts.serverKeys).toEqual(['STRIPE_KEY', 'API_URL']); } }); @@ -8142,7 +8097,7 @@ describe('env-schema probe (real subprocess)', () => { }); it('parses ONLY the dedicated fd-3 result channel (injected spawn)', async () => { - const facts = { hasSchema: true, requiredKeys: ['X'], secretKeys: [] }; + const facts = { hasSchema: true, serverKeys: ['X'], requiredKeys: ['X'] }; const okSpawn = (): EnvSchemaProbeSpawnResult => ({ status: 0, fd3: JSON.stringify({ kind: 'ok', facts }), @@ -8170,54 +8125,7 @@ describe('env-schema probe (real subprocess)', () => { ).rejects.toThrow(/exited with code/); }); - it('rejects a custom Vite envDir (resolves the config in the child, reads the effective envDir)', async () => { - const root = makeProject({ - 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), - 'vite.config.ts': `export default { envDir: './cfg' };`, - 'cfg/.gitkeep': '', - }); - const r = await resolveEnvSchemaFactsViaSubprocess({ root }, { ...process.env }); - expect(r.kind).toBe('reject'); - if (r.kind === 'reject') { - // Only the fixed reason code crosses the boundary — never the raw resolved envDir string. - expect(r.reason).toBe('custom-envdir'); - expect(JSON.stringify(r)).not.toContain('cfg'); - } - }); - - it('rejects an ABSOLUTE envDir equal to the root (build`s join(root, rawEnvDir) duplicates it)', async () => { - const root = makeProject({ - 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), - // `import.meta.dirname` in the config === the project root (absolute). Vite normalizes it - // back to `resolved.root` (would ACCEPT), but the build's `join(root, )` - // duplicates the path — the probe reads the RAW envDir and rejects via that computation. - 'vite.config.ts': viteConfigWithVoidEnvStub('import.meta.dirname'), - }); - const r = await resolveEnvSchemaFactsViaSubprocess({ root }, { ...process.env }); - expect(r.kind).toBe('reject'); - if (r.kind === 'reject') { - expect(r.reason).toBe('custom-envdir'); - // Only the fixed reason code crosses the boundary — never the raw (absolute) envDir string. - expect(JSON.stringify(r)).not.toContain(root); - } - }); - - it('accepts envDir: "." (the root-relative dot resolves to the project root)', async () => { - const root = makeProject({ - 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), - 'env.ts': [ - `import { defineEnv } from ${JSON.stringify(srcEnvPublic)};`, - `import { string } from ${JSON.stringify(srcEnvHelpers)};`, - `export default defineEnv({ API_URL: string() });`, - ].join('\n'), - // `join(root, '.') === root` and `resolve(root, '.') === root` — both views accept. - 'vite.config.ts': viteConfigWithVoidEnvStub(`'.'`), - }); - const r = await resolveEnvSchemaFactsViaSubprocess({ root }, { ...process.env }); - expect(r.kind).toBe('ok'); - }); - - it('accepts the default (root) envDir + production mode', async () => { + it('derives facts without resolving the Vite config', async () => { const root = makeProject({ 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), 'env.ts': [ diff --git a/packages/void/test/unit/database-provider.test.ts b/packages/void/test/unit/database-provider.test.ts index 599f1ba20..cbfd18bc8 100644 --- a/packages/void/test/unit/database-provider.test.ts +++ b/packages/void/test/unit/database-provider.test.ts @@ -48,11 +48,11 @@ describe('database provider adapters', () => { it('updates DATABASE_URL without discarding other local env values', () => { const root = mkdtempSync(join(tmpdir(), 'void-db-connect-')); roots.push(root); - writeFileSync(join(root, '.env.local'), 'API_KEY=test\nDATABASE_URL="old"\n'); + writeFileSync(join(root, '.env'), 'API_KEY=test\nDATABASE_URL="old"\n'); writeLocalDatabaseUrl(root, 'postgresql://user:p@ss@host/app'); - expect(readFileSync(join(root, '.env.local'), 'utf-8')).toBe( + expect(readFileSync(join(root, '.env'), 'utf-8')).toBe( 'API_KEY=test\nDATABASE_URL="postgresql://user:p@ss@host/app"\n', ); }); diff --git a/packages/void/test/unit/db-commands.test.ts b/packages/void/test/unit/db-commands.test.ts index 65d9a8c77..df5b74359 100644 --- a/packages/void/test/unit/db-commands.test.ts +++ b/packages/void/test/unit/db-commands.test.ts @@ -314,7 +314,7 @@ describe('void db drizzle-kit subprocesses', () => { expect(mockExecFileSync).toHaveBeenCalledOnce(); }); - it('resolves function-based Better Auth schemas with production env values', async () => { + it('resolves function-based Better Auth schemas with local env values', async () => { const root = createDrizzleProjectRoot(); const voidStubDir = join(root, 'node_modules', 'void'); mkdirSync(voidStubDir, { recursive: true }); @@ -335,13 +335,11 @@ describe('void db drizzle-kit subprocesses', () => { ].join('\n'), ); writeFileSync(join(root, '.env'), 'AUTH_USER_TABLE=development_users\n'); - writeFileSync(join(root, '.env.production'), 'AUTH_USER_TABLE=production_users\n'); await runDbCommand(root, { action: 'generate' }); const authSchema = readFileSync(join(root, '.void', 'better-auth-schema.ts'), 'utf-8'); - expect(authSchema).toContain('sqliteTable("production_users"'); - expect(authSchema).not.toContain('sqliteTable("development_users"'); + expect(authSchema).toContain('sqliteTable("development_users"'); }); it('uses the framework emulator store for SQLite push in SvelteKit apps', async () => { diff --git a/packages/void/test/unit/db-studio-remote.test.ts b/packages/void/test/unit/db-studio-remote.test.ts index ce93592e1..47b291541 100644 --- a/packages/void/test/unit/db-studio-remote.test.ts +++ b/packages/void/test/unit/db-studio-remote.test.ts @@ -297,7 +297,7 @@ describe('void db studio (local, regression)', () => { it('local PG studio does not call getDatabaseUrl (uses local URL path)', async () => { const root = createProjectRoot({ database: 'pg' }); - // The local PG path calls getPgUrl (reads .env.local). No DATABASE_URL set, so it throws. + // The local PG path calls getPgUrl (reads .env). No DATABASE_URL set, so it throws. // Regardless, getDatabaseUrl must NOT be called — it's only used for --remote. await expect(runDbCommand(root, { action: 'studio' })).rejects.toThrow(); diff --git a/packages/void/test/unit/deploy-env-expand.test.ts b/packages/void/test/unit/deploy-env-expand.test.ts deleted file mode 100644 index 17c75364a..000000000 --- a/packages/void/test/unit/deploy-env-expand.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'pathslash'; -import { afterEach, describe, expect, it } from 'vite-plus/test'; -import { loadDeployEnv } from '../../src/cli/deploy.ts'; - -describe('loadDeployEnv', () => { - const roots: Array = []; - - afterEach(() => { - for (const root of roots.splice(0, roots.length)) { - rmSync(root, { recursive: true, force: true }); - } - }); - - function makeRoot(files: Record): string { - const root = mkdtempSync(join(tmpdir(), 'void-deploy-env-')); - roots.push(root); - for (const [name, content] of Object.entries(files)) { - writeFileSync(join(root, name), content); - } - return root; - } - - it('expands ${VAR} references so shipped values match what was validated', () => { - const root = makeRoot({ - '.env': 'PLAIN=hello\nAPI_HOST=api.example.com\nAPI_URL=https://${API_HOST}/v1\n', - }); - - const env = loadDeployEnv(root); - - expect(env.PLAIN).toBe('hello'); - expect(env.API_HOST).toBe('api.example.com'); - expect(env.API_URL).toBe('https://api.example.com/v1'); - }); - - it('expands references across .env and .env.production', () => { - const root = makeRoot({ - '.env': 'BASE=example.com\n', - '.env.production': 'URL=https://${BASE}/prod\n', - }); - - expect(loadDeployEnv(root).URL).toBe('https://example.com/prod'); - }); -}); diff --git a/packages/void/test/unit/deploy-prerender-skip.test.ts b/packages/void/test/unit/deploy-prerender-skip.test.ts index a939e4aba..b012144db 100644 --- a/packages/void/test/unit/deploy-prerender-skip.test.ts +++ b/packages/void/test/unit/deploy-prerender-skip.test.ts @@ -3,14 +3,9 @@ import { hasPrerenderSources } from '../../src/cli/deploy.ts'; import { compileRouteTable } from '../../src/router/compile.ts'; import type { ScanResult } from '../../src/router/scan.ts'; -// `collectPrerenderPaths` boots the BUILT worker in Miniflare with bindings from -// `wranglerVars + loadEnv('production', root, '')` only. The generated fetch -// handler validates the `env.ts` schema at boot, so an app whose secrets live on -// the platform (never in a committed `.env`) gets HTTP 500 there and the deploy -// dies. The only workaround was moving the secret into `.env.production`, which -// `loadDeployEnv` then publishes as a PLAINTEXT manifest var — so skipping the -// boot when it provably cannot contribute anything also removes a real incentive -// to leak a secret. +// `collectPrerenderPaths` boots the built worker in Miniflare without remote +// production secrets. The generated handler validates `env.ts` at boot, so an +// unnecessary boot could fail even when there is nothing to prerender. // // The skip is only safe if it is result-identical, which is what the second // describe block below pins against the real code generator. diff --git a/packages/void/test/unit/env-client-fold.test.ts b/packages/void/test/unit/env-client-fold.test.ts index 6f7d0d09f..8945016ba 100644 --- a/packages/void/test/unit/env-client-fold.test.ts +++ b/packages/void/test/unit/env-client-fold.test.ts @@ -130,16 +130,16 @@ describe('envClientFoldPlugin', () => { expect(result).toBeFalsy(); }); - it('honours a custom envPrefix string', async () => { + it('does not fold custom-prefix keys', async () => { const code = `import { env } from "void/env";\nconsole.log(env.PUBLIC_TITLE);\n`; const { result } = await runFold(code, `${ROOT}/src/page.ts`, { envPrefix: 'PUBLIC_', env: { PUBLIC_TITLE: 'Public' }, }); - expect(result?.code).toContain(`"Public"`); + expect(result).toBeFalsy(); }); - it('honours a custom envPrefix array', async () => { + it('does not fold keys from custom prefix arrays', async () => { const code = [ `import { env } from "void/env";`, `const a = env.PUBLIC_A;`, @@ -149,8 +149,7 @@ describe('envClientFoldPlugin', () => { envPrefix: ['PUBLIC_', 'NEXT_PUBLIC_'], env: { PUBLIC_A: 'aa', NEXT_PUBLIC_B: 'bb' }, }); - expect(result?.code).toContain(`"aa"`); - expect(result?.code).toContain(`"bb"`); + expect(result).toBeFalsy(); }); it('leaves unknown keys untouched so the runtime Proxy can serve them', async () => { diff --git a/packages/void/test/unit/env-client-guard.test.ts b/packages/void/test/unit/env-client-guard.test.ts index 8ff323cb8..0e523bad1 100644 --- a/packages/void/test/unit/env-client-guard.test.ts +++ b/packages/void/test/unit/env-client-guard.test.ts @@ -302,7 +302,7 @@ describe('envClientGuardPlugin', () => { expect(ctx.errors).toHaveLength(1); }); - it('honours a custom envPrefix string', async () => { + it('does not let a custom Vite envPrefix redefine Void client keys', async () => { defineEnv({ PUBLIC_TITLE: string() }); const ctx = makeContext('client'); const result = await runTransform( @@ -311,11 +311,11 @@ describe('envClientGuardPlugin', () => { `${ROOT}/src/page.ts`, { envPrefix: 'PUBLIC_' }, ); - expect(result.threw).toBe(false); - expect(ctx.errors).toEqual([]); + expect(result.threw).toBe(true); + expect(ctx.errors[0]!.message).toContain('VITE_*'); }); - it('honours a custom envPrefix array', async () => { + it('rejects non-VITE keys even when Vite exposes multiple custom prefixes', async () => { defineEnv({ PUBLIC_A: string(), NEXT_PUBLIC_B: string() }); const ctx = makeContext('client'); const result = await runTransform( @@ -324,11 +324,11 @@ describe('envClientGuardPlugin', () => { `${ROOT}/src/page.ts`, { envPrefix: ['PUBLIC_', 'NEXT_PUBLIC_'] }, ); - expect(result.threw).toBe(false); - expect(ctx.errors).toEqual([]); + expect(result.threw).toBe(true); + expect(ctx.errors).toHaveLength(1); }); - it('errors with custom envPrefix in the message when set', async () => { + it('always points to the fixed VITE prefix', async () => { defineEnv({ STRIPE_KEY: string() }); const ctx = makeContext('client'); const result = await runTransform( @@ -338,6 +338,6 @@ describe('envClientGuardPlugin', () => { { envPrefix: 'PUBLIC_' }, ); expect(result.threw).toBe(true); - expect(ctx.errors[0]!.message).toContain('PUBLIC_*'); + expect(ctx.errors[0]!.message).toContain('VITE_*'); }); }); diff --git a/packages/void/test/unit/env-command-target.test.ts b/packages/void/test/unit/env-command-target.test.ts new file mode 100644 index 000000000..a9081557e --- /dev/null +++ b/packages/void/test/unit/env-command-target.test.ts @@ -0,0 +1,94 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathslash'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { string } from '../../src/runtime/env-helpers.ts'; +import { defineEnv } from '../../src/runtime/env-public.ts'; + +const mocks = vi.hoisted(() => ({ + getToken: vi.fn((): string | null => 'void-token'), + listHostedSecrets: vi.fn(async () => [{ name: 'API_TOKEN' }]), + listCloudflareSecrets: vi.fn(async () => ['API_TOKEN']), + resolveCloudflareContext: vi.fn(() => ({ workerName: 'app' })), + platformClient: vi.fn(), +})); + +vi.mock('../../src/cli/auth.ts', () => ({ getToken: mocks.getToken })); +vi.mock('../../src/cli/client.ts', () => ({ + PlatformClient: class { + constructor(...args: Array) { + mocks.platformClient(...args); + } + + listSecrets = mocks.listHostedSecrets; + }, +})); +vi.mock('../../src/cli/cloudflare-operations.ts', () => ({ + listCloudflareSecrets: mocks.listCloudflareSecrets, + resolveCloudflareOperationContext: mocks.resolveCloudflareContext, +})); + +import { runEnvCommand } from '../../src/cli/env.ts'; + +const roots: Array = []; + +function project(platform: 'cloudflare' | 'void' | 'none'): string { + const root = mkdtempSync(join(tmpdir(), 'void-env-target-')); + roots.push(root); + mkdirSync(join(root, '.void'), { recursive: true }); + writeFileSync( + join(root, '.void/project.json'), + JSON.stringify({ platform, projectId: 'project-1', slug: 'app' }), + ); + writeFileSync(join(root, 'env.ts'), 'export {};\n'); + return root; +} + +beforeEach(() => { + defineEnv({ API_TOKEN: string() }); + vi.clearAllMocks(); + mocks.getToken.mockReturnValue('void-token'); + mocks.listHostedSecrets.mockResolvedValue([{ name: 'API_TOKEN' }]); + mocks.listCloudflareSecrets.mockResolvedValue(['API_TOKEN']); +}); + +afterEach(() => { + for (const root of roots.splice(0, roots.length)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('env check --remote target dispatch', () => { + it('lists direct Cloudflare secrets without hosted authentication', async () => { + const root = project('cloudflare'); + + await runEnvCommand(root, { subcommand: 'check', useRemoteSecrets: true }); + + expect(mocks.resolveCloudflareContext).toHaveBeenCalledWith(root); + expect(mocks.listCloudflareSecrets).toHaveBeenCalledWith({ workerName: 'app' }); + expect(mocks.getToken).not.toHaveBeenCalled(); + expect(mocks.platformClient).not.toHaveBeenCalled(); + expect(mocks.listHostedSecrets).not.toHaveBeenCalled(); + }); + + it('retains hosted Void secret lookup for Void projects', async () => { + const root = project('void'); + + await runEnvCommand(root, { subcommand: 'check', useRemoteSecrets: true }); + + expect(mocks.getToken).toHaveBeenCalledWith(root); + expect(mocks.platformClient).toHaveBeenCalledWith('void-token', { root }); + expect(mocks.listHostedSecrets).toHaveBeenCalledWith('project-1'); + expect(mocks.listCloudflareSecrets).not.toHaveBeenCalled(); + }); + + it('rejects remote checks when deployment is disabled', async () => { + const root = project('none'); + + await expect( + runEnvCommand(root, { subcommand: 'check', useRemoteSecrets: true }), + ).rejects.toThrow('Deployment is disabled'); + expect(mocks.listCloudflareSecrets).not.toHaveBeenCalled(); + expect(mocks.listHostedSecrets).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/void/test/unit/env-example.test.ts b/packages/void/test/unit/env-example.test.ts deleted file mode 100644 index b560099d0..000000000 --- a/packages/void/test/unit/env-example.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test'; -import { - applyMarkerBlock, - classifySchema, - ENV_EXAMPLE_END_MARKER, - ENV_EXAMPLE_START_MARKER, - formatEnvExample, -} from '../../src/cli/env-example.ts'; -import { boolean, json, number, oneOf, string, url } from '../../src/runtime/env-helpers.ts'; - -describe('classifySchema', () => { - it('classifies a plain string() as required', () => { - expect(classifySchema(string())).toEqual({ kind: 'required', enumValues: undefined }); - }); - - it('classifies .optional() as optional', () => { - expect(classifySchema(string().optional())).toEqual({ - kind: 'optional', - enumValues: undefined, - }); - }); - - it('classifies .default(x) as default with value', () => { - expect(classifySchema(number().default(3000))).toEqual({ - kind: 'default', - defaultValue: 3000, - enumValues: undefined, - }); - }); - - it('classifies boolean().default(false) correctly (falsy default preserved)', () => { - expect(classifySchema(boolean().default(false))).toEqual({ - kind: 'default', - defaultValue: false, - enumValues: undefined, - }); - }); - - it('extracts oneOf enum members for hint comment', () => { - const schema = oneOf(['development', 'production'] as const); - expect(classifySchema(schema)).toEqual({ - kind: 'required', - enumValues: ['development', 'production'], - }); - }); - - it('extracts oneOf enum members even with a default', () => { - const schema = oneOf(['debug', 'info', 'warn', 'error'] as const).default('info'); - expect(classifySchema(schema)).toEqual({ - kind: 'default', - defaultValue: 'info', - enumValues: ['debug', 'info', 'warn', 'error'], - }); - }); - - it('returns no enum hint for non-oneOf schemas', () => { - expect(classifySchema(url()).enumValues).toBeUndefined(); - expect(classifySchema(json()).enumValues).toBeUndefined(); - }); -}); - -describe('formatEnvExample', () => { - it('renders the canonical grouped body (no markers)', () => { - const out = formatEnvExample({ - STRIPE_KEY: string(), - PORT: number().default(3000), - DEBUG: boolean().optional(), - NODE_ENV: oneOf(['development', 'production'] as const), - }); - // Grouping order, enum hint, and prefilled defaults — no header, - // no markers; that's the marker block's job. - expect(out).toBe( - [ - '# required', - 'STRIPE_KEY=', - '# enum: development | production', - 'NODE_ENV=', - '', - '# with defaults', - 'PORT=3000', - '', - '# optional', - 'DEBUG=', - '', - ].join('\n'), - ); - }); - - it('skips empty groups cleanly', () => { - const out = formatEnvExample({ - ONLY: string().default('x'), - }); - expect(out).toBe(['# with defaults', 'ONLY=x', ''].join('\n')); - }); - - it('preserves insertion order within each group', () => { - const out = formatEnvExample({ - B: string(), - A: string(), - C: string(), - }); - const idxB = out.indexOf('\nB='); - const idxA = out.indexOf('\nA='); - const idxC = out.indexOf('\nC='); - expect(idxB).toBeLessThan(idxA); - expect(idxA).toBeLessThan(idxC); - }); - - it('serializes boolean and number defaults as bare strings', () => { - const out = formatEnvExample({ - FEATURE_FLAG: boolean().default(false), - PORT: number().default(8080), - }); - expect(out).toContain('FEATURE_FLAG=false'); - expect(out).toContain('PORT=8080'); - }); - - it('handles empty schema (empty body)', () => { - expect(formatEnvExample({})).toBe(''); - }); -}); - -describe('applyMarkerBlock', () => { - const body = formatEnvExample({ - STRIPE_KEY: string(), - PORT: number().default(3000), - }); - - it('creates a fresh file with markers when none exists', () => { - const result = applyMarkerBlock(null, body); - expect(result.action).toBe('create'); - const lines = result.content.split('\n'); - expect(lines[0]).toBe(ENV_EXAMPLE_START_MARKER); - // Trailing newline → last entry of split is "". - expect(lines.at(-2)).toBe(ENV_EXAMPLE_END_MARKER); - expect(result.content).toContain('STRIPE_KEY='); - expect(result.content).toContain('PORT=3000'); - }); - - it('replaces only the managed block, preserving lines above and below', () => { - const existing = [ - '# Custom CI key — do not remove', - 'CI_TOKEN=abc123', - '', - ENV_EXAMPLE_START_MARKER, - '# Run `void env example` to refresh.', - '# required', - 'OLD_KEY=', - ENV_EXAMPLE_END_MARKER, - '', - '# Custom build flag', - 'BUILD_VARIANT=fast', - '', - ].join('\n'); - - const result = applyMarkerBlock(existing, body); - expect(result.action).toBe('replace'); - expect(result.content).toContain('# Custom CI key — do not remove'); - expect(result.content).toContain('CI_TOKEN=abc123'); - expect(result.content).toContain('# Custom build flag'); - expect(result.content).toContain('BUILD_VARIANT=fast'); - // Old managed key is gone, new ones are present. - expect(result.content).not.toContain('OLD_KEY='); - expect(result.content).toContain('STRIPE_KEY='); - expect(result.content).toContain('PORT=3000'); - - // Pre-block content is verbatim. - expect(result.content.startsWith('# Custom CI key — do not remove\nCI_TOKEN=abc123\n\n')).toBe( - true, - ); - // Post-block content is verbatim. - expect(result.content.endsWith('\n\n# Custom build flag\nBUILD_VARIANT=fast\n')).toBe(true); - }); - - it('appends the block when no markers are present', () => { - const existing = ['# Existing CI config', 'CI_TOKEN=abc', ''].join('\n'); - const result = applyMarkerBlock(existing, body); - expect(result.action).toBe('append'); - // Original content stays at the top, verbatim. - expect(result.content.startsWith('# Existing CI config\nCI_TOKEN=abc\n\n')).toBe(true); - // Block follows. - expect(result.content).toContain(ENV_EXAMPLE_START_MARKER); - expect(result.content).toContain(ENV_EXAMPLE_END_MARKER); - expect(result.content).toContain('STRIPE_KEY='); - // Exactly one blank line separator between original content and the marker. - const startIdx = result.content.indexOf(ENV_EXAMPLE_START_MARKER); - expect(result.content.slice(0, startIdx)).toBe('# Existing CI config\nCI_TOKEN=abc\n\n'); - }); - - it('returns an error when only the start marker is present', () => { - const existing = ['CI_TOKEN=abc', ENV_EXAMPLE_START_MARKER, '# required', 'STRIPE_KEY='].join( - '\n', - ); - const result = applyMarkerBlock(existing, body); - expect(result.action).toBe('error'); - expect(result.reason).toMatch(/end marker/i); - expect(result.content).toBe(existing); - }); - - it('returns an error when only the end marker is present', () => { - const existing = ['CI_TOKEN=abc', '# required', 'STRIPE_KEY=', ENV_EXAMPLE_END_MARKER].join( - '\n', - ); - const result = applyMarkerBlock(existing, body); - expect(result.action).toBe('error'); - expect(result.reason).toMatch(/start marker/i); - expect(result.content).toBe(existing); - }); - - it('is idempotent: running twice produces identical output', () => { - const first = applyMarkerBlock(null, body).content; - const second = applyMarkerBlock(first, body).content; - expect(second).toBe(first); - const third = applyMarkerBlock(second, body).content; - expect(third).toBe(second); - }); - - it('appends cleanly even when the existing file lacks a trailing newline', () => { - const existing = '# Existing\nCI_TOKEN=abc'; - const result = applyMarkerBlock(existing, body); - expect(result.action).toBe('append'); - expect(result.content.startsWith('# Existing\nCI_TOKEN=abc\n\n')).toBe(true); - expect(result.content.endsWith('\n')).toBe(true); - expect(result.content).toContain(ENV_EXAMPLE_START_MARKER); - expect(result.content).toContain(ENV_EXAMPLE_END_MARKER); - }); - - it('tolerates trailing whitespace on marker lines when matching', () => { - const existing = [ - 'CI_TOKEN=abc', - `${ENV_EXAMPLE_START_MARKER} `, - '# Run `void env example` to refresh.', - '# required', - 'OLD=', - `${ENV_EXAMPLE_END_MARKER}\t`, - 'BUILD_VARIANT=fast', - ].join('\n'); - const result = applyMarkerBlock(existing, body); - expect(result.action).toBe('replace'); - expect(result.content).toContain('CI_TOKEN=abc'); - expect(result.content).toContain('BUILD_VARIANT=fast'); - expect(result.content).not.toContain('OLD='); - expect(result.content).toContain('STRIPE_KEY='); - }); -}); diff --git a/packages/void/test/unit/env-mask.test.ts b/packages/void/test/unit/env-mask.test.ts index b6b70353c..1015baba7 100644 --- a/packages/void/test/unit/env-mask.test.ts +++ b/packages/void/test/unit/env-mask.test.ts @@ -88,14 +88,6 @@ describe('maskValue', () => { expect(maskValue('NODE_ENV', 'production')).toBe('production'); }); - it('override="public" beats every heuristic', () => { - expect(maskValue('STRIPE_KEY', 'sk_live_abc', { override: 'public' })).toBe('sk_live_abc'); - }); - - it('override="secret" redacts even neutral keys/values', () => { - expect(maskValue('PORT', '3000', { override: 'secret' })).toBe(REDACTED); - }); - it('unmask=true disables all redaction', () => { expect(maskValue('STRIPE_KEY', 'sk_test', { unmask: true })).toBe('sk_test'); }); @@ -117,21 +109,9 @@ describe('redactMessage', () => { expect(msg).toBe(`Expected a number, got "${REDACTED}"`); }); - it('leaves plain values alone', () => { + it('redacts plain invalid values too', () => { const msg = redactMessage('PORT', 'abc', 'Expected a number, got "abc"'); - expect(msg).toBe('Expected a number, got "abc"'); - }); - - it('respects override="public" even for secret-looking keys', () => { - const msg = redactMessage('API_KEY', 'sk_live_abc', 'got "sk_live_abc"', { - override: 'public', - }); - expect(msg).toBe('got "sk_live_abc"'); - }); - - it('respects override="secret" even for plain keys/values', () => { - const msg = redactMessage('FLAG', 'abc', 'got "abc"', { override: 'secret' }); - expect(msg).toBe(`got "${REDACTED}"`); + expect(msg).toBe(`Expected a number, got "${REDACTED}"`); }); it('is a no-op when unmask=true', () => { diff --git a/packages/void/test/unit/env-scaffold.test.ts b/packages/void/test/unit/env-scaffold.test.ts index fabdd50e2..284cf24ff 100644 --- a/packages/void/test/unit/env-scaffold.test.ts +++ b/packages/void/test/unit/env-scaffold.test.ts @@ -111,10 +111,9 @@ describe('maybeScaffoldEnvTs', () => { expect(result.skipReason).toBe('no-dotenv-files'); }); - it('merges keys across .env and .env.example, first non-empty value wins', () => { + it('collects keys from the single .env file', () => { const root = tmpProject(); - writeFileSync(join(root, '.env.example'), 'PORT=\nSTRIPE_KEY=\n'); - writeFileSync(join(root, '.env'), 'PORT=3000\nDEBUG=true\n'); + writeFileSync(join(root, '.env'), 'PORT=3000\nDEBUG=true\nSTRIPE_KEY=\n'); const entries = collectInferredEntries(root); expect(entries.map((e) => e.key)).toEqual(['DEBUG', 'PORT', 'STRIPE_KEY']); diff --git a/packages/void/test/unit/env-schema.test.ts b/packages/void/test/unit/env-schema.test.ts index ae6eb7f14..ee54d0259 100644 --- a/packages/void/test/unit/env-schema.test.ts +++ b/packages/void/test/unit/env-schema.test.ts @@ -167,39 +167,6 @@ describe('defineEnv + env proxy', () => { }); }); -describe('.secret() / .public() modifiers', () => { - it('.secret() marks the schema for forced redaction', async () => { - defineEnv({ TOKEN: string().secret() }); - const report = await validateAllEnv({ TOKEN: 'sk_live_abc' }); - expect(report.valid).toBe(true); - // Force a validation failure to observe redaction in messages: - defineEnv({ FLAG: oneOf(['a', 'b'] as const).secret() }); - const bad = await validateAllEnv({ FLAG: 'c' }); - expect(bad.valid).toBe(false); - const msg = bad.invalid[0]!.messages.join(' '); - expect(msg).not.toContain('"c"'); - expect(msg).toContain(''); - }); - - it('.public() disables redaction even for secret-looking keys', async () => { - defineEnv({ - // A key whose NAME looks like a secret (matches /KEY/) but whose value - // is deliberately public; `.public()` preserves the value in errors. - APIKEY: oneOf(['a', 'b'] as const).public(), - }); - const bad = await validateAllEnv({ APIKEY: 'nope' }); - expect(bad.valid).toBe(false); - expect(bad.invalid[0]!.messages.join(' ')).toContain('"nope"'); - }); - - it('.secret() / .public() compose with .optional() and .default()', () => { - const s = string().secret().default('x'); - expect(s['~standard'].validate(undefined)).toEqual({ value: 'x' }); - const p = string().public().optional(); - expect(p['~standard'].validate(undefined)).toEqual({ value: undefined }); - }); -}); - describe('redaction roundtrip (heuristic + content)', () => { beforeEach(() => { resetSchema(); diff --git a/packages/void/test/unit/env-validation-selfhost.test.ts b/packages/void/test/unit/env-validation-selfhost.test.ts index 1dbd2e661..d3ba61e70 100644 --- a/packages/void/test/unit/env-validation-selfhost.test.ts +++ b/packages/void/test/unit/env-validation-selfhost.test.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os'; import { join } from 'pathslash'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { - collectPlaintextVarWarningsFromVars, computeInvalidEmittedKeys, createDefaultSecretListRunner, deriveSelfHostEnvContextFromVars, @@ -95,8 +94,8 @@ const CONFIG_PATH = '/tmp/proj/wrangler.jsonc'; // A schema requiring exactly one non-secret key, reused across the gate tests. const requiredStripe: SelfHostSchemaFacts = { hasSchema: true, + serverKeys: ['STRIPE_KEY'], requiredKeys: ['STRIPE_KEY'], - secretKeys: [], }; // --------------------------------------------------------------------------- @@ -143,7 +142,7 @@ describe('deriveSelfHostEnvContextFromVars — ground-truth context from the emi it('hasSchema:false yields the empty context shape (nothing to gate)', () => { const ctx = deriveSelfHostEnvContextFromVars( { STRIPE_KEY: '' }, - { hasSchema: false, requiredKeys: [], secretKeys: [] }, + { hasSchema: false, serverKeys: [], requiredKeys: [] }, ); expect(ctx).toEqual({ @@ -156,124 +155,18 @@ describe('deriveSelfHostEnvContextFromVars — ground-truth context from the emi }); }); -// --------------------------------------------------------------------------- -// PURE unit — collectPlaintextVarWarningsFromVars (emitted vars + secret flags) -// --------------------------------------------------------------------------- - -describe('collectPlaintextVarWarningsFromVars — plaintext worker-vars classification', () => { - it('classifies .secret()-flagged, heuristic, and benign emitted keys; sorted, keys only', () => { - // APP_PEPPER is `.secret()`-flagged (it does NOT trip the name heuristic on - // its own). API_TOKEN trips the heuristic but is not flagged. GREETING is benign. - const result = collectPlaintextVarWarningsFromVars( - { APP_PEPPER: 'super-pepper-value', API_TOKEN: 'heuristic-value', GREETING: 'hello' }, - ['APP_PEPPER'], - ); - - // Every emitted key is counted… - expect(result.totalVarKeys).toBe(3); - // …and the warnings are sorted by key (API_TOKEN < APP_PEPPER). - expect(result.secretKeys).toEqual([ - { key: 'API_TOKEN', reason: 'heuristic' }, - { key: 'APP_PEPPER', reason: 'schema-secret' }, - ]); - // Keys only — no emitted value ever rides along on the result. - const serialized = JSON.stringify(result); - expect(serialized).not.toContain('super-pepper-value'); - expect(serialized).not.toContain('heuristic-value'); - expect(serialized).not.toContain('hello'); - }); - - it('warns nothing for a benign-only set but still counts every key', () => { - const result = collectPlaintextVarWarningsFromVars({ GREETING: 'hi', REGION: 'us' }, []); - - expect(result.secretKeys).toEqual([]); - expect(result.totalVarKeys).toBe(2); - }); - - it('warns on a credential-bearing connection URL whose KEY name looks benign', () => { - // DATABASE_URL trips NO name heuristic (SECRET_KEY_RE has no URL/DATABASE branch) - // and no shared value heuristic either (HIGH_ENTROPY_RE excludes `@`), so before - // the value-shape check this shipped as plaintext with no warning at all. - const result = collectPlaintextVarWarningsFromVars( - { DATABASE_URL: 'postgres://admin:hunter2@db.example.com:5432/app' }, - [], - ); - - expect(result.secretKeys).toEqual([{ key: 'DATABASE_URL', reason: 'value-shape' }]); - // The value decided the classification but must never ride along on the result. - const serialized = JSON.stringify(result); - expect(serialized).not.toContain('hunter2'); - expect(serialized).not.toContain('db.example.com'); - expect(serialized).not.toContain('admin'); - }); - - it('flags the credential URL shape across schemes, but not a benign URL value', () => { - const result = collectPlaintextVarWarningsFromVars( - { - // Credential-bearing → flagged regardless of scheme. Every key name here is - // deliberately one the NAME heuristic does NOT match, so only the value can - // have caused the warning. - MYSQL_ENDPOINT: 'mysql://root:s3cr3t@10.0.0.4/prod', - REDIS_ENDPOINT: 'rediss://default:abc123@cache.internal:6379', - // Benign URLs → NOT flagged: no userinfo, or userinfo with no password. - PUBLIC_SITE: 'https://example.com/a/b?c=d', - USERINFO_ONLY: 'https://someuser@example.com/path', - NO_USERINFO: 'postgres://db.example.com:5432/app', - // A bare value containing an @ is not a URL at all. - CONTACT: 'ops@example.com', - }, - [], - ); - - expect(result.secretKeys).toEqual([ - { key: 'MYSQL_ENDPOINT', reason: 'value-shape' }, - { key: 'REDIS_ENDPOINT', reason: 'value-shape' }, - ]); - expect(result.totalVarKeys).toBe(6); - }); - - it('keeps the KEY-name heuristic and .secret() flags winning over the value shape', () => { - // Regression guard: the value-shape check is additive — an existing secret-looking - // KEY is still reported as `heuristic`, and a flagged key still as `schema-secret`, - // even when its value ALSO happens to be a credential URL. - const result = collectPlaintextVarWarningsFromVars( - { - API_TOKEN: 'plain-token-value', - APP_PEPPER: 'postgres://u:p@h/db', - DB_URI: 'postgres://u:p@h/db', - }, - ['APP_PEPPER'], - ); - - expect(result.secretKeys).toEqual([ - { key: 'API_TOKEN', reason: 'heuristic' }, - { key: 'APP_PEPPER', reason: 'schema-secret' }, - { key: 'DB_URI', reason: 'value-shape' }, - ]); - }); - - it('ignores non-string emitted values without throwing', () => { - const result = collectPlaintextVarWarningsFromVars( - { PORT: 8080, FLAG: true, NOTHING: null, NESTED: { url: 'postgres://u:p@h/db' } }, - [], - ); - - expect(result.secretKeys).toEqual([]); - expect(result.totalVarKeys).toBe(4); - }); -}); - // --------------------------------------------------------------------------- // deriveSelfHostSchemaFacts — env-insensitive schema facts (imports env.ts) // --------------------------------------------------------------------------- describe('deriveSelfHostSchemaFacts — env-insensitive schema facts', () => { - it('derives required key names + .secret() flags; a `.default()` key is NOT required', async () => { + it('derives server and required key names while excluding client keys', async () => { const root = createTempProject(); defineEnv({ - STRIPE_KEY: string().secret(), + STRIPE_KEY: string(), API_URL: string(), GREETING: string().default('hi'), + VITE_TITLE: string(), }); const facts = await deriveSelfHostSchemaFacts(root); @@ -282,8 +175,7 @@ describe('deriveSelfHostSchemaFacts — env-insensitive schema facts', () => { // STRIPE_KEY + API_URL reject `undefined` → required; GREETING has a default → not required. expect([...facts.requiredKeys].sort()).toEqual(['API_URL', 'STRIPE_KEY']); expect(facts.requiredKeys).not.toContain('GREETING'); - // Only the explicitly `.secret()`-flagged key is a schema secret. - expect(facts.secretKeys).toEqual(['STRIPE_KEY']); + expect([...facts.serverKeys].sort()).toEqual(['API_URL', 'GREETING', 'STRIPE_KEY']); }); it('a project with no env.ts has no schema facts', async () => { @@ -291,7 +183,7 @@ describe('deriveSelfHostSchemaFacts — env-insensitive schema facts', () => { const facts = await deriveSelfHostSchemaFacts(root); - expect(facts).toEqual({ hasSchema: false, requiredKeys: [], secretKeys: [] }); + expect(facts).toEqual({ hasSchema: false, serverKeys: [], requiredKeys: [] }); }); // --- schema load / validator throws never leak a value, even under VOID_ENV_UNMASK --- @@ -593,7 +485,7 @@ describe('dischargeSelfHostEnvContext — ground-truth secret gate', () => { // BEFORE any remote apply: a remote secret cannot rescue an invalid SHIPPED value. const ctx = deriveSelfHostEnvContextFromVars( {}, - { hasSchema: true, requiredKeys: [], secretKeys: [] }, + { hasSchema: true, serverKeys: [], requiredKeys: [] }, ); ctx.invalidKeys = ['OPT_URL']; const { run, calls } = stubRunner(okList(['OPT_URL'])); @@ -618,7 +510,7 @@ describe('dischargeSelfHostEnvContext — ground-truth secret gate', () => { const root = createTempProject({ withEnvTs: false }); const ctx = deriveSelfHostEnvContextFromVars( {}, - { hasSchema: false, requiredKeys: [], secretKeys: [] }, + { hasSchema: false, serverKeys: [], requiredKeys: [] }, ); const { run, calls } = stubRunner(okList([])); @@ -764,7 +656,11 @@ describe('dischargeSelfHostEnvContext — ground-truth secret gate', () => { const root = createTempProject({ withEnvTs: false }); const ctx = deriveSelfHostEnvContextFromVars( {}, - { hasSchema: true, requiredKeys: ['FOO', 'API_TOKEN'], secretKeys: [] }, + { + hasSchema: true, + serverKeys: ['FOO', 'API_TOKEN'], + requiredKeys: ['FOO', 'API_TOKEN'], + }, ); const { run } = stubRunner({ stdout: '', stderr: WORKER_NOT_FOUND_STDERR, status: 1 }); @@ -781,8 +677,8 @@ describe('dischargeSelfHostEnvContext — ground-truth secret gate', () => { const fde = error as SelfHostFirstDeployError; expect(fde.workerNotFound).toBe(true); expect([...fde.missingKeys].sort()).toEqual(['API_TOKEN', 'FOO']); - expect(fde.message).toContain('wrangler secret put FOO'); - expect(fde.message).toContain('wrangler secret put API_TOKEN'); + expect(fde.message).toContain('void secret put FOO'); + expect(fde.message).toContain('void secret put API_TOKEN'); // Still a fail-closed list error at heart (subclass). expect(error).toBeInstanceOf(SelfHostSecretListError); }); @@ -1005,7 +901,11 @@ describe('dischargeSelfHostEnvContext — ground-truth secret gate', () => { const root = createTempProject({ withEnvTs: false }); const ctx = deriveSelfHostEnvContextFromVars( {}, - { hasSchema: true, requiredKeys: ['FOO', 'API_TOKEN'], secretKeys: [] }, + { + hasSchema: true, + serverKeys: ['FOO', 'API_TOKEN'], + requiredKeys: ['FOO', 'API_TOKEN'], + }, ); // The worker-not-found signal AND a sentinel share the child's stderr: the // signal must be read (classification), the sentinel must not leak. @@ -1030,7 +930,7 @@ describe('dischargeSelfHostEnvContext — ground-truth secret gate', () => { expect(error).toBeInstanceOf(SelfHostFirstDeployError); expect(error.workerNotFound).toBe(true); expect([...error.missingKeys].sort()).toEqual(['API_TOKEN', 'FOO']); - expect(error.message).toContain('wrangler secret put FOO'); + expect(error.message).toContain('void secret put FOO'); expect(error.message).not.toContain(SENTINEL); expect(fullySerialize(error)).not.toContain(SENTINEL); }); diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index 6681256b7..53df96213 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -2269,8 +2269,7 @@ export default config; expect(gitignore).toContain('node_modules'); expect(gitignore).toContain('.void'); expect(gitignore).toContain('.wrangler'); - expect(gitignore).toContain('.env.local'); - expect(gitignore).toContain('.env.*.local'); + expect(gitignore).toContain('.env'); } finally { rmSync(root, { recursive: true, force: true }); } @@ -2287,8 +2286,7 @@ export default config; expect(gitignore).toContain('custom-cache'); expect(gitignore).toContain('.void'); expect(gitignore).toContain('.wrangler'); - expect(gitignore).toContain('.env.local'); - expect(gitignore).toContain('.env.*.local'); + expect(gitignore).toContain('.env'); expect(gitignore.match(/^node_modules$/gm)).toHaveLength(1); } finally { rmSync(root, { recursive: true, force: true }); diff --git a/packages/void/test/unit/prerender.test.ts b/packages/void/test/unit/prerender.test.ts index bbbd2fae1..67d1609dc 100644 --- a/packages/void/test/unit/prerender.test.ts +++ b/packages/void/test/unit/prerender.test.ts @@ -110,7 +110,7 @@ describe('collectPrerenderWorkerModules', () => { }); describe('collectPrerenderPathsNode', () => { - it('scrubs orchestrator credentials before importing the built app', async () => { + it('scrubs orchestrator credentials and ignores production dotenv before importing the built app', async () => { const root = createTempWorkerDir(); const ssrDir = join(root, 'dist', 'ssr'); mkdirSync(ssrDir, { recursive: true }); @@ -147,7 +147,7 @@ export default { process.env[name] = `secret-${name}`; } try { - await expect(collectPrerenderPathsNode({ root, ssrDir })).resolves.toEqual(['/safe']); + await expect(collectPrerenderPathsNode({ root, ssrDir })).resolves.toEqual(['/safe-missing']); } finally { for (const [name, value] of previous) { if (value === undefined) { diff --git a/packages/void/test/unit/secret-cloudflare-validation.test.ts b/packages/void/test/unit/secret-cloudflare-validation.test.ts new file mode 100644 index 000000000..3738b5703 --- /dev/null +++ b/packages/void/test/unit/secret-cloudflare-validation.test.ts @@ -0,0 +1,87 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathslash'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { number, string } from '../../src/runtime/env-helpers.ts'; +import { defineEnv } from '../../src/runtime/env-public.ts'; + +const cloudflare = vi.hoisted(() => ({ + put: vi.fn(), + sync: vi.fn(), + resolve: vi.fn(() => ({ workerName: 'app' })), +})); + +vi.mock('../../src/cli/cloudflare-operations.ts', () => ({ + deleteCloudflareSecret: vi.fn(), + listCloudflareSecrets: vi.fn(), + putCloudflareSecret: cloudflare.put, + resolveCloudflareOperationContext: cloudflare.resolve, + syncCloudflareSecrets: cloudflare.sync, +})); + +import { runSecretCommand } from '../../src/cli/secret.ts'; + +const roots: Array = []; + +function project(): string { + const root = mkdtempSync(join(tmpdir(), 'void-cf-secret-validation-')); + roots.push(root); + mkdirSync(join(root, '.void'), { recursive: true }); + writeFileSync(join(root, '.void/project.json'), '{"platform":"cloudflare"}\n'); + writeFileSync(join(root, 'env.ts'), 'export {};\n'); + return root; +} + +beforeEach(() => { + defineEnv({}); + cloudflare.put.mockReset(); + cloudflare.sync.mockReset(); + cloudflare.resolve.mockClear(); +}); + +afterEach(() => { + for (const root of roots.splice(0, roots.length)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('direct Cloudflare secret validation', () => { + it.each([ + ['an undeclared name', 'OTHER', 'value', 'OTHER is not declared in env.ts'], + [ + 'a client-exposed name', + 'VITE_API_URL', + 'https://example.com', + 'VITE_API_URL is client-exposed', + ], + ])('rejects %s before invoking Wrangler', async (_label, name, value, message) => { + const root = project(); + defineEnv({ API_TOKEN: string(), VITE_API_URL: string() }); + + await expect(runSecretCommand(root, { action: 'put', name, value })).rejects.toThrow(message); + expect(cloudflare.put).not.toHaveBeenCalled(); + expect(cloudflare.sync).not.toHaveBeenCalled(); + }); + + it('rejects invalid bulk values before invoking Wrangler', async () => { + const root = project(); + const dotenv = join(root, 'production.env'); + writeFileSync(dotenv, 'PORT=not-a-number\n'); + defineEnv({ PORT: number() }); + + await expect(runSecretCommand(root, { action: 'sync', file: dotenv })).rejects.toThrow( + 'PORT failed env.ts validation', + ); + expect(cloudflare.put).not.toHaveBeenCalled(); + expect(cloudflare.sync).not.toHaveBeenCalled(); + }); + + it('uploads a declared server value after it passes validation', async () => { + const root = project(); + defineEnv({ API_TOKEN: string() }); + + await runSecretCommand(root, { action: 'put', name: 'API_TOKEN', value: 'secret' }); + + expect(cloudflare.put).toHaveBeenCalledWith({ workerName: 'app' }, 'API_TOKEN', 'secret'); + }); +}); diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index f312f3d29..1a4e8542a 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -137,8 +137,8 @@ export default defineConfig({ 'src/pages/islands-plugin.ts', 'src/cli/cli.ts', // Standalone entry so `void deploy --backend cloudflare` can self-spawn it as an isolated - // child (via import.meta.url) to derive the env-insensitive schema facts (required-secret - // names + `.secret()` flags) under a scrubbed env — a stable dist filename for the spawn. + // child (via import.meta.url) to derive the env-insensitive schema facts (server and + // required-secret names) under a scrubbed env — a stable dist filename for the spawn. 'src/cli/env-schema-probe.ts', ], format: 'esm', diff --git a/playground/kitchen-sink/.env b/playground/kitchen-sink/.env index 62e1f17e9..17ca5ad72 100644 --- a/playground/kitchen-sink/.env +++ b/playground/kitchen-sink/.env @@ -2,3 +2,4 @@ APP_NAME=Kitchen Sink SECRET_KEY=supersecret123 FEATURE_FLAG=true API_URL=https://api.example.com +VITE_APP_TITLE=Kitchen Sink Local diff --git a/playground/kitchen-sink/env.ts b/playground/kitchen-sink/env.ts index af903eec9..4d53d5d6d 100644 --- a/playground/kitchen-sink/env.ts +++ b/playground/kitchen-sink/env.ts @@ -6,4 +6,5 @@ export default defineEnv({ FEATURE_FLAG: boolean().default(false), API_URL: url(), LOG_LEVEL: oneOf(['debug', 'info', 'warn', 'error']).default('info'), + VITE_APP_TITLE: string().default('Kitchen Sink'), }); From bd8319ca00218fa5e0de681118f341b7ae54a5fa Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Fri, 11 Sep 2026 01:14:22 +0200 Subject: [PATCH 2/3] docs(env): document the simplified environment model --- README.md | 2 +- docs/.vitepress/config.ts | 1 + docs/guide/ai.md | 2 +- docs/guide/database/mysql.md | 4 +- docs/guide/database/postgresql.md | 2 +- docs/guide/deployment.md | 2 +- docs/guide/env-migration.md | 93 +++++ docs/guide/env-vars.md | 319 ++++-------------- docs/guide/jobs.md | 2 +- docs/guide/queues.md | 2 +- docs/integrations/cloudflare.md | 12 +- docs/integrations/frameworks/astro.md | 2 +- docs/reference/api.md | 8 +- docs/reference/cli.md | 56 +-- docs/reference/config.md | 6 +- docs/reference/structure.md | 20 +- packages/void/README.md | 2 +- packages/void/src/plugin-dev-triggers-hint.ts | 6 +- .../unit/plugin-dev-triggers-hint.test.ts | 4 +- 19 files changed, 210 insertions(+), 335 deletions(-) create mode 100644 docs/guide/env-migration.md diff --git a/README.md b/README.md index 47dca17ac..8496c7878 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ pnpm void deploy - **One CLI** — deploy and manage your app on your Cloudflare account or your team's Void platform - **Team platforms** — install a shared deployment service in your company's Cloudflare account and connect developers through the same CLI - **Automatic provisioning** — use `void/db`, `void/kv`, `void/storage`, or `void/ai`, and Void provisions the resources your app needs -- **Typed secrets** — mark env schema keys with `.secret()`; direct Cloudflare deploys upload them as encrypted Worker secrets instead of plaintext vars +- **Typed production secrets** — schema-declared server keys are required in remote secret storage and rejected as plaintext Worker vars - **File-based routing** — drop a file in `routes/`, get an endpoint with method exports, dynamic params, middleware, and [Standard Schema](https://standardschema.dev/) validation - **Full-stack pages** — `pages/` directory for server-rendered UI with co-located data loading, SPA navigation, and typed forms (React, Vue, Svelte, Solid) - **End-to-end type safety** — SQL migrations generate DB types, route handlers infer return types, typed fetch client checks everything at the call site diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index da7972956..f493fc484 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -219,6 +219,7 @@ export default extendConfig( { text: 'Key-Value Storage', link: '/guide/kv' }, { text: 'Object Storage', link: '/guide/storage' }, { text: 'Environment Variables', link: '/guide/env-vars' }, + { text: 'Environment Migration', link: '/guide/env-migration' }, ], }, { diff --git a/docs/guide/ai.md b/docs/guide/ai.md index 5fc42b314..82731c828 100644 --- a/docs/guide/ai.md +++ b/docs/guide/ai.md @@ -266,7 +266,7 @@ For production, add your API key as a project secret: void secret put OPENAI_API_KEY=sk-... ``` -For local development, add it to `.env.local` in your project root: +For local development, add it to `.env` in your project root: ``` OPENAI_API_KEY=sk-... diff --git a/docs/guide/database/mysql.md b/docs/guide/database/mysql.md index a1c0b7fc6..7eaa4cef2 100644 --- a/docs/guide/database/mysql.md +++ b/docs/guide/database/mysql.md @@ -22,7 +22,7 @@ Add a direct connection for local development: DATABASE_URL=mysql://user:password@host:3306/app ``` -Void reads `.env.local` for `void dev` and `void db` commands. In production, the Worker connects through the inferred `HYPERDRIVE` binding. +Void reads `.env` for `void dev` and `void db` commands. In production, the Worker connects through the inferred `HYPERDRIVE` binding. ## Define a schema @@ -57,4 +57,4 @@ MySQL may implicitly commit schema changes. If a multi-statement migration fails ## Database commands -`void db push`, `generate`, `migrate`, `status`, `reset`, `seed`, `execute`, `studio`, `export`, `rename-migrations`, and `set-url` all understand MySQL. Local commands use `DATABASE_URL` from `.env.local`; `--remote` execution and Studio use the encrypted URL stored for a linked Void project. +`void db push`, `generate`, `migrate`, `status`, `reset`, `seed`, `execute`, `studio`, `export`, `rename-migrations`, and `set-url` all understand MySQL. Local commands use `DATABASE_URL` from `.env`; `--remote` execution and Studio use the encrypted URL stored for a linked Void project. diff --git a/docs/guide/database/postgresql.md b/docs/guide/database/postgresql.md index 1dd0b922f..37cdbbea2 100644 --- a/docs/guide/database/postgresql.md +++ b/docs/guide/database/postgresql.md @@ -26,7 +26,7 @@ Add to your `void.json`: ### 2. Add your connection string -For local development, add `DATABASE_URL` to `.env.local`: +For local development, add `DATABASE_URL` to `.env`: ``` DATABASE_URL=postgresql://user:password@host:5432/mydb?sslmode=require diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index fd626fc32..8f795acef 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -206,7 +206,7 @@ Select Cloudflare during setup, or run: void deploy --platform cloudflare ``` -Void supports full Worker apps, static sites, SPAs, and Cloudflare builds from supported frameworks. It provisions resources, applies migrations, and manages secrets for you. Commands such as `void secret put`, `void project logs`, and `void project rollback` then use the same account and Worker. +Void supports full Worker apps, static sites, SPAs, and Cloudflare builds from supported frameworks. It provisions resources, applies migrations, and manages secrets for you. `.env` stays local; production server keys declared in `env.ts` must exist in encrypted remote secret storage and are rejected as plaintext Worker vars. Commands such as `void secret put`, `void project logs`, and `void project rollback` then use the same account and Worker. See the [Cloudflare guide](../integrations/cloudflare.md#deploy-to-your-own-cloudflare-account) for supported features, CI setup, and deployment limits. diff --git a/docs/guide/env-migration.md b/docs/guide/env-migration.md new file mode 100644 index 000000000..3fe1df80a --- /dev/null +++ b/docs/guide/env-migration.md @@ -0,0 +1,93 @@ +# Environment migration + +The environment model now uses `.env` for local development only and remote secrets for every server-side production value. There are no compatibility shims for the previous four-file behavior. + +## 1. Consolidate local development values + +Merge the values you still need locally into `.env`: + +```text +.env.local +.env.development +.env.development.local +→ .env +``` + +Delete every `.env.*` file, `.env.example`, and `.dev.vars*` file. Void fails fast when it finds one, rather than silently applying a precedence order. + +Ensure `.env` is ignored by Git: + +```text +.env +``` + +## 2. Move production server values remotely + +For managed Void projects, upload each non-client schema key: + +```sh +void secret put DATABASE_URL +void secret put STRIPE_KEY +``` + +To migrate a production file in one pass before deleting it: + +```sh +void secret sync .env.production +``` + +The sync command validates every entry against `env.ts`. It rejects undeclared names, invalid values, and client-prefixed names. + +For direct Cloudflare deployments, select Cloudflare and use the same commands: + +```sh +void connect --platform cloudflare +void secret put DATABASE_URL +void secret put STRIPE_KEY +``` + +Remove those keys from `vars` in `wrangler.jsonc`. Void now treats any schema-declared server key in Worker `vars` as an error. + +## 3. Supply client values at build time + +Move `VITE_*` production values to CI/build-shell variables, or make the value an explicit schema default: + +```sh +VITE_API_ORIGIN=https://api.example.com void deploy +``` + +Client values are compiled into browser assets and are public. + +## 4. Remove masking modifiers + +Change: + +```ts +export default defineEnv({ + STRIPE_KEY: string().secret(), + VITE_API_ORIGIN: url().public(), +}); +``` + +to: + +```ts +export default defineEnv({ + STRIPE_KEY: string(), + VITE_API_ORIGIN: url(), +}); +``` + +Server storage is selected by the absence of the client prefix, not a modifier. Invalid input is redacted uniformly. + +## 5. Replace removed commands + +`void env example` was removed because `.env.example` created a second dotenv source. Treat `env.ts` as the checked-in list of names, types, defaults, and requiredness. + +Then verify: + +```sh +void env check +void env check --remote +void deploy +``` diff --git a/docs/guide/env-vars.md b/docs/guide/env-vars.md index 5f3ce6690..caf821d53 100644 --- a/docs/guide/env-vars.md +++ b/docs/guide/env-vars.md @@ -1,310 +1,121 @@ ---- -outline: deep ---- +# Environment variables -# Environment Variables +Void uses one schema, one local dotenv file, and one production rule: -Declare your environment variables in `env.ts`. Void uses that schema to give you types, validate values, and check for missing keys before deployment: +- `env.ts` declares every application environment variable. +- `.env` supplies local development values only. It is never deployed. +- Production server values live in the platform's encrypted secret store. +- Client-prefixed values are embedded at build time, so they are public by definition. -1. **Runtime validation** — declared keys are validated on first access; missing/invalid values produce a clear error -2. **TypeScript** — `env.X` and `c.env.X` become typed automatically (no manual interface to maintain) -3. **Deploy safety** — `void deploy` refuses to upload if a required key is missing from `.env*` files or remote secrets +This removes the distinction between “plain” and “secret-looking” server variables. Every server variable uses the same secure production path. -## `env.ts` (recommended) +## Define the schema -Place an `env.ts` at your project root and call `defineEnv` once: - -::: tip Scaffolded on `void init` -If your project has `.env` or `.env.example`, `void init` creates an `env.ts` from those keys. It recognizes booleans, URLs, and small integers; other values start as strings. Review the generated types and add any validation your app needs. -::: +Create `env.ts` at the project root: ```ts -// env.ts -import { defineEnv, string, number, boolean, oneOf, url } from 'void/env'; +import { defineEnv, number, string, url } from 'void/env'; export default defineEnv({ + DATABASE_URL: url(), STRIPE_KEY: string(), PORT: number().default(3000), - NODE_ENV: oneOf(['development', 'production']), - WEBHOOK_URL: url(), - DEBUG: boolean().optional(), - VITE_APP_TITLE: string(), + VITE_API_ORIGIN: url(), }); ``` -Then read values from anywhere in your app: +Void generates `.void/env.d.ts`, validates values, and provides the typed `env` proxy: ```ts import { env } from 'void/env'; -console.log(env.STRIPE_KEY); // typed as string -console.log(env.PORT); // typed as number +const databaseUrl = env.DATABASE_URL; ``` -`c.env.STRIPE_KEY` inside Hono handlers is also typed once the schema is declared. - -### Built-in helpers +The built-in helpers are `string()`, `number()`, `boolean()`, `url()`, `email()`, `oneOf([...])`, and `json()`. They support `.optional()` and `.default(value)`. Any [Standard Schema](https://standardschema.dev/) validator can also be used directly. -`string()`, `number()`, `boolean()`, `url()`, `email()`, `oneOf([...])`, `json()` cover the common cases. Each helper returns a Standard Schema-conformant builder with `.optional()` and `.default(value)` modifiers. +Defaults are source-code constants. They are appropriate for non-sensitive fallback behavior; do not put credentials in a default. -For richer validation (regex, transforms, branded types), drop in any [Standard Schema](https://standardschema.dev) library — valibot, zod, or arktype work out of the box, and you can mix them with the built-ins: +## Local development -```ts -import * as v from 'valibot'; +Put local values in a single root `.env` file: -export default defineEnv({ - STRIPE_KEY: string(), // built-in - WEBHOOK_URL: v.pipe(v.string(), v.url(), v.endsWith('/webhook')), // valibot -}); +```ini +DATABASE_URL=postgres://localhost/my-app +STRIPE_KEY=sk_test_example +VITE_API_ORIGIN=http://localhost:5173 ``` -### Client vs server - -Keys matching Vite's `envPrefix` (normally `VITE_`) are available in client code. Other keys are server-only. If a client module references a server-only key, the build stops and points to the source location. The runtime also rejects that access. - -If your project sets a custom `envPrefix` (e.g. `'PUBLIC_'` or an array like `['PUBLIC_', 'NEXT_PUBLIC_']`) in `vite.config.ts`, the build guard honours it automatically. Schema keys that match any configured prefix pass the client check. - -### Build-time constant folding +`.env` should be gitignored. Void rejects legacy `.env.local`, `.env.development`, `.env.production`, `.env.example`, other `.env.*` files, and Wrangler `.dev.vars*` files so there is no hidden precedence order. -During a production build, Void replaces static reads of public environment variables with their values. This lets the bundler remove branches that can't run, as it does with Vite's `import.meta.env`. +Run the local check at any time: -```ts -// source -import { env } from 'void/env'; -if (env.MODE === 'production') { - initAnalytics(); -} - -// production client bundle (after folding + DCE) -initAnalytics(); +```sh +void env check ``` -Rules: - -- **Client bundles only.** Server / SSR / worker code keeps the full runtime proxy so secrets, runtime-injected vars, and HMR keep working. -- **Prefix-gated.** Only keys matching Vite's `envPrefix` (default `VITE_`, honours custom string or array config) are ever folded — server-only keys never appear as literals in client output. -- **Static access only.** `env.FOO` and `env["FOO"]` get folded. Dynamic access (`env[key]`), destructuring, and reassignment fall back to the runtime proxy. -- **Values come from** the same source Vite already uses for `import.meta.env`: `.env*` files (respecting `envDir`) plus schema `.default(x)` values for missing keys. Unknown keys are left untouched and the proxy serves them at runtime. -- **Build-only.** Dev keeps going through the proxy so editing `.env.local` takes effect without a full rebuild. - This optimization runs automatically. - -### Vite `define` interop - -Vite's `define` replaces expressions at build time without going through `env.ts` validation. If you use both for the same name, compiled client code can see a different value from server code. Void warns when it finds this overlap. - -### `envDir` - -The Vite plugin respects `envDir` when loading dotenv files for development. The deploy CLI reads production dotenv files from the project root, so keep them there even if local development uses another directory. +Local database, migration, auth, and dev-server tooling all read the same `.env` file. Shell values override it. -### Imports inside `env.ts` +## Production server values -`env.ts` is loaded outside Vite's plugin pipeline — the dev plugin and the deploy CLI both read it through Node's native `import()`. That means only **relative imports** (`./shared/env-keys`) and **bare package names** (`zod`, `valibot`) resolve. Known limitations: +Every schema key that is not client-prefixed is a server value. Set it remotely: -- **tsconfig path aliases are not resolved.** Writing `import { X } from "@/shared/env-keys"` or `import { Y } from "~/foo"` inside `env.ts` fails to load; Void detects this and re-throws with a hint pointing at the offending specifier. Use a relative import instead. -- **Custom Vite plugins don't run.** Anything that needs Vite's transform pipeline (SVG-as-component, GraphQL loaders, virtual modules, etc.) won't apply to `env.ts`. Keep this file to pure TS + schema declarations. - -In practice `env.ts` should only import schema helpers from `void/env` and — at most — a shared constants file via a relative path. - -## `.env` files - -Void follows Vite's dotenv file naming. Deployment treats portable configuration and local secrets differently: - -| File | Loaded in dev | Shipped by `void deploy` | Shipped by `--platform cloudflare` | -| ----------------------- | ------------- | ------------------------ | ---------------------------------- | -| `.env` | yes | yes (`plain_text`) | yes (worker `vars`) | -| `.env.local` | yes | no | no | -| `.env.production` | yes | yes (`plain_text`) | yes (worker `vars`) | -| `.env.production.local` | yes | no | `.secret()` keys only (encrypted) | - -`.local` files are gitignored by convention — use them for secrets you don't want in source control. - -For direct Cloudflare deploys, mark sensitive keys with `.secret()`. Void reads their production values, uploads them as encrypted secrets, and removes them from plaintext Worker vars. Shell values take precedence. - -Unmarked values in `.env` and `.env.production` remain plaintext. Unmarked local and shell values aren't deployed. See [Cloudflare deployment](/integrations/cloudflare#databases-and-secrets). - -### Dotenv variable expansion - -Values can reference other keys defined in the same (or earlier-precedence) `.env` file using `${VAR}` or `$VAR`: - -```ini -# .env -BASE_URL=https://api.example.com -API_URL=${BASE_URL}/v1 # → "https://api.example.com/v1" -BUILD=$BASE_URL/build.json # bare form also works -LITERAL=\$NOT_EXPANDED # → "$NOT_EXPANDED" (backslash-escape) +```sh +void secret put DATABASE_URL +void secret put STRIPE_KEY ``` -Nested references resolve transitively (`A=${B}`, `B=${C}`, `C=value` → `A=value`). Cycles are detected and leave the raw literal (Void emits a warning rather than looping). Missing references expand to the empty string. - -Void resolves references against values in your dotenv files, not arbitrary shell variables. The typed `env` API, CLI validation, deploy, and setup use the same expansion rules. This keeps machine-specific shell values from appearing unexpectedly in generated configuration. +You can bulk upload an existing dotenv file during migration: -## Production secrets - -To store production credentials such as Stripe keys or OAuth secrets, upload them with the CLI: - -```bash -void secret put STRIPE_KEY # prompts for value -void secret put STRIPE_KEY < key.txt # from stdin -void secret sync .env.production # bulk upload from a dotenv file -void secret list -void secret delete STRIPE_KEY +```sh +void secret sync .env ``` -These commands follow the platform saved in `.void/project.json`: they use the hosted secret store for Void projects and Cloudflare's encrypted Worker secret APIs for direct Cloudflare projects. Secret values are never printed, and direct Cloudflare commands use the account and Worker name pinned in root `wrangler.jsonc`. - -Remote secrets count as "present" for deploy validation — `void deploy` checks both `.env*` and the remote secret list before uploading. - -On direct Cloudflare deploys, a built-in schema helper's `.secret()` modifier also enables automatic upload. Values can come from `.env`, `.env.production`, `.env.production.local`, or the deploy process environment. Void removes them from Worker vars and records them as required secrets. +`void secret put` and `void secret sync` validate the plaintext against `env.ts` before upload. The remote store encrypts the accepted value and only returns its name later, so subsequent deploy checks can prove presence but cannot re-read the plaintext. The worker validates the real value again at runtime. -Existing remote-only secrets are preserved, optional missing values stay absent, and deployment doesn't delete remote secrets. Warnings based on a key's name or value don't make it a secret; mark it explicitly. +`void deploy` never adds `.env` values to its manifest. It requires every required server key to exist remotely before upload. -For auth-enabled apps, both Void Cloud and `void deploy --platform cloudflare` manage `BETTER_AUTH_SECRET` automatically: they preserve an existing encrypted secret and generate one only when missing. +For direct Cloudflare deployment, select Cloudflare and use the same Void commands: -## Defaults - -Schema defaults from `defineEnv({ PORT: number().default(3000) })` flow into: - -- The typed `env` proxy: `env.PORT` returns `3000` when no value is set. -- Worker bindings (`c.env.PORT`, `process.env.PORT` on Node target): the - default value is injected as a stringified `var` in dev, in `void deploy` - manifests, and in prerender bindings, so any code path that reads the raw - env sees the same fallback. -- Client-exposed keys (`VITE_*` by default, or your configured `envPrefix`): - defaults are added to Vite's client env, so browser code reading - `env.VITE_APP_TITLE` sees the same fallback. - -User-provided `.env` / shell values always win — defaults only fill gaps. - -## Validation behavior - -| Phase | Behavior | -| -------------------- | ---------------------------------------------------------------------------------------------------------------- | -| Dev server start | Warns about missing/invalid keys (does not block). | -| First runtime access | Throws `EnvValidationError` with the failing key and reason. | -| `void env check` | Validates `.env` + `.env.production` (and optionally remote secrets with `--remote`); exits non-zero on failure. | -| `void deploy` | Hard-errors before upload if any required key is missing from the union of `.env*` and remote secrets. | - -Use `void env check --remote` in CI before deploying — it catches missing prod secrets without running a full build. - -## Manual type regeneration - -Types are auto-generated to `.void/env.d.ts` on dev server start and whenever `env.ts` changes. To regenerate manually (e.g. after a fresh clone): - -```bash -void env types +```sh +void connect --platform cloudflare +void secret put DATABASE_URL +void secret put STRIPE_KEY +void deploy --platform cloudflare ``` -## Scaffolding `.env.example` +Void stores the values as encrypted Worker secrets, emits the required server names through `secrets.required`, and refuses to deploy a schema-declared server key as a plaintext Worker `vars` entry. -Generate (or refresh) a `.env.example` from your `env.ts` schema: +## Client values -```bash -void env example # creates or refreshes the void-managed block -void env example --force # silences the "appended block" notice (use in CI) -``` +Keys beginning with `VITE_` are client values. This boundary is fixed, so the CLI, build, and secret commands always classify a name the same way even when a framework customizes Vite's own `envPrefix`. -`void env example` manages a marker-delimited block inside `.env.example` — anything outside the markers (custom CI tokens, build flags, comments) is preserved verbatim across refreshes. The block looks like this: +Client values are embedded in browser JavaScript and are never secrets. Supply them through the build shell or use a schema default: -```ini -# >>> void env: managed block — do not edit between markers <<< -# Run `void env example` to refresh. -# required -STRIPE_KEY= -# with defaults -PORT=3000 -# >>> end void env <<< +```sh +VITE_API_ORIGIN=https://api.example.com void deploy ``` -On first run, Void writes a fresh `.env.example` containing only the marker block. On subsequent runs, only the lines between the markers are replaced. If the file already exists with no markers, the block is appended at the end and Void prints a one-line notice (suppressed by `--force`). - -The managed block is grouped into `required`, `with defaults`, and `optional` sections. Keys defined via `oneOf([...])` get a leading `# enum: A | B | C` comment listing the valid values, and keys with a `.default(x)` are prefilled. Commit the `.env.example` — it's the single source of truth for teammates and coding agents setting up the project. - -## Secret redaction - -Env validation errors, dev-server warnings, and CLI reports automatically replace secret-looking values with `` before they surface. This keeps tokens out of Discord screenshots, GitHub issues, and CI logs when you paste an `EnvValidationError` stack. - -Void decides which values to redact in this order: - -1. **Explicit override.** The built-in helpers support `.secret()` and `.public()` modifiers, and they win over every heuristic: - - ```ts - export default defineEnv({ - STRIPE_KEY: string().secret(), // always redacted - PUBLIC_KEY: string().public(), // never redacted (opts out of the KEY heuristic) - PORT: number(), - }); - ``` - - Third-party Standard Schema validators (valibot, zod, arktype) can't carry this flag — they fall back to the heuristics below. - -2. **Key-name heuristic.** Values for keys matching `/KEY|TOKEN|SECRET|PASSWORD|PASS(WD)?|CREDENTIAL|PRIVATE|AUTH|BEARER|APIKEY|DSN/i` are redacted by default. - -3. **Value-content heuristic.** Even on neutral keys, values that start with `sk_` / `pk_` / `ghp_` / `xoxb-` / `AKIA` or look like a ≥24-char high-entropy string are redacted. This catches wrong-file typos like `PORT=sk_live_abc123`, where the key is innocent but the value is a Stripe key. - -The **key name itself is never masked** — you need it to locate the offending entry. - -For local debugging, set `VOID_ENV_UNMASK=1` to show raw values in error output. Void prints a notice when this is enabled. Unset it before sharing logs. - -## Using `env.ts` with a meta-framework - -All supported frameworks get environment types, validation, checks for server secrets reaching client code, and CLI commands. Public variables are also inlined in production client builds. - -| Framework | `env.ts` typegen | Client leak guard | Constant folding | `env` on server | `env` on client | `void env check` + deploy gate | -| ----------------- | :--------------: | :---------------: | :--------------: | :-----------------: | :---------------------: | :----------------------------: | -| Void (Pages mode) | yes | yes | yes | yes | yes (`VITE_*`) | yes | -| TanStack Start | yes | yes | yes | yes | yes (`VITE_*`) | yes | -| React Router v7 | yes | yes | yes | yes (in `loader()`) | yes (`VITE_*`) | yes | -| SvelteKit | yes | yes | yes | yes (runtime proxy) | yes (`VITE_*`) | yes | -| Nuxt | yes | yes | yes | yes | yes (`VITE_*`) | yes | -| Analog | yes | yes | yes | yes | yes (`VITE_*`) | yes | -| Astro | yes | yes | yes | yes | yes (`envPrefix`-gated) | yes | - -What you always get, regardless of framework: - -- `void env check [--remote]` validates `.env*` files + remote secret names against the schema. -- `void deploy` hard-fails before upload when any required key is missing. -- `void env example` generates the marker-delimited `.env.example` block. -- `void init` scaffolds `env.ts` from existing dotenv files. -- Typed `.void/env.d.ts` so `env.X` autocompletes in editors. -- `void:env-client-guard` fails the build with a file:line:col error when a server-only key is referenced from a client module. -- Build-time constant folding for `envPrefix`-matched reads in the client bundle. - -In Nuxt, Analog, and Astro, `import { env } from "void/env"` reads Worker bindings at request time. You can also keep using your framework's native environment API where it suits your app. +```ts +export default defineEnv({ + VITE_API_ORIGIN: url().default('https://api.example.com'), +}); +``` -## Migration from untyped `c.env` +Builds ignore client values from `.env`; this prevents a developer's local value from silently becoming production configuration. -If you don't have an `env.ts`, `c.env.X` continues to work as `unknown` — no breaking change. To opt in, create `env.ts`, declare your keys, and the existing call sites become typed automatically. The recommended access pattern for new code is `import { env } from "void/env"`. +## Validation and redaction -## How it compares +```sh +void env check # validate .env + shell for local development +void env check --remote # validate build-time client values and remote server names +void env types # regenerate .void/env.d.ts +``` -`void/env` is part of Void rather than a standalone environment library. It connects your schema to local development, the build, and deployment. The table below compares that integration with other approaches. +Invalid-value diagnostics redact the supplied value by default. Set `VOID_ENV_UNMASK=1` only for deliberate local debugging; Void prints a notice when redaction is disabled. -| Feature | Void `env.ts` | `@t3-oss/env` | Astro `astro:env` | SvelteKit `$env` | Vite `import.meta.env` | Nuxt `runtimeConfig` | `dotenv` + `zod` (DIY) | -| ----------------------------------------------- | :-----------: | :-----------: | :---------------: | :--------------: | :--------------------: | :------------------: | :--------------------: | -| Single schema file | yes | yes | yes (config) | no | no | partial | yes | -| Runtime validation | yes | yes | yes | no | no | no | yes | -| Auto-generated types | yes | yes | yes | yes | manual `env.d.ts` | yes (aug.) | manual | -| Bring-your-own validator (Standard Schema) | yes | yes | no | no | no | no | yes | -| Built-in helpers without installing a validator | yes | no | yes | — | — | — | no | -| Build-time guard against leaking server env | yes | yes | yes | yes | prefix only | prefix only | no | -| Custom `envPrefix` honoured | yes | yes | n/a | n/a | yes | n/a | n/a | -| Schema defaults flow into runtime bindings | yes | partial | yes | no | no | partial | manual | -| Async validators | yes | no | no | no | no | no | yes | -| `.env.example` scaffolding from schema | yes | no | no | no | no | no | no | -| Deploy-time check (local + remote secrets) | yes | no | no | no | no | no | no | -| Automatic secret redaction in validation errors | yes | no | no | no | no | no | no | -| Cloudflare secret store integration | yes | no | no | no | no | no | no | -| Zero-config (auto-discovered, no plugin wiring) | yes | no | yes | yes | yes | yes | no | -| Schema scaffolded from existing `.env` files | yes | no | no | no | no | no | no | -| `${VAR}` expansion, no `process.env` leakage | yes | no | no | no | partial (leaks) | no | needs plugin | +There are no `.secret()` or `.public()` schema modifiers. Those modifiers controlled diagnostic masking, not storage, and made a server value look encrypted when it could still be deployed as plaintext. Storage now follows the server/client boundary, and diagnostics follow one redaction rule. -### What the integration provides +## Migrating from the multi-file model -- **A shared schema.** `env.ts` supplies the types and validation used by the plugin, Worker, CLI, and generated declarations. -- **Built-in helpers and custom validators.** Start with `string()`, `number()`, or the other helpers. Use a Standard Schema validator when a field needs more detailed rules. -- **Checks before deployment.** Void checks required keys against dotenv values and remote secret names before uploading. -- **Client and server separation.** Client references to server-only keys stop the build with a source location. Public values can be inlined without embedding server secrets. -- **Consistent defaults.** Defaults fill missing values in the typed API, Worker vars, deploy manifest, and prerender bindings. -- **Generated examples.** `void env example` updates a marked section of `.env.example` while preserving your team's comments and other keys. -- **Async validation.** Validators can return promises, for example to resolve a secret reference at startup. -- **Setup from existing files.** `void init` can create an initial schema from your dotenv files for you to review. +See [Environment migration](/guide/env-migration) for the breaking-change checklist. diff --git a/docs/guide/jobs.md b/docs/guide/jobs.md index fe1250c02..880544e45 100644 --- a/docs/guide/jobs.md +++ b/docs/guide/jobs.md @@ -82,7 +82,7 @@ curl -X POST http://localhost:5173/__void/scheduled \ -d '{"cron":"0 * * * *","scheduledTime":'"$(date +%s000)"'}' ``` -If you set `__VOID_PROXY_TOKEN` in `.dev.vars`, that explicit token takes precedence and the printed curl command uses `x-void-internal: ` instead. +If you set `__VOID_PROXY_TOKEN` in `.env`, that explicit token takes precedence and the printed curl command uses `x-void-internal: ` instead. The endpoint works in native Void apps and supported frameworks. Framework jobs run in the adapter's development runtime and use the bindings it provides. diff --git a/docs/guide/queues.md b/docs/guide/queues.md index 11263acea..8b6683430 100644 --- a/docs/guide/queues.md +++ b/docs/guide/queues.md @@ -139,6 +139,6 @@ curl -X POST http://localhost:5173/__void/queue \ -d '{"queue":"my-queue","messages":[{"id":"1","timestamp":'"$(date +%s000)"',"body":{"hello":"world"},"attempts":1}]}' ``` -If you set `__VOID_PROXY_TOKEN` in `.dev.vars`, that explicit token takes precedence and the printed curl command uses `x-void-internal: ` instead. +If you set `__VOID_PROXY_TOKEN` in `.env`, that explicit token takes precedence and the printed curl command uses `x-void-internal: ` instead. In supported meta-frameworks, use `/__void/queue` to test a consumer manually. It runs inside the framework adapter's development runtime with that adapter's bindings. Sending to a queue binding doesn't automatically invoke the consumer in this mode. diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index 36f7491df..11206294b 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -121,7 +121,7 @@ You can set non-binding Cloudflare fields like `compatibility_date` and `compati } ``` -For environment variables, use `.env` files for local values and secrets. Void automatically loads `.env` files through Vite's `loadEnv` and merges them into the worker's `vars` bindings: +For environment variables, declare the schema in `env.ts` and put local values in the single root `.env` file. Void loads `.env` into local development bindings only; it never becomes production configuration: ```bash # .env @@ -130,7 +130,7 @@ API_URL=https://api.example.com Binding arrays such as `d1_databases`, `kv_namespaces`, and `r2_buckets` are not allowed in the `worker` field because Void manages bindings for you. If you need custom bindings with real resource IDs, add a `wrangler.jsonc` to the project root instead. See [Cloudflare config merging](#cloudflare-config-merging) for details. -For non-secret plain-text defaults, you can also set `worker.vars` in `void.json`. Values from `.env` files override `worker.vars`. +For non-secret plain-text defaults, you can also set `worker.vars` in `void.json`. Local `.env` values override `worker.vars` during development. Production builds reject any `worker.vars` name that is declared as a server key in `env.ts`; store those values remotely with `void secret put` instead. ## Cloudflare config merging @@ -260,7 +260,7 @@ Void reads your app's configuration and source, then: 1. Checks the app type, selected account, and Cloudflare credentials. 2. Creates missing resources or reuses resources already in your account. 3. Builds the app and checks the generated Worker config, secrets, and migration history. -4. Preserves or creates the auth secret, applies pending migrations, and uploads keys marked `.secret()` as encrypted Worker secrets. +4. Preserves or creates the auth secret, verifies required server keys in encrypted remote storage, and applies pending migrations. 5. Uploads a Worker Version and checks that it is ready. 6. Sends traffic to the new version and applies routes, custom domains, cron schedules, and queue consumers. @@ -298,9 +298,7 @@ For auth-enabled apps, run `void db generate` and commit the migrations. Void in Custom D1 layouts are supported through `migrations_dir`, `migrations_table`, and `migrations_pattern`. The files Cloudflare will apply must match the files Void validated, including their content and numeric order. Missing, extra, or reordered migrations stop deployment. -Mark sensitive values with [`.secret()`](../guide/env-vars.md). Void removes them from plaintext Worker vars and uploads them as encrypted secrets. Values can come from `.env`, `.env.production`, `.env.production.local`, or the deploy environment; the environment takes precedence. Existing remote secrets are preserved, optional missing secrets are skipped, and deploy doesn't delete remote secrets. - -Unmarked values from `.env` and `.env.production` are deployed as plaintext vars. Unmarked local and shell values aren't deployed. Warnings about likely secrets don't encrypt them for you; use `.secret()`. +Root `.env` is local-only and is never emitted into Worker vars. Every non-client key declared in `env.ts` is a server value: store it with `void secret put `. Void emits required server names through `secrets.required`, rejects plaintext Worker vars with those names, and preserves existing remote secrets. For apps using auth, Void creates a random 32-byte `BETTER_AUTH_SECRET` only when the remote secret is missing. It reuses that value on later deploys. Both auth secrets and schema-marked secrets can be set up on a new Worker's draft before its first upload. @@ -338,6 +336,8 @@ Worker apps support D1, KV, R2, Queues, typed state, PostgreSQL and MySQL throug WebSocket routes and typed state use SQLite-backed Durable Objects. Commit the bindings and `new_sqlite_classes` migration history in `wrangler.jsonc`; don't delete or reorder deployed migration steps. Older hosted classes using `new_classes` keep their existing storage. +Bare `vite build && wrangler deploy` does not provision schema-declared secrets. Use `wrangler secret bulk` / `wrangler secret put` and list every inherited name under `secrets.required` yourself. + ISR uses the shared cache protocol. Entries are scoped to a deployment and hostname, and `routing.revalidateQueryAllowlist` adds bounded query variants. `revalidate()` purges matching entries and variants from KV and the local edge cache. See [ISR](#isr-self-host) below for cache behavior across regions. ### Managing the deployed app diff --git a/docs/integrations/frameworks/astro.md b/docs/integrations/frameworks/astro.md index f073b02f8..c2d189f96 100644 --- a/docs/integrations/frameworks/astro.md +++ b/docs/integrations/frameworks/astro.md @@ -164,7 +164,7 @@ export default defineQueue<{ to: string; subject: string }>(async (batch) => { Declare environment variables in `env.ts`, then read them with `import { env } from "void/env"`. Void supplies types, checks values during build and deploy, and stops the build if client code references a server-only key. See [Environment Variables](../../guide/env-vars.md). -Astro-specific note: the client prefix is `PUBLIC_*` (Astro's default `envPrefix`), not `VITE_*` — name client-exposed keys accordingly and the leak guard + constant folding honour the prefix automatically. +Void always uses `VITE_*` for client-exposed schema keys, including in Astro projects. Astro's separate `PUBLIC_*` convention still applies to direct `import.meta.env` access, but it does not change the `void/env` server/client boundary. `void/env` replaces [`astro:env`](https://docs.astro.build/en/guides/environment-variables/) and `Astro.locals.runtime.env` for env-var access. Keep `Astro.locals.runtime.env` around when you need raw binding access (D1, KV, R2, etc.). diff --git a/docs/reference/api.md b/docs/reference/api.md index 9bb89349b..5fa919e2e 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -905,10 +905,10 @@ Register an env schema and return the typed [`env`](#env) proxy. Void auto-disco import { defineEnv, string, number, oneOf, url } from 'void/env'; export default defineEnv({ - STRIPE_KEY: string().secret(), + STRIPE_KEY: string(), PORT: number().default(3000), NODE_ENV: oneOf(['development', 'production']), - VITE_PUBLIC_URL: url().public(), + VITE_PUBLIC_URL: url(), }); ``` @@ -924,7 +924,9 @@ const port = env.PORT; ### Schema helpers -`void/env` includes Standard Schema-compatible helpers: `string()`, `number()`, `boolean()`, `url()`, `email()`, `oneOf([...])`, and `json()`. Each helper supports `.optional()`, `.default(value)`, `.secret()`, and `.public()`. Besides forcing redaction, `.secret()` tells `void deploy --platform cloudflare` to remove the value from plaintext Worker vars and upload it through Cloudflare's encrypted secret mechanism. This automation applies only to built-in helpers and the Void CLI deploy path. +`void/env` includes Standard Schema-compatible helpers: `string()`, `number()`, `boolean()`, `url()`, `email()`, `oneOf([...])`, and `json()`. Each helper supports `.optional()` and `.default(value)`. + +Non-client keys are server values and use remote secret storage in production. `VITE_*` keys are public build-time client values. Invalid input is redacted uniformly; storage is not selected with schema modifiers. ### Types diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9b9f533fb..b45629693 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -25,10 +25,9 @@ Use this page as a command reference. If you are setting up a project for the fi | `void db studio` | Open Drizzle Studio (--remote for a deployed external database) | | `void secret put ` | Set a production secret | | `void secret list` | List production secrets | -| `void secret sync .env.local` | Bulk upload secrets from dotenv file | +| `void secret sync .env` | Bulk upload and validate secrets from a dotenv file | | `void env check [--remote]` | Validate env.ts schema | | `void env types` | Regenerate .void/env.d.ts from env.ts | -| `void env example` | Refresh the void-managed block in .env.example | | `void auth login` | Authenticate with Void | | `void cloudflare login` | Authenticate with Cloudflare through Void | | `void platform install` | Install a company Void platform in Cloudflare | @@ -40,6 +39,7 @@ Use this page as a command reference. If you are setting up a project for the fi | `void project cancel` | Cancel an active deployment | | `void project purge-cache` | Purge all cached pages | | `void build logs` | Stream, tail, or download build logs | +| `void mcp` | Start the Void MCP server | | `void init` | Setup wizard for new or existing projects | ## Binary Invocation @@ -97,7 +97,7 @@ After that, the full interactive flow walks through: 5. **Demo code:** for existing non-Pages projects, optionally scaffolds a `db/migrations/` directory plus an API route and typed fetch example. 6. **Deployment platform:** asks where `void deploy` should send the app: Cloudflare (the default), Void, or Skip deployment setup. The choice is stored as `platform` in `.void/project.json`. Choosing Cloudflare creates or augments `wrangler.jsonc`, checks the Cloudflare session through Void's bundled tooling, opens secure browser sign-in when needed, and writes the selected account as `account_id` (automatically when only one account is available). 7. **GitHub Actions:** optionally creates `.github/workflows/void-deploy.yml` for the selected target. Cloudflare workflows run `void deploy --platform cloudflare` with `CLOUDFLARE_API_TOKEN` and pass the optional `DATABASE_URL` secret needed by PostgreSQL/MySQL apps. Void workflows use the selected platform's API URL and are offered only when its discovery document advertises GitHub Actions support. -8. **`env.ts` scaffold:** if the project has no `env.ts` but has `.env` / `.env.example` / `.env.local` / `.env.development*` files on disk, generates an `env.ts` pre-populated with their keys. Values get conservative type inference (`boolean`/`url`/`number`/`string`) — the file carries a banner nudging you to tighten anything the heuristic got wrong. +8. **`env.ts` scaffold:** if the project has no `env.ts` but has a root `.env`, generates an `env.ts` pre-populated with its keys. Values get conservative type inference (`boolean`/`url`/`number`/`string`) — the file carries a banner nudging you to tighten anything the heuristic got wrong. 9. **Void project setup:** when Void is selected, optionally logs you in, lets you select or create a project, and adds the link to `.void/project.json` so your first deploy can just be `void deploy`. If Cloudflare sign-in is declined or does not complete, initialization still finishes with the configuration in place. Rerun `void init`, or use `void cloudflare login`, when you are ready. @@ -667,6 +667,8 @@ The token needs Workers Scripts: Edit, read access to bound resources, and edit Sandbox apps need Docker, [Workers Paid](https://dash.cloudflare.com/?to=/:account/workers/plans), and Containers access. API tokens need Account / Containers: Edit and Account / Cloudchamber: Edit. Void checks access before provisioning or building; apps without Sandbox skip that check. +For worker-bearing apps, it settles the app class, pins the account and checks auth, automatically provisions missing resources, **builds**, validates the emitted artifact and migrations, rejects schema-declared server keys in plaintext Worker vars, preserves or creates the auth secret, and applies remote D1 migrations or checked-in PostgreSQL/MySQL migrations through the production `DATABASE_URL`. It then uploads an immutable Worker Version, records the exact migration filenames in its annotation, probes that version directly, activates it at 100%, and synchronizes routes, custom domains, cron triggers, and queue consumers. Versions without preview URLs are staged at 0% and probed through the production workers.dev URL with a version override. The same zero-percent path is used when an account-wide Access default-deny policy blocks the generated preview alias but admits the stable Worker hostname, so failed readiness never sends the new version traffic. A failed trigger sync restores the previous version. PostgreSQL migrations are transactional; MySQL DDL is not. External database URLs are never written to the Worker config. + Void provisions inferred resources, builds and validates the app, applies migrations, uploads encrypted secrets, and checks the uploaded Worker Version before sending it traffic. Static, hybrid, and SSR output from supported frameworks is also supported. A brand-new Worker may need one ordinary deployment before the Versions API can be used. If Cloudflare Access protects readiness URLs, supply an allowed `CF_ACCESS_CLIENT_ID` and `CF_ACCESS_CLIENT_SECRET` pair, or a short-lived local `CF_ACCESS_TOKEN`. These credentials are used only for matching HTTPS readiness requests. Versions without accessible previews can be checked at 0% traffic through the stable hostname. @@ -675,7 +677,9 @@ Secrets and migrations are validated after the build, so a failed check may leav Provisioning reuses known resource IDs and writes newly resolved IDs into `wrangler.jsonc`, preserving comments but possibly changing indentation. Commit that file for other machines and CI. Run the first deploy from one machine at a time because provisioning locks are local. The old `--provision` flag is accepted but no longer needed. -Keys marked `.secret()` become encrypted Worker secrets; existing remote secrets are preserved. Void also preserves or creates `BETTER_AUTH_SECRET` for auth apps. Custom D1 migration layouts are accepted only when Cloudflare's file set, content, and order match the validated migrations. +`.env` is local-only and isn't emitted into Worker vars. Store every schema-declared server key with `void secret put `; Void emits required names through `secrets.required` and blocks plaintext server vars. On a new Worker, set required secrets before retrying if the initial remote check reports them missing. Custom D1 layouts are accepted only when Cloudflare's exact file set, bytes, and numeric order match the migrations Void validated. Direct deploy and operational commands use the top-level root config and reject named environments and alternate config-path overrides. + +Existing remote secrets are preserved. Void also preserves or creates `BETTER_AUTH_SECRET` for auth apps. See the [Cloudflare guide](../integrations/cloudflare.md#deploy-to-your-own-cloudflare-account) for the complete deployment sequence, first-deploy exceptions, secret precedence, and recovery behavior. @@ -683,7 +687,7 @@ See the [Cloudflare guide](../integrations/cloudflare.md#deploy-to-your-own-clou ### `void db push` -Apply your Drizzle schema directly to the development database without creating migration files. D1 updates the local database; PostgreSQL and MySQL use `DATABASE_URL` from `.env.local`. +Apply your Drizzle schema directly to the development database without creating migration files. D1 updates the local database; PostgreSQL and MySQL use `DATABASE_URL` from `.env`. Use this for quick schema iteration while prototyping. Before deploying, generate and review migration files with `void db generate`. @@ -773,7 +777,7 @@ NEON_API_KEY=... void db connect --provider neon --name my-app void db connect --provider @acme/void-db-provider --region region-id ``` -The command saves `DATABASE_URL` in `.env.local`. When authenticated with a linked Void project, it also updates the encrypted deployment URL; pass `--local-only` to skip that sync. `neon` is built in. Other adapters are project dependencies or local modules exporting a `DatabaseProviderAdapter` from `void/database-provider`. +The command saves `DATABASE_URL` in `.env`. When authenticated with a linked Void project, it also updates the encrypted deployment URL; pass `--local-only` to skip that sync. `neon` is built in. Other adapters are project dependencies or local modules exporting a `DatabaseProviderAdapter` from `void/database-provider`. Provider-created credentials are never printed. For direct Cloudflare deploys, configure the same URL as a protected `DATABASE_URL` in the shell or CI environment that runs deploy. @@ -944,11 +948,11 @@ void secret sync [--project ] Bulk upload secrets from a dotenv file. Each `KEY=value` line in the file is uploaded as a secret. ```sh -void secret sync .env.local # uploads secrets from .env.local -void secret sync .env.production # uploads a specific file +void secret sync .env # validates and uploads declared server values ``` Direct Cloudflare targets use Cloudflare's bulk-secret API. Existing remote secrets absent from the file are not pruned. +Every entry must be a non-client key declared in `env.ts`, and its plaintext value must pass the schema before upload. ### `void secret delete` @@ -966,7 +970,7 @@ Secret commands use the platform saved in `.void/project.json`. Hosted project r void env check [--remote] ``` -Validate `env.ts` against `.env` + `.env.production` (and, with `--remote`, also against the remote secret list). Exits non-zero if any required key is missing or invalid. Use in CI before deploy. +Without `--remote`, validate `.env` plus the shell for local development. With `--remote`, validate build-shell client values and the remote server-secret names. Exits non-zero if a required key is missing or a readable value is invalid. ### `void env types` @@ -976,40 +980,6 @@ void env types Regenerate `.void/env.d.ts` from `env.ts`. Normally happens automatically on dev server start and HMR; use this command after a fresh clone or to refresh stale types in non-dev contexts. -### `void env example` - -``` -void env example [--force] -``` - -Generate or refresh a marker-delimited "void env" block inside `.env.example` at the project root, sourced from the registered `env.ts` schema. The block is grouped into `required`, `with defaults`, and `optional` sections, with enum members emitted as inline comments. Prefilled values are used for keys with a `.default(...)`. - -The command never overwrites the whole file — anything above or below the markers (custom CI tokens, build flags, etc.) is preserved verbatim: - -| State of `.env.example` | Behavior | -| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| File doesn't exist | Writes a fresh file containing only the marker block. | -| Exists, contains both markers | Replaces only the lines between (and including) the markers; everything else is preserved. | -| Exists, no markers | Appends the block at the end (one blank line separator) and prints `appended void env block to existing .env.example`. | -| Exists, only one of the two markers present | Hard error — fix the file (restore the missing marker or delete the file) and rerun. | - -Pass `--force` to suppress the "appended block" notice for scripted runs. - -Example output: - -```ini -# >>> void env: managed block — do not edit between markers <<< -# Run `void env example` to refresh. -# required -STRIPE_KEY= -# enum: development | production -NODE_ENV= - -# with defaults -PORT=3000 -# >>> end void env <<< -``` - ::: tip Deploy validation `void deploy` runs the same schema validation automatically (with remote secrets) and refuses to upload if any required key is missing — no need to call `env check` separately when deploying. ::: diff --git a/docs/reference/config.md b/docs/reference/config.md index 92748af89..8ed2e0489 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -53,7 +53,7 @@ When set, those conventions move under the configured directory: { "sourceDir": "src" } ``` -With that config, Void reads `src/pages`, `src/routes`, `src/db/schema.ts`, `src/db/migrations`, `src/auth.ts`, and `src/env.ts`. Project files such as `void.json`, `vite.config.ts`, `package.json`, `tsconfig.json`, `wrangler.json`, `public/`, and `.env*` stay at the project root. Void does not scan both locations; if source conventions exist in both places during dev/build, remove one copy so the active source tree is unambiguous. +With that config, Void reads `src/pages`, `src/routes`, `src/db/schema.ts`, `src/db/migrations`, `src/auth.ts`, and `src/env.ts`. Project files such as `void.json`, `vite.config.ts`, `package.json`, `tsconfig.json`, `wrangler.json`, `public/`, and the local-only `.env` stay at the project root. Void does not scan both locations; if source conventions exist in both places during dev/build, remove one copy so the active source tree is unambiguous. ### `auth` @@ -127,7 +127,7 @@ Database backend. Omit for D1/SQLite (default). Set to `"pg"` or `"mysql"` for a When using an external database: -- Add `DATABASE_URL` to `.env.local` for local development +- Add `DATABASE_URL` to `.env` for local development - Import schema helpers from `void/schema-pg` or `void/schema-mysql` - `void/db` connects via Hyperdrive in production, direct connection locally - `void gen model` generates dialect-appropriate table and column builders @@ -269,7 +269,7 @@ Curated Cloudflare Workers configuration. Cloudflare-targeted apps require an ex } ``` -`worker.vars` values must be strings. They are merged into Worker bindings before `.env` files are loaded, so project `.env` values override `worker.vars` for local dev/build. Do not put secrets here; use `env.ts` plus `void secret put` for production secrets. +`worker.vars` values must be strings. Root `.env` values override them during local development only. Production builds do not load `.env`, and reject any `worker.vars` name declared as a server key in `env.ts`. Use `void secret put` for production server values. `worker.limits.cpu_ms` caps Workers CPU time per request, in milliseconds. It must be an integer between 1 and 300000 (Cloudflare's hard maximum, 5 minutes). A deploy that requests more than your account plan's ceiling fails with an error naming both the requested value and the plan maximum. Only a rollback clamps: rolling back to a deployment whose configured limit now exceeds your plan applies the plan ceiling instead of failing. If your plan changes so that a previously valid value now exceeds the ceiling, deploys keep failing until you lower `cpu_ms` in `void.json`. On the direct Cloudflare path, the value is written into the generated `dist/ssr/wrangler.json` as `limits.cpu_ms` and enforced by Cloudflare directly. The Void plan ceiling does not apply there, but your Cloudflare account's own CPU-time allowance still does: the Workers Free plan caps CPU at 10 ms per request, and the Workers Paid plan allows up to 300000 ms. A value above what your Cloudflare account permits is constrained or rejected by Cloudflare, not by Void. diff --git a/docs/reference/structure.md b/docs/reference/structure.md index d187edc49..04cf56c95 100644 --- a/docs/reference/structure.md +++ b/docs/reference/structure.md @@ -164,14 +164,12 @@ Generate or refresh it with `void prepare`, or let `vite dev` / `vite build` pop ## Config Files -| File | Purpose | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `vite.config.ts` | Vite configuration. Must include `voidPlugin()`. | -| `void.json` | Optional Void config for [routing](/reference/config#routing), [inference](/reference/config#inference), and [worker settings](/reference/config#worker) | -| `tsconfig.json` | TypeScript config. Extend `.void/tsconfig.json` for auto-generated types; run `void init --tsconfig` when an existing config already uses `extends`. | -| `.env` | Public environment variables (committed) | -| `.env.local` | Secret variables for local dev (gitignored) | -| `.env.production` | Public production variables (committed) | -| `.env.production.local` | Production secrets for local testing (gitignored) | - -See [Environment Variables](/guide/env-vars) for the full env file loading order. +| File | Purpose | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `vite.config.ts` | Vite configuration. Must include `voidPlugin()`. | +| `void.json` | Optional Void config for [routing](/reference/config#routing), [inference](/reference/config#inference), and [worker settings](/reference/config#worker) | +| `tsconfig.json` | TypeScript config. Extend `.void/tsconfig.json` for auto-generated types; run `void init --tsconfig` when an existing config already uses `extends`. | +| `.env` | Local development values only (gitignored; never deployed) | +| `env.ts` | Checked-in env names, types, defaults, and requiredness | + +See [Environment Variables](/guide/env-vars) for local, production-secret, and client build behavior. diff --git a/packages/void/README.md b/packages/void/README.md index e6760ec2d..0c563db38 100644 --- a/packages/void/README.md +++ b/packages/void/README.md @@ -31,7 +31,7 @@ npm install -D void If you run `void init` in an empty directory, the scaffold flow adds the matching Pages adapter and Vite+ by default. -Then run `npx void init`. In an empty directory, the full interactive flow can scaffold a starter and configure local project files. It then asks where to deploy: Cloudflare (the default) creates or augments `wrangler.jsonc`, handles secure browser sign-in through Void's bundled tooling, and saves the selected account; Void asks for the API URL of an explicitly connected company platform (`void connect ` or `void platform install`). Back up `~/.void/platforms/` after installing a company platform because its owner-only recovery material contains runtime keys that Cloudflare cannot return. The next app step is just `npx void deploy`. Auth-enabled deploys preserve an existing `BETTER_AUTH_SECRET` or create a persistent encrypted secret when missing on either platform. Direct Cloudflare deploys also promote env schema keys explicitly marked `.secret()` to encrypted Worker secrets, removing them from plaintext vars; values may come from production dotenv files or the deploy process environment. In an existing app, init configures Void in place by adding missing Vite scripts and creating or patching `vite.config.*` with `voidPlugin()`. +Then run `npx void init`. In an empty directory, the full interactive flow can scaffold a starter and configure local project files. It then asks where to deploy: Cloudflare (the default) creates or augments `wrangler.jsonc`, handles secure browser sign-in through Void's bundled tooling, and saves the selected account; Void asks for the API URL of an explicitly connected company platform (`void connect ` or `void platform install`). Back up `~/.void/platforms/` after installing a company platform because its owner-only recovery material contains runtime keys that Cloudflare cannot return. The next app step is just `npx void deploy`. Auth-enabled deploys preserve an existing `BETTER_AUTH_SECRET` or create a persistent encrypted secret when missing on either platform. Root `.env` is local-only; production server keys declared in `env.ts` use encrypted remote secret storage and are rejected as plaintext Worker vars. In an existing app, init configures Void in place by adding missing Vite scripts and creating or patching `vite.config.*` with `voidPlugin()`. ```ts // vite.config.ts diff --git a/packages/void/src/plugin-dev-triggers-hint.ts b/packages/void/src/plugin-dev-triggers-hint.ts index 5a4b7b8ec..e3e624189 100644 --- a/packages/void/src/plugin-dev-triggers-hint.ts +++ b/packages/void/src/plugin-dev-triggers-hint.ts @@ -55,15 +55,15 @@ export function devTriggersHintPlugin( console.log(); voidLog(`Detected ${summary.join(' and ')}. Trigger locally:`); // `__VOID_PROXY_TOKEN` is the platform's prod handshake header, mirrored - // locally via `.dev.vars` (which @cloudflare/vite-plugin populates into + // locally via `.env` (which Void loads into the dev worker), // worker bindings). Other env sources are intentionally NOT considered: // `.env`/process.env entries are stripped by `stripInternalEnvKeys` on // the Void path, and wrangler `[vars]` go through the CF plugin which // also surfaces them via the worker env — but that's a deploy-config - // path, not a dev-trigger ergonomics one. `.dev.vars` is the single + // path, not a dev-trigger ergonomics one. `.env` is the single // place a developer is expected to define this token, so it's the // single source we consult here to keep hint and dispatcher in sync. - const configuredProxyToken = parseDotenvFile(join(root, '.dev.vars'), { + const configuredProxyToken = parseDotenvFile(join(root, '.env'), { expand: false, }).__VOID_PROXY_TOKEN; const authHeader = configuredProxyToken diff --git a/packages/void/test/unit/plugin-dev-triggers-hint.test.ts b/packages/void/test/unit/plugin-dev-triggers-hint.test.ts index 3eb4bc773..b7228e5c2 100644 --- a/packages/void/test/unit/plugin-dev-triggers-hint.test.ts +++ b/packages/void/test/unit/plugin-dev-triggers-hint.test.ts @@ -100,14 +100,14 @@ describe('devTriggersHintPlugin', () => { expect(output).toContain('"queue":"emails"'); }); - it('prints x-void-internal when .dev.vars defines __VOID_PROXY_TOKEN', async () => { + it('prints x-void-internal when .env defines __VOID_PROXY_TOKEN', async () => { const root = createTempRoot(); mkdirSync(join(root, 'crons'), { recursive: true }); writeFileSync( join(root, 'crons', 'heartbeat.ts'), 'export const cron = "0 * * * *";\nexport default async () => {};\n', ); - writeFileSync(join(root, '.dev.vars'), '__VOID_PROXY_TOKEN=local-proxy-token\n'); + writeFileSync(join(root, '.env'), '__VOID_PROXY_TOKEN=local-proxy-token\n'); const log = vi.spyOn(console, 'log').mockImplementation(() => {}); await runConfigureServer( From 95cc099e3f44331e81f974cb5b8d2798dc133bae Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Fri, 11 Sep 2026 01:14:32 +0200 Subject: [PATCH 3/3] docs(env): update agent guidance --- packages/void/AGENTS.md | 5 +++-- packages/void/AGENT_PROMPT.md | 2 +- packages/void/getting-started-prompt.txt | 2 +- packages/void/skills/void/SKILL.md | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index e1f899e21..14105dcf2 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -105,8 +105,9 @@ src/ - **Direct activation invariant**: native Cloudflare deploy uploads a Worker Version without traffic, records the validated migration filenames in its annotation, probes that version directly, activates it at 100%, and only then synchronizes triggers. A trigger-sync failure restores the prior active version. Database migrations are forward-only; rollback warns before activating code whose recorded migration set is older or unknown. - **Direct Sandbox entitlement invariant**: `void/sandbox` is opt-in and is the only public Void feature that currently requires Workers Paid on the native Cloudflare path. When source inference or `void.json` enables it, deploy must verify Containers with a read-only account probe after authentication and before provisioning/building, then explain both the Workers Paid signup and Containers/Cloudchamber token permissions on failure. A non-Sandbox app must emit no Container and perform no Containers probe. - **Migration DB path**: Miniflare stores D1 data at a deterministic path computed from the `database_id` using HMAC-SHA256. The `getMiniflareDatabasePath()` function in `migrations/runner.ts` replicates this algorithm so migrations target `/v3/d1/miniflare-D1DatabaseObject/.sqlite` (for example `.void/v3/...` in default/class-A mode and `.wrangler/state/v3/...` in class-B/C framework mode). -- **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a dialect-aware Drizzle instance for D1, PostgreSQL, or MySQL. `@schema` is an auto-configured alias for `db/schema.ts`. Migrations live in `db/migrations/`. CLI commands: `void db push` (prototype), `void db generate` (create migration files), `void db migrate` (apply locally). When auth is enabled, `void db generate` runs codegen in production mode and adds `.void/better-auth-schema.ts` to Drizzle Kit's schema inputs. Direct Cloudflare deploy applies PostgreSQL or MySQL migrations from the shell-provided production `DATABASE_URL`; PostgreSQL is transactional, while MySQL DDL is not. `drizzle-orm` and `drizzle-kit` ship with void. -- **Database provisioning adapters**: `void db connect ` saves an external database URL to `.env.local` and syncs a linked Void project; `--local-only` skips the sync. `void db connect --provider neon` provisions through the built-in Neon adapter using `NEON_API_KEY`. Third-party packages or local modules can export a `DatabaseProviderAdapter` built with `void/database-provider`; runtime database code remains provider-neutral. +- **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a dialect-aware Drizzle instance for D1, PostgreSQL, or MySQL. `@schema` is an auto-configured alias for `db/schema.ts`. Migrations live in `db/migrations/`. CLI commands: `void db push` (prototype), `void db generate` (create migration files), `void db migrate` (apply locally). When auth is enabled, `void db generate` adds `.void/better-auth-schema.ts` to Drizzle Kit's schema inputs. Direct Cloudflare deploy applies PostgreSQL or MySQL migrations from the shell-provided production `DATABASE_URL`; PostgreSQL is transactional, while MySQL DDL is not. `drizzle-orm` and `drizzle-kit` ship with void. +- **Database provisioning adapters**: `void db connect ` saves an external database URL to `.env` and syncs a linked Void project; `--local-only` skips the sync. `void db connect --provider neon` provisions through the built-in Neon adapter using `NEON_API_KEY`. Third-party packages or local modules can export a `DatabaseProviderAdapter` built with `void/database-provider`; runtime database code remains provider-neutral. +- **Environment model**: `env.ts` is the checked-in schema; root `.env` is the only dotenv file and is local-only. Production server keys use the remote secret store, while fixed-prefix `VITE_*` keys are public build-time values. Never add `.env.*` precedence, `.secret()`/`.public()` modifiers, or plaintext Worker vars for schema-declared server keys. - **Typed Durable State convention**: each default-exported `defineDurableState()` definition in `durable-objects/*.ts` becomes a filename-derived binding and worker class (`shopping-cart.ts` → `SHOPPING_CART` / `ShoppingCartDurableObject`). The generated entry calls the internal `__bind()` hook so named imports can use `State.get(name)` without touching `c.env`, exports `State.class`, and emits a stable `new_sqlite_classes` migration. `syncWranglerDurableObjects()` persists bindings and append-only class migration history in `wrangler.jsonc`; never regenerate migration order solely from the current sorted files. Runtime operations are serialized and persisted after successful typed RPC, `fetch`, or `alarm` handlers; versioned value migrations run on load. This is native-Void + Cloudflare only and is supported by direct Cloudflare deploys, unlike legacy KV-backed `*.ws.ts` classes. - **Journal coherence invariant**: `db/migrations/*.sql` and `db/migrations/meta/_journal.json` must stay in sync — every `.sql` file has a matching journal entry (by `tag`) and vice versa. `assertJournalCoherence` in `src/migrations/validate.ts` is the gate, called from `deploy`, `db status`, `db migrate`, and `db reset`. The two CLI commands that mutate migration state — `void gen migration` and `void db rename-migrations` — are responsible for keeping the journal coherent themselves: the first appends a new entry when writing a file (failing loud if the journal is missing — run `drizzle-kit generate` first), the second rewrites `tag` values alongside the file renames and also renames the matching `meta/_snapshot.json` (with byte-level rollback on failure). `gen migration` additionally copies the previous `meta/_snapshot.json` forward to the new idx so drizzle-kit's drop/rollback tooling keeps working on mixed hand-written + drizzle projects; projects without prior snapshots are skipped silently. Drift will fail loudly before touching any remote DB. This prevents an orphan scaffolded `.sql` file from half-applying against D1 and blocking later deploys. diff --git a/packages/void/AGENT_PROMPT.md b/packages/void/AGENT_PROMPT.md index 09ac1751c..db79829ea 100644 --- a/packages/void/AGENT_PROMPT.md +++ b/packages/void/AGENT_PROMPT.md @@ -40,7 +40,7 @@ Logs: surface app-level errors that should show up under `void project logs --le Requests: `void project logs` only has rows when a worker actually ran. 5xx generated by the edge router, and every request to a static/SPA project, produce no log line at all. Use `void project requests --status 5xx --range 12h` to see those — it lists status, method, and duration for every request the edge served, whether or not a worker was invoked. -Deploy platform: `void init` asks for Cloudflare (default), Void, or Skip and saves `platform` in `.void/project.json`. Choosing Cloudflare creates or augments `wrangler.jsonc`, handles secure browser sign-in through Void's bundled tooling, and saves the selected `account_id`; Void deployment requires an explicit platform connection (`void connect `) or a company installation (`void platform install --plan`, then `void platform install`). One connection auto-selects, multiple connections prompt, and non-interactive use must select explicitly. `void deploy` uses the saved choice; `--platform cloudflare` and `--platform void` override it without changing project config, while the legacy `--backend cloudflare` alias remains supported. The Cloudflare path supports full Void Workers; static sites, SPAs, known SSGs, and `--dir`; fully prerenderable `output: "static"` Pages apps; and Cloudflare builds from TanStack Start, React Router, vinext App Router and Pages Router, SvelteKit, Nuxt, Analog, and Astro. Hybrid/SSR apps retain their application Worker; static assets run behind a thin shared edge Worker. Worker apps automatically create or adopt D1/KV/R2/Queues/Hyperdrive resources and configure inferred typed-state and WebSocket classes as SQLite Durable Objects. Custom D1 migration directories, tables, and patterns are accepted only when Cloudflare's exact file set and numeric order match the migrations Void validated. Keys marked `.secret()` in `env.ts` become encrypted Worker secrets; auth-enabled deploys preserve or create a persistent encrypted `BETTER_AUTH_SECRET`. PostgreSQL/MySQL provisioning and migrations read `DATABASE_URL` only from the deploy shell. Direct deploy uploads an immutable Worker Version, probes it, activates it at 100%, synchronizes triggers, and restores the previous version if readiness or trigger sync fails. Durable Object versions without preview URLs are staged at 0% and probed with a workers.dev version override; the same safe fallback applies when Access blocks a generated preview alias but admits the stable Worker hostname. Set `CLOUDFLARE_WORKERS_SUBDOMAIN` in fresh CI, while local deploys cache it automatically. If Cloudflare Access protects the Worker's `workers.dev` hostname, supply an admitted `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` service-token pair for CI or a short-lived `CF_ACCESS_TOKEN` from `cloudflared` locally. Saved Cloudflare targets make `void secret`, `void domain`, `void project status|logs|rollback`, and remote `void db` commands operate directly; logs are live tailing. Rollback restores the selected version's saved schedules, queues, workflows, routes, and domains but never reverses database migrations; versions without complete trigger snapshots use code-only rollback and keep the current routes and schedules. Direct commands target the root top-level Cloudflare config only; named environments, worker-bearing `--skip-build`, and node/bun/deno deploy targets fail closed. Avoid concurrent first deploys from different hosts because provisioning locks only the local config path. +Deploy platform: `void init` asks for Cloudflare (default), Void, or Skip and saves `platform` in `.void/project.json`. Choosing Cloudflare creates or augments `wrangler.jsonc`, handles secure browser sign-in through Void's bundled tooling, and saves the selected `account_id`; Void deployment requires an explicit platform connection (`void connect `) or a company installation (`void platform install --plan`, then `void platform install`). One connection auto-selects, multiple connections prompt, and non-interactive use must select explicitly. `void deploy` uses the saved choice; `--platform cloudflare` and `--platform void` override it without changing project config, while the legacy `--backend cloudflare` alias remains supported. The Cloudflare path supports full Void Workers; static sites, SPAs, known SSGs, and `--dir`; fully prerenderable `output: "static"` Pages apps; and Cloudflare builds from TanStack Start, React Router, vinext App Router and Pages Router, SvelteKit, Nuxt, Analog, and Astro. Hybrid/SSR apps retain their application Worker; static assets run behind a thin shared edge Worker. Worker apps automatically create or adopt D1/KV/R2/Queues/Hyperdrive resources and configure inferred typed-state and WebSocket classes as SQLite Durable Objects. Custom D1 migration directories, tables, and patterns are accepted only when Cloudflare's exact file set and numeric order match the migrations Void validated. `.env` is local-only; schema-declared server values use encrypted remote secret storage, required names are emitted through `secrets.required`, and plaintext Worker vars with those names are rejected. Auth-enabled deploys preserve or create a persistent encrypted `BETTER_AUTH_SECRET`. PostgreSQL/MySQL provisioning and migrations read `DATABASE_URL` only from the deploy shell. Direct deploy uploads an immutable Worker Version, probes it, activates it at 100%, synchronizes triggers, and restores the previous version if readiness or trigger sync fails. Durable Object versions without preview URLs are staged at 0% and probed with a workers.dev version override; the same safe fallback applies when Access blocks a generated preview alias but admits the stable Worker hostname. Set `CLOUDFLARE_WORKERS_SUBDOMAIN` in fresh CI, while local deploys cache it automatically. If Cloudflare Access protects the Worker's `workers.dev` hostname, supply an admitted `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` service-token pair for CI or a short-lived `CF_ACCESS_TOKEN` from `cloudflared` locally. Saved Cloudflare targets make `void secret`, `void domain`, `void project status|logs|rollback`, and remote `void db` commands operate directly; logs are live tailing. Rollback restores the selected version's saved schedules, queues, workflows, routes, and domains but never reverses database migrations; versions without complete trigger snapshots use code-only rollback and keep the current routes and schedules. Direct commands target the root top-level Cloudflare config only; named environments, worker-bearing `--skip-build`, and node/bun/deno deploy targets fail closed. Avoid concurrent first deploys from different hosts because provisioning locks only the local config path. Self-hosted lifecycle: installation requires a dedicated empty PostgreSQL database and transactionally claims it for one installation. Interactive administration uses Cloudflare browser OAuth with OS-keychain storage; the installed control plane receives a separate scoped runtime API token. Mutating lifecycle commands use the database claim for a cross-host advisory lock and store a secret-free authoritative lifecycle manifest in the claimed row. Local recovery checkpoints are AES-256-GCM encrypted with a key in the system keychain. After discovery elsewhere, export `VOID_PLATFORM_DATABASE_URL`; ordinary upgrades preserve deployed Worker secrets, while recreating a missing API or proxy Worker requires the original runtime, GitHub, R2, JWT, and project-encryption secrets. Existing runtime keys are never regenerated. `void platform disable [id]` durably gates traffic through installation-owned routing storage, repair and upgrade preserve that state, and `void platform enable [id]` restores it explicitly. Lifecycle redeploys preserve unmanaged Worker routes/custom domains and their rollback state, and stop before migrations or upload when Hyperdrive no longer matches the pinned database. Platform upgrades require exact packaged SQL hashes, an exact live migration prefix, and an explicit source-version/schema rollback edge before pending SQL; migrations are transactional and the old Worker version remains authoritative until target-version health passes. A locally built runtime can be supplied to install, upgrade, repair, or enable with `--runtime `; it receives the same integrity, migration, cutover, health, and rollback checks as the packaged runtime. Set `VOID_PLATFORM_SOURCE_REVISION` during its build to record a source commit in the installation checkpoint. Disable, enable, upgrade, repair, and uninstall verify remote and per-resource ownership before mutating Cloudflare; uninstall retains data unless `--purge-data` is supplied, and always retains name-addressed Workers, R2, AI Gateway, external PostgreSQL, and zones for explicit manual cleanup. diff --git a/packages/void/getting-started-prompt.txt b/packages/void/getting-started-prompt.txt index 70220f8b7..2ff123d5d 100644 --- a/packages/void/getting-started-prompt.txt +++ b/packages/void/getting-started-prompt.txt @@ -31,7 +31,7 @@ void deploy For a self-hosted company platform, interactive administration uses Cloudflare browser OAuth with system-keychain storage, while the installed control plane receives a separate scoped runtime API token. Provide a dedicated empty PostgreSQL database; the installer transactionally claims it for that installation, uses the claim to serialize lifecycle changes across hosts, and stores a secret-free authoritative lifecycle manifest in the claimed row. Local recovery material is AES-256-GCM encrypted with an OS-keychain key. After discovery on a new machine, export `VOID_PLATFORM_DATABASE_URL`; normal upgrades preserve deployed Worker secrets, while recreating a missing Worker requires restoring its original runtime, GitHub, R2, JWT, and project-encryption secrets. Void never regenerates those keys for an existing installation. `void platform disable [id]` gates installation-owned traffic through routing storage across repair and upgrade; use `void platform enable [id]` to restore it explicitly. Lifecycle redeploys preserve unrelated routes and custom domains, including rollback state, and refuse to proceed when Hyperdrive no longer points at the pinned database. Platform upgrades verify packaged SQL and the live migration prefix, require an explicit rollback-compatible source-version/schema edge before pending SQL, apply it transactionally, and retain the old Worker version until target health passes. Install, upgrade, repair, and enable accept `--runtime ` for a source-built runtime; it follows the same integrity and rollback path as packaged artifacts, and records `VOID_PLATFORM_SOURCE_REVISION` when set during the build. Lifecycle changes verify the remote installation and per-resource ownership before mutating Cloudflare. Safe uninstall always retains name-addressed Workers, R2, AI Gateway, external PostgreSQL, and zones for explicit manual cleanup. -To override the saved choice, use `void deploy --platform cloudflare` or `void deploy --platform void`. Cloudflare supports full Void Workers; static sites, SPAs, known SSGs, `--dir`, and fully prerenderable `output: "static"` Pages apps; and Cloudflare output from TanStack Start, React Router, vinext App Router and Pages Router, SvelteKit, Nuxt, Analog, and Astro. Hybrid/SSR apps keep their generated Worker, while static deployments use a thin shared edge Worker. Worker apps automatically create or adopt D1/KV/R2/Queues/Hyperdrive resources and configure typed state and WebSocket routes as SQLite-backed Durable Objects. Custom D1 migration layouts are accepted only when Cloudflare's exact file set and order match the migration set Void validated. Keys marked `.secret()` become encrypted Worker secrets; auth deploys preserve or create a persistent `BETTER_AUTH_SECRET`; PostgreSQL/MySQL deploys read `DATABASE_URL` only from the shell. Direct deploy uploads a Worker Version, probes it before traffic, activates it at 100%, and synchronizes triggers with automatic restoration on failure. Versions without previews are staged at 0% and probed through workers.dev with a version override; the same safe fallback applies when Access blocks a generated preview alias but admits the stable Worker hostname. Set `CLOUDFLARE_WORKERS_SUBDOMAIN` in fresh CI, while local deploys cache it automatically. When Cloudflare Access protects `workers.dev`, export an admitted `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` service-token pair for CI or a short-lived `CF_ACCESS_TOKEN` from `cloudflared` locally. With Cloudflare saved, `void secret`, `void domain`, `void project status|logs|rollback`, and remote `void db` commands target the pinned Worker or D1 database; logs are live tails and rollback never reverses database migrations. `void init --github` creates a Cloudflare workflow that requires a scoped `CLOUDFLARE_API_TOKEN`. Direct commands target the top-level root Cloudflare config only; named environments, worker-bearing `--skip-build`, and node/bun/deno deploy targets fail closed. Avoid concurrent first deploys from different hosts because provisioning locks only the local config path. +To override the saved choice, use `void deploy --platform cloudflare` or `void deploy --platform void`. Cloudflare supports full Void Workers; static sites, SPAs, known SSGs, `--dir`, and fully prerenderable `output: "static"` Pages apps; and Cloudflare output from TanStack Start, React Router, vinext App Router and Pages Router, SvelteKit, Nuxt, Analog, and Astro. Hybrid/SSR apps keep their generated Worker, while static deployments use a thin shared edge Worker. Worker apps automatically create or adopt D1/KV/R2/Queues/Hyperdrive resources and configure typed state and WebSocket routes as SQLite-backed Durable Objects. Custom D1 migration layouts are accepted only when Cloudflare's exact file set and order match the migration set Void validated. `.env` is local-only; schema-declared server values use encrypted remote secret storage and are rejected in plaintext Worker vars. Auth deploys preserve or create a persistent `BETTER_AUTH_SECRET`; PostgreSQL/MySQL deploys read `DATABASE_URL` only from the shell. Direct deploy uploads a Worker Version, probes it before traffic, activates it at 100%, and synchronizes triggers with automatic restoration on failure. Versions without previews are staged at 0% and probed through workers.dev with a version override; the same safe fallback applies when Access blocks a generated preview alias but admits the stable Worker hostname. Set `CLOUDFLARE_WORKERS_SUBDOMAIN` in fresh CI, while local deploys cache it automatically. When Cloudflare Access protects `workers.dev`, export an admitted `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` service-token pair for CI or a short-lived `CF_ACCESS_TOKEN` from `cloudflared` locally. With Cloudflare saved, `void secret`, `void domain`, `void project status|logs|rollback`, and remote `void db` commands target the pinned Worker or D1 database; logs are live tails and rollback never reverses database migrations. `void init --github` creates a Cloudflare workflow that requires a scoped `CLOUDFLARE_API_TOKEN`. Direct commands target the top-level root Cloudflare config only; named environments, worker-bearing `--skip-build`, and node/bun/deno deploy targets fail closed. Avoid concurrent first deploys from different hosts because provisioning locks only the local config path. For strongly consistent per-key state, add `durable-objects/counter.ts` and named- and default-export the same `defineDurableState()` value from `void/durable`. Void derives the binding, worker class, and SQLite-backed Cloudflare migration from the filename; call typed RPC methods with `Counter.get(name)`. This works in native Void apps on Cloudflare and with direct Cloudflare deploys. Typed state and `*.ws.ts` WebSocket routes both work with direct Cloudflare deploys. diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index da67e7b77..67c261b8e 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -31,7 +31,7 @@ Use `void connect` for deployment onboarding: no arguments offers Cloudflare or Use Void commands for every user-facing workflow. Never ask the user to install, authenticate, or run Wrangler directly. Say Cloudflare or Void instead, except when naming literal `wrangler.jsonc` / `wrangler.json` files or `WRANGLER_*` environment variables the user must inspect. -Use `void` in examples and commands in this skill. For first-time setup, prefer `void init` followed by `void deploy`; in an empty directory, install `void` first and let `void init` add the matching Pages adapter and starter dependencies with Vite+ as the default scaffold toolchain. In an existing app, `void init` configures Void in place by adding missing Vite scripts and creating or patching `vite.config.*` with `voidPlugin()`. For Cloudflare deployment, Void uses bundled tooling and secure browser OAuth; init or the first deploy saves the selected `account_id`, and `void cloudflare login|status|logout` manages the session; Void-managed deployment requires an explicitly connected platform. Use `void connect ` for an existing platform or `void platform install --plan` to preview a company control plane in the user's Cloudflare account. The core self-hosted platform excludes the dashboard, GitHub App, and build Containers; lifecycle commands are resumable and verify remote, Worker, and R2 ownership before mutations. `void platform disable` gates traffic through installation-owned routing storage and remains disabled through repair or upgrade; `void platform enable` explicitly restores traffic. Safe uninstall retains name-addressed Workers, R2, AI Gateway, external PostgreSQL, and zones for explicit manual cleanup. Direct Cloudflare deploys support native Void apps, static/SPA/SSG output, and Cloudflare builds from TanStack Start, React Router, vinext, SvelteKit, Nuxt, Analog, and Astro. They provision inferred resources, validate and apply migrations, promote `.secret()` env keys to encrypted Worker secrets, upload and probe an immutable Worker Version, then activate and synchronize triggers. Native Void features are Workers Free-compatible by default; `void/sandbox` is an explicit exception because it uses Cloudflare Containers and therefore requires Workers Paid. A Sandbox deploy checks Containers access before provisioning/building, while apps without Sandbox perform no entitlement probe. Versions without preview URLs are staged at 0% and probed through workers.dev using a version override; the same safe fallback applies when Access blocks a generated preview alias but admits the stable Worker hostname. Set `CLOUDFLARE_WORKERS_SUBDOMAIN` in a fresh CI checkout, while local deploys cache it automatically. When Cloudflare Access protects `workers.dev`, use an admitted `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` service-token pair for CI, or a short-lived `CF_ACCESS_TOKEN` from `cloudflared` for an interactive local readiness probe. With Cloudflare saved in `.void/project.json`, secret, domain, project status/log/rollback, and remote database commands operate directly on the pinned Worker/account. Logs are a live tail. Rollback restores the selected version's saved schedules, queues, workflows, routes, and domains but never reverses database migrations; versions without complete trigger snapshots use code-only rollback and keep the current routes and schedules. On either deployment platform, auth-enabled `void deploy` preserves an existing `BETTER_AUTH_SECRET` or creates a persistent encrypted secret when missing; always use Void's deployment flow so it can manage that secret safely. +Use `void` in examples and commands in this skill. For first-time setup, prefer `void init` followed by `void deploy`; in an empty directory, install `void` first and let `void init` add the matching Pages adapter and starter dependencies with Vite+ as the default scaffold toolchain. In an existing app, `void init` configures Void in place by adding missing Vite scripts and creating or patching `vite.config.*` with `voidPlugin()`. For Cloudflare deployment, Void uses bundled tooling and secure browser OAuth; init or the first deploy saves the selected `account_id`, and `void cloudflare login|status|logout` manages the session; Void-managed deployment requires an explicitly connected platform. Use `void connect ` for an existing platform or `void platform install --plan` to preview a company control plane in the user's Cloudflare account. The core self-hosted platform excludes the dashboard, GitHub App, and build Containers; lifecycle commands are resumable and verify remote, Worker, and R2 ownership before mutations. `void platform disable` gates traffic through installation-owned routing storage and remains disabled through repair or upgrade; `void platform enable` explicitly restores traffic. Safe uninstall retains name-addressed Workers, R2, AI Gateway, external PostgreSQL, and zones for explicit manual cleanup. Direct Cloudflare deploys support native Void apps, static/SPA/SSG output, and Cloudflare builds from TanStack Start, React Router, vinext, SvelteKit, Nuxt, Analog, and Astro. They provision inferred resources, validate and apply migrations, require schema-declared server values in encrypted remote secret storage, upload and probe an immutable Worker Version, then activate and synchronize triggers. Native Void features are Workers Free-compatible by default; `void/sandbox` is an explicit exception because it uses Cloudflare Containers and therefore requires Workers Paid. A Sandbox deploy checks Containers access before provisioning/building, while apps without Sandbox perform no entitlement probe. Versions without preview URLs are staged at 0% and probed through workers.dev using a version override; the same safe fallback applies when Access blocks a generated preview alias but admits the stable Worker hostname. Set `CLOUDFLARE_WORKERS_SUBDOMAIN` in a fresh CI checkout, while local deploys cache it automatically. When Cloudflare Access protects `workers.dev`, use an admitted `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` service-token pair for CI, or a short-lived `CF_ACCESS_TOKEN` from `cloudflared` for an interactive local readiness probe. With Cloudflare saved in `.void/project.json`, secret, domain, project status/log/rollback, and remote database commands operate directly on the pinned Worker/account. Logs are a live tail. Rollback restores the selected version's saved schedules, queues, workflows, routes, and domains but never reverses database migrations; versions without complete trigger snapshots use code-only rollback and keep the current routes and schedules. On either deployment platform, auth-enabled `void deploy` preserves an existing `BETTER_AUTH_SECRET` or creates a persistent encrypted secret when missing; always use Void's deployment flow so it can manage that secret safely. Use `void` and `@void/*` in code examples and package manifests.