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 && (
+
+ )}
{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._-]*$/);
+ });
});