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
11 changes: 7 additions & 4 deletions __tests__/unit/cat/action-executor-columns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1349,19 +1349,22 @@ 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
});

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<string, unknown>).username).toBeUndefined();
expect((update as Record<string, unknown>).email).toBeUndefined();
expect((update as Record<string, unknown>).id).toBeUndefined();
});
Expand All @@ -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');
Expand Down
152 changes: 152 additions & 0 deletions __tests__/unit/cat/rename-handle.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[] = [];
const from = () => {
let isUpdate = false;
const chain: Record<string, unknown> = {};
const self = () => chain;
Object.assign(chain, {
select: self,
eq: self,
update: (values: Record<string, unknown>) => {
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<string, unknown>) =>
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');
});
});
86 changes: 86 additions & 0 deletions __tests__/unit/profile/retired-handle-not-available.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* A handle nobody uses any more is not a free handle.
*
* A retired handle still resolves: /profiles/<old> 301s to its owner's current
* handle, and <old>@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<string, unknown> = {};
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);
});
});
9 changes: 8 additions & 1 deletion src/config/cat-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1655,12 +1655,18 @@ export const CAT_ACTIONS: Record<string, CatAction> = {
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',
Expand Down Expand Up @@ -1689,6 +1695,7 @@ export const CAT_ACTIONS: Record<string, CatAction> = {
},
],
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',
Expand Down
2 changes: 1 addition & 1 deletion src/lib/profile-guidance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const profileGuidanceContent: Record<NonNullable<ProfileFieldType>, 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',
],
Expand Down
7 changes: 7 additions & 0 deletions src/services/cat/action-descriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(', ');
Expand Down
Loading
Loading