Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nine-baths-take.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/workers-utils": minor
---

Add experimental `secrets` property to config validation
20 changes: 20 additions & 0 deletions packages/workers-utils/src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,26 @@ export interface EnvironmentNonInheritable {
*/
vars: Record<string, string | Json>;

/**
* Secrets configuration.
*
* NOTE: This field is not automatically inherited from the top level environment,
* and so must be specified in every named environment.
*
* @default undefined
* @nonInheritable
*/
secrets?: {
/**
* List of secret names that are required by your Worker.
* When defined, this property:
* - Replaces .dev.vars/.env inference for type generation
* - Enables deploy-time validation to ensure secrets are configured
* - Enables local dev validation with warnings for missing secrets
*/
required?: string[];
};

/**
* A list of durable objects that your Worker should be bound to.
*
Expand Down
51 changes: 51 additions & 0 deletions packages/workers-utils/src/config/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,7 @@ function normalizeAndValidateEnvironment(
);

experimental(diagnostics, rawEnv, "unsafe");
experimental(diagnostics, rawEnv, "secrets");

const route = normalizeAndValidateRoute(diagnostics, topLevelEnv, rawEnv);

Expand Down Expand Up @@ -1558,6 +1559,16 @@ function normalizeAndValidateEnvironment(
validateVars(envName),
{}
),
secrets: notInheritable(
diagnostics,
topLevelEnv,
rawConfig,
rawEnv,
envName,
"secrets",
validateSecrets(envName),
undefined
),
define: notInheritable(
diagnostics,
topLevelEnv,
Expand Down Expand Up @@ -2235,6 +2246,42 @@ const validateVars =
return isValid;
};

const validateSecrets =
(envName: string): ValidatorFn =>
(diagnostics, field, value, config) => {
const fieldPath =
config === undefined ? `${field}` : `env.${envName}.${field}`;

if (value === undefined) {
return true;
}

if (typeof value !== "object" || value === null || Array.isArray(value)) {
diagnostics.errors.push(
`The field "${fieldPath}" should be an object but got ${JSON.stringify(value)}.`
);
return false;
}

let isValid = true;

// Warn about unexpected properties
validateAdditionalProperties(diagnostics, fieldPath, Object.keys(value), [
"required",
]);

// Validate 'required' property if present
isValid =
validateOptionalTypedArray(
diagnostics,
`${fieldPath}.required`,
(value as Record<string, unknown>).required,
"string"
) && isValid;

return isValid;
};

const validateBindingsProperty =
(envName: string, validateBinding: ValidatorFn): ValidatorFn =>
(diagnostics, field, value, config) => {
Expand Down Expand Up @@ -3921,6 +3968,10 @@ const validateBindingsHaveUniqueNames = (
])
);

// Add secrets to binding name validation (secrets is not a CfWorkerInit binding type,
// but we want to validate that secret names don't conflict with other bindings)
bindingsGroupedByType["Secret"] = config.secrets?.required ?? [];

const bindingsGroupedByName: Record<string, string[]> = {};

for (const bindingType in bindingsGroupedByType) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6090,6 +6090,169 @@ describe("normalizeAndValidateConfig()", () => {
});
});

describe("[secrets]", () => {
it("should accept valid secrets config", ({ expect }) => {
const rawConfig: RawConfig = {
secrets: {
required: ["API_KEY", "DATABASE_PASSWORD"],
},
};
const { config, diagnostics } = normalizeAndValidateConfig(
rawConfig,
undefined,
undefined,
{ env: undefined }
);

expect(config.secrets).toEqual({
required: ["API_KEY", "DATABASE_PASSWORD"],
});
expect(diagnostics.hasErrors()).toBe(false);
// Expect experimental warning
expect(diagnostics.hasWarnings()).toBe(true);
expect(diagnostics.renderWarnings()).toContain(
'"secrets" fields are experimental'
);
});

it("should error if secrets is not an object", ({ expect }) => {
const rawConfig: RawConfig = {
// @ts-expect-error purposely using an invalid value
secrets: "invalid",
};
const { diagnostics } = normalizeAndValidateConfig(
rawConfig,
undefined,
undefined,
{ env: undefined }
);

expect(diagnostics.hasErrors()).toBe(true);
expect(diagnostics.renderErrors()).toContain(
'The field "secrets" should be an object'
);
});

it("should error if secrets.required is not an array", ({ expect }) => {
const rawConfig: RawConfig = {
// @ts-expect-error purposely using an invalid value
secrets: { required: "API_KEY" },
};
const { diagnostics } = normalizeAndValidateConfig(
rawConfig,
undefined,
undefined,
{ env: undefined }
);

expect(diagnostics.hasErrors()).toBe(true);
expect(diagnostics.renderErrors()).toContain(
'Expected "secrets.required" to be an array of strings'
);
});

it("should error if secrets.required contains non-strings", ({
expect,
}) => {
const rawConfig: RawConfig = {
// @ts-expect-error purposely using an invalid value
secrets: { required: ["VALID_KEY", 123, true] },
};
const { diagnostics } = normalizeAndValidateConfig(
rawConfig,
undefined,
undefined,
{ env: undefined }
);

expect(diagnostics.hasErrors()).toBe(true);
expect(diagnostics.renderErrors()).toContain(
'Expected "secrets.required.[1]" to be of type string'
);
expect(diagnostics.renderErrors()).toContain(
'Expected "secrets.required.[2]" to be of type string'
);
});

it("should error on duplicate secret names", ({ expect }) => {
const rawConfig: RawConfig = {
secrets: { required: ["API_KEY", "API_KEY"] },
};
const { diagnostics } = normalizeAndValidateConfig(
rawConfig,
undefined,
undefined,
{ env: undefined }
);

expect(diagnostics.hasErrors()).toBe(true);
expect(diagnostics.renderErrors()).toContain(
"API_KEY assigned to multiple Secret bindings"
);
});

it("should error on secret name conflicting with var", ({ expect }) => {
const rawConfig: RawConfig = {
vars: { API_KEY: "not-a-secret" },
secrets: { required: ["API_KEY"] },
};
const { diagnostics } = normalizeAndValidateConfig(
rawConfig,
undefined,
undefined,
{ env: undefined }
);

expect(diagnostics.hasErrors()).toBe(true);
expect(diagnostics.renderErrors()).toContain(
"API_KEY assigned to Environment Variable and Secret bindings"
);
});

describe("per-environment overrides", () => {
it("should accept valid secrets in environment", ({ expect }) => {
const rawConfig: RawConfig = {
env: {
production: {
secrets: { required: ["PROD_API_KEY"] },
},
},
};
const { config, diagnostics } = normalizeAndValidateConfig(
rawConfig,
undefined,
undefined,
{ env: "production" }
);

expect(config.secrets).toEqual({ required: ["PROD_API_KEY"] });
expect(diagnostics.hasErrors()).toBe(false);
});

it("should warn when secrets exists at top level but not in environment", ({
expect,
}) => {
const rawConfig: RawConfig = {
secrets: { required: ["API_KEY"] },
env: {
production: {},
},
};
const { diagnostics } = normalizeAndValidateConfig(
rawConfig,
undefined,
undefined,
{ env: "production" }
);

expect(diagnostics.hasErrors()).toBe(false);
expect(diagnostics.renderWarnings()).toContain(
'"secrets" exists at the top level, but not on "env.production"'
);
});
});
});

describe("[durable_objects]", () => {
it("should error if durable_objects is an array", ({ expect }) => {
const { diagnostics } = normalizeAndValidateConfig(
Expand Down
Loading