diff --git a/__tests__/unit/services/cat-account.test.ts b/__tests__/unit/services/cat-account.test.ts new file mode 100644 index 000000000..4692cd15b --- /dev/null +++ b/__tests__/unit/services/cat-account.test.ts @@ -0,0 +1,106 @@ +/** + * The Cat needs a row before it can speak. + * + * Everything else about tagging depends on the Cat being an ordinary account: + * that is what lets a Cat reply be an ordinary message row, with no branch in + * read receipts, search, threading or realtime. So this is the one piece that + * cannot be "handled in the renderer". + * + * The behaviours pinned here are the ones that decide whether it is safe to run + * 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. + */ + +import { ensureCatAccount } from '@/services/mentions/cat-account'; + +type Row = { id: string; username: string } | null; + +function adminWith({ + profile, + createError, + profileAfterCreate, +}: { + profile: Row; + createError?: { message: string }; + profileAfterCreate?: Row; +}) { + const createUser = jest.fn().mockResolvedValue({ error: createError ?? null }); + const update = jest.fn(() => ({ eq: jest.fn().mockResolvedValue({ error: null }) })); + let lookups = 0; + + 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 }); + }, + }), + }), + update, + }), + }; + return { admin: admin as never, createUser, update, lookups: () => lookups }; +} + +describe('ensureCatAccount', () => { + it('returns the existing account without creating a second one', async () => { + const { admin, createUser } = adminWith({ profile: { id: 'cat-1', username: 'cat' } }); + await expect(ensureCatAccount(admin)).resolves.toEqual({ id: 'cat-1', username: 'cat' }); + expect(createUser).not.toHaveBeenCalled(); + }); + + it('creates the account with an address nobody can ever receive mail at', async () => { + const { admin, createUser } = adminWith({ + profile: null, + profileAfterCreate: { id: 'cat-1', username: 'cat' }, + }); + await ensureCatAccount(admin); + + const [args] = createUser.mock.calls[0]; + // .invalid is reserved by RFC 2606. An account's email is its password-reset + // channel, so a bot identity with a routable address is one somebody can + // eventually take. + expect(args.email).toMatch(/@orangecat\.invalid$/); + expect(args.email.split('@')[0]).toBe('cat'); + // No password is set, so it cannot be signed into with one. + expect(args.password).toBeUndefined(); + }); + + it('recovers when the auth user already exists but the profile was deleted', async () => { + const { admin } = adminWith({ + profile: null, + createError: { message: 'A user with this email address has already been registered' }, + profileAfterCreate: { id: 'cat-1', username: 'cat' }, + }); + // The auth user survives a deleted profile row, so "already registered" is + // the expected path on a re-run, not a failure. + await expect(ensureCatAccount(admin)).resolves.toEqual({ id: 'cat-1', username: 'cat' }); + }); + + it('refuses rather than improvising when creation genuinely fails', async () => { + const { admin } = adminWith({ + profile: null, + createError: { message: 'database is on fire' }, + }); + await expect(ensureCatAccount(admin)).resolves.toBeNull(); + }); + + it('refuses when the auth user exists but no profile ever appears', async () => { + const { admin } = adminWith({ profile: null, profileAfterCreate: null }); + await expect(ensureCatAccount(admin)).resolves.toBeNull(); + }); + + it('describes the account so a half-built one converges', async () => { + const { admin, update } = adminWith({ + profile: null, + profileAfterCreate: { id: 'cat-1', username: 'cat' }, + }); + await ensureCatAccount(admin); + expect(update).toHaveBeenCalledWith(expect.objectContaining({ name: 'Cat' })); + }); +}); diff --git a/scripts/deploy-selfhost.sh b/scripts/deploy-selfhost.sh index f613d9df2..045aba082 100755 --- a/scripts/deploy-selfhost.sh +++ b/scripts/deploy-selfhost.sh @@ -253,40 +253,44 @@ echo "=== ship ops scripts + nightly Cat-eval timer ===" "$OC_BOX:$OC_APP_BASE/scripts/eval-cat-outcomes.mjs" scp "${SSH_OPTS[@]}" -q scripts/check-data-invariants.mjs \ "$OC_BOX:$OC_APP_BASE/scripts/check-data-invariants.mjs" - scp "${SSH_OPTS[@]}" -q scripts/systemd/orangecat-cat-eval.service \ - scripts/systemd/orangecat-cat-eval.timer \ - scripts/systemd/orangecat-cat-outcomes.service \ - scripts/systemd/orangecat-cat-outcomes.timer \ - scripts/systemd/orangecat-data-invariants.service \ - scripts/systemd/orangecat-data-invariants.timer \ - "scripts/systemd/orangecat-cron@cat-brief.timer" \ - "scripts/systemd/orangecat-cron@cat-watches.timer" \ - "scripts/systemd/orangecat-cron@reindex-embeddings.timer" "$OC_BOX:/tmp/" - ssh "${SSH_OPTS[@]}" "$OC_BOX" 'bash -s' <<'EVAL_UNITS' + # ONE list. It used to be written three times — the scp arguments, the + # install loop and the enable calls — so adding a timer meant editing three + # places, and missing the third shipped a unit file that never ran. That is + # the same silent-failure shape as a cron route nothing invokes. + UNITS=( + orangecat-cat-eval.service + orangecat-cat-eval.timer + orangecat-cat-outcomes.service + orangecat-cat-outcomes.timer + orangecat-data-invariants.service + orangecat-data-invariants.timer + "orangecat-cron@cat-account.timer" + "orangecat-cron@cat-brief.timer" + "orangecat-cron@cat-watches.timer" + "orangecat-cron@reindex-embeddings.timer" + ) + scp "${SSH_OPTS[@]}" -q "${UNITS[@]/#/scripts/systemd/}" "$OC_BOX:/tmp/" + ssh "${SSH_OPTS[@]}" "$OC_BOX" 'bash -s' "${UNITS[@]}" <<'EVAL_UNITS' set -e +units=("$@") changed=0 -for u in orangecat-cat-eval.service orangecat-cat-eval.timer \ - orangecat-cat-outcomes.service orangecat-cat-outcomes.timer \ - orangecat-data-invariants.service orangecat-data-invariants.timer \ - orangecat-cron@cat-brief.timer orangecat-cron@cat-watches.timer \ - orangecat-cron@reindex-embeddings.timer; do +for u in "${units[@]}"; do if ! cmp -s "/tmp/$u" "/etc/systemd/system/$u" 2>/dev/null; then install -m 644 "/tmp/$u" "/etc/systemd/system/$u"; changed=1 fi rm -f "/tmp/$u" done [ "$changed" = 1 ] && systemctl daemon-reload -systemctl enable --now orangecat-cat-eval.timer >/dev/null -systemctl enable --now orangecat-cat-outcomes.timer >/dev/null -systemctl enable --now orangecat-data-invariants.timer >/dev/null -systemctl enable --now orangecat-cron@cat-brief.timer >/dev/null -systemctl enable --now orangecat-cron@cat-watches.timer >/dev/null -systemctl enable --now orangecat-cron@reindex-embeddings.timer >/dev/null -echo "cat-eval timer: $(systemctl is-enabled orangecat-cat-eval.timer) / $(systemctl is-active orangecat-cat-eval.timer)" -echo "cat-outcomes timer: $(systemctl is-enabled orangecat-cat-outcomes.timer) / $(systemctl is-active orangecat-cat-outcomes.timer)" -echo "data-invariants timer: $(systemctl is-enabled orangecat-data-invariants.timer) / $(systemctl is-active orangecat-data-invariants.timer)" -echo "cat-brief timer: $(systemctl is-enabled 'orangecat-cron@cat-brief.timer') / $(systemctl is-active 'orangecat-cron@cat-brief.timer')" -echo "cat-watches timer: $(systemctl is-enabled 'orangecat-cron@cat-watches.timer') / $(systemctl is-active 'orangecat-cron@cat-watches.timer')" +# Enable every TIMER we shipped, derived from the same list — a timer that is +# installed but never enabled is a job that silently never runs. +for u in "${units[@]}"; do + case "$u" in + *.timer) + systemctl enable --now "$u" >/dev/null + echo "$u: $(systemctl is-enabled "$u") / $(systemctl is-active "$u")" + ;; + esac +done EVAL_UNITS } || echo "WARN: cat-eval ship step failed (non-fatal)" >&2 diff --git a/scripts/systemd/orangecat-cron@cat-account.timer b/scripts/systemd/orangecat-cron@cat-account.timer new file mode 100644 index 000000000..c7a481a15 --- /dev/null +++ b/scripts/systemd/orangecat-cron@cat-account.timer @@ -0,0 +1,17 @@ +# SSOT: repo scripts/systemd/ — installed by deploy-selfhost.sh. +# Instance of the generic orangecat-cron@.service template (curls +# /api/cron/cat-account with CRON_SECRET). +# +# Asserts that the Cat has a profile. @cat is linked wherever it is written, so +# a missing account makes every mention resolve to nobody — daily re-assertion +# turns that from a support ticket into a self-healing invariant. +[Unit] +Description=OrangeCat Cat account invariant (daily) + +[Timer] +OnCalendar=daily +Persistent=true +RandomizedDelaySec=15m + +[Install] +WantedBy=timers.target diff --git a/src/app/api/cron/cat-account/route.ts b/src/app/api/cron/cat-account/route.ts new file mode 100644 index 000000000..a9a5407d2 --- /dev/null +++ b/src/app/api/cron/cat-account/route.ts @@ -0,0 +1,39 @@ +/** + * Cat Account Invariant Cron Route + * + * Schedule: systemd timer `orangecat-cron@cat-account.timer` on bitbaum, daily. + * + * Asserts that the Cat has an account. `@cat` is already tokenized and linked by + * utils/markdown.tsx, so if the profile row ever goes missing, every mention of + * the Cat across the platform silently resolves to nobody. Re-asserting it + * daily makes that self-healing instead of a support ticket. + * + * Cheap when it is a no-op, which is almost always: one indexed lookup. + */ + +import { createAdminClient } from '@/lib/supabase/admin'; +import { ensureCatAccount } from '@/services/mentions/cat-account'; +import { logger } from '@/utils/logger'; +import { apiSuccess, apiError, apiUnauthorized } from '@/lib/api/standardResponse'; +import { verifyCronSecret } from '@/lib/api/cronAuth'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; + +export async function GET(request: Request) { + if (!verifyCronSecret(request)) { + return apiUnauthorized(); + } + try { + const account = await ensureCatAccount(createAdminClient()); + if (!account) { + // Loud rather than silent: a missing Cat account breaks every @cat. + logger.error('Cat account could not be established', {}, 'CronCatAccount'); + return apiError('Cat account unavailable', 'INTERNAL_ERROR', 500); + } + return apiSuccess({ id: account.id, username: account.username }); + } catch (error) { + logger.error('Cat account check crashed', { error }, 'CronCatAccount'); + return apiError('Cat account check failed', 'INTERNAL_ERROR', 500); + } +} diff --git a/src/services/mentions/cat-account.ts b/src/services/mentions/cat-account.ts new file mode 100644 index 000000000..95a5c2edb --- /dev/null +++ b/src/services/mentions/cat-account.ts @@ -0,0 +1,111 @@ +/** + * The Cat's account — created once, then re-asserted. + * + * The Cat is a real profile rather than a rendering convention, which is what + * lets a Cat reply be an ordinary `messages` or `timeline_events` row: read + * receipts, search, deletion, threading, moderation and realtime all work on it + * without a single branch. The cost of that is exactly this file — something has + * to create the row. + * + * 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. + */ + +import { CAT_USERNAME, CAT_DISPLAY_NAME } from '@/config/cat-identity'; +import { DATABASE_TABLES } from '@/config/database-tables'; +import { logger } from '@/utils/logger'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +/** + * The Cat's login address. + * + * `.invalid` is reserved by RFC 2606 and can never be delivered to or + * registered by anyone, which matters more than it looks: an account's email is + * its password-reset channel, so a bot identity with a routable address is a + * bot identity somebody else can eventually take. This one has no password and + * no reachable mailbox, so there is no credential to phish and no reset to + * intercept. + * + * The local part is also load-bearing. `handle_new_user` derives the profile's + * username from `split_part(email, '@', 1)`, so this address is what makes the + * Cat's handle `cat` at creation time. + */ +const CAT_EMAIL = `${CAT_USERNAME}@orangecat.invalid`; + +const CAT_BIO = + 'The OrangeCat agent. Tag @cat in a message or under a post and I will answer there.'; + +export interface CatAccount { + id: string; + username: string; +} + +/** + * @param admin a service-role client. Creating an auth user and writing another + * account's profile are both privileged, so this cannot run as the caller. + * @returns the Cat's account, or null if it could not be established — callers + * must treat null as "do not attempt to speak as the Cat" rather than + * inventing a sender. + */ +export async function ensureCatAccount( + admin: SupabaseClient +): Promise { + const existing = await findCatProfile(admin); + if (existing) { + return existing; + } + + // No profile. Either the auth user does not exist either, or it does and its + // profile row was removed; both are handled by creating and then re-reading. + const { error: createError } = await admin.auth.admin.createUser({ + email: CAT_EMAIL, + email_confirm: true, + // No password is set, so the account cannot be signed into with one. + user_metadata: { full_name: CAT_DISPLAY_NAME }, + }); + + // "already registered" is the expected race and the expected second run: the + // auth user survives even when the profile row does not. + if (createError && !/already|exists|registered/i.test(createError.message)) { + logger.error('Could not create the Cat auth user', { error: createError.message }, 'CatAccount'); + return null; + } + + // handle_new_user inserts the profile from the email local part, so the + // username is already `cat`. Re-assert the presentation fields so a partially + // created account converges rather than staying half-built. + const profile = await findCatProfile(admin); + if (!profile) { + logger.error('Cat auth user exists but no profile row followed', {}, 'CatAccount'); + return null; + } + + const { error: updateError } = await admin + .from(DATABASE_TABLES.PROFILES) + .update({ name: CAT_DISPLAY_NAME, bio: CAT_BIO }) + .eq('id', profile.id); + + if (updateError) { + // The account exists and is usable; only its presentation is stale. + logger.warn('Cat profile created but could not be described', { error: updateError.message }, 'CatAccount'); + } + + logger.info('Cat account established', { id: profile.id }, 'CatAccount'); + return profile; +} + +async function findCatProfile(admin: SupabaseClient): Promise { + const { data, error } = await admin + .from(DATABASE_TABLES.PROFILES) + .select('id, username') + .eq('username', CAT_USERNAME) + .maybeSingle(); + + if (error) { + logger.error('Could not look up the Cat profile', { error: error.message }, 'CatAccount'); + return null; + } + return data ? { id: data.id as string, username: data.username as string } : null; +}