From dffebf786a968db5fbadffe3cd8e671a6574f30a Mon Sep 17 00:00:00 2001 From: CodeKnight Date: Thu, 4 Jun 2026 13:57:16 -0500 Subject: [PATCH 1/8] =?UTF-8?q?docs(spec):=20account-deletion=20completene?= =?UTF-8?q?ss=20=E2=80=94=20events=20cascade=20+=20regression=20guard=20(w?= =?UTF-8?q?ar-room=20#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit found deletion is sound (profiles-cascade chokepoint) except events, which SET-NULLs instead of deleting. Fix: events.user_id -> ON DELETE CASCADE. Add a gated schema-completeness test (pg + SUPABASE_DB_URL, skips in CI) + one-time MCP proof + external-processor GDPR follow-up docs. Co-Authored-By: Claude Opus 4.8 --- ...6-05-30-account-deletion-cascade-design.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 ai-form-coach/docs/superpowers/specs/2026-05-30-account-deletion-cascade-design.md diff --git a/ai-form-coach/docs/superpowers/specs/2026-05-30-account-deletion-cascade-design.md b/ai-form-coach/docs/superpowers/specs/2026-05-30-account-deletion-cascade-design.md new file mode 100644 index 0000000..0c9c623 --- /dev/null +++ b/ai-form-coach/docs/superpowers/specs/2026-05-30-account-deletion-cascade-design.md @@ -0,0 +1,131 @@ +# Account Deletion Completeness β€” `events` cascade + regression guard (war-room #14) + +**Date:** 2026-05-30 +**Branch:** `fix/account-deletion-cascade` +**War-room concern:** #14 β€” Account deletion not E2E tested (GDPR: risk of incomplete deletion). +**Goal:** close the one real deletion gap (`events` anonymizes instead of deletes), add a durable regression guard that deletion stays complete, and flip #14 β†’ 🟒. + +## Background & audit (Supabase project `kqmjhjtfogplhmzzbxnw`) + +Account deletion: `DeleteAccountModal` β†’ `POST /api/auth/delete-account` β†’ +`getSupabaseServiceClient().auth.admin.deleteUser(user.id)`. Completeness depends +entirely on FK cascade behavior. + +A full schema audit (every public base table with a `user_id` column, 42 tables) +found the cascade design is **sound**: `profiles.id β†’ auth.users(id) ON DELETE +CASCADE` is the chokepoint, and every user table cascades to `auth.users` either +directly or via `profiles` β€” **with one exception**: + +- **`events.user_id β†’ profiles ON DELETE SET NULL`** (261 rows). On deletion the + event rows are kept with `user_id`/`session_id` nulled (anonymized) rather than + deleted. + +(An earlier, flawed pass that only checked *direct* FKs to `auth.users` wrongly +flagged `sessions`, `user_subscriptions`, etc. as orphaned. They cascade via +`profiles`. Corrected.) + +`events` columns: `id, user_id, name, payload(jsonb), session_id, created_at`. +Because `payload` could in principle hold residual identifiers, the chosen +remediation is a clean delete rather than relying on anonymization. + +## Decision (brainstorm + specialist review, 2026-05-30) + +- **`events` β†’ CASCADE.** Change `events.user_id` FK from `SET NULL` to + `ON DELETE CASCADE` so a deleted user's analytics rows are removed entirely + (unambiguous right-to-erasure; no need to reason about `payload` PII). +- **No other schema change** β€” every other user table already cascades. +- **Regression guard:** a gated schema-completeness test (runs locally / when DB + creds are present; auto-skips in CI which uses placeholder Supabase creds). +- **Status target:** #14 β†’ 🟒 (active deletion complete + guarded). + +## Remediation migration + +`supabase/migrations/10_account_deletion_events_cascade.sql` (applied via Supabase +MCP, then committed β€” same pattern as `09_user_consents.sql`). Idempotent and +re-runnable: + +```sql +-- war-room #14: events analytics must be DELETED on account deletion, +-- not anonymized. Change events.user_id FK from SET NULL to CASCADE. + +-- 1. Scrub any pre-existing orphan events so the new FK validates. +DELETE FROM public.events + WHERE user_id IS NOT NULL + AND user_id NOT IN (SELECT id FROM public.profiles); + +-- 2. Drop whatever FK currently sits on events.user_id (name-agnostic), re-add CASCADE. +DO $$ +DECLARE c text; +BEGIN + SELECT con.conname INTO c + FROM pg_constraint con + WHERE con.conrelid = 'public.events'::regclass + AND con.contype = 'f' + AND con.conkey = ( + SELECT array_agg(att.attnum) + FROM pg_attribute att + WHERE att.attrelid = 'public.events'::regclass AND att.attname = 'user_id' + ); + IF c IS NOT NULL THEN + EXECUTE format('ALTER TABLE public.events DROP CONSTRAINT %I', c); + END IF; +END $$; + +ALTER TABLE public.events + ADD CONSTRAINT events_user_id_fkey + FOREIGN KEY (user_id) REFERENCES public.profiles(id) ON DELETE CASCADE; +``` + +(`events.session_id β†’ sessions ON DELETE SET NULL` is left unchanged β€” that +governs *session* deletion, not account deletion, and is the correct semantics +there.) + +## One-time end-to-end proof (during implementation, via Supabase MCP) + +Inside a single transaction that is **rolled back** (so nothing persists), insert a +synthetic `auth.users` row + `profiles` + `sessions` + `events` for it, `DELETE` +the `auth.users` row, and assert the `sessions` and `events` rows are gone (cascade +chain works). Roll back. This proves the chain live without leaving test data; if a +transactional rollback isn't supported by the SQL tool, do explicit insert β†’ +delete-user β†’ assert β†’ cleanup instead. + +## Regression guard β€” gated schema-completeness test + +`src/__tests__/mvp/accountDeletionCompleteness.test.ts`: + +- Add dev dependency **`pg`** (node-postgres). Connect with + `process.env.SUPABASE_DB_URL` (a direct Postgres connection string). +- `describe.skipIf(!process.env.SUPABASE_DB_URL)(...)` so the suite **auto-skips + in CI** (placeholder creds, no DB URL) and runs locally / pre-release. +- Assertions (querying `pg_constraint`): + 1. `profiles.id` has an `ON DELETE CASCADE` FK to `auth.users` (the chokepoint). + 2. **Every** public base table (`relkind='r'`) with a `user_id` column has an + `ON DELETE CASCADE` FK on `user_id` (to `profiles` or `auth.users`). Fails if + any user table uses `SET NULL` / `NO ACTION` / no FK β€” catching a future + table that forgets to cascade, and confirming the `events` fix. +- Document in the test file how to set `SUPABASE_DB_URL` (Supabase dashboard β†’ + Project Settings β†’ Database β†’ connection string). It is a **secret**: it lives in + gitignored `.env.local`, never committed. + +## Documentation + +- `docs/technical/account-deletion.md` (or a section): the deletion flow, the + profiles-cascade chokepoint, how to run the gated completeness test, and a + **GDPR follow-up list for external processors** not covered by DB cascade: + Stripe (no payments in Beta β€” when added, delete the customer on erasure), + Sentry (PII capture is off), Umami analytics, and Supabase PITR backups (deleted + data ages out of the backup window β€” standard, documented). +- War-room tracker #14 β†’ 🟒 + Resolution Log row. + +## Non-goals + +- Touching the 25 already-cascading user tables (no change needed). +- A real signupβ†’delete Playwright e2e in CI (CI uses placeholder Supabase; the + schema test + one-time MCP proof cover it without that infra change). +- Implementing external-processor erasure now (documented follow-up; Beta has no + payments and Sentry PII is off). + +## Rollout + +Branch `fix/account-deletion-cascade` from `dev`, one purpose. User opens the PR +manually. From 726e7eb924eb0526fdd0e993a31d04de090b4e98 Mon Sep 17 00:00:00 2001 From: CodeKnight Date: Thu, 4 Jun 2026 19:12:16 -0500 Subject: [PATCH 2/8] docs(spec): rev account-deletion #14 after specialist re-review Pivot regression guard from pg+DB-URL to a Postgres account_deletion_completeness() function + service-client RPC test (no new dep, MCP-verifiable). NOT EXISTS orphan scrub. Downgrade one-time proof to structural+admin.createUser. Add CSRF/SameSite note, .env*.local gitignore check, auth.audit_log_entries + Art 15/20 GDPR follow-ups, test:db checklist. Co-Authored-By: Claude Opus 4.8 --- ...6-05-30-account-deletion-cascade-design.md | 169 ++++++++++++------ 1 file changed, 117 insertions(+), 52 deletions(-) diff --git a/ai-form-coach/docs/superpowers/specs/2026-05-30-account-deletion-cascade-design.md b/ai-form-coach/docs/superpowers/specs/2026-05-30-account-deletion-cascade-design.md index 0c9c623..768fcce 100644 --- a/ai-form-coach/docs/superpowers/specs/2026-05-30-account-deletion-cascade-design.md +++ b/ai-form-coach/docs/superpowers/specs/2026-05-30-account-deletion-cascade-design.md @@ -1,9 +1,9 @@ # Account Deletion Completeness β€” `events` cascade + regression guard (war-room #14) -**Date:** 2026-05-30 +**Date:** 2026-05-30 (rev. 2 after specialist re-review) **Branch:** `fix/account-deletion-cascade` **War-room concern:** #14 β€” Account deletion not E2E tested (GDPR: risk of incomplete deletion). -**Goal:** close the one real deletion gap (`events` anonymizes instead of deletes), add a durable regression guard that deletion stays complete, and flip #14 β†’ 🟒. +**Goal:** close the one real deletion gap (`events` anonymizes instead of deletes), add a durable, *verifiable* regression guard that deletion stays complete, and flip #14 β†’ 🟒. ## Background & audit (Supabase project `kqmjhjtfogplhmzzbxnw`) @@ -11,7 +11,7 @@ Account deletion: `DeleteAccountModal` β†’ `POST /api/auth/delete-account` β†’ `getSupabaseServiceClient().auth.admin.deleteUser(user.id)`. Completeness depends entirely on FK cascade behavior. -A full schema audit (every public base table with a `user_id` column, 42 tables) +A full schema audit (every public base table with a `user_id` column β€” 42 tables) found the cascade design is **sound**: `profiles.id β†’ auth.users(id) ON DELETE CASCADE` is the chokepoint, and every user table cascades to `auth.users` either directly or via `profiles` β€” **with one exception**: @@ -20,38 +20,40 @@ directly or via `profiles` β€” **with one exception**: event rows are kept with `user_id`/`session_id` nulled (anonymized) rather than deleted. -(An earlier, flawed pass that only checked *direct* FKs to `auth.users` wrongly -flagged `sessions`, `user_subscriptions`, etc. as orphaned. They cascade via -`profiles`. Corrected.) +(An earlier pass that only checked *direct* FKs to `auth.users` wrongly flagged +`sessions`, `user_subscriptions`, etc. as orphaned β€” they cascade via `profiles`. +Corrected by the second audit.) `events` columns: `id, user_id, name, payload(jsonb), session_id, created_at`. Because `payload` could in principle hold residual identifiers, the chosen remediation is a clean delete rather than relying on anonymization. -## Decision (brainstorm + specialist review, 2026-05-30) +## Decisions (brainstorm + two specialist reviews, 2026-05-30) - **`events` β†’ CASCADE.** Change `events.user_id` FK from `SET NULL` to - `ON DELETE CASCADE` so a deleted user's analytics rows are removed entirely - (unambiguous right-to-erasure; no need to reason about `payload` PII). + `ON DELETE CASCADE`. Unambiguous right-to-erasure; no `payload`-PII reasoning needed. - **No other schema change** β€” every other user table already cascades. -- **Regression guard:** a gated schema-completeness test (runs locally / when DB - creds are present; auto-skips in CI which uses placeholder Supabase creds). -- **Status target:** #14 β†’ 🟒 (active deletion complete + guarded). +- **Regression guard = a Postgres function + service-client RPC test** (NOT a `pg` + dependency). The audit logic lives in `public.account_deletion_completeness()`, + callable from the test via the existing Supabase service client AND verifiable + immediately via the Supabase MCP. No new npm dependency. +- **Status target:** #14 β†’ 🟒 (active deletion complete + guarded + verified). ## Remediation migration `supabase/migrations/10_account_deletion_events_cascade.sql` (applied via Supabase MCP, then committed β€” same pattern as `09_user_consents.sql`). Idempotent and -re-runnable: +re-runnable. It does two things: fix the `events` FK, and create the audit function. ```sql --- war-room #14: events analytics must be DELETED on account deletion, --- not anonymized. Change events.user_id FK from SET NULL to CASCADE. +-- war-room #14: events analytics must be DELETED on account deletion, not +-- anonymized. Change events.user_id FK from SET NULL to CASCADE. -- 1. Scrub any pre-existing orphan events so the new FK validates. -DELETE FROM public.events - WHERE user_id IS NOT NULL - AND user_id NOT IN (SELECT id FROM public.profiles); +-- NOT EXISTS (not NOT IN) to avoid the NULL-subquery footgun. +DELETE FROM public.events e + WHERE e.user_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = e.user_id); -- 2. Drop whatever FK currently sits on events.user_id (name-agnostic), re-add CASCADE. DO $$ @@ -74,56 +76,119 @@ END $$; ALTER TABLE public.events ADD CONSTRAINT events_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.profiles(id) ON DELETE CASCADE; + +-- 3. Audit function: returns any public base table whose user_id column does NOT +-- cascade-delete to profiles/auth.users. Empty result == deletion is complete. +CREATE OR REPLACE FUNCTION public.account_deletion_completeness() +RETURNS TABLE(table_name text, references_table text, on_delete text) +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT cl.relname::text, + COALESCE(rf.relname::text, '(no fk)'), + CASE con.confdeltype WHEN 'c' THEN 'CASCADE' WHEN 'a' THEN 'NO ACTION' + WHEN 'r' THEN 'RESTRICT' WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' ELSE '(no fk)' END + FROM pg_class cl + JOIN pg_namespace ns ON ns.oid = cl.relnamespace AND ns.nspname = 'public' + JOIN pg_attribute a ON a.attrelid = cl.oid AND a.attname = 'user_id' + AND a.attnum > 0 AND NOT a.attisdropped + LEFT JOIN pg_constraint con ON con.conrelid = cl.oid AND con.contype = 'f' + AND a.attnum = ANY(con.conkey) + LEFT JOIN pg_class rf ON rf.oid = con.confrelid + WHERE cl.relkind = 'r' + -- offender == NOT (has a CASCADE fk on user_id to profiles or auth.users) + AND NOT (con.oid IS NOT NULL AND con.confdeltype = 'c' + AND rf.relname IN ('profiles', 'users')); +$$; + +REVOKE EXECUTE ON FUNCTION public.account_deletion_completeness() FROM public; +GRANT EXECUTE ON FUNCTION public.account_deletion_completeness() TO service_role; ``` -(`events.session_id β†’ sessions ON DELETE SET NULL` is left unchanged β€” that -governs *session* deletion, not account deletion, and is the correct semantics -there.) +(`events.session_id β†’ sessions ON DELETE SET NULL` is left unchanged β€” it governs +*session* deletion, not account deletion, and is correct there.) + +**Verification (immediate, via MCP):** after applying, run +`SELECT * FROM public.account_deletion_completeness();` β€” it MUST return **zero +rows** (proves the `events` fix landed and nothing else regressed). -## One-time end-to-end proof (during implementation, via Supabase MCP) +## One-time end-to-end proof (optional, during implementation) -Inside a single transaction that is **rolled back** (so nothing persists), insert a -synthetic `auth.users` row + `profiles` + `sessions` + `events` for it, `DELETE` -the `auth.users` row, and assert the `sessions` and `events` rows are gone (cascade -chain works). Roll back. This proves the chain live without leaving test data; if a -transactional rollback isn't supported by the SQL tool, do explicit insert β†’ -delete-user β†’ assert β†’ cleanup instead. +The cascade is proven *structurally* by the function above (MCP-verified empty). +If a live round-trip is also wanted, use the Supabase **admin API** as a throwaway +script β€” `admin.createUser` β†’ seed a `sessions` + `events` row for it β†’ +`admin.deleteUser` β†’ assert both rows are gone β†’ done. Do NOT hand-insert synthetic +`auth.users` rows via raw SQL (that table has many `NOT NULL`/trigger requirements +and is fiddly). This live proof is belt-and-suspenders, not required for 🟒. -## Regression guard β€” gated schema-completeness test +## Regression guard β€” gated RPC test `src/__tests__/mvp/accountDeletionCompleteness.test.ts`: -- Add dev dependency **`pg`** (node-postgres). Connect with - `process.env.SUPABASE_DB_URL` (a direct Postgres connection string). -- `describe.skipIf(!process.env.SUPABASE_DB_URL)(...)` so the suite **auto-skips - in CI** (placeholder creds, no DB URL) and runs locally / pre-release. -- Assertions (querying `pg_constraint`): - 1. `profiles.id` has an `ON DELETE CASCADE` FK to `auth.users` (the chokepoint). - 2. **Every** public base table (`relkind='r'`) with a `user_id` column has an - `ON DELETE CASCADE` FK on `user_id` (to `profiles` or `auth.users`). Fails if - any user table uses `SET NULL` / `NO ACTION` / no FK β€” catching a future - table that forgets to cascade, and confirming the `events` fix. -- Document in the test file how to set `SUPABASE_DB_URL` (Supabase dashboard β†’ - Project Settings β†’ Database β†’ connection string). It is a **secret**: it lives in - gitignored `.env.local`, never committed. +```ts +import { describe, it, expect } from "vitest"; +import { createClient } from "@supabase/supabase-js"; + +const url = process.env.NEXT_PUBLIC_SUPABASE_URL; +const key = process.env.SUPABASE_SERVICE_ROLE_KEY; +// Runs only with REAL service creds. CI uses placeholders, so it auto-skips. +const canRun = + !!url && !!key && + url.startsWith("https://") && !url.includes("placeholder") && + !key.includes("placeholder"); + +describe.skipIf(!canRun)("account deletion completeness (DB schema)", () => { + it("every user_id table cascade-deletes (no orphans)", async () => { + const supabase = createClient(url!, key!, { auth: { persistSession: false } }); + const { data, error } = await supabase.rpc("account_deletion_completeness"); + expect(error).toBeNull(); + expect(data ?? []).toEqual([]); // any row = a table that won't be deleted + }); +}); +``` + +- Reuses `@supabase/supabase-js` (already a dependency) + the service client. **No + new dependency.** +- Auto-skips in CI (placeholder creds) and runs when real service creds are present + (locally / pre-release). The audit *logic* is independently MCP-verified, so the + guard's correctness does not depend on the test ever executing in our env. +- Catches a future table that forgets to cascade (it would appear in the result). ## Documentation -- `docs/technical/account-deletion.md` (or a section): the deletion flow, the - profiles-cascade chokepoint, how to run the gated completeness test, and a - **GDPR follow-up list for external processors** not covered by DB cascade: - Stripe (no payments in Beta β€” when added, delete the customer on erasure), - Sentry (PII capture is off), Umami analytics, and Supabase PITR backups (deleted - data ages out of the backup window β€” standard, documented). +- `docs/technical/account-deletion.md`: the deletion flow, the `profiles`-cascade + chokepoint, the `account_deletion_completeness()` function + how to run the gated + test (set real `NEXT_PUBLIC_SUPABASE_URL` + `SUPABASE_SERVICE_ROLE_KEY` in + gitignored `.env.local`), and a **GDPR follow-up list for data NOT covered by DB + cascade**: + - `auth.audit_log_entries` (Supabase-managed; may retain email/IP). + - Stripe (no payments in Beta; when added, delete the customer on erasure). + - Sentry (PII capture is off β€” `sendDefaultPii: false`), Umami analytics. + - Supabase PITR backups (deleted data ages out of the backup window β€” standard). + - **Separate future item:** GDPR data *access/portability* (Art. 15/20) β€” not part + of erasure (#14). - War-room tracker #14 β†’ 🟒 + Resolution Log row. +## Security notes (scoped) + +- **CSRF on `/api/auth/delete-account`:** it's a credentialed POST that deletes the + caller's account. Supabase `@supabase/ssr` sets auth cookies `SameSite=Lax` by + default, which blocks cross-site POSTs (`getUser()` β†’ 401), so CSRF is largely + mitigated. The plan will **confirm the cookie SameSite setting** and add a cheap + `Origin`-header check to the route as defense-in-depth. (If this grows, split it + into a separate hardening task β€” it is adjacent to, not core to, #14.) +- **Secret hygiene:** the plan verifies `.env*.local` is gitignored before the DB + service key / connection details are used locally. + ## Non-goals - Touching the 25 already-cascading user tables (no change needed). -- A real signupβ†’delete Playwright e2e in CI (CI uses placeholder Supabase; the - schema test + one-time MCP proof cover it without that infra change). -- Implementing external-processor erasure now (documented follow-up; Beta has no - payments and Sentry PII is off). +- A real signupβ†’delete Playwright e2e in CI (CI uses placeholder Supabase; the RPC + test + MCP verification cover it without that infra change). +- Implementing external-processor erasure or data-access/portability now (documented + follow-ups; Beta has no payments and Sentry PII is off). ## Rollout From 54e4eefc4b0a37fb75f3aca656d2188cf9ca5b52 Mon Sep 17 00:00:00 2001 From: CodeKnight Date: Thu, 4 Jun 2026 19:28:39 -0500 Subject: [PATCH 3/8] docs(plan): account-deletion completeness implementation plan (war-room #14) 5 tasks: MCP migration (events CASCADE + audit fn, verify empty), gated RPC test, same-origin CSRF check, docs + gitignore check, tracker to green. T1/T5 are controller-run (Supabase MCP); T2-T4 subagent-implementable. Co-Authored-By: Claude Opus 4.8 --- .../2026-05-30-account-deletion-cascade.md | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 ai-form-coach/docs/superpowers/plans/2026-05-30-account-deletion-cascade.md diff --git a/ai-form-coach/docs/superpowers/plans/2026-05-30-account-deletion-cascade.md b/ai-form-coach/docs/superpowers/plans/2026-05-30-account-deletion-cascade.md new file mode 100644 index 0000000..86a8f47 --- /dev/null +++ b/ai-form-coach/docs/superpowers/plans/2026-05-30-account-deletion-cascade.md @@ -0,0 +1,287 @@ +# Account Deletion Completeness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make account deletion provably complete β€” change `events.user_id` to `ON DELETE CASCADE`, add an MCP-verifiable `account_deletion_completeness()` audit function + a gated RPC regression test, add a same-origin check to the delete route, document the GDPR follow-ups, and flip war-room #14 β†’ 🟒. + +**Architecture:** One idempotent SQL migration fixes the `events` FK and creates a `SECURITY DEFINER` audit function that returns any user-scoped table that won't cascade-delete (empty == complete). A Vitest test calls that function via the existing Supabase service client (auto-skips without real creds). No new npm dependency. + +**Tech Stack:** Supabase Postgres, `@supabase/supabase-js` (already a dep), Next 16 App Router, Vitest. npm (not pnpm). + +**Branch:** `fix/account-deletion-cascade` (cut from `dev`; spec `726e7eb`). + +**Commit trailer:** every commit ends with a blank line then `Co-Authored-By: Claude Opus 4.8 `. + +> **Controller-run tasks:** Tasks 1 and 5 call the Supabase MCP (apply_migration / execute_sql) and must be run by the controller (the main session), not a subagent β€” subagents cannot reach the MCP. Tasks 2–4 are subagent-implementable. + +--- + +## File Structure + +- **Create** `supabase/migrations/10_account_deletion_events_cascade.sql` β€” events FK fix + audit function. +- **Create** `src/__tests__/mvp/accountDeletionCompleteness.test.ts` β€” gated RPC regression test. +- **Create** `docs/technical/account-deletion.md` β€” flow, chokepoint, how to run the gated test, GDPR follow-ups. +- **Modify** `src/app/api/auth/delete-account/route.ts` β€” same-origin (CSRF) check. +- **Modify** `docs/war-room-v2/11_BETA1_CONCERNS_TRACKER.md` β€” #14 β†’ 🟒. + +--- + +## Task 1 (CONTROLLER / MCP): migration β€” events cascade + audit function + +**Files:** +- Create: `supabase/migrations/10_account_deletion_events_cascade.sql` + +- [ ] **Step 1: Write the migration file** with EXACTLY this content: +```sql +-- war-room #14: events analytics must be DELETED on account deletion, not +-- anonymized. Change events.user_id FK from SET NULL to CASCADE, and add an +-- audit function that proves every user-scoped table cascade-deletes. + +-- 1. Scrub any pre-existing orphan events so the new FK validates (NOT EXISTS +-- avoids the NULL-subquery footgun of NOT IN). +DELETE FROM public.events e + WHERE e.user_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = e.user_id); + +-- 2. Drop whatever FK currently sits on events.user_id (name-agnostic), re-add CASCADE. +DO $$ +DECLARE c text; +BEGIN + SELECT con.conname INTO c + FROM pg_constraint con + WHERE con.conrelid = 'public.events'::regclass + AND con.contype = 'f' + AND con.conkey = ( + SELECT array_agg(att.attnum) + FROM pg_attribute att + WHERE att.attrelid = 'public.events'::regclass AND att.attname = 'user_id' + ); + IF c IS NOT NULL THEN + EXECUTE format('ALTER TABLE public.events DROP CONSTRAINT %I', c); + END IF; +END $$; + +ALTER TABLE public.events + ADD CONSTRAINT events_user_id_fkey + FOREIGN KEY (user_id) REFERENCES public.profiles(id) ON DELETE CASCADE; + +-- 3. Audit function: returns any public base table whose user_id column does NOT +-- cascade-delete to profiles/auth.users. Empty result == deletion is complete. +CREATE OR REPLACE FUNCTION public.account_deletion_completeness() +RETURNS TABLE(table_name text, references_table text, on_delete text) +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT cl.relname::text, + COALESCE(rf.relname::text, '(no fk)'), + CASE con.confdeltype WHEN 'c' THEN 'CASCADE' WHEN 'a' THEN 'NO ACTION' + WHEN 'r' THEN 'RESTRICT' WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' ELSE '(no fk)' END + FROM pg_class cl + JOIN pg_namespace ns ON ns.oid = cl.relnamespace AND ns.nspname = 'public' + JOIN pg_attribute a ON a.attrelid = cl.oid AND a.attname = 'user_id' + AND a.attnum > 0 AND NOT a.attisdropped + LEFT JOIN pg_constraint con ON con.conrelid = cl.oid AND con.contype = 'f' + AND a.attnum = ANY(con.conkey) + LEFT JOIN pg_class rf ON rf.oid = con.confrelid + WHERE cl.relkind = 'r' + AND NOT (con.oid IS NOT NULL AND con.confdeltype = 'c' + AND rf.relname IN ('profiles', 'users')); +$$; + +REVOKE EXECUTE ON FUNCTION public.account_deletion_completeness() FROM public; +GRANT EXECUTE ON FUNCTION public.account_deletion_completeness() TO service_role; +``` + +- [ ] **Step 2: Apply via Supabase MCP** β€” `apply_migration(project_id="kqmjhjtfogplhmzzbxnw", name="account_deletion_events_cascade", query=)`. Expect `{"success": true}`. + +- [ ] **Step 3: Verify via Supabase MCP** β€” `execute_sql("SELECT * FROM public.account_deletion_completeness();")`. + Expected: **zero rows** (proves the `events` fix landed and no other user table is non-cascading). If any row returns, STOP β€” investigate that table before continuing. + +- [ ] **Step 4: Confirm the events FK specifically** β€” `execute_sql` to read `events.user_id` FK on_delete; expect `CASCADE` referencing `profiles`. + +- [ ] **Step 5: Commit the migration file** +```bash +git add supabase/migrations/10_account_deletion_events_cascade.sql +git commit -m "feat(db): events cascade-deletes on account deletion + completeness audit fn (war-room #14)" +``` + +--- + +## Task 2: gated RPC regression test + +**Files:** +- Create: `src/__tests__/mvp/accountDeletionCompleteness.test.ts` + +- [ ] **Step 1: Create the test** with EXACTLY this content: +```ts +import { describe, it, expect } from "vitest"; +import { createClient } from "@supabase/supabase-js"; + +const url = process.env.NEXT_PUBLIC_SUPABASE_URL; +const key = process.env.SUPABASE_SERVICE_ROLE_KEY; +// Runs only with REAL service creds. CI uses placeholders, so it auto-skips. +const canRun = + !!url && !!key && + url.startsWith("https://") && !url.includes("placeholder") && + !key.includes("placeholder"); + +describe.skipIf(!canRun)("account deletion completeness (DB schema)", () => { + it("every user_id table cascade-deletes (no orphans)", async () => { + const supabase = createClient(url!, key!, { auth: { persistSession: false } }); + const { data, error } = await supabase.rpc("account_deletion_completeness"); + expect(error).toBeNull(); + expect(data ?? []).toEqual([]); // any row = a table that won't be deleted + }); +}); +``` + +- [ ] **Step 2: Run it, confirm it SKIPS cleanly** (no real creds in this env): + `npx vitest run src/__tests__/mvp/accountDeletionCompleteness.test.ts` + Expected: the suite is reported skipped (0 failures). A skipped suite is the + correct outcome here β€” the audit logic itself is verified via the MCP in Task 1, + not by this test running locally. Confirm `npm run lint` is clean for the file. + +- [ ] **Step 3: Commit** +```bash +git add src/__tests__/mvp/accountDeletionCompleteness.test.ts +git commit -m "test(db): gated account-deletion completeness RPC guard" +``` + +--- + +## Task 3: same-origin (CSRF) check on the delete route + +**Files:** +- Modify: `src/app/api/auth/delete-account/route.ts` + +Current file (for reference) starts `export async function POST() {` and uses +`getSupabaseServerClient()` then `serviceClient.auth.admin.deleteUser(user.id)`. + +- [ ] **Step 1: Add a same-origin guard.** Change the handler signature to accept + the request and reject cross-site POSTs before doing anything else. Replace: +```ts +export async function POST() { + try { + const supabase = await getSupabaseServerClient(); +``` +with: +```ts +export async function POST(request: Request) { + try { + // Defense-in-depth against CSRF (Supabase cookies are SameSite=Lax, which + // already blocks cross-site POSTs; this rejects any that slip through). + const origin = request.headers.get("origin"); + const host = request.headers.get("host"); + if (origin && host && new URL(origin).host !== host) { + return NextResponse.json({ error: "Invalid origin" }, { status: 403 }); + } + + const supabase = await getSupabaseServerClient(); +``` + Leave the rest of the handler unchanged. + +- [ ] **Step 2: Verify** β€” `npm run lint`, `npx tsc --noEmit` (no new error in this + file), `npm run build` (green). The route still compiles as a POST handler. + +- [ ] **Step 3: Commit** +```bash +git add "src/app/api/auth/delete-account/route.ts" +git commit -m "fix(security): reject cross-origin POST to delete-account (CSRF defense-in-depth)" +``` + +--- + +## Task 4: documentation + secret-hygiene check + +**Files:** +- Create: `docs/technical/account-deletion.md` +- (Check only) `.gitignore` / `ai-form-coach/.gitignore` + +- [ ] **Step 1: Verify `.env*.local` is gitignored** β€” + `git check-ignore .env.local ai-form-coach/.env.local` should print the paths + (meaning they are ignored). If EITHER is not ignored, add a line `.env*.local` + to the appropriate `.gitignore` and note it in the doc. Report what you found. + +- [ ] **Step 2: Write `docs/technical/account-deletion.md`:** +```markdown +# Account Deletion (GDPR right-to-erasure) + +## Flow +`DeleteAccountModal` β†’ `POST /api/auth/delete-account` (same-origin checked) β†’ +`auth.admin.deleteUser(user.id)`. + +## Why it's complete +`profiles.id β†’ auth.users(id) ON DELETE CASCADE` is the chokepoint. Every +user-scoped table cascade-deletes to `auth.users` directly or via `profiles`, so +deleting the auth user removes all of the user's rows. `events.user_id` was changed +from `SET NULL` to `ON DELETE CASCADE` (war-room #14) so analytics are deleted, not +just anonymized. + +## Regression guard +`public.account_deletion_completeness()` returns any user-scoped base table that +would NOT cascade-delete. **Empty result == deletion is complete.** + +- Verify anytime via the Supabase SQL editor / MCP: + `SELECT * FROM public.account_deletion_completeness();` +- Automated: `src/__tests__/mvp/accountDeletionCompleteness.test.ts` calls it via + the service client and asserts empty. It **auto-skips** without real creds (CI + uses placeholders). To run it locally, set real values in gitignored `.env.local`: + `NEXT_PUBLIC_SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` (Supabase dashboard β†’ + Project Settings β†’ API). Run before promoting `dev β†’ main`. + +## Not covered by DB cascade (GDPR follow-ups) +- `auth.audit_log_entries` β€” Supabase-managed; may retain email/IP. +- Stripe β€” no payments in Beta; when added, delete the customer on erasure. +- Sentry β€” PII capture is off (`sendDefaultPii: false`); Umami analytics. +- Supabase PITR backups β€” deleted data ages out of the backup window (standard). +- **Separate future item:** data *access/portability* (GDPR Art. 15/20) β€” not part + of erasure. +``` + +- [ ] **Step 3: Commit** +```bash +git add docs/technical/account-deletion.md +git commit -m "docs(legal): document account-deletion completeness + GDPR follow-ups" +``` +(If a `.gitignore` line was added in Step 1, include it in this commit.) + +--- + +## Task 5 (CONTROLLER): tracker β†’ 🟒, full gate, push + +**Files:** +- Modify: `docs/war-room-v2/11_BETA1_CONCERNS_TRACKER.md` + +- [ ] **Step 1: Flip tracker #14** β€” in `docs/war-room-v2/11_BETA1_CONCERNS_TRACKER.md`, + change concern #14's `**Status:**` line to: + `**Status:** 🟒 resolved (2026-05-30) β€” deletion verified complete (profiles-cascade chokepoint); events now CASCADE-deletes; audit fn + gated test guard regressions` + and append a Resolution Log row: + `| 2026-05-30 | #14 Account deletion | πŸ”΄ β†’ 🟒 | Audit: all user tables cascade via profiles chokepoint; fixed events SET NULL β†’ CASCADE; added account_deletion_completeness() fn (MCP-verified empty) + gated RPC test + same-origin check + GDPR follow-up docs |` + +- [ ] **Step 2: Full local gate** β€” `npm run lint` (clean), `npm run test` (all pass; + the new RPC test skips), `npm run build` (green). + +- [ ] **Step 3: Commit + push** +```bash +git add docs/war-room-v2/11_BETA1_CONCERNS_TRACKER.md +git commit -m "docs(war-room): #14 resolved β€” account deletion verified complete + guarded" +git push -u origin fix/account-deletion-cascade +``` +(Do NOT run `gh pr create`.) + +- [ ] **Step 4: Invoke superpowers:finishing-a-development-branch** and present completion options with a paste-ready PR body. Note for the PR: the migration was already applied to the live DB via MCP (the committed `.sql` documents/reproduces it and is idempotent). + +--- + +## Self-Review + +**Spec coverage:** events β†’ CASCADE migration (T1) βœ“; orphan scrub via NOT EXISTS (T1) βœ“; name-agnostic drop-then-add FK (T1) βœ“; `account_deletion_completeness()` SECURITY DEFINER + REVOKE/GRANT (T1) βœ“; MCP apply + verify-empty (T1 steps 2–4) βœ“; gated RPC test, no new dep (T2) βœ“; same-origin CSRF check (T3) βœ“; `.env*.local` gitignore check (T4) βœ“; docs incl. auth.audit_log_entries + Art 15/20 + how-to-run (T4) βœ“; tracker β†’ 🟒 (T5) βœ“. Non-goals (no pg dep, no CI signupβ†’delete e2e, no external-processor erasure now) respected. + +**Placeholder scan:** no TBD/implement-later; every code/SQL step shows full content. The gated test *skipping* in this env is the documented expected outcome (the logic is MCP-verified in T1), not a gap. + +**Type/identifier consistency:** the function name `account_deletion_completeness` is identical in the migration (T1), the verify query (T1), the RPC test (T2), and the docs (T4). The new FK is named `events_user_id_fkey` consistently. The route stays a `POST` handler (now taking `request: Request`). + +**Controller vs subagent:** T1 and T5 are controller-run (MCP + final gate/push); T2–T4 are subagent-implementable. Marked at top + per task. +``` From 13890eb66a4192b677800c3d5b1721b4922f0850 Mon Sep 17 00:00:00 2001 From: CodeKnight Date: Sun, 7 Jun 2026 12:45:12 -0500 Subject: [PATCH 4/8] feat(db): events cascade-deletes on account deletion + completeness audit fn (war-room #14) Applied to the ai-form-coach Supabase project via MCP. Changes events.user_id FK SET NULL -> ON DELETE CASCADE, and adds account_deletion_completeness() (SECURITY DEFINER) which returns zero rows == every user table cascade-deletes. Verified empty post-apply. Co-Authored-By: Claude Opus 4.8 --- .../10_account_deletion_events_cascade.sql | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 ai-form-coach/supabase/migrations/10_account_deletion_events_cascade.sql diff --git a/ai-form-coach/supabase/migrations/10_account_deletion_events_cascade.sql b/ai-form-coach/supabase/migrations/10_account_deletion_events_cascade.sql new file mode 100644 index 0000000..8abee36 --- /dev/null +++ b/ai-form-coach/supabase/migrations/10_account_deletion_events_cascade.sql @@ -0,0 +1,59 @@ +-- war-room #14: events analytics must be DELETED on account deletion, not +-- anonymized. Change events.user_id FK from SET NULL to CASCADE, and add an +-- audit function that proves every user-scoped table cascade-deletes. + +-- 1. Scrub any pre-existing orphan events so the new FK validates (NOT EXISTS +-- avoids the NULL-subquery footgun of NOT IN). +DELETE FROM public.events e + WHERE e.user_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = e.user_id); + +-- 2. Drop whatever FK currently sits on events.user_id (name-agnostic), re-add CASCADE. +DO $$ +DECLARE c text; +BEGIN + SELECT con.conname INTO c + FROM pg_constraint con + WHERE con.conrelid = 'public.events'::regclass + AND con.contype = 'f' + AND con.conkey = ( + SELECT array_agg(att.attnum) + FROM pg_attribute att + WHERE att.attrelid = 'public.events'::regclass AND att.attname = 'user_id' + ); + IF c IS NOT NULL THEN + EXECUTE format('ALTER TABLE public.events DROP CONSTRAINT %I', c); + END IF; +END $$; + +ALTER TABLE public.events + ADD CONSTRAINT events_user_id_fkey + FOREIGN KEY (user_id) REFERENCES public.profiles(id) ON DELETE CASCADE; + +-- 3. Audit function: returns any public base table whose user_id column does NOT +-- cascade-delete to profiles/auth.users. Empty result == deletion is complete. +CREATE OR REPLACE FUNCTION public.account_deletion_completeness() +RETURNS TABLE(table_name text, references_table text, on_delete text) +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ + SELECT cl.relname::text, + COALESCE(rf.relname::text, '(no fk)'), + CASE con.confdeltype WHEN 'c' THEN 'CASCADE' WHEN 'a' THEN 'NO ACTION' + WHEN 'r' THEN 'RESTRICT' WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' ELSE '(no fk)' END + FROM pg_class cl + JOIN pg_namespace ns ON ns.oid = cl.relnamespace AND ns.nspname = 'public' + JOIN pg_attribute a ON a.attrelid = cl.oid AND a.attname = 'user_id' + AND a.attnum > 0 AND NOT a.attisdropped + LEFT JOIN pg_constraint con ON con.conrelid = cl.oid AND con.contype = 'f' + AND a.attnum = ANY(con.conkey) + LEFT JOIN pg_class rf ON rf.oid = con.confrelid + WHERE cl.relkind = 'r' + AND NOT (con.oid IS NOT NULL AND con.confdeltype = 'c' + AND rf.relname IN ('profiles', 'users')); +$$; + +REVOKE EXECUTE ON FUNCTION public.account_deletion_completeness() FROM public; +GRANT EXECUTE ON FUNCTION public.account_deletion_completeness() TO service_role; From f7534578c1f55d021ac25fc24d1ce6064972e3d2 Mon Sep 17 00:00:00 2001 From: CodeKnight Date: Sun, 7 Jun 2026 12:47:05 -0500 Subject: [PATCH 5/8] test(db): gated account-deletion completeness RPC guard Co-Authored-By: Claude Opus 4.8 --- .../mvp/accountDeletionCompleteness.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 ai-form-coach/src/__tests__/mvp/accountDeletionCompleteness.test.ts diff --git a/ai-form-coach/src/__tests__/mvp/accountDeletionCompleteness.test.ts b/ai-form-coach/src/__tests__/mvp/accountDeletionCompleteness.test.ts new file mode 100644 index 0000000..d7752fd --- /dev/null +++ b/ai-form-coach/src/__tests__/mvp/accountDeletionCompleteness.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { createClient } from "@supabase/supabase-js"; + +const url = process.env.NEXT_PUBLIC_SUPABASE_URL; +const key = process.env.SUPABASE_SERVICE_ROLE_KEY; +// Runs only with REAL service creds. CI uses placeholders, so it auto-skips. +const canRun = + !!url && !!key && + url.startsWith("https://") && !url.includes("placeholder") && + !key.includes("placeholder"); + +describe.skipIf(!canRun)("account deletion completeness (DB schema)", () => { + it("every user_id table cascade-deletes (no orphans)", async () => { + const supabase = createClient(url!, key!, { auth: { persistSession: false } }); + const { data, error } = await supabase.rpc("account_deletion_completeness"); + expect(error).toBeNull(); + expect(data ?? []).toEqual([]); // any row = a table that won't be deleted + }); +}); From eef73eacf1bddb3cfffa54db2d3869f9aacd25db Mon Sep 17 00:00:00 2001 From: CodeKnight Date: Sun, 7 Jun 2026 12:49:36 -0500 Subject: [PATCH 6/8] fix(security): reject cross-origin POST to delete-account (CSRF defense-in-depth) Co-Authored-By: Claude Opus 4.8 --- ai-form-coach/src/app/api/auth/delete-account/route.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ai-form-coach/src/app/api/auth/delete-account/route.ts b/ai-form-coach/src/app/api/auth/delete-account/route.ts index 47071ae..daa91d4 100644 --- a/ai-form-coach/src/app/api/auth/delete-account/route.ts +++ b/ai-form-coach/src/app/api/auth/delete-account/route.ts @@ -1,8 +1,16 @@ import { NextResponse } from "next/server"; import { getSupabaseServerClient, getSupabaseServiceClient } from "@/lib/supabase/server"; -export async function POST() { +export async function POST(request: Request) { try { + // Defense-in-depth against CSRF (Supabase cookies are SameSite=Lax, which + // already blocks cross-site POSTs; this rejects any that slip through). + const origin = request.headers.get("origin"); + const host = request.headers.get("host"); + if (origin && host && new URL(origin).host !== host) { + return NextResponse.json({ error: "Invalid origin" }, { status: 403 }); + } + const supabase = await getSupabaseServerClient(); const { data: { user }, error: authError } = await supabase.auth.getUser(); From 65938c3a6eca23db666c9284b89e502b548b1e36 Mon Sep 17 00:00:00 2001 From: CodeKnight Date: Sun, 7 Jun 2026 12:52:47 -0500 Subject: [PATCH 7/8] docs(legal): document account-deletion completeness + GDPR follow-ups Co-Authored-By: Claude Opus 4.8 --- .../docs/technical/account-deletion.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 ai-form-coach/docs/technical/account-deletion.md diff --git a/ai-form-coach/docs/technical/account-deletion.md b/ai-form-coach/docs/technical/account-deletion.md new file mode 100644 index 0000000..941410e --- /dev/null +++ b/ai-form-coach/docs/technical/account-deletion.md @@ -0,0 +1,32 @@ +# Account Deletion (GDPR right-to-erasure) + +## Flow +`DeleteAccountModal` β†’ `POST /api/auth/delete-account` (same-origin checked) β†’ +`auth.admin.deleteUser(user.id)`. + +## Why it's complete +`profiles.id β†’ auth.users(id) ON DELETE CASCADE` is the chokepoint. Every +user-scoped table cascade-deletes to `auth.users` directly or via `profiles`, so +deleting the auth user removes all of the user's rows. `events.user_id` was changed +from `SET NULL` to `ON DELETE CASCADE` (war-room #14) so analytics are deleted, not +just anonymized. + +## Regression guard +`public.account_deletion_completeness()` returns any user-scoped base table that +would NOT cascade-delete. **Empty result == deletion is complete.** + +- Verify anytime via the Supabase SQL editor / MCP: + `SELECT * FROM public.account_deletion_completeness();` +- Automated: `src/__tests__/mvp/accountDeletionCompleteness.test.ts` calls it via + the service client and asserts empty. It **auto-skips** without real creds (CI + uses placeholders). To run it locally, set real values in gitignored `.env.local`: + `NEXT_PUBLIC_SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` (Supabase dashboard β†’ + Project Settings β†’ API). Run before promoting `dev β†’ main`. + +## Not covered by DB cascade (GDPR follow-ups) +- `auth.audit_log_entries` β€” Supabase-managed; may retain email/IP. +- Stripe β€” no payments in Beta; when added, delete the customer on erasure. +- Sentry β€” PII capture is off (`sendDefaultPii: false`); Umami analytics. +- Supabase PITR backups β€” deleted data ages out of the backup window (standard). +- **Separate future item:** data *access/portability* (GDPR Art. 15/20) β€” not part + of erasure. From 0f1e58e4bcabc4567cd2f6a898a0d055f6d3fd3d Mon Sep 17 00:00:00 2001 From: CodeKnight Date: Sun, 7 Jun 2026 12:54:36 -0500 Subject: [PATCH 8/8] =?UTF-8?q?docs(war-room):=20#14=20resolved=20?= =?UTF-8?q?=E2=80=94=20account=20deletion=20verified=20complete=20+=20guar?= =?UTF-8?q?ded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../docs/war-room-v2/11_BETA1_CONCERNS_TRACKER.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ai-form-coach/docs/war-room-v2/11_BETA1_CONCERNS_TRACKER.md b/ai-form-coach/docs/war-room-v2/11_BETA1_CONCERNS_TRACKER.md index fd9d83f..8bac2d9 100644 --- a/ai-form-coach/docs/war-room-v2/11_BETA1_CONCERNS_TRACKER.md +++ b/ai-form-coach/docs/war-room-v2/11_BETA1_CONCERNS_TRACKER.md @@ -284,10 +284,20 @@ angle, lighting, clothing, distance. ### 14. Account deletion not E2E tested (GDPR) -**Status:** πŸ”΄ open +**Status:** 🟒 resolved (2026-05-30) β€” deletion verified complete (profiles-cascade chokepoint); `events` now CASCADE-deletes; audit fn + gated test guard regressions **Owner:** Dev **Risk:** Regulatory liability if deletion is incomplete -**Estimate:** 30 min β€” one Playwright test: signup β†’ delete β†’ assert zero rows. +**Resolution:** Audited all 42 user-scoped tables. Deletion cascades correctly via +the `profiles.id β†’ auth.users ON DELETE CASCADE` chokepoint β€” every user table +cascade-deletes directly or through `profiles`. The one gap, `events.user_id` +(`SET NULL`), was changed to `ON DELETE CASCADE` (migration `10_...`, applied via +MCP). Added `public.account_deletion_completeness()` (MCP-verified to return zero +offending tables) + a gated RPC regression test (`accountDeletionCompleteness.test.ts`, +auto-skips in CI) + a same-origin CSRF check on the delete route. GDPR +external-processor follow-ups documented in `docs/technical/account-deletion.md`. +A literal signupβ†’delete Playwright e2e was *not* added β€” CI uses placeholder +Supabase creds; the audit function + MCP verification cover completeness instead. +**Estimate (original):** 30 min β€” one Playwright test: signup β†’ delete β†’ assert zero rows. ### 15. No A/B test framework for cue effectiveness @@ -321,6 +331,7 @@ Everything else can wait until after beta launches. But those three before the f | 2026-05-30 | #4 CI workflow | πŸ”΄ β†’ 🟒 | Added `dev-pr-gate.yml` (lint+test+build on PRs to dev) complementing `release-gate.yml` (e2e on PRs to main) | | 2026-05-30 | #2 Liability waiver | πŸ”΄ β†’ 🟑 | Draft medical disclaimer + assumption-of-risk, /medical-disclaimer page, strengthened terms/privacy, signup clickwrap + 18+, first-session self-attestation gate, consent record. PLACEHOLDER copy pending lawyer review. | | 2026-05-30 | #1 S-G coefficient | 🟑 β†’ 🟒 | Deleted broken Savitzky-Golay; shipped correct EMA smoother (Ξ±=2/(windowSize+1)), re-enabled smoothing, inverted the pinning test, rep-count tests green | +| 2026-05-30 | #14 Account deletion | πŸ”΄ β†’ 🟒 | Audit: all user tables cascade via profiles chokepoint; fixed events SET NULL β†’ CASCADE; added account_deletion_completeness() fn (MCP-verified empty) + gated RPC test + same-origin check + GDPR follow-up docs | ---