diff --git a/kits/delete-user-data/README.md b/kits/delete-user-data/README.md index c920771b0e..3c781c3c7f 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 624e5751ec..438e286325 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", ]; const REQUIRED_APIS = [ { diff --git a/kits/firestore-bigquery-export/README.md b/kits/firestore-bigquery-export/README.md index c8205556a8..318e996b4e 100644 --- a/kits/firestore-bigquery-export/README.md +++ b/kits/firestore-bigquery-export/README.md @@ -24,15 +24,16 @@ below, enables the listed APIs, and attaches the account to every function in this kit. Do not set a custom runtime service account for this codebase — it conflicts with that automatic setup. -| Role / API | Why | -| ------------------------------ | -------------------------------------------------------------------------------------- | -| `roles/bigquery.dataEditor` | create dataset/table/views; insert rows | -| `roles/bigquery.user` | run BigQuery jobs and materialized views | -| `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/cloudtasks.enqueuer` | enqueue failed writes onto the kit's own `syncBigQuery` task queue | -| `bigquery.googleapis.com` | mirror Firestore collection changes in BigQuery | +| Role / API | Why | +| ------------------------------ | ------------------------------------------------------------------------------------------ | +| `roles/bigquery.dataEditor` | create dataset/table/views; insert rows | +| `roles/bigquery.user` | run BigQuery jobs and materialized views | +| `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) | +| `roles/cloudtasks.enqueuer` | enqueue failed writes onto the kit's own `syncBigQuery` task queue | +| `bigquery.googleapis.com` | mirror Firestore collection changes in BigQuery | If the dataset lives in a different project (`BIGQUERY_PROJECT_ID`), grant the managed runtime service account the `bigquery.*` roles on that project. For a diff --git a/kits/firestore-bigquery-export/src/index.ts b/kits/firestore-bigquery-export/src/index.ts index d74383ae35..63d0711410 100644 --- a/kits/firestore-bigquery-export/src/index.ts +++ b/kits/firestore-bigquery-export/src/index.ts @@ -77,6 +77,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", // The trigger enqueues failed writes onto its own syncBigQuery task queue. "roles/cloudtasks.enqueuer", ]; diff --git a/kits/firestore-counter/README.md b/kits/firestore-counter/README.md index d9e4ecfe90..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 @@ -161,16 +162,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 +192,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/src/index.ts b/kits/firestore-counter/src/index.ts index b1fe3b1d66..881a3c3713 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", ]; const REQUIRED_APIS = [ { 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..54295c8a20 --- /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: "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", () => { + 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/pages/home/_counter_shards_/0000", + }, + params: { collection: "pages", counter: "home", 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/pages/home/_counter_shards_/0000" + ); + }); + + test("passes the trigger wildcards through unchanged", () => { + 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 08da4d04b6..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,11 +92,16 @@ describe("event publishing", () => { test("publishes start events", async () => { const events = await setupEnabledEvents(); - await events.recordStartEvent({ params: { shardId: "0000" } }); + const context = toEventContext(SHARD_WRITE); + + 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,12 +135,41 @@ describe("event publishing", () => { test("publishes completion events", async () => { const events = await setupEnabledEvents(); - await events.recordCompletionEvent({ params: { shardId: "0000" } }); + const context = toEventContext(SHARD_WRITE); + + await events.recordCompletionEvent({ context }); expect(publish).toHaveBeenCalledWith({ type: "firebase.extensions.firestore-counter.v1.onCompletion", - data: { params: { shardId: "0000" } }, + data: { context }, + }); + }); + + 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 () => { diff --git a/kits/firestore-counter/tests/handlers.test.ts b/kits/firestore-counter/tests/handlers.test.ts index f5803772fe..2f10cc256f 100644 --- a/kits/firestore-counter/tests/handlers.test.ts +++ b/kits/firestore-counter/tests/handlers.test.ts @@ -113,24 +113,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: "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()); + // 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/pages/home/_counter_shards_/0000", + }, + params: { collection: "pages", counter: "home", 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-send-email/README.md b/kits/firestore-send-email/README.md index c473548f80..51ad5a8cda 100644 --- a/kits/firestore-send-email/README.md +++ b/kits/firestore-send-email/README.md @@ -22,11 +22,12 @@ Firebase CLI 15.23.0 or later creates that account, grants the roles below, and attaches it to every function in this kit. Do not set a custom runtime service account for this codebase — it conflicts with that automatic setup. -| Role | Why | -| ------------------------------ | --------------------------------------------------- | -| `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 | +| Role | Why | +| ------------------------------ | ------------------------------------------------------------------------------------------ | +| `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 094f7cf62b..069fe6db69 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", ]; const REQUIRED_APIS = [ { diff --git a/kits/firestore-translate-text/README.md b/kits/firestore-translate-text/README.md index bec8b76ffb..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 @@ -185,16 +186,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 +214,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 3c187525ef..ca5a07477c 100644 --- a/kits/firestore-translate-text/src/handlers.ts +++ b/kits/firestore-translate-text/src/handlers.ts @@ -16,6 +16,7 @@ import { type DocumentSnapshot, FieldValue } 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"; @@ -61,13 +62,14 @@ export async function handleDocumentWrite( const { config, service } = ctx; 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; } @@ -77,7 +79,7 @@ export async function handleDocumentWrite( ]) ) { logs.inputFieldNameIsOutputPath(); - await events.recordCompletionEvent({ params: event.params }); + await events.recordCompletionEvent({ context }); return; } @@ -104,7 +106,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/src/index.ts b/kits/firestore-translate-text/src/index.ts index a31f41509c..2de3dc308b 100644 --- a/kits/firestore-translate-text/src/index.ts +++ b/kits/firestore-translate-text/src/index.ts @@ -36,6 +36,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/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..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,11 +83,16 @@ describe("events", () => { test("publishes the start event", async () => { const events = await importEvents(CHANNEL); - await events.recordStartEvent({ params: { messageId: "id1" } }); + const context = toEventContext(DOCUMENT_WRITE); + + await events.recordStartEvent({ + change: { before: {}, after: {} }, + context, + }); expect(publish).toHaveBeenCalledWith({ type: `${EVENT_PREFIX}.onStart`, - data: { params: { messageId: "id1" } }, + data: { change: { before: {}, after: {} }, context }, }); }); @@ -109,12 +125,41 @@ describe("events", () => { test("publishes the completion event", async () => { const events = await importEvents(CHANNEL); - await events.recordCompletionEvent({ params: { messageId: "id1" } }); + const context = toEventContext(DOCUMENT_WRITE); + + await events.recordCompletionEvent({ context }); expect(publish).toHaveBeenCalledWith({ type: `${EVENT_PREFIX}.onCompletion`, - data: { params: { messageId: "id1" } }, + data: { context }, + }); + }); + + 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 () => { diff --git a/kits/firestore-translate-text/tests/handlers.test.ts b/kits/firestore-translate-text/tests/handlers.test.ts index 58f9560185..881d414315 100644 --- a/kits/firestore-translate-text/tests/handlers.test.ts +++ b/kits/firestore-translate-text/tests/handlers.test.ts @@ -30,6 +30,7 @@ import { createTranslationService } from "../src/translate"; import { defaultEnvironment, defaultLanguages, + expectedEventContext, makeConfig, makeEvent, makeFirestore, @@ -97,12 +98,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(), }); }); @@ -359,7 +362,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/firestore-translate-text/tests/index.test.ts b/kits/firestore-translate-text/tests/index.test.ts index ae0828054b..ab4edd46a0 100644 --- a/kits/firestore-translate-text/tests/index.test.ts +++ b/kits/firestore-translate-text/tests/index.test.ts @@ -122,9 +122,22 @@ 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 Firestore API requirement", async () => { await importIndex(); diff --git a/kits/firestore-vector-search/src/index.ts b/kits/firestore-vector-search/src/index.ts index a90a84f6c4..5143f73ed4 100644 --- a/kits/firestore-vector-search/src/index.ts +++ b/kits/firestore-vector-search/src/index.ts @@ -66,6 +66,8 @@ const REQUIRED_ROLES: ReadonlyArray = [ // Gen2 Firestore triggers need Eventarc receive and run.invoker on the function SA. "roles/eventarc.eventReceiver", "roles/run.invoker", + // No roles/eventarc.publisher here: the extension declares `events:` but never + // publishes any of them, so the kit publishes nothing either (see #3094). ]; const REQUIRED_APIS = [ { diff --git a/kits/speech-to-text/README.md b/kits/speech-to-text/README.md index 0e2a92ec62..0a08aebc2c 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 @@ -194,14 +195,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 @@ -225,6 +218,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/src/index.ts b/kits/speech-to-text/src/index.ts index 4c94c48729..cb678c3d02 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/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 () => { diff --git a/kits/storage-resize-images/README.md b/kits/storage-resize-images/README.md index 41dd252436..213dea9b70 100644 --- a/kits/storage-resize-images/README.md +++ b/kits/storage-resize-images/README.md @@ -23,14 +23,15 @@ below, enables the listed APIs, and attaches the account to every function in this kit. Do not set a custom runtime service account for this codebase — it conflicts with that automatic setup. -| Role / API | Why | -| ---------------------------------- | --------------------------------------------------- | -| `roles/storage.admin` | read originals and write resized objects | -| `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 | -| `aiplatform.googleapis.com` | Vertex AI content filtering, if enabled | -| `storage-component.googleapis.com` | use Cloud Storage | +| Role / API | Why | +| ---------------------------------- | ------------------------------------------------------------------------------------------ | +| `roles/storage.admin` | read originals and write resized objects | +| `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) | +| `aiplatform.googleapis.com` | Vertex AI content filtering, if enabled | +| `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 648c1771e6..9ec5f6d1a8 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 = [ {