Skip to content

Commit 2a4332a

Browse files
ralyodioclaude
andauthored
Profiles: emoji, pronouns and website, served as OpenProfile.md (0.6.0, qc 0.5.0) (#293)
OpenProfile 0.4 default fields on qrypt.chat profiles: - users.emoji / users.pronouns (migration 20261006230000, applied on dev2). - Settings > Profile edits emoji (OpenEmoji picker), pronouns, website, bio. - /u/<username> shows them and links rel=openprofile to /u/<username>/openprofile.md (same public columns, via @profullstack/openprofile). - Chat shows a sender's emoji (OpenEmoji art) and pronouns. - qc: qc profile [--emoji --pronouns --website --bio] [--openprofile]; the TUI shows senders' emoji (HD where supported) and pronouns. - /api/profile/update used setSession with an empty refresh token, so updates ran anonymously and matched no row: now a token-scoped client. Fields are validated (one emoji grapheme, 40-char pronouns, http(s) only). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8bb22af commit 2a4332a

19 files changed

Lines changed: 482 additions & 160 deletions

File tree

‎bun.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 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.5.9",
4+
"version": "0.6.0",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",
@@ -61,6 +61,7 @@
6161
"@noble/hashes": "^2.0.0",
6262
"@profullstack/autoblog": "github:profullstack/autoblog#75e54af77cbcf61dbd90fb3ed529c1b2dad1ed6f",
6363
"@profullstack/encrypt": "^0.1.0",
64+
"@profullstack/openprofile": "^0.2.0",
6465
"@profullstack/pairux-embed": "^0.1.0",
6566
"@profullstack/stack": "^0.1.3",
6667
"@profullstack/text-type-detection": "^1.0.0",

‎packages/qryptchat/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/qryptchat",
3-
"version": "0.4.7",
3+
"version": "0.5.0",
44
"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.",
55
"type": "module",
66
"bin": {

‎src/app/api/messages/load/route.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export const POST = withAuth(async ({ request, locals }) => {
6767
.from('messages')
6868
.select(`
6969
*,
70-
sender:users!messages_sender_id_fkey(id, username, display_name, avatar_url),
70+
sender:users!messages_sender_id_fkey(id, username, display_name, avatar_url, emoji, pronouns),
7171
message_recipients!inner(encrypted_content, recipient_user_id)
7272
`)
7373
.eq('conversation_id', conversationId)

‎src/app/api/profile/update/route.js‎

Lines changed: 42 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,15 @@
11
import { NextResponse } from 'next/server';
2-
import { createSupabaseServerClient } from '@/lib/supabase.js';
2+
import { createSupabaseServerClientWithToken } from '@/lib/supabase.js';
3+
import { bearerToken } from '@/lib/auth/bearer.js';
4+
import { cleanEmoji, cleanPronouns, cleanWebsite } from '@/lib/profile/fields.js';
35

46
/**
5-
* @param {string | null} authHeader
6-
* @returns {string | null}
7+
* POST /api/profile/update { bio?, website?, emoji?, pronouns? }
8+
* Updates the caller's public profile. A field left out is unchanged; an
9+
* empty string clears it. Emoji, Pronouns and Web are OpenProfile 0.4's
10+
* default fields (logicsrc.com/openprofile).
711
*/
8-
function getBearerToken(authHeader) {
9-
if (typeof authHeader !== 'string') return null;
10-
11-
const match = authHeader.match(/^Bearer\s+(.+)$/i);
12-
const token = match?.[1]?.trim();
13-
14-
return token || null;
15-
}
16-
17-
export async function POST(request, { params } = {}) {
12+
export async function POST(request) {
1813
try {
1914
let body;
2015
try {
@@ -23,101 +18,67 @@ export async function POST(request, { params } = {}) {
2318
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
2419
}
2520

26-
const { bio, website } = body;
27-
28-
// Get authorization header
29-
const authHeader = request.headers.get('authorization');
30-
const token = getBearerToken(authHeader);
21+
const token = bearerToken(request);
3122
if (!token) {
3223
return NextResponse.json({ error: 'Missing or invalid authorization header' }, { status: 401 });
3324
}
3425

35-
// Create Supabase client and set session
36-
const supabase = await createSupabaseServerClient();
37-
38-
// Set the session to ensure auth.uid() is available for RLS
26+
// A client that carries the token on every request, so RLS sees this user.
27+
// (setSession() with an empty refresh token is refused by auth-js, which
28+
// made every update here run anonymously and match no row.)
29+
const supabase = await createSupabaseServerClientWithToken(token);
3930
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
4031
if (authError || !user) {
41-
console.error('Auth error:', authError);
4232
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
4333
}
4434

45-
// Set the session for RLS context
46-
await supabase.auth.setSession({
47-
access_token: token,
48-
refresh_token: '' // Not needed for this operation
49-
});
50-
51-
console.log('Authenticated user from JWT:', { id: user.id, email: user.email, phone: user.phone });
52-
53-
console.log('Authenticated user from JWT:', { id: user.id, email: user.email, phone: user.phone });
35+
const { bio, website, emoji, pronouns } = body ?? {};
36+
const updateData = { updated_at: new Date().toISOString() };
5437

55-
// Validate input
56-
if (bio !== undefined && typeof bio !== 'string') {
57-
return NextResponse.json({ error: 'Bio must be a string' }, { status: 400 });
38+
if (bio !== undefined) {
39+
if (bio !== null && typeof bio !== 'string') return NextResponse.json({ error: 'Bio must be a string' }, { status: 400 });
40+
if (bio && bio.length > 500) return NextResponse.json({ error: 'Bio must be 500 characters or less' }, { status: 400 });
41+
updateData.bio = bio?.trim() || null;
5842
}
59-
60-
if (website !== undefined && typeof website !== 'string') {
61-
return NextResponse.json({ error: 'Website must be a string' }, { status: 400 });
43+
for (const [field, value, clean] of [
44+
['website', website, cleanWebsite],
45+
['emoji', emoji, cleanEmoji],
46+
['pronouns', pronouns, cleanPronouns]
47+
]) {
48+
if (value === undefined) continue;
49+
const result = clean(value);
50+
if ('error' in result) return NextResponse.json({ error: result.error, field }, { status: 400 });
51+
updateData[field] = result.value;
6252
}
6353

64-
// Validate bio length
65-
if (bio && bio.length > 500) {
66-
return NextResponse.json({ error: 'Bio must be 500 characters or less' }, { status: 400 });
67-
}
68-
69-
// Validate website URL format if provided
70-
if (website && website.trim()) {
71-
const websiteUrl = website.trim();
72-
// Basic URL validation - allow with or without protocol
73-
const urlPattern = /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/i;
74-
if (!urlPattern.test(websiteUrl)) {
75-
return NextResponse.json({ error: 'Please enter a valid website URL' }, { status: 400 });
76-
}
77-
}
78-
79-
// Prepare update data
80-
const updateData = {
81-
updated_at: new Date().toISOString(),
82-
...(bio !== undefined && { bio: bio.trim() || null }),
83-
...(website !== undefined && { website: website.trim() || null })
84-
};
85-
86-
// Update user profile using RLS - the policy ensures only the authenticated user can update their own profile
8754
const { data: updatedUsers, error: updateError } = await supabase
8855
.from('users')
8956
.update(updateData)
9057
.eq('auth_user_id', user.id)
91-
.select('id, username, display_name, avatar_url, bio, website');
58+
.select('id, username, display_name, avatar_url, bio, website, emoji, pronouns');
9259

9360
if (updateError) {
9461
console.error('Error updating profile:', updateError);
9562
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
9663
}
97-
98-
// Check if any rows were updated
9964
if (!updatedUsers || updatedUsers.length === 0) {
100-
console.error('No rows updated - user not found or RLS policy blocked update');
10165
return NextResponse.json({ error: 'Profile update failed - user not found or permission denied' }, { status: 404 });
10266
}
10367

104-
const updatedUser = updatedUsers[0];
105-
106-
// Transform response data
107-
const responseData = {
108-
id: updatedUser.id,
109-
username: updatedUser.username,
110-
displayName: updatedUser.display_name,
111-
avatarUrl: updatedUser.avatar_url,
112-
bio: updatedUser.bio,
113-
website: updatedUser.website
114-
};
115-
116-
return NextResponse.json({
117-
success: true,
118-
user: responseData
68+
const u = updatedUsers[0];
69+
return NextResponse.json({
70+
success: true,
71+
user: {
72+
id: u.id,
73+
username: u.username,
74+
displayName: u.display_name,
75+
avatarUrl: u.avatar_url,
76+
bio: u.bio,
77+
website: u.website,
78+
emoji: u.emoji,
79+
pronouns: u.pronouns
80+
}
11981
});
120-
12182
} catch (err) {
12283
console.error('Profile update error:', err);
12384
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
Lines changed: 60 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,29 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
22

3-
const mocks = vi.hoisted(() => ({
4-
getUser: vi.fn(),
5-
setSession: vi.fn(),
6-
createSupabaseServerClient: vi.fn(() => ({
7-
auth: {
8-
getUser: mocks.getUser,
9-
setSession: mocks.setSession
10-
},
3+
const mocks = vi.hoisted(() => {
4+
const update = vi.fn();
5+
const getUser = vi.fn();
6+
const client = {
7+
auth: { getUser },
118
from: vi.fn(() => ({
12-
update: vi.fn(() => ({
13-
eq: vi.fn(() => ({
14-
select: vi.fn(() => ({
15-
data: [{
16-
id: 'row-1',
17-
username: 'alice',
18-
display_name: 'Alice',
19-
avatar_url: null,
20-
bio: 'hello',
21-
website: null
22-
}],
23-
error: null
24-
}))
25-
}))
26-
}))
9+
update: (data) => {
10+
update(data);
11+
return {
12+
eq: () => ({
13+
select: () => ({
14+
data: [{ id: 'row-1', username: 'alice', display_name: 'Alice', avatar_url: null, bio: 'hello', website: null, emoji: '🔭', pronouns: 'she/her', ...data }],
15+
error: null
16+
})
17+
})
18+
};
19+
}
2720
}))
28-
}))
29-
}));
21+
};
22+
return { update, getUser, client, createSupabaseServerClientWithToken: vi.fn(async () => client) };
23+
});
3024

3125
vi.mock('@/lib/supabase.js', () => ({
32-
createSupabaseServerClient: mocks.createSupabaseServerClient
26+
createSupabaseServerClientWithToken: mocks.createSupabaseServerClientWithToken
3327
}));
3428

3529
function profileRequest(authorization, body = { bio: 'hello' }) {
@@ -40,75 +34,72 @@ function profileRequest(authorization, body = { bio: 'hello' }) {
4034
});
4135
}
4236

43-
describe('profile update bearer authentication', () => {
37+
describe('profile update', () => {
4438
beforeEach(() => {
4539
vi.resetModules();
4640
vi.clearAllMocks();
47-
mocks.getUser.mockResolvedValue({
48-
data: { user: { id: 'user-1', email: 'alice@example.com', phone: null } },
49-
error: null
50-
});
41+
mocks.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null });
5142
});
5243

53-
it('normalizes bearer scheme casing and extra spaces before validating the token', async () => {
44+
it('authenticates with a token-scoped client (no setSession with an empty refresh token)', async () => {
5445
const { POST } = await import('./route.js');
55-
5646
const response = await POST(profileRequest('bearer access-token-123 '));
57-
const body = await response.json();
58-
5947
expect(response.status).toBe(200);
60-
expect(body.success).toBe(true);
48+
expect(mocks.createSupabaseServerClientWithToken).toHaveBeenCalledWith('access-token-123');
6149
expect(mocks.getUser).toHaveBeenCalledWith('access-token-123');
62-
expect(mocks.setSession).toHaveBeenCalledWith({
63-
access_token: 'access-token-123',
64-
refresh_token: ''
65-
});
6650
});
6751

6852
it('rejects an empty bearer header before creating a Supabase client', async () => {
6953
const { POST } = await import('./route.js');
70-
7154
const response = await POST(profileRequest('Bearer '));
72-
const body = await response.json();
73-
7455
expect(response.status).toBe(401);
75-
expect(body.error).toBe('Missing or invalid authorization header');
76-
expect(mocks.createSupabaseServerClient).not.toHaveBeenCalled();
77-
expect(mocks.getUser).not.toHaveBeenCalled();
56+
expect((await response.json()).error).toBe('Missing or invalid authorization header');
57+
expect(mocks.createSupabaseServerClientWithToken).not.toHaveBeenCalled();
7858
});
7959

8060
it('returns 400 for malformed JSON before authentication work', async () => {
8161
const { POST } = await import('./route.js');
82-
8362
const response = await POST({
8463
headers: new Headers({ authorization: 'Bearer access-token-123' }),
8564
json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token'))
8665
});
87-
const body = await response.json();
88-
8966
expect(response.status).toBe(400);
90-
expect(body.error).toBe('Invalid JSON body');
91-
expect(mocks.createSupabaseServerClient).not.toHaveBeenCalled();
92-
expect(mocks.getUser).not.toHaveBeenCalled();
67+
expect(mocks.createSupabaseServerClientWithToken).not.toHaveBeenCalled();
9368
});
9469

95-
it.each([
96-
['bio', null, 'Bio must be a string'],
97-
['bio', false, 'Bio must be a string'],
98-
['bio', 0, 'Bio must be a string'],
99-
['website', null, 'Website must be a string'],
100-
['website', false, 'Website must be a string'],
101-
['website', 0, 'Website must be a string']
102-
])(
103-
'rejects falsy non-string %s values instead of throwing',
104-
async (field, value, expectedError) => {
105-
const { POST } = await import('./route.js');
70+
it('saves emoji, pronouns and website (OpenProfile 0.4 default fields)', async () => {
71+
const { POST } = await import('./route.js');
72+
const response = await POST(profileRequest('Bearer t', { emoji: ' 🔭 ', pronouns: ' she/her ', website: 'ada.example' }));
73+
const body = await response.json();
74+
expect(response.status).toBe(200);
75+
expect(mocks.update).toHaveBeenCalledWith(expect.objectContaining({ emoji: '🔭', pronouns: 'she/her', website: 'https://ada.example/' }));
76+
expect(body.user).toMatchObject({ emoji: '🔭', pronouns: 'she/her', website: 'https://ada.example/' });
77+
});
10678

107-
const response = await POST(profileRequest('Bearer access-token-123', { [field]: value }));
108-
const body = await response.json();
79+
it('clears a field with an empty string or null, and leaves out what is not sent', async () => {
80+
const { POST } = await import('./route.js');
81+
await POST(profileRequest('Bearer t', { emoji: '', pronouns: null }));
82+
const data = mocks.update.mock.calls[0][0];
83+
expect(data).toMatchObject({ emoji: null, pronouns: null });
84+
expect('website' in data).toBe(false);
85+
expect('bio' in data).toBe(false);
86+
});
10987

110-
expect(response.status).toBe(400);
111-
expect(body.error).toBe(expectedError);
112-
}
113-
);
88+
it.each([
89+
[{ emoji: 'abc' }, 'Pick a single emoji'],
90+
[{ emoji: '🔭🚀' }, 'Pick a single emoji'],
91+
[{ pronouns: 'x'.repeat(41) }, 'Pronouns can be at most 40 characters'],
92+
[{ pronouns: 'she/<b>her</b>' }, 'Pronouns contain characters that are not allowed'],
93+
[{ website: 'javascript:alert(1)' }, 'Website must be an http(s) link'],
94+
[{ website: 'not a url' }, 'Please enter a valid website URL'],
95+
[{ bio: false }, 'Bio must be a string'],
96+
[{ bio: 'x'.repeat(501) }, 'Bio must be 500 characters or less'],
97+
[{ website: 0 }, 'Website must be text']
98+
])('rejects %j', async (payload, error) => {
99+
const { POST } = await import('./route.js');
100+
const response = await POST(profileRequest('Bearer t', payload));
101+
expect(response.status).toBe(400);
102+
expect((await response.json()).error).toBe(error);
103+
expect(mocks.update).not.toHaveBeenCalled();
104+
});
114105
});

0 commit comments

Comments
 (0)