@caelo-cms/provisioning 0.10.52 → 0.10.55

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 (145) 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 +198 -102
  4. package/dist/audit-job-setup.d.ts +69 -0
  5. package/dist/audit-job-setup.d.ts.map +1 -0
  6. package/dist/audit-job-setup.js +317 -0
  7. package/dist/audit-job-setup.js.map +1 -0
  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/caddy.d.ts +9 -0
  13. package/dist/caddy.d.ts.map +1 -1
  14. package/dist/caddy.js +38 -0
  15. package/dist/caddy.js.map +1 -1
  16. package/dist/cli-routing.d.ts +20 -0
  17. package/dist/cli-routing.d.ts.map +1 -1
  18. package/dist/cli-routing.js +106 -0
  19. package/dist/cli-routing.js.map +1 -1
  20. package/dist/cli.js +37 -14
  21. package/dist/cli.js.map +1 -1
  22. package/dist/compose.d.ts +4 -3
  23. package/dist/compose.d.ts.map +1 -1
  24. package/dist/compose.js +3 -2
  25. package/dist/compose.js.map +1 -1
  26. package/dist/gateway-credentials.d.ts +3 -5
  27. package/dist/gateway-credentials.d.ts.map +1 -1
  28. package/dist/gateway-credentials.js +8 -18
  29. package/dist/gateway-credentials.js.map +1 -1
  30. package/dist/gcp-names.d.ts +16 -1
  31. package/dist/gcp-names.d.ts.map +1 -1
  32. package/dist/gcp-names.js +23 -0
  33. package/dist/gcp-names.js.map +1 -1
  34. package/dist/infra-contract.d.ts +1 -1
  35. package/dist/infra-contract.d.ts.map +1 -1
  36. package/dist/infra-contract.js +14 -1
  37. package/dist/infra-contract.js.map +1 -1
  38. package/dist/private-file.d.ts +26 -0
  39. package/dist/private-file.d.ts.map +1 -0
  40. package/dist/private-file.js +49 -0
  41. package/dist/private-file.js.map +1 -0
  42. package/dist/release-contract.d.ts.map +1 -1
  43. package/dist/release-contract.js +3 -0
  44. package/dist/release-contract.js.map +1 -1
  45. package/dist/runtime-secrets.d.ts +1 -3
  46. package/dist/runtime-secrets.d.ts.map +1 -1
  47. package/dist/runtime-secrets.js +2 -6
  48. package/dist/runtime-secrets.js.map +1 -1
  49. package/dist/self-hosted-roles.d.ts +9 -8
  50. package/dist/self-hosted-roles.d.ts.map +1 -1
  51. package/dist/self-hosted-roles.js +9 -9
  52. package/dist/self-hosted-roles.js.map +1 -1
  53. package/dist/self-hosted-upgrade.d.ts +7 -1
  54. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  55. package/dist/self-hosted-upgrade.js +17 -4
  56. package/dist/self-hosted-upgrade.js.map +1 -1
  57. package/dist/stack-contract.d.ts +60 -3
  58. package/dist/stack-contract.d.ts.map +1 -1
  59. package/dist/stack-contract.js +49 -37
  60. package/dist/stack-contract.js.map +1 -1
  61. package/dist/tar-entries.d.ts.map +1 -1
  62. package/dist/tar-entries.js +84 -9
  63. package/dist/tar-entries.js.map +1 -1
  64. package/dist/upgrade/app-roll.d.ts.map +1 -1
  65. package/dist/upgrade/app-roll.js +35 -0
  66. package/dist/upgrade/app-roll.js.map +1 -1
  67. package/dist/upgrade/audit-job-roll.d.ts +38 -0
  68. package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
  69. package/dist/upgrade/audit-job-roll.js +43 -0
  70. package/dist/upgrade/audit-job-roll.js.map +1 -0
  71. package/dist/upgrade/converge.d.ts.map +1 -1
  72. package/dist/upgrade/converge.js +38 -15
  73. package/dist/upgrade/converge.js.map +1 -1
  74. package/dist/upgrader-setup.d.ts.map +1 -1
  75. package/dist/upgrader-setup.js +9 -3
  76. package/dist/upgrader-setup.js.map +1 -1
  77. package/dist/wizards/gcp-cost.js +3 -3
  78. package/dist/wizards/gcp-cost.js.map +1 -1
  79. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  80. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  81. package/dist/wizards/gcp-pulumi.js +7 -0
  82. package/dist/wizards/gcp-pulumi.js.map +1 -1
  83. package/dist/wizards/gcp.d.ts.map +1 -1
  84. package/dist/wizards/gcp.js +32 -30
  85. package/dist/wizards/gcp.js.map +1 -1
  86. package/package.json +4 -4
  87. package/src/admin-memory.test.ts +1 -1
  88. package/src/agent/command.ts +1 -0
  89. package/src/agent/run.test.ts +1 -0
  90. package/src/audit-job-setup.test.ts +234 -0
  91. package/src/audit-job-setup.ts +360 -0
  92. package/src/aws-task-roles.ts +0 -2
  93. package/src/caddy-redirects.docker.test.ts +19 -6
  94. package/src/caddy-redirects.test.ts +1 -1
  95. package/src/caddy.test.ts +30 -0
  96. package/src/caddy.ts +43 -0
  97. package/src/checkpoint-secrets.test.ts +0 -2
  98. package/src/cli-init-validation.test.ts +44 -0
  99. package/src/cli-routing.test.ts +27 -1
  100. package/src/cli-routing.ts +131 -0
  101. package/src/cli.ts +41 -15
  102. package/src/compose.ts +13 -5
  103. package/src/gateway-credentials.test.ts +0 -29
  104. package/src/gateway-credentials.ts +11 -25
  105. package/src/gateway-env-isolation.test.ts +7 -2
  106. package/src/gcp-names.ts +28 -1
  107. package/src/index.test.ts +7 -6
  108. package/src/infra-contract.test.ts +4 -3
  109. package/src/infra-contract.ts +14 -1
  110. package/src/private-file.test.ts +80 -0
  111. package/src/private-file.ts +50 -0
  112. package/src/release-contract.ts +3 -0
  113. package/src/runtime-secrets.test.ts +9 -81
  114. package/src/runtime-secrets.ts +2 -6
  115. package/src/self-hosted-compose.test.ts +1 -1
  116. package/src/self-hosted-roles.integration.test.ts +23 -38
  117. package/src/self-hosted-roles.test.ts +11 -7
  118. package/src/self-hosted-roles.ts +17 -16
  119. package/src/self-hosted-upgrade.test.ts +28 -1
  120. package/src/self-hosted-upgrade.ts +29 -4
  121. package/src/stack-contract.test.ts +19 -11
  122. package/src/stack-contract.ts +54 -5
  123. package/src/stack-converge-secrets.test.ts +2 -0
  124. package/src/stack-converge.test.ts +3 -1
  125. package/src/tar-entries.test.ts +149 -7
  126. package/src/tar-entries.ts +90 -9
  127. package/src/upgrade/app-roll.ts +39 -0
  128. package/src/upgrade/audit-job-roll.ts +68 -0
  129. package/src/upgrade/converge.ts +47 -20
  130. package/src/upgrade/upgrade.test.ts +76 -2
  131. package/src/upgrader-assess.test.ts +1 -0
  132. package/src/upgrader-job.test.ts +1 -0
  133. package/src/upgrader-setup.test.ts +1 -0
  134. package/src/upgrader-setup.ts +9 -2
  135. package/src/wizards/gcp-cost.ts +3 -3
  136. package/src/wizards/gcp-pulumi.ts +7 -0
  137. package/src/wizards/gcp.ts +38 -31
  138. package/stacks/aws/README.md +1 -1
  139. package/stacks/aws/index.ts +9 -6
  140. package/stacks/azure/README.md +1 -1
  141. package/stacks/azure/index.ts +18 -6
  142. package/stacks/gcp/README.md +1 -1
  143. package/stacks/gcp/index.ts +14 -4
  144. package/stacks/gcp-firebase/index.ts +14 -4
  145. package/stacks/self-hosted/index.ts +4 -11
