feat(profiles): let a handle be retired without breaking what points at it - #773
Merged
Conversation
…at it 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: 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.
75 accounts still publish their email local part as a public username. #769/#770
stopped minting new ones and deliberately renamed nobody — because a
username here is not just a display name:
/profiles/<username><username>@orangecat.ch(
.well-known/lnurlp,/api/lnurlp/<username>/callback)A bare rename would break saved payment addresses and every inbound link.
Silently: the wallet gets "no such recipient", nobody sees an error, and the
money just doesn't arrive.
What makes renaming safe
profile_username_historykeeps the old handle resolving forever, so arename changes what a profile is called without changing what can still find
it:
/profiles/<old>permanentRedirect, so search engines move the entry instead of recording a 404 against the account)<old>@orangecat.chNot expired: a saved Lightning address 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.
A live matcher bug on the payment path, fixed here
resolveLnurlRecipientused.ilike('username', handle).iliketreats_as a single-character wildcard, and
_is a legal username character. Nowthat every newly minted handle is shaped
user_<hex>, an ilike lookup foruser_823e4d9d2714would also matchuserX823e4d9d2714— on the lookup thatdecides which wallet a payment settles into.
Now an exact match on
username_lower(the generated column from20260826120000). The history column is stored lowercase with aCHECKenforcing it, for the same reason: PostgREST can only filter on columns, so
a
lower()index would be unusable from the app and the code would fall back toilikeagain.The rename itself
scripts/rename-email-derived-usernames.sql: records history first (arenamed profile with no history row is a dangling payment address), then
renames, then clears the display names that are also the email local part —
the same leak wearing another label.
Not a migration, on purpose. It rewrites rows for real accounts, so it's 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.
Tests
npm run verifygreen; 2373 pass. The existing lnurl suite stubbed.ilike()— updated to the new lookup rather than bypassed — plus four newcases covering what actually matters: a payment sent to a retired handle still
reaches its owner, case-insensitively, and returns null rather than a wrong
recipient when the account is gone.