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
+67
View File
@@ -0,0 +1,67 @@
import { appConfig } from '../config';
import { getAccessToken } from '../auth/session';
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
) {
super(`API error ${status}: ${code}`);
}
}
// Mirrors the backend contract in services/api/src/handler.ts.
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
export interface Me {
subscriptionStatus: 'free' | 'active';
messagesUsed: number;
freeMessageLimit: number;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = await getAccessToken();
if (!token) throw new ApiError(401, 'not_signed_in');
const response = await fetch(`${appConfig.apiUrl}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
...init?.headers,
},
});
if (!response.ok) {
let code = 'unknown';
try {
code = ((await response.json()) as { error?: string }).error ?? 'unknown';
} catch {
// keep 'unknown'
}
throw new ApiError(response.status, code);
}
return (await response.json()) as T;
}
export function fetchMe(): Promise<Me> {
return request<Me>('/me');
}
export function sendChat(messages: ChatMessage[]): Promise<{ reply: string }> {
return request<{ reply: string }>('/chat', {
method: 'POST',
body: JSON.stringify({ messages }),
});
}
export function verifyPurchase(body: {
platform: 'ios' | 'android';
productId: string;
receipt: string;
}): Promise<{ subscriptionStatus: 'free' | 'active' }> {
return request('/subscription/verify', { method: 'POST', body: JSON.stringify(body) });
}
+106
View File
@@ -0,0 +1,106 @@
import {
AuthRequest,
exchangeCodeAsync,
makeRedirectUri,
refreshAsync,
type DiscoveryDocument,
} from 'expo-auth-session';
import * as SecureStore from 'expo-secure-store';
import * as WebBrowser from 'expo-web-browser';
import { appConfig } from '../config';
WebBrowser.maybeCompleteAuthSession();
export type Provider = 'apple' | 'google' | 'email';
interface StoredSession {
accessToken: string;
refreshToken?: string;
expiresAt: number; // epoch ms
}
const STORE_KEY = 'neurosemantics.session';
const discovery: DiscoveryDocument = {
authorizationEndpoint: `${appConfig.cognitoDomain}/oauth2/authorize`,
tokenEndpoint: `${appConfig.cognitoDomain}/oauth2/token`,
revocationEndpoint: `${appConfig.cognitoDomain}/oauth2/revoke`,
};
const redirectUri = makeRedirectUri({ scheme: 'neurosemantics', path: 'redirect' });
/** Maps our provider names to Cognito hosted UI identity providers. */
const IDP: Record<Provider, string | undefined> = {
apple: 'SignInWithApple',
google: 'Google',
email: undefined, // Hosted UI's own email/password form
};
async function persist(session: StoredSession): Promise<void> {
await SecureStore.setItemAsync(STORE_KEY, JSON.stringify(session));
}
/** Signs in via the Cognito hosted UI with PKCE. Returns the session. */
export async function signIn(provider: Provider): Promise<StoredSession> {
const request = new AuthRequest({
clientId: appConfig.cognitoClientId,
redirectUri,
scopes: ['openid', 'email'],
usePKCE: true,
extraParams: IDP[provider] ? { identity_provider: IDP[provider] as string } : {},
});
const result = await request.promptAsync(discovery);
if (result.type !== 'success' || !result.params.code) {
throw new Error('Sign-in was cancelled');
}
const tokens = await exchangeCodeAsync(
{
clientId: appConfig.cognitoClientId,
code: result.params.code,
redirectUri,
extraParams: { code_verifier: request.codeVerifier ?? '' },
},
discovery,
);
const session: StoredSession = {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: Date.now() + (tokens.expiresIn ?? 3600) * 1000,
};
await persist(session);
return session;
}
/** Returns a valid access token, refreshing if needed, or null if signed out. */
export async function getAccessToken(): Promise<string | null> {
const raw = await SecureStore.getItemAsync(STORE_KEY);
if (!raw) return null;
const session = JSON.parse(raw) as StoredSession;
if (Date.now() < session.expiresAt - 60_000) return session.accessToken;
if (!session.refreshToken) return null;
try {
const tokens = await refreshAsync(
{ clientId: appConfig.cognitoClientId, refreshToken: session.refreshToken },
discovery,
);
const refreshed: StoredSession = {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? session.refreshToken,
expiresAt: Date.now() + (tokens.expiresIn ?? 3600) * 1000,
};
await persist(refreshed);
return refreshed.accessToken;
} catch {
await signOut();
return null;
}
}
export async function signOut(): Promise<void> {
await SecureStore.deleteItemAsync(STORE_KEY);
}
+19
View File
@@ -0,0 +1,19 @@
import Constants from 'expo-constants';
const extra = (Constants.expoConfig?.extra ?? {}) as Record<string, string>;
function required(key: string): string {
const value = extra[key];
if (!value) throw new Error(`Missing "${key}" in app.json extra`);
return value;
}
export const appConfig = {
apiUrl: required('apiUrl'),
cognitoDomain: required('cognitoDomain'),
cognitoClientId: required('cognitoClientId'),
iosMonthlyProductId: required('iosMonthlyProductId'),
iosYearlyProductId: required('iosYearlyProductId'),
androidMonthlyProductId: required('androidMonthlyProductId'),
androidYearlyProductId: required('androidYearlyProductId'),
};
+67
View File
@@ -0,0 +1,67 @@
import { Platform } from 'react-native';
import {
endConnection,
getAvailablePurchases,
initConnection,
requestSubscription,
type SubscriptionPurchase,
} from 'react-native-iap';
import { verifyPurchase } from '../api/client';
import { appConfig } from '../config';
export type Plan = 'monthly' | 'yearly';
const platform: 'ios' | 'android' = Platform.OS === 'ios' ? 'ios' : 'android';
export function productIdFor(plan: Plan): string {
if (platform === 'ios') {
return plan === 'monthly' ? appConfig.iosMonthlyProductId : appConfig.iosYearlyProductId;
}
return plan === 'monthly' ? appConfig.androidMonthlyProductId : appConfig.androidYearlyProductId;
}
function receiptOf(purchase: SubscriptionPurchase): string {
// iOS: base64 app receipt. Android: Play Billing purchase token.
return platform === 'ios' ? purchase.transactionReceipt : (purchase.purchaseToken ?? '');
}
async function withConnection<T>(fn: () => Promise<T>): Promise<T> {
await initConnection();
try {
return await fn();
} finally {
await endConnection();
}
}
/**
* Runs the native purchase flow, then lets the backend verify the receipt
* with the store. Returns the resulting subscription status.
*/
export async function purchase(plan: Plan): Promise<'free' | 'active'> {
const productId = productIdFor(plan);
return withConnection(async () => {
await requestSubscription({ sku: productId });
const purchases = await getAvailablePurchases();
const match = purchases.find((p) => p.productId === productId);
if (!match) throw new Error('Purchase not found after transaction');
const result = await verifyPurchase({ platform, productId, receipt: receiptOf(match) });
return result.subscriptionStatus;
});
}
/** Re-checks existing purchases with the store ("Restore Purchase"). */
export async function restore(): Promise<'free' | 'active'> {
return withConnection(async () => {
const purchases = await getAvailablePurchases();
for (const p of purchases) {
const result = await verifyPurchase({
platform,
productId: p.productId,
receipt: receiptOf(p),
});
if (result.subscriptionStatus === 'active') return 'active';
}
return 'free';
});
}
+147
View File
@@ -0,0 +1,147 @@
import { useRef, useState } from 'react';
import {
FlatList,
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { ApiError, sendChat, type ChatMessage } from '../api/client';
import { colors, spacing, type } from '../theme';
interface Props {
onLimitReached: () => void;
}
/**
* The whole product: one heading, the conversation, a text field and send.
* Conversations live in memory only — nothing is stored.
*/
export function ChatScreen({ onLimitReached }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const listRef = useRef<FlatList<ChatMessage>>(null);
async function send() {
const content = draft.trim();
if (!content || busy) return;
const next: ChatMessage[] = [...messages, { role: 'user', content }];
setMessages(next);
setDraft('');
setBusy(true);
setError(null);
try {
const { reply } = await sendChat(next);
setMessages([...next, { role: 'assistant', content: reply }]);
} catch (e) {
if (e instanceof ApiError && e.status === 402) {
onLimitReached();
} else {
setError('Something went wrong. Please try again.');
setMessages(messages);
setDraft(content);
}
} finally {
setBusy(false);
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Text style={styles.heading}>NeuroSemantics AI</Text>
<FlatList
ref={listRef}
style={styles.list}
contentContainerStyle={styles.listContent}
data={messages}
keyExtractor={(_, index) => String(index)}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
ListEmptyComponent={<Text style={styles.empty}>What would you like to explore today?</Text>}
renderItem={({ item }) => (
<View style={[styles.message, item.role === 'user' ? styles.user : styles.assistant]}>
<Text style={item.role === 'user' ? styles.userText : styles.assistantText}>
{item.content}
</Text>
</View>
)}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.inputRow}>
<TextInput
style={styles.input}
value={draft}
onChangeText={setDraft}
placeholder="Write a message"
placeholderTextColor={colors.textMuted}
multiline
editable={!busy}
/>
<Pressable
style={[styles.send, (!draft.trim() || busy) && styles.sendDisabled]}
onPress={send}
disabled={!draft.trim() || busy}
>
<Text style={styles.sendText}>{busy ? '…' : 'Send'}</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
heading: {
...type.heading,
textAlign: 'center',
paddingTop: spacing.xxl,
paddingBottom: spacing.m,
},
list: { flex: 1 },
listContent: { paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.s },
empty: { ...type.body, color: colors.textMuted, textAlign: 'center', marginTop: spacing.xxl },
message: { maxWidth: '85%', borderRadius: 12, padding: spacing.m },
user: { alignSelf: 'flex-end', backgroundColor: colors.accent },
assistant: {
alignSelf: 'flex-start',
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
},
userText: { ...type.body, color: colors.accentText },
assistantText: { ...type.body },
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center', marginBottom: spacing.s },
inputRow: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: spacing.s,
paddingHorizontal: spacing.l,
paddingBottom: spacing.xl,
},
input: {
flex: 1,
...type.body,
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: spacing.m,
paddingVertical: spacing.s + 2,
maxHeight: 120,
},
send: {
backgroundColor: colors.accent,
borderRadius: 12,
paddingHorizontal: spacing.l,
paddingVertical: spacing.m - 2,
},
sendDisabled: { opacity: 0.4 },
sendText: { ...type.heading, color: colors.accentText },
});
+83
View File
@@ -0,0 +1,83 @@
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { purchase, restore, type Plan } from '../purchases';
import { colors, spacing, type } from '../theme';
interface Props {
onSubscribed: () => void;
}
export function PaywallScreen({ onSubscribed }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run(action: () => Promise<'free' | 'active'>) {
setBusy(true);
setError(null);
try {
const status = await action();
if (status === 'active') {
onSubscribed();
} else {
setError('No active subscription was found.');
}
} catch {
setError('The purchase did not complete. Please try again.');
} finally {
setBusy(false);
}
}
const buy = (plan: Plan) => run(() => purchase(plan));
return (
<View style={styles.container}>
<View style={styles.top}>
<Text style={styles.title}>Continue your journey</Text>
<Text style={styles.subtitle}>
You have used your free conversations. Subscribe to continue.
</Text>
</View>
<View style={styles.actions}>
<Pressable style={styles.primaryButton} onPress={() => buy('monthly')} disabled={busy}>
<Text style={styles.primaryButtonText}>Monthly</Text>
</Pressable>
<Pressable style={styles.primaryButton} onPress={() => buy('yearly')} disabled={busy}>
<Text style={styles.primaryButtonText}>Yearly</Text>
</Pressable>
<Pressable style={styles.restore} onPress={() => run(restore)} disabled={busy}>
<Text style={styles.restoreText}>Restore Purchase</Text>
</Pressable>
{error ? <Text style={styles.error}>{error}</Text> : null}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
paddingHorizontal: spacing.l,
justifyContent: 'space-between',
},
top: { marginTop: spacing.xxl * 2 },
title: { ...type.title, textAlign: 'center' },
subtitle: {
...type.body,
color: colors.textMuted,
textAlign: 'center',
marginTop: spacing.m,
},
actions: { marginBottom: spacing.xxl, gap: spacing.s },
primaryButton: {
backgroundColor: colors.accent,
paddingVertical: spacing.m,
borderRadius: 10,
alignItems: 'center',
},
primaryButtonText: { ...type.heading, color: colors.accentText },
restore: { alignItems: 'center', paddingVertical: spacing.m },
restoreText: { ...type.body, color: colors.accent },
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center' },
});
+98
View File
@@ -0,0 +1,98 @@
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { signIn, type Provider } from '../auth/session';
import { colors, spacing, type } from '../theme';
interface Props {
onSignedIn: () => void;
}
export function WelcomeScreen({ onSignedIn }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSignIn(provider: Provider) {
setBusy(true);
setError(null);
try {
await signIn(provider);
onSignedIn();
} catch {
setError('Sign-in did not complete. Please try again.');
} finally {
setBusy(false);
}
}
return (
<View style={styles.container}>
<View style={styles.top}>
<Text style={styles.title}>NeuroSemantics AI</Text>
<Text style={styles.subtitle}>
A quiet conversation partner for neurosemantics and NLP.
</Text>
</View>
<View style={styles.actions}>
<Pressable
style={styles.primaryButton}
onPress={() => handleSignIn('apple')}
disabled={busy}
>
<Text style={styles.primaryButtonText}>Continue with Apple</Text>
</Pressable>
<Pressable
style={styles.secondaryButton}
onPress={() => handleSignIn('google')}
disabled={busy}
>
<Text style={styles.secondaryButtonText}>Continue with Google</Text>
</Pressable>
<Pressable
style={styles.secondaryButton}
onPress={() => handleSignIn('email')}
disabled={busy}
>
<Text style={styles.secondaryButtonText}>Continue with Email</Text>
</Pressable>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Text style={styles.footnote}>Start free. No card required.</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
paddingHorizontal: spacing.l,
justifyContent: 'space-between',
},
top: { marginTop: spacing.xxl * 2 },
title: { ...type.title, textAlign: 'center' },
subtitle: {
...type.body,
color: colors.textMuted,
textAlign: 'center',
marginTop: spacing.m,
},
actions: { marginBottom: spacing.xxl, gap: spacing.s },
primaryButton: {
backgroundColor: colors.accent,
paddingVertical: spacing.m,
borderRadius: 10,
alignItems: 'center',
},
primaryButtonText: { ...type.heading, color: colors.accentText },
secondaryButton: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
paddingVertical: spacing.m,
borderRadius: 10,
alignItems: 'center',
},
secondaryButtonText: { ...type.heading },
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center', marginTop: spacing.s },
footnote: { ...type.caption, textAlign: 'center', marginTop: spacing.m },
});
+29
View File
@@ -0,0 +1,29 @@
/**
* The entire visual language. Off-white, near-black, one dark blue-green
* accent. Generous whitespace. No animations, no gradients.
*/
export const colors = {
background: '#F7F6F2',
text: '#16181A',
textMuted: '#6E7472',
accent: '#14554C',
accentText: '#F7F6F2',
border: '#E4E2DB',
surface: '#FFFFFF',
} as const;
export const spacing = {
xs: 4,
s: 8,
m: 16,
l: 24,
xl: 40,
xxl: 64,
} as const;
export const type = {
title: { fontSize: 28, fontWeight: '600', color: colors.text } as const,
heading: { fontSize: 17, fontWeight: '600', color: colors.text } as const,
body: { fontSize: 17, lineHeight: 26, color: colors.text } as const,
caption: { fontSize: 13, lineHeight: 18, color: colors.textMuted } as const,
} as const;