@caelo-cms/provisioning 0.10.51 → 0.10.53

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 (117) hide show
  1. package/caddy/Caddyfile.production +1 -1
  2. package/caddy/Caddyfile.staging +1 -1
  3. package/dist/agent/command.js +1 -0
  4. package/dist/agent/command.js.map +1 -1
  5. package/dist/agent/units.js +1 -1
  6. package/dist/agent/units.js.map +1 -1
  7. package/dist/agent-db.js +89 -45
  8. package/dist/aws-task-roles.d.ts +1 -1
  9. package/dist/aws-task-roles.d.ts.map +1 -1
  10. package/dist/aws-task-roles.js +0 -1
  11. package/dist/aws-task-roles.js.map +1 -1
  12. package/dist/cli-routing.d.ts +20 -0
  13. package/dist/cli-routing.d.ts.map +1 -1
  14. package/dist/cli-routing.js +106 -0
  15. package/dist/cli-routing.js.map +1 -1
  16. package/dist/cli.js +5 -6
  17. package/dist/cli.js.map +1 -1
  18. package/dist/compose.d.ts +4 -3
  19. package/dist/compose.d.ts.map +1 -1
  20. package/dist/compose.js +3 -2
  21. package/dist/compose.js.map +1 -1
  22. package/dist/gateway-credentials.d.ts +3 -5
  23. package/dist/gateway-credentials.d.ts.map +1 -1
  24. package/dist/gateway-credentials.js +8 -18
  25. package/dist/gateway-credentials.js.map +1 -1
  26. package/dist/infra-contract.d.ts +1 -1
  27. package/dist/infra-contract.d.ts.map +1 -1
  28. package/dist/infra-contract.js +14 -1
  29. package/dist/infra-contract.js.map +1 -1
  30. package/dist/ops-backend.js +1 -1
  31. package/dist/release-contract.d.ts.map +1 -1
  32. package/dist/release-contract.js +3 -0
  33. package/dist/release-contract.js.map +1 -1
  34. package/dist/runtime-secrets.d.ts +1 -3
  35. package/dist/runtime-secrets.d.ts.map +1 -1
  36. package/dist/runtime-secrets.js +2 -6
  37. package/dist/runtime-secrets.js.map +1 -1
  38. package/dist/self-hosted-roles.d.ts +9 -8
  39. package/dist/self-hosted-roles.d.ts.map +1 -1
  40. package/dist/self-hosted-roles.js +9 -9
  41. package/dist/self-hosted-roles.js.map +1 -1
  42. package/dist/self-hosted-upgrade.d.ts +7 -1
  43. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  44. package/dist/self-hosted-upgrade.js +17 -4
  45. package/dist/self-hosted-upgrade.js.map +1 -1
  46. package/dist/stack-contract.d.ts +52 -3
  47. package/dist/stack-contract.d.ts.map +1 -1
  48. package/dist/stack-contract.js +36 -36
  49. package/dist/stack-contract.js.map +1 -1
  50. package/dist/state-store.d.ts +8 -0
  51. package/dist/state-store.d.ts.map +1 -1
  52. package/dist/state-store.js +10 -2
  53. package/dist/state-store.js.map +1 -1
  54. package/dist/upgrade/converge.d.ts.map +1 -1
  55. package/dist/upgrade/converge.js +5 -13
  56. package/dist/upgrade/converge.js.map +1 -1
  57. package/dist/upgrader-job.d.ts.map +1 -1
  58. package/dist/upgrader-job.js +7 -1
  59. package/dist/upgrader-job.js.map +1 -1
  60. package/dist/upgrader-setup.d.ts +5 -0
  61. package/dist/upgrader-setup.d.ts.map +1 -1
  62. package/dist/upgrader-setup.js +40 -6
  63. package/dist/upgrader-setup.js.map +1 -1
  64. package/dist/wizards/gcp.d.ts.map +1 -1
  65. package/dist/wizards/gcp.js +2 -31
  66. package/dist/wizards/gcp.js.map +1 -1
  67. package/package.json +7 -5
  68. package/src/admin-memory.test.ts +1 -1
  69. package/src/agent/command.ts +1 -0
  70. package/src/agent/units.ts +1 -1
  71. package/src/aws-task-roles.ts +0 -2
  72. package/src/caddy-redirects.docker.test.ts +8 -1
  73. package/src/caddy-redirects.test.ts +1 -1
  74. package/src/checkpoint-secrets.test.ts +0 -2
  75. package/src/cli-routing.test.ts +27 -1
  76. package/src/cli-routing.ts +131 -0
  77. package/src/cli.ts +5 -8
  78. package/src/compose.ts +13 -5
  79. package/src/error-pages-stacks.test.ts +38 -0
  80. package/src/gateway-credentials.test.ts +0 -29
  81. package/src/gateway-credentials.ts +11 -25
  82. package/src/gateway-env-isolation.test.ts +7 -2
  83. package/src/index.test.ts +7 -6
  84. package/src/infra-contract.test.ts +5 -3
  85. package/src/infra-contract.ts +14 -1
  86. package/src/ops-backend.ts +1 -1
  87. package/src/release-contract.ts +3 -0
  88. package/src/release-manifest.test.ts +1 -0
  89. package/src/runtime-secrets.test.ts +9 -81
  90. package/src/runtime-secrets.ts +2 -6
  91. package/src/self-hosted-compose.test.ts +1 -1
  92. package/src/self-hosted-roles.integration.test.ts +23 -38
  93. package/src/self-hosted-roles.test.ts +11 -7
  94. package/src/self-hosted-roles.ts +17 -16
  95. package/src/self-hosted-upgrade.test.ts +28 -1
  96. package/src/self-hosted-upgrade.ts +29 -4
  97. package/src/stack-contract.test.ts +15 -11
  98. package/src/stack-contract.ts +40 -5
  99. package/src/stack-converge-secrets.test.ts +2 -0
  100. package/src/stack-converge.test.ts +2 -0
  101. package/src/state-store.ts +11 -2
  102. package/src/upgrade/converge.ts +5 -19
  103. package/src/upgrader-job.test.ts +17 -1
  104. package/src/upgrader-job.ts +13 -3
  105. package/src/upgrader-setup.test.ts +12 -1
  106. package/src/upgrader-setup.ts +41 -5
  107. package/src/welcome-page.test.ts +40 -0
  108. package/src/wizards/gcp.ts +2 -32
  109. package/stacks/aws/README.md +3 -1
  110. package/stacks/aws/index.ts +8 -6
  111. package/stacks/azure/README.md +3 -1
  112. package/stacks/azure/index.ts +9 -6
  113. package/stacks/gcp/README.md +1 -1
  114. package/stacks/gcp/index.ts +1 -4
  115. package/stacks/gcp-firebase/index.ts +1 -4
  116. package/stacks/self-hosted/index.ts +4 -11
  117. package/static/welcome.html +93 -111
