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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add `defineJsonSecret` API for storing structured JSON objects in Cloud Secret Manager
106 changes: 106 additions & 0 deletions spec/params/params.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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({});
});
Comment on lines +279 to +283
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this the behavior we want? maybe we should error on empty JSON

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that user would've have to store {} in SCM, I feel kinda okay not throwing.


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");
Expand Down
21 changes: 20 additions & 1 deletion src/params/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
Param,
ParamOptions,
SecretParam,
JsonSecretParam,
StringParam,
ListParam,
InternalExpression,
Expand All @@ -50,7 +51,7 @@ export {

export { ParamOptions, Expression };

type SecretOrExpr = Param<any> | SecretParam;
type SecretOrExpr = Param<any> | SecretParam | JsonSecretParam<any>;
export const declaredParams: SecretOrExpr[] = [];

/**
Expand Down Expand Up @@ -123,6 +124,24 @@ export function defineSecret(name: string): SecretParam {
return param;
}

/**
* 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.
*
* 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.
* ```
*/
export function defineJsonSecret<T = any>(name: string): JsonSecretParam<T> {
const param = new JsonSecretParam<T>(name);
registerParam(param);
return param;
}

/**
* Declare a string parameter.
*
Expand Down
56 changes: 56 additions & 0 deletions src/params/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,8 @@ export type ParamSpec<T extends string | number | boolean | string[]> = {
type: ParamValueType;
/** The way in which the Firebase CLI will prompt for the value of this parameter. Defaults to a TextInput. */
input?: ParamInput<T>;
/** Optional format annotation for additional type information (e.g., "json" for JSON-encoded secrets). */
format?: string;
};

/**
Expand All @@ -324,6 +326,7 @@ export type WireParamSpec<T extends string | number | boolean | string[]> = {
description?: string;
type: ParamValueType;
input?: ParamInput<T>;
format?: string;
};

/** Configuration options which can be used to customize the prompting behavior of a parameter. */
Expand Down Expand Up @@ -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<T = any> {
static type: ParamValueType = "secret";
name: string;

constructor(name: string) {
this.name = name;
}

/** @internal */
runtimeValue(): T {
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) as T;
} catch (error) {
throw new Error(
`"${this.name}" could not be parsed as JSON. Please verify its value in Secret Manager. Details: ${error}`
);
}
}

/** @internal */
toSpec(): ParamSpec<string> {
return {
type: "secret",
name: this.name,
format: "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(
`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.
Expand Down
Loading