From 706b3d99f51733389f7290af0fa2266162f1563c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 12:53:03 +0000 Subject: [PATCH] AI agents in qrypt.chat: invite by email, SMS or link; E2EE chat (0.7.0, qc 0.6.0) - Migration 20261007000000 (applied on dev2): account_type 'agent', users.operator_user_id, agent_invites (sha256 of a 256-bit token only). - Settings > AI agents: invite by link, email (Resend) or SMS (Telnyx); list invites and agents; revoke open invites. - /agents/join#: human- and agent-readable instructions; the token lives in the fragment, never in a request line or Referer. - POST /api/agents/redeem: the agent sends only its ML-KEM-1024 public key; the server creates the agent account (operator = inviter), stores the key, opens a direct conversation and returns a session. Claimed atomically and rolled back on failure. - qc agent join [--name --username]: makes the keypair locally, seals the session like qc login; then qc listen / send / mcp. - Chat shows an AI agent badge; an agent's OpenProfile is Kind agent with an Operator section. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- packages/qryptchat/package.json | 2 +- src/app/agents/join/page.jsx | 63 ++++++ src/app/api/agents/invites/route.js | 108 ++++++++++ src/app/api/agents/redeem/route.js | 37 ++++ src/app/api/messages/load/route.js | 2 +- src/app/settings/page.jsx | 5 + src/app/u/[username]/openprofile.md/route.js | 8 +- src/app/u/[username]/page.jsx | 1 + src/cli/agent.js | 65 ++++++ src/cli/commands.js | 17 +- src/lib/agents/agents.js | 195 ++++++++++++++++++ src/lib/agents/agents.test.js | 152 ++++++++++++++ src/lib/components/chat/MessageItem.jsx | 2 + src/lib/components/settings/AgentSettings.jsx | 139 +++++++++++++ src/lib/profile/fields.js | 24 ++- supabase/migrations/20261007000000_agents.sql | 37 ++++ 17 files changed, 849 insertions(+), 10 deletions(-) create mode 100644 src/app/agents/join/page.jsx create mode 100644 src/app/api/agents/invites/route.js create mode 100644 src/app/api/agents/redeem/route.js create mode 100644 src/cli/agent.js create mode 100644 src/lib/agents/agents.js create mode 100644 src/lib/agents/agents.test.js create mode 100644 src/lib/components/settings/AgentSettings.jsx create mode 100644 supabase/migrations/20261007000000_agents.sql diff --git a/package.json b/package.json index cdfcac7b..bd72e0b2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.6.1", + "version": "0.7.0", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index e0b5107c..1580716e 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/qryptchat", - "version": "0.5.1", + "version": "0.6.0", "description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.", "type": "module", "bin": { diff --git a/src/app/agents/join/page.jsx b/src/app/agents/join/page.jsx new file mode 100644 index 00000000..7ad783b3 --- /dev/null +++ b/src/app/agents/join/page.jsx @@ -0,0 +1,63 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * /agents/join#: where an agent invite link lands. The token is in the + * fragment, which browsers never send to the server; this page only shows the + * command that redeems it. The steps are plain text so an agent reading the + * page can follow them too. + */ +export default function AgentJoinPage() { + const [url, setUrl] = useState(''); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (typeof window !== 'undefined' && window.location.hash.length > 1) setUrl(window.location.href); + }, []); + + const command = `npx -y @profullstack/qryptchat agent join "${url || 'https://qrypt.chat/agents/join#'}"`; + + async function copy() { + try { + await navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch {} + } + + return ( +
+

🤖 An invite for an AI agent

+

+ Someone on qrypt.chat wants to chat with an AI agent, end-to-end encrypted with ML-KEM-1024. This link is the + agent's invite. It works once and expires in 7 days. +

+ +

For the agent

+
    +
  1. + Run this. It makes the agent's own encryption keys on its machine (the private key never leaves it), creates + its account and opens a chat with the person who invited it: +
    {command}
    + + {' '}Add --name "Athena" and --username athena_bot to choose how it appears. +
  2. +
  3. + Then chat: qc listen prints new messages, qc send <chat> "text" replies, and{' '} + qc mcp runs qrypt.chat as an MCP server (list_chats, read_chat, send_message). Set{' '} + QC_PASSPHRASE for unattended use; the keys are stored sealed. +
  4. +
+ +

Without the CLI

+

+ POST /api/agents/redeem with {'{ token, username, displayName, publicKey }'}, where{' '} + publicKey is the agent's ML-KEM-1024 public key (base64) and token is the part of this link + after #. The response is a session (access and refresh token), the account, and the conversation id. + Messages are encrypted per recipient with ML-KEM-1024 + HKDF-SHA-256 + ChaCha20-Poly1305; the{' '} + @profullstack/encrypt library implements it. +

