Skip to content

Commit 8983050

Browse files
sh1pt-actions-fleet[bot]ralyodioclaude
authored
fix(qc): qc profile after a username rename (0.19.1, qc 0.14.1) (#352)
qc looked its own profile up by the username cached in the session at login, so after a rename (mailcoder -> cha0spunk) `qc profile` and `qc profile --openprofile` said "User not found". New GET /api/profile returns the caller's own public profile by token; qc reads it and writes the current username/display name back into the saved session (also after a profile update). Co-authored-by: Anthony Ettinger <anthony@profullstack.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 68f410d commit 8983050

6 files changed

Lines changed: 103 additions & 4 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.0",
4+
"version": "0.19.1",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",

‎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.14.0",
3+
"version": "0.14.1",
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/profile/route.js‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { NextResponse } from 'next/server';
2+
import { createSupabaseServerClientWithToken } from '@/lib/supabase.js';
3+
import { bearerToken } from '@/lib/auth/bearer.js';
4+
import { PUBLIC_PROFILE_COLUMNS } from '@/lib/profile/fields.js';
5+
6+
/**
7+
* GET /api/profile: the caller's own public profile, found by token rather
8+
* than username, so a client that cached an old username (qc after a rename)
9+
* still finds itself.
10+
*/
11+
export async function GET(request) {
12+
try {
13+
const token = bearerToken(request);
14+
if (!token) {
15+
return NextResponse.json({ error: 'Missing or invalid authorization header' }, { status: 401 });
16+
}
17+
const supabase = await createSupabaseServerClientWithToken(token);
18+
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
19+
if (authError || !user) {
20+
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
21+
}
22+
const { data, error } = await supabase.from('users').select(PUBLIC_PROFILE_COLUMNS).eq('auth_user_id', user.id).single();
23+
if (error || !data) return NextResponse.json({ error: 'User not found' }, { status: 404 });
24+
return NextResponse.json({ user: data });
25+
} catch (err) {
26+
console.error('Profile read error:', err);
27+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
28+
}
29+
}

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const mocks = vi.hoisted(() => {
4+
const getUser = vi.fn();
5+
const eq = vi.fn(() => ({ single: async () => ({ data: { id: 'row-1', username: 'cha0spunk', display_name: 'cha0spunk' }, error: null }) }));
6+
const client = { auth: { getUser }, from: vi.fn(() => ({ select: () => ({ eq }) })) };
7+
return { getUser, eq, client, createSupabaseServerClientWithToken: vi.fn(async () => client) };
8+
});
9+
10+
vi.mock('@/lib/supabase.js', () => ({
11+
createSupabaseServerClientWithToken: mocks.createSupabaseServerClientWithToken
12+
}));
13+
14+
const get = (authorization) => new Request('https://example.com/api/profile', { headers: authorization ? { authorization } : {} });
15+
16+
describe('own profile', () => {
17+
beforeEach(() => {
18+
vi.clearAllMocks();
19+
mocks.getUser.mockResolvedValue({ data: { user: { id: 'auth-1' } }, error: null });
20+
});
21+
22+
it('finds the caller by token, not by username', async () => {
23+
const { GET } = await import('./route.js');
24+
const response = await GET(get('Bearer t'));
25+
expect(response.status).toBe(200);
26+
expect(mocks.eq).toHaveBeenCalledWith('auth_user_id', 'auth-1');
27+
expect((await response.json()).user.username).toBe('cha0spunk');
28+
});
29+
30+
it('401s without a token', async () => {
31+
const { GET } = await import('./route.js');
32+
expect((await GET(get())).status).toBe(401);
33+
expect(mocks.createSupabaseServerClientWithToken).not.toHaveBeenCalled();
34+
});
35+
36+
it('401s with a token auth rejects', async () => {
37+
mocks.getUser.mockResolvedValue({ data: { user: null }, error: new Error('bad jwt') });
38+
const { GET } = await import('./route.js');
39+
expect((await GET(get('Bearer nope'))).status).toBe(401);
40+
});
41+
});

‎src/cli/api.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ export class QcClient {
125125

126126
/** Your public profile (what qrypt.chat/u/<you> shows): display name, emoji, pronouns, website, bio. */
127127
async profile() {
128-
const { user } = await this.request(`/api/users/by-username/${encodeURIComponent(this.me?.username ?? '')}`);
128+
const { user } = await this.request('/api/profile');
129+
this.remember(user);
129130
return user;
130131
}
131132

@@ -136,16 +137,26 @@ export class QcClient {
136137
*/
137138
async updateProfile(fields) {
138139
const { user } = await this.request('/api/profile/update', { method: 'POST', body: fields });
140+
this.remember({ username: user.username, display_name: user.displayName });
139141
return user;
140142
}
141143

142144
/** Your public profile as OpenProfile.md (logicsrc.com/openprofile). */
143145
async openProfile() {
144-
const res = await this.fetch(`${this.base}/u/${encodeURIComponent(this.me?.username ?? '')}/openprofile.md`);
146+
const { username } = await this.profile();
147+
const res = await this.fetch(`${this.base}/u/${encodeURIComponent(username)}/openprofile.md`);
145148
if (!res.ok) throw new QcError(`HTTP ${res.status}`, res.status);
146149
return res.text();
147150
}
148151

152+
/** Keep the saved session's name current: the username and display name can change after qc login. */
153+
remember({ username, display_name } = {}) {
154+
const me = this.session.user ?? {};
155+
if (!username || (me.username === username && me.display_name === display_name)) return;
156+
this.session = { ...this.session, user: { ...me, username, display_name: display_name ?? me.display_name } };
157+
this.save(this.session);
158+
}
159+
149160
/** Archive a chat for you (or bring it back with on = false); the others still see it. */
150161
async archive(conversationId, on = true) {
151162
return this.request(`/api/conversations/${on ? 'archive' : 'unarchive'}`, { method: 'POST', body: { conversationId } });

‎tests/cli/qc.test.js‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,24 @@ describe('archiving from qc', () => {
122122
}, 20000);
123123
});
124124

125+
describe('qc profile after a rename', () => {
126+
it('reads the profile by token and updates the username the session cached at login', async () => {
127+
const saved = [];
128+
const fetch = vi.fn(async (url) => {
129+
const path = new URL(url).pathname;
130+
if (path === '/api/profile') return json({ user: { username: 'cha0spunk', display_name: 'cha0spunk' } });
131+
if (path === '/u/cha0spunk/openprofile.md') return new Response('# cha0spunk\n');
132+
return json({ error: 'User not found' }, 404);
133+
});
134+
const client = new QcClient({ access_token: 't', keys: await account(), user: { id: 'a', username: 'mailcoder', display_name: 'mailcoder' } }, { fetch, save: (s) => saved.push(s) });
135+
expect((await client.profile()).username).toBe('cha0spunk');
136+
expect(client.me).toMatchObject({ id: 'a', username: 'cha0spunk', display_name: 'cha0spunk' });
137+
expect(saved).toHaveLength(1);
138+
expect(await client.openProfile()).toBe('# cha0spunk\n');
139+
expect(saved).toHaveLength(1);
140+
}, 20000);
141+
});
142+
125143
describe('the SSE reader', () => {
126144
it('parses frames split across chunks', async () => {
127145
const enc = new TextEncoder();

0 commit comments

Comments
 (0)