@caelo-cms/provisioning 0.10.41 → 0.10.42

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.
Files changed (52) hide show
  1. package/caddy/Caddyfile.production +1 -3
  2. package/dist/adapter.d.ts +3 -3
  3. package/dist/admin-domain.d.ts +8 -0
  4. package/dist/admin-domain.d.ts.map +1 -1
  5. package/dist/admin-domain.js +2 -2
  6. package/dist/admin-domain.js.map +1 -1
  7. package/dist/azure-front-door.d.ts +29 -0
  8. package/dist/azure-front-door.d.ts.map +1 -0
  9. package/dist/azure-front-door.js +32 -0
  10. package/dist/azure-front-door.js.map +1 -0
  11. package/dist/cli.js +69 -20
  12. package/dist/cli.js.map +1 -1
  13. package/dist/default-firewall.d.ts +12 -9
  14. package/dist/default-firewall.d.ts.map +1 -1
  15. package/dist/default-firewall.js +140 -28
  16. package/dist/default-firewall.js.map +1 -1
  17. package/dist/gcloud.d.ts.map +1 -1
  18. package/dist/gcloud.js +3 -0
  19. package/dist/gcloud.js.map +1 -1
  20. package/dist/internal-token.d.ts +9 -0
  21. package/dist/internal-token.d.ts.map +1 -0
  22. package/dist/internal-token.js +23 -0
  23. package/dist/internal-token.js.map +1 -0
  24. package/dist/lifecycle.js +2 -2
  25. package/dist/lifecycle.js.map +1 -1
  26. package/dist/stack-contract.d.ts +34 -13
  27. package/dist/stack-contract.d.ts.map +1 -1
  28. package/dist/stack-contract.js +45 -15
  29. package/dist/stack-contract.js.map +1 -1
  30. package/dist/stack-converge.d.ts +3 -3
  31. package/dist/stack-converge.js +7 -7
  32. package/dist/stack-converge.js.map +1 -1
  33. package/dist/staging-admin.d.ts +79 -0
  34. package/dist/staging-admin.d.ts.map +1 -0
  35. package/dist/staging-admin.js +223 -0
  36. package/dist/staging-admin.js.map +1 -0
  37. package/dist/wizards/gcp.js +15 -13
  38. package/dist/wizards/gcp.js.map +1 -1
  39. package/package.json +3 -4
  40. package/stacks/aws/Pulumi.yaml +3 -3
  41. package/stacks/aws/README.md +4 -28
  42. package/stacks/aws/index.ts +53 -71
  43. package/stacks/azure/Pulumi.yaml +3 -3
  44. package/stacks/azure/README.md +7 -11
  45. package/stacks/azure/index.ts +132 -42
  46. package/stacks/gcp/Pulumi.yaml +3 -3
  47. package/stacks/gcp/README.md +3 -18
  48. package/stacks/aws/build-edge.ts +0 -80
  49. package/stacks/aws/edge-handler-bundle.js +0 -20
  50. package/stacks/aws/edge-handler.ts +0 -86
  51. package/stacks/azure/edge-handler.ts +0 -87
  52. package/stacks/gcp/edge-handler.ts +0 -105
@@ -8,18 +8,16 @@
8
8
  * 2. Azure Database for PostgreSQL flexible server, zone-redundant in
9
9
  * production. Two databases (cms_admin + cms_public) created via
10
10
  * post-create Container Apps job (bootstrap.sh).
11
- * 3. Storage account + two containers (`media` + `static`); the
12
- * `static` container is `$web`-enabled for static-website hosting.
13
- * 4. Azure Front Door Standard with two origins (static blob + Container
14
- * Apps fronting admin/gateway). Rules engine handles A/B split
15
- * (matches `pageSlug` from the routing manifest, rewrites to the
16
- * variant blob path) and redirects (per-row from the redirects table
17
- * emitted via emitRedirectsAzureFrontDoor).
18
- * 5. Azure Container Apps (admin/gateway/orchestrator/runner + edge-router),
11
+ * 3. Storage account with a private `media` container and static-website
12
+ * hosting (the `$web` container the static generator publishes to).
13
+ * 4. Azure Front Door Standard serving the static website on the primary
14
+ * domain with a managed certificate — files served as files, no compute
15
+ * on the request path (CLAUDE.md §11.B Tier 1).
16
+ * 5. Azure Container Apps (admin/gateway/orchestrator/runner),
19
17
  * mounted with Key Vault secrets via managed identity.
