diff --git a/package.json b/package.json index 01cdd3e2..576efdd4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.19.1", + "version": "0.20.0", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/src/app/api/users/by-username/[username]/route.js b/src/app/api/users/by-username/[username]/route.js index 26b09fb4..95a638e4 100644 --- a/src/app/api/users/by-username/[username]/route.js +++ b/src/app/api/users/by-username/[username]/route.js @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { getServiceRoleClient } from '@/lib/supabase/service-role.js'; -import { PUBLIC_PROFILE_COLUMNS } from '@/lib/profile/fields.js'; +import { loadPublicProfile } from '@/lib/profile/fields.js'; export async function GET(request, { params } = {}) { try { @@ -13,22 +13,18 @@ export async function GET(request, { params } = {}) { // Public profile page (/u/[username]) is viewable while logged out, so this // read cannot rely on the caller's session. RLS on `users` is restricted to // the `authenticated` role, so use the service role and keep the explicit - // column list (PUBLIC_PROFILE_COLUMNS) as the boundary — never widen it to `*`, and never add + // column list (PUBLIC_PROFILE_COLUMNS, in loadPublicProfile) as the boundary — never widen it to `*`, and never add // phone_number / salt. (backup_pin_hash no longer lives on `users`; PIN // material moved to the service-role-only user_backup_pins table.) const supabase = getServiceRoleClient(); - const { data, error } = await supabase - .from('users') - .select(PUBLIC_PROFILE_COLUMNS) - .eq('username', normalizedUsername) - .single(); - - if (error || !data) { + const profile = await loadPublicProfile(supabase, normalizedUsername); + if (!profile) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } - return NextResponse.json({ user: data }); + // operator: the owner of an agent; agents: the agents a person owns. + return NextResponse.json(profile); } catch (err) { console.error('[by-username] error:', err); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/src/app/api/users/by-username/[username]/route.test.js b/src/app/api/users/by-username/[username]/route.test.js index c68700cb..046173d9 100644 --- a/src/app/api/users/by-username/[username]/route.test.js +++ b/src/app/api/users/by-username/[username]/route.test.js @@ -15,6 +15,7 @@ function createUsersQuery() { const query = { select: vi.fn(() => query), eq: mocks.eq, + order: vi.fn().mockResolvedValue({ data: [{ username: 'alices_bot', display_name: 'Bot' }], error: null }), single: vi.fn().mockResolvedValue({ data: { id: 'user-1', @@ -50,6 +51,14 @@ describe('GET /api/users/by-username/[username]', () => { expect(mocks.eq).toHaveBeenCalledWith('username', 'alice'); }); + it('lists the agents a person owns', async () => { + const { GET } = await import('./route.js'); + const body = await (await GET(new Request('https://qrypt.chat/api/users/by-username/alice'), { params: Promise.resolve({ username: 'alice' }) })).json(); + expect(mocks.eq).toHaveBeenCalledWith('operator_user_id', 'user-1'); + expect(mocks.eq).toHaveBeenCalledWith('account_type', 'agent'); + expect(body).toMatchObject({ operator: null, agents: [{ username: 'alices_bot' }] }); + }); + it('trims usernames before database lookup', async () => { const { GET } = await import('./route.js'); const response = await GET(new Request('https://qrypt.chat/api/users/by-username/%20Alice%20'), { diff --git a/src/app/u/[username]/openagent.json/route.js b/src/app/u/[username]/openagent.json/route.js new file mode 100644 index 00000000..2eadd21f --- /dev/null +++ b/src/app/u/[username]/openagent.json/route.js @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import { getServiceRoleClient } from '@/lib/supabase/service-role.js'; +import { loadPublicProfile, toOpenAgent } from '@/lib/profile/fields.js'; + +/** + * GET /u//openagent.json: an agent account as OpenAgent + * (logicsrc.com/openagent), with its owner as owner_did. 404 for people and + * for an agent with no owner on record. The page links it with rel="openagent". + */ +export async function GET(request, { params } = {}) { + const { username } = (await params) || {}; + const name = username?.trim().toLowerCase(); + if (!name) return NextResponse.json({ error: 'Username required' }, { status: 400 }); + + const profile = await loadPublicProfile(getServiceRoleClient(), name); + if (!profile || profile.user.account_type !== 'agent' || !profile.operator) { + return NextResponse.json({ error: 'Agent not found' }, { status: 404 }); + } + return NextResponse.json(toOpenAgent(profile.user, profile.operator), { + headers: { 'Cache-Control': 'public, max-age=300' } + }); +} diff --git a/src/app/u/[username]/openprofile.md/route.js b/src/app/u/[username]/openprofile.md/route.js index 8dc32697..cdc58c17 100644 --- a/src/app/u/[username]/openprofile.md/route.js +++ b/src/app/u/[username]/openprofile.md/route.js @@ -1,30 +1,20 @@ import { getServiceRoleClient } from '@/lib/supabase/service-role.js'; -import { PUBLIC_PROFILE_COLUMNS, toOpenProfile } from '@/lib/profile/fields.js'; +import { loadPublicProfile, toOpenProfile } from '@/lib/profile/fields.js'; /** * GET /u//openprofile.md: the public profile (exactly what /u/ * shows) as OpenProfile.md, logicsrc.com/openprofile. The page links it with - * rel="openprofile". + * rel="openprofile". An agent names its owner; a person lists the agents they own. */ export async function GET(request, { params } = {}) { const { username } = (await params) || {}; const name = username?.trim().toLowerCase(); if (!name) return new Response('Username required\n', { status: 400 }); - const { data, error } = await getServiceRoleClient() - .from('users') - .select(PUBLIC_PROFILE_COLUMNS) - .eq('username', name) - .single(); - if (error || !data) return new Response('Profile not found\n', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); + const profile = await loadPublicProfile(getServiceRoleClient(), name); + if (!profile) return new Response('Profile not found\n', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); - 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), { + return new Response(toOpenProfile(profile.user, profile.operator, profile.agents), { 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 7b5a6c2d..37728ec5 100644 --- a/src/app/u/[username]/page.jsx +++ b/src/app/u/[username]/page.jsx @@ -9,12 +9,13 @@ function UserProfileInner() { const router = useRouter(); const currentUser = useAuthStore((s) => s.user); const [profile, setProfile] = useState(null); + const [owner, setOwner] = useState({ operator: null, agents: [] }); const [loading, setLoading] = useState(true); useEffect(() => { fetch(`/api/users/by-username/${encodeURIComponent(username)}`, { credentials: 'include' }) .then(r => r.json()) - .then(d => setProfile(d.user || null)) + .then(d => { setProfile(d.user || null); setOwner({ operator: d.operator ?? null, agents: d.agents ?? [] }); }) .catch(() => {}) .finally(() => setLoading(false)); }, [username]); @@ -41,6 +42,10 @@ function UserProfileInner() { {/* The same profile as OpenProfile.md (logicsrc.com/openprofile). */} + {/* An agent's OpenAgent descriptor (logicsrc.com/openagent), owner included. */} + {profile.account_type === 'agent' && owner.operator && ( + + )}

