From aabc2ee9d48330c819834e3e445b5cb6592a7f81 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Thu, 30 Jul 2026 15:48:46 +0100 Subject: [PATCH 1/4] feat: accept RegExp values in Expression/thenElse for CORS options --- spec/params/params.spec.ts | 39 +++++++++++++++++++++++++++++++++ spec/v2/providers/https.spec.ts | 39 +++++++++++++++++++++++++++++++++ src/common/providers/https.ts | 2 ++ src/params/types.ts | 33 ++++++++++++++++++---------- src/v2/providers/https.ts | 8 +------ 5 files changed, 103 insertions(+), 18 deletions(-) diff --git a/spec/params/params.spec.ts b/spec/params/params.spec.ts index 19d18c83a..b84f7b94b 100644 --- a/spec/params/params.spec.ts +++ b/spec/params/params.spec.ts @@ -258,6 +258,29 @@ describe("Params value extraction", () => { expect(trueExpr.thenElse(twentytwo, 0).value()).to.equal(22); expect(falseExpr.thenElse(1, twentytwo).value()).to.equal(22); }); + + it("can select between RegExp/RegExp[] literals via a ternary expression", () => { + const localPattern = /^http:\/\/localhost:8080$/; + const prodPattern = /^https:\/\/example\.com$/; + const trueExpr = params.defineString("A_STRING").equals(params.defineString("SAME_STRING")); + const falseExpr = params.defineInt("AN_INT").equals(params.defineInt("DIFF_INT")); + + expect(trueExpr.thenElse(localPattern, prodPattern).value()).to.equal(localPattern); + expect(falseExpr.thenElse(localPattern, prodPattern).value()).to.equal(prodPattern); + + const localPatterns = [localPattern]; + const prodPatterns = [prodPattern]; + expect(trueExpr.thenElse(localPatterns, prodPatterns).value()).to.equal(localPatterns); + expect(falseExpr.thenElse(localPatterns, prodPatterns).value()).to.equal(prodPatterns); + + // Nested thenElse, mirroring a boolean-param-selected CORS origin config. + const stagingExpr = params.defineBoolean("TRUE"); + const nested = trueExpr.thenElse( + localPatterns, + stagingExpr.thenElse(prodPatterns, [/^https:\/\/other\.example\.com$/]) + ); + expect(nested.value()).to.equal(localPatterns); + }); }); describe("defineJsonSecret", () => { @@ -457,6 +480,22 @@ describe("Params as CEL", () => { cmpExpr.thenElse(params.defineString("FOO"), params.defineString("BAR")).toCEL() ).to.equal("{{ params.A != params.B ? params.FOO : params.BAR }}"); }); + + it("represents RegExp array branches as their string form, not '{}'", () => { + const booleanExpr = params.defineBoolean("BOOL"); + const localPattern = /^http:\/\/localhost$/; + const prodPattern = /^https:\/\/example\.com$/; + const cel = booleanExpr.thenElse([localPattern], [prodPattern]).toCEL(); + + // Regression check: JSON.stringify(regexArray) alone would render each RegExp + // as "{}", silently dropping the pattern. + expect(cel).to.not.include("{}"); + expect(cel).to.equal( + `{{ params.BOOL ? ${JSON.stringify([localPattern.toString()])} : ${JSON.stringify([ + prodPattern.toString(), + ])} }}` + ); + }); }); describe("expr template tag", () => { diff --git a/spec/v2/providers/https.spec.ts b/spec/v2/providers/https.spec.ts index 85930c36c..b75088b96 100644 --- a/spec/v2/providers/https.spec.ts +++ b/spec/v2/providers/https.spec.ts @@ -307,6 +307,45 @@ describe("onRequest", () => { } }); + it("should allow a RegExp[] chosen dynamically via a ternary expression", async () => { + const isStaging = defineBoolean("IS_STAGING"); + const localPattern = /^http:\/\/localhost:8080$/; + const stagingPattern = /^https:\/\/staging\.example\.com$/; + + try { + process.env.IS_STAGING = "true"; + const func = https.onRequest( + { + cors: isStaging.equals(true).thenElse([stagingPattern], [localPattern]), + }, + (req, res) => { + res.send("42"); + } + ); + const req = request({ + headers: { + referrer: "https://staging.example.com", + "content-type": "application/json", + origin: "https://staging.example.com", + }, + method: "OPTIONS", + }); + + const response = await runHandler(func, req); + + expect(response.status).to.equal(204); + expect(response.headers).to.deep.equal({ + "Access-Control-Allow-Origin": "https://staging.example.com", + "Access-Control-Allow-Methods": "GET,HEAD,PUT,PATCH,POST,DELETE", + "Content-Length": "0", + Vary: "Origin, Access-Control-Request-Headers", + }); + } finally { + delete process.env.IS_STAGING; + clearParams(); + } + }); + it("should add CORS headers if debug feature is enabled", async () => { sinon.stub(debug, "isDebugFeatureEnabled").withArgs("enableCors").returns(true); diff --git a/src/common/providers/https.ts b/src/common/providers/https.ts index c636067a7..04f871630 100644 --- a/src/common/providers/https.ts +++ b/src/common/providers/https.ts @@ -715,6 +715,8 @@ export type CorsOption = | string | Expression | Expression + | Expression + | Expression> | boolean | RegExp | Array; diff --git a/src/params/types.ts b/src/params/types.ts index c1e834b80..d00f9d829 100644 --- a/src/params/types.ts +++ b/src/params/types.ts @@ -29,7 +29,9 @@ const EXPRESSION_TAG = Symbol.for("firebase-functions:Expression:Tag"); * resolved to a value of the generic type parameter: i.e, you can pass * an Expression as the value of an option that normally accepts numbers. */ -export abstract class Expression { +export abstract class Expression< + T extends string | number | boolean | string[] | RegExp | Array +> { /** * Handle the "Dual-Package Hazard" . * @@ -144,13 +146,15 @@ export class TransformedStringExpression extends Expression { } } -export function valueOf(arg: T | Expression): T { +export function valueOf< + T extends string | number | boolean | string[] | RegExp | Array +>(arg: T | Expression): T { return arg instanceof Expression ? arg.runtimeValue() : arg; } -export function celOf( - arg: T | Expression -): T | string { +export function celOf< + T extends string | number | boolean | string[] | RegExp | Array +>(arg: T | Expression): T | string { return arg instanceof Expression ? arg.toCEL() : arg; } @@ -171,13 +175,17 @@ export function transform( * - Arrays are represented as []-delimited, parsable JSON * - Numbers and booleans are not quoted explicitly */ -function refOf(arg: T | Expression): string { +function refOf>( + arg: T | Expression +): string { if (arg instanceof Expression) { return arg.toString(); } else if (typeof arg === "string") { return `"${arg}"`; } else if (Array.isArray(arg)) { - return JSON.stringify(arg); + // RegExp has no useful JSON representation (JSON.stringify(/foo/) === "{}"), + // so fall back to its string form instead of silently dropping the pattern. + return JSON.stringify(arg.map((item) => (item instanceof RegExp ? item.toString() : item))); } else { return arg.toString(); } @@ -187,7 +195,7 @@ function refOf(arg: T | Expressi * A CEL expression corresponding to a ternary operator, e.g {{ cond ? ifTrue : ifFalse }} */ export class TernaryExpression< - T extends string | number | boolean | string[] + T extends string | number | boolean | string[] | RegExp | Array > extends Expression { constructor( private readonly test: Expression, @@ -263,7 +271,7 @@ export class CompareExpression< } /** Returns a `TernaryExpression` which can resolve to one of two values, based on the resolution of this comparison. */ - thenElse( + thenElse>( ifTrue: retT | Expression, ifFalse: retT | Expression ) { @@ -719,11 +727,14 @@ export class BooleanParam extends Param { } /** @deprecated */ - then(ifTrue: T | Expression, ifFalse: T | Expression) { + then>( + ifTrue: T | Expression, + ifFalse: T | Expression + ) { return this.thenElse(ifTrue, ifFalse); } - thenElse( + thenElse>( ifTrue: T | Expression, ifFalse: T | Expression ) { diff --git a/src/v2/providers/https.ts b/src/v2/providers/https.ts index 8aa9438ec..43e6dec53 100644 --- a/src/v2/providers/https.ts +++ b/src/v2/providers/https.ts @@ -76,13 +76,7 @@ export interface HttpsOptions extends Omit - | Expression - | boolean - | RegExp - | Array; + cors?: CorsOption; /** * Amount of memory to allocate to a function. From 7d119b54ef6d610c0269822aed51b0ace996ca1a Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Thu, 30 Jul 2026 16:02:17 +0100 Subject: [PATCH 2/4] fix: quote bare RegExp branches in refOf so CEL stays valid A single RegExp (not wrapped in an array) passed to thenElse fell through to arg.toString(), producing an unquoted /pattern/ in the generated CEL string, which is invalid and fails at deploy time. --- spec/params/params.spec.ts | 15 +++++++++++++++ src/params/types.ts | 2 ++ 2 files changed, 17 insertions(+) diff --git a/spec/params/params.spec.ts b/spec/params/params.spec.ts index b84f7b94b..edd6dde1d 100644 --- a/spec/params/params.spec.ts +++ b/spec/params/params.spec.ts @@ -496,6 +496,21 @@ describe("Params as CEL", () => { ])} }}` ); }); + + it("represents a bare RegExp branch as a quoted string form, not unquoted source", () => { + const booleanExpr = params.defineBoolean("BOOL"); + const localPattern = /^http:\/\/localhost$/; + const prodPattern = /^https:\/\/example\.com$/; + const cel = booleanExpr.thenElse(localPattern, prodPattern).toCEL(); + + // Regression check: arg.toString() alone would render the RegExp as an + // unquoted /pattern/, which is not a valid CEL string literal. + expect(cel).to.equal( + `{{ params.BOOL ? ${JSON.stringify(localPattern.toString())} : ${JSON.stringify( + prodPattern.toString() + )} }}` + ); + }); }); describe("expr template tag", () => { diff --git a/src/params/types.ts b/src/params/types.ts index d00f9d829..4e9dbc1a8 100644 --- a/src/params/types.ts +++ b/src/params/types.ts @@ -182,6 +182,8 @@ function refOf Date: Thu, 6 Aug 2026 14:16:38 +0100 Subject: [PATCH 3/4] refactor: extract ExpressionValue alias and strengthen RegExp tests Replace the eight inline copies of the Expression type bound with a single exported ExpressionValue alias, so future additions to the set of values an Expression can resolve to only need editing in one place. Also close two gaps in the new tests: - the nested thenElse case resolved the outer true branch, so the nested expression was never evaluated. Drive it from the false branch instead and assert both inner branches. - the onRequest CORS case only asserted the true branch, so an implementation that always returned ifTrue would have passed. Add the false-branch case, asserting the non-matching origin is not allowed. --- spec/params/params.spec.ts | 15 +++++++---- spec/v2/providers/https.spec.ts | 44 +++++++++++++++++++++++++++++++ src/params/index.ts | 1 + src/params/types.ts | 46 ++++++++++++++++----------------- 4 files changed, 77 insertions(+), 29 deletions(-) diff --git a/spec/params/params.spec.ts b/spec/params/params.spec.ts index edd6dde1d..36dd96f5b 100644 --- a/spec/params/params.spec.ts +++ b/spec/params/params.spec.ts @@ -274,12 +274,17 @@ describe("Params value extraction", () => { expect(falseExpr.thenElse(localPatterns, prodPatterns).value()).to.equal(prodPatterns); // Nested thenElse, mirroring a boolean-param-selected CORS origin config. + // The outer test is false so that the nested expression is the one resolved. + const otherPatterns = [/^https:\/\/other\.example\.com$/]; const stagingExpr = params.defineBoolean("TRUE"); - const nested = trueExpr.thenElse( - localPatterns, - stagingExpr.thenElse(prodPatterns, [/^https:\/\/other\.example\.com$/]) - ); - expect(nested.value()).to.equal(localPatterns); + expect( + falseExpr.thenElse(localPatterns, stagingExpr.thenElse(prodPatterns, otherPatterns)).value() + ).to.equal(prodPatterns); + expect( + falseExpr + .thenElse(localPatterns, stagingExpr.equals(false).thenElse(prodPatterns, otherPatterns)) + .value() + ).to.equal(otherPatterns); }); }); diff --git a/spec/v2/providers/https.spec.ts b/spec/v2/providers/https.spec.ts index b75088b96..f4a91dcac 100644 --- a/spec/v2/providers/https.spec.ts +++ b/spec/v2/providers/https.spec.ts @@ -346,6 +346,50 @@ describe("onRequest", () => { } }); + it("should resolve the other RegExp[] branch when the ternary expression is false", async () => { + const isStaging = defineBoolean("IS_STAGING"); + const localPattern = /^http:\/\/localhost:8080$/; + const stagingPattern = /^https:\/\/staging\.example\.com$/; + + try { + process.env.IS_STAGING = "false"; + const func = https.onRequest( + { + cors: isStaging.equals(true).thenElse([stagingPattern], [localPattern]), + }, + (req, res) => { + res.send("42"); + } + ); + const preflight = (origin: string) => + request({ + headers: { + referrer: origin, + "content-type": "application/json", + origin, + }, + method: "OPTIONS", + }); + + const allowed = await runHandler(func, preflight("http://localhost:8080")); + + expect(allowed.status).to.equal(204); + expect(allowed.headers).to.deep.equal({ + "Access-Control-Allow-Origin": "http://localhost:8080", + "Access-Control-Allow-Methods": "GET,HEAD,PUT,PATCH,POST,DELETE", + "Content-Length": "0", + Vary: "Origin, Access-Control-Request-Headers", + }); + + const denied = await runHandler(func, preflight("https://staging.example.com")); + + expect(denied.headers).to.not.have.property("Access-Control-Allow-Origin"); + } finally { + delete process.env.IS_STAGING; + clearParams(); + } + }); + it("should add CORS headers if debug feature is enabled", async () => { sinon.stub(debug, "isDebugFeatureEnabled").withArgs("enableCors").returns(true); diff --git a/src/params/index.ts b/src/params/index.ts index e824f61fa..e130e9251 100644 --- a/src/params/index.ts +++ b/src/params/index.ts @@ -47,6 +47,7 @@ export type { SelectInput, SelectOptions, MultiSelectInput, + ExpressionValue, Param, SecretParam, JsonSecretParam, diff --git a/src/params/types.ts b/src/params/types.ts index 4e9dbc1a8..11e0a5678 100644 --- a/src/params/types.ts +++ b/src/params/types.ts @@ -24,14 +24,26 @@ import * as logger from "../logger"; const EXPRESSION_TAG = Symbol.for("firebase-functions:Expression:Tag"); +/** + * The types an `Expression` can resolve to. `string`, `number`, `boolean` and + * `string[]` cover the values a param itself can hold; `RegExp` and + * `Array` are additionally allowed so that expressions can + * select between literals for options that accept them, such as `cors`. + */ +export type ExpressionValue = + | string + | number + | boolean + | string[] + | RegExp + | Array; + /* * A CEL expression which can be evaluated during function deployment, and * resolved to a value of the generic type parameter: i.e, you can pass * an Expression as the value of an option that normally accepts numbers. */ -export abstract class Expression< - T extends string | number | boolean | string[] | RegExp | Array -> { +export abstract class Expression { /** * Handle the "Dual-Package Hazard" . * @@ -146,15 +158,11 @@ export class TransformedStringExpression extends Expression { } } -export function valueOf< - T extends string | number | boolean | string[] | RegExp | Array ->(arg: T | Expression): T { +export function valueOf(arg: T | Expression): T { return arg instanceof Expression ? arg.runtimeValue() : arg; } -export function celOf< - T extends string | number | boolean | string[] | RegExp | Array ->(arg: T | Expression): T | string { +export function celOf(arg: T | Expression): T | string { return arg instanceof Expression ? arg.toCEL() : arg; } @@ -175,9 +183,7 @@ export function transform( * - Arrays are represented as []-delimited, parsable JSON * - Numbers and booleans are not quoted explicitly */ -function refOf>( - arg: T | Expression -): string { +function refOf(arg: T | Expression): string { if (arg instanceof Expression) { return arg.toString(); } else if (typeof arg === "string") { @@ -196,9 +202,7 @@ function refOf -> extends Expression { +export class TernaryExpression extends Expression { constructor( private readonly test: Expression, private readonly ifTrue: T | Expression, @@ -273,7 +277,7 @@ export class CompareExpression< } /** Returns a `TernaryExpression` which can resolve to one of two values, based on the resolution of this comparison. */ - thenElse>( + thenElse( ifTrue: retT | Expression, ifFalse: retT | Expression ) { @@ -729,17 +733,11 @@ export class BooleanParam extends Param { } /** @deprecated */ - then>( - ifTrue: T | Expression, - ifFalse: T | Expression - ) { + then(ifTrue: T | Expression, ifFalse: T | Expression) { return this.thenElse(ifTrue, ifFalse); } - thenElse>( - ifTrue: T | Expression, - ifFalse: T | Expression - ) { + thenElse(ifTrue: T | Expression, ifFalse: T | Expression) { return new TernaryExpression(this, ifTrue, ifFalse); } } From adea727989fc377bc1974d45fea6a4eac76b85ae Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 24 Aug 2026 20:16:22 +0100 Subject: [PATCH 4/4] chore: export CorsOption from v1/v2 https and document RegExp in refOf CorsOption is referenced by HttpsOptions.cors in both providers but was not in either module's export list, so api-extractor reported ae-forgotten-export and the docs rendered the type unlinked. --- src/params/types.ts | 1 + src/v1/providers/https.ts | 2 +- src/v2/providers/https.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/params/types.ts b/src/params/types.ts index 11e0a5678..97d3c2c12 100644 --- a/src/params/types.ts +++ b/src/params/types.ts @@ -181,6 +181,7 @@ export function transform( * - Expressions delegate to the `.toString()` method, which is used by the WireManifest * - Strings have to be quoted explicitly * - Arrays are represented as []-delimited, parsable JSON + * - RegExps are emitted as their quoted `toString()` form, e.g. `"/foo$/"` * - Numbers and booleans are not quoted explicitly */ function refOf(arg: T | Expression): string { diff --git a/src/v1/providers/https.ts b/src/v1/providers/https.ts index b51bd8537..d430203a2 100644 --- a/src/v1/providers/https.ts +++ b/src/v1/providers/https.ts @@ -42,7 +42,7 @@ import { withInit } from "../../common/onInit"; import { wrapTraceContext } from "../../v2/trace"; export { HttpsError }; -export type { Request, CallableContext, FunctionsErrorCode }; +export type { Request, CallableContext, FunctionsErrorCode, CorsOption }; export interface HttpsOptions { /** diff --git a/src/v2/providers/https.ts b/src/v2/providers/https.ts index 43e6dec53..be9e03db4 100644 --- a/src/v2/providers/https.ts +++ b/src/v2/providers/https.ts @@ -51,7 +51,7 @@ import * as options from "../options"; import { withInit } from "../../common/onInit"; import * as logger from "../../logger"; -export type { Request, CallableRequest, CallableResponse, FunctionsErrorCode }; +export type { Request, CallableRequest, CallableResponse, FunctionsErrorCode, CorsOption }; export { HttpsError }; /**