From b1e19452d5658b677f17d6e5d68e6e5e2b781a9d Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:20:43 +0200 Subject: [PATCH] fix(profiles): resolve a retired handle through an RPC, not a query string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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 --- .../lightning-address/lnurl-service.test.ts | 43 +++++++++++++++---- .../lightning-address/username-history.ts | 27 ++++++------ ...826180000_resolve_username_history_rpc.sql | 40 +++++++++++++++++ 3 files changed, 87 insertions(+), 23 deletions(-) create mode 100644 supabase/migrations/20260826180000_resolve_username_history_rpc.sql diff --git a/__tests__/unit/lightning-address/lnurl-service.test.ts b/__tests__/unit/lightning-address/lnurl-service.test.ts index 2744bb566..46d7a386a 100644 --- a/__tests__/unit/lightning-address/lnurl-service.test.ts +++ b/__tests__/unit/lightning-address/lnurl-service.test.ts @@ -39,18 +39,22 @@ function adminReturning( opts: { history?: { profile_id: string } | null; byId?: Record | null } = {} ) { const calls: Array<{ table: string; column: string; value: unknown }> = []; + const rpcCalls: Array<{ fn: string; args: unknown }> = []; const stub = { calls, + rpcCalls, + // History goes through an RPC, not a filter: PostgREST reads `+` in a query + // string as a space, so `.eq('old_username', 'butaeff+ocauth2')` searched + // for "butaeff ocauth2". An RPC argument travels in a JSON body. + rpc: async (fn: string, args: unknown) => { + rpcCalls.push({ fn, args }); + return { data: opts.history?.profile_id ?? null, error: null }; + }, from: (table: string) => ({ select: () => ({ eq: (column: string, value: unknown) => { calls.push({ table, column, value }); - const data = - table === 'profile_username_history' - ? (opts.history ?? null) - : column === 'id' - ? (opts.byId ?? null) - : profile; + const data = column === 'id' ? (opts.byId ?? null) : profile; return { maybeSingle: async () => ({ data, error: null }) }; }, }), @@ -83,15 +87,36 @@ describe('a handle the profile no longer uses', () => { }); }); - it('is looked up case-insensitively, like a current handle', async () => { + // The RPC lowercases and trims server-side, so callers cannot drift from the + // stored form. The handle is passed through verbatim. + it('hands the raw handle to the RPC, which canonicalises it', async () => { const stub = adminReturning(null, { history: { profile_id: 'user-1' }, byId: { id: 'user-1', username: 'user_a1b2c3d4e5f6', display_name: null }, }); mockGetAdmin.mockReturnValue(stub as never); await resolveLnurlRecipient('Georgy.Butaev'); - const historyCall = stub.calls.find((c) => c.table === 'profile_username_history'); - expect(historyCall?.value).toBe('georgy.butaev'); + expect(stub.rpcCalls[0]).toEqual({ + fn: 'resolve_username_history', + args: { handle: 'Georgy.Butaev' }, + }); + }); + + // THE regression. Two live profiles carry a '+' in their legacy handle. Sent + // as a PostgREST filter the character becomes a space server-side, so the + // owner was unfindable and a payment to that address had nowhere to go. + // Measured on production: eq.butaeff+ocauth2 -> [], eq.butaeff%2Bocauth2 -> [row]. + it("finds an owner whose old handle contains '+'", async () => { + const stub = adminReturning(null, { + history: { profile_id: 'user-1' }, + byId: { id: 'user-1', username: 'user_cbd30e0570d3', display_name: null }, + }); + mockGetAdmin.mockReturnValue(stub as never); + const recipient = await resolveLnurlRecipient('butaeff+ocauth2'); + expect(recipient?.userId).toBe('user-1'); + // Never a query-string filter — that is what mangled it. + expect(stub.calls.some((c) => c.table === 'profile_username_history')).toBe(false); + expect(stub.rpcCalls[0].args).toEqual({ handle: 'butaeff+ocauth2' }); }); it('does not resolve when the account behind it is gone', async () => { diff --git a/src/domain/lightning-address/username-history.ts b/src/domain/lightning-address/username-history.ts index ec5b23e3d..ce9da0846 100644 --- a/src/domain/lightning-address/username-history.ts +++ b/src/domain/lightning-address/username-history.ts @@ -12,17 +12,22 @@ * changes what a profile is CALLED without changing what can still find it. */ -import { DATABASE_TABLES } from '@/config/database-tables'; import type { SupabaseClient } from '@supabase/supabase-js'; /** * The profile id a retired handle used to belong to, or null. * - * Matches on `lower(old_username)` rather than `ilike`. `ilike` treats `_` as a - * single-character wildcard and `_` is a legal username character — with every - * newly minted handle now shaped `user_`, an `ilike` lookup for - * `user_823e4d9d2714` would also match `userX823e4d9d2714`. On a lookup that - * decides where money goes, "close enough" is the wrong matcher. + * Goes through an RPC rather than a PostgREST filter. 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, on two live profiles whose + * legacy handles contain '+'. For those the profile redirect 404'd and a + * Lightning payment could not find its owner, which is the precise failure + * this table exists to prevent. + * + * An RPC argument travels in a JSON body, so nothing needs escaping and no + * future handle can be mangled by the transport. The function lowercases and + * trims server-side, so callers cannot drift from the stored form either. */ export async function resolveHistoricalUsername( client: SupabaseClient, @@ -32,12 +37,6 @@ export async function resolveHistoricalUsername( if (!trimmed) { return null; } - const { data } = await client - .from(DATABASE_TABLES.PROFILE_USERNAME_HISTORY) - .select('profile_id') - .eq('old_username', trimmed.toLowerCase()) - .maybeSingle(); - - const row = data as { profile_id?: string } | null; - return row?.profile_id ?? null; + const { data } = await client.rpc('resolve_username_history', { handle: trimmed }); + return typeof data === 'string' && data ? data : null; } diff --git a/supabase/migrations/20260826180000_resolve_username_history_rpc.sql b/supabase/migrations/20260826180000_resolve_username_history_rpc.sql new file mode 100644 index 000000000..7fbc51671 --- /dev/null +++ b/supabase/migrations/20260826180000_resolve_username_history_rpc.sql @@ -0,0 +1,40 @@ +-- Resolve a retired handle through an RPC, not a query-string filter. +-- +-- PostgREST reads `+` in a query string as a space, and supabase-js sends the +-- character raw. So `.eq('old_username', 'butaeff+ocauth2')` searches for +-- "butaeff ocauth2" and finds nothing: +-- +-- ...&old_username=eq.butaeff+ocauth2 -> [] +-- ...&old_username=eq.butaeff%2Bocauth2 -> [{...}] +-- +-- Verified against production 2026-08-26. It is not hypothetical: two live +-- profiles carry a '+' (legacy handles minted from email local parts, back +-- when the address went in verbatim), and for those the profile redirect +-- returned 404 and the Lightning-address fallback could not find its owner — +-- exactly the silent breakage profile_username_history exists to prevent. +-- +-- An RPC takes its argument in a JSON body, so no character needs escaping and +-- no future handle can be mangled by the transport. Same reasoning as the rest +-- of this table: it decides where a payment goes, so the lookup has to be exact +-- for every input, not for the convenient ones. + +CREATE OR REPLACE FUNCTION public.resolve_username_history(handle text) +RETURNS uuid +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path TO 'public' +AS $$ + SELECT profile_id + FROM public.profile_username_history + WHERE old_username = lower(btrim(handle)) + LIMIT 1; +$$; + +COMMENT ON FUNCTION public.resolve_username_history(text) IS + 'Profile id behind a retired handle, or null. Called instead of a PostgREST filter because a query string mangles "+" into a space.'; + +-- Public on purpose: 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. +GRANT EXECUTE ON FUNCTION public.resolve_username_history(text) TO anon, authenticated, service_role;