@caelo-cms/provisioning 0.11.12 → 0.11.14

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 (165) hide show
  1. package/dist/admin-domain.d.ts +4 -22
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +4 -58
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/agent-db.js +29 -5
  6. package/dist/audit-job-setup.d.ts.map +1 -1
  7. package/dist/audit-job-setup.js +14 -19
  8. package/dist/audit-job-setup.js.map +1 -1
  9. package/dist/caddy.d.ts +2 -1
  10. package/dist/caddy.d.ts.map +1 -1
  11. package/dist/caddy.js +21 -3
  12. package/dist/caddy.js.map +1 -1
  13. package/dist/cli.js +111 -62
  14. package/dist/cli.js.map +1 -1
  15. package/dist/compose.d.ts +22 -2
  16. package/dist/compose.d.ts.map +1 -1
  17. package/dist/compose.js +53 -3
  18. package/dist/compose.js.map +1 -1
  19. package/dist/converge-checkpoint.d.ts +1 -0
  20. package/dist/converge-checkpoint.d.ts.map +1 -1
  21. package/dist/converge-checkpoint.js +1 -0
  22. package/dist/converge-checkpoint.js.map +1 -1
  23. package/dist/deployed-release.d.ts.map +1 -1
  24. package/dist/deployed-release.js +40 -8
  25. package/dist/deployed-release.js.map +1 -1
  26. package/dist/gcp-names.d.ts +33 -14
  27. package/dist/gcp-names.d.ts.map +1 -1
  28. package/dist/gcp-names.js +43 -0
  29. package/dist/gcp-names.js.map +1 -1
  30. package/dist/gcp-url-map.d.ts +19 -4
  31. package/dist/gcp-url-map.d.ts.map +1 -1
  32. package/dist/gcp-url-map.js +33 -5
  33. package/dist/gcp-url-map.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 +22 -1
  37. package/dist/infra-contract.js.map +1 -1
  38. package/dist/lifecycle.d.ts.map +1 -1
  39. package/dist/lifecycle.js +5 -29
  40. package/dist/lifecycle.js.map +1 -1
  41. package/dist/mcp-auth-proxy-setup.d.ts +8 -2
  42. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
  43. package/dist/mcp-auth-proxy-setup.js +4 -0
  44. package/dist/mcp-auth-proxy-setup.js.map +1 -1
  45. package/dist/release-manifest.d.ts +1 -1
  46. package/dist/release-manifest.d.ts.map +1 -1
  47. package/dist/release-manifest.js +2 -0
  48. package/dist/release-manifest.js.map +1 -1
  49. package/dist/remote-mcp.d.ts.map +1 -1
  50. package/dist/remote-mcp.js +11 -1
  51. package/dist/remote-mcp.js.map +1 -1
  52. package/dist/runtime-secrets.d.ts +10 -0
  53. package/dist/runtime-secrets.d.ts.map +1 -1
  54. package/dist/runtime-secrets.js +10 -1
  55. package/dist/runtime-secrets.js.map +1 -1
  56. package/dist/scheduler-setup.d.ts +5 -0
  57. package/dist/scheduler-setup.d.ts.map +1 -1
  58. package/dist/scheduler-setup.js +7 -0
  59. package/dist/scheduler-setup.js.map +1 -1
  60. package/dist/stack-contract.d.ts +61 -20
  61. package/dist/stack-contract.d.ts.map +1 -1
  62. package/dist/stack-contract.js +173 -35
  63. package/dist/stack-contract.js.map +1 -1
  64. package/dist/stack-converge.d.ts.map +1 -1
  65. package/dist/stack-converge.js +4 -2
  66. package/dist/stack-converge.js.map +1 -1
  67. package/dist/staging-admin.d.ts +2 -2
  68. package/dist/staging-admin.d.ts.map +1 -1
  69. package/dist/staging-gateway-setup.d.ts +140 -0
  70. package/dist/staging-gateway-setup.d.ts.map +1 -0
  71. package/dist/staging-gateway-setup.js +848 -0
  72. package/dist/staging-gateway-setup.js.map +1 -0
  73. package/dist/upgrade/app-roll.d.ts +4 -4
  74. package/dist/upgrade/app-roll.d.ts.map +1 -1
  75. package/dist/upgrade/app-roll.js +7 -5
  76. package/dist/upgrade/app-roll.js.map +1 -1
  77. package/dist/upgrade/converge.d.ts.map +1 -1
  78. package/dist/upgrade/converge.js +36 -27
  79. package/dist/upgrade/converge.js.map +1 -1
  80. package/dist/upgrade/image-forms.d.ts +3 -3
  81. package/dist/upgrade/image-forms.d.ts.map +1 -1
  82. package/dist/upgrade/image-forms.js +2 -2
  83. package/dist/upgrade/image-forms.js.map +1 -1
  84. package/dist/upgrade/release-images.d.ts +20 -0
  85. package/dist/upgrade/release-images.d.ts.map +1 -1
  86. package/dist/upgrade/release-images.js +28 -0
  87. package/dist/upgrade/release-images.js.map +1 -1
  88. package/dist/upgrade/upgrade.d.ts +3 -1
  89. package/dist/upgrade/upgrade.d.ts.map +1 -1
  90. package/dist/upgrade/upgrade.js +22 -1
  91. package/dist/upgrade/upgrade.js.map +1 -1
  92. package/dist/upgrader-job.d.ts.map +1 -1
  93. package/dist/upgrader-job.js +30 -5
  94. package/dist/upgrader-job.js.map +1 -1
  95. package/dist/upgrader-records.d.ts +2 -2
  96. package/dist/upgrader-records.d.ts.map +1 -1
  97. package/dist/upgrader-records.js +2 -2
  98. package/dist/upgrader-records.js.map +1 -1
  99. package/dist/wizard.d.ts.map +1 -1
  100. package/dist/wizard.js +3 -3
  101. package/dist/wizard.js.map +1 -1
  102. package/dist/wizards/gcp.d.ts.map +1 -1
  103. package/dist/wizards/gcp.js +48 -9
  104. package/dist/wizards/gcp.js.map +1 -1
  105. package/package.json +2 -2
  106. package/src/admin-domain.test.ts +0 -91
  107. package/src/admin-domain.ts +4 -64
  108. package/src/admin-memory.test.ts +2 -0
  109. package/src/audit-job-setup.test.ts +11 -11
  110. package/src/audit-job-setup.ts +14 -19
  111. package/src/caddy-redirects.docker.test.ts +10 -5
  112. package/src/caddy-redirects.test.ts +7 -5
  113. package/src/caddy-validate.test.ts +1 -1
  114. package/src/caddy.test.ts +12 -6
  115. package/src/caddy.ts +23 -4
  116. package/src/cli.ts +138 -77
  117. package/src/compose.ts +67 -2
  118. package/src/converge-checkpoint.ts +1 -0
  119. package/src/database-tls-builders.test.ts +3 -1
  120. package/src/deployed-release.test.ts +21 -0
  121. package/src/deployed-release.ts +42 -7
  122. package/src/gateway-env-isolation.test.ts +2 -0
  123. package/src/gcp-names.ts +53 -0
  124. package/src/gcp-url-map.test.ts +26 -2
  125. package/src/gcp-url-map.ts +36 -5
  126. package/src/index.test.ts +9 -3
  127. package/src/infra-contract.test.ts +5 -3
  128. package/src/infra-contract.ts +22 -1
  129. package/src/lifecycle.ts +6 -40
  130. package/src/mcp-auth-proxy-setup.test.ts +15 -6
  131. package/src/mcp-auth-proxy-setup.ts +14 -2
  132. package/src/release-manifest.ts +3 -1
  133. package/src/remote-mcp-stack-config.test.ts +1 -0
  134. package/src/remote-mcp.test.ts +5 -1
  135. package/src/remote-mcp.ts +10 -1
  136. package/src/runtime-secrets.test.ts +10 -1
  137. package/src/runtime-secrets.ts +21 -0
  138. package/src/scheduler-setup.test.ts +6 -0
  139. package/src/scheduler-setup.ts +7 -0
  140. package/src/self-hosted-backup.test.ts +6 -4
  141. package/src/self-hosted-compose.test.ts +2 -0
  142. package/src/stack-contract-parity.test.ts +7 -3
  143. package/src/stack-contract.test.ts +73 -13
  144. package/src/stack-contract.ts +195 -42
  145. package/src/stack-converge-secrets.test.ts +7 -2
  146. package/src/stack-converge.test.ts +37 -9
  147. package/src/stack-converge.ts +4 -1
  148. package/src/staging-admin.ts +2 -2
  149. package/src/staging-gateway-setup.test.ts +361 -0
  150. package/src/staging-gateway-setup.ts +1083 -0
  151. package/src/upgrade/app-roll.ts +7 -4
  152. package/src/upgrade/converge.ts +40 -26
  153. package/src/upgrade/image-forms.ts +7 -7
  154. package/src/upgrade/release-images.test.ts +28 -0
  155. package/src/upgrade/release-images.ts +44 -0
  156. package/src/upgrade/upgrade.test.ts +63 -11
  157. package/src/upgrade/upgrade.ts +29 -2
  158. package/src/upgrader-job.test.ts +23 -0
  159. package/src/upgrader-job.ts +45 -11
  160. package/src/upgrader-records.ts +7 -7
  161. package/src/wizard.ts +2 -3
  162. package/src/wizards/gcp.ts +73 -9
  163. package/stacks/gcp/index.ts +47 -6
  164. package/stacks/gcp-firebase/index.ts +44 -12
  165. package/stacks/self-hosted/index.ts +9 -2
