TailSpend Docs

AWS access

TailSpend deploys to a single AWS account from a developer workstation. CDK and the AWS CLI both authenticate against AWS IAM Identity Center (SSO) — there are no long-lived IAM access keys.

This page covers the one-time SSO setup and the day-to-day login flow.

Account

Field Value
AWS account 177754533438
SSO start URL https://fraserbenjamin.awsapps.com/start
SSO region eu-west-2
Default CLI region us-east-1 (where TailSpend is deployed)
Role Administrators

The SSO portal is hosted in eu-west-2; deployed resources (Lambda, API Gateway, DynamoDB, Cognito) all live in us-east-1.

One-time setup

Run aws configure sso to register a named profile and an SSO session. On a Cloud Desktop or any headless machine, pass --use-device-code so the CLI prints a device code instead of trying to open a local browser (the default browser flow uses a 127.0.0.1 callback that won't work over SSH):

aws configure sso --use-device-code

Answer the prompts:

SSO session name (Recommended): fraserbenjamin-sso
SSO start URL [None]: https://fraserbenjamin.awsapps.com/start
SSO region [None]: eu-west-2
SSO registration scopes [sso:account:access]: <enter>

# device-code prompt: open the printed URL on any machine, paste the code,
# sign in, and approve the request.

Default client Region [None]: us-east-1
CLI default output format (json if not specified) [None]: <enter>
Profile name [Administrators-177754533438]: fraserbenjamin

This writes two blocks to ~/.aws/config:

[sso-session fraserbenjamin-sso]
sso_start_url = https://fraserbenjamin.awsapps.com/start
sso_region = eu-west-2
sso_registration_scopes = sso:account:access

[profile fraserbenjamin]
sso_session = fraserbenjamin-sso
sso_account_id = 177754533438
sso_role_name = Administrators
region = us-east-1

Verify the profile works:

aws sts get-caller-identity --profile fraserbenjamin

You should see an arn:aws:sts::177754533438:assumed-role/AWSReservedSSO_Administrators_* ARN.

Daily use

SSO sessions expire (typically every ~8 hours). When a CLI or CDK command starts failing with Token has expired and refresh failed, log back in:

aws sso login --profile fraserbenjamin

Re-run with --use-device-code if you're on a headless host:

aws sso login --profile fraserbenjamin --use-device-code

Using the profile

Either pass --profile fraserbenjamin per command, or export it for the shell session:

export AWS_PROFILE=fraserbenjamin

CDK picks the profile up the same way:

AWS_PROFILE=fraserbenjamin npm run deploy --workspace=@repo/infra

Deploying a specific stage

main.ts resolves the target stage from CDK context (stage), defaulting to dev. Override it with -c stage=.... The flag goes after -- so npm forwards it to cdk:

# dev (default)
AWS_PROFILE=fraserbenjamin npm run deploy --workspace=@repo/infra

# beta or prod
AWS_PROFILE=fraserbenjamin npm run deploy --workspace=@repo/infra -- -c stage=beta
AWS_PROFILE=fraserbenjamin npm run deploy --workspace=@repo/infra -- -c stage=prod

Each invocation only synthesizes one stage, so to deploy multiple stages run the command once per stage.

CDK runs TypeScript as ES modules through tsx. Resolve handler paths from import.meta.url; CommonJS globals such as __dirname are unavailable. The infrastructure tests check ServiceStack for dev, beta and prod. A separate Node ESM process synthesizes the dev stack, including the expense chat worker, to check the deployment loader without repeating the stage matrix. This check skips asset bundling and requires neither Docker nor AWS credentials; deployment still bundles the actual Lambda assets.

Deploying a single stack

npm run deploy deploys all four stacks (Dns, Data, Auth, Service) for the resolved stage. To target one stack, use cdk deploy directly:

AWS_PROFILE=fraserbenjamin npm run cdk --workspace=@repo/infra -- deploy TailSpend-dev-Auth

List the stacks for the current stage:

AWS_PROFILE=fraserbenjamin npm run cdk --workspace=@repo/infra -- list

Troubleshooting

Scan processing and the SQS free tier

Scan submissions invoke tailspend-{stage}-scan-job-worker with Lambda's asynchronous Event invocation type. A 202 confirms durable acceptance into Lambda's internal queue; there is no continuously polled SQS source for new jobs. The producer has lambda:InvokeFunction permission on only its stage's worker.

The worker reserves 10 concurrent executions, retries function errors twice, and retains asynchronous events for at most 16 minutes, matching the processing lease rather than Lambda's six-hour default. Exhausted events go to the existing tailspend-{stage}-scan-jobs-dlq (14-day retention). Unlike the former one-day SQS source retention, Lambda's async event age cannot exceed six hours. Completed/failed job records remain idempotent on sequential redelivery. Monitor Lambda Errors, AsyncEventAge, AsyncEventsDropped and DeadLetterErrors, and the DLQ's ApproximateNumberOfMessagesVisible when diagnosing failures.

The retired scan SQS queues and disabled event source mappings are removed in issue #699. Before removal, all three stages were verified to have disabled mappings and zero visible, in-flight or delayed messages (17 October 2026). The live async worker and its DLQ remain. Replay a DLQ message by invoking the worker asynchronously with the original {userId, jobId, traceHeaders?} JSON body. Do not wrap it in an SQS Records event. Replays of settled jobs are no-ops; leases, saved OCR/model output and recovery checks prevent duplicate paid work.

The September migration retained these queues temporarily for rollback. That rollback window is over. Restoring the old SQS producer would now require a separate reviewed infrastructure change, rather than enabling an idle poller.

September 2026 incident

On September 12, Free Tier reported 859,289 of 1,000,000 SQS requests used, forecasting 2,148,222.5 for the month. Cost Explorer attributed 807,554 requests to receives and 51,490 to attribute reads, versus only 96 sends. US East (N. Virginia) accounted for 758,280 requests; London accounted for 101,009. TailSpend's three scan workers generated approximately 64,800 empty receives per day. Changing only their maximum concurrency would leave substantial idle traffic and would not protect the remaining allowance this month.

With the owner's approval, the unused Mental Fitness project's DynamoDB and SQS mappings in eu-west-2 were also paused (queues empty, no messages sent that month). Their IDs are 9014a842-cd40-4acf-a723-b1b4f97fc667 (DynamoDB) and 376c2cd5-13af-40b0-81e9-3fe5eb13a21f (SQS). These are separate from TailSpend's CDK stacks. Re-enable only when resuming that project; DynamoDB stream records expire after 24 hours, so pausing the stream consumer is not long-term event storage. SQS requests already consumed this month cannot be undone. Future scan traffic should use SQS only when a failed asynchronous job reaches its DLQ.

CDK asset encryption and KMS requests

The stage deployment workflow configures the shared CDK bootstrap asset bucket with S3-managed AES256 encryption (SSE-S3) before publishing new assets. This retains encryption at rest without KMS requests for new bundle/template objects. The deploy role needs s3:PutEncryptionConfiguration on that bucket. The step checks the expected bucket owner and fails the deployment if it cannot apply the setting. No public-access, TLS, or bucket-access policy is relaxed.

Existing SSE-KMS asset versions keep their encryption and remain readable through the existing KMS grants. They are not rewritten; lifecycle expiry removes old versions. Do not remove the old key or decrypt permissions while any deployment or rollback still references those versions. Re-running CDK bootstrap can restore its encryption defaults; the next stage deploy reapplies SSE-S3. This default is managed alongside the bootstrap bucket lifecycle in deploy-stage.yml, outside the application stacks. Uploaders that explicitly select SSE-KMS still use KMS.

Runtime credentials and signing keys remain encrypted SSM SecureStrings. Public configuration and application S3 buckets use non-KMS storage where possible. Avoidable Expo token reads are described in Notifications.

Deployment Storage Controls

Desktop update buckets retain overwritten object versions for seven days and abort incomplete multipart uploads after one day. Current installer keys are not age-expired: the existing release cleanup still keeps three releases per architecture and every file advertised by the live update feed. Private Store submission/review artifacts retain their existing seven-day policy. S3 lifecycle cleanup is asynchronous; the change does not immediately delete objects.

CDK current asset retention remains 90 days for CloudFormation rollback. Browser source-map releases now follow browser build inputs rather than every main commit, avoiding unnecessary hashed bundle uploads on backend-only changes. See CI for the conservative client-only rollout guard that also reduces API and DynamoDB test traffic.

View Markdown source