{profile.emoji && } {profile.display_name || profile.username} @@ -50,12 +55,29 @@ function UserProfileInner() { {profile.pronouns && · {profile.pronouns}} {profile.account_type === 'agent' && · AI agent}

+ {owner.operator && ( +

+ Owned and operated by {owner.operator.display_name || owner.operator.username} (@{owner.operator.username}) +

+ )} {profile.bio &&

{profile.bio}

} {profile.website && (

{profile.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}

)} + {owner.agents.length > 0 && ( +
+

Agents

+

+ {owner.agents.map((a, i) => ( + + {i > 0 && ' · '} {a.display_name || a.username} + + ))} +

+
+ )} {currentUser && currentUser.id !== profile.id && ( )} diff --git a/src/lib/profile/fields.js b/src/lib/profile/fields.js index 1c358937..c9674eae 100644 --- a/src/lib/profile/fields.js +++ b/src/lib/profile/fields.js @@ -3,13 +3,39 @@ * the OpenProfile.md a public profile is also served as. * * The public profile is exactly PUBLIC_PROFILE_COLUMNS: never phone_number, - * salt or anything else on `users`. + * salt or anything else on `users`. An agent account also has an OpenAgent + * descriptor (logicsrc.com/openagent) naming its owner, and the owner's + * profile lists the agents, so ownership reads both ways. */ import { makeOpenProfile, renderOpenProfile } from '@profullstack/openprofile'; export const PUBLIC_PROFILE_COLUMNS = 'id, username, display_name, avatar_url, bio, website, emoji, pronouns, unique_identifier, account_type, operator_user_id'; const SITE = 'https://qrypt.chat'; +const profileUrl = (username) => `${SITE}/u/${encodeURIComponent(username)}`; + +/** + * A public profile with the accounts on the other side of agent ownership: + * `operator` (owner of an agent) and `agents` (agents a person owns). + * @param {import('@supabase/supabase-js').SupabaseClient} service service-role client + */ +export async function loadPublicProfile(service, username) { + const { data: user, error } = await service.from('users').select(PUBLIC_PROFILE_COLUMNS).eq('username', username).single(); + if (error || !user) return null; + let operator = null; + let agents = []; + if (user.account_type === 'agent' && user.operator_user_id) { + const { data } = await service.from('users').select('username, display_name').eq('id', user.operator_user_id).maybeSingle(); + operator = data ?? null; + } else if (user.account_type !== 'agent') { + const { data } = await service.from('users').select('username, display_name').eq('operator_user_id', user.id).eq('account_type', 'agent').order('username'); + agents = data ?? []; + } + return { user, operator, agents }; +} + +/** `.qrypt.chat`: the LogicSRC identity (name.namespace) of a qrypt.chat account. */ +export const qryptDid = (username) => `${String(username).toLowerCase()}.qrypt.chat`; const graphemes = (text) => [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)]; /** @@ -82,8 +108,8 @@ export function cleanWebsite(input) { * The public profile as OpenProfile.md (logicsrc.com/openprofile). An agent is * `Kind: agent` with an Operator section naming the person answerable for it. */ -export function toOpenProfile(user, operator = null) { - const page = `${SITE}/u/${encodeURIComponent(user.username)}`; +export function toOpenProfile(user, operator = null, agents = []) { + const page = profileUrl(user.username); const agent = user.account_type === 'agent'; const operatorSection = agent && operator ? { @@ -91,10 +117,20 @@ export function toOpenProfile(user, operator = null) { name: 'operator', body: [ `- **Name**: ${operator.display_name || operator.username}`, - `- **Profile**: ${SITE}/u/${encodeURIComponent(operator.username)}/openprofile.md` + `- **Role**: owner and operator, who answers for this agent`, + `- **Profile**: ${profileUrl(operator.username)}/openprofile.md` ].join('\n') } : null; + const agentsSection = !agent && agents.length + ? { + title: 'Agents', + name: 'agents', + body: agents + .map((a) => `- [${a.display_name || a.username}](${profileUrl(a.username)}) @${a.username}, owned and operated by me: ${profileUrl(a.username)}/openagent.json`) + .join('\n') + } + : null; return renderOpenProfile( makeOpenProfile({ name: user.display_name || user.username, @@ -105,9 +141,31 @@ export function toOpenProfile(user, operator = null) { Pronouns: user.pronouns, Web: user.website, Avatar: user.avatar_url, + OpenAgent: agent ? `${page}/openagent.json` : undefined, }, headline: user.bio?.split('\n')[0], - sections: [{ title: 'Accounts', name: 'accounts', body: `- [qrypt.chat](${page})` }, operatorSection], + sections: [{ title: 'Accounts', name: 'accounts', body: `- [qrypt.chat](${page})` }, operatorSection, agentsSection], }), ); } + +/** + * An agent account as OpenAgent (logicsrc.com/openagent): the logicsrc.agent + * 0.1 record, owner by did. Only fields the schema allows (it rejects extras); + * the owner's name and page are in OpenProfile.md and on /u/. + */ +export function toOpenAgent(user, operator) { + return { + type: 'logicsrc.agent', + version: '0.1', + name: user.display_name || user.username, + did: qryptDid(user.username), + owner_did: qryptDid(operator.username), + description: user.bio?.split('\n')[0] || `@${user.username}, an AI agent on qrypt.chat operated by @${operator.username}.`, + skills: ['chat', 'end-to-end-encrypted-messaging'], + supported_task_types: ['direct-message', 'group-chat'], + permissions_requested: ['chat:read', 'chat:write'], + polling_mode: false, + public: true + }; +} diff --git a/src/lib/profile/fields.test.js b/src/lib/profile/fields.test.js index bc34c111..4f14e69a 100644 --- a/src/lib/profile/fields.test.js +++ b/src/lib/profile/fields.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { emoji, parseOpenProfile, pronouns, web, accounts } from '@profullstack/openprofile'; -import { cleanDisplayName, cleanEmoji, cleanPronouns, cleanWebsite, PUBLIC_PROFILE_COLUMNS, toOpenProfile } from './fields.js'; +import { emoji, parseOpenProfile, pronouns, web, accounts, section } from '@profullstack/openprofile'; +import { cleanDisplayName, cleanEmoji, cleanPronouns, cleanWebsite, PUBLIC_PROFILE_COLUMNS, toOpenAgent, toOpenProfile } from './fields.js'; describe('profile fields', () => { it('emoji: exactly one emoji grapheme, empty clears', () => { @@ -68,4 +68,23 @@ describe('profile fields', () => { expect(pronouns(doc)).toBeNull(); expect(emoji(doc)).toBeNull(); }); + + it('an owner lists their agents; an agent links its OpenAgent descriptor', () => { + const owner = parseOpenProfile(toOpenProfile({ username: 'chovy', display_name: 'Anthony' }, null, [{ username: 'riotcoder', display_name: 'riotcoder' }, { username: 'cha0spunk', display_name: null }])); + const body = section(owner, 'agents')?.body ?? ''; + expect(body).toContain('https://qrypt.chat/u/riotcoder/openagent.json'); + expect(body).toContain('[cha0spunk](https://qrypt.chat/u/cha0spunk)'); + const md = toOpenProfile({ username: 'riotcoder', display_name: 'riotcoder', account_type: 'agent' }, { username: 'chovy', display_name: 'Anthony' }); + expect(md).toContain('https://qrypt.chat/u/riotcoder/openagent.json'); + expect(md).toMatch(/owner and operator/); + expect(toOpenProfile({ username: 'ada', display_name: 'Ada' })).not.toMatch(/OpenAgent|## Agents/); + }); + + it('OpenAgent: the logicsrc.agent 0.1 fields only, owner by did', () => { + const doc = toOpenAgent({ username: 'Riotcoder', display_name: 'riotcoder', bio: 'Ships code.\nMore.' }, { username: 'chovy' }); + expect(doc).toMatchObject({ type: 'logicsrc.agent', version: '0.1', did: 'riotcoder.qrypt.chat', owner_did: 'chovy.qrypt.chat', description: 'Ships code.' }); + expect(Object.keys(doc).sort()).toEqual(['description', 'did', 'name', 'owner_did', 'permissions_requested', 'polling_mode', 'public', 'skills', 'supported_task_types', 'type', 'version']); + expect(doc.skills.length).toBeGreaterThan(0); + for (const did of [doc.did, doc.owner_did]) expect(did).toMatch(/^[a-z0-9][a-z0-9._-]*\.[a-z0-9][a-z0-9._-]*$/); + }); });