+
+ ); +} diff --git a/src/app/api/agents/invites/route.js b/src/app/api/agents/invites/route.js new file mode 100644 index 00000000..58ff5d88 --- /dev/null +++ b/src/app/api/agents/invites/route.js @@ -0,0 +1,108 @@ +import { NextResponse } from 'next/server'; +import { withAuth } from '@/lib/api/middleware/auth.js'; +import { getServiceRoleClient } from '@/lib/supabase/service-role.js'; +import { AgentError, cleanAgentName, createInvite, inviteMessage } from '@/lib/agents/agents.js'; +import { createSMSWebhookEmailService } from '@/lib/services/sms-alert-email-service.js'; +import { e164, sendViaTelnyx } from '@/lib/auth/sms-hook.js'; + +const EMAIL_RE = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/; + +async function me(db, authUserId) { + const { data } = await db.from('users').select('id, username, display_name, account_type').eq('auth_user_id', authUserId).single(); + return data; +} + +const fail = (error) => + error instanceof AgentError + ? NextResponse.json({ error: error.message, code: error.code }, { status: error.status }) + : (console.error('[agents/invites]', error), NextResponse.json({ error: 'Internal server error' }, { status: 500 })); + +/** + * POST /api/agents/invites { name?, email?, phone? } + * Invite an AI agent: returns the link, and sends it by email or SMS when one + * is given. The token is shown once; only its hash is stored. + */ +export const POST = withAuth(async ({ request, locals }) => { + try { + const body = await request.json().catch(() => ({})); + const db = getServiceRoleClient(); + const inviter = await me(db, locals.user.id); + if (!inviter) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + if (inviter.account_type === 'agent') return NextResponse.json({ error: 'Agents cannot invite agents' }, { status: 403 }); + + const name = cleanAgentName(body?.name); + const email = typeof body?.email === 'string' ? body.email.trim() : ''; + const phone = typeof body?.phone === 'string' ? body.phone.trim() : ''; + if (email && !EMAIL_RE.test(email)) return NextResponse.json({ error: 'That email address does not look right' }, { status: 400 }); + const to = phone ? e164(phone) : null; + if (phone && !to) return NextResponse.json({ error: 'That phone number does not look right' }, { status: 400 }); + + const channel = email ? 'email' : to ? 'sms' : 'link'; + const invite = await createInvite(db, inviter.id, { name, channel, destination: email || to || null }); + const message = inviteMessage({ inviterName: inviter.display_name || `@${inviter.username}`, agentName: name, url: invite.url }); + + let sent = false; + let warning; + if (channel === 'email') { + const mailer = createSMSWebhookEmailService(); + const result = mailer ? await mailer.sendEmail({ to: email, subject: message.subject, text: message.text }) : { success: false }; + sent = !!result?.success; + if (!sent) warning = 'The invite was created but the email could not be sent. Share the link instead.'; + } else if (channel === 'sms') { + try { + await sendViaTelnyx({ to, text: message.sms }); + sent = true; + } catch (error) { + console.error('[agents/invites] sms', error?.message); + warning = 'The invite was created but the text could not be sent. Share the link instead.'; + } + } + + return NextResponse.json({ invite: { id: invite.id, url: invite.url, expiresAt: invite.expiresAt, channel }, sent, ...(warning ? { warning } : {}) }); + } catch (error) { + return fail(error); + } +}); + +/** GET /api/agents/invites: my invites (no tokens) and the agents I operate. */ +export const GET = withAuth(async ({ locals }) => { + try { + const db = getServiceRoleClient(); + const inviter = await me(db, locals.user.id); + if (!inviter) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + const [{ data: invites }, { data: agents }] = await Promise.all([ + db + .from('agent_invites') + .select('id, agent_name, channel, destination, created_at, expires_at, revoked_at, redeemed_at, redeemed_by') + .eq('inviter_user_id', inviter.id) + .order('created_at', { ascending: false }) + .limit(50), + db.from('users').select('id, username, display_name, emoji, created_at').eq('operator_user_id', inviter.id).eq('account_type', 'agent').order('created_at', { ascending: false }), + ]); + return NextResponse.json({ invites: invites ?? [], agents: agents ?? [] }); + } catch (error) { + return fail(error); + } +}); + +/** DELETE /api/agents/invites?id=: revoke an unused invite. */ +export const DELETE = withAuth(async ({ request, locals }) => { + try { + const id = new URL(request.url).searchParams.get('id'); + if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 }); + const db = getServiceRoleClient(); + const inviter = await me(db, locals.user.id); + if (!inviter) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + const { data } = await db + .from('agent_invites') + .update({ revoked_at: new Date().toISOString() }) + .eq('id', id) + .eq('inviter_user_id', inviter.id) + .is('redeemed_at', null) + .select('id'); + if (!data?.length) return NextResponse.json({ error: 'No open invite with that id' }, { status: 404 }); + return NextResponse.json({ success: true }); + } catch (error) { + return fail(error); + } +}); diff --git a/src/app/api/agents/redeem/route.js b/src/app/api/agents/redeem/route.js new file mode 100644 index 00000000..603a9c8b --- /dev/null +++ b/src/app/api/agents/redeem/route.js @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; +import { getServiceRoleClient } from '@/lib/supabase/service-role.js'; +import { AgentError, redeemInvite, tokenFrom } from '@/lib/agents/agents.js'; +import { mintSession } from '@/lib/auth/cli-auth.js'; + +/** + * POST /api/agents/redeem { token, username, displayName?, publicKey } + * An agent joins qrypt.chat with an invite. `publicKey` is its own + * ML-KEM-1024 public key; the private key never leaves the agent. Returns a + * session (access + refresh token) for qc, the account, and the conversation + * with the person who invited it. + * + * No session needed: the 256-bit invite token is the credential. + */ +export async function POST(request) { + try { + const body = await request.json().catch(() => null); + const token = tokenFrom(body?.token); + if (!token) return NextResponse.json({ error: 'A valid invite token is required', code: 'invalid_token' }, { status: 400 }); + + const db = getServiceRoleClient(); + const { authUserId, user, conversationId, operator } = await redeemInvite(db, token, { + username: body?.username, + displayName: body?.displayName, + publicKey: body?.publicKey, + }); + const session = await mintSession(db, authUserId); + return NextResponse.json( + { ...session, user, conversation_id: conversationId, operator }, + { headers: { 'Cache-Control': 'no-store' } } + ); + } catch (error) { + if (error instanceof AgentError) return NextResponse.json({ error: error.message, code: error.code }, { status: error.status }); + console.error('[agents/redeem]', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/messages/load/route.js b/src/app/api/messages/load/route.js index 2e018d03..b078581c 100644 --- a/src/app/api/messages/load/route.js +++ b/src/app/api/messages/load/route.js @@ -67,7 +67,7 @@ export const POST = withAuth(async ({ request, locals }) => { .from('messages') .select(` *, - sender:users!messages_sender_id_fkey(id, username, display_name, avatar_url, emoji, pronouns), + sender:users!messages_sender_id_fkey(id, username, display_name, avatar_url, emoji, pronouns, account_type), message_recipients!inner(encrypted_content, recipient_user_id) `) .eq('conversation_id', conversationId) diff --git a/src/app/settings/page.jsx b/src/app/settings/page.jsx index cbb80580..b8b0513e 100644 --- a/src/app/settings/page.jsx +++ b/src/app/settings/page.jsx @@ -10,6 +10,7 @@ import WebhookSettings from '@/lib/components/settings/WebhookSettings.jsx'; import InviteAnon from '@/lib/components/settings/InviteAnon.jsx'; import PasskeySettings from '@/lib/components/settings/PasskeySettings.jsx'; import ProfileSettings from '@/lib/components/settings/ProfileSettings.jsx'; +import AgentSettings from '@/lib/components/settings/AgentSettings.jsx'; export default function SettingsPage() { const router = useRouter(); @@ -30,6 +31,10 @@ export default function SettingsPage() {

Profile

+
+

AI agents

+ +

Passkeys

diff --git a/src/app/u/[username]/openprofile.md/route.js b/src/app/u/[username]/openprofile.md/route.js index 0255b139..8dc32697 100644 --- a/src/app/u/[username]/openprofile.md/route.js +++ b/src/app/u/[username]/openprofile.md/route.js @@ -18,7 +18,13 @@ export async function GET(request, { params } = {}) { .single(); if (error || !data) return new Response('Profile not found\n', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); - return new Response(toOpenProfile(data), { + let operator = null; + if (data.account_type === 'agent' && data.operator_user_id) { + const { data: op } = await getServiceRoleClient().from('users').select('username, display_name').eq('id', data.operator_user_id).maybeSingle(); + operator = op ?? null; + } + + return new Response(toOpenProfile(data, operator), { headers: { 'Content-Type': 'text/markdown; charset=utf-8', 'Cache-Control': 'public, max-age=300' diff --git a/src/app/u/[username]/page.jsx b/src/app/u/[username]/page.jsx index 032a5678..fe7250e3 100644 --- a/src/app/u/[username]/page.jsx +++ b/src/app/u/[username]/page.jsx @@ -47,6 +47,7 @@ function UserProfileInner() {

@{profile.username} {profile.pronouns && · {profile.pronouns}} + {profile.account_type === 'agent' && · AI agent}

{profile.bio &&

{profile.bio}

} {profile.website && ( diff --git a/src/cli/agent.js b/src/cli/agent.js new file mode 100644 index 00000000..2a2611db --- /dev/null +++ b/src/cli/agent.js @@ -0,0 +1,65 @@ +/** + * `qc agent join `: an AI agent joins qrypt.chat. + * + * The agent's ML-KEM-1024 keypair is made here and the private key never + * leaves this machine: only the public key goes to the server, which creates + * an `agent` account (its operator is the person who invited it), opens a chat + * between them and returns a session. The session and keys are then sealed at + * rest like any `qc login`. + */ +import { randomBytes } from 'node:crypto'; +import { DEFAULT_URL, storeSession } from './config.js'; +import { ephemeralKeypair } from './crypto.js'; + +/** The invite token and the site it belongs to, from a link or a bare token. */ +export function parseInvite(input) { + const text = String(input ?? '').trim(); + let base = DEFAULT_URL; + let token = text; + if (/^https?:\/\//i.test(text)) { + const url = new URL(text); + base = url.origin; + token = url.hash.replace(/^#/, '') || url.searchParams.get('token') || ''; + } + if (!/^[A-Za-z0-9_-]{40,64}$/.test(token)) throw new Error('That is not an agent invite link (https://qrypt.chat/agents/join#...).'); + return { base, token }; +} + +/** A username from a display name: lowercase letters, digits and _ plus a short suffix. */ +export function usernameFor(name) { + const stem = String(name || 'agent') + .toLowerCase() + .replace(/[^a-z0-9_]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 20) || 'agent'; + return `${stem.length >= 3 ? stem : `${stem}_agent`}_${randomBytes(3).toString('hex')}`.slice(0, 30); +} + +/** + * Redeem an invite and store the new agent's session. Returns + * { session, conversationId, operator }. + */ +export async function joinAsAgent(invite, { name, username, env = process.env, fetchImpl = fetch, vault } = {}) { + const { base, token } = parseInvite(invite); + const keys1024 = await ephemeralKeypair(); + const res = await fetchImpl(`${base}/api/agents/redeem`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, username: username || usernameFor(name), displayName: name, publicKey: keys1024.publicKey }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || `Could not join (HTTP ${res.status})`); + const session = { + base, + access_token: body.access_token, + refresh_token: body.refresh_token, + expires_at: body.expires_at, + user: body.user, + keys: { keys1024 }, + agent: true, + created_at: new Date().toISOString(), + }; + // Sealed before it touches disk, like qc login: keys and tokens never plaintext at rest. + await storeSession(session, env, vault); + return { session, conversationId: body.conversation_id, operator: body.operator }; +} diff --git a/src/cli/commands.js b/src/cli/commands.js index 844c5d10..82f6a50c 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -5,11 +5,13 @@ import { QcClient } from './api.js'; import { clearSession, loadSession, sessionPath, unlockSession } from './config.js'; import { login } from './login.js'; +import { joinAsAgent } from './agent.js'; export const HELP = `qc: qrypt.chat in your terminal (end-to-end encrypted, ML-KEM-1024) Usage: qc open the chat client (signs you in first if needed) + qc agent join join as an AI agent from an invite (makes its own keys) [--name --username] qc login [--oob] sign in through your browser; --oob to paste a code (SSH) qc logout forget this terminal's session and keys qc whoami who this terminal is signed in as @@ -42,7 +44,7 @@ export function parseArgs(argv) { if (a.startsWith('--')) { const [k, v] = a.slice(2).split('=', 2); if (v !== undefined) args.flags[k] = v; - else if (['url', 'n', 'limit', 'reply', 'emoji', 'pronouns', 'website', 'bio'].includes(k) && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) args.flags[k] = argv[++i]; + else if (['url', 'n', 'limit', 'reply', 'emoji', 'pronouns', 'website', 'bio', 'name', 'username'].includes(k) && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) args.flags[k] = argv[++i]; else args.flags[k] = true; } else if (a === '-n' && argv[i + 1] !== undefined) { args.flags.n = argv[++i]; @@ -106,6 +108,19 @@ export async function main(argv, { version = '0.0.0' } = {}) { const { runTui } = await import('./tui.js'); return runTui(c, { initialChat: rest[0] }); } + case 'agent': { + if (rest[0] !== 'join' || !rest[1]) throw new Error('Usage: qc agent join [--name NAME] [--username NAME]'); + const { session, conversationId, operator } = await joinAsAgent(rest[1], { + name: typeof flags.name === 'string' ? flags.name : undefined, + username: typeof flags.username === 'string' ? flags.username : undefined, + }); + if (flags.json) return json({ user: session.user, conversation_id: conversationId, operator }); + out(`Joined qrypt.chat as @${session.user?.username} (agent), operated by @${operator?.username ?? '?'}.`); + out(`Keys made here and sealed in ${sessionPath()}; only the public key left this machine.`); + const chatName = operator?.display_name || operator?.username || ''; + out(`Your chat with ${chatName} is open. Try: qc listen or qc send "${chatName}" "hello" or qc mcp`); + return; + } case 'login': { const { session } = await login({ oob: !!flags.oob }); out(`Signed in as @${session.user?.username ?? 'unknown'} on ${session.base}. Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`); diff --git a/src/lib/agents/agents.js b/src/lib/agents/agents.js new file mode 100644 index 00000000..a796b0a1 --- /dev/null +++ b/src/lib/agents/agents.js @@ -0,0 +1,195 @@ +/** + * AI agents as qrypt.chat accounts. + * + * A person invites an agent (email, SMS or a link they share). The agent runs + * `qc agent join `: it makes its own ML-KEM keys locally and redeems the + * invite with only the public key. The server creates an `agent` account whose + * operator is the inviter, stores the public key, opens a direct conversation + * between them, and hands back a session. From then on the agent chats like + * any qc user, end to end encrypted. + * + * The invite token is a 256-bit secret carried in the link's fragment (never + * sent in a request line or a Referer); the database keeps only its SHA-256. + */ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; + +export const INVITE_TTL_DAYS = 7; +export const MAX_OPEN_INVITES = 25; +export const SITE = 'https://qrypt.chat'; +export const AGENT_USERNAME_RE = /^[A-Za-z0-9_]{3,30}$/; +const ML_KEM_1024_PUBLIC_KEY_BYTES = 1568; + +export class AgentError extends Error { + constructor(code, message, status = 400) { + super(message); + this.code = code; + this.status = status; + } +} + +export const newInviteToken = () => randomBytes(32).toString('base64url'); +export const hashInviteToken = (token) => createHash('sha256').update(String(token)).digest('hex'); + +/** The link a person shares; the token rides in the fragment. */ +export const inviteUrl = (token) => `${SITE}/agents/join#${token}`; + +/** A token from a link, a fragment or the bare token. */ +export function tokenFrom(input) { + const text = String(input ?? '').trim(); + const fromUrl = /[#?&](?:token=)?([A-Za-z0-9_-]{40,})$/.exec(text); + const token = fromUrl ? fromUrl[1] : text; + return /^[A-Za-z0-9_-]{40,64}$/.test(token) ? token : null; +} + +/** What the inviter types, cleaned: a name of up to 60 characters, or null. */ +export function cleanAgentName(name) { + if (name === undefined || name === null) return null; + const text = String(name).trim().replace(/\s+/g, ' '); + if (!text) return null; + if (text.length > 60 || /[<>\p{Cc}]/u.test(text)) throw new AgentError('invalid_name', 'Give the agent a name of up to 60 characters'); + return text; +} + +/** + * Create an invite. Returns { id, token, url, expiresAt }; the token is never + * stored, so it can only be shown now. + */ +export async function createInvite(db, inviterUserId, { name = null, channel = 'link', destination = null } = {}) { + const { count } = await db + .from('agent_invites') + .select('id', { count: 'exact', head: true }) + .eq('inviter_user_id', inviterUserId) + .is('redeemed_at', null) + .is('revoked_at', null) + .gt('expires_at', new Date().toISOString()); + if ((count ?? 0) >= MAX_OPEN_INVITES) { + throw new AgentError('too_many_invites', `You have ${MAX_OPEN_INVITES} open agent invites. Revoke some first.`, 429); + } + const token = newInviteToken(); + const { data, error } = await db + .from('agent_invites') + .insert({ token_hash: hashInviteToken(token), inviter_user_id: inviterUserId, agent_name: name, channel, destination }) + .select('id, expires_at') + .single(); + if (error || !data) throw new AgentError('server_error', 'Could not create the invite', 500); + return { id: data.id, token, url: inviteUrl(token), expiresAt: data.expires_at }; +} + +/** The invite behind a token if it can still be redeemed, else null. */ +export async function openInvite(db, token) { + const { data } = await db + .from('agent_invites') + .select('id, inviter_user_id, agent_name, expires_at, revoked_at, redeemed_at') + .eq('token_hash', hashInviteToken(token)) + .maybeSingle(); + if (!data || data.revoked_at || data.redeemed_at || Date.parse(data.expires_at) <= Date.now()) return null; + return data; +} + +/** + * Redeem an invite as a new agent account. `publicKey` is the agent's + * ML-KEM-1024 public key (base64); its private key never leaves the agent. + * Returns { authUserId, user, conversationId, operator }. + */ +export async function redeemInvite(db, token, { username, displayName, publicKey }) { + if (!AGENT_USERNAME_RE.test(username ?? '')) throw new AgentError('invalid_username', 'Pick a username of 3-30 letters, digits or underscores'); + let keyBytes; + try { + keyBytes = Buffer.from(String(publicKey ?? ''), 'base64'); + } catch { + keyBytes = Buffer.alloc(0); + } + if (keyBytes.length !== ML_KEM_1024_PUBLIC_KEY_BYTES) throw new AgentError('invalid_key', 'publicKey must be an ML-KEM-1024 public key (base64)'); + const name = cleanAgentName(displayName); + + const invite = await openInvite(db, token); + if (!invite) throw new AgentError('invite_invalid', 'This invite is invalid, used, revoked or expired', 410); + + const { data: taken } = await db.from('users').select('id').ilike('username', username).maybeSingle(); + if (taken) throw new AgentError('username_taken', 'That username is taken', 409); + + // Claim the invite before creating anything: two redeemers cannot both win. + const { data: claimed } = await db + .from('agent_invites') + .update({ redeemed_at: new Date().toISOString() }) + .eq('id', invite.id) + .is('redeemed_at', null) + .select('id'); + if (!claimed?.length) throw new AgentError('invite_invalid', 'This invite was just used', 410); + + let authUserId = null; + const undo = async () => { + if (authUserId) await db.auth.admin.deleteUser(authUserId).catch(() => {}); + await db.from('agent_invites').update({ redeemed_at: null, redeemed_by: null }).eq('id', invite.id); + }; + + try { + const { data: created, error: authError } = await db.auth.admin.createUser({ + email: `agent-${randomUUID()}@agents.qrypt.chat`, + email_confirm: true, + user_metadata: { agent: true, operator_user_id: invite.inviter_user_id }, + }); + if (authError || !created?.user) throw new AgentError('server_error', 'Could not create the agent account', 500); + authUserId = created.user.id; + + const { data: user, error: userError } = await db + .from('users') + .insert({ + auth_user_id: authUserId, + phone_number: null, + account_type: 'agent', + operator_user_id: invite.inviter_user_id, + username, + display_name: name || invite.agent_name || username, + }) + .select('id, username, display_name, account_type, operator_user_id') + .single(); + if (userError || !user) { + if (userError?.code === '23505') throw new AgentError('username_taken', 'That username is taken', 409); + throw new AgentError('server_error', 'Could not create the agent account', 500); + } + + const { error: keyError } = await db + .from('user_public_keys') + .insert({ user_id: authUserId, public_key: keyBytes.toString('base64'), key_type: 'ML-KEM-1024' }); + if (keyError) throw new AgentError('server_error', 'Could not store the agent key', 500); + + const { data: conversationId, error: convError } = await db.rpc('create_direct_conversation', { + user1_id: invite.inviter_user_id, + user2_id: user.id, + }); + if (convError || !conversationId) throw new AgentError('server_error', 'Could not open the conversation', 500); + + await db.from('agent_invites').update({ redeemed_by: user.id }).eq('id', invite.id); + + const { data: operator } = await db + .from('users') + .select('id, username, display_name') + .eq('id', invite.inviter_user_id) + .single(); + return { authUserId, user, conversationId, operator }; + } catch (error) { + await undo(); + throw error; + } +} + +/** The invite message for email and SMS. */ +export function inviteMessage({ inviterName, agentName, url }) { + const who = agentName ? `your agent "${agentName}"` : 'your AI agent'; + const subject = `${inviterName} invited ${agentName ? `"${agentName}"` : 'an AI agent'} to qrypt.chat`; + const command = `npx -y @profullstack/qryptchat agent join "${url}"`; + const text = [ + `${inviterName} wants to chat with ${who} on qrypt.chat, end-to-end encrypted (ML-KEM-1024).`, + '', + 'Give the agent this command; it makes its own keys and joins:', + '', + ` ${command}`, + '', + `Then it can use qc listen / qc send, or run qc mcp as an MCP server. The link works once and expires in ${INVITE_TTL_DAYS} days.`, + '', + `Instructions: ${url}`, + ].join('\n'); + const sms = `${inviterName} invited ${agentName ? `"${agentName}"` : 'an AI agent'} to qrypt.chat. Have the agent run: ${command}`; + return { subject, text, sms }; +} diff --git a/src/lib/agents/agents.test.js b/src/lib/agents/agents.test.js new file mode 100644 index 00000000..bf4e0602 --- /dev/null +++ b/src/lib/agents/agents.test.js @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from 'vitest'; +import { emoji, kindOf, parseOpenProfile, section } from '@profullstack/openprofile'; +import { AgentError, cleanAgentName, hashInviteToken, inviteMessage, inviteUrl, newInviteToken, redeemInvite, tokenFrom } from './agents.js'; +import { toOpenProfile } from '../profile/fields.js'; +import { parseInvite, usernameFor } from '../../cli/agent.js'; + +const KEY = Buffer.alloc(1568, 7).toString('base64'); + +/** A tiny in-memory stand-in for the service-role client. */ +function fakeDb({ invite, usernameTaken = false, keyInsertFails = false } = {}) { + const state = { invite: invite ? { ...invite } : null, users: [], keys: [], deletedAuth: [], rpc: [] }; + const db = { + state, + auth: { + admin: { + createUser: vi.fn(async () => ({ data: { user: { id: 'auth-agent' } }, error: null })), + deleteUser: vi.fn(async (id) => state.deletedAuth.push(id)) + } + }, + rpc: vi.fn(async (name, args) => (state.rpc.push([name, args]), { data: 'conv-1', error: null })), + from(table) { + let op = 'select'; + let patch = null; + const filters = []; + const q = { + select: () => q, + eq: (k, v) => (filters.push(['eq', k, v]), q), + is: (k, v) => (filters.push(['is', k, v]), q), + ilike: (k, v) => (filters.push(['ilike', k, v]), q), + update: (p) => ((op = 'update'), (patch = p), q), + insert: (row) => ((op = 'insert'), (patch = row), q), + maybeSingle: async () => run('one'), + single: async () => run('one'), + then: (res, rej) => run('many').then(res, rej) + }; + async function run(shape) { + if (table === 'agent_invites') { + const inv = state.invite; + const match = inv && filters.every(([t, k, v]) => (t === 'is' ? inv[k] === v || (v === null && inv[k] == null) : k === 'token_hash' ? inv.token_hash === v : inv[k] === v)); + if (op === 'update') { + if (!match) return { data: [], error: null }; + Object.assign(inv, patch); + return { data: [{ id: inv.id }], error: null }; + } + return { data: match ? inv : null, error: null }; + } + if (table === 'users') { + if (op === 'insert') { + const row = { id: 'user-agent', ...patch }; + state.users.push(row); + return { data: row, error: null }; + } + const ilike = filters.find(([t]) => t === 'ilike'); + if (ilike) return { data: usernameTaken ? { id: 'someone' } : null, error: null }; + return { data: { id: 'inviter', username: 'chovy', display_name: 'Anthony' }, error: null }; + } + if (table === 'user_public_keys') { + if (keyInsertFails) return { data: null, error: { message: 'nope' } }; + state.keys.push(patch); + return { data: null, error: null }; + } + return { data: shape === 'one' ? null : [], error: null }; + } + return q; + } + }; + return db; +} + +function openInviteFor(token, extra = {}) { + return { + id: 'inv-1', + token_hash: hashInviteToken(token), + inviter_user_id: 'inviter', + agent_name: 'Athena', + expires_at: new Date(Date.now() + 86400000).toISOString(), + revoked_at: null, + redeemed_at: null, + ...extra + }; +} + +describe('agent invites', () => { + it('tokens are 256-bit, ride in the fragment, and parse back from a link', () => { + const token = newInviteToken(); + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(inviteUrl(token)).toBe(`https://qrypt.chat/agents/join#${token}`); + expect(tokenFrom(inviteUrl(token))).toBe(token); + expect(tokenFrom(token)).toBe(token); + expect(tokenFrom('nope')).toBeNull(); + expect(parseInvite(inviteUrl(token))).toEqual({ base: 'https://qrypt.chat', token }); + expect(() => parseInvite('https://qrypt.chat/agents/join')).toThrow(/not an agent invite/); + }); + + it('names are cleaned, usernames derived', () => { + expect(cleanAgentName(' Athena ')).toBe('Athena'); + expect(cleanAgentName('')).toBeNull(); + expect(() => cleanAgentName('x')).toThrow(AgentError); + expect(usernameFor('Athena Bot')).toMatch(/^athena_bot_[0-9a-f]{6}$/); + expect(usernameFor('')).toMatch(/^agent_[0-9a-f]{6}$/); + }); + + it('the message carries the one command the agent needs', () => { + const m = inviteMessage({ inviterName: 'Anthony', agentName: 'Athena', url: 'https://qrypt.chat/agents/join#t' }); + expect(m.subject).toContain('Athena'); + expect(m.text).toContain('npx -y @profullstack/qryptchat agent join "https://qrypt.chat/agents/join#t"'); + expect(m.sms.length).toBeLessThan(320); + }); +}); + +describe('redeemInvite', () => { + it('creates the agent account with its own public key and opens a chat with the operator', async () => { + const token = newInviteToken(); + const db = fakeDb({ invite: openInviteFor(token) }); + const out = await redeemInvite(db, token, { username: 'athena_bot', displayName: 'Athena', publicKey: KEY }); + expect(out.conversationId).toBe('conv-1'); + expect(db.state.users[0]).toMatchObject({ account_type: 'agent', operator_user_id: 'inviter', username: 'athena_bot', display_name: 'Athena' }); + expect(db.state.keys[0]).toEqual({ user_id: 'auth-agent', public_key: KEY, key_type: 'ML-KEM-1024' }); + expect(db.rpc).toHaveBeenCalledWith('create_direct_conversation', { user1_id: 'inviter', user2_id: 'user-agent' }); + expect(db.state.invite.redeemed_at).toBeTruthy(); + expect(db.state.invite.redeemed_by).toBe('user-agent'); + }); + + it('refuses used, revoked or expired invites, bad keys and taken usernames', async () => { + const token = newInviteToken(); + await expect(redeemInvite(fakeDb({ invite: openInviteFor(token, { redeemed_at: 'x' }) }), token, { username: 'athena_bot', publicKey: KEY })).rejects.toMatchObject({ code: 'invite_invalid' }); + await expect(redeemInvite(fakeDb({ invite: openInviteFor(token, { revoked_at: 'x' }) }), token, { username: 'athena_bot', publicKey: KEY })).rejects.toMatchObject({ code: 'invite_invalid' }); + await expect(redeemInvite(fakeDb({ invite: openInviteFor(token, { expires_at: new Date(0).toISOString() }) }), token, { username: 'athena_bot', publicKey: KEY })).rejects.toMatchObject({ code: 'invite_invalid' }); + await expect(redeemInvite(fakeDb({ invite: openInviteFor(token) }), token, { username: 'athena_bot', publicKey: 'AAAA' })).rejects.toMatchObject({ code: 'invalid_key' }); + await expect(redeemInvite(fakeDb({ invite: openInviteFor(token) }), token, { username: 'x', publicKey: KEY })).rejects.toMatchObject({ code: 'invalid_username' }); + await expect(redeemInvite(fakeDb({ invite: openInviteFor(token), usernameTaken: true }), token, { username: 'athena_bot', publicKey: KEY })).rejects.toMatchObject({ code: 'username_taken' }); + }); + + it('undoes everything when a step fails, so the invite can be used again', async () => { + const token = newInviteToken(); + const db = fakeDb({ invite: openInviteFor(token), keyInsertFails: true }); + await expect(redeemInvite(db, token, { username: 'athena_bot', publicKey: KEY })).rejects.toBeInstanceOf(AgentError); + expect(db.state.deletedAuth).toEqual(['auth-agent']); + expect(db.state.invite.redeemed_at).toBeNull(); + }); +}); + +describe('an agent profile', () => { + it('is Kind agent with an Operator section pointing at the person', () => { + const md = toOpenProfile({ username: 'athena_bot', display_name: 'Athena', emoji: '🦉', account_type: 'agent' }, { username: 'chovy', display_name: 'Anthony' }); + const doc = parseOpenProfile(md); + expect(kindOf(doc)).toBe('agent'); + expect(emoji(doc)).toBe('🦉'); + expect(section(doc, 'operator')?.body).toContain('https://qrypt.chat/u/chovy/openprofile.md'); + expect(kindOf(parseOpenProfile(toOpenProfile({ username: 'ada', display_name: 'Ada' })))).toBe('person'); + }); +}); diff --git a/src/lib/components/chat/MessageItem.jsx b/src/lib/components/chat/MessageItem.jsx index 3130abd7..f53039a8 100644 --- a/src/lib/components/chat/MessageItem.jsx +++ b/src/lib/components/chat/MessageItem.jsx @@ -84,6 +84,7 @@ export default function MessageItem({ message, showAvatar = true, showTimestamp {sender?.emoji &&