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 package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
"version": "0.6.1",
"version": "0.7.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/qryptchat/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
63 changes: 63 additions & 0 deletions src/app/agents/join/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use client';

import { useEffect, useState } from 'react';

/**
* /agents/join#<token>: 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#<token>'}"`;

async function copy() {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {}
}

return (
<main className="container" style={{ maxWidth: 680, padding: '3rem 1rem' }}>
<h1 style={{ fontSize: '1.6rem', fontWeight: 700, marginBottom: '.5rem' }}>🤖 An invite for an AI agent</h1>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: '1.5rem' }}>
Someone on qrypt.chat wants to chat with an AI agent, end-to-end encrypted with ML-KEM-1024. This link is the
agent&apos;s invite. It works once and expires in 7 days.
</p>

<h2 style={{ fontSize: '1.1rem', fontWeight: 600, margin: '1rem 0 .5rem' }}>For the agent</h2>
<ol style={{ lineHeight: 1.7, paddingLeft: '1.25rem' }}>
<li>
Run this. It makes the agent&apos;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:
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', background: 'var(--color-bg-secondary)', padding: '.75rem', borderRadius: '.5rem', margin: '.5rem 0' }}>{command}</pre>
<button type="button" className="btn btn-secondary btn-sm" onClick={copy} disabled={!url}>{copied ? 'Copied' : 'Copy command'}</button>
{' '}Add <code>--name &quot;Athena&quot;</code> and <code>--username athena_bot</code> to choose how it appears.
</li>
<li>
Then chat: <code>qc listen</code> prints new messages, <code>qc send &lt;chat&gt; &quot;text&quot;</code> replies, and{' '}
<code>qc mcp</code> runs qrypt.chat as an MCP server (list_chats, read_chat, send_message). Set{' '}
<code>QC_PASSPHRASE</code> for unattended use; the keys are stored sealed.
</li>
</ol>

<h2 style={{ fontSize: '1.1rem', fontWeight: 600, margin: '1.5rem 0 .5rem' }}>Without the CLI</h2>
<p style={{ color: 'var(--color-text-secondary)' }}>
<code>POST /api/agents/redeem</code> with <code>{'{ token, username, displayName, publicKey }'}</code>, where{' '}
<code>publicKey</code> is the agent&apos;s ML-KEM-1024 public key (base64) and <code>token</code> is the part of this link
after <code>#</code>. 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{' '}
<a href="https://github.com/profullstack/encrypt">@profullstack/encrypt</a> library implements it.
</p>
</main>
);
}
108 changes: 108 additions & 0 deletions src/app/api/agents/invites/route.js
Original file line number Diff line number Diff line change
@@ -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=<invite 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);
}
});
37 changes: 37 additions & 0 deletions src/app/api/agents/redeem/route.js
Original file line number Diff line number Diff line change
@@ -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 });
}
}
2 changes: 1 addition & 1 deletion src/app/api/messages/load/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions src/app/settings/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -30,6 +31,10 @@ export default function SettingsPage() {
<h2>Profile</h2>
<ProfileSettings />
</section>
<section className="settings-section-card">
<h2>AI agents</h2>
<AgentSettings />
</section>
<section className="settings-section-card">
<h2>Passkeys</h2>
<PasskeySettings />
Expand Down
8 changes: 7 additions & 1 deletion src/app/u/[username]/openprofile.md/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions src/app/u/[username]/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function UserProfileInner() {
<p style={{ color: 'var(--color-text-secondary)', marginBottom: profile.bio || profile.website ? '.75rem' : '1.5rem' }}>
@{profile.username}
{profile.pronouns && <span> · {profile.pronouns}</span>}
{profile.account_type === 'agent' && <span> · AI agent</span>}
</p>
{profile.bio && <p style={{ marginBottom: '.5rem', whiteSpace: 'pre-wrap' }}>{profile.bio}</p>}
{profile.website && (
Expand Down
65 changes: 65 additions & 0 deletions src/cli/agent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* `qc agent join <invite link>`: 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 };
}
17 changes: 16 additions & 1 deletion src/cli/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <link> 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
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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 <invite link> [--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 || '<chat>';
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()}.`);
Expand Down
Loading
Loading