e5b81f46b0
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
184 lines
7.4 KiB
TypeScript
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() });
|
|
}
|
|
}
|