From 0660a784dcb2666db9948b94a96a39ddeb9ac6a4 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:33:52 +0200 Subject: [PATCH] fix(profile): a rename recorded nothing, so the old handle died silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported by a user: asked to change their handle, the Cat replied that usernames "cannot be changed once set" because it "would break all existing links". It was not inventing that — its brief said "Never update username (it breaks public URLs)" and the handler excluded the field for the same reason. Both were true until profile_username_history (20260826160000), which exists precisely so a rename breaks nothing: /profiles/ 301s to the new handle and @orangecat.ch keeps resolving. Neither the prompt nor the handler was corrected afterwards, so the product went on refusing something it could do. Underneath the wrong answer was a real bug. PUT /api/profile has always accepted a new username, and the profile editor has always shown an editable handle field — but neither wrote the history row. The only writer was the one-off admin script. So a user who renamed themselves through the UI got exactly the breakage they were warned about: old profile URL 404s, Lightning address stops resolving, no error to anyone, and a payment sent to the address they had published simply does not arrive. Measured against production before this change: renaming a live profile recorded zero history rows. The rule now lives in the database, not in the route. A username is a payment identifier, so "a rename is recorded" has to hold for the route, for this repo's SQL scripts, for a psql session, and for whatever gets written next — and there is exactly one place a username can change. The same trigger refuses a handle another account retired: the profile page and LNURL both resolve live profiles FIRST, so reissuing one would hand the new holder the previous owner's inbound links and payments. 20260826160000 named that risk in a comment; nothing enforced it, and availability was checked against profiles alone, so every retired handle read as free. Verified against the production schema in a rolled-back transaction: a rename records, a reissue is refused, reclaiming your own handle clears its retired row, and neither an unchanged save nor a case-only change retires anything. Without the migration the first check fails on real data. The Cat can now do it too. Folded into update_profile rather than a new action: the static prompt sits one character under a budget that only ratchets down, so the capability is paid for by compressing the same line instead of raising the ceiling. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn --- .../unit/cat/action-executor-columns.test.ts | 11 +- __tests__/unit/cat/rename-handle.test.ts | 152 ++++++++++++++++++ .../retired-handle-not-available.test.ts | 86 ++++++++++ src/config/cat-actions.ts | 9 +- src/lib/profile-guidance.ts | 2 +- src/services/cat/action-descriptions.ts | 7 + src/services/cat/handlers/context.ts | 71 +++++++- src/services/cat/system-prompt.ts | 2 +- src/services/profile/server.ts | 22 ++- ...a_rename_records_the_handle_it_retired.sql | 92 +++++++++++ 10 files changed, 441 insertions(+), 13 deletions(-) create mode 100644 __tests__/unit/cat/rename-handle.test.ts create mode 100644 __tests__/unit/profile/retired-handle-not-available.test.ts create mode 100644 supabase/migrations/20260829090000_a_rename_records_the_handle_it_retired.sql diff --git a/__tests__/unit/cat/action-executor-columns.test.ts b/__tests__/unit/cat/action-executor-columns.test.ts index eab408b8a..bdab8fcc9 100644 --- a/__tests__/unit/cat/action-executor-columns.test.ts +++ b/__tests__/unit/cat/action-executor-columns.test.ts @@ -1349,11 +1349,15 @@ describe('Cat action-executor — correct DB column names', () => { expect(update!.location_country).toBe('CH'); }); - it('filters out unsafe fields — username must never appear in update payload', async () => { + it('filters out unsafe fields — email and id must never appear in update payload', async () => { + // username used to be asserted here too, as a field that must always be + // dropped. That stopped being true once profile_username_history made a + // rename safe: the handle is now updatable, validated apart from the + // free-text fields, and covered in cat/rename-handle.test.ts. email and id + // are still never writable from a chat action. const supabase = buildMockSupabase(); await run(supabase, 'update_profile', { bio: 'Safe bio', - username: 'hacked_username', // must be silently dropped email: 'hacker@evil.com', // must be silently dropped id: 'injected-id', // must be silently dropped }); @@ -1361,7 +1365,6 @@ describe('Cat action-executor — correct DB column names', () => { const update = getEntityUpdate(supabase, DATABASE_TABLES.PROFILES); expect(update!.bio).toBe('Safe bio'); // Unsafe fields must NOT appear in the DB update - expect((update as Record).username).toBeUndefined(); expect((update as Record).email).toBeUndefined(); expect((update as Record).id).toBeUndefined(); }); @@ -1385,8 +1388,8 @@ describe('Cat action-executor — correct DB column names', () => { const supabase = buildMockSupabase(); const result = await run(supabase, 'update_profile', { // Only unsafe fields — nothing valid to update - username: 'blocked', email: 'blocked@example.com', + id: 'injected-id', }); expect(result.status).toBe('failed'); diff --git a/__tests__/unit/cat/rename-handle.test.ts b/__tests__/unit/cat/rename-handle.test.ts new file mode 100644 index 000000000..8fb074537 --- /dev/null +++ b/__tests__/unit/cat/rename-handle.test.ts @@ -0,0 +1,152 @@ +/** + * The Cat can change your @handle, and says what happens to the old one. + * + * Reported by a user 2026-08-29: asked to change their handle, the Cat replied + * that "usernames cannot be changed once set" because "changing it would break + * all existing links", and offered a display-name change instead. It was not + * inventing that. Its brief said "Never update username (it breaks public + * URLs)", and the handler excluded the field for the same stated reason. + * + * Both were true until profile_username_history (20260826160000) made a rename + * safe, and neither was corrected afterwards. That is the failure worth pinning: + * a rule outlived the constraint that justified it, and a stale instruction is a + * second source of truth about what the product can do — one that does not get + * updated when the database gains a capability. + * + * So what is asserted here is the whole user-visible contract, not just the + * write: the rename happens, and the reply says the old handle still works. + * That fact is the reason it is safe, and a rename announced without it reads + * exactly like the breakage the user was wrongly warned about. + */ + +import { contextHandlers } from '@/services/cat/handlers/context'; +import { ProfileServerService } from '@/services/profile/server'; + +jest.mock('@/utils/logger', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); +jest.mock('@/services/profile/server', () => ({ + ProfileServerService: { checkUsernameAvailability: jest.fn() }, +})); + +const availabilityMock = ProfileServerService.checkUsernameAvailability as jest.Mock; + +const USER = 'cec88bc9-0000-0000-0000-000000000001'; + +/** Records the write the handler attempted, without a real Supabase. */ +function makeSupabase( + currentUsername: string | null, + updateError: { code?: string; message: string } | null = null +) { + const writes: Record[] = []; + const from = () => { + let isUpdate = false; + const chain: Record = {}; + const self = () => chain; + Object.assign(chain, { + select: self, + eq: self, + update: (values: Record) => { + isUpdate = true; + writes.push(values); + return chain; + }, + single: () => + isUpdate + ? Promise.resolve({ data: null, error: updateError }) + : Promise.resolve({ data: { username: currentUsername }, error: null }), + }); + return chain; + }; + return { supabase: { from } as never, writes }; +} + +const run = (supabase: never, params: Record) => + contextHandlers.update_profile(supabase, USER, 'actor-1', params); + +describe('update_profile — changing the @handle', () => { + beforeEach(() => { + jest.clearAllMocks(); + availabilityMock.mockResolvedValue(true); + }); + + it('renames the account and tells them the old handle still works', async () => { + const { supabase, writes } = makeSupabase('mao'); + + const result = await run(supabase, { username: 'catomean' }); + + expect(result.success).toBe(true); + expect(writes[0]).toMatchObject({ username: 'catomean' }); + // Both halves of the promise, named explicitly: the old profile URL and the + // old Lightning address. Asserting only "success" would let the Cat land the + // rename and still leave the user believing they had broken something. + const message = String((result.data as { displayMessage: string }).displayMessage); + expect(message).toContain('@catomean'); + expect(message).toContain('mao@orangecat.ch'); + expect(message.toLowerCase()).toContain('redirect'); + }); + + it('accepts a handle typed with the @ the user sees everywhere', async () => { + const { supabase, writes } = makeSupabase('mao'); + + const result = await run(supabase, { username: '@catomean' }); + + expect(result.success).toBe(true); + expect(writes[0]).toMatchObject({ username: 'catomean' }); + }); + + it('refuses a reserved handle through the same schema the signup form uses', async () => { + const { supabase, writes } = makeSupabase('mao'); + + const result = await run(supabase, { username: 'cat' }); + + expect(result.success).toBe(false); + expect(writes).toHaveLength(0); + }); + + it('refuses a handle that is taken', async () => { + availabilityMock.mockResolvedValue(false); + const { supabase, writes } = makeSupabase('mao'); + + const result = await run(supabase, { username: 'catomean' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('taken'); + expect(writes).toHaveLength(0); + }); + + it('reports the database guard as "taken", not as a raw error', async () => { + // profiles_username_rename_guard raises unique_violation for a handle + // another account retired. It is the authority, not the availability check: + // that check can go stale between reading and writing, the trigger cannot — + // and the user should read the same sentence either way. + const { supabase } = makeSupabase('mao', { code: '23505', message: 'unique_violation' }); + + const result = await run(supabase, { username: 'catomean' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('taken'); + }); + + it('does not check availability against the handle they already have', async () => { + // Saving an unchanged handle is not a rename. Treating it as one would tell + // people their own handle is taken. + const { supabase, writes } = makeSupabase('catomean'); + + const result = await run(supabase, { username: 'catomean', bio: 'hello' }); + + expect(result.success).toBe(true); + expect(availabilityMock).not.toHaveBeenCalled(); + expect(writes[0]).not.toHaveProperty('username'); + expect(writes[0]).toMatchObject({ bio: 'hello' }); + }); + + it('still updates ordinary fields without touching the handle', async () => { + const { supabase, writes } = makeSupabase('mao'); + + const result = await run(supabase, { bio: 'freelance photographer' }); + + expect(result.success).toBe(true); + expect(writes[0]).not.toHaveProperty('username'); + }); +}); diff --git a/__tests__/unit/profile/retired-handle-not-available.test.ts b/__tests__/unit/profile/retired-handle-not-available.test.ts new file mode 100644 index 000000000..f8766aa4e --- /dev/null +++ b/__tests__/unit/profile/retired-handle-not-available.test.ts @@ -0,0 +1,86 @@ +/** + * A handle nobody uses any more is not a free handle. + * + * A retired handle still resolves: /profiles/ 301s to its owner's current + * handle, and @orangecat.ch still reaches them through + * profile_username_history. Both lookups consult the LIVE profiles table first + * and only fall back to history on a miss — so if a retired handle were handed + * to somebody else, that person would silently intercept everything still + * pointing at its previous owner, including payments to a Lightning address + * they had published. + * + * 20260826160000 named this risk in a comment and nothing enforced it: + * availability was checked against profiles alone, so every retired handle read + * as available. + */ + +import { ProfileServerService } from '@/services/profile/server'; +import { resolveHistoricalUsername } from '@/domain/lightning-address/username-history'; + +jest.mock('@/utils/logger', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); +jest.mock('@/domain/lightning-address/username-history', () => ({ + resolveHistoricalUsername: jest.fn(), +})); + +const resolveHistoricalUsernameMock = resolveHistoricalUsername as jest.Mock; + +const OWNER = 'cec88bc9-0000-0000-0000-000000000001'; +const SOMEONE_ELSE = 'cec88bc9-0000-0000-0000-000000000002'; + +/** Supabase stub whose profiles lookup always reports "no live row". */ +function makeSupabase() { + const chain: Record = {}; + const self = () => chain; + Object.assign(chain, { + select: self, + eq: self, + neq: self, + single: () => Promise.resolve({ data: null }), + }); + return { from: () => chain } as never; +} + +describe('checkUsernameAvailability', () => { + beforeEach(() => jest.clearAllMocks()); + + it('refuses a handle another account retired', async () => { + resolveHistoricalUsernameMock.mockResolvedValue(SOMEONE_ELSE); + + const available = await ProfileServerService.checkUsernameAvailability( + makeSupabase(), + 'their_old_handle', + OWNER + ); + + expect(available).toBe(false); + }); + + it('lets an account take back a handle it retired itself', async () => { + // Not merely permissive: the profile page and LNURL both resolve live + // profiles first, so reclaiming your own handle points it back at exactly + // the account it already pointed at. Nothing is intercepted. + resolveHistoricalUsernameMock.mockResolvedValue(OWNER); + + const available = await ProfileServerService.checkUsernameAvailability( + makeSupabase(), + 'my_old_handle', + OWNER + ); + + expect(available).toBe(true); + }); + + it('still allows a handle nobody has ever held', async () => { + resolveHistoricalUsernameMock.mockResolvedValue(null); + + const available = await ProfileServerService.checkUsernameAvailability( + makeSupabase(), + 'catomean', + OWNER + ); + + expect(available).toBe(true); + }); +}); diff --git a/src/config/cat-actions.ts b/src/config/cat-actions.ts index 31ea636f1..c826a7ae8 100644 --- a/src/config/cat-actions.ts +++ b/src/config/cat-actions.ts @@ -1655,12 +1655,18 @@ export const CAT_ACTIONS: Record = { id: 'update_profile', name: 'Update Profile', description: - "Update the user's public profile — name, bio, background, location, or website. Only these fields: to make someone findable for a TOPIC, use publish_interest instead, which adds a searchable interest rather than rewriting their bio.", + "Update the user's public profile — handle, name, bio, background, location, or website. Changing the @handle IS supported: the old one keeps redirecting and still receives payments. To make someone findable for a TOPIC use publish_interest instead, which adds a searchable interest rather than rewriting their bio.", category: 'context', icon: Settings, riskLevel: 'medium', requiresConfirmation: false, parameters: [ + { + name: 'username', + type: 'string', + required: false, + description: 'New @handle (without the @). The old handle keeps resolving.', + }, { name: 'name', type: 'string', required: false, description: 'Display name' }, { name: 'bio', @@ -1689,6 +1695,7 @@ export const CAT_ACTIONS: Record = { }, ], examples: [ + 'Change my handle to @catomean', "Update my bio to say I'm a freelance photographer", 'Set my location to Zurich, Switzerland', 'My website is example.com, add it to my profile', diff --git a/src/lib/profile-guidance.ts b/src/lib/profile-guidance.ts index 50d65f34f..743666d5a 100644 --- a/src/lib/profile-guidance.ts +++ b/src/lib/profile-guidance.ts @@ -51,7 +51,7 @@ export const profileGuidanceContent: Record, Field tips: [ '3-30 characters, letters, numbers, underscores, and hyphens only', 'Must be unique across all users', - 'Cannot be changed easily later - choose wisely', + 'Can be changed later — your old handle keeps redirecting here', 'This becomes your @handle (e.g., @yourname)', 'Required field - you need this to use the platform', ], diff --git a/src/services/cat/action-descriptions.ts b/src/services/cat/action-descriptions.ts index eda18fbbf..58f3e24a7 100644 --- a/src/services/cat/action-descriptions.ts +++ b/src/services/cat/action-descriptions.ts @@ -103,6 +103,13 @@ export function generateActionDescription( case 'create_organization': return `Create organization "${parameters.name}"`; case 'update_profile': { + // A handle change is named on its own, and names what happens to the old + // one: "update profile: username" would hide the only part of this write + // that changes a public URL and a payment address. + if (typeof parameters.username === 'string' && parameters.username) { + const handle = parameters.username.replace(/^@/, ''); + return `Change your handle to @${handle} (your old handle keeps working)`; + } const fields = ['name', 'bio', 'background', 'website', 'location_city', 'location_country'] .filter(f => parameters[f] !== undefined) .join(', '); diff --git a/src/services/cat/handlers/context.ts b/src/services/cat/handlers/context.ts index b76d001d4..df056ef54 100644 --- a/src/services/cat/handlers/context.ts +++ b/src/services/cat/handlers/context.ts @@ -7,6 +7,8 @@ import { } from '../economic-profile'; import { forgetMemoriesMatching, rememberFacts, editMemoryMatching } from '../memory'; import type { ActionHandler } from './types'; +import { usernameSchema } from '@/lib/validation'; +import { ProfileServerService } from '@/services/profile/server'; export const contextHandlers: Record = { // Persist latent economic value the user discloses (skills/assets/goals/etc.) @@ -209,8 +211,17 @@ export const contextHandlers: Record = { }, update_profile: async (supabase, userId, _actorId, params) => { - // Update the user's public profile. Only safe text fields — no username (affects URLs), - // no email, no financial addresses. Profile.id = auth.users.id = userId. + // Update the user's public profile. No email, no financial addresses. + // Profile.id = auth.users.id = userId. + // + // The handle IS updatable. It used to be excluded here because a rename + // broke public URLs — true until profile_username_history (20260826160000) + // made the old handle keep resolving, and left uncorrected afterwards. That + // stale exclusion is what made the Cat tell a user on 2026-08-29 that + // handles "cannot be changed once set", which was simply wrong. + // + // It is validated apart from the free-text fields because it is not free + // text: it is a public URL and a Lightning address. const SAFE_FIELDS = [ 'name', 'bio', @@ -221,18 +232,49 @@ export const contextHandlers: Record = { ] as const; type SafeField = (typeof SAFE_FIELDS)[number]; - const updates: Partial> = {}; + const updates: Partial> = {}; for (const field of SAFE_FIELDS) { if (params[field] !== undefined && params[field] !== null) { updates[field] = params[field] as string; } } + let oldUsername: string | null = null; + if (typeof params.username === 'string' && params.username.trim()) { + // The same schema registration and the profile editor use, so a handle + // the Cat accepts is exactly one the form would have accepted — length, + // shape and reserved names decided in one place. The leading @ is + // stripped because that is how people write a handle. + const parsed = usernameSchema.safeParse(params.username.trim().replace(/^@/, '')); + if (!parsed.success) { + return { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid handle' }; + } + + const { data: before } = await supabase + .from(DATABASE_TABLES.PROFILES) + .select('username') + .eq('id', userId) + .single(); + oldUsername = (before as { username: string | null } | null)?.username ?? null; + + if (!oldUsername || oldUsername.toLowerCase() !== parsed.data.toLowerCase()) { + const free = await ProfileServerService.checkUsernameAvailability( + supabase, + parsed.data, + userId + ); + if (!free) { + return { success: false, error: `@${parsed.data} is already taken.` }; + } + updates.username = parsed.data; + } + } + if (Object.keys(updates).length === 0) { return { success: false, error: - 'No profile fields to update — provide at least one of: name, bio, background, website, location_city, location_country', + 'No profile fields to update — provide at least one of: username, name, bio, background, website, location_city, location_country', }; } @@ -240,13 +282,32 @@ export const contextHandlers: Record = { .from(DATABASE_TABLES.PROFILES) .update({ ...updates, updated_at: new Date().toISOString() }) .eq('id', userId) - .select('name, bio, background, website, location_city, location_country') + .select('username, name, bio, background, website, location_city, location_country') .single(); if (error) { + // profiles_username_rename_guard raises unique_violation for a handle + // another account retired. It is the authority, not the check above: that + // check can go stale between reading and writing, the trigger cannot. + if (error.code === '23505' && updates.username) { + return { success: false, error: `@${updates.username} is already taken.` }; + } return { success: false, error: error.message }; } + // Say what happens to the old handle. It is the fact that makes the rename + // safe, and a rename announced without it reads exactly like the breakage + // the user was (wrongly) warned about. + if (updates.username && oldUsername) { + return { + success: true, + data: { + ...data, + displayMessage: `🪪 You are now @${updates.username}. Links to @${oldUsername} still work — the old profile URL redirects here, and ${oldUsername}@orangecat.ch still reaches you.`, + }, + }; + } + const updatedFields = Object.keys(updates).join(', '); return { success: true, diff --git a/src/services/cat/system-prompt.ts b/src/services/cat/system-prompt.ts index fdfcf10e7..d603dfc2e 100644 --- a/src/services/cat/system-prompt.ts +++ b/src/services/cat/system-prompt.ts @@ -466,7 +466,7 @@ Catalog below as **id(params)**; \`?\` marks an optional parameter. **CONFIRM** - **publish_entity(entity_type, entity_id)** — CONFIRM (riskLevel medium). Sets status to "active" — it becomes public and discoverable. Triggers: "publish it", "make it live", "launch it", "go live", or confirming they're ready to publish a draft. - **archive_entity(entity_type, entity_id)** — CONFIRM (riskLevel high). Soft delete: status becomes "archived" and it leaves public view, but can be restored. Triggers: "delete", "remove", "archive", "get rid of", "take down". - **invite_to_organization(organization_id, username, role?)** — CONFIRM (riskLevel medium). organization_id is shown as "(id: ...)" in "Group Memberships" context — only groups where their role is founder or admin. username = @username. role: member (default) | admin | founder. Only suggest when they already have groups. -- **update_profile(bio?, background?, name?, website?, location_city?, location_country?)** — include ONLY the fields they want changed. location_country is a 2-letter ISO code (CH, US, DE, FR, GB…). After a profile-building conversation, offer: "Want me to update your profile with this?" Never update username (it breaks public URLs), and never email, phone, or financial addresses. +- **update_profile(username?, bio?, background?, name?, website?, location_city?, location_country?)** — only fields they want changed. location_country is ISO-2 (CH, US…). After profile talk offer: "Update your profile with this?" username renames their @handle — never refuse; the old one keeps redirecting and receiving payments, say so. Never email, phone, or financial addresses. ${buildActionCatalogAppendix()} diff --git a/src/services/profile/server.ts b/src/services/profile/server.ts index c44c0199c..3868ecee6 100644 --- a/src/services/profile/server.ts +++ b/src/services/profile/server.ts @@ -14,6 +14,7 @@ import { getTableName } from '@/config/entity-registry'; import { getOrCreateUserActor } from '@/services/actors/getOrCreateUserActor'; import { STATUS } from '@/config/database-constants'; import { neutralUsernameFor } from '@/lib/profile/neutral-username'; +import { resolveHistoricalUsername } from '@/domain/lightning-address/username-history'; type ProfileRow = Database['public']['Tables']['profiles']['Row']; type ProfileInsert = Database['public']['Tables']['profiles']['Insert']; @@ -55,6 +56,18 @@ export class ProfileServerService { /** * Check if username is available + * + * Checks the handles nobody uses any more as well as the live ones. A + * retired handle still resolves — /profiles/ 301s to its owner's + * current handle and @orangecat.ch still finds them through + * profile_username_history — and the live profiles table is consulted + * FIRST, so reissuing one to somebody else would silently hand them the + * previous owner's inbound links and Lightning payments. + * + * The database refuses that write regardless (profiles_username_rename_guard, + * 20260829090000) — that is where the rule is enforced, because it has to + * hold for every writer. This check exists so the person typing the handle + * finds out from the form that it is taken, instead of from a failed save. */ static async checkUsernameAvailability( supabase: AnySupabaseClient, @@ -62,10 +75,17 @@ export class ProfileServerService { excludeUserId?: string ): Promise { try { + const trimmed = username.trim(); + + const retiredBy = await resolveHistoricalUsername(supabase, trimmed); + if (retiredBy && retiredBy !== excludeUserId) { + return false; + } + let query = supabase .from(DATABASE_TABLES.PROFILES) .select('id') - .eq('username', username.trim()); + .eq('username', trimmed); if (excludeUserId) { query = query.neq('id', excludeUserId); diff --git a/supabase/migrations/20260829090000_a_rename_records_the_handle_it_retired.sql b/supabase/migrations/20260829090000_a_rename_records_the_handle_it_retired.sql new file mode 100644 index 000000000..d9222cbf8 --- /dev/null +++ b/supabase/migrations/20260829090000_a_rename_records_the_handle_it_retired.sql @@ -0,0 +1,92 @@ +-- A rename has to record the handle it retired. +-- +-- profile_username_history (20260826160000) is what makes renaming an account +-- safe: /profiles/ 301s to the new handle, and @orangecat.ch keeps +-- resolving through it. But the only thing that ever wrote a row there was the +-- one-off admin script scripts/rename-email-derived-usernames.sql. +-- +-- The product's own rename path recorded nothing. PUT /api/profile accepts a +-- new username from any signed-in user — the profile editor has an editable +-- handle field — checks it is not taken, and UPDATEs profiles.username. No +-- history row. So a user who renamed themselves got precisely the breakage this +-- table exists to prevent: the old profile URL 404s, and the Lightning address +-- @orangecat.ch stops resolving. Silently, on both counts. A payment sent +-- to the address they had published simply does not arrive, and nobody — not +-- the sender, not the recipient, not us — sees an error. +-- +-- WHY THE TRIGGER AND NOT THE ROUTE. A username here is a payment identifier, +-- so "a rename is recorded" has to hold for every writer: this route, the SQL +-- scripts in this repo, a psql session someone opens on the box, and whatever +-- path gets written next. Those are many places to remember one rule. There is +-- exactly one place a username can change — an UPDATE on this table — so the +-- rule belongs there, where it cannot be bypassed by a caller that never heard +-- of it. Fixing only the route would leave the same bug one new caller away. + +CREATE OR REPLACE FUNCTION public.profiles_username_rename_guard() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + retired_owner uuid; +BEGIN + IF NEW.username IS NULL THEN + RETURN NEW; + END IF; + + -- A write that leaves the handle alone is not a rename. The app saves the + -- whole profile on every edit, so this fires on bio edits too; without this + -- the row's own handle would be recorded as retired while it is still live. + IF TG_OP = 'UPDATE' AND NOT (NEW.username IS DISTINCT FROM OLD.username) THEN + RETURN NEW; + END IF; + + -- A handle another account retired is not free to take. The lookup order is + -- what makes this dangerous rather than untidy: the profile page and the + -- LNURL endpoint both resolve the live profiles table FIRST and only fall + -- back to history on a miss. So whoever holds the handle live intercepts + -- everything still pointing at its previous owner — including payments to + -- their saved Lightning address. 20260826160000 named this risk in a comment + -- ("re-issuing it to somebody else would silently redirect the first person's + -- payments to the second") and nothing enforced it: availability was checked + -- against profiles alone, so a retired handle read as free. + SELECT profile_id INTO retired_owner + FROM public.profile_username_history + WHERE old_username = lower(btrim(NEW.username)); + + IF retired_owner IS NOT NULL AND retired_owner <> NEW.id THEN + RAISE EXCEPTION 'username "%" was retired by another account and cannot be reissued', NEW.username + USING ERRCODE = 'unique_violation'; + END IF; + + IF TG_OP = 'UPDATE' AND OLD.username IS NOT NULL THEN + -- ON CONFLICT DO NOTHING covers a handle retired twice by the same account + -- (a -> b -> a -> b). The row already maps it to this profile, and the + -- guard above means it cannot belong to anyone else. + INSERT INTO public.profile_username_history (old_username, profile_id) + VALUES (lower(btrim(OLD.username)), NEW.id) + ON CONFLICT (old_username) DO NOTHING; + END IF; + + -- Taking back your own retired handle makes it live again, so it must stop + -- being listed as retired: a row saying "this handle used to be theirs" is + -- false once it is theirs again. Runs after the INSERT above so that a + -- case-only change (Mao -> mao) nets out to no row, which is right — nothing + -- was retired, the two forms resolve to the same lowercase handle. + DELETE FROM public.profile_username_history + WHERE old_username = lower(btrim(NEW.username)) + AND profile_id = NEW.id; + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION public.profiles_username_rename_guard() IS + 'Records the old handle in profile_username_history on every rename, and refuses a handle another account retired. On profiles.username because that is the one place a username changes.'; + +DROP TRIGGER IF EXISTS profiles_username_rename_guard ON public.profiles; +CREATE TRIGGER profiles_username_rename_guard + BEFORE INSERT OR UPDATE OF username ON public.profiles + FOR EACH ROW + EXECUTE FUNCTION public.profiles_username_rename_guard();