From da4f8e87175fe090931c880bf5beb1fecc2935a7 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Wed, 26 Aug 2026 18:26:24 +0100 Subject: [PATCH 1/3] fix(delete-user-data): move clearData to the v2 auth trigger Kits reject gen1 endpoints, so the v1 auth.user().onDelete trigger made the kit undeployable. onUserDeleted emits a gcfv2 endpoint and carries the same user record. It is missing from the published firebase-functions type declarations, so a local module augmentation declares it until the types catch up. --- kits/delete-user-data/src/identity-shim.d.ts | 29 ++++++++++++++++++++ kits/delete-user-data/src/index.ts | 8 +++--- 2 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 kits/delete-user-data/src/identity-shim.d.ts diff --git a/kits/delete-user-data/src/identity-shim.d.ts b/kits/delete-user-data/src/identity-shim.d.ts new file mode 100644 index 000000000..a364bb796 --- /dev/null +++ b/kits/delete-user-data/src/identity-shim.d.ts @@ -0,0 +1,29 @@ +/* + * 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 { UserRecord } from "firebase-functions/v1/auth"; +import type { CloudEvent, CloudFunction } from "firebase-functions/v2"; + +/** + * `onUserDeleted` ships in firebase-functions 7.3.2 and emits a `gcfv2` + * endpoint, but is absent from the published type declarations. Remove this + * file once the types catch up. + */ +declare module "firebase-functions/v2/identity" { + export function onUserDeleted( + handler: (event: CloudEvent) => unknown + ): CloudFunction>; +} diff --git a/kits/delete-user-data/src/index.ts b/kits/delete-user-data/src/index.ts index bd6012502..8c0a41655 100644 --- a/kits/delete-user-data/src/index.ts +++ b/kits/delete-user-data/src/index.ts @@ -17,9 +17,9 @@ import { PubSub } from "@google-cloud/pubsub"; import * as admin from "firebase-admin"; import { getFirestore } from "firebase-admin/firestore"; -import * as functionsV1 from "firebase-functions/v1"; import type { Role } from "firebase-functions/v2"; import { requiresRole } from "firebase-functions/v2"; +import { onUserDeleted } from "firebase-functions/v2/identity"; import { onMessagePublished } from "firebase-functions/v2/pubsub"; import { CONFIG_EXPRESSIONS, configFromEnv } from "./config"; import * as events from "./events"; @@ -81,9 +81,9 @@ function getContext(): HandlerContext { return ctx; } -export const clearData = functionsV1.auth.user().onDelete((user) => { - return handleClear(user.uid, getContext()); -}); +export const clearData = onUserDeleted((event) => + handleClear(event.data.uid, getContext()) +); export const handleSearch = onMessagePublished( { From fe5675285d2d7314bc7f1fa4bdd468d7a4b75ce0 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 28 Aug 2026 14:56:25 +0100 Subject: [PATCH 2/3] docs(delete-user-data): correct the generation notes for clearData The README still said clearData stays 1st gen. Also state why the onUserDeleted type is shimmed: it is beta and internal upstream, not a missing declaration that will appear on its own. --- kits/delete-user-data/README.md | 14 ++++++++------ kits/delete-user-data/src/identity-shim.d.ts | 7 +++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/kits/delete-user-data/README.md b/kits/delete-user-data/README.md index 3cc0ec9e3..256b5d9ca 100644 --- a/kits/delete-user-data/README.md +++ b/kits/delete-user-data/README.md @@ -182,12 +182,14 @@ The extension deployed to the location you picked at install time. This kit sets no region, so its functions deploy to your codebase's default (`us-central1` unless you have changed it). -### Pub/Sub handlers are 2nd gen - -`handleSearch` and `handleDeletion` are now 2nd gen functions. `clearData` -stays 1st gen, because the Firebase Auth `user.delete` trigger has no 2nd gen -equivalent. This mainly matters if you have infrastructure or alerting keyed to -function generation. +### All functions are 2nd gen + +`clearData`, `handleSearch` and `handleDeletion` are all 2nd gen functions. Kits +do not support 1st gen endpoints, so `clearData` listens on the 2nd gen Firebase +Auth event `google.firebase.auth.user.v2.deleted` instead of the 1st gen +`user.delete` trigger. That event type is still beta in `firebase-functions`. +This mainly matters if you have infrastructure or alerting keyed to function +generation. ### Empty search fields no longer error diff --git a/kits/delete-user-data/src/identity-shim.d.ts b/kits/delete-user-data/src/identity-shim.d.ts index a364bb796..25217d9b6 100644 --- a/kits/delete-user-data/src/identity-shim.d.ts +++ b/kits/delete-user-data/src/identity-shim.d.ts @@ -19,8 +19,11 @@ import type { CloudEvent, CloudFunction } from "firebase-functions/v2"; /** * `onUserDeleted` ships in firebase-functions 7.3.2 and emits a `gcfv2` - * endpoint, but is absent from the published type declarations. Remove this - * file once the types catch up. + * endpoint, but is marked `@beta @internal` upstream and so is stripped from + * the published type declarations. It is the only 2nd gen Firebase Auth + * deletion trigger, and kits reject 1st gen endpoints. Keep `firebase-functions` + * pinned exactly: this signature carries no stability guarantee. Delete this + * file once the export is public. */ declare module "firebase-functions/v2/identity" { export function onUserDeleted( From 2adb791b148842b3d7a3031da3b5a9e182422c23 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 28 Aug 2026 15:35:53 +0100 Subject: [PATCH 3/3] fix(delete-user-data): guard clearData against an event with no user record The Auth event only carries data when the payload envelope is present, so reading uid off it directly threw a TypeError. Log and skip instead. Adds the first test over the trigger itself: it pins the gen2 endpoint, which is what the deploy rejects if clearData regresses to gen1. --- kits/delete-user-data/src/index.ts | 13 ++++- kits/delete-user-data/src/logs.ts | 6 ++ kits/delete-user-data/tests/index.test.ts | 70 +++++++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 kits/delete-user-data/tests/index.test.ts diff --git a/kits/delete-user-data/src/index.ts b/kits/delete-user-data/src/index.ts index 8c0a41655..5c39e70ff 100644 --- a/kits/delete-user-data/src/index.ts +++ b/kits/delete-user-data/src/index.ts @@ -81,9 +81,16 @@ function getContext(): HandlerContext { return ctx; } -export const clearData = onUserDeleted((event) => - handleClear(event.data.uid, getContext()) -); +export const clearData = onUserDeleted((event) => { + // The Auth event delivers no user record when the payload envelope is empty, + // so bail before getContext() rather than initialising the SDKs for nothing. + const uid = event.data?.uid; + if (!uid) { + logs.deletionEventMissingUid(event.id); + return; + } + return handleClear(uid, getContext()); +}); export const handleSearch = onMessagePublished( { diff --git a/kits/delete-user-data/src/logs.ts b/kits/delete-user-data/src/logs.ts index 4145c8ac0..cbacbc8dd 100644 --- a/kits/delete-user-data/src/logs.ts +++ b/kits/delete-user-data/src/logs.ts @@ -120,6 +120,12 @@ export const customFunctionError = (err: Error) => { logger.error(`Call to custom hook function threw an error`, err); }; +export const deletionEventMissingUid = (eventId: string) => { + logger.error( + `Auth deletion event ${eventId} carried no user id, so no data was deleted` + ); +}; + export function warnInvalidPaths(invalidPathCount: number, uid: string) { logger.warn( `Attempted to delete ${invalidPathCount} invalid paths for deleted user ${uid}` diff --git a/kits/delete-user-data/tests/index.test.ts b/kits/delete-user-data/tests/index.test.ts new file mode 100644 index 000000000..b031ab638 --- /dev/null +++ b/kits/delete-user-data/tests/index.test.ts @@ -0,0 +1,70 @@ +/** + * 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 { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../src/logs"); +vi.mock("../src/handlers", async (importOriginal) => ({ + ...(await importOriginal()), + handleClear: vi.fn(), +})); + +import { handleClear } from "../src/handlers"; +import { clearData } from "../src/index"; +import * as logs from "../src/logs"; + +function deletionEvent(data: unknown) { + return { + specversion: "1.0", + id: "event-id", + type: "google.firebase.auth.user.v2.deleted", + source: "//identitytoolkit.googleapis.com/projects/test-project", + time: "2026-01-01T00:00:00.000Z", + data, + } as any; +} + +describe("clearData", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Kits reject gen1 endpoints, so the deploy fails if this regresses. + test("registers a gen2 Firebase Auth deletion trigger", () => { + const endpoint = (clearData as any).__endpoint; + + expect(endpoint.platform).toBe("gcfv2"); + expect(endpoint.eventTrigger.eventType).toBe( + "google.firebase.auth.user.v2.deleted" + ); + }); + + test("logs and skips deletion when the event carries no user record", () => { + expect(() => clearData(deletionEvent(undefined))).not.toThrow(); + + expect(handleClear).not.toHaveBeenCalled(); + expect(logs.deletionEventMissingUid).toHaveBeenCalledWith("event-id"); + }); + + test("logs and skips deletion when the user record has no uid", () => { + expect(() => + clearData(deletionEvent({ email: "user@example.com" })) + ).not.toThrow(); + + expect(handleClear).not.toHaveBeenCalled(); + expect(logs.deletionEventMissingUid).toHaveBeenCalledWith("event-id"); + }); +});