@@ -48,3 +48,134 @@ export function resolveCliRoute(argv: readonly string[], handlerNames: Iterable<
48
48
  export function initDelegatesToWizard(provider: string): boolean {
49
49
  return provider !== "self-hosted";
50
50
  }
51
+
52
+ /** One documented `cms-provision` sub-command (the usage text and the public CLI reference). */
53
+ export interface CliCommandDoc {
54
+ /** The sub-command as typed, e.g. `upgrade` or `state migrate`. */
55
+ readonly name: string;
56
+ /** Arguments and flags in usage notation; empty when it takes none. */
57
+ readonly args: string;
58
+ /** One sentence for an operator: what it does and when to run it. */
59
+ readonly summary: string;
60
+ }
61
+
62
+ /**
63
+ * The documented sub-commands, in usage order. The single source for both
64
+ * the usage text `cms-provision --help` prints ({@link cliUsage}) and the
65
+ * provisioning CLI page of the developer reference mirrored into the docs
66
+ * on every release (scripts/docs-reference.ts, #828), so the two never
67
+ * drift. Internal entry points (`install-job`, `agent`, the
68
+ * `*-self-hosted` aliases) are deliberately absent.
69
+ */
70
+ export const CLI_COMMANDS: readonly CliCommandDoc[] = [
71
+ {
72
+ name: "init",
73
+ args: "--domain <d> --owner-email <e> [--staging-domain <host>] [--version x.y.z] [--tls acme|internal] [--http-port 80] [--https-port 443]",
74
+ summary:
75
+ "Self-hosted only: write the install's config with fresh secrets, verify the signed release images and start the Docker Compose stack; prints the Owner setup link. Cloud providers run the wizard instead.",
76
+ },
77
+ {
78
+ name: "up",
79
+ args: "",
80
+ summary:
81
+ "Self-hosted only: converge the database roles, re-emit the Compose file and Caddyfile, and (re)start the stack.",
82
+ },
83
+ {
84
+ name: "migrate",
85
+ args: "",
86
+ summary:
87
+ "Self-hosted only: apply the database migrations with the admin image the install runs. Idempotent.",
88
+ },
89
+ {
90
+ name: "status",
91
+ args: "[--install <id|domain|all>]",
92
+ summary: "Show the install's health and its estimated monthly cost.",
93
+ },
94
+ {
95
+ name: "upgrade",
96
+ args: "[--version x.y.z] [--channel stable|rc|beta] [--region <r>] [--skip-verify] [--skip-health-check] [--install <id|domain|all>]",
97
+ summary:
98
+ "Roll the install to a newer release (latest stable by default) after verifying the image signatures.",
99
+ },
100
+ {
101
+ name: "backup",
102
+ args: "[--to <file>] [--install <id|domain|all>]",
103
+ summary:
104
+ "Take a backup now: an on-demand Cloud SQL backup on cloud installs, an archive of the databases, published site and media on self-hosted.",
105
+ },
106
+ {
107
+ name: "restore",
108
+ args: "--from <backup.tar.gz> --yes",
109
+ summary:
110
+ "Self-hosted only: replace both databases, the published site and the media with a backup archive. Destructive.",
111
+ },
112
+ {
113
+ name: "rotate-secret",
114
+ args: "<name>",
115
+ summary: "Rotate one of the install's runtime secrets; run without a name to list them.",
116
+ },
117
+ {
118
+ name: "truncate",
119
+ args: "",
120
+ summary:
121
+ "Delete all content and history while keeping users, roles, providers, domains and settings. Destructive.",
122
+ },
123
+ {
124
+ name: "destroy",
125
+ args: "",
126
+ summary: "Permanently delete the install and every resource it created. Asks twice.",
127
+ },
128
+ {
129
+ name: "regenerate-caddy",
130
+ args: "",
131
+ summary: "Self-hosted only: re-emit the Caddyfile from the install's domains and reload Caddy.",
132
+ },
133
+ {
134
+ name: "admin-domain",
135
+ args: "enable",
136
+ summary:
137
+ "gcp-firebase: bind admin.<domain> to the admin and wait for its TLS certificate (resumable).",
138
+ },
139
+ {
140
+ name: "staging-domain",
141
+ args: "enable [--staging-domain <host>] [--rotate-staging-password] [--show-staging-password]",
142
+ summary:
143
+ "gcp-firebase: bind the protected staging host and set up its basic auth; prints the credentials on the first run.",
144
+ },
145
+ {
146
+ name: "owner-reset",
147
+ args: "[--install <id>] [--email <addr>]",
148
+ summary:
149
+ "Print a one-time password-reset link for a locked-out Owner on installs whose admin cannot send email.",
150
+ },
151
+ {
152
+ name: "state migrate",
153
+ args: "[--install <id|domain>] [--to remote|local] [--dry-run] [--yes]",
154
+ summary: "Move the install's Pulumi state into (or out of) the install's own cloud project.",
155
+ },
156
+ {
157
+ name: "attach",
158
+ args: "--project <gcp-project-id>",
159
+ summary: "Set up this machine to manage an existing install whose state lives in its project.",
160
+ },
161
+ {
162
+ name: "pulumi-output-sync",
163
+ args: "",
164
+ summary:
165
+ "Copy the stack outputs into the admin so its DNS and settings pages show current values; run after a manual `pulumi up`.",
166
+ },
167
+ {
168
+ name: "version",
169
+ args: "",
170
+ summary: "Print the CLI's version.",
171
+ },
172
+ ];
173
+
174
+ /** The text `cms-provision --help` prints, built from {@link CLI_COMMANDS}. */
175
+ export function cliUsage(): string {
176
+ return (
177
+ `Usage: cms-provision [wizard] [--provider <name> --domain <d> [--staging-domain <host>] --owner-email <e> --region <r>] [--local|--install-job] | <${CLI_COMMANDS.map((c) => c.name).join("|")}> [options]\n` +
178
+ "With several installs on this machine, pick one with --install <install-id or domain> (status, upgrade and backup also take --install all); in a terminal you are asked otherwise.\n" +
179
+ "Pass --no-wizard with no sub-command to print this usage instead of the wizard."
180
+ );
181
+ }
package/src/cli.ts CHANGED
@@ -51,12 +51,13 @@ import { generateBootstrapToken } from "./bootstrap-token.js";
51
51
  import {
52
52
  type CaddyDomainSpec,
53
53
  type CaddyTlsMode,
54
+ caddyTokenProblem,
54
55
  generateCaddyfile,
55
56
  selfHostedAdminHost,
56
57
  selfHostedSeedDomains,
57
58
  } from "./caddy.js";
58
59
  import { redactCredentials } from "./checkpoint-secrets.js";
59
- import { initDelegatesToWizard, resolveCliRoute } from "./cli-routing.js";
60
+ import { cliUsage, initDelegatesToWizard, resolveCliRoute } from "./cli-routing.js";
60
61
  import {
61
62
  BOOTSTRAP_DIR,
62
63
  COMPOSE_ADMIN_UPSTREAM,
@@ -71,6 +72,7 @@ import {
71
72
  SITE_OUTPUT_ROOT,
72
73
  selfHostedSiteUrl,
73
74
  } from "./compose.js";
75
+ import { tightenPrivateTree, writePrivateFile } from "./private-file.js";
74
76
  import { selfCommand } from "./self-command.js";
75
77
  import {
76
78
  bunHostRunner,
@@ -192,8 +194,8 @@ function loadProvider(): Provider {
192
194
  }
193
195
 
194
196
  function saveProvider(provider: Provider): void {
195
- if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true });
196
- writeFileSync(PROVIDER_PATH, JSON.stringify({ provider }, null, 2));
197
+ if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true, mode: 0o700 });
198
+ writePrivateFile(PROVIDER_PATH, JSON.stringify({ provider }, null, 2));
197
199
  }
