Skip to content

Commit 5fec22d

Browse files
sh1pt-actions-fleet[bot]ralyodioclaude
authored
feat(profile): OpenAgent for agent accounts, ownership both ways (0.20.0) (#353)
- /u/<agent>/openagent.json: the logicsrc.agent 0.1 record (logicsrc.com/openagent), did <username>.qrypt.chat, owner_did the operator's. Spec fields only: the validator rejects extras. 404 for people and owner-less agents. Linked rel="openagent" from the page and as OpenAgent: in the agent's OpenProfile.md. - Owner -> agents: a person's OpenProfile.md gets an Agents section and /u/<person> lists them; /u/<agent> says "Owned and operated by". - /api/users/by-username returns { user, operator, agents } (user unchanged), all through loadPublicProfile in fields.js. Co-authored-by: Anthony Ettinger <anthony@profullstack.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8983050 commit 5fec22d

8 files changed

Lines changed: 150 additions & 34 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qryptchat-web",
33
"private": true,
4-
"version": "0.19.1",
4+
"version": "0.20.0",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",

‎src/app/api/users/by-username/[username]/route.js‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NextResponse } from 'next/server';
22
import { getServiceRoleClient } from '@/lib/supabase/service-role.js';
3-
import { PUBLIC_PROFILE_COLUMNS } from '@/lib/profile/fields.js';
3+
import { loadPublicProfile } from '@/lib/profile/fields.js';
44

