Skip to content

feat(account): data export and account deletion - #83

Merged
Programcode merged 8 commits into
mainfrom
feat/account-export-deletion
Oct 1, 2026
Merged

Programcode merged 8 commits into
mainfrom
feat/account-export-deletion

Conversation

@Programcode

@Programcode Programcode commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

What this adds

Settings now has Download my data (in the Account card) and Delete account (in the Danger Zone, beside the existing Delete All My Data, which keeps the account). Both call a new account Edge Function. It acts only on the caller, taking the user ID from the verified session and never from the request body.

Export returns one JSON file with the auth profile plus every row whose ownership column (user_id, owner_id, author_id, created_by, recipient_id) references the user, chat messages, published announcements addressed to the user, and announcements they wrote as an admin. It finds tables through the catalog, so a new user-owned table is exported automatically. 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 API key ciphertext, sparring claim tokens, audit request fingerprints, 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 asks the user to type their email (or DELETE for an account without one), then runs in this order:

  1. Stop Stripe billing for every customer in subscriptions, in the new stripe_customers table, 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 fails, it stops and deletes nothing.
  2. Remove Storage objects whose Storage owner is the user. The app uploads none today. source_files.storage_key is user-writable, so it never selects an object for deletion.
  3. prepare_account_deletion deletes workspaces the user owns and their sources, threads, messages, and results in other workspaces.
  4. Delete the auth user with the Auth admin API. Foreign keys remove the rest.

Why deletion cancels Stripe instead of refusing

Refusing while a subscription is active would leave a user who already canceled at period end unable to delete until the period ran out. Canceling first and stopping on any Stripe error means the function never deletes an account that Stripe still bills. Stripe keeps its own invoices and payments for tax and accounting. The dialog, the Privacy page, and docs/architecture.md all say so.

Schema changes (migration 20261001100000)

  • promo_codes.created_by and curated_sources.created_by: change from NO ACTION to SET NULL. Before, they blocked deleting any admin who had created one.
  • shared_items.created_by: changes from SET NULL to CASCADE, so public share links to the user's work stop working.
  • admin_announcements.created_by becomes nullable. A BEFORE DELETE trigger on auth.users clears it and removes the user from target_user_ids, deleting an announcement left with no recipients. src/lib/announcements.ts now accepts a null author.
  • Workspace foreign keys stay RESTRICT. workspaces_rls_test asserts that a raw delete cannot remove an owner, and that still holds; only the account path clears them first.
  • New stripe_customers table, service role only. create-checkout records the customer there before it opens a session and fails closed if it can't, so deletion finds a subscription the webhook hasn't recorded yet.
  • New service-role-only RPCs: export_account_data, account_deletion_plan, prepare_account_deletion.

Workspace is hidden in production. Deleting an account deletes the workspaces it owns, even ones with other members. If Workspace ships, transferring ownership is the alternative.

Codex review

Four Codex review rounds (gpt-6.1-sol, high effort) ran on this branch.

The first found three P1 issues, fixed in the second commit. A user could get another user's Storage object deleted by naming it in their own source_files row. The export returned admin grant notes that RLS hides. Deletion could miss a subscription created between Checkout and the webhook.

The second found two P1s and two P2s. Three are fixed in the third commit: Stripe customers created before stripe_customers existed (now found by Stripe search), export rows selected through non-ownership columns like resolved_by, and scheduled announcements in the export.

One is accepted, not fixed. A Checkout session opened in another tab during the few seconds deletion runs is not stopped. Only the user deleting their own account can trigger it, and closing it would need a lock shared by create-checkout and account.

The third found two P1s, both fixed in the fourth commit. A removed workspace member could still export shared rows they had created. Separately, a resource_missing error on a single Checkout session or subscription skipped that customer's remaining subscriptions. The pgTAP test and Deno tests now cover both.

The fourth ran on the final head, 894c063, and found no actionable defects. It read the full diff and the migrations it touches, and ran typecheck, the Settings tests, and the account function tests.

Tests

  • supabase/tests/account_deletion_test.sql (27 assertions, including a removed workspace member) gives the leaving user a row in every table with a foreign key to auth.users. It fails if a future table is missing from the fixture or from the export, then scans every row of every public and private table as text for the user ID after deletion. It also checks that the other user keeps their data and that only service_role can call the RPCs.
  • e2e/full-stack/account-deletion.spec.ts drives the real Settings UI, Auth, and Edge Function. It downloads the export, deletes the account, then scans Postgres directly for the user ID. It checks that a request naming another user returns only the caller's data, that another user's confirmation deletes nothing, that calls without a session get 401, and that deletion searched Stripe for the user. The gate's fake provider answers that search through STRIPE_API_URL, which only the gate sets.
  • supabase/functions/account/index_test.ts covers step order, stopping on a Stripe or Storage failure, confirmation matching, which subscription states get canceled, and customers found only by Stripe search.
  • src/test/pages/SettingsPage.test.tsx covers the dialog's exact-match gate and the failure path.

No test calls real Stripe, because the local key is a placeholder. The Deno tests run checkout expiry and cancellation against a fake.

Verification at 894c063

