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.19.1",
"version": "0.20.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
16 changes: 6 additions & 10 deletions src/app/api/users/by-username/[username]/route.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 });
Expand Down
9 changes: 9 additions & 0 deletions src/app/api/users/by-username/[username]/route.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'), {
Expand Down
22 changes: 22 additions & 0 deletions src/app/u/[username]/openagent.json/route.js
Original file line number Diff line number Diff line change
@@ -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/<username>/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' }
});
}
20 changes: 5 additions & 15 deletions src/app/u/[username]/openprofile.md/route.js
Original file line number Diff line number Diff line change
@@ -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/<username>/openprofile.md: the public profile (exactly what /u/<username>
* 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'
Expand Down
24 changes: 23 additions & 1 deletion src/app/u/[username]/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand All @@ -41,6 +42,10 @@ function UserProfileInner() {
</div>
{/* The same profile as OpenProfile.md (logicsrc.com/openprofile). */}
<link rel="openprofile" type="text/markdown" href={`/u/${encodeURIComponent(profile.username)}/openprofile.md`} />
{/* An agent's OpenAgent descriptor (logicsrc.com/openagent), owner included. */}
{profile.account_type === 'agent' && owner.operator && (
<link rel="openagent" type="application/json" href={`/u/${encodeURIComponent(profile.username)}/openagent.json`} />
)}
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: '.25rem' }}>
{profile.emoji && <span aria-hidden="true" style={{ marginRight: '.4rem' }}><OpenEmoji char={profile.emoji} /></span>}
{profile.display_name || profile.username}
Expand All @@ -50,12 +55,29 @@ function UserProfileInner() {
{profile.pronouns && <span> · {profile.pronouns}</span>}
{profile.account_type === 'agent' && <span> · <OpenEmoji char="🤖" /> AI agent</span>}
</p>
{owner.operator && (
<p style={{ marginBottom: '.75rem' }}>
Owned and operated by <a href={`/u/${encodeURIComponent(owner.operator.username)}`}>{owner.operator.display_name || owner.operator.username}</a> (@{owner.operator.username})
</p>
)}
{profile.bio && <p style={{ marginBottom: '.5rem', whiteSpace: 'pre-wrap' }}>{profile.bio}</p>}
{profile.website && (
<p style={{ marginBottom: '1.5rem' }}>
<a href={profile.website} rel="me nofollow noopener" target="_blank">{profile.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}</a>
</p>
)}
{owner.agents.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '.9rem', fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: '.35rem' }}>Agents</h2>
<p>
{owner.agents.map((a, i) => (
<span key={a.username}>
{i > 0 && ' · '}<OpenEmoji char="🤖" /> <a href={`/u/${encodeURIComponent(a.username)}`}>{a.display_name || a.username}</a>
</span>
))}
</p>
</div>
)}
{currentUser && currentUser.id !== profile.id && (
<button className="btn btn-primary" onClick={startChat}>Start Chat</button>
)}
Expand Down
68 changes: 63 additions & 5 deletions src/lib/profile/fields.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

/** `<username>.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)];

/**
Expand Down Expand Up @@ -82,19 +108,29 @@ 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
? {
title: 'Operator',
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,
Expand All @@ -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/<username>.
*/
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
};
}
23 changes: 21 additions & 2 deletions src/lib/profile/fields.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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._-]*$/);
});
});
Loading