You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Clear the public profile when an account is soft-deleted #427
The privacy page tells people to email us to delete their account. The only delete tool is the admin ghost-account delete. It soft-deletes anyone who has blocking messages or owns documents. A soft delete sets users.deleted_at and bans the auth user, but the name, photo, bio and links stay readable by anyone. The users read policies are USING (true).
Severity: Medium. It breaks a privacy promise. Confidence is medium, because the operator may handle requests by hand.
Area: hocuspocus admin API, Supabase Storage
Source: security review of 2026-10-06, finding M14
Production check (2026-10-06, read-only)
0 soft-deleted users hold a name, avatar or profile_data. So nothing leaks today. The gap opens with the next soft delete.
Routes: DELETE /api/admin/audit/ghost-accounts/:id and POST /api/admin/audit/ghost-accounts/bulk-delete (apps/hocuspocus.server/src/api/routers/admin.router.ts:222-228).
Both soft-delete paths live in apps/hocuspocus.server/src/api/services/adminGhostAccounts.service.ts. Each sets only deleted_at and ban_duration:
deleteGhostAccount, :290-307.
bulkDeleteGhostAccounts, :366-382.
Profile columns: packages/supabase/scripts/02-users.sql:9-35. display_name is generated from full_name and username, so it follows them. username must match ^[a-z][a-z0-9_-]{2,29}$ and be unique.
Read policies users_select and users_public_anon_select: packages/supabase/scripts/13-RLS.sql:60-62 and :322-324. They do not hide deleted_at rows. The column grants hide email already (:71-74).
Avatar object path: {userId}/avatar.png in user_avatars (apps/webapp/src/components/settings/hooks/useAvatarUpload.ts:69).
Fix plan
Before you start: #412 makes the ghost delete refuse any account that is not a ghost. A real user who asks for deletion is not a ghost, so that path will refuse them. The maintainer must choose the deletion path for such a request first. Then call the helper below from that path too.
Change only the service layer. Keep the row and its id, so message history still resolves its author.
In adminGhostAccounts.service.ts, add one private helper, softDeleteUsers(client, userIds). Both soft-delete paths call it in place of their own update({ deleted_at }). For each id it:
Updates users in one call: deleted_at = now, full_name = null, avatar_url = null, avatar_updated_at = null, profile_data = {}, username = 'deleted_' + <first 12 hex chars of the id, no hyphens>. The placeholder passes the format check, and the id prefix keeps it unique.
Lists the {userId}/ folder in user_avatars and removes every object in it through client.storage. A failed removal is logged, not fatal, as the ban failure is today.
Keeps the existing ban call.
Keep each path's own result shape and messages. deleteGhostAccount still returns { status: 'soft_delete', reason }.
Do not change the hard-delete path, the controller, or any RLS policy.
Out of scope
Message text the user wrote, and @username text in other people's messages. History is kept by design.
After a single or bulk soft delete, the user's row has a deleted_ username, and null full_name, avatar_url and avatar_updated_at, and profile_data = {}.
GET /rest/v1/users?id=eq.<uid> with the anon key returns no name, photo, bio or links.
storage.objects has no user_avatars row under <uid>/.
Old chat messages from that user still render, with the placeholder name and the default avatar.
A hard delete behaves as before.
Verify
cd apps/hocuspocus.server && bun run typecheck.
In the local stack, sign in as a test user, upload an avatar, fill the profile, and post one chat message.
changed the title [-][Privacy] A soft-deleted account keeps its public profile[/-][+]Clear the public profile when an account is soft-deleted[/+]on Oct 6, 2026
Deployed in bbdaae66c (production run 37976785981, green).
Every soft delete goes through softDeleteUsers, which clears the public profile, sets a deleted_ username, removes the avatar objects and bans the user. The follow-up admin route for deletion on request is Admin route to delete an active account on request #471.
Summary
The privacy page tells people to email us to delete their account. The only delete tool is the admin ghost-account delete. It soft-deletes anyone who has blocking messages or owns documents. A soft delete sets
users.deleted_atand bans the auth user, but the name, photo, bio and links stay readable by anyone. Theusersread policies areUSING (true).Production check (2026-10-06, read-only)
0 soft-deleted users hold a name, avatar or
profile_data. So nothing leaks today. The gap opens with the next soft delete.Where
apps/webapp/src/pages/privacy.tsx:104-107.DELETE /api/admin/audit/ghost-accounts/:idandPOST /api/admin/audit/ghost-accounts/bulk-delete(apps/hocuspocus.server/src/api/routers/admin.router.ts:222-228).apps/hocuspocus.server/src/api/services/adminGhostAccounts.service.ts. Each sets onlydeleted_atandban_duration:deleteGhostAccount,:290-307.bulkDeleteGhostAccounts,:366-382.packages/supabase/scripts/02-users.sql:9-35.display_nameis generated fromfull_nameandusername, so it follows them.usernamemust match^[a-z][a-z0-9_-]{2,29}$and be unique.users_selectandusers_public_anon_select:packages/supabase/scripts/13-RLS.sql:60-62and:322-324. They do not hidedeleted_atrows. The column grants hideemailalready (:71-74).{userId}/avatar.pnginuser_avatars(apps/webapp/src/components/settings/hooks/useAvatarUpload.ts:69).Fix plan
Before you start: #412 makes the ghost delete refuse any account that is not a ghost. A real user who asks for deletion is not a ghost, so that path will refuse them. The maintainer must choose the deletion path for such a request first. Then call the helper below from that path too.
Change only the service layer. Keep the row and its id, so message history still resolves its author.
adminGhostAccounts.service.ts, add one private helper,softDeleteUsers(client, userIds). Both soft-delete paths call it in place of their ownupdate({ deleted_at }). For each id it:usersin one call:deleted_at = now,full_name = null,avatar_url = null,avatar_updated_at = null,profile_data = {},username = 'deleted_' + <first 12 hex chars of the id, no hyphens>. The placeholder passes the format check, and the id prefix keeps it unique.{userId}/folder inuser_avatarsand removes every object in it throughclient.storage. A failed removal is logged, not fatal, as the ban failure is today.deleteGhostAccountstill returns{ status: 'soft_delete', reason }.Out of scope
@usernametext in other people's messages. History is kept by design.Acceptance criteria
deleted_username, and nullfull_name,avatar_urlandavatar_updated_at, andprofile_data = {}.GET /rest/v1/users?id=eq.<uid>with the anon key returns no name, photo, bio or links.storage.objectshas nouser_avatarsrow under<uid>/.Verify
cd apps/hocuspocus.server && bun run typecheck.DELETEroute with an admin token. After Re-check ghost status before an admin delete, and lock the last-admin guard #412 lands, this path refuses a user who signed in. Use the deletion path the maintainer chose instead.psql:select username, full_name, display_name, avatar_url, avatar_updated_at, profile_data, deleted_at from public.users where id = '<uid>';select name from storage.objects where bucket_id = 'user_avatars' and name like '<uid>/%';returns no rows.Related
Generated by Claude Code