package/src/lifecycle.ts CHANGED
@@ -69,12 +69,7 @@ import {
69
69
  resolveBackend,
70
70
  } from "./state-backend.js";
71
71
  import { acquireStateLock, realSyncGcloud, releaseOnSignal } from "./state-store.js";
72
- import {
73
- type AppRollPlan,
74
- type ReleaseImage,
75
- ROLL_ORDER,
76
- type RollSlug,
77
- } from "./upgrade/app-roll.js";
72
+ import type { AppRollPlan, RollSlug } from "./upgrade/app-roll.js";
78
73
  import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
79
74
  import { findServiceUrl, lookupFailure, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
80
75
  import { createIapHealthProbe, type HealthProbe } from "./upgrade/health.js";
@@ -84,12 +79,10 @@ import {
84
79
  canonicalReleaseTag,
85
80
  ensureCosignAvailable,
86
81
  fetchLatestReleaseTag,
87
- RELEASE_REGISTRY,
88
- releaseImageRef,
89
82
  releaseTag,
90
83
  releaseVersionForDigest,
91
84
  resolveOpsImageRef,
92
- resolveTagDigest,
85
+ resolveReleaseImages,
93
86
  verifyCosignAll,
94
87
  } from "./upgrade/release-images.js";
95
88
  import { clackReporter } from "./upgrade/reporter.js";
@@ -463,32 +456,6 @@ export async function upgradeCommand(opts: UpgradeOpts = {}): Promise<void> {
463
456
  );
464
457
  }
465
458
 
