Add chat-first onboarding and intelligent context-based paywall

Replaces the hardcoded message limit with a model-driven paywall and
removes registration before the first question:

- Onboarding: the app opens directly into the chat. Dynamic conversation
  starters are served by GET /suggestions (services/api/suggestions.json),
  updatable with a deploy — no app release needed. Guests chat via
  POST /guest/chat, identified by an app-generated device id; sign-in
  moves to the paywall, where a purchase must attach to an account.
- Free experience: the model runs in discovery mode — follow-up
  questions, pattern identification, visible understanding — building an
  analysis without delivering the full solution.
- Intelligent paywall: the model returns structured output (reply +
  analysis_ready). Only when the problem is described, the information is
  sufficient and an action plan is ready does it write a calm transition
  and pause the conversation. Manufactured urgency, emotional pressure,
  fake readiness and mid-answer stops are explicitly forbidden.
- Premium: on unlock the app resends the transcript and the backend
  immediately delivers the full analysis, strategies and exercises, then
  the dialogue continues without restriction.
- The old FREE_MESSAGE_LIMIT becomes MESSAGE_CAP (default 200/30 days),
  kept purely as an abuse backstop — it is not the paywall.
- WelcomeScreen removed; the app is now two views (Chat, Paywall).

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:42:44 +00:00
parent 07fbeea09b
commit 6b7f0263e4
11 changed files with 498 additions and 221 deletions
+45 -15
View File
@@ -10,7 +10,7 @@ one dark blue-green accent. No animations, no gradients.
## System overview
```
apps/mobile Expo / React Native app (three views: Welcome, Chat, Paywall)
apps/mobile Expo / React Native app (two views: Chat, Paywall)
services/api One Lambda backend (chat, usage, subscription verification)
infra AWS CDK stack (the entire cloud environment)
db/migrations SQL schema (two tables: users, usage)
@@ -22,16 +22,42 @@ it is not built.
### Request flow
1. The app signs in via the **Cognito** hosted UI (Apple / Google / email)
and receives a JWT.
2. `POST /chat` goes through **API Gateway** (JWT authorizer) to the single
**Lambda**.
3. The Lambda checks the free-tier quota in **PostgreSQL** (Aurora
Serverless v2). Over the limit and not subscribed → HTTP 402 → the app
shows the paywall.
4. Otherwise it calls the **OpenAI Responses API** with the Markdown
knowledge base (`services/api/knowledge/`) as system instructions and
returns the reply.
1. The user lands directly in the chat — no registration before the first
question. Guests are identified by an app-generated device id
(`POST /guest/chat`); dynamic conversation starters come from
`GET /suggestions` (both public routes). The suggestions live in
`services/api/suggestions.json` and can be updated with a deploy — no
app release needed.
2. Requests go through **API Gateway** to the single **Lambda**; signed-in
users use `POST /chat` with a **Cognito** JWT (Apple / Google / email via
the hosted UI).
3. The Lambda calls the **OpenAI Responses API** with the Markdown knowledge
base (`services/api/knowledge/`) as system instructions. The model
returns structured output: a reply plus an `analysis_ready` flag.
4. Sign-in happens at the paywall, since a purchase must attach to an
account. Usage counters live in **PostgreSQL** (Aurora Serverless v2).
### The intelligent paywall
The paywall is not a hardcoded message count. Free conversations run in
_discovery mode_: the model asks relevant follow-up questions, names
patterns, shows understanding, and builds toward an analysis — without
delivering the full solution. When the problem is described, the information
is sufficient, and a concrete action plan is ready, the model signals
`analysis_ready`, writes a calm transition ("…I have a concrete strategy I
would recommend. Continue with Premium to see the analysis and the
recommended steps."), and the conversation pauses.
The instructions explicitly forbid manufactured urgency, emotional pressure,
fake readiness, and stopping mid-answer — Premium should feel like the
natural continuation of an already valuable dialogue.
On unlock, the app resends the transcript; premium mode then delivers the
full analysis, recommended strategies and concrete exercises immediately,
and the dialogue continues without restriction.
A generous `MESSAGE_CAP` (default 200 per 30 days) exists purely as an
abuse backstop for the free tier — it is not the paywall.
Conversations are never stored server-side; the client holds them in memory
and sends the running transcript with each request. The database stores the
@@ -51,9 +77,6 @@ provider differs per platform behind one `PaymentProvider` interface:
Plans: Monthly and Yearly. Nothing else.
Free tier: `FREE_MESSAGE_LIMIT` messages (default 50 ≈ 510 conversations),
resetting every `USAGE_RESET_DAYS` days.
## Getting started
```sh
@@ -100,12 +123,19 @@ GitHub Actions: `ci.yml` lints, type-checks and tests every PR;
## Security
- All traffic over HTTPS; every API route requires a Cognito JWT.
- All traffic over HTTPS. Account routes require a Cognito JWT; the two
public routes (`/suggestions`, `/guest/chat`) carry no account data and
are bounded by the free-tier message cap.
- Secrets live in AWS Secrets Manager only — no API keys in the client.
- The Lambda runs in private subnets; the database is not publicly reachable.
## Product philosophy
Every free user should leave the app feeling that the system understood
their situation, that a concrete analysis is ready, and that the next step
is available in Premium — never that they were held back by an artificial
interruption.
Every new feature must justify itself. The allowed AWS surface is
API Gateway, Lambda, Cognito, S3, Secrets Manager and CloudWatch — and V1
does not even need S3. No Redis, no Kubernetes, no Kafka, no Elasticsearch,
+31 -15
View File
@@ -1,29 +1,45 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { StatusBar } from 'expo-status-bar';
import { getAccessToken } from './src/auth/session';
import type { ChatMessage } from './src/api/client';
import { ChatScreen } from './src/screens/ChatScreen';
import { PaywallScreen } from './src/screens/PaywallScreen';
import { WelcomeScreen } from './src/screens/WelcomeScreen';
type View = 'loading' | 'welcome' | 'chat' | 'paywall';
/**
* Three views, no navigation library. The backend decides when the paywall
* appears (HTTP 402 on /chat).
* Two views, no navigation library. The user lands directly in the chat —
* no registration before the first question. The backend decides the paywall
* moment (the analysis is ready); after unlock the conversation continues
* with the full analysis delivered immediately.
*/
export default function App() {
const [view, setView] = useState<View>('loading');
useEffect(() => {
getAccessToken().then((token) => setView(token ? 'chat' : 'welcome'));
}, []);
const [view, setView] = useState<'chat' | 'paywall'>('chat');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [paywalled, setPaywalled] = useState(false);
const [deliverAnalysis, setDeliverAnalysis] = useState(false);
return (
<>
<StatusBar style="dark" />
{view === 'welcome' && <WelcomeScreen onSignedIn={() => setView('chat')} />}
{view === 'chat' && <ChatScreen onLimitReached={() => setView('paywall')} />}
{view === 'paywall' && <PaywallScreen onSubscribed={() => setView('chat')} />}
{view === 'chat' && (
<ChatScreen
messages={messages}
setMessages={setMessages}
paywalled={paywalled}
onPaywallTriggered={() => setPaywalled(true)}
onContinueWithPremium={() => setView('paywall')}
deliverAnalysis={deliverAnalysis}
onAnalysisDelivered={() => setDeliverAnalysis(false)}
/>
)}
{view === 'paywall' && (
<PaywallScreen
onSubscribed={() => {
setPaywalled(false);
setDeliverAnalysis(true);
setView('chat');
}}
onDismiss={() => setView('chat')}
/>
)}
</>
);
}
+53 -18
View File
@@ -1,3 +1,5 @@
import * as Crypto from 'expo-crypto';
import * as SecureStore from 'expo-secure-store';
import { appConfig } from '../config';
import { getAccessToken } from '../auth/session';
@@ -16,23 +18,38 @@ export interface ChatMessage {
content: string;
}
export interface ChatReply {
reply: string;
/** True when the backend judged the analysis ready — the paywall moment. */
paywall: boolean;
}
export interface Me {
subscriptionStatus: 'free' | 'active';
messagesUsed: number;
freeMessageLimit: number;
messageCap: 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 DEVICE_KEY = 'neurosemantics.device';
/** Stable anonymous id so guests can chat before signing in. */
async function getDeviceId(): Promise<string> {
let id = await SecureStore.getItemAsync(DEVICE_KEY);
if (!id) {
id = Crypto.randomUUID();
await SecureStore.setItemAsync(DEVICE_KEY, id);
}
return id;
}
async function request<T>(
path: string,
init: RequestInit = {},
headers: Record<string, string> = {},
): Promise<T> {
const response = await fetch(`${appConfig.apiUrl}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
...init?.headers,
},
headers: { 'Content-Type': 'application/json', ...headers },
});
if (!response.ok) {
@@ -47,21 +64,39 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return (await response.json()) as T;
}
export function fetchMe(): Promise<Me> {
return request<Me>('/me');
async function authHeader(): Promise<Record<string, string>> {
const token = await getAccessToken();
if (!token) throw new ApiError(401, 'not_signed_in');
return { Authorization: `Bearer ${token}` };
}
export function sendChat(messages: ChatMessage[]): Promise<{ reply: string }> {
return request<{ reply: string }>('/chat', {
method: 'POST',
body: JSON.stringify({ messages }),
});
export async function fetchSuggestions(): Promise<string[]> {
const data = await request<{ suggestions: string[] }>('/suggestions');
return data.suggestions;
}
export function verifyPurchase(body: {
/** Signed-in users chat via /chat; guests via /guest/chat with a device id. */
export async function sendChat(messages: ChatMessage[]): Promise<ChatReply> {
const body = { method: 'POST', body: JSON.stringify({ messages }) };
const token = await getAccessToken();
if (token) {
return request<ChatReply>('/chat', body, { Authorization: `Bearer ${token}` });
}
return request<ChatReply>('/guest/chat', body, { 'X-Device-Id': await getDeviceId() });
}
export async function fetchMe(): Promise<Me> {
return request<Me>('/me', {}, await authHeader());
}
export async function verifyPurchase(body: {
platform: 'ios' | 'android';
productId: string;
receipt: string;
}): Promise<{ subscriptionStatus: 'free' | 'active' }> {
return request('/subscription/verify', { method: 'POST', body: JSON.stringify(body) });
return request(
'/subscription/verify',
{ method: 'POST', body: JSON.stringify(body) },
await authHeader(),
);
}
+114 -38
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
FlatList,
KeyboardAvoidingView,
@@ -9,54 +9,104 @@ import {
TextInput,
View,
} from 'react-native';
import { ApiError, sendChat, type ChatMessage } from '../api/client';
import { fetchSuggestions, sendChat, type ChatMessage } from '../api/client';
import { colors, spacing, type } from '../theme';
interface Props {
onLimitReached: () => void;
messages: ChatMessage[];
setMessages: (messages: ChatMessage[]) => void;
/** The analysis is ready behind Premium; input is replaced by the unlock button. */
paywalled: boolean;
onPaywallTriggered: () => void;
onContinueWithPremium: () => void;
/** Set right after unlock: fetch the promised analysis automatically. */
deliverAnalysis: boolean;
onAnalysisDelivered: () => void;
}
const SUGGESTIONS_SHOWN = 4;
/**
* The whole product: one heading, the conversation, a text field and send.
* Conversations live in memory only — nothing is stored.
* The user lands here directly — no registration before the first question.
* Conversations live in memory only; nothing is stored.
*/
export function ChatScreen({ onLimitReached }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
export function ChatScreen({
messages,
setMessages,
paywalled,
onPaywallTriggered,
onContinueWithPremium,
deliverAnalysis,
onAnalysisDelivered,
}: Props) {
const [suggestions, setSuggestions] = useState<string[]>([]);
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 }];
useEffect(() => {
fetchSuggestions()
.then((all) =>
setSuggestions([...all].sort(() => Math.random() - 0.5).slice(0, SUGGESTIONS_SHOWN)),
)
.catch(() => setSuggestions([]));
}, []);
// After unlocking Premium the transcript ends with the assistant's
// transition message; resend it so the backend delivers the full analysis.
useEffect(() => {
if (!deliverAnalysis || busy) return;
setBusy(true);
setError(null);
sendChat(messages)
.then(({ reply }) => {
setMessages([...messages, { role: 'assistant', content: reply }]);
onAnalysisDelivered();
})
.catch(() => setError('Something went wrong. Please try again.'))
.finally(() => setBusy(false));
}, [deliverAnalysis]);
async function send(content: string) {
const trimmed = content.trim();
if (!trimmed || busy || paywalled) return;
const next: ChatMessage[] = [...messages, { role: 'user', content: trimmed }];
setMessages(next);
setDraft('');
setBusy(true);
setError(null);
try {
const { reply } = await sendChat(next);
const { reply, paywall } = 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);
}
if (paywall) onPaywallTriggered();
} catch {
setError('Something went wrong. Please try again.');
setMessages(messages);
setDraft(trimmed);
} finally {
setBusy(false);
}
}
const showSuggestions = messages.length === 0 && suggestions.length > 0;
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Text style={styles.heading}>NeuroSemantics AI</Text>
{showSuggestions ? (
<View style={styles.suggestions}>
{suggestions.map((s) => (
<Pressable key={s} style={styles.suggestion} onPress={() => send(s)} disabled={busy}>
<Text style={styles.suggestionText}>{s}</Text>
</Pressable>
))}
</View>
) : null}
<FlatList
ref={listRef}
style={styles.list}
@@ -74,24 +124,32 @@ export function ChatScreen({ onLimitReached }: Props) {
)}
/>
{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>
{paywalled ? (
<View style={styles.inputRow}>
<Pressable style={styles.unlock} onPress={onContinueWithPremium}>
<Text style={styles.unlockText}>Continue with Premium</Text>
</Pressable>
</View>
) : (
<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(draft)}
disabled={!draft.trim() || busy}
>
<Text style={styles.sendText}>{busy ? '…' : 'Send'}</Text>
</Pressable>
</View>
)}
</KeyboardAvoidingView>
);
}
@@ -104,9 +162,19 @@ const styles = StyleSheet.create({
paddingTop: spacing.xxl,
paddingBottom: spacing.m,
},
suggestions: { paddingHorizontal: spacing.l, gap: spacing.s, marginBottom: spacing.m },
suggestion: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 10,
paddingVertical: spacing.s + 2,
paddingHorizontal: spacing.m,
},
suggestionText: { ...type.body, color: colors.accent },
list: { flex: 1 },
listContent: { paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.s },
empty: { ...type.body, color: colors.textMuted, textAlign: 'center', marginTop: spacing.xxl },
empty: { ...type.body, color: colors.textMuted, textAlign: 'center', marginTop: spacing.xl },
message: { maxWidth: '85%', borderRadius: 12, padding: spacing.m },
user: { alignSelf: 'flex-end', backgroundColor: colors.accent },
assistant: {
@@ -144,4 +212,12 @@ const styles = StyleSheet.create({
},
sendDisabled: { opacity: 0.4 },
sendText: { ...type.heading, color: colors.accentText },
unlock: {
flex: 1,
backgroundColor: colors.accent,
borderRadius: 12,
paddingVertical: spacing.m,
alignItems: 'center',
},
unlockText: { ...type.heading, color: colors.accentText },
});
+80 -14
View File
@@ -1,16 +1,40 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { getAccessToken, signIn, type Provider } from '../auth/session';
import { purchase, restore, type Plan } from '../purchases';
import { colors, spacing, type } from '../theme';
interface Props {
onSubscribed: () => void;
onDismiss: () => void;
}
export function PaywallScreen({ onSubscribed }: Props) {
/**
* Shown when the backend has a complete analysis ready. Guests sign in
* first (purchases must attach to an account), then choose a plan.
*/
export function PaywallScreen({ onSubscribed, onDismiss }: Props) {
const [signedIn, setSignedIn] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getAccessToken().then((token) => setSignedIn(token !== null));
}, []);
async function handleSignIn(provider: Provider) {
setBusy(true);
setError(null);
try {
await signIn(provider);
setSignedIn(true);
} catch {
setError('Sign-in did not complete. Please try again.');
} finally {
setBusy(false);
}
}
async function run(action: () => Promise<'free' | 'active'>) {
setBusy(true);
setError(null);
@@ -35,20 +59,52 @@ export function PaywallScreen({ onSubscribed }: Props) {
<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.
Your analysis and recommended steps are ready. Premium unlocks them and lets the
conversation continue without limits.
</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>
{signedIn === false ? (
<>
<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>
</>
) : signedIn === true ? (
<>
<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.textButton} onPress={() => run(restore)} disabled={busy}>
<Text style={styles.textButtonText}>Restore Purchase</Text>
</Pressable>
</>
) : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable style={styles.textButton} onPress={onDismiss} disabled={busy}>
<Text style={styles.dismissText}>Not now</Text>
</Pressable>
</View>
</View>
);
@@ -77,7 +133,17 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
primaryButtonText: { ...type.heading, color: colors.accentText },
restore: { alignItems: 'center', paddingVertical: spacing.m },
restoreText: { ...type.body, color: colors.accent },
secondaryButton: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
paddingVertical: spacing.m,
borderRadius: 10,
alignItems: 'center',
},
secondaryButtonText: { ...type.heading },
textButton: { alignItems: 'center', paddingVertical: spacing.m },
textButtonText: { ...type.body, color: colors.accent },
dismissText: { ...type.caption },
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center' },
});
-98
View File
@@ -1,98 +0,0 @@
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 },
});
+17 -2
View File
@@ -1,5 +1,5 @@
import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
import { HttpApi, HttpMethod } from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpApi, HttpMethod, HttpNoneAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as cognito from 'aws-cdk-lib/aws-cognito';
@@ -141,7 +141,7 @@ export class NeuroSemanticsStack extends Stack {
DB_SECRET_ARN: db.secret?.secretArn ?? '',
DB_NAME,
KNOWLEDGE_DIR: 'knowledge',
FREE_MESSAGE_LIMIT: '50',
MESSAGE_CAP: '200',
USAGE_RESET_DAYS: '30',
},
});
@@ -161,6 +161,21 @@ export class NeuroSemanticsStack extends Stack {
api.addRoutes({ path: '/chat', methods: [HttpMethod.POST], integration });
api.addRoutes({ path: '/subscription/verify', methods: [HttpMethod.POST], integration });
// Public routes: the user meets the chat before any registration.
const publicAuthorizer = new HttpNoneAuthorizer();
api.addRoutes({
path: '/suggestions',
methods: [HttpMethod.GET],
integration,
authorizer: publicAuthorizer,
});
api.addRoutes({
path: '/guest/chat',
methods: [HttpMethod.POST],
integration,
authorizer: publicAuthorizer,
});
new CfnOutput(this, 'ApiUrl', { value: api.apiEndpoint });
new CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId });
new CfnOutput(this, 'UserPoolClientId', { value: userPoolClient.userPoolClientId });
+82 -4
View File
@@ -7,6 +7,14 @@ export interface ChatMessage {
content: string;
}
export type ChatMode = 'free' | 'premium';
export interface ChatResult {
reply: string;
/** Free mode only: the model judges a complete analysis is ready to present. */
analysisReady: boolean;
}
const BASE_INSTRUCTIONS = `You are NeuroSemantics AI — a calm, precise conversation partner
specialized in neurosemantics and NLP (Neuro-Linguistic Programming).
@@ -19,16 +27,74 @@ Principles:
distress or a medical condition, recommend seeking professional help.
- Answer in the language the user writes in.`;
const FREE_INSTRUCTIONS = `# Conversation mode: free tier (discovery)
Work in discovery mode. In every reply you should:
- ask relevant follow-up questions (one at a time),
- identify and name patterns you notice,
- show genuine understanding of the user's situation,
- build toward a complete analysis.
Do not yet present the full analysis, the recommended strategy, or concrete
exercises.
Set "analysis_ready" to true ONLY when all of the following are genuinely met:
- the user has described their problem,
- you have enough information to give a concrete, personal recommendation,
- a specific action plan is ready to present.
When analysis_ready is true, the reply must be a calm, natural transition —
not an interruption mid-answer. Summarize at a high level what you have
understood and that a concrete strategy is ready. Example of tone:
"I think I'm starting to understand what lies behind this situation, and I
can see some clear communication patterns. I also have a concrete strategy
I would recommend for your specific situation. Continue with Premium to see
the analysis and the recommended steps."
Never:
- manufacture urgency or emotional pressure to drive a purchase,
- claim readiness or insight you do not have,
- stop in the middle of answering a direct question,
- mention Premium in any other situation.
Otherwise, set analysis_ready to false.`;
const PREMIUM_INSTRUCTIONS = `# Conversation mode: premium
The user has full access. Deliver complete value:
- when your analysis is ready, present it in full: the analysis, recommended
strategies, and concrete exercises,
- if the conversation ends with your own message announcing that an analysis
is ready, the user has just unlocked Premium — deliver the full analysis
and the recommended steps now, without being asked again,
- continue the dialogue without restriction.
Always set "analysis_ready" to false; it is not used in this mode.`;
const RESPONSE_SCHEMA = {
type: 'object',
properties: {
reply: { type: 'string' },
analysis_ready: { type: 'boolean' },
},
required: ['reply', 'analysis_ready'],
additionalProperties: false,
} as const;
/**
* 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.
*
* The model returns structured output so the backend — not a hardcoded
* message count — decides when the paywall moment has arrived.
*/
export async function generateReply(messages: ChatMessage[]): Promise<string> {
export async function generateReply(messages: ChatMessage[], mode: ChatMode): Promise<ChatResult> {
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 modeInstructions = mode === 'premium' ? PREMIUM_INSTRUCTIONS : FREE_INSTRUCTIONS;
const response = await fetch('https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
@@ -37,8 +103,16 @@ export async function generateReply(messages: ChatMessage[]): Promise<string> {
},
body: JSON.stringify({
model: config.openAiModel,
instructions: `${BASE_INSTRUCTIONS}\n\n# Knowledge base\n\n${loadKnowledgeBase()}`,
instructions: `${BASE_INSTRUCTIONS}\n\n${modeInstructions}\n\n# Knowledge base\n\n${loadKnowledgeBase()}`,
input: messages.map((m) => ({ role: m.role, content: m.content })),
text: {
format: {
type: 'json_schema',
name: 'chat_turn',
strict: true,
schema: RESPONSE_SCHEMA,
},
},
}),
});
@@ -56,7 +130,11 @@ export async function generateReply(messages: ChatMessage[]): Promise<string> {
.filter((part) => part.type === 'output_text')
.map((part) => part.text ?? '')
.join('');
if (!text) throw new Error('OpenAI response contained no output text');
return text;
const parsed = JSON.parse(text) as { reply: string; analysis_ready: boolean };
return {
reply: parsed.reply,
analysisReady: mode === 'free' && parsed.analysis_ready === true,
};
}
+6 -2
View File
@@ -3,8 +3,12 @@
* 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'),
/**
* Abuse backstop, NOT the paywall. The paywall is intelligent: the model
* decides when an analysis is ready. This cap only bounds how many free
* messages a single user/device can send per reset window.
*/
messageCap: Number(process.env.MESSAGE_CAP ?? '200'),
/** 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',
+56 -15
View File
@@ -1,10 +1,11 @@
import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyResultV2 } from 'aws-lambda';
import type { APIGatewayProxyEventV2, 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';
import suggestionsFile from '../suggestions.json';
function json(statusCode: number, body: unknown): APIGatewayProxyResultV2 {
return {
@@ -20,10 +21,17 @@ interface Identity {
provider: string;
}
function identityFromClaims(event: APIGatewayProxyEventV2WithJWTAuthorizer): Identity {
const claims = event.requestContext.authorizer.jwt.claims;
/** 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) throw new Error('JWT is missing a sub claim');
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 ?? '');
@@ -49,29 +57,43 @@ async function handleMe(user: UserRow): Promise<APIGatewayProxyResultV2> {
return json(200, {
subscriptionStatus: user.subscription_status,
messagesUsed: usage.messages_used,
freeMessageLimit: config.freeMessageLimit,
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_status === 'active';
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()) {
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_status, config.freeMessageLimit)) {
return json(402, { error: 'free_limit_reached' });
if (!canSendMessage(usage.messages_used, user.subscription_status, config.messageCap)) {
return json(402, { error: 'message_cap_reached' });
}
const reply = await generateReply(messages);
const result = await generateReply(messages, premium ? 'premium' : 'free');
await incrementUsage(user.id);
return json(200, { reply });
return json(200, { reply: result.reply, paywall: result.analysisReady });
}
async function handleVerifyPurchase(
@@ -90,14 +112,33 @@ async function handleVerifyPurchase(
return json(200, { subscriptionStatus: status });
}
export async function handler(
event: APIGatewayProxyEventV2WithJWTAuthorizer,
): Promise<APIGatewayProxyResultV2> {
/** 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 identity = identityFromClaims(event);
const user = await getOrCreateUser(identity.sub, identity.email, identity.provider);
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);
+14
View File
@@ -0,0 +1,14 @@
{
"suggestions": [
"I need support before a difficult conversation.",
"Help me prepare for a salary negotiation.",
"I'm going on a first date.",
"Why do I get stuck in the same conflicts?",
"Help me understand my reactions.",
"How do I set clearer boundaries?",
"I want to become a better communicator.",
"Help me prepare for a job interview.",
"How do I build better self-confidence?",
"Help me make a difficult decision."
]
}