Bygg NeuroSemantics AI: minimal mobilapp, en backend, IaC och CI/CD

Ersätter den tidigare webappen på denna branch med ett fokuserat monorepo:

- apps/mobile: Expo/React Native med tre vyer (Welcome, Chat, Paywall),
  Cognito hosted UI-inloggning (Apple/Google/e-post) och In-App
  Purchase/Play Billing via en gemensam purchases-modul.
- services/api: en enda Lambda-backend — OpenAI Responses API med
  Markdown-kunskapsbas som systeminstruktioner, free tier-gräns i
  PostgreSQL (HTTP 402 -> paywall) och kvittoverifiering bakom ett
  delat PaymentProvider-interface (Apple/Google, Stripe kan läggas
  till för webb senare).
- infra: AWS CDK-stack med API Gateway (JWT-authorizer), Lambda,
  Cognito, Aurora Serverless v2 och Secrets Manager.
- db/migrations: minimal datamodell (users + usage), inga
  konversationer sparas.
- GitHub Actions: CI (lint, typecheck, test) och deploy från main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
This commit is contained in:
Claude
2026-08-03 14:35:48 +00:00
parent 508cb534b0
commit 07fbeea09b
199 changed files with 13626 additions and 18001 deletions
+62
View File
@@ -0,0 +1,62 @@
import { config } from './config.js';
import { getSecret } from './secrets.js';
import { loadKnowledgeBase } from './knowledge.js';
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
const BASE_INSTRUCTIONS = `You are NeuroSemantics AI — a calm, precise conversation partner
specialized in neurosemantics and NLP (Neuro-Linguistic Programming).
Principles:
- Be warm but restrained. Short paragraphs. No filler, no hype.
- Ground answers in the knowledge base below. If something is outside
neurosemantics/NLP, gently steer the conversation back.
- Ask one clarifying question at a time when the user's goal is unclear.
- You are not a therapist and do not diagnose. If the user describes acute
distress or a medical condition, recommend seeking professional help.
- Answer in the language the user writes in.`;
/**
* Calls the OpenAI Responses API with the knowledge base as system
* instructions and the conversation as input. Conversations are held by the
* client and passed through — nothing is persisted server-side.
*/
export async function generateReply(messages: ChatMessage[]): Promise<string> {
const appSecret = await getSecret(config.appSecretArn);
const apiKey = appSecret.OPENAI_API_KEY;
if (!apiKey) throw new Error('OPENAI_API_KEY missing from application secret');
const response = await fetch('https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: config.openAiModel,
instructions: `${BASE_INSTRUCTIONS}\n\n# Knowledge base\n\n${loadKnowledgeBase()}`,
input: messages.map((m) => ({ role: m.role, content: m.content })),
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenAI request failed (${response.status}): ${body}`);
}
const data = (await response.json()) as {
output?: { type: string; content?: { type: string; text?: string }[] }[];
};
const text = (data.output ?? [])
.filter((item) => item.type === 'message')
.flatMap((item) => item.content ?? [])
.filter((part) => part.type === 'output_text')
.map((part) => part.text ?? '')
.join('');
if (!text) throw new Error('OpenAI response contained no output text');
return text;
}
+18
View File
@@ -0,0 +1,18 @@
/**
* All runtime configuration in one place. Values come from Lambda environment
* variables (set by the CDK stack) — secrets never live here.
*/
export const config = {
/** Number of free messages before the paywall is shown (~510 conversations). */
freeMessageLimit: Number(process.env.FREE_MESSAGE_LIMIT ?? '50'),
/** Free-tier usage resets after this many days. */
usageResetDays: Number(process.env.USAGE_RESET_DAYS ?? '30'),
openAiModel: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
/** ARN of the application secret (OpenAI key, store credentials). */
appSecretArn: process.env.APP_SECRET_ARN ?? '',
/** ARN of the RDS-managed database credentials secret. */
dbSecretArn: process.env.DB_SECRET_ARN ?? '',
dbName: process.env.DB_NAME ?? 'neurosemantics',
/** Android application id, needed for Google Play purchase verification. */
androidPackageName: process.env.ANDROID_PACKAGE_NAME ?? 'com.neurosemantics.app',
};
+84
View File
@@ -0,0 +1,84 @@
import pg from 'pg';
import { config } from './config.js';
import { getSecret } from './secrets.js';
let pool: pg.Pool | undefined;
/** Lazily creates a single connection pool, reused across warm invocations. */
export async function getPool(): Promise<pg.Pool> {
if (pool) return pool;
const dbSecret = await getSecret(config.dbSecretArn);
pool = new pg.Pool({
host: dbSecret.host,
port: Number(dbSecret.port ?? '5432'),
user: dbSecret.username,
password: dbSecret.password,
database: config.dbName,
max: 2,
// Traffic stays inside the VPC; pin the RDS CA bundle before going to production.
ssl: { rejectUnauthorized: false },
});
return pool;
}
export interface UserRow {
id: string;
email: string;
provider: string;
subscription_status: 'free' | 'active';
created_at: Date;
}
export interface UsageRow {
user_id: string;
messages_used: number;
last_reset: Date;
}
/** Creates the user (and its usage row) on first contact; returns the user. */
export async function getOrCreateUser(
id: string,
email: string,
provider: string,
): Promise<UserRow> {
const db = await getPool();
const result = await db.query<UserRow>(
`insert into users (id, email, provider)
values ($1, $2, $3)
on conflict (id) do update set email = excluded.email
returning *`,
[id, email, provider],
);
const user = result.rows[0];
if (!user) throw new Error('User upsert returned no row');
await db.query(`insert into usage (user_id) values ($1) on conflict (user_id) do nothing`, [id]);
return user;
}
export async function getUsage(userId: string): Promise<UsageRow> {
const db = await getPool();
const result = await db.query<UsageRow>(`select * from usage where user_id = $1`, [userId]);
const usage = result.rows[0];
if (!usage) throw new Error(`No usage row for user ${userId}`);
return usage;
}
export async function resetUsage(userId: string): Promise<void> {
const db = await getPool();
await db.query(`update usage set messages_used = 0, last_reset = now() where user_id = $1`, [
userId,
]);
}
export async function incrementUsage(userId: string): Promise<void> {
const db = await getPool();
await db.query(`update usage set messages_used = messages_used + 1 where user_id = $1`, [userId]);
}
export async function setSubscriptionStatus(
userId: string,
status: 'free' | 'active',
): Promise<void> {
const db = await getPool();
await db.query(`update users set subscription_status = $2 where id = $1`, [userId, status]);
}
+116
View File
@@ -0,0 +1,116 @@
import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyResultV2 } from 'aws-lambda';
import { generateReply, type ChatMessage } from './chat.js';
import { config } from './config.js';
import { 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';
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;
}
function identityFromClaims(event: APIGatewayProxyEventV2WithJWTAuthorizer): Identity {
const claims = event.requestContext.authorizer.jwt.claims;
const sub = String(claims.sub ?? '');
if (!sub) throw new Error('JWT is missing a sub claim');
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_status,
messagesUsed: usage.messages_used,
freeMessageLimit: config.freeMessageLimit,
});
}
async function handleChat(
user: UserRow,
body: string | undefined,
): Promise<APIGatewayProxyResultV2> {
const parsed = body ? (JSON.parse(body) as { messages?: ChatMessage[] }) : {};
const messages = parsed.messages ?? [];
const last = messages[messages.length - 1];
if (!last || last.role !== 'user' || typeof last.content !== 'string' || !last.content.trim()) {
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_status, config.freeMessageLimit)) {
return json(402, { error: 'free_limit_reached' });
}
const reply = await generateReply(messages);
await incrementUsage(user.id);
return json(200, { reply });
}
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 });
}
export async function handler(
event: APIGatewayProxyEventV2WithJWTAuthorizer,
): Promise<APIGatewayProxyResultV2> {
try {
const identity = identityFromClaims(event);
const user = await getOrCreateUser(identity.sub, identity.email, identity.provider);
const route = `${event.requestContext.http.method} ${event.rawPath}`;
switch (route) {
case 'GET /me':
return await handleMe(user);
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' });
}
}
+19
View File
@@ -0,0 +1,19 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
let cached: string | undefined;
/**
* The knowledge base is a set of Markdown files bundled with the Lambda.
* They are concatenated and injected as system instructions. RAG (selective
* retrieval) can replace this later without changing the API surface.
*/
export function loadKnowledgeBase(): string {
if (cached !== undefined) return cached;
const dir = process.env.KNOWLEDGE_DIR ?? join(import.meta.dirname, '..', 'knowledge');
const files = readdirSync(dir)
.filter((f) => f.endsWith('.md'))
.sort();
cached = files.map((f) => readFileSync(join(dir, f), 'utf-8')).join('\n\n---\n\n');
return cached;
}
+20
View File
@@ -0,0 +1,20 @@
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
const client = new SecretsManagerClient({});
const cache = new Map<string, Record<string, string>>();
/**
* Fetches a JSON secret from AWS Secrets Manager and caches it for the
* lifetime of the Lambda container.
*/
export async function getSecret(arn: string): Promise<Record<string, string>> {
const cached = cache.get(arn);
if (cached) return cached;
const result = await client.send(new GetSecretValueCommand({ SecretId: arn }));
if (!result.SecretString) {
throw new Error(`Secret ${arn} has no string value`);
}
const parsed = JSON.parse(result.SecretString) as Record<string, string>;
cache.set(arn, parsed);
return parsed;
}
+49
View File
@@ -0,0 +1,49 @@
import { config } from '../config.js';
import { getSecret } from '../secrets.js';
import type { PaymentProvider, VerificationResult, VerifyPurchaseRequest } from './types.js';
const PRODUCTION_URL = 'https://buy.itunes.apple.com/verifyReceipt';
const SANDBOX_URL = 'https://sandbox.itunes.apple.com/verifyReceipt';
/** Apple status code meaning "this is a sandbox receipt, retry against sandbox". */
const STATUS_SANDBOX_RECEIPT = 21007;
interface AppleResponse {
status: number;
latest_receipt_info?: { product_id: string; expires_date_ms: string }[];
}
async function verifyReceipt(url: string, receipt: string, secret: string): Promise<AppleResponse> {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
'receipt-data': receipt,
password: secret,
'exclude-old-transactions': true,
}),
});
if (!response.ok) throw new Error(`Apple receipt verification failed (${response.status})`);
return (await response.json()) as AppleResponse;
}
export const appleProvider: PaymentProvider = {
async verify(request: VerifyPurchaseRequest): Promise<VerificationResult> {
const appSecret = await getSecret(config.appSecretArn);
const sharedSecret = appSecret.APPLE_SHARED_SECRET;
if (!sharedSecret) throw new Error('APPLE_SHARED_SECRET missing from application secret');
let data = await verifyReceipt(PRODUCTION_URL, request.receipt, sharedSecret);
if (data.status === STATUS_SANDBOX_RECEIPT) {
data = await verifyReceipt(SANDBOX_URL, request.receipt, sharedSecret);
}
if (data.status !== 0) return { active: false };
const latest = (data.latest_receipt_info ?? [])
.filter((info) => info.product_id === request.productId)
.map((info) => Number(info.expires_date_ms))
.sort((a, b) => b - a)[0];
if (!latest) return { active: false };
return { active: latest > Date.now(), expiresAt: new Date(latest) };
},
};
+66
View File
@@ -0,0 +1,66 @@
import { createSign } from 'node:crypto';
import { config } from '../config.js';
import { getSecret } from '../secrets.js';
import type { PaymentProvider, VerificationResult, VerifyPurchaseRequest } from './types.js';
interface ServiceAccount {
client_email: string;
private_key: string;
}
function base64url(input: string | Buffer): string {
return Buffer.from(input).toString('base64url');
}
/** Mints a Google OAuth access token from a service account key (RS256 JWT). */
async function getAccessToken(account: ServiceAccount): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const claims = base64url(
JSON.stringify({
iss: account.client_email,
scope: 'https://www.googleapis.com/auth/androidpublisher',
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600,
}),
);
const signer = createSign('RSA-SHA256');
signer.update(`${header}.${claims}`);
const signature = signer.sign(account.private_key).toString('base64url');
const assertion = `${header}.${claims}.${signature}`;
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}),
});
if (!response.ok) throw new Error(`Google token exchange failed (${response.status})`);
const data = (await response.json()) as { access_token: string };
return data.access_token;
}
export const googleProvider: PaymentProvider = {
async verify(request: VerifyPurchaseRequest): Promise<VerificationResult> {
const appSecret = await getSecret(config.appSecretArn);
const raw = appSecret.GOOGLE_SERVICE_ACCOUNT_JSON;
if (!raw) throw new Error('GOOGLE_SERVICE_ACCOUNT_JSON missing from application secret');
const account = JSON.parse(raw) as ServiceAccount;
const token = await getAccessToken(account);
const url =
`https://androidpublisher.googleapis.com/androidpublisher/v3/applications/` +
`${config.androidPackageName}/purchases/subscriptions/` +
`${encodeURIComponent(request.productId)}/tokens/${encodeURIComponent(request.receipt)}`;
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (response.status === 404 || response.status === 410) return { active: false };
if (!response.ok) throw new Error(`Google purchase lookup failed (${response.status})`);
const data = (await response.json()) as { expiryTimeMillis?: string };
const expiry = Number(data.expiryTimeMillis ?? 0);
return { active: expiry > Date.now(), expiresAt: expiry ? new Date(expiry) : undefined };
},
};
+24
View File
@@ -0,0 +1,24 @@
import { setSubscriptionStatus } from '../db.js';
import { appleProvider } from './apple.js';
import { googleProvider } from './google.js';
import type { PaymentProvider, VerifyPurchaseRequest } from './types.js';
const providers: Record<VerifyPurchaseRequest['platform'], PaymentProvider> = {
ios: appleProvider,
android: googleProvider,
};
/**
* Verifies a purchase with the platform's store and updates the user's
* subscription status. Returns the resulting status.
*/
export async function verifyAndApplyPurchase(
userId: string,
request: VerifyPurchaseRequest,
): Promise<'free' | 'active'> {
const provider = providers[request.platform];
const result = await provider.verify(request);
const status = result.active ? 'active' : 'free';
await setSubscriptionStatus(userId, status);
return status;
}
+22
View File
@@ -0,0 +1,22 @@
export type Platform = 'ios' | 'android';
export interface VerifyPurchaseRequest {
platform: Platform;
productId: string;
/** iOS: base64 app receipt. Android: the purchase token from Play Billing. */
receipt: string;
}
export interface VerificationResult {
active: boolean;
expiresAt?: Date;
}
/**
* Shared subscription logic with per-platform payment providers.
* A Stripe adapter can be added here for a future web version without
* touching the rest of the backend.
*/
export interface PaymentProvider {
verify(request: VerifyPurchaseRequest): Promise<VerificationResult>;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Pure free-tier gating logic, separated from I/O so it can be unit tested.
*/
export function shouldResetUsage(lastReset: Date, now: Date, resetDays: number): boolean {
const ms = resetDays * 24 * 60 * 60 * 1000;
return now.getTime() - lastReset.getTime() >= ms;
}
export function canSendMessage(
messagesUsed: number,
subscriptionStatus: 'free' | 'active',
freeLimit: number,
): boolean {
if (subscriptionStatus === 'active') return true;
return messagesUsed < freeLimit;
}