diff --git a/__tests__/unit/lightning-address/lnurl-service.test.ts b/__tests__/unit/lightning-address/lnurl-service.test.ts index 656deff95..2744bb566 100644 --- a/__tests__/unit/lightning-address/lnurl-service.test.ts +++ b/__tests__/unit/lightning-address/lnurl-service.test.ts @@ -22,23 +22,99 @@ jest.mock('@/lib/supabase/admin', () => ({ const mockResolveWallet = resolveUserWallet as jest.MockedFunction; const mockGetAdmin = getAdminClient as jest.MockedFunction; -/** Admin stub whose profiles `.ilike(...).maybeSingle()` resolves to `profile`. */ -function adminReturning(profile: Record | null) { - return { - from: () => ({ +/** + * Table-aware admin stub. + * + * `profiles` is now looked up with `.eq('username_lower', ...)`, not `.ilike`. + * That is the point of the change these tests cover: ilike treats `_` as a + * single-character wildcard and `_` is legal in a username, so with every new + * handle shaped `user_` an ilike lookup for `user_823e4d9d2714` also + * matches `userX823e4d9d2714` — on the lookup that decides where a payment + * settles. + * + * `byId` serves the second profiles read, after a hit in username history. + */ +function adminReturning( + profile: Record | null, + opts: { history?: { profile_id: string } | null; byId?: Record | null } = {} +) { + const calls: Array<{ table: string; column: string; value: unknown }> = []; + const stub = { + calls, + from: (table: string) => ({ select: () => ({ - ilike: () => ({ - maybeSingle: async () => ({ data: profile, error: null }), - }), + 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; + return { maybeSingle: async () => ({ data, error: null }) }; + }, }), }), }; + return stub; } beforeEach(() => { jest.clearAllMocks(); }); +describe('a handle the profile no longer uses', () => { + // 75 accounts published their email local part as a public handle and are + // being renamed. A username here is a Lightning address, and a saved + // Lightning address has no expiry — so if a rename made the old one stop + // resolving, somebody's next payment would fail with "no such recipient" + // and nobody would get an error report. + it('still resolves to the account, under its NEW handle', async () => { + mockGetAdmin.mockReturnValue( + adminReturning(null, { + history: { profile_id: 'user-1' }, + byId: { id: 'user-1', username: 'user_a1b2c3d4e5f6', display_name: 'Mao' }, + }) as never + ); + expect(await resolveLnurlRecipient('georgy.butaev')).toEqual({ + userId: 'user-1', + username: 'user_a1b2c3d4e5f6', + displayName: 'Mao', + }); + }); + + it('is looked up case-insensitively, like a current handle', 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'); + }); + + it('does not resolve when the account behind it is gone', async () => { + mockGetAdmin.mockReturnValue( + adminReturning(null, { history: { profile_id: 'user-1' }, byId: null }) as never + ); + expect(await resolveLnurlRecipient('georgy.butaev')).toBeNull(); + }); + + it('never uses a wildcard matcher on the profiles lookup', async () => { + // `_` is legal in a username and ilike treats it as a wildcard, so + // `user_823e4d9d2714` would also match `userX823e4d9d2714`. Exact only. + const stub = adminReturning({ id: 'u', username: 'alice', display_name: 'A' }); + mockGetAdmin.mockReturnValue(stub as never); + await resolveLnurlRecipient('Alice'); + expect(stub.calls[0]).toEqual({ + table: 'profiles', + column: 'username_lower', + value: 'alice', + }); + }); +}); + describe('resolveLnurlRecipient', () => { it('returns null for an unknown username', async () => { mockGetAdmin.mockReturnValue(adminReturning(null) as never); diff --git a/scripts/rename-email-derived-usernames.sql b/scripts/rename-email-derived-usernames.sql new file mode 100644 index 000000000..192d91931 --- /dev/null +++ b/scripts/rename-email-derived-usernames.sql @@ -0,0 +1,69 @@ +-- Retire the handles that publish someone's email address. +-- +-- WHAT: every profile whose public username is exactly its owner's email local +-- part gets a neutral `user_<12 hex>` handle, and any display name that is also +-- the email local part is cleared. The old handle is recorded in +-- profile_username_history first, so: +-- +-- * /profiles/ keeps working — it 301s to the new handle +-- * @orangecat.ch keeps working — LNURL resolves through history +-- +-- That is the whole reason this is safe to run. A username here is a payment +-- identifier, not just a label; without the history row this script would +-- silently break saved Lightning addresses and every inbound link, and nobody +-- would find out until a payment failed to arrive. +-- +-- NOT a migration on purpose. It rewrites rows for real accounts, so it is a +-- deliberate operation someone runs and checks, not something that rides along +-- with a deploy. (apply-schema.sh would refuse it anyway — see the destructive +-- guard.) +-- +-- DRY RUN first — this prints what would change and touches nothing: +-- +-- sudo docker exec supabase-db psql -U postgres -c " +-- SELECT p.username AS old_handle, +-- 'user_' || left(replace(p.id::text,'-',''),12) AS new_handle, +-- (p.name = split_part(u.email,'@',1)) AS name_also_leaks +-- FROM public.profiles p JOIN auth.users u ON u.id = p.id +-- WHERE p.username = split_part(u.email,'@',1) ORDER BY 1;" +-- +-- THEN: sudo docker exec -i supabase-db psql -U postgres -v ON_ERROR_STOP=1 \ +-- -f - < scripts/rename-email-derived-usernames.sql +-- +-- REVERSIBLE: profile_username_history holds the old handle for every row this +-- touches, so `UPDATE profiles SET username = h.old_username FROM +-- profile_username_history h WHERE h.profile_id = profiles.id` puts them back. + +BEGIN; + +-- Record first. If this half fails the rename never happens, which is the +-- correct order: a renamed profile with no history row is a dangling payment +-- address. +INSERT INTO public.profile_username_history (old_username, profile_id) +SELECT lower(p.username), p.id +FROM public.profiles p +JOIN auth.users u ON u.id = p.id +WHERE p.username = split_part(u.email, '@', 1) +ON CONFLICT (old_username) DO NOTHING; + +-- Same shape as handle_new_user() and neutralUsernameFor(); see +-- 20260826130000. A uuid-derived handle cannot collide, so the unique index +-- cannot abort this. +UPDATE public.profiles p +SET username = 'user_' || left(replace(p.id::text, '-', ''), 12), + updated_at = now() +FROM auth.users u +WHERE u.id = p.id + AND p.username = split_part(u.email, '@', 1); + +-- A display name set to the email local part is the same leak wearing another +-- label. NULL rather than a placeholder: the UI already falls back to the +-- handle, and inventing a name for someone is worse than showing none. +UPDATE public.profiles p +SET name = NULL, + updated_at = now() +FROM auth.users u +WHERE u.id = p.id + AND p.name = split_part(u.email, '@', 1); + +COMMIT; diff --git a/src/app/profiles/[username]/page.tsx b/src/app/profiles/[username]/page.tsx index e859c85d7..1d63549ba 100644 --- a/src/app/profiles/[username]/page.tsx +++ b/src/app/profiles/[username]/page.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next'; import { createServerClient } from '@/lib/supabase/server'; -import { notFound, redirect } from 'next/navigation'; +import { notFound, redirect, permanentRedirect } from 'next/navigation'; import ProfilePageClient from '@/components/profile/ProfilePageClient'; import { DATABASE_TABLES } from '@/config/database-tables'; import { getTableName } from '@/config/entity-registry'; @@ -15,6 +15,7 @@ import { countActiveProfileWallets } from '@/services/wallets/countPublicWallets import { ROUTES } from '@/config/routes'; import { APP_NAME, APP_KICKER, SITE_URL } from '@/config/brand'; import { applyProfilePrivacy } from '@/config/profile-privacy'; +import { resolveHistoricalUsername } from '@/domain/lightning-address/username-history'; interface PageProps { params: Promise<{ username: string }>; @@ -161,6 +162,24 @@ export default async function PublicProfilePage({ params }: PageProps) { const profile = profileData as unknown as ScalableProfile; if (profileError || !profile) { + // Before 404ing, check whether this handle is one an account used to have. + // Renaming the profiles that published their email local part would + // otherwise dead-link every inbound URL pointing at the old handle — and + // the person who lost the traffic would never find out. A permanent + // redirect also tells search engines to move their index entry rather than + // record a 404 against the account. + const movedTo = await resolveHistoricalUsername(supabase, targetUsername); + if (movedTo) { + const { data: currentData } = await supabase + .from(DATABASE_TABLES.PROFILES) + .select('username') + .eq('id', movedTo) + .single(); + const current = currentData as { username: string | null } | null; + if (current?.username) { + permanentRedirect(ROUTES.PROFILES.VIEW(current.username)); + } + } notFound(); } diff --git a/src/config/database-tables.ts b/src/config/database-tables.ts index 11dcdfab6..0eedb2a2c 100644 --- a/src/config/database-tables.ts +++ b/src/config/database-tables.ts @@ -19,6 +19,7 @@ export const DATABASE_TABLES = { // Social PROFILES: 'profiles', PROFILE_CLAIMS: 'profile_claims', + PROFILE_USERNAME_HISTORY: 'profile_username_history', FOLLOWS: 'follows', ACTORS: 'actors', diff --git a/src/domain/lightning-address/lnurl-service.ts b/src/domain/lightning-address/lnurl-service.ts index 97bbf21fc..f6a234344 100644 --- a/src/domain/lightning-address/lnurl-service.ts +++ b/src/domain/lightning-address/lnurl-service.ts @@ -16,6 +16,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'; import { getAdminClient } from '@/lib/supabase/admin'; import { DATABASE_TABLES } from '@/config/database-tables'; +import { resolveHistoricalUsername } from './username-history'; import { resolveUserWallet } from '@/domain/payments/walletResolutionService'; import type { ResolvedWallet } from '@/domain/payments/types'; @@ -39,20 +40,48 @@ export async function resolveLnurlRecipient(username: string): Promise`, an ilike lookup for + // `user_823e4d9d2714` would also match `userX823e4d9d2714`. This lookup + // decides which wallet a payment settles into; it cannot be approximate. const { data } = await admin() .from(DATABASE_TABLES.PROFILES) .select('id, username, display_name:name') - .ilike('username', handle) + .eq('username_lower', handle.toLowerCase()) .maybeSingle(); const row = data as { id?: string; username?: string; display_name?: string } | null; - if (!row?.id || !row.username) { + if (row?.id && row.username) { + return { + userId: row.id, + username: row.username, + displayName: row.display_name || row.username, + }; + } + + // Not a current handle. It may be one this profile used to have: a Lightning + // address someone saved has no expiry, so a rename must not turn their next + // payment into "no such recipient". + const historicalId = await resolveHistoricalUsername(admin(), handle); + if (!historicalId) { + return null; + } + const { data: current } = await admin() + .from(DATABASE_TABLES.PROFILES) + .select('id, username, display_name:name') + .eq('id', historicalId) + .maybeSingle(); + + const owner = current as { id?: string; username?: string; display_name?: string } | null; + if (!owner?.id || !owner.username) { return null; } return { - userId: row.id, - username: row.username, - displayName: row.display_name || row.username, + userId: owner.id, + username: owner.username, + displayName: owner.display_name || owner.username, }; } diff --git a/src/domain/lightning-address/username-history.ts b/src/domain/lightning-address/username-history.ts new file mode 100644 index 000000000..ec5b23e3d --- /dev/null +++ b/src/domain/lightning-address/username-history.ts @@ -0,0 +1,43 @@ +/** + * Resolving a handle a profile no longer uses. + * + * A username here is a public profile URL AND a Lightning address + * (`@orangecat.ch`). So when an account is renamed — which the fleet + * has to do for the 77 profiles still publishing their email local part — every + * saved payment address and inbound link pointing at the old handle would + * silently stop working. Silently: a wallet gets "no such recipient", nobody + * gets an error report, and the money simply does not arrive. + * + * 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. + */ + +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. + */ +export async function resolveHistoricalUsername( + client: SupabaseClient, + handle: string +): Promise { + const trimmed = handle.trim(); + 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; +} diff --git a/supabase/migrations/20260826160000_profile_username_history.sql b/supabase/migrations/20260826160000_profile_username_history.sql new file mode 100644 index 000000000..089cb9796 --- /dev/null +++ b/supabase/migrations/20260826160000_profile_username_history.sql @@ -0,0 +1,57 @@ +-- Let a handle change without breaking what already points at it. +-- +-- 77 accounts still publish their email local part as a username +-- (20260826130000 stopped minting new ones; it deliberately renamed nobody). +-- Renaming them is the fix, but a username here is not just a display name: +-- +-- * it is a public profile URL /profiles/ +-- * it is a LIGHTNING ADDRESS @orangecat.ch +-- (.well-known/lnurlp, /api/lnurlp//callback) +-- +-- So a bare rename would silently break saved payment addresses and every +-- inbound link — for real people, with no error anyone would see until a +-- payment failed. This table is what makes renaming safe: the old handle keeps +-- resolving forever, so a rename changes what a profile is CALLED without +-- changing what can still find it. +-- +-- Kept forever, not expired: a Lightning address someone saved in 2025 has no +-- expiry either, and a dangling payment identifier is worse than a stale row. + +CREATE TABLE IF NOT EXISTS public.profile_username_history ( + -- The handle as it used to be. Primary key: one old handle can only ever + -- have belonged to one account, and re-issuing it to somebody else would + -- silently redirect the first person's payments to the second. + -- Stored lowercase, enforced. PostgREST can only filter on COLUMNS, not on + -- expressions, so a `lower(old_username)` index would be unusable from the + -- app and the lookup would fall back to `ilike` — which treats `_` as a + -- wildcard, and `_` is legal in a username. With every new handle shaped + -- `user_`, an ilike lookup for `user_823e4d9d2714` also matches + -- `userX823e4d9d2714`. On a lookup that decides where money goes, the + -- matcher has to be exact, so the column holds the canonical form. + old_username text PRIMARY KEY CHECK (old_username = lower(old_username)), + profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + changed_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE public.profile_username_history IS + 'Handles a profile used to have. Old profile URLs 301 here and Lightning addresses still resolve through it, so renaming an account breaks nothing that already points at it.'; + +CREATE INDEX IF NOT EXISTS profile_username_history_profile_id_idx + ON public.profile_username_history (profile_id); + +ALTER TABLE public.profile_username_history ENABLE ROW LEVEL SECURITY; + +-- Readable by anyone, like the profiles it points at: resolving an old handle +-- is exactly as public as resolving the current one, and both the profile page +-- and the LNURL endpoint are unauthenticated. It holds no more than a mapping +-- between two public handles. +CREATE POLICY "username history is public" + ON public.profile_username_history FOR SELECT + USING (true); + +-- Writes only through the rename path (service_role). A user who could insert +-- here could claim someone else's old handle and capture their payments. +CREATE POLICY "username history is service-write only" + ON public.profile_username_history FOR ALL + TO service_role + USING (true) WITH CHECK (true);