Blogging platform with advanced tools for arts and sciences.
6

Configure Feed

Select the types of activity you want to include in your feed.

Add staging env

lemma (Jul 7, 2026, 3:05 PM -0500) b49759e9 c0e24835

+214 -12
+2 -1
CLAUDE.md
··· 7 7 - Never run the project directly (pnpm `dev` or `start`). I'll run any build or serve operations manually through `make` targets in a separate terminal. 8 8 - Use `pnpm exec` instead of `npx` to run local binaries (e.g. `pnpm exec vitest run ...`). 9 9 - When listing a `make` target always specify all variables that need to be set like the domain or the AWS profile. 10 - - By default, the AWS profile names for local development and AWS are `localstack` and `production`, respectively. 10 + - By default, the AWS profile names for local development and AWS are `localstack` and `production`, respectively. A third profile, `staging` (IAM Identity Center — start sessions with `aws sso login --profile staging`), targets the staging AWS account. 11 11 - `DOMAIN_NAME` is not needed for local `make` targets (only for production targets). 12 + - `ENV=staging` on any make target selects the staging environment in one variable (implies `AWS_PROFILE=staging`, `PROJECT_NAME=atproto-blog-staging`, `DOMAIN_NAME=staging.lemma.pub`). See `STAGING.md` for setup, bring-up, and teardown. 12 13 - Never suggest actions that cause CloudFormation stack drift (e.g. manually terminating EC2 instances, deleting resources outside of CloudFormation). Always go through CloudFormation to make infrastructure changes. 13 14 14 15 ## Important reference material
+2
PROD.md
··· 1 1 # Production 2 2 3 + For the disposable staging environment in the second AWS account (`staging.lemma.pub`), see `STAGING.md`. 4 + 3 5 ## Additional Production Prerequisites 4 6 5 7 - A registered domain name with Route 53 as the DNS service.
+108
STAGING.md
··· 1 + # Staging 2 + 3 + A disposable staging environment in a **second AWS account** at `staging.lemma.pub`, managed with AWS Organizations. The main account stays the payer (consolidated billing) and keeps the `lemma.pub` hosted zone; the staging account owns only a delegated `staging.lemma.pub` zone and its own copy of every stack. 4 + 5 + All commands below use placeholders like `<staging-account-id>` — never commit real account IDs or email addresses to this file. 6 + 7 + ## One-time: organization and account 8 + 9 + 1. Create the organization (skip if one exists). The account you run this from becomes the management (payer) account: 10 + ```sh 11 + aws organizations create-organization --feature-set ALL --profile production 12 + ``` 13 + 2. Create the staging member account (the email must be unique across all AWS accounts; a `+` alias works): 14 + ```sh 15 + aws organizations create-account --email <you+aws-staging@example.com> --account-name lemma-staging --profile production 16 + aws organizations list-create-account-status --states IN_PROGRESS --profile production # poll until SUCCEEDED 17 + ``` 18 + Consolidated billing is automatic — staging usage appears as a line item on the main account's bill. 19 + 3. Enable **IAM Identity Center** (console, management account, one time): create your user, an `AdministratorAccess` permission set, and assign both to the staging account. Then configure the CLI profile: 20 + ```sh 21 + aws configure sso # session name e.g. "lemma", start URL from Identity Center, account lemma-staging, profile name "staging" 22 + ``` 23 + Sessions are short-lived; start each work session with: 24 + ```sh 25 + aws sso login --profile staging 26 + ``` 27 + 4. Recommended: create an AWS Budget in the management account scoped to the staging linked account (e.g. alert at $20/month) as a safety net for forgotten teardowns. 28 + 29 + ## One-time: DNS delegation 30 + 31 + 1. Create the staging hosted zone **in the staging account**. This zone is created outside CloudFormation on purpose: `teardown-aws` never touches it, so the delegation below survives every rebuild cycle (a hosted zone costs $0.50/month). 32 + ```sh 33 + aws route53 create-hosted-zone --name staging.lemma.pub --caller-reference "staging-$(date +%s)" --profile staging 34 + ``` 35 + Note the four name servers in `DelegationSet.NameServers`. 36 + 2. Delegate from the main account by adding one NS record set to the `lemma.pub` zone: 37 + ```sh 38 + aws route53 change-resource-record-sets --profile production \ 39 + --hosted-zone-id <lemma-pub-zone-id> \ 40 + --change-batch '{ 41 + "Changes": [{ 42 + "Action": "CREATE", 43 + "ResourceRecordSet": { 44 + "Name": "staging.lemma.pub", 45 + "Type": "NS", 46 + "TTL": 300, 47 + "ResourceRecords": [ 48 + {"Value": "<ns-1>"}, {"Value": "<ns-2>"}, {"Value": "<ns-3>"}, {"Value": "<ns-4>"} 49 + ] 50 + } 51 + }] 52 + }' 53 + ``` 54 + 3. Verify: `dig NS staging.lemma.pub` returns the staging zone's name servers. 55 + 56 + ## Bring-up 57 + 58 + `ENV=staging` on any make target selects the staging account, project name, and domain in one variable (`AWS_PROFILE=staging`, `PROJECT_NAME=atproto-blog-staging`, `DOMAIN_NAME=staging.lemma.pub`). The distinct `PROJECT_NAME` matters: S3 bucket names are globally unique and production owns the `atproto-blog*` names. 59 + 60 + 1. Base stacks + code (the `aws` target runs `deploy-aws.sh`, which reads its configuration from the environment): 61 + ```sh 62 + aws sso login --profile staging 63 + export IFRAMELY_API_KEY=<staging-or-shared-key> # mind shell history (HISTCONTROL) 64 + AWS_PROFILE=staging PROJECT_NAME=atproto-blog-staging API_DID=did:web:staging.lemma.pub make -C infra aws 65 + ``` 66 + 2. Domain, TLS, CDN, DNS records, and the SPA: 67 + ```sh 68 + make -C infra setup-aws-domain ENV=staging 69 + ``` 70 + This issues the ACM cert (DNS-validated against the delegated zone), deploys WAF + CloudFront, creates the `staging.lemma.pub` and `api.staging.lemma.pub` records, redeploys the API with the right `AllowedOrigin`, and deploys the site. `did.json` and `oauth-client-metadata.json` are generated for `staging.lemma.pub` into the build output — the committed files in `public/` keep production values. 71 + 3. Everyday update targets take `ENV=staging` too, e.g.: 72 + ```sh 73 + make -C infra update-aws-api ENV=staging 74 + make -C infra deploy-site ENV=staging 75 + make -C infra create-aws-api ENV=staging 76 + ``` 77 + 78 + ### Stripe (test mode) 79 + 80 + Staging uses Stripe **test mode** with its own webhook endpoint — production keys never enter the staging account: 81 + 82 + 1. Stripe Dashboard (test mode): create the Pro product/price, and a webhook endpoint `https://api.staging.lemma.pub/webhooks/stripe` with events `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`. Save a default Customer Portal configuration in test mode. 83 + 2. ```sh 84 + make -C infra create-aws-secret ENV=staging STRIPE_PRICE_ID=<test price_...> 85 + aws secretsmanager put-secret-value --profile staging --secret-id /blog/stripe-secret-key --secret-string <sk_test_...> 86 + aws secretsmanager put-secret-value --profile staging --secret-id /blog/stripe-webhook-secret --secret-string <whsec_...> 87 + make -C infra create-aws-api ENV=staging 88 + make -C infra update-aws-api ENV=staging 89 + ``` 90 + Test cards: `4242 4242 4242 4242`. Leaving the Stripe secrets/price unset simply disables billing (custom domains ungated) — fine for tests that don't touch billing. 91 + 92 + ## Teardown 93 + 94 + ```sh 95 + make -C infra teardown-aws ENV=staging CONFIRM=atproto-blog-staging 96 + ``` 97 + 98 + Deletes every stack in reverse dependency order, deleting CloudFront distribution tenants and emptying S3 buckets first, and force-deletes the SecretsManager secrets so an immediate rebuild doesn't collide with names scheduled for deletion. Guard rails: refuses `AWS_PROFILE=production` or `DOMAIN_NAME=lemma.pub`, and requires `CONFIRM` to match the project name exactly. 99 + 100 + **Kept on purpose:** the `staging.lemma.pub` hosted zone (and the NS delegation in the main account). Rebuilding is just the two Bring-up steps again. 101 + 102 + ## Notes 103 + 104 + - A full teardown/bring-up cycle is roughly half an hour each way — CloudFront distribution creation/deletion dominates; ACM issuance is a few minutes once DNS resolves. 105 + - The staging indexer consumes the same public Jetstream firehose as production, so staging indexes real public publications into its own DynamoDB table. Expected and harmless. 106 + - Branding strings (ToS/FAQ copy, footer links) still say `lemma.pub` on staging — cosmetic only. 107 + - The WAF WebACL (including its geo-block rules) is deployed to staging unchanged. 108 + - OAuth sign-in on staging works because `did:web:staging.lemma.pub` resolves via the generated `did.json` and the PDS fetches `https://staging.lemma.pub/oauth-client-metadata.json` live.
+102 -11
infra/Makefile
··· 2 2 3 3 .EXPORT_ALL_VARIABLES: 4 4 5 + # ENV=staging selects the staging environment (second AWS account) in one 6 + # variable: make -C infra <target> ENV=staging. Command-line variable 7 + # assignments still override these. See STAGING.md. 8 + ifeq ($(ENV),staging) 9 + AWS_PROFILE = staging 10 + PROJECT_NAME = atproto-blog-staging 11 + DOMAIN_NAME = staging.lemma.pub 12 + ALLOWED_ORIGIN = https://staging.lemma.pub 13 + endif 14 + 5 15 AWS_PROFILE ?= localstack 6 16 AWS_REGION ?= us-east-1 7 17 ··· 59 69 create-aws-acm \ 60 70 create-aws-route53 \ 61 71 setup-aws-domain \ 72 + teardown-aws \ 73 + generate-site-metadata \ 62 74 update-local-api update-aws-api \ 63 75 install-indexer build-indexer \ 64 76 upload-run-local-indexer upload-indexer-aws \ ··· 99 111 @echo " run-local-backfill - Upload dids.txt to LocalStack trigger bucket (DIDS_FILE=...)" 100 112 @echo " run-aws-backfill - Upload dids.txt to AWS trigger bucket (DIDS_FILE=...)" 101 113 @echo " logs-aws-backfill - Tail the most recent backfill Lambda CloudWatch log stream" 114 + @echo " teardown-aws - Delete ALL stacks for this environment (requires CONFIRM=<PROJECT_NAME>; refuses production)" 115 + @echo " generate-site-metadata- Rewrite did.json + oauth-client-metadata.json in build/client/ for DOMAIN_NAME" 102 116 @echo " build-site - Build the SPA with VITE_API_DID/VITE_API_URL derived from DOMAIN_NAME" 103 117 @echo " deploy-site - Build, sync, and invalidate CloudFront cache (requires DOMAIN_NAME, AWS_PROFILE)" 104 118 @echo " invalidate-site - Invalidate CloudFront cache for the site distribution" ··· 436 450 SITE_SUBDOMAIN=$(SITE_SUBDOMAIN) \ 437 451 API_SUBDOMAIN=$(API_SUBDOMAIN) && \ 438 452 $(MAKE) create-aws-api ALLOWED_ORIGIN=https://$(DOMAIN_NAME) && \ 439 - jq --arg id "did:web:$(DOMAIN_NAME)" \ 440 - --arg url "https://$(API_SUBDOMAIN).$(DOMAIN_NAME)" \ 441 - '.id = $$id | .service[0].serviceEndpoint = $$url' \ 442 - ../public/.well-known/did.json > /tmp/did.json && \ 443 - mv /tmp/did.json ../public/.well-known/did.json && \ 444 - jq --arg domain "$(DOMAIN_NAME)" \ 445 - --arg scope "atproto repo:site.standard.publication repo:site.standard.document repo:site.standard.graph.subscription repo:app.bsky.feed.post?action=create&action=update&action=delete repo:app.bsky.feed.like rpc:com.atproto.server.getServiceAuth?aud=* blob:image/jpeg blob:image/png blob:image/gif blob:image/webp" \ 446 - '.client_id = ("https://" + $$domain + "/oauth-client-metadata.json") | .client_uri = ("https://" + $$domain) | .redirect_uris = [("https://" + $$domain + "/")] | .scope = $$scope' \ 447 - ../public/oauth-client-metadata.json > /tmp/oauth-client-metadata.json && \ 448 - mv /tmp/oauth-client-metadata.json ../public/oauth-client-metadata.json && \ 449 453 $(MAKE) deploy-site 450 454 @echo "✅ Custom domain $(DOMAIN_NAME) setup complete" 451 455 456 + # Deletes every stack in this environment, in reverse dependency order. 457 + # Intended for the staging account (make teardown-aws ENV=staging 458 + # CONFIRM=atproto-blog-staging). The Route 53 hosted zone is NOT touched — 459 + # it lives outside CloudFormation so NS delegation survives rebuild cycles. 460 + # Guard rails: refuses the production profile/domain and requires CONFIRM 461 + # to match PROJECT_NAME exactly. 462 + teardown-aws: 463 + @[ "$(AWS_PROFILE)" != "production" ] || (echo "❌ Refusing to tear down the production account."; exit 1) 464 + @[ "$(DOMAIN_NAME)" != "lemma.pub" ] || (echo "❌ Refusing to tear down lemma.pub."; exit 1) 465 + @[ "$(CONFIRM)" = "$(PROJECT_NAME)" ] || (echo "❌ Set CONFIRM=$(PROJECT_NAME) to confirm teardown of every $(PROJECT_NAME) stack in profile '$(AWS_PROFILE)'."; exit 1) 466 + @echo "🗑️ Tearing down $(PROJECT_NAME) in profile '$(AWS_PROFILE)'..." 467 + @echo "→ Deleting custom-domain distribution tenants..." 468 + @TENANT_DIST_ID=$$(aws cloudformation describe-stacks \ 469 + --profile $${AWS_PROFILE} --region $${AWS_REGION} \ 470 + --stack-name $(CLOUDFRONT_STACK_NAME) \ 471 + --query 'Stacks[0].Outputs[?OutputKey==`TenantDistributionId`].OutputValue' \ 472 + --output text 2>/dev/null || echo ''); \ 473 + if [ -n "$$TENANT_DIST_ID" ] && [ "$$TENANT_DIST_ID" != "None" ]; then \ 474 + for ID in $$(aws cloudfront list-distribution-tenants --profile $${AWS_PROFILE} \ 475 + --association-filter DistributionId=$$TENANT_DIST_ID \ 476 + --query 'DistributionTenantList[].Id' --output text 2>/dev/null); do \ 477 + ETAG=$$(aws cloudfront get-distribution-tenant --profile $${AWS_PROFILE} --identifier "$$ID" --query 'ETag' --output text); \ 478 + DOMS=$$(aws cloudfront get-distribution-tenant --profile $${AWS_PROFILE} --identifier "$$ID" --query 'DistributionTenant.Domains[].Domain' --output json); \ 479 + aws cloudfront update-distribution-tenant --profile $${AWS_PROFILE} \ 480 + --id "$$ID" --if-match "$$ETAG" --no-enabled \ 481 + --domains "$$(echo $$DOMS | jq '[.[] | {Domain: .}]')" > /dev/null; \ 482 + NEW_ETAG=$$(aws cloudfront get-distribution-tenant --profile $${AWS_PROFILE} --identifier "$$ID" --query 'ETag' --output text); \ 483 + aws cloudfront delete-distribution-tenant --profile $${AWS_PROFILE} --id "$$ID" --if-match "$$NEW_ETAG"; \ 484 + echo " deleted tenant $$ID"; \ 485 + done; \ 486 + fi 487 + @echo "→ Deleting Route 53 records stack (hosted zone is kept)..." 488 + -aws cloudformation delete-stack --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(ROUTE53_STACK_NAME) 489 + -aws cloudformation wait stack-delete-complete --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(ROUTE53_STACK_NAME) 490 + @echo "→ Deleting indexer stack..." 491 + -aws cloudformation delete-stack --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(INDEXER_STACK_NAME) 492 + -aws cloudformation wait stack-delete-complete --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(INDEXER_STACK_NAME) 493 + @echo "→ Emptying backfill trigger bucket and deleting backfill stack..." 494 + -aws s3 rm --profile $${AWS_PROFILE} s3://$(BACKFILL_TRIGGER_BUCKET) --recursive 2>/dev/null 495 + -aws cloudformation delete-stack --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(BACKFILL_STACK_NAME) 496 + -aws cloudformation wait stack-delete-complete --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(BACKFILL_STACK_NAME) 497 + @echo "→ Deleting CloudFront stack (slow — distributions must disable first)..." 498 + -aws cloudformation delete-stack --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(CLOUDFRONT_STACK_NAME) 499 + -aws cloudformation wait stack-delete-complete --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(CLOUDFRONT_STACK_NAME) 500 + @echo "→ Deleting WAF stack (us-east-1)..." 501 + -aws cloudformation delete-stack --profile $${AWS_PROFILE} --region $(WAF_REGION) --stack-name $(WAF_STACK_NAME) 502 + -aws cloudformation wait stack-delete-complete --profile $${AWS_PROFILE} --region $(WAF_REGION) --stack-name $(WAF_STACK_NAME) 503 + @echo "→ Deleting ACM stack (us-east-1)..." 504 + -aws cloudformation delete-stack --profile $${AWS_PROFILE} --region $(ACM_REGION) --stack-name $(ACM_STACK_NAME) 505 + -aws cloudformation wait stack-delete-complete --profile $${AWS_PROFILE} --region $(ACM_REGION) --stack-name $(ACM_STACK_NAME) 506 + @echo "→ Deleting API stack..." 507 + -aws cloudformation delete-stack --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(PROJECT_NAME)-api-stack 508 + -aws cloudformation wait stack-delete-complete --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(PROJECT_NAME)-api-stack 509 + @echo "→ Deleting secrets stack and force-deleting secrets (avoids name collisions on rebuild)..." 510 + -aws cloudformation delete-stack --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(PROJECT_NAME)-secrets-stack 511 + -aws cloudformation wait stack-delete-complete --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(PROJECT_NAME)-secrets-stack 512 + -aws secretsmanager delete-secret --profile $${AWS_PROFILE} --region $${AWS_REGION} --secret-id /blog/iframely-api-key --force-delete-without-recovery 2>/dev/null 513 + -aws secretsmanager delete-secret --profile $${AWS_PROFILE} --region $${AWS_REGION} --secret-id /blog/stripe-secret-key --force-delete-without-recovery 2>/dev/null 514 + -aws secretsmanager delete-secret --profile $${AWS_PROFILE} --region $${AWS_REGION} --secret-id /blog/stripe-webhook-secret --force-delete-without-recovery 2>/dev/null 515 + @echo "→ Emptying and deleting artifacts + site buckets..." 516 + -aws s3 rm --profile $${AWS_PROFILE} s3://$(ARTIFACTS_BUCKET_NAME) --recursive 2>/dev/null 517 + -aws cloudformation delete-stack --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(PROJECT_NAME)-artifacts-stack 518 + -aws cloudformation wait stack-delete-complete --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(PROJECT_NAME)-artifacts-stack 519 + -aws s3 rm --profile $${AWS_PROFILE} s3://$(BUCKET_NAME) --recursive 2>/dev/null 520 + -aws cloudformation delete-stack --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(PROJECT_NAME)-s3-stack 521 + -aws cloudformation wait stack-delete-complete --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(PROJECT_NAME)-s3-stack 522 + @echo "→ Deleting DynamoDB stack..." 523 + -aws cloudformation delete-stack --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(PROJECT_NAME)-dynamodb-stack 524 + -aws cloudformation wait stack-delete-complete --profile $${AWS_PROFILE} --region $${AWS_REGION} --stack-name $(PROJECT_NAME)-dynamodb-stack 525 + @echo "✅ Teardown of $(PROJECT_NAME) complete (hosted zone preserved)" 526 + 452 527 update-local-api: 453 528 @echo "🔧 Deploying blog API lambda to LocalStack..." 454 529 cd api && npm run build && npm run zip ··· 674 749 @[ -n "$(DOMAIN_NAME)" ] || (echo "❌ DOMAIN_NAME is required (e.g. make build-site DOMAIN_NAME=example.com)."; exit 1) 675 750 VITE_API_URL=$(VITE_API_URL) VITE_API_DID=$(API_DID) VITE_SITE_DOMAIN=$(DOMAIN_NAME) pnpm --dir .. build 676 751 677 - deploy-site: build-site sync-site invalidate-site invalidate-tenants 752 + # Rewrites did.json and oauth-client-metadata.json in the build output for 753 + # DOMAIN_NAME. The committed files in public/ hold production values and are 754 + # never modified, so staging and production deploys don't interfere. The 755 + # OAuth scope is domain-independent and copied through from the committed file. 756 + generate-site-metadata: 757 + @[ -n "$(DOMAIN_NAME)" ] || (echo "❌ DOMAIN_NAME is required."; exit 1) 758 + @echo "📝 Generating site metadata for $(DOMAIN_NAME)..." 759 + @jq --arg id "did:web:$(DOMAIN_NAME)" \ 760 + --arg url "https://$(API_SUBDOMAIN).$(DOMAIN_NAME)" \ 761 + '.id = $$id | .service[0].serviceEndpoint = $$url' \ 762 + ../public/.well-known/did.json > ../build/client/.well-known/did.json 763 + @jq --arg domain "$(DOMAIN_NAME)" \ 764 + '.client_id = ("https://" + $$domain + "/oauth-client-metadata.json") | .client_uri = ("https://" + $$domain) | .redirect_uris = [("https://" + $$domain + "/")]' \ 765 + ../public/oauth-client-metadata.json > ../build/client/oauth-client-metadata.json 766 + @echo "✅ Site metadata generated in build/client/" 767 + 768 + deploy-site: build-site generate-site-metadata sync-site invalidate-site invalidate-tenants 678 769 679 770 invalidate-site: 680 771 @[ -n "$(DOMAIN_NAME)" ] || (echo "❌ DOMAIN_NAME is required."; exit 1)