Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
"version": "0.13.4",
"version": "0.14.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions src/app/api/phone/call/route.js
Original file line number Diff line number Diff line change
@@ -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');
}
});
16 changes: 16 additions & 0 deletions src/app/api/phone/hangup/route.js
Original file line number Diff line number Diff line change
@@ -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');
}
});
23 changes: 23 additions & 0 deletions src/app/api/phone/route.js
Original file line number Diff line number Diff line change
@@ -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');
}
});
24 changes: 24 additions & 0 deletions src/app/api/phone/token/route.js
Original file line number Diff line number Diff line change
@@ -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');
}
});
35 changes: 35 additions & 0 deletions src/app/api/phone/webhook/route.js
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Loading
Loading