From c1c632e0029e8a43befc9edfe9f2dcb01bd3f68f Mon Sep 17 00:00:00 2001 From: Kirill Dubovitskiy Date: Mon, 8 Sep 2025 04:04:48 -0700 Subject: [PATCH] feat: add voice token endpoint with RevenueCat paywall - Add POST /v1/voice/token endpoint for 11Labs voice authentication - Integrate RevenueCat subscription validation (pro entitlement required) - Skip paywall in development (NODE_ENV=development) - Fetch 11Labs conversation tokens for authorized users - Return allowed:false for users without subscription - Add NODE_ENV=development to .env.dev for local testing Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .env.dev | 6 ++ sources/app/api/api.ts | 2 + sources/app/api/routes/voiceRoutes.ts | 113 ++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 sources/app/api/routes/voiceRoutes.ts diff --git a/.env.dev b/.env.dev index 61088d5..b8021fc 100644 --- a/.env.dev +++ b/.env.dev @@ -18,3 +18,9 @@ S3_ACCESS_KEY=minioadmin S3_SECRET_KEY=minioadmin S3_BUCKET=happy S3_PUBLIC_URL=http://localhost:9000/happy + +# --- Voice Feature --- +# 11Labs API for voice conversations (required for voice feature) +# Secret - congiured in .env, not checked in + +NODE_ENV=development diff --git a/sources/app/api/api.ts b/sources/app/api/api.ts index 0ff46a9..8ffcd16 100644 --- a/sources/app/api/api.ts +++ b/sources/app/api/api.ts @@ -13,6 +13,7 @@ import { startSocket } from "./socket"; import { machinesRoutes } from "./routes/machinesRoutes"; import { devRoutes } from "./routes/devRoutes"; import { versionRoutes } from "./routes/versionRoutes"; +import { voiceRoutes } from "./routes/voiceRoutes"; import { enableMonitoring } from "./utils/enableMonitoring"; import { enableErrorHandlers } from "./utils/enableErrorHandlers"; import { enableAuthentication } from "./utils/enableAuthentication"; @@ -55,6 +56,7 @@ export async function startApi(eventRouter: EventRouter) { machinesRoutes(typed, eventRouter); devRoutes(typed); versionRoutes(typed); + voiceRoutes(typed); // Start HTTP const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005; diff --git a/sources/app/api/routes/voiceRoutes.ts b/sources/app/api/routes/voiceRoutes.ts new file mode 100644 index 0000000..ddc2842 --- /dev/null +++ b/sources/app/api/routes/voiceRoutes.ts @@ -0,0 +1,113 @@ +import { z } from "zod"; +import { type Fastify } from "../types"; +import { log } from "@/utils/log"; + +export function voiceRoutes(app: Fastify) { + app.post('/v1/voice/token', { + preHandler: app.authenticate, + schema: { + body: z.object({ + agentId: z.string(), + revenueCatPublicKey: z.string().optional() + }), + response: { + 200: z.object({ + allowed: z.boolean(), + token: z.string().optional(), + agentId: z.string().optional() + }), + 400: z.object({ + allowed: z.boolean(), + error: z.string() + }) + } + } + }, async (request, reply) => { + const userId = request.userId; // CUID from JWT + const { agentId, revenueCatPublicKey } = request.body; + + log({ module: 'voice' }, `Voice token request from user ${userId}`); + + const isDevelopment = process.env.NODE_ENV === 'development' || process.env.ENV === 'dev'; + + // Production requires RevenueCat key + if (!isDevelopment && !revenueCatPublicKey) { + log({ module: 'voice' }, 'Production environment requires RevenueCat public key'); + return reply.code(400).send({ + allowed: false, + error: 'RevenueCat public key required' + }); + } + + // Check subscription in production + if (!isDevelopment && revenueCatPublicKey) { + const response = await fetch( + `https://api.revenuecat.com/v1/subscribers/${userId}`, + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${revenueCatPublicKey}`, + 'Content-Type': 'application/json' + } + } + ); + + if (!response.ok) { + log({ module: 'voice' }, `RevenueCat check failed for user ${userId}: ${response.status}`); + return reply.send({ + allowed: false, + agentId + }); + } + + const data = await response.json() as any; + const proEntitlement = data.subscriber?.entitlements?.active?.pro; + + if (!proEntitlement) { + log({ module: 'voice' }, `User ${userId} does not have active subscription`); + return reply.send({ + allowed: false, + agentId + }); + } + } + + // Check if 11Labs API key is configured + const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY; + if (!elevenLabsApiKey) { + log({ module: 'voice' }, 'Missing 11Labs API key'); + return reply.code(400).send({ allowed: false, error: 'Missing 11Labs API key on the server' }); + } + + // Get 11Labs conversation token + const response = await fetch( + `https://api.elevenlabs.io/v1/convai/conversation/token?agent_id=${agentId}`, + { + method: 'GET', + headers: { + 'xi-api-key': elevenLabsApiKey, + 'Accept': 'application/json' + } + } + ); + + if (!response.ok) { + log({ module: 'voice' }, `Failed to get 11Labs token for user ${userId}`); + return reply.code(400).send({ + allowed: false, + error: `Failed to get 11Labs token for user ${userId}` + }); + } + + const data = await response.json() as any; + console.log(data); + const token = data.token; + + log({ module: 'voice' }, `Voice token issued for user ${userId}`); + return reply.send({ + allowed: true, + token, + agentId + }); + }); +} \ No newline at end of file