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() {
+
{error}
: } +Calls run on PairUX. Every audio and video frame is encrypted on your device with a key only this conversation holds.
+