466
- /**
467
- * Resolve both release images' digests from the public registry. Via the
468
- * registry's anonymous V2 API, not `gcloud artifacts docker tags list`: that
469
- * needs IAM read on Caelo's registry project, which operators don't have.
470
- */
471
- async function resolveReleaseImages(
472
- tag: string,
473
- ): Promise<{ ok: true; images: Record<RollSlug, ReleaseImage> } | { ok: false; error: string }> {
474
- const images: Partial<Record<RollSlug, ReleaseImage>> = {};
475
- for (const slug of ROLL_ORDER) {
476
- const res = await resolveTagDigest(slug, tag);
477
- if (!res.ok) {
478
- const r = RELEASE_REGISTRY;
479
- return {
480
- ok: false,
481
- error:
482
- `Couldn't resolve image digest for ${slug}:${tag}: the public registry returned ${res.reason}.\n` +
483
- `Verify the tag exists at https://${r.region}-docker.pkg.dev/${r.project}/${r.repo}/${slug}:${tag}\n` +
484
- "(latest releases live at https://github.com/caelo-cms/caelo-cms/releases — pass --version vX.Y.Z to pin.)",
485
- };
486
- }
487
- images[slug] = { digest: res.digest, imageRef: releaseImageRef(slug, res.digest) };
488
- }
489
- return { ok: true, images: images as Record<RollSlug, ReleaseImage> };
490
- }
491
-
492
459
  /**
493
460
  * `upgrade`'s step of the keyless provisioner migration (#696,
494
461
  * provisioner-identity.ts) — a converge step (upgrade/converge.ts runs it
@@ -685,12 +652,11 @@ async function upgradeLockedInstall(
685
652
  // #696 — upgrade never moves Pulumi state; it says how to.
686
653
  const stateNotice = localStateNotice(meta);
687
654
  if (stateNotice) note(stateNotice, "Pulumi state is on this machine");
688
- if (ok && provider === "gcp-firebase") {
689
- // #650 — staging builds now render the staging host; the gateway
690
- // serves it once it is mapped. The command is idempotent, so this
691
- // reminder is safe to repeat on every upgrade.
655
+ if (ok) {
656
+ // #1330 — the converge routed the staging host to the staging gateway
657
+ // and printed its DNS records (Settings → Updates shows them as well).
692
658
  log.info(
693
- `Protected staging site: run ${bold("cms-provision staging-domain enable")} once to serve the staging host (it maps the host the admin uses — ${bold(stagingHost)} unless changed there —, turns on password protection, prints the login and the DNS record). Host and access: admin → Settings → Domains / Staging access.`,
659
+ `Protected staging site: https://${stagingHost}/ — served by the staging gateway behind the staging gate. Password protection and other access: admin → Settings → Staging access (or ${bold("cms-provision staging-domain enable")}).`,
694
660
  );
695
661
  }
696
662
  return ok;
@@ -44,8 +44,17 @@ const firebase: ReleaseServicesTarget = {
44
44
  region: "europe-west1",
45
45
  env: "production",
46
46
  domain: "acme.com",
47
+ stagingHost: "staging.acme.com",
47
48
  mcpAuthProxyImageRef: `${IMAGE}/mcp-auth-proxy@sha256:${"c".repeat(64)}`,
49
+ gatewayImageRef: `${IMAGE}/gateway@sha256:${"b".repeat(64)}`,
48
50
  };
51
+
52
+ /**
53
+ * The outcomes of the #1328 part; the staging gateway's (#1330) come after
54
+ * them and are covered by staging-gateway-setup.test.ts.
55
+ */
56
+ const proxyPart = (outcomes: readonly { id: string; status: string }[]) =>
57
+ outcomes.filter((o) => !/staging gateway/.test(o.id)).map((o) => o.status);
49
58
  const gcp: ReleaseServicesTarget = { ...firebase, provider: "gcp" };
50
59
 
51
60
  /** The old gateway service as `gcloud run services describe --format=json` shows it. */
@@ -210,7 +219,7 @@ describe("ensureReleaseServices", () => {
210
219
  };
211
220
  },
212
221
  });
213
- expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "applied"]);
222
+ expect(proxyPart(outcomes)).toEqual(["applied", "present", "present", "applied"]);
214
223
  expect(calls.filter((c) => c.startsWith("secrets add-iam-policy-binding"))).toEqual([
215
224
  `secrets add-iam-policy-binding caelo-production-mcp-oauth-seal-key --project=acme --member=serviceAccount:${PROXY_SA} --role=roles/secretmanager.secretAccessor --condition=None --quiet`,
216
225
  `secrets add-iam-policy-binding caelo-production-operator-oauth-client --project=acme --member=serviceAccount:${PROXY_SA} --role=roles/secretmanager.secretAccessor --condition=None --quiet`,
@@ -237,7 +246,7 @@ describe("ensureReleaseServices", () => {
237
246
  "run services describe caelo-production-data-gateway": ok("caelo-production-data-gateway\n"),
238
247
  });
239
248
  const outcomes = await ensureReleaseServices(firebase, { run });
240
- expect(outcomes.map((o) => o.status)).toEqual(["present", "present", "present", "present"]);
249
+ expect(proxyPart(outcomes)).toEqual(["present", "present", "present", "present"]);
241
250
  expect(calls.some((c) => c.includes("replace") || c.includes("create"))).toBe(false);
242
251
  });
243
252
 
@@ -267,7 +276,7 @@ describe("ensureReleaseServices", () => {
267
276
  "run deploy caelo-production-mcp-auth-proxy": ok(),
268
277
  });
269
278
  const outcomes = await ensureReleaseServices(gcp, { run });
270
- expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "applied"]);
279
+ expect(proxyPart(outcomes)).toEqual(["applied", "present", "present", "applied"]);
271
280
  expect(calls).toContain(gcpProxyDeployArgs(gcp).join(" "));
272
281
  });
273
282
 
@@ -280,7 +289,7 @@ describe("ensureReleaseServices", () => {
280
289
  ),
281
290
  });
282
291
  const outcomes = await ensureReleaseServices(gcp, { run });
283
- expect(outcomes.at(-1)?.status).toBe("present");
292
+ expect(proxyPart(outcomes).at(-1)).toBe("present");
284
293
  expect(calls.some((c) => c.startsWith("run deploy"))).toBe(false);
285
294
  });
286
295
 
@@ -292,7 +301,7 @@ describe("ensureReleaseServices", () => {
292
301
  ),
293
302
  });
294
303
  const outcomes = await ensureReleaseServices(gcp, { run });
