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
+169
View File
@@ -0,0 +1,169 @@
import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
import { HttpApi, HttpMethod } 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';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as rds from 'aws-cdk-lib/aws-rds';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import type { Construct } from 'constructs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const DB_NAME = 'neurosemantics';
const APP_SCHEME_REDIRECT = 'neurosemantics://redirect';
/**
* The entire system: one HTTP API, one Lambda, one database, one user pool,
* one application secret. CloudWatch logging comes with Lambda by default.
*/
export class NeuroSemanticsStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// --- Network (required by RDS; one NAT gateway for outbound HTTPS) ---
const vpc = new ec2.Vpc(this, 'Vpc', { maxAzs: 2, natGateways: 1 });
// --- Database: Aurora Serverless v2, PostgreSQL ---
const db = new rds.DatabaseCluster(this, 'Database', {
engine: rds.DatabaseClusterEngine.auroraPostgres({
version: rds.AuroraPostgresEngineVersion.VER_16_4,
}),
writer: rds.ClusterInstance.serverlessV2('Writer'),
serverlessV2MinCapacity: 0.5,
serverlessV2MaxCapacity: 2,
defaultDatabaseName: DB_NAME,
vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
storageEncrypted: true,
removalPolicy: RemovalPolicy.SNAPSHOT,
});
// --- Application secret (filled in manually after first deploy) ---
const appSecret = new secretsmanager.Secret(this, 'AppSecret', {
secretName: 'neurosemantics/app',
description:
'OPENAI_API_KEY, APPLE_SHARED_SECRET, GOOGLE_SERVICE_ACCOUNT_JSON for NeuroSemantics AI',
});
// --- Authentication: Cognito with Apple, Google and email ---
const userPool = new cognito.UserPool(this, 'UserPool', {
selfSignUpEnabled: true,
signInAliases: { email: true },
autoVerify: { email: true },
passwordPolicy: { minLength: 10 },
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
removalPolicy: RemovalPolicy.RETAIN,
});
const domainPrefix = this.node.tryGetContext('cognitoDomainPrefix') ?? 'neurosemantics';
const domain = userPool.addDomain('Domain', { cognitoDomain: { domainPrefix } });
// Apple/Google federation requires developer credentials; provide them via
// CDK context to enable. Email sign-in works without any of this.
const providers: cognito.UserPoolClientIdentityProvider[] = [
cognito.UserPoolClientIdentityProvider.COGNITO,
];
const googleClientId = this.node.tryGetContext('googleClientId');
const googleClientSecret = this.node.tryGetContext('googleClientSecret');
if (googleClientId && googleClientSecret) {
new cognito.UserPoolIdentityProviderGoogle(this, 'Google', {
userPool,
clientId: googleClientId,
clientSecretValue: secretsmanager.Secret.fromSecretNameV2(
this,
'GoogleSecretRef',
googleClientSecret,
).secretValue,
scopes: ['openid', 'email'],
attributeMapping: { email: cognito.ProviderAttribute.GOOGLE_EMAIL },
});
providers.push(cognito.UserPoolClientIdentityProvider.GOOGLE);
}
const appleTeamId = this.node.tryGetContext('appleTeamId');
const appleKeyId = this.node.tryGetContext('appleKeyId');
const applePrivateKeySecretName = this.node.tryGetContext('applePrivateKeySecretName');
if (appleTeamId && appleKeyId && applePrivateKeySecretName) {
new cognito.UserPoolIdentityProviderApple(this, 'Apple', {
userPool,
clientId: 'com.neurosemantics.app.signin',
teamId: appleTeamId,
keyId: appleKeyId,
privateKeyValue: secretsmanager.Secret.fromSecretNameV2(
this,
'AppleKeyRef',
applePrivateKeySecretName,
).secretValue,
scopes: ['email'],
attributeMapping: { email: cognito.ProviderAttribute.APPLE_EMAIL },
});
providers.push(cognito.UserPoolClientIdentityProvider.APPLE);
}
const userPoolClient = userPool.addClient('MobileClient', {
generateSecret: false,
authFlows: { userSrp: true },
supportedIdentityProviders: providers,
oAuth: {
flows: { authorizationCodeGrant: true },
scopes: [cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL],
callbackUrls: [APP_SCHEME_REDIRECT],
logoutUrls: [APP_SCHEME_REDIRECT],
},
});
// --- The single backend Lambda ---
const apiFunction = new NodejsFunction(this, 'Api', {
entry: join(repoRoot, 'services', 'api', 'src', 'handler.ts'),
runtime: Runtime.NODEJS_22_X,
memorySize: 512,
timeout: Duration.seconds(30),
vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
logRetention: logs.RetentionDays.ONE_MONTH,
bundling: {
format: OutputFormat.ESM,
commandHooks: {
beforeBundling: () => [],
beforeInstall: () => [],
afterBundling: (inputDir, outputDir) => [
`cp -r ${inputDir}/services/api/knowledge ${outputDir}/knowledge`,
],
},
},
environment: {
APP_SECRET_ARN: appSecret.secretArn,
DB_SECRET_ARN: db.secret?.secretArn ?? '',
DB_NAME,
KNOWLEDGE_DIR: 'knowledge',
FREE_MESSAGE_LIMIT: '50',
USAGE_RESET_DAYS: '30',
},
});
appSecret.grantRead(apiFunction);
db.secret?.grantRead(apiFunction);
db.connections.allowDefaultPortFrom(apiFunction);
// --- HTTP API with Cognito JWT authorization ---
const authorizer = new HttpJwtAuthorizer(
'JwtAuthorizer',
`https://cognito-idp.${this.region}.amazonaws.com/${userPool.userPoolId}`,
{ jwtAudience: [userPoolClient.userPoolClientId] },
);
const api = new HttpApi(this, 'HttpApi', { defaultAuthorizer: authorizer });
const integration = new HttpLambdaIntegration('ApiIntegration', apiFunction);
api.addRoutes({ path: '/me', methods: [HttpMethod.GET], integration });
api.addRoutes({ path: '/chat', methods: [HttpMethod.POST], integration });
api.addRoutes({ path: '/subscription/verify', methods: [HttpMethod.POST], integration });
new CfnOutput(this, 'ApiUrl', { value: api.apiEndpoint });
new CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId });
new CfnOutput(this, 'UserPoolClientId', { value: userPoolClient.userPoolClientId });
new CfnOutput(this, 'CognitoDomain', { value: domain.baseUrl() });
}
}