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:
@@ -0,0 +1,52 @@
|
||||
# Neurosemantics
|
||||
|
||||
Neurosemantics studies how humans create meaning — how neurology (body, brain,
|
||||
nervous system) and semantics (meaning, language, symbols) interact to produce
|
||||
our experienced reality. The field was developed primarily by L. Michael Hall,
|
||||
building on NLP, Alfred Korzybski's general semantics, and cognitive psychology.
|
||||
|
||||
## Core ideas
|
||||
|
||||
- **Meaning is constructed, not found.** Events have no inherent meaning; we
|
||||
give them meaning through framing, language, and evaluation.
|
||||
- **The map is not the territory** (Korzybski). Our mental models of reality
|
||||
are abstractions — useful, but never the thing itself. Suffering often comes
|
||||
from confusing map with territory.
|
||||
- **Meaning becomes embodied.** The meanings we hold trigger neurological and
|
||||
emotional states — "meaning becomes muscle". Changing meaning changes state.
|
||||
|
||||
## The Meta-States model
|
||||
|
||||
Hall's central contribution. Humans do not only experience primary states
|
||||
(fear, joy, curiosity); we reflexively bring states to bear _on_ other states:
|
||||
|
||||
- Fear **of** fear → panic.
|
||||
- Acceptance **of** fear → calm courage.
|
||||
- Appreciation **of** learning → engagement.
|
||||
- Shame **about** anger → self-suppression.
|
||||
|
||||
A meta-state is a state about a state. The higher-level state qualifies,
|
||||
tempers, or intensifies the lower one. Much of personal change work is about
|
||||
deliberately choosing which higher frames to apply to primary experiences.
|
||||
|
||||
Practical use: ask "How do you feel _about_ feeling X?" to surface the
|
||||
meta-level structure, then invite a resourceful higher frame (acceptance,
|
||||
curiosity, self-compassion, humor).
|
||||
|
||||
## Frames and frame games
|
||||
|
||||
A frame is the context or reference point that determines meaning. Reframing
|
||||
changes the meaning by changing the frame:
|
||||
|
||||
- **Context reframe**: "In what situation would this behavior be useful?"
|
||||
- **Meaning reframe**: "What else could this mean?"
|
||||
- **Outframing**: stepping to a higher logical level and setting a frame about
|
||||
the whole game ("What does it say about you that you keep trying?").
|
||||
|
||||
## The Meaning–Performance axis
|
||||
|
||||
Hall describes human excellence as the interplay of two axes: the meanings we
|
||||
hold (beliefs, values, identities) and the performances we deliver (skills,
|
||||
actions). Peak performance occurs when rich, empowering meanings are closed
|
||||
into action. Coaching works both axes: enrich meaning, then bridge it to
|
||||
behavior ("How will you act on this today?").
|
||||
@@ -0,0 +1,51 @@
|
||||
# NLP fundamentals
|
||||
|
||||
NLP (Neuro-Linguistic Programming) was developed in the 1970s by Richard
|
||||
Bandler and John Grinder by modeling effective therapists (Virginia Satir,
|
||||
Fritz Perls, Milton Erickson). It offers models for how people structure
|
||||
subjective experience.
|
||||
|
||||
## Key presuppositions (working assumptions)
|
||||
|
||||
- The map is not the territory.
|
||||
- People respond to their map of reality, not reality itself.
|
||||
- There is no failure, only feedback.
|
||||
- Every behavior has a positive intention in some context.
|
||||
- People already have the resources they need, or can create them.
|
||||
- The meaning of communication is the response it elicits.
|
||||
|
||||
These are adopted as useful stances, not truth claims.
|
||||
|
||||
## The Meta-Model
|
||||
|
||||
A set of language patterns for recovering information deleted, distorted, or
|
||||
generalized in speech. Examples:
|
||||
|
||||
- **Deletion**: "I'm afraid." → "Afraid of what, specifically?"
|
||||
- **Unspecified verb**: "He rejected me." → "How, specifically, did he reject you?"
|
||||
- **Universal quantifier**: "I always fail." → "Always? Every single time?"
|
||||
- **Modal operator**: "I can't speak up." → "What would happen if you did?"
|
||||
- **Nominalization**: "Our communication is bad." → "How are you communicating?"
|
||||
- **Mind reading**: "She thinks I'm useless." → "How do you know she thinks that?"
|
||||
|
||||
The Meta-Model turns vague, limiting maps into specific, workable ones.
|
||||
|
||||
## Representational systems and submodalities
|
||||
|
||||
Experience is coded in sensory systems (visual, auditory, kinesthetic).
|
||||
Submodalities are the fine qualities of those codes — brightness, distance,
|
||||
size, volume, temperature. Shifting submodalities (e.g. shrinking and dimming
|
||||
a threatening inner image) often shifts the emotional response.
|
||||
|
||||
## Anchoring
|
||||
|
||||
Associating a state with a stimulus (a touch, word, or image) so the state can
|
||||
be re-accessed deliberately. Built by eliciting a strong state and applying
|
||||
the stimulus at its peak, then testing.
|
||||
|
||||
## Well-formed outcomes
|
||||
|
||||
Goals stated positively, within the person's control, sensory-specific,
|
||||
context-bound, and ecological (checked against the wider system of the
|
||||
person's life): "What do you want? How will you know you have it? What might
|
||||
it cost you?"
|
||||
@@ -0,0 +1,28 @@
|
||||
# Conversation guide
|
||||
|
||||
How NeuroSemantics AI conducts a conversation.
|
||||
|
||||
## Stance
|
||||
|
||||
- Calm, spacious, precise. One idea at a time.
|
||||
- Curious before instructive: explore the user's map before offering models.
|
||||
- Prefer questions that move the user, over explanations that impress.
|
||||
|
||||
## Typical flow
|
||||
|
||||
1. **Hear the experience.** Reflect it briefly in the user's own words.
|
||||
2. **Find the structure.** Use Meta-Model questions to locate deletions,
|
||||
generalizations, and distortions; or Meta-States questions ("How do you
|
||||
feel about feeling that?") to find the layered structure.
|
||||
3. **Offer one lever.** A reframe, a submodality shift, a higher frame, or a
|
||||
well-formed outcome — one intervention at a time, invited rather than
|
||||
imposed.
|
||||
4. **Bridge to action.** "What is a small way to act on this before we speak
|
||||
again?"
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No diagnosis, no treatment of medical or psychiatric conditions.
|
||||
- On signs of acute crisis: acknowledge with care and recommend local
|
||||
professional or emergency help immediately.
|
||||
- Stay inside neurosemantics/NLP; decline unrelated topics kindly and briefly.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@neurosemantics/api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-secrets-manager": "^3.716.0",
|
||||
"pg": "^8.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/aws-lambda": "^8.10.146",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/pg": "^8.11.10",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (~5–10 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',
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) };
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { canSendMessage, shouldResetUsage } from '../src/usage.js';
|
||||
|
||||
describe('canSendMessage', () => {
|
||||
it('allows messages under the free limit', () => {
|
||||
expect(canSendMessage(0, 'free', 50)).toBe(true);
|
||||
expect(canSendMessage(49, 'free', 50)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks free users at the limit', () => {
|
||||
expect(canSendMessage(50, 'free', 50)).toBe(false);
|
||||
expect(canSendMessage(51, 'free', 50)).toBe(false);
|
||||
});
|
||||
|
||||
it('never blocks active subscribers', () => {
|
||||
expect(canSendMessage(10_000, 'active', 50)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldResetUsage', () => {
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
|
||||
it('does not reset within the window', () => {
|
||||
const now = new Date();
|
||||
expect(shouldResetUsage(new Date(now.getTime() - 29 * day), now, 30)).toBe(false);
|
||||
});
|
||||
|
||||
it('resets once the window has passed', () => {
|
||||
const now = new Date();
|
||||
expect(shouldResetUsage(new Date(now.getTime() - 30 * day), now, 30)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user