diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index ed4a1b464..33b3b5745 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -160,6 +160,15 @@ export type EventMap = { new_username: string; }; 'user.email-changed': { user_id: number; new_email: string }; + // Fired after an account is torn down (self-serve, admin, or temp-user + // logout cleanup). Listeners purge external state tied to the account — + // e.g. the marketplace extension cancels the user's Stripe subscriptions. + // The row is already gone by emit time, so identifiers ride the payload. + 'user.delete': { + user_id: number; + user_uuid?: string; + stripe_customer_id?: string | null; + }; // ---- Filesystem ---- 'fs.copy.node': { diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index d0b2ef975..ea650b137 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -74,6 +74,7 @@ type SignupValidateOverride = (data: { let signupValidateOverride: SignupValidateOverride | null = null; const heardSignupSuccess: Array> = []; +const heardUserDelete: Array> = []; const installSharedListeners = () => { eventClient.on('puter.signup.validate', (_k: unknown, data: unknown) => { @@ -86,6 +87,9 @@ const installSharedListeners = () => { eventClient.on('puter.signup.success', (_k: unknown, data: unknown) => { heardSignupSuccess.push(data as Record); }); + eventClient.on('user.delete', (_k: unknown, data: unknown) => { + heardUserDelete.push(data as Record); + }); }; const withSignupValidateOverride = async ( @@ -2682,6 +2686,35 @@ describe('AuthController.handleDeleteOwnUser', () => { }); expect(after).toBeFalsy(); }); + + it('emits user.delete with the uuid + stripe customer id for downstream teardown', async () => { + // `stripe_customer_id` ships in the MySQL/Postgres migrations but not + // the sqlite ones the test harness runs — add it so the delete path + // captures it (it's how the marketplace extension cancels the sub). + try { + await server.clients.db.write( + 'ALTER TABLE user ADD COLUMN stripe_customer_id TEXT', + [], + ); + } catch { + /* already exists */ + } + const { user, actor } = await makeUserAndActor(); + await server.clients.db.write( + 'UPDATE user SET stripe_customer_id = ? WHERE id = ?', + ['cus_delete_test', user.id], + ); + + heardUserDelete.length = 0; + await controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()); + + const evt = heardUserDelete.find((e) => e.user_id === user.id); + expect(evt).toMatchObject({ + user_id: user.id, + user_uuid: user.uuid, + stripe_customer_id: 'cus_delete_test', + }); + }); }); // ── Additional branch coverage ───────────────────────────────────── diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index cc78b3fe7..7ec17c8f7 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -2820,6 +2820,22 @@ export class AuthController extends PuterController { // -- Private helpers ---------------------------------------------- async #cascadeDeleteUser(userId: number): Promise { + // Capture the identifiers downstream teardown needs before the row is + // gone — the marketplace extension cancels the user's Stripe + // subscriptions off `user.delete`, keyed by uuid / customer id. + let userUuid: string | undefined; + let stripeCustomerId: string | null = null; + try { + const rows = (await this.clients.db.read( + 'SELECT `uuid`, `stripe_customer_id` FROM `user` WHERE `id` = ?', + [userId], + )) as Array<{ uuid?: string; stripe_customer_id?: string | null }>; + userUuid = rows[0]?.uuid; + stripeCustomerId = rows[0]?.stripe_customer_id ?? null; + } catch (e) { + console.warn('[cascade-delete-user] identifier lookup failed:', e); + } + try { await this.services.fs.removeAllForUser(userId); } catch (e) { @@ -2837,6 +2853,24 @@ export class AuthController extends PuterController { userId, ]); await this.stores.user.invalidateById(userId); + + // Fire-and-forget: let listeners purge external state tied to the + // account (Stripe subscriptions are cancelled immediately, without + // proration). Emitted after the row delete — listeners key off the + // payload, not the DB row. + try { + this.clients.event?.emit( + 'user.delete', + { + user_id: userId, + user_uuid: userUuid, + stripe_customer_id: stripeCustomerId, + }, + {}, + ); + } catch { + // ignore — event emission shouldn't block deletion + } } async #generateRandomUsername(): Promise {