Skip to content

Publish a privacy policy and let people delete their account - #115

Merged
Alexgodoroja merged 2 commits into
mainfrom
privacy-and-account-deletion
Sep 11, 2026
Merged

Alexgodoroja merged 2 commits into
mainfrom
privacy-and-account-deletion

Conversation

@Alexgodoroja

Copy link
Copy Markdown
Collaborator

What changed

Launch blocker: the app had no privacy policy (app.shell.online/privacy fell through to sign-in) and no way to delete an account.

Privacy policy (/privacy). A public page laid out and styled like the Terms. It covers what the service keeps, what it never receives, what a team sees, the processors (Google: Firebase Auth and Cloud SQL; Cloudflare; Twilio SendGrid), email, browser storage, analytics, retention, deletion, rights, security, transfers and children. Sign-in, sign-up (the acceptance checkbox), the Terms (lede, Termination, footer) and the Account page link to it. Each statement was checked against the code, for example: SendGrid tracking is disabled in the send body, the rate limiter keeps addresses in memory only, the landing analytics writes no IPs, and Cloud SQL keeps 7 backups plus 7 days of logs.

Account deletion. A Delete account button on the Account page opens a panel. The panel lists exactly what will happen, including who becomes owner. The button stays disabled until the account's email is typed back and, for email accounts, a password is entered.

  1. The browser re-authenticates: a password credential, or a Google popup. That satisfies both Firebase's recent-login rule and the service's 10-minute window.
  2. DELETE /api/account checks the typed email and a sign-in within 10 minutes (the vault-reset rule). It then runs Store.deleteAccount in one transaction:
    • Deletes: memberships, auth codes, CLI tokens (every linked machine stops working), agent commands, the person's sessions (key shares cascade), shares sealed to them, their vault, their comments, and notifications to or from them.
    • Updates: their uid is dropped from other sessions' assignees, ownership of those sessions falls back to the row's account, and the address is removed from invites they accepted.
    • The team's audit trail keeps their entries, with actor_email set to deleted account.
    • A team with nobody else in it is dissolved. A team they own passes to the longest-standing admin, or to the longest-standing member if there is no admin.
  3. The browser clears its vault key, cached passwords and open tabs, then calls deleteUser.

The service goes first on purpose. If deleteUser fails, what's left is an empty Firebase account, and deleting again finishes the job (the route is idempotent). The reverse order would strand data under an account nobody can sign into.

Tombstone (008_deleted_accounts.sql). An ID token minted before the deletion still verifies for up to an hour. Without a guard, the first background request from another open tab would reach ensureMembership, find no membership, and create a new team for the person. ensureMembership now returns null for a uid deleted in the last two hours, and those routes answer 401. purgeExpired drops the row after that, so only the uid is ever kept.

Deploy note

Migration 008_deleted_accounts.sql must be applied (npm run db:migrate) before the Worker goes out. The new code queries deleted_accounts from ensureMembership, which is on the create-membership path.

Verification

  • npm --prefix app test: 755 passed, 3 skipped (742 on main). New tests:
    • app/server/app.test.ts, DELETE /api/account:
      • refuses a wrong typed email and a stale sign-in
      • a lone account loses its team, machines and CLI token
      • a leftover token cannot rebuild a team
      • ownership passes to the longest-standing admin, not an earlier member
      • a retry succeeds
    • store-conformance.test.ts, "account deletion", run against both stores:
      • a full removal, checking every table plus the scrubbed trail and the reassigned assignee
      • dissolving a team
      • invite address scrubbing
      • the tombstone window and its purge
    • successorFor in orgs.test.ts and src/lib/account-deletion.test.ts
  • The Postgres SQL was run against real Postgres. No Docker was available, so it ran on PGlite (Postgres compiled to WASM) behind pglite-server. All 4 account-deletion conformance tests pass on PostgresStore. Across the whole suite on PGlite, 131/134 pass. The 3 failures are the existing concurrent "claiming an organization" race tests, which hit PGlite socket protocol errors ("unexpected commandComplete message from backend"). They are unrelated to this change and worth confirming on a real Postgres in CI.
  • tsc -b --force in app/: clean. oxlint: no new warnings. The AuthProvider fast-refresh warning is also present on main.
  • Rendered in a local harness (stubbed Firebase, mocked API) at 1280×900 and 390×844:
    • the panel lists the right lines for a three-person team and names the admin as the next owner
    • the button is disabled with only the email, enabled with a matching email (any case or spacing) and a password, and disabled again for a near-miss address
    • /privacy renders with 17 numbered sections
    • no console errors on either page

For the owner to decide or check

  • Automatic handover. An owner who deletes their account passes the team to the longest-standing admin, or member. The product had no ownership transfer path, and refusing would have left owners with teammates unable to delete. The panel names the successor before anything happens.
  • The activity trail stays. Typed input in a team's sessions stays, with the email replaced. That matches the Terms' existing line that ending an account does not undo records others have seen. If you'd rather erase it, it's one statement in each store.
  • Legal review. The policy was written against the code, not reviewed by counsel. Two lines rest on infrastructure outside the repo and should be confirmed: that Cloudflare Workers logs are the only error-log store, and the Cloud SQL backup settings (7 backups, 7-day PITR at the time of writing).
  • shell.online/privacy, on the landing and relay Worker, still returns the landing page. The policy lives at app.shell.online/privacy. A redirect there needs a relay deploy, so it is not in this PR.

Changelog

Two entries under Unreleased → Added.

The app had no /privacy route and no way to delete an account, though the
Terms describe ending one. Both are launch blockers.

/privacy is a public page laid out like the Terms. It names what the service
keeps, what it never receives, what a team sees, the three processors
(Google, Cloudflare, Twilio SendGrid), what the browser stores, how long each
kind of record lasts, and what deletion removes. Sign-in, sign-up, the Terms
and the Account page link to it.

Delete account on the Account page asks for the account's email typed back
and a fresh sign-in, then:

- DELETE /api/account removes the person's tokens, sessions, sealed password
  copies, vault, comments, notifications and membership in one transaction.
  What they typed into colleagues' sessions stays in the team's trail with the
  email replaced. A team nobody else is in is dissolved; a team they own is
  handed to its longest-standing admin, or member if there is none.
- The browser then clears its vault key, cached passwords and open tabs, and
  deletes the Firebase user.

A deleted uid is remembered for two hours (migration 008), because an ID token
issued before the deletion still verifies for up to an hour, and the first
request from a tab left open would otherwise build the person a new team.
@Alexgodoroja
Alexgodoroja merged commit 6078905 into main Sep 11, 2026
15 checks passed
@Alexgodoroja
Alexgodoroja deleted the privacy-and-account-deletion branch September 11, 2026 22:36
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.

1 participant