Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions __tests__/unit/lightning-address/lnurl-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,22 @@ function adminReturning(
opts: { history?: { profile_id: string } | null; byId?: Record<string, unknown> | 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 }) };
},
}),
Expand Down Expand Up @@ -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 () => {
Expand Down
27 changes: 13 additions & 14 deletions src/domain/lightning-address/username-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_<hex>`, 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,
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
Loading