@caelo-cms/provisioning 0.10.52 → 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 (85) hide show
  1. package/dist/agent/command.js +1 -0
  2. package/dist/agent/command.js.map +1 -1
  3. package/dist/agent-db.js +9 -0
  4. package/dist/aws-task-roles.d.ts +1 -1
  5. package/dist/aws-task-roles.d.ts.map +1 -1
  6. package/dist/aws-task-roles.js +0 -1
  7. package/dist/aws-task-roles.js.map +1 -1
  8. package/dist/cli-routing.d.ts +20 -0
  9. package/dist/cli-routing.d.ts.map +1 -1
  10. package/dist/cli-routing.js +106 -0
  11. package/dist/cli-routing.js.map +1 -1
  12. package/dist/cli.js +5 -6
  13. package/dist/cli.js.map +1 -1
  14. package/dist/compose.d.ts +4 -3
  15. package/dist/compose.d.ts.map +1 -1
  16. package/dist/compose.js +3 -2
  17. package/dist/compose.js.map +1 -1
  18. package/dist/gateway-credentials.d.ts +3 -5
  19. package/dist/gateway-credentials.d.ts.map +1 -1
  20. package/dist/gateway-credentials.js +8 -18
  21. package/dist/gateway-credentials.js.map +1 -1
  22. package/dist/infra-contract.d.ts +1 -1
  23. package/dist/infra-contract.d.ts.map +1 -1
  24. package/dist/infra-contract.js +8 -1
  25. package/dist/infra-contract.js.map +1 -1
  26. package/dist/runtime-secrets.d.ts +1 -3
  27. package/dist/runtime-secrets.d.ts.map +1 -1
  28. package/dist/runtime-secrets.js +2 -6
  29. package/dist/runtime-secrets.js.map +1 -1
  30. package/dist/self-hosted-roles.d.ts +9 -8
  31. package/dist/self-hosted-roles.d.ts.map +1 -1
  32. package/dist/self-hosted-roles.js +9 -9
  33. package/dist/self-hosted-roles.js.map +1 -1
  34. package/dist/self-hosted-upgrade.d.ts +7 -1
  35. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  36. package/dist/self-hosted-upgrade.js +17 -4
  37. package/dist/self-hosted-upgrade.js.map +1 -1
  38. package/dist/stack-contract.d.ts +52 -3
  39. package/dist/stack-contract.d.ts.map +1 -1
  40. package/dist/stack-contract.js +36 -36
  41. package/dist/stack-contract.js.map +1 -1
  42. package/dist/upgrade/converge.d.ts.map +1 -1
  43. package/dist/upgrade/converge.js +5 -13
  44. package/dist/upgrade/converge.js.map +1 -1
  45. package/dist/wizards/gcp.d.ts.map +1 -1
  46. package/dist/wizards/gcp.js +1 -30
  47. package/dist/wizards/gcp.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/admin-memory.test.ts +1 -1
  50. package/src/agent/command.ts +1 -0
  51. package/src/aws-task-roles.ts +0 -2
  52. package/src/caddy-redirects.test.ts +1 -1
  53. package/src/checkpoint-secrets.test.ts +0 -2
  54. package/src/cli-routing.test.ts +27 -1
  55. package/src/cli-routing.ts +131 -0
  56. package/src/cli.ts +5 -8
  57. package/src/compose.ts +13 -5
  58. package/src/gateway-credentials.test.ts +0 -29
  59. package/src/gateway-credentials.ts +11 -25
  60. package/src/gateway-env-isolation.test.ts +7 -2
  61. package/src/index.test.ts +7 -6
  62. package/src/infra-contract.test.ts +4 -3
  63. package/src/infra-contract.ts +8 -1
  64. package/src/runtime-secrets.test.ts +9 -81
  65. package/src/runtime-secrets.ts +2 -6
  66. package/src/self-hosted-compose.test.ts +1 -1
  67. package/src/self-hosted-roles.integration.test.ts +23 -38
  68. package/src/self-hosted-roles.test.ts +11 -7
  69. package/src/self-hosted-roles.ts +17 -16
  70. package/src/self-hosted-upgrade.test.ts +28 -1
  71. package/src/self-hosted-upgrade.ts +29 -4
  72. package/src/stack-contract.test.ts +15 -11
  73. package/src/stack-contract.ts +40 -5
  74. package/src/stack-converge-secrets.test.ts +2 -0
  75. package/src/stack-converge.test.ts +2 -0
  76. package/src/upgrade/converge.ts +5 -19
  77. package/src/wizards/gcp.ts +1 -31
  78. package/stacks/aws/README.md +1 -1
  79. package/stacks/aws/index.ts +1 -6
  80. package/stacks/azure/README.md +1 -1
  81. package/stacks/azure/index.ts +9 -6
  82. package/stacks/gcp/README.md +1 -1
  83. package/stacks/gcp/index.ts +1 -4
  84. package/stacks/gcp-firebase/index.ts +1 -4
  85. package/stacks/self-hosted/index.ts +4 -11
