Skip to content

Commit bc7c89a

Browse files
Add defineJsonSecret API for structured secret configuration (#1745)
* 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) * Add changelog entry for defineJsonSecret API * Add generic type parameter to defineJsonSecret for type safety * Update src/params/types.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/params/types.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * nit: doc comments. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent f2099ec commit bc7c89a

File tree

4 files changed

+183
-1
lines changed

4 files changed

+183
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Add `defineJsonSecret` API for storing structured JSON objects in Cloud Secret Manager

spec/params/params.spec.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ describe("Params value extraction", () => {
3131
process.env.BAD_LIST = JSON.stringify(["a", 22, "c"]);
3232
process.env.ESCAPED_LIST = JSON.stringify(["f\to\no"]);
3333
process.env.A_SECRET_STRING = "123456supersecret";
34+
process.env.STRIPE_CONFIG = JSON.stringify({
35+
apiKey: "sk_test_123",
36+
webhookSecret: "whsec_456",
37+
clientId: "ca_789",
38+
});
39+
process.env.INVALID_JSON_SECRET = "not valid json{";
3440
});
3541

3642
afterEach(() => {
@@ -49,6 +55,8 @@ describe("Params value extraction", () => {
4955
delete process.env.BAD_LIST;
5056
delete process.env.ESCAPED_LIST;
5157
delete process.env.A_SECRET_STRING;
58+
delete process.env.STRIPE_CONFIG;
59+
delete process.env.INVALID_JSON_SECRET;
5260
});
5361

5462
it("extracts identity params from the environment", () => {
@@ -74,6 +82,14 @@ describe("Params value extraction", () => {
7482
expect(listParamWithEscapes.value()).to.deep.equal(["f\to\no"]);
7583
const secretParam = params.defineSecret("A_SECRET_STRING");
7684
expect(secretParam.value()).to.equal("123456supersecret");
85+
86+
const jsonSecretParam = params.defineJsonSecret("STRIPE_CONFIG");
87+
const secretValue = jsonSecretParam.value();
88+
expect(secretValue).to.deep.equal({
89+
apiKey: "sk_test_123",
90+
webhookSecret: "whsec_456",
91+
clientId: "ca_789",
92+
});
7793
});
7894

7995
it("extracts the special case internal params from env.FIREBASE_CONFIG", () => {
@@ -223,6 +239,96 @@ describe("Params value extraction", () => {
223239
});
224240
});
225241

242+
describe("defineJsonSecret", () => {
243+
beforeEach(() => {
244+
process.env.VALID_JSON = JSON.stringify({ key: "value", nested: { foo: "bar" } });
245+
process.env.INVALID_JSON = "not valid json{";
246+
process.env.EMPTY_OBJECT = JSON.stringify({});
247+
process.env.ARRAY_JSON = JSON.stringify([1, 2, 3]);
248+
});
249+
250+
afterEach(() => {
251+
params.clearParams();
252+
delete process.env.VALID_JSON;
253+
delete process.env.INVALID_JSON;
254+
delete process.env.EMPTY_OBJECT;
255+
delete process.env.ARRAY_JSON;
256+
delete process.env.FUNCTIONS_CONTROL_API;
257+
});
258+
259+
it("parses valid JSON secrets correctly", () => {
260+
const jsonSecret = params.defineJsonSecret("VALID_JSON");
261+
const value = jsonSecret.value();
262+
expect(value).to.deep.equal({ key: "value", nested: { foo: "bar" } });
263+
});
264+
265+
it("throws an error when JSON is invalid", () => {
266+
const jsonSecret = params.defineJsonSecret("INVALID_JSON");
267+
expect(() => jsonSecret.value()).to.throw(
268+
'"INVALID_JSON" could not be parsed as JSON. Please verify its value in Secret Manager.'
269+
);
270+
});
271+
272+
it("throws an error when secret is not found", () => {
273+
const jsonSecret = params.defineJsonSecret("NON_EXISTENT");
274+
expect(() => jsonSecret.value()).to.throw(
275+
'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.'
276+
);
277+
});
278+
279+
it("handles empty object JSON", () => {
280+
const jsonSecret = params.defineJsonSecret("EMPTY_OBJECT");
281+
const value = jsonSecret.value();
282+
expect(value).to.deep.equal({});
283+
});
284+
285+
it("handles array JSON", () => {
286+
const jsonSecret = params.defineJsonSecret("ARRAY_JSON");
287+
const value = jsonSecret.value();
288+
expect(value).to.deep.equal([1, 2, 3]);
289+
});
290+
291+
it("throws an error when accessed during deployment", () => {
292+
process.env.FUNCTIONS_CONTROL_API = "true";
293+
const jsonSecret = params.defineJsonSecret("VALID_JSON");
294+
expect(() => jsonSecret.value()).to.throw(
295+
'Cannot access the value of secret "VALID_JSON" during function deployment. Secret values are only available at runtime.'
296+
);
297+
});
298+
299+
it("supports destructuring of JSON objects", () => {
300+
process.env.STRIPE_CONFIG = JSON.stringify({
301+
apiKey: "sk_test_123",
302+
webhookSecret: "whsec_456",
303+
clientId: "ca_789",
304+
});
305+
306+
const stripeConfig = params.defineJsonSecret("STRIPE_CONFIG");
307+
const { apiKey, webhookSecret, clientId } = stripeConfig.value();
308+
309+
expect(apiKey).to.equal("sk_test_123");
310+
expect(webhookSecret).to.equal("whsec_456");
311+
expect(clientId).to.equal("ca_789");
312+
313+
delete process.env.STRIPE_CONFIG;
314+
});
315+
316+
it("registers the param in declaredParams", () => {
317+
const initialLength = params.declaredParams.length;
318+
const jsonSecret = params.defineJsonSecret("TEST_SECRET");
319+
expect(params.declaredParams.length).to.equal(initialLength + 1);
320+
expect(params.declaredParams[params.declaredParams.length - 1]).to.equal(jsonSecret);
321+
});
322+
323+
it("has correct type and format annotation in toSpec", () => {
324+
const jsonSecret = params.defineJsonSecret("TEST_SECRET");
325+
const spec = jsonSecret.toSpec();
326+
expect(spec.type).to.equal("secret");
327+
expect(spec.name).to.equal("TEST_SECRET");
328+
expect(spec.format).to.equal("json");
329+
});
330+
});
331+
226332
describe("Params as CEL", () => {
227333
it("internal expressions behave like strings", () => {
228334
const str = params.defineString("A_STRING");

src/params/index.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
Param,
3434
ParamOptions,
3535
SecretParam,
36+
JsonSecretParam,
3637
StringParam,
3738
ListParam,
3839
InternalExpression,
@@ -50,7 +51,7 @@ export {
5051

5152
export { ParamOptions, Expression };
5253

53-
type SecretOrExpr = Param<any> | SecretParam;
54+
type SecretOrExpr = Param<any> | SecretParam | JsonSecretParam<any>;
5455
export const declaredParams: SecretOrExpr[] = [];
5556

5657
/**
@@ -123,6 +124,24 @@ export function defineSecret(name: string): SecretParam {
123124
return param;
124125
}
125126

127+
/**
128+
* Declares a secret parameter that retrieves a structured JSON object in Cloud Secret Manager.
129+
* This is useful for managing groups of related configuration values, such as all settings
130+
* for a third-party API, as a single unit.
131+
*
132+
* The secret value must be a valid JSON string. At runtime, the value will be automatically parsed
133+
* and returned as a JavaScript object. If the value is not set or is not valid JSON, an error will be thrown.
134+
*
135+
* @param name The name of the environment variable to use to load the parameter.
136+
* @returns A parameter whose `.value()` method returns the parsed JSON object.
137+
* ```
138+
*/
139+
export function defineJsonSecret<T = any>(name: string): JsonSecretParam<T> {
140+
const param = new JsonSecretParam<T>(name);
141+
registerParam(param);
142+
return param;
143+
}
144+
126145
/**
127146
* Declare a string parameter.
128147
*

src/params/types.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,8 @@ export type ParamSpec<T extends string | number | boolean | string[]> = {
307307
type: ParamValueType;
308308
/** The way in which the Firebase CLI will prompt for the value of this parameter. Defaults to a TextInput. */
309309
input?: ParamInput<T>;
310+
/** Optional format annotation for additional type information (e.g., "json" for JSON-encoded secrets). */
311+
format?: string;
310312
};
311313

312314
/**
@@ -324,6 +326,7 @@ export type WireParamSpec<T extends string | number | boolean | string[]> = {
324326
description?: string;
325327
type: ParamValueType;
326328
input?: ParamInput<T>;
329+
format?: string;
327330
};
328331

329332
/** Configuration options which can be used to customize the prompting behavior of a parameter. */
@@ -464,6 +467,59 @@ export class SecretParam {
464467
}
465468
}
466469

470+
/**
471+
* A parametrized object whose value is stored as a JSON string in Cloud Secret Manager.
472+
* This is useful for managing groups of related configuration values, such as all settings
473+
* for a third-party API, as a single unit. Supply instances of JsonSecretParam to the
474+
* secrets array while defining a Function to make their values accessible during execution
475+
* of that Function.
476+
*/
477+
export class JsonSecretParam<T = any> {
478+
static type: ParamValueType = "secret";
479+
name: string;
480+
481+
constructor(name: string) {
482+
this.name = name;
483+
}
484+
485+
/** @internal */
486+
runtimeValue(): T {
487+
const val = process.env[this.name];
488+
if (val === undefined) {
489+
throw new Error(
490+
`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.`
491+
);
492+
}
493+
494+
try {
495+
return JSON.parse(val) as T;
496+
} catch (error) {
497+
throw new Error(
498+
`"${this.name}" could not be parsed as JSON. Please verify its value in Secret Manager. Details: ${error}`
499+
);
500+
}
501+
}
502+
503+
/** @internal */
504+
toSpec(): ParamSpec<string> {
505+
return {
506+
type: "secret",
507+
name: this.name,
508+
format: "json",
509+
};
510+
}
511+
512+
/** 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. */
513+
value(): T {
514+
if (process.env.FUNCTIONS_CONTROL_API === "true") {
515+
throw new Error(
516+
`Cannot access the value of secret "${this.name}" during function deployment. Secret values are only available at runtime.`
517+
);
518+
}
519+
return this.runtimeValue();
520+
}
521+
}
522+
467523
/**
468524
* A parametrized value of String type that will be read from .env files
469525
* if present, or prompted for by the CLI if missing.

0 commit comments

Comments
 (0)