diff --git a/bun.lock b/bun.lock
index b638fa74..d456c3f6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -19,6 +19,7 @@
"@simplewebauthn/server": "^14.0.3",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.56.0",
+ "@telnyx/webrtc": "2.28.0",
"blessed": "^0.1.81",
"dilithium-crystals": "^1.0.6",
"dotenv": "^17.2.2",
@@ -307,8 +308,6 @@
"@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=="],
-
"@next/env": ["@next/env@16.4.3", "", {}, "sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.4.3", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "sha512-oxuC06wt/71h6ACYCV9GbKOfnf5a7VlRe/p80YHTut15TNgDEmynDnaq8t/xhy5rlvk7dhItIPs6H3rvIM4kyw=="],
@@ -415,6 +414,8 @@
"@peculiar/x509": ["@peculiar/x509@2.1.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.9.4", "@peculiar/asn1-csr": "^2.9.4", "@peculiar/asn1-ecc": "^2.9.4", "@peculiar/asn1-pkcs9": "^2.9.4", "@peculiar/asn1-rsa": "^2.9.4", "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "@peculiar/asn1-x509-post-quantum": "^2.9.4", "pvtsutils": "^1.3.6", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA=="],
+ "@peermetrics/webrtc-stats": ["@peermetrics/webrtc-stats@5.9.0", "", { "dependencies": { "events": "^3.3.0", "uuid": "^8.3.2" } }, "sha512-eQYGGdj+H4MUEuwbccy9bxRV3uAqPM5+Say9PSx/alrtv5ccmKCqGfLbqXrRJ/qThZvjZNBna5K/YnCWoaTQBw=="],
+
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@posthog/core": ["@posthog/core@1.30.9", "", { "dependencies": { "@posthog/types": "1.381.0" } }, "sha512-Cn004VJ7ZWQRaVQG7efh+pMtRMIVZznktngXe5I3E8wClRdMwCZaSa6jo/X04Oc04z8PeMp4GEcWVkdEhmAEXw=="],
@@ -523,6 +524,8 @@
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
+ "@telnyx/webrtc": ["@telnyx/webrtc@2.28.0", "", { "dependencies": { "@peermetrics/webrtc-stats": "^5.7.1", "loglevel": "^1.6.8", "uuid": "^7.0.3" } }, "sha512-ayOq8jAE+qO3EHR/sZCI42TF/9b7u3TmzgzIE+wzske0qPXBjZtdIdqiMfFCp8YZmWNbmbiwRefnI8vSa6/MVA=="],
+
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "7.29.7", "@babel/runtime": "7.28.6", "@types/aria-query": "5.0.4", "aria-query": "5.3.0", "dom-accessibility-api": "0.5.16", "lz-string": "1.5.0", "picocolors": "1.1.1", "pretty-format": "27.5.1" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "4.4.4", "aria-query": "5.3.2", "css.escape": "1.5.1", "dom-accessibility-api": "0.6.3", "picocolors": "1.1.1", "redent": "3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
@@ -1521,14 +1524,16 @@
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
- "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
-
"@next/eslint-plugin-next/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
+ "@peermetrics/webrtc-stats/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
+
"@sinonjs/samsam/type-detect": ["type-detect@4.1.0", "", {}, "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw=="],
"@supabase/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "0.0.3", "webidl-conversions": "3.0.1" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
+ "@telnyx/webrtc/uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="],
+
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
@@ -1547,6 +1552,8 @@
"@unrs/resolver-binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
+ "@unrs/resolver-binding-wasm32-wasi/@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=="],
+
"@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"array.prototype.findlastindex/call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-define-property": "1.0.1", "get-intrinsic": "1.3.0", "set-function-length": "1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
diff --git a/package.json b/package.json
index d9bc29e5..cff246bb 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
- "version": "0.13.4",
+ "version": "0.14.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
@@ -71,6 +71,7 @@
"@simplewebauthn/server": "^14.0.3",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.56.0",
+ "@telnyx/webrtc": "2.28.0",
"blessed": "^0.1.81",
"dilithium-crystals": "^1.0.6",
"dotenv": "^17.2.2",
diff --git a/src/app/api/phone/call/route.js b/src/app/api/phone/call/route.js
new file mode 100644
index 00000000..a4e533b4
--- /dev/null
+++ b/src/app/api/phone/call/route.js
@@ -0,0 +1,26 @@
+import { NextResponse } from 'next/server';
+import { withAuth } from '@/lib/api/middleware/auth.js';
+import { RateLimiter, applyRateLimitForKey } from '@/lib/server/rate-limiter.js';
+import { startCall } from '@/lib/server/phone.js';
+import { phoneContext, phoneError, phoneUser } from '@/lib/server/phone-route.js';
+
+const perMinute = new RateLimiter({ maxRequests: 10, windowMs: 60 * 1000 });
+const perDay = new RateLimiter({ maxRequests: 200, windowMs: 24 * 60 * 60 * 1000 });
+
+/**
+ * POST /api/phone/call { to }: call a US or Canadian phone. Rings this user's
+ * browser line first; the number is dialled once the browser answers.
+ */
+export const POST = withAuth(async ({ request, locals }) => {
+ const limited = applyRateLimitForKey(perMinute, locals.user.id) || applyRateLimitForKey(perDay, locals.user.id);
+ if (limited) return limited;
+ try {
+ const body = await request.json().catch(() => ({}));
+ const ctx = phoneContext();
+ const user = await phoneUser(ctx.db, locals.user.id);
+ const result = await startCall({ ...ctx, user, to: body?.to });
+ return NextResponse.json(result, { headers: { 'Cache-Control': 'no-store' } });
+ } catch (error) {
+ return phoneError(error, 'call');
+ }
+});
diff --git a/src/app/api/phone/hangup/route.js b/src/app/api/phone/hangup/route.js
new file mode 100644
index 00000000..0d5bec03
--- /dev/null
+++ b/src/app/api/phone/hangup/route.js
@@ -0,0 +1,16 @@
+import { NextResponse } from 'next/server';
+import { withAuth } from '@/lib/api/middleware/auth.js';
+import { hangupCall } from '@/lib/server/phone.js';
+import { phoneContext, phoneError, phoneUser } from '@/lib/server/phone-route.js';
+
+/** POST /api/phone/hangup { callId }: end this user's call on both legs. */
+export const POST = withAuth(async ({ request, locals }) => {
+ try {
+ const body = await request.json().catch(() => ({}));
+ const ctx = phoneContext();
+ const user = await phoneUser(ctx.db, locals.user.id);
+ return NextResponse.json(await hangupCall({ ...ctx, user, callId: String(body?.callId || '') }));
+ } catch (error) {
+ return phoneError(error, 'hangup');
+ }
+});
diff --git a/src/app/api/phone/route.js b/src/app/api/phone/route.js
new file mode 100644
index 00000000..5b4debe0
--- /dev/null
+++ b/src/app/api/phone/route.js
@@ -0,0 +1,23 @@
+import { NextResponse } from 'next/server';
+import { withAuth } from '@/lib/api/middleware/auth.js';
+import { accountState } from '@/lib/server/phone.js';
+import { phoneContext, phoneError, phoneUser } from '@/lib/server/phone-route.js';
+import { CENTS_PER_MINUTE, FREE_SECONDS_PER_DAY } from '@/lib/phone/rules.js';
+
+export const dynamic = 'force-dynamic';
+
+/** GET /api/phone: today's free minutes, recent calls, caller ID. */
+export const GET = withAuth(async ({ locals }) => {
+ try {
+ const { db, store, config } = phoneContext();
+ const user = await phoneUser(db, locals.user.id);
+ const account = await accountState({ store, user });
+ const calls = (await store.recentCalls(user.id)) || [];
+ return NextResponse.json(
+ { ...account, freeSecondsPerDay: FREE_SECONDS_PER_DAY, centsPerMinute: CENTS_PER_MINUTE, callerId: config.callerId, calls },
+ { headers: { 'Cache-Control': 'no-store' } }
+ );
+ } catch (error) {
+ return phoneError(error, 'status');
+ }
+});
diff --git a/src/app/api/phone/token/route.js b/src/app/api/phone/token/route.js
new file mode 100644
index 00000000..d9999f80
--- /dev/null
+++ b/src/app/api/phone/token/route.js
@@ -0,0 +1,24 @@
+import { NextResponse } from 'next/server';
+import { withAuth } from '@/lib/api/middleware/auth.js';
+import { RateLimiter, applyRateLimitForKey } from '@/lib/server/rate-limiter.js';
+import { loginToken } from '@/lib/server/phone.js';
+import { phoneContext, phoneError, phoneUser } from '@/lib/server/phone-route.js';
+
+const limiter = new RateLimiter({ maxRequests: 20, windowMs: 60 * 1000 });
+
+/**
+ * POST /api/phone/token: a short-lived login for the browser's phone line
+ * (Telnyx WebRTC). The line can only receive calls; the server places them.
+ */
+export const POST = withAuth(async ({ locals }) => {
+ const limited = applyRateLimitForKey(limiter, locals.user.id);
+ if (limited) return limited;
+ try {
+ const ctx = phoneContext();
+ const user = await phoneUser(ctx.db, locals.user.id);
+ const { token } = await loginToken({ ...ctx, user });
+ return NextResponse.json({ token }, { headers: { 'Cache-Control': 'no-store' } });
+ } catch (error) {
+ return phoneError(error, 'token');
+ }
+});
diff --git a/src/app/api/phone/webhook/route.js b/src/app/api/phone/webhook/route.js
new file mode 100644
index 00000000..ad9dd9d6
--- /dev/null
+++ b/src/app/api/phone/webhook/route.js
@@ -0,0 +1,35 @@
+import { NextResponse } from 'next/server';
+import { handleEvent } from '@/lib/server/phone.js';
+import { phoneContext } from '@/lib/server/phone-route.js';
+import { telnyxConfig, verifyWebhook } from '@/lib/server/telnyx-voice.js';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * POST /api/phone/webhook: Telnyx Call Control events for qrypt.chat calls
+ * (answered, hangup, ...). Signed with Ed25519; anything unsigned is refused.
+ */
+export async function POST(request) {
+ const body = await request.text();
+ const ok = verifyWebhook({
+ body,
+ signature: request.headers.get('telnyx-signature-ed25519'),
+ timestamp: request.headers.get('telnyx-timestamp'),
+ publicKey: telnyxConfig().publicKey,
+ });
+ if (!ok) return NextResponse.json({ error: 'Bad signature' }, { status: 401 });
+ let event;
+ try {
+ event = JSON.parse(body)?.data;
+ } catch {
+ return NextResponse.json({ error: 'Bad JSON' }, { status: 400 });
+ }
+ try {
+ const result = await handleEvent({ ...phoneContext(), event });
+ return NextResponse.json({ ok: true, ...result });
+ } catch (error) {
+ console.error('[phone/webhook]', event?.event_type, error?.message);
+ // 500 makes Telnyx retry, which handleEvent tolerates.
+ return NextResponse.json({ error: 'failed' }, { status: 500 });
+ }
+}
diff --git a/src/app/phone/page.jsx b/src/app/phone/page.jsx
new file mode 100644
index 00000000..91207d03
--- /dev/null
+++ b/src/app/phone/page.jsx
@@ -0,0 +1,242 @@
+'use client';
+
+/**
+ * Call ordinary phones (US and Canada) from qrypt.chat, like Skype used to.
+ * 10 free minutes a day for signed-in accounts. The browser's phone line only
+ * receives calls: the server rings it, then connects it to the number with a
+ * hard time limit (src/lib/server/phone.js).
+ */
+import { useEffect, useRef, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { useAuthStore } from '@/lib/stores/auth.js';
+import { blockedReason, formatNumber, normalizeNumber } from '@/lib/phone/rules.js';
+import { setBusy } from '@/lib/app-update.js';
+
+const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
+const mmss = (s) => `${Math.floor(Math.max(0, s) / 60)}:${String(Math.floor(Math.max(0, s) % 60)).padStart(2, '0')}`;
+
+export default function PhonePage() {
+ const router = useRouter();
+ const user = useAuthStore((s) => s.user);
+ const [info, setInfo] = useState(null);
+ const [number, setNumber] = useState('');
+ const [phase, setPhase] = useState('idle'); // idle | starting | ringing | connected | ended
+ const [error, setError] = useState('');
+ const [muted, setMuted] = useState(false);
+ const [elapsed, setElapsed] = useState(0);
+ const [limit, setLimit] = useState(0);
+ const [showKeys, setShowKeys] = useState(false);
+ const clientRef = useRef(null);
+ const callRef = useRef(null);
+ const callIdRef = useRef(null);
+ const startedRef = useRef(0);
+
+ useEffect(() => {
+ if (!user) router.replace('/auth');
+ }, [user]);
+
+ async function refresh() {
+ const res = await fetch('/api/phone').catch(() => null);
+ const data = res ? await res.json().catch(() => ({})) : {};
+ if (res?.ok) setInfo(data);
+ else setError(data.error || 'Phone calls are unavailable right now.');
+ }
+ useEffect(() => {
+ if (user) refresh();
+ }, [user]);
+
+ // The call clock, and no auto-reload after a deploy while on a call.
+ useEffect(() => {
+ setBusy('phone', phase === 'starting' || phase === 'ringing' || phase === 'connected');
+ if (phase !== 'connected') return;
+ const t = setInterval(() => setElapsed(Math.floor((Date.now() - startedRef.current) / 1000)), 500);
+ return () => clearInterval(t);
+ }, [phase]);
+ useEffect(() => () => { setBusy('phone', false); clientRef.current?.disconnect?.(); }, []);
+
+ const e164 = normalizeNumber(number);
+ const why = number.trim() ? blockedReason(e164) : '';
+
+ function finish(msg) {
+ callRef.current = null;
+ setPhase('ended');
+ if (msg) setError(msg);
+ try { clientRef.current?.disconnect(); } catch {}
+ clientRef.current = null;
+ setTimeout(() => { setPhase('idle'); refresh(); }, 1500);
+ }
+
+ async function call() {
+ if (!e164 || why || phase !== 'idle') return;
+ setError('');
+ setMuted(false);
+ setElapsed(0);
+ setPhase('starting');
+ try {
+ // 1. Log the browser's phone line in.
+ const tok = await fetch('/api/phone/token', { method: 'POST' });
+ const t = await tok.json().catch(() => ({}));
+ if (!tok.ok) throw new Error(t.error || 'Could not start the phone line');
+ const { TelnyxRTC } = await import('@telnyx/webrtc');
+ const client = new TelnyxRTC({ login_token: t.token });
+ clientRef.current = client;
+ client.remoteElement = 'phone-remote-audio';
+ client.on('telnyx.error', () => finish('The phone line dropped.'));
+ client.on('telnyx.notification', (n) => {
+ if (n.type !== 'callUpdate') return;
+ const c = n.call;
+ // 2. The server rings this line: answer, and the number is dialled.
+ if (c.state === 'ringing' && c.direction === 'inbound') {
+ callRef.current = c;
+ c.answer();
+ }
+ if (c.state === 'active') {
+ startedRef.current = Date.now();
+ setPhase('connected');
+ }
+ if (c.state === 'hangup' || c.state === 'destroy') finish();
+ });
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('The phone line did not connect')), 15000);
+ client.on('telnyx.ready', () => { clearTimeout(timer); resolve(); });
+ client.connect();
+ });
+ // 3. Ask the server to place the call.
+ const res = await fetch('/api/phone/call', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to: e164 }) });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.error || 'Could not place the call');
+ callIdRef.current = data.callId;
+ setLimit(data.limitSeconds);
+ setPhase('ringing');
+ } catch (e) {
+ finish(e.message);
+ }
+ }
+
+ function hangup() {
+ try { callRef.current?.hangup(); } catch {}
+ if (callIdRef.current) fetch('/api/phone/hangup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ callId: callIdRef.current }) }).catch(() => {});
+ finish();
+ }
+
+ function toggleMute() {
+ const c = callRef.current;
+ if (!c) return;
+ if (muted) c.unmuteAudio(); else c.muteAudio();
+ setMuted(!muted);
+ }
+
+ function press(k) {
+ if (phase === 'connected') callRef.current?.dtmf(k);
+ else if (phase === 'idle') setNumber((n) => (n + k).slice(0, 20));
+ }
+
+ if (!user) return null;
+ const onCall = phase === 'starting' || phase === 'ringing' || phase === 'connected';
+ const free = info ? Math.floor(info.freeSecondsLeft / 60) : null;
+
+ return (
+
+
+
+
Phone
+
+ Call any US or Canadian phone.{' '}
+ {info?.operator ? 'Unlimited for you.' : info?.anonymous ? 'Sign in with a phone number, passkey or CoinPay to get 10 free minutes a day.' : `${free ?? '…'} of ${info ? info.freeSecondsPerDay / 60 : 10} free minutes left today.`}
+
+
+ {!onCall ? (
+ <>
+
setNumber(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && call()}
+ aria-label="Phone number"
+ />
+ {why &&
{why}
}
+
+ {KEYS.map((k) => press(k)}>{k} )}
+
+
+ 📞 Call {e164 ? formatNumber(e164) : ''}
+ {number && setNumber((n) => n.slice(0, -1))} aria-label="Delete">⌫ }
+
+ >
+ ) : (
+
+
{formatNumber(e164)}
+
+ {phase === 'starting' && 'Starting…'}
+ {phase === 'ringing' && 'Calling…'}
+ {phase === 'connected' && <>{mmss(elapsed)} · {mmss(limit - elapsed)} left >}
+
+ {showKeys && phase === 'connected' && (
+
+ {KEYS.map((k) => press(k)}>{k} )}
+
+ )}
+
+ {muted ? 'Unmute' : 'Mute'}
+ setShowKeys((v) => !v)} disabled={phase !== 'connected'}>Keypad
+ Hang up
+
+
+ )}
+ {phase === 'ended' &&
Call ended.
}
+ {error &&
{error}
}
+
+
+ Calls to phones go over the public phone network, so unlike calls between qrypt.chat users they are not end-to-end
+ encrypted. The person you call sees {info?.callerId ? formatNumber(info.callerId) : 'our number'}. Emergency (911) and premium-rate numbers can't be called.
+
+
+ {info?.calls?.length > 0 && (
+
+
Recent calls
+
+ {info.calls.map((c) => (
+
+ !onCall && setNumber(c.to_number)}>{formatNumber(c.to_number)}
+ {c.billed_seconds ? mmss(c.billed_seconds) : c.status === 'ended' || c.status === 'failed' ? 'not connected' : c.status}
+ {new Date(c.created_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/lib/components/Navbar.jsx b/src/lib/components/Navbar.jsx
index 9b4155b7..29b91a7f 100644
--- a/src/lib/components/Navbar.jsx
+++ b/src/lib/components/Navbar.jsx
@@ -83,7 +83,10 @@ export default function Navbar() {
{t('nav.home')}
Blog
{isAuthenticated && (
- {t('nav.chat')}
+ <>
+ {t('nav.chat')}
+ Phone
+ >
)}
@@ -169,6 +172,7 @@ export default function Navbar() {
{isAuthenticated && (
<>
{t('nav.chat')}
+ Phone
{user?.username && Profile}
{t('nav.settings')}
>
diff --git a/src/lib/phone/rules.js b/src/lib/phone/rules.js
new file mode 100644
index 00000000..7d7d6243
--- /dev/null
+++ b/src/lib/phone/rules.js
@@ -0,0 +1,77 @@
+/**
+ * Phone calls from qrypt.chat to ordinary phones (US and Canada): which numbers
+ * may be called, how long a call may last, and what it costs.
+ *
+ * Everyone signed in gets FREE_SECONDS_PER_DAY of calls a day (UTC). Anonymous
+ * accounts get none, so free minutes cannot be farmed with throwaway accounts.
+ * Operators (Anthony and his agents, PHONE_OPERATOR_IDS) are not limited.
+ * Telnyx bills whole seconds after a minimum; we count every started minute.
+ */
+
+export const FREE_SECONDS_PER_DAY = 10 * 60;
+/** Telnyx will not accept a time limit under 30 s. */
+export const MIN_CALL_SECONDS = 30;
+export const OPERATOR_CALL_SECONDS = 60 * 60;
+/** What a minute costs past the free allowance, in US cents (about 2x wholesale). */
+export const CENTS_PER_MINUTE = 1;
+
+/**
+ * A North American number in E.164 (+1NXXNXXXXXX), or null. Accepts the usual
+ * ways people type one: (408) 555-0199, 408.555.0199, 1-408-555-0199, +14085550199.
+ */
+export function normalizeNumber(input) {
+ const digits = String(input ?? '').replace(/[^\d+]/g, '');
+ let d = digits.replace(/^\+/, '');
+ if (d.length === 10) d = `1${d}`;
+ if (!/^1[2-9]\d{2}[2-9]\d{6}$/.test(d)) return null;
+ return `+${d}`;
+}
+
+/** Why a (normalized) number cannot be called, or '' when it can. */
+export function blockedReason(e164) {
+ if (!e164) return 'That is not a US or Canadian phone number.';
+ const area = e164.slice(2, 5);
+ const exchange = e164.slice(5, 8);
+ // N11 service codes (211, 311, 411, 511, 611, 711, 811, 911) as area code or exchange.
+ if (/^[2-9]11$/.test(area) || /^[2-9]11$/.test(exchange)) return 'Service numbers like 911 cannot be called from qrypt.chat. For an emergency, use a phone.';
+ // Premium-rate and pay-per-call ranges.
+ if (area === '900' || area === '976' || exchange === '976') return 'Premium-rate numbers cannot be called.';
+ // Fictional 555-01XX numbers never connect.
+ if (exchange === '555' && /^01\d\d$/.test(e164.slice(8))) return 'That is a fictional 555 number.';
+ return '';
+}
+
+/** Start of today (UTC) as an ISO string: the free allowance resets then. */
+export const dayStart = (now = new Date()) => new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString();
+
+/** Free seconds left today, given what was billed today. */
+export function freeSecondsLeft({ anonymous, operator, usedTodaySeconds }) {
+ if (operator) return OPERATOR_CALL_SECONDS;
+ if (anonymous) return 0;
+ return Math.max(0, FREE_SECONDS_PER_DAY - Math.max(0, usedTodaySeconds || 0));
+}
+
+/**
+ * How long the next call may last (seconds), or 0 when it cannot be placed.
+ * Paid credit will add to this; until then it is the free allowance.
+ */
+export function callLimitSeconds(account) {
+ const left = freeSecondsLeft(account);
+ return left >= MIN_CALL_SECONDS ? left : 0;
+}
+
+/** Billable seconds of a connected call: from answer to hang-up, rounded up. */
+export function billableSeconds(answeredAt, endedAt) {
+ if (!answeredAt || !endedAt) return 0;
+ const ms = Date.parse(endedAt) - Date.parse(answeredAt);
+ return Number.isFinite(ms) && ms > 0 ? Math.ceil(ms / 1000) : 0;
+}
+
+/** Started minutes of a call, for display and (later) paid billing. */
+export const startedMinutes = (seconds) => (seconds > 0 ? Math.ceil(seconds / 60) : 0);
+
+/** +14085550199 -> (408) 555-0199 */
+export function formatNumber(e164) {
+ const m = /^\+1(\d{3})(\d{3})(\d{4})$/.exec(String(e164 || ''));
+ return m ? `(${m[1]}) ${m[2]}-${m[3]}` : String(e164 || '');
+}
diff --git a/src/lib/phone/rules.test.js b/src/lib/phone/rules.test.js
new file mode 100644
index 00000000..48e6a2a4
--- /dev/null
+++ b/src/lib/phone/rules.test.js
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import { billableSeconds, blockedReason, callLimitSeconds, dayStart, formatNumber, freeSecondsLeft, normalizeNumber, startedMinutes } from './rules.js';
+
+describe('phone rules', () => {
+ it('normalizes the ways people type North American numbers', () => {
+ for (const n of ['(408) 555-0199', '408.555.0199', '1-408-555-0199', '+1 408 555 0199', '4085550199']) expect(normalizeNumber(n)).toBe('+14085550199');
+ for (const n of ['555-0199', '+44 20 7946 0958', '011-408-555-0199', '(108) 555-0199', '408-155-0199', '', null]) expect(normalizeNumber(n)).toBeNull();
+ });
+
+ it('blocks 911 and other service codes, premium rate and fictional numbers', () => {
+ expect(blockedReason(normalizeNumber('911'))).toMatch(/not a US/);
+ expect(blockedReason('+19115550100')).toMatch(/911/);
+ expect(blockedReason('+14089110000')).toMatch(/911/);
+ expect(blockedReason('+19005551234')).toMatch(/Premium/);
+ expect(blockedReason('+14089761234')).toMatch(/Premium/);
+ expect(blockedReason('+14085550123')).toMatch(/fictional/);
+ expect(blockedReason('+13034997111')).toBe('');
+ });
+
+ it('gives signed-in people 10 free minutes a day, anonymous none, operators an hour per call', () => {
+ expect(freeSecondsLeft({ usedTodaySeconds: 0 })).toBe(600);
+ expect(freeSecondsLeft({ usedTodaySeconds: 420 })).toBe(180);
+ expect(freeSecondsLeft({ usedTodaySeconds: 900 })).toBe(0);
+ expect(freeSecondsLeft({ anonymous: true, usedTodaySeconds: 0 })).toBe(0);
+ expect(freeSecondsLeft({ operator: true, usedTodaySeconds: 99999 })).toBe(3600);
+ expect(callLimitSeconds({ usedTodaySeconds: 580 })).toBe(0); // 20 s left: under Telnyx's 30 s minimum
+ expect(callLimitSeconds({ usedTodaySeconds: 500 })).toBe(100);
+ });
+
+ it('bills from answer to hang-up, rounding up', () => {
+ expect(billableSeconds('2026-10-26T12:39:57.481Z', '2026-10-26T12:40:30.041Z')).toBe(33);
+ expect(billableSeconds(null, '2026-10-26T12:40:30Z')).toBe(0);
+ expect(startedMinutes(33)).toBe(1);
+ expect(startedMinutes(61)).toBe(2);
+ expect(startedMinutes(0)).toBe(0);
+ });
+
+ it('resets at UTC midnight and formats numbers', () => {
+ expect(dayStart(new Date('2026-10-26T23:59:00-07:00'))).toBe('2026-10-27T00:00:00.000Z');
+ expect(formatNumber('+14085550199')).toBe('(408) 555-0199');
+ });
+});
diff --git a/src/lib/server/phone-route.js b/src/lib/server/phone-route.js
new file mode 100644
index 00000000..a9acfeaf
--- /dev/null
+++ b/src/lib/server/phone-route.js
@@ -0,0 +1,29 @@
+// Shared bits of the /api/phone routes.
+import { NextResponse } from 'next/server';
+import { getServiceRoleClient } from '@/lib/supabase/service-role.js';
+import { PhoneError, supabaseStore } from './phone.js';
+import { TelnyxError, phoneConfigured, telnyxClient, telnyxConfig } from './telnyx-voice.js';
+
+export function phoneContext() {
+ if (!phoneConfigured()) throw new PhoneError('Phone calls are not set up on this server.', 503);
+ const config = telnyxConfig();
+ const db = getServiceRoleClient();
+ return { db, store: supabaseStore(db), telnyx: telnyxClient(config), config };
+}
+
+/** The signed-in person's users row (id, account_type). */
+export async function phoneUser(db, authUserId) {
+ const { data } = await db.from('users').select('id, username, account_type').eq('auth_user_id', authUserId).single();
+ if (!data) throw new PhoneError('User not found', 404);
+ return data;
+}
+
+export function phoneError(error, tag) {
+ if (error instanceof PhoneError) return NextResponse.json({ error: error.message }, { status: error.status });
+ if (error instanceof TelnyxError) {
+ console.error(`[phone/${tag}] telnyx`, error.status, error.code, error.message);
+ return NextResponse.json({ error: 'The phone service refused the request.' }, { status: 502 });
+ }
+ console.error(`[phone/${tag}]`, error?.message);
+ return NextResponse.json({ error: 'Something went wrong with the call.' }, { status: 500 });
+}
diff --git a/src/lib/server/phone.js b/src/lib/server/phone.js
new file mode 100644
index 00000000..82c53abd
--- /dev/null
+++ b/src/lib/server/phone.js
@@ -0,0 +1,159 @@
+/**
+ * Phone calls from qrypt.chat: start, follow and bill them (Telnyx, US/Canada).
+ *
+ * 1. POST /api/phone/call checks the number and the caller's allowance, saves a
+ * call row and rings the caller's browser (its WebRTC credential) through
+ * the Call Control app, carrying { c: callId } as client_state.
+ * 2. The browser answers; Telnyx sends call.answered for that leg, and we
+ * transfer it to the number with time_limit_secs = the allowance, so Telnyx
+ * itself ends the call when the free minutes run out.
+ * 3. call.answered on the phone leg starts the clock; call.hangup on it bills
+ * the seconds from answer to hang-up.
+ *
+ * Storage goes through a small store (supabaseStore below; a memory one in tests).
+ */
+import { billableSeconds, blockedReason, callLimitSeconds, dayStart, freeSecondsLeft, normalizeNumber } from '@/lib/phone/rules.js';
+import { decodeState, encodeState } from './telnyx-voice.js';
+
+const LIVE = ['ringing', 'connecting', 'active'];
+const STALE_MS = 2 * 60 * 60 * 1000;
+
+export class PhoneError extends Error {
+ constructor(message, status = 400) {
+ super(message);
+ this.status = status;
+ }
+}
+
+export const operatorIds = (env = process.env) => new Set(String(env.PHONE_OPERATOR_IDS || '').split(',').map((s) => s.trim()).filter(Boolean));
+
+/** What this person may do right now. */
+export async function accountState({ store, user, env = process.env, now = new Date() }) {
+ const operator = operatorIds(env).has(user.id);
+ const anonymous = user.account_type === 'anonymous';
+ const usedTodaySeconds = await store.usedSince(user.id, dayStart(now));
+ return { operator, anonymous, usedTodaySeconds, freeSecondsLeft: freeSecondsLeft({ operator, anonymous, usedTodaySeconds }) };
+}
+
+/** The user's WebRTC line (a Telnyx telephony credential), made on first use. */
+export async function ensureLine({ store, telnyx, config, user }) {
+ const existing = await store.getLine(user.id);
+ if (existing) return existing;
+ const cred = await telnyx.createCredential(config.connectionId, `qrypt-${user.id}`);
+ const line = { user_id: user.id, credential_id: cred.id, sip_username: cred.sip_username };
+ await store.saveLine(line);
+ return line;
+}
+
+/** A login token for the browser SDK. */
+export async function loginToken({ store, telnyx, config, user }) {
+ const line = await ensureLine({ store, telnyx, config, user });
+ return { token: await telnyx.loginToken(line.credential_id), line };
+}
+
+export async function startCall({ store, telnyx, config, user, to, env = process.env, now = new Date() }) {
+ const number = normalizeNumber(to);
+ const reason = blockedReason(number);
+ if (reason) throw new PhoneError(reason, 400);
+ const account = await accountState({ store, user, env, now });
+ const limit = callLimitSeconds(account);
+ if (!limit) {
+ throw new PhoneError(account.anonymous ? 'Free calling minutes are for signed-in accounts. Sign in with a phone number, passkey or CoinPay.' : "You've used today's 10 free minutes. They come back at midnight UTC.", 402);
+ }
+ const live = await store.liveCall(user.id, new Date(now.getTime() - STALE_MS).toISOString());
+ if (live) throw new PhoneError('You are already on a call.', 409);
+
+ const line = await ensureLine({ store, telnyx, config, user });
+ const call = await store.insertCall({ user_id: user.id, to_number: number, status: 'ringing', limit_seconds: limit });
+ try {
+ const leg = await telnyx.dial({
+ connection_id: config.appId,
+ to: `sip:${line.sip_username}@sip.telnyx.com`,
+ from: config.callerId,
+ timeout_secs: 30,
+ client_state: encodeState({ c: call.id }),
+ });
+ await store.updateCall(call.id, { user_leg_id: leg.call_control_id, session_id: leg.call_session_id });
+ } catch (e) {
+ await store.updateCall(call.id, { status: 'failed', ended_at: now.toISOString(), hangup_cause: 'dial_failed' });
+ throw new PhoneError(`Could not place the call: ${e.message}`, 502);
+ }
+ return { callId: call.id, to: number, limitSeconds: limit, account };
+}
+
+/** One Telnyx webhook event. Safe to receive twice. */
+export async function handleEvent({ store, telnyx, config, event }) {
+ const type = event?.event_type;
+ const p = event?.payload || {};
+ const state = decodeState(p.client_state);
+ const call = (state?.c && (await store.findCall({ id: state.c }))) || (p.call_session_id && (await store.findCall({ sessionId: p.call_session_id })));
+ if (!call) return { ignored: 'unknown call' };
+ const ccid = p.call_control_id;
+ const isUserLeg = ccid && ccid === call.user_leg_id;
+ const isPhoneLeg = p.to === call.to_number || (call.pstn_leg_id && ccid === call.pstn_leg_id);
+
+ if (type === 'call.answered' && isUserLeg && call.status === 'ringing') {
+ await store.updateCall(call.id, { status: 'connecting' });
+ try {
+ await telnyx.transfer(ccid, {
+ to: call.to_number,
+ from: config.callerId,
+ time_limit_secs: call.limit_seconds,
+ timeout_secs: 45,
+ client_state: encodeState({ c: call.id }),
+ });
+ } catch (e) {
+ await store.updateCall(call.id, { status: 'failed', hangup_cause: 'transfer_failed' });
+ await telnyx.hangup(ccid).catch(() => {});
+ return { error: e.message };
+ }
+ return { transferred: true };
+ }
+ if (type === 'call.answered' && isPhoneLeg && !call.answered_at) {
+ await store.updateCall(call.id, { status: 'active', answered_at: p.start_time || event.occurred_at, pstn_leg_id: ccid });
+ return { active: true };
+ }
+ if (type === 'call.hangup' && isPhoneLeg) {
+ const answeredAt = call.answered_at || (p.start_time && call.status === 'active' ? p.start_time : null);
+ const endedAt = p.end_time || event.occurred_at;
+ const billed = billableSeconds(answeredAt, endedAt);
+ await store.updateCall(call.id, { status: 'ended', ended_at: endedAt, billed_seconds: billed, hangup_cause: p.hangup_cause || null, pstn_leg_id: call.pstn_leg_id || ccid });
+ return { billed };
+ }
+ if (type === 'call.hangup' && isUserLeg && call.status !== 'ended') {
+ // The caller hung up (or never answered) before the other side picked up.
+ if (call.status === 'active') return { waiting: 'phone leg hangup' };
+ await store.updateCall(call.id, { status: 'ended', ended_at: p.end_time || event.occurred_at, hangup_cause: p.hangup_cause || null });
+ return { ended: true };
+ }
+ return { ignored: type };
+}
+
+export async function hangupCall({ store, telnyx, user, callId }) {
+ const call = await store.findCall({ id: callId });
+ if (!call || call.user_id !== user.id) throw new PhoneError('No such call', 404);
+ for (const leg of [call.pstn_leg_id, call.user_leg_id]) if (leg) await telnyx.hangup(leg).catch(() => {});
+ return { ok: true };
+}
+
+/** Storage on qrypt's Supabase (tables from supabase/migrations/20261026120000_phone_calls.sql). */
+export function supabaseStore(db) {
+ const one = async (q) => {
+ const { data, error } = await q;
+ if (error && error.code !== 'PGRST116') throw new Error(error.message);
+ return data ?? null;
+ };
+ return {
+ getLine: (userId) => one(db.from('phone_lines').select('*').eq('user_id', userId).maybeSingle()),
+ saveLine: (line) => one(db.from('phone_lines').insert(line)),
+ async usedSince(userId, iso) {
+ const rows = (await one(db.from('phone_calls').select('billed_seconds').eq('user_id', userId).gte('created_at', iso))) || [];
+ return rows.reduce((n, r) => n + (r.billed_seconds || 0), 0);
+ },
+ liveCall: (userId, sinceIso) => one(db.from('phone_calls').select('id').eq('user_id', userId).in('status', LIVE).gte('created_at', sinceIso).limit(1).maybeSingle()),
+ insertCall: (row) => one(db.from('phone_calls').insert(row).select('*').single()),
+ updateCall: (id, patch) => one(db.from('phone_calls').update({ ...patch, updated_at: new Date().toISOString() }).eq('id', id)),
+ findCall: ({ id, sessionId }) => one(id ? db.from('phone_calls').select('*').eq('id', id).maybeSingle() : db.from('phone_calls').select('*').eq('session_id', sessionId).order('created_at', { ascending: false }).limit(1).maybeSingle()),
+ recentCalls: (userId, n = 20) => one(db.from('phone_calls').select('id, to_number, status, billed_seconds, hangup_cause, created_at, answered_at, ended_at').eq('user_id', userId).order('created_at', { ascending: false }).limit(n)),
+ };
+}
diff --git a/src/lib/server/phone.test.js b/src/lib/server/phone.test.js
new file mode 100644
index 00000000..85900050
--- /dev/null
+++ b/src/lib/server/phone.test.js
@@ -0,0 +1,114 @@
+import crypto from 'node:crypto';
+import { describe, expect, it, vi } from 'vitest';
+import { handleEvent, hangupCall, startCall } from './phone.js';
+import { decodeState, encodeState, verifyWebhook } from './telnyx-voice.js';
+
+function memoryStore() {
+ const lines = new Map();
+ const calls = new Map();
+ let n = 0;
+ return {
+ calls,
+ getLine: async (u) => lines.get(u) || null,
+ saveLine: async (l) => lines.set(l.user_id, l),
+ usedSince: async (u, iso) => [...calls.values()].filter((c) => c.user_id === u && c.created_at >= iso).reduce((s, c) => s + (c.billed_seconds || 0), 0),
+ liveCall: async (u) => [...calls.values()].find((c) => c.user_id === u && ['ringing', 'connecting', 'active'].includes(c.status)) || null,
+ insertCall: async (row) => {
+ const c = { id: `call-${++n}`, created_at: new Date().toISOString(), billed_seconds: 0, ...row };
+ calls.set(c.id, c);
+ return c;
+ },
+ updateCall: async (id, patch) => Object.assign(calls.get(id), patch),
+ findCall: async ({ id, sessionId }) => (id ? calls.get(id) : [...calls.values()].find((c) => c.session_id === sessionId)) || null,
+ };
+}
+
+const config = { appId: 'app', connectionId: 'conn', callerId: '+19492847328' };
+const fakeTelnyx = () => ({
+ createCredential: vi.fn(async () => ({ id: 'cred-1', sip_username: 'gencredABC' })),
+ loginToken: vi.fn(async () => 'jwt'),
+ dial: vi.fn(async () => ({ call_control_id: 'user-leg', call_session_id: 'sess-1' })),
+ transfer: vi.fn(async () => ({})),
+ hangup: vi.fn(async () => ({})),
+});
+const user = { id: 'u1', account_type: 'verified' };
+
+describe('phone calls', () => {
+ it('places a call, transfers on answer, and bills from answer to hang-up (replaying a real Telnyx sequence)', async () => {
+ const store = memoryStore();
+ const telnyx = fakeTelnyx();
+ const r = await startCall({ store, telnyx, config, user, to: '(303) 499-7111', env: {} });
+ expect(r).toMatchObject({ to: '+13034997111', limitSeconds: 600 });
+ const [dial] = telnyx.dial.mock.calls[0];
+ expect(dial).toMatchObject({ connection_id: 'app', to: 'sip:gencredABC@sip.telnyx.com', from: '+19492847328' });
+ expect(decodeState(dial.client_state)).toEqual({ c: r.callId });
+
+ const ev = (event_type, payload) => handleEvent({ store, telnyx, config, event: { event_type, occurred_at: '2026-10-26T12:40:30Z', payload } });
+ const cs = encodeState({ c: r.callId });
+ await ev('call.initiated', { call_control_id: 'user-leg', call_session_id: 'sess-1', to: 'sip:gencredABC@sip.telnyx.com', client_state: cs });
+ await ev('call.answered', { call_control_id: 'user-leg', call_session_id: 'sess-1', to: 'sip:gencredABC@sip.telnyx.com', client_state: cs, start_time: '2026-10-26T12:39:55.641Z' });
+ expect(telnyx.transfer).toHaveBeenCalledWith('user-leg', expect.objectContaining({ to: '+13034997111', time_limit_secs: 600 }));
+ // The browser-side internal leg Telnyx also reports is ignored.
+ await ev('call.answered', { call_control_id: 'other-leg', call_session_id: 'sess-1', to: 'gencredABC' });
+ await ev('call.answered', { call_control_id: 'pstn-leg', call_session_id: 'sess-1', to: '+13034997111', start_time: '2026-10-26T12:39:57.481Z' });
+ expect(store.calls.get(r.callId).status).toBe('active');
+ await ev('call.hangup', { call_control_id: 'pstn-leg', call_session_id: 'sess-1', to: '+13034997111', start_time: '2026-10-26T12:39:57.481Z', end_time: '2026-10-26T12:40:30.041Z', hangup_cause: 'time_limit' });
+ await ev('call.hangup', { call_control_id: 'user-leg', call_session_id: 'sess-1', to: 'sip:gencredABC@sip.telnyx.com', end_time: '2026-10-26T12:40:30.081Z' });
+ expect(store.calls.get(r.callId)).toMatchObject({ status: 'ended', billed_seconds: 33, hangup_cause: 'time_limit' });
+
+ // 33 s used: the next call may last 567 s.
+ const r2 = await startCall({ store, telnyx, config, user, to: '4085551234', env: {} });
+ expect(r2.limitSeconds).toBe(567);
+ });
+
+ it('refuses blocked numbers, anonymous accounts, a second live call and an empty allowance', async () => {
+ const store = memoryStore();
+ const telnyx = fakeTelnyx();
+ await expect(startCall({ store, telnyx, config, user, to: '911', env: {} })).rejects.toMatchObject({ status: 400 });
+ await expect(startCall({ store, telnyx, config, user, to: '+19005551234', env: {} })).rejects.toThrow(/Premium/);
+ await expect(startCall({ store, telnyx, config, user: { id: 'a', account_type: 'anonymous' }, to: '4085551234', env: {} })).rejects.toMatchObject({ status: 402 });
+ await startCall({ store, telnyx, config, user, to: '4085551234', env: {} });
+ await expect(startCall({ store, telnyx, config, user, to: '4085551234', env: {} })).rejects.toMatchObject({ status: 409 });
+ const spent = memoryStore();
+ await spent.insertCall({ user_id: 'u1', status: 'ended', billed_seconds: 590 });
+ await expect(startCall({ store: spent, telnyx, config, user, to: '4085551234', env: {} })).rejects.toThrow(/10 free minutes/);
+ });
+
+ it('lets operators call for an hour and marks a call that never connected as ended', async () => {
+ const store = memoryStore();
+ const telnyx = fakeTelnyx();
+ const r = await startCall({ store, telnyx, config, user, to: '4085551234', env: { PHONE_OPERATOR_IDS: 'x, u1' } });
+ expect(r.limitSeconds).toBe(3600);
+ await handleEvent({ store, telnyx, config, event: { event_type: 'call.hangup', payload: { call_control_id: 'user-leg', call_session_id: 'sess-1', hangup_cause: 'timeout' } } });
+ expect(store.calls.get(r.callId)).toMatchObject({ status: 'ended', billed_seconds: 0, hangup_cause: 'timeout' });
+ await hangupCall({ store, telnyx, user, callId: r.callId });
+ expect(telnyx.hangup).toHaveBeenCalledWith('user-leg');
+ await expect(hangupCall({ store, telnyx, user: { id: 'other' }, callId: r.callId })).rejects.toMatchObject({ status: 404 });
+ });
+
+ it('marks the call failed if Telnyx refuses to dial', async () => {
+ const store = memoryStore();
+ const telnyx = fakeTelnyx();
+ telnyx.dial.mockRejectedValueOnce(new Error('channel limit'));
+ await expect(startCall({ store, telnyx, config, user, to: '4085551234', env: {} })).rejects.toMatchObject({ status: 502 });
+ expect([...store.calls.values()][0].status).toBe('failed');
+ });
+});
+
+describe('Telnyx webhook signatures', () => {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
+ const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12).toString('base64');
+ const pem = publicKey.export({ format: 'pem', type: 'spki' });
+ const body = '{"data":{"event_type":"call.hangup"}}';
+ const now = Date.now();
+ const timestamp = String(Math.floor(now / 1000));
+ const signature = crypto.sign(null, Buffer.from(`${timestamp}|${body}`), privateKey).toString('base64');
+
+ it('accepts the bare 32-byte key and PEM, and rejects tampering and replays', () => {
+ expect(verifyWebhook({ body, signature, timestamp, publicKey: raw, now })).toBe(true);
+ expect(verifyWebhook({ body, signature, timestamp, publicKey: pem, now })).toBe(true);
+ expect(verifyWebhook({ body: body.replace('hangup', 'answered'), signature, timestamp, publicKey: raw, now })).toBe(false);
+ expect(verifyWebhook({ body, signature, timestamp, publicKey: raw, now: now + 600_000 })).toBe(false);
+ expect(verifyWebhook({ body, signature: '', timestamp, publicKey: raw, now })).toBe(false);
+ });
+});
diff --git a/src/lib/server/telnyx-voice.js b/src/lib/server/telnyx-voice.js
new file mode 100644
index 00000000..bf64a0f9
--- /dev/null
+++ b/src/lib/server/telnyx-voice.js
@@ -0,0 +1,100 @@
+/**
+ * Telnyx for phone calls from qrypt.chat (see src/lib/server/phone.js).
+ *
+ * The browser never dials the phone network itself: its WebRTC credential sits
+ * on a credential connection with no outbound voice profile. The server rings
+ * the browser through a Call Control app, then transfers that leg to the number
+ * with a hard time limit, so the limit holds whatever the browser does.
+ *
+ * Env: TELNYX_API_KEY, TELNYX_PUBLIC_KEY (webhook signatures, raw 32-byte or PEM),
+ * TELNYX_CALL_APP_ID (Call Control app), TELNYX_CREDENTIAL_CONNECTION_ID,
+ * PHONE_CALLER_ID (+1 number shown to the people called).
+ */
+import crypto from 'node:crypto';
+
+const API = 'https://api.telnyx.com/v2';
+
+export class TelnyxError extends Error {
+ constructor(message, status, code) {
+ super(message);
+ this.status = status;
+ this.code = code;
+ }
+}
+
+export function telnyxConfig(env = process.env) {
+ return {
+ apiKey: env.TELNYX_API_KEY,
+ appId: env.TELNYX_CALL_APP_ID,
+ connectionId: env.TELNYX_CREDENTIAL_CONNECTION_ID,
+ callerId: env.PHONE_CALLER_ID,
+ publicKey: env.TELNYX_PUBLIC_KEY,
+ };
+}
+
+export const phoneConfigured = (env = process.env) => {
+ const c = telnyxConfig(env);
+ return !!(c.apiKey && c.appId && c.connectionId && c.callerId && c.publicKey);
+};
+
+export function telnyxClient({ apiKey, fetchImpl = fetch } = telnyxConfig()) {
+ async function call(path, { method = 'POST', body, raw = false } = {}) {
+ const res = await fetchImpl(`${API}${path}`, {
+ method,
+ headers: { Authorization: `Bearer ${apiKey}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ if (raw && res.ok) return res.text();
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ const e = data?.errors?.[0];
+ throw new TelnyxError(e?.detail || e?.title || `Telnyx HTTP ${res.status}`, res.status, e?.code);
+ }
+ return data.data;
+ }
+ const cc = (id) => `/calls/${encodeURIComponent(id)}/actions`;
+ return {
+ /** A WebRTC login for one user on the credential connection. */
+ createCredential: (connectionId, name) => call('/telephony_credentials', { body: { connection_id: connectionId, name, tag: 'qrypt-phone' } }),
+ /** A short-lived JWT the browser SDK logs in with. */
+ loginToken: (credentialId) => call(`/telephony_credentials/${encodeURIComponent(credentialId)}/token`, { raw: true }),
+ /** Ring the user's browser. */
+ dial: (body) => call('/calls', { body }),
+ transfer: (callControlId, body) => call(`${cc(callControlId)}/transfer`, { body }),
+ hangup: (callControlId) => call(`${cc(callControlId)}/hangup`, { body: {} }),
+ };
+}
+
+/** Telnyx's key may be stored bare (32 bytes, base64) or as PEM: both verify. */
+export function publicKeyObject(key) {
+ const s = String(key || '').trim();
+ if (s.startsWith('-----BEGIN')) return crypto.createPublicKey(s);
+ const raw = Buffer.from(s, 'base64');
+ if (raw.length !== 32) throw new Error('TELNYX_PUBLIC_KEY is neither PEM nor a 32-byte Ed25519 key');
+ const der = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), raw]);
+ return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
+}
+
+/**
+ * Whether a webhook really came from Telnyx: Ed25519 over `${timestamp}|${body}`,
+ * and no older than `toleranceSec` (replays).
+ */
+export function verifyWebhook({ body, signature, timestamp, publicKey, now = Date.now(), toleranceSec = 300 }) {
+ if (!publicKey || !signature || !timestamp) return false;
+ if (Math.abs(now / 1000 - Number(timestamp)) > toleranceSec) return false;
+ try {
+ return crypto.verify(null, Buffer.from(`${timestamp}|${body}`), publicKeyObject(publicKey), Buffer.from(signature, 'base64'));
+ } catch {
+ return false;
+ }
+}
+
+/** client_state travels base64-encoded on every leg; we keep { c: callId }. */
+export const encodeState = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64');
+export function decodeState(s) {
+ try {
+ return s ? JSON.parse(Buffer.from(s, 'base64').toString('utf8')) : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/proxy.js b/src/proxy.js
index d492aa43..39cfb8f2 100644
--- a/src/proxy.js
+++ b/src/proxy.js
@@ -48,7 +48,7 @@ export async function proxy(request) {
} else if (pathname.startsWith('/api/auth/')) {
const { allowed } = authRateLimiter.check(ip);
if (!allowed) return rateLimitedResponse();
- } else if (pathname.startsWith('/api/webhooks/')) {
+ } else if (pathname.startsWith('/api/webhooks/') || pathname === '/api/phone/webhook') {
const { allowed } = webhookRateLimiter.check(ip);
if (!allowed) return rateLimitedResponse();
} else if (pathname.startsWith('/api/')) {
diff --git a/supabase/migrations/20261026120000_phone_calls.sql b/supabase/migrations/20261026120000_phone_calls.sql
new file mode 100644
index 00000000..f1691585
--- /dev/null
+++ b/supabase/migrations/20261026120000_phone_calls.sql
@@ -0,0 +1,34 @@
+-- Phone calls from qrypt.chat to US/Canadian phones (Telnyx). Service role only:
+-- the API routes read and write these, never the browser.
+
+-- Each user's WebRTC line: a Telnyx telephony credential on the qrypt.chat credential connection.
+create table if not exists public.phone_lines (
+ user_id uuid primary key references public.users(id) on delete cascade,
+ credential_id text not null,
+ sip_username text not null,
+ created_at timestamptz not null default now()
+);
+
+create table if not exists public.phone_calls (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references public.users(id) on delete cascade,
+ to_number text not null,
+ status text not null default 'ringing' check (status in ('ringing', 'connecting', 'active', 'ended', 'failed')),
+ limit_seconds integer not null,
+ user_leg_id text,
+ pstn_leg_id text,
+ session_id text,
+ answered_at timestamptz,
+ ended_at timestamptz,
+ billed_seconds integer not null default 0,
+ hangup_cause text,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+create index if not exists phone_calls_user_created on public.phone_calls (user_id, created_at desc);
+create index if not exists phone_calls_session on public.phone_calls (session_id);
+
+alter table public.phone_lines enable row level security;
+alter table public.phone_calls enable row level security;
+-- No policies: only the service role (which bypasses RLS) touches these tables.