From 46538a9f0686f493e5e6227f4c61f73b191ae4f7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 29 Oct 2026 00:09:02 +0000 Subject: [PATCH] fix(qc): qc profile after a username rename (0.19.1, qc 0.14.1) 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: Claude Opus 5.5 --- package.json | 2 +- packages/qryptchat/package.json | 2 +- src/app/api/profile/route.js | 29 ++++++++++++++++++++++ src/app/api/profile/route.test.js | 41 +++++++++++++++++++++++++++++++ src/cli/api.js | 15 +++++++++-- tests/cli/qc.test.js | 18 ++++++++++++++ 6 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 src/app/api/profile/route.js create mode 100644 src/app/api/profile/route.test.js diff --git a/package.json b/package.json index b34ba3fc..01cdd3e2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.19.0", + "version": "0.19.1", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index d4e1b192..ae28307c 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/qryptchat", - "version": "0.14.0", + "version": "0.14.1", "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.", "type": "module", "bin": { diff --git a/src/app/api/profile/route.js b/src/app/api/profile/route.js new file mode 100644 index 00000000..10148335 --- /dev/null +++ b/src/app/api/profile/route.js @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { createSupabaseServerClientWithToken } from '@/lib/supabase.js'; +import { bearerToken } from '@/lib/auth/bearer.js'; +import { PUBLIC_PROFILE_COLUMNS } from '@/lib/profile/fields.js'; + +/** + * GET /api/profile: the caller's own public profile, found by token rather + * than username, so a client that cached an old username (qc after a rename) + * still finds itself. + */ +export async function GET(request) { + try { + const token = bearerToken(request); + if (!token) { + return NextResponse.json({ error: 'Missing or invalid authorization header' }, { status: 401 }); + } + const supabase = await createSupabaseServerClientWithToken(token); + const { data: { user }, error: authError } = await supabase.auth.getUser(token); + if (authError || !user) { + return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 }); + } + const { data, error } = await supabase.from('users').select(PUBLIC_PROFILE_COLUMNS).eq('auth_user_id', user.id).single(); + if (error || !data) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + return NextResponse.json({ user: data }); + } catch (err) { + console.error('Profile read error:', err); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/profile/route.test.js b/src/app/api/profile/route.test.js new file mode 100644 index 00000000..80bb5ba4 --- /dev/null +++ b/src/app/api/profile/route.test.js @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const getUser = vi.fn(); + const eq = vi.fn(() => ({ single: async () => ({ data: { id: 'row-1', username: 'cha0spunk', display_name: 'cha0spunk' }, error: null }) })); + const client = { auth: { getUser }, from: vi.fn(() => ({ select: () => ({ eq }) })) }; + return { getUser, eq, client, createSupabaseServerClientWithToken: vi.fn(async () => client) }; +}); + +vi.mock('@/lib/supabase.js', () => ({ + createSupabaseServerClientWithToken: mocks.createSupabaseServerClientWithToken +})); + +const get = (authorization) => new Request('https://example.com/api/profile', { headers: authorization ? { authorization } : {} }); + +describe('own profile', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getUser.mockResolvedValue({ data: { user: { id: 'auth-1' } }, error: null }); + }); + + it('finds the caller by token, not by username', async () => { + const { GET } = await import('./route.js'); + const response = await GET(get('Bearer t')); + expect(response.status).toBe(200); + expect(mocks.eq).toHaveBeenCalledWith('auth_user_id', 'auth-1'); + expect((await response.json()).user.username).toBe('cha0spunk'); + }); + + it('401s without a token', async () => { + const { GET } = await import('./route.js'); + expect((await GET(get())).status).toBe(401); + expect(mocks.createSupabaseServerClientWithToken).not.toHaveBeenCalled(); + }); + + it('401s with a token auth rejects', async () => { + mocks.getUser.mockResolvedValue({ data: { user: null }, error: new Error('bad jwt') }); + const { GET } = await import('./route.js'); + expect((await GET(get('Bearer nope'))).status).toBe(401); + }); +}); diff --git a/src/cli/api.js b/src/cli/api.js index 55c193b2..1b3f0058 100644 --- a/src/cli/api.js +++ b/src/cli/api.js @@ -125,7 +125,8 @@ export class QcClient { /** Your public profile (what qrypt.chat/u/ shows): display name, emoji, pronouns, website, bio. */ async profile() { - const { user } = await this.request(`/api/users/by-username/${encodeURIComponent(this.me?.username ?? '')}`); + const { user } = await this.request('/api/profile'); + this.remember(user); return user; } @@ -136,16 +137,26 @@ export class QcClient { */ async updateProfile(fields) { const { user } = await this.request('/api/profile/update', { method: 'POST', body: fields }); + this.remember({ username: user.username, display_name: user.displayName }); return user; } /** Your public profile as OpenProfile.md (logicsrc.com/openprofile). */ async openProfile() { - const res = await this.fetch(`${this.base}/u/${encodeURIComponent(this.me?.username ?? '')}/openprofile.md`); + const { username } = await this.profile(); + const res = await this.fetch(`${this.base}/u/${encodeURIComponent(username)}/openprofile.md`); if (!res.ok) throw new QcError(`HTTP ${res.status}`, res.status); return res.text(); } + /** Keep the saved session's name current: the username and display name can change after qc login. */ + remember({ username, display_name } = {}) { + const me = this.session.user ?? {}; + if (!username || (me.username === username && me.display_name === display_name)) return; + this.session = { ...this.session, user: { ...me, username, display_name: display_name ?? me.display_name } }; + this.save(this.session); + } + /** Archive a chat for you (or bring it back with on = false); the others still see it. */ async archive(conversationId, on = true) { return this.request(`/api/conversations/${on ? 'archive' : 'unarchive'}`, { method: 'POST', body: { conversationId } }); diff --git a/tests/cli/qc.test.js b/tests/cli/qc.test.js index a61b34d9..ac3b86e7 100644 --- a/tests/cli/qc.test.js +++ b/tests/cli/qc.test.js @@ -122,6 +122,24 @@ describe('archiving from qc', () => { }, 20000); }); +describe('qc profile after a rename', () => { + it('reads the profile by token and updates the username the session cached at login', async () => { + const saved = []; + const fetch = vi.fn(async (url) => { + const path = new URL(url).pathname; + if (path === '/api/profile') return json({ user: { username: 'cha0spunk', display_name: 'cha0spunk' } }); + if (path === '/u/cha0spunk/openprofile.md') return new Response('# cha0spunk\n'); + return json({ error: 'User not found' }, 404); + }); + const client = new QcClient({ access_token: 't', keys: await account(), user: { id: 'a', username: 'mailcoder', display_name: 'mailcoder' } }, { fetch, save: (s) => saved.push(s) }); + expect((await client.profile()).username).toBe('cha0spunk'); + expect(client.me).toMatchObject({ id: 'a', username: 'cha0spunk', display_name: 'cha0spunk' }); + expect(saved).toHaveLength(1); + expect(await client.openProfile()).toBe('# cha0spunk\n'); + expect(saved).toHaveLength(1); + }, 20000); +}); + describe('the SSE reader', () => { it('parses frames split across chunks', async () => { const enc = new TextEncoder();