Skip to content

Commit 0fe5cd5

Browse files
authored
Preserve public Worker variables in managed deployments (#423)
Managed deployments omit `worker.vars`, so public settings disappear after legacy remote secrets are removed. Include configured variables in deployment manifests for Void and framework applications. Reject schema-declared server keys in plain-text variables, including with `--skip-build`. When a remote secret and a variable share a name, upload only the secret binding. This preserves secret precedence and prevents Cloudflare from rejecting duplicate binding names. For the temporary bridge fix, see [pkg-pr-registry-bridge#134](voidzero-dev/pkg-pr-registry-bridge#134).
1 parent 23ddc6e commit 0fe5cd5

10 files changed

Lines changed: 142 additions & 22 deletions

File tree

‎docs/reference/config.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,8 @@ Curated Cloudflare Workers configuration. `void init` pins `compatibility_date`
297297

298298
`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.
299299

300+
Managed deployments include `worker.vars` as plain-text bindings. A remote secret with the same name takes precedence. To use the configured variable instead, remove that remote secret and deploy again. Secret deletion changes the running Worker immediately, so plan this migration during a maintenance window.
301+
300302
`worker.limits.cpu_ms` sets the CPU time limit per request, from 1 to 300000 ms. On a Void platform, deploy fails if the limit exceeds the account plan; lower it in `void.config.ts` before retrying. Rollback instead caps the old limit at the current plan ceiling. On direct deploys, [Cloudflare enforces the value](https://developers.cloudflare.com/workers/platform/limits/#cpu-time): Workers Free allows up to 10 ms and Workers Paid up to 300000 ms per request.
301303

302304
```json

‎packages/void/src/cli/deploy-helpers.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,13 +543,14 @@ export function toManifestSchedulesAndQueues(
543543

544544
/**
545545
* If the project has an `env.ts`, validate its schema against the union of
546-
* production env files and remote secret names. Hard-error before upload so
546+
* build-shell public values and remote secret names. Hard-error before upload so
547547
* deploys never ship missing required env vars.
548548
*/
549549
export async function assertEnvSchemaValid(
550550
root: string,
551551
client: PlatformClient,
552552
projectId: string,
553+
workerVars: Record<string, string> | undefined,
553554
): Promise<void> {
554555
// Skip the remote secret round-trip when the project has no typed env schema.
555556
if (findEnvFile(root) == null) {
@@ -560,6 +561,7 @@ export async function assertEnvSchemaValid(
560561
const { hasSchema, report } = await validateProdEnv(root, {
561562
productionOnly: true,
562563
remoteSecrets,
564+
workerVars,
563565
});
564566
if (!hasSchema || report.valid) {
565567
return;

‎packages/void/src/cli/deploy-managed-framework.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export async function runFrameworkDeploy(
154154

155155
const { schedules, queues } = toManifestSchedulesAndQueues(rawJobs, rawQueues);
156156

157-
await assertEnvSchemaValid(root, client, config.projectId);
157+
await assertEnvSchemaValid(root, client, config.projectId, voidConfig.worker?.vars);
158158

159159
const s = createSpinner();
160160
const onProgress = (msg: string) => s.message(msg);
@@ -202,6 +202,7 @@ export async function runFrameworkDeploy(
202202
revalidate,
203203
revalidateQueryAllowlist: voidConfig.routing?.revalidateQueryAllowlist,
204204
prerender,
205+
vars: voidConfig.worker?.vars,
205206
// Not `routing.notFound`; see `warnIgnoredNotFoundForFramework`.
206207
assetConfig: { not_found_handling: 'none' },
207208
packageOptions,

‎packages/void/src/cli/deploy-managed-full.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ export async function runFullDeploy(
344344
);
345345
const workerMain = resolveDeployWorkerMain(voidConfig, join(distDir, workerDirName));
346346

347-
await assertEnvSchemaValid(root, client, config.projectId);
347+
await assertEnvSchemaValid(root, client, config.projectId, voidConfig.worker?.vars);
348348

349349
const s = createSpinner();
350350
const onProgress = (msg: string) => s.message(msg);
@@ -421,6 +421,7 @@ export async function runFullDeploy(
421421
ssr: isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled,
422422
framework: isFrameworkMode ? detected!.name : undefined,
423423
revalidate,
424+
vars: voidConfig.worker?.vars,
424425
queues,
425426
prerender: uniquePrerenderPaths,
426427
assetConfig,

‎packages/void/src/cli/env-validation.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ export type ValidateProdEnvOptions = {
8585
productionOnly: boolean;
8686
/** Optional remote secret list (names only). Treated as "value present". */
8787
remoteSecrets?: Array<string>;
88+
/** Explicit public vars included in a managed deployment. Never satisfy server schema keys. */
89+
workerVars?: Record<string, string>;
8890
};
8991

9092
export type ProdEnvValidationResult = {
@@ -131,6 +133,15 @@ export async function validateProdEnv(
131133
if (reservedInSchema.length > 0) {
132134
throw new Error(`env: ${reservedEnvKeysMessage('env.ts', reservedInSchema)}`);
133135
}
136+
const serverVarKeys = Object.keys(mod._getRegisteredSchema() ?? {}).filter(
137+
(key) => !isClientEnvKey(key) && Object.hasOwn(options.workerVars ?? {}, key),
138+
);
139+
if (serverVarKeys.length > 0) {
140+
throw new Error(
141+
`env: Server env keys cannot be Worker plaintext vars: ${serverVarKeys.join(', ')}. ` +
142+
'Remove them from worker.vars and set them with the remote secret store.',
143+
);
144+
}
134145

135146
const local = options.productionOnly
136147
? {}

‎packages/void/src/cli/package.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ export function buildVoidManifest(opts: {
442442
revalidate?: number | Record<string, number>;
443443
pages?: Array<{ pattern: string; revalidate?: number }>;
444444
pathRevalidate?: Record<string, number>;
445+
vars?: Record<string, string>;
445446
queues?: Array<QueueMeta>;
446447
prerender?: Array<string>;
447448
assetConfig?: AssetConfig;
@@ -463,6 +464,7 @@ export function buildVoidManifest(opts: {
463464
revalidate,
464465
pages,
465466
pathRevalidate,
467+
vars,
466468
queues,
467469
prerender,
468470
assetConfig,
@@ -569,6 +571,10 @@ export function buildVoidManifest(opts: {
569571
manifest.assetConfig = resolvedAssetConfig;
570572
}
571573

574+
if (vars && Object.keys(vars).length > 0) {
575+
manifest.vars = vars;
576+
}
577+
572578
if (options?.workerMain) {
573579
manifest.workerMain = options.workerMain;
574580
}
@@ -693,6 +699,7 @@ export function buildFrameworkManifest(opts: {
693699
revalidate?: number | Record<string, number>;
694700
revalidateQueryAllowlist?: Record<string, Array<string>>;
695701
prerender?: Array<string>;
702+
vars?: Record<string, string>;
696703
assetConfig?: AssetConfig;
697704
packageOptions?: Omit<PackageOptions, 'workerMain'>;
698705
hashedAssetsPrefix?: string;
@@ -735,6 +742,9 @@ export function buildFrameworkManifest(opts: {
735742
if (opts.assetConfig) {
736743
manifest.assetConfig = opts.assetConfig;
737744
}
745+
if (opts.vars && Object.keys(opts.vars).length > 0) {
746+
manifest.vars = opts.vars;
747+
}
738748

739749
manifest.ssr = true;
740750
manifest.framework = opts.frameworkName;

‎packages/void/test/integration/full-deploy-manifest.test.ts‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1+
import {
2+
copyFileSync,
3+
cpSync,
4+
existsSync,
5+
mkdirSync,
6+
readFileSync,
7+
rmSync,
8+
writeFileSync,
9+
} from 'node:fs';
210
import { join } from 'node:path';
311
import spawn from 'cross-spawn';
412
import {
@@ -89,7 +97,13 @@ let savedToken: string | undefined;
8997
let savedProject: string | undefined;
9098

9199
type Captured = {
92-
finalizeBody: { manifest?: { pages?: unknown; bindings?: Record<string, unknown> } } | null;
100+
finalizeBody: {
101+
manifest?: {
102+
pages?: unknown;
103+
bindings?: Record<string, unknown>;
104+
vars?: Record<string, string>;
105+
};
106+
} | null;
93107
};
94108

95109
// Every asset is reported as present, so the deploy skips uploads and goes straight to finalize.
@@ -176,7 +190,7 @@ function ndjson(events: Array<unknown>): Response {
176190
});
177191
}
178192

179-
describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () => {
193+
describe('managed deployment manifests (built Worker)', () => {
180194
beforeAll(() => {
181195
playground = createIsolatedPlayground(ROOT, SOURCE_PLAYGROUND);
182196
fixture = playground.root;
@@ -318,6 +332,44 @@ describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () =
318332
}
319333
});
320334

335+
it.each([
336+
['Void', undefined],
337+
['Class A', { name: 'vinext-pages', class: 'a' }],
338+
['Class C', { name: 'astro', class: 'c' }],
339+
] as const)(
340+
'includes configured worker.vars in the %s deployment request',
341+
async (_, detected) => {
342+
mockDetectFramework.mockReturnValue(detected);
343+
const configPath = join(fixture, 'void.config.ts');
344+
const original = readFileSync(configPath, 'utf8');
345+
const dotenvPath = join(fixture, '.env');
346+
const originalDotenv = existsSync(dotenvPath) ? readFileSync(dotenvPath) : undefined;
347+
const vars = { API_URL: 'https://api.example.test', EMPTY_SETTING: '' };
348+
writeFileSync(
349+
configPath,
350+
original.replace('worker: {', `worker: { vars: ${JSON.stringify(vars)},`),
351+
);
352+
writeFileSync(dotenvPath, 'API_URL=https://local.example.test\nADMIN_TOKEN=local-secret\n');
353+
if (detected?.name === 'astro') {
354+
// Reuse the built Worker at the Class C preset's output paths.
355+
cpSync(join(fixture, 'dist/ssr'), join(fixture, 'dist/server'), { recursive: true });
356+
copyFileSync(join(fixture, 'dist/server/index.js'), join(fixture, 'dist/server/entry.mjs'));
357+
}
358+
try {
359+
const captured = installPlatformMocks();
360+
await runDeploy(fixture, { skipBuild: true });
361+
expect(captured.finalizeBody?.manifest?.vars).toEqual(vars);
362+
} finally {
363+
writeFileSync(configPath, original);
364+
if (originalDotenv === undefined) {
365+
rmSync(dotenvPath, { force: true });
366+
} else {
367+
writeFileSync(dotenvPath, originalDotenv);
368+
}
369+
}
370+
},
371+
);
372+
321373
it('uses the alternate project for Worker packaging and prerendering while retaining the saved link', async () => {
322374
const original = readFileSync(projectJson, 'utf8');
323375
const saved = JSON.stringify({

‎packages/void/test/unit/env-prod-validation.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,30 @@ afterEach(() => {
3636
});
3737

3838
describe('validateProdEnv — deploy-time schema enforcement', () => {
39+
it('rejects schema-declared server keys in managed Worker vars even when a remote secret exists', async () => {
40+
const root = createTempProject();
41+
defineEnv({ ADMIN_TOKEN: string().optional() });
42+
43+
await expect(
44+
validateProdEnv(root, {
45+
productionOnly: true,
46+
remoteSecrets: ['ADMIN_TOKEN'],
47+
workerVars: { ADMIN_TOKEN: 'must-not-be-uploaded' },
48+
}),
49+
).rejects.toThrow('Server env keys cannot be Worker plaintext vars: ADMIN_TOKEN');
50+
});
51+
52+
it('permits public Worker vars outside the server schema', async () => {
53+
const root = createTempProject();
54+
defineEnv({ ADMIN_TOKEN: string() });
55+
const { report } = await validateProdEnv(root, {
56+
productionOnly: true,
57+
remoteSecrets: ['ADMIN_TOKEN'],
58+
workerVars: { API_URL: 'https://api.example.test' },
59+
});
60+
expect(report.valid).toBe(true);
61+
});
62+
3963
it('returns hasSchema=false when the project has no env.ts (skips validation)', async () => {
4064
const root = createTempProject({ withEnvTs: false });
4165
const result = await validateProdEnv(root, { productionOnly: true });

‎platform/packages/api/src/deploy/upload-worker.ts‎

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -274,14 +274,9 @@ export async function uploadDeploymentWorker(input: {
274274
// route can reject before persisting; see the invariant test there.
275275
const userVarNames = Object.keys(manifest.vars ?? {});
276276
const userSecretNames = secretBindings.map((s) => s.name);
277-
// Check BOTH vars and project secrets. A secret is uploaded in the
278-
// same `metadata.bindings` array, so a secret named e.g. `SEND_EMAIL`
279-
// or `__VOID_PROJECT_SLUG` either shadows a platform binding or is
280-
// shadowed by one — CF resolves duplicates last-wins with no error.
281-
// The secrets route rejects these at PUT time, but older stored rows
282-
// can still reach here. Failing the deploy with
283-
// a named binding beats shipping a worker that silently reads the
284-
// wrong value.
277+
// Vars and secrets share metadata.bindings with platform bindings. Reject
278+
// reserved names before Cloudflare rejects the duplicate binding. The secrets
279+
// route rejects these at PUT time, but older stored rows can still reach here.
285280
const reserved = [
286281
...userVarNames.filter((n) => RESERVED_BINDING_NAMES.has(n)).map((n) => `vars.${n}`),
287282
...userSecretNames.filter((n) => RESERVED_BINDING_NAMES.has(n)).map((n) => `secret.${n}`),
@@ -294,11 +289,13 @@ export async function uploadDeploymentWorker(input: {
294289
if (collisions.length > 0) {
295290
logger.warn('worker_upload', 'var_secret_collisions', { collisions });
296291
}
297-
const plainTextBindings = Object.entries(manifest.vars ?? {}).map(([name, text]) => ({
298-
type: 'plain_text' as const,
299-
name,
300-
text,
301-
}));
292+
const plainTextBindings = Object.entries(manifest.vars ?? {})
293+
.filter(([name]) => !secretNames.has(name))
294+
.map(([name, text]) => ({
295+
type: 'plain_text' as const,
296+
name,
297+
text,
298+
}));
302299
const durableObjects = await prepareManagedDurableObjects(
303300
context.env,
304301
context.get('db'),

‎platform/packages/api/test/integration/deploy-collision-warning.test.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,18 +69,18 @@ function buildDeployRequest(projectId: string, jwt: string, manifest: object): R
6969
}
7070

7171
describe('manifest var / secret collision warning', () => {
72-
it('logs warning when manifest vars collide with project secrets', async () => {
72+
it('uploads each binding once and warns when a secret overrides a manifest var', async () => {
7373
const user = await createTestUser(env.db);
7474
const project = await createTestProject(env.db, user.id, 'collision-app');
7575
await createTestProjectSecret(env.db, env, project.id, 'API_KEY', 'secret-value');
76-
mockCfApi();
76+
const fetchMock = mockCfApi();
7777
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
7878

7979
const res = await app.fetch(
8080
buildDeployRequest(project.id, user.jwt, {
8181
version: 3,
8282
bindings: {},
83-
vars: { API_KEY: 'from-manifest' },
83+
vars: { API_KEY: 'from-manifest', API_URL: 'https://api.example.test' },
8484
}),
8585
env,
8686
);
@@ -93,5 +93,25 @@ describe('manifest var / secret collision warning', () => {
9393
// in `collisions`.
9494
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('var_secret_collisions'));
9595
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('API_KEY'));
96+
97+
const uploadCall = fetchMock.mock.calls.find(
98+
([url, init]) =>
99+
String(url).includes('/workers/dispatch/') && (init as RequestInit)?.method === 'PUT',
100+
);
101+
expect(uploadCall).toBeDefined();
102+
const form = (uploadCall![1] as RequestInit).body as FormData;
103+
const metadata = JSON.parse(await (form.get('metadata') as Blob).text()) as {
104+
bindings: Array<{ name: string; type: string; text?: string }>;
105+
};
106+
expect(metadata.bindings.filter(({ name }) => name === 'API_KEY')).toEqual([
107+
{ type: 'secret_text', name: 'API_KEY', text: 'secret-value' },
108+
]);
109+
expect(metadata.bindings).toContainEqual({
110+
type: 'plain_text',
111+
name: 'API_URL',
112+
text: 'https://api.example.test',
113+
});
114+
const names = metadata.bindings.map(({ name }) => name);
115+
expect(new Set(names).size).toBe(names.length);
96116
});
97117
});

0 commit comments

Comments
 (0)