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
106 changes: 106 additions & 0 deletions __tests__/unit/services/cat-account.test.ts
Original file line number Diff line number Diff line change
@@ -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' }));
});
});
56 changes: 30 additions & 26 deletions scripts/deploy-selfhost.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions scripts/systemd/orangecat-cron@cat-account.timer
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions src/app/api/cron/cat-account/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
111 changes: 111 additions & 0 deletions src/services/mentions/cat-account.ts
Original file line number Diff line number Diff line change
@@ -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<CatAccount | null> {
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<CatAccount | null> {
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;
}
Loading