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';