@@ -7,13 +7,14 @@
7
7
  * bootstrap.sh creates the application roles, but only on the Postgres
8
8
  * container's FIRST start (docker-entrypoint-initdb.d). An install created
9
9
  * before the per-role passwords has a data volume whose roles have other
10
- * passwords — and no `gateway_role` at all — so emitting Compose URLs with
11
- * the new passwords would lock the admin and the gateway out on the next
12
- * restart. `cms-provision up` therefore runs {@link roleConvergenceScript}
13
- * through the running container as the Postgres superuser BEFORE it writes
14
- * the new Compose file: it creates missing roles and databases, sets each
15
- * role's password to the configured one, and re-applies migration 0248's
16
- * gateway grants. Every statement is idempotent.
10
+ * passwords, so emitting Compose URLs with the new passwords would lock the
11
+ * admin and the gateway out on the next restart. `cms-provision up`
12
+ * therefore runs {@link roleConvergenceScript} through the running container
13
+ * as the Postgres superuser BEFORE it writes the new Compose file: it
14
+ * creates missing roles and databases and sets each role's password to the
15
+ * configured one. It also drops the former `gateway_role` (#635: the
16
+ * gateway has no cms_admin login), with whatever it still holds. Every
17
+ * statement is idempotent.
17
18
  *
18
19
  * Passwords travel on psql's stdin, never on argv.
19
20
  */
