fix(profiles): stop minting public handles out of people's email addresses - #769
Merged
Conversation
…esses
handle_new_user() set a new profile's PUBLIC username to
split_part(NEW.email, '@', 1), and fell back to the same value for the
display name. /profiles/<username> is served to anyone with no auth and
robots.txt has no /profiles rule, so every signup published its owner's
email local part as a crawlable handle. With a handful of common domains
that reconstructs the address.
Measured on production before this change: 72 of 99 profiles have
username = split_part(email, '@', 1), and /profiles/mao returns 200
unauthenticated. It was reported through OrangeCat's own feedback widget
("the New Message people picker exposes email local-parts as public
usernames") and sat unactioned in the Control queue.
20260818140000 already fixed the worse half — usernames that were the
FULL address — and added a CHECK forbidding '@'. It left the local-part
derivation in place, so this is the same leak one character shorter.
Two forward-only changes:
username now derived from the user's id, which carries no personal
information. Users still pick their own via PUT /api/profile.
name no longer falls back to the email local part. NULL is honest
— the UI already falls back to the username — whereas a
display name quietly set to someone's email prefix is the
same leak wearing a different label.
Also fixes a latent signup failure: username is NOT NULL with a unique
index, and ON CONFLICT (id) DO NOTHING does not catch a username
collision, so two users sharing a local part across domains
(mao@a.com, mao@b.com) meant the second signup raised. An id-derived
handle cannot collide.
EXISTING ROWS ARE NOT RENAMED, deliberately. A username is also a
Lightning address (<username>@orangecat.ch — see .well-known/lnurlp and
/api/lnurlp/<username>/callback) and a public profile URL. Renaming the
72 affected accounts would break saved payment addresses and inbound
links for real people. That is a product decision with its own migration
path (opt-in rename, alias retained for lnurlp, 301 on the old profile
URL), not something a schema migration should smuggle in.
The detection half ships with it. count_email_derived_usernames() is a
service_role-only SECURITY DEFINER counter (auth.users is not reachable
through PostgREST), returning a COUNT and never rows — a list of
"profiles whose handle is their email prefix" is exactly what we are
trying not to publish. check-data-invariants.mjs gates on it as a
RATCHET against the known 72: it may fall or hold, never rise. A
zero-check would be red every night about code that is fine, which is
how a fleet learns to ignore its own gates.
npm run verify green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
catomean
added a commit
that referenced
this pull request
Aug 26, 2026
… from emails (#770) The trigger fix (#769) was not enough, and the database said so: after applying it, count_email_derived_usernames() returned 77, not the 72 measured while writing it. Five accounts had acquired an email-derived handle in the intervening hour — because profiles are not only created by the handle_new_user trigger. Three sites were still deriving the PUBLIC handle from the address: services/profile/server.ts ensureProfile() creates a profile when one is missing, with username = the sanitized email local part. This is the one that was actively minting them. useProfileEditor.ts pre-filled the username FIELD with the useProfileWizard.ts email local part — one Save from publishing it. Only fires on a half-loaded profile (username is NOT NULL), so: a loaded gun with no purpose. All three now use neutralUsernameFor(userId) or an empty field. The display-name email fallback goes too — a name quietly set to someone's email prefix is the same leak wearing another label. neutralUsernameFor mirrors the SQL in 20260826130000 and says so in both directions, because two independent creation paths must not disagree about what a fresh handle looks like. Also corrects the ratchet baseline 72 -> 77. 72 was measured before the fix landed; a nightly gate set to it would have failed on its first run — a gate red about code that is fine, which is the exact habit this ratchet exists to avoid. 77 is a floor now rather than a moving target: no path mints these any more. The class is closed with a test, not a comment: any line in src/ that mentions a username and splits a string on '@' fails the suite. A comment would not have caught ensureProfile — nothing did, until the count moved. Verified by reintroducing the defect and watching the gate name the line. npm run verify green; 2369 tests pass. Co-authored-by: Georgy Butaev <41178744+g-but@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
catomean
added a commit
that referenced
this pull request
Aug 26, 2026
…at it (#773) 75 accounts still publish their email local part as a public username. Renaming them is the fix, and #769/#770 deliberately renamed nobody, because a username here is not just a display name: * a public profile URL /profiles/<username> * a LIGHTNING ADDRESS <username>@orangecat.ch (.well-known/lnurlp, /api/lnurlp/<username>/callback) A bare rename would silently break saved payment addresses and every inbound link. Silently: the wallet gets "no such recipient", nobody sees an error, and the money just does not arrive. profile_username_history makes a rename safe. The old handle keeps resolving forever, so a rename changes what a profile is CALLED without changing what can still find it: /profiles/<old> 301s to the new handle (permanentRedirect, so search engines move the entry instead of recording a 404 against the account) <old>@orangecat.ch LNURL resolves through history to the account Old handles are kept, not expired: a Lightning address someone saved has no expiry either, and a dangling payment identifier is worse than a stale row. The primary key is the old handle, so one can never be re-issued to a second account — that would silently redirect the first person's payments to somebody else. Also fixes a live matcher bug on the payment path. resolveLnurlRecipient used `.ilike('username', handle)`; ilike treats `_` as a single-character wildcard and `_` is a legal username character. Now that every newly minted handle is shaped `user_<hex>`, an ilike lookup for `user_823e4d9d2714` also matches `userX823e4d9d2714`. It now matches exactly on username_lower, the generated column added in 20260826120000. The history column is stored lowercase with a CHECK enforcing it, for the same reason: PostgREST can only filter on columns, so a lower() index would be unusable and the code would fall back to ilike again. scripts/rename-email-derived-usernames.sql performs the rename: records history first (a renamed profile with no history row is a dangling payment address), then renames, then clears the 13 display names that are also the email local part — the same leak wearing another label. It is NOT a migration: it rewrites rows for real accounts, so it is a deliberate operation someone runs and checks. It documents its own dry run and is reversible from the history table. Dry run on production: 75 to rename, 13 names to clear. npm run verify green; 2373 tests pass, including four new ones covering the case that matters — a payment sent to a retired handle still reaches its owner. Co-authored-by: Georgy Butaev <41178744+g-but@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The leak
handle_new_user()set a new profile's public username tosplit_part(NEW.email, '@', 1), and fell back to the same value for the displayname.
/profiles/<username>is served with no auth, androbots.txtallows/with no/profilesrule — so every signup published its owner's email localpart as a crawlable handle. With a handful of common domains, that
reconstructs the address.
Measured on production before this change:
username = split_part(email,'@',1)GET /profiles/maounauthenticated/profilesin robots.txtIt was reported through OrangeCat's own feedback widget — "the New Message
people picker exposes email local-parts as public usernames" — and sat "Not
started" in the Control queue.
20260818140000already fixed the worse half (usernames that were the fulladdress) and added a CHECK forbidding
@. It left the local-part derivation inplace, so this is the same leak one character shorter.
The change
usernamesplit_part(email,'@',1)'user_' || left(replace(id::text,'-',''), 12)name'User'NULLNULLis honest: the UI already falls back to the username, whereas a displayname quietly set to someone's email prefix is the same leak wearing a different
label. Users still choose their own handle via
PUT /api/profile.Bonus: a latent signup failure.
usernameisNOT NULLwith a unique index,and
ON CONFLICT (id) DO NOTHINGdoes not catch a username collision — sotwo users sharing a local part across domains (
mao@a.com,mao@b.com) meantthe second signup raised. An id-derived handle cannot collide.
What this deliberately does NOT do
It does not rename the 72 existing accounts. A username is also:
<username>@orangecat.ch, see.well-known/lnurlpand
/api/lnurlp/<username>/callbackRenaming would break saved payment addresses and inbound links for real people.
That's a product decision with its own migration path — opt-in rename, alias
retained for
lnurlp, 301 on the old profile URL — not something a schemamigration should smuggle in. Flagging it rather than doing it.
The detection half
count_email_derived_usernames()—service_role-onlySECURITY DEFINER(auth.users isn't reachable through PostgREST), returning a count, never
rows: a list of "profiles whose handle is their email prefix" is exactly what
we're trying not to publish. Same shape as the existing
count_orphaned_profiles().check-data-invariants.mjsgates on it as a ratchet against the known 72 —it may fall or hold, never rise. A zero-check would be red every night about
code that is fine, which is how a fleet learns to ignore its own gates.
Note on applying
supabase/migrations/is not applied by the deploy —fleetcrown-scripts/.../apply-schema.shprobes onlydrizzle/andprisma/migrations/, and skips everything else withexit 0. I'll apply thisby hand after merge and verify against production. Fixing that silent skip is
the next PR.
npm run verifygreen; 2349 tests pass.