295
- expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "failed"]);
304
+ expect(proxyPart(outcomes)).toEqual(["applied", "present", "failed"]);
296
305
  expect(calls.some((c) => c.startsWith("run "))).toBe(false);
297
306
  });
298
307
 
@@ -320,7 +329,7 @@ describe("ensureReleaseServices", () => {
320
329
  pauses.push(ms);
321
330
  },
322
331
  });
323
- expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "present"]);
332
+ expect(proxyPart(outcomes)).toEqual(["applied", "present", "present", "present"]);
324
333
  expect(pauses.length).toBe(2);
325
334
  expect(calls.filter((c) => c.startsWith("secrets add-iam-policy-binding"))).toHaveLength(2);
326
335
  });
@@ -49,6 +49,7 @@ import {
49
49
  serviceSecrets,
50
50
  } from "./stack-contract.js";
51
51
  import { listFlag } from "./stack-converge.js";
52
+ import { ensureStagingGatewayFromLiveGateway } from "./staging-gateway-setup.js";
52
53
  import { lookupFailure, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
53
54
 
54
55
  /** The install the services are ensured on. */
@@ -61,8 +62,15 @@ export interface ReleaseServicesTarget {
61
62
  readonly domain: string;
62
63
  /** The release's digest-pinned MCP-Auth-Proxy image (`…/mcp-auth-proxy@sha256:…`). */
63
64
  readonly mcpAuthProxyImageRef: string;
65
+ /** #1330 — the release's digest-pinned gateway image, which the staging gateway runs too. */
66
+ readonly gatewayImageRef: string;
67
+ /** #1330 — the staging host (install.json `stagingDomain`). */
68
+ readonly stagingHost: string;
64
69
  }
65
70
 
71
+ /** What the `gcp` proxy's own service needs. */
72
+ export type ProxyServiceTarget = Omit<ReleaseServicesTarget, "gatewayImageRef" | "stagingHost">;
73
+
66
74
  export interface ReleaseServicesDeps {
67
75
  readonly run?: GcloudRunner;
68
76
  /** Writes a service spec for `gcloud run services replace`; returns its path and a cleanup. */
@@ -95,6 +103,10 @@ export async function ensureReleaseServices(
95
103
  ? await ensureDataGatewayService(target, run, deps.writeSpecFile ?? writeTempSpec)
96
104
  : await ensureMcpAuthProxyServiceGcp(target, run),
97
105
  );
106
+ if (outcomes.some((o) => o.status === "failed")) return outcomes;
107
+ outcomes.push(
108
+ ...(await ensureStagingGatewayFromLiveGateway(target, { run, sleep: deps.sleep ?? realSleep })),
109
+ );
98
110
  return outcomes;
99
111
  }
100
112
 
@@ -317,7 +329,7 @@ function gcpProxyEnv(target: Pick<ReleaseServicesTarget, "env" | "domain">): Clo
317
329
  * invokable without a Google identity (MCP clients have none), request-based
318
330
  * CPU, no VPC, and its Google sign-in client file as a secret volume.
319
331
  */
320
- export function gcpProxyDeployArgs(target: ReleaseServicesTarget): string[] {
332
+ export function gcpProxyDeployArgs(target: ProxyServiceTarget): string[] {
321
333
  const name = fixedCloudRunServiceName("gcp", target.env, "mcp-auth-proxy") as string;
322
334
  const env = gcpProxyEnv(target);
323
335
  const plain = env.flatMap((e) => ("value" in e ? [`${e.name}=${e.value}`] : []));
@@ -356,7 +368,7 @@ export function gcpProxyDeployArgs(target: ReleaseServicesTarget): string[] {
356
368
  * app-roll moves it onto each release's image after that.
357
369
  */
358
370
  export async function ensureMcpAuthProxyServiceGcp(
359
- target: ReleaseServicesTarget,
371
+ target: ProxyServiceTarget,
360
372
  run: GcloudRunner,
361
373
  ): Promise<EnsureOutcome> {
362
374
  const name = fixedCloudRunServiceName("gcp", target.env, "mcp-auth-proxy") as string;
@@ -254,7 +254,7 @@ export function manifestServiceImages(m: Pick<ReleaseManifest, "version" | "dige
254
254
  | {
255
255
  readonly ok: true;
256
256
  readonly images: Readonly<
257
- Record<"admin" | "gateway" | "mcp-auth-proxy", ManifestServiceImage>
257
+ Record<"admin" | "gateway" | "staging-gateway" | "mcp-auth-proxy", ManifestServiceImage>
258
258
  >;
259
259
  }
260
260
  | { readonly ok: false; readonly error: string } {
@@ -274,6 +274,8 @@ export function manifestServiceImages(m: Pick<ReleaseManifest, "version" | "dige
274
274
  images: {
275
275
  admin: image("admin", m.digests.admin),
276
276
  gateway: image("gateway", m.digests.gateway),
277
+ // #1330 — the staging gateway runs the live gateway's image.
278
+ "staging-gateway": image("gateway", m.digests.gateway),
277
279
  "mcp-auth-proxy": image("mcp-auth-proxy", proxy),
278
280
  },
279
281
  };
@@ -96,6 +96,7 @@ describe("remote-MCP identity as stack config (#1082)", () => {
96
96
  expect(remoteMcpIdentityStackEnv(undefined)).toEqual({
97
97
  admin: [],
98
98
  gateway: [],
99
+ "staging-gateway": [],
99
100
  "mcp-auth-proxy": [],
100
101
  });
101
102
  });
@@ -413,6 +413,7 @@ const firebase: RemoteMcpTarget = {
413
413
  services: {
414
414
  admin: "caelo-production-admin-aaa",
415
415
  gateway: "caelo-production-data-gateway",
416
+ "staging-gateway": "caelo-production-staging-gateway",
416
417
  "mcp-auth-proxy": "caelo-production-gateway-bbb",
417
418
  },
418
419
  };
@@ -606,7 +607,10 @@ describe("checkRemoteMcpIdentityEnv (#1082)", () => {
606
607
  ),
607
608
  });
608
609
  const r = await checkRemoteMcpIdentityEnv(firebase, identity, { run });
609
- expect(r).toEqual({ ok: true, drift: { admin: [], gateway: [], "mcp-auth-proxy": [] } });
610
+ expect(r).toEqual({
611
+ ok: true,
612
+ drift: { admin: [], gateway: [], "staging-gateway": [], "mcp-auth-proxy": [] },
613
+ });
610
614
  expect(calls.some((c) => c.startsWith("run services update"))).toBe(false);
611
615
  });
612
616
 
package/src/remote-mcp.ts CHANGED
@@ -345,8 +345,17 @@ export async function checkRemoteMcpIdentityEnv(
345
345
  ): Promise<RemoteMcpResult<{ readonly drift: Record<CloudRunSlug, EnvChange[]> }>> {
346
346
  const run = deps.run ?? defaultGcloud;
347
347
  const desired = remoteMcpIdentityEnv(identity);
348
- const drift: Record<CloudRunSlug, EnvChange[]> = { admin: [], gateway: [], "mcp-auth-proxy": [] };
348
+ const drift: Record<CloudRunSlug, EnvChange[]> = {
349
+ admin: [],
350
+ gateway: [],
351
+ "staging-gateway": [],
352
+ "mcp-auth-proxy": [],
353
+ };
349
354
  for (const slug of CLOUD_RUN_SLUGS) {
355
+ // Nothing to check where the identity names nothing (the visitor
356
+ // gateways), and the staging gateway is the CLI's (#1330), not the
357
+ // stack's: it may not exist yet right after a first `up`.
358
+ if (desired[slug].length === 0) continue;
350
359
  const described = await describeService(target, target.services[slug], run);
351
360
  if (!described.ok) return described;
352
361
  const plan = planEnvUpdate(liveContainerEnv(described.json), desired[slug]);
@@ -323,7 +323,12 @@ describe("rotateRuntimeSecret", () => {
323
323
  projectId: "acme",
324
324
  region: "europe-west1",
325
325
  env: "production",
326
- services: { admin: "adm-svc", gateway: "gw-svc", "mcp-auth-proxy": "px-svc" },
326
+ services: {
327
+ admin: "adm-svc",
328
+ gateway: "gw-svc",
329
+ "staging-gateway": "stg-svc",
330
+ "mcp-auth-proxy": "px-svc",
331
+ },
327
332
  sqlInstance: "caelo-production-pg-1",
328
333
  };
329
334
  const ref = (name: string, secret: string, version = "latest") => ({
@@ -347,6 +352,10 @@ describe("rotateRuntimeSecret", () => {
347
352
  "run services describe gw-svc": [
348
353
  service([ref("PUBLIC_DATABASE_PASSWORD", "public-role-password")]),
349
354
  ],
355
+ // #1330 — the staging gateway reads its own login password.
356
+ "run services describe stg-svc": [
357
+ service([ref("PUBLIC_DATABASE_PASSWORD", "public-staging-role-password")]),
358
+ ],
350
359
  // #1328 — the proxy reads none of the rotatable database secrets.
351
360
  "run services describe px-svc": [
352
361
  service([ref("CAELO_MCP_OAUTH_SEAL_KEY", "mcp-oauth-seal-key")]),
@@ -30,6 +30,8 @@ import {
30
30
  gcpSecretId,
31
31
  mcpAuthProxyServiceAccountEmail,
32
32
  mcpAuthProxyServiceAccountId,
33
+ stagingGatewayServiceAccountEmail,
34
+ stagingGatewayServiceAccountId,
33
35
  } from "./gcp-names.js";
34
36
  import {
35
37
  CLI_GENERATED_SECRETS,
@@ -123,6 +125,25 @@ export function ensureMcpAuthProxyServiceAccount(
123
125
  );
124
126
  }
125
127
 
128
+ /**
129
+ * #1330 — create the staging gateway's run SA unless it exists. Its rights:
130
+ * `secretAccessor` on `public-staging-role-password` (runtimeSecretBindings),
131
+ * the log and metric writer, and object viewer on the staging bucket
132
+ * (staging-gateway-setup.ts) — nothing of the live site's.
133
+ */
134
+ export function ensureStagingGatewayServiceAccount(
135
+ install: { readonly projectId: string; readonly env: string },
136
+ deps: RuntimeDeps = {},
137
+ ): Promise<EnsureOutcome> {
138
+ return ensureServiceAccount(
139
+ install.projectId,
140
+ stagingGatewayServiceAccountId(install.env),
141
+ stagingGatewayServiceAccountEmail(install.projectId, install.env),
142
+ `Caelo ${install.env} staging gateway`,
143
+ deps,
144
+ );
145
+ }
146
+
126
147
  async function ensureServiceAccount(
127
148
  projectId: string,
128
149
  accountId: string,
@@ -177,6 +177,12 @@ describe("schedulerWorkflowSource", () => {
177
177
  expect(src).toContain(`exp: \${now + 600}`);
178
178
  });
179
179
 
180
+ it("declares the tick as JSON, or SvelteKit refuses it as a cross-site form post", () => {
181
+ const tick = src.slice(src.indexOf("- tick:"), src.indexOf("- done:"));
182
+ expect(tick).toContain("Content-Type: application/json");
183
+ expect(tick).toContain("body: {}");
184
+ });
185
+
180
186
  it("posts the tick with the signed JWT and a bounded timeout", () => {
181
187
  expect(src).toContain('url: "https://admin.acme.com/api/internal/scheduler/tick"');
182
188
  expect(src).toContain(`Authorization: \${"Bearer " + signed.body.signedJwt}`);
@@ -165,6 +165,11 @@ export function tickOrigin(
165
165
  * Credentials API, authenticated with the workflow's own OAuth token), then
166
166
  * POST the tick with it. A refused or failed tick (HTTP ≥ 400) fails the
167
167
  * execution, so it shows in the workflow's execution history and logs.
168
+ *
169
+ * The tick carries `Content-Type: application/json` and a JSON body, like the
170
+ * self-hosted clock (admin-core scheduler/clock.ts), because the admin's
171
+ * SvelteKit treats a POST with no content type and no `Origin` as a
172
+ * cross-site form submission: it answers 403 before the route runs.
168
173
  */
169
174
  export function schedulerWorkflowSource(opts: {
170
175
  readonly serviceAccount: string;
@@ -198,6 +203,8 @@ main:
198
203
  url: "${origin}${SCHEDULER_TICK_PATH}"
199
204
  headers:
200
205
  Authorization: \${"Bearer " + signed.body.signedJwt}
206
+ Content-Type: application/json
207
+ body: {}
201
208
  timeout: ${SCHEDULER_TICK_TIMEOUT_SECONDS}
202
209
  result: answer
203
210
  - done:
@@ -109,12 +109,12 @@ describe("backupSelfHosted", () => {
109
109
  expect(out).toEqual({ ok: true, path: to });
110
110
  expect(probe(r.calls.map((c) => c.cmd))).toEqual([
111
111
  "exec -T postgres pg_isready -U caelo",
112
- "stop caelo-admin caelo-gateway",
112
+ "stop caelo-admin caelo-gateway caelo-staging-gateway",
113
113
  "exec -T postgres pg_dumpall -U caelo --globals-only",
114
114
  "exec -T postgres pg_dump -U caelo -Fc cms_admin",
115
115
  "exec -T postgres pg_dump -U caelo -Fc cms_public",
116
116
  "run --rm --no-deps -T --entrypoint tar caelo-admin -C / -cf - srv/caelo data/media",
117
- "up -d caelo-admin caelo-gateway",
117
+ "up -d caelo-admin caelo-gateway caelo-staging-gateway",
118
118
  "exec -T caelo-admin bun -e <probe>",
119
119
  "exec -T caelo-gateway bun -e <probe>",
120
120
  ]);
@@ -146,7 +146,9 @@ describe("backupSelfHosted", () => {
146
146
  ...quiet,
147
147
  });
148
148
  expect(out.ok).toBe(false);
149
- expect(r.calls.map((c) => c.cmd)).toContain("up -d caelo-admin caelo-gateway");
149
+ expect(r.calls.map((c) => c.cmd)).toContain(
150
+ "up -d caelo-admin caelo-gateway caelo-staging-gateway",
151
+ );
150
152
  });
151
153
 
152
154
  it("refuses when Postgres is not running and changes nothing", async () => {
@@ -194,7 +196,7 @@ describe("restoreSelfHosted", () => {
194
196
  const out = await restoreSelfHosted(archive, restoreDeps(r));
195
197
  expect(out.ok).toBe(true);
196
198
  expect(probe(r.calls.map((c) => c.cmd))).toEqual([
197
- "stop caelo-admin caelo-gateway",
199
+ "stop caelo-admin caelo-gateway caelo-staging-gateway",
198
200
  "up -d --wait postgres",
199
201
  "exec -T postgres psql -U caelo -d postgres -v ON_ERROR_STOP=1 -c DROP DATABASE IF EXISTS cms_admin WITH (FORCE) -c DROP DATABASE IF EXISTS cms_public WITH (FORCE)",
200
202
  "<converge roles>",
@@ -28,6 +28,8 @@ const base: Omit<ComposeSpec, "images"> = {
28
28
  caeloSecretKek: "0".repeat(64),
29
29
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
30
30
  internalSecret: "i".repeat(48),
31
+ stagingHost: "staging.example.com",
32
+ stagingGateSecret: "g".repeat(64),
31
33
  anthropicApiKey: "sk-ant-x",
32
34
  diskSize: "20Gi",
33
35
  ports: { http: 80, https: 443 },
@@ -63,6 +63,7 @@ const MEMBER_EXPR: Record<IamInvariant["member"], string> = {
63
63
  // The secret loop resolves the SA per service through `serviceSa`.
64
64
  "gateway-sa": "gatewaySa.email",
65
65
  "mcp-auth-proxy-sa": "mcpAuthProxySa.email",
66
+ "staging-gateway-sa": "stagingGatewaySa.email",
66
67
  "static-publisher-sa": "staticPublisherSa.email",
67
68
  "iap-service-agent": "iapServiceIdentity.email",
68
69
  allUsers: 'member: "allUsers"',
@@ -166,15 +167,18 @@ describe.each(["gcp", "gcp-firebase"] as const)("%s stack ↔ upgrade parity", (
166
167
  /serviceName: "gateway",[\s\S]*?contractEnv: \[\s*\.\.\.gatewayEnvContract\(envContractInputs\),\s*\.\.\.poolBudget\.env\.gateway,?\s*\]/,
167
168
  );
168
169
  if (provider === "gcp-firebase") {
169
- // The proxy: its contract, its identity env, and (until #1331) the
170
- // data gateway it forwards to — a stack output no contract can name.
170
+ // The proxy: its contract and its identity env, nothing else (#1331:
171
+ // no forward origin any more).
171
172
  expect(src).toMatch(
172
- /serviceName: "mcp-auth-proxy",[\s\S]*?contractEnv: \[\s*\.\.\.mcpAuthProxyEnvContract\(\{ provider: "gcp-firebase", env \}\),\s*\.\.\.remoteMcpEnv\["mcp-auth-proxy"\],[\s\S]*?\{ name: INTERIM_FORWARD_ORIGIN_ENV, value: gatewaySvc\.uri \},\s*\]/,
173
+ /serviceName: "mcp-auth-proxy",[\s\S]*?contractEnv: \[\s*\.\.\.mcpAuthProxyEnvContract\(\{ provider: "gcp-firebase", env \}\),\s*\.\.\.remoteMcpEnv\["mcp-auth-proxy"\],?\s*\]/,
173
174
  );
175
+ expect(src).not.toContain("INTERIM_FORWARD_ORIGIN");
174
176
  } else {
175
177
  // gcp: the proxy is CLI-owned (mcp-auth-proxy-setup.ts), not a stack service.
176
178
  expect(src).not.toContain('serviceName: "mcp-auth-proxy"');
177
179
  }
180
+ // #1330 — the staging gateway is CLI-owned on both (staging-gateway-setup.ts).
181
+ expect(src).not.toContain('serviceName: "staging-gateway"');
178
182
  expect(src).toContain("envs: [...args.contractEnv],");
179
183
  });
180
184
 
@@ -14,7 +14,6 @@ import {
14
14
  defaultStagingHost,
15
15
  fixedCloudRunServiceName,
16
16
  gatewayEnvContract,
17
- INTERIM_FORWARD_ORIGIN_ENV,
18
17
  iamMember,
19
18
  MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS,
20
19
  mcpAuthProxyEnvContract,
@@ -24,12 +23,15 @@ import {
24
23
  RETIRED_SERVICE_ENV,
25
24
  runtimeSecretBindings,
26
25
  SECRETS_SET_UP_AHEAD_OF_THEIR_READER,
26
+ SERVICE_IMAGE,
27
27
  SERVICE_SECRET_ENV,
28
28
  SERVICE_SECRET_FILES,
29
29
  SERVICE_VPC_EGRESS,
30
30
  STACK_IAM_NOT_ENSURED,
31
+ STAGING_GATEWAY_TRUSTED_PROXY_HOPS,
31
32
  serviceSecrets,
32
33
  stackIamInvariants,
34
+ stagingGatewayEnvContract,
33
35
  } from "./stack-contract.js";
34
36
 
35
37
  const base = {
@@ -108,6 +110,8 @@ describe("adminEnvContract", () => {
108
110
  CAELO_CAPTURE_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-capture",
109
111
  CAELO_STATIC_BUCKET: "acme-caelo-production-static",
110
112
  CAELO_STAGING_BUCKET: "acme-caelo-production-staging",
113
+ // #1330 — every provider serves staging at its own host now.
114
+ CAELO_STAGING_HOST: "staging.acme.com",
111
115
  ...ADMIN_SECRETS,
112
116
  });
113
117
  });
@@ -119,10 +123,13 @@ describe("adminEnvContract", () => {
119
123
  ...base,
120
124
  provider: "gcp-firebase",
121
125
  firebaseSiteId: "caelo-production-site-abc123",
126
+ firebaseStagingSiteId: "caelo-production-stg-6eace1",
122
127
  gatewayService: "caelo-production-gateway-1a2b3c4",
123
128
  }),
124
129
  ),
125
130
  ).toEqual({
131
+ CAELO_FIREBASE_STAGING_SITE: "caelo-production-stg-6eace1",
132
+ CAELO_STAGING_BUCKET: "acme-caelo-production-staging",
126
133
  CAELO_PROVIDER: "gcp-firebase",
127
134
  CAELO_ENV: "production",
128
135
  MEDIA_STORAGE_URL: "gs://acme-caelo-production-media",
@@ -210,8 +217,8 @@ describe("gatewayEnvContract", () => {
210
217
  PUBLIC_DATABASE_URL:
211
218
  "postgresql://public_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
212
219
  PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-role-password:latest",
213
- CAELO_STAGING_GATE_SECRET: "secret:caelo-production-staging-gate-secret:latest",
214
- // No remote-MCP env: the broker is the MCP-Auth-Proxy's (#1328).
220
+ // No remote-MCP env: the broker is the MCP-Auth-Proxy's (#1328); no
221
+ // staging gate key: the staging gateway's, through its projection (#1330).
215
222
  });
216
223
  });
217
224
 
@@ -222,10 +229,53 @@ describe("gatewayEnvContract", () => {
222
229
  expect(asRecord(gateway).CAELO_TRUSTED_PROXY_HOPS).toBe("2");
223
230
  });
224
231
 
225
- it("#650 — reads the staging host from the database, not its env", () => {
232
+ it("#1330 — the live gateway knows no staging host and holds no staging gate key", () => {
226
233
  const fb = asRecord(gatewayEnvContract({ ...base, provider: "gcp-firebase" }));
227
234
  expect(fb.CAELO_STAGING_HOST).toBeUndefined();
228
- expect(fb.CAELO_STAGING_GATE_SECRET).toBe("secret:caelo-production-staging-gate-secret:latest");
235
+ expect(fb.CAELO_STAGING_GATE_SECRET).toBeUndefined();
236
+ expect(RETIRED_SERVICE_ENV.gateway).toContain("CAELO_STAGING_GATE_SECRET");
237
+ });
238
+
239
+ it("#1330 — the staging gateway is the gateway image set to staging: its one login, its storage, nothing live", () => {
240
+ for (const provider of ["gcp", "gcp-firebase"] as const) {
241
+ expect(asRecord(stagingGatewayEnvContract({ ...base, provider }))).toEqual({
242
+ CAELO_PROVIDER: provider,
243
+ CAELO_ENV: "production",
244
+ CAELO_GATEWAY_ENVIRONMENT: "staging",
245
+ CAELO_TRUSTED_PROXY_HOPS: String(STAGING_GATEWAY_TRUSTED_PROXY_HOPS[provider]),
246
+ PUBLIC_DATABASE_URL:
247
+ "postgresql://public_staging_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public_staging?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
248
+ CAELO_STAGING_STORAGE: "gs://acme-caelo-production-staging",
249
+ PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-staging-role-password:latest",
250
+ });
251
+ }
252
+ });
253
+
254
+ it("pins the trusted hop count per provider, so a forged X-Forwarded-For entry is never trusted", () => {
255
+ expect(STAGING_GATEWAY_TRUSTED_PROXY_HOPS).toEqual({
256
+ gcp: 2,
257
+ "gcp-firebase": 1,
258
+ "self-hosted": 1,
259
+ aws: 2,
260
+ azure: 1,
261
+ });
262
+ });
263
+
264
+ it("shares no secret with the live gateway and reads exactly its own login", () => {
265
+ expect(serviceSecrets("staging-gateway")).toEqual(["public-staging-role-password"]);
266
+ const live = new Set(serviceSecrets("gateway"));
267
+ expect(serviceSecrets("staging-gateway").some((x) => live.has(x))).toBe(false);
268
+ expect(serviceSecrets("gateway")).not.toContain("staging-gate-secret");
269
+ expect(SECRETS_SET_UP_AHEAD_OF_THEIR_READER["public-staging-role-password"]).toBeUndefined();
270
+ });
271
+
272
+ it("runs the live gateway's image under its own fixed name", () => {
273
+ expect(SERVICE_IMAGE["staging-gateway"]).toBe("gateway");
274
+ for (const provider of ["gcp", "gcp-firebase"] as const) {
275
+ expect(fixedCloudRunServiceName(provider, "production", "staging-gateway")).toBe(
276
+ "caelo-production-staging-gateway",
277
+ );
278
+ }
229
279
  });
230
280
 
231
281
  it("#650 — the admin's staging host seed is the install setting, any host", () => {
@@ -235,6 +285,7 @@ describe("gatewayEnvContract", () => {
235
285
  stagingHost: "preview.other.de",
236
286
  provider: "gcp-firebase",
237
287
  firebaseSiteId: "s",
288
+ firebaseStagingSiteId: "s-stg",
238
289
  gatewayService: "g",
239
290
  }),
240
291
  );
@@ -243,7 +294,7 @@ describe("gatewayEnvContract", () => {
243
294
  });
244
295
 
245
296
  it("the gateway's run SA reads no admin-only secret (admin password, KEK, internal, tool approval)", () => {
246
- expect(serviceSecrets("gateway")).toEqual(["public-role-password", "staging-gate-secret"]);
297
+ expect(serviceSecrets("gateway")).toEqual(["public-role-password"]);
247
298
  for (const v of gateway) {
248
299
  expect(v.name).not.toMatch(/KEK|INTERNAL|TOOL_APPROVAL|PUBLIC_ADMIN/);
249
300
  }
@@ -336,10 +387,11 @@ describe("runtime secrets", () => {
336
387
  secret: "public-role-password",
337
388
  stackResource: "gateway-public-role-password-binding",
338
389
  },
390
+ // #1330 — the staging gateway: its own login only.
339
391
  {
340
- service: "gateway",
341
- secret: "staging-gate-secret",
342
- stackResource: "gateway-staging-gate-secret-binding",
392
+ service: "staging-gateway",
393
+ secret: "public-staging-role-password",
394
+ stackResource: "staging-gateway-public-staging-role-password-binding",
343
395
  },
344
396
  // #1328 — the broker's seal key (env) and Google sign-in client (a
345
397
  // file, which counts like an env secret: the SA reads it).
@@ -385,6 +437,8 @@ describe("self-hosted compose", () => {
385
437
  caeloSecretKek: "k",
386
438
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
387
439
  internalSecret: "i".repeat(48),
440
+ stagingHost: "staging.example.com",
441
+ stagingGateSecret: "g".repeat(64),
388
442
  diskSize: "10G",
389
443
  ports: { http: 80, https: 443 },
390
444
  project: "caelo-example-com",
@@ -424,14 +478,19 @@ describe("stackIamInvariants", () => {
424
478
  }
425
479
  });
426
480
 
427
- it("grants the gateway SA only its own role password, the staging gate key + telemetry roles", () => {
481
+ it("grants the gateway SA only its own role password + telemetry roles (#1330: no staging gate key)", () => {
428
482
  for (const provider of ["gcp", "gcp-firebase"] as const) {
429
483
  const gateway = stackIamInvariants(provider).filter((i) => i.member === "gateway-sa");
430
484
  expect(gateway.map((i) => `${i.role} ${JSON.stringify(i.target)}`).sort()).toEqual([
431
485
  'roles/logging.logWriter {"kind":"project"}',
432
486
  'roles/monitoring.metricWriter {"kind":"project"}',
433
487
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"public-role-password"}',
434
- 'roles/secretmanager.secretAccessor {"kind":"secret","name":"staging-gate-secret"}',
488
+ ]);
489
+ const staging = stackIamInvariants(provider).filter((i) => i.member === "staging-gateway-sa");
490
+ expect(staging.map((i) => `${i.role} ${JSON.stringify(i.target)}`).sort()).toEqual([
491
+ 'roles/logging.logWriter {"kind":"project"}',
492
+ 'roles/monitoring.metricWriter {"kind":"project"}',
493
+ 'roles/secretmanager.secretAccessor {"kind":"secret","name":"public-staging-role-password"}',
435
494
  ]);
436
495
  }
437
496
  });
@@ -508,10 +567,11 @@ describe("MCP-Auth-Proxy contract (#1328)", () => {
508
567
  CAELO_REMOTE_MCP: "unavailable",
509
568
  CAELO_TRUSTED_PROXY_HOPS: String(MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS.azure),
510
569
  });
511
- // The interim forward origin is the stack's / converge's, never the contract's.
570
+ // #1331 — the interim forwarder is gone; the roll removes its env.
512
571
  expect(
513
572
  mcpAuthProxyEnvContract({ provider: "gcp-firebase", env: "production" }).map((v) => v.name),
514
- ).not.toContain(INTERIM_FORWARD_ORIGIN_ENV);
573
+ ).not.toContain("CAELO_INTERIM_FORWARD_ORIGIN");
574
+ expect(RETIRED_SERVICE_ENV["mcp-auth-proxy"]).toContain("CAELO_INTERIM_FORWARD_ORIGIN");
515
575
  });
516
576
 
517
577
  it("the proxy's SA reads exactly its two secrets and holds no other invariant", () => {