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
29 changes: 29 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.3.0",
"version": "0.4.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down Expand Up @@ -60,6 +60,7 @@
"@noble/ciphers": "^2.0.0",
"@noble/hashes": "^2.0.0",
"@profullstack/autoblog": "github:profullstack/autoblog#75e54af77cbcf61dbd90fb3ed529c1b2dad1ed6f",
"@profullstack/pairux-embed": "^0.1.0",
"@profullstack/stack": "^0.1.3",
"@profullstack/text-type-detection": "^1.0.0",
"@simplewebauthn/browser": "^14.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.3.0",
"version": "0.4.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
58 changes: 58 additions & 0 deletions src/app/api/calls/token/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/middleware/auth.js';

/**
* POST /api/calls/token { conversationId }: a PairUX token for an end-to-end
* encrypted call in this conversation.
*
* qrypt.chat is a PairUX partner: this server holds PAIRUX_PARTNER_KEY and
* mints tokens for its own users, so nobody needs a PairUX account. The token
* only lets a participant into the conversation's room; the media key that
* actually encrypts the call travels in an ML-KEM-encrypted 'call' message and
* never reaches this server or PairUX.
*/
const PAIRUX_URL = (process.env.PAIRUX_URL || 'https://pairux.com').replace(/\/+$/, '');
const ROOM_RE = /^[A-Za-z0-9_-]{1,64}$/;

export const POST = withAuth(async ({ request, locals }) => {
const key = process.env.PAIRUX_PARTNER_KEY;
if (!key) return NextResponse.json({ error: 'Calls are not configured' }, { status: 503 });

const body = await request.json().catch(() => ({}));
const conversationId = typeof body?.conversationId === 'string' ? body.conversationId.trim() : '';
if (!ROOM_RE.test(conversationId)) return NextResponse.json({ error: 'conversationId is required' }, { status: 400 });

const { supabase, user: authUser } = locals;
const { data: me } = await supabase.from('users').select('id, username, display_name').eq('auth_user_id', authUser.id).single();
if (!me) return NextResponse.json({ error: 'User not found' }, { status: 404 });

const { data: participant } = await supabase
.from('conversation_participants')
.select('id')
.eq('conversation_id', conversationId)
.eq('user_id', me.id)
.is('left_at', null)
.maybeSingle();
if (!participant) return NextResponse.json({ error: 'Not a participant in this conversation' }, { status: 403 });

let res;
try {
res = await fetch(`${PAIRUX_URL}/api/v1/partner/token`, {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ room: conversationId, identity: me.id, name: (me.display_name || me.username || 'qrypt user').slice(0, 50) }),
});
} catch {
return NextResponse.json({ error: 'Could not reach the call service' }, { status: 502 });
}
const data = await res.json().catch(() => ({}));
const payload = data?.data ?? data;
if (!res.ok || !payload?.token) {
console.error('[calls/token] pairux refused', res.status, payload?.error || payload?.message);
return NextResponse.json({ error: 'The call service refused the request' }, { status: 502 });
}
return NextResponse.json(
{ token: payload.token, url: payload.url, iceServers: payload.iceServers ?? [], roomName: payload.roomName },
{ headers: { 'Cache-Control': 'no-store' } }
);
});
2 changes: 1 addition & 1 deletion src/app/api/messages/send/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { MESSAGE_TYPES } from '@/lib/api/protocol.js';
import { getServiceRoleClient } from '@/lib/supabase/service-role.js';


const SENDABLE_TYPES = new Set(['text', 'image', 'file', 'reaction']);
const SENDABLE_TYPES = new Set(['text', 'image', 'file', 'reaction', 'call']);

export const POST = withAuth(async ({ request, locals }) => {
try {
Expand Down
32 changes: 14 additions & 18 deletions src/app/chat/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import MessageInput from '@/lib/components/chat/MessageInput.jsx';
import AddParticipantModal from '@/lib/components/chat/AddParticipantModal.jsx';
import EncryptionWarning from '@/lib/components/EncryptionWarning.jsx';
import MLKEMCallInterface from '@/lib/components/calls/MLKEMCallInterface.jsx';
import CallPanel from '@/lib/components/calls/CallPanel.jsx';
import { MLKEMCallManager, CALL_STATES } from '@/lib/webrtc/ml-kem-call-manager.js';
import { callAudioManager } from '@/lib/audio/call-sounds.js';
import { pwaSessionManager } from '@/lib/utils/pwa-session-manager.js';
Expand Down Expand Up @@ -94,26 +95,20 @@ function ChatPageInner() {
useChatStore.getState().joinConversation(conversationId);
}

async function handleMLKEMVoiceCall() {
if (!currentConversation) return;
try {
const other = currentConversation.participants?.find((p) => p.id !== user?.id);
if (!other?.id) { alert('Cannot start call: No valid participant found'); return; }
let mgr = mlkemCallManager;
if (!mgr) { mgr = new MLKEMCallManager(null); setMlkemCallManager(mgr); setupCallManagerSub(mgr); }
await mgr.initiateCall(other.id, false);
} catch (err) { alert(`Failed to start encrypted voice call: ${err.message}`); }
// End-to-end encrypted calls on PairUX (src/lib/chat/calls.js): the media key
// goes to the conversation in an ML-KEM-encrypted 'call' message.
async function startEncryptedCall(video) {
if (!activeConversationId) return;
const result = await useChatStore.getState().startCall(activeConversationId, { video });
if (!result?.success) alert(`Could not start the call: ${result?.error || 'unknown error'}`);
}

async function handleMLKEMVideoCall() {
if (!currentConversation) return;
try {
const other = currentConversation.participants?.find((p) => p.id !== user?.id);
if (!other?.id) { alert('Cannot start call: No valid participant found'); return; }
let mgr = mlkemCallManager;
if (!mgr) { mgr = new MLKEMCallManager(null); setMlkemCallManager(mgr); setupCallManagerSub(mgr); }
await mgr.initiateCall(other.id, true);
} catch (err) { alert(`Failed to start encrypted video call: ${err.message}`); }
function handleMLKEMVoiceCall() {
return startEncryptedCall(false);
}

function handleMLKEMVideoCall() {
return startEncryptedCall(true);
}

function setupCallManagerSub(mgr) {
Expand Down Expand Up @@ -174,6 +169,7 @@ function ChatPageInner() {
</button>
</div>
</div>
<CallPanel />
<MessageList conversationId={activeConversationId} />
<MessageInput conversationId={activeConversationId} />
</div>
Expand Down
6 changes: 5 additions & 1 deletion src/cli/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { baseUrl } from './config.js';
import { keyring } from './crypto.js';
import { foldMessages, reactionEnvelope, REACTION_TYPE } from '../lib/chat/reactions.js';
import { callSummary, CALL_TYPE } from '../lib/chat/calls.js';

export class QcError extends Error {
constructor(message, status) {
Expand Down Expand Up @@ -97,7 +98,10 @@ export class QcClient {
const raw = [];
for (const m of messages) {
let content = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : '';
if (m.message_type === 'file' || m.has_attachments) {
if (m.message_type === CALL_TYPE) {
// The envelope holds the call's media key: show what happened, never the key.
content = `${callSummary({ ...m, content }, this.me?.id)} · join on qrypt.chat`;
} else if (m.message_type === 'file' || m.has_attachments) {
content = `📎 ${content && content !== '[File attachment]' ? `${content} ` : ''}(attachment: open qrypt.chat to download)`;
}
raw.push({ ...m, content });
Expand Down
Loading
Loading