Skip to content

feat(web): self-serve account deletion; hide GitHub sign-in until enabled - #255

Merged
ralyodio merged 5 commits into
masterfrom
fix/auth-account
Sep 26, 2026
Merged

ralyodio merged 5 commits into
masterfrom
fix/auth-account

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Built on #247 (fix/auth-session-leak). Merge #247 first; until then this PR's diff also shows #247's commit. This branch uses #247's createSupabaseAuthClient() for the password check, and its bearer-only /api/auth/me.

What

  1. "Continue with GitHub" is hidden until the provider is actually on. In production the button leads to Supabase 400 Unsupported provider: provider is not enabled, because the GitHub provider isn't enabled in Supabase Auth. The button and its "or continue with" divider on /auth/login and /auth/signup now render only when NEXT_PUBLIC_GITHUB_OAUTH_ENABLED=true. When the flag is off, GET /api/auth/github returns 404 {"error":"GitHub sign-in is not enabled. Log in with your email and password instead."} instead of redirecting into the Supabase 400. Nothing else about OAuth changes. The flag is documented in .env.example and docs/SELF_HOSTING.md.
  2. Self-serve account deletion (DELETE /api/auth/me). The privacy policy promises deletion, but /api/auth/me only had GET and PATCH.
    • Requires the bearer token. Password accounts must re-enter their password, checked with a GoTrue password grant on a throwaway client. OAuth-only accounts (no password exists) confirm by typing their account email.
    • Refused (409) while the caller is the only owner of an org that has other members. The response lists those orgs (organizations: [{id,name,slug}]) and tells the user to make another member an owner or delete the org. The refusal happens before the password check, so a refused attempt never mints a session.
    • Otherwise: orgs where the caller is the only member are deleted. Their servers, detections, remediation actions, hardening findings, allowlists, alert destinations/rules and push subscriptions go with them via the existing ON DELETE CASCADEs. For shared orgs the caller created, created_by moves to the longest-standing remaining owner, or the longest-standing member if there is no other owner. Then the GoTrue admin API deletes the auth user, which cascades to user_profiles, org memberships, push_subscriptions and the other auth.users cascades listed under Decisions.
    • GET /api/auth/me now also returns has_password, so the UI knows which confirmation to ask for.
  3. Account page "Danger zone". A new Delete account… section at the bottom of /account opens a confirmation form: password, or the typed email for OAuth-only accounts. On success it signs out and lands on /. On a 409 it shows the error plus links to each blocking org's settings page, where roles are managed.
  4. Migration 20260925130000_account_deletion.sql. Two FKs onto user_profiles made deletion fail or destroy other people's data:
    • servers.created_by was NOT NULL but ON DELETE SET NULL. Deleting anyone who ever added a server to an org they share failed with a NOT NULL violation. The column is now nullable, and the server stays with the org. Server.created_by in lib/organizations.ts is now typed string | null.
    • organizations.created_by was ON DELETE CASCADE. Deleting an org's creator silently deleted the whole org, including every other member's servers and detections, even after ownership had been shared. It is now ON DELETE RESTRICT: every deletion path must first delete or re-home the orgs the user created, which the route does.

