fix(profiles): close the two app-side paths that still minted handles from emails - #770
Merged
Merged
Conversation
… from emails 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: 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.
#769 was not enough, and the database said so. After applying that migration
to production,
count_email_derived_usernames()returned 77 — not the 72measured while writing it. Five accounts had acquired an email-derived handle in
the intervening hour, because profiles are not only created by the trigger.
Three sites were still deriving the public handle from the address
services/profile/server.ts—ensureProfile()username= the sanitized email local part. This is the one actively minting them.useProfileEditor.tsuseProfileWizard.tsThe two form pre-fills only fire on a half-loaded profile (
usernameisNOT NULL), so they were a loaded gun with no purpose — but the gun was aimedat the public handle field.
All three now use
neutralUsernameFor(userId)or an empty field. Thedisplay-name email fallback goes too: a name quietly set to someone's email
prefix is the same leak wearing another label.
neutralUsernameFormirrors the SQL in20260826130000and says so in bothdirections — two independent creation paths must not disagree about what a fresh
handle looks like.
The ratchet baseline was wrong: 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 the ratchet exists to avoid. 77 is a floor now rather than a moving
target, because 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'@'failsthe suite.
A comment would not have caught
ensureProfile— nothing did, until the countmoved. That is the whole lesson here: the trigger fix looked complete, read
complete, and shipped, and production disagreed within the hour.
Verified by reintroducing the defect and watching the gate name the exact line,
then passing on a clean tree.
npm run verifygreen; 2369 tests pass.