55
export async function GET(request, { params } = {}) {
66
try {
@@ -13,22 +13,18 @@ export async function GET(request, { params } = {}) {
1313
// Public profile page (/u/[username]) is viewable while logged out, so this
1414
// read cannot rely on the caller's session. RLS on `users` is restricted to
1515
// the `authenticated` role, so use the service role and keep the explicit
16-
// column list (PUBLIC_PROFILE_COLUMNS) as the boundary — never widen it to `*`, and never add
16+
// column list (PUBLIC_PROFILE_COLUMNS, in loadPublicProfile) as the boundary — never widen it to `*`, and never add
1717
// phone_number / salt. (backup_pin_hash no longer lives on `users`; PIN
1818
// material moved to the service-role-only user_backup_pins table.)
1919
const supabase = getServiceRoleClient();
2020

21-
const { data, error } = await supabase
22-
.from('users')
23-
.select(PUBLIC_PROFILE_COLUMNS)
24-
.eq('username', normalizedUsername)
25-
.single();
26-
27-
if (error || !data) {
21+
const profile = await loadPublicProfile(supabase, normalizedUsername);
22+
if (!profile) {
2823
return NextResponse.json({ error: 'User not found' }, { status: 404 });
2924
}
3025

31-
return NextResponse.json({ user: data });
26+
// operator: the owner of an agent; agents: the agents a person owns.
27+
return NextResponse.json(profile);
3228
} catch (err) {
3329
console.error('[by-username] error:', err);
3430
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });

‎src/app/api/users/by-username/[username]/route.test.js‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ function createUsersQuery() {
1515
const query = {
1616
select: vi.fn(() => query),
1717
eq: mocks.eq,
18+
order: vi.fn().mockResolvedValue({ data: [{ username: 'alices_bot', display_name: 'Bot' }], error: null }),
1819
single: vi.fn().mockResolvedValue({
1920
data: {
2021
id: 'user-1',
@@ -50,6 +51,14 @@ describe('GET /api/users/by-username/[username]', () => {
5051
expect(mocks.eq).toHaveBeenCalledWith('username', 'alice');
5152
});
5253

54+
it('lists the agents a person owns', async () => {
55+
const { GET } = await import('./route.js');
56+
const body = await (await GET(new Request('https://qrypt.chat/api/users/by-username/alice'), { params: Promise.resolve({ username: 'alice' }) })).json();
57+
expect(mocks.eq).toHaveBeenCalledWith('operator_user_id', 'user-1');
58+
expect(mocks.eq).toHaveBeenCalledWith('account_type', 'agent');
59+
expect(body).toMatchObject({ operator: null, agents: [{ username: 'alices_bot' }] });
60+
});
61+
5362
it('trims usernames before database lookup', async () => {
5463
const { GET } = await import('./route.js');
5564
const response = await GET(new Request('https://qrypt.chat/api/users/by-username/%20Alice%20'), {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { NextResponse } from 'next/server';
2+
import { getServiceRoleClient } from '@/lib/supabase/service-role.js';
3+
import { loadPublicProfile, toOpenAgent } from '@/lib/profile/fields.js';
4+
5+
/**
6+
* GET /u/<username>/openagent.json: an agent account as OpenAgent
7+
* (logicsrc.com/openagent), with its owner as owner_did. 404 for people and
8+
* for an agent with no owner on record. The page links it with rel="openagent".
9+
*/
10+
export async function GET(request, { params } = {}) {
11+
const { username } = (await params) || {};
12+
const name = username?.trim().toLowerCase();
13+
if (!name) return NextResponse.json({ error: 'Username required' }, { status: 400 });
14+
15+
const profile = await loadPublicProfile(getServiceRoleClient(), name);
16+
if (!profile || profile.user.account_type !== 'agent' || !profile.operator) {
17+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
18+
}
19+
return NextResponse.json(toOpenAgent(profile.user, profile.operator), {
20+
headers: { 'Cache-Control': 'public, max-age=300' }
21+
});
22+
}

‎src/app/u/[username]/openprofile.md/route.js‎

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,20 @@
11
import { getServiceRoleClient } from '@/lib/supabase/service-role.js';
2-
import { PUBLIC_PROFILE_COLUMNS, toOpenProfile } from '@/lib/profile/fields.js';
2+
import { loadPublicProfile, toOpenProfile } from '@/lib/profile/fields.js';
33

44
/**
55
* GET /u/<username>/openprofile.md: the public profile (exactly what /u/<username>
66
* shows) as OpenProfile.md, logicsrc.com/openprofile. The page links it with
7-
* rel="openprofile".
7+
* rel="openprofile". An agent names its owner; a person lists the agents they own.
88
*/
99
export async function GET(request, { params } = {}) {
1010
const { username } = (await params) || {};
1111
const name = username?.trim().toLowerCase();
1212
if (!name) return new Response('Username required\n', { status: 400 });
1313

14-
const { data, error } = await getServiceRoleClient()
15-
.from('users')
16-
.select(PUBLIC_PROFILE_COLUMNS)
17-
.eq('username', name)
18-
.single();
19-
if (error || !data) return new Response('Profile not found\n', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
14+
const profile = await loadPublicProfile(getServiceRoleClient(), name);
15+
if (!profile) return new Response('Profile not found\n', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
2016

21-
let operator = null;
22-
if (data.account_type === 'agent' && data.operator_user_id) {
23-
const { data: op } = await getServiceRoleClient().from('users').select('username, display_name').eq('id', data.operator_user_id).maybeSingle();
24-
operator = op ?? null;
25-
}
26-
27-
return new Response(toOpenProfile(data, operator), {
17+
return new Response(toOpenProfile(profile.user, profile.operator, profile.agents), {
2818
headers: {
2919
'Content-Type': 'text/markdown; charset=utf-8',
3020
'Cache-Control': 'public, max-age=300'

‎src/app/u/[username]/page.jsx‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ function UserProfileInner() {
99
const router = useRouter();
1010
const currentUser = useAuthStore((s) => s.user);
1111
const [profile, setProfile] = useState(null);
12+
const [owner, setOwner] = useState({ operator: null, agents: [] });
1213
const [loading, setLoading] = useState(true);
1314

1415
useEffect(() => {
1516
fetch(`/api/users/by-username/${encodeURIComponent(username)}`, { credentials: 'include' })
1617
.then(r => r.json())
17-
.then(d => setProfile(d.user || null))
18+
.then(d => { setProfile(d.user || null); setOwner({ operator: d.operator ?? null, agents: d.agents ?? [] }); })
1819
.catch(() => {})
1920
.finally(() => setLoading(false));
2021
}, [username]);
@@ -41,6 +42,10 @@ function UserProfileInner() {
4142
</div>
4243
{/* The same profile as OpenProfile.md (logicsrc.com/openprofile). */}
4344
<link rel="openprofile" type="text/markdown" href={`/u/${encodeURIComponent(profile.username)}/openprofile.md`} />
45+
{/* An agent's OpenAgent descriptor (logicsrc.com/openagent), owner included. */}
46+
{profile.account_type === 'agent' && owner.operator && (
47+
<link rel="openagent" type="application/json" href={`/u/${encodeURIComponent(profile.username)}/openagent.json`} />
48+
)}
4449
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: '.25rem' }}>
4550
{profile.emoji && <span aria-hidden="true" style={{ marginRight: '.4rem' }}><OpenEmoji char={profile.emoji} /></span>}
4651
{profile.display_name || profile.username}
@@ -50,12 +55,29 @@ function UserProfileInner() {
5055
{profile.pronouns && <span> · {profile.pronouns}</span>}
5156
{profile.account_type === 'agent' && <span> · <OpenEmoji char="🤖" /> AI agent</span>}
5257
</p>
58+
{owner.operator && (
59+
<p style={{ marginBottom: '.75rem' }}>
60+
Owned and operated by <a href={`/u/${encodeURIComponent(owner.operator.username)}`}>{owner.operator.display_name || owner.operator.username}</a> (@{owner.operator.username})
61+
</p>
62+
)}
5363
{profile.bio && <p style={{ marginBottom: '.5rem', whiteSpace: 'pre-wrap' }}>{profile.bio}</p>}
5464
{profile.website && (
5565
<p style={{ marginBottom: '1.5rem' }}>
5666
<a href={profile.website} rel="me nofollow noopener" target="_blank">{profile.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}</a>
5767
</p>
5868
)}
69+
{owner.agents.length > 0 && (
70+
<div style={{ marginBottom: '1.5rem' }}>
71+
<h2 style={{ fontSize: '.9rem', fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: '.35rem' }}>Agents</h2>
72+
<p>
73+
{owner.agents.map((a, i) => (
74+
<span key={a.username}>
75+
{i > 0 && ' · '}<OpenEmoji char="🤖" /> <a href={`/u/${encodeURIComponent(a.username)}`}>{a.display_name || a.username}</a>
76+
</span>
77+
))}
78+
</p>
79+
</div>
80+
)}
5981
{currentUser && currentUser.id !== profile.id && (
6082
<button className="btn btn-primary" onClick={startChat}>Start Chat</button>
6183
)}

‎src/lib/profile/fields.js‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,39 @@
33
* the OpenProfile.md a public profile is also served as.
44
*
55
* The public profile is exactly PUBLIC_PROFILE_COLUMNS: never phone_number,
6-
* salt or anything else on `users`.
6+
* salt or anything else on `users`. An agent account also has an OpenAgent
7+
* descriptor (logicsrc.com/openagent) naming its owner, and the owner's
8+
* profile lists the agents, so ownership reads both ways.
79
*/
810
import { makeOpenProfile, renderOpenProfile } from '@profullstack/openprofile';
911

1012
export const PUBLIC_PROFILE_COLUMNS = 'id, username, display_name, avatar_url, bio, website, emoji, pronouns, unique_identifier, account_type, operator_user_id';
1113

1214
const SITE = 'https://qrypt.chat';
15+
const profileUrl = (username) => `${SITE}/u/${encodeURIComponent(username)}`;
16+
17+
/**
18+
* A public profile with the accounts on the other side of agent ownership:
19+
* `operator` (owner of an agent) and `agents` (agents a person owns).
20+
* @param {import('@supabase/supabase-js').SupabaseClient} service service-role client
21+
*/
22+
export async function loadPublicProfile(service, username) {
23+
const { data: user, error } = await service.from('users').select(PUBLIC_PROFILE_COLUMNS).eq('username', username).single();
24+
if (error || !user) return null;
25+
let operator = null;
26+
let agents = [];
27+
if (user.account_type === 'agent' && user.operator_user_id) {
28+
const { data } = await service.from('users').select('username, display_name').eq('id', user.operator_user_id).maybeSingle();
29+
operator = data ?? null;
30+
} else if (user.account_type !== 'agent') {
31+
const { data } = await service.from('users').select('username, display_name').eq('operator_user_id', user.id).eq('account_type', 'agent').order('username');
32+
agents = data ?? [];
33+
}
34+
return { user, operator, agents };
35+
}
36+
37+
/** `<username>.qrypt.chat`: the LogicSRC identity (name.namespace) of a qrypt.chat account. */
38+
export const qryptDid = (username) => `${String(username).toLowerCase()}.qrypt.chat`;
1339
const graphemes = (text) => [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)];
1440

1541
/**
@@ -82,19 +108,29 @@ export function cleanWebsite(input) {
82108
* The public profile as OpenProfile.md (logicsrc.com/openprofile). An agent is
83109
* `Kind: agent` with an Operator section naming the person answerable for it.
84110
*/
85-
export function toOpenProfile(user, operator = null) {
86-
const page = `${SITE}/u/${encodeURIComponent(user.username)}`;
111+
export function toOpenProfile(user, operator = null, agents = []) {
112+
const page = profileUrl(user.username);
87113
const agent = user.account_type === 'agent';
88114
const operatorSection = agent && operator
89115
? {
90116
title: 'Operator',
91117
name: 'operator',
92118
body: [
93119
`- **Name**: ${operator.display_name || operator.username}`,
94-
`- **Profile**: ${SITE}/u/${encodeURIComponent(operator.username)}/openprofile.md`
120+
`- **Role**: owner and operator, who answers for this agent`,
121+
`- **Profile**: ${profileUrl(operator.username)}/openprofile.md`
95122
].join('\n')
96123
}
97124
: null;
125+
const agentsSection = !agent && agents.length
126+
? {
127+
title: 'Agents',
128+
name: 'agents',
129+
body: agents
130+
.map((a) => `- [${a.display_name || a.username}](${profileUrl(a.username)}) @${a.username}, owned and operated by me: ${profileUrl(a.username)}/openagent.json`)
131+
.join('\n')
132+
}
133+
: null;
98134
return renderOpenProfile(
99135
makeOpenProfile({
100136
name: user.display_name || user.username,
@@ -105,9 +141,31 @@ export function toOpenProfile(user, operator = null) {
105141
Pronouns: user.pronouns,
106142
Web: user.website,
107143
Avatar: user.avatar_url,
144+
OpenAgent: agent ? `${page}/openagent.json` : undefined,
108145
},
109146
headline: user.bio?.split('\n')[0],
110-
sections: [{ title: 'Accounts', name: 'accounts', body: `- [qrypt.chat](${page})` }, operatorSection],
147+
sections: [{ title: 'Accounts', name: 'accounts', body: `- [qrypt.chat](${page})` }, operatorSection, agentsSection],
111148
}),
112149
);
113150
}
151+
152+
/**
153+
* An agent account as OpenAgent (logicsrc.com/openagent): the logicsrc.agent
154+
* 0.1 record, owner by did. Only fields the schema allows (it rejects extras);
155+
* the owner's name and page are in OpenProfile.md and on /u/<username>.
156+
*/
157+
export function toOpenAgent(user, operator) {
158+
return {
159+
type: 'logicsrc.agent',
160+
version: '0.1',
161+
name: user.display_name || user.username,
162+
did: qryptDid(user.username),
163+
owner_did: qryptDid(operator.username),
164+
description: user.bio?.split('\n')[0] || `@${user.username}, an AI agent on qrypt.chat operated by @${operator.username}.`,
165+
skills: ['chat', 'end-to-end-encrypted-messaging'],
166+
supported_task_types: ['direct-message', 'group-chat'],
167+
permissions_requested: ['chat:read', 'chat:write'],
168+
polling_mode: false,
169+
public: true
170+
};
171+
}

‎src/lib/profile/fields.test.js‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest';
2-
import { emoji, parseOpenProfile, pronouns, web, accounts } from '@profullstack/openprofile';
3-
import { cleanDisplayName, cleanEmoji, cleanPronouns, cleanWebsite, PUBLIC_PROFILE_COLUMNS, toOpenProfile } from './fields.js';
2+
import { emoji, parseOpenProfile, pronouns, web, accounts, section } from '@profullstack/openprofile';
3+
import { cleanDisplayName, cleanEmoji, cleanPronouns, cleanWebsite, PUBLIC_PROFILE_COLUMNS, toOpenAgent, toOpenProfile } from './fields.js';
44

55
describe('profile fields', () => {
66
it('emoji: exactly one emoji grapheme, empty clears', () => {
@@ -68,4 +68,23 @@ describe('profile fields', () => {
6868
expect(pronouns(doc)).toBeNull();
6969
expect(emoji(doc)).toBeNull();
7070
});
71+
72+
it('an owner lists their agents; an agent links its OpenAgent descriptor', () => {
73+
const owner = parseOpenProfile(toOpenProfile({ username: 'chovy', display_name: 'Anthony' }, null, [{ username: 'riotcoder', display_name: 'riotcoder' }, { username: 'cha0spunk', display_name: null }]));
74+
const body = section(owner, 'agents')?.body ?? '';
75+
expect(body).toContain('https://qrypt.chat/u/riotcoder/openagent.json');
76+
expect(body).toContain('[cha0spunk](https://qrypt.chat/u/cha0spunk)');
77+
const md = toOpenProfile({ username: 'riotcoder', display_name: 'riotcoder', account_type: 'agent' }, { username: 'chovy', display_name: 'Anthony' });
78+
expect(md).toContain('https://qrypt.chat/u/riotcoder/openagent.json');
79+
expect(md).toMatch(/owner and operator/);
80+
expect(toOpenProfile({ username: 'ada', display_name: 'Ada' })).not.toMatch(/OpenAgent|## Agents/);
81+
});
82+
83+
it('OpenAgent: the logicsrc.agent 0.1 fields only, owner by did', () => {
84+
const doc = toOpenAgent({ username: 'Riotcoder', display_name: 'riotcoder', bio: 'Ships code.\nMore.' }, { username: 'chovy' });
85+
expect(doc).toMatchObject({ type: 'logicsrc.agent', version: '0.1', did: 'riotcoder.qrypt.chat', owner_did: 'chovy.qrypt.chat', description: 'Ships code.' });
86+
expect(Object.keys(doc).sort()).toEqual(['description', 'did', 'name', 'owner_did', 'permissions_requested', 'polling_mode', 'public', 'skills', 'supported_task_types', 'type', 'version']);
87+
expect(doc.skills.length).toBeGreaterThan(0);
88+
for (const did of [doc.did, doc.owner_did]) expect(did).toMatch(/^[a-z0-9][a-z0-9._-]*\.[a-z0-9][a-z0-9._-]*$/);
89+
});
7190
});

0 commit comments

Comments
 (0)