From f1ab29dbcea905c64b9493480572d4557634676a Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Thu, 16 Oct 2025 12:58:34 -0700 Subject: [PATCH 1/6] Add defineJsonSecret API for structured secret configuration Implements defineJsonSecret() to store JSON objects in Cloud Secret Manager. Useful for consolidating related secrets (e.g., API keys, webhooks, client IDs) into a single secret, reducing costs and improving organization. Features: - Automatic JSON parsing with error handling - Supports object destructuring - Throws on missing or invalid JSON Wire protocol changes (backward compatible): - Added optional format field to ParamSpec/WireParamSpec - JsonSecretParam.toSpec() returns format: "json" as CLI hint - Old CLIs ignore unknown fields, new CLIs can enhance UX - Format is NOT stored in Secret Manager (just in param spec) --- CHANGELOG.md | 4 -- spec/params/params.spec.ts | 106 +++++++++++++++++++++++++++++++++++++ src/params/index.ts | 33 +++++++++++- src/params/types.ts | 56 ++++++++++++++++++++ 4 files changed, 194 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebf9ddc91..e69de29bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +0,0 @@ -- Add LLM guidance (#1736) -- Fix issue calling DataSnapshot methods with null data (#1661) -- Adds auth.rawToken to context to allow access to the underlying token. (#1678) -- Fix logger runtime exceptions #(1704) diff --git a/spec/params/params.spec.ts b/spec/params/params.spec.ts index 676d63e1a..1a37c40cb 100644 --- a/spec/params/params.spec.ts +++ b/spec/params/params.spec.ts @@ -31,6 +31,12 @@ describe("Params value extraction", () => { process.env.BAD_LIST = JSON.stringify(["a", 22, "c"]); process.env.ESCAPED_LIST = JSON.stringify(["f\to\no"]); process.env.A_SECRET_STRING = "123456supersecret"; + process.env.STRIPE_CONFIG = JSON.stringify({ + apiKey: "sk_test_123", + webhookSecret: "whsec_456", + clientId: "ca_789", + }); + process.env.INVALID_JSON_SECRET = "not valid json{"; }); afterEach(() => { @@ -49,6 +55,8 @@ describe("Params value extraction", () => { delete process.env.BAD_LIST; delete process.env.ESCAPED_LIST; delete process.env.A_SECRET_STRING; + delete process.env.STRIPE_CONFIG; + delete process.env.INVALID_JSON_SECRET; }); it("extracts identity params from the environment", () => { @@ -74,6 +82,14 @@ describe("Params value extraction", () => { expect(listParamWithEscapes.value()).to.deep.equal(["f\to\no"]); const secretParam = params.defineSecret("A_SECRET_STRING"); expect(secretParam.value()).to.equal("123456supersecret"); + + const jsonSecretParam = params.defineJsonSecret("STRIPE_CONFIG"); + const secretValue = jsonSecretParam.value(); + expect(secretValue).to.deep.equal({ + apiKey: "sk_test_123", + webhookSecret: "whsec_456", + clientId: "ca_789", + }); }); it("extracts the special case internal params from env.FIREBASE_CONFIG", () => { @@ -223,6 +239,96 @@ describe("Params value extraction", () => { }); }); +describe("defineJsonSecret", () => { + beforeEach(() => { + process.env.VALID_JSON = JSON.stringify({ key: "value", nested: { foo: "bar" } }); + process.env.INVALID_JSON = "not valid json{"; + process.env.EMPTY_OBJECT = JSON.stringify({}); + process.env.ARRAY_JSON = JSON.stringify([1, 2, 3]); + }); + + afterEach(() => { + params.clearParams(); + delete process.env.VALID_JSON; + delete process.env.INVALID_JSON; + delete process.env.EMPTY_OBJECT; + delete process.env.ARRAY_JSON; + delete process.env.FUNCTIONS_CONTROL_API; + }); + + it("parses valid JSON secrets correctly", () => { + const jsonSecret = params.defineJsonSecret("VALID_JSON"); + const value = jsonSecret.value(); + expect(value).to.deep.equal({ key: "value", nested: { foo: "bar" } }); + }); + + it("throws an error when JSON is invalid", () => { + const jsonSecret = params.defineJsonSecret("INVALID_JSON"); + expect(() => jsonSecret.value()).to.throw( + '"INVALID_JSON" could not be parsed as JSON. Please verify its value in Secret Manager.' + ); + }); + + it("throws an error when secret is not found", () => { + const jsonSecret = params.defineJsonSecret("NON_EXISTENT"); + expect(() => jsonSecret.value()).to.throw( + 'No value found for secret parameter "NON_EXISTENT". A function can only access a secret if you include the secret in the function\'s dependency array.' + ); + }); + + it("handles empty object JSON", () => { + const jsonSecret = params.defineJsonSecret("EMPTY_OBJECT"); + const value = jsonSecret.value(); + expect(value).to.deep.equal({}); + }); + + it("handles array JSON", () => { + const jsonSecret = params.defineJsonSecret("ARRAY_JSON"); + const value = jsonSecret.value(); + expect(value).to.deep.equal([1, 2, 3]); + }); + + it("throws an error when accessed during deployment", () => { + process.env.FUNCTIONS_CONTROL_API = "true"; + const jsonSecret = params.defineJsonSecret("VALID_JSON"); + expect(() => jsonSecret.value()).to.throw( + 'Cannot access the value of secret "VALID_JSON" during function deployment. Secret values are only available at runtime.' + ); + }); + + it("supports destructuring of JSON objects", () => { + process.env.STRIPE_CONFIG = JSON.stringify({ + apiKey: "sk_test_123", + webhookSecret: "whsec_456", + clientId: "ca_789", + }); + + const stripeConfig = params.defineJsonSecret("STRIPE_CONFIG"); + const { apiKey, webhookSecret, clientId } = stripeConfig.value(); + + expect(apiKey).to.equal("sk_test_123"); + expect(webhookSecret).to.equal("whsec_456"); + expect(clientId).to.equal("ca_789"); + + delete process.env.STRIPE_CONFIG; + }); + + it("registers the param in declaredParams", () => { + const initialLength = params.declaredParams.length; + const jsonSecret = params.defineJsonSecret("TEST_SECRET"); + expect(params.declaredParams.length).to.equal(initialLength + 1); + expect(params.declaredParams[params.declaredParams.length - 1]).to.equal(jsonSecret); + }); + + it("has correct type and format annotation in toSpec", () => { + const jsonSecret = params.defineJsonSecret("TEST_SECRET"); + const spec = jsonSecret.toSpec(); + expect(spec.type).to.equal("secret"); + expect(spec.name).to.equal("TEST_SECRET"); + expect(spec.format).to.equal("json"); + }); +}); + describe("Params as CEL", () => { it("internal expressions behave like strings", () => { const str = params.defineString("A_STRING"); diff --git a/src/params/index.ts b/src/params/index.ts index fadd36b54..046a51eaa 100644 --- a/src/params/index.ts +++ b/src/params/index.ts @@ -33,6 +33,7 @@ import { Param, ParamOptions, SecretParam, + JsonSecretParam, StringParam, ListParam, InternalExpression, @@ -50,7 +51,7 @@ export { export { ParamOptions, Expression }; -type SecretOrExpr = Param | SecretParam; +type SecretOrExpr = Param | SecretParam | JsonSecretParam; export const declaredParams: SecretOrExpr[] = []; /** @@ -123,6 +124,36 @@ export function defineSecret(name: string): SecretParam { return param; } +/** + * Declares a secret parameter that stores a structured JSON object in Cloud Secret Manager. + * This is useful for managing groups of related configuration values, such as all settings + * for a third-party API, as a single unit. + * + * The secret value must be valid JSON. At runtime, the value will be automatically parsed + * and returned as a JavaScript object. If the value is not valid JSON, an error will be thrown. + * + * @param name The name of the environment variable to use to load the parameter. + * @returns A parameter whose `.value()` method returns the parsed JSON object. + * + * @example + * ```typescript + * const stripeConfig = defineJsonSecret("STRIPE_CONFIG"); + * + * exports.myApi = onRequest( + * { secrets: [stripeConfig] }, + * (req, res) => { + * const { apiKey, webhookSecret, clientId } = stripeConfig.value(); + * // ... use the configuration values + * } + * ); + * ``` + */ +export function defineJsonSecret(name: string): JsonSecretParam { + const param = new JsonSecretParam(name); + registerParam(param); + return param; +} + /** * Declare a string parameter. * diff --git a/src/params/types.ts b/src/params/types.ts index 0d0413413..5664db99b 100644 --- a/src/params/types.ts +++ b/src/params/types.ts @@ -307,6 +307,8 @@ export type ParamSpec = { type: ParamValueType; /** The way in which the Firebase CLI will prompt for the value of this parameter. Defaults to a TextInput. */ input?: ParamInput; + /** Optional format annotation for additional type information (e.g., "json" for JSON-encoded secrets). */ + format?: string; }; /** @@ -324,6 +326,7 @@ export type WireParamSpec = { description?: string; type: ParamValueType; input?: ParamInput; + format?: string; }; /** Configuration options which can be used to customize the prompting behavior of a parameter. */ @@ -464,6 +467,59 @@ export class SecretParam { } } +/** + * A parametrized object whose value is stored as a JSON string in Cloud Secret Manager. + * This is useful for managing groups of related configuration values, such as all settings + * for a third-party API, as a single unit. Supply instances of JsonSecretParam to the + * secrets array while defining a Function to make their values accessible during execution + * of that Function. + */ +export class JsonSecretParam { + static type: ParamValueType = "secret"; + name: string; + + constructor(name: string) { + this.name = name; + } + + /** @internal */ + runtimeValue(): any { + const val = process.env[this.name]; + if (val === undefined) { + throw new Error( + `No value found for secret parameter "${this.name}". A function can only access a secret if you include the secret in the function's dependency array.` + ); + } + + try { + return JSON.parse(val); + } catch (error) { + throw new Error( + `"${this.name}" could not be parsed as JSON. Please verify its value in Secret Manager.` + ); + } + } + + /** @internal */ + toSpec(): ParamSpec { + return { + type: "secret", + name: this.name, + format: "json", + }; + } + + /** Returns the secret's parsed JSON value at runtime. Throws an error if accessed during deployment or if the value is not valid JSON. */ + value(): any { + if (process.env.FUNCTIONS_CONTROL_API === "true") { + throw new Error( + `Cannot access the value of secret "${this.name}" during function deployment. Secret values are only available at runtime.` + ); + } + return this.runtimeValue(); + } +} + /** * A parametrized value of String type that will be read from .env files * if present, or prompted for by the CLI if missing. From 00c62452cc2199f3d35480b5369b35615fcb8e3e Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Thu, 16 Oct 2025 13:00:10 -0700 Subject: [PATCH 2/6] Add changelog entry for defineJsonSecret API --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb..6461e7d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Add `defineJsonSecret` API for storing structured JSON objects in Cloud Secret Manager From 6e5d68b59a27fa419e9025266a9dffd96baf64ff Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Thu, 16 Oct 2025 14:34:50 -0700 Subject: [PATCH 3/6] Add generic type parameter to defineJsonSecret for type safety --- src/params/index.ts | 23 +++++++++++++++++------ src/params/types.ts | 8 ++++---- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/params/index.ts b/src/params/index.ts index 046a51eaa..6ee00b845 100644 --- a/src/params/index.ts +++ b/src/params/index.ts @@ -51,7 +51,7 @@ export { export { ParamOptions, Expression }; -type SecretOrExpr = Param | SecretParam | JsonSecretParam; +type SecretOrExpr = Param | SecretParam | JsonSecretParam; export const declaredParams: SecretOrExpr[] = []; /** @@ -129,27 +129,38 @@ export function defineSecret(name: string): SecretParam { * This is useful for managing groups of related configuration values, such as all settings * for a third-party API, as a single unit. * - * The secret value must be valid JSON. At runtime, the value will be automatically parsed - * and returned as a JavaScript object. If the value is not valid JSON, an error will be thrown. + * The secret value must be a valid JSON string. At runtime, the value will be automatically parsed + * and returned as a JavaScript object. If the value is not set or is not valid JSON, an error will be thrown. * * @param name The name of the environment variable to use to load the parameter. * @returns A parameter whose `.value()` method returns the parsed JSON object. * * @example * ```typescript + * // Without type parameter * const stripeConfig = defineJsonSecret("STRIPE_CONFIG"); + * const { apiKey, webhookSecret, clientId } = stripeConfig.value(); + * + * // With type parameter for type safety + * interface StripeConfig { + * apiKey: string; + * webhookSecret: string; + * clientId: string; + * } + * const stripeConfig = defineJsonSecret("STRIPE_CONFIG"); + * const { apiKey } = stripeConfig.value(); // apiKey is typed as string * * exports.myApi = onRequest( * { secrets: [stripeConfig] }, * (req, res) => { - * const { apiKey, webhookSecret, clientId } = stripeConfig.value(); + * const config = stripeConfig.value(); * // ... use the configuration values * } * ); * ``` */ -export function defineJsonSecret(name: string): JsonSecretParam { - const param = new JsonSecretParam(name); +export function defineJsonSecret(name: string): JsonSecretParam { + const param = new JsonSecretParam(name); registerParam(param); return param; } diff --git a/src/params/types.ts b/src/params/types.ts index 5664db99b..0143fed19 100644 --- a/src/params/types.ts +++ b/src/params/types.ts @@ -474,7 +474,7 @@ export class SecretParam { * secrets array while defining a Function to make their values accessible during execution * of that Function. */ -export class JsonSecretParam { +export class JsonSecretParam { static type: ParamValueType = "secret"; name: string; @@ -483,7 +483,7 @@ export class JsonSecretParam { } /** @internal */ - runtimeValue(): any { + runtimeValue(): T { const val = process.env[this.name]; if (val === undefined) { throw new Error( @@ -492,7 +492,7 @@ export class JsonSecretParam { } try { - return JSON.parse(val); + return JSON.parse(val) as T; } catch (error) { throw new Error( `"${this.name}" could not be parsed as JSON. Please verify its value in Secret Manager.` @@ -510,7 +510,7 @@ export class JsonSecretParam { } /** Returns the secret's parsed JSON value at runtime. Throws an error if accessed during deployment or if the value is not valid JSON. */ - value(): any { + value(): T { if (process.env.FUNCTIONS_CONTROL_API === "true") { throw new Error( `Cannot access the value of secret "${this.name}" during function deployment. Secret values are only available at runtime.` From 3284e98a1e797f9531ee2814fa13a3b2752fa19c Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Thu, 16 Oct 2025 15:56:45 -0700 Subject: [PATCH 4/6] Update src/params/types.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/params/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/params/types.ts b/src/params/types.ts index 0143fed19..c64120e4a 100644 --- a/src/params/types.ts +++ b/src/params/types.ts @@ -495,7 +495,7 @@ export class JsonSecretParam { return JSON.parse(val) as T; } catch (error) { throw new Error( - `"${this.name}" could not be parsed as JSON. Please verify its value in Secret Manager.` + `"${this.name}" could not be parsed as JSON. Please verify its value in Secret Manager. Details: ${error}` ); } } From 83d3e32beb628655d1a9627fddf1bdd35aceadd7 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Thu, 16 Oct 2025 15:56:52 -0700 Subject: [PATCH 5/6] Update src/params/types.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/params/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/params/types.ts b/src/params/types.ts index c64120e4a..e937e2e33 100644 --- a/src/params/types.ts +++ b/src/params/types.ts @@ -509,7 +509,7 @@ export class JsonSecretParam { }; } - /** Returns the secret's parsed JSON value at runtime. Throws an error if accessed during deployment or if the value is not valid JSON. */ + /** Returns the secret's parsed JSON value at runtime. Throws an error if accessed during deployment, if the secret is not set, or if the value is not valid JSON. */ value(): T { if (process.env.FUNCTIONS_CONTROL_API === "true") { throw new Error( From 551c3758f7f2f52b7ab928eab0effe8ac86be3d5 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Thu, 16 Oct 2025 17:52:54 -0700 Subject: [PATCH 6/6] nit: doc comments. --- src/params/index.ts | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/src/params/index.ts b/src/params/index.ts index 6ee00b845..3e9f35dfc 100644 --- a/src/params/index.ts +++ b/src/params/index.ts @@ -125,7 +125,7 @@ export function defineSecret(name: string): SecretParam { } /** - * Declares a secret parameter that stores a structured JSON object in Cloud Secret Manager. + * Declares a secret parameter that retrieves a structured JSON object in Cloud Secret Manager. * This is useful for managing groups of related configuration values, such as all settings * for a third-party API, as a single unit. * @@ -134,29 +134,6 @@ export function defineSecret(name: string): SecretParam { * * @param name The name of the environment variable to use to load the parameter. * @returns A parameter whose `.value()` method returns the parsed JSON object. - * - * @example - * ```typescript - * // Without type parameter - * const stripeConfig = defineJsonSecret("STRIPE_CONFIG"); - * const { apiKey, webhookSecret, clientId } = stripeConfig.value(); - * - * // With type parameter for type safety - * interface StripeConfig { - * apiKey: string; - * webhookSecret: string; - * clientId: string; - * } - * const stripeConfig = defineJsonSecret("STRIPE_CONFIG"); - * const { apiKey } = stripeConfig.value(); // apiKey is typed as string - * - * exports.myApi = onRequest( - * { secrets: [stripeConfig] }, - * (req, res) => { - * const config = stripeConfig.value(); - * // ... use the configuration values - * } - * ); * ``` */ export function defineJsonSecret(name: string): JsonSecretParam {