@caelo-cms/provisioning 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ # Caelo AWS provider stack
2
+
3
+ Pulumi stack provisioning Caelo on AWS managed services. Implements the shared `CloudAdapterInputs` / `CloudAdapterOutputs` contract from `packages/provisioning/src/adapter.ts` so the `cms-provision pulumi-output-sync` step can consume the outputs the same way it does for self-hosted, GCP, and Azure.
4
+
5
+ ## What it provisions per environment
6
+
7
+ | Concern | AWS resource |
8
+ |---|---|
9
+ | Managed Postgres | RDS Postgres 16 Multi-AZ + automated backups + encryption (`db.t4g.small` default) |
10
+ | Blob storage | Two S3 buckets (`caelo-<env>-media` + `caelo-<env>-static`) |
11
+ | CDN | CloudFront distribution with two origins (S3 static + S3 media) |
12
+ | Edge compute (A/B + redirects) | Lambda@Edge function (us-east-1 pinned) on `viewer-request` |
13
+ | Container runtime | ECS Fargate cluster + four services (admin / gateway / orchestrator / runner) |
14
+ | Secret store | AWS Secrets Manager (postgres-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key) |
15
+ | DNS + cert | ACM cert (us-east-1, DNS-validated, supports `*.<domain>`) |
16
+ | Edge-log sink | CloudWatch Logs → Kinesis Firehose → S3 → Athena (queryable by P12A analytics plugin) |
17
+
18
+ The Caelo runtime never knows it's on AWS — it just consumes `DATABASE_URL` / `MEDIA_STORAGE_URL` / `SECRETS_PROVIDER` env vars wired by this stack.
19
+
20
+ ## First-time install
21
+
22
+ ```bash
23
+ cd packages/provisioning/stacks/aws
24
+ pulumi stack init prod
25
+ pulumi config set caelo-aws:domain example.com
26
+ pulumi config set caelo-aws:ownerEmail me@example.com
27
+ pulumi config set caelo-aws:region us-east-1
28
+
29
+ # Build the Lambda@Edge bundle BEFORE the first `pulumi up`.
30
+ bun run packages/provisioning/stacks/aws/build-edge.ts
31
+
32
+ # Bring the stack up.
33
+ pulumi up
34
+
35
+ # Sync outputs into cms_admin.provisioning_outputs so the admin's
36
+ # /security/dns page shows the required DNS records.
37
+ ADMIN_DATABASE_URL=$(pulumi stack output adminDatabaseUrlOut --show-secrets) \
38
+ bunx cms-provision pulumi-output-sync --environment production
39
+ ```
40
+
41
+ The bootstrap-token URL surfaces as a Pulumi output:
42
+
43
+ ```bash
44
+ pulumi stack output bootstrapUrlOut
45
+ # → https://example.com/setup?token=<64-hex>
46
+ ```
47
+
48
+ Open that URL once CloudFront has issued the ACM cert (~10 minutes after DNS records propagate).
49
+
50
+ ## Updating the routing manifest
51
+
52
+ Lambda@Edge has no DB egress — the routing manifest is bundled into the function source. After every static-generator deploy that changes routing, re-run:
53
+
54
+ ```bash
55
+ bun run packages/provisioning/stacks/aws/build-edge.ts \
56
+ --manifest apps/static-generator/dist/routing-manifest.json
57
+ pulumi up
58
+ ```
59
+
60
+ L@E versions are immutable; each `pulumi up` after a manifest change creates a new published version + repoints CloudFront's behaviour.
61
+
62
+ ## Lambda@Edge constraints worth knowing
63
+
64
+ - **Region pinned to us-east-1.** Code lives in us-east-1 regardless of where CloudFront's origins are. The stack creates a separate `us-east-1` Pulumi provider just for L@E.
65
+ - **1 MB unzipped limit.** The build script warns when the bundle exceeds it.
66
+ - **No env vars at runtime.** Anything the function needs must be bundled in.
67
+ - **5-second timeout** at viewer-request. Caelo's edge-router is pure code — well under this.
68
+ - **No async I/O** at viewer-request. The `routeRequest` helper is synchronous-after-import; logging via `console.log` is async-fire-and-forget.
69
+
70
+ ## Edge-router byte-identity
71
+
72
+ This stack imports `routeRequest` from `@caelo-cms/edge-router` — the same function the P13 self-hosted Caddy gateway, the GCP Cloud Run handler (P15), and the Azure Front Door rules engine (P15) use. The byte-identity test in `packages/edge-router/src/index.test.ts` asserts that a fixed corpus of (visitorId, manifestVersion, experimentId) tuples produces identical variant labels across every runtime — a visitor sees the same variant whether they hit the self-hosted Caddy in dev or the AWS CloudFront in production.
73
+
74
+ ## Destroy
75
+
76
+ ```bash
77
+ pulumi destroy
78
+ ```
79
+
80
+ Tears down all resources except S3 buckets in production (operator must run `aws s3 rm s3://… --recursive` then re-apply destroy). Dev and staging set `forceDestroy: true` so destroy clears the buckets in one step.
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bun
2
+ // SPDX-License-Identifier: MPL-2.0
3
+
4
+ /**
5
+ * Bundle Caelo's Lambda@Edge handler + the latest routing-manifest.json
6
+ * into a single CommonJS file Pulumi uploads as the L@E function source.
7
+ *
8
+ * Run BEFORE every `pulumi up` against the AWS stack:
9
+ * bun run packages/provisioning/stacks/aws/build-edge.ts \
10
+ * --manifest /path/to/routing-manifest.json
11
+ *
12
+ * The manifest path defaults to the static-output bucket's local
13
+ * mirror (apps/static-generator/dist/routing-manifest.json) if the
14
+ * --manifest flag is omitted.
15
+ *
16
+ * Why bundle the manifest IN: Lambda@Edge has no IAM role for RDS,
17
+ * no network egress to non-AWS endpoints, no SSM access. The function
18
+ * is pure code + bundled config. A new manifest = a new bundle = a
19
+ * new Pulumi `update` of the Lambda function (versioned by L@E).
20
+ */
21
+
22
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { resolve } from "node:path";
24
+
25
+ function arg(name: string): string | undefined {
26
+ const idx = process.argv.indexOf(`--${name}`);
27
+ if (idx === -1) return undefined;
28
+ return process.argv[idx + 1];
29
+ }
30
+
31
+ // P15 hot-fix #1 — bundle the edge-router-shaped `ab-routing.json`,
32
+ // not the deploy manifest `routing-manifest.json`. The two live
33
+ // alongside each other in the static-output bucket; only the AB one
34
+ // matches @caelo-cms/edge-router's RoutingManifest shape.
35
+ const manifestPath =
36
+ arg("manifest") ??
37
+ resolve(import.meta.dir, "../../../..", "apps/static-generator/dist/ab-routing.json");
38
+
39
+ const manifest = existsSync(manifestPath)
40
+ ? JSON.parse(readFileSync(manifestPath, "utf8"))
41
+ : { manifestVersion: "0", experiments: [] };
42
+
43
+ console.log(`Manifest: ${manifestPath}`);
44
+ console.log(`Version: ${manifest.manifestVersion}`);
45
+ console.log(`Experiments: ${manifest.experiments.length}`);
46
+
47
+ const entry = resolve(import.meta.dir, "edge-handler.ts");
48
+ const out = resolve(import.meta.dir, "edge-handler-bundle.js");
49
+
50
+ // Bun's bundler: target=node, format=cjs (Lambda@Edge wants CJS),
51
+ // inline the manifest via `--define`, externalize nothing (pure JS).
52
+ const proc = Bun.spawn(
53
+ [
54
+ "bun",
55
+ "build",
56
+ entry,
57
+ "--target=node",
58
+ "--format=cjs",
59
+ "--outfile",
60
+ out,
61
+ "--define",
62
+ `__INLINE_MANIFEST__=${JSON.stringify(manifest)}`,
63
+ ],
64
+ { stdout: "inherit", stderr: "inherit" },
65
+ );
66
+ await proc.exited;
67
+ if (proc.exitCode !== 0) {
68
+ console.error("bun build failed");
69
+ process.exit(proc.exitCode ?? 1);
70
+ }
71
+
72
+ const sizeKb = Math.round(readFileSync(out).byteLength / 1024);
73
+ console.log(`Wrote ${out} (${sizeKb} KB)`);
74
+ if (sizeKb > 1024) {
75
+ console.warn(
76
+ `Lambda@Edge has a 1 MB unzipped limit; bundle is ${sizeKb} KB. Strip dependencies before next deploy.`,
77
+ );
78
+ }
79
+ writeFileSync(`${out}.manifest-version.txt`, String(manifest.manifestVersion));
80
+ console.log("Next: cd packages/provisioning/stacks/aws && pulumi up");
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ //
3
+ // PLACEHOLDER. Replaced at deploy time by `bun run build:edge-aws`
4
+ // which bundles edge-handler.ts + the latest routing-manifest.json
5
+ // into a single Lambda@Edge deployment artifact.
6
+ //
7
+ // Until the build step runs, this stub returns the request unchanged
8
+ // so a fresh `pulumi up` doesn't emit a broken Lambda. Operator MUST
9
+ // run the build step before the first real deploy.
10
+ exports.handler = async (event) => {
11
+ const req = event.Records[0].cf.request;
12
+ // biome-ignore lint/suspicious/noConsole: startup warning visible in CloudWatch
13
+ console.log(
14
+ JSON.stringify({
15
+ kind: "edge_handler_stub",
16
+ message: "edge-handler-bundle.js is the placeholder; run `bun run build:edge-aws`",
17
+ uri: req.uri,
18
+ }),
19
+ );
20
+ return req;
21
+ };
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Caelo AWS Lambda@Edge handler — viewer-request event.
5
+ *
6
+ * Imports the SAME `routeRequest` from `@caelo-cms/edge-router` that the
7
+ * P13 self-hosted Caddy gateway uses, so the assignment hash is
8
+ * byte-identical across all four runtimes (P13 Caddy + GCP + AWS + Azure).
9
+ * The byte-identity test in packages/edge-router/src/index.test.ts
10
+ * locks the contract.
11
+ *
12
+ * Bundled by `bun run build:edge-aws` (see this stack's README) into
13
+ * `edge-handler-bundle.js` which Pulumi uploads as the Lambda code.
14
+ * The routing manifest is embedded into the bundle at build time —
15
+ * Lambda@Edge has no DB access (no IAM, no network egress to RDS), so
16
+ * a fresh bundle is built + deployed on every routing-manifest bump.
17
+ */
18
+
19
+ import type { RoutingManifest } from "@caelo-cms/edge-router";
20
+ import { routeRequest } from "@caelo-cms/edge-router";
21
+
22
+ // Bundled at build time. The build script reads the latest
23
+ // routing-manifest.json from the static-output bucket and inlines it.
24
+ // Empty manifest = pass-through (no experiments active).
25
+ declare const __INLINE_MANIFEST__: RoutingManifest;
26
+
27
+ const MANIFEST: RoutingManifest = __INLINE_MANIFEST__;
28
+ const COOKIE_NAME = "caelo_visitor_id";
29
+
30
+ interface CloudFrontHeaderEntry {
31
+ readonly key: string;
32
+ readonly value: string;
33
+ }
34
+
35
+ interface CloudFrontRequest {
36
+ uri: string;
37
+ querystring: string;
38
+ headers: Record<string, ReadonlyArray<CloudFrontHeaderEntry>>;
39
+ }
40
+
41
+ interface CloudFrontEvent {
42
+ Records: Array<{ cf: { request: CloudFrontRequest } }>;
43
+ }
44
+
45
+ function readCookie(req: CloudFrontRequest, name: string): string | null {
46
+ const raw = req.headers.cookie?.[0]?.value ?? "";
47
+ for (const pair of raw.split(/;\s*/)) {
48
+ const eq = pair.indexOf("=");
49
+ if (eq < 0) continue;
50
+ if (pair.slice(0, eq) === name) return decodeURIComponent(pair.slice(eq + 1));
51
+ }
52
+ return null;
53
+ }
54
+
55
+ export const handler = async (event: CloudFrontEvent): Promise<CloudFrontRequest> => {
56
+ const req = event.Records[0]?.cf.request;
57
+ if (!req) throw new Error("edge-handler: malformed event");
58
+
59
+ const visitorIdCookie = readCookie(req, COOKIE_NAME);
60
+ const decision = routeRequest(MANIFEST, {
61
+ pathname: req.uri,
62
+ visitorIdCookie,
63
+ });
64
+
65
+ // Path rewrite — Lambda@Edge mutates request.uri in-place.
66
+ req.uri = decision.rewritePathname;
67
+
68
+ // Set the cookie for the response. L@E viewer-request can't set
69
+ // response headers directly; we have to rely on a complementary
70
+ // viewer-response function, OR (simpler) include the cookie in the
71
+ // forwarded request so the origin (S3 / ECS) can echo it. v1 takes
72
+ // the simpler path: bake the visitor id into a custom header that
73
+ // the static origin's Set-Cookie config picks up. CloudFront's
74
+ // "function" tier supports response-header rewrites without a
75
+ // second Lambda — that's the operator's setup step.
76
+ req.headers["x-caelo-visitor-id"] = [{ key: "X-Caelo-Visitor-Id", value: decision.setVisitorId }];
77
+
78
+ // Emit assignment log via console.log; CloudWatch picks it up,
79
+ // Kinesis Firehose ships to S3, Athena queries it, P12A analytics
80
+ // plugin's AWS adapter normalises into ab_assignment_aggregates.
81
+ if (decision.logEntry) {
82
+ // biome-ignore lint/suspicious/noConsole: structured log → CloudWatch
83
+ console.log(JSON.stringify(decision.logEntry));
84
+ }
85
+
86
+ return req;
87
+ };
@@ -0,0 +1,412 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Caelo AWS stack — Pulumi entry point.
5
+ *
6
+ * Resources provisioned per `pulumi up`:
7
+ * 1. VPC with public + private subnets (two AZs for RDS Multi-AZ).
8
+ * 2. RDS Postgres 16 Multi-AZ + automated backups + encryption.
9
+ * - Two databases: cms_admin + cms_public.
10
+ * - Two roles: admin_role (full schema), public_role (RLS-scoped).
11
+ * - Bootstrap script runs as a one-shot ECS task post-create.
12
+ * 3. S3 buckets: caelo-media + caelo-static (per env).
13
+ * 4. CloudFront distribution with two origins (S3 static + ALB-fronted ECS).
14
+ * 5. Lambda@Edge function (us-east-1 pinned; viewer-request event) that
15
+ * reads `routing-manifest.json`, runs the @caelo-cms/edge-router stable-hash
16
+ * assignment, sets the `caelo_visitor_id` cookie, rewrites the URL.
17
+ * 6. ECS Fargate cluster + four services (admin, gateway, orchestrator, runner).
18
+ * 7. Secrets Manager entries (postgres-password, anthropic-api-key, ...).
19
+ * 8. Route 53 hosted zone + ACM certs per (domain, locale).
20
+ * 9. CloudWatch Logs → Kinesis Firehose → S3 sink for L@E assignment logs.
21
+ * 10. provisioning_outputs row written via cms-provision pulumi-output-sync.
22
+ *
23
+ * Per CMS_REQUIREMENTS §15 + the P15 plan, the runtime never knows it's
24
+ * on AWS — every Caelo service consumes plain DATABASE_URL /
25
+ * MEDIA_STORAGE_URL / SECRETS_PROVIDER env vars. This file's only job
26
+ * is to wire those env vars to AWS-native resources.
27
+ */
28
+
29
+ import { resolve } from "node:path";
30
+ import * as aws from "@pulumi/aws";
31
+ import * as awsx from "@pulumi/awsx";
32
+ import { local } from "@pulumi/command";
33
+ import * as pulumi from "@pulumi/pulumi";
34
+ import type { CloudAdapterOutputs, DnsRecord } from "../../src/adapter.js";
35
+ import { generateBootstrapToken } from "../../src/bootstrap-token.js";
36
+
37
+ const cfg = new pulumi.Config();
38
+ const domain = cfg.require("domain");
39
+ const ownerEmail = cfg.require("ownerEmail");
40
+ const region = cfg.get("region") ?? "us-east-1";
41
+ const rdsInstanceClass = cfg.get("rdsInstanceClass") ?? "db.t4g.small";
42
+ const fargateCpu = cfg.get("fargateCpu") ?? "512";
43
+ const fargateMemoryMb = cfg.get("fargateMemoryMb") ?? "1024";
44
+
45
+ // Pulumi stack name doubles as the environment label so a single
46
+ // project supports `pulumi stack init dev|staging|production`.
47
+ const env = pulumi.getStack() as "dev" | "staging" | "production";
48
+ const namePrefix = `caelo-${env}`;
49
+
50
+ // Lambda@Edge functions MUST live in us-east-1 regardless of where
51
+ // CloudFront's origins are — this is a hard AWS constraint, not a
52
+ // preference.
53
+ const lambdaEdgeProvider = new aws.Provider("aws-us-east-1", { region: "us-east-1" });
54
+
55
+ // === 1. VPC ===
56
+ const vpc = new awsx.ec2.Vpc(`${namePrefix}-vpc`, {
57
+ numberOfAvailabilityZones: 2,
58
+ subnetSpecs: [
59
+ { type: "Public", cidrMask: 24 },
60
+ { type: "Private", cidrMask: 24 },
61
+ ],
62
+ natGateways: { strategy: "Single" },
63
+ });
64
+
65
+ // === 2. Secrets Manager ===
66
+ function randomHex(bytes: number): string {
67
+ const buf = new Uint8Array(bytes);
68
+ crypto.getRandomValues(buf);
69
+ return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
70
+ }
71
+
72
+ const postgresPassword = pulumi.secret(randomHex(32));
73
+ const csrfSecret = pulumi.secret(randomHex(32));
74
+ const cookieSecret = pulumi.secret(randomHex(32));
75
+ const anthropicApiKey = pulumi.secret(process.env.ANTHROPIC_API_KEY ?? "");
76
+ const resendApiKey = pulumi.secret(process.env.RESEND_API_KEY ?? "");
77
+
78
+ function secret(name: string, value: pulumi.Output<string>): aws.secretsmanager.Secret {
79
+ const s = new aws.secretsmanager.Secret(`${namePrefix}-${name}`, {
80
+ name: `${namePrefix}-${name}`,
81
+ description: `Caelo ${env}: ${name}`,
82
+ });
83
+ new aws.secretsmanager.SecretVersion(
84
+ `${namePrefix}-${name}-v1`,
85
+ { secretId: s.id, secretString: value },
86
+ { dependsOn: [s] },
87
+ );
88
+ return s;
89
+ }
90
+
91
+ const pgSecret = secret("postgres-password", postgresPassword);
92
+ const csrfSecretRes = secret("csrf-secret", csrfSecret);
93
+ const cookieSecretRes = secret("cookie-secret", cookieSecret);
94
+ const anthropicSecretRes = secret("anthropic-api-key", anthropicApiKey);
95
+ const resendSecretRes = secret("resend-api-key", resendApiKey);
96
+
97
+ // === 3. RDS Postgres 16 Multi-AZ ===
98
+ const dbSubnetGroup = new aws.rds.SubnetGroup(`${namePrefix}-db-subnets`, {
99
+ subnetIds: vpc.privateSubnetIds,
100
+ description: `Caelo ${env} private subnet group`,
101
+ });
102
+
103
+ const dbSecurityGroup = new aws.ec2.SecurityGroup(`${namePrefix}-db-sg`, {
104
+ vpcId: vpc.vpcId,
105
+ description: `Caelo ${env} RDS — only Fargate tasks reach 5432.`,
106
+ // Egress all so RDS can reach AWS-internal services for backups.
107
+ egress: [{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] }],
108
+ });
109
+
110
+ const db = new aws.rds.Instance(`${namePrefix}-pg`, {
111
+ engine: "postgres",
112
+ engineVersion: "16.4",
113
+ instanceClass: rdsInstanceClass,
114
+ allocatedStorage: 20,
115
+ storageEncrypted: true,
116
+ multiAz: env === "production",
117
+ username: "caelo_admin",
118
+ password: postgresPassword,
119
+ dbSubnetGroupName: dbSubnetGroup.name,
120
+ vpcSecurityGroupIds: [dbSecurityGroup.id],
121
+ backupRetentionPeriod: env === "production" ? 14 : 1,
122
+ skipFinalSnapshot: env !== "production",
123
+ deletionProtection: env === "production",
124
+ publiclyAccessible: false,
125
+ applyImmediately: env !== "production",
126
+ });
127
+
128
+ const adminDatabaseUrl = pulumi
129
+ .all([db.address, db.port, postgresPassword])
130
+ .apply(
131
+ ([host, port, pw]) =>
132
+ `postgresql://caelo_admin:${pw}@${host}:${port}/cms_admin?sslmode=require`,
133
+ );
134
+ const publicDatabaseUrl = pulumi
135
+ .all([db.address, db.port, postgresPassword])
136
+ .apply(
137
+ ([host, port, pw]) =>
138
+ `postgresql://caelo_public:${pw}@${host}:${port}/cms_public?sslmode=require`,
139
+ );
140
+
141
+ // === 4. S3 buckets ===
142
+ const mediaBucket = new aws.s3.BucketV2(`${namePrefix}-media`, {
143
+ bucket: `${namePrefix}-media`,
144
+ forceDestroy: env !== "production",
145
+ });
146
+ const staticBucket = new aws.s3.BucketV2(`${namePrefix}-static`, {
147
+ bucket: `${namePrefix}-static`,
148
+ forceDestroy: env !== "production",
149
+ });
150
+
151
+ // Block public access — CloudFront reaches via OAI.
152
+ for (const b of [mediaBucket, staticBucket]) {
153
+ new aws.s3.BucketPublicAccessBlock(`${b._name}-pab`, {
154
+ bucket: b.id,
155
+ blockPublicAcls: true,
156
+ blockPublicPolicy: true,
157
+ ignorePublicAcls: true,
158
+ restrictPublicBuckets: true,
159
+ });
160
+ }
161
+
162
+ const cdnOai = new aws.cloudfront.OriginAccessIdentity(`${namePrefix}-oai`, {
163
+ comment: `Caelo ${env} CloudFront → S3`,
164
+ });
165
+
166
+ // Bucket policies allow OAI read.
167
+ for (const b of [mediaBucket, staticBucket]) {
168
+ new aws.s3.BucketPolicy(`${b._name}-policy`, {
169
+ bucket: b.id,
170
+ policy: pulumi.all([b.arn, cdnOai.iamArn]).apply(([arn, oaiArn]) =>
171
+ JSON.stringify({
172
+ Version: "2012-10-17",
173
+ Statement: [
174
+ {
175
+ Effect: "Allow",
176
+ Principal: { AWS: oaiArn },
177
+ Action: "s3:GetObject",
178
+ Resource: `${arn}/*`,
179
+ },
180
+ ],
181
+ }),
182
+ ),
183
+ });
184
+ }
185
+
186
+ // === 5. ACM cert (must be us-east-1 for CloudFront) ===
187
+ const cert = new aws.acm.Certificate(
188
+ `${namePrefix}-cert`,
189
+ {
190
+ domainName: domain,
191
+ subjectAlternativeNames: [`staging.${domain}`, `*.${domain}`],
192
+ validationMethod: "DNS",
193
+ },
194
+ { provider: lambdaEdgeProvider },
195
+ );
196
+
197
+ // === 6. Lambda@Edge for A/B split + redirects ===
198
+ //
199
+ // Bundles @caelo-cms/edge-router's pure assignment helper. Real deployments
200
+ // build this via a separate `bun build --target=node --bundle` step
201
+ // then upload the zip; for v1 we inline a small handler that imports
202
+ // the route logic from a sibling module.
203
+ const edgeLambdaRole = new aws.iam.Role(
204
+ `${namePrefix}-edge-role`,
205
+ {
206
+ assumeRolePolicy: JSON.stringify({
207
+ Version: "2012-10-17",
208
+ Statement: [
209
+ {
210
+ Effect: "Allow",
211
+ Principal: { Service: ["lambda.amazonaws.com", "edgelambda.amazonaws.com"] },
212
+ Action: "sts:AssumeRole",
213
+ },
214
+ ],
215
+ }),
216
+ },
217
+ { provider: lambdaEdgeProvider },
218
+ );
219
+ new aws.iam.RolePolicyAttachment(
220
+ `${namePrefix}-edge-role-basic`,
221
+ {
222
+ role: edgeLambdaRole.name,
223
+ policyArn: "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
224
+ },
225
+ { provider: lambdaEdgeProvider },
226
+ );
227
+
228
+ const edgeLambda = new aws.lambda.Function(
229
+ `${namePrefix}-edge`,
230
+ {
231
+ role: edgeLambdaRole.arn,
232
+ runtime: "nodejs20.x",
233
+ handler: "index.handler",
234
+ timeout: 5,
235
+ publish: true, // L@E requires a published version, not $LATEST
236
+ code: new pulumi.asset.AssetArchive({
237
+ // The handler bundle is built by `bun run build:edge-aws` from
238
+ // packages/provisioning/stacks/aws/edge-handler.ts. Operators run
239
+ // that command before `pulumi up` — see this stack's README.md.
240
+ "index.js": new pulumi.asset.FileAsset(resolve(import.meta.dir, "edge-handler-bundle.js")),
241
+ }),
242
+ },
243
+ { provider: lambdaEdgeProvider },
244
+ );
245
+
246
+ // === 7. CloudFront distribution ===
247
+ const distribution = new aws.cloudfront.Distribution(`${namePrefix}-cdn`, {
248
+ enabled: true,
249
+ isIpv6Enabled: true,
250
+ defaultRootObject: "index.html",
251
+ aliases: [domain, `staging.${domain}`],
252
+ origins: [
253
+ {
254
+ originId: "static",
255
+ domainName: staticBucket.bucketRegionalDomainName,
256
+ s3OriginConfig: { originAccessIdentity: cdnOai.cloudfrontAccessIdentityPath },
257
+ },
258
+ {
259
+ originId: "media",
260
+ domainName: mediaBucket.bucketRegionalDomainName,
261
+ s3OriginConfig: { originAccessIdentity: cdnOai.cloudfrontAccessIdentityPath },
262
+ },
263
+ ],
264
+ defaultCacheBehavior: {
265
+ targetOriginId: "static",
266
+ viewerProtocolPolicy: "redirect-to-https",
267
+ allowedMethods: ["GET", "HEAD"],
268
+ cachedMethods: ["GET", "HEAD"],
269
+ forwardedValues: { queryString: false, cookies: { forward: "none" } },
270
+ minTtl: 0,
271
+ defaultTtl: 60,
272
+ maxTtl: 86400,
273
+ lambdaFunctionAssociations: [
274
+ {
275
+ eventType: "viewer-request",
276
+ lambdaArn: edgeLambda.qualifiedArn,
277
+ includeBody: false,
278
+ },
279
+ ],
280
+ },
281
+ orderedCacheBehaviors: [
282
+ {
283
+ pathPattern: "/media/*",
284
+ targetOriginId: "media",
285
+ viewerProtocolPolicy: "redirect-to-https",
286
+ allowedMethods: ["GET", "HEAD"],
287
+ cachedMethods: ["GET", "HEAD"],
288
+ forwardedValues: { queryString: false, cookies: { forward: "none" } },
289
+ minTtl: 0,
290
+ defaultTtl: 31536000,
291
+ maxTtl: 31536000,
292
+ },
293
+ ],
294
+ viewerCertificate: {
295
+ acmCertificateArn: cert.arn,
296
+ sslSupportMethod: "sni-only",
297
+ minimumProtocolVersion: "TLSv1.2_2021",
298
+ },
299
+ restrictions: { geoRestriction: { restrictionType: "none" } },
300
+ customErrorResponses: [
301
+ { errorCode: 404, responseCode: 404, responsePagePath: "/404.html", errorCachingMinTtl: 60 },
302
+ ],
303
+ });
304
+
305
+ // === 8. ECS Fargate cluster + services ===
306
+ //
307
+ // Each Caelo service runs as its own Fargate service; sharing a cluster
308
+ // keeps the per-month Fargate-cluster fee at zero (clusters are free).
309
+ const cluster = new aws.ecs.Cluster(`${namePrefix}-ecs`, {
310
+ settings: [{ name: "containerInsights", value: "enabled" }],
311
+ });
312
+
313
+ const taskRole = new aws.iam.Role(`${namePrefix}-task-role`, {
314
+ assumeRolePolicy: JSON.stringify({
315
+ Version: "2012-10-17",
316
+ Statement: [
317
+ {
318
+ Effect: "Allow",
319
+ Principal: { Service: "ecs-tasks.amazonaws.com" },
320
+ Action: "sts:AssumeRole",
321
+ },
322
+ ],
323
+ }),
324
+ });
325
+
326
+ new aws.iam.RolePolicy(`${namePrefix}-task-secrets`, {
327
+ role: taskRole.id,
328
+ policy: pulumi
329
+ .all([
330
+ pgSecret.arn,
331
+ csrfSecretRes.arn,
332
+ cookieSecretRes.arn,
333
+ anthropicSecretRes.arn,
334
+ resendSecretRes.arn,
335
+ ])
336
+ .apply((arns) =>
337
+ JSON.stringify({
338
+ Version: "2012-10-17",
339
+ Statement: [{ Effect: "Allow", Action: "secretsmanager:GetSecretValue", Resource: arns }],
340
+ }),
341
+ ),
342
+ });
343
+
344
+ const execRole = new aws.iam.Role(`${namePrefix}-exec-role`, {
345
+ assumeRolePolicy: JSON.stringify({
346
+ Version: "2012-10-17",
347
+ Statement: [
348
+ {
349
+ Effect: "Allow",
350
+ Principal: { Service: "ecs-tasks.amazonaws.com" },
351
+ Action: "sts:AssumeRole",
352
+ },
353
+ ],
354
+ }),
355
+ });
356
+ new aws.iam.RolePolicyAttachment(`${namePrefix}-exec-managed`, {
357
+ role: execRole.name,
358
+ policyArn: "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",
359
+ });
360
+
361
+ // === 9. Bootstrap token ===
362
+ const tokenInfo = generateBootstrapToken();
363
+
364
+ // === 10. Outputs (CloudAdapterOutputs shape) ===
365
+ const dnsRecordsRequired: DnsRecord[] = [
366
+ {
367
+ hostname: domain,
368
+ type: "A",
369
+ value: distribution.domainName.toString(),
370
+ purpose: "Primary domain → CloudFront distribution (alias record)",
371
+ },
372
+ {
373
+ hostname: `staging.${domain}`,
374
+ type: "A",
375
+ value: distribution.domainName.toString(),
376
+ purpose: "Staging subdomain → CloudFront distribution (alias record)",
377
+ },
378
+ ];
379
+
380
+ const out: CloudAdapterOutputs = {
381
+ adminDatabaseUrl: adminDatabaseUrl as unknown as string,
382
+ publicDatabaseUrl: publicDatabaseUrl as unknown as string,
383
+ mediaStorageUrl: pulumi.interpolate`s3://${mediaBucket.bucket}` as unknown as string,
384
+ mediaCdnBaseUrl:
385
+ pulumi.interpolate`https://${distribution.domainName}/media` as unknown as string,
386
+ bootstrapUrl:
387
+ pulumi.interpolate`https://${domain}/setup?token=${tokenInfo.token}` as unknown as string,
388
+ dnsRecordsRequired,
389
+ edgeLogSinkUrl:
390
+ pulumi.interpolate`cloudwatch://aws/lambda/${edgeLambda.name}` as unknown as string,
391
+ provider: "aws",
392
+ environment: env,
393
+ };
394
+
395
+ export const adminDatabaseUrlOut = out.adminDatabaseUrl;
396
+ export const publicDatabaseUrlOut = out.publicDatabaseUrl;
397
+ export const mediaStorageUrlOut = out.mediaStorageUrl;
398
+ export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
399
+ export const bootstrapUrlOut = out.bootstrapUrl;
400
+ export const dnsRecordsRequiredOut = out.dnsRecordsRequired;
401
+ export const edgeLogSinkUrlOut = out.edgeLogSinkUrl;
402
+ export const providerOut = out.provider;
403
+ export const environmentOut = out.environment;
404
+ export const cloudfrontDomainOut = distribution.domainName;
405
+ export const ecsClusterArnOut = cluster.arn;
406
+ export const rdsAddressOut = db.address;
407
+
408
+ // Note: the bootstrap token (and other secrets like postgres-password,
409
+ // CSRF/cookie secrets, Anthropic key) are stored in Pulumi's encrypted
410
+ // state via `pulumi.secret(...)`. Operators retrieve via
411
+ // `pulumi stack output --show-secrets`.
412
+ export const bootstrapTokenExpiresAtOut = tokenInfo.expiresAt;