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
90 changes: 83 additions & 7 deletions __tests__/unit/lightning-address/lnurl-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,99 @@ jest.mock('@/lib/supabase/admin', () => ({
const mockResolveWallet = resolveUserWallet as jest.MockedFunction<typeof resolveUserWallet>;
const mockGetAdmin = getAdminClient as jest.MockedFunction<typeof getAdminClient>;

/** Admin stub whose profiles `.ilike(...).maybeSingle()` resolves to `profile`. */
function adminReturning(profile: Record<string, unknown> | 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_<hex>` 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<string, unknown> | null,
opts: { history?: { profile_id: string } | null; byId?: Record<string, unknown> | 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);
Expand Down
69 changes: 69 additions & 0 deletions scripts/rename-email-derived-usernames.sql
Original file line number Diff line number Diff line change
@@ -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/<old> keeps working — it 301s to the new handle
-- * <old>@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;
21 changes: 20 additions & 1 deletion src/app/profiles/[username]/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 }>;
Expand Down Expand Up @@ -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();
}

Expand Down
1 change: 1 addition & 0 deletions src/config/database-tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down
39 changes: 34 additions & 5 deletions src/domain/lightning-address/lnurl-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -39,20 +40,48 @@ export async function resolveLnurlRecipient(username: string): Promise<LnurlReci
if (!handle) {
return null;
}
// username_lower is a generated column (20260826120000), so this is an exact
// match on the canonical form. It replaces `ilike`, which treats `_` as a
// single-character wildcard — and `_` is a legal username character. Now that
// every newly minted handle is shaped `user_<hex>`, 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,
};
}

Expand Down
43 changes: 43 additions & 0 deletions src/domain/lightning-address/username-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Resolving a handle a profile no longer uses.
*
* A username here is a public profile URL AND a Lightning address
* (`<username>@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_<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.
*/
export async function resolveHistoricalUsername(
client: SupabaseClient,
handle: string
): Promise<string | null> {
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;
}
57 changes: 57 additions & 0 deletions supabase/migrations/20260826160000_profile_username_history.sql
Original file line number Diff line number Diff line change
@@ -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/<username>
-- * it is a LIGHTNING ADDRESS <username>@orangecat.ch
-- (.well-known/lnurlp, /api/lnurlp/<username>/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_<hex>`, 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);
Loading