diff --git a/bun.lock b/bun.lock index d3edbe0a..ccc0a031 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,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", @@ -113,6 +114,8 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "7.29.7", "@babel/helper-validator-identifier": "7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="], + "@bundled-es-modules/cookie": ["@bundled-es-modules/cookie@2.0.1", "", { "dependencies": { "cookie": "1.1.1" } }, "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw=="], "@bundled-es-modules/statuses": ["@bundled-es-modules/statuses@1.0.1", "", { "dependencies": { "statuses": "2.0.2" } }, "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg=="], @@ -285,6 +288,10 @@ "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + "@livekit/mutex": ["@livekit/mutex@1.1.1", "", {}, "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw=="], + + "@livekit/protocol": ["@livekit/protocol@1.50.4", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.0" } }, "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA=="], + "@mswjs/interceptors": ["@mswjs/interceptors@0.39.7", "", { "dependencies": { "@open-draft/deferred-promise": "2.2.0", "@open-draft/logger": "0.3.0", "@open-draft/until": "2.1.0", "is-node-process": "1.2.0", "outvariant": "1.4.3", "strict-event-emitter": "0.5.1" } }, "sha512-sURvQbbKsq5f8INV54YJgJEdk8oxBanqkTiXXd33rKmofFCwZLhLRszPduMZ9TA9b8/1CHc/IJmOlBHJk2Q5AQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], @@ -407,6 +414,8 @@ "@profullstack/hqtui": ["@profullstack/hqtui@0.8.0", "", { "bin": { "hqtui": "bin/hqtui.mjs" } }, "sha512-7dLq4Tb/GO7PyUaSRDf86K0AjeD55aBYW2Y7Mb2CM8YEAANno79ey0o9Dzr7QiGz/smJByah+gGp5UdAdDVGrA=="], + "@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=="], "@profullstack/stack": ["@profullstack/stack@0.1.3", "", { "dependencies": { "@profullstack/emailer": "1.0.1", "@profullstack/referrals": "0.1.0" }, "peerDependencies": { "@supabase/ssr": ">=0.5.0", "next": ">=13.0.0", "react": ">=18.0.0" }, "optionalPeers": ["@supabase/ssr", "next", "react"] }, "sha512-NOseYE5cWMwH75/luUE46kJLyAd1xD+DSgGgwhxvgNMoJYfZ9W9vKmqdcGA/4Lgahzf7hWfjNvmLtVIzAqwhJw=="], @@ -519,6 +528,8 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/dom-mediacapture-record": ["@types/dom-mediacapture-record@1.0.22", "", {}, "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -835,6 +846,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], @@ -1011,6 +1024,8 @@ "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "1.1.4", "es-object-atoms": "1.1.1", "get-intrinsic": "1.3.0", "get-proto": "1.0.1", "has-symbols": "1.1.0", "set-function-name": "2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1043,6 +1058,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "1.2.1", "type-check": "0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "livekit-client": ["livekit-client@2.22.3", "", { "dependencies": { "@livekit/mutex": "1.1.1", "@livekit/protocol": "1.50.4", "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", "machina": "^7.0.0", "sdp-transform": "^2.15.0", "tslib": "2.8.1", "typed-emitter": "^2.1.0", "webrtc-adapter": "9.0.6" }, "peerDependencies": { "@types/dom-mediacapture-record": "^1" } }, "sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], @@ -1063,12 +1080,16 @@ "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "4.1.2", "is-unicode-supported": "0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "3.1.1" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "machina": ["machina@7.0.1", "", {}, "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -1233,6 +1254,8 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "1.2.3" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "get-intrinsic": "1.3.0", "has-symbols": "1.1.0", "isarray": "2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -1251,6 +1274,8 @@ "sdp": ["sdp@3.2.1", "", {}, "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw=="], + "sdp-transform": ["sdp-transform@2.15.0", "", { "bin": { "sdp-verify": "checker.js" } }, "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw=="], + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "serialize-javascript": ["serialize-javascript@7.0.5", "", {}, "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw=="], @@ -1371,6 +1396,8 @@ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "1.0.8", "for-each": "0.3.5", "gopd": "1.2.0", "is-typed-array": "1.1.15", "possible-typed-array-names": "1.1.0", "reflect.getprototypeof": "1.0.10" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + "typed-emitter": ["typed-emitter@2.1.0", "", { "optionalDependencies": { "rxjs": "*" } }, "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "1.0.4", "has-bigints": "1.1.0", "has-symbols": "1.1.0", "which-boxed-primitive": "1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], @@ -1539,6 +1566,8 @@ "jsonwebtoken/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "livekit-client/webrtc-adapter": ["webrtc-adapter@9.0.6", "", { "dependencies": { "sdp": "^3.2.0" } }, "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "mocha/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "2.0.2" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], diff --git a/package.json b/package.json index 72a612f7..dbbf01d8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.3.0", + "version": "0.4.0", "type": "module", "bin": { "qc": "./bin/qc.js", @@ -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", diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index 46705d3f..9438d44e 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -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": { diff --git a/src/app/api/calls/token/route.js b/src/app/api/calls/token/route.js new file mode 100644 index 00000000..cefcaa41 --- /dev/null +++ b/src/app/api/calls/token/route.js @@ -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' } } + ); +}); diff --git a/src/app/api/messages/send/route.js b/src/app/api/messages/send/route.js index f5ef10f6..6db3978e 100644 --- a/src/app/api/messages/send/route.js +++ b/src/app/api/messages/send/route.js @@ -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 { diff --git a/src/app/chat/page.jsx b/src/app/chat/page.jsx index b718a56c..11252c41 100644 --- a/src/app/chat/page.jsx +++ b/src/app/chat/page.jsx @@ -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'; @@ -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) { @@ -174,6 +169,7 @@ function ChatPageInner() { + diff --git a/src/cli/api.js b/src/cli/api.js index 68b5e143..0920a7c9 100644 --- a/src/cli/api.js +++ b/src/cli/api.js @@ -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) { @@ -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 }); diff --git a/src/lib/chat/calls.js b/src/lib/chat/calls.js new file mode 100644 index 00000000..27aba5e1 --- /dev/null +++ b/src/lib/chat/calls.js @@ -0,0 +1,58 @@ +/** + * End-to-end encrypted calls (voice, video, screen) on PairUX's SFU. + * + * Starting a call sends an ordinary qrypt message of type 'call' whose + * ENCRYPTED body carries the room and a fresh 32-byte media key: + * { v: 1, type: 'call', room, key, video, at } + * Like every message it is ML-KEM-1024 encrypted per participant, so only the + * people in the conversation ever hold the key. @profullstack/pairux-embed + * encrypts every frame with it in the browser; PairUX forwards ciphertext and + * neither qrypt's server nor PairUX can hear or see the call. + */ + +export const CALL_TYPE = 'call'; +/** A call card stops offering Join after this long. */ +export const CALL_JOINABLE_MS = 6 * 60 * 60 * 1000; + +const b64 = (bytes) => { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s); +}; + +/** A fresh media key (32 random bytes). */ +export function newCallKey() { + return crypto.getRandomValues(new Uint8Array(32)); +} + +/** The encrypted body of a call invite. */ +export function callEnvelope({ room, key, video = false, at = new Date().toISOString() }) { + return JSON.stringify({ v: 1, type: CALL_TYPE, room: String(room), key: b64(key), video: !!video, at }); +} + +/** A call invite from decrypted content, or null. */ +export function parseCall(content) { + try { + const d = JSON.parse(content); + if (d?.type !== CALL_TYPE || typeof d.room !== 'string' || typeof d.key !== 'string') return null; + const key = Uint8Array.from(atob(d.key), (c) => c.charCodeAt(0)); + if (key.length < 32) return null; + return { room: d.room, key, video: !!d.video, at: typeof d.at === 'string' ? d.at : null }; + } catch { + return null; + } +} + +/** Whether a call invite is recent enough to join. */ +export function isJoinable(call, createdAt, now = Date.now()) { + if (!call) return false; + const started = Date.parse(call.at ?? createdAt); + return Number.isFinite(started) && now - started < CALL_JOINABLE_MS; +} + +/** One-line text for a call invite (conversation previews, qc). */ +export function callSummary(message, myUserId) { + const call = parseCall(message.content); + const who = message.sender_id === myUserId ? 'You' : message.sender?.display_name || message.sender?.username || 'Someone'; + return `๐Ÿ“ž ${who} started ${call?.video ? 'a video' : 'a voice'} call`; +} diff --git a/src/lib/components/calls/CallPanel.jsx b/src/lib/components/calls/CallPanel.jsx new file mode 100644 index 00000000..ca0f6768 --- /dev/null +++ b/src/lib/components/calls/CallPanel.jsx @@ -0,0 +1,76 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useChatStore } from '@/lib/stores/chat.js'; + +/** + * The call this tab is in, end-to-end encrypted on PairUX's SFU. The media key + * came from an ML-KEM-encrypted 'call' message; @profullstack/pairux-embed + * encrypts every frame with it before it leaves this browser, so neither + * qrypt.chat nor PairUX can hear or see the call. A browser that cannot do that + * is told so; it never falls back to an unencrypted call. + */ +export default function CallPanel() { + const activeCall = useChatStore((s) => s.activeCall); + const endCall = useChatStore((s) => s.endCall); + const hostRef = useRef(null); + const [error, setError] = useState(''); + + useEffect(() => { + if (!activeCall || !hostRef.current) return; + let call; + let ui; + let cancelled = false; + setError(''); + (async () => { + try { + const session = JSON.parse(localStorage.getItem('qrypt_session') || '{}'); + const res = await fetch('/api/calls/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(session.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}) }, + credentials: 'include', + body: JSON.stringify({ conversationId: activeCall.conversationId }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Could not start the call'); + const { PairuxCall, mountCall } = await import('@profullstack/pairux-embed'); + if (cancelled) return; + call = new PairuxCall({ url: data.url, token: data.token, iceServers: data.iceServers, key: activeCall.key }); + ui = mountCall(hostRef.current, call, { onLeave: endCall }); + await call.join({ audio: true, video: !!activeCall.video }); + } catch (err) { + if (!cancelled) setError(err?.message || 'The call failed'); + } + })(); + return () => { + cancelled = true; + ui?.destroy(); + call?.leave().catch(() => {}); + }; + }, [activeCall, endCall]); + + if (!activeCall) return null; + + return ( +
+
+
+ ๐Ÿ”’ End-to-end encrypted {activeCall.video ? 'video' : 'voice'} call + +
+ {error ?

{error}

:
} +

Calls run on PairUX. Every audio and video frame is encrypted on your device with a key only this conversation holds.

+
+ +
+ ); +} diff --git a/src/lib/components/chat/MessageItem.jsx b/src/lib/components/chat/MessageItem.jsx index b6c88ce7..eb9865e1 100644 --- a/src/lib/components/chat/MessageItem.jsx +++ b/src/lib/components/chat/MessageItem.jsx @@ -8,6 +8,8 @@ import MessageAttachments from './MessageAttachments.jsx'; import EmojiPicker, { Icon } from './EmojiPicker.jsx'; import { renderOpenEmoji, isEmojiOnly, artworkKey, emojiSrc } from '@/lib/emoji/openemoji.js'; import { QUICK_REACTIONS, myReaction } from '@/lib/chat/reactions.js'; +import { parseCall, isJoinable, CALL_TYPE } from '@/lib/chat/calls.js'; +import { useChatStore } from '@/lib/stores/chat.js'; /** An emoji drawn with our artwork when we have it, else as text. */ function Emoji({ char, size = 18 }) { @@ -24,7 +26,10 @@ export default function MessageItem({ message, showAvatar = true, showTimestamp const hasAttachments = message.message_type === 'file' || message.has_attachments === true; const content = message.content || ''; // Hide the placeholder caption that the upload flow stores on file messages. - const showText = content.trim().length > 0 && !(hasAttachments && content.trim() === '[File attachment]'); + // A call invite's body is the (decrypted) call envelope: never show it as text. + const call = message.message_type === CALL_TYPE ? parseCall(content) : null; + const joinCall = useChatStore((s) => s.joinCall); + const showText = !call && message.message_type !== CALL_TYPE && content.trim().length > 0 && !(hasAttachments && content.trim() === '[File attachment]'); const detectedFormat = detectTextFormat(content); const isAsciiArt = message.metadata?.isAsciiArt === true || detectedFormat.type === 'ascii-art'; const isCodeBlock = detectedFormat.type === 'code' || detectedFormat.language !== null; @@ -88,6 +93,23 @@ export default function MessageItem({ message, showAvatar = true, showTimestamp )} + {message.message_type === CALL_TYPE && ( +
+ + + {isOwn ? 'You started' : `${displayName} started`} {call?.video ? 'a video' : 'a voice'} call + ๐Ÿ”’ end-to-end encrypted + + {call && isJoinable(call, message.created_at) ? ( + + ) : ( + {call ? 'Ended' : 'Cannot open this call'} + )} +
+ )} + {showText && (
{ let eventSource = null; @@ -153,6 +154,8 @@ export const useChatStore = create((set, get) => { typingUsers: [], /** The message the composer is replying to, or null. */ replyingTo: null, + /** The call this tab is in: { conversationId, key, video } or null. */ + activeCall: null, connected: false, authenticated: false, user: null, @@ -287,6 +290,30 @@ export const useChatStore = create((set, get) => { return result; }, + /** + * Start an end-to-end encrypted call: a fresh media key goes to everyone in + * the conversation inside an ML-KEM-encrypted 'call' message, then this tab joins. + */ + async startCall(conversationId, { video = false } = {}) { + const key = newCallKey(); + const result = await get().sendMessage(conversationId, callEnvelope({ room: conversationId, key, video }), CALL_TYPE); + if (!result?.success) return result; + const { messages, activeConversation } = get(); + if (activeConversation === conversationId && result.data && !messages.some((m) => m.id === result.data.id)) { + set({ messages: [...messages, result.data] }); + } + set({ activeCall: { conversationId, key, video } }); + return result; + }, + + joinCall(conversationId, key, video = false) { + set({ activeCall: { conversationId, key, video } }); + }, + + endCall() { + set({ activeCall: null }); + }, + async startTyping(conversationId) { try { await apiPost('/api/typing/start', { conversationId }); } catch {} }, diff --git a/supabase/migrations/20261006200000_message_type_call.sql b/supabase/migrations/20261006200000_message_type_call.sql new file mode 100644 index 00000000..d5e910d5 --- /dev/null +++ b/supabase/migrations/20261006200000_message_type_call.sql @@ -0,0 +1,5 @@ +-- End-to-end encrypted calls: a call invite is a message of type 'call' whose +-- ENCRYPTED body carries the room and the media key (src/lib/chat/calls.js). +ALTER TABLE messages DROP CONSTRAINT IF EXISTS messages_message_type_check; +ALTER TABLE messages ADD CONSTRAINT messages_message_type_check + CHECK (message_type IN ('text', 'image', 'file', 'system', 'reaction', 'call')); diff --git a/tests/calls.test.js b/tests/calls.test.js new file mode 100644 index 00000000..6d1bd8a5 --- /dev/null +++ b/tests/calls.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { callEnvelope, parseCall, newCallKey, isJoinable, callSummary, CALL_JOINABLE_MS } from '../src/lib/chat/calls.js'; + +describe('qc and call invites', () => { + it('never prints the media key', async () => { + const { QcClient } = await import('../src/cli/api.js'); + const { ephemeralKeypair, keyring } = await import('../src/cli/crypto.js'); + const me = { keys1024: await ephemeralKeypair() }; + const content = callEnvelope({ room: 'c', key: newCallKey(), video: true }); + const encrypted = await keyring(me).encrypt(content, me.keys1024.publicKey); + const rows = [{ id: 'm', conversation_id: 'c', sender_id: 'b', sender: { display_name: 'Bob' }, message_type: 'call', encrypted_content: encrypted, created_at: new Date().toISOString() }]; + const client = new QcClient( + { access_token: 't', keys: me, user: { id: 'a' } }, + { fetch: async () => new Response(JSON.stringify({ messages: rows }), { status: 200 }), save: () => {} }, + ); + const { messages } = await client.messages('c'); + expect(messages[0].text).toBe('๐Ÿ“ž Bob started a video call ยท join on qrypt.chat'); + expect(messages[0].text).not.toContain(JSON.parse(content).key); + }, 20000); +}); + +describe('call invites', () => { + it('carry the room and a 32-byte media key inside the encrypted body', () => { + const key = newCallKey(); + expect(key).toHaveLength(32); + const body = callEnvelope({ room: 'conv-1', key, video: true, at: '2026-10-06T10:00:00.000Z' }); + const call = parseCall(body); + expect(call).toMatchObject({ room: 'conv-1', video: true, at: '2026-10-06T10:00:00.000Z' }); + expect(Array.from(call.key)).toEqual(Array.from(key)); + }); + + it('reject anything that is not a well-formed invite', () => { + expect(parseCall('hello')).toBeNull(); + expect(parseCall(JSON.stringify({ type: 'call', room: 'r', key: btoa('short') }))).toBeNull(); + expect(parseCall(JSON.stringify({ type: 'reaction', room: 'r', key: 'x' }))).toBeNull(); + }); + + it('stop being joinable after a while', () => { + const call = parseCall(callEnvelope({ room: 'r', key: newCallKey(), at: '2026-10-06T10:00:00.000Z' })); + const start = Date.parse('2026-10-06T10:00:00.000Z'); + expect(isJoinable(call, null, start + 60_000)).toBe(true); + expect(isJoinable(call, null, start + CALL_JOINABLE_MS + 1)).toBe(false); + expect(isJoinable(null, null)).toBe(false); + }); + + it('summarise without the key', () => { + const content = callEnvelope({ room: 'r', key: newCallKey(), video: false }); + const text = callSummary({ content, sender_id: 'b', sender: { display_name: 'Bob' } }, 'me'); + expect(text).toBe('๐Ÿ“ž Bob started a voice call'); + expect(callSummary({ content, sender_id: 'me' }, 'me')).toBe('๐Ÿ“ž You started a voice call'); + }); +});