diff --git a/bun.lock b/bun.lock index d339a703..3ca971df 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,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", @@ -417,6 +418,8 @@ "@profullstack/hqtui": ["@profullstack/hqtui@0.10.1", "", { "bin": { "hqtui": "bin/hqtui.mjs" } }, "sha512-bYP6vMtwxAhB2gr31zBHkd/ONKA1jIAXwxJABSVCpEmjXDxQEhfMKIDJ17MGOEdEpfyjnEVl2xROpRMXmkVU4Q=="], + "@profullstack/openprofile": ["@profullstack/openprofile@0.2.0", "", {}, "sha512-cdFsBbfRXurvy5KqHDNcKXxurK2s/YlfVgnILFRpVl2hq3860rExqDee8IfsFGxw5cPdqGv2fxUxtek1Xy2U5Q=="], + "@profullstack/pairux-embed": ["@profullstack/pairux-embed@0.1.0", "", { "dependencies": { "livekit-client": "^2.17.0" } }, "sha512-dGRx0kW6Y0awBOy9nbWrqAEcZNJEHrBOfxG/RikmNHuGyE+BaP0Qn1ZFis8dDWyQdEJ6VsTIH6xJbu+Gx5ITqg=="], "@profullstack/referrals": ["@profullstack/referrals@0.1.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg=="], diff --git a/package.json b/package.json index 79cf06d2..54d85751 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.5.9", + "version": "0.6.0", "type": "module", "bin": { "qc": "./bin/qc.js", @@ -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", diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index 903f5a94..7bae91a2 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -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": { diff --git a/src/app/api/messages/load/route.js b/src/app/api/messages/load/route.js index 8b33b0fb..2e018d03 100644 --- a/src/app/api/messages/load/route.js +++ b/src/app/api/messages/load/route.js @@ -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) diff --git a/src/app/api/profile/update/route.js b/src/app/api/profile/update/route.js index 05dec403..75e29091 100644 --- a/src/app/api/profile/update/route.js +++ b/src/app/api/profile/update/route.js @@ -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 { @@ -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 }); diff --git a/src/app/api/profile/update/route.test.js b/src/app/api/profile/update/route.test.js index d9585df4..3ba91cb7 100644 --- a/src/app/api/profile/update/route.test.js +++ b/src/app/api/profile/update/route.test.js @@ -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' }) { @@ -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/her' }, '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(); + }); }); diff --git a/src/app/api/users/by-username/[username]/route.js b/src/app/api/users/by-username/[username]/route.js index c19e8e36..26b09fb4 100644 --- a/src/app/api/users/by-username/[username]/route.js +++ b/src/app/api/users/by-username/[username]/route.js @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { getServiceRoleClient } from '@/lib/supabase/service-role.js'; +import { PUBLIC_PROFILE_COLUMNS } from '@/lib/profile/fields.js'; export async function GET(request, { params } = {}) { try { @@ -12,14 +13,14 @@ export async function GET(request, { params } = {}) { // Public profile page (/u/[username]) is viewable while logged out, so this // read cannot rely on the caller's session. RLS on `users` is restricted to // the `authenticated` role, so use the service role and keep the explicit - // column list below as the boundary β never widen it to `*`, and never add + // column list (PUBLIC_PROFILE_COLUMNS) as the boundary β never widen it to `*`, and never add // phone_number / salt. (backup_pin_hash no longer lives on `users`; PIN // material moved to the service-role-only user_backup_pins table.) const supabase = getServiceRoleClient(); const { data, error } = await supabase .from('users') - .select('id, username, display_name, avatar_url, bio, unique_identifier') + .select(PUBLIC_PROFILE_COLUMNS) .eq('username', normalizedUsername) .single(); diff --git a/src/app/settings/page.jsx b/src/app/settings/page.jsx index 1ea5c33d..cbb80580 100644 --- a/src/app/settings/page.jsx +++ b/src/app/settings/page.jsx @@ -9,6 +9,7 @@ import SMSNotificationSettings from '@/lib/components/settings/SMSNotificationSe import WebhookSettings from '@/lib/components/settings/WebhookSettings.jsx'; import InviteAnon from '@/lib/components/settings/InviteAnon.jsx'; import PasskeySettings from '@/lib/components/settings/PasskeySettings.jsx'; +import ProfileSettings from '@/lib/components/settings/ProfileSettings.jsx'; export default function SettingsPage() { const router = useRouter(); @@ -25,6 +26,10 @@ export default function SettingsPage() {
@{profile.username}
+ {/* The same profile as OpenProfile.md (logicsrc.com/openprofile). */} + ++ @{profile.username} + {profile.pronouns && Β· {profile.pronouns}} +
+ {profile.bio &&{profile.bio}
} + {profile.website && ( ++ {profile.website.replace(/^https?:\/\//, '').replace(/\/$/, '')} +
+ )} {currentUser && currentUser.id !== profile.id && ( )} diff --git a/src/cli/api.js b/src/cli/api.js index e45c471f..6dbfd793 100644 --- a/src/cli/api.js +++ b/src/cli/api.js @@ -111,6 +111,28 @@ export class QcClient { } } + /** Your public profile (what qrypt.chat/u/