@@ -34,7 +35,7 @@ function literal(s: string): string {
34
35
  export const AGENT_DB_ROLE = "caelo_agent";
35
36
 
36
37
  /** The application roles bootstrap.sh and the convergence create. */
37
- const APP_ROLES = ["admin_role", "public_role", "gateway_role"] as const;
38
+ const APP_ROLES = ["admin_role", "public_role"] as const;
38
39
  type AppRole = (typeof APP_ROLES)[number];
39
40
 
40
41
  /** How a role's password appears in the SQL: a literal, or a psql variable. */
@@ -69,12 +70,14 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
69
70
  "REVOKE ALL ON DATABASE cms_public FROM PUBLIC;",
70
71
  "GRANT CONNECT ON DATABASE cms_admin TO admin_role;",
71
72
  "GRANT CONNECT ON DATABASE cms_public TO public_role, admin_role;",
73
+ // #635 — the gateway's former cms_admin login goes, with whatever it
74
+ // still holds in either database (the superuser may drop what
75
+ // migration 0285 could only revoke).
76
+ "\\connect cms_public",
77
+ "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
72
78
  "\\connect cms_admin",
73
- // A database that ran migration 0248 before gateway_role existed has no
74
- // grants for it yet; one that has not run it yet gets them from it.
75
- "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'caelo_grant_gateway_role') THEN PERFORM caelo_grant_gateway_role(); END IF; END $$;",
76
- // Migration 0251's staging-gate grants (issue #650), same convergence.
77
- "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'caelo_grant_gateway_staging_access') THEN PERFORM caelo_grant_gateway_staging_access(); END IF; END $$;",
79
+ "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
80
+ "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP ROLE gateway_role; END IF; END $$;",
78
81
  // Migrations 0267 + 0270 (#696): the upgrader role's grants, applied
79
82
  // now that the role exists.
80
83
  "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'caelo_grant_platform_updater') THEN PERFORM caelo_grant_platform_updater(); END IF; END $$;",
@@ -84,7 +87,7 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
84
87
 
85
88
  /**
86
89
  * The psql script (run as the superuser, connected to any database) that
87
- * brings roles, databases and the gateway's grants to the configured state.
90
+ * brings roles and databases to the configured state.
88
91
  * Mirrors bootstrap.sh, plus the password reset bootstrap.sh never does.
89
92
  * With `agentPassword`, also the host agent's login (#696 Phase 4c).
90
93
  */
@@ -92,7 +95,6 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
92
95
  const byRole: Record<AppRole, string> = {
93
96
  admin_role: passwords.admin,
94
97
  public_role: passwords.public,
95
- gateway_role: passwords.gateway,
96
98
  };
97
99
  return convergenceSql(
98
100
  (role) => literal(role === AGENT_DB_ROLE ? (agentPassword ?? "") : byRole[role]),
@@ -104,7 +106,6 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
104
106
  const PASSWORD_ENV: Record<AppRole | typeof AGENT_DB_ROLE, string> = {
105
107
  admin_role: "ADMIN_ROLE_PASSWORD",
106
108
  public_role: "PUBLIC_ROLE_PASSWORD",
107
- gateway_role: "GATEWAY_ROLE_PASSWORD",
108
109
  [AGENT_DB_ROLE]: "AGENT_ROLE_PASSWORD",
109
110
  };
110
111
 
@@ -19,6 +19,7 @@ import {
19
19
  readRelease,
20
20
  recordRelease,
21
21
  } from "./self-hosted-release.js";
22
+ import type { ConvergeOutcome } from "./self-hosted-roles.js";
22
23
  import {
23
24
  composePath,
24
25
  legacyComposePath,
@@ -121,11 +122,15 @@ beforeEach(() => {
121
122
  });
122
123
  afterEach(() => rmSync(dir, { recursive: true, force: true }));
123
124
 
124
- function deps(runner: HostRunner) {
125
+ function deps(runner: HostRunner & { calls: string[] }, roles: ConvergeOutcome = { ok: true }) {
125
126
  return {
126
127
  caeloDir: dir,
127
128
  runner,
128
129
  renderCompose: render,
130
+ convergeRoles: async () => {
131
+ runner.calls.push("converge roles");
132
+ return roles;
133
+ },
129
134
  log,
130
135
  fetchImpl: registry,
131
136
  sleep: async () => {},
@@ -151,6 +156,7 @@ describe("upgradeSelfHosted", () => {
151
156
  "pull",
152
157
  "migrate admin",
153
158
  "migrate public",
159
+ "converge roles",
154
160
  "up caelo-admin",
155
161
  "health admin",
156
162
  "up caelo-gateway",
@@ -269,6 +275,27 @@ describe("upgradeSelfHosted", () => {
269
275
  expect(readFileSync(composePath(dir), "utf8")).toBe(render(OLD));
270
276
  });
271
277
 
278
+ it("converges the database roles on every upgrade, after the migrations (#635: drops gateway_role)", async () => {
279
+ const runner = fakeRunner();
280
+ expect((await upgradeSelfHosted({ version: "0.11.0" }, deps(runner))).ok).toBe(true);
281
+ const s = steps(runner.calls);
282
+ expect(s.indexOf("converge roles")).toBeGreaterThan(s.indexOf("migrate public"));
283
+ expect(s.indexOf("converge roles")).toBeLessThan(s.indexOf("up caelo-admin"));
284
+ });
285
+
286
+ it("restores the Compose file and restarts nothing when the role convergence fails", async () => {
287
+ const runner = fakeRunner();
288
+ const r = await upgradeSelfHosted(
289
+ { version: "0.11.0" },
290
+ deps(runner, { ok: false, reachable: true, error: "role is busy" }),
291
+ );
292
+ expect(r).toMatchObject({ ok: false, stage: "roles", previousRunning: true });
293
+ if (!r.ok) expect(r.error).toContain("role is busy");
294
+ expect(steps(runner.calls).some((x) => x.startsWith("up "))).toBe(false);
295
+ expect(readFileSync(composePath(dir), "utf8")).toBe(render(OLD));
296
+ expect(readRelease(dir)?.version).toBe("0.10.0");
297
+ });
298
+
272
299
  it("rolls back to the previous images when the gateway fails its health check", async () => {
273
300
  let compose = "";
274
301
  const runner = fakeRunner([
@@ -13,9 +13,15 @@
13
13
  * the same `migrate.ts` entrypoint the GCP migrate job runs) while the
14
14
  * old containers keep serving — migrations are expand-only, so the
15
15
  * running release works on the new schema;
16
- * 5. recreate the admin, health-check it (`/_caelo/health`, which proves
16
+ * 5. converge the database roles (the same script `cms-provision up`
17
+ * runs): bootstrap.sh only runs on the data volume's first start, so a
18
+ * role a release retires — #635's gateway_role — is dropped here, on
19
+ * every existing install. After the migrations, because the runner
20
+ * refuses an install that skipped the release the old role's last user
21
+ * was retired in (run-order.ts): past it, nothing running uses it;
22
+ * 6. recreate the admin, health-check it (`/_caelo/health`, which proves
17
23
  * the DB pool and roles); then the gateway (`/healthz`);
18
- * 6. record the release in `.caelo/release.json`.
24
+ * 7. record the release in `.caelo/release.json`.
19
25
  * Any failure after step 3 puts the previous Compose file back; once a
20
26
  * container was recreated the previous images are started again and
21
27
  * health-checked (rollback). The database is never restored automatically:
@@ -52,6 +58,7 @@ import {
52
58
  releaseImages,
53
59
  resolveVerifiedRelease,
54
60
  } from "./self-hosted-release.js";
61
+ import type { ConvergeOutcome } from "./self-hosted-roles.js";
55
62
  import { fetchLatestReleaseTag, RELEASE_SERVICES, releaseTag } from "./upgrade/release-images.js";
56
63
 
57
64
  /** The Compose service names of the two Caelo services. */
@@ -78,6 +85,11 @@ export interface SelfHostedUpgradeDeps {
78
85
  /** Renders the Compose file for `images` from the install's config (secrets included). */
79
86
  readonly renderCompose: (images: ReleaseImages) => string;
80
87
  readonly log: UpgradeLog;
88
+ /**
89
+ * Converges the database roles as the Postgres superuser
90
+ * (`convergeSelfHostedRoles`); drops roles a release retired (#635).
91
+ */
92
+ readonly convergeRoles: () => Promise<ConvergeOutcome>;
81
93
  readonly fetchImpl?: typeof fetch;
82
94
  readonly sleep?: (ms: number) => Promise<void>;
83
95
  readonly now?: () => Date;
@@ -116,6 +128,7 @@ type UpgradeStage =
116
128
  | "backup"
117
129
  | "pull"
118
130
  | "migrate"
131
+ | "roles"
119
132
  | "roll-admin"
120
133
  | "roll-gateway";
121
134
 
@@ -321,7 +334,19 @@ export async function upgradeSelfHosted(
321
334
  );
322
335
  }
323
336
 
324
- // 5. Roll admin, then gateway, each health-checked.
337
+ // 5. Database roles: drops what this release retired (#635 gateway_role).
338
+ log.step("Converging the database roles");
339
+ const roles = await deps.convergeRoles();
340
+ if (!roles.ok) {
341
+ restoreCompose(file, previousCompose);
342
+ return fail(
343
+ "roles",
344
+ `Could not converge the database roles: ${roles.error}\nThe previous release keeps running; the migrations are applied (expand-only). Fix the cause and re-run the upgrade. Database backup: ${backup.dir}`,
345
+ true,
346
+ );
347
+ }
348
+
349
+ // 6. Roll admin, then gateway, each health-checked.
325
350
  for (const service of RELEASE_SERVICES) {
326
351
  const stage = service === "admin" ? "roll-admin" : "roll-gateway";
327
352
  log.phase?.("roll");
@@ -353,7 +378,7 @@ export async function upgradeSelfHosted(
353
378
  log.info(`${service} healthy ✓`);
354
379
  }
355
380
 
356
- // 6. Record.
381
+ // 7. Record.
357
382
  log.phase?.("record");
358
383
  recordRelease(
359
384
  caeloDir,
@@ -158,12 +158,21 @@ describe("gatewayEnvContract", () => {
158
158
  CAELO_PROVIDER: "gcp",
159
159
  CAELO_ENV: "production",
160
160
  MEDIA_STORAGE_URL: "gs://acme-caelo-production-media",
161
+ // #845 — gcp: the LB appends <client>,<LB IP>.
162
+ CAELO_TRUSTED_PROXY_HOPS: "2",
161
163
  PUBLIC_DATABASE_URL: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
162
164
  PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-role-password:latest",
163
165
  CAELO_STAGING_GATE_SECRET: "secret:caelo-production-staging-gate-secret:latest",
164
166
  });
165
167
  });
166
168
 
169
+ it("#845 — tells the gateway its provider's proxy hop count", () => {
170
+ // Firebase Hosting / the domain mapping: Cloud Run's front end only.
171
+ const fb = asRecord(gatewayEnvContract({ ...base, provider: "gcp-firebase" }));
172
+ expect(fb.CAELO_TRUSTED_PROXY_HOPS).toBe("1");
173
+ expect(asRecord(gateway).CAELO_TRUSTED_PROXY_HOPS).toBe("2");
174
+ });
175
+
167
176
  it("#650 — reads the staging host from the database, not its env", () => {
168
177
  const fb = asRecord(gatewayEnvContract({ ...base, provider: "gcp-firebase" }));
169
178
  expect(fb.CAELO_STAGING_HOST).toBeUndefined();
@@ -223,14 +232,13 @@ describe("database role secrets (#613)", () => {
223
232
  expect(DATABASE_ROLE_SECRET).toEqual({
224
233
  admin_role: "postgres-password",
225
234
  public_role: "public-role-password",
226
- gateway_role: "gateway-role-password",
227
235
  });
228
236
  });
229
237
 
230
- it("the CLI generates the gateway's role passwords (never seeded from admin_role's)", () => {
231
- expect(CLI_GENERATED_SECRETS).toEqual(
232
- expect.arrayContaining(["public-role-password", "gateway-role-password"]),
233
- );
238
+ it("the CLI generates the gateway's role password (never seeded from admin_role's)", () => {
239
+ expect(CLI_GENERATED_SECRETS).toContain("public-role-password");
240
+ // #635 — the former gateway_role is gone; nothing generates its password.
241
+ expect(CLI_GENERATED_SECRETS as readonly string[]).not.toContain("gateway-role-password");
234
242
  });
235
243
 
236
244
  it("retires the gateway SA's read access to admin_role's password", () => {
@@ -287,11 +295,7 @@ describe("runtime secrets", () => {
287
295
  });
288
296
 
289
297
  it("every CLI-generated secret is read by some service", () => {
290
- // gateway-role-password is read by no service since #635: it only keeps
291
- // the legacy gateway_role's password set until the contract release
292
- // drops the role (the previous release's gateway logs in with it while
293
- // an upgrade rolls).
294
- for (const s of CLI_GENERATED_SECRETS.filter((x) => x !== "gateway-role-password")) {
298
+ for (const s of CLI_GENERATED_SECRETS) {
295
299
  expect([...serviceSecrets("admin"), ...serviceSecrets("gateway")]).toContain(s);
296
300
  }
297
301
  });
@@ -302,7 +306,7 @@ describe("self-hosted compose", () => {
302
306
  const yaml = generateDockerCompose({
303
307
  domain: "acme.com",
304
308
  postgresPassword: "p",
305
- rolePasswords: { admin: "a", public: "b", gateway: "g" },
309
+ rolePasswords: { admin: "a", public: "b" },
306
310
  caeloSecretKek: "k",
307
311
  diskSize: "10G",
308
312
  ports: { http: 80, https: 443 },
@@ -34,6 +34,7 @@
34
34
  * Pure module — the stacks import it from `dist/`.
35
35
  */
36
36
 
37
+ import type { SupportedProvider } from "./adapter.js";
37
38
  import {
38
39
  gatewayServiceAccountEmail,
39
40
  gcpBucketName,
@@ -237,14 +238,13 @@ export function adminMediaVolumeTemplate(volume: MediaVolumeContract) {
237
238
  export type RuntimeSecret =
238
239
  | "postgres-password"
239
240
  | "public-role-password"
240
- | "gateway-role-password"
241
241
  | "secret-kek"
242
242
  | "internal-secret"
243
243
  | "tool-approval-secret"
244
244
  | "staging-gate-secret";
245
245
 
246
246
  /** The Postgres roles Caelo's services log in as, with their passwords. */
247
- export type DatabaseRole = "admin_role" | "public_role" | "gateway_role";
247
+ export type DatabaseRole = "admin_role" | "public_role";
248
248
 
249
249
  /**
250
250
  * Each database role's password secret — one per role (#613), so the
@@ -256,7 +256,6 @@ export type DatabaseRole = "admin_role" | "public_role" | "gateway_role";
256
256
  export const DATABASE_ROLE_SECRET: Readonly<Record<DatabaseRole, RuntimeSecret>> = {
257
257
  admin_role: "postgres-password",
258
258
  public_role: "public-role-password",
259
- gateway_role: "gateway-role-password",
260
259
  };
261
260
 
262
261
  /**
@@ -269,10 +268,9 @@ export const DATABASE_ROLE_SECRET: Readonly<Record<DatabaseRole, RuntimeSecret>>
269
268
  export const CLI_GENERATED_SECRETS = [
270
269
  "internal-secret",
271
270
  "tool-approval-secret",
272
- // #613 — the gateway's own role passwords. Generated (never seeded from
271
+ // #613 — the gateway's own role password. Generated (never seeded from
273
272
  // `postgres-password`), so the gateway never learns admin_role's.
274
273
  "public-role-password",
275
- "gateway-role-password",
276
274
  // #650 — the staging access gate's key: signs "Open staging" links and
277
275
  // session cookies, derives the crawler credentials. Read by the admin
278
276
  // (issues) and the gateway (verifies).
@@ -358,6 +356,42 @@ function commonEnv(inputs: ServiceEnvInputs): CloudRunEnvVar[] {
358
356
  ];
359
357
  }
360
358
 
359
+ /**
360
+ * The env var telling the gateway which `X-Forwarded-For` entry is the
361
+ * visitor (apps/api-gateway client-ip.ts): the number of entries the proxies
362
+ * in front of it append, counting the client's own.
363
+ */
364
+ export const TRUSTED_PROXY_HOPS_ENV = "CAELO_TRUSTED_PROXY_HOPS";
365
+
366
+ /**
367
+ * {@link TRUSTED_PROXY_HOPS_ENV} per provider — a property of each
368
+ * adapter's topology (CLAUDE.md §11.B), so it is declared here once and every
369
+ * adapter sets it on its gateway. The gateway refuses to boot without it.
370
+ * A change to an adapter's path to the gateway must update its entry, or
371
+ * rate limits pool every visitor (too high) or trust a forgeable entry (too
372
+ * low).
373
+ */
374
+ export const GATEWAY_TRUSTED_PROXY_HOPS: Readonly<Record<SupportedProvider, number>> = {
375
+ // External Application LB appends `<client>,<LB IP>`; the gateway's
376
+ // ingress is internal-and-LB only, so nothing reaches it around the LB.
377
+ gcp: 2,
378
+ // Cloud Run's front end appends its peer. Behind the Firebase Hosting
379
+ // rewrite that peer is the Hosting CDN edge, and the entry Hosting adds
380
+ // before it cannot be told apart from a forged one (the service is
381
+ // invokable on *.run.app too) — so the edge address is the most that can
382
+ // be trusted. Staging (Cloud Run domain mapping) gets the visitor.
383
+ "gcp-firebase": 1,
384
+ // Caddy without `trusted_proxies` replaces the header with its peer; the
385
+ // gateway is reachable only on the Compose network.
386
+ "self-hosted": 1,
387
+ // CloudFront appends the viewer, the ALB appends CloudFront. (No ECS task
388
+ // definition consumes it yet — see stacks/aws.)
389
+ aws: 2,
390
+ // Container Apps ingress appends its peer; the gateway's ingress is
391
+ // internal to the environment (no Front Door route to it yet).
392
+ azure: 1,
393
+ };
394
+
361
395
  /**
362
396
  * The env of the gateway service. Order is stable so the stacks see no
363
397
  * spurious diffs.
@@ -365,6 +399,7 @@ function commonEnv(inputs: ServiceEnvInputs): CloudRunEnvVar[] {
365
399
  export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvVar<V | string>[] {
366
400
  return [
367
401
  ...commonEnv(inputs),
402
+ { name: TRUSTED_PROXY_HOPS_ENV, value: String(GATEWAY_TRUSTED_PROXY_HOPS[inputs.provider]) },
368
403
  { name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
369
404
  ...secretEnv("gateway", inputs.env),
370
405
  ];
@@ -344,6 +344,8 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
344
344
  "ADMIN_DATABASE_URL",
345
345
  // #650 — the staging gate key arrives in the same roll.
346
346
  "CAELO_STAGING_GATE_SECRET",
347
+ // #845 — and which forwarded address is the visitor.
348
+ "CAELO_TRUSTED_PROXY_HOPS",
347
349
  "PUBLIC_DATABASE_PASSWORD",
348
350
  ]);
349
351
  });
@@ -316,6 +316,8 @@ describe("planContractEnv", () => {
316
316
  "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
317
317
  ]);
318
318
  expect(plan.services.gateway.flags).toEqual([
319
+ // The gateway learns which forwarded address is the visitor (#845).
320
+ "--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1",
319
321
  "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
320
322
  ]);
321
323
  });
@@ -24,11 +24,7 @@
24
24
  * {@link AppRollHooks} the app-roll calls in between, and a part after it.
25
25
  */
26
26
 
27
- import {
28
- ensureGatewayRolePassword,
29
- findSqlInstance,
30
- planPublicRoleSwitch,
31
- } from "../gateway-credentials.js";
27
+ import { findSqlInstance, planPublicRoleSwitch } from "../gateway-credentials.js";
32
28
  import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
33
29
  import { upgraderServiceAccountEmail } from "../gcp-names.js";
34
30
  import type { InstallMetadata } from "../install-state.js";
@@ -291,10 +287,9 @@ export function startConverge(
291
287
  }
292
288
 
293
289
  async function databaseLogins(): Promise<boolean> {
294
- // #613 — migration 0248 creates gateway_role without a password; it gets
295
- // its secret's value before the gateway rolls onto it. public_role
296
- // moves onto its own password right before the gateway's roll.
297
- const s = r.step("Ensuring the gateway's database login (gateway_role)...");
290
+ // #613 — public_role moves onto its own password right before the
291
+ // gateway's roll. The gateway has no other database login (#635).
292
+ const s = r.step("Preparing the gateway's database login (public_role)...");
298
293
  const sqlInstance = await findSqlInstance(install, { run });
299
294
  if (!sqlInstance) {
300
295
  s.failed("Could not find the Cloud SQL instance. Aborting upgrade.");
@@ -303,16 +298,7 @@ export function startConverge(
303
298
  }
304
299
  const roleTarget = { projectId: install.projectId, env: install.env, sqlInstance };
305
300
  const roleDeps = { ...runDeps, ...(deps.http ? { http: deps.http } : {}) };
306
- const gatewayRole = await ensureGatewayRolePassword(roleTarget, roleDeps);
307
- if (gatewayRole.status === "failed") {
308
- s.failed("gateway_role could not be given its password. Aborting upgrade.");
309
- r.error(` ${gatewayRole.error ?? ""}`);
310
- r.warn(
311
- "Migrations ran (0248 creates gateway_role); no traffic was shifted. Fix the above and re-run upgrade.",
312
- );
313
- return false;
314
- }
315
- s.done("gateway_role ready");
301
+ s.done("public_role ready to switch");
316
302
  publicRole = planPublicRoleSwitch(roleTarget, service("gateway").liveEnv, roleDeps);
317
303
  return true;
318
304
  }
@@ -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
@@ -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
 
@@ -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);
@@ -365,7 +361,6 @@ const cluster = new aws.ecs.Cluster(`${namePrefix}-ecs`, {
365
361
  const secretArns: Record<AwsSecret, pulumi.Output<string>> = {
366
362
  "postgres-password": pgSecret.arn,
367
363
  "public-role-password": publicRoleSecret.arn,
368
- "gateway-role-password": gatewayRoleSecret.arn,
369
364
  "csrf-secret": csrfSecretRes.arn,
370
365
  "cookie-secret": cookieSecretRes.arn,
371
366
  "anthropic-api-key": anthropicSecretRes.arn,
@@ -10,7 +10,7 @@ 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.
@@ -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") },