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.0",
"version": "0.19.1",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/qryptchat/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
29 changes: 29 additions & 0 deletions src/app/api/profile/route.js
Original file line number Diff line number Diff line change
@@ -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 });
}
}
41 changes: 41 additions & 0 deletions src/app/api/profile/route.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
15 changes: 13 additions & 2 deletions src/cli/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ export class QcClient {

/** Your public profile (what qrypt.chat/u/<you> 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;
}

Expand All @@ -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 } });
Expand Down
18 changes: 18 additions & 0 deletions tests/cli/qc.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading