From a95079fd6a970bdccfd8ac1809d16cfaeb698381 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 12:51:43 +0000 Subject: [PATCH] feat: call ordinary phones from qrypt.chat, 10 free minutes a day (0.14.0) Anthony: "rebuild so it works like skype used to" (after Suparelay), and "let people make ten minutes of phone calls a day for free". /phone (Navbar "Phone"): a dialer for US and Canadian numbers on Telnyx. - The browser's line is a per-user Telnyx telephony credential on a credential connection with NO outbound voice profile: a WebRTC token cannot dial the phone network. POST /api/phone/call checks the number and the allowance, rings that line through a Call Control app, and on call.answered transfers it to the number with time_limit_secs = the allowance, so Telnyx itself ends the call. - call.answered / call.hangup on the phone leg (signed Ed25519 webhooks at /api/phone/webhook, raw 32-byte or PEM key) bill answer -> hang-up seconds. - 10 free minutes a day (UTC) for signed-in accounts, none for anonymous ones, operators (PHONE_OPERATOR_IDS) an hour a call. 911/N11, 900/976 and fictional 555-01XX numbers are refused; one live call per person. - Caller ID is forced to PHONE_CALLER_ID by the connection's ANI override. The outbound profile allows US/CA only with a $10/day spend cap. - The page says plainly that calls to phones are not end-to-end encrypted. Tables phone_lines and phone_calls (service role only), migration applied on dev2. Tested against the real phone network before building: browser rang, server transferred to NIST's time line (+1 303-499-7111), audio transcribed as "At the tone, 12 hours, 43 minutes, coordinated universal time", and Telnyx ended the call at the time limit (hangup_cause time_limit). Co-Authored-By: Claude Opus 5.5 --- bun.lock | 15 +- package.json | 3 +- src/app/api/phone/call/route.js | 26 ++ src/app/api/phone/hangup/route.js | 16 ++ src/app/api/phone/route.js | 23 ++ src/app/api/phone/token/route.js | 24 ++ src/app/api/phone/webhook/route.js | 35 +++ src/app/phone/page.jsx | 242 ++++++++++++++++++ src/lib/components/Navbar.jsx | 6 +- src/lib/phone/rules.js | 77 ++++++ src/lib/phone/rules.test.js | 42 +++ src/lib/server/phone-route.js | 29 +++ src/lib/server/phone.js | 159 ++++++++++++ src/lib/server/phone.test.js | 114 +++++++++ src/lib/server/telnyx-voice.js | 100 ++++++++ src/proxy.js | 2 +- .../migrations/20261026120000_phone_calls.sql | 34 +++ 17 files changed, 940 insertions(+), 7 deletions(-) create mode 100644 src/app/api/phone/call/route.js create mode 100644 src/app/api/phone/hangup/route.js create mode 100644 src/app/api/phone/route.js create mode 100644 src/app/api/phone/token/route.js create mode 100644 src/app/api/phone/webhook/route.js create mode 100644 src/app/phone/page.jsx create mode 100644 src/lib/phone/rules.js create mode 100644 src/lib/phone/rules.test.js create mode 100644 src/lib/server/phone-route.js create mode 100644 src/lib/server/phone.js create mode 100644 src/lib/server/phone.test.js create mode 100644 src/lib/server/telnyx-voice.js create mode 100644 supabase/migrations/20261026120000_phone_calls.sql 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 ( +
+
+ ); +} 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.