CI, on clean runners:

  • quality passed: docs, lint, typecheck, unit, collaboration, Edge, and build.
  • database-tests passed: every pgTAP file, including account_deletion_test, then 59 full-stack browser tests with the one existing mobile skip. Both account journeys ran on desktop and mobile Chromium.
  • browser-tests passed.
  • live-e2e fails on AI-provider timeouts against live OpenRouter and Exa. This change doesn't touch those paths, and the same workflow fails on main and on other branches today.

Locally, the account Deno tests (15) and the affected pgTAP files passed at this head. Earlier in the branch, npm test (1110 tests) and npm run test:edge (283 tests) passed. The local browser stage was unreliable. Something outside the gate scripts ran docker rm -f on the shared debatelab-fake-openrouter container mid-run (Docker recorded SIGKILL, exit 137).

Deploy notes

Merging ships the frontend only. The migration, the new account function, and the updated create-checkout need a separate, authorized deploy, with the migration first. Until the backend is live, the two new Settings buttons return an error.

🤖 Generated with Claude Code

Programcode and others added 3 commits October 1, 2026 09:13
Settings gets "Download my data" and "Delete account". Both call a new
`account` Edge Function that acts only on the caller's own account.

Export returns the auth profile and every row reached through a foreign
key into auth.users, plus chat messages and announcements, with
credential columns left out.

Deletion cancels live Stripe subscriptions immediately and stops if
Stripe fails, removes the user's Storage objects, clears their
workspaces, then deletes the auth user. The migration makes the other
blocking foreign keys cascade or set null, and a trigger clears
announcement authors and recipients. Workspace foreign keys stay
RESTRICT, so a raw auth delete still cannot remove shared work.

A pgTAP test seeds a row in every user-owned table and checks that no
public or private row mentions the user after deletion. A full-stack
spec does the same through the real UI, Auth, and Edge Function, and
checks that one user cannot export or delete another.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Select Storage objects for deletion only by their Storage owner. A
  user could point a source_files row at another user's object, and
  the service role would have deleted it.
- Export admin grants as type and dates only, and skip admin catalog
  tables. The generic export returned grant notes that RLS hides from
  the user.
- Record Stripe customers in a service-role-only stripe_customers
  table when Checkout opens, and expire open Checkout sessions before
  canceling subscriptions. Deletion no longer depends on the webhook
  having recorded a subscription.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… rows

- Search Stripe for customers by metadata.user_id during deletion, in
  addition to recorded ones, so customers created before
  stripe_customers existed are still stopped. A failed search stops
  deletion. The local gate's fake answers the search through the new
  STRIPE_API_URL override.
- Export only rows whose ownership column references the user.
  Columns such as resolved_by and actor_id pulled in rows other
  people own.
- Leave scheduled and withdrawn announcements out of the export, as
  the read policy does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 1, 2026 11:12
@vercel

vercel Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
debate-navigator Ready Ready Preview Oct 1, 2026 6:02pm UTC

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Next included review available in 2 seconds.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 06fcc037-9047-4a80-82b2-01ec83750c15

📥 Commits

Reviewing files that changed from the base of the PR and between 363533e and 1f4c2ce.

📒 Files selected for processing (21)
  • .superset/db-gate.sh
  • .superset/workspace-test.env
  • docs/architecture.md
  • docs/testing.md
  • e2e/full-stack/account-deletion.spec.ts
  • e2e/full-stack/fake-openrouter.mjs
  • src/lib/account.ts
  • src/lib/announcements.ts
  • src/lib/export-utils.ts
  • src/locales/en.json
  • src/locales/es.json
  • src/locales/hu.json
  • src/pages/SettingsPage.tsx
  • src/test/pages/SettingsPage.test.tsx
  • supabase/README.md
  • supabase/config.toml
  • supabase/functions/account/index.ts
  • supabase/functions/account/index_test.ts
  • supabase/functions/create-checkout/index.ts
  • supabase/migrations/20261001100000_account_deletion_and_export.sql
  • supabase/tests/account_deletion_test.sql
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@Programcode

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 58 minutes.

- Export workspace rows only while the user is still a member of that
  workspace, as the read policies require. A removed member could
  otherwise download shared work edited after their removal.
- Treat resource_missing as "customer gone" only when listing. A
  session or subscription that disappears mid-cleanup is now skipped
  on its own, so the customer's other subscriptions still get
  canceled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Programcode

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 44 minutes.

private.workspace_invite_attempts records the actor's user id without a
foreign key, so deleting an account left those rows behind. The deletion
test never seeded one, so its "no row mentions the user" check could not
see the gap. Seed an attempt and clear it in prepare_account_deletion.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Deleting a leaver's invite attempts also deleted the failures the per-IP
limit counts, so deleting an account and signing up again reset that
limit. Anonymize the rows instead: the actor becomes the nil UUID, the
IP failures still count, and the redeem function purges them after a
day as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Programcode

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Programcode
Programcode merged commit 1bec669 into main Oct 1, 2026
8 checks passed
Programcode added a commit that referenced this pull request Oct 1, 2026
Brings in #79 to #83 and #85.

Conflict in .superset/db-gate.sh: both sides added a function to the
readiness loop. The loop now waits for both `voice` and `account`.

#82 replaced the "Which debate is this for?" dialog with the first-debate
offer when no debate is current. e2e/voice-rounds.spec.ts still clicked
the old button in two places, so it answers the new offer the way
e2e/sparring.spec.ts does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 1f4c2ce7 Deployed Oct 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants