Call Analyst — Auth & AWS Plan

Pablo's next-steps, researched + specced. us-east-2 · Cognito · CDK · Stripe-linked · US-only geofence. Dense by request. Companion to your AWS proposal + refactor progress.

Re the API keys you flagged — confirmed + fixed. A live Resend key was hardcoded in a comment in welcome-email.mjs. Caught + removed on the refactor branch before any merge; runtime already used process.env. It was the only hardcoded secret in the tree. Key should be rotated (it's in history). Good catch — that would've shipped public.

Decisions locked (from your notes + research)

AreaDecisionWhy
Regionus-east-2your call
AuthCognito User Pools, custom UI via SDK (not Hosted UI)AWS-native, CDK construct, cheapest at scale, app has bespoke UI
IaCAWS CDK (TS), deploy via AWS CLI/MCPone control plane
MFATOTP only (skip SMS initially)SMS = per-msg cost + SIM-swap/pumping fraud
Auth methodsemail/password + Google socialGoogle OAuth client already exists
Identity↔billing joinCognito sub (UUID), stored both waysdeterministic, no extra link table
Entitlement storeDynamoDB (PK cognito_sub), NOT a Cognito attrfast idempotent webhook writes; hot-path read
Legal postureUS-only geofence at CDN + TOS + cookie consent at registrationparks GDPR; lightweight, not consent-mgmt
MailSES, CustomEmailSender Lambda (branded) + KMSfull HTML brand control
GeofenceCloudFront Function (viewer-request), US allowlistredirect not bare 403, path-scoped, ~$0
Login fallbackClerk if Cognito DX hurts (not Auth0)best DX, ~$1k@100k; Auth0 = $6k+ trap

Cost @ 100k MAU

ServicePlan$/mo @ 100kNote
CognitoEssentials $0.015/MAU, 10k free~$1,350Plus (adaptive auth) = $2,000, no free tier — skip unless needed
SES$0.10 / 1k emails~$10–50auto-scales from 50k/day after prod access
DynamoDBon-demand~$5–25entitlement table, tiny
CloudFront Fn$0.10 / 1M invals~$1–10geofence
Lambda (triggers)per-invoke~$1–5pre-signup / post-confirm / email-sender

Auth RPS @ 100k MAU ≈ 20 RPS peak — well under Cognito's 120 RPS default UserAuthentication quota. No quota increase needed. Users-per-pool soft limit = 40M.

A · Cognito stack (CDK) ~55 lines

import { Stack, Duration, CfnOutput, SecretValue } from 'aws-cdk-lib';
import * as cognito from 'aws-cdk-lib/aws-cognito';

const pool = new cognito.UserPool(this, 'CallAnalystPool', {
  selfSignUpEnabled: true,
  signInAliases: { email: true },
  autoVerify: { email: true },
  signInCaseSensitive: false,
  standardAttributes: { email: { required: true, mutable: true } },
  customAttributes: {                            // the join key + legal record
    stripe_customer_id: new cognito.StringAttribute({ mutable: true }),
    tos_accepted_at:    new cognito.StringAttribute({ mutable: true }),
    tos_version:        new cognito.StringAttribute({ mutable: true }),
    cookie_consent_at:  new cognito.StringAttribute({ mutable: true }),
  },
  passwordPolicy: { minLength: 12, requireLowercase:true, requireUppercase:true,
                    requireDigits:true, requireSymbols:true },
  mfa: cognito.Mfa.OPTIONAL,
  mfaSecondFactor: { sms: false, otp: true },     // TOTP only
  accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
  featurePlan: cognito.FeaturePlan.ESSENTIALS,   // $0.015/MAU (PLUS=$0.020, adaptive auth)
});                                              // removalPolicy RETAIN by default — never auto-delete

new cognito.UserPoolIdentityProviderGoogle(this, 'Google', {
  userPool: pool, clientId: 'EXISTING_GOOGLE_CLIENT_ID',
  clientSecretValue: SecretValue.secretsManager('google-oauth/client-secret'),
  scopes: ['email','profile','openid'],
  attributeMapping: { email: cognito.ProviderAttribute.GOOGLE_EMAIL },
});

const client = pool.addClient('WebClient', {     // PUBLIC client, no secret (browser SPA)
  authFlows: { userPassword: true, userSrp: true },
  supportedIdentityProviders: [ ...Google, ...COGNITO ],
  accessTokenValidity: Duration.hours(1), idTokenValidity: Duration.hours(1),
  refreshTokenValidity: Duration.days(30), preventUserExistenceErrors: true,
});
// + addTrigger(PRE_SIGN_UP, ...), addTrigger(POST_CONFIRMATION, ...), customSenderKmsKey (stack B)

v2 note: advancedSecurityMode deprecated → featurePlan. Token caps: id/access 5min–1d, refresh 1h–3650d.

Custom-UI flow (no Hosted UI)

SDK = @aws-sdk/client-cognito-identity-provider v3. SignUpCommandConfirmSignUpCommandInitiateAuth USER_SRP_AUTH (or USER_PASSWORD_AUTH simpler) → {Id,Access,Refresh}TokenREFRESH_TOKEN_AUTH. Verify JWTs against JWKS, never GetUser per request. Google = one redirect leg to the Cognito domain /oauth2/authorize?identity_provider=Google.

Deploy (AWS CLI MCP shells these)

export CDK_DEFAULT_REGION=us-east-2
aws secretsmanager create-secret --name google-oauth/client-secret --secret-string '…' --region us-east-2
cdk bootstrap aws://ACCOUNT_ID/us-east-2          # one-time
cdk deploy AuthStack --require-approval never --outputs-file cdk-outputs.json
# → COGNITO_USER_POOL_ID=us-east-2_xxxxx, COGNITO_CLIENT_ID=… → push to app env

B · SES (mail) + SNS (events) stack (CDK)

// SES domain identity + Easy DKIM (2048-bit) + config set + bounce SNS topic
const cfgSet  = new ses.ConfigurationSet(this, 'AuthCfgSet', {});
const identity = new ses.EmailIdentity(this, 'DomainId', {
  identity: ses.Identity.domain('callanalyst.app'), configurationSet: cfgSet,
});                                              // emits 3 DKIM CNAMEs
const repTopic = new sns.Topic(this, 'SesEvents');   // bounce/complaint → suppress
new ses.ConfigurationSetEventDestination(this, 'BounceDest', {
  configurationSet: cfgSet, destination: ses.EventDestination.snsTopic(repTopic),
  events: [BOUNCE, COMPLAINT, DELIVERY, OPEN, CLICK] });

// CustomEmailSender needs a KMS key (Cognito hands you the OTP KMS-encrypted)
const codeKey = new kms.Key(this, 'CognitoCodeKey', { removalPolicy: RETAIN });
const emailSender = new lambdaNode.NodejsFunction(this, 'EmailSender', { entry:'lambda/email-sender.ts' });
codeKey.grantDecrypt(emailSender);
emailSender.addToRolePolicy(new iam.PolicyStatement({ actions:['ses:SendEmail'], resources:['*'] }));
// on the pool:  customSenderKmsKey: codeKey,  lambdaTriggers: { customEmailSender: emailSender }

Lambda: AWS Encryption SDK decrypts event.request.code, branch on event.triggerSource (CustomEmailSender_SignUp / _ForgotPassword / …), build HTML, ses.sendEmail. Alt (zero code, cosmetic templates): UserPoolEmail.withSES({sesVerifiedDomain, fromEmail, sesRegion:'us-east-2', configurationSetName}).

Cloudflare DNS (DNS-only / grey-cloud)

TypeNameValue
CNAME ×3<tok>._domainkey.callanalyst.app<tok>.dkim.amazonses.com
TXT (SPF)callanalyst.appv=spf1 include:amazonses.com ~all
TXT (DMARC)_dmarc.callanalyst.appv=DMARC1; p=quarantine; rua=mailto:dmarc@callanalyst.app; adkim=s; aspf=s

SES sandbox = 200/day, verified recipients only → request production (Service Quotas) → 50k/day @14 msg/s, auto-scales. Bounce SNS topic is mandatory — bounce >5% / complaint >0.1% throttles the account. SMS MFA later: SNS role w/ sts:ExternalId, $1/mo sandbox cap — TOTP-first.

C · Cognito ↔ Stripe + TOS / UUID

post-confirmation Lambda — eager create, double-idempotent

export const handler = async (event) => {
  const { sub, email, "custom:stripe_customer_id": existing,
          "custom:tos_accepted_at": tosAt, "custom:tos_version": tosVer } = event.request.userAttributes;
  if (existing) return event;                          // guard 1: already linked
  const customer = await stripe.customers.create(
    { email, metadata: { cognito_sub: sub, tos_accepted_at: tosAt, tos_version: tosVer } },
    { idempotencyKey: `cust_create_${sub}` });           // guard 2: Stripe collapses retries
  await cog.send(new AdminUpdateUserAttributesCommand({ UserPoolId: event.userPoolId,
    Username: event.userName, UserAttributes:[{ Name:"custom:stripe_customer_id", Value: customer.id }] }));
  return event;                                       // MUST return event
};

Eager (every CA user is a billing subject) with lazy-at-first-checkout kept as self-heal. Gotcha: adding a custom attr can silently reset a trigger to None — re-attach.

TOS gate — pre-sign-up Lambda

// client: signUp({ ..., userAttributes:{ "custom:tos_accepted_at": now, "custom:tos_version":"2026-06-01" },
//                  clientMetadata:{ tos_accepted:"true", tos_version:"2026-06-01" } })
export const handler = async (event) => {
  const md = event.request.clientMetadata || {};
  if (md.tos_accepted !== "true" || !event.request.userAttributes["custom:tos_accepted_at"])
    throw new Error("TOS_NOT_ACCEPTED");              // rejects sign-up, string surfaced to client
  return event;
};

checkout + webhook (Cognito JWT)

const verifier = CognitoJwtVerifier.create({ userPoolId, tokenUse:"access", clientId }); // aws-jwt-verify, caches JWKS, build OUTSIDE handler
const claims   = await verifier.verify(bearer);          // ~2ms vs Supabase ~30-60ms; throws→401
const session  = await stripe.checkout.sessions.create({ mode:"subscription",
  customer: claims["custom:stripe_customer_id"], line_items:[{ price: PRICE[tier], quantity:1 }],
  subscription_data:{ metadata:{ cognito_sub: claims.sub } }, success_url, cancel_url });
// webhook: cognito_sub = obj.metadata.cognito_sub ?? (await stripe.customers.retrieve(obj.customer)).metadata.cognito_sub
//          → setEntitlement(cognito_sub, tier, status)  in DynamoDB
Entitlement → DynamoDB, not Cognito. Table PK=cognito_sub, attrs {tier,status,stripe_customer_id,stripe_sub_id,current_period_end,updated_at}, GSI on stripe_customer_id. Webhooks need fast idempotent writes (dedupe on event.id); /api/ask reads tier on the hot path. Cognito attrs are slow to mutate, eventual in tokens, 2500-char cap. Cognito holds only the link + legal attrs. Cookie consent = checkbox + banner → custom:cookie_consent_at (anon → cookie, backfill at sign-up).

D · Sequence diagrams + timings p50 / p95

1 · Registration

sequenceDiagram
autonumber
participant B as Browser
participant Cog as Cognito
participant L as Lambda
participant Stripe
participant SES
participant DDB as DynamoDB
B->>Cog: SignUp(email,pw) ~120/300ms
Cog->>L: PreSignUp (TOS gate) ~40/120ms
L-->>Cog: allow
Cog->>SES: verification code ~250/700ms (SLOWEST, async)
Cog-->>B: CONFIRM_REQUIRED
B->>Cog: ConfirmSignUp(code) ~110/280ms
Cog->>L: PostConfirmation ~45/140ms
L->>DDB: PutItem profile{sub} ~8/25ms
L->>Stripe: customers.create(meta.cognito_sub) ~180/450ms
L->>DDB: UpdateItem stripe_customer_id ~8/25ms
L->>SES: welcome (Pro-branched) ~200/600ms

2 · Login + refresh

sequenceDiagram
autonumber
participant B as Browser
participant Cog as Cognito
B->>Cog: InitiateAuth USER_SRP_AUTH ~150/400ms (SLOWEST)
Cog-->>B: PASSWORD_VERIFIER challenge
B->>Cog: RespondToAuthChallenge (SRP proof)
Cog-->>B: id+access+refresh JWT (RS256)
Note over B: access TTL 1h, refresh 30d
B->>Cog: REFRESH_TOKEN_AUTH ~90/220ms (silent)
Cog-->>B: new id+access JWT

3 · Authenticated /api/ask (streaming)

sequenceDiagram
autonumber
participant B as Browser
participant CF as CloudFront
participant L as Lambda(ask)
participant J as Cognito JWKS
participant DDB as DynamoDB
participant AI as Anthropic/OpenAI
B->>CF: POST /api/ask + Bearer ~5ms edge
CF->>L: forward ~10/30ms
L->>J: JWKS (cached >99%) ~2ms / ~120ms cold
L->>L: verify RS256 + claims ~2/4ms
L->>DDB: GetItem tier + quota ~16/50ms
L->>AI: messages stream=true first-token ~600/1400ms (SLOWEST)
AI-->>B: SSE tokens ~30 tok/s
L->>DDB: bump_usage (post-stream) ~10/30ms

JWKS-local verify ~2ms replaces Supabase /auth/v1/user ~30-60ms network call on every authed hop — net latency win.

4 · Notetaker bot

sequenceDiagram
autonumber
participant B as Browser
participant L as Lambda
participant R as Recall
participant M as Zoom/Meet
participant WH as recall-webhook
participant DDB as DynamoDB
B->>L: POST /api/recall/bot (JWT ~2ms)
L->>R: POST /bot (join_url, streaming, logo) ~250/600ms
L->>DDB: PutItem bot ~8/25ms
R->>M: bot joins ~5-15s (human-scale, SLOWEST)
loop live transcript
  R->>WH: webhook chunk (Svix HMAC ~1ms) ~50/150ms
  WH->>DDB: PutItem chunk ~8/25ms
end
loop client poll 2-3s
  B->>L: GET /api/recall/chunks?since= ~12ms
end

5 · Calendar auto-join

sequenceDiagram
autonumber
participant B as Browser
participant L as Lambda
participant G as Google
participant P as calendar-poll(cron */2m)
participant R as Recall
participant DDB as DynamoDB
B->>L: oauth-start (JWT ~2ms)
L-->>B: redirect to Google
G->>L: callback?code
L->>G: exchange code→refresh_token ~200/500ms
L->>DDB: PutItem connection ~8/25ms
Note over P: every 2 min
P->>G: token refresh + list events(+30m) ~220/600ms (SLOWEST)
P->>R: POST /bot {join_at} ~250/600ms
P->>DDB: PutItem dispatch (dedupe)

6 · Checkout + webhook

sequenceDiagram
autonumber
participant B as Browser
participant L as Lambda(checkout)
participant S as Stripe
participant WH as stripe-webhook
participant DDB as DynamoDB
B->>L: POST /api/checkout (JWT ~2ms)
L->>DDB: GetItem stripe_customer_id ~6/20ms
L->>S: checkout.sessions.create(customer,coupon) ~280/700ms (SLOWEST)
S-->>B: redirect to Checkout
B->>S: pays (human)
S->>WH: checkout.session.completed (sig ~1ms) ~40/120ms
WH->>DDB: GetItem stripe_events (idempotency) ~6/20ms
WH->>S: subscriptions.retrieve (tier) ~200/500ms
WH->>DDB: UpdateItem {tier,status} ~8/25ms

US geofence (CDN)

CloudFront Function on viewer-request (sub-ms, ~$0.10/1M, redirect not bare 403, path-scoped). Built-in geo-restriction (allowlist US) is the zero-code alt but only 403s and can't scope paths.

function handler(event) {
  var request = event.request, uri = request.uri;
  var c = request.headers['cloudfront-viewer-country']; c = c && c.value;
  if (uri.startsWith('/api/') || uri.startsWith('/webhooks/') || uri === '/blocked.html') return request;
  if (c !== 'US') return { statusCode: 302, statusDescription:'Found',
    headers: { location: { value: 'https://' + request.headers.host.value + '/blocked.html' } } };
  return request;
}

Interim on Netlify (CloudFront not live yet): Edge Function reading context.geo.country.code.

export default (request, context) => context.geo?.country?.code !== 'US'
  ? new Response('US only', { status: 403 }) : context.next();
export const config = { path:'/*', excludedPath:['/api/*','/webhooks/*'] };

Soft geofence — VPN-bypassable, not legal-grade. Good enough to park GDPR for US-only launch; pair w/ TOS + "US only" at registration. CloudFront-Viewer-Country is auto-added before viewer-request fns.

Login method — recommendation

OptionAWS-nativeCustom-UI$/mo @100kDXLock-in
Cognito + custom UI pickyesfull$1,350mediocremoderate
Cognito + Hosted UIyespoor$1,350easy/uglymoderate
Clerk fallbacknoexcellent~$1k–1.2kbesthigh
Auth0/Oktanogood$6k–7k+goodhigh+price
WorkOSnogoodSSO-focusedgoodmoderate

Cognito User Pools, custom UI. AWS-native (CDK construct, same IAM/CloudWatch), cheapest credible at scale, Google federation reuses the existing OAuth client. Fallback = Clerk (not Auth0) if Cognito DX (token refresh, custom-auth flows, thin docs) becomes painful. Enable: email/password + Google + TOTP MFA.

Migration (Supabase→Cognito) + open Qs for Saturday

Path

Open questions for you

Researched 2026-06-19 via 5 agents (current AWS/Stripe docs, cited inline). Dense by request. Companions: AWS proposal · refactor progress · build docs. Behind the Daxos gate.