How verified

  • pnpm --filter @profullstack/threatcrush-web test: 57 files, 503 tests pass. The new tests are:
    • me/__tests__/delete.test.ts, which runs against an in-memory org/member store. It covers: 401 without a token; 400 for a bad body or missing password; 403 for a wrong password with nothing deleted; a typed email not accepted in place of a password; OAuth-only email confirmation (case-insensitive); the 409 sole-owner refusal, with no password check and nothing deleted; solo org deleted while a shared org is untouched; created_by handed to the longest-standing other owner; a membership-less created org deleted; a failed GoTrue delete returning 500.
    • has_password on GET.
    • /api/auth/github returning 404 without redirecting for flag values unset, "", false and 1.
  • Migration applied to local Supabase with psql, twice (idempotent). Result: organizations_created_by_fkey has confdeltype = r and servers.created_by is nullable.
  • next dev on :3446 against local Supabase, headless Chromium:
    • Flag unset: /auth/login and /auth/signup have 0 "Continue with GitHub" elements (screenshots inspected: no divider, no button). GET /api/auth/github returns 404 {"error":"GitHub sign-in is not enabled. Log in with your email and password instead."}.
    • NEXT_PUBLIC_GITHUB_OAUTH_ENABLED=true: 1 button on each page (screenshot inspected: divider plus button, as before). GET /api/auth/github returns 307 to …/auth/v1/authorize?provider=github…, unchanged behaviour.
    • Deleting user X through the UI. Setup: X owned a solo org with a server, a detection and a push subscription, and created a second org where Y is a co-owner, containing a server X added. In the browser: log in → /account → "Delete account…" → wrong password shows "Incorrect password" (DELETE → 403) → correct password (DELETE → 200) → landed on / with the token gone from localStorage. Afterwards:
      • GoTrue admin GET /admin/users/X returned 404.
      • auth.users, user_profiles, organization_members and push_subscriptions rows for X: 0.
      • Solo org, its server and its detection: 0.
      • The shared org survived, with created_by = Y and only Y's owner membership left. The server X had added to it survived with created_by NULL.
    • Sole owner refused: Y, sole owner of "Y Team" which has member Z, tried the same flow. Both attempts got DELETE → 409, and the UI showed "You are the only owner of an organization that other people belong to: Y Team 1252. Make another member an owner or delete the organization, then try again." with a link to /org/y-team-1252/settings (screenshot inspected). Y's auth user, orgs and memberships were unchanged.
  • Cleanup: throwaway users and orgs deleted. I reverted the two constraint changes on the shared local DB afterwards, so other agents' user cleanups keep working. Re-apply the migration file to test there.

