Repository navigation
Clear the public profile when an account is soft-deleted #427
Description
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_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.
Where
- Promise:
apps/webapp/src/pages/privacy.tsx:104-107. - Routes:
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). - Both soft-delete paths live in
apps/hocuspocus.server/src/api/services/adminGhostAccounts.service.ts. Each sets onlydeleted_atandban_duration:deleteGhostAccount,:290-307.bulkDeleteGhostAccounts,:366-382.
- Profile columns:
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. - Read policies
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). - Avatar object path:
{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.
- In
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:- Updates
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. - Lists the
{userId}/folder inuser_avatarsand removes every object in it throughclient.storage. A failed removal is logged, not fatal, as the ban failure is today. - Keeps the existing ban call.
- Updates
- Keep each path's own result shape and messages.
deleteGhostAccountstill 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
@usernametext in other people's messages. History is kept by design. - Freeing the old username for reuse. Mention routing by username text is Resolve chat mentions by user id, not by username text #415.
- Re-checks before delete, the admin refusal and the last-admin race. Those are Re-check ghost status before an admin delete, and lock the last-admin guard #412, in the same function.
Acceptance criteria
- After a single or bulk soft delete, the user's row has a
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>/. - 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.
- Soft-delete the user from the admin dashboard ghost-accounts page, or call the
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. - Check in
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.
- Open the channel and check the old message.
Related
- Re-check ghost status before an admin delete, and lock the last-admin guard #412 ghost delete re-checks and the last-admin race (same function). It changes which accounts the ghost path may delete.
- Resolve chat mentions by user id, not by username text #415 resolve chat mentions by user id
- Stop the public avatar buckets from accepting SVG files #431 public avatar buckets accept SVG
- Stop exposing every user's online status and last-active time #434 user directory and live activity
Generated by Claude Code
Activity
[-][Privacy] A soft-deleted account keeps its public profile[/-][+]Clear the public profile when an account is soft-deleted[/+]fix(hocuspocus): re-check ghost accounts before delete and clear soft…
docs(hocuspocus): sync server docs with the 9 October fixes
docs(repo): record the 9 Oct security and email batch in the changelo…
HMarzban commentedon Oct 9, 2026
Fixed and live.
ce2bfde30bbdaae66c(production run 37976785981, green).softDeleteUsers, which clears the public profile, sets adeleted_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.