Files
alva/infra/lib/semantika-stack.ts
Claude e5b81f46b0 Store readiness: in-app account deletion, privacy policy, terms, listing copy
Closes the remaining store-compliance gaps before App Store / Play
submission:

- Account deletion (App Store guideline 5.1.1): DELETE /me removes the
  user row (usage cascades). The app exposes it through one quiet
  'Account' caption link under the chat, visible only when signed in,
  driving two native dialogs (Sign out / Delete account with a
  destructive confirm) — no new views, no menus, minimalism intact.
  Deletion signs out and resets the app; copy notes that store
  subscriptions are cancelled in App Store / Play settings.
- docs/store/privacy-policy.md: the complete data inventory (matching
  the actual schema), transient OpenAI processing with no training, no
  profiling or ads, GDPR legal bases and rights, in-app erasure.
- docs/store/terms-of-service.md: not-therapy positioning with crisis
  guidance, AI-generated-content caveat, 18+ eligibility,
  auto-renewal/cancellation terms, liability, Swedish governing law.
- docs/store/listing.md: App Store and Play copy written to the
  honest-claims rule (subtitle 'Think Beyond Thought', keywords,
  descriptions), plus App Privacy and Data safety questionnaire
  mappings.
- LAUNCH.md updated: host the policy/terms, set store URLs, use the
  prepared listing copy.
- Tests: 30 passing (adds DELETE /me coverage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
2026-08-03 22:37:25 +00:00

184 lines
7.4 KiB
TypeScript

import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
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';
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 = 'semantika';
const APP_SCHEME_REDIRECT = 'semantika://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 SemantikaStack 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: 'semantika/app',
description: 'OPENAI_API_KEY, APPLE_SHARED_SECRET, GOOGLE_SERVICE_ACCOUNT_JSON for Semantika',
});
// --- 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') ?? 'semantika';
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.semantika.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',
MESSAGE_CAP: '200',
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, HttpMethod.DELETE], integration });
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 });
new CfnOutput(this, 'CognitoDomain', { value: domain.baseUrl() });
}
}