20
18
  * 6. Key Vault entries (postgres-password, csrf-secret, cookie-secret,
21
19
  * anthropic-api-key, resend-api-key).
22
- * 7. Log Analytics workspace receives Front Door + Container Apps logs;
20
+ * 7. Log Analytics workspace receives Container Apps logs;
23
21
  * P12A analytics plugin's Azure adapter queries via monitor-query.
24
22
  * 8. Azure DNS zone for the primary domain (operator may opt out via
25
23
  * config + manage at their existing registrar).
@@ -39,6 +37,7 @@
39
37
  import * as azure from "@pulumi/azure-native";
40
38
  import * as pulumi from "@pulumi/pulumi";
41
39
  import type { CloudAdapterOutputs, DnsRecord } from "../../dist/adapter.js";
40
+ import { azureFrontDoorDnsRecords } from "../../dist/azure-front-door.js";
42
41
  import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
43
42
  import { ADMIN_MEMORY_DEFAULT, azureContainerAppResources } from "../../dist/stack-contract.js";
44
43
 
@@ -190,13 +189,124 @@ const _mediaContainer = new azure.storage.BlobContainer(`${namePrefix}-media`, {
190
189
  publicAccess: "None",
191
190
  });
192
191
 
193
- const _staticContainer = new azure.storage.BlobContainer(`${namePrefix}-static`, {
192
+ // The container the static generator publishes to. Managed explicitly (not
193
+ // left to static-website hosting, which would create it implicitly) so a
194
+ // stack created before Front Door keeps owning it — changing the resource
195
+ // type would make Pulumi delete the container and every published page.
196
+ const staticContainer = new azure.storage.BlobContainer(`${namePrefix}-static`, {
194
197
  resourceGroupName: rg.name,
195
198
  accountName: storage.name,
196
199
  containerName: "$web",
197
200
  publicAccess: "Blob",
198
201
  });
199
202
 
203
+ // Static-website hosting serves `$web` on the account's web endpoint, which
204
+ // resolves `/path/` to `/path/index.html` — the URL shape the static
205
+ // generator emits. Front Door fronts that endpoint.
206
+ const staticWebsite = new azure.storage.StorageAccountStaticWebsite(
207
+ `${namePrefix}-static-website`,
208
+ {
209
+ resourceGroupName: rg.name,
210
+ accountName: storage.name,
211
+ indexDocument: "index.html",
212
+ error404Document: "404.html",
213
+ },
214
+ { dependsOn: [staticContainer] },
215
+ );
216
+
217
+ // === 4b. Front Door (static site on the primary domain) ===
218
+ const frontDoor = new azure.cdn.Profile(`${namePrefix}-fd`, {
219
+ resourceGroupName: rg.name,
220
+ profileName: `${namePrefix}-fd`,
221
+ location: "Global",
222
+ sku: { name: "Standard_AzureFrontDoor" },
223
+ });
224
+
225
+ const frontDoorEndpoint = new azure.cdn.AFDEndpoint(`${namePrefix}-fd-endpoint`, {
226
+ resourceGroupName: rg.name,
227
+ profileName: frontDoor.name,
228
+ endpointName: `${namePrefix}-site`,
229
+ location: "Global",
230
+ enabledState: "Enabled",
231
+ });
232
+
233
+ const staticOriginGroup = new azure.cdn.AFDOriginGroup(`${namePrefix}-fd-static`, {
234
+ resourceGroupName: rg.name,
235
+ profileName: frontDoor.name,
236
+ originGroupName: "static",
237
+ loadBalancingSettings: { sampleSize: 4, successfulSamplesRequired: 3 },
238
+ healthProbeSettings: {
239
+ probePath: "/",
240
+ probeProtocol: "Https",
241
+ probeRequestType: "HEAD",
242
+ probeIntervalInSeconds: 100,
243
+ },
244
+ });
245
+
246
+ // `primaryEndpoints.web` is `https://<account>.z<n>.web.core.windows.net/`;
247
+ // Front Door wants the bare host.
248
+ const staticWebHost = storage.primaryEndpoints.apply((e) => new URL(e.web).host);
249
+ const staticOrigin = new azure.cdn.AFDOrigin(
250
+ `${namePrefix}-fd-static-origin`,
251
+ {
252
+ resourceGroupName: rg.name,
253
+ profileName: frontDoor.name,
254
+ originGroupName: staticOriginGroup.name,
255
+ originName: "static-website",
256
+ hostName: staticWebHost,
257
+ originHostHeader: staticWebHost,
258
+ httpsPort: 443,
259
+ enabledState: "Enabled",
260
+ },
261
+ { dependsOn: [staticWebsite] },
262
+ );
263
+
264
+ const siteDomain = new azure.cdn.AFDCustomDomain(`${namePrefix}-fd-domain`, {
265
+ resourceGroupName: rg.name,
266
+ profileName: frontDoor.name,
267
+ customDomainName: domain.replace(/\./g, "-"),
268
+ hostName: domain,
269
+ tlsSettings: { certificateType: "ManagedCertificate", minimumTlsVersion: "TLS12" },
270
+ });
271
+
272
+ new azure.cdn.Route(
273
+ `${namePrefix}-fd-route`,
274
+ {
275
+ resourceGroupName: rg.name,
276
+ profileName: frontDoor.name,
277
+ endpointName: frontDoorEndpoint.name,
278
+ routeName: "static",
279
+ originGroup: { id: staticOriginGroup.id },
280
+ customDomains: [{ id: siteDomain.id }],
281
+ patternsToMatch: ["/*"],
282
+ // Without a cache configuration Front Door does not cache the route at
283
+ // all; static files are edge-cached per their Cache-Control (§11.B
284
+ // Tier 1). The generated site has no query-string-dependent content.
285
+ cacheConfiguration: {
286
+ queryStringCachingBehavior: "IgnoreQueryString",
287
+ compressionSettings: {
288
+ isCompressionEnabled: true,
289
+ contentTypesToCompress: [
290
+ "text/html",
291
+ "text/css",
292
+ "text/plain",
293
+ "text/xml",
294
+ "application/javascript",
295
+ "application/json",
296
+ "application/xml",
297
+ "image/svg+xml",
298
+ ],
299
+ },
300
+ },
301
+ supportedProtocols: ["Http", "Https"],
302
+ forwardingProtocol: "HttpsOnly",
303
+ httpsRedirect: "Enabled",
304
+ linkToDefaultDomain: "Enabled",
305
+ },
306
+ // A route needs an origin in its group before Front Door accepts it.
307
+ { dependsOn: [staticOrigin] },
308
+ );
309
+
200
310
  // === 5. Log Analytics workspace ===
201
311
  const logWorkspace = new azure.operationalinsights.Workspace(`${namePrefix}-logs`, {
202
312
  resourceGroupName: rg.name,
@@ -251,7 +361,7 @@ function containerApp(args: ContainerAppArgs): azure.app.ContainerApp {
251
361
  configuration: {
252
362
  activeRevisionsMode: "Single",
253
363
  ingress: {
254
- external: args.serviceName === "edge-router",
364
+ external: false,
255
365
  targetPort: 8080,
256
366
  transport: "Auto",
257
367
  },
@@ -306,37 +416,19 @@ const _orchestratorApp = containerApp({
306
416
  databaseEnv: adminDatabaseEnv,
307
417
  });
308
418
  const _runnerApp = containerApp({ serviceName: "runner", databaseEnv: adminDatabaseEnv });
309
- const edgeRouterApp = containerApp({
310
- serviceName: "edge-router",
311
- // Serves the static site and the A/B routing manifest; no database.
312
- databaseEnv: [],
313
- extraEnv: [
314
- {
315
- name: "STATIC_BUCKET_URL",
316
- value: pulumi.interpolate`https://${storage.name}.blob.core.windows.net/$web`,
317
- },
318
- { name: "MANIFEST_OBJECT", value: "ab-routing.json" },
319
- ],
320
- });
321
-
322
419
  // === 7. Bootstrap token ===
323
420
  const tokenInfo = generateBootstrapToken();
324
421
 
325
422
  // === 8. CloudAdapterOutputs ===
326
- const dnsRecordsRequired: DnsRecord[] = [
327
- {
328
- hostname: domain,
329
- type: "CNAME",
330
- // Front Door domain comes from the resource (P15.4 review-pass adds
331
- // the actual Front Door + custom-domain bindings; v1 surfaces the
332
- // edge-router Container App FQDN as the temporary target).
333
- value: edgeRouterApp.configuration.apply(
334
- (c) => c?.ingress?.fqdn ?? "<edge-router-fqdn-pending>",
335
- ) as unknown as string,
336
- purpose:
337
- "Primary domain → edge-router Container App (P15.4 review-pass moves this behind Front Door)",
338
- },
339
- ];
423
+ const dnsRecordsRequired = pulumi
424
+ .all([frontDoorEndpoint.hostName, siteDomain.validationProperties])
425
+ .apply(([endpointHostName, validation]) =>
426
+ azureFrontDoorDnsRecords({
427
+ domain,
428
+ endpointHostName,
429
+ validationToken: validation.validationToken,
430
+ }),
431
+ );
340
432
 
341
433
  const out: CloudAdapterOutputs = {
342
434
  adminDatabaseUrl: adminDatabaseUrl as unknown as string,
@@ -347,7 +439,7 @@ const out: CloudAdapterOutputs = {
347
439
  mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
348
440
  bootstrapUrl:
349
441
  pulumi.interpolate`https://${domain}/setup?token=${tokenInfo.token}` as unknown as string,
350
- dnsRecordsRequired,
442
+ dnsRecordsRequired: dnsRecordsRequired as unknown as DnsRecord[],
351
443
  edgeLogSinkUrl: pulumi.interpolate`loganalytics://${logWorkspace.name}` as unknown as string,
352
444
  provider: "azure",
353
445
  environment: env,
@@ -366,7 +458,5 @@ export const environmentOut = out.environment;
366
458
  export const pgServerFqdnOut = pgServer.fullyQualifiedDomainName;
367
459
  export const storageAccountNameOut = storage.name;
368
460
  export const adminContainerAppFqdnOut = adminApp.configuration.apply((c) => c?.ingress?.fqdn);
369
- export const edgeRouterContainerAppFqdnOut = edgeRouterApp.configuration.apply(
370
- (c) => c?.ingress?.fqdn,
371
- );
461
+ export const frontDoorEndpointHostNameOut = frontDoorEndpoint.hostName;
372
462
  export const bootstrapTokenExpiresAtOut = tokenInfo.expiresAt;
@@ -1,9 +1,9 @@
1
1
  # SPDX-License-Identifier: MPL-2.0
2
2
  #
3
3
  # Caelo GCP provider stack. Provisions Cloud SQL HA + GCS (media + static
4
- # output) + Cloud CDN + Cloud Run (admin/gateway/orchestrator/runner +
5
- # edge-router) + Secret Manager + Cloud Logging → BigQuery sink for
6
- # A/B assignment logs. Implements the shared CloudAdapterInputs /
4
+ # output) + Cloud CDN + Cloud Run (admin/gateway/orchestrator/runner) +
5
+ # Secret Manager + Cloud Logging → BigQuery sink for gateway request
6
+ # logs. Implements the shared CloudAdapterInputs /
7
7
  # CloudAdapterOutputs contract from packages/provisioning/src/adapter.ts.
8
8
  name: caelo-gcp
9
9
  runtime:
@@ -8,8 +8,7 @@ Pulumi stack provisioning Caelo on GCP managed services. Implements the shared `
8
8
  |---|---|
9
9
  | Managed Postgres | Cloud SQL Postgres 16 (REGIONAL HA in production, ZONAL elsewhere) + automated backups + PITR + private IP only |
10
10
  | Blob storage | Two GCS buckets (`<project>-caelo-<env>-media`, `<project>-caelo-<env>-static`) with uniform bucket-level access |
11
- | CDN | Cloud CDN backend bucket (operator wires the URL map + load balancer for v1; full LB in P15 review-pass) |
12
- | Edge compute (A/B + redirects) | Cloud Run service `<env>-edge-router` running `edge-handler.ts` with `@caelo-cms/edge-router` |
11
+ | CDN | Global HTTPS load balancer + Cloud CDN backend bucket: `<domain>` serves the static bucket as files, `/api/*` goes to the gateway, `admin.<domain>` to the IAP-gated admin |
13
12
  | Container runtime | Four Cloud Run services (admin / gateway / orchestrator / runner) |
14
13
  | Secret store | Secret Manager: postgres-password (admin_role) + secret-kek (Pulumi); internal-secret, tool-approval-secret, public-role-password and gateway-role-password (created by the CLI before `pulumi up`, see `src/runtime-secrets.ts`). Cloud Run reads each as a `secretKeyRef`; database URLs carry no password. The gateway's service account reads only the two gateway role passwords (#613); `gateway_role` itself is created by migration 0248 and gets its password from the CLI after migrations. |
15
14
  | Edge-log sink | Cloud Logging project sink → BigQuery dataset `<env>_edge_logs` (queryable by P12A analytics plugin) |
@@ -36,7 +35,7 @@ pulumi config set caelo-gcp:region europe-west1
36
35
  # Build + push images. Operator does this once + on every release.
37
36
  gcloud auth configure-docker us-central1-docker.pkg.dev
38
37
  gcloud builds submit ... --tag us-central1-docker.pkg.dev/$PROJECT/caelo/admin:latest
39
- # Repeat for gateway, orchestrator, runner, edge-router.
38
+ # Repeat for gateway, orchestrator, runner.
40
39
 
41
40
  # The stack references two secrets it does not create (the CLI wizard and
42
41
  # `upgrade` create them; a direct `pulumi up` needs them first). Values go
@@ -65,28 +64,14 @@ pulumi stack output bootstrapUrlOut
65
64
  # → https://example.com/setup?token=<64-hex>
66
65
  ```
67
66
 
68
- Open after the operator has wired the GCP HTTPS load balancer to the edge-router Cloud Run service + the admin's DNS A record points at the LB IP.
69
-
70
- ## Edge-router behaviour
71
-
72
- This stack's `edge-handler.ts` imports `routeRequest` from `@caelo-cms/edge-router` — same function the AWS Lambda@Edge function and the P13 self-hosted Caddy gateway use. Differences from AWS L@E:
73
-
74
- - **Manifest lives in GCS, not bundled in the binary.** The handler fetches `gs://<static-bucket>/routing-manifest.json` on a 30s TTL cache. A new manifest takes effect within 30s without a Cloud Run redeploy.
75
- - **Returns 307 redirects** (instead of L@E's request-mutation rewrite) so the browser hits the variant URL directly + the static origin's cache respects the variant cache key.
76
- - **Sets the `caelo_visitor_id` cookie directly** in the response (Cloud Run handlers control headers; L@E's viewer-request cannot).
77
- - **Cloud Logging → BigQuery**, not CloudWatch → Athena. Same `{kind: "ab_assignment", ...}` JSON shape.
67
+ Open after the domain's and the admin's DNS A records point at the load balancer IP and the managed certificate is active.
78
68
 
79
69
  ## v1 limitations (P15 review-pass items)
80
70
 
81
- - **GCP HTTPS load balancer + URL map** not auto-provisioned. Operator wires the LB to point `/api/*` → gateway, `/admin/*` → admin, `/_caelo-variant/*` → static bucket directly, and everything else → edge-router. Auto-provisioning lands when telemetry shows operators want it.
82
71
  - **Container image build pipeline** — operators push via `gcloud builds submit` for v1. A unified `cms-provision build-images --provider gcp` lands later.
83
72
  - **Per-locale managed SSL certs** — operator creates via `gcloud compute ssl-certificates create` per locale subdomain for v1. Auto-provisioning lands alongside the LB.
84
73
  - **DNS records** — `dnsRecordsRequired` includes a `<gcp-load-balancer-ip>` placeholder; the DNS UI shows the right hostname/type for the operator to fill in once the LB IP exists.
85
74
 
86
- ## Edge-router byte-identity
87
-
88
- The byte-identity test in `packages/edge-router/src/index.test.ts` asserts 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, the AWS CloudFront in staging, or the GCP Cloud Run in production.
89
-
90
75
  ## Destroy
91
76
 
92
77
  ```bash
@@ -1,80 +0,0 @@
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");
@@ -1,20 +0,0 @@
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
- console.log(
13
- JSON.stringify({
14
- kind: "edge_handler_stub",
15
- message: "edge-handler-bundle.js is the placeholder; run `bun run build:edge-aws`",
16
- uri: req.uri,
17
- }),
18
- );
19
- return req;
20
- };
@@ -1,86 +0,0 @@
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
- console.log(JSON.stringify(decision.logEntry));
83
- }
84
-
85
- return req;
86
- };
@@ -1,87 +0,0 @@
1
- // SPDX-License-Identifier: MPL-2.0
2
-
3
- /**
4
- * Caelo Azure edge-router — Azure Container App HTTP handler.
5
- *
6
- * Imports the SAME `routeRequest` from `@caelo-cms/edge-router` that the
7
- * P13 self-hosted Caddy gateway, the AWS Lambda@Edge function, and the
8
- * GCP Cloud Run handler use. The byte-identity test in
9
- * packages/edge-router/src/index.test.ts asserts identical variant
10
- * labels across every runtime — same hash, same variant, every visitor.
11
- *
12
- * Differences from the GCP Cloud Run handler:
13
- * - Logs go to stdout in JSON; Container Apps' Log Analytics integration
14
- * ships them to the workspace; the P12A Azure adapter queries via
15
- * Azure Monitor's Log Analytics API.
16
- * - Manifest fetched from Blob storage (anonymous public read on the
17
- * `$web` container's `ab-routing.json` blob).
18
- *
19
- * v1 ships the source; the build pipeline (`az acr build` per service,
20
- * pushing to ACR) lands in P17.0 release engineering.
21
- */
22
-
23
- import { EMPTY_MANIFEST, type RoutingManifest, routeRequest } from "@caelo-cms/edge-router";
24
-
25
- const STATIC_BUCKET_URL = process.env.STATIC_BUCKET_URL ?? "";
26
- const MANIFEST_OBJECT = process.env.MANIFEST_OBJECT ?? "ab-routing.json";
27
- const MANIFEST_REFRESH_MS = 30_000;
28
- const COOKIE_NAME = "caelo_visitor_id";
29
-
30
- let manifestCache: { value: RoutingManifest; loadedAt: number } = {
31
- value: EMPTY_MANIFEST,
32
- loadedAt: 0,
33
- };
34
-
35
- async function fetchManifest(): Promise<RoutingManifest> {
36
- if (Date.now() - manifestCache.loadedAt < MANIFEST_REFRESH_MS) {
37
- return manifestCache.value;
38
- }
39
- if (!STATIC_BUCKET_URL) return EMPTY_MANIFEST;
40
- try {
41
- const url = `${STATIC_BUCKET_URL}/${MANIFEST_OBJECT}`;
42
- const r = await fetch(url, { signal: AbortSignal.timeout(2000) });
43
- if (!r.ok) return manifestCache.value;
44
- const m = (await r.json()) as RoutingManifest;
45
- manifestCache = { value: m, loadedAt: Date.now() };
46
- return m;
47
- } catch {
48
- return manifestCache.value;
49
- }
50
- }
51
-
52
- function readCookie(header: string | null, name: string): string | null {
53
- if (!header) return null;
54
- for (const pair of header.split(/;\s*/)) {
55
- const eq = pair.indexOf("=");
56
- if (eq < 0) continue;
57
- if (pair.slice(0, eq) === name) return decodeURIComponent(pair.slice(eq + 1));
58
- }
59
- return null;
60
- }
61
-
62
- const port = Number(process.env.PORT ?? 8080);
63
- Bun.serve({
64
- port,
65
- async fetch(req: Request): Promise<Response> {
66
- const url = new URL(req.url);
67
- const manifest = await fetchManifest();
68
- const visitorIdCookie = readCookie(req.headers.get("cookie"), COOKIE_NAME);
69
- const decision = routeRequest(manifest, {
70
- pathname: url.pathname,
71
- visitorIdCookie,
72
- });
73
-
74
- if (decision.logEntry) {
75
- console.log(JSON.stringify(decision.logEntry));
76
- }
77
-
78
- const target = new URL(url.toString());
79
- target.pathname = decision.rewritePathname;
80
- const headers = new Headers({
81
- Location: target.toString(),
82
- "Cache-Control": "no-store, private",
83
- "Set-Cookie": `${COOKIE_NAME}=${encodeURIComponent(decision.setVisitorId)}; Path=/; Max-Age=31536000; SameSite=Lax; Secure; HttpOnly`,
84
- });
85
- return new Response(null, { status: 307, headers });
86
- },
87
- });