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
@@ -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 MeaningPerformance 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?").
+51
View File
@@ -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.
+21
View File
@@ -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"
}
}
+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;
}
+32
View File
@@ -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);
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["node"],
"noEmit": true
},
"include": ["src", "test"]
}