Files
alva/services/api/src/handler.ts
T
Claude e5b81f46b0 Store readiness: in-app account deletion, privacy policy, terms, listing copy
Closes the remaining store-compliance gaps before App Store / Play
submission:

- Account deletion (App Store guideline 5.1.1): DELETE /me removes the
  user row (usage cascades). The app exposes it through one quiet
  'Account' caption link under the chat, visible only when signed in,
  driving two native dialogs (Sign out / Delete account with a
  destructive confirm) — no new views, no menus, minimalism intact.
  Deletion signs out and resets the app; copy notes that store
  subscriptions are cancelled in App Store / Play settings.
- docs/store/privacy-policy.md: the complete data inventory (matching
  the actual schema), transient OpenAI processing with no training, no
  profiling or ads, GDPR legal bases and rights, in-app erasure.
- docs/store/terms-of-service.md: not-therapy positioning with crisis
  guidance, AI-generated-content caveat, 18+ eligibility,
  auto-renewal/cancellation terms, liability, Swedish governing law.
- docs/store/listing.md: App Store and Play copy written to the
  honest-claims rule (subtitle 'Think Beyond Thought', keywords,
  descriptions), plus App Privacy and Data safety questionnaire
  mappings.
- LAUNCH.md updated: host the policy/terms, set store URLs, use the
  prepared listing copy.
- Tests: 30 passing (adds DELETE /me coverage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
2026-08-03 22:37:25 +00:00

171 lines
6.1 KiB
TypeScript

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<string, unknown> } };
}
).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<APIGatewayProxyResultV2> {
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<APIGatewayProxyResultV2> {
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<APIGatewayProxyResultV2> {
const parsed = body ? (JSON.parse(body) as Partial<VerifyPurchaseRequest>) : {};
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<APIGatewayProxyResultV2> {
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<APIGatewayProxyResultV2> {
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' });
}
}