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;