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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion __tests__/unit/ci/e2e-reset-fixture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 47 additions & 1 deletion __tests__/unit/domain/mention-autocomplete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
85 changes: 76 additions & 9 deletions __tests__/unit/services/cat-account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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',
});
});
});
40 changes: 40 additions & 0 deletions __tests__/unit/services/cat-handle-invariant.test.ts
Original file line number Diff line number Diff line change
@@ -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\}/);
});
});
2 changes: 1 addition & 1 deletion docs/architecture/TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 8 additions & 8 deletions docs/operations/enable-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| -------------------------------------- | ------------------------------------------------ |
Expand All @@ -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
Expand All @@ -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"
```

Expand Down
62 changes: 62 additions & 0 deletions scripts/check-data-invariants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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'));

Expand Down Expand Up @@ -454,6 +515,7 @@ async function main() {
checkSilentlyDroppedCatTurns,
checkOrphanedProfiles,
checkEmailDerivedUsernames,
checkCatHandle,
checkOrphanedCatConversations,
checkOrphanedActors,
];
Expand Down
Loading
Loading