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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 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.5.9",
"version": "0.6.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down Expand Up @@ -61,6 +61,7 @@
"@noble/hashes": "^2.0.0",
"@profullstack/autoblog": "github:profullstack/autoblog#75e54af77cbcf61dbd90fb3ed529c1b2dad1ed6f",
"@profullstack/encrypt": "^0.1.0",
"@profullstack/openprofile": "^0.2.0",
"@profullstack/pairux-embed": "^0.1.0",
"@profullstack/stack": "^0.1.3",
"@profullstack/text-type-detection": "^1.0.0",
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.4.7",
"version": "0.5.0",
"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
2 changes: 1 addition & 1 deletion src/app/api/messages/load/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const POST = withAuth(async ({ request, locals }) => {
.from('messages')
.select(`
*,
sender:users!messages_sender_id_fkey(id, username, display_name, avatar_url),
sender:users!messages_sender_id_fkey(id, username, display_name, avatar_url, emoji, pronouns),
message_recipients!inner(encrypted_content, recipient_user_id)
`)
.eq('conversation_id', conversationId)
Expand Down
123 changes: 42 additions & 81 deletions src/app/api/profile/update/route.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import { NextResponse } from 'next/server';
import { createSupabaseServerClient } from '@/lib/supabase.js';
import { createSupabaseServerClientWithToken } from '@/lib/supabase.js';
import { bearerToken } from '@/lib/auth/bearer.js';
import { cleanEmoji, cleanPronouns, cleanWebsite } from '@/lib/profile/fields.js';

/**
* @param {string | null} authHeader
* @returns {string | null}
* POST /api/profile/update { bio?, website?, emoji?, pronouns? }
* Updates the caller's public profile. A field left out is unchanged; an
* empty string clears it. Emoji, Pronouns and Web are OpenProfile 0.4's
* default fields (logicsrc.com/openprofile).
*/
function getBearerToken(authHeader) {
if (typeof authHeader !== 'string') return null;

const match = authHeader.match(/^Bearer\s+(.+)$/i);
const token = match?.[1]?.trim();

return token || null;
}

export async function POST(request, { params } = {}) {
export async function POST(request) {
try {
let body;
try {
Expand All @@ -23,101 +18,67 @@ export async function POST(request, { params } = {}) {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}

const { bio, website } = body;

// Get authorization header
const authHeader = request.headers.get('authorization');
const token = getBearerToken(authHeader);
const token = bearerToken(request);
if (!token) {
return NextResponse.json({ error: 'Missing or invalid authorization header' }, { status: 401 });
}

// Create Supabase client and set session
const supabase = await createSupabaseServerClient();

// Set the session to ensure auth.uid() is available for RLS
// A client that carries the token on every request, so RLS sees this user.
// (setSession() with an empty refresh token is refused by auth-js, which
// made every update here run anonymously and match no row.)
const supabase = await createSupabaseServerClientWithToken(token);
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
if (authError || !user) {
console.error('Auth error:', authError);
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
}

// Set the session for RLS context
await supabase.auth.setSession({
access_token: token,
refresh_token: '' // Not needed for this operation
});

console.log('Authenticated user from JWT:', { id: user.id, email: user.email, phone: user.phone });

console.log('Authenticated user from JWT:', { id: user.id, email: user.email, phone: user.phone });
const { bio, website, emoji, pronouns } = body ?? {};
const updateData = { updated_at: new Date().toISOString() };

// Validate input
if (bio !== undefined && typeof bio !== 'string') {
return NextResponse.json({ error: 'Bio must be a string' }, { status: 400 });
if (bio !== undefined) {
if (bio !== null && typeof bio !== 'string') return NextResponse.json({ error: 'Bio must be a string' }, { status: 400 });
if (bio && bio.length > 500) return NextResponse.json({ error: 'Bio must be 500 characters or less' }, { status: 400 });
updateData.bio = bio?.trim() || null;
}

if (website !== undefined && typeof website !== 'string') {
return NextResponse.json({ error: 'Website must be a string' }, { status: 400 });
for (const [field, value, clean] of [
['website', website, cleanWebsite],
['emoji', emoji, cleanEmoji],
['pronouns', pronouns, cleanPronouns]
]) {
if (value === undefined) continue;
const result = clean(value);
if ('error' in result) return NextResponse.json({ error: result.error, field }, { status: 400 });
updateData[field] = result.value;
}

// Validate bio length
if (bio && bio.length > 500) {
return NextResponse.json({ error: 'Bio must be 500 characters or less' }, { status: 400 });
}

// Validate website URL format if provided
if (website && website.trim()) {
const websiteUrl = website.trim();
// Basic URL validation - allow with or without protocol
const urlPattern = /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/i;
if (!urlPattern.test(websiteUrl)) {
return NextResponse.json({ error: 'Please enter a valid website URL' }, { status: 400 });
}
}

// Prepare update data
const updateData = {
updated_at: new Date().toISOString(),
...(bio !== undefined && { bio: bio.trim() || null }),
...(website !== undefined && { website: website.trim() || null })
};

// Update user profile using RLS - the policy ensures only the authenticated user can update their own profile
const { data: updatedUsers, error: updateError } = await supabase
.from('users')
.update(updateData)
.eq('auth_user_id', user.id)
.select('id, username, display_name, avatar_url, bio, website');
.select('id, username, display_name, avatar_url, bio, website, emoji, pronouns');

if (updateError) {
console.error('Error updating profile:', updateError);
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
}

// Check if any rows were updated
if (!updatedUsers || updatedUsers.length === 0) {
console.error('No rows updated - user not found or RLS policy blocked update');
return NextResponse.json({ error: 'Profile update failed - user not found or permission denied' }, { status: 404 });
}

const updatedUser = updatedUsers[0];

// Transform response data
const responseData = {
id: updatedUser.id,
username: updatedUser.username,
displayName: updatedUser.display_name,
avatarUrl: updatedUser.avatar_url,
bio: updatedUser.bio,
website: updatedUser.website
};

return NextResponse.json({
success: true,
user: responseData
const u = updatedUsers[0];
return NextResponse.json({
success: true,
user: {
id: u.id,
username: u.username,
displayName: u.display_name,
avatarUrl: u.avatar_url,
bio: u.bio,
website: u.website,
emoji: u.emoji,
pronouns: u.pronouns
}
});

} catch (err) {
console.error('Profile update error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
Expand Down
129 changes: 60 additions & 69 deletions src/app/api/profile/update/route.test.js
Original file line number Diff line number Diff line change
@@ -1,35 +1,29 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
getUser: vi.fn(),
setSession: vi.fn(),
createSupabaseServerClient: vi.fn(() => ({
auth: {
getUser: mocks.getUser,
setSession: mocks.setSession
},
const mocks = vi.hoisted(() => {
const update = vi.fn();
const getUser = vi.fn();
const client = {
auth: { getUser },
from: vi.fn(() => ({
update: vi.fn(() => ({
eq: vi.fn(() => ({
select: vi.fn(() => ({
data: [{
id: 'row-1',
username: 'alice',
display_name: 'Alice',
avatar_url: null,
bio: 'hello',
website: null
}],
error: null
}))
}))
}))
update: (data) => {
update(data);
return {
eq: () => ({
select: () => ({
data: [{ id: 'row-1', username: 'alice', display_name: 'Alice', avatar_url: null, bio: 'hello', website: null, emoji: '🔭', pronouns: 'she/her', ...data }],
error: null
})
})
};
}
}))
}))
}));
};
return { update, getUser, client, createSupabaseServerClientWithToken: vi.fn(async () => client) };
});

vi.mock('@/lib/supabase.js', () => ({
createSupabaseServerClient: mocks.createSupabaseServerClient
createSupabaseServerClientWithToken: mocks.createSupabaseServerClientWithToken
}));

function profileRequest(authorization, body = { bio: 'hello' }) {
Expand All @@ -40,75 +34,72 @@ function profileRequest(authorization, body = { bio: 'hello' }) {
});
}

describe('profile update bearer authentication', () => {
describe('profile update', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mocks.getUser.mockResolvedValue({
data: { user: { id: 'user-1', email: 'alice@example.com', phone: null } },
error: null
});
mocks.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null });
});

it('normalizes bearer scheme casing and extra spaces before validating the token', async () => {
it('authenticates with a token-scoped client (no setSession with an empty refresh token)', async () => {
const { POST } = await import('./route.js');

const response = await POST(profileRequest('bearer access-token-123 '));
const body = await response.json();

expect(response.status).toBe(200);
expect(body.success).toBe(true);
expect(mocks.createSupabaseServerClientWithToken).toHaveBeenCalledWith('access-token-123');
expect(mocks.getUser).toHaveBeenCalledWith('access-token-123');
expect(mocks.setSession).toHaveBeenCalledWith({
access_token: 'access-token-123',
refresh_token: ''
});
});

it('rejects an empty bearer header before creating a Supabase client', async () => {
const { POST } = await import('./route.js');

const response = await POST(profileRequest('Bearer '));
const body = await response.json();

expect(response.status).toBe(401);
expect(body.error).toBe('Missing or invalid authorization header');
expect(mocks.createSupabaseServerClient).not.toHaveBeenCalled();
expect(mocks.getUser).not.toHaveBeenCalled();
expect((await response.json()).error).toBe('Missing or invalid authorization header');
expect(mocks.createSupabaseServerClientWithToken).not.toHaveBeenCalled();
});

it('returns 400 for malformed JSON before authentication work', async () => {
const { POST } = await import('./route.js');

const response = await POST({
headers: new Headers({ authorization: 'Bearer access-token-123' }),
json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token'))
});
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toBe('Invalid JSON body');
expect(mocks.createSupabaseServerClient).not.toHaveBeenCalled();
expect(mocks.getUser).not.toHaveBeenCalled();
expect(mocks.createSupabaseServerClientWithToken).not.toHaveBeenCalled();
});

it.each([
['bio', null, 'Bio must be a string'],
['bio', false, 'Bio must be a string'],
['bio', 0, 'Bio must be a string'],
['website', null, 'Website must be a string'],
['website', false, 'Website must be a string'],
['website', 0, 'Website must be a string']
])(
'rejects falsy non-string %s values instead of throwing',
async (field, value, expectedError) => {
const { POST } = await import('./route.js');
it('saves emoji, pronouns and website (OpenProfile 0.4 default fields)', async () => {
const { POST } = await import('./route.js');
const response = await POST(profileRequest('Bearer t', { emoji: ' 🔭 ', pronouns: ' she/her ', website: 'ada.example' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(mocks.update).toHaveBeenCalledWith(expect.objectContaining({ emoji: '🔭', pronouns: 'she/her', website: 'https://ada.example/' }));
expect(body.user).toMatchObject({ emoji: '🔭', pronouns: 'she/her', website: 'https://ada.example/' });
});

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

expect(response.status).toBe(400);
expect(body.error).toBe(expectedError);
}
);
it.each([
[{ emoji: 'abc' }, 'Pick a single emoji'],
[{ emoji: '🔭🚀' }, 'Pick a single emoji'],
[{ pronouns: 'x'.repeat(41) }, 'Pronouns can be at most 40 characters'],
[{ pronouns: 'she/<b>her</b>' }, 'Pronouns contain characters that are not allowed'],
[{ website: 'javascript:alert(1)' }, 'Website must be an http(s) link'],
[{ website: 'not a url' }, 'Please enter a valid website URL'],
[{ bio: false }, 'Bio must be a string'],
[{ bio: 'x'.repeat(501) }, 'Bio must be 500 characters or less'],
[{ website: 0 }, 'Website must be text']
])('rejects %j', async (payload, error) => {
const { POST } = await import('./route.js');
const response = await POST(profileRequest('Bearer t', payload));
expect(response.status).toBe(400);
expect((await response.json()).error).toBe(error);
expect(mocks.update).not.toHaveBeenCalled();
});
});
Loading
Loading