Blocked on the owner

  • Apply the migration 20260925130000_account_deletion.sql to production Supabase before deploying. Without it, a deletion that touches a shared org's created_by or a server the user added to a shared org fails partway, after the user's solo orgs are already gone.
  • To turn GitHub sign-in on: enable the GitHub provider in Supabase Auth (supabase.threatcrush.com, with the GitHub OAuth app's client id/secret and callback https://supabase.threatcrush.com/auth/v1/callback), then set NEXT_PUBLIC_GITHUB_OAUTH_ENABLED=true in the environment used by next build on the server and redeploy. It's a NEXT_PUBLIC_ variable, so it is inlined at build time.
  • The /account "Scan announcements" section still says "Sign in with GitHub to see the installations you manage". I left it unchanged. It's accurate once the flag is on, but misleading until then.

Decisions

  • Confirmation method: password re-entry, the stronger proof, for every account that has a password (all accounts today). Typing the email is only the fallback for OAuth-only accounts, which have no password to re-enter. A typed email is not accepted in place of a password.
  • Sole owner with other members → refuse rather than auto-promote someone, as the task specified. A co-owned org is not blocking: the user's membership just goes.
  • organizations.created_by → RESTRICT rather than SET NULL. With SET NULL the surviving org would have no creator, and DELETE /api/orgs/:id (creator-only) could never delete it. RESTRICT makes a missed re-home fail loudly instead of deleting data. Side effect: deleting a user in the Supabase dashboard now errors if they still created an org. Delete or re-home the org first.
  • What deletion also removes through the existing auth.users cascades, left unchanged: license_purchases, credit_deposits, usage_events, referral_wallets, user_settings, user_installed_modules, phone_verification_codes. If you need payment records kept for accounting, change those FKs before shipping this.
  • What deletion keeps: marketplace reviews/installs keyed by email (module_reviews.user_email, module_installs.user_email), funding_payments, and waitlist/contact/whitepaper rows. Those have no FK to the user. Say if reviews should go too.
  • The GitHub route answers 404 when the flag is off, since the feature doesn't exist on that deployment. 503 would also be defensible.
  • No new middleware rate-limit rule. The endpoint needs a valid bearer token, and the general /api/ limit applies.

…session

/api/auth/login (signInWithPassword), /api/auth/refresh (refreshSession) and
/api/auth/signup (signUp) ran on the process-wide anon Supabase client from
getSupabaseClient(). supabase-js keeps the resulting session in that client's
memory, and GET/PATCH /api/auth/me and GET /api/auth/check fell back to
supabase.auth.getSession() when a request had no bearer token. Any
unauthenticated request was therefore answered as whoever last signed in:
their profile and phone were readable, and PATCH /api/auth/me could rewrite
their wallet_address (referral payouts) and notification webhook.

Session-creating calls now use a fresh per-request client
(createSupabaseAuthClient), and the getSession() fallbacks are gone, so each
layer alone closes the leak.
"Continue with GitHub" led production users into Supabase's
"400 Unsupported provider: provider is not enabled", because the GitHub
provider is not enabled in Supabase Auth. The button (and its divider) on
login/signup now render only with NEXT_PUBLIC_GITHUB_OAUTH_ENABLED=true, and
/api/auth/github answers a clear 404 instead of redirecting into that 400,
so the owner can switch it on after enabling the provider.
The privacy policy promises deletion, but /api/auth/me had only GET and
PATCH. DELETE /api/auth/me now deletes the caller's account after they
re-enter their password (OAuth-only accounts type their email instead). It
refuses while they are the only owner of an org with other members, deletes
the orgs only they belong to (servers, detections, etc. cascade), hands
shared orgs they created to a remaining owner, then deletes the GoTrue user,
which cascades to the profile, memberships and push subscriptions. /account
gets a danger-zone section for it that signs out and returns home.

The migration fixes two FKs that made this fail or destroy other people's
data: servers.created_by was NOT NULL yet ON DELETE SET NULL, and
organizations.created_by cascaded the whole org away when its creator was
deleted; it is now RESTRICT.
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

ThreatCrush Security Scan

16 finding(s)

HIGH/CRITICAL: 1 | MEDIUM: 7 | LOW: 8

Severity Rule Location
HIGH secret-aws-access-key prd/0003-detect-hardcoded-secrets-before-they-are-committed-or-served.md:126
MEDIUM sql-string-concatenation .github/workflows/migrate-dev2.yml:113
MEDIUM js-open-redirect apps/web/src/app/auth/login/page.tsx:67
MEDIUM js-unescaped-html-sink apps/web/src/app/hire/page.tsx:104
MEDIUM js-unescaped-html-sink apps/web/src/app/hire/page.tsx:108
MEDIUM js-open-redirect apps/web/src/components/funding/FundingClient.tsx:97
MEDIUM js-unescaped-html-sink apps/web/src/components/GuideReader.tsx:265
MEDIUM js-uninitialized-buffer packages/scan/src/node-rules.ts:456
LOW secret-generic-credential apps/web/src/app/api/auth/refresh/route.ts:17
LOW secret-generic-credential apps/web/src/app/api/auth/reset-password/route.ts:26
LOW secret-generic-credential apps/web/src/app/api/auth/reset-password/route.ts:27
LOW secret-generic-credential PRD.md:269
LOW tls-verification-disabled prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md:121
LOW tls-verification-disabled prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md:122
LOW sh-remote-script-execution scripts/smoke-test.sh:72
LOW secret-aws-access-key scripts/smoke-test.sh:150

Snippets are redacted; ThreatCrush never prints matched credential material.

Comment thread apps/web/src/app/api/auth/me/route.ts Dismissed
# Conflicts:
#	apps/web/src/app/api/auth/me/route.ts
#	apps/web/src/app/auth/signup/page.tsx
ralyodio added a commit that referenced this pull request Sep 26, 2026
`sh -c 'true & ...; exec sleep 30'` only leaves a zombie if `true` exits
after the exec. When it exits first, sh reaps it and /proc/<pid>/stat is
gone, so the poll threw ENOENT (failed twice in a row on #255's CI).
Background a short sleep instead so the child outlives the exec, tolerate
a missing stat file while polling, and assert the zombie state was reached.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit 4fd74db into master Sep 26, 2026
12 checks passed
@ralyodio
ralyodio deleted the fix/auth-account branch September 26, 2026 01:51
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