Skip to content

fix(profiles): close the two app-side paths that still minted handles from emails - #770

Merged
catomean merged 1 commit into
mainfrom
fix/username-ratchet-baseline
Aug 26, 2026
Merged

fix(profiles): close the two app-side paths that still minted handles from emails#770
catomean merged 1 commit into
mainfrom
fix/username-ratchet-baseline

Conversation

@catomean

Copy link
Copy Markdown
Collaborator

#769 was not enough, and the database said so. After applying that migration
to production, 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 trigger.

Three sites were still deriving the public handle from the address

site what it did
services/profile/server.tsensureProfile() creates a profile when one is missing, with username = the sanitized email local part. This is the one actively minting them.
useProfileEditor.ts pre-filled the username field with the email local part — one Save from publishing it
useProfileWizard.ts same, on onboarding

The two form pre-fills only fire on a half-loaded profile (username is
NOT NULL), so they were a loaded gun with no purpose — but the gun was aimed
at the public handle field.

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 — 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 '@' fails
the suite.

A comment would not have caught ensureProfilenothing did, until the count
moved.
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 verify green; 2369 tests pass.

… 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
catomean merged commit 56df584 into main Aug 26, 2026
5 checks passed
@catomean
catomean deleted the fix/username-ratchet-baseline branch August 26, 2026 12:17
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