From 6f2bb3726cf215b18b5fea9a20a3c33133b89fb6 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 09:13:04 +0000 Subject: [PATCH 1/6] feat(account): add data export and account deletion Settings gets "Download my data" and "Delete account". Both call a new `account` Edge Function that acts only on the caller's own account. Export returns the auth profile and every row reached through a foreign key into auth.users, plus chat messages and announcements, with credential columns left out. Deletion cancels live Stripe subscriptions immediately and stops if Stripe fails, removes the user's Storage objects, clears their workspaces, then deletes the auth user. The migration makes the other blocking foreign keys cascade or set null, and a trigger clears announcement authors and recipients. Workspace foreign keys stay RESTRICT, so a raw auth delete still cannot remove shared work. A pgTAP test seeds a row in every user-owned table and checks that no public or private row mentions the user after deletion. A full-stack spec does the same through the real UI, Auth, and Edge Function, and checks that one user cannot export or delete another. Co-Authored-By: Claude Opus 5.5 (1M context) --- .superset/db-gate.sh | 2 +- docs/architecture.md | 13 + docs/testing.md | 3 +- e2e/full-stack/account-deletion.spec.ts | 140 +++++++ src/lib/account.ts | 32 ++ src/lib/announcements.ts | 5 +- src/lib/export-utils.ts | 2 +- src/locales/en.json | 20 +- src/locales/es.json | 20 +- src/locales/hu.json | 20 +- src/pages/SettingsPage.tsx | 123 +++++- src/test/pages/SettingsPage.test.tsx | 59 ++- supabase/README.md | 1 + supabase/config.toml | 3 + supabase/functions/account/index.ts | 192 ++++++++++ supabase/functions/account/index_test.ts | 167 ++++++++ ...1001100000_account_deletion_and_export.sql | 209 ++++++++++ supabase/tests/account_deletion_test.sql | 361 ++++++++++++++++++ 18 files changed, 1350 insertions(+), 22 deletions(-) create mode 100644 e2e/full-stack/account-deletion.spec.ts create mode 100644 src/lib/account.ts create mode 100644 supabase/functions/account/index.ts create mode 100644 supabase/functions/account/index_test.ts create mode 100644 supabase/migrations/20261001100000_account_deletion_and_export.sql create mode 100644 supabase/tests/account_deletion_test.sql diff --git a/.superset/db-gate.sh b/.superset/db-gate.sh index ac98b5a0b..9e51b6576 100755 --- a/.superset/db-gate.sh +++ b/.superset/db-gate.sh @@ -137,7 +137,7 @@ for url in "$mailer_url/api/v1/messages" "$app_origin" "http://127.0.0.1:18090/_ done curl --fail --silent --output /dev/null "$url" done -for function_name in workspace-invites ai ai-stream demo-code-login admin announcements; do +for function_name in workspace-invites ai ai-stream demo-code-login admin announcements account; do function_url="$api_url/functions/v1/$function_name" for _ in $(seq 1 200); do if curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url"; then break; fi diff --git a/docs/architecture.md b/docs/architecture.md index 176cc50f3..2ad776bea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,6 +84,19 @@ The browser uses user-scoped Supabase access. Privileged Edge Function operation Do not infer current schema from old README examples or store query names. Replay migrations and run pgTAP tests for database proof. +## Account export and deletion + +Settings offers three controls. "Download my data" calls the `account` Edge Function, which returns `export_account_data`: the auth profile plus every row reached through a foreign key into `auth.users`, chat messages, and announcements addressed to the user, with credential and integrity columns left out. "Delete All My Data" calls `clear_current_user_data` and keeps the account. "Delete account" asks the user to type their email (or DELETE for an account without one), then calls the `account` function. + +Deletion runs in a fixed order, and a failure at any step leaves the account in place: + +1. Cancel every live Stripe subscription immediately, without a refund. If Stripe cannot be reached, nothing is deleted. +2. Remove the user's Storage objects through the Storage API, because Storage forbids deleting objects with SQL. +3. Call `prepare_account_deletion`, which deletes the workspaces the user owns and the user's own sources, threads, messages, and results in other workspaces. Those foreign keys stay `RESTRICT` so that a raw delete of an auth user cannot remove shared work by accident. +4. Delete the auth user with the Auth admin API. Every other foreign key into `auth.users` cascades or sets null, and the `on_auth_user_deleted` trigger clears the user from announcements, which have no foreign key. + +Admin-authored rows such as promo codes, curated sources, announcements, and grants to other users survive with the author cleared. Stripe keeps its own invoices and payments for tax and accounting. A new foreign key to `auth.users` must cascade, set null, or be cleared by `prepare_account_deletion`. `supabase/tests/account_deletion_test.sql` fails if its fixture has no row in that table or if any row still mentions the deleted user. + ## Authentication and authorization Supabase Auth owns user sessions. Protected routes provide a client navigation boundary, but server authorization is decisive. diff --git a/docs/testing.md b/docs/testing.md index 4e76d21bd..188974b57 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -180,12 +180,13 @@ Each test signs in through the Login page with a fresh demo code and deletes tha - Current Debate context: every request carries the debate's motion, instructions, and sources; Review drops a source and pins a saved case for exactly one request; the pin is stored; Crossfire leaves instructions out; and one-off work sends nothing; - a judged Lincoln-Douglas sparring round and a Crossfire debrief after a Jev signal attaches to an answer; - Library activation and deletion, output-language sync across devices, flowchart saves, and Delete All My Data (it must leave another account's rows intact); +- Download my data (only the caller's rows, even when the request names another user) and Delete account (no row in any `public` or `private` table mentions the user afterwards, another account is untouched, and a call without a session or with another user's confirmation deletes nothing); - a promo code redeemed in Settings that restores AI access, and signed Stripe webhooks that grant and revoke Pro while a forged or replayed one changes nothing; - admin announcements seen by another user, denied admin access, and a public share link. Tests assert the rendered result, what the Edge Function sent upstream (read back from the fake provider), and the resulting database rows. They also fail on any uncaught page error or horizontal page overflow. The fake provider rejects prompts it does not recognize, so a prompt change must update it. -These journeys do not prove live provider behavior, web research (the local stack has no search key), real Jev judgement (the fake answers every Jev question with a fixed confident choice), Stripe Checkout and the billing portal (they call the Stripe API; the local key is a placeholder), the `checkout.session.completed` and `invoice.payment_succeeded` webhooks (they fetch the subscription from Stripe), or Google export. +These journeys do not prove live provider behavior, web research (the local stack has no search key), real Jev judgement (the fake answers every Jev question with a fixed confident choice), Stripe Checkout, the billing portal, and subscription cancellation during account deletion (they call the Stripe API; the local key is a placeholder, and the Deno tests cover the cancellation logic), the `checkout.session.completed` and `invoice.payment_succeeded` webhooks (they fetch the subscription from Stripe), or Google export. ### Live provider journeys diff --git a/e2e/full-stack/account-deletion.spec.ts b/e2e/full-stack/account-deletion.spec.ts new file mode 100644 index 000000000..191e5feee --- /dev/null +++ b/e2e/full-stack/account-deletion.spec.ts @@ -0,0 +1,140 @@ +import { readFile } from "node:fs/promises"; +import pg from "pg"; +import type { Page } from "@playwright/test"; +import { admin, createDemoUser, deleteDemoUser, expect, signIn, stackReady, startDebate, test } from "./stack"; + +test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); + +const accountUrl = `${process.env.LOCAL_SUPABASE_URL}/functions/v1/account`; + +async function addSource(page: Page, title: string, content: string) { + await page.goto("/app/library"); + await page.getByRole("button", { name: "Add Source" }).click(); + await page.getByRole("textbox", { name: "Source title..." }).fill(title); + await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("button", { name: "Save to Library" }).click(); + await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); +} + +/** The signed-in browser session's access token, for calling the function directly. */ +async function accessToken(page: Page) { + return page.evaluate(() => { + const key = Object.keys(localStorage).find((candidate) => candidate.startsWith("debate-auth-")); + return key ? (JSON.parse(localStorage.getItem(key)!) as { access_token: string }).access_token : ""; + }); +} + +function callAccount(token: string | null, body: unknown) { + return fetch(accountUrl, { + method: "POST", + headers: { + "content-type": "application/json", + apikey: process.env.SUPABASE_ANON_KEY ?? "", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(body), + }); +} + +/** Every public and private table holding a row whose text contains the id. */ +async function tablesMentioning(id: string) { + const client = new pg.Client({ connectionString: process.env.COLLABORATION_DATABASE_URL }); + await client.connect(); + try { + const { rows: tables } = await client.query<{ name: string }>( + "SELECT format('%I.%I', schemaname, tablename) AS name FROM pg_tables WHERE schemaname IN ('public', 'private')", + ); + const found: string[] = []; + for (const { name } of tables) { + const { rows } = await client.query(`SELECT 1 FROM ${name} AS t WHERE t::text LIKE $1 LIMIT 1`, [`%${id}%`]); + if (rows.length > 0) found.push(name); + } + return found; + } finally { + await client.end(); + } +} + +async function seedBystander(page: Page, other: Awaited>) { + const context = await page.context().browser()!.newContext(); + const bystander = await context.newPage(); + await bystander.addInitScript(() => localStorage.setItem("debate-trainer-onboarding", JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 }))); + await signIn(bystander, other); + await startDebate(bystander, "Bystander motion"); + await addSource(bystander, "Bystander study", "BYSTANDER-PRIVATE evidence"); + await context.close(); +} + +test("Download my data saves the account's own data as JSON", async ({ signedIn: page, user }) => { + const other = await createDemoUser("export-bystander"); + try { + await seedBystander(page, other); + await startDebate(page, "Exported motion"); + await addSource(page, "Exported study", "EXPORTED-PRIVATE evidence"); + + await page.goto("/app/settings"); + const download = page.waitForEvent("download"); + await page.getByRole("button", { name: "Download my data" }).click(); + const file = await download; + expect(file.suggestedFilename()).toMatch(/^debatelab-data-\d{4}-\d{2}-\d{2}\.json$/); + const text = await readFile((await file.path())!, "utf8"); + const exported = JSON.parse(text); + + expect(exported.format).toBe("debatelab-account-export"); + expect(exported.account).toMatchObject({ id: await user.userId(), email: user.email }); + expect(exported.data.debates.map((row: { motion: string }) => row.motion)).toEqual(["Exported motion"]); + expect(exported.data.sources.map((row: { title: string }) => row.title)).toEqual(["Exported study"]); + expect(text).not.toContain("BYSTANDER-PRIVATE"); + + // Naming another account in the request still returns only the caller's data. + const response = await callAccount(await accessToken(page), { action: "export", userId: await other.userId() }); + expect(response.status).toBe(200); + const direct = await response.json(); + expect(direct.account.id).toBe(await user.userId()); + expect(JSON.stringify(direct)).not.toContain("BYSTANDER-PRIVATE"); + } finally { + await deleteDemoUser(other); + } +}); + +test("Delete account removes every row that references the user and nobody else's", async ({ signedIn: page, user }) => { + const other = await createDemoUser("delete-bystander"); + try { + await seedBystander(page, other); + await startDebate(page, "Deleted motion"); + await addSource(page, "Deleted study", "Removed with the account."); + const userId = await user.userId(); + const otherId = await other.userId(); + expect(await tablesMentioning(userId)).not.toEqual([]); + + // Without a session, or with another account's confirmation, nothing is deleted. + expect((await callAccount(null, { action: "delete", confirmation: user.email })).status).toBe(401); + expect((await callAccount(null, { action: "export" })).status).toBe(401); + const token = await accessToken(page); + const forged = await callAccount(token, { action: "delete", confirmation: other.email, userId: otherId }); + expect(forged.status).toBe(400); + expect((await admin.auth.admin.getUserById(userId)).data.user?.id).toBe(userId); + expect((await admin.auth.admin.getUserById(otherId)).data.user?.id).toBe(otherId); + + await page.goto("/app/settings"); + await page.getByRole("button", { name: "Delete account" }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Delete account" })).toBeDisabled(); + await dialog.getByLabel("Type your email to confirm").fill(user.email); + await dialog.getByRole("button", { name: "Delete account" }).click(); + await expect(page.getByText("Account deleted").first()).toBeVisible({ timeout: 20_000 }); + await expect(page).toHaveURL(/\/$/); + + expect((await admin.auth.admin.getUserById(userId)).data.user).toBeNull(); + expect(await tablesMentioning(userId)).toEqual([]); + + expect((await admin.auth.admin.getUserById(otherId)).data.user?.id).toBe(otherId); + const { data: theirs } = await admin.from("sources").select("title").eq("user_id", otherId); + expect(theirs?.map((row) => row.title)).toEqual(["Bystander study"]); + + // The deleted session no longer works. + expect((await callAccount(token, { action: "export" })).status).toBe(401); + } finally { + await deleteDemoUser(other); + } +}); diff --git a/src/lib/account.ts b/src/lib/account.ts new file mode 100644 index 000000000..037c94c63 --- /dev/null +++ b/src/lib/account.ts @@ -0,0 +1,32 @@ +import { supabase } from '@/lib/supabase/client'; +import { getEdgeFunctionErrorMessage } from '@/lib/supabase/edge-function-error'; +import { saveBlob } from '@/lib/export-utils'; + +async function invokeAccount(body: Record, fallback: string): Promise { + const { data: sessionData } = await supabase.auth.getSession(); + const accessToken = sessionData.session?.access_token; + if (!accessToken) throw new Error('Please sign in first'); + + const { data, error } = await supabase.functions.invoke('account', { + headers: { Authorization: `Bearer ${accessToken}` }, + body, + }); + if (error) throw new Error(await getEdgeFunctionErrorMessage(error) || fallback); + return data; +} + +/** Downloads everything the signed-in account owns as one JSON file. */ +export async function downloadAccountData(): Promise { + const data = await invokeAccount({ action: 'export' }, 'Could not export your data'); + const date = new Date().toISOString().slice(0, 10); + saveBlob(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), `debatelab-data-${date}.json`); +} + +/** + * Permanently deletes the signed-in account. `confirmation` is the account + * email, or DELETE for an account without one. Any Stripe subscription is + * canceled first. + */ +export async function deleteAccount(confirmation: string): Promise { + await invokeAccount({ action: 'delete', confirmation }, 'Could not delete your account'); +} diff --git a/src/lib/announcements.ts b/src/lib/announcements.ts index 37d22727a..75a886775 100644 --- a/src/lib/announcements.ts +++ b/src/lib/announcements.ts @@ -16,7 +16,7 @@ export interface AdminAnnouncement extends UserAnnouncement { targetType: AnnouncementTargetType; targetUserIds: string[] | null; deactivatedAt: string | null; - createdBy: string; + createdBy: string | null; } export interface CreateAnnouncementInput { @@ -41,7 +41,8 @@ const AdminAnnouncementRowSchema = AnnouncementRowSchema.extend({ target_type: z.enum(['all', 'users']), target_user_ids: z.array(z.uuid()).nullable(), deactivated_at: z.iso.datetime({ offset: true }).nullable(), - created_by: z.uuid(), + // Null once the author's account is deleted. + created_by: z.uuid().nullable(), }); const ActiveAnnouncementsResponseSchema = z.object({ diff --git a/src/lib/export-utils.ts b/src/lib/export-utils.ts index fa395b6cd..48cbe4cf6 100644 --- a/src/lib/export-utils.ts +++ b/src/lib/export-utils.ts @@ -16,7 +16,7 @@ import { } from './export-sparring-labels'; /** Triggers a browser download from a Blob. */ -function saveBlob(blob: Blob, filename: string): void { +export function saveBlob(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; diff --git a/src/locales/en.json b/src/locales/en.json index be6e4f240..0ea8c3bdb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -396,7 +396,21 @@ "noSubscriptionDesc": "This account is not connected to Stripe billing. Upgrade to Pro to start a subscription.", "portalError": "Error", "adminAccessFailed": "Could not check admin access.", - "retry": "Try again" + "retry": "Try again", + "yourData": "Your data", + "yourDataDesc": "Download a copy of everything saved to your account as a JSON file.", + "downloadData": "Download my data", + "downloadingData": "Preparing download...", + "downloadDataFailed": "Could not download your data", + "deleteAccount": "Delete account", + "deleteAccountDesc": "Permanently delete your account and everything saved in it. Any Pro subscription is canceled immediately.", + "deleteAccountDialogTitle": "Delete your account?", + "deleteAccountDialogDesc": "This permanently deletes the account {{email}} with every debate, source, saved result, and your usage history. Any Pro subscription is canceled immediately, with no refund for the rest of the billing period. This cannot be undone, so download your data first if you want a copy.", + "typeDeleteConfirm": "Type DELETE to confirm", + "deletingAccount": "Deleting...", + "deleteAccountSuccess": "Account deleted", + "deleteAccountSuccessDesc": "Your account and all of its data have been permanently deleted.", + "deleteAccountFailed": "Account not deleted" }, "login": { "title": "Sign in to DebateLab", @@ -1566,7 +1580,7 @@ "privacyPage": { "title": "Privacy Policy", "lastUpdated": "Last updated", - "date": "August 31, 2026", + "date": "October 1, 2026", "s1": "Information We Collect", "p1": "We collect information you provide when creating an account, including your name and email from Google sign-in or email sign-in, plus usage data such as generated content, saved items, and interaction patterns. If you use an admin-issued demo code, we process that code to authenticate your demo account.", "s2": "How We Use Your Information", @@ -1578,7 +1592,7 @@ "s5": "Data Sharing", "p5": "We do not sell your personal data. We share data only with OpenRouter for AI processing and payment processors for billing.", "s6": "Your Rights", - "p6": "You may access, export, or delete your data at any time from your account settings. Account deletion removes all associated data.", + "p6": "In Settings you can download a copy of your data as a JSON file, erase your saved work, or delete your account. Deleting your account permanently removes it and all data stored with it, and cancels any Pro subscription immediately. Stripe keeps its own billing records, such as invoices and payments, as required for tax and accounting.", "s7": "Cookies and Tracking", "p7": "We use essential cookies and browser storage for authentication and preferences. We use PostHog for product analytics and Sentry for error monitoring, structured logs, application metrics, performance tracing, and error-only Session Replay. Sentry does not record ordinary sessions. It records sessions containing captured errors; all replay text and inputs are masked and media is blocked. Sentry receives technical metadata such as routes, browser details, request URLs, methods and status codes, stack traces, timings, and safe event classifications. Automatic collection of user identity, cookies, headers, HTTP bodies, query parameters, AI inputs and outputs, and stack-frame variables is disabled. PostHog autocapture, pageview capture, session recording, and persistent browser storage are disabled. We do not send debate content, chat messages, source titles, or email addresses in analytics events or structured logs.", "s8": "Children's Privacy", diff --git a/src/locales/es.json b/src/locales/es.json index 515962493..7967e18d4 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -396,7 +396,21 @@ "noSubscriptionDesc": "Esta cuenta no está conectada a facturación de Stripe. Mejora a Pro para iniciar una suscripción.", "portalError": "Error", "adminAccessFailed": "No se pudo comprobar el acceso de administrador.", - "retry": "Intentar de nuevo" + "retry": "Intentar de nuevo", + "yourData": "Tus datos", + "yourDataDesc": "Descarga una copia de todo lo guardado en tu cuenta en un archivo JSON.", + "downloadData": "Descargar mis datos", + "downloadingData": "Preparando la descarga...", + "downloadDataFailed": "No se pudieron descargar tus datos", + "deleteAccount": "Eliminar cuenta", + "deleteAccountDesc": "Elimina para siempre tu cuenta y todo lo guardado en ella. Cualquier suscripción Pro se cancela de inmediato.", + "deleteAccountDialogTitle": "¿Eliminar tu cuenta?", + "deleteAccountDialogDesc": "Esto elimina para siempre la cuenta {{email}} con todos sus debates, fuentes, resultados guardados y tu historial de uso. Cualquier suscripción Pro se cancela de inmediato, sin reembolso por el resto del periodo de facturación. No se puede deshacer, así que descarga tus datos antes si quieres una copia.", + "typeDeleteConfirm": "Escribe DELETE para confirmar", + "deletingAccount": "Eliminando...", + "deleteAccountSuccess": "Cuenta eliminada", + "deleteAccountSuccessDesc": "Tu cuenta y todos sus datos se han eliminado para siempre.", + "deleteAccountFailed": "La cuenta no se eliminó" }, "login": { "title": "Iniciar Sesión en DebateLab", @@ -1566,7 +1580,7 @@ "privacyPage": { "title": "Política de Privacidad", "lastUpdated": "Última actualización", - "date": "31 de agosto de 2026", + "date": "1 de octubre de 2026", "s1": "Información que Recopilamos", "p1": "Recopilamos la información que proporcionas al crear una cuenta, incluido tu nombre y correo electrónico, que pueden proceder del inicio de sesión con Google o por email, además de datos de uso como contenido generado, elementos guardados y patrones de interacción. Si usas un código demo emitido por un administrador, procesamos ese código para autenticar tu cuenta demo.", "s2": "Cómo Usamos Tu Información", @@ -1578,7 +1592,7 @@ "s5": "Compartir Datos", "p5": "No vendemos tus datos personales. Solo compartimos datos con OpenRouter para el procesamiento de IA y con procesadores de pago para la facturación.", "s6": "Tus Derechos", - "p6": "Puedes acceder, exportar o eliminar tus datos en cualquier momento desde la configuración de tu cuenta.", + "p6": "En Configuración puedes descargar una copia de tus datos en un archivo JSON, borrar tu trabajo guardado o eliminar tu cuenta. Eliminar la cuenta la borra para siempre junto con todos los datos guardados en ella y cancela de inmediato cualquier suscripción Pro. Stripe conserva sus propios registros de facturación, como facturas y pagos, según exigen las normas fiscales y contables.", "s7": "Cookies y Seguimiento", "p7": "Usamos cookies y almacenamiento del navegador esenciales para la autenticación y las preferencias. Usamos PostHog para analítica de producto y Sentry para monitorización de errores, registros estructurados, métricas de la aplicación, seguimiento del rendimiento y reproducción de sesiones solo cuando hay errores. Sentry no registra sesiones normales. Registra sesiones que contienen errores capturados; todo el texto y los campos de entrada se ocultan en las reproducciones y el contenido multimedia se bloquea. Sentry recibe metadatos técnicos como rutas, datos del navegador, URL, métodos y códigos de estado de solicitudes, trazas de pila, tiempos y clasificaciones seguras de eventos. La recopilación automática de identidad del usuario, cookies, cabeceras, cuerpos HTTP, parámetros de consulta, entradas y salidas de IA y variables de marcos de pila está desactivada. La captura automática, el registro de páginas vistas, la grabación de sesiones y el almacenamiento persistente de PostHog están desactivados. No enviamos contenido de debates, mensajes de chat, títulos de fuentes ni direcciones de correo electrónico en eventos analíticos o registros estructurados.", "s8": "Privacidad de Menores", diff --git a/src/locales/hu.json b/src/locales/hu.json index 6bfd53361..564fda32e 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -396,7 +396,21 @@ "noSubscriptionDesc": "Ez a fiók nincs Stripe-számlázáshoz kapcsolva. Válts Pro-ra az előfizetés indításához.", "portalError": "Hiba", "adminAccessFailed": "Nem sikerült ellenőrizni az adminisztrátori hozzáférést.", - "retry": "Újrapróbálás" + "retry": "Újrapróbálás", + "yourData": "Adataid", + "yourDataDesc": "Tölts le egy JSON-fájlt mindenről, amit a fiókodba mentettél.", + "downloadData": "Adataim letöltése", + "downloadingData": "Letöltés előkészítése...", + "downloadDataFailed": "Nem sikerült letölteni az adataidat", + "deleteAccount": "Fiók törlése", + "deleteAccountDesc": "A fiókod és minden benne mentett adat végleges törlése. Az esetleges Pro-előfizetést azonnal lemondjuk.", + "deleteAccountDialogTitle": "Törlöd a fiókodat?", + "deleteAccountDialogDesc": "Ez véglegesen törli a(z) {{email}} fiókot az összes vitával, forrással, mentett eredménnyel és a használati előzményekkel együtt. Az esetleges Pro-előfizetést azonnal lemondjuk, a számlázási időszak hátralévő részét nem térítjük vissza. A művelet nem vonható vissza, ezért ha szeretnél másolatot, előbb töltsd le az adataidat.", + "typeDeleteConfirm": "A megerősítéshez írd be: DELETE", + "deletingAccount": "Törlés...", + "deleteAccountSuccess": "Fiók törölve", + "deleteAccountSuccessDesc": "A fiókodat és minden adatát véglegesen töröltük.", + "deleteAccountFailed": "A fiók nem lett törölve" }, "login": { "title": "Bejelentkezés a DebateLab-ba", @@ -1566,7 +1580,7 @@ "privacyPage": { "title": "Adatvédelmi Irányelvek", "lastUpdated": "Utolsó frissítés", - "date": "2026. augusztus 31.", + "date": "2026. október 1.", "s1": "Gyűjtött Információk", "p1": "Gyűjtjük a fiók létrehozásakor megadott adatokat, például a nevedet és az e-mail-címedet, amelyek Google- vagy e-mailes bejelentkezésből származhatnak, valamint a használati adatokat, például a generált tartalmakat, a mentett elemeket és az interakciós mintákat. Adminisztrátor által kiadott demókód használatakor a kódot a demo-fiókod hitelesítéséhez dolgozzuk fel.", "s2": "Hogyan Használjuk az Információidat", @@ -1578,7 +1592,7 @@ "s5": "Adatmegosztás", "p5": "Nem adjuk el személyes adataidat. Csak az OpenRouterrel osztunk meg adatot MI-feldolgozás céljából, valamint fizetési szolgáltatókkal a számlázáshoz.", "s6": "Jogaid", - "p6": "Bármikor hozzáférhetsz, exportálhatod vagy törölheted adataidat a fiókbeállításokból. A fiók törlése eltávolítja az összes kapcsolódó adatot.", + "p6": "A Beállításokban letöltheted az adataidat egy JSON-fájlban, törölheted a mentett munkáidat, vagy törölheted a fiókodat. A fiók törlése véglegesen eltávolítja a fiókot és minden hozzá tárolt adatot, és azonnal lemondja az esetleges Pro-előfizetést. A Stripe a saját számlázási nyilvántartásait, például a számlákat és a befizetéseket, az adózási és számviteli előírások szerint megőrzi.", "s7": "Sütik és Nyomkövetés", "p7": "Alapvető sütiket és böngészőtárhelyet használunk a hitelesítéshez és a beállításokhoz. A PostHogot termékanalitikához, a Sentryt pedig hibamegfigyeléshez, strukturált naplókhoz, alkalmazásmetrikákhoz, teljesítménykövetéshez és kizárólag hibákhoz kapcsolódó munkamenet-visszajátszáshoz használjuk. A Sentry nem rögzít szokásos munkameneteket. A rögzített hibát tartalmazó munkameneteket rögzíti; a visszajátszásokban minden szöveg és beviteli érték maszkolva van, a médiatartalom pedig blokkolva. A Sentry technikai metaadatokat kap, például útvonalakat, böngészőadatokat, kérés-URL-eket, metódusokat és állapotkódokat, veremkövetéseket, időzítéseket és biztonságos eseménybesorolásokat. A felhasználói azonosítók, sütik, fejlécek, HTTP-törzsek, lekérdezési paraméterek, MI-bemenetek és -kimenetek, valamint veremkeret-változók automatikus gyűjtése le van tiltva. A PostHog automatikus eseménygyűjtése, oldalmegtekintés-követése, munkamenet-rögzítése és tartós böngészőtárhelye ki van kapcsolva. Az analitikai eseményekben és strukturált naplókban nem küldünk vitatartalmat, chatüzeneteket, forráscímeket vagy e-mail-címeket.", "s8": "Gyermekek Adatvédelme", diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 62cb67155..3e1fe4833 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,9 +1,9 @@ // src/pages/SettingsPage.tsx import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Settings, Lock, Trash2, Crown, CreditCard, Zap, Shield } from 'lucide-react'; +import { Settings, Lock, Trash2, Crown, CreditCard, Zap, Shield, Download, UserX } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; @@ -24,6 +24,7 @@ import { supabase } from '@/lib/supabase/client'; import { useToast } from '@/hooks/use-toast'; import { useSubscription } from '@/hooks/use-subscription'; import { redirectToPortal } from '@/lib/stripe'; +import { deleteAccount, downloadAccountData } from '@/lib/account'; import { PricingModal } from '@/components/features/subscription/PricingModal'; import { formatEuropeanDate } from '@/lib/date-format'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; @@ -50,6 +51,13 @@ export default function SettingsPage() { const [portalLoading, setPortalLoading] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteEmail, setDeleteEmail] = useState(''); + const [exporting, setExporting] = useState(false); + const [deleteAccountOpen, setDeleteAccountOpen] = useState(false); + const [accountConfirmation, setAccountConfirmation] = useState(''); + const [deletingAccount, setDeletingAccount] = useState(false); + const navigate = useNavigate(); + // Must match deletionConfirmation in supabase/functions/account/index.ts. + const expectedAccountConfirmation = user?.email || 'DELETE'; const adminRole = useQuery({ queryKey: ['admin-role', user?.id], @@ -69,6 +77,13 @@ export default function SettingsPage() { }); const isAdmin = Boolean(user) && adminRole.isSuccess && adminRole.data; + const clearLocalData = () => { + clearSession(); + resetSparringLocalData(); + useDebateStore.setState({ debates: [], currentDebateId: null, oneOff: false }); + localStorage.removeItem('debate-trainer-storage'); + }; + const handleDeleteAllData = () => { if (deleteEmail !== user?.email) { toast({ title: t('settings.deleteCancelled'), description: t('settings.deleteCancelledDesc') }); @@ -76,10 +91,7 @@ export default function SettingsPage() { } void clearAllData().then(() => { - clearSession(); - resetSparringLocalData(); - useDebateStore.setState({ debates: [], currentDebateId: null, oneOff: false }); - localStorage.removeItem('debate-trainer-storage'); + clearLocalData(); setDeleteDialogOpen(false); setDeleteEmail(''); toast({ title: t('settings.deleteSuccess'), description: t('settings.deleteSuccessDesc') }); @@ -89,6 +101,43 @@ export default function SettingsPage() { }); }; + const handleDownloadData = async () => { + setExporting(true); + try { + await downloadAccountData(); + } catch (error) { + toast({ + title: t('settings.downloadDataFailed'), + description: error instanceof Error ? error.message : undefined, + variant: 'destructive', + }); + } finally { + setExporting(false); + } + }; + + const handleDeleteAccount = async () => { + if (accountConfirmation !== expectedAccountConfirmation || deletingAccount) return; + setDeletingAccount(true); + try { + await deleteAccount(accountConfirmation); + } catch (error) { + setDeletingAccount(false); + toast({ + title: t('settings.deleteAccountFailed'), + description: error instanceof Error ? error.message : undefined, + variant: 'destructive', + }); + return; + } + + clearLocalData(); + // The account is gone, so a server sign-out may fail; the local session must still end. + await signOut().catch(() => supabase.auth.signOut({ scope: 'local' })); + toast({ title: t('settings.deleteAccountSuccess'), description: t('settings.deleteAccountSuccessDesc') }); + navigate('/', { replace: true }); + }; + const handleSignOut = () => { void signOut() .then(() => resetSparringLocalData()) @@ -256,6 +305,16 @@ export default function SettingsPage() { {t('settings.signOut')} +
+
+

