diff --git a/README.md b/README.md index d945b8ecf..fd674e269 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Functional composition replaces inheritance. Each middleware does one thing. ### Setup ```bash -git clone https://github.com/maonakamoto/orangecat.git +git clone https://github.com/bitbaum/orangecat.git cd orangecat npm install cp .env.example .env.local diff --git a/__tests__/unit/ci/e2e-reset-fixture.test.ts b/__tests__/unit/ci/e2e-reset-fixture.test.ts index c3e64c326..bd26eb3ce 100644 --- a/__tests__/unit/ci/e2e-reset-fixture.test.ts +++ b/__tests__/unit/ci/e2e-reset-fixture.test.ts @@ -11,7 +11,7 @@ * CI turned a stalled merge queue into a genuinely red main. * * These lived in auto-merge-base-guard.test.ts until the sweep itself moved to - * maonakamoto/dotfiles (where its behaviour is tested against the canonical + * bitbaum/dotfiles (where its behaviour is tested against the canonical * script). The fixture script stayed HERE, so its guard stays here too — in its * own file, because the thing it tests no longer shares a file-worth of context * with a script this repo no longer carries. diff --git a/__tests__/unit/domain/mention-autocomplete.test.ts b/__tests__/unit/domain/mention-autocomplete.test.ts index 2eee6f636..073a951bb 100644 --- a/__tests__/unit/domain/mention-autocomplete.test.ts +++ b/__tests__/unit/domain/mention-autocomplete.test.ts @@ -125,8 +125,54 @@ describe('rankMentionSuggestions', () => { }); it('falls back to the handle when a profile has no display name', () => { - const items = rankMentionSuggestions('', [{ id: 'x', username: 'solo' }], null); + const items = rankMentionSuggestions('so', [{ id: 'x', username: 'solo' }], null); expect(items[0].name).toBe('solo'); + expect(items[0].isAnonymous).toBe(true); + }); + + // Most accounts on OrangeCat have no display name — 14 of the first 20 on + // 2026-08-28 — because handles stopped being minted from email local parts + // and NULL is the honest result. So this is the menu's common case, not an + // edge one. + describe('nameless profiles', () => { + const nameless = [ + { id: 'a', username: 'user_d58c7dccec41' }, + { id: 'b', username: 'user_09bf1419e7e7' }, + ]; + + it('offers none of them on a bare @, where they are unrecognisable hex', () => { + expect(rankMentionSuggestions('', nameless, null)).toHaveLength(0); + }); + + it('still offers the Cat on a bare @ — it is the row worth discovering', () => { + const items = rankMentionSuggestions('', nameless, cat); + expect(items).toHaveLength(1); + expect(items[0].isCat).toBe(true); + }); + + it('offers one the moment its handle is typed', () => { + const items = rankMentionSuggestions('user_d58', nameless, null); + expect(items[0].username).toBe('user_d58c7dccec41'); + }); + + it('sinks below a named person who matches just as well', () => { + const items = rankMentionSuggestions( + 'u', + [{ id: 'a', username: 'user_d58c7dccec41' }, { id: 'b', username: 'ursula', name: 'Ursula' }], + null + ); + expect(items[0].username).toBe('ursula'); + }); + + it('still wins on an exact handle match against a named person', () => { + // Demotion must not override "this is plainly the row being asked for". + const items = rankMentionSuggestions( + 'user_d58c7dccec41', + [{ id: 'b', username: 'ursula', name: 'user_d58c7dccec41 fan' }, { id: 'a', username: 'user_d58c7dccec41' }], + null + ); + expect(items[0].username).toBe('user_d58c7dccec41'); + }); }); it('honours the limit', () => { diff --git a/__tests__/unit/services/cat-account.test.ts b/__tests__/unit/services/cat-account.test.ts index 4692cd15b..bcba5c3a0 100644 --- a/__tests__/unit/services/cat-account.test.ts +++ b/__tests__/unit/services/cat-account.test.ts @@ -10,6 +10,13 @@ * on every tick: it must not create a second Cat, it must recover from a * half-built account, and it must refuse rather than improvise when it cannot * establish one — a caller that gets null must not invent a sender. + * + * Recovering from a RENAME is here because the suite below used to cover only + * a deleted profile, and production broke the other way: on 2026-08-26 the + * email-derived-handle retirement renamed the Cat from `cat` to + * `user_0234d5e38e66`, this file searched by username, found nothing, and + * returned null on every tick for two days. Self-healing that keys on the field + * most likely to break heals nothing. */ import { ensureCatAccount } from '@/services/mentions/cat-account'; @@ -20,31 +27,45 @@ function adminWith({ profile, createError, profileAfterCreate, + updateError, }: { profile: Row; createError?: { message: string }; profileAfterCreate?: Row; + updateError?: { message: string }; }) { const createUser = jest.fn().mockResolvedValue({ error: createError ?? null }); - const update = jest.fn(() => ({ eq: jest.fn().mockResolvedValue({ error: null }) })); + const update = jest.fn(() => ({ + eq: jest.fn().mockResolvedValue({ error: updateError ?? null }), + })); let lookups = 0; + const lookupColumns: string[] = []; const admin = { auth: { admin: { createUser } }, from: () => ({ select: () => ({ - eq: () => ({ - maybeSingle: () => { - lookups += 1; - const row = lookups === 1 ? profile : (profileAfterCreate ?? profile); - return Promise.resolve({ data: row, error: null }); - }, - }), + eq: (column: string) => { + lookupColumns.push(column); + return { + maybeSingle: () => { + lookups += 1; + const row = lookups === 1 ? profile : (profileAfterCreate ?? profile); + return Promise.resolve({ data: row, error: null }); + }, + }; + }, }), update, }), }; - return { admin: admin as never, createUser, update, lookups: () => lookups }; + return { + admin: admin as never, + createUser, + update, + lookups: () => lookups, + lookupColumns, + }; } describe('ensureCatAccount', () => { @@ -103,4 +124,50 @@ describe('ensureCatAccount', () => { await ensureCatAccount(admin); expect(update).toHaveBeenCalledWith(expect.objectContaining({ name: 'Cat' })); }); + + it('finds the Cat by its login address, not by the handle that can be taken away', async () => { + const { admin, lookupColumns } = adminWith({ + profile: { id: 'cat-1', username: 'cat' }, + }); + await ensureCatAccount(admin); + + // Searching by `username` is what made the 2026-08-26 rename unrecoverable: + // the field being repaired was the field being searched by. + expect(lookupColumns).toContain('email'); + expect(lookupColumns).not.toContain('username'); + }); + + it('restores the handle when something has renamed the Cat', async () => { + const { admin, update } = adminWith({ + profile: { id: 'cat-1', username: 'user_0234d5e38e66' }, + }); + + await expect(ensureCatAccount(admin)).resolves.toEqual({ + id: 'cat-1', + username: 'cat', + }); + expect(update).toHaveBeenCalledWith({ username: 'cat' }); + }); + + it('does not write on an ordinary tick', async () => { + const { admin, update } = adminWith({ profile: { id: 'cat-1', username: 'cat' } }); + await ensureCatAccount(admin); + // This runs on every worker tick; a rename repair that writes unconditionally + // is a write per tick forever. + expect(update).not.toHaveBeenCalled(); + }); + + it('still returns the account when the handle cannot be restored', async () => { + const { admin } = adminWith({ + profile: { id: 'cat-1', username: 'user_0234d5e38e66' }, + updateError: { message: 'duplicate key value violates unique constraint' }, + }); + + // Somebody else holding `cat` is bad, but refusing to return the account + // would turn a wrong name into total silence — strictly worse. + await expect(ensureCatAccount(admin)).resolves.toEqual({ + id: 'cat-1', + username: 'user_0234d5e38e66', + }); + }); }); diff --git a/__tests__/unit/services/cat-handle-invariant.test.ts b/__tests__/unit/services/cat-handle-invariant.test.ts new file mode 100644 index 000000000..0aecb92cd --- /dev/null +++ b/__tests__/unit/services/cat-handle-invariant.test.ts @@ -0,0 +1,40 @@ +/** + * The nightly gate hardcodes the Cat's handle. This is what stops that from + * becoming a second definition of it. + * + * `scripts/check-data-invariants.mjs` runs against production over PostgREST + * and cannot import from `src/` — it is a standalone .mjs with no build step and + * no path aliases. So it carries the string `cat` of its own. That is exactly + * the shape that has already cost this codebase real bugs: three definitions of + * what an `@handle` is meant the resolver notified one person while the + * renderer linked another. + * + * A duplicated literal is acceptable only when something fails the moment the + * two disagree. This is that something. + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { CAT_USERNAME } from '@/config/cat-identity'; + +const script = readFileSync( + join(process.cwd(), 'scripts/check-data-invariants.mjs'), + 'utf8' +); + +describe('the invariant gate and the Cat agree on the handle', () => { + it('checks the handle the Cat actually answers to', () => { + const declared = /const CAT_HANDLE = '([^']+)'/.exec(script); + + // If this is null the check was renamed or removed. Either way the gate no + // longer guards `@cat`, which is the thing worth knowing. + expect(declared).not.toBeNull(); + expect(declared![1]).toBe(CAT_USERNAME); + }); + + it('asks by handle, not by id — an id lookup would pass while @cat was broken', () => { + // The whole failure this gate exists for was: account healthy, handle gone. + // Querying profiles by id would have been green throughout. + expect(script).toMatch(/profiles\?select=[^`]*&username=eq\.\$\{CAT_HANDLE\}/); + }); +}); diff --git a/docs/architecture/TECHNICAL.md b/docs/architecture/TECHNICAL.md index f10f6a2ee..88ee4b1bf 100644 --- a/docs/architecture/TECHNICAL.md +++ b/docs/architecture/TECHNICAL.md @@ -216,7 +216,7 @@ create table funding_pages ( 1. Clone the repository: ```bash - git clone https://github.com/maonakamoto/orangecat.git + git clone https://github.com/bitbaum/orangecat.git cd orangecat ``` diff --git a/docs/development/WORKFLOW_VERIFICATION_MASTER_CHECKLIST_2026-02-18.md b/docs/development/WORKFLOW_VERIFICATION_MASTER_CHECKLIST_2026-02-18.md index 1ad824cd7..38e617779 100644 --- a/docs/development/WORKFLOW_VERIFICATION_MASTER_CHECKLIST_2026-02-18.md +++ b/docs/development/WORKFLOW_VERIFICATION_MASTER_CHECKLIST_2026-02-18.md @@ -120,7 +120,7 @@ Objective: verify **all critical workflows** in Orangecat one by one, capture ev ## Phase 9 — Operational & Quality Gates - ☑ Health endpoint behavior (rerun passed: `@p0 health endpoint responds`) -- ☑ P0 matrix runs green in CI with required secrets — [GitHub Actions run 28722171024](https://github.com/maonakamoto/orangecat/actions/runs/28722171024) / commit `d3e1bbe6`: **11/11 passed** (2026-07-05) +- ☑ P0 matrix runs green in CI with required secrets — [GitHub Actions run 28722171024](https://github.com/bitbaum/orangecat/actions/runs/28722171024) / commit `d3e1bbe6`: **11/11 passed** (2026-07-05) - ☑ No skip-based false green in required P0 checks — bootstrap mints reset tokens; missing secrets skip matrix explicitly - ☑ Lint/type-check/unit tests pass on final state — CI job green; local unit 713/713; type-check pass (2026-07-05) - ☑ Workflow YAML validity check (all `.github/workflows/*.yml` parse successfully after fixes) diff --git a/docs/operations/enable-cd.md b/docs/operations/enable-cd.md index eaa629187..dd7b8fde6 100644 --- a/docs/operations/enable-cd.md +++ b/docs/operations/enable-cd.md @@ -10,7 +10,7 @@ can't take the site down; worst case it stays on the current version). ### Required repo secrets (`Settings → Secrets and variables → Actions`) -`https://github.com/maonakamoto/orangecat/settings/secrets/actions` +`https://github.com/bitbaum/orangecat/settings/secrets/actions` | Secret | Value | | -------------------------------------- | ------------------------------------------------ | @@ -30,8 +30,8 @@ Optional repo **variables** (`vars.*`): `OC_BOX` (default `root@167.233.22.31`), ssh-keygen -t ed25519 -C "orangecat-cd-deploy" -f ~/.ssh/orangecat_cd -N "" # 2. store the PRIVATE half as the secret (file -> gh, never echoed) -gh secret set SELFHOST_SSH_KEY -R maonakamoto/orangecat < ~/.ssh/orangecat_cd -ssh-keyscan -H 167.233.22.31 2>/dev/null | gh secret set SELFHOST_KNOWN_HOSTS -R maonakamoto/orangecat +gh secret set SELFHOST_SSH_KEY -R bitbaum/orangecat < ~/.ssh/orangecat_cd +ssh-keyscan -H 167.233.22.31 2>/dev/null | gh secret set SELFHOST_KNOWN_HOSTS -R bitbaum/orangecat # 3. authorize the PUBLIC half on the box (security change — do this deliberately) ssh-copy-id -i ~/.ssh/orangecat_cd.pub root@167.233.22.31 @@ -41,16 +41,16 @@ ssh-copy-id -i ~/.ssh/orangecat_cd.pub root@167.233.22.31 ### Set the public build values ```bash -gh secret set NEXT_PUBLIC_SUPABASE_URL -R maonakamoto/orangecat -b "https://supabase.orangecat.ch" -grep '^NEXT_PUBLIC_SUPABASE_ANON_KEY=' .env.local | cut -d= -f2- | tr -d '"' | gh secret set NEXT_PUBLIC_SUPABASE_ANON_KEY -R maonakamoto/orangecat -grep '^NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=' .env.local | cut -d= -f2- | tr -d '"' | gh secret set NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY -R maonakamoto/orangecat +gh secret set NEXT_PUBLIC_SUPABASE_URL -R bitbaum/orangecat -b "https://supabase.orangecat.ch" +grep '^NEXT_PUBLIC_SUPABASE_ANON_KEY=' .env.local | cut -d= -f2- | tr -d '"' | gh secret set NEXT_PUBLIC_SUPABASE_ANON_KEY -R bitbaum/orangecat +grep '^NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=' .env.local | cut -d= -f2- | tr -d '"' | gh secret set NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY -R bitbaum/orangecat ``` ## Deploy now / verify ```bash -gh workflow run cd.yml -R maonakamoto/orangecat # deploy current main -gh run watch "$(gh run list -R maonakamoto/orangecat --workflow cd.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status +gh workflow run cd.yml -R bitbaum/orangecat # deploy current main +gh run watch "$(gh run list -R bitbaum/orangecat --workflow cd.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status curl -fsS https://orangecat.ch/api/health && echo " ✓ live" ``` diff --git a/scripts/check-data-invariants.mjs b/scripts/check-data-invariants.mjs index a93c9cbcf..01bd67a14 100644 --- a/scripts/check-data-invariants.mjs +++ b/scripts/check-data-invariants.mjs @@ -303,6 +303,12 @@ async function checkSilentlyDroppedCatTurns() { // three that used to: the handle_new_user trigger, ensureProfile(), and two // profile form pre-fills. Each is covered by a test, so a regression here means // a FOURTH one was added. +// +// System accounts (RFC 2606 `.invalid` addresses) are excluded from the count +// as of 20260828070000. They matched the predicate perfectly while leaking +// nothing — an undeliverable address has no mailbox, so no owner, so no +// personal information in its local part — and the retirement that predicate +// authorised took `@cat` off the platform for two days. See checkCatHandle. const EMAIL_DERIVED_USERNAME_BASELINE = 0; async function checkEmailDerivedUsernames() { @@ -322,6 +328,61 @@ async function checkEmailDerivedUsernames() { } } +/** + * `@cat` must point at the Cat. + * + * This exists because on 2026-08-26 it stopped, and nothing anywhere noticed + * for two days. The email-derived-handle retirement renamed the Cat from `cat` + * to `user_0234d5e38e66` — correctly, by its own predicate, since the Cat's + * handle IS derived from `cat@orangecat.invalid`. Every `@cat` on the platform + * then resolved to nobody: no reply in any message, none under any post. The + * profile still existed, /profiles/cat still 301'd through the history table, + * CI was green and health was 200. The only observable symptom was silence, + * from a feature whose whole job is to answer. + * + * So this checks the product claim rather than a schema fact: not "the Cat + * account exists" — it did throughout — but "the name the platform tells people + * to type reaches it". Those came apart, which is the entire lesson. + * + * Reads by handle deliberately. The resolver looks mentions up by username, so + * this asks the question in the same terms the resolver does, and a lookup by + * id would pass while `@cat` stayed broken. + */ +async function checkCatHandle() { + // Kept in step with src/config/cat-identity.ts by + // __tests__/unit/services/cat-handle-invariant.test.ts, which fails if the + // handle there ever changes without this literal changing with it. + const CAT_HANDLE = 'cat'; + const rows = await rest(`profiles?select=id,email&username=eq.${CAT_HANDLE}`); + + if (rows.length === 0) { + violation( + 'cat.handle_resolves', + `no profile answers to @${CAT_HANDLE}, so every @${CAT_HANDLE} in a message or under a ` + + `post resolves to nobody and the Cat replies to nothing. The account itself may be ` + + `perfectly healthy — check whether something renamed it (the handle-retirement script ` + + `did exactly this once), then let the worker re-assert it via ensureCatAccount`, + [] + ); + return; + } + + // A handle held by the WRONG account is impersonation of the platform's own + // agent, and that is worth naming separately from "missing". + const holder = rows[0]; + if (!String(holder.email ?? '').endsWith('.invalid')) { + violation( + 'cat.handle_resolves', + `@${CAT_HANDLE} is held by an account with a deliverable email address, which means it is ` + + `not the platform's agent — somebody is receiving every mention meant for the Cat`, + [holder.id] + ); + return; + } + + notes.push(`cat: @${CAT_HANDLE} resolves to the Cat`); +} + async function checkOrphanedProfiles() { const count = Number(await rpc('count_orphaned_profiles')); @@ -454,6 +515,7 @@ async function main() { checkSilentlyDroppedCatTurns, checkOrphanedProfiles, checkEmailDerivedUsernames, + checkCatHandle, checkOrphanedCatConversations, checkOrphanedActors, ]; diff --git a/scripts/rename-email-derived-usernames.sql b/scripts/rename-email-derived-usernames.sql index 192d91931..c40205cd3 100644 --- a/scripts/rename-email-derived-usernames.sql +++ b/scripts/rename-email-derived-usernames.sql @@ -25,7 +25,8 @@ -- '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;" +-- WHERE p.username = split_part(u.email,'@',1) +-- AND u.email NOT LIKE '%.invalid' 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 @@ -33,6 +34,14 @@ -- 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. +-- +-- SYSTEM ACCOUNTS ARE EXCLUDED (`.invalid`, RFC 2606). Learned the hard way: +-- the first run retired the Cat. Deriving the handle from the address is HOW +-- the Cat is created — cat-account.ts registers `cat@orangecat.invalid` so that +-- handle_new_user mints `cat` — so it matched the predicate perfectly while +-- leaking nothing, and `@cat` resolved to nobody for two days. An undeliverable +-- address has no mailbox, so no owner, so no personal information in its local +-- part. See 20260828070000_system_accounts_keep_their_handles.sql. BEGIN; @@ -44,6 +53,7 @@ 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) + AND u.email NOT LIKE '%.invalid' ON CONFLICT (old_username) DO NOTHING; -- Same shape as handle_new_user() and neutralUsernameFor(); see @@ -54,7 +64,8 @@ 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); + AND p.username = split_part(u.email, '@', 1) + AND u.email NOT LIKE '%.invalid'; -- 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 @@ -64,6 +75,7 @@ SET name = NULL, updated_at = now() FROM auth.users u WHERE u.id = p.id - AND p.name = split_part(u.email, '@', 1); + AND p.name = split_part(u.email, '@', 1) + AND u.email NOT LIKE '%.invalid'; COMMIT; diff --git a/src/components/mentions/MentionSuggestions.tsx b/src/components/mentions/MentionSuggestions.tsx index fde8beb42..a68945464 100644 --- a/src/components/mentions/MentionSuggestions.tsx +++ b/src/components/mentions/MentionSuggestions.tsx @@ -84,7 +84,13 @@ export default function MentionSuggestions({ - {item.name} + + {/* A nameless profile's `name` IS its handle, so printing both + lines gave two identical rows of hex — the most common shape + in the menu, since most accounts have no display name. Show + the handle once, in the position the eye reads first. */} + {item.isAnonymous ? `@${item.username}` : item.name} + {item.isCat && ( // `text-on-accent` rather than `text-white`: white on this // orange is 3.10:1 and fails AA (see tailwind.config.ts). @@ -93,13 +99,15 @@ export default function MentionSuggestions({ )} - {/* The handle is always shown, including for the Cat: the point of - the menu is that you learn `@cat` exists and can type it next - time without opening anything. */} - - @{item.username} - {item.isCat && ' · ask about this thread'} - + {/* For a named profile the handle is always shown, including for the + Cat: the point of the menu is that you learn `@cat` exists and + can type it next time without opening anything. */} + {!item.isAnonymous && ( + + @{item.username} + {item.isCat && ' · ask about this thread'} + + )} ))} diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 9a65d54a9..2831cf10b 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -438,7 +438,7 @@ export const footerNavigation = { }, { name: 'GitHub', - href: 'https://github.com/maonakamoto/orangecat', + href: 'https://github.com/bitbaum/orangecat', icon: GitHubIcon, }, ], @@ -447,7 +447,7 @@ export const footerNavigation = { // change. External URLs are marked with `external: true`. bottomBar: [ { name: 'Documentation', href: ROUTES.DOCS }, - { name: 'Source Code', href: 'https://github.com/maonakamoto/orangecat', external: true }, + { name: 'Source Code', href: 'https://github.com/bitbaum/orangecat', external: true }, { name: 'Roadmap', href: ROUTES.ROADMAP }, { name: 'Support', href: ROUTES.SUPPORT }, ] as { name: string; href: string; external?: boolean }[], diff --git a/src/domain/mentions/rank.ts b/src/domain/mentions/rank.ts index e87c6af44..fd7809c53 100644 --- a/src/domain/mentions/rank.ts +++ b/src/domain/mentions/rank.ts @@ -39,6 +39,17 @@ export interface MentionSuggestion { avatarUrl: string | null; /** Renders the Cat differently, and explains why it is at the top. */ isCat: boolean; + /** + * This profile has no display name, so `name` above is just the handle again. + * + * Worth a flag rather than leaving the renderer to compare the two strings: + * it is the difference between a row you can recognise and a row you cannot, + * and both the order and the markup depend on it. Measured on production + * 2026-08-28: 14 of the first 20 profiles, because the handle-retirement + * fix correctly stopped inventing display names out of email local parts — + * NULL is honest, and it means most accounts genuinely have no name yet. + */ + isAnonymous: boolean; } /** How many rows the menu shows. Enough to choose from, few enough to scan. */ @@ -63,12 +74,14 @@ function toSuggestion(profile: MentionCandidateProfile): MentionSuggestion | nul if (!username) { return null; } + const name = profile.name?.trim(); return { id: profile.id, username, - name: profile.name?.trim() || username, + name: name || username, avatarUrl: profile.avatar_url ?? null, isCat: isCatHandle(username), + isAnonymous: !name, }; } @@ -80,6 +93,12 @@ function toSuggestion(profile: MentionCandidateProfile): MentionSuggestion | nul * and handle beats display name. Ties keep the order the query returned rather * than being re-sorted alphabetically, because that order is already * newest-first and stable. + * + * A nameless profile sinks below every named one, UNLESS the query matches its + * handle — in which case it is plainly the row being asked for and leads. The + * asymmetry is the point: `user_d58c7dccec41` is unrecognisable, so offering it + * to someone who has typed nothing about it is noise, while offering it to + * someone typing `user_d58` is precisely right. */ function score(suggestion: MentionSuggestion, query: string): number { const q = normalizeUsername(query); @@ -87,7 +106,6 @@ function score(suggestion: MentionSuggestion, query: string): number { return 0; } const handle = normalizeUsername(suggestion.username); - const name = suggestion.name.toLowerCase(); if (handle === q) { return -3; @@ -95,7 +113,9 @@ function score(suggestion: MentionSuggestion, query: string): number { if (handle.startsWith(q)) { return -2; } - if (name.startsWith(query.toLowerCase())) { + // Only for a real name. For a nameless profile `name` is the handle again, so + // this would silently re-run the check above and promote a row nobody can read. + if (!suggestion.isAnonymous && suggestion.name.toLowerCase().startsWith(query.toLowerCase())) { return -1; } return 0; @@ -125,8 +145,24 @@ export function rankMentionSuggestions( // The Cat is placed deliberately, so drop it from the general pool rather // than letting the people search list it a second time further down. .filter(s => !s.isCat) + // On a bare `@` there is nothing to match on, so a nameless profile is a + // row of hex offered to someone who cannot possibly be looking for it — + // and with most accounts unnamed it is what the menu opened with. You + // cannot be searching for a name that does not exist; type any of the + // handle and they come back immediately via the prefix rules above. + .filter(s => query.length > 0 || !s.isAnonymous) .map((s, index) => ({ s, index, rank: score(s, query) })) - .sort((a, b) => a.rank - b.rank || a.index - b.index) + // Match quality first, then recognisability, then the order the query + // returned. The middle term matters because most accounts have no name: + // `@u` matches both `ursula` and `user_d58c7dccec41` as handle prefixes, + // equally well by any measure the score can see, and only one of them is a + // row a human can act on. + .sort( + (a, b) => + a.rank - b.rank || + Number(a.s.isAnonymous) - Number(b.s.isAnonymous) || + a.index - b.index + ) .map(entry => entry.s); const deduped: MentionSuggestion[] = []; diff --git a/src/services/mentions/cat-account.ts b/src/services/mentions/cat-account.ts index 95a5c2edb..1b4f2d5b5 100644 --- a/src/services/mentions/cat-account.ts +++ b/src/services/mentions/cat-account.ts @@ -9,8 +9,20 @@ * * Idempotent by design, and cheap when it is a no-op: one indexed lookup. It is * safe to call on every worker tick, and doing so makes the account - * self-healing — if the profile is ever deleted, the Cat comes back rather than - * every `@cat` on the platform quietly resolving to nobody. + * self-healing — if the profile is ever deleted OR RENAMED, the Cat comes back + * rather than every `@cat` on the platform quietly resolving to nobody. + * + * The rename half was learned in production. This file used to FIND the Cat by + * its username, which is the one field about the Cat that another policy is + * entitled to change: on 2026-08-26 the email-derived-handle retirement renamed + * `cat` to `user_0234d5e38e66` (see + * supabase/migrations/20260828070000_system_accounts_keep_their_handles.sql). + * The lookup then missed, creation said "already registered", the second lookup + * missed too, and this returned null on every tick thereafter — self-healing + * that could not heal, because the thing it searched by was the thing that + * broke. Identity is now keyed on the login address, which is a literal in this + * file and cannot be reassigned, and the handle is treated as a field to + * ASSERT rather than a key to search by. */ import { CAT_USERNAME, CAT_DISPLAY_NAME } from '@/config/cat-identity'; @@ -54,7 +66,7 @@ export async function ensureCatAccount( ): Promise { const existing = await findCatProfile(admin); if (existing) { - return existing; + return assertCatHandle(admin, existing); } // No profile. Either the auth user does not exist either, or it does and its @@ -93,14 +105,24 @@ export async function ensureCatAccount( } logger.info('Cat account established', { id: profile.id }, 'CatAccount'); - return profile; + return assertCatHandle(admin, profile); } +/** + * Find the Cat by the one thing about it that cannot be reassigned. + * + * NOT by username. `@cat` is what the platform advertises, which makes the + * handle the thing most worth repairing and therefore the worst possible thing + * to search by — if it is wrong, the lookup that would notice returns nothing. + * The login address is a literal in this file, belongs to a domain RFC 2606 + * guarantees nobody can receive mail at, and no product policy has any reason + * to rewrite it. + */ async function findCatProfile(admin: SupabaseClient): Promise { const { data, error } = await admin .from(DATABASE_TABLES.PROFILES) .select('id, username') - .eq('username', CAT_USERNAME) + .eq('email', CAT_EMAIL) .maybeSingle(); if (error) { @@ -109,3 +131,52 @@ async function findCatProfile(admin: SupabaseClient): Promise } return data ? { id: data.id as string, username: data.username as string } : null; } + +/** + * Make the account answer to `@cat`, whatever it currently says. + * + * A no-op on every ordinary tick — the comparison is free and the write only + * happens when the handle has actually drifted. When it has, this is the whole + * repair: the resolver looks mentions up by username, so restoring it is what + * makes `@cat` mean the Cat again. + * + * A failure here is reported and swallowed rather than propagated. The account + * still exists and the Cat can still WRITE under the wrong handle; refusing to + * return it would turn a wrong name into total silence, which is strictly + * worse. The mismatch is logged at error level because it means something + * outside this file is renaming a system account. + */ +async function assertCatHandle( + admin: SupabaseClient, + profile: CatAccount +): Promise { + if (profile.username === CAT_USERNAME) { + return profile; + } + + logger.error( + 'The Cat is not answering to its own handle', + { found: profile.username, expected: CAT_USERNAME }, + 'CatAccount' + ); + + const { error } = await admin + .from(DATABASE_TABLES.PROFILES) + .update({ username: CAT_USERNAME }) + .eq('id', profile.id); + + if (error) { + // The likeliest cause is the unique index: something else holds `cat`. + // That is impersonation of the platform's own agent, so it is worth the + // loud log even though the Cat keeps working under the wrong name. + logger.error( + 'Could not restore the Cat handle', + { error: error.message, holding: profile.username }, + 'CatAccount' + ); + return profile; + } + + logger.info('Restored the Cat handle', { was: profile.username }, 'CatAccount'); + return { ...profile, username: CAT_USERNAME }; +} diff --git a/supabase/migrations/20260828070000_system_accounts_keep_their_handles.sql b/supabase/migrations/20260828070000_system_accounts_keep_their_handles.sql new file mode 100644 index 000000000..f0697604c --- /dev/null +++ b/supabase/migrations/20260828070000_system_accounts_keep_their_handles.sql @@ -0,0 +1,82 @@ +-- A system account's handle is not a leaked email address. Stop retiring it. +-- +-- WHAT BROKE: scripts/rename-email-derived-usernames.sql retires every profile +-- where `username = split_part(email, '@', 1)`. That predicate is exactly right +-- for a person — it means their public, crawlable handle republishes their +-- email local part. It is exactly wrong for the Cat, because deriving the +-- handle from the address is HOW the Cat is created: cat-account.ts registers +-- `cat@orangecat.invalid` precisely so `handle_new_user` mints the handle +-- `cat`. So the retirement matched, and on 2026-08-26 21:58:59Z the Cat's +-- handle became `user_0234d5e38e66`. +-- +-- The damage was total and silent. `@cat` is the platform's advertised +-- interface — it is in the Cat's own bio, in the composer placeholder, and in +-- the docs — and it resolves by username. With no profile named `cat`: +-- +-- * every `@cat` in a message or under a post resolved to nobody, so nothing +-- was queued and the Cat answered nothing, for two days; +-- * ensureCatAccount() could not even repair it. It looked the Cat up BY +-- USERNAME, found nothing, tried to create the auth user, got "already +-- registered", looked again, still found nothing, and returned null — +-- permanently. Its self-healing was written for a DELETED profile and a +-- rename walks straight past it. +-- +-- Nothing went red. The retirement is correct-by-design for people, the profile +-- still existed, /profiles/cat still 301'd through profile_username_history, +-- and health stayed 200. +-- +-- WHY `.invalid` IS THE RIGHT LINE, and not a list of names to maintain: +-- RFC 2606 reserves `.invalid` for addresses that are guaranteed undeliverable. +-- An account there has no mailbox, so it has no owner and no correspondence, +-- so its local part cannot be anybody's personal information. That is the +-- actual property the retirement cares about — "this handle republishes a +-- person's email" — rather than a hardcoded 'cat', which would put a second +-- definition of the Cat's handle in SQL, to drift against +-- src/config/cat-identity.ts. Any future system identity gets this for free by +-- using a `.invalid` address, which it should be doing anyway. + +-- 1. The counting function the nightly gate reads (check-data-invariants.mjs, +-- EMAIL_DERIVED_USERNAME_BASELINE = 0). Without this exclusion, putting the +-- Cat's handle back would take the count from 0 to 1 and turn the gate red +-- for a profile that is fine — which is how a gate teaches people to ignore +-- it. +CREATE OR REPLACE FUNCTION public.count_email_derived_usernames() +RETURNS bigint +LANGUAGE sql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ + SELECT count(*)::bigint + FROM public.profiles p + JOIN auth.users u ON u.id = p.id + WHERE p.username = split_part(u.email, '@', 1) + -- RFC 2606: undeliverable by definition, so there is no person and no leak. + AND u.email NOT LIKE '%.invalid'; +$$; + +COMMENT ON FUNCTION public.count_email_derived_usernames() IS + 'How many profiles still publish their email local part as a public handle. Excludes RFC 2606 .invalid addresses, which have no mailbox and therefore no owner to expose. A ratchet for check-data-invariants.mjs: it may fall or hold, never rise.'; + +-- 2. Put back what the retirement should never have taken. Written as a +-- property of system accounts rather than as `username = 'cat'`, so it +-- repairs any system identity caught the same way and states no handle +-- literal of its own. +UPDATE public.profiles p +SET username = h.old_username, + updated_at = now() +FROM public.profile_username_history h +JOIN auth.users u ON u.id = h.profile_id +WHERE h.profile_id = p.id + AND u.email LIKE '%.invalid' + AND p.username IS DISTINCT FROM h.old_username; + +-- 3. Drop the history rows that claim a system handle is retired. Leaving them +-- would be a contradiction in the data — `cat` recorded as a former handle +-- of the very profile that currently holds it — and `old_username` is the +-- primary key, so a stale row would also block recording a real future +-- rename. Nothing is lost: history exists to keep OLD handles resolving, and +-- this handle is current again, so the direct lookup answers first. +DELETE FROM public.profile_username_history h +USING auth.users u +WHERE u.id = h.profile_id + AND u.email LIKE '%.invalid';