diff --git a/.superset/db-gate.sh b/.superset/db-gate.sh index cde8bf80a..0d2055c3a 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/.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 51a65c578..787fb2683 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`, 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: + +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. + +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 f4d43e000..d7ffcaa5b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -172,7 +172,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: @@ -183,12 +183,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..5a963d67f --- /dev/null +++ b/e2e/full-stack/account-deletion.spec.ts @@ -0,0 +1,143 @@ +import { readFile } from "node:fs/promises"; +import pg from "pg"; +import type { Page } from "@playwright/test"; +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)"); + +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([]); + // 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); + 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/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/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 105090d97..005bfbdde 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -397,7 +397,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", @@ -1552,7 +1566,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", @@ -1564,7 +1578,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 027e9ae0c..3ea1cff20 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -397,7 +397,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", @@ -1552,7 +1566,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", @@ -1564,7 +1578,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 a7215a078..7e1fc411d 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -397,7 +397,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", @@ -1552,7 +1566,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", @@ -1564,7 +1578,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 c03f9640e..148e15e46 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'; @@ -25,6 +25,7 @@ import { getActiveEnv } from '@/lib/supabase/env'; 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 { EnvironmentSwitcher } from '@/components/features/settings/EnvironmentSwitcher'; import { formatEuropeanDate } from '@/lib/date-format'; @@ -52,6 +53,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], @@ -71,6 +79,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') }); @@ -78,10 +93,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') }); @@ -91,6 +103,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()) @@ -258,6 +307,16 @@ export default function SettingsPage() { {t('settings.signOut')} +
+
+

{t('settings.yourData')}

+

{t('settings.yourDataDesc')}

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

{t('settings.deleteAccountDesc')}

+ +
@@ -342,6 +408,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 2133e3882..205fd70a5 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -52,10 +52,11 @@ 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 -- `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. @@ -144,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/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..5b416fc30 --- /dev/null +++ b/supabase/functions/account/index.ts @@ -0,0 +1,242 @@ +// 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 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"; +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; + /** 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; + 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 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; +}; + +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. + 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].sort()) { + // 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; +} + +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.stopBilling(user.id, 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; + }, + + 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), + }, userId, knownCustomerIds); + } 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..dd9c7ca7a --- /dev/null +++ b/supabase/functions/account/index_test.ts @@ -0,0 +1,248 @@ +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 { createHandler, stopBillingFor } = 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` }], + }); + }, + stopBilling: (userId, customerIds) => { + calls.push(`billing:${userId}:${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}`, + `billing:${caller.id}: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({ + stopBilling: () => 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, []); +}); + +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" }, + { 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 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}`)), + }, "user-1", ["cus_2", "cus_1"]); + assertEquals(count, 3); + assertEquals(actions, ["cancel:sub_active", "expire:cs_open", "cancel:sub_past_due", "cancel:sub_trial"]); +}); + +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) => ({ + searchCustomers: () => iterate([]), + listOpenCheckouts: () => failing(error), + expireCheckout: () => Promise.resolve(), + listSubscriptions: () => iterate([]), + cancelSubscription: () => Promise.resolve(), + }); + 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"); +}); + +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/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 new file mode 100644 index 000000000..80e30dfe2 --- /dev/null +++ b/supabase/migrations/20261001100000_account_deletion_and_export.sql @@ -0,0 +1,270 @@ +-- 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; + +-- 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 +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; + -- 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; +$$; + +-- 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 +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']; + -- 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; + 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, + -- 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 + 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' + AND NOT owner.relname = ANY (v_skipped) + AND reference.attname = ANY (v_ownership) + 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)%s', + v_table.relation, + 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; + + IF v_rows IS NOT NULL THEN + v_data := v_data || jsonb_build_object(v_table.name, v_rows); + 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 + 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 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) + 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; + + 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, 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 +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT jsonb_build_object( + 'stripe_customer_ids', ( + 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 + -- 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 + ) + ); +$$; + +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..d68c77fc2 --- /dev/null +++ b/supabase/tests/account_deletion_test.sql @@ -0,0 +1,417 @@ +BEGIN; +SELECT plan(28); + +-- 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'); +-- 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', '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', '{}', '{}'); +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'); +-- 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) + 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, 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 + ('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'); + +-- 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; +-- Invite redemption attempts name the actor without a foreign key. +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); +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; + +-- 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 + ('account-test', 'leaver/notes.txt', 'ac000000-0000-4000-8000-00000000000a'), + ('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', 'cus_leaver_checkout'), + 'storage_objects', jsonb_build_array( + jsonb_build_object('bucket_id', 'account-test', 'name', 'leaver/notes.txt') + ) + ), + 'the plan lists every Stripe customer and only Storage objects the leaver uploaded, not one a source_files key names' +); +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 + 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 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%' + 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'], + '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 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, + '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 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'], + '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', 'SCHEDULED-ANNOUNCEMENT'], + '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;