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 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..3e9f35dfc 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,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(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..e937e2e33 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(): 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 { + 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.