fix(profiles): resolve a retired handle through an RPC, not a query string - #776
Merged
Conversation
…tring
PostgREST reads `+` in a query string as a space, and supabase-js sends
the character raw. So `.eq('old_username', 'butaeff+ocauth2')` searched
for "butaeff ocauth2" and found nothing. Measured against production:
...&old_username=eq.butaeff+ocauth2 -> []
...&old_username=eq.butaeff%2Bocauth2 -> [{profile_id: ...}]
Not hypothetical. Two live profiles carry a '+' in their legacy handle —
minted back when the whole email address went in verbatim — and for those
the profile redirect returned 404 and the Lightning-address fallback
could not find its owner. That is exactly the silent breakage
profile_username_history was added to prevent, so the mechanism was
failing on the first accounts it had to protect.
Found by renaming one canary account and checking the result rather than
assuming it worked: /profiles/<old> 404'd. A clean synthetic alias
redirected 308 correctly, which isolated the fault to the '+' rather than
the mechanism.
resolve_username_history(handle) takes its argument in a JSON body, so
nothing needs escaping and no future handle can be mangled by the
transport. It lowercases and trims server-side, so callers cannot drift
from the stored form either. Public execute: resolving an old handle is
exactly as public as resolving a current one — both the profile page and
the LNURL endpoint are unauthenticated — and it returns an id, never a
list.
Tests updated rather than bypassed: the lnurl suite's stub now models
.rpc(), and a new case pins the '+' handle resolving to its owner AND
never going through a query-string filter, which is the thing that
mangled it.
npm run verify green; 2378 tests pass.
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.
Follow-up to #773. The alias mechanism was failing on the first accounts it had to protect.
What broke
PostgREST reads
+in a query string as a space, and supabase-js sends thecharacter raw. So
.eq('old_username', 'butaeff+ocauth2')searched for"butaeff ocauth2". Measured against production:Not hypothetical. Two live profiles carry a
+in their legacy handle —minted back when the whole email address went in verbatim — and for those the
profile redirect returned 404 and the Lightning-address fallback could not
find its owner. That is precisely the silent breakage
profile_username_historyexists to prevent.How it was found
By renaming one canary account and checking the result instead of assuming
the mechanism worked:
/profiles/butaeff%2Bocauth2(renamed canary)/profiles/zzz-alias-probe(clean synthetic alias)The clean alias isolated the fault to the
+, not the mechanism. Had I renamedall 75 accounts first and reported success, two of them would have been quietly
unreachable.
The fix
resolve_username_history(handle)takes its argument in a JSON body, sonothing needs escaping and no future handle can be mangled by the transport. It
lowercases and trims server-side, so callers cannot drift from the stored
form either.
Public execute: resolving an old handle is exactly as public as resolving a
current one — both the profile page and the LNURL endpoint are unauthenticated —
and it returns an id, never a list.
Tests
Updated rather than bypassed: the lnurl stub now models
.rpc(), plus a newcase pinning that a
+handle resolves to its owner and never goes through aquery-string filter, which is the thing that mangled it.
npm run verifygreen; 2378 tests pass.