From 9be208d893d89a1060395d329be4413fc17f4565 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 20 Oct 2026 16:28:12 +0100 Subject: [PATCH 1/7] fix(delete-user-data): trim event types in EXT_SELECTED_EVENTS firebase-admin splits a string allowlist on commas without trimming, so a hand-written list with spaces after the commas only allowed the first type. Split and trim the list before opening the channel, and treat a whitespace-only value as unset. --- kits/delete-user-data/CHANGELOG.md | 2 ++ kits/delete-user-data/README.md | 7 ++++--- kits/delete-user-data/src/events.ts | 11 +++++++++-- kits/delete-user-data/tests/events.test.ts | 23 +++++++++++++++++++--- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/kits/delete-user-data/CHANGELOG.md b/kits/delete-user-data/CHANGELOG.md index 0e40c6a6f..ea2f2f613 100644 --- a/kits/delete-user-data/CHANGELOG.md +++ b/kits/delete-user-data/CHANGELOG.md @@ -1,3 +1,5 @@ +- fix: spaces in `EXT_SELECTED_EVENTS` no longer drop event types. firebase-admin splits the list on commas without trimming, so `a, b, c` published only `a` and silently dropped the rest. The kit now trims each type before opening the channel, and a whitespace-only value is treated as unset, so nothing is published. The extension never hit this because the platform wrote the list without spaces. + - fix: an Eventarc publish failure no longer fails the function. A channel that was deleted or is unreachable answers `PERMISSION_DENIED`, and the publish was awaited after the work, so the deletion completed but the invocation still failed. The publish failure is now logged as a warning and the invocation continues. Deploys also declare `eventarcpublishing.googleapis.com`, so the API is enabled on your project whether or not you use events. The failed invocation used to be redelivered, re-running a deletion that had already succeeded; it no longer is. - fix: `ENABLE_AUTO_DISCOVERY` accepts the extension's `yes` / `no` values again. The kit had declared it as a boolean param, which only counts the literal `true` as enabled, so a `.env` copied from an installed extension instance carried `yes` and silently left auto-discovery switched off. The param is now the extension's labeled `Yes` / `No` select and is read the same way the extension read it. diff --git a/kits/delete-user-data/README.md b/kits/delete-user-data/README.md index 465d7a7cd..a13bae5bc 100644 --- a/kits/delete-user-data/README.md +++ b/kits/delete-user-data/README.md @@ -155,9 +155,10 @@ The functions can publish deletion events for each backend under | `EVENTARC_CHANNEL` | Full channel name, e.g. `projects//locations/us-central1/channels/firebase` | | `EXT_SELECTED_EVENTS` | Comma-separated event types to publish, e.g. `firebase.extensions.delete-user-data.v1.firestore,firebase.extensions.delete-user-data.v1.storage` | -Only the listed types are published. If `EXT_SELECTED_EVENTS` is unset or -empty, nothing is published, which matches an extension instance installed with -no event types selected. +Only the listed types are published. Spaces around each type are ignored, so +`a, b` works the same as `a,b`. If `EXT_SELECTED_EVENTS` is unset, empty or +only whitespace, nothing is published, which matches an extension instance +installed with no event types selected. The channel must already exist. Deploying the kit does not create it, and publishing to a missing channel logs a warning without failing the function. diff --git a/kits/delete-user-data/src/events.ts b/kits/delete-user-data/src/events.ts index 4be640340..4d5fe9b26 100644 --- a/kits/delete-user-data/src/events.ts +++ b/kits/delete-user-data/src/events.ts @@ -26,13 +26,20 @@ let eventChannel: eventArc.Channel | undefined; * selected, the platform cleared the channel and nothing was published. * firebase-admin reads a missing allowlist as "allow every type", so an unset * or empty `EXT_SELECTED_EVENTS` has to disable publishing here. + * + * The list is split and trimmed here because firebase-admin splits a string + * on `,` without trimming, so a hand-written `a, b` would only allow `a`. The + * platform wrote the extension's value without spaces. */ export const setupEventChannel = (): void => { const channel = process.env.EVENTARC_CHANNEL; - const selectedEvents = process.env.EXT_SELECTED_EVENTS; + const selectedEvents = (process.env.EXT_SELECTED_EVENTS ?? "") + .split(",") + .map((type) => type.trim()) + .filter(Boolean); eventChannel = - channel && selectedEvents + channel && selectedEvents.length > 0 ? eventArc.getEventarc().channel(channel, { allowedEventTypes: selectedEvents, }) diff --git a/kits/delete-user-data/tests/events.test.ts b/kits/delete-user-data/tests/events.test.ts index 87004ff8d..b80ab6010 100644 --- a/kits/delete-user-data/tests/events.test.ts +++ b/kits/delete-user-data/tests/events.test.ts @@ -26,11 +26,12 @@ vi.mock("firebase-admin/eventarc", () => ({ const CHANNEL = "projects/p/locations/l/channels/firebase"; -const ALL_EVENTS = [ +const ALL_EVENT_TYPES = [ "firebase.extensions.delete-user-data.v1.firestore", "firebase.extensions.delete-user-data.v1.database", "firebase.extensions.delete-user-data.v1.storage", -].join(","); +]; +const ALL_EVENTS = ALL_EVENT_TYPES.join(","); // `null` unsets the variable; `undefined` would trip importEvents' default. function setEnv(name: string, value: string | null | undefined) { @@ -73,7 +74,21 @@ describe("setupEventChannel", () => { ); expect(channel).toHaveBeenCalledWith(CHANNEL, { - allowedEventTypes: "firebase.extensions.delete-user-data.v1.firestore", + allowedEventTypes: ["firebase.extensions.delete-user-data.v1.firestore"], + }); + }); + + // firebase-admin splits a string allowlist on "," without trimming, so + // "a, b" would allow only "a". The extension's value never had spaces. + test.each([ + ["spaces after commas", ALL_EVENT_TYPES.join(", ")], + ["surrounding whitespace", ` ${ALL_EVENT_TYPES.join(" , ")} `], + ["empty entries", `,${ALL_EVENT_TYPES.join(",,")},`], + ])("trims each selected event type (%s)", async (_label, selectedEvents) => { + await importEvents(CHANNEL, selectedEvents); + + expect(channel).toHaveBeenCalledWith(CHANNEL, { + allowedEventTypes: ALL_EVENT_TYPES, }); }); @@ -83,6 +98,8 @@ describe("setupEventChannel", () => { test.each([ ["unset", null], ["empty", ""], + ["whitespace only", " "], + ["only commas", " , ,"], ])( "stays disabled when a channel is set but EXT_SELECTED_EVENTS is %s", async (_label, selectedEvents) => { From a94b1f63b39941e8e9968637dddd1240b5263c40 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Mon, 2 Nov 2026 18:57:54 +0000 Subject: [PATCH 2/7] fix(delete-user-data): reject invalid EXT_SELECTED_EVENTS at deploy instead of trimming The extension's install failed on spaces, wrong case, unknown types, duplicates or ';'. Validate the list at module load, next to assertRequiredParams(), so the deploy fails the same way. --- kits/delete-user-data/CHANGELOG.md | 2 +- kits/delete-user-data/src/events.ts | 43 ++++++++++++++++++---- kits/delete-user-data/src/index.ts | 1 + kits/delete-user-data/tests/events.test.ts | 20 +++++----- 4 files changed, 46 insertions(+), 20 deletions(-) diff --git a/kits/delete-user-data/CHANGELOG.md b/kits/delete-user-data/CHANGELOG.md index ea2f2f613..e8b8da299 100644 --- a/kits/delete-user-data/CHANGELOG.md +++ b/kits/delete-user-data/CHANGELOG.md @@ -1,4 +1,4 @@ -- fix: spaces in `EXT_SELECTED_EVENTS` no longer drop event types. firebase-admin splits the list on commas without trimming, so `a, b, c` published only `a` and silently dropped the rest. The kit now trims each type before opening the channel, and a whitespace-only value is treated as unset, so nothing is published. The extension never hit this because the platform wrote the list without spaces. +- fix: `EXT_SELECTED_EVENTS` is validated at deploy, as the extension's install did. firebase-admin splits the list on commas without trimming, so `a, b, c` published only `a` and silently dropped the rest, and a type with different case or a typo was dropped without a warning. The deploy now fails if an entry has spaces or different case, isn't one of the three event types, or is listed twice. Empty entries are still skipped, and an unset or empty value still publishes nothing. - fix: an Eventarc publish failure no longer fails the function. A channel that was deleted or is unreachable answers `PERMISSION_DENIED`, and the publish was awaited after the work, so the deletion completed but the invocation still failed. The publish failure is now logged as a warning and the invocation continues. Deploys also declare `eventarcpublishing.googleapis.com`, so the API is enabled on your project whether or not you use events. The failed invocation used to be redelivered, re-running a deletion that had already succeeded; it no longer is. diff --git a/kits/delete-user-data/src/events.ts b/kits/delete-user-data/src/events.ts index 4d5fe9b26..03816b8c2 100644 --- a/kits/delete-user-data/src/events.ts +++ b/kits/delete-user-data/src/events.ts @@ -19,6 +19,40 @@ import { logger } from "firebase-functions"; let eventChannel: eventArc.Channel | undefined; +const EVENT_TYPES: ReadonlyArray = [ + "firestore", + "database", + "storage", +].map((t) => `firebase.extensions.delete-user-data.v1.${t}`); + +/** + * Reads `EXT_SELECTED_EVENTS`, failing on a value the extension's install + * rejected. + * + * Splits on "," and skips "" (as the CLI did); rejects anything that isn't an + * exact declared type, or is listed twice (as the platform did). Called at + * module load so a bad value fails the deploy. + */ +export function selectedEventTypes(): string[] { + const types = (process.env.EXT_SELECTED_EVENTS ?? "") + .split(",") + .filter((t) => t !== ""); + const errors = types.flatMap((t, i) => + !EVENT_TYPES.includes(t) + ? [`'${t}' is not one of ${EVENT_TYPES.join(", ")}`] + : types.indexOf(t) !== i + ? [`'${t}' is listed more than once`] + : [] + ); + if (errors.length) { + throw new Error( + `EXT_SELECTED_EVENTS is invalid: ${errors.join("; ")}. ` + + "List event types separated by commas, without spaces." + ); + } + return types; +} + /** * Opens the Eventarc channel only when event types are selected. * @@ -26,17 +60,10 @@ let eventChannel: eventArc.Channel | undefined; * selected, the platform cleared the channel and nothing was published. * firebase-admin reads a missing allowlist as "allow every type", so an unset * or empty `EXT_SELECTED_EVENTS` has to disable publishing here. - * - * The list is split and trimmed here because firebase-admin splits a string - * on `,` without trimming, so a hand-written `a, b` would only allow `a`. The - * platform wrote the extension's value without spaces. */ export const setupEventChannel = (): void => { const channel = process.env.EVENTARC_CHANNEL; - const selectedEvents = (process.env.EXT_SELECTED_EVENTS ?? "") - .split(",") - .map((type) => type.trim()) - .filter(Boolean); + const selectedEvents = selectedEventTypes(); eventChannel = channel && selectedEvents.length > 0 diff --git a/kits/delete-user-data/src/index.ts b/kits/delete-user-data/src/index.ts index cca4a43d0..542dba1fa 100644 --- a/kits/delete-user-data/src/index.ts +++ b/kits/delete-user-data/src/index.ts @@ -37,6 +37,7 @@ import { import * as logs from "./logs"; assertRequiredParams(); +events.selectedEventTypes(); export * from "./lib"; const REQUIRED_ROLES: ReadonlyArray = [ diff --git a/kits/delete-user-data/tests/events.test.ts b/kits/delete-user-data/tests/events.test.ts index b80ab6010..9a7b2ac99 100644 --- a/kits/delete-user-data/tests/events.test.ts +++ b/kits/delete-user-data/tests/events.test.ts @@ -78,18 +78,18 @@ describe("setupEventChannel", () => { }); }); - // firebase-admin splits a string allowlist on "," without trimming, so - // "a, b" would allow only "a". The extension's value never had spaces. + // The extension's install rejected a list with spaces, so the deploy fails + // instead of trimming. test.each([ ["spaces after commas", ALL_EVENT_TYPES.join(", ")], ["surrounding whitespace", ` ${ALL_EVENT_TYPES.join(" , ")} `], - ["empty entries", `,${ALL_EVENT_TYPES.join(",,")},`], - ])("trims each selected event type (%s)", async (_label, selectedEvents) => { - await importEvents(CHANNEL, selectedEvents); - - expect(channel).toHaveBeenCalledWith(CHANNEL, { - allowedEventTypes: ALL_EVENT_TYPES, - }); + ["whitespace only", " "], + ["only commas and spaces", " , ,"], + ])("rejects EXT_SELECTED_EVENTS with %s", async (_label, selectedEvents) => { + await expect(importEvents(CHANNEL, selectedEvents)).rejects.toThrow( + "EXT_SELECTED_EVENTS is invalid" + ); + expect(channel).not.toHaveBeenCalled(); }); // The extension cleared its channel when no event types were selected, so @@ -98,8 +98,6 @@ describe("setupEventChannel", () => { test.each([ ["unset", null], ["empty", ""], - ["whitespace only", " "], - ["only commas", " , ,"], ])( "stays disabled when a channel is set but EXT_SELECTED_EVENTS is %s", async (_label, selectedEvents) => { From 875e4ef35ad9120d25aa3279f43648dae2bcdb08 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Mon, 2 Nov 2026 18:58:00 +0000 Subject: [PATCH 3/7] docs(delete-user-data): document that invalid EXT_SELECTED_EVENTS fails the deploy --- kits/delete-user-data/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/kits/delete-user-data/README.md b/kits/delete-user-data/README.md index a13bae5bc..46c4b5e6a 100644 --- a/kits/delete-user-data/README.md +++ b/kits/delete-user-data/README.md @@ -155,10 +155,12 @@ The functions can publish deletion events for each backend under | `EVENTARC_CHANNEL` | Full channel name, e.g. `projects//locations/us-central1/channels/firebase` | | `EXT_SELECTED_EVENTS` | Comma-separated event types to publish, e.g. `firebase.extensions.delete-user-data.v1.firestore,firebase.extensions.delete-user-data.v1.storage` | -Only the listed types are published. Spaces around each type are ignored, so -`a, b` works the same as `a,b`. If `EXT_SELECTED_EVENTS` is unset, empty or -only whitespace, nothing is published, which matches an extension instance -installed with no event types selected. +Only the listed types are published. List the types exactly as shown, +separated by commas with no spaces. Empty entries are skipped. The deploy fails +if an entry has spaces or different case, isn't one of the three types, or is +listed twice, as the extension's install did. If `EXT_SELECTED_EVENTS` is unset +or empty, nothing is published, which matches an extension instance installed +with no event types selected. The channel must already exist. Deploying the kit does not create it, and publishing to a missing channel logs a warning without failing the function. From 3d8136101e5afa9fc34c607cf423f516dd411114 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Mon, 2 Nov 2026 18:58:27 +0000 Subject: [PATCH 4/7] test(delete-user-data): cover every EXT_SELECTED_EVENTS value the extension rejected Keep the skipped-empty-entries case, expect a throw for wrong case, unknown types, duplicates and ';', and check that importing the kit with a bad value fails. --- kits/delete-user-data/tests/events.test.ts | 20 ++++++++++++++++++-- kits/delete-user-data/tests/index.test.ts | 22 +++++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/kits/delete-user-data/tests/events.test.ts b/kits/delete-user-data/tests/events.test.ts index 9a7b2ac99..cbd1d7a8c 100644 --- a/kits/delete-user-data/tests/events.test.ts +++ b/kits/delete-user-data/tests/events.test.ts @@ -78,13 +78,29 @@ describe("setupEventChannel", () => { }); }); - // The extension's install rejected a list with spaces, so the deploy fails - // instead of trimming. + // The CLI dropped empty entries before the platform checked the list. + test("skips empty entries", async () => { + await importEvents(CHANNEL, `,${ALL_EVENT_TYPES.join(",,")},`); + + expect(channel).toHaveBeenCalledWith(CHANNEL, { + allowedEventTypes: ALL_EVENT_TYPES, + }); + }); + + // The extension's install rejected each of these, so the deploy fails + // instead of trimming or dropping entries. test.each([ ["spaces after commas", ALL_EVENT_TYPES.join(", ")], ["surrounding whitespace", ` ${ALL_EVENT_TYPES.join(" , ")} `], ["whitespace only", " "], ["only commas and spaces", " , ,"], + ["different case", "firebase.extensions.delete-user-data.v1.Firestore"], + [ + "an unknown type", + "firebase.extensions.delete-user-data.v1.bogus,firebase.extensions.delete-user-data.v1.database", + ], + ["a duplicate type", `${ALL_EVENT_TYPES[0]},${ALL_EVENT_TYPES[0]}`], + ["a ';' separator", ALL_EVENT_TYPES.join(";")], ])("rejects EXT_SELECTED_EVENTS with %s", async (_label, selectedEvents) => { await expect(importEvents(CHANNEL, selectedEvents)).rejects.toThrow( "EXT_SELECTED_EVENTS is invalid" diff --git a/kits/delete-user-data/tests/index.test.ts b/kits/delete-user-data/tests/index.test.ts index b031ab638..cb8b013bb 100644 --- a/kits/delete-user-data/tests/index.test.ts +++ b/kits/delete-user-data/tests/index.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; vi.mock("../src/logs"); vi.mock("../src/handlers", async (importOriginal) => ({ @@ -68,3 +68,23 @@ describe("clearData", () => { expect(logs.deletionEventMissingUid).toHaveBeenCalledWith("event-id"); }); }); + +// The CLI loads the kit to read its manifest, so a throw at import fails the +// deploy, as the extension's install failed on the same value. +describe("module load", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test("fails on an invalid EXT_SELECTED_EVENTS", async () => { + vi.resetModules(); + vi.stubEnv( + "EXT_SELECTED_EVENTS", + "firebase.extensions.delete-user-data.v1.firestore, firebase.extensions.delete-user-data.v1.storage" + ); + + await expect(import("../src/index")).rejects.toThrow( + "EXT_SELECTED_EVENTS is invalid" + ); + }); +}); From 1f883513bdc3df68069abd3b997685663497959b Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Mon, 2 Nov 2026 18:59:07 +0000 Subject: [PATCH 5/7] fix(delete-user-data): reject event types without EVENTARC_CHANNEL, and a malformed channel The extension's install rejected both ("Must specify eventarc channel if allowing some events to be emitted"); the kit silently turned events off. --- kits/delete-user-data/CHANGELOG.md | 2 +- kits/delete-user-data/README.md | 7 ++++--- kits/delete-user-data/src/events.ts | 21 ++++++++++++++++++-- kits/delete-user-data/tests/events.test.ts | 23 ++++++++++++++++++++-- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/kits/delete-user-data/CHANGELOG.md b/kits/delete-user-data/CHANGELOG.md index e8b8da299..67e35a802 100644 --- a/kits/delete-user-data/CHANGELOG.md +++ b/kits/delete-user-data/CHANGELOG.md @@ -1,4 +1,4 @@ -- fix: `EXT_SELECTED_EVENTS` is validated at deploy, as the extension's install did. firebase-admin splits the list on commas without trimming, so `a, b, c` published only `a` and silently dropped the rest, and a type with different case or a typo was dropped without a warning. The deploy now fails if an entry has spaces or different case, isn't one of the three event types, or is listed twice. Empty entries are still skipped, and an unset or empty value still publishes nothing. +- fix: `EXT_SELECTED_EVENTS` is validated at deploy, as the extension's install did. firebase-admin splits the list on commas without trimming, so `a, b, c` published only `a` and silently dropped the rest, and a type with different case or a typo was dropped without a warning. The deploy now fails if an entry has spaces or different case, isn't one of the three event types, or is listed twice. It also fails if event types are listed without `EVENTARC_CHANNEL`, or the channel isn't a full `projects//locations//channels/` name; before, events were silently turned off. Empty entries are still skipped, and an unset or empty value still publishes nothing. - fix: an Eventarc publish failure no longer fails the function. A channel that was deleted or is unreachable answers `PERMISSION_DENIED`, and the publish was awaited after the work, so the deletion completed but the invocation still failed. The publish failure is now logged as a warning and the invocation continues. Deploys also declare `eventarcpublishing.googleapis.com`, so the API is enabled on your project whether or not you use events. The failed invocation used to be redelivered, re-running a deletion that had already succeeded; it no longer is. diff --git a/kits/delete-user-data/README.md b/kits/delete-user-data/README.md index 46c4b5e6a..bf9065b69 100644 --- a/kits/delete-user-data/README.md +++ b/kits/delete-user-data/README.md @@ -158,9 +158,10 @@ The functions can publish deletion events for each backend under Only the listed types are published. List the types exactly as shown, separated by commas with no spaces. Empty entries are skipped. The deploy fails if an entry has spaces or different case, isn't one of the three types, or is -listed twice, as the extension's install did. If `EXT_SELECTED_EVENTS` is unset -or empty, nothing is published, which matches an extension instance installed -with no event types selected. +listed twice, or if types are listed but `EVENTARC_CHANNEL` is unset or isn't +a full channel name, as the extension's install did. If `EXT_SELECTED_EVENTS` +is unset or empty, nothing is published, which matches an extension instance +installed with no event types selected. The channel must already exist. Deploying the kit does not create it, and publishing to a missing channel logs a warning without failing the function. diff --git a/kits/delete-user-data/src/events.ts b/kits/delete-user-data/src/events.ts index 03816b8c2..e25feff8e 100644 --- a/kits/delete-user-data/src/events.ts +++ b/kits/delete-user-data/src/events.ts @@ -25,13 +25,16 @@ const EVENT_TYPES: ReadonlyArray = [ "storage", ].map((t) => `firebase.extensions.delete-user-data.v1.${t}`); +const CHANNEL_NAME = /^projects\/[^/]+\/locations\/[^/]+\/channels\/[^/]+$/; + /** * Reads `EXT_SELECTED_EVENTS`, failing on a value the extension's install * rejected. * * Splits on "," and skips "" (as the CLI did); rejects anything that isn't an - * exact declared type, or is listed twice (as the platform did). Called at - * module load so a bad value fails the deploy. + * exact declared type, or is listed twice (as the platform did). Also rejects + * event types without `EVENTARC_CHANNEL`, and a malformed channel name, as the + * install did. Called at module load so a bad value fails the deploy. */ export function selectedEventTypes(): string[] { const types = (process.env.EXT_SELECTED_EVENTS ?? "") @@ -50,6 +53,20 @@ export function selectedEventTypes(): string[] { "List event types separated by commas, without spaces." ); } + + const channel = process.env.EVENTARC_CHANNEL; + if (types.length > 0 && !channel) { + throw new Error( + "EXT_SELECTED_EVENTS is set but EVENTARC_CHANNEL is not. " + + "Must specify eventarc channel if allowing some events to be emitted." + ); + } + if (channel && !CHANNEL_NAME.test(channel)) { + throw new Error( + `EVENTARC_CHANNEL '${channel}' is invalid. Use the full channel name, ` + + "e.g. projects//locations//channels/." + ); + } return types; } diff --git a/kits/delete-user-data/tests/events.test.ts b/kits/delete-user-data/tests/events.test.ts index cbd1d7a8c..7f27e0635 100644 --- a/kits/delete-user-data/tests/events.test.ts +++ b/kits/delete-user-data/tests/events.test.ts @@ -127,13 +127,32 @@ describe("setupEventChannel", () => { } ); - test("stays disabled when no channel is configured", async () => { - const events = await importEvents(); + test("stays disabled when neither is configured", async () => { + const events = await importEvents(undefined, null); expect(channel).not.toHaveBeenCalled(); await events.publishDeletionEvent("firestore", { uid: "u1" }); expect(publish).not.toHaveBeenCalled(); }); + + // The extension's install rejected selected events without a channel. + test("rejects selected event types without EVENTARC_CHANNEL", async () => { + await expect(importEvents(undefined, ALL_EVENTS)).rejects.toThrow( + "Must specify eventarc channel" + ); + expect(channel).not.toHaveBeenCalled(); + }); + + test.each([ + ["a bare channel id", "firebase"], + ["a missing location", "projects/p/channels/firebase"], + ["a trailing slash", `${CHANNEL}/`], + ])("rejects an EVENTARC_CHANNEL with %s", async (_label, channelName) => { + await expect(importEvents(channelName, ALL_EVENTS)).rejects.toThrow( + "EVENTARC_CHANNEL" + ); + expect(channel).not.toHaveBeenCalled(); + }); }); describe("publish failures", () => { From 1073e6631e8f5d59a75ef52261f4cc3d5bf910fd Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Mon, 9 Nov 2026 15:51:27 +0000 Subject: [PATCH 6/7] fix(delete-user-data): reject an EVENTARC_CHANNEL in another project or an unsupported location --- kits/delete-user-data/CHANGELOG.md | 2 +- kits/delete-user-data/README.md | 11 +++-- kits/delete-user-data/src/events.ts | 48 +++++++++++++++++---- kits/delete-user-data/tests/events.test.ts | 49 +++++++++++++++++++++- 4 files changed, 96 insertions(+), 14 deletions(-) diff --git a/kits/delete-user-data/CHANGELOG.md b/kits/delete-user-data/CHANGELOG.md index 741ea086d..66cf755b7 100644 --- a/kits/delete-user-data/CHANGELOG.md +++ b/kits/delete-user-data/CHANGELOG.md @@ -1,4 +1,4 @@ -- fix: `EXT_SELECTED_EVENTS` is validated at deploy, as the extension's install did. firebase-admin splits the list on commas without trimming, so `a, b, c` published only `a` and silently dropped the rest, and a type with different case or a typo was dropped without a warning. The deploy now fails if an entry has spaces or different case, isn't one of the three event types, or is listed twice. It also fails if event types are listed without `EVENTARC_CHANNEL`, or the channel isn't a full `projects//locations//channels/` name; before, events were silently turned off. Empty entries are still skipped, and an unset or empty value still publishes nothing. +- fix: `EXT_SELECTED_EVENTS` is validated at deploy, as the extension's install did. firebase-admin splits the list on commas without trimming, so `a, b, c` published only `a` and silently dropped the rest, and a type with different case or a typo was dropped without a warning. The deploy now fails if an entry has spaces or different case, isn't one of the three event types, or is listed twice. It also fails if event types are listed without `EVENTARC_CHANNEL`, or the channel isn't a full `projects//locations//channels/` name in this project and a location the extension supported (`us-central1`, `us-west1`, `europe-west4` or `asia-northeast1`); before, events were silently turned off. Empty entries are still skipped, and an unset or empty value still publishes nothing. - fix: `clearData`, `handleSearch` and `handleDeletion` can scale to 3000 instances each again, as the extension's 1st gen functions could. The kit set no `maxInstances`, so each function took the 2nd gen default of 100, and with one event per instance it handled at most 100 deletions or searches at once. `setGlobalOptions({ maxInstances })` still overrides it. Existing kit deployments switch on their next deploy. diff --git a/kits/delete-user-data/README.md b/kits/delete-user-data/README.md index 105654118..723c06523 100644 --- a/kits/delete-user-data/README.md +++ b/kits/delete-user-data/README.md @@ -177,10 +177,13 @@ The functions can publish deletion events for each backend under Only the listed types are published. List the types exactly as shown, separated by commas with no spaces. Empty entries are skipped. The deploy fails if an entry has spaces or different case, isn't one of the three types, or is -listed twice, or if types are listed but `EVENTARC_CHANNEL` is unset or isn't -a full channel name, as the extension's install did. If `EXT_SELECTED_EVENTS` -is unset or empty, nothing is published, which matches an extension instance -installed with no event types selected. +listed twice, or if types are listed but `EVENTARC_CHANNEL` is unset, as the +extension's install did. It also fails if `EVENTARC_CHANNEL` isn't a full +channel name, is in another project, or is in a location other than +`us-central1`, `us-west1`, `europe-west4` or `asia-northeast1`, the locations +the extension supported. If `EXT_SELECTED_EVENTS` is unset or empty, nothing +is published, which matches an extension instance installed with no event types +selected. The channel must already exist. Deploying the kit does not create it, and publishing to a missing channel logs a warning without failing the function. diff --git a/kits/delete-user-data/src/events.ts b/kits/delete-user-data/src/events.ts index e25feff8e..0a5d33b2e 100644 --- a/kits/delete-user-data/src/events.ts +++ b/kits/delete-user-data/src/events.ts @@ -25,7 +25,16 @@ const EVENT_TYPES: ReadonlyArray = [ "storage", ].map((t) => `firebase.extensions.delete-user-data.v1.${t}`); -const CHANNEL_NAME = /^projects\/[^/]+\/locations\/[^/]+\/channels\/[^/]+$/; +const CHANNEL_NAME = /^projects\/([^/]+)\/locations\/([^/]+)\/channels\/[^/]+$/; + +// The channel locations the extension's install accepted, as in the Firebase +// CLI's ALLOWED_EVENT_ARC_REGIONS. +const CHANNEL_LOCATIONS: ReadonlyArray = [ + "us-central1", + "us-west1", + "europe-west4", + "asia-northeast1", +]; /** * Reads `EXT_SELECTED_EVENTS`, failing on a value the extension's install @@ -33,8 +42,9 @@ const CHANNEL_NAME = /^projects\/[^/]+\/locations\/[^/]+\/channels\/[^/]+$/; * * Splits on "," and skips "" (as the CLI did); rejects anything that isn't an * exact declared type, or is listed twice (as the platform did). Also rejects - * event types without `EVENTARC_CHANNEL`, and a malformed channel name, as the - * install did. Called at module load so a bad value fails the deploy. + * event types without `EVENTARC_CHANNEL`, and a channel that is malformed, in + * another project or in an unsupported location, as the install did. Called at + * module load so a bad value fails the deploy. */ export function selectedEventTypes(): string[] { const types = (process.env.EXT_SELECTED_EVENTS ?? "") @@ -61,11 +71,33 @@ export function selectedEventTypes(): string[] { "Must specify eventarc channel if allowing some events to be emitted." ); } - if (channel && !CHANNEL_NAME.test(channel)) { - throw new Error( - `EVENTARC_CHANNEL '${channel}' is invalid. Use the full channel name, ` + - "e.g. projects//locations//channels/." - ); + if (channel) { + const match = CHANNEL_NAME.exec(channel); + if (!match) { + throw new Error( + `EVENTARC_CHANNEL '${channel}' is invalid. Use the full channel name, ` + + "e.g. projects//locations//channels/." + ); + } + const [, project, location] = match; + // Set by the CLI when it loads the kit, and at runtime. + const currentProject = process.env.GCLOUD_PROJECT; + const channelErrors = [ + ...(CHANNEL_LOCATIONS.includes(location) + ? [] + : [ + `location '${location}' is not supported, use one of ` + + CHANNEL_LOCATIONS.join(", "), + ]), + ...(currentProject && project !== currentProject + ? [`it must be in the same project, '${currentProject}'`] + : []), + ]; + if (channelErrors.length) { + throw new Error( + `EVENTARC_CHANNEL '${channel}' is invalid: ${channelErrors.join("; ")}.` + ); + } } return types; } diff --git a/kits/delete-user-data/tests/events.test.ts b/kits/delete-user-data/tests/events.test.ts index 7f27e0635..c9f897543 100644 --- a/kits/delete-user-data/tests/events.test.ts +++ b/kits/delete-user-data/tests/events.test.ts @@ -24,7 +24,7 @@ vi.mock("firebase-admin/eventarc", () => ({ getEventarc: () => ({ channel }), })); -const CHANNEL = "projects/p/locations/l/channels/firebase"; +const CHANNEL = "projects/p/locations/us-central1/channels/firebase"; const ALL_EVENT_TYPES = [ "firebase.extensions.delete-user-data.v1.firestore", @@ -56,14 +56,17 @@ async function importEvents( const ORIGINAL_CHANNEL = process.env.EVENTARC_CHANNEL; const ORIGINAL_SELECTED_EVENTS = process.env.EXT_SELECTED_EVENTS; +const ORIGINAL_PROJECT = process.env.GCLOUD_PROJECT; beforeEach(() => { vi.clearAllMocks(); + setEnv("GCLOUD_PROJECT", "p"); }); afterEach(() => { setEnv("EVENTARC_CHANNEL", ORIGINAL_CHANNEL); setEnv("EXT_SELECTED_EVENTS", ORIGINAL_SELECTED_EVENTS); + setEnv("GCLOUD_PROJECT", ORIGINAL_PROJECT); }); describe("setupEventChannel", () => { @@ -153,6 +156,50 @@ describe("setupEventChannel", () => { ); expect(channel).not.toHaveBeenCalled(); }); + + // The extension's install accepted only a channel in its own project, in one + // of the CLI's ALLOWED_EVENT_ARC_REGIONS, even with no event types selected. + test.each(["us-central1", "us-west1", "europe-west4", "asia-northeast1"])( + "accepts a channel in %s", + async (location) => { + const name = `projects/p/locations/${location}/channels/c`; + await importEvents(name, ALL_EVENTS); + + expect(channel).toHaveBeenCalledWith(name, { + allowedEventTypes: ALL_EVENT_TYPES, + }); + } + ); + + test.each([ + [ + "an unsupported location", + "projects/p/locations/europe-west1/channels/c", + "location 'europe-west1' is not supported", + ], + [ + "the global location", + "projects/p/locations/global/channels/c", + "location 'global' is not supported", + ], + [ + "another project", + "projects/other/locations/us-central1/channels/c", + "it must be in the same project, 'p'", + ], + [ + "the project number", + "projects/123456/locations/us-central1/channels/c", + "it must be in the same project, 'p'", + ], + ])("rejects a channel in %s", async (_label, channelName, message) => { + for (const selected of [ALL_EVENTS, null]) { + await expect(importEvents(channelName, selected)).rejects.toThrow( + message + ); + } + expect(channel).not.toHaveBeenCalled(); + }); }); describe("publish failures", () => { From 8acdd31536e4d263085fa44ba20eb75adc69b7e7 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Mon, 9 Nov 2026 15:51:27 +0000 Subject: [PATCH 7/7] fix(delete-user-data): report a repeated EXT_SELECTED_EVENTS entry as a duplicate first, as the extension did --- kits/delete-user-data/src/events.ts | 8 +++++--- kits/delete-user-data/tests/events.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/kits/delete-user-data/src/events.ts b/kits/delete-user-data/src/events.ts index 0a5d33b2e..d50a9faa2 100644 --- a/kits/delete-user-data/src/events.ts +++ b/kits/delete-user-data/src/events.ts @@ -50,11 +50,13 @@ export function selectedEventTypes(): string[] { const types = (process.env.EXT_SELECTED_EVENTS ?? "") .split(",") .filter((t) => t !== ""); + // A repeat is reported as a duplicate even if it isn't a declared type, as + // the platform did. const errors = types.flatMap((t, i) => - !EVENT_TYPES.includes(t) - ? [`'${t}' is not one of ${EVENT_TYPES.join(", ")}`] - : types.indexOf(t) !== i + types.indexOf(t) !== i ? [`'${t}' is listed more than once`] + : !EVENT_TYPES.includes(t) + ? [`'${t}' is not one of ${EVENT_TYPES.join(", ")}`] : [] ); if (errors.length) { diff --git a/kits/delete-user-data/tests/events.test.ts b/kits/delete-user-data/tests/events.test.ts index c9f897543..ea6073542 100644 --- a/kits/delete-user-data/tests/events.test.ts +++ b/kits/delete-user-data/tests/events.test.ts @@ -111,6 +111,14 @@ describe("setupEventChannel", () => { expect(channel).not.toHaveBeenCalled(); }); + // The platform reported a repeat as a duplicate before checking the type. + test("reports a repeated unknown entry as listed more than once", async () => { + const load = importEvents(CHANNEL, " , ,"); + + await expect(load).rejects.toThrow("' ' is not one of"); + await expect(load).rejects.toThrow("' ' is listed more than once"); + }); + // The extension cleared its channel when no event types were selected, so // it published nothing. firebase-admin reads a missing allowlist as "allow // every type", which would publish all three.