198
200
 
199
201
  /**
@@ -224,6 +226,20 @@ function randomSecret(bytes = 32): string {
224
226
 
225
227
  function loadConfig(): CaeloConfig | null {
226
228
  if (!existsSync(CONFIG_PATH)) return null;
229
+ // Every command that works on an install reads its config first, so this
230
+ // is where an install made by an older CLI loses its world-readable
231
+ // secrets (#888) — not only once each file happens to be rewritten.
232
+ tightenPrivateTree(
233
+ CAELO_DIR,
234
+ [BOOTSTRAP_DIR, "backups"],
235
+ [
236
+ "config.json",
237
+ "provider.json",
238
+ "internal-secret.json",
239
+ "docker-compose.yml",
240
+ `${BOOTSTRAP_DIR}/pending-token.json`,
241
+ ],
242
+ );
227
243
  const cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as CaeloConfig;
228
244
  // #707 — settings added later, back-filled (and stored) with what every
229
245
  // install ran before they existed, so `upgrade` / `agent` render the same
@@ -238,8 +254,8 @@ function loadConfig(): CaeloConfig | null {
238
254
  }
239
255
 
240
256
  function saveConfig(c: CaeloConfig): void {
241
- if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true });
242
- writeFileSync(CONFIG_PATH, JSON.stringify(c, null, 2));
257
+ if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true, mode: 0o700 });
258
+ writePrivateFile(CONFIG_PATH, JSON.stringify(c, null, 2));
243
259
  }
244
260
 
245
261
  function arg(name: string): string | undefined {
@@ -248,9 +264,9 @@ function arg(name: string): string | undefined {
248
264
  return process.argv[idx + 1];
249
265
  }
250
266
 
251
- /** Fresh passwords for the three application roles. */
267
+ /** Fresh passwords for the two application roles. */
252
268
  function newRolePasswords(): RolePasswords {
253
- return { admin: randomSecret(32), public: randomSecret(32), gateway: randomSecret(32) };
269
+ return { admin: randomSecret(32), public: randomSecret(32) };
254
270
  }
