Skip to content

fix(profiles): stop minting public handles out of people's email addresses - #769

Merged
catomean merged 1 commit into
mainfrom
fix/username-not-from-email
Aug 26, 2026
Merged

fix(profiles): stop minting public handles out of people's email addresses#769
catomean merged 1 commit into
mainfrom
fix/username-not-from-email

Conversation

@catomean

Copy link
Copy Markdown
Collaborator

The leak

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 with no auth, and robots.txt allows
/ with 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:

profiles where username = split_part(email,'@',1) 72 of 99
GET /profiles/mao unauthenticated 200
/profiles in robots.txt not disallowed

It 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.

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.

The change

field before after
username split_part(email,'@',1) 'user_' || left(replace(id::text,'-',''), 12)
name OAuth name → email local part'User' OAuth name → NULL

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. Users still choose their own handle via PUT /api/profile.

Bonus: 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.

What this deliberately does NOT do

It does not rename the 72 existing accounts. A username is also:

  • a Lightning address<username>@orangecat.ch, see .well-known/lnurlp
    and /api/lnurlp/<username>/callback
  • a public profile URL

Renaming 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 schema
migration should smuggle in. Flagging it rather than doing it.

The detection half

count_email_derived_usernames()service_role-only SECURITY 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.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.

Note on applying

supabase/migrations/ is not applied by the deploy —
fleetcrown-scripts/.../apply-schema.sh probes only drizzle/ and
prisma/migrations/, and skips everything else with exit 0. I'll apply this
by hand after merge and verify against production. Fixing that silent skip is
the next PR.

npm run verify green; 2349 tests pass.

…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
catomean merged commit bf373ad into main Aug 26, 2026
9 checks passed
@catomean
catomean deleted the fix/username-not-from-email branch August 26, 2026 12:01
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>
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