{t('settings.yourData')}

+

{t('settings.yourDataDesc')}

+
+ +
@@ -297,11 +356,18 @@ export default function SettingsPage() { {t('settings.dangerDesc', { email: user?.email })} - + +
+

{t('settings.deleteAccountDesc')}

+ +
@@ -339,6 +405,49 @@ export default function SettingsPage() { + { + if (deletingAccount) return; + setDeleteAccountOpen(open); + if (!open) setAccountConfirmation(''); + }} + > + + + {t('settings.deleteAccountDialogTitle')} + + {t('settings.deleteAccountDialogDesc', { email: user?.email || t('settings.unknownUser') })} + + +
+ + setAccountConfirmation(event.target.value)} + onKeyDown={(event) => { if (event.key === 'Enter') void handleDeleteAccount(); }} + placeholder={expectedAccountConfirmation} + disabled={deletingAccount} + /> +
+ + + + +
+
); } diff --git a/src/test/pages/SettingsPage.test.tsx b/src/test/pages/SettingsPage.test.tsx index 83720ebc5..2c4ecd945 100644 --- a/src/test/pages/SettingsPage.test.tsx +++ b/src/test/pages/SettingsPage.test.tsx @@ -17,10 +17,14 @@ const mocks = vi.hoisted(() => ({ refresh: vi.fn(), }, redirectToPortal: vi.fn(), + user: null as { id: string; email: string } | null, + signOut: vi.fn(), + deleteAccount: vi.fn(), + downloadAccountData: vi.fn(), })); vi.mock('@/lib/supabase/AuthProvider', () => ({ - useAuth: () => ({ user: null as { id: string; email: string } | null, signOut: vi.fn() }), + useAuth: () => ({ user: mocks.user, signOut: mocks.signOut }), })); vi.mock('@/lib/supabase/client', () => ({ supabase: { from: vi.fn() } })); vi.mock('@/lib/stores/settings-store', () => ({ useSettingsStore: () => ({ @@ -34,6 +38,7 @@ vi.mock('@/components/features/subscription/PricingModal', () => ({ })); vi.mock('@/hooks/use-subscription', () => ({ useSubscription: () => mocks.subscription })); vi.mock('@/lib/stripe', () => ({ redirectToPortal: mocks.redirectToPortal })); +vi.mock('@/lib/account', () => ({ deleteAccount: mocks.deleteAccount, downloadAccountData: mocks.downloadAccountData })); function renderSettings() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -48,6 +53,10 @@ beforeEach(async () => { await i18n.changeLanguage('en'); Object.assign(mocks.subscription, { tier: 'trial', loading: false, hasStripeBilling: false }); mocks.redirectToPortal.mockReset().mockResolvedValue(undefined); + mocks.user = null; + mocks.signOut.mockReset().mockResolvedValue(undefined); + mocks.deleteAccount.mockReset().mockResolvedValue(undefined); + mocks.downloadAccountData.mockReset().mockResolvedValue(undefined); }); afterEach(() => cleanup()); @@ -100,3 +109,51 @@ describe('Settings subscription card', () => { await waitFor(() => expect(mocks.redirectToPortal).toHaveBeenCalledOnce()); }); }); + +describe('Settings account controls', () => { + const student = { id: '11111111-1111-4111-8111-111111111111', email: 'student@example.test' }; + + function openDeleteAccount() { + mocks.user = student; + renderSettings(); + fireEvent.click(screen.getByRole('button', { name: 'Delete account' })); + const confirm = screen.getAllByRole('button', { name: 'Delete account' }).at(-1)!; + return { confirm, input: screen.getByLabelText('Type your email to confirm') }; + } + + it('downloads the account data', async () => { + mocks.user = student; + renderSettings(); + + fireEvent.click(screen.getByRole('button', { name: 'Download my data' })); + + await waitFor(() => expect(mocks.downloadAccountData).toHaveBeenCalledTimes(1)); + }); + + it('deletes the account only after the email is typed exactly', async () => { + const { confirm, input } = openDeleteAccount(); + expect(confirm).toBeDisabled(); + + fireEvent.change(input, { target: { value: 'STUDENT@example.test' } }); + expect(confirm).toBeDisabled(); + + fireEvent.change(input, { target: { value: student.email } }); + fireEvent.click(confirm); + + await waitFor(() => expect(mocks.signOut).toHaveBeenCalledTimes(1)); + expect(mocks.deleteAccount).toHaveBeenCalledWith(student.email); + }); + + it('keeps the user signed in when deletion fails', async () => { + mocks.deleteAccount.mockRejectedValue(new Error('Could not cancel your subscription')); + const { confirm, input } = openDeleteAccount(); + + fireEvent.change(input, { target: { value: student.email } }); + fireEvent.click(confirm); + + await waitFor(() => expect(confirm).toBeEnabled()); + expect(mocks.deleteAccount).toHaveBeenCalledTimes(1); + expect(mocks.signOut).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog', { name: 'Delete your account?' })).toBeInTheDocument(); + }); +}); diff --git a/supabase/README.md b/supabase/README.md index 5e2fafc19..b8d37a3cf 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -52,6 +52,7 @@ The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Sh - `admin`: authenticated administrator operations with server-side role checks, including redacted demo-code list/create/disable/reset actions. - `announcements`: authenticated announcement listing and dismissal. - `redeem-promo`: authenticated atomic promo redemption. +- `account`: authenticated data export and account deletion for the caller only. It never reads a user ID from the request. Deletion cancels Stripe subscriptions first and stops if that fails, then removes Storage objects, clears the user's workspaces, and deletes the auth user. Export and the deletion plan use service-role-only RPCs. See [Account export and deletion](../docs/architecture.md#account-export-and-deletion). ### Billing diff --git a/supabase/config.toml b/supabase/config.toml index 9314aec59..4b785663d 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -36,6 +36,9 @@ verify_jwt = false [functions.workspace-invites] verify_jwt = false +[functions.account] +verify_jwt = false + # Stripe webhook calls come from Stripe (no Supabase JWT), so gateway JWT # verification must be disabled for this endpoint. [functions.stripe-webhook] diff --git a/supabase/functions/account/index.ts b/supabase/functions/account/index.ts new file mode 100644 index 000000000..78864d247 --- /dev/null +++ b/supabase/functions/account/index.ts @@ -0,0 +1,192 @@ +// Supabase Edge Function: export everything a user owns, or delete the account. +// +// POST { action: "export" } returns the account and its rows as one JSON document. +// POST { action: "delete", confirmation } cancels Stripe billing, removes the +// user's Storage objects, clears their workspaces, then deletes the auth user. +// Foreign keys and the auth.users delete trigger remove every remaining row in +// that delete; see supabase/migrations/20261001100000_account_deletion_and_export.sql. +// +// Billing is canceled first and immediately, without a refund. If Stripe +// cannot be reached, nothing is deleted and the user can try again. + +import Stripe from "https://esm.sh/stripe@14.10.0?target=deno"; +import { createAdminClient, HttpError, jsonError, requireUser } from "../_shared/auth.ts"; +import { getCorsHeaders } from "../_shared/cors.ts"; + +export type AccountUser = { id: string; email?: string | null }; +export type StorageObject = { bucket_id: string; name: string }; +export type DeletionPlan = { stripe_customer_ids: string[]; storage_objects: StorageObject[] }; + +export type AccountDeps = { + authenticate: (req: Request) => Promise; + exportData: (userId: string) => Promise; + deletionPlan: (userId: string) => Promise; + /** Cancels every live subscription and returns how many it canceled. */ + cancelSubscriptions: (customerIds: string[]) => Promise; + removeStorageObjects: (objects: StorageObject[]) => Promise; + /** Clears rows that deliberately block a raw auth user delete. */ + prepareDeletion: (userId: string) => Promise; + deleteAuthUser: (userId: string) => Promise; +}; + +/** The text a user types to confirm deletion: their email, or DELETE without one. */ +export function deletionConfirmation(user: AccountUser) { + return user.email || "DELETE"; +} + +// Subscriptions in these states no longer bill. +const ENDED_SUBSCRIPTION_STATUSES = new Set(["canceled", "incomplete_expired"]); + +export type SubscriptionApi = { + list: (customerId: string) => AsyncIterable<{ id: string; status: string }>; + cancel: (subscriptionId: string) => Promise; +}; + +export async function cancelLiveSubscriptions(api: SubscriptionApi, customerIds: string[]) { + let canceled = 0; + for (const customerId of customerIds) { + try { + for await (const subscription of api.list(customerId)) { + if (ENDED_SUBSCRIPTION_STATUSES.has(subscription.status)) continue; + await api.cancel(subscription.id); + canceled += 1; + } + } catch (error) { + // A customer already deleted in Stripe has nothing left to bill. + if ((error as { code?: string }).code === "resource_missing") continue; + throw error; + } + } + return canceled; +} + +async function readBody(req: Request): Promise> { + try { + const body = await req.json(); + return body && typeof body === "object" ? body as Record : {}; + } catch { + throw new HttpError(400, "Request body must be valid JSON"); + } +} + +export function createHandler(deps: AccountDeps) { + return async (req: Request) => { + const corsHeaders = getCorsHeaders(req.headers.get("origin")); + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: corsHeaders }); + + try { + // The account is always the caller's own. No user id is read from the body. + const user = await deps.authenticate(req); + const body = await readBody(req); + + if (body.action === "export") { + return Response.json(await deps.exportData(user.id), { headers: corsHeaders }); + } + + if (body.action === "delete") { + if (body.confirmation !== deletionConfirmation(user)) { + throw new HttpError(400, "The confirmation does not match, so the account was not deleted"); + } + const plan = await deps.deletionPlan(user.id); + const canceledSubscriptions = await deps.cancelSubscriptions(plan.stripe_customer_ids); + await deps.removeStorageObjects(plan.storage_objects); + await deps.prepareDeletion(user.id); + await deps.deleteAuthUser(user.id); + console.log(`[ACCOUNT] Deleted account ${user.id}`); + return Response.json({ deleted: true, canceledSubscriptions }, { headers: corsHeaders }); + } + + throw new HttpError(400, 'action must be "export" or "delete"'); + } catch (error) { + if (error instanceof HttpError) return jsonError(error, corsHeaders); + console.error("[ACCOUNT] Error:", error instanceof Error ? error.message : "unknown"); + return jsonError(new HttpError(500, "Something went wrong. Please try again."), corsHeaders); + } + }; +} + +function liveDeps(): AccountDeps { + const admin = createAdminClient(); + + return { + authenticate: async (req) => (await requireUser(req)).user, + + exportData: async (userId) => { + const { data, error } = await admin.rpc("export_account_data", { p_user_id: userId }); + if (error) { + console.error("[ACCOUNT] Export failed", error.code); + throw new HttpError(500, "Could not export your data. Please try again."); + } + return data; + }, + + deletionPlan: async (userId) => { + const { data, error } = await admin.rpc("account_deletion_plan", { p_user_id: userId }); + if (error) { + console.error("[ACCOUNT] Deletion plan failed", error.code); + throw new HttpError(500, "Could not delete your account. Please try again."); + } + return data as DeletionPlan; + }, + + cancelSubscriptions: async (customerIds) => { + if (customerIds.length === 0) return 0; + const billingError = new HttpError( + 502, + "Could not cancel your subscription, so your account was not deleted. Please try again.", + ); + const secretKey = Deno.env.get("STRIPE_SECRET_KEY"); + if (!secretKey) throw billingError; + const stripe = new Stripe(secretKey, { + apiVersion: "2023-10-16", + httpClient: Stripe.createFetchHttpClient(), + }); + try { + return await cancelLiveSubscriptions({ + list: (customer) => stripe.subscriptions.list({ customer, status: "all", limit: 100 }), + cancel: (subscriptionId) => stripe.subscriptions.cancel(subscriptionId), + }, customerIds); + } catch (error) { + console.error("[ACCOUNT] Stripe cancellation failed", (error as { code?: string }).code ?? "unknown"); + throw billingError; + } + }, + + removeStorageObjects: async (objects) => { + const byBucket = new Map(); + for (const object of objects) { + byBucket.set(object.bucket_id, [...(byBucket.get(object.bucket_id) ?? []), object.name]); + } + for (const [bucket, names] of byBucket) { + for (let start = 0; start < names.length; start += 100) { + const { error } = await admin.storage.from(bucket).remove(names.slice(start, start + 100)); + if (error) { + console.error("[ACCOUNT] Storage removal failed", error.message); + throw new HttpError(500, "Could not delete your stored files, so your account was not deleted. Please try again."); + } + } + } + }, + + prepareDeletion: async (userId) => { + const { error } = await admin.rpc("prepare_account_deletion", { p_user_id: userId }); + if (error) { + console.error("[ACCOUNT] Deletion preparation failed", error.code); + throw new HttpError(500, "Could not delete your account. Please try again."); + } + }, + + deleteAuthUser: async (userId) => { + const { error } = await admin.auth.admin.deleteUser(userId); + if (error) { + console.error("[ACCOUNT] Auth user deletion failed", error.status ?? "unknown"); + throw new HttpError(500, "Could not delete your account. Please try again."); + } + }, + }; +} + +if (import.meta.main) { + Deno.serve(createHandler(liveDeps())); +} diff --git a/supabase/functions/account/index_test.ts b/supabase/functions/account/index_test.ts new file mode 100644 index 000000000..1d348c51b --- /dev/null +++ b/supabase/functions/account/index_test.ts @@ -0,0 +1,167 @@ +import { assertEquals, assertRejects } from "jsr:@std/assert@1.0.19"; + +for (const [key, value] of Object.entries({ + SUPABASE_URL: "https://supabase.test", + SUPABASE_ANON_KEY: "test-anon-key", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", +})) Deno.env.set(key, value); + +const { HttpError } = await import("../_shared/auth.ts"); +const { cancelLiveSubscriptions, createHandler } = await import("./index.ts"); +type AccountDeps = import("./index.ts").AccountDeps; + +const caller = { id: "11111111-1111-4111-8111-111111111111", email: "student@example.test" }; +const otherUserId = "22222222-2222-4222-8222-222222222222"; + +function harness(overrides: Partial = {}) { + const calls: string[] = []; + const deps: AccountDeps = { + authenticate: () => Promise.resolve(caller), + exportData: (userId) => { + calls.push(`export:${userId}`); + return Promise.resolve({ account: { id: userId } }); + }, + deletionPlan: (userId) => { + calls.push(`plan:${userId}`); + return Promise.resolve({ + stripe_customer_ids: ["cus_1"], + storage_objects: [{ bucket_id: "files", name: `${userId}/a.pdf` }], + }); + }, + cancelSubscriptions: (customerIds) => { + calls.push(`cancel:${customerIds.join(",")}`); + return Promise.resolve(1); + }, + removeStorageObjects: (objects) => { + calls.push(`storage:${objects.map((object) => object.name).join(",")}`); + return Promise.resolve(); + }, + prepareDeletion: (userId) => { + calls.push(`prepare:${userId}`); + return Promise.resolve(); + }, + deleteAuthUser: (userId) => { + calls.push(`delete:${userId}`); + return Promise.resolve(); + }, + ...overrides, + }; + const handler = createHandler(deps); + const post = (body: unknown) => handler(new Request("https://edge.test/account", { + method: "POST", + headers: { authorization: "Bearer token", "content-type": "application/json" }, + body: JSON.stringify(body), + })); + return { calls, handler, post }; +} + +Deno.test("export returns the caller's own data and ignores a user id in the body", async () => { + const { calls, post } = harness(); + const response = await post({ action: "export", userId: otherUserId, p_user_id: otherUserId }); + assertEquals(response.status, 200); + assertEquals(await response.json(), { account: { id: caller.id } }); + assertEquals(calls, [`export:${caller.id}`]); +}); + +Deno.test("delete removes only the caller's account, after billing and files", async () => { + const { calls, post } = harness(); + const response = await post({ action: "delete", confirmation: caller.email, userId: otherUserId }); + assertEquals(response.status, 200); + assertEquals(await response.json(), { deleted: true, canceledSubscriptions: 1 }); + assertEquals(calls, [ + `plan:${caller.id}`, + "cancel:cus_1", + `storage:${caller.id}/a.pdf`, + `prepare:${caller.id}`, + `delete:${caller.id}`, + ]); +}); + +Deno.test("delete without the typed confirmation deletes nothing", async () => { + for (const confirmation of [undefined, "", "STUDENT@example.test", "DELETE"]) { + const { calls, post } = harness(); + const response = await post({ action: "delete", confirmation }); + assertEquals(response.status, 400); + assertEquals(calls, []); + } +}); + +Deno.test("an account without an email confirms with DELETE", async () => { + const { calls, post } = harness({ authenticate: () => Promise.resolve({ id: caller.id, email: null }) }); + assertEquals((await post({ action: "delete", confirmation: "DELETE" })).status, 200); + assertEquals(calls.at(-1), `delete:${caller.id}`); +}); + +Deno.test("a billing failure stops deletion before any data is touched", async () => { + const { calls, post } = harness({ + cancelSubscriptions: () => Promise.reject(new HttpError(502, "Could not cancel your subscription")), + }); + const response = await post({ action: "delete", confirmation: caller.email }); + assertEquals(response.status, 502); + assertEquals((await response.json()).error, "Could not cancel your subscription"); + assertEquals(calls, [`plan:${caller.id}`]); +}); + +Deno.test("a Storage failure keeps the account", async () => { + const { calls, post } = harness({ + removeStorageObjects: () => Promise.reject(new HttpError(500, "Could not delete your stored files")), + }); + assertEquals((await post({ action: "delete", confirmation: caller.email })).status, 500); + assertEquals(calls.some((call) => call.startsWith("prepare:") || call.startsWith("delete:")), false); +}); + +Deno.test("unexpected errors do not leak internal messages", async () => { + const { post } = harness({ exportData: () => Promise.reject(new Error("relation secret_table does not exist")) }); + const response = await post({ action: "export" }); + assertEquals(response.status, 500); + assertEquals((await response.json()).error, "Something went wrong. Please try again."); +}); + +Deno.test("requests without a valid session reach no data", async () => { + const { calls, post } = harness({ + authenticate: () => Promise.reject(new HttpError(401, "Authentication required")), + }); + for (const body of [{ action: "export" }, { action: "delete", confirmation: caller.email }]) { + assertEquals((await post(body)).status, 401); + } + assertEquals(calls, []); +}); + +Deno.test("only POST export and delete are accepted", async () => { + const { calls, handler, post } = harness(); + assertEquals((await post({ action: "erase" })).status, 400); + assertEquals((await handler(new Request("https://edge.test/account", { method: "GET" }))).status, 405); + assertEquals((await handler(new Request("https://edge.test/account", { method: "OPTIONS" }))).status, 200); + assertEquals(calls, []); +}); + +Deno.test("cancellation stops every live subscription and skips ended ones", async () => { + const canceled: string[] = []; + const subscriptions: Record> = { + cus_1: [ + { id: "sub_active", status: "active" }, + { id: "sub_old", status: "canceled" }, + { id: "sub_expired", status: "incomplete_expired" }, + ], + cus_2: [{ id: "sub_past_due", status: "past_due" }, { id: "sub_trial", status: "trialing" }], + }; + const count = await cancelLiveSubscriptions({ + list: async function* (customerId) { + yield* subscriptions[customerId]; + }, + cancel: (id) => Promise.resolve(canceled.push(id)), + }, ["cus_1", "cus_2"]); + assertEquals(count, 3); + assertEquals(canceled, ["sub_active", "sub_past_due", "sub_trial"]); +}); + +Deno.test("cancellation skips customers Stripe already deleted but fails on other errors", async () => { + const missing = Object.assign(new Error("No such customer"), { code: "resource_missing" }); + const outage = Object.assign(new Error("Stripe is down"), { code: "api_error" }); + const api = (error: Error) => ({ + list: () => ({ [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(error) }) }), + cancel: () => Promise.resolve(), + }); + assertEquals(await cancelLiveSubscriptions(api(missing), ["cus_gone"]), 0); + await assertRejects(() => cancelLiveSubscriptions(api(outage), ["cus_1"]), Error, "Stripe is down"); +}); diff --git a/supabase/migrations/20261001100000_account_deletion_and_export.sql b/supabase/migrations/20261001100000_account_deletion_and_export.sql new file mode 100644 index 000000000..ef2670d70 --- /dev/null +++ b/supabase/migrations/20261001100000_account_deletion_and_export.sql @@ -0,0 +1,209 @@ +-- Account deletion and data export for the `account` Edge Function. +-- +-- The function deletes the auth user through the Auth admin API. Foreign keys +-- into auth.users cascade or set null, with two deliberate exceptions: +-- * Workspace ownership and authorship are RESTRICT, so a raw delete cannot +-- silently remove shared work. prepare_account_deletion clears them first. +-- * admin_announcements has no foreign key; a trigger clears it on any delete. + +-- Admin-authored catalog rows outlive the admin's account. +ALTER TABLE public.promo_codes + DROP CONSTRAINT promo_codes_created_by_fkey, + ADD CONSTRAINT promo_codes_created_by_fkey + FOREIGN KEY (created_by) REFERENCES auth.users(id) ON DELETE SET NULL; + +ALTER TABLE public.curated_sources + DROP CONSTRAINT curated_sources_created_by_fkey, + ADD CONSTRAINT curated_sources_created_by_fkey + FOREIGN KEY (created_by) REFERENCES auth.users(id) ON DELETE SET NULL; + +-- A share link publishes the user's own saved work, so it goes with the account. +ALTER TABLE public.shared_items + DROP CONSTRAINT shared_items_created_by_fkey, + ADD CONSTRAINT shared_items_created_by_fkey + FOREIGN KEY (created_by) REFERENCES auth.users(id) ON DELETE CASCADE; + +ALTER TABLE public.admin_announcements ALTER COLUMN created_by DROP NOT NULL; + +-- Announcements name their author and recipients without foreign keys. +CREATE FUNCTION private.clear_deleted_user_announcements() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + UPDATE public.admin_announcements SET created_by = NULL WHERE created_by = OLD.id; + + -- An announcement left with no recipients cannot satisfy its target shape. + DELETE FROM public.admin_announcements + WHERE OLD.id = ANY (target_user_ids) + AND cardinality(array_remove(target_user_ids, OLD.id)) = 0; + + UPDATE public.admin_announcements + SET target_user_ids = array_remove(target_user_ids, OLD.id) + WHERE OLD.id = ANY (target_user_ids); + + RETURN OLD; +END; +$$; + +REVOKE ALL ON FUNCTION private.clear_deleted_user_announcements() FROM PUBLIC, anon, authenticated, service_role; + +CREATE TRIGGER on_auth_user_deleted + BEFORE DELETE ON auth.users + FOR EACH ROW EXECUTE FUNCTION private.clear_deleted_user_announcements(); + +-- Account deletion removes the workspaces a user owns, with every document, and +-- the user's own sources, threads, messages, and results in other workspaces. +-- The account function calls this after Stripe is canceled and immediately +-- before deleting the auth user. +CREATE FUNCTION public.prepare_account_deletion(p_user_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + DELETE FROM public.workspaces WHERE owner_id = p_user_id; + DELETE FROM public.workspace_thread_messages WHERE author_id = p_user_id; + DELETE FROM public.workspace_threads WHERE created_by = p_user_id; + DELETE FROM public.workspace_artifacts WHERE created_by = p_user_id; + DELETE FROM public.workspace_sources WHERE created_by = p_user_id; +END; +$$; + +-- Everything the user owns, as one JSON document. Rows are found through every +-- single-column foreign key into auth.users, so a new user-owned table is +-- exported without changing this function. +CREATE FUNCTION public.export_account_data(p_user_id uuid) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + -- Credentials and server-side integrity values are not the user's data to carry away. + v_redacted constant text[] := ARRAY['key_ciphertext', 'claim_token', 'request_fingerprint']; + v_account jsonb; + v_data jsonb := '{}'::jsonb; + v_rows jsonb; + v_table record; +BEGIN + SELECT jsonb_build_object( + 'id', account.id, + 'email', account.email, + 'phone', account.phone, + 'created_at', account.created_at, + 'last_sign_in_at', account.last_sign_in_at, + 'user_metadata', account.raw_user_meta_data + ) + INTO v_account + FROM auth.users AS account + WHERE account.id = p_user_id; + + IF v_account IS NULL THEN + RAISE EXCEPTION 'account not found' USING ERRCODE = 'P0002'; + END IF; + + FOR v_table IN + SELECT + owner.oid::regclass AS relation, + owner.relname AS name, + string_agg(format('%I = $1', reference.attname), ' OR ' ORDER BY reference.attname) AS predicate + FROM pg_constraint AS fk + JOIN pg_class AS owner ON owner.oid = fk.conrelid + JOIN pg_namespace AS schema ON schema.oid = owner.relnamespace + JOIN pg_attribute AS reference ON reference.attrelid = fk.conrelid AND reference.attnum = fk.conkey[1] + WHERE fk.contype = 'f' + AND fk.confrelid = 'auth.users'::regclass + AND cardinality(fk.conkey) = 1 + AND schema.nspname = 'public' + GROUP BY owner.oid, owner.relname + ORDER BY owner.relname + LOOP + EXECUTE format( + 'SELECT jsonb_agg(to_jsonb(owned) - $2) FROM %s AS owned WHERE %s', + v_table.relation, + v_table.predicate + ) + INTO v_rows + USING p_user_id, v_redacted; + + IF v_rows IS NOT NULL THEN + v_data := v_data || jsonb_build_object(v_table.name, v_rows); + END IF; + END LOOP; + + -- Chat messages belong to the user through their chat. + SELECT jsonb_agg(to_jsonb(message)) + INTO v_rows + FROM public.messages AS message + JOIN public.chats AS chat ON chat.id = message.chat_id + WHERE chat.user_id = p_user_id; + IF v_rows IS NOT NULL THEN + v_data := v_data || jsonb_build_object('messages', v_rows); + END IF; + + -- Announcements the user wrote as an admin. + SELECT jsonb_agg(to_jsonb(announcement)) + INTO v_rows + FROM public.admin_announcements AS announcement + WHERE announcement.created_by = p_user_id; + IF v_rows IS NOT NULL THEN + v_data := v_data || jsonb_build_object('admin_announcements', v_rows); + END IF; + + -- Announcements addressed to the user, without the other recipients. + SELECT jsonb_agg(to_jsonb(announcement) - 'target_user_ids'::text - 'created_by'::text) + INTO v_rows + FROM public.admin_announcements AS announcement + WHERE p_user_id = ANY (announcement.target_user_ids); + IF v_rows IS NOT NULL THEN + v_data := v_data || jsonb_build_object('announcements_received', v_rows); + END IF; + + RETURN jsonb_build_object( + 'format', 'debatelab-account-export', + 'version', 1, + 'exported_at', now(), + 'account', v_account, + 'data', v_data + ); +END; +$$; + +-- What the Edge Function must clear outside Postgres before deleting the auth +-- user: Stripe customers to cancel and Storage objects to remove. Storage +-- forbids deleting objects with SQL, so the function uses the Storage API. +CREATE FUNCTION public.account_deletion_plan(p_user_id uuid) +RETURNS jsonb +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT jsonb_build_object( + 'stripe_customer_ids', ( + SELECT coalesce(jsonb_agg(DISTINCT subscription.stripe_customer_id), '[]'::jsonb) + FROM public.subscriptions AS subscription + WHERE subscription.user_id = p_user_id + AND subscription.stripe_customer_id IS NOT NULL + ), + 'storage_objects', ( + SELECT coalesce(jsonb_agg(jsonb_build_object('bucket_id', object.bucket_id, 'name', object.name)), '[]'::jsonb) + FROM storage.objects AS object + WHERE object.owner = p_user_id + OR object.owner_id = p_user_id::text + OR object.name IN (SELECT file.storage_key FROM public.source_files AS file WHERE file.user_id = p_user_id) + ) + ); +$$; + +REVOKE ALL ON FUNCTION public.export_account_data(uuid) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.account_deletion_plan(uuid) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.prepare_account_deletion(uuid) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.export_account_data(uuid) TO service_role; +GRANT EXECUTE ON FUNCTION public.account_deletion_plan(uuid) TO service_role; +GRANT EXECUTE ON FUNCTION public.prepare_account_deletion(uuid) TO service_role; diff --git a/supabase/tests/account_deletion_test.sql b/supabase/tests/account_deletion_test.sql new file mode 100644 index 000000000..1f270c8c6 --- /dev/null +++ b/supabase/tests/account_deletion_test.sql @@ -0,0 +1,361 @@ +BEGIN; +SELECT plan(25); + +-- The leaver deletes their account; the stayer shares workspaces, announcements, +-- and grants with them and must keep everything of their own. +INSERT INTO auth.users (id, email) VALUES + ('ac000000-0000-4000-8000-00000000000a', 'leaver@example.test'), + ('ac000000-0000-4000-8000-00000000000b', 'stayer@example.test'); + +-- Table names whose foreign keys into auth.users find no row for the user. +CREATE FUNCTION pg_temp.owned_tables_without_rows(p_user_id uuid) +RETURNS SETOF text +LANGUAGE plpgsql +AS $$ +DECLARE + v_table record; + v_found boolean; +BEGIN + FOR v_table IN + SELECT owner.oid::regclass AS relation, owner.relname AS name, + string_agg(format('%I = $1', reference.attname), ' OR ') AS predicate + FROM pg_constraint AS fk + JOIN pg_class AS owner ON owner.oid = fk.conrelid + JOIN pg_namespace AS schema ON schema.oid = owner.relnamespace + JOIN pg_attribute AS reference ON reference.attrelid = fk.conrelid AND reference.attnum = fk.conkey[1] + WHERE fk.contype = 'f' AND fk.confrelid = 'auth.users'::regclass AND schema.nspname = 'public' + GROUP BY owner.oid, owner.relname + LOOP + EXECUTE format('SELECT EXISTS (SELECT 1 FROM %s WHERE %s)', v_table.relation, v_table.predicate) + INTO v_found USING p_user_id; + IF NOT v_found THEN RETURN NEXT v_table.name; END IF; + END LOOP; +END; +$$; + +-- Every public and private table with any row whose text contains the id, +-- whichever column, array, or JSON value holds it. +CREATE FUNCTION pg_temp.tables_mentioning(p_user_id uuid) +RETURNS SETOF text +LANGUAGE plpgsql +AS $$ +DECLARE + v_table record; + v_found boolean; +BEGIN + FOR v_table IN + SELECT format('%I.%I', schemaname, tablename) AS name + FROM pg_tables WHERE schemaname IN ('public', 'private') + LOOP + EXECUTE format('SELECT EXISTS (SELECT 1 FROM %s AS t WHERE t::text LIKE $1)', v_table.name) + INTO v_found USING '%' || p_user_id::text || '%'; + IF v_found THEN RETURN NEXT v_table.name; END IF; + END LOOP; +END; +$$; + +SELECT ok( + has_function_privilege('service_role', 'public.export_account_data(uuid)', 'EXECUTE') + AND has_function_privilege('service_role', 'public.account_deletion_plan(uuid)', 'EXECUTE') + AND NOT has_function_privilege('authenticated', 'public.export_account_data(uuid)', 'EXECUTE') + AND NOT has_function_privilege('authenticated', 'public.account_deletion_plan(uuid)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.export_account_data(uuid)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.account_deletion_plan(uuid)', 'EXECUTE') + AND has_function_privilege('service_role', 'public.prepare_account_deletion(uuid)', 'EXECUTE') + AND NOT has_function_privilege('authenticated', 'public.prepare_account_deletion(uuid)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.prepare_account_deletion(uuid)', 'EXECUTE'), + 'only the service role can export, plan, or prepare an account deletion, so a user cannot name another user' +); +SELECT ok( + NOT has_function_privilege('authenticated', 'private.clear_deleted_user_announcements()', 'EXECUTE') + AND NOT has_function_privilege('service_role', 'private.clear_deleted_user_announcements()', 'EXECUTE'), + 'the deletion trigger is not callable through the Data API' +); + +-- Debate work, saved results, and usage for both users. +INSERT INTO public.debates (id, user_id, motion) VALUES + ('ac100000-0000-4000-8000-00000000000a', 'ac000000-0000-4000-8000-00000000000a', 'This House would leave'), + ('ac100000-0000-4000-8000-00000000000b', 'ac000000-0000-4000-8000-00000000000b', 'This House would stay'); +INSERT INTO public.current_debates (user_id, debate_id) VALUES + ('ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a'), + ('ac000000-0000-4000-8000-00000000000b', 'ac100000-0000-4000-8000-00000000000b'); +INSERT INTO public.sources (id, user_id, debate_id, title, content, type) VALUES + ('ac200000-0000-4000-8000-00000000000a', 'ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a', 'Leaver study', 'LEAVER-PRIVATE evidence', 'text'), + ('ac200000-0000-4000-8000-00000000000b', 'ac000000-0000-4000-8000-00000000000b', 'ac100000-0000-4000-8000-00000000000b', 'Stayer study', 'STAYER-PRIVATE evidence', 'text'); +INSERT INTO public.source_files (user_id, source_id, filename, storage_key) VALUES + ('ac000000-0000-4000-8000-00000000000a', 'ac200000-0000-4000-8000-00000000000a', 'study.pdf', 'leaver/study.pdf'); +INSERT INTO public.source_folders (user_id, name) VALUES ('ac000000-0000-4000-8000-00000000000a', 'Folder'); +INSERT INTO public.cases (user_id, debate_id, side, title, input, output) + VALUES ('ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a', 'pro', 'Case', '{}', '{}'); +INSERT INTO public.attacks (user_id, debate_id, title, input, output) + VALUES ('ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a', 'Attack', '{}', '{}'); +INSERT INTO public.brainstorms (user_id, debate_id, title, input, output) + VALUES ('ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a', 'Brainstorm', '{}', '{}'); +INSERT INTO public.factoids (user_id, debate_id, title, input, output) + VALUES ('ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a', 'Factoids', '{}', '{}'); +INSERT INTO public.crossfires (user_id, debate_id, title, input, output) + VALUES ('ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a', 'Crossfire', '{}', '{}'); +INSERT INTO public.chats (id, user_id, debate_id, title) + VALUES ('ac300000-0000-4000-8000-00000000000a', 'ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a', 'Chat'); +INSERT INTO public.messages (chat_id, role, content) + VALUES ('ac300000-0000-4000-8000-00000000000a', 'user', 'LEAVER-MESSAGE'); +INSERT INTO public.flowcharts (user_id, title) VALUES ('ac000000-0000-4000-8000-00000000000a', 'Flow'); +INSERT INTO public.evidence_cards (user_id, claim, quote) + VALUES ('ac000000-0000-4000-8000-00000000000a', 'Claim', 'Quote'); +INSERT INTO public.sparring_sessions (user_id, debate_id, motion, user_side, persona) + VALUES ('ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a', 'Motion', 'pro', 'coach'); +INSERT INTO public.debate_context_pins (user_id, debate_id, item_kind, item_id) + VALUES ('ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a', 'chat', 'ac300000-0000-4000-8000-00000000000a'); +INSERT INTO public.ai_requests (user_id, feature, provider, model) + VALUES ('ac000000-0000-4000-8000-00000000000a', 'brainstorm', 'openrouter', 'model'); +INSERT INTO public.daily_usage_counters (user_id, usage_date) VALUES ('ac000000-0000-4000-8000-00000000000a', current_date); +INSERT INTO public.rate_limits (user_id, limit_type, limit_value) VALUES ('ac000000-0000-4000-8000-00000000000a', 'daily', 5); +INSERT INTO public.api_keys (user_id, provider, key_ciphertext) VALUES ('ac000000-0000-4000-8000-00000000000a', 'openai', 'LEAVER-CIPHERTEXT'); +INSERT INTO public.user_roles (user_id, role) VALUES ('ac000000-0000-4000-8000-00000000000a', 'admin'); +INSERT INTO public.user_data_clear_markers (user_id, cleared_at) VALUES ('ac000000-0000-4000-8000-00000000000a', now()); +INSERT INTO public.subscriptions (user_id, stripe_customer_id, stripe_subscription_id, status, current_period_start, current_period_end) + VALUES ('ac000000-0000-4000-8000-00000000000a', 'cus_leaver', 'sub_leaver', 'active', now(), now() + interval '30 days'); + +-- Admin-authored rows: the leaver is an admin whose catalog work must survive. +INSERT INTO public.promo_codes (id, code, type, created_by) + VALUES ('ac400000-0000-4000-8000-00000000000a', 'LEAVERPROMO', 'free_month', 'ac000000-0000-4000-8000-00000000000a'); +INSERT INTO public.user_promos (user_id, promo_code_id) VALUES ('ac000000-0000-4000-8000-00000000000a', 'ac400000-0000-4000-8000-00000000000a'); +INSERT INTO public.curated_sources (title, content, created_by) VALUES ('Curated', 'Curated content', 'ac000000-0000-4000-8000-00000000000a'); +INSERT INTO public.global_prompts (feature, prompt_text, created_by) VALUES ('brainstorm', 'Prompt', 'ac000000-0000-4000-8000-00000000000a'); +INSERT INTO public.demo_access_codes (code_hash, label, email, created_by) + VALUES ('leaver-hash', 'Leaver code', 'demo@example.test', 'ac000000-0000-4000-8000-00000000000a'); +INSERT INTO public.admin_grants (admin_user_id, target_user_id, grant_type, starts_at, ends_at) VALUES + ('ac000000-0000-4000-8000-00000000000a', 'ac000000-0000-4000-8000-00000000000b', 'pro', now(), now() + interval '1 day'), + ('ac000000-0000-4000-8000-00000000000b', 'ac000000-0000-4000-8000-00000000000a', 'pro', now(), now() + interval '1 day'); +INSERT INTO public.shared_items (share_code, item_type, item_data, title, created_by) + VALUES ('LEAVERSHARE', 'case', '{}', 'Shared case', 'ac000000-0000-4000-8000-00000000000a'); +INSERT INTO public.admin_announcements (id, title, body, target_type, target_user_ids, created_by) VALUES + ('ac500000-0000-4000-8000-000000000001', 'By leaver', 'Body', 'users', ARRAY['ac000000-0000-4000-8000-00000000000b']::uuid[], 'ac000000-0000-4000-8000-00000000000a'), + ('ac500000-0000-4000-8000-000000000002', 'Only leaver', 'Body', 'users', ARRAY['ac000000-0000-4000-8000-00000000000a']::uuid[], 'ac000000-0000-4000-8000-00000000000b'), + ('ac500000-0000-4000-8000-000000000003', 'Both', 'Body', 'users', ARRAY['ac000000-0000-4000-8000-00000000000a', 'ac000000-0000-4000-8000-00000000000b']::uuid[], 'ac000000-0000-4000-8000-00000000000b'); +INSERT INTO public.user_announcement_dismissals (announcement_id, user_id) + VALUES ('ac500000-0000-4000-8000-000000000003', 'ac000000-0000-4000-8000-00000000000a'); + +-- Workspaces: one the leaver owns with the stayer as a member, and one the +-- stayer owns where the leaver contributed. +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT set_config('request.jwt.claim.sub', 'ac000000-0000-4000-8000-00000000000a', true); +SELECT public.create_workspace('Leaver motion', 'Leaver workspace', 'en', 'ac600000-0000-4000-8000-00000000000a'); +SELECT set_config('request.jwt.claim.sub', 'ac000000-0000-4000-8000-00000000000b', true); +SELECT public.create_workspace('Stayer motion', 'Stayer workspace', 'en', 'ac600000-0000-4000-8000-00000000000b'); +RESET ROLE; +SELECT set_config('test.leaver_workspace', (SELECT id::text FROM public.workspaces WHERE owner_id = 'ac000000-0000-4000-8000-00000000000a'), true); +SELECT set_config('test.stayer_workspace', (SELECT id::text FROM public.workspaces WHERE owner_id = 'ac000000-0000-4000-8000-00000000000b'), true); +SELECT set_config('test.stayer_document', (SELECT id::text FROM public.workspace_documents WHERE workspace_id = current_setting('test.stayer_workspace')::uuid ORDER BY side, position LIMIT 1), true); +INSERT INTO public.workspace_members (workspace_id, user_id) VALUES + (current_setting('test.leaver_workspace')::uuid, 'ac000000-0000-4000-8000-00000000000b'), + (current_setting('test.stayer_workspace')::uuid, 'ac000000-0000-4000-8000-00000000000a'); +UPDATE public.workspace_documents SET assignee_id = 'ac000000-0000-4000-8000-00000000000a' + WHERE id = current_setting('test.stayer_document')::uuid; + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'ac000000-0000-4000-8000-00000000000a', true); +SELECT public.save_workspace_source( + current_setting('test.stayer_workspace')::uuid, 'ac700000-0000-4000-8000-000000000001', + 'Leaver evidence', 'Shared evidence', 'text', '{}', '{}' +); +SELECT public.create_workspace_thread(current_setting('test.stayer_workspace')::uuid, 'ac700000-0000-4000-8000-000000000002', 'Leaver thread'); +SELECT public.save_workspace_thread_message( + current_setting('test.stayer_workspace')::uuid, 'ac700000-0000-4000-8000-000000000002', + 'ac700000-0000-4000-8000-000000000003', 'ac700000-0000-4000-8000-000000000004', 'user', 'Question', 'complete' +); +SELECT public.save_workspace_artifact( + current_setting('test.stayer_workspace')::uuid, 'ac700000-0000-4000-8000-000000000005', + 'ac700000-0000-4000-8000-000000000006', current_setting('test.stayer_document')::uuid, 0, + 'attack', 'complete', '{}', '{}', '{}' +); +SELECT public.create_workspace_proposal( + current_setting('test.stayer_workspace')::uuid, current_setting('test.stayer_document')::uuid, + 'ac700000-0000-4000-8000-000000000007', 0, 0, + jsonb_build_object( + 'id', 'ac700000-0000-4000-8000-000000000007', + 'authorId', 'ac000000-0000-4000-8000-00000000000a', + 'documentEpoch', 0, + 'baseStateVector', jsonb_build_array(), + 'hunks', jsonb_build_array(jsonb_build_object('id', 'hunk-1')) + ) +); +SELECT public.create_workspace_comment( + current_setting('test.stayer_workspace')::uuid, current_setting('test.stayer_document')::uuid, + 'ac700000-0000-4000-8000-000000000008', + jsonb_build_object('id', 'ac700000-0000-4000-8000-000000000008', 'documentEpoch', 0, + 'from', jsonb_build_array(1), 'to', jsonb_build_array(2), 'quote', 'claim'), + 'Leaver comment', ARRAY['ac000000-0000-4000-8000-00000000000b']::uuid[] +); +SELECT set_config('request.jwt.claim.sub', 'ac000000-0000-4000-8000-00000000000b', true); +SELECT public.create_workspace_comment( + current_setting('test.stayer_workspace')::uuid, current_setting('test.stayer_document')::uuid, + 'ac700000-0000-4000-8000-000000000009', + jsonb_build_object('id', 'ac700000-0000-4000-8000-000000000009', 'documentEpoch', 0, + 'from', jsonb_build_array(1), 'to', jsonb_build_array(2), 'quote', 'claim'), + 'Stayer comment', ARRAY['ac000000-0000-4000-8000-00000000000a']::uuid[] +); +SELECT set_config('request.jwt.claim.sub', 'ac000000-0000-4000-8000-00000000000a', true); +SELECT public.reply_workspace_comment( + current_setting('test.stayer_workspace')::uuid, 'ac700000-0000-4000-8000-000000000009', + 'ac700000-0000-4000-8000-00000000000a', 'Leaver reply' +); +SELECT public.resolve_workspace_comment(current_setting('test.stayer_workspace')::uuid, 'ac700000-0000-4000-8000-000000000009', true); +SELECT public.create_workspace_version( + current_setting('test.stayer_workspace')::uuid, current_setting('test.stayer_document')::uuid, + 'Leaver version', 0, 'ac700000-0000-4000-8000-00000000000b' +); +RESET ROLE; + +-- Something outside Postgres for the plan to report. +INSERT INTO storage.buckets (id, name) VALUES ('account-test', 'account-test'); +INSERT INTO storage.objects (bucket_id, name, owner_id) VALUES + ('account-test', 'leaver/notes.txt', 'ac000000-0000-4000-8000-00000000000a'), + ('account-test', 'leaver/study.pdf', NULL), + ('account-test', 'stayer/notes.txt', 'ac000000-0000-4000-8000-00000000000b'); + +SELECT is_empty( + $$SELECT * FROM pg_temp.owned_tables_without_rows('ac000000-0000-4000-8000-00000000000a')$$, + 'the fixture gives the leaver a row in every table with a foreign key to auth.users' +); + +-- Export, as the account Edge Function calls it. +SET LOCAL ROLE service_role; +SELECT set_config('test.export', public.export_account_data('ac000000-0000-4000-8000-00000000000a')::text, true); +SELECT throws_ok( + $$SELECT public.export_account_data('ac000000-0000-4000-8000-0000000000ff')$$, + 'P0002', 'account not found', + 'exporting a missing account fails instead of returning an empty document' +); +SELECT is( + public.account_deletion_plan('ac000000-0000-4000-8000-00000000000a'), + jsonb_build_object( + 'stripe_customer_ids', jsonb_build_array('cus_leaver'), + 'storage_objects', jsonb_build_array( + jsonb_build_object('bucket_id', 'account-test', 'name', 'leaver/notes.txt'), + jsonb_build_object('bucket_id', 'account-test', 'name', 'leaver/study.pdf') + ) + ), + 'the deletion plan lists the Stripe customer and only the leaver''s Storage objects' +); +RESET ROLE; + +SELECT is( + current_setting('test.export')::jsonb #>> '{account,email}', + 'leaver@example.test', + 'the export describes the signed-in account' +); +SELECT is_empty( + $$SELECT name FROM pg_temp.owned_tables_without_rows('ac000000-0000-4000-8000-00000000000a') AS owned(name) + UNION ALL + SELECT fk_table FROM ( + SELECT DISTINCT owner.relname::text AS fk_table + FROM pg_constraint AS fk + JOIN pg_class AS owner ON owner.oid = fk.conrelid + JOIN pg_namespace AS schema ON schema.oid = owner.relnamespace + WHERE fk.contype = 'f' AND fk.confrelid = 'auth.users'::regclass AND schema.nspname = 'public' + ) AS tables + WHERE NOT (current_setting('test.export')::jsonb -> 'data') ? fk_table$$, + 'the export has a section for every table that references the user' +); +SELECT ok( + (current_setting('test.export')::jsonb -> 'data') ?& ARRAY['messages', 'announcements_received', 'admin_announcements'], + 'the export includes chat messages, announcements addressed to the user, and announcements they wrote' +); +SELECT ok( + current_setting('test.export') LIKE '%LEAVER-PRIVATE%' + AND current_setting('test.export') LIKE '%LEAVER-MESSAGE%' + AND current_setting('test.export') NOT LIKE '%STAYER-PRIVATE%', + 'the export contains the user''s own content and none of another user''s' +); +SELECT ok( + current_setting('test.export') NOT LIKE '%LEAVER-CIPHERTEXT%' + AND current_setting('test.export')::jsonb #> '{data,api_keys,0}' ? 'key_last4' + AND NOT current_setting('test.export')::jsonb #> '{data,api_keys,0}' ? 'key_ciphertext' + AND NOT current_setting('test.export')::jsonb #> '{data,sparring_sessions,0}' ? 'claim_token', + 'credentials and integrity tokens are left out of the export' +); +SELECT is( + jsonb_array_length(current_setting('test.export')::jsonb #> '{data,announcements_received}'), + 2, + 'the export lists both announcements addressed to the user' +); +SELECT ok( + NOT (current_setting('test.export')::jsonb #> '{data,announcements_received}' @? '$[*].target_user_ids') + AND NOT (current_setting('test.export')::jsonb #> '{data,announcements_received}' @? '$[*].created_by'), + 'received announcements do not reveal other recipients or the author' +); + +SELECT isnt_empty( + $$SELECT * FROM pg_temp.tables_mentioning('ac000000-0000-4000-8000-00000000000a')$$, + 'before deletion the leaver''s id appears across the database' +); + +SELECT throws_ok( + $$DELETE FROM auth.users WHERE id = 'ac000000-0000-4000-8000-00000000000a'$$, + '23503', NULL, + 'a raw delete still cannot remove a workspace owner without the account deletion path' +); + +SET LOCAL ROLE service_role; +SELECT lives_ok( + $$SELECT public.prepare_account_deletion('ac000000-0000-4000-8000-00000000000a')$$, + 'the account function clears the user''s workspaces before deleting the auth user' +); +RESET ROLE; + +-- The Auth admin API deletes this row; the database does the rest. +SELECT lives_ok( + $$DELETE FROM auth.users WHERE id = 'ac000000-0000-4000-8000-00000000000a'$$, + 'deleting the auth user is not blocked by any reference' +); + +SELECT is_empty( + $$SELECT * FROM pg_temp.tables_mentioning('ac000000-0000-4000-8000-00000000000a')$$, + 'after deletion no row in any public or private table contains the user id' +); +SELECT is( + (SELECT array_agg(title ORDER BY title) FROM public.sources), + ARRAY['Stayer study'], + 'the stayer keeps their own sources' +); +SELECT ok( + NOT EXISTS (SELECT 1 FROM public.workspaces WHERE id = current_setting('test.leaver_workspace')::uuid) + AND EXISTS (SELECT 1 FROM public.workspaces WHERE id = current_setting('test.stayer_workspace')::uuid), + 'a workspace the leaver owned is deleted; the stayer''s workspace remains' +); +SELECT is( + (SELECT count(*)::int FROM public.workspace_sources WHERE workspace_id = current_setting('test.stayer_workspace')::uuid), + 0, + 'the leaver''s contributions to another workspace are deleted' +); +SELECT is( + (SELECT count(*)::int FROM public.workspace_comments WHERE id = 'ac700000-0000-4000-8000-000000000009'), + 1, + 'the stayer''s own comment survives the leaver''s reply being deleted' +); +SELECT is( + (SELECT array_agg(title ORDER BY title) FROM public.admin_announcements), + ARRAY['Both', 'By leaver'], + 'an announcement for only the leaver is deleted; others lose the leaver as recipient or author' +); +SELECT is( + (SELECT target_user_ids FROM public.admin_announcements WHERE title = 'Both'), + ARRAY['ac000000-0000-4000-8000-00000000000b']::uuid[], + 'a shared announcement still reaches the stayer' +); +SELECT ok( + EXISTS (SELECT 1 FROM public.promo_codes WHERE code = 'LEAVERPROMO' AND created_by IS NULL) + AND EXISTS (SELECT 1 FROM public.curated_sources WHERE title = 'Curated' AND created_by IS NULL) + AND EXISTS (SELECT 1 FROM public.admin_grants WHERE target_user_id = 'ac000000-0000-4000-8000-00000000000b' AND admin_user_id IS NULL), + 'admin-authored catalog rows and grants to others survive without the author' +); +SELECT is( + (SELECT count(*)::int FROM public.shared_items WHERE share_code = 'LEAVERSHARE'), + 0, + 'share links to the leaver''s work stop working' +); + +SELECT * FROM finish(); +ROLLBACK; From c1671c10db5634f071f92e09a974df4d6c5699f0 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 09:41:11 +0000 Subject: [PATCH 2/6] fix(account): close review gaps in file deletion, export, and billing - Select Storage objects for deletion only by their Storage owner. A user could point a source_files row at another user's object, and the service role would have deleted it. - Export admin grants as type and dates only, and skip admin catalog tables. The generic export returned grant notes that RLS hides from the user. - Record Stripe customers in a service-role-only stripe_customers table when Checkout opens, and expire open Checkout sessions before canceling subscriptions. Deletion no longer depends on the webhook having recorded a subscription. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 6 +-- supabase/README.md | 2 +- supabase/functions/account/index.ts | 39 ++++++++------ supabase/functions/account/index_test.ts | 46 +++++++++++------ supabase/functions/create-checkout/index.ts | 12 +++++ ...1001100000_account_deletion_and_export.sql | 51 ++++++++++++++++--- supabase/tests/account_deletion_test.sql | 39 +++++++++----- 7 files changed, 140 insertions(+), 55 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 2ad776bea..358303d55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,12 +86,12 @@ Do not infer current schema from old README examples or store query names. Repla ## Account export and deletion -Settings offers three controls. "Download my data" calls the `account` Edge Function, which returns `export_account_data`: the auth profile plus every row reached through a foreign key into `auth.users`, chat messages, and announcements addressed to the user, with credential and integrity columns left out. "Delete All My Data" calls `clear_current_user_data` and keeps the account. "Delete account" asks the user to type their email (or DELETE for an account without one), then calls the `account` function. +Settings offers three controls. "Download my data" calls the `account` Edge Function, which returns `export_account_data`, which holds the auth profile, every row reached through a foreign key into `auth.users`, chat messages, and announcements addressed to or written by the user. It leaves out credential and integrity columns and admin catalog work (promo codes, curated sources, global prompts, demo codes). Admin grants appear as grant type and dates only, because RLS hides their notes and author from the user. "Delete All My Data" calls `clear_current_user_data` and keeps the account. "Delete account" asks the user to type their email (or DELETE for an account without one), then calls the `account` function. Deletion runs in a fixed order, and a failure at any step leaves the account in place: -1. Cancel every live Stripe subscription immediately, without a refund. If Stripe cannot be reached, nothing is deleted. -2. Remove the user's Storage objects through the Storage API, because Storage forbids deleting objects with SQL. +1. Stop Stripe billing for every customer in `subscriptions` or `stripe_customers`: expire open Checkout sessions, then cancel live subscriptions immediately, without a refund. If Stripe cannot be reached, nothing is deleted. `create-checkout` records the customer in `stripe_customers` before it opens a session, so a subscription the webhook has not recorded yet is still found. +2. Remove Storage objects whose owner is the user through the Storage API, because Storage forbids deleting objects with SQL. `source_files.storage_key` is user-writable, so it never selects an object for deletion. 3. Call `prepare_account_deletion`, which deletes the workspaces the user owns and the user's own sources, threads, messages, and results in other workspaces. Those foreign keys stay `RESTRICT` so that a raw delete of an auth user cannot remove shared work by accident. 4. Delete the auth user with the Auth admin API. Every other foreign key into `auth.users` cascades or sets null, and the `on_auth_user_deleted` trigger clears the user from announcements, which have no foreign key. diff --git a/supabase/README.md b/supabase/README.md index b8d37a3cf..a1eaa4caa 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -56,7 +56,7 @@ The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Sh ### Billing -- `create-checkout`: authenticated Stripe Checkout creation. Price selection is server-controlled and redirects are validated. +- `create-checkout`: authenticated Stripe Checkout creation. Price selection is server-controlled and redirects are validated. It records the customer in `stripe_customers` before opening a session so that account deletion can stop its billing. - `create-portal`: authenticated Stripe Billing Portal creation with customer ownership checks. - `stripe-webhook`: Stripe-signature-validated subscription and invoice event handling with idempotency state. diff --git a/supabase/functions/account/index.ts b/supabase/functions/account/index.ts index 78864d247..bafe77504 100644 --- a/supabase/functions/account/index.ts +++ b/supabase/functions/account/index.ts @@ -6,8 +6,9 @@ // Foreign keys and the auth.users delete trigger remove every remaining row in // that delete; see supabase/migrations/20261001100000_account_deletion_and_export.sql. // -// Billing is canceled first and immediately, without a refund. If Stripe -// cannot be reached, nothing is deleted and the user can try again. +// Billing stops first: open Checkout sessions expire and subscriptions are +// canceled immediately, without a refund. If Stripe cannot be reached, nothing +// is deleted and the user can try again. import Stripe from "https://esm.sh/stripe@14.10.0?target=deno"; import { createAdminClient, HttpError, jsonError, requireUser } from "../_shared/auth.ts"; @@ -21,8 +22,8 @@ export type AccountDeps = { authenticate: (req: Request) => Promise; exportData: (userId: string) => Promise; deletionPlan: (userId: string) => Promise; - /** Cancels every live subscription and returns how many it canceled. */ - cancelSubscriptions: (customerIds: string[]) => Promise; + /** Stops all billing for the customers and returns how many subscriptions it canceled. */ + stopBilling: (customerIds: string[]) => Promise; removeStorageObjects: (objects: StorageObject[]) => Promise; /** Clears rows that deliberately block a raw auth user delete. */ prepareDeletion: (userId: string) => Promise; @@ -37,18 +38,24 @@ export function deletionConfirmation(user: AccountUser) { // Subscriptions in these states no longer bill. const ENDED_SUBSCRIPTION_STATUSES = new Set(["canceled", "incomplete_expired"]); -export type SubscriptionApi = { - list: (customerId: string) => AsyncIterable<{ id: string; status: string }>; - cancel: (subscriptionId: string) => Promise; +export type BillingApi = { + listOpenCheckouts: (customerId: string) => AsyncIterable<{ id: string }>; + expireCheckout: (sessionId: string) => Promise; + listSubscriptions: (customerId: string) => AsyncIterable<{ id: string; status: string }>; + cancelSubscription: (subscriptionId: string) => Promise; }; -export async function cancelLiveSubscriptions(api: SubscriptionApi, customerIds: string[]) { +export async function stopBillingFor(api: BillingApi, customerIds: string[]) { let canceled = 0; for (const customerId of customerIds) { try { - for await (const subscription of api.list(customerId)) { + // An open Checkout session could otherwise start a subscription after deletion. + for await (const session of api.listOpenCheckouts(customerId)) { + await api.expireCheckout(session.id); + } + for await (const subscription of api.listSubscriptions(customerId)) { if (ENDED_SUBSCRIPTION_STATUSES.has(subscription.status)) continue; - await api.cancel(subscription.id); + await api.cancelSubscription(subscription.id); canceled += 1; } } catch (error) { @@ -89,7 +96,7 @@ export function createHandler(deps: AccountDeps) { throw new HttpError(400, "The confirmation does not match, so the account was not deleted"); } const plan = await deps.deletionPlan(user.id); - const canceledSubscriptions = await deps.cancelSubscriptions(plan.stripe_customer_ids); + const canceledSubscriptions = await deps.stopBilling(plan.stripe_customer_ids); await deps.removeStorageObjects(plan.storage_objects); await deps.prepareDeletion(user.id); await deps.deleteAuthUser(user.id); @@ -130,7 +137,7 @@ function liveDeps(): AccountDeps { return data as DeletionPlan; }, - cancelSubscriptions: async (customerIds) => { + stopBilling: async (customerIds) => { if (customerIds.length === 0) return 0; const billingError = new HttpError( 502, @@ -143,9 +150,11 @@ function liveDeps(): AccountDeps { httpClient: Stripe.createFetchHttpClient(), }); try { - return await cancelLiveSubscriptions({ - list: (customer) => stripe.subscriptions.list({ customer, status: "all", limit: 100 }), - cancel: (subscriptionId) => stripe.subscriptions.cancel(subscriptionId), + return await stopBillingFor({ + listOpenCheckouts: (customer) => stripe.checkout.sessions.list({ customer, status: "open", limit: 100 }), + expireCheckout: (sessionId) => stripe.checkout.sessions.expire(sessionId), + listSubscriptions: (customer) => stripe.subscriptions.list({ customer, status: "all", limit: 100 }), + cancelSubscription: (subscriptionId) => stripe.subscriptions.cancel(subscriptionId), }, customerIds); } catch (error) { console.error("[ACCOUNT] Stripe cancellation failed", (error as { code?: string }).code ?? "unknown"); diff --git a/supabase/functions/account/index_test.ts b/supabase/functions/account/index_test.ts index 1d348c51b..4d5342c2e 100644 --- a/supabase/functions/account/index_test.ts +++ b/supabase/functions/account/index_test.ts @@ -7,7 +7,7 @@ for (const [key, value] of Object.entries({ })) Deno.env.set(key, value); const { HttpError } = await import("../_shared/auth.ts"); -const { cancelLiveSubscriptions, createHandler } = await import("./index.ts"); +const { createHandler, stopBillingFor } = await import("./index.ts"); type AccountDeps = import("./index.ts").AccountDeps; const caller = { id: "11111111-1111-4111-8111-111111111111", email: "student@example.test" }; @@ -28,7 +28,7 @@ function harness(overrides: Partial = {}) { storage_objects: [{ bucket_id: "files", name: `${userId}/a.pdf` }], }); }, - cancelSubscriptions: (customerIds) => { + stopBilling: (customerIds) => { calls.push(`cancel:${customerIds.join(",")}`); return Promise.resolve(1); }, @@ -94,7 +94,7 @@ Deno.test("an account without an email confirms with DELETE", async () => { Deno.test("a billing failure stops deletion before any data is touched", async () => { const { calls, post } = harness({ - cancelSubscriptions: () => Promise.reject(new HttpError(502, "Could not cancel your subscription")), + stopBilling: () => Promise.reject(new HttpError(502, "Could not cancel your subscription")), }); const response = await post({ action: "delete", confirmation: caller.email }); assertEquals(response.status, 502); @@ -135,8 +135,17 @@ Deno.test("only POST export and delete are accepted", async () => { assertEquals(calls, []); }); -Deno.test("cancellation stops every live subscription and skips ended ones", async () => { - const canceled: string[] = []; +function iterate(items: T[]): AsyncIterable { + return { + [Symbol.asyncIterator]: () => { + let index = 0; + return { next: () => Promise.resolve(index < items.length ? { value: items[index++], done: false } : { value: undefined, done: true }) }; + }, + }; +} + +Deno.test("billing stops open checkouts, then every live subscription, and skips ended ones", async () => { + const actions: string[] = []; const subscriptions: Record> = { cus_1: [ { id: "sub_active", status: "active" }, @@ -145,23 +154,28 @@ Deno.test("cancellation stops every live subscription and skips ended ones", asy ], cus_2: [{ id: "sub_past_due", status: "past_due" }, { id: "sub_trial", status: "trialing" }], }; - const count = await cancelLiveSubscriptions({ - list: async function* (customerId) { - yield* subscriptions[customerId]; - }, - cancel: (id) => Promise.resolve(canceled.push(id)), + const count = await stopBillingFor({ + listOpenCheckouts: (customerId) => iterate(customerId === "cus_2" ? [{ id: "cs_open" }] : []), + expireCheckout: (id) => Promise.resolve(actions.push(`expire:${id}`)), + listSubscriptions: (customerId) => iterate(subscriptions[customerId]), + cancelSubscription: (id) => Promise.resolve(actions.push(`cancel:${id}`)), }, ["cus_1", "cus_2"]); assertEquals(count, 3); - assertEquals(canceled, ["sub_active", "sub_past_due", "sub_trial"]); + assertEquals(actions, ["cancel:sub_active", "expire:cs_open", "cancel:sub_past_due", "cancel:sub_trial"]); }); -Deno.test("cancellation skips customers Stripe already deleted but fails on other errors", async () => { +Deno.test("billing skips customers Stripe already deleted but fails on other errors", async () => { const missing = Object.assign(new Error("No such customer"), { code: "resource_missing" }); const outage = Object.assign(new Error("Stripe is down"), { code: "api_error" }); + const failing = (error: Error): AsyncIterable => ({ + [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(error) }), + }); const api = (error: Error) => ({ - list: () => ({ [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(error) }) }), - cancel: () => Promise.resolve(), + listOpenCheckouts: () => failing(error), + expireCheckout: () => Promise.resolve(), + listSubscriptions: () => iterate([]), + cancelSubscription: () => Promise.resolve(), }); - assertEquals(await cancelLiveSubscriptions(api(missing), ["cus_gone"]), 0); - await assertRejects(() => cancelLiveSubscriptions(api(outage), ["cus_1"]), Error, "Stripe is down"); + assertEquals(await stopBillingFor(api(missing), ["cus_gone"]), 0); + await assertRejects(() => stopBillingFor(api(outage), ["cus_1"]), Error, "Stripe is down"); }); diff --git a/supabase/functions/create-checkout/index.ts b/supabase/functions/create-checkout/index.ts index f49c4a908..f700f62c6 100644 --- a/supabase/functions/create-checkout/index.ts +++ b/supabase/functions/create-checkout/index.ts @@ -270,6 +270,18 @@ Deno.serve(async (req) => { customerId = customer.id; } + // Account deletion reads this to cancel billing before the webhook has + // recorded a subscription, so no session opens without it. + const { error: customerRecordError } = await supabase.from("stripe_customers") + .upsert({ user_id: user.id, customer_id: customerId }, { onConflict: "user_id,customer_id", ignoreDuplicates: true }); + if (customerRecordError) { + console.error(`[CHECKOUT] Could not record customer for ${user.id}: ${customerRecordError.code}`); + return new Response(JSON.stringify({ error: "Failed to create checkout session" }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + // Build checkout session params const sessionParams: Stripe.Checkout.SessionCreateParams = { customer: customerId, diff --git a/supabase/migrations/20261001100000_account_deletion_and_export.sql b/supabase/migrations/20261001100000_account_deletion_and_export.sql index ef2670d70..72374f9c8 100644 --- a/supabase/migrations/20261001100000_account_deletion_and_export.sql +++ b/supabase/migrations/20261001100000_account_deletion_and_export.sql @@ -25,6 +25,19 @@ ALTER TABLE public.shared_items ALTER TABLE public.admin_announcements ALTER COLUMN created_by DROP NOT NULL; +-- Stripe customers created for a user, recorded by create-checkout before it +-- opens a session. Subscription rows arrive later through the webhook, so +-- deletion cannot rely on them alone to find everything Stripe may bill. +CREATE TABLE public.stripe_customers ( + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + customer_id text NOT NULL CHECK (customer_id LIKE 'cus\_%'), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, customer_id) +); +ALTER TABLE public.stripe_customers ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON TABLE public.stripe_customers FROM PUBLIC, anon, authenticated; +GRANT SELECT, INSERT, DELETE ON TABLE public.stripe_customers TO service_role; + -- Announcements name their author and recipients without foreign keys. CREATE FUNCTION private.clear_deleted_user_announcements() RETURNS trigger @@ -86,6 +99,9 @@ AS $$ DECLARE -- Credentials and server-side integrity values are not the user's data to carry away. v_redacted constant text[] := ARRAY['key_ciphertext', 'claim_token', 'request_fingerprint']; + -- Admin catalog work rather than the user's personal data, and admin grants, + -- which RLS hides from the user and which are exported below without notes. + v_skipped constant text[] := ARRAY['admin_grants', 'curated_sources', 'demo_access_codes', 'global_prompts', 'promo_codes']; v_account jsonb; v_data jsonb := '{}'::jsonb; v_rows jsonb; @@ -120,6 +136,7 @@ BEGIN AND fk.confrelid = 'auth.users'::regclass AND cardinality(fk.conkey) = 1 AND schema.nspname = 'public' + AND NOT owner.relname = ANY (v_skipped) GROUP BY owner.oid, owner.relname ORDER BY owner.relname LOOP @@ -136,6 +153,20 @@ BEGIN END IF; END LOOP; + -- Access the user was granted, without the admin's identity or notes. + SELECT jsonb_agg(jsonb_build_object( + 'grant_type', grant_row.grant_type, + 'starts_at', grant_row.starts_at, + 'ends_at', grant_row.ends_at, + 'created_at', grant_row.created_at + )) + INTO v_rows + FROM public.admin_grants AS grant_row + WHERE grant_row.target_user_id = p_user_id; + IF v_rows IS NOT NULL THEN + v_data := v_data || jsonb_build_object('access_grants', v_rows); + END IF; + -- Chat messages belong to the user through their chat. SELECT jsonb_agg(to_jsonb(message)) INTO v_rows @@ -175,7 +206,7 @@ END; $$; -- What the Edge Function must clear outside Postgres before deleting the auth --- user: Stripe customers to cancel and Storage objects to remove. Storage +-- user, namely the Stripe customers to cancel and the Storage objects to remove. Storage -- forbids deleting objects with SQL, so the function uses the Storage API. CREATE FUNCTION public.account_deletion_plan(p_user_id uuid) RETURNS jsonb @@ -186,17 +217,21 @@ SET search_path = '' AS $$ SELECT jsonb_build_object( 'stripe_customer_ids', ( - SELECT coalesce(jsonb_agg(DISTINCT subscription.stripe_customer_id), '[]'::jsonb) - FROM public.subscriptions AS subscription - WHERE subscription.user_id = p_user_id - AND subscription.stripe_customer_id IS NOT NULL + SELECT coalesce(jsonb_agg(DISTINCT customer.id ORDER BY customer.id), '[]'::jsonb) + FROM ( + SELECT subscription.stripe_customer_id AS id + FROM public.subscriptions AS subscription + WHERE subscription.user_id = p_user_id AND subscription.stripe_customer_id IS NOT NULL + UNION + SELECT recorded.customer_id FROM public.stripe_customers AS recorded WHERE recorded.user_id = p_user_id + ) AS customer ), 'storage_objects', ( SELECT coalesce(jsonb_agg(jsonb_build_object('bucket_id', object.bucket_id, 'name', object.name)), '[]'::jsonb) FROM storage.objects AS object - WHERE object.owner = p_user_id - OR object.owner_id = p_user_id::text - OR object.name IN (SELECT file.storage_key FROM public.source_files AS file WHERE file.user_id = p_user_id) + -- Storage sets the owner from the uploader's session. source_files.storage_key + -- is user-writable, so it must never select objects for deletion. + WHERE object.owner = p_user_id OR object.owner_id = p_user_id::text ) ); $$; diff --git a/supabase/tests/account_deletion_test.sql b/supabase/tests/account_deletion_test.sql index 1f270c8c6..96ea75ed4 100644 --- a/supabase/tests/account_deletion_test.sql +++ b/supabase/tests/account_deletion_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(25); +SELECT plan(26); -- The leaver deletes their account; the stayer shares workspaces, announcements, -- and grants with them and must keep everything of their own. @@ -82,8 +82,9 @@ INSERT INTO public.current_debates (user_id, debate_id) VALUES INSERT INTO public.sources (id, user_id, debate_id, title, content, type) VALUES ('ac200000-0000-4000-8000-00000000000a', 'ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a', 'Leaver study', 'LEAVER-PRIVATE evidence', 'text'), ('ac200000-0000-4000-8000-00000000000b', 'ac000000-0000-4000-8000-00000000000b', 'ac100000-0000-4000-8000-00000000000b', 'Stayer study', 'STAYER-PRIVATE evidence', 'text'); +-- source_files is user-writable, so this key names the stayer's object to try to get it deleted. INSERT INTO public.source_files (user_id, source_id, filename, storage_key) VALUES - ('ac000000-0000-4000-8000-00000000000a', 'ac200000-0000-4000-8000-00000000000a', 'study.pdf', 'leaver/study.pdf'); + ('ac000000-0000-4000-8000-00000000000a', 'ac200000-0000-4000-8000-00000000000a', 'notes.txt', 'stayer/notes.txt'); INSERT INTO public.source_folders (user_id, name) VALUES ('ac000000-0000-4000-8000-00000000000a', 'Folder'); INSERT INTO public.cases (user_id, debate_id, side, title, input, output) VALUES ('ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a', 'pro', 'Case', '{}', '{}'); @@ -115,6 +116,8 @@ INSERT INTO public.user_roles (user_id, role) VALUES ('ac000000-0000-4000-8000-0 INSERT INTO public.user_data_clear_markers (user_id, cleared_at) VALUES ('ac000000-0000-4000-8000-00000000000a', now()); INSERT INTO public.subscriptions (user_id, stripe_customer_id, stripe_subscription_id, status, current_period_start, current_period_end) VALUES ('ac000000-0000-4000-8000-00000000000a', 'cus_leaver', 'sub_leaver', 'active', now(), now() + interval '30 days'); +-- A checkout customer whose subscription the webhook has not recorded yet. +INSERT INTO public.stripe_customers (user_id, customer_id) VALUES ('ac000000-0000-4000-8000-00000000000a', 'cus_leaver_checkout'); -- Admin-authored rows: the leaver is an admin whose catalog work must survive. INSERT INTO public.promo_codes (id, code, type, created_by) @@ -124,9 +127,9 @@ INSERT INTO public.curated_sources (title, content, created_by) VALUES ('Curated INSERT INTO public.global_prompts (feature, prompt_text, created_by) VALUES ('brainstorm', 'Prompt', 'ac000000-0000-4000-8000-00000000000a'); INSERT INTO public.demo_access_codes (code_hash, label, email, created_by) VALUES ('leaver-hash', 'Leaver code', 'demo@example.test', 'ac000000-0000-4000-8000-00000000000a'); -INSERT INTO public.admin_grants (admin_user_id, target_user_id, grant_type, starts_at, ends_at) VALUES - ('ac000000-0000-4000-8000-00000000000a', 'ac000000-0000-4000-8000-00000000000b', 'pro', now(), now() + interval '1 day'), - ('ac000000-0000-4000-8000-00000000000b', 'ac000000-0000-4000-8000-00000000000a', 'pro', now(), now() + interval '1 day'); +INSERT INTO public.admin_grants (admin_user_id, target_user_id, grant_type, starts_at, ends_at, notes) VALUES + ('ac000000-0000-4000-8000-00000000000a', 'ac000000-0000-4000-8000-00000000000b', 'pro', now(), now() + interval '1 day', 'NOTE-BY-LEAVER'), + ('ac000000-0000-4000-8000-00000000000b', 'ac000000-0000-4000-8000-00000000000a', 'pro', now(), now() + interval '1 day', 'ADMIN-ONLY-NOTE'); INSERT INTO public.shared_items (share_code, item_type, item_data, title, created_by) VALUES ('LEAVERSHARE', 'case', '{}', 'Shared case', 'ac000000-0000-4000-8000-00000000000a'); INSERT INTO public.admin_announcements (id, title, body, target_type, target_user_ids, created_by) VALUES @@ -212,7 +215,6 @@ RESET ROLE; INSERT INTO storage.buckets (id, name) VALUES ('account-test', 'account-test'); INSERT INTO storage.objects (bucket_id, name, owner_id) VALUES ('account-test', 'leaver/notes.txt', 'ac000000-0000-4000-8000-00000000000a'), - ('account-test', 'leaver/study.pdf', NULL), ('account-test', 'stayer/notes.txt', 'ac000000-0000-4000-8000-00000000000b'); SELECT is_empty( @@ -231,13 +233,12 @@ SELECT throws_ok( SELECT is( public.account_deletion_plan('ac000000-0000-4000-8000-00000000000a'), jsonb_build_object( - 'stripe_customer_ids', jsonb_build_array('cus_leaver'), + 'stripe_customer_ids', jsonb_build_array('cus_leaver', 'cus_leaver_checkout'), 'storage_objects', jsonb_build_array( - jsonb_build_object('bucket_id', 'account-test', 'name', 'leaver/notes.txt'), - jsonb_build_object('bucket_id', 'account-test', 'name', 'leaver/study.pdf') + jsonb_build_object('bucket_id', 'account-test', 'name', 'leaver/notes.txt') ) ), - 'the deletion plan lists the Stripe customer and only the leaver''s Storage objects' + 'the plan lists every Stripe customer and only Storage objects the leaver uploaded, not one a source_files key names' ); RESET ROLE; @@ -256,8 +257,9 @@ SELECT is_empty( JOIN pg_namespace AS schema ON schema.oid = owner.relnamespace WHERE fk.contype = 'f' AND fk.confrelid = 'auth.users'::regclass AND schema.nspname = 'public' ) AS tables - WHERE NOT (current_setting('test.export')::jsonb -> 'data') ? fk_table$$, - 'the export has a section for every table that references the user' + WHERE NOT (current_setting('test.export')::jsonb -> 'data') ? fk_table + AND fk_table NOT IN ('admin_grants', 'curated_sources', 'demo_access_codes', 'global_prompts', 'promo_codes')$$, + 'the export has a section for every table that references the user, apart from admin catalog work and grants' ); SELECT ok( (current_setting('test.export')::jsonb -> 'data') ?& ARRAY['messages', 'announcements_received', 'admin_announcements'], @@ -276,6 +278,19 @@ SELECT ok( AND NOT current_setting('test.export')::jsonb #> '{data,sparring_sessions,0}' ? 'claim_token', 'credentials and integrity tokens are left out of the export' ); +SELECT ok( + current_setting('test.export')::jsonb #> '{data,access_grants}' = jsonb_build_array(jsonb_build_object( + 'grant_type', 'pro', + 'starts_at', (SELECT starts_at FROM public.admin_grants WHERE target_user_id = 'ac000000-0000-4000-8000-00000000000a'), + 'ends_at', (SELECT ends_at FROM public.admin_grants WHERE target_user_id = 'ac000000-0000-4000-8000-00000000000a'), + 'created_at', (SELECT created_at FROM public.admin_grants WHERE target_user_id = 'ac000000-0000-4000-8000-00000000000a') + )) + AND current_setting('test.export') NOT LIKE '%ADMIN-ONLY-NOTE%' + AND current_setting('test.export') NOT LIKE '%NOTE-BY-LEAVER%' + AND current_setting('test.export') NOT LIKE '%LEAVERPROMO%' + AND NOT (current_setting('test.export')::jsonb -> 'data') ?| ARRAY['admin_grants', 'promo_codes', 'demo_access_codes'], + 'the export lists access granted to the user but no admin notes, grants they made, or admin catalog rows' +); SELECT is( jsonb_array_length(current_setting('test.export')::jsonb #> '{data,announcements_received}'), 2, From 6469981145e82cf520dddf8db859aaa47caa20cd Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 11:03:53 +0000 Subject: [PATCH 3/6] fix(account): find every Stripe customer and keep the export to owned rows - Search Stripe for customers by metadata.user_id during deletion, in addition to recorded ones, so customers created before stripe_customers existed are still stopped. A failed search stops deletion. The local gate's fake answers the search through the new STRIPE_API_URL override. - Export only rows whose ownership column references the user. Columns such as resolved_by and actor_id pulled in rows other people own. - Leave scheduled and withdrawn announcements out of the export, as the read policy does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .superset/workspace-test.env | 1 + docs/architecture.md | 4 +- docs/testing.md | 2 +- e2e/full-stack/account-deletion.spec.ts | 5 +- e2e/full-stack/fake-openrouter.mjs | 6 +++ supabase/README.md | 2 +- supabase/functions/account/index.ts | 39 ++++++++++----- supabase/functions/account/index_test.ts | 48 ++++++++++++++++--- ...1001100000_account_deletion_and_export.sql | 17 +++++-- supabase/tests/account_deletion_test.sql | 17 +++++-- 10 files changed, 111 insertions(+), 30 deletions(-) diff --git a/.superset/workspace-test.env b/.superset/workspace-test.env index 703812916..3316d3795 100644 --- a/.superset/workspace-test.env +++ b/.superset/workspace-test.env @@ -11,3 +11,4 @@ DEMO_CODE_PEPPER=local-demo-code-pepper-for-browser-gate SPARRING_INTEGRITY_SECRET=local-sparring-integrity-secret-for-browser-gate STRIPE_SECRET_KEY=sk_test_local-full-stack-unused STRIPE_WEBHOOK_SECRET=whsec_local-full-stack-test +STRIPE_API_URL=http://debatelab-fake-openrouter:8787 diff --git a/docs/architecture.md b/docs/architecture.md index 358303d55..2c25524cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,11 +86,11 @@ Do not infer current schema from old README examples or store query names. Repla ## Account export and deletion -Settings offers three controls. "Download my data" calls the `account` Edge Function, which returns `export_account_data`, which holds the auth profile, every row reached through a foreign key into `auth.users`, chat messages, and announcements addressed to or written by the user. It leaves out credential and integrity columns and admin catalog work (promo codes, curated sources, global prompts, demo codes). Admin grants appear as grant type and dates only, because RLS hides their notes and author from the user. "Delete All My Data" calls `clear_current_user_data` and keeps the account. "Delete account" asks the user to type their email (or DELETE for an account without one), then calls the `account` function. +Settings offers three controls. "Download my data" calls the `account` Edge Function, which returns `export_account_data`, which holds the auth profile, every row whose ownership column (`user_id`, `owner_id`, `author_id`, `created_by`, or `recipient_id`) references the user, chat messages, published announcements addressed to the user, and announcements they wrote. Columns that only mention the user, such as `actor_id` or `resolved_by`, never select a row. It leaves out credential and integrity columns and admin catalog work (promo codes, curated sources, global prompts, demo codes). Admin grants appear as grant type and dates only, because RLS hides their notes and author from the user. "Delete All My Data" calls `clear_current_user_data` and keeps the account. "Delete account" asks the user to type their email (or DELETE for an account without one), then calls the `account` function. Deletion runs in a fixed order, and a failure at any step leaves the account in place: -1. Stop Stripe billing for every customer in `subscriptions` or `stripe_customers`: expire open Checkout sessions, then cancel live subscriptions immediately, without a refund. If Stripe cannot be reached, nothing is deleted. `create-checkout` records the customer in `stripe_customers` before it opens a session, so a subscription the webhook has not recorded yet is still found. +1. Stop Stripe billing for every customer in `subscriptions`, in `stripe_customers`, or found by a Stripe search on `metadata.user_id`. The function expires open Checkout sessions, then cancels live subscriptions immediately, without a refund. If Stripe cannot be reached, nothing is deleted. `create-checkout` records the customer in `stripe_customers` before it opens a session, because Stripe search can lag a new customer by up to a minute. A Checkout session opened in another tab during the few seconds deletion takes is not stopped. 2. Remove Storage objects whose owner is the user through the Storage API, because Storage forbids deleting objects with SQL. `source_files.storage_key` is user-writable, so it never selects an object for deletion. 3. Call `prepare_account_deletion`, which deletes the workspaces the user owns and the user's own sources, threads, messages, and results in other workspaces. Those foreign keys stay `RESTRICT` so that a raw delete of an auth user cannot remove shared work by accident. 4. Delete the auth user with the Auth admin API. Every other foreign key into `auth.users` cascades or sets null, and the `on_auth_user_deleted` trigger clears the user from announcements, which have no foreign key. diff --git a/docs/testing.md b/docs/testing.md index 188974b57..54b1eae8a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -170,7 +170,7 @@ The stack replays migrations and runs pgTAP tests. The browser portion creates i ### Full-stack feature journeys -The same gate serves every Edge Function and runs `e2e/full-stack/` on desktop and mobile Chromium. Only the paid model provider is replaced: [`e2e/full-stack/fake-openrouter.mjs`](../e2e/full-stack/fake-openrouter.mjs) runs as a `node:22-alpine` container on the Supabase Docker network, and the functions reach it through `OPENROUTER_API_URL`. Real code handles everything else: Auth, demo-code sign-in, RLS, quota, source loading, prompt building, output validation, streaming, and persistence. +The same gate serves every Edge Function and runs `e2e/full-stack/` on desktop and mobile Chromium. Only the paid model provider is replaced: [`e2e/full-stack/fake-openrouter.mjs`](../e2e/full-stack/fake-openrouter.mjs) runs as a `node:22-alpine` container on the Supabase Docker network, and the functions reach it through `OPENROUTER_API_URL`. It also answers the Stripe customer search that account deletion runs, which the `account` function reaches through `STRIPE_API_URL`. Real code handles everything else: Auth, demo-code sign-in, RLS, quota, source loading, prompt building, output validation, streaming, and persistence. Each test signs in through the Login page with a fresh demo code and deletes that user afterwards. The specs cover: diff --git a/e2e/full-stack/account-deletion.spec.ts b/e2e/full-stack/account-deletion.spec.ts index 191e5feee..5a963d67f 100644 --- a/e2e/full-stack/account-deletion.spec.ts +++ b/e2e/full-stack/account-deletion.spec.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises"; import pg from "pg"; import type { Page } from "@playwright/test"; -import { admin, createDemoUser, deleteDemoUser, expect, signIn, stackReady, startDebate, test } from "./stack"; +import { admin, createDemoUser, deleteDemoUser, expect, fake, signIn, stackReady, startDebate, test } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -127,6 +127,9 @@ test("Delete account removes every row that references the user and nobody else' expect((await admin.auth.admin.getUserById(userId)).data.user).toBeNull(); expect(await tablesMentioning(userId)).toEqual([]); + // Billing was checked in Stripe, not only in the database, before anything was deleted. + const searches = (await fake.requests()).filter((request) => request.feature === "stripe-customer-search"); + expect(searches.map((request) => request.user)).toEqual([`metadata['user_id']:'${userId}'`]); expect((await admin.auth.admin.getUserById(otherId)).data.user?.id).toBe(otherId); const { data: theirs } = await admin.from("sources").select("title").eq("user_id", otherId); diff --git a/e2e/full-stack/fake-openrouter.mjs b/e2e/full-stack/fake-openrouter.mjs index 303f58a8d..289fafd9a 100644 --- a/e2e/full-stack/fake-openrouter.mjs +++ b/e2e/full-stack/fake-openrouter.mjs @@ -267,6 +267,12 @@ createServer(async (req, res) => { requests.push({ feature: "jev", model: body.model, status: 200 }); return json(res, 200, { model: body.model, answers: jevAnswers(body.questions ?? {}), usage: { input_tokens: 40, output_tokens: 8, cost: 0.00001 } }); } + if (url.pathname === "/v1/customers/search" && req.method === "GET") { + // Account deletion searches Stripe (through STRIPE_API_URL) for the user's + // customers. Test accounts have none. + requests.push({ feature: "stripe-customer-search", user: url.searchParams.get("query") ?? "" }); + return json(res, 200, { object: "search_result", data: [], has_more: false, next_page: null, url: "/v1/customers/search" }); + } if (url.pathname !== "/api/v1/chat/completions" || req.method !== "POST") { return json(res, 404, { error: { message: `Unexpected ${req.method} ${url.pathname}` } }); } diff --git a/supabase/README.md b/supabase/README.md index a1eaa4caa..f273537a1 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -145,7 +145,7 @@ Document names, never values. Depending on deployed features, server-side config - Supabase URL, anon key, and service-role key; - `OPENROUTER_API_KEY` for all model requests, plus `OPENROUTER_API_URL`, which only the local full-stack gate sets to reach its fake provider (leave it unset everywhere else); - research provider keys; -- Stripe secret, webhook secret, and price configuration; +- Stripe secret, webhook secret, and price configuration, plus `STRIPE_API_URL`, which only the local full-stack gate sets so the `account` function reaches its fake Stripe (leave it unset everywhere else); - demo-code hashing material; - `WORKSPACE_INVITE_SECRET` for invite derivation, keyed hashes, and target-email encryption; - `WORKSPACE_INVITE_MAILER_URL`, `WORKSPACE_INVITE_MAILER_TOKEN`, and `WORKSPACE_INVITE_BASE_URL` for the operator-supplied email relay, plus `WORKSPACE_INVITE_ALLOWED_ORIGINS` for an exact preview origin; diff --git a/supabase/functions/account/index.ts b/supabase/functions/account/index.ts index bafe77504..9ae7b84fa 100644 --- a/supabase/functions/account/index.ts +++ b/supabase/functions/account/index.ts @@ -6,9 +6,10 @@ // Foreign keys and the auth.users delete trigger remove every remaining row in // that delete; see supabase/migrations/20261001100000_account_deletion_and_export.sql. // -// Billing stops first: open Checkout sessions expire and subscriptions are -// canceled immediately, without a refund. If Stripe cannot be reached, nothing -// is deleted and the user can try again. +// Billing stops first, for every customer the database records and every one +// Stripe finds by metadata.user_id: open Checkout sessions expire and +// subscriptions are canceled immediately, without a refund. If Stripe cannot be +// reached, nothing is deleted and the user can try again. import Stripe from "https://esm.sh/stripe@14.10.0?target=deno"; import { createAdminClient, HttpError, jsonError, requireUser } from "../_shared/auth.ts"; @@ -22,8 +23,8 @@ export type AccountDeps = { authenticate: (req: Request) => Promise; exportData: (userId: string) => Promise; deletionPlan: (userId: string) => Promise; - /** Stops all billing for the customers and returns how many subscriptions it canceled. */ - stopBilling: (customerIds: string[]) => Promise; + /** Stops all billing for the user and returns how many subscriptions it canceled. */ + stopBilling: (userId: string, knownCustomerIds: string[]) => Promise; removeStorageObjects: (objects: StorageObject[]) => Promise; /** Clears rows that deliberately block a raw auth user delete. */ prepareDeletion: (userId: string) => Promise; @@ -39,15 +40,23 @@ export function deletionConfirmation(user: AccountUser) { const ENDED_SUBSCRIPTION_STATUSES = new Set(["canceled", "incomplete_expired"]); export type BillingApi = { + searchCustomers: (userId: string) => AsyncIterable<{ id: string; deleted?: boolean; metadata?: Record | null }>; listOpenCheckouts: (customerId: string) => AsyncIterable<{ id: string }>; expireCheckout: (sessionId: string) => Promise; listSubscriptions: (customerId: string) => AsyncIterable<{ id: string; status: string }>; cancelSubscription: (subscriptionId: string) => Promise; }; -export async function stopBillingFor(api: BillingApi, customerIds: string[]) { +export async function stopBillingFor(api: BillingApi, userId: string, knownCustomerIds: string[]) { + // Recorded customers cover the minute Stripe search can lag a new customer; + // search covers customers created before create-checkout recorded them. + const customerIds = new Set(knownCustomerIds); + for await (const customer of api.searchCustomers(userId)) { + if (!customer.deleted && customer.metadata?.user_id === userId) customerIds.add(customer.id); + } + let canceled = 0; - for (const customerId of customerIds) { + for (const customerId of [...customerIds].sort()) { try { // An open Checkout session could otherwise start a subscription after deletion. for await (const session of api.listOpenCheckouts(customerId)) { @@ -96,7 +105,7 @@ export function createHandler(deps: AccountDeps) { throw new HttpError(400, "The confirmation does not match, so the account was not deleted"); } const plan = await deps.deletionPlan(user.id); - const canceledSubscriptions = await deps.stopBilling(plan.stripe_customer_ids); + const canceledSubscriptions = await deps.stopBilling(user.id, plan.stripe_customer_ids); await deps.removeStorageObjects(plan.storage_objects); await deps.prepareDeletion(user.id); await deps.deleteAuthUser(user.id); @@ -137,25 +146,33 @@ function liveDeps(): AccountDeps { return data as DeletionPlan; }, - stopBilling: async (customerIds) => { - if (customerIds.length === 0) return 0; + stopBilling: async (userId, knownCustomerIds) => { const billingError = new HttpError( 502, "Could not cancel your subscription, so your account was not deleted. Please try again.", ); const secretKey = Deno.env.get("STRIPE_SECRET_KEY"); if (!secretKey) throw billingError; + // Only the local full-stack gate sets this, to reach its fake Stripe. + const apiUrl = Deno.env.get("STRIPE_API_URL"); + const endpoint = apiUrl ? new URL(apiUrl) : null; const stripe = new Stripe(secretKey, { apiVersion: "2023-10-16", httpClient: Stripe.createFetchHttpClient(), + ...(endpoint && { + host: endpoint.hostname, + port: endpoint.port || (endpoint.protocol === "http:" ? "80" : "443"), + protocol: endpoint.protocol.replace(":", "") as "http" | "https", + }), }); try { return await stopBillingFor({ + searchCustomers: (user) => stripe.customers.search({ query: `metadata['user_id']:'${user}'`, limit: 100 }), listOpenCheckouts: (customer) => stripe.checkout.sessions.list({ customer, status: "open", limit: 100 }), expireCheckout: (sessionId) => stripe.checkout.sessions.expire(sessionId), listSubscriptions: (customer) => stripe.subscriptions.list({ customer, status: "all", limit: 100 }), cancelSubscription: (subscriptionId) => stripe.subscriptions.cancel(subscriptionId), - }, customerIds); + }, userId, knownCustomerIds); } catch (error) { console.error("[ACCOUNT] Stripe cancellation failed", (error as { code?: string }).code ?? "unknown"); throw billingError; diff --git a/supabase/functions/account/index_test.ts b/supabase/functions/account/index_test.ts index 4d5342c2e..f39e4fbcc 100644 --- a/supabase/functions/account/index_test.ts +++ b/supabase/functions/account/index_test.ts @@ -28,8 +28,8 @@ function harness(overrides: Partial = {}) { storage_objects: [{ bucket_id: "files", name: `${userId}/a.pdf` }], }); }, - stopBilling: (customerIds) => { - calls.push(`cancel:${customerIds.join(",")}`); + stopBilling: (userId, customerIds) => { + calls.push(`billing:${userId}:${customerIds.join(",")}`); return Promise.resolve(1); }, removeStorageObjects: (objects) => { @@ -70,7 +70,7 @@ Deno.test("delete removes only the caller's account, after billing and files", a assertEquals(await response.json(), { deleted: true, canceledSubscriptions: 1 }); assertEquals(calls, [ `plan:${caller.id}`, - "cancel:cus_1", + `billing:${caller.id}:cus_1`, `storage:${caller.id}/a.pdf`, `prepare:${caller.id}`, `delete:${caller.id}`, @@ -155,11 +155,12 @@ Deno.test("billing stops open checkouts, then every live subscription, and skips cus_2: [{ id: "sub_past_due", status: "past_due" }, { id: "sub_trial", status: "trialing" }], }; const count = await stopBillingFor({ + searchCustomers: () => iterate([]), listOpenCheckouts: (customerId) => iterate(customerId === "cus_2" ? [{ id: "cs_open" }] : []), expireCheckout: (id) => Promise.resolve(actions.push(`expire:${id}`)), listSubscriptions: (customerId) => iterate(subscriptions[customerId]), cancelSubscription: (id) => Promise.resolve(actions.push(`cancel:${id}`)), - }, ["cus_1", "cus_2"]); + }, "user-1", ["cus_2", "cus_1"]); assertEquals(count, 3); assertEquals(actions, ["cancel:sub_active", "expire:cs_open", "cancel:sub_past_due", "cancel:sub_trial"]); }); @@ -171,11 +172,46 @@ Deno.test("billing skips customers Stripe already deleted but fails on other err [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(error) }), }); const api = (error: Error) => ({ + searchCustomers: () => iterate([]), listOpenCheckouts: () => failing(error), expireCheckout: () => Promise.resolve(), listSubscriptions: () => iterate([]), cancelSubscription: () => Promise.resolve(), }); - assertEquals(await stopBillingFor(api(missing), ["cus_gone"]), 0); - await assertRejects(() => stopBillingFor(api(outage), ["cus_1"]), Error, "Stripe is down"); + assertEquals(await stopBillingFor(api(missing), "user-1", ["cus_gone"]), 0); + await assertRejects(() => stopBillingFor(api(outage), "user-1", ["cus_1"]), Error, "Stripe is down"); +}); + +Deno.test("billing also stops customers Stripe finds for the user that the database never recorded", async () => { + const searched: string[] = []; + const canceled: string[] = []; + const count = await stopBillingFor({ + searchCustomers: (userId) => { + searched.push(userId); + return iterate([ + { id: "cus_unrecorded", metadata: { user_id: "user-1" } }, + { id: "cus_recorded", metadata: { user_id: "user-1" } }, + { id: "cus_someone_else", metadata: { user_id: "user-2" } }, + { id: "cus_deleted", deleted: true, metadata: { user_id: "user-1" } }, + ]); + }, + listOpenCheckouts: () => iterate([]), + expireCheckout: () => Promise.resolve(), + listSubscriptions: (customerId) => iterate([{ id: `sub_${customerId}`, status: "active" }]), + cancelSubscription: (id) => Promise.resolve(canceled.push(id)), + }, "user-1", ["cus_recorded"]); + assertEquals(searched, ["user-1"]); + assertEquals(count, 2); + assertEquals(canceled, ["sub_cus_recorded", "sub_cus_unrecorded"]); +}); + +Deno.test("a failed Stripe search stops deletion", async () => { + const outage = Object.assign(new Error("Stripe is down"), { code: "api_error" }); + await assertRejects(() => stopBillingFor({ + searchCustomers: () => ({ [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(outage) }) }), + listOpenCheckouts: () => iterate([]), + expireCheckout: () => Promise.resolve(), + listSubscriptions: () => iterate([]), + cancelSubscription: () => Promise.resolve(), + }, "user-1", []), Error, "Stripe is down"); }); diff --git a/supabase/migrations/20261001100000_account_deletion_and_export.sql b/supabase/migrations/20261001100000_account_deletion_and_export.sql index 72374f9c8..2feb8f38b 100644 --- a/supabase/migrations/20261001100000_account_deletion_and_export.sql +++ b/supabase/migrations/20261001100000_account_deletion_and_export.sql @@ -86,9 +86,11 @@ BEGIN END; $$; --- Everything the user owns, as one JSON document. Rows are found through every --- single-column foreign key into auth.users, so a new user-owned table is --- exported without changing this function. +-- Everything the user owns, as one JSON document. Rows are found through +-- single-column foreign keys into auth.users named for ownership, so a new +-- user-owned table is exported without changing this function. Columns that +-- only mention the user, such as actor_id or resolved_by, would pull in rows +-- other people own, so they never select a row. CREATE FUNCTION public.export_account_data(p_user_id uuid) RETURNS jsonb LANGUAGE plpgsql @@ -102,6 +104,7 @@ DECLARE -- Admin catalog work rather than the user's personal data, and admin grants, -- which RLS hides from the user and which are exported below without notes. v_skipped constant text[] := ARRAY['admin_grants', 'curated_sources', 'demo_access_codes', 'global_prompts', 'promo_codes']; + v_ownership constant name[] := ARRAY['user_id', 'owner_id', 'author_id', 'created_by', 'recipient_id']::name[]; v_account jsonb; v_data jsonb := '{}'::jsonb; v_rows jsonb; @@ -137,6 +140,7 @@ BEGIN AND cardinality(fk.conkey) = 1 AND schema.nspname = 'public' AND NOT owner.relname = ANY (v_skipped) + AND reference.attname = ANY (v_ownership) GROUP BY owner.oid, owner.relname ORDER BY owner.relname LOOP @@ -186,11 +190,14 @@ BEGIN v_data := v_data || jsonb_build_object('admin_announcements', v_rows); END IF; - -- Announcements addressed to the user, without the other recipients. + -- Announcements published to the user, without the other recipients. Like + -- the read policy, this hides scheduled and withdrawn ones. SELECT jsonb_agg(to_jsonb(announcement) - 'target_user_ids'::text - 'created_by'::text) INTO v_rows FROM public.admin_announcements AS announcement - WHERE p_user_id = ANY (announcement.target_user_ids); + WHERE p_user_id = ANY (announcement.target_user_ids) + AND announcement.starts_at <= now() + AND announcement.deactivated_at IS NULL; IF v_rows IS NOT NULL THEN v_data := v_data || jsonb_build_object('announcements_received', v_rows); END IF; diff --git a/supabase/tests/account_deletion_test.sql b/supabase/tests/account_deletion_test.sql index 96ea75ed4..1cbcfad43 100644 --- a/supabase/tests/account_deletion_test.sql +++ b/supabase/tests/account_deletion_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(26); +SELECT plan(27); -- The leaver deletes their account; the stayer shares workspaces, announcements, -- and grants with them and must keep everything of their own. @@ -136,6 +136,8 @@ INSERT INTO public.admin_announcements (id, title, body, target_type, target_use ('ac500000-0000-4000-8000-000000000001', 'By leaver', 'Body', 'users', ARRAY['ac000000-0000-4000-8000-00000000000b']::uuid[], 'ac000000-0000-4000-8000-00000000000a'), ('ac500000-0000-4000-8000-000000000002', 'Only leaver', 'Body', 'users', ARRAY['ac000000-0000-4000-8000-00000000000a']::uuid[], 'ac000000-0000-4000-8000-00000000000b'), ('ac500000-0000-4000-8000-000000000003', 'Both', 'Body', 'users', ARRAY['ac000000-0000-4000-8000-00000000000a', 'ac000000-0000-4000-8000-00000000000b']::uuid[], 'ac000000-0000-4000-8000-00000000000b'); +INSERT INTO public.admin_announcements (id, title, body, target_type, target_user_ids, created_by, starts_at) VALUES + ('ac500000-0000-4000-8000-000000000004', 'SCHEDULED-ANNOUNCEMENT', 'Body', 'users', ARRAY['ac000000-0000-4000-8000-00000000000a', 'ac000000-0000-4000-8000-00000000000b']::uuid[], 'ac000000-0000-4000-8000-00000000000b', now() + interval '1 day'); INSERT INTO public.user_announcement_dismissals (announcement_id, user_id) VALUES ('ac500000-0000-4000-8000-000000000003', 'ac000000-0000-4000-8000-00000000000a'); @@ -255,11 +257,20 @@ SELECT is_empty( FROM pg_constraint AS fk JOIN pg_class AS owner ON owner.oid = fk.conrelid JOIN pg_namespace AS schema ON schema.oid = owner.relnamespace + JOIN pg_attribute AS reference ON reference.attrelid = fk.conrelid AND reference.attnum = fk.conkey[1] WHERE fk.contype = 'f' AND fk.confrelid = 'auth.users'::regclass AND schema.nspname = 'public' + AND reference.attname IN ('user_id', 'owner_id', 'author_id', 'created_by', 'recipient_id') ) AS tables WHERE NOT (current_setting('test.export')::jsonb -> 'data') ? fk_table AND fk_table NOT IN ('admin_grants', 'curated_sources', 'demo_access_codes', 'global_prompts', 'promo_codes')$$, - 'the export has a section for every table that references the user, apart from admin catalog work and grants' + 'the export has a section for every table the user owns rows in, apart from admin catalog work and grants' +); +SELECT ok( + current_setting('test.export')::jsonb #> '{data,workspace_comments}' @> '[{"body": "Leaver comment"}]' + AND NOT current_setting('test.export')::jsonb #> '{data,workspace_comments}' @> '[{"body": "Stayer comment"}]' + AND NOT (current_setting('test.export')::jsonb -> 'data') ? 'workspace_audit_events' + AND current_setting('test.export') NOT LIKE '%SCHEDULED-ANNOUNCEMENT%', + 'a comment the user only resolved, audit events naming them as actor, and scheduled announcements stay out of the export' ); SELECT ok( (current_setting('test.export')::jsonb -> 'data') ?& ARRAY['messages', 'announcements_received', 'admin_announcements'], @@ -352,7 +363,7 @@ SELECT is( ); SELECT is( (SELECT array_agg(title ORDER BY title) FROM public.admin_announcements), - ARRAY['Both', 'By leaver'], + ARRAY['Both', 'By leaver', 'SCHEDULED-ANNOUNCEMENT'], 'an announcement for only the leaver is deleted; others lose the leaver as recipient or author' ); SELECT is( From 894c063773129af891c2b8bc87f36b403e4ba2ac Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 13:15:29 +0000 Subject: [PATCH 4/6] fix(account): respect workspace membership and finish billing cleanup - Export workspace rows only while the user is still a member of that workspace, as the read policies require. A removed member could otherwise download shared work edited after their removal. - Treat resource_missing as "customer gone" only when listing. A session or subscription that disappears mid-cleanup is now skipped on its own, so the customer's other subscriptions still get canceled. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 2 +- supabase/functions/account/index.ts | 52 ++++++++++++++----- supabase/functions/account/index_test.ts | 31 +++++++++++ ...1001100000_account_deletion_and_export.sql | 19 +++++-- supabase/tests/account_deletion_test.sql | 25 ++++++++- 5 files changed, 109 insertions(+), 20 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 2c25524cb..69848793d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ Do not infer current schema from old README examples or store query names. Repla ## Account export and deletion -Settings offers three controls. "Download my data" calls the `account` Edge Function, which returns `export_account_data`, which holds the auth profile, every row whose ownership column (`user_id`, `owner_id`, `author_id`, `created_by`, or `recipient_id`) references the user, chat messages, published announcements addressed to the user, and announcements they wrote. Columns that only mention the user, such as `actor_id` or `resolved_by`, never select a row. It leaves out credential and integrity columns and admin catalog work (promo codes, curated sources, global prompts, demo codes). Admin grants appear as grant type and dates only, because RLS hides their notes and author from the user. "Delete All My Data" calls `clear_current_user_data` and keeps the account. "Delete account" asks the user to type their email (or DELETE for an account without one), then calls the `account` function. +Settings offers three controls. "Download my data" calls the `account` Edge Function, which returns `export_account_data`, which holds the auth profile, every row whose ownership column (`user_id`, `owner_id`, `author_id`, `created_by`, or `recipient_id`) references the user, chat messages, published announcements addressed to the user, and announcements they wrote. Columns that only mention the user, such as `actor_id` or `resolved_by`, never select a row, and workspace rows are exported only while the user is still a member, as their read policies require. It leaves out credential and integrity columns and admin catalog work (promo codes, curated sources, global prompts, demo codes). Admin grants appear as grant type and dates only, because RLS hides their notes and author from the user. "Delete All My Data" calls `clear_current_user_data` and keeps the account. "Delete account" asks the user to type their email (or DELETE for an account without one), then calls the `account` function. Deletion runs in a fixed order, and a failure at any step leaves the account in place: diff --git a/supabase/functions/account/index.ts b/supabase/functions/account/index.ts index 9ae7b84fa..5b416fc30 100644 --- a/supabase/functions/account/index.ts +++ b/supabase/functions/account/index.ts @@ -47,6 +47,32 @@ export type BillingApi = { cancelSubscription: (subscriptionId: string) => Promise; }; +function isMissing(error: unknown) { + return (error as { code?: string }).code === "resource_missing"; +} + +async function listUnlessCustomerMissing(items: AsyncIterable): Promise { + const listed: T[] = []; + try { + for await (const item of items) listed.push(item); + } catch (error) { + if (isMissing(error)) return null; + throw error; + } + return listed; +} + +/** Runs one Stripe change; a session or subscription that has disappeared needs none. */ +async function unlessAlreadyGone(change: () => Promise) { + try { + await change(); + return true; + } catch (error) { + if (isMissing(error)) return false; + throw error; + } +} + export async function stopBillingFor(api: BillingApi, userId: string, knownCustomerIds: string[]) { // Recorded customers cover the minute Stripe search can lag a new customer; // search covers customers created before create-checkout recorded them. @@ -57,20 +83,18 @@ export async function stopBillingFor(api: BillingApi, userId: string, knownCusto let canceled = 0; for (const customerId of [...customerIds].sort()) { - try { - // An open Checkout session could otherwise start a subscription after deletion. - for await (const session of api.listOpenCheckouts(customerId)) { - await api.expireCheckout(session.id); - } - for await (const subscription of api.listSubscriptions(customerId)) { - if (ENDED_SUBSCRIPTION_STATUSES.has(subscription.status)) continue; - await api.cancelSubscription(subscription.id); - canceled += 1; - } - } catch (error) { - // A customer already deleted in Stripe has nothing left to bill. - if ((error as { code?: string }).code === "resource_missing") continue; - throw error; + // Listing reports resource_missing only when the customer itself is gone, + // and a deleted customer has nothing left to bill. + const sessions = await listUnlessCustomerMissing(api.listOpenCheckouts(customerId)); + if (sessions === null) continue; + // An open Checkout session could otherwise start a subscription after deletion. + for (const session of sessions) await unlessAlreadyGone(() => api.expireCheckout(session.id)); + + const subscriptions = await listUnlessCustomerMissing(api.listSubscriptions(customerId)); + if (subscriptions === null) continue; + for (const subscription of subscriptions) { + if (ENDED_SUBSCRIPTION_STATUSES.has(subscription.status)) continue; + if (await unlessAlreadyGone(() => api.cancelSubscription(subscription.id))) canceled += 1; } } return canceled; diff --git a/supabase/functions/account/index_test.ts b/supabase/functions/account/index_test.ts index f39e4fbcc..dd9c7ca7a 100644 --- a/supabase/functions/account/index_test.ts +++ b/supabase/functions/account/index_test.ts @@ -215,3 +215,34 @@ Deno.test("a failed Stripe search stops deletion", async () => { cancelSubscription: () => Promise.resolve(), }, "user-1", []), Error, "Stripe is down"); }); + +Deno.test("a subscription that disappears mid-cancellation does not skip the customer's others", async () => { + const gone = Object.assign(new Error("No such subscription"), { code: "resource_missing" }); + const attempted: string[] = []; + const count = await stopBillingFor({ + searchCustomers: () => iterate([]), + listOpenCheckouts: () => iterate([{ id: "cs_gone" }, { id: "cs_open" }]), + expireCheckout: (id) => { + attempted.push(`expire:${id}`); + return id === "cs_gone" ? Promise.reject(gone) : Promise.resolve(); + }, + listSubscriptions: () => iterate([{ id: "sub_gone", status: "active" }, { id: "sub_live", status: "active" }]), + cancelSubscription: (id) => { + attempted.push(`cancel:${id}`); + return id === "sub_gone" ? Promise.reject(gone) : Promise.resolve(); + }, + }, "user-1", ["cus_1"]); + assertEquals(attempted, ["expire:cs_gone", "expire:cs_open", "cancel:sub_gone", "cancel:sub_live"]); + assertEquals(count, 1); +}); + +Deno.test("a failed cancellation stops deletion", async () => { + const declined = Object.assign(new Error("Stripe is down"), { code: "api_error" }); + await assertRejects(() => stopBillingFor({ + searchCustomers: () => iterate([]), + listOpenCheckouts: () => iterate([]), + expireCheckout: () => Promise.resolve(), + listSubscriptions: () => iterate([{ id: "sub_live", status: "active" }]), + cancelSubscription: () => Promise.reject(declined), + }, "user-1", ["cus_1"]), Error, "Stripe is down"); +}); diff --git a/supabase/migrations/20261001100000_account_deletion_and_export.sql b/supabase/migrations/20261001100000_account_deletion_and_export.sql index 2feb8f38b..019b79463 100644 --- a/supabase/migrations/20261001100000_account_deletion_and_export.sql +++ b/supabase/migrations/20261001100000_account_deletion_and_export.sql @@ -130,7 +130,16 @@ BEGIN SELECT owner.oid::regclass AS relation, owner.relname AS name, - string_agg(format('%I = $1', reference.attname), ' OR ' ORDER BY reference.attname) AS predicate + string_agg(format('%I = $1', reference.attname), ' OR ' ORDER BY reference.attname) AS predicate, + -- Workspace rows are shared work. Like their read policies, export them + -- only while the user is still a member of that workspace. + CASE + WHEN owner.relname = 'workspaces' THEN 'id' + WHEN EXISTS ( + SELECT 1 FROM pg_attribute AS scope + WHERE scope.attrelid = owner.oid AND scope.attname = 'workspace_id' AND NOT scope.attisdropped + ) THEN 'workspace_id' + END AS workspace_column FROM pg_constraint AS fk JOIN pg_class AS owner ON owner.oid = fk.conrelid JOIN pg_namespace AS schema ON schema.oid = owner.relnamespace @@ -145,9 +154,13 @@ BEGIN ORDER BY owner.relname LOOP EXECUTE format( - 'SELECT jsonb_agg(to_jsonb(owned) - $2) FROM %s AS owned WHERE %s', + 'SELECT jsonb_agg(to_jsonb(owned) - $2) FROM %s AS owned WHERE (%s)%s', v_table.relation, - v_table.predicate + v_table.predicate, + CASE WHEN v_table.workspace_column IS NULL THEN '' ELSE format( + ' AND EXISTS (SELECT 1 FROM public.workspace_members AS member WHERE member.workspace_id = owned.%I AND member.user_id = $1)', + v_table.workspace_column + ) END ) INTO v_rows USING p_user_id, v_redacted; diff --git a/supabase/tests/account_deletion_test.sql b/supabase/tests/account_deletion_test.sql index 1cbcfad43..86ae9f9e4 100644 --- a/supabase/tests/account_deletion_test.sql +++ b/supabase/tests/account_deletion_test.sql @@ -213,6 +213,25 @@ SELECT public.create_workspace_version( ); RESET ROLE; +-- A workspace the leaver contributed to and was then removed from. +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'ac000000-0000-4000-8000-00000000000b', true); +SELECT public.create_workspace('Former motion', 'Former workspace', 'en', 'ac600000-0000-4000-8000-00000000000c'); +RESET ROLE; +SELECT set_config('test.former_workspace', (SELECT id::text FROM public.workspaces WHERE create_request_id = 'ac600000-0000-4000-8000-00000000000c'), true); +INSERT INTO public.workspace_members (workspace_id, user_id) + VALUES (current_setting('test.former_workspace')::uuid, 'ac000000-0000-4000-8000-00000000000a'); +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'ac000000-0000-4000-8000-00000000000a', true); +SELECT public.save_workspace_source( + current_setting('test.former_workspace')::uuid, 'ac700000-0000-4000-8000-00000000000c', + 'FORMER-MEMBER-SOURCE', 'Shared evidence', 'text', '{}', '{}' +); +RESET ROLE; +DELETE FROM public.workspace_members + WHERE workspace_id = current_setting('test.former_workspace')::uuid + AND user_id = 'ac000000-0000-4000-8000-00000000000a'; + -- Something outside Postgres for the plan to report. INSERT INTO storage.buckets (id, name) VALUES ('account-test', 'account-test'); INSERT INTO storage.objects (bucket_id, name, owner_id) VALUES @@ -269,8 +288,10 @@ SELECT ok( current_setting('test.export')::jsonb #> '{data,workspace_comments}' @> '[{"body": "Leaver comment"}]' AND NOT current_setting('test.export')::jsonb #> '{data,workspace_comments}' @> '[{"body": "Stayer comment"}]' AND NOT (current_setting('test.export')::jsonb -> 'data') ? 'workspace_audit_events' - AND current_setting('test.export') NOT LIKE '%SCHEDULED-ANNOUNCEMENT%', - 'a comment the user only resolved, audit events naming them as actor, and scheduled announcements stay out of the export' + AND current_setting('test.export') NOT LIKE '%SCHEDULED-ANNOUNCEMENT%' + AND current_setting('test.export') LIKE '%Leaver evidence%' + AND current_setting('test.export') NOT LIKE '%FORMER-MEMBER-SOURCE%', + 'the export leaves out a comment the user only resolved, audit events naming them, scheduled announcements, and work in a workspace they left' ); SELECT ok( (current_setting('test.export')::jsonb -> 'data') ?& ARRAY['messages', 'announcements_received', 'admin_announcements'], From 58b85cb409d41f4daa2a81d9812212ec17c7ec1a Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 16:20:09 +0000 Subject: [PATCH 5/6] fix(account): clear invite attempts when an account is deleted private.workspace_invite_attempts records the actor's user id without a foreign key, so deleting an account left those rows behind. The deletion test never seeded one, so its "no row mentions the user" check could not see the gap. Seed an attempt and clear it in prepare_account_deletion. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../migrations/20261001100000_account_deletion_and_export.sql | 2 ++ supabase/tests/account_deletion_test.sql | 3 +++ 2 files changed, 5 insertions(+) diff --git a/supabase/migrations/20261001100000_account_deletion_and_export.sql b/supabase/migrations/20261001100000_account_deletion_and_export.sql index 019b79463..fdf32593c 100644 --- a/supabase/migrations/20261001100000_account_deletion_and_export.sql +++ b/supabase/migrations/20261001100000_account_deletion_and_export.sql @@ -83,6 +83,8 @@ BEGIN DELETE FROM public.workspace_threads WHERE created_by = p_user_id; DELETE FROM public.workspace_artifacts WHERE created_by = p_user_id; DELETE FROM public.workspace_sources WHERE created_by = p_user_id; + -- Invite rate-limit history names the actor without a foreign key. + DELETE FROM private.workspace_invite_attempts WHERE actor_id = p_user_id; END; $$; diff --git a/supabase/tests/account_deletion_test.sql b/supabase/tests/account_deletion_test.sql index 86ae9f9e4..781d1ed29 100644 --- a/supabase/tests/account_deletion_test.sql +++ b/supabase/tests/account_deletion_test.sql @@ -158,6 +158,9 @@ INSERT INTO public.workspace_members (workspace_id, user_id) VALUES (current_setting('test.stayer_workspace')::uuid, 'ac000000-0000-4000-8000-00000000000a'); UPDATE public.workspace_documents SET assignee_id = 'ac000000-0000-4000-8000-00000000000a' WHERE id = current_setting('test.stayer_document')::uuid; +-- Invite redemption attempts name the actor without a foreign key. +INSERT INTO private.workspace_invite_attempts (actor_id, success, failure_reason) + VALUES ('ac000000-0000-4000-8000-00000000000a', false, 'invalid_code'); SET LOCAL ROLE authenticated; SELECT set_config('request.jwt.claim.sub', 'ac000000-0000-4000-8000-00000000000a', true); From ed74820c248eaac6b1a3d997f47f2779df72a770 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 16:24:20 +0000 Subject: [PATCH 6/6] fix(account): keep invite attempts for the IP limit, without the user id Deleting a leaver's invite attempts also deleted the failures the per-IP limit counts, so deleting an account and signing up again reset that limit. Anonymize the rows instead: the actor becomes the nil UUID, the IP failures still count, and the redeem function purges them after a day as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../20261001100000_account_deletion_and_export.sql | 8 ++++++-- supabase/tests/account_deletion_test.sql | 12 +++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/supabase/migrations/20261001100000_account_deletion_and_export.sql b/supabase/migrations/20261001100000_account_deletion_and_export.sql index fdf32593c..80e30dfe2 100644 --- a/supabase/migrations/20261001100000_account_deletion_and_export.sql +++ b/supabase/migrations/20261001100000_account_deletion_and_export.sql @@ -83,8 +83,12 @@ BEGIN DELETE FROM public.workspace_threads WHERE created_by = p_user_id; DELETE FROM public.workspace_artifacts WHERE created_by = p_user_id; DELETE FROM public.workspace_sources WHERE created_by = p_user_id; - -- Invite rate-limit history names the actor without a foreign key. - DELETE FROM private.workspace_invite_attempts WHERE actor_id = p_user_id; + -- Invite attempts name the actor without a foreign key. Keep the rows, + -- because the per-IP limit counts them, but drop the user id; the redeem + -- function purges them after a day. + UPDATE private.workspace_invite_attempts + SET actor_id = '00000000-0000-0000-0000-000000000000' + WHERE actor_id = p_user_id; END; $$; diff --git a/supabase/tests/account_deletion_test.sql b/supabase/tests/account_deletion_test.sql index 781d1ed29..d68c77fc2 100644 --- a/supabase/tests/account_deletion_test.sql +++ b/supabase/tests/account_deletion_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(27); +SELECT plan(28); -- The leaver deletes their account; the stayer shares workspaces, announcements, -- and grants with them and must keep everything of their own. @@ -159,8 +159,8 @@ INSERT INTO public.workspace_members (workspace_id, user_id) VALUES UPDATE public.workspace_documents SET assignee_id = 'ac000000-0000-4000-8000-00000000000a' WHERE id = current_setting('test.stayer_document')::uuid; -- Invite redemption attempts name the actor without a foreign key. -INSERT INTO private.workspace_invite_attempts (actor_id, success, failure_reason) - VALUES ('ac000000-0000-4000-8000-00000000000a', false, 'invalid_code'); +INSERT INTO private.workspace_invite_attempts (actor_id, ip_hash, success, failure_reason) + VALUES ('ac000000-0000-4000-8000-00000000000a', decode(repeat('ac', 32), 'hex'), false, 'unavailable'); SET LOCAL ROLE authenticated; SELECT set_config('request.jwt.claim.sub', 'ac000000-0000-4000-8000-00000000000a', true); @@ -365,6 +365,12 @@ SELECT is_empty( $$SELECT * FROM pg_temp.tables_mentioning('ac000000-0000-4000-8000-00000000000a')$$, 'after deletion no row in any public or private table contains the user id' ); +SELECT is( + (SELECT count(*)::int FROM private.workspace_invite_attempts + WHERE ip_hash = decode(repeat('ac', 32), 'hex') AND success = false), + 1, + 'failed invite attempts still count toward the IP limit after the account is gone' +); SELECT is( (SELECT array_agg(title ORDER BY title) FROM public.sources), ARRAY['Stayer study'],