From 8145b508f8b9e7f89f513723ff3583a9e4c30081 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Wed, 2 Sep 2026 15:12:15 +0100 Subject: [PATCH 1/5] fix(kits): restore the extensions' event payload shapes The 2nd gen migration renamed what the Eventarc payloads carry. `firestore-counter` and `firestore-translate-text` published `{change, context}` on `onStart` and `{context}` on `onCompletion`; the kits published `{data, params}` and `{params}`, following the 2nd gen handler signature. That drops `context.eventId`, `context.timestamp`, `context.eventType` and `context.resource` outright and moves the trigger wildcards, so every subscriber reading them breaks. Nothing about 2nd gen forces this. A custom event's payload is whatever the kit hands `channel.publish`, and `FirestoreEvent` carries all five 1st gen `EventContext` fields under different names, so `toEventContext` rebuilds the object and the handlers publish the original shape. `event.data` and the 1st gen `change` are the same `Change` and serialize identically, so the `change` key costs nothing. `speech-to-text` published the caught `Error` itself, which serializes to `{"error":{}}` because `message` and `stack` are not enumerable; the kit published `{message, stack}` instead. That is an upgrade rather than parity, and it dropped the `name` that `errorFromAny` puts on a thrown non-error, so the error is published as-is again. Refs #3027 --- kits/firestore-counter/README.md | 17 ++--- kits/firestore-counter/src/event-context.ts | 64 +++++++++++++++++++ kits/firestore-counter/src/handlers.ts | 6 +- .../tests/event-context.test.ts | 60 +++++++++++++++++ kits/firestore-counter/tests/events.test.ts | 33 ++++++++-- kits/firestore-counter/tests/handlers.test.ts | 33 +++++++--- kits/firestore-translate-text/README.md | 14 ++-- .../src/event-context.ts | 64 +++++++++++++++++++ kits/firestore-translate-text/src/handlers.ts | 10 +-- .../tests/event-context.test.ts | 60 +++++++++++++++++ .../tests/events.test.ts | 33 ++++++++-- .../tests/handlers.test.ts | 11 ++-- .../firestore-translate-text/tests/helpers.ts | 30 +++++++++ kits/speech-to-text/README.md | 13 ++-- kits/speech-to-text/src/events.ts | 11 ++-- kits/speech-to-text/tests/events.test.ts | 31 +++++---- 16 files changed, 419 insertions(+), 71 deletions(-) create mode 100644 kits/firestore-counter/src/event-context.ts create mode 100644 kits/firestore-counter/tests/event-context.test.ts create mode 100644 kits/firestore-translate-text/src/event-context.ts create mode 100644 kits/firestore-translate-text/tests/event-context.test.ts diff --git a/kits/firestore-counter/README.md b/kits/firestore-counter/README.md index d9e4ecfe90..2d05c128ee 100644 --- a/kits/firestore-counter/README.md +++ b/kits/firestore-counter/README.md @@ -161,16 +161,6 @@ for them, so no events are published until you create a channel and put both values in your `.env`. If you set `EVENTARC_CHANNEL` and leave `EXT_SELECTED_EVENTS` unset, every event type is published. -### Event payloads have a different shape - -The event types are unchanged, but what they carry is not. `onStart` used to -carry `{change, context}` and now carries `{data, params}`: the write is under -`data` instead of `change`, and the 1st gen `context` is gone. `onCompletion` -used to carry `{context}` and now carries `{params}` only. Anything reading -`context.eventId`, `context.timestamp`, `context.eventType` or -`context.resource` from these events needs updating; the trigger wildcards -(`collection`, `counter`, `shardId`) survive as `params`. - ### Your codebase's global options apply to these functions The functions are exported from your own functions codebase, so a @@ -201,8 +191,11 @@ carry on getting them from the extension repo. - The aggregation behaviour: inline aggregation up to 200 shards, workers above that, 45 second self-scheduling worker runs, partial shard cleanup, and deletion of shards once they are summed into the counter field. -- The three functions and the event types they publish, aside from the worker - and payload points above. +- The three functions, the event types they publish and their payloads: + `onStart` still carries `{change, context}` and `onCompletion` still carries + `{context}`, with `context.eventId`, `context.timestamp`, `context.eventType`, + `context.resource` and the trigger wildcards under `context.params`. Aside + from the worker point above. ## API surface diff --git a/kits/firestore-counter/src/event-context.ts b/kits/firestore-counter/src/event-context.ts new file mode 100644 index 0000000000..fe48483610 --- /dev/null +++ b/kits/firestore-counter/src/event-context.ts @@ -0,0 +1,64 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { FirestoreEvent } from "firebase-functions/v2/firestore"; + +/** + * The 1st gen `EventContext` shape that the extension published inside its + * `onStart` and `onCompletion` payloads. + */ +export interface EventContext { + eventId: string; + timestamp: string; + eventType: string; + resource: { + service: string; + name: string; + }; + params: Record; +} + +/** + * Every 1st gen Firestore `onWrite` trigger reported this event type, so + * subscribers matching on `context.eventType` keep matching it. + */ +const FIRESTORE_WRITE_EVENT_TYPE = "google.firestore.document.write"; + +const FIRESTORE_SERVICE = "firestore.googleapis.com"; + +/** + * Rebuilds the 1st gen `EventContext` from a 2nd gen `FirestoreEvent`. + * + * The extension handed the 1st gen handler's `context` straight to Eventarc, so + * subscribers read `eventId`, `timestamp`, `eventType`, `resource` and `params` + * off it. `FirestoreEvent` carries the same information under different names, + * so the published payload keeps its original shape instead of following the + * 2nd gen handler signature. + */ +export function toEventContext( + event: FirestoreEvent> +): EventContext { + return { + eventId: event.id, + timestamp: event.time, + eventType: FIRESTORE_WRITE_EVENT_TYPE, + resource: { + service: FIRESTORE_SERVICE, + name: `projects/${event.project}/databases/${event.database}/documents/${event.document}`, + }, + params: event.params, + }; +} diff --git a/kits/firestore-counter/src/handlers.ts b/kits/firestore-counter/src/handlers.ts index 3079ec66d2..b4f34af319 100644 --- a/kits/firestore-counter/src/handlers.ts +++ b/kits/firestore-counter/src/handlers.ts @@ -18,6 +18,7 @@ import type { DocumentSnapshot, Firestore } from "firebase-admin/firestore"; import type { Change, FirestoreEvent } from "firebase-functions/v2/firestore"; import type { ScheduledEvent } from "firebase-functions/v2/scheduler"; import { ControllerStatus, ShardedCounterController } from "./controller"; +import { toEventContext } from "./event-context"; import * as events from "./events"; import type { ResolvedCounterConfig } from "./export-config"; import { ShardedCounterWorker } from "./worker"; @@ -62,7 +63,8 @@ export async function handleShardWrite( event: CounterWriteEvent, ctx: HandlerContext ): Promise { - await events.recordStartEvent({ data: event.data, params: event.params }); + const context = toEventContext(event); + await events.recordStartEvent({ change: event.data, context }); const metadocRef = ctx.firestore.doc(ctx.config.internalStatePath); const controller = new ShardedCounterController( metadocRef, @@ -73,7 +75,7 @@ export async function handleShardWrite( INLINE_AGGREGATION_LIMIT, INLINE_AGGREGATION_TIMEOUT_MS ); - await events.recordCompletionEvent({ params: event.params }); + await events.recordCompletionEvent({ context }); } export async function handleWorker(event: CounterWriteEvent): Promise { diff --git a/kits/firestore-counter/tests/event-context.test.ts b/kits/firestore-counter/tests/event-context.test.ts new file mode 100644 index 0000000000..fc55057c17 --- /dev/null +++ b/kits/firestore-counter/tests/event-context.test.ts @@ -0,0 +1,60 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, test } from "vitest"; +import { toEventContext } from "../src/event-context"; + +/** + * The extension published its 1st gen handler's `context` verbatim, so these + * assertions pin the fields subscribers read off it. + */ +describe("toEventContext", () => { + const event = { + id: "event-1", + time: "2026-01-01T00:00:00.000Z", + project: "demo-project", + database: "(default)", + document: "_firebase_ext_/sharded_counter", + params: { shardId: "0000" }, + } as any; + + test("rebuilds the 1st gen event context from a 2nd gen event", () => { + expect(toEventContext(event)).toEqual({ + eventId: "event-1", + timestamp: "2026-01-01T00:00:00.000Z", + eventType: "google.firestore.document.write", + resource: { + service: "firestore.googleapis.com", + name: "projects/demo-project/databases/(default)/documents/_firebase_ext_/sharded_counter", + }, + params: { shardId: "0000" }, + }); + }); + + test("names the resource under the event's own database", () => { + const context = toEventContext({ ...event, database: "counters" }); + + expect(context.resource.name).toBe( + "projects/demo-project/databases/counters/documents/_firebase_ext_/sharded_counter" + ); + }); + + test("passes the trigger wildcards through unchanged", () => { + const params = { collection: "pages", counter: "home", shardId: "0001" }; + + expect(toEventContext({ ...event, params }).params).toEqual(params); + }); +}); diff --git a/kits/firestore-counter/tests/events.test.ts b/kits/firestore-counter/tests/events.test.ts index 08da4d04b6..f2ef871712 100644 --- a/kits/firestore-counter/tests/events.test.ts +++ b/kits/firestore-counter/tests/events.test.ts @@ -81,11 +81,25 @@ describe("event publishing", () => { test("publishes start events", async () => { const events = await setupEnabledEvents(); - await events.recordStartEvent({ params: { shardId: "0000" } }); + const context = { + eventId: "event-1", + timestamp: "2026-01-01T00:00:00.000Z", + eventType: "google.firestore.document.write", + resource: { + service: "firestore.googleapis.com", + name: "projects/demo-project/databases/(default)/documents/_firebase_ext_/sharded_counter", + }, + params: { shardId: "0000" }, + }; + + await events.recordStartEvent({ + change: { before: {}, after: {} }, + context, + }); expect(publish).toHaveBeenCalledWith({ type: "firebase.extensions.firestore-counter.v1.onStart", - data: { params: { shardId: "0000" } }, + data: { change: { before: {}, after: {} }, context }, }); }); @@ -119,11 +133,22 @@ describe("event publishing", () => { test("publishes completion events", async () => { const events = await setupEnabledEvents(); - await events.recordCompletionEvent({ params: { shardId: "0000" } }); + const context = { + eventId: "event-1", + timestamp: "2026-01-01T00:00:00.000Z", + eventType: "google.firestore.document.write", + resource: { + service: "firestore.googleapis.com", + name: "projects/demo-project/databases/(default)/documents/_firebase_ext_/sharded_counter", + }, + params: { shardId: "0000" }, + }; + + await events.recordCompletionEvent({ context }); expect(publish).toHaveBeenCalledWith({ type: "firebase.extensions.firestore-counter.v1.onCompletion", - data: { params: { shardId: "0000" } }, + data: { context }, }); }); diff --git a/kits/firestore-counter/tests/handlers.test.ts b/kits/firestore-counter/tests/handlers.test.ts index f5803772fe..8abe0f3446 100644 --- a/kits/firestore-counter/tests/handlers.test.ts +++ b/kits/firestore-counter/tests/handlers.test.ts @@ -24,9 +24,10 @@ const workerRun = vi.fn(); const workerCtor = vi.fn(); vi.mock("../src/controller", async () => { - const actual = await vi.importActual( - "../src/controller" - ); + const actual = + await vi.importActual( + "../src/controller" + ); return { ControllerStatus: actual.ControllerStatus, ShardedCounterController: class { @@ -113,24 +114,40 @@ describe("handleSchedule", () => { describe("handleShardWrite", () => { test("aggregates continuously and records lifecycle events", async () => { const event = { + id: "event-1", + time: "2026-01-01T00:00:00.000Z", + project: "demo-project", + database: "(default)", + document: "_firebase_ext_/sharded_counter", data: { after: { exists: true } }, params: { shardId: "0000" }, } as any; await handleShardWrite(event, makeCtx()); + // The extension published the 1st gen `{change, context}` payload, so the + // kit rebuilds the same shape rather than exposing the 2nd gen event. + const context = { + eventId: "event-1", + timestamp: "2026-01-01T00:00:00.000Z", + eventType: "google.firestore.document.write", + resource: { + service: "firestore.googleapis.com", + name: "projects/demo-project/databases/(default)/documents/_firebase_ext_/sharded_counter", + }, + params: { shardId: "0000" }, + }; + expect(events.recordStartEvent).toHaveBeenCalledWith({ - data: event.data, - params: event.params, + change: event.data, + context, }); expect(aggregateContinuously).toHaveBeenCalledWith( { start: "", end: "" }, 200, 60000 ); - expect(events.recordCompletionEvent).toHaveBeenCalledWith({ - params: event.params, - }); + expect(events.recordCompletionEvent).toHaveBeenCalledWith({ context }); }); }); diff --git a/kits/firestore-translate-text/README.md b/kits/firestore-translate-text/README.md index bec8b76ffb..1e2fa145ef 100644 --- a/kits/firestore-translate-text/README.md +++ b/kits/firestore-translate-text/README.md @@ -185,16 +185,6 @@ beginning with `EXT_`, so `EXT_SELECTED_EVENTS` cannot be set and every event type is published. With `EVENTARC_CHANNEL` unset, nothing is published and the function is otherwise unaffected. -### Event payloads have a different shape - -The event types are unchanged, but what `onStart` and `onCompletion` carry is -not. `onStart` used to carry `{change, context}` and now carries `{data, params}`: -the write is under `data` instead of `change`, and the 1st gen `context` is gone. -`onCompletion` used to carry `{context}` and now carries `{params}` only. Anything -reading `context.eventId`, `context.timestamp`, `context.eventType` or -`context.resource` needs updating; the `messageId` trigger wildcard survives as -`params`. `onSuccess` and `onError` payloads are unchanged. - ### No backfill There is no function to translate documents that already exist in the @@ -223,6 +213,10 @@ Cloud Translation API is still required whichever provider you choose. behave as before. - Translations are still written in a transaction, and each `onSuccess` event still carries the output field name and the translations. +- The four event payloads: `onStart` still carries `{change, context}` and + `onCompletion` still carries `{context}`, with `context.eventId`, + `context.timestamp`, `context.eventType`, `context.resource` and the + `messageId` wildcard under `context.params`. ## API surface diff --git a/kits/firestore-translate-text/src/event-context.ts b/kits/firestore-translate-text/src/event-context.ts new file mode 100644 index 0000000000..fe48483610 --- /dev/null +++ b/kits/firestore-translate-text/src/event-context.ts @@ -0,0 +1,64 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { FirestoreEvent } from "firebase-functions/v2/firestore"; + +/** + * The 1st gen `EventContext` shape that the extension published inside its + * `onStart` and `onCompletion` payloads. + */ +export interface EventContext { + eventId: string; + timestamp: string; + eventType: string; + resource: { + service: string; + name: string; + }; + params: Record; +} + +/** + * Every 1st gen Firestore `onWrite` trigger reported this event type, so + * subscribers matching on `context.eventType` keep matching it. + */ +const FIRESTORE_WRITE_EVENT_TYPE = "google.firestore.document.write"; + +const FIRESTORE_SERVICE = "firestore.googleapis.com"; + +/** + * Rebuilds the 1st gen `EventContext` from a 2nd gen `FirestoreEvent`. + * + * The extension handed the 1st gen handler's `context` straight to Eventarc, so + * subscribers read `eventId`, `timestamp`, `eventType`, `resource` and `params` + * off it. `FirestoreEvent` carries the same information under different names, + * so the published payload keeps its original shape instead of following the + * 2nd gen handler signature. + */ +export function toEventContext( + event: FirestoreEvent> +): EventContext { + return { + eventId: event.id, + timestamp: event.time, + eventType: FIRESTORE_WRITE_EVENT_TYPE, + resource: { + service: FIRESTORE_SERVICE, + name: `projects/${event.project}/databases/${event.database}/documents/${event.document}`, + }, + params: event.params, + }; +} diff --git a/kits/firestore-translate-text/src/handlers.ts b/kits/firestore-translate-text/src/handlers.ts index c7da5d7ccc..7d487130b4 100644 --- a/kits/firestore-translate-text/src/handlers.ts +++ b/kits/firestore-translate-text/src/handlers.ts @@ -20,6 +20,7 @@ import { type Firestore, } from "firebase-admin/firestore"; import type { Change, FirestoreEvent } from "firebase-functions/v2/firestore"; +import { toEventContext } from "./event-context"; import * as events from "./events"; import type { ResolvedTranslateConfig } from "./export-config"; import * as logs from "./logs"; @@ -70,13 +71,14 @@ export async function handleDocumentWrite( const service = createTranslationService(config, ctx.firestore); logs.start(config); - await events.recordStartEvent({ data: event.data, params: event.params }); + const context = toEventContext(event); + await events.recordStartEvent({ change: event.data, context }); const { languages, inputFieldName, outputFieldName } = config; if (validators.fieldNamesMatch(inputFieldName, outputFieldName)) { logs.fieldNamesNotDifferent(); - await events.recordCompletionEvent({ params: event.params }); + await events.recordCompletionEvent({ context }); return; } @@ -86,7 +88,7 @@ export async function handleDocumentWrite( ]) ) { logs.inputFieldNameIsOutputPath(); - await events.recordCompletionEvent({ params: event.params }); + await events.recordCompletionEvent({ context }); return; } @@ -113,7 +115,7 @@ export async function handleDocumentWrite( logs.error(err as Error); await events.recordErrorEvent(err as Error); } - await events.recordCompletionEvent({ params: event.params }); + await events.recordCompletionEvent({ context }); } async function handleCreateDocument( diff --git a/kits/firestore-translate-text/tests/event-context.test.ts b/kits/firestore-translate-text/tests/event-context.test.ts new file mode 100644 index 0000000000..ebb0c7e94e --- /dev/null +++ b/kits/firestore-translate-text/tests/event-context.test.ts @@ -0,0 +1,60 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, test } from "vitest"; +import { toEventContext } from "../src/event-context"; + +/** + * The extension published its 1st gen handler's `context` verbatim, so these + * assertions pin the fields subscribers read off it. + */ +describe("toEventContext", () => { + const event = { + id: "event-1", + time: "2026-01-01T00:00:00.000Z", + project: "demo-project", + database: "(default)", + document: "translations/id1", + params: { messageId: "id1" }, + } as any; + + test("rebuilds the 1st gen event context from a 2nd gen event", () => { + expect(toEventContext(event)).toEqual({ + eventId: "event-1", + timestamp: "2026-01-01T00:00:00.000Z", + eventType: "google.firestore.document.write", + resource: { + service: "firestore.googleapis.com", + name: "projects/demo-project/databases/(default)/documents/translations/id1", + }, + params: { messageId: "id1" }, + }); + }); + + test("names the resource under the event's own database", () => { + const context = toEventContext({ ...event, database: "messages" }); + + expect(context.resource.name).toBe( + "projects/demo-project/databases/messages/documents/translations/id1" + ); + }); + + test("passes the trigger wildcards through unchanged", () => { + const params = { messageId: "id2" }; + + expect(toEventContext({ ...event, params }).params).toEqual(params); + }); +}); diff --git a/kits/firestore-translate-text/tests/events.test.ts b/kits/firestore-translate-text/tests/events.test.ts index b5bf0a70e1..49dea069f7 100644 --- a/kits/firestore-translate-text/tests/events.test.ts +++ b/kits/firestore-translate-text/tests/events.test.ts @@ -72,11 +72,25 @@ describe("events", () => { test("publishes the start event", async () => { const events = await importEvents(CHANNEL); - await events.recordStartEvent({ params: { messageId: "id1" } }); + const context = { + eventId: "event-1", + timestamp: "2026-01-01T00:00:00.000Z", + eventType: "google.firestore.document.write", + resource: { + service: "firestore.googleapis.com", + name: "projects/demo-project/databases/(default)/documents/translations/id1", + }, + params: { messageId: "id1" }, + }; + + await events.recordStartEvent({ + change: { before: {}, after: {} }, + context, + }); expect(publish).toHaveBeenCalledWith({ type: `${EVENT_PREFIX}.onStart`, - data: { params: { messageId: "id1" } }, + data: { change: { before: {}, after: {} }, context }, }); }); @@ -109,11 +123,22 @@ describe("events", () => { test("publishes the completion event", async () => { const events = await importEvents(CHANNEL); - await events.recordCompletionEvent({ params: { messageId: "id1" } }); + const context = { + eventId: "event-1", + timestamp: "2026-01-01T00:00:00.000Z", + eventType: "google.firestore.document.write", + resource: { + service: "firestore.googleapis.com", + name: "projects/demo-project/databases/(default)/documents/translations/id1", + }, + params: { messageId: "id1" }, + }; + + await events.recordCompletionEvent({ context }); expect(publish).toHaveBeenCalledWith({ type: `${EVENT_PREFIX}.onCompletion`, - data: { params: { messageId: "id1" } }, + data: { context }, }); }); diff --git a/kits/firestore-translate-text/tests/handlers.test.ts b/kits/firestore-translate-text/tests/handlers.test.ts index b4befcddf6..09c74afc6a 100644 --- a/kits/firestore-translate-text/tests/handlers.test.ts +++ b/kits/firestore-translate-text/tests/handlers.test.ts @@ -29,6 +29,7 @@ import { messages } from "../src/logs/messages"; import { defaultEnvironment, defaultLanguages, + expectedEventContext, makeConfig, makeEvent, makeFirestore, @@ -91,12 +92,14 @@ describe("handleDocumentWrite", () => { await handleDocumentWrite(event, context()); + // The extension published the 1st gen `{change, context}` payload, so the + // kit rebuilds the same shape rather than exposing the 2nd gen event. expect(events.recordStartEvent).toHaveBeenCalledWith({ - data: event.data, - params: event.params, + change: event.data, + context: expectedEventContext(), }); expect(events.recordCompletionEvent).toHaveBeenCalledWith({ - params: event.params, + context: expectedEventContext(), }); }); @@ -353,7 +356,7 @@ describe("handleDocumentWrite", () => { expect(firestore.update).not.toHaveBeenCalled(); // the handler still completes so the extension lifecycle event fires expect(events.recordCompletionEvent).toHaveBeenCalledWith({ - params: { messageId: "id1" }, + context: expectedEventContext(), }); }); diff --git a/kits/firestore-translate-text/tests/helpers.ts b/kits/firestore-translate-text/tests/helpers.ts index 64333efef1..191e5eba0c 100644 --- a/kits/firestore-translate-text/tests/helpers.ts +++ b/kits/firestore-translate-text/tests/helpers.ts @@ -93,17 +93,47 @@ export function makeSnapshot( } as unknown as DocumentSnapshot; } +export const EVENT_ID = "event-1"; +export const EVENT_TIME = "2026-01-01T00:00:00.000Z"; +export const EVENT_PROJECT = "demo-project"; +export const EVENT_DATABASE = "(default)"; +export const EVENT_DOCUMENT = "translations/id1"; + export function makeEvent( before: DocumentSnapshot | undefined, after: DocumentSnapshot | undefined, params: Record = { messageId: "id1" } ): TranslateWriteEvent { return { + id: EVENT_ID, + time: EVENT_TIME, + project: EVENT_PROJECT, + database: EVENT_DATABASE, + document: EVENT_DOCUMENT, data: before || after ? { before, after } : undefined, params, } as unknown as TranslateWriteEvent; } +/** + * The 1st gen `EventContext` the extension published inside its `onStart` and + * `onCompletion` payloads, as rebuilt from the event `makeEvent` produces. + */ +export function expectedEventContext( + params: Record = { messageId: "id1" } +) { + return { + eventId: EVENT_ID, + timestamp: EVENT_TIME, + eventType: "google.firestore.document.write", + resource: { + service: "firestore.googleapis.com", + name: `projects/${EVENT_PROJECT}/databases/${EVENT_DATABASE}/documents/${EVENT_DOCUMENT}`, + }, + params, + }; +} + /** * A `Firestore` stub whose transaction handler records `update` calls, matching * `mockFirestoreTransaction`/`mockFirestoreUpdate` in the extension tests. diff --git a/kits/speech-to-text/README.md b/kits/speech-to-text/README.md index c522474333..9262ddc5fe 100644 --- a/kits/speech-to-text/README.md +++ b/kits/speech-to-text/README.md @@ -191,14 +191,6 @@ published. Per-event selection is gone in practice, because the CLI rejects any event types are published. With `EVENTARC_CHANNEL` unset, nothing is published and the function is otherwise unaffected. -### `fail` events for unexpected errors now say what went wrong - -Typed pipeline failures (a zero-stream file, an ffmpeg error, a null -transcription) carry the same payload as before. Unexpected errors did not: the -extension published the caught `Error` directly, and because an `Error`'s -`message` and `stack` are not serialised to JSON, subscribers received -`{"error":{}}`. The kit publishes `{ error: { message, stack } }` instead. - ### The trigger is 2nd gen `transcribeAudio` is a 2nd gen Cloud Storage function where the extension was 1st @@ -222,6 +214,11 @@ gen. Its service account needs `roles/eventarc.eventReceiver` and - Multi-channel audio still produces a transcript per channel tag, and a file with more than one stream still produces a warning rather than a failure. - There is no backfill for audio already in the bucket, as before. +- The `complete` and `fail` payloads. Typed pipeline failures still carry the + failure and the object name, and an unexpected error is still published as + `{ error }`. An `Error`'s `message` and `stack` are not enumerable, so + subscribers still receive `{"error":{}}` for those and have to read the + function logs; a thrown non-error still arrives with its `name` and `message`. ## API surface diff --git a/kits/speech-to-text/src/events.ts b/kits/speech-to-text/src/events.ts index 64fd1c5c30..2a5700a2f1 100644 --- a/kits/speech-to-text/src/events.ts +++ b/kits/speech-to-text/src/events.ts @@ -83,6 +83,12 @@ export const recordFailureEvent = async ( /** * Publishes the `fail` event for an unexpected error thrown by the pipeline. * + * The error is published as-is to keep the payload identical to the extension's. + * An `Error`'s `message` and `stack` are not enumerable, so a genuine `Error` + * serialises to `{"error":{}}`, while the plain object `errorFromAny` builds for + * a thrown non-error keeps its `name` and `message`. Anything richer would be a + * payload change for existing subscribers. + * * @param error - The error that aborted processing. */ export const recordErrorEvent = async (error: Error): Promise => { @@ -90,10 +96,7 @@ export const recordErrorEvent = async (error: Error): Promise => { await eventChannel.publish({ type: FAIL_EVENT_TYPE, data: { - error: { - message: error.message, - stack: error.stack, - }, + error, }, }); }; diff --git a/kits/speech-to-text/tests/events.test.ts b/kits/speech-to-text/tests/events.test.ts index 45f772e52c..5f1caddd08 100644 --- a/kits/speech-to-text/tests/events.test.ts +++ b/kits/speech-to-text/tests/events.test.ts @@ -73,7 +73,7 @@ describe("events", () => { }); }); - test("serializes the error message and stack into the fail payload", async () => { + test("publishes the error itself in the fail payload", async () => { process.env.EVENTARC_CHANNEL = "projects/p/locations/l/channels/c"; const events = await import("../src/events"); events.setupEventChannel(); @@ -83,17 +83,26 @@ describe("events", () => { expect(publish).toHaveBeenCalledWith({ type: "firebase.extensions.storage-transcribe-audio.v1.fail", - data: { - error: { message: "kaboom", stack: err.stack }, - }, + data: { error: err }, + }); + // Parity with the extension: `message` and `stack` are not enumerable, so + // subscribers receive `{"error":{}}` for a genuine `Error`. + const payload = publish.mock.calls[0][0]; + expect(JSON.parse(JSON.stringify(payload)).data).toEqual({ error: {} }); + }); + + test("keeps the name and message of a thrown non-error in the fail payload", async () => { + process.env.EVENTARC_CHANNEL = "projects/p/locations/l/channels/c"; + const events = await import("../src/events"); + const { errorFromAny } = await import("../src/util"); + events.setupEventChannel(); + + await events.recordErrorEvent(errorFromAny("not an error")); + + const payload = publish.mock.calls[0][0]; + expect(JSON.parse(JSON.stringify(payload)).data).toEqual({ + error: { name: "Thrown non-error object", message: "not an error" }, }); - // Guard against the original bug: a raw Error serializes to `{}`. - const payload = publish.mock.calls[0][0] as { - data: { error: { message: string } }; - }; - expect(JSON.parse(JSON.stringify(payload)).data.error.message).toBe( - "kaboom" - ); }); test("is a no-op when no channel is configured", async () => { From 6396a75fd4b9544077abbab38433787cbfd9963b Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Wed, 2 Sep 2026 15:20:22 +0100 Subject: [PATCH 2/5] test(kits): pin the event payloads at the trigger and the wire Two problems in the parity tests. The `firestore-counter` fixtures used the internal-state path with only a `shardId`, but `handleShardWrite` serves the `{collection}/{counter=**}/_counter_shards_/{shardId}` trigger, so they pinned a `resource.name` and `params` map no subscriber receives. And nothing asserted the serialized payload for either Firestore kit, even though `firebase-admin` puts it on the wire as `JSON.stringify(data)` and that round-trip is the whole point of the fix. Both `events.test.ts` suites now build the context with `toEventContext` and assert what a subscriber reads. Refs #3027 --- .../tests/event-context.test.ts | 12 ++-- kits/firestore-counter/tests/events.test.ts | 60 ++++++++++++------- kits/firestore-counter/tests/handlers.test.ts | 8 +-- .../tests/events.test.ts | 60 ++++++++++++------- 4 files changed, 90 insertions(+), 50 deletions(-) diff --git a/kits/firestore-counter/tests/event-context.test.ts b/kits/firestore-counter/tests/event-context.test.ts index fc55057c17..54295c8a20 100644 --- a/kits/firestore-counter/tests/event-context.test.ts +++ b/kits/firestore-counter/tests/event-context.test.ts @@ -27,8 +27,8 @@ describe("toEventContext", () => { time: "2026-01-01T00:00:00.000Z", project: "demo-project", database: "(default)", - document: "_firebase_ext_/sharded_counter", - params: { shardId: "0000" }, + document: "pages/home/_counter_shards_/0000", + params: { collection: "pages", counter: "home", shardId: "0000" }, } as any; test("rebuilds the 1st gen event context from a 2nd gen event", () => { @@ -38,9 +38,9 @@ describe("toEventContext", () => { eventType: "google.firestore.document.write", resource: { service: "firestore.googleapis.com", - name: "projects/demo-project/databases/(default)/documents/_firebase_ext_/sharded_counter", + name: "projects/demo-project/databases/(default)/documents/pages/home/_counter_shards_/0000", }, - params: { shardId: "0000" }, + params: { collection: "pages", counter: "home", shardId: "0000" }, }); }); @@ -48,12 +48,12 @@ describe("toEventContext", () => { const context = toEventContext({ ...event, database: "counters" }); expect(context.resource.name).toBe( - "projects/demo-project/databases/counters/documents/_firebase_ext_/sharded_counter" + "projects/demo-project/databases/counters/documents/pages/home/_counter_shards_/0000" ); }); test("passes the trigger wildcards through unchanged", () => { - const params = { collection: "pages", counter: "home", shardId: "0001" }; + const params = { collection: "docs", counter: "a/b/c", shardId: "0001" }; expect(toEventContext({ ...event, params }).params).toEqual(params); }); diff --git a/kits/firestore-counter/tests/events.test.ts b/kits/firestore-counter/tests/events.test.ts index f2ef871712..deb3f50425 100644 --- a/kits/firestore-counter/tests/events.test.ts +++ b/kits/firestore-counter/tests/events.test.ts @@ -15,6 +15,17 @@ */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { toEventContext } from "../src/event-context"; + +/** A shard write on the `{collection}/{counter=**}/_counter_shards_/{shardId}` trigger. */ +const SHARD_WRITE = { + id: "event-1", + time: "2026-01-01T00:00:00.000Z", + project: "demo-project", + database: "(default)", + document: "pages/home/_counter_shards_/0000", + params: { collection: "pages", counter: "home", shardId: "0000" }, +} as any; const publish = vi.fn(); const channel = vi.fn(() => ({ publish })); @@ -81,16 +92,7 @@ describe("event publishing", () => { test("publishes start events", async () => { const events = await setupEnabledEvents(); - const context = { - eventId: "event-1", - timestamp: "2026-01-01T00:00:00.000Z", - eventType: "google.firestore.document.write", - resource: { - service: "firestore.googleapis.com", - name: "projects/demo-project/databases/(default)/documents/_firebase_ext_/sharded_counter", - }, - params: { shardId: "0000" }, - }; + const context = toEventContext(SHARD_WRITE); await events.recordStartEvent({ change: { before: {}, after: {} }, @@ -133,16 +135,7 @@ describe("event publishing", () => { test("publishes completion events", async () => { const events = await setupEnabledEvents(); - const context = { - eventId: "event-1", - timestamp: "2026-01-01T00:00:00.000Z", - eventType: "google.firestore.document.write", - resource: { - service: "firestore.googleapis.com", - name: "projects/demo-project/databases/(default)/documents/_firebase_ext_/sharded_counter", - }, - params: { shardId: "0000" }, - }; + const context = toEventContext(SHARD_WRITE); await events.recordCompletionEvent({ context }); @@ -152,6 +145,33 @@ describe("event publishing", () => { }); }); + test("puts the whole 1st gen context on the wire", async () => { + const events = await setupEnabledEvents(); + const context = toEventContext(SHARD_WRITE); + + await events.recordStartEvent({ + change: { before: {}, after: {} }, + context, + }); + await events.recordCompletionEvent({ context }); + + // `firebase-admin` sends the payload as `JSON.stringify(data)`, so this is + // what a subscriber of the extension's events actually reads. + expect(publish.mock.calls.length).toBe(2); + for (const [event] of publish.mock.calls) { + expect(JSON.parse(JSON.stringify(event.data)).context).toEqual({ + eventId: "event-1", + timestamp: "2026-01-01T00:00:00.000Z", + eventType: "google.firestore.document.write", + resource: { + service: "firestore.googleapis.com", + name: "projects/demo-project/databases/(default)/documents/pages/home/_counter_shards_/0000", + }, + params: { collection: "pages", counter: "home", shardId: "0000" }, + }); + } + }); + test("does nothing before the channel is set up", async () => { vi.stubEnv("EVENTARC_CHANNEL", "channel"); const events = await importEvents(); diff --git a/kits/firestore-counter/tests/handlers.test.ts b/kits/firestore-counter/tests/handlers.test.ts index 8abe0f3446..bf7bf2c4ce 100644 --- a/kits/firestore-counter/tests/handlers.test.ts +++ b/kits/firestore-counter/tests/handlers.test.ts @@ -118,9 +118,9 @@ describe("handleShardWrite", () => { time: "2026-01-01T00:00:00.000Z", project: "demo-project", database: "(default)", - document: "_firebase_ext_/sharded_counter", + document: "pages/home/_counter_shards_/0000", data: { after: { exists: true } }, - params: { shardId: "0000" }, + params: { collection: "pages", counter: "home", shardId: "0000" }, } as any; await handleShardWrite(event, makeCtx()); @@ -133,9 +133,9 @@ describe("handleShardWrite", () => { eventType: "google.firestore.document.write", resource: { service: "firestore.googleapis.com", - name: "projects/demo-project/databases/(default)/documents/_firebase_ext_/sharded_counter", + name: "projects/demo-project/databases/(default)/documents/pages/home/_counter_shards_/0000", }, - params: { shardId: "0000" }, + params: { collection: "pages", counter: "home", shardId: "0000" }, }; expect(events.recordStartEvent).toHaveBeenCalledWith({ diff --git a/kits/firestore-translate-text/tests/events.test.ts b/kits/firestore-translate-text/tests/events.test.ts index 49dea069f7..4ba9e6b196 100644 --- a/kits/firestore-translate-text/tests/events.test.ts +++ b/kits/firestore-translate-text/tests/events.test.ts @@ -15,6 +15,7 @@ */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { toEventContext } from "../src/event-context"; const publish = vi.fn().mockResolvedValue(undefined); const channel = vi.fn(() => ({ publish })); @@ -26,6 +27,16 @@ vi.mock("firebase-admin/eventarc", () => ({ const CHANNEL = "projects/p/locations/l/channels/firebase"; const EVENT_PREFIX = "firebase.extensions.firestore-translate-text.v1"; +/** A write on the `COLLECTION_PATH/{messageId}` trigger. */ +const DOCUMENT_WRITE = { + id: "event-1", + time: "2026-01-01T00:00:00.000Z", + project: "demo-project", + database: "(default)", + document: "translations/id1", + params: { messageId: "id1" }, +} as any; + async function importEvents(channelName?: string) { if (channelName) { process.env.EVENTARC_CHANNEL = channelName; @@ -72,16 +83,7 @@ describe("events", () => { test("publishes the start event", async () => { const events = await importEvents(CHANNEL); - const context = { - eventId: "event-1", - timestamp: "2026-01-01T00:00:00.000Z", - eventType: "google.firestore.document.write", - resource: { - service: "firestore.googleapis.com", - name: "projects/demo-project/databases/(default)/documents/translations/id1", - }, - params: { messageId: "id1" }, - }; + const context = toEventContext(DOCUMENT_WRITE); await events.recordStartEvent({ change: { before: {}, after: {} }, @@ -123,16 +125,7 @@ describe("events", () => { test("publishes the completion event", async () => { const events = await importEvents(CHANNEL); - const context = { - eventId: "event-1", - timestamp: "2026-01-01T00:00:00.000Z", - eventType: "google.firestore.document.write", - resource: { - service: "firestore.googleapis.com", - name: "projects/demo-project/databases/(default)/documents/translations/id1", - }, - params: { messageId: "id1" }, - }; + const context = toEventContext(DOCUMENT_WRITE); await events.recordCompletionEvent({ context }); @@ -142,6 +135,33 @@ describe("events", () => { }); }); + test("puts the whole 1st gen context on the wire", async () => { + const events = await importEvents(CHANNEL); + const context = toEventContext(DOCUMENT_WRITE); + + await events.recordStartEvent({ + change: { before: {}, after: {} }, + context, + }); + await events.recordCompletionEvent({ context }); + + // `firebase-admin` sends the payload as `JSON.stringify(data)`, so this is + // what a subscriber of the extension's events actually reads. + expect(publish.mock.calls.length).toBe(2); + for (const [event] of publish.mock.calls) { + expect(JSON.parse(JSON.stringify(event.data)).context).toEqual({ + eventId: "event-1", + timestamp: "2026-01-01T00:00:00.000Z", + eventType: "google.firestore.document.write", + resource: { + service: "firestore.googleapis.com", + name: "projects/demo-project/databases/(default)/documents/translations/id1", + }, + params: { messageId: "id1" }, + }); + } + }); + test("is a no-op when no channel is configured", async () => { const events = await importEvents(); From 0b5f8306d7c4abd32fa5bc8e772742c1b2a675be Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Wed, 2 Sep 2026 15:22:03 +0100 Subject: [PATCH 3/5] style(kits): revert a stray prettier 3 reformat The pre-commit hook shells out to a bare `prettier`, which resolved to a global 3.7.4 in a worktree with no root `node_modules` and reflowed the `vi.importActual` call in the counter handler tests. The repo pins 2.8.8. Refs #3027 --- kits/firestore-counter/tests/handlers.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/kits/firestore-counter/tests/handlers.test.ts b/kits/firestore-counter/tests/handlers.test.ts index bf7bf2c4ce..2f10cc256f 100644 --- a/kits/firestore-counter/tests/handlers.test.ts +++ b/kits/firestore-counter/tests/handlers.test.ts @@ -24,10 +24,9 @@ const workerRun = vi.fn(); const workerCtor = vi.fn(); vi.mock("../src/controller", async () => { - const actual = - await vi.importActual( - "../src/controller" - ); + const actual = await vi.importActual( + "../src/controller" + ); return { ControllerStatus: actual.ControllerStatus, ShardedCounterController: class { From f69b065a169871a83fe89cb68d459e63756bb2e9 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Mon, 7 Sep 2026 16:21:02 +0100 Subject: [PATCH 4/5] fix(kits): declare roles/eventarc.publisher on event-publishing kits The Extensions platform granted the extension SA publish rights on the instance's Eventarc channel implicitly from the `events:` block in extension.yaml. Kits get no implicit grant, so every channel.publish() call failed with PERMISSION_DENIED and no custom event was delivered. ## Changes - Declare `roles/eventarc.publisher` in the eight kits that publish custom events (delete-user-data, firestore-bigquery-export, firestore-counter, firestore-send-email, firestore-translate-text, firestore-vector-search, speech-to-text, storage-resize-images). - Document the role in each kit's README role table. - Extend the firestore-translate-text role-declaration test and add a regression test pinning the publisher role. --- kits/delete-user-data/README.md | 1 + kits/delete-user-data/src/index.ts | 5 +++++ kits/firestore-bigquery-export/README.md | 1 + kits/firestore-bigquery-export/src/index.ts | 5 +++++ kits/firestore-counter/README.md | 1 + kits/firestore-counter/src/index.ts | 5 +++++ kits/firestore-send-email/README.md | 1 + kits/firestore-send-email/src/index.ts | 5 +++++ kits/firestore-translate-text/README.md | 1 + kits/firestore-translate-text/src/index.ts | 5 +++++ kits/firestore-translate-text/tests/index.test.ts | 11 +++++++++++ kits/firestore-vector-search/README.md | 1 + kits/firestore-vector-search/src/index.ts | 5 +++++ kits/speech-to-text/README.md | 1 + kits/speech-to-text/src/index.ts | 5 +++++ kits/storage-resize-images/README.md | 1 + kits/storage-resize-images/src/index.ts | 5 +++++ 17 files changed, 59 insertions(+) diff --git a/kits/delete-user-data/README.md b/kits/delete-user-data/README.md index 66b1cb1f14..c0f094a008 100644 --- a/kits/delete-user-data/README.md +++ b/kits/delete-user-data/README.md @@ -30,6 +30,7 @@ service account for this codebase — it conflicts with that automatic setup. | `roles/pubsub.admin` | publish/subscribe discovery and deletion topics | | `roles/eventarc.eventReceiver` | receive Gen2 event triggers | | `roles/run.invoker` | allow Eventarc to invoke the Gen2 Cloud Run service | +| `roles/eventarc.publisher` | publish the kit's custom Eventarc events (the Extensions platform granted this implicitly) | ## Usage diff --git a/kits/delete-user-data/src/index.ts b/kits/delete-user-data/src/index.ts index 5c39e70ff7..49d9e85f90 100644 --- a/kits/delete-user-data/src/index.ts +++ b/kits/delete-user-data/src/index.ts @@ -42,6 +42,11 @@ const REQUIRED_ROLES: ReadonlyArray = [ // Gen2 event triggers need Eventarc receive and run.invoker on the function SA. "roles/eventarc.eventReceiver", "roles/run.invoker", + // The Extensions platform granted publish rights on the extension's Eventarc + // channel implicitly from `events:` in extension.yaml. Kits get no implicit + // grant, so without this the `channel.publish()` calls in ./events fail with + // PERMISSION_DENIED and no custom event is ever delivered. + "roles/eventarc.publisher", ]; for (const role of REQUIRED_ROLES) { diff --git a/kits/firestore-bigquery-export/README.md b/kits/firestore-bigquery-export/README.md index 41697a92b7..82fa3b8a7f 100644 --- a/kits/firestore-bigquery-export/README.md +++ b/kits/firestore-bigquery-export/README.md @@ -30,6 +30,7 @@ conflicts with that automatic setup. | `roles/datastore.user` | write failed-row records back to Firestore (only if you configure a backup collection) | | `roles/eventarc.eventReceiver` | receive Gen2 Firestore trigger events | | `roles/run.invoker` | allow Eventarc to invoke the Gen2 Cloud Run service | +| `roles/eventarc.publisher` | publish the kit's custom Eventarc events (the Extensions platform granted this implicitly) | | `bigquery.googleapis.com` | mirror Firestore collection changes in BigQuery | If the dataset lives in a different project (`BIGQUERY_PROJECT_ID`), grant the diff --git a/kits/firestore-bigquery-export/src/index.ts b/kits/firestore-bigquery-export/src/index.ts index 39711f40a0..ce30af85ab 100644 --- a/kits/firestore-bigquery-export/src/index.ts +++ b/kits/firestore-bigquery-export/src/index.ts @@ -61,6 +61,11 @@ const REQUIRED_ROLES: ReadonlyArray = [ // Gen2 Firestore triggers need Eventarc receive and run.invoker on the function SA. "roles/eventarc.eventReceiver", "roles/run.invoker", + // The Extensions platform granted publish rights on the extension's Eventarc + // channel implicitly from `events:` in extension.yaml. Kits get no implicit + // grant, so without this the `channel.publish()` calls in ./events fail with + // PERMISSION_DENIED and no custom event is ever delivered. + "roles/eventarc.publisher", ]; const REQUIRED_APIS = [ { diff --git a/kits/firestore-counter/README.md b/kits/firestore-counter/README.md index 2d05c128ee..43f643b2aa 100644 --- a/kits/firestore-counter/README.md +++ b/kits/firestore-counter/README.md @@ -27,6 +27,7 @@ service account for this codebase — it conflicts with that automatic setup. | `roles/cloudscheduler.admin` | schedule the controller that flushes shards | | `roles/eventarc.eventReceiver` | receive Gen2 Firestore trigger events | | `roles/run.invoker` | allow Eventarc/Scheduler to invoke the Gen2 Cloud Run service | +| `roles/eventarc.publisher` | publish the kit's custom Eventarc events (the Extensions platform granted this implicitly) | ## Usage diff --git a/kits/firestore-counter/src/index.ts b/kits/firestore-counter/src/index.ts index 57b6dfb4c6..7a8900e76a 100644 --- a/kits/firestore-counter/src/index.ts +++ b/kits/firestore-counter/src/index.ts @@ -39,6 +39,11 @@ const REQUIRED_ROLES: ReadonlyArray = [ // Gen2 Firestore triggers need Eventarc receive and run.invoker on the function SA. "roles/eventarc.eventReceiver", "roles/run.invoker", + // The Extensions platform granted publish rights on the extension's Eventarc + // channel implicitly from `events:` in extension.yaml. Kits get no implicit + // grant, so without this the `channel.publish()` calls in ./events fail with + // PERMISSION_DENIED and no custom event is ever delivered. + "roles/eventarc.publisher", ]; for (const role of REQUIRED_ROLES) { diff --git a/kits/firestore-send-email/README.md b/kits/firestore-send-email/README.md index 722146cd36..70fe221591 100644 --- a/kits/firestore-send-email/README.md +++ b/kits/firestore-send-email/README.md @@ -27,6 +27,7 @@ service account for this codebase — it conflicts with that automatic setup. | `roles/datastore.user` | read mail documents and write delivery status | | `roles/eventarc.eventReceiver` | receive Gen2 Firestore trigger events | | `roles/run.invoker` | allow Eventarc to invoke the Gen2 Cloud Run service | +| `roles/eventarc.publisher` | publish the kit's custom Eventarc events (the Extensions platform granted this implicitly) | ## Usage diff --git a/kits/firestore-send-email/src/index.ts b/kits/firestore-send-email/src/index.ts index de44f6f815..a40fe3c494 100644 --- a/kits/firestore-send-email/src/index.ts +++ b/kits/firestore-send-email/src/index.ts @@ -34,6 +34,11 @@ const REQUIRED_ROLES: ReadonlyArray = [ // Gen2 Firestore triggers need Eventarc receive and run.invoker on the function SA. "roles/eventarc.eventReceiver", "roles/run.invoker", + // The Extensions platform granted publish rights on the extension's Eventarc + // channel implicitly from `events:` in extension.yaml. Kits get no implicit + // grant, so without this the `channel.publish()` calls in ./events fail with + // PERMISSION_DENIED and no custom event is ever delivered. + "roles/eventarc.publisher", ]; for (const role of REQUIRED_ROLES) { diff --git a/kits/firestore-translate-text/README.md b/kits/firestore-translate-text/README.md index 1e2fa145ef..2e70642691 100644 --- a/kits/firestore-translate-text/README.md +++ b/kits/firestore-translate-text/README.md @@ -28,6 +28,7 @@ conflicts with that automatic setup. | `roles/datastore.user` | read input docs and write translations | | `roles/eventarc.eventReceiver` | receive Gen2 Firestore trigger events | | `roles/run.invoker` | allow Eventarc to invoke the Gen2 Cloud Run service | +| `roles/eventarc.publisher` | publish the kit's custom Eventarc events (the Extensions platform granted this implicitly) | | `translate.googleapis.com` | Google Translate provider | ## Usage diff --git a/kits/firestore-translate-text/src/index.ts b/kits/firestore-translate-text/src/index.ts index 2a9158fb9c..3d7fb80194 100644 --- a/kits/firestore-translate-text/src/index.ts +++ b/kits/firestore-translate-text/src/index.ts @@ -35,6 +35,11 @@ const REQUIRED_ROLES: ReadonlyArray = [ // Gen2 Firestore triggers need Eventarc receive and run.invoker on the function SA. "roles/eventarc.eventReceiver", "roles/run.invoker", + // The Extensions platform granted publish rights on the extension's Eventarc + // channel implicitly from `events:` in extension.yaml. Kits get no implicit + // grant, so without this the `channel.publish()` calls in ./events fail with + // PERMISSION_DENIED and no custom event is ever delivered. + "roles/eventarc.publisher", ]; const REQUIRED_APIS = [ { diff --git a/kits/firestore-translate-text/tests/index.test.ts b/kits/firestore-translate-text/tests/index.test.ts index 9cb9c6ab6c..e0fdce4f31 100644 --- a/kits/firestore-translate-text/tests/index.test.ts +++ b/kits/firestore-translate-text/tests/index.test.ts @@ -119,9 +119,20 @@ describe("index", () => { "roles/datastore.user", "roles/eventarc.eventReceiver", "roles/run.invoker", + "roles/eventarc.publisher", ]); }); + // The extension published custom events with only `datastore.user` declared: + // the Extensions platform granted publish on the channel implicitly from the + // `events:` block in extension.yaml. Kits get no implicit grant, so the role + // has to be declared or every `channel.publish()` 403s at runtime. + test("declares the Eventarc publisher role the custom events need", async () => { + await importIndex(); + + expect(requiresRole.mock.calls.flat()).toContain("roles/eventarc.publisher"); + }); + test("declares the Cloud Translation API requirement", async () => { await importIndex(); diff --git a/kits/firestore-vector-search/README.md b/kits/firestore-vector-search/README.md index 0552ca77b5..10b147634b 100644 --- a/kits/firestore-vector-search/README.md +++ b/kits/firestore-vector-search/README.md @@ -31,6 +31,7 @@ conflicts with that automatic setup. | `roles/datastore.indexAdmin` | manage vector indexes | | `roles/eventarc.eventReceiver` | receive Gen2 Firestore trigger events | | `roles/run.invoker` | allow Eventarc/Tasks to invoke the Gen2 Cloud Run service | +| `roles/eventarc.publisher` | publish the kit's custom Eventarc events (the Extensions platform granted this implicitly) | | `aiplatform.googleapis.com` | Vertex AI embedding/search | | `storage-component.googleapis.com` | read image data from Cloud Storage | diff --git a/kits/firestore-vector-search/src/index.ts b/kits/firestore-vector-search/src/index.ts index b8770b5370..5fbb19411c 100644 --- a/kits/firestore-vector-search/src/index.ts +++ b/kits/firestore-vector-search/src/index.ts @@ -66,6 +66,11 @@ const REQUIRED_ROLES: ReadonlyArray = [ // Gen2 Firestore triggers need Eventarc receive and run.invoker on the function SA. "roles/eventarc.eventReceiver", "roles/run.invoker", + // The Extensions platform granted publish rights on the extension's Eventarc + // channel implicitly from `events:` in extension.yaml. Kits get no implicit + // grant, so without this the `channel.publish()` calls in ./events fail with + // PERMISSION_DENIED and no custom event is ever delivered. + "roles/eventarc.publisher", ]; const REQUIRED_APIS = [ { diff --git a/kits/speech-to-text/README.md b/kits/speech-to-text/README.md index 9262ddc5fe..bde98d6fb6 100644 --- a/kits/speech-to-text/README.md +++ b/kits/speech-to-text/README.md @@ -29,6 +29,7 @@ conflicts with that automatic setup. | `roles/datastore.user` | write transcript documents to Firestore | | `roles/eventarc.eventReceiver` | receive Gen2 Storage trigger events | | `roles/run.invoker` | allow Eventarc to invoke the Gen2 Cloud Run service | +| `roles/eventarc.publisher` | publish the kit's custom Eventarc events (the Extensions platform granted this implicitly) | | `speech.googleapis.com` | transcribe audio | ## Usage diff --git a/kits/speech-to-text/src/index.ts b/kits/speech-to-text/src/index.ts index b256954256..2e623f3a60 100644 --- a/kits/speech-to-text/src/index.ts +++ b/kits/speech-to-text/src/index.ts @@ -37,6 +37,11 @@ const REQUIRED_ROLES: ReadonlyArray = [ // Gen2 Storage triggers need Eventarc receive and run.invoker on the function SA. "roles/eventarc.eventReceiver", "roles/run.invoker", + // The Extensions platform granted publish rights on the extension's Eventarc + // channel implicitly from `events:` in extension.yaml. Kits get no implicit + // grant, so without this the `channel.publish()` calls in ./events fail with + // PERMISSION_DENIED and no custom event is ever delivered. + "roles/eventarc.publisher", ]; const REQUIRED_APIS = [ { diff --git a/kits/storage-resize-images/README.md b/kits/storage-resize-images/README.md index bb18cf74b3..f8713bb494 100644 --- a/kits/storage-resize-images/README.md +++ b/kits/storage-resize-images/README.md @@ -29,6 +29,7 @@ conflicts with that automatic setup. | `roles/aiplatform.user` | optional content filtering via Vertex AI | | `roles/eventarc.eventReceiver` | receive Gen2 Storage trigger events | | `roles/run.invoker` | allow Eventarc to invoke the Gen2 Cloud Run service | +| `roles/eventarc.publisher` | publish the kit's custom Eventarc events (the Extensions platform granted this implicitly) | | `storage-component.googleapis.com` | use Cloud Storage | ## Usage diff --git a/kits/storage-resize-images/src/index.ts b/kits/storage-resize-images/src/index.ts index 790f1c3322..1b342df7e5 100644 --- a/kits/storage-resize-images/src/index.ts +++ b/kits/storage-resize-images/src/index.ts @@ -37,6 +37,11 @@ const REQUIRED_ROLES: ReadonlyArray = [ // Gen2 Storage triggers need Eventarc receive and run.invoker on the function SA. "roles/eventarc.eventReceiver", "roles/run.invoker", + // The Extensions platform granted publish rights on the extension's Eventarc + // channel implicitly from `events:` in extension.yaml. Kits get no implicit + // grant, so without this the `channel.publish()` calls in ./events fail with + // PERMISSION_DENIED and no custom event is ever delivered. + "roles/eventarc.publisher", ]; const REQUIRED_APIS = [ { From 91a956f3af2d6c726f2fe2f6de5a74555da08522 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 8 Sep 2026 15:14:59 +0100 Subject: [PATCH 5/5] style(kits): wrap the publisher-role assertion for prettier --- kits/firestore-translate-text/tests/index.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/kits/firestore-translate-text/tests/index.test.ts b/kits/firestore-translate-text/tests/index.test.ts index 76faea82bd..ab4edd46a0 100644 --- a/kits/firestore-translate-text/tests/index.test.ts +++ b/kits/firestore-translate-text/tests/index.test.ts @@ -133,7 +133,9 @@ describe("index", () => { test("declares the Eventarc publisher role the custom events need", async () => { await importIndex(); - expect(requiresRole.mock.calls.flat()).toContain("roles/eventarc.publisher"); + expect(requiresRole.mock.calls.flat()).toContain( + "roles/eventarc.publisher" + ); }); test("declares the Cloud Firestore API requirement", async () => {