Skip to content

feat(profiles): let a handle be retired without breaking what points at it - #773

Merged
catomean merged 1 commit into
mainfrom
feat/username-history
Aug 26, 2026
Merged

feat(profiles): let a handle be retired without breaking what points at it#773
catomean merged 1 commit into
mainfrom
feat/username-history

Conversation

@catomean

Copy link
Copy Markdown
Collaborator

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:

  • a public profile URL — /profiles/<username>
  • a Lightning address<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_history keeps the old handle resolving forever, so a
rename changes what a profile is called without changing what can still find
it:

old handle after rename
/profiles/<old> 301 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

Not 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

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 would also match userX823e4d9d2714 — on the lookup that
decides which wallet a payment settles into.

Now an exact match on username_lower (the generated column from
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 from the app and the code would fall back to
ilike again.

The rename itself

scripts/rename-email-derived-usernames.sql: records history first (a
renamed 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 verify green; 2373 pass. The existing lnurl suite stubbed
.ilike() — updated to the new lookup rather than bypassed — plus four new
cases 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.

…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>
@catomean
catomean merged commit de30fdb into main Aug 26, 2026
5 of 6 checks passed
@catomean
catomean deleted the feat/username-history branch August 26, 2026 13:22
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