255
271
 
256
272
  function emitConfig(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[] = []): void {
@@ -293,7 +309,7 @@ function emitConfig(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[] = []): voi
293
309
  * operator-added domains only `up` / `regenerate-caddy` read back).
294
310
  */
295
311
  function emitComposeFiles(cfg: CaeloConfig): void {
296
- writeFileSync(COMPOSE_PATH, renderCompose(cfg, installImages(cfg)), { mode: 0o600 });
312
+ writePrivateFile(COMPOSE_PATH, renderCompose(cfg, installImages(cfg)));
297
313
  // No secrets (it reads the passwords from the container's environment),
298
314
  // and the postgres user inside the container must be able to read it.
299
315
  writeFileSync(
@@ -527,6 +543,19 @@ async function init(): Promise<void> {
527
543
  console.error(`--staging-domain: ${stagingProblem}`);
528
544
  process.exit(2);
529
545
  }
546
+ // The Caddyfile check would only fire in emitConfig, after config.json is
547
+ // written — and a re-run then stops at "config already exists".
548
+ for (const [flag, value, kind] of [
549
+ ["--domain", domain, "address"],
550
+ ["--staging-domain", stagingArg, "address"],
551
+ ["--owner-email", ownerEmail, "token"],
552
+ ] as const) {
553
+ const problem = value === undefined ? null : caddyTokenProblem(value, kind);
554
+ if (problem) {
555
+ console.error(`${flag} ${JSON.stringify(value)}: ${problem}.`);
556
+ process.exit(2);
557
+ }
558
+ }
530
559
  // #696 — the prebuilt release images, resolved to digests and
531
560
  // cosign-verified BEFORE anything is written, so a failed check leaves no
532
561
  // half-made install. `--dev` (contributors only) runs this checkout instead.
@@ -589,7 +618,7 @@ async function init(): Promise<void> {
589
618
  // staging file.
590
619
  const tok = generateBootstrapToken();
591
620
  mkdirSync(resolve(CAELO_DIR, BOOTSTRAP_DIR), { recursive: true, mode: 0o700 });
592
- writeFileSync(PENDING_TOKEN_PATH, JSON.stringify(tok, null, 2), { mode: 0o600 });
621
+ writePrivateFile(PENDING_TOKEN_PATH, JSON.stringify(tok, null, 2));
593
622
 
594
623
  // P15.1 — mint the shared HMAC secret for /api/internal/* endpoints.
595
624
  // Same secret is read by:
@@ -601,7 +630,7 @@ async function init(): Promise<void> {
601
630
  // internal-secret`.
602
631
  const internalSecret = randomSecret(48);
603
632
  const internalSecretPath = resolve(CAELO_DIR, "internal-secret.json");
604
- writeFileSync(internalSecretPath, JSON.stringify({ secret: internalSecret }, null, 2));
633
+ writePrivateFile(internalSecretPath, JSON.stringify({ secret: internalSecret }, null, 2));
605
634
  // #696 Phase 4c — the update agent (Linux + systemd): the fresh volume's
606
635
  // bootstrap.sh creates its database login on Postgres's first start.
607
636
  const { reportAgentSetup, setUpAgent } = await import("./agent/command.js");
@@ -979,6 +1008,7 @@ async function upgradeSelfHostedCommand(opts: {
979
1008
  caeloDir: CAELO_DIR,
980
1009
  runner: bunHostRunner,
981
1010
  renderCompose: (images) => renderCompose(cfg, images),
1011
+ convergeRoles: agentContext(cfg).convergeRoles,
982
1012
  log: {
983
1013
  step: (m) => console.log(`\n==> ${m}`),
984
1014
  info: (m) => console.log(` ${m}`),
@@ -1534,10 +1564,6 @@ if (route.kind === "handler" || route.kind === "wizard") {
1534
1564
  process.exit(1);
1535
1565
  }
1536
1566
  } else {
1537
- console.log(
1538
- "Usage: cms-provision [wizard] [--provider <name> --domain <d> [--staging-domain <host>] --owner-email <e> --region <r>] [--local|--install-job] | <init|up|migrate|status|upgrade|backup|restore|rotate-secret|truncate|destroy|regenerate-caddy|admin-domain|staging-domain|owner-reset|state migrate|attach|pulumi-output-sync|version> [options]\n" +
1539
- "With several installs on this machine, pick one with --install <install-id or domain> (status, upgrade and backup also take --install all); in a terminal you are asked otherwise.\n" +
1540
- "Pass --no-wizard with no sub-command to print this usage instead of the wizard.",
1541
- );
1567
+ console.log(cliUsage());
1542
1568
  process.exit(cmd && !cmd.startsWith("-") ? 2 : 0);
1543
1569
  }
package/src/compose.ts CHANGED
@@ -29,7 +29,13 @@
29
29
 
30
30
  import { CADDY_REDIRECTS_FILE } from "@caelo-cms/shared";
31
31
  import { caddyRedirectReloadCommand } from "./caddy-redirects.js";
32
- import { ADMIN_MEMORY_DEFAULT, memoryQuantityMiB, publicSiteUrl } from "./stack-contract.js";
32
+ import {
33
+ ADMIN_MEMORY_DEFAULT,
34
+ GATEWAY_TRUSTED_PROXY_HOPS,
35
+ memoryQuantityMiB,
36
+ publicSiteUrl,
37
+ TRUSTED_PROXY_HOPS_ENV,
38
+ } from "./stack-contract.js";
33
39
  import { ghcrRepository, isImageDigest, type ReleaseService } from "./upgrade/release-images.js";
34
40
 
35
41
  /** The admin + gateway release images, each `ghcr.io/caelo-cms/<svc>@sha256:…`. */
@@ -160,13 +166,14 @@ export function ghcrImageDigest(service: ReleaseService, ref: string): string |
160
166
 
161
167
  /**
162
168
  * The application roles' passwords (bootstrap.sh creates the roles with
163
- * them). One per role, so the gateway — which gets only the public and
164
- * gateway logins — never holds admin_role's (#613, CLAUDE.md §2).
169
+ * them). One per role, so the gateway — which gets only the public login —
170
+ * never holds admin_role's (#613, #635, CLAUDE.md §2). An install's
171
+ * config.json from before #635 still carries a `gateway` entry; it is
172
+ * ignored.
165
173
  */
166
174
  export interface RolePasswords {
167
175
  readonly admin: string;
168
176
  readonly public: string;
169
- readonly gateway: string;
170
177
  }
171
178
 
172
179
  export interface ComposeSpec {
@@ -254,7 +261,6 @@ services:
254
261
  POSTGRES_DB: caelo
255
262
  ADMIN_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.admin)}"
256
263
  PUBLIC_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.public)}"
257
- GATEWAY_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.gateway)}"
258
264
  ${postgresAgent(spec.agent)} volumes:
259
265
  - caelo-pg:/var/lib/postgresql/data
260
266
  - ./bootstrap.sh:/docker-entrypoint-initdb.d/00_bootstrap.sh:ro
@@ -392,6 +398,8 @@ ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts
392
398
  # CLAUDE.md §2: the public side has no path to the authoring database).
393
399
  PUBLIC_DATABASE_URL: "${dbUrl("public_role", spec.rolePasswords.public, "cms_public")}"
394
400
  GATEWAY_PORT: "${COMPOSE_GATEWAY_PORT}"
401
+ # Caddy is the one proxy in front (client-ip.ts in the gateway).
402
+ ${TRUSTED_PROXY_HOPS_ENV}: "${GATEWAY_TRUSTED_PROXY_HOPS["self-hosted"]}"
395
403
  NODE_ENV: production`;
396
404
  }
397
405
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
4
  import {
5
- ensureGatewayRolePassword,
6
5
  findSqlInstance,
7
6
  gatewayMovesPublicRole,
8
7
  planPublicRoleSwitch,
@@ -52,7 +51,6 @@ const target = { projectId: "acme", env: "production", sqlInstance: "caelo-produ
52
51
  const SECRETS = {
53
52
  "postgres-password": "admin-pw",
54
53
  "public-role-password": "public-pw",
55
- "gateway-role-password": "gateway-pw",
56
54
  };
57
55
  const secretRef = (secret: string): LiveEnvValue => ({
58
56
  kind: "secret",
@@ -66,7 +64,6 @@ const BEFORE_613 = new Map<string, LiveEnvValue>([
66
64
  ]);
67
65
  const AFTER_613 = new Map<string, LiveEnvValue>([
68
66
  ["PUBLIC_DATABASE_PASSWORD", secretRef("public-role-password")],
69
- ["GATEWAY_DATABASE_PASSWORD", secretRef("gateway-role-password")],
70
67
  ]);
71
68
 
72
69
  describe("gatewayMovesPublicRole", () => {
@@ -85,32 +82,6 @@ describe("gatewayMovesPublicRole", () => {
85
82
  });
86
83
  });
87
84
 
88
- describe("ensureGatewayRolePassword", () => {
89
- it("sets gateway_role from its secret through the API, never on argv", async () => {
90
- const { run, lines } = fakeGcloud(SECRETS);
91
- const sql = fakeSqlApi();
92
- const r = await ensureGatewayRolePassword(target, {
93
- run,
94
- http: sql.http,
95
- sleep: async () => {},
96
- });
97
- expect(r.status).toBe("applied");
98
- expect(sql.set).toEqual(["gateway_role gateway-pw"]);
99
- expect(lines.some((l) => l.includes("gateway-pw"))).toBe(false);
100
- });
101
-
102
- it("fails, naming the secret, when the secret can't be read", async () => {
103
- const { run } = fakeGcloud({});
104
- const r = await ensureGatewayRolePassword(target, {
105
- run,
106
- http: fakeSqlApi().http,
107
- sleep: async () => {},
108
- });
109
- expect(r.status).toBe("failed");
110
- expect(r.error).toContain("caelo-production-gateway-role-password");
111
- });
112
- });
113
-
114
85
  describe("planPublicRoleSwitch", () => {
115
86
  it("first upgrade: moves public_role onto its own password before the gateway rolls", async () => {
116
87
  const { run } = fakeGcloud(SECRETS);
@@ -2,28 +2,22 @@
2
2
 
3
3
  /**
4
4
  * Moving an existing install's API gateway off admin_role (issue #613),
5
- * as `cms-provision upgrade` does it. The env side — the per-role password
6
- * secrets in, ADMIN_DATABASE_* out (and, since #635, GATEWAY_DATABASE_* out
7
- * too: the gateway has no cms_admin login at all) — rides the gateway's
8
- * roll (stack-contract.ts). gateway_role keeps getting its password until
9
- * the contract release drops the role, because the previous release's
10
- * gateway logs in with it until it is rolled. The database side is ordered around
11
- * that roll so /api/* keeps working:
5
+ * as `cms-provision upgrade` does it. The env side — public_role's own
6
+ * password secret in, every cms_admin credential out (#613, #635) — rides
7
+ * the gateway's roll (stack-contract.ts). The database side is ordered
8
+ * around that roll so /api/* keeps working:
12
9
  *
13
- * 1. after migrations (which create `gateway_role` without a password):
14
- * `gateway_role` gets its password. Nothing uses the role yet, so this
15
- * is safe at any point before the gateway rolls.
16
- * 2. right before the gateway rolls: `public_role` moves off admin_role's
10
+ * 1. right before the gateway rolls: `public_role` moves off admin_role's
17
11
  * password onto its own. Only the gateway logs in as public_role, so
18
12
  * the window in which the old revision cannot open NEW public
19
13
  * connections is the roll itself (open connections stay up).
20
- * 3. if the gateway's roll fails and it is rolled back: `public_role`
14
+ * 2. if the gateway's roll fails and it is rolled back: `public_role`
21
15
  * goes back to the password the prior revision reads, so the rollback
22
16
  * leaves a working gateway, not a locked-out one.
23
17
  *
24
18
  * On every later upgrade the gateway already reads public_role's own
25
- * secret: step 2 then re-applies the same value (a no-op for clients) and
26
- * step 3 has nothing to undo.
19
+ * secret: step 1 then re-applies the same value (a no-op for clients) and
20
+ * step 2 has nothing to undo.
27
21
  */
28
22
 
29
23
  import { gcloud as defaultGcloud } from "./gcloud.js";
@@ -60,22 +54,14 @@ export interface GatewayCredentialDeps {
60
54
  readonly http?: HttpFetch;
61
55
  }
62
56
 
63
- /** Step 1: `gateway_role`'s password from its secret. */
64
- export function ensureGatewayRolePassword(
65
- target: DatabaseRoleTarget,
66
- deps: GatewayCredentialDeps = {},
67
- ): Promise<EnsureOutcome> {
68
- return ensureDatabaseRolePassword(target, "gateway_role", deps);
69
- }
70
-
71
- /** Steps 2 and 3 for one roll of the gateway. */
57
+ /** Steps 1 and 2 for one roll of the gateway. */
72
58
  export interface PublicRoleSwitch {
73
59
  /** Whether this roll moves public_role off admin_role's password. */
74
60
  readonly moves: boolean;
75
- /** Step 2 — run right before the gateway's `services update`. */
61
+ /** Step 1 — run right before the gateway's `services update`. */
76
62
  beforeGatewayRoll(): Promise<EnsureOutcome>;
77
63
  /**
78
- * Step 3 — run when the gateway's roll failed and it goes back to its
64
+ * Step 2 — run when the gateway's roll failed and it goes back to its
79
65
  * prior revision. Returns what happened, for the operator.
80
66
  */
81
67
  afterGatewayRollFailed(): Promise<string>;
@@ -126,7 +126,7 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
126
126
  const out = generateDockerCompose({
127
127
  domain: "example.com",
128
128
  postgresPassword: "superpw",
129
- rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
129
+ rolePasswords: { admin: "adminpw", public: "publicpw" },
130
130
  caeloSecretKek: "0".repeat(64),
131
131
  diskSize: "20Gi",
132
132
  images: {
@@ -148,7 +148,12 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
148
148
  expect(gateway).not.toMatch(/^ {4}(secrets|env_file|volumes):/m);
149
149
  const env = gateway.slice(gateway.indexOf(" environment:"));
150
150
  const keys = [...env.matchAll(/^ {6}([A-Z_][A-Z0-9_]*):/gm)].map((m) => m[1]);
151
- expect(keys.sort()).toEqual(["GATEWAY_PORT", "NODE_ENV", "PUBLIC_DATABASE_URL"]);
151
+ expect(keys.sort()).toEqual([
152
+ "CAELO_TRUSTED_PROXY_HOPS",
153
+ "GATEWAY_PORT",
154
+ "NODE_ENV",
155
+ "PUBLIC_DATABASE_URL",
156
+ ]);
152
157
  expect(out).not.toMatch(/^secrets:/m);
153
158
  });
154
159
  });
package/src/gcp-names.ts CHANGED
@@ -69,7 +69,7 @@ export function staticPublisherServiceAccountId(env: string): string {
69
69
  export function gcpBucketName(
70
70
  projectId: string,
71
71
  env: string,
72
- bucket: "media" | "static" | "staging",
72
+ bucket: "media" | "static" | "staging" | "audit-results",
73
73
  ): string {
74
74
  return `${projectId}-${gcpNamePrefix(env)}-${bucket}`;
75
75
  }
@@ -120,6 +120,33 @@ export function upgraderJobResource(projectId: string, region: string, env: stri
120
120
  return `projects/${projectId}/locations/${region}/jobs/${upgraderJobName(env)}`;
121
121
  }
122
122
 
123
+ /**
124
+ * #875 — Cloud Run job that runs the quality audit's Lighthouse runs in
125
+ * parallel, one task per run (audit-job-setup.ts).
126
+ */
127
+ export function auditJobName(env: string): string {
128
+ return `${gcpNamePrefix(env)}-audit`;
129
+ }
130
+
131
+ /** Full resource name of the audit job, as the admin's env carries it. */
132
+ export function auditJobResource(projectId: string, region: string, env: string): string {
133
+ return `projects/${projectId}/locations/${region}/jobs/${auditJobName(env)}`;
134
+ }
135
+
136
+ /**
137
+ * Account id of the audit job's own service account: reads the staged
138
+ * build, creates audit results, writes logs — nothing else
139
+ * (audit-job-setup.ts).
140
+ */
141
+ export function auditServiceAccountId(env: string): string {
142
+ return `${gcpNamePrefix(env)}-audit`;
143
+ }
144
+
145
+ /** Email of the audit job's service account. */
146
+ export function auditServiceAccountEmail(projectId: string, env: string): string {
147
+ return `${auditServiceAccountId(env)}@${projectId}.iam.gserviceaccount.com`;
148
+ }
149
+
123
150
  /**
124
151
  * Account id of the upgrader job's own service account: may roll the two
125
152
  * Cloud Run services, run the migration job and back up Cloud SQL, never
package/src/index.test.ts CHANGED
@@ -115,7 +115,7 @@ describe("docker-compose generator", () => {
115
115
  const out = generateDockerCompose({
116
116
  domain: "example.com",
117
117
  postgresPassword: "supersecret",
118
- rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
118
+ rolePasswords: { admin: "adminpw", public: "publicpw" },
119
119
  caeloSecretKek: "0".repeat(64),
120
120
  anthropicApiKey: "sk-ant-…",
121
121
  diskSize: "20Gi",
@@ -136,7 +136,7 @@ describe("docker-compose generator", () => {
136
136
  const out = generateDockerCompose({
137
137
  domain: "example.com",
138
138
  postgresPassword: "supersecret",
139
- rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
139
+ rolePasswords: { admin: "adminpw", public: "publicpw" },
140
140
  caeloSecretKek: "0".repeat(64),
141
141
  diskSize: "20Gi",
142
142
  images: RELEASE_IMAGES,
@@ -150,16 +150,17 @@ describe("docker-compose generator", () => {
150
150
  expect(gateway).not.toMatch(
151
151
  /ADMIN_DATABASE|GATEWAY_DATABASE|cms_admin|admin_role|gateway_role|adminpw|gatewaypw|supersecret|CAELO_SECRET_KEK/,
152
152
  );
153
- // bootstrap.sh still creates the three roles from these (gateway_role
154
- // until the contract release drops it).
155
- expect(out).toContain('GATEWAY_ROLE_PASSWORD: "gatewaypw"');
153
+ // bootstrap.sh creates the two application roles from these (#635: no
154
+ // gateway_role).
155
+ expect(out).toContain('PUBLIC_ROLE_PASSWORD: "publicpw"');
156
+ expect(out).not.toContain("GATEWAY_ROLE_PASSWORD");
156
157
  });
157
158
 
158
159
  it("escapes secrets containing quotes safely", () => {
159
160
  const out = generateDockerCompose({
160
161
  domain: "example.com",
161
162
  postgresPassword: 'pa"ss',
162
- rolePasswords: { admin: "a", public: "b", gateway: "g" },
163
+ rolePasswords: { admin: "a", public: "b" },
163
164
  caeloSecretKek: "0".repeat(64),
164
165
  diskSize: "1Gi",
165
166
  images: RELEASE_IMAGES,
@@ -20,12 +20,13 @@ describe("infrastructure contract steps", () => {
20
20
  );
21
21
  });
22
22
 
23
- it("is at 6: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6)", () => {
24
- expect(INFRA_CONTRACT_VERSION).toBe(6);
23
+ it("is at 8: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6), gateway proxy hops (7), quality-audit job (8)", () => {
24
+ expect(INFRA_CONTRACT_VERSION).toBe(8);
25
25
  expect(INFRA_CONTRACT_STEPS[3]).toContain("#633");
26
26
  expect(INFRA_CONTRACT_STEPS[4]).toContain("#587");
27
27
  expect(INFRA_CONTRACT_STEPS[5]).toContain("#635");
28
28
  expect(INFRA_CONTRACT_STEPS[6]).toContain("#799");
29
+ expect(INFRA_CONTRACT_STEPS[7]).toContain("#845");
29
30
  });
30
31
 
31
32
  it("lists the pending steps oldest first", () => {
@@ -36,6 +37,6 @@ describe("infrastructure contract steps", () => {
36
37
  });
37
38
 
38
39
  it("names a version a newer release added by number", () => {
39
- expect(infraStepsText(6, 7)).toBe("infrastructure step 7 (see that release's notes)");
40
+ expect(infraStepsText(8, 9)).toBe("infrastructure step 9 (see that release's notes)");
40
41
  });
41
42
  });
@@ -22,7 +22,7 @@
22
22
  * whose app-roll references infrastructure an install does not have yet fails
23
23
  * at roll time, after migrations ran.
24
24
  */
25
- export const INFRA_CONTRACT_VERSION = 6;
25
+ export const INFRA_CONTRACT_VERSION = 8;
26
26
 
27
27
  // History (why each bump happened — an install below a number needs the
28
28
  // converge that introduced it):
@@ -45,6 +45,17 @@ export const INFRA_CONTRACT_VERSION = 6;
45
45
  // into the bucket lists it first; without the binding the in-cloud
46
46
  // upgrader fails at its state lock, so it must not run before the
47
47
  // converge grants it.
48
+ // 7 — #845: the gateway refuses to boot without CAELO_TRUSTED_PROXY_HOPS
49
+ // (which X-Forwarded-For entry is the visitor). A plain env var, but
50
+ // the in-cloud upgrader rolls with the env contract of the release it
51
+ // runs from (the installed one), which does not set it — so the new
52
+ // gateway would not start. The operator-identity upgrade runs the new
53
+ // release's contract and sets it.
54
+ // 8 — #875: the quality-audit job, its service account, the audit results
55
+ // bucket and their grants, plus CAELO_AUDIT_JOB /
56
+ // CAELO_AUDIT_RESULTS_BUCKET on the admin. The new admin fans every
57
+ // Stage audit out to that job, and the app-roll moves the job's image,
58
+ // so it must exist before an in-cloud upgrader rolls onto it.
48
59
 
49
60
  /**
50
61
  * What each contract version adds, in operator terms — the upgrader's
@@ -58,6 +69,8 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
58
69
  4: "the admin's permission to serve redirects from the load balancer (#587)",
59
70
  5: "the gateway's cms_admin login removed from its env and IAM (#635)",
60
71
  6: "the upgrader's permission to list its ops bucket (#799)",
72
+ 7: "the gateway's trusted proxy hop count, CAELO_TRUSTED_PROXY_HOPS (#845)",
73
+ 8: "the quality-audit job, its service account, results bucket and grants (#875)",
61
74
  };
62
75
 
63
76
  /**