import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda'; import { generateReply, type ChatMessage } from './chat.js'; import { config } from './config.js'; import { deleteUser, getOrCreateUser, getUsage, incrementUsage, resetUsage, type UserRow, } from './db.js'; import { verifyAndApplyPurchase } from './subscription/index.js'; import type { VerifyPurchaseRequest } from './subscription/types.js'; import { canSendMessage, shouldResetUsage } from './usage.js'; import suggestionsFile from '../suggestions.json'; function json(statusCode: number, body: unknown): APIGatewayProxyResultV2 { return { statusCode, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }; } interface Identity { sub: string; email: string; provider: string; } /** Reads the Cognito JWT claims on authorized routes; null on public routes. */ function identityFromEvent(event: APIGatewayProxyEventV2): Identity | null { const authorizer = ( event.requestContext as { authorizer?: { jwt?: { claims?: Record } }; } ).authorizer; const claims = authorizer?.jwt?.claims; if (!claims) return null; const sub = String(claims.sub ?? ''); if (!sub) return null; const email = String(claims.email ?? ''); // Cognito sets an `identities` claim for federated sign-ins (Apple/Google). const identities = String(claims.identities ?? ''); const provider = identities.includes('SignInWithApple') ? 'apple' : identities.includes('Google') ? 'google' : 'email'; return { sub, email, provider }; } async function currentUsage(user: UserRow) { let usage = await getUsage(user.id); if (shouldResetUsage(usage.last_reset, new Date(), config.usageResetDays)) { await resetUsage(user.id); usage = await getUsage(user.id); } return usage; } async function handleMe(user: UserRow): Promise { const usage = await currentUsage(user); return json(200, { subscriptionStatus: user.subscription, messagesUsed: usage.messages_used, messageCap: config.messageCap, }); } /** * The paywall is intelligent: no hardcoded message count. In free mode the * model works in discovery (follow-up questions, patterns, understanding) * and signals `analysisReady` when a concrete, valuable answer could be * delivered — that is the paywall moment, returned as `paywall: true`. * Premium mode delivers the full analysis, including immediately after * unlock (the transcript then ends with the assistant's transition message). */ async function handleChat( user: UserRow, body: string | undefined, ): Promise { const premium = user.subscription === 'active'; const parsed = body ? (JSON.parse(body) as { messages?: ChatMessage[] }) : {}; const messages = parsed.messages ?? []; const last = messages[messages.length - 1]; const validLast = last && typeof last.content === 'string' && last.content.trim() && (last.role === 'user' || (premium && last.role === 'assistant')); if (!validLast) { return json(400, { error: 'messages must end with a non-empty user message' }); } const usage = await currentUsage(user); if (!canSendMessage(usage.messages_used, user.subscription, config.messageCap)) { return json(402, { error: 'message_cap_reached' }); } const result = await generateReply(messages, premium ? 'premium' : 'free'); await incrementUsage(user.id); return json(200, { reply: result.reply, paywall: result.analysisReady }); } async function handleVerifyPurchase( user: UserRow, body: string | undefined, ): Promise { const parsed = body ? (JSON.parse(body) as Partial) : {}; if ( (parsed.platform !== 'ios' && parsed.platform !== 'android') || typeof parsed.productId !== 'string' || typeof parsed.receipt !== 'string' ) { return json(400, { error: 'platform, productId and receipt are required' }); } const status = await verifyAndApplyPurchase(user.id, parsed as VerifyPurchaseRequest); return json(200, { subscriptionStatus: status }); } /** Guests chat before signing in, identified by an app-generated device id. */ async function handleGuestChat(event: APIGatewayProxyEventV2): Promise { const deviceId = event.headers?.['x-device-id'] ?? ''; if (!/^[0-9a-fA-F-]{8,64}$/.test(deviceId)) { return json(400, { error: 'a valid X-Device-Id header is required' }); } const user = await getOrCreateUser(`guest:${deviceId}`, '', 'guest'); return handleChat(user, event.body); } export async function handler(event: APIGatewayProxyEventV2): Promise { try { const route = `${event.requestContext.http.method} ${event.rawPath}`; // Public routes (no sign-in before the first question). if (route === 'GET /suggestions') { return json(200, { suggestions: suggestionsFile.suggestions }); } if (route === 'POST /guest/chat') { return await handleGuestChat(event); } // Authorized routes (Cognito JWT, enforced by API Gateway). const identity = identityFromEvent(event); if (!identity) return json(401, { error: 'unauthorized' }); const user = await getOrCreateUser(identity.sub, identity.email, identity.provider); switch (route) { case 'GET /me': return await handleMe(user); case 'DELETE /me': // In-app account deletion (App Store guideline 5.1.1). Removes the // user and usage rows; store subscriptions are cancelled by the user // in App Store / Play settings. await deleteUser(user.id); return json(200, { deleted: true }); case 'POST /chat': return await handleChat(user, event.body); case 'POST /subscription/verify': return await handleVerifyPurchase(user, event.body); default: return json(404, { error: 'not_found' }); } } catch (error) { // Log without request payloads: no conversation content ends up in logs. console.error('request_failed', error instanceof Error ? error.message : 'unknown'); return json(500, { error: 'internal_error' }); } }