@@ -14,6 +14,7 @@ import type { GcloudResult } from "./gcloud.js";
14
14
  import type { GcloudRunner } from "./gcloud-retry.js";
15
15
  import type { InstallMetadata } from "./install-state.js";
16
16
  import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
17
+ import { StateLockHeldError } from "./state-store.js";
17
18
  import type { HealthOutcome, HealthProbe } from "./upgrade/health.js";
18
19
  import {
19
20
  type ClaimedUpdate,
@@ -162,6 +163,7 @@ function setup(
162
163
  deep?: HealthOutcome;
163
164
  migrate?: { ok: boolean; error?: string };
164
165
  locked?: boolean;
166
+ lockDenied?: boolean;
165
167
  } = {},
166
168
  ) {
167
169
  const order: string[] = [];
@@ -188,7 +190,8 @@ function setup(
188
190
  },
189
191
  lockState: () => {
190
192
  order.push("lock");
191
- if (opts.locked) throw new Error("held by caelo installer");
193
+ if (opts.locked) throw new StateLockHeldError("held by caelo installer");
194
+ if (opts.lockDenied) throw new Error("storage.objects.list denied");
192
195
  return () => order.push("unlock");
193
196
  },
194
197
  readInstall: () => (opts.meta === undefined ? META : opts.meta),
@@ -286,6 +289,19 @@ describe("runUpgraderJob", () => {
286
289
  expect(busy.order.some((l) => l.startsWith("manifest") || l.startsWith("gcloud"))).toBe(false);
287
290
  });
288
291
 
292
+ it("does not call a lock it cannot take 'busy': an access failure asks for Cloud Shell", async () => {
293
+ const denied = setup({ lockDenied: true });
294
+ const r = await runUpgraderJob(denied.deps);
295
+ expect(r.status).toBe("needs_cloud_shell");
296
+ const end = denied.q.end();
297
+ expect(end?.codes).toEqual(["state-access"]);
298
+ expect(end?.reason).not.toContain("another install operation");
299
+ expect(end?.reason).toContain("storage.objects.list denied");
300
+ expect(denied.order.some((l) => l.startsWith("manifest") || l.startsWith("gcloud"))).toBe(
301
+ false,
302
+ );
303
+ });
304
+
289
305
  it("retries recording the end, and fails the run when it cannot", async () => {
290
306
  const { deps, q } = setup();
291
307
  let calls = 0;
@@ -43,6 +43,7 @@ import type { GcloudRunner, Sleep } from "./gcloud-retry.js";
43
43
  import { gcpNamePrefix, upgraderJobName } from "./gcp-names.js";
44
44
  import type { InstallMetadata } from "./install-state.js";
45
45
  import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
46
+ import { StateLockHeldError } from "./state-store.js";
46
47
  import {
47
48
  type AppRollPlan,
48
49
  executeAppRoll,
@@ -224,10 +225,19 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
224
225
  try {
225
226
  releaseLock = deps.lockState();
226
227
  } catch (e) {
228
+ if (e instanceof StateLockHeldError) {
229
+ return stopWith(
230
+ "failed",
231
+ ["state-locked"],
232
+ `Nothing was changed: another install operation is running on this site (${errorText(e)}). Try again when it has finished.`,
233
+ );
234
+ }
235
+ // Not contention: the job cannot reach its own ops bucket. Retrying
236
+ // will not help; a Cloud Shell upgrade re-converges the job's grants.
227
237
  return stopWith(
228
- "failed",
229
- ["state-locked"],
230
- `Nothing was changed: another install operation is running on this site (${errorText(e)}). Try again when it has finished.`,
238
+ "needs_cloud_shell",
239
+ ["state-access"],
240
+ `Nothing was changed: the upgrader could not take the install's state lock (${errorText(e)}). Run the upgrade once with Cloud Shell; it repairs the upgrader's access.`,
231
241
  );
232
242
  }
233
243
 
@@ -13,6 +13,7 @@ import type { GcloudResult } from "./gcloud.js";
13
13
  import {
14
14
  ensureUpgraderJob,
15
15
  UPGRADER_FORBIDDEN_ROLES,
16
+ UPGRADER_OPS_LIST_ROLE,
16
17
  UPGRADER_SQL_BACKUP_ROLE,
17
18
  type UpgraderSetupTarget,
18
19
  } from "./upgrader-setup.js";
@@ -83,6 +84,7 @@ describe("ensureUpgraderJob", () => {
83
84
  "iam service-accounts add-iam-policy-binding caelo-production-upgrader@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
84
85
  "iam service-accounts add-iam-policy-binding caelo-mcp@acme.iam.gserviceaccount.com → roles/iam.serviceAccountTokenCreator",
85
86
  "storage buckets add-iam-policy-binding gs://acme-caelo-ops → roles/storage.objectUser",
87
+ "storage buckets add-iam-policy-binding gs://acme-caelo-ops → projects/acme/roles/caeloUpgraderOpsList",
86
88
  ].sort(),
87
89
  );
88
90
  // The bucket grant only reaches install.json, not the Pulumi state.
@@ -90,6 +92,12 @@ describe("ensureUpgraderJob", () => {
90
92
  expect(bucket?.condition).toContain(
91
93
  'resource.name.startsWith("projects/_/buckets/acme-caelo-ops/objects/install/")',
92
94
  );
95
+ // gcloud storage cp lists the bucket before every upload; GCS checks the
96
+ // list against the bucket itself, so this grant cannot carry the
97
+ // install/ condition — and so it carries nothing but the list.
98
+ const list = mine.find((g) => g.role.endsWith("/caeloUpgraderOpsList"));
99
+ expect(list?.condition).toBe("None");
100
+ expect(UPGRADER_OPS_LIST_ROLE.permissions).toEqual(["storage.objects.list"]);
93
101
  });
94
102
 
95
103
  it("never grants a forbidden role, a setIamPolicy permission, or a project-wide iam role", async () => {
@@ -101,7 +109,10 @@ describe("ensureUpgraderJob", () => {
101
109
  }
102
110
  if (g.role.startsWith("roles/iam.")) expect(g.where).toStartWith("iam service-accounts");
103
111
  }
104
- const role = calls.find((c) => c.slice(0, 3).join(" ") === "iam roles create");
112
+ const role = calls.find(
113
+ (c) =>
114
+ c.slice(0, 3).join(" ") === "iam roles create" && c[3] === UPGRADER_SQL_BACKUP_ROLE.roleId,
115
+ );
105
116
  const perms = (flag(role ?? [], "permissions") ?? "").split(",");
106
117
  expect(perms.sort()).toEqual([...UPGRADER_SQL_BACKUP_ROLE.permissions].sort());
107
118
  // Nothing but the one write it needs: create a backup.
@@ -31,7 +31,13 @@
31
31
  * health check signs its IAP credential as that account
32
32
  * (upgrade/health.ts). The operator-access sync job keeps it there.
33
33
  * - `roles/storage.objectUser` on the ops bucket, conditioned to the
34
- * `install/` prefix (install.json) — not the Pulumi state.
34
+ * `install/` prefix (install.json, the state lock) — not the Pulumi state.
35
+ * - the custom role `caeloUpgraderOpsList` (`storage.objects.list` only)
36
+ * on the ops bucket, unconditioned: `gcloud storage cp` lists the
37
+ * destination prefix and reads the bucket's storage layout before every
38
+ * upload, and GCS checks both against the bucket, never an object path,
39
+ * so no `install/` condition can admit them. It reveals object names,
40
+ * not contents.
35
41
  * - `roles/logging.logWriter`.
36
42
  *
37
43
  * It gets no `*.setIamPolicy`, no service-account admin, no secret
@@ -71,6 +77,18 @@ export const UPGRADER_SQL_BACKUP_ROLE: CustomRoleSpec = {
71
77
  ],
72
78
  };
73
79
 
80
+ /**
81
+ * Listing the ops bucket, which every `gcloud storage cp` into it needs (see
82
+ * the file header). Names only — reads and writes stay on `install/`.
83
+ */
84
+ export const UPGRADER_OPS_LIST_ROLE: CustomRoleSpec = {
85
+ roleId: "caeloUpgraderOpsList",
86
+ title: "Caelo upgrader: list the ops bucket",
87
+ description:
88
+ "Lets the Caelo upgrader job list object names in the install's ops bucket (gcloud storage cp needs it). Names only; object reads/writes stay limited to install/.",
89
+ permissions: ["storage.objects.list"],
90
+ };
91
+
74
92
  /** Project-level roles of the upgrader's service account. */
75
93
  export const UPGRADER_PROJECT_ROLES = [
76
94
  "roles/run.developer",
@@ -176,10 +194,12 @@ export async function ensureUpgraderJob(
176
194
  };
177
195
  }
178
196
 
179
- // 1. The backup role.
180
- const roleError = await ensureCustomRole(run, projectId, UPGRADER_SQL_BACKUP_ROLE);
181
- if (roleError) return { ok: false, done, error: roleError };
182
- done.push(`custom role ${UPGRADER_SQL_BACKUP_ROLE.roleId}`);
197
+ // 1. The backup and ops-bucket-list roles.
198
+ for (const role of [UPGRADER_SQL_BACKUP_ROLE, UPGRADER_OPS_LIST_ROLE]) {
199
+ const roleError = await ensureCustomRole(run, projectId, role);
200
+ if (roleError) return { ok: false, done, error: roleError };
201
+ done.push(`custom role ${role.roleId}`);
202
+ }
183
203
 
184
204
  // 2. The job's own service account.
185
205
  const describe = await run([
@@ -276,6 +296,22 @@ export async function ensureUpgraderJob(
276
296
  return fail(`grant the upgrader install.json in gs://${bucket}`, bucketGrant);
277
297
  done.push(`upgrader: roles/storage.objectUser on gs://${bucket}/${OPS_INSTALL_PREFIX}/`);
278
298
 
299
+ const listRole = customRoleName(projectId, UPGRADER_OPS_LIST_ROLE.roleId);
300
+ const listGrant = await retry([
301
+ "storage",
302
+ "buckets",
303
+ "add-iam-policy-binding",
304
+ `gs://${bucket}`,
305
+ `--member=${member}`,
306
+ `--role=${listRole}`,
307
+ "--condition=None",
308
+ project,
309
+ "--quiet",
310
+ "--format=none",
311
+ ]);
312
+ if (!listGrant.ok) return fail(`grant the upgrader object listing on gs://${bucket}`, listGrant);
313
+ done.push(`upgrader: ${listRole} on gs://${bucket}`);
314
+
279
315
  // 6. Its database login (migration 0267's platform_updater role).
280
316
  const sqlUser = await ensureCloudSqlIamUser(run, {
281
317
  projectId,
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #781 — contracts of `static/welcome.html`, the placeholder the GCP
5
+ * wizard uploads to a fresh static bucket. It must stay fully
6
+ * self-contained (the bucket holds nothing else, and a first impression
7
+ * that fetches from third parties leaks the visitor), keep the
8
+ * `{{ADMIN_URL}}` placeholder the wizard substitutes, and carry the
9
+ * Caelatum sky instead of the old animated mesh.
10
+ */
11
+
12
+ import { describe, expect, it } from "bun:test";
13
+ import { readFileSync } from "node:fs";
14
+
15
+ const html = readFileSync(new URL("../static/welcome.html", import.meta.url), "utf8");
16
+
17
+ describe("welcome.html", () => {
18
+ it("makes no external requests", () => {
19
+ // Any absolute or protocol-relative URL, stylesheet/script/image
20
+ // reference or CSS import/url() would be a network fetch.
21
+ expect(html).not.toMatch(/https?:\/\//i);
22
+ expect(html).not.toMatch(/["'(]\s*\/\//);
23
+ expect(html).not.toMatch(/<link\b/i);
24
+ expect(html).not.toMatch(/<script\b/i);
25
+ expect(html).not.toMatch(/<img\b/i);
26
+ expect(html).not.toMatch(/@import|url\(|@font-face/i);
27
+ expect(html).not.toMatch(/\b(src|href)\s*=/i);
28
+ });
29
+
30
+ it("keeps the admin URL placeholder the wizard substitutes", () => {
31
+ expect(html).toContain("{{ADMIN_URL}}");
32
+ });
33
+
34
+ it("uses the sky + relief plate with the inline mark, not the mesh", () => {
35
+ expect(html).toContain("#c3dbf2 0%, #dce9f6 40%, #efe7ef 72%, #f8e6d8 100%");
36
+ expect(html).toContain("0 0 0 5px var(--plate-ring)");
37
+ expect(html).toMatch(/<svg viewBox="0 0 32 32" aria-hidden="true"/);
38
+ expect(html).not.toMatch(/class="blob|@keyframes (drift|pulse)/);
39
+ });
40
+ });
@@ -45,7 +45,6 @@ import {
45
45
  import { formatDnsRecordGroups, groupDnsRecords, type InstallDnsRecord } from "../dns/groups.js";
46
46
  import { pickDnsAdapter } from "../dns/index.js";
47
47
  import type { DnsRecord } from "../dns/types.js";
48
- import { ensureGatewayRolePassword, findSqlInstance } from "../gateway-credentials.js";
49
48
  import {
50
49
  activeAccount,
51
50
  type BillingAccount,
@@ -453,13 +452,7 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
453
452
  }
454
453
  }
455
454
 
456
- // === 11.2. The gateway's database login (#613) ===
457
- // Migration 0248 created gateway_role without a password; it gets the
458
- // gateway-role-password secret's value here. The gateway (deployed by
459
- // Pulumi before migrations) retries its database on every request, so it
460
- // starts serving /api/* as soon as this lands — no restart.
461
- phase("Finishing: database logins, operator access, welcome page");
462
- await stepGatewayRolePassword(projectId);
455
+ phase("Finishing: operator access, welcome page");
463
456
 
464
457
  // === 11.4. The upgrader job: Settings → Updates → Install update (#696) ===
465
458
  // Before operator access, which keeps the upgrader's caelo-mcp grant.
@@ -934,29 +927,6 @@ async function stepUrlMapRedirectsAccess(projectId: string): Promise<void> {
934
927
  s.stop(green(`Redirects role ready (${r.role})`));
935
928
  }
936
929
 
937
- /**
938
- * Give `gateway_role` (created by migration 0248) its password from the
939
- * `gateway-role-password` secret (#613). public_role already has its own:
940
- * the stack set it from `public-role-password`. Idempotent, so it runs on
941
- * every wizard pass.
942
- */
943
- async function stepGatewayRolePassword(projectId: string): Promise<void> {
944
- const s = spinner();
945
- s.start("Setting the gateway's database login (gateway_role)...");
946
- const install = { projectId, env: GCP_STACK_ENV };
947
- const sqlInstance = await findSqlInstance(install);
948
- const r = sqlInstance
949
- ? await ensureGatewayRolePassword({ ...install, sqlInstance })
950
- : { status: "failed" as const, error: "the Cloud SQL instance was not found" };
951
- if (r.status === "failed") {
952
- s.stop(red(`Failed: ${r.error ?? ""}`));
953
- log.error("Re-run the wizard once the cause is fixed (this step is idempotent).");
954
- cancel("Aborted.");
955
- process.exit(1);
956
- }
957
- s.stop(green("gateway_role ready"));
958
- }
959
-
960
930
  /**
961
931
  * Set up the upgrader job (upgrader-setup.ts, #696): what the admin's
962
932
  * "Install update" starts. After migrations, because the job's database
@@ -1965,7 +1935,7 @@ async function stepFinalize(installId: string, setupLink: "print" | "secret"): P
1965
1935
  ` click ${bold("New token")} (scope ${bold("chat")}: Caelo's AI does the work; ${bold("admin")}: your agent drives Caelo's tools),`,
1966
1936
  " copy the bearer (shown once) and run the `claude mcp add` command the page shows.",
1967
1937
  " The admin is behind Google IAP, so run `gcloud auth application-default login` once on that machine.",
1968
- ` Details: ${cyan("https://caelo-cms.com/mcp")}`,
1938
+ ` Details: ${cyan("https://caelo-cms.com/docs/mcp/")}`,
1969
1939
  ...adminNote,
1970
1940
  "",
1971
1941
  bold("Lifecycle commands:"),
@@ -10,7 +10,7 @@ Pulumi stack provisioning Caelo on AWS managed services. Implements the shared `
10
10
  | Blob storage | Two S3 buckets (`caelo-<env>-media` + `caelo-<env>-static`) |
11
11
  | CDN | CloudFront distribution with two origins (S3 static + S3 media); static files are served as files, no edge compute on the request path |
12
12
  | Container runtime | ECS Fargate cluster + four services (admin / gateway / orchestrator / runner) |
13
- | Secret store | AWS Secrets Manager (postgres-password, public-role-password, gateway-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway gets `publicDatabaseUrlOut` only, never a cms_admin URL of any role (#613, #635). |
13
+ | Secret store | AWS Secrets Manager (postgres-password, public-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway gets `publicDatabaseUrlOut` only, never a cms_admin URL of any role (#613, #635). |
14
14
  | DNS + cert | ACM cert (us-east-1, DNS-validated, supports `*.<domain>`) |
15
15
  | Edge-log sink | CloudFront access logs delivered to the CloudWatch log group `/caelo/<env>/cloudfront` in us-east-1 (queryable by P12A analytics plugin) |
16
16
 
@@ -18,6 +18,8 @@ The Caelo runtime never knows it's on AWS — it just consumes `DATABASE_URL` /
18
18
 
19
19
  ## First-time install
20
20
 
21
+ Run this stack from a checkout of the Caelo repository after `bun install` at its root. `@pulumi/aws` + `@pulumi/awsx` are devDependencies of `@caelo-cms/provisioning`: the CLI wizard does not provision AWS yet, so the published package does not make every GCP and self-hosted install download them (the one-click install from Cloud Shell runs in a 5 GB home directory). They move back to `dependencies` when the AWS wizard ships.
22
+
21
23
  ```bash
22
24
  cd packages/provisioning/stacks/aws
23
25
  pulumi stack init prod
@@ -97,11 +97,8 @@ function randomHex(bytes: number): string {
97
97
  const postgresPassword = pulumi.secret(randomHex(32));
98
98
  // #613 — one password per database role, so the gateway (public_role only,
99
99
  // #635) never holds admin_role's. bootstrap.sh creates the roles with these
100
- // (PUBLIC_ROLE_PASSWORD / GATEWAY_ROLE_PASSWORD). gateway_role is no longer
101
- // any service's login; its password stays until the contract release that
102
- // drops the role (#635).
100
+ // (ADMIN_ROLE_PASSWORD / PUBLIC_ROLE_PASSWORD).
103
101
  const publicRolePassword = pulumi.secret(randomHex(32));
104
- const gatewayRolePassword = pulumi.secret(randomHex(32));
105
102
  const csrfSecret = pulumi.secret(randomHex(32));
106
103
  const cookieSecret = pulumi.secret(randomHex(32));
107
104
  const anthropicApiKey = pulumi.secret(process.env.ANTHROPIC_API_KEY ?? "");
@@ -122,7 +119,6 @@ function secret(name: string, value: pulumi.Output<string>): aws.secretsmanager.
122
119
 
123
120
  const pgSecret = secret("postgres-password", postgresPassword);
124
121
  const publicRoleSecret = secret("public-role-password", publicRolePassword);
125
- const gatewayRoleSecret = secret("gateway-role-password", gatewayRolePassword);
126
122
  const csrfSecretRes = secret("csrf-secret", csrfSecret);
127
123
  const cookieSecretRes = secret("cookie-secret", cookieSecret);
128
124
  const anthropicSecretRes = secret("anthropic-api-key", anthropicApiKey);
@@ -297,7 +293,14 @@ const distribution = new aws.cloudfront.Distribution(`${namePrefix}-cdn`, {
297
293
  minimumProtocolVersion: "TLSv1.2_2021",
298
294
  },
299
295
  restrictions: { geoRestriction: { restrictionType: "none" } },
296
+ // #802 — unknown URLs get the site's own 404 page (the generator writes
297
+ // `/404.html` from the designated page) with status 404. The S3 origins
298
+ // are OAI-only with `s3:GetObject` and no `s3:ListBucket`, so S3 answers
299
+ // a missing key with 403, not 404; without the 403 entry visitors saw
300
+ // S3's AccessDenied XML. Both origins on this distribution are private
301
+ // buckets, so a 403 here always means "no such object".
300
302
  customErrorResponses: [
303
+ { errorCode: 403, responseCode: 404, responsePagePath: "/404.html", errorCachingMinTtl: 60 },
301
304
  { errorCode: 404, responseCode: 404, responsePagePath: "/404.html", errorCachingMinTtl: 60 },
302
305
  ],
303
306
  });
@@ -358,7 +361,6 @@ const cluster = new aws.ecs.Cluster(`${namePrefix}-ecs`, {
358
361
  const secretArns: Record<AwsSecret, pulumi.Output<string>> = {
359
362
  "postgres-password": pgSecret.arn,
360
363
  "public-role-password": publicRoleSecret.arn,
361
- "gateway-role-password": gatewayRoleSecret.arn,
362
364
  "csrf-secret": csrfSecretRes.arn,
363
365
  "cookie-secret": cookieSecretRes.arn,
364
366
  "anthropic-api-key": anthropicSecretRes.arn,
@@ -10,13 +10,15 @@ Pulumi stack provisioning Caelo on Azure managed services. Implements the shared
10
10
  | Blob storage | Storage Account with a private `media` container and static-website hosting (`$web`) |
11
11
  | CDN / Edge | Azure Front Door Standard: the primary domain serves the static website as files, with a Front Door managed certificate; no edge compute on the request path |
12
12
  | Container runtime | Four Container Apps (admin / gateway / orchestrator / runner) in one managed environment |
13
- | Secret store | Key Vault (postgres-password, public-role-password, gateway-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway container gets `PUBLIC_DATABASE_URL` only, never a cms_admin credential of any role (#613, #635). |
13
+ | Secret store | Key Vault (postgres-password, public-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway container gets `PUBLIC_DATABASE_URL` only, never a cms_admin credential of any role (#613, #635). |
14
14
  | Edge-log sink | Log Analytics workspace receives Container Apps logs (queryable by P12A analytics plugin via Azure Monitor) |
15
15
 
16
16
  The Caelo runtime never knows it's on Azure — every Container App consumes plain `DATABASE_URL` / `MEDIA_STORAGE_URL` / `SECRETS_PROVIDER` env vars.
17
17
 
18
18
  ## First-time install
19
19
 
20
+ Run this stack from a checkout of the Caelo repository after `bun install` at its root. `@pulumi/azure-native` is a devDependency of `@caelo-cms/provisioning`: the CLI wizard does not provision Azure yet, so the published package does not make every GCP and self-hosted install download it (the one-click install from Cloud Shell runs in a 5 GB home directory). It moves back to `dependencies` when the Azure wizard ships.
21
+
20
22
  ```bash
21
23
  cd packages/provisioning/stacks/azure
22
24
  pulumi stack init prod
@@ -39,7 +39,12 @@ import * as pulumi from "@pulumi/pulumi";
39
39
  import type { CloudAdapterOutputs, DnsRecord } from "../../dist/adapter.js";
40
40
  import { azureFrontDoorDnsRecords } from "../../dist/azure-front-door.js";
41
41
  import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
42
- import { ADMIN_MEMORY_DEFAULT, azureContainerAppResources } from "../../dist/stack-contract.js";
42
+ import {
43
+ ADMIN_MEMORY_DEFAULT,
44
+ azureContainerAppResources,
45
+ GATEWAY_TRUSTED_PROXY_HOPS,
46
+ TRUSTED_PROXY_HOPS_ENV,
47
+ } from "../../dist/stack-contract.js";
43
48
 
44
49
  const cfg = new pulumi.Config();
45
50
  const domain = cfg.require("domain");
@@ -83,11 +88,8 @@ function randomHex(bytes: number): string {
83
88
  const postgresPassword = pulumi.secret(randomHex(32));
84
89
  // #613 — one password per database role, so the gateway (public_role only,
85
90
  // #635) never holds admin_role's. bootstrap.sh creates the roles with these
86
- // (PUBLIC_ROLE_PASSWORD / GATEWAY_ROLE_PASSWORD). gateway_role is no longer
87
- // any service's login; its password stays until the contract release that
88
- // drops the role (#635).
91
+ // (ADMIN_ROLE_PASSWORD / PUBLIC_ROLE_PASSWORD).
89
92
  const publicRolePassword = pulumi.secret(randomHex(32));
90
- const gatewayRolePassword = pulumi.secret(randomHex(32));
91
93
  const csrfSecret = pulumi.secret(randomHex(32));
92
94
  const cookieSecret = pulumi.secret(randomHex(32));
93
95
  const anthropicApiKey = pulumi.secret(process.env.ANTHROPIC_API_KEY ?? "");
@@ -117,7 +119,6 @@ function keyVaultSecret(shortName: string, value: pulumi.Output<string>): azure.
117
119
 
118
120
  const _pgSecret = keyVaultSecret("pg-password", postgresPassword);
119
121
  keyVaultSecret("public-role-password", publicRolePassword);
120
- keyVaultSecret("gateway-role-password", gatewayRolePassword);
121
122
  keyVaultSecret("csrf-secret", csrfSecret);
122
123
  keyVaultSecret("cookie-secret", cookieSecret);
123
124
  keyVaultSecret("anthropic-api-key", anthropicApiKey);
@@ -419,6 +420,8 @@ const adminApp = containerApp({
419
420
  const _gatewayApp = containerApp({
420
421
  serviceName: "gateway",
421
422
  databaseEnv: [{ name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl }],
423
+ // Which X-Forwarded-For entry is the visitor (api-gateway client-ip.ts).
424
+ extraEnv: [{ name: TRUSTED_PROXY_HOPS_ENV, value: String(GATEWAY_TRUSTED_PROXY_HOPS.azure) }],
422
425
  });
423
426
  const _orchestratorApp = containerApp({
424
427
  serviceName: "orchestrator",
@@ -10,7 +10,7 @@ Pulumi stack provisioning Caelo on GCP managed services. Implements the shared `
10
10
  | Blob storage | Two GCS buckets (`<project>-caelo-<env>-media`, `<project>-caelo-<env>-static`) with uniform bucket-level access |
11
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 |
12
12
  | Container runtime | Four Cloud Run services (admin / gateway / orchestrator / runner) |
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 `public-role-password` (#613, #635: the gateway has no cms_admin login); `gateway_role`, created by migration 0248, is no service's login any more. |
13
+ | Secret store | Secret Manager: postgres-password (admin_role) + secret-kek (Pulumi); internal-secret, tool-approval-secret and public-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 `public-role-password` (#613, #635: the gateway has no cms_admin login). |
14
14
  | Edge-log sink | Cloud Logging project sink → BigQuery dataset `<env>_edge_logs` (queryable by P12A analytics plugin) |
15
15
 
16
16
  The Caelo runtime never knows it's on GCP — every Cloud Run service consumes plain `DATABASE_URL` / `MEDIA_STORAGE_URL` / `SECRETS_PROVIDER` env vars.
@@ -251,14 +251,11 @@ const runtimeSecretIds: Record<RuntimeSecret, pulumi.Input<string>> = {
251
251
  "tool-approval-secret": gcpSecretId(env, "tool-approval-secret"),
252
252
  "staging-gate-secret": gcpSecretId(env, "staging-gate-secret"),
253
253
  "public-role-password": gcpSecretId(env, "public-role-password"),
254
- "gateway-role-password": gcpSecretId(env, "gateway-role-password"),
255
254
  };
256
255
 
257
256
  // #613 — public_role's own password (CLI-generated, stack-contract.ts
258
257
  // DATABASE_ROLE_SECRET), so the gateway, which reads it, never holds
259
- // admin_role's. gateway_role is not declared here: migration 0248 creates
260
- // it in SQL (an API-created user would join cloudsqlsuperuser) and the CLI
261
- // sets its password after migrations.
258
+ // admin_role's. The gateway has no other database login (#635).
262
259
  const publicRolePassword = pulumi.secret(
263
260
  gcp.secretmanager.getSecretVersionOutput(
264
261
  { project, secret: gcpSecretId(env, "public-role-password") },
@@ -224,14 +224,11 @@ const runtimeSecretIds: Record<RuntimeSecret, pulumi.Input<string>> = {
224
224
  "tool-approval-secret": gcpSecretId(env, "tool-approval-secret"),
225
225
  "staging-gate-secret": gcpSecretId(env, "staging-gate-secret"),
226
226
  "public-role-password": gcpSecretId(env, "public-role-password"),
227
- "gateway-role-password": gcpSecretId(env, "gateway-role-password"),
228
227
  };
229
228
 
230
229
  // #613 — public_role's own password (CLI-generated, stack-contract.ts
231
230
  // DATABASE_ROLE_SECRET), so the gateway, which reads it, never holds
232
- // admin_role's. gateway_role is not declared here: migration 0248 creates
233
- // it in SQL (an API-created user would join cloudsqlsuperuser) and the CLI
234
- // sets its password after migrations.
231
+ // admin_role's. The gateway has no other database login (#635).
235
232
  const publicRolePassword = pulumi.secret(
236
233
  gcp.secretmanager.getSecretVersionOutput(
237
234
  { project, secret: gcpSecretId(env, "public-role-password") },
@@ -62,26 +62,19 @@ const tokenPath = resolve(caeloDir, "bootstrap", "pending-token.json");
62
62
  // is the operator's recovery path.
63
63
  const postgresPassword = pulumi.secret(randomHex(32));
64
64
  // #613 — one password per application role; the gateway gets only the
65
- // public_role and gateway_role ones.
65
+ // public_role one (#635: no cms_admin login).
66
66
  const adminRolePassword = pulumi.secret(randomHex(32));
67
67
  const publicRolePassword = pulumi.secret(randomHex(32));
68
- const gatewayRolePassword = pulumi.secret(randomHex(32));
69
68
  // P18 — the KEK that encrypts stored provider keys.
70
69
  const caeloSecretKek = pulumi.secret(randomHex(32));
71
70
 
72
71
  const composeYaml = pulumi
73
- .all([
74
- postgresPassword,
75
- adminRolePassword,
76
- publicRolePassword,
77
- gatewayRolePassword,
78
- caeloSecretKek,
79
- ])
80
- .apply(([pgPw, adminPw, publicPw, gatewayPw, kek]) =>
72
+ .all([postgresPassword, adminRolePassword, publicRolePassword, caeloSecretKek])
73
+ .apply(([pgPw, adminPw, publicPw, kek]) =>
81
74
  generateDockerCompose({
82
75
  domain,
83
76
  postgresPassword: pgPw,
84
- rolePasswords: { admin: adminPw, public: publicPw, gateway: gatewayPw },
77
+ rolePasswords: { admin: adminPw, public: publicPw },
85
78
  caeloSecretKek: kek,
86
79
  anthropicApiKey: process.env.ANTHROPIC_API_KEY,
87
80
  resendApiKey: process.env.RESEND_API_KEY,