@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
@@ -50,6 +50,7 @@ import {
50
50
  operatorAccessJobResource,
51
51
  runServiceAccountEmail,
52
52
  schedulerServiceAccountEmail,
53
+ stagingGatewayServiceAccountEmail,
53
54
  staticPublisherServiceAccountId,
54
55
  upgraderJobResource,
55
56
  } from "./gcp-names.js";
@@ -57,15 +58,45 @@ import {
57
58
  export type GcpProvider = "gcp" | "gcp-firebase";
58
59
  /**
59
60
  * The install's Cloud Run services, by role: the admin, the visitor data
60
- * gateway and (#1328) the MCP-Auth-Proxy — remote MCP's sign-in broker and
61
- * its forwarder to the IAP-protected admin, a service of its own so a hole
62
- * in a visitor handler can never reach the operators' Google tokens.
63
- * Which deployed service plays which role is {@link cloudRunServicePrefix}.
61
+ * gateway, (#1330) the staging gateway — the same image as the live one,
62
+ * gate on, serving the staged build and staging's own data
63
+ * (`cms_public_staging`) — and (#1328) the MCP-Auth-Proxy — remote MCP's
64
+ * sign-in broker and its forwarder to the IAP-protected admin, a service of
65
+ * its own so a hole in a visitor handler can never reach the operators'
66
+ * Google tokens. Which deployed service plays which role is
67
+ * {@link cloudRunServicePrefix}.
64
68
  */
65
- export type CloudRunSlug = "admin" | "gateway" | "mcp-auth-proxy";
69
+ export type CloudRunSlug = "admin" | "gateway" | "staging-gateway" | "mcp-auth-proxy";
66
70
 
67
71
  /** Every {@link CloudRunSlug}, in roll order: the admin first, the proxy last. */
68
- export const CLOUD_RUN_SLUGS: readonly CloudRunSlug[] = ["admin", "gateway", "mcp-auth-proxy"];
72
+ export const CLOUD_RUN_SLUGS: readonly CloudRunSlug[] = [
73
+ "admin",
74
+ "gateway",
75
+ "staging-gateway",
76
+ "mcp-auth-proxy",
77
+ ];
78
+
79
+ /** The release images the Cloud Run services run (one per image, not per service). */
80
+ export type ServiceImageName = "admin" | "gateway" | "mcp-auth-proxy";
81
+
82
+ /** Every {@link ServiceImageName}, in roll order. */
83
+ export const SERVICE_IMAGE_NAMES: readonly ServiceImageName[] = [
84
+ "admin",
85
+ "gateway",
86
+ "mcp-auth-proxy",
87
+ ];
88
+
89
+ /**
90
+ * The release image each service runs: the staging gateway runs the live
91
+ * gateway's image (#1330 — one image, its environment decides what it
92
+ * serves), every other service its own.
93
+ */
94
+ export const SERVICE_IMAGE: Readonly<Record<CloudRunSlug, ServiceImageName>> = {
95
+ admin: "admin",
96
+ gateway: "gateway",
97
+ "staging-gateway": "gateway",
98
+ "mcp-auth-proxy": "mcp-auth-proxy",
99
+ };
69
100
 
70
101
  /**
71
102
  * The name prefix of the Cloud Run service that plays `slug` on an install
@@ -84,8 +115,13 @@ export const CLOUD_RUN_SLUGS: readonly CloudRunSlug[] = ["admin", "gateway", "mc
84
115
  * the MCP address is the site's own host behind the load balancer, so no
85
116
  * service name is pinned there and the proxy is the new service.
86
117
  *
118
+ * #1330 — the staging gateway is `caelo-<env>-staging-gateway` on both
119
+ * providers, created by the CLI (staging-gateway-setup.ts) under that fixed
120
+ * name.
121
+ *
87
122
  * No prefix is a prefix of another (`…-gateway` does not prefix
88
- * `…-data-gateway`), so a `metadata.name~^<prefix>` lookup finds one role.
123
+ * `…-data-gateway` or `…-staging-gateway`), so a `metadata.name~^<prefix>`
124
+ * lookup finds one role.
89
125
  */
90
126
  export function cloudRunServicePrefix(
91
127
  provider: GcpProvider,
@@ -94,6 +130,7 @@ export function cloudRunServicePrefix(
94
130
  ): string {
95
131
  const prefix = gcpNamePrefix(env);
96
132
  if (slug === "admin") return `${prefix}-admin`;
133
+ if (slug === "staging-gateway") return `${prefix}-staging-gateway`;
97
134
  if (provider === "gcp-firebase") {
98
135
  return slug === "gateway" ? `${prefix}-data-gateway` : `${prefix}-gateway`;
99
136
  }
@@ -102,7 +139,8 @@ export function cloudRunServicePrefix(
102
139
 
103
140
  /**
104
141
  * The exact name of a service that is created under a fixed name (#1328):
105
- * the `gcp-firebase` data gateway and the `gcp` MCP-Auth-Proxy. Fixed, not
142
+ * the `gcp-firebase` data gateway, the `gcp` MCP-Auth-Proxy and (#1330) the
143
+ * staging gateway on both. Fixed, not
106
144
  * Pulumi-suffixed, because the converge creates them on existing installs
107
145
  * with gcloud, and the stack's next `pulumi up` must then find the same
108
146
  * service instead of creating a second one. Null for the services Pulumi
@@ -114,6 +152,7 @@ export function fixedCloudRunServiceName(
114
152
  slug: CloudRunSlug,
115
153
  ): string | null {
116
154
  const fixed =
155
+ slug === "staging-gateway" ||
117
156
  (provider === "gcp-firebase" && slug === "gateway") ||
118
157
  (provider === "gcp" && slug === "mcp-auth-proxy");
119
158
  return fixed ? cloudRunServicePrefix(provider, env, slug) : null;
@@ -253,6 +292,8 @@ export function cloudSqlPrivateDnsName(
253
292
  export const DATABASE_URL_VARS = {
254
293
  admin: ["ADMIN_DATABASE_URL", "PUBLIC_ADMIN_DATABASE_URL", "PUBLIC_STAGING_ADMIN_DATABASE_URL"],
255
294
  gateway: ["PUBLIC_DATABASE_URL"],
295
+ // #1330 — public_staging_role on cms_public_staging, its only login.
296
+ "staging-gateway": ["PUBLIC_DATABASE_URL"],
256
297
  // #1328 — no database login at all. On gcp-firebase the service still
257
298
  // carries the gateway's URL until its roll removes it with the rest of
258
299
  // the gateway env (RETIRED_SERVICE_ENV), so the TLS step leaves it alone.
@@ -276,6 +317,8 @@ export type AdminEnvInputs<V = string> =
276
317
  | (RuntimeEnvInputs<V> & {
277
318
  readonly provider: "gcp-firebase";
278
319
  readonly firebaseSiteId: V;
320
+ /** #1330 — the second Hosting site that serves the staging host (gcp-names.ts firebaseStagingSiteId). */
321
+ readonly firebaseStagingSiteId: V;
279
322
  readonly gatewayService: V;
280
323
  });
281
324
 
@@ -296,6 +339,9 @@ export function defaultStagingHost(domain: string): string {
296
339
  /** Env var the admin seeds its staging host setting from. */
297
340
  export const STAGING_HOST_ENV = "CAELO_STAGING_HOST";
298
341
 
342
+ /** #1330 — env var naming the gcp-firebase staging Hosting site on the admin. */
343
+ export const FIREBASE_STAGING_SITE_ENV = "CAELO_FIREBASE_STAGING_SITE";
344
+
299
345
  /**
300
346
  * The admin env var naming the MCP service account (issue #37). `upgrade`
301
347
  * leaves it untouched when it could not set that account up.
@@ -515,7 +561,10 @@ export const CLI_GENERATED_SECRETS = [
515
561
  "public-staging-role-password",
516
562
  // #650 — the staging access gate's key: signs "Open staging" links and
517
563
  // session cookies, derives the crawler credentials. Read by the admin
518
- // (issues) and the gateway (verifies).
564
+ // only; since #1330 the admin publishes it to the staging gateway through
565
+ // the staging projection (cms_public_staging), so no gateway reads it
566
+ // from Secret Manager (moved, never re-minted: the crawler credentials
567
+ // derived from it keep working).
519
568
  "staging-gate-secret",
520
569
  // #914 / #806 — the Google sign-in client starts as the `{}` placeholder
521
570
  // (not configured; the Owner fills it under Settings → Google sign-in),
@@ -533,10 +582,7 @@ export const CLI_GENERATED_SECRETS = [
533
582
  */
534
583
  export const SECRETS_SET_UP_AHEAD_OF_THEIR_READER: Readonly<
535
584
  Partial<Record<(typeof CLI_GENERATED_SECRETS)[number], string>>
536
- > = {
537
- "public-staging-role-password":
538
- "#1329 — public_staging_role's password. The stacks (gcp.sql.User) and the converge (staging-database.ts) set the role's password from it; the staging gateway service (#1330) is its first runtime reader.",
539
- };
585
+ > = {};
540
586
 
541
587
  /** A CLI-generated secret's first value: random hex, random base64, or a fixed placeholder. */
542
588
  export type GeneratedSecretFormat =
@@ -607,10 +653,15 @@ export const SERVICE_SECRET_ENV: Readonly<
607
653
  },
608
654
  gateway: {
609
655
  // CLAUDE.md §2: no cms_admin credential of any role (#613, #635).
610
- // public_role on cms_public is the gateway's only login.
656
+ // public_role on cms_public is the gateway's only login. Its one
657
+ // credential (#763: each gateway holds exactly one).
611
658
  PUBLIC_DATABASE_PASSWORD: "public-role-password",
612
- // #650 — verifies staging-host requests (api-gateway staging-proxy.ts).
613
- CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
659
+ },
660
+ // #1330 — public_staging_role on cms_public_staging: the staging
661
+ // gateway's one credential. The gate key comes through the staging
662
+ // projection, never from here; the two gateways share no secret.
663
+ "staging-gateway": {
664
+ PUBLIC_DATABASE_PASSWORD: "public-staging-role-password",
614
665
  },
615
666
  // #1328 — with the Google sign-in client file below, the proxy's whole
616
667
  // Secret Manager reach: no database password, no other key.
@@ -668,6 +719,7 @@ export function secretFilePath(file: SecretFileContract): string {
668
719
  export const SERVICE_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly SecretFileContract[]>> = {
669
720
  admin: [],
670
721
  gateway: [],
722
+ "staging-gateway": [],
671
723
  "mcp-auth-proxy": [OPERATOR_OAUTH_CLIENT_FILE],
672
724
  };
673
725
 
@@ -706,6 +758,7 @@ export const RETIRED_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly Retire
706
758
  why: "the remote-MCP broker moved to the MCP-Auth-Proxy (#1328); the visitor gateway must not hold the Google sign-in client",
707
759
  },
708
760
  ],
761
+ "staging-gateway": [],
709
762
  "mcp-auth-proxy": [
710
763
  {
711
764
  // gcp-firebase: the proxy is the service that was the gateway, which
@@ -780,7 +833,13 @@ export const RETIRED_SERVICE_ENV: Readonly<Record<CloudRunSlug, readonly string[
780
833
  "CAELO_MCP_OAUTH_SEAL_KEY_PREVIOUS",
781
834
  "CAELO_MCP_PUBLIC_URL",
782
835
  "CAELO_ADMIN_URL",
836
+ // #1330/#1331 — the staging gate moved to the staging gateway, which
837
+ // gets its key through the staging projection.
838
+ "CAELO_STAGING_GATE_SECRET",
783
839
  ],
840
+ // #1330 — a fresh service; nothing older to remove. Listed so the
841
+ // contract names every service.
842
+ "staging-gateway": [],
784
843
  // #1328 — on gcp-firebase the proxy is the service that was the gateway:
785
844
  // everything that service carried for visitor traffic goes, above all
786
845
  // every database login and the staging gate key. The proxy's SA cannot
@@ -798,6 +857,9 @@ export const RETIRED_SERVICE_ENV: Readonly<Record<CloudRunSlug, readonly string[
798
857
  "ADMIN_DATABASE_PASSWORD",
799
858
  "GATEWAY_DATABASE_URL",
800
859
  "GATEWAY_DATABASE_PASSWORD",
860
+ // #1331 — the interim forwarder to the data gateway is gone: the live
861
+ // rewrite names the data gateway, the staging host the staging gateway.
862
+ "CAELO_INTERIM_FORWARD_ORIGIN",
801
863
  ],
802
864
  };
803
865
 
@@ -871,6 +933,48 @@ export const GATEWAY_TRUSTED_PROXY_HOPS: Readonly<Record<SupportedProvider, numb
871
933
  azure: 1,
872
934
  };
873
935
 
936
+ /**
937
+ * #1330 — {@link TRUSTED_PROXY_HOPS_ENV} of the staging gateway per
938
+ * provider: its own topology, so the staging gate's IP allowlist trusts
939
+ * exactly the entry its own front end appends and a forged
940
+ * `X-Forwarded-For` cannot pass it (stack-contract.test.ts pins the table).
941
+ */
942
+ export const STAGING_GATEWAY_TRUSTED_PROXY_HOPS: Readonly<Record<SupportedProvider, number>> = {
943
+ // The staging host's own host rule on the external Application LB:
944
+ // `<client>,<LB IP>`; ingress is internal-and-LB only, so nothing reaches
945
+ // the service around the LB.
946
+ gcp: 2,
947
+ // The second Hosting site's `**` rewrite, then Cloud Run's front end,
948
+ // which appends a per-request Google proxy (#1325). The visitor entry
949
+ // Hosting writes before it cannot be told from a forged one (the service
950
+ // is invokable on *.run.app too), so the IP-allowlist mode is refused on
951
+ // gcp-firebase and the entry is used only to throttle failed attempts.
952
+ "gcp-firebase": 1,
953
+ // Caddy without `trusted_proxies` replaces the header with its peer; the
954
+ // staging gateway is reachable only on the Compose network.
955
+ "self-hosted": 1,
956
+ // Not deployed yet (#749): the live gateway's topology once it is.
957
+ aws: 2,
958
+ // Not deployed yet (#750).
959
+ azure: 1,
960
+ };
961
+
962
+ /**
963
+ * #1330 — where the staging gateway reads the staged build:
964
+ * `gs://<staging bucket>` on gcp / gcp-firebase, `file:///srv/staging` on
965
+ * self-hosted (the admin's `output/staging`, mounted read-only). The
966
+ * gateway reads only the build slots under it (`_slots/` on GCS).
967
+ */
968
+ export const STAGING_STORAGE_ENV = "CAELO_STAGING_STORAGE";
969
+
970
+ /** {@link STAGING_STORAGE_ENV} of a GCP install. */
971
+ export function gcpStagingStorage(projectId: string, env: string): string {
972
+ return `gs://${gcpBucketName(projectId, env, "staging")}`;
973
+ }
974
+
975
+ /** #1330 — the staging gateway's request timeout: Firebase Hosting cuts a rewrite at 60 s (#1325). */
976
+ export const STAGING_GATEWAY_TIMEOUT_SECONDS = 60;
977
+
874
978
  /**
875
979
  * The env of the gateway service. Order is stable so the stacks see no
876
980
  * spurious diffs.
@@ -887,6 +991,30 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
887
991
  ];
888
992
  }
889
993
 
994
+ /**
995
+ * #1330 — the env of the staging gateway: the live gateway's image with
996
+ * `CAELO_GATEWAY_ENVIRONMENT=staging`. Its one credential is
997
+ * `public_staging_role` on `cms_public_staging`; it reads the staged build
998
+ * from {@link STAGING_STORAGE_ENV}. No media bucket, no gate key (the
999
+ * admin publishes that to the staging database), nothing of the live data.
1000
+ */
1001
+ export function stagingGatewayEnvContract<V>(
1002
+ inputs: RuntimeEnvInputs<V>,
1003
+ ): CloudRunEnvVar<V | string>[] {
1004
+ return [
1005
+ { name: "CAELO_PROVIDER", value: inputs.provider },
1006
+ { name: "CAELO_ENV", value: inputs.env },
1007
+ { name: GATEWAY_ENVIRONMENT_ENV, value: "staging" },
1008
+ {
1009
+ name: TRUSTED_PROXY_HOPS_ENV,
1010
+ value: String(STAGING_GATEWAY_TRUSTED_PROXY_HOPS[inputs.provider]),
1011
+ },
1012
+ { name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.publicStaging },
1013
+ { name: STAGING_STORAGE_ENV, value: gcpStagingStorage(inputs.projectId, inputs.env) },
1014
+ ...secretEnv("staging-gateway", inputs.env),
1015
+ ];
1016
+ }
1017
+
890
1018
  /**
891
1019
  * #1328 — whether remote MCP can exist on a provider, as the MCP-Auth-Proxy
892
1020
  * reads it. It needs the admin behind Google IAP, which only the Google
@@ -926,26 +1054,12 @@ export const MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS: Readonly<Record<SupportedProvide
926
1054
  azure: 1,
927
1055
  };
928
1056
 
929
- /**
930
- * #1328 — points the `gcp-firebase` MCP-Auth-Proxy at the visitor data
931
- * gateway for the traffic that still reaches the service that used to be
932
- * the gateway: the Firebase `/api/**` rewrite of the release that is live
933
- * until the next Publish live, and the staging host's Cloud Run domain
934
- * mapping (#650), which only the domain's verified owner could move. The
935
- * proxy streams that traffic to the data gateway unchanged instead of
936
- * answering it (apps/mcp-auth-proxy interim-forward.ts). gcp-firebase only.
937
- */
938
- // FIXME(issue #1331): goes with the staging cutover, once neither a mapping
939
- // nor a published rewrite can point at the proxy any more.
940
- export const INTERIM_FORWARD_ORIGIN_ENV = "CAELO_INTERIM_FORWARD_ORIGIN";
941
-
942
1057
  /**
943
1058
  * The env of the MCP-Auth-Proxy. No database URL, no pool size, no site
944
1059
  * settings: the broker reads env plus one secret file and nothing else
945
1060
  * (apps/mcp-auth-proxy, whose import lint keeps every database client out).
946
1061
  * The remote-MCP identity env — its public origin, the admin's URL — comes
947
- * on top from {@link remoteMcpIdentityEnv}, the gcp-firebase forward origin
948
- * from the stack or the converge ({@link INTERIM_FORWARD_ORIGIN_ENV}).
1062
+ * on top from {@link remoteMcpIdentityEnv}.
949
1063
  */
950
1064
  export function mcpAuthProxyEnvContract(inputs: {
951
1065
  readonly provider: SupportedProvider;
@@ -1006,6 +1120,7 @@ export const SERVICE_CPU_IDLE: Readonly<Record<CloudRunSlug, boolean>> = {
1006
1120
  // between requests (the category-A jobs, the heartbeat at request start).
1007
1121
  admin: false,
1008
1122
  gateway: true,
1123
+ "staging-gateway": true,
1009
1124
  "mcp-auth-proxy": true,
1010
1125
  };
1011
1126
 
@@ -1018,6 +1133,8 @@ export const SERVICE_CPU_IDLE: Readonly<Record<CloudRunSlug, boolean>> = {
1018
1133
  export const SERVICE_VPC_EGRESS: Readonly<Record<CloudRunSlug, boolean>> = {
1019
1134
  admin: true,
1020
1135
  gateway: true,
1136
+ // #1330 — reaches the staging database on Cloud SQL's private IP.
1137
+ "staging-gateway": true,
1021
1138
  "mcp-auth-proxy": false,
1022
1139
  };
1023
1140
 
@@ -1158,9 +1275,10 @@ export function remoteMcpIdentityEnv(
1158
1275
  ? [{ name: REMOTE_MCP_ENV.googleOrgDomain, value: id.googleOrgDomain }]
1159
1276
  : []),
1160
1277
  ],
1161
- // #1328 — the broker lives in the MCP-Auth-Proxy; the visitor gateway
1162
- // gets none of it.
1278
+ // #1328 — the broker lives in the MCP-Auth-Proxy; the visitor gateways
1279
+ // get none of it.
1163
1280
  gateway: [],
1281
+ "staging-gateway": [],
1164
1282
  "mcp-auth-proxy": [
1165
1283
  { name: REMOTE_MCP_ENV.adminUrl, value: id.adminUrl },
1166
1284
  { name: REMOTE_MCP_ENV.mcpPublicUrl, value: id.mcpPublicUrl },
@@ -1228,7 +1346,9 @@ export function remoteMcpIdentityStackEnv(
1228
1346
  raw: string | undefined,
1229
1347
  ): Record<CloudRunSlug, CloudRunEnvVar[]> {
1230
1348
  const id = parseRemoteMcpIdentityConfig(raw);
1231
- return id ? remoteMcpIdentityEnv(id) : { admin: [], gateway: [], "mcp-auth-proxy": [] };
1349
+ return id
1350
+ ? remoteMcpIdentityEnv(id)
1351
+ : { admin: [], gateway: [], "staging-gateway": [], "mcp-auth-proxy": [] };
1232
1352
  }
1233
1353
 
1234
1354
  /** Whether two identities would give the services the same env. */
@@ -1326,26 +1446,31 @@ export function adminEnvContract<V>(inputs: AdminEnvInputs<V>): CloudRunEnvVar<V
1326
1446
  value: operatorOAuthClientSecretResource(projectId, env),
1327
1447
  },
1328
1448
  ];
1449
+ // #1330 — the staging bucket: the full staged build in two slots that the
1450
+ // staging gateway serves from (gcp: Stage also keeps its per-run prefixes
1451
+ // there for Publish live and rollback).
1452
+ admin.push({ name: "CAELO_STAGING_BUCKET", value: gcpBucketName(projectId, env, "staging") });
1329
1453
  if (inputs.provider === "gcp") {
1330
1454
  // v0.2.78 — the GCS StaticPublisher: Stage uploads to staging,
1331
1455
  // Confirm-publish copies to static.
1332
- admin.push(
1333
- { name: "CAELO_STATIC_BUCKET", value: gcpBucketName(projectId, env, "static") },
1334
- { name: "CAELO_STAGING_BUCKET", value: gcpBucketName(projectId, env, "staging") },
1335
- );
1456
+ admin.push({ name: "CAELO_STATIC_BUCKET", value: gcpBucketName(projectId, env, "static") });
1336
1457
  } else {
1337
1458
  // v0.3.1 — the Firebase publisher deploys to this site and declares
1338
1459
  // the /api/** rewrite to the gateway service.
1339
1460
  admin.push(
1340
1461
  { name: "CAELO_FIREBASE_SITE", value: inputs.firebaseSiteId },
1462
+ // #1330 — the staging host's Hosting site: Settings → Updates reads
1463
+ // the DNS records of its custom domain.
1464
+ { name: FIREBASE_STAGING_SITE_ENV, value: inputs.firebaseStagingSiteId },
1341
1465
  { name: "CAELO_GATEWAY_SERVICE", value: inputs.gatewayService },
1342
1466
  { name: "CAELO_GATEWAY_REGION", value: inputs.region },
1343
- // #650 — seeds the admin's staging host setting (staging builds render
1344
- // it, the gateway's gate answers on it); the admin DB is the truth
1345
- // afterwards, changed under Settings → Domains.
1346
- { name: STAGING_HOST_ENV, value: inputs.stagingHost },
1347
1467
  );
1348
1468
  }
1469
+ // #650 — seeds the admin's staging host setting (staging pages carry it,
1470
+ // the staging gateway's gate answers on it); the admin DB is the truth
1471
+ // afterwards, changed under Settings → Domains. Every provider since
1472
+ // #1330: each serves staging at its own host.
1473
+ admin.push({ name: STAGING_HOST_ENV, value: inputs.stagingHost });
1349
1474
  admin.push(...secretEnv("admin", env));
1350
1475
  return admin;
1351
1476
  }
@@ -1378,6 +1503,8 @@ export type IamPrincipal =
1378
1503
  | "gateway-sa"
1379
1504
  /** #1328 — the MCP-Auth-Proxy's SA: two secrets, nothing else. */
1380
1505
  | "mcp-auth-proxy-sa"
1506
+ /** #1330 — the staging gateway's SA: its login password and the staging bucket. */
1507
+ | "staging-gateway-sa"
1381
1508
  /** The `gcp` stack's static-publisher SA. */
1382
1509
  | "static-publisher-sa"
1383
1510
  /** IAP's Google-managed service agent (forwards IAP traffic to Cloud Run). */
@@ -1442,6 +1569,7 @@ export function runtimeSecretBindings(): RuntimeSecretBinding[] {
1442
1569
  export const SERVICE_PRINCIPAL: Readonly<Record<CloudRunSlug, IamPrincipal>> = {
1443
1570
  admin: "run-sa",
1444
1571
  gateway: "gateway-sa",
1572
+ "staging-gateway": "staging-gateway-sa",
1445
1573
  "mcp-auth-proxy": "mcp-auth-proxy-sa",
1446
1574
  };
1447
1575
 
@@ -1508,6 +1636,22 @@ const TELEMETRY: readonly IamInvariant[] = [
1508
1636
  onFailure: "warn",
1509
1637
  why: "gateway CPU/memory metrics reach Cloud Monitoring (autoscaling signals)",
1510
1638
  },
1639
+ {
1640
+ stackResource: "staging-gateway-log-writer",
1641
+ role: "roles/logging.logWriter",
1642
+ member: "staging-gateway-sa",
1643
+ target: { kind: "project" },
1644
+ onFailure: "warn",
1645
+ why: "staging gateway container logs reach Cloud Logging",
1646
+ },
1647
+ {
1648
+ stackResource: "staging-gateway-metric-writer",
1649
+ role: "roles/monitoring.metricWriter",
1650
+ member: "staging-gateway-sa",
1651
+ target: { kind: "project" },
1652
+ onFailure: "warn",
1653
+ why: "staging gateway CPU/memory metrics reach Cloud Monitoring (autoscaling signals)",
1654
+ },
1511
1655
  ];
1512
1656
 
1513
1657
  const GCP_ONLY: readonly IamInvariant[] = [
@@ -1657,6 +1801,13 @@ export const RETIRED_IAM_BINDINGS: readonly RetiredIamBinding[] = [
1657
1801
  target: { kind: "secret", name: "operator-oauth-client" },
1658
1802
  why: "the remote-MCP broker moved to the MCP-Auth-Proxy (#1328); the visitor gateway must not read the Google sign-in client's secret",
1659
1803
  },
1804
+ {
1805
+ // #1330/#1331 — the gate key moved to the staging gateway's projection.
1806
+ role: SECRET_ACCESSOR,
1807
+ member: "gateway-sa",
1808
+ target: { kind: "secret", name: "staging-gate-secret" },
1809
+ why: "the staging gate moved to the staging gateway, which gets its key through the staging projection (#1330); the live gateway must not hold it",
1810
+ },
1660
1811
  {
1661
1812
  // gcp-firebase: the Firebase rewrite targets the data gateway since
1662
1813
  // #1328, so the publisher no longer needs to read the proxy.
@@ -1713,6 +1864,8 @@ export function iamMember(
1713
1864
  return `serviceAccount:${gatewayServiceAccountEmail(install.projectId, install.env)}`;
1714
1865
  case "mcp-auth-proxy-sa":
1715
1866
  return `serviceAccount:${mcpAuthProxyServiceAccountEmail(install.projectId, install.env)}`;
1867
+ case "staging-gateway-sa":
1868
+ return `serviceAccount:${stagingGatewayServiceAccountEmail(install.projectId, install.env)}`;
1716
1869
  case "static-publisher-sa":
1717
1870
  return `serviceAccount:${staticPublisherServiceAccountId(install.env)}@${install.projectId}.iam.gserviceaccount.com`;
1718
1871
  case "iap-service-agent":
@@ -203,12 +203,14 @@ const INSTALL = {
203
203
  };
204
204
  /** The fixtures' services; the proxy is added by {@link upgrade} when absent. */
205
205
  type InstallServices = Record<"admin" | "gateway", FakeService> & {
206
+ "staging-gateway"?: FakeService;
206
207
  "mcp-auth-proxy"?: FakeService;
207
208
  };
208
209
 
209
210
  const SERVICE_ACCOUNTS: Record<CloudRunSlug, string> = {
210
211
  admin: "caelo-production-run-sa@acme.iam.gserviceaccount.com",
211
212
  gateway: "caelo-production-gateway-sa@acme.iam.gserviceaccount.com",
213
+ "staging-gateway": "caelo-production-stg-gw-sa@acme.iam.gserviceaccount.com",
212
214
  "mcp-auth-proxy": "caelo-production-mcp-proxy-sa@acme.iam.gserviceaccount.com",
213
215
  };
214
216
 
@@ -217,9 +219,12 @@ function upgrade(services: InstallServices) {
217
219
  // #1328 — the gcp MCP-Auth-Proxy: created by the converge before the plan,
218
220
  // with no env yet in these fixtures (they are about database logins).
219
221
  services["mcp-auth-proxy"] ??= new FakeService(new Map(), SERVICE_ACCOUNTS["mcp-auth-proxy"]);
222
+ // #1330 — likewise the staging gateway.
223
+ services["staging-gateway"] ??= new FakeService(new Map(), SERVICE_ACCOUNTS["staging-gateway"]);
220
224
  const plan = planContractEnv(INSTALL, {
221
225
  admin: { serviceName: "adm", liveEnv: services.admin.env },
222
226
  gateway: { serviceName: "gw", liveEnv: services.gateway.env },
227
+ "staging-gateway": { serviceName: "stg", liveEnv: services["staging-gateway"].env },
223
228
  "mcp-auth-proxy": { serviceName: "proxy", liveEnv: services["mcp-auth-proxy"].env },
224
229
  });
225
230
  if (!plan.ok) throw new Error(plan.error);
@@ -363,8 +368,8 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
363
368
  // #1329 — the gateway says it is the live site's.
364
369
  "CAELO_GATEWAY_ENVIRONMENT",
365
370
  // No remote-MCP env: the broker is the MCP-Auth-Proxy's (#1328).
366
- // #650 — the staging gate key arrives in the same roll.
367
- "CAELO_STAGING_GATE_SECRET",
371
+ // No staging gate key: it reaches the staging gateway through the
372
+ // staging projection (#1330), never the live gateway.
368
373
  // #845 — and which forwarded address is the visitor.
369
374
  "CAELO_TRUSTED_PROXY_HOPS",
370
375
  "PUBLIC_DATABASE_PASSWORD",
@@ -59,9 +59,11 @@ const policy = (bindings: { role: string; members: string[]; condition?: unknown
59
59
  const RUN_SA = "serviceAccount:caelo-production-run-sa@acme.iam.gserviceaccount.com";
60
60
  const GW_SA = "serviceAccount:caelo-production-gateway-sa@acme.iam.gserviceaccount.com";
61
61
  const PROXY_SA = "serviceAccount:caelo-production-mcp-proxy-sa@acme.iam.gserviceaccount.com";
62
+ /** #1330 — the staging gateway's run SA. */
63
+ const STG_SA = "serviceAccount:caelo-production-stg-gw-sa@acme.iam.gserviceaccount.com";
62
64
  const GW_TELEMETRY = [
63
- { role: "roles/logging.logWriter", members: [GW_SA] },
64
- { role: "roles/monitoring.metricWriter", members: [GW_SA] },
65
+ { role: "roles/logging.logWriter", members: [GW_SA, STG_SA] },
66
+ { role: "roles/monitoring.metricWriter", members: [GW_SA, STG_SA] },
65
67
  ];
66
68
  const IAP_AGENT = "serviceAccount:service-42@gcp-sa-iap.iam.gserviceaccount.com";
67
69
 
@@ -73,6 +75,7 @@ const firebase: InstallTarget = {
73
75
  services: {
74
76
  admin: "caelo-production-admin-aaa",
75
77
  gateway: "caelo-production-data-gateway",
78
+ "staging-gateway": "caelo-production-staging-gateway",
76
79
  "mcp-auth-proxy": "caelo-production-gateway-bbb",
77
80
  },
78
81
  };
@@ -328,6 +331,7 @@ describe("planContractEnv", () => {
328
331
  // #1328 — the data gateway, a copy of the old gateway service.
329
332
  gateway: { serviceName: "caelo-production-data-gateway", liveEnv: gatewayLive },
330
333
  // The old gateway service, which becomes the MCP-Auth-Proxy (E13 a).
334
+ "staging-gateway": { serviceName: "caelo-production-staging-gateway", liveEnv: new Map() },
331
335
  "mcp-auth-proxy": { serviceName: "caelo-production-gateway-bbb", liveEnv: gatewayLive },
332
336
  },
333
337
  );
@@ -336,7 +340,7 @@ describe("planContractEnv", () => {
336
340
  // and gate key onto an install that predates it.
337
341
  expect(plan.services.admin.flags).toEqual([
338
342
  // #1329 — and the staging database's pool onto one that predates it.
339
- "--update-env-vars=PUBLIC_STAGING_ADMIN_DATABASE_URL=postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public_staging?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem,CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_CONVERGE_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-converge,CAELO_AUDIT_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-audit,CAELO_AUDIT_RESULTS_BUCKET=acme-caelo-production-audit-results,CAELO_CAPTURE_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-capture,CAELO_CAPTURE_BUCKET=acme-caelo-production-capture,CAELO_MEDIA_BUCKET=acme-caelo-production-media,CAELO_SCHEDULER_SERVICE_ACCOUNT=caelo-production-scheduler@acme.iam.gserviceaccount.com,CAELO_OPERATOR_OAUTH_CLIENT_STORE=projects/acme/secrets/caelo-production-operator-oauth-client,CAELO_GATEWAY_SERVICE=caelo-production-data-gateway,CAELO_STAGING_HOST=staging.acme.com",
343
+ "--update-env-vars=PUBLIC_STAGING_ADMIN_DATABASE_URL=postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public_staging?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem,CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_CONVERGE_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-converge,CAELO_AUDIT_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-audit,CAELO_AUDIT_RESULTS_BUCKET=acme-caelo-production-audit-results,CAELO_CAPTURE_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-capture,CAELO_CAPTURE_BUCKET=acme-caelo-production-capture,CAELO_MEDIA_BUCKET=acme-caelo-production-media,CAELO_SCHEDULER_SERVICE_ACCOUNT=caelo-production-scheduler@acme.iam.gserviceaccount.com,CAELO_OPERATOR_OAUTH_CLIENT_STORE=projects/acme/secrets/caelo-production-operator-oauth-client,CAELO_STAGING_BUCKET=acme-caelo-production-staging,CAELO_FIREBASE_STAGING_SITE=caelo-production-stg-6eace1,CAELO_GATEWAY_SERVICE=caelo-production-data-gateway,CAELO_STAGING_HOST=staging.acme.com",
340
344
  "--update-secrets=PUBLIC_STAGING_ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest,CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
341
345
  ]);
342
346
  // #1328 — the Firebase rewrite follows the data gateway (above), which
@@ -345,7 +349,6 @@ describe("planContractEnv", () => {
345
349
  // The gateway learns which forwarded address is the visitor (#845),
346
350
  // and (#1329) that it is the live site's.
347
351
  "--update-env-vars=CAELO_GATEWAY_ENVIRONMENT=live,CAELO_TRUSTED_PROXY_HOPS=1",
348
- "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
349
352
  ]);
350
353
  // The old gateway service becomes the proxy in one revision: every
351
354
  // database and site var goes, its own contract arrives.
@@ -379,6 +382,7 @@ describe("planContractEnv", () => {
379
382
  serviceName: "caelo-production-data-gateway",
380
383
  liveEnv: liveContainerEnv(serviceJson(CONVERGED_GATEWAY_DB)),
381
384
  },
385
+ "staging-gateway": { serviceName: "caelo-production-staging-gateway", liveEnv: new Map() },
382
386
  "mcp-auth-proxy": { serviceName: "caelo-production-gateway-bbb", liveEnv: new Map() },
383
387
  },
384
388
  { leaveUntouched: ["CAELO_MCP_IAP_SERVICE_ACCOUNT", "CAELO_OPERATOR_ACCESS_JOB"] },
@@ -400,6 +404,7 @@ describe("planContractEnv", () => {
400
404
  {
401
405
  admin: { serviceName: "adm", liveEnv: liveContainerEnv(serviceJson(CONVERGED_ADMIN_DB)) },
402
406
  gateway: { serviceName: "gw", liveEnv: new Map() },
407
+ "staging-gateway": { serviceName: "caelo-production-staging-gateway", liveEnv: new Map() },
403
408
  "mcp-auth-proxy": { serviceName: "px", liveEnv: new Map() },
404
409
  },
405
410
  );
@@ -429,6 +434,7 @@ describe("planContractEnv", () => {
429
434
  liveEnv: liveContainerEnv(serviceJson(onIp(CONVERGED_GATEWAY_DB) as never)),
430
435
  },
431
436
  // #1328 — no database URL, so the TLS step has nothing to move on it.
437
+ "staging-gateway": { serviceName: "caelo-production-staging-gateway", liveEnv: new Map() },
432
438
  "mcp-auth-proxy": { serviceName: "px", liveEnv: new Map() },
433
439
  };
434
440
  const install = {
@@ -465,6 +471,7 @@ describe("planContractEnv", () => {
465
471
  {
466
472
  admin: { serviceName: "adm", liveEnv: new Map() },
467
473
  gateway: { serviceName: "gw", liveEnv: new Map() },
474
+ "staging-gateway": { serviceName: "caelo-production-staging-gateway", liveEnv: new Map() },
468
475
  "mcp-auth-proxy": { serviceName: "px", liveEnv: new Map() },
469
476
  },
470
477
  );
@@ -565,7 +572,7 @@ describe("ensureStackInvariants", () => {
565
572
  ],
566
573
  "secrets get-iam-policy": [
567
574
  policy([
568
- { role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA, PROXY_SA] },
575
+ { role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA, STG_SA, PROXY_SA] },
569
576
  // #806 — the admin adds remote-MCP OAuth client versions.
570
577
  { role: "roles/secretmanager.secretVersionAdder", members: [RUN_SA] },
571
578
  ]),
@@ -825,7 +832,10 @@ describe("ensureStackInvariants", () => {
825
832
  ],
826
833
  "secrets get-iam-policy": [
827
834
  policy([
828
- { role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA, PROXY_SA] },
835
+ {
836
+ role: "roles/secretmanager.secretAccessor",
837
+ members: [RUN_SA, GW_SA, STG_SA, PROXY_SA],
838
+ },
829
839
  { role: "roles/secretmanager.secretVersionAdder", members: [RUN_SA] },
830
840
  ]),
831
841
  ],
@@ -952,11 +962,10 @@ describe("ensureStackInvariants", () => {
952
962
  (c) => c.startsWith("secrets add-iam-policy-binding") && c.includes(GW_SA),
953
963
  );
954
964
  // #613 — never admin_role's postgres-password; #635 — nor
955
- // gateway_role's. #650 — the staging gate key is the one secret the
956
- // gateway shares with the admin.
965
+ // gateway_role's; #1330 — nor the staging gate key: its own login
966
+ // password only.
957
967
  expect(gatewayGrants).toEqual([
958
968
  `secrets add-iam-policy-binding caelo-production-public-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
959
- `secrets add-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
960
969
  ]);
961
970
  // #1328 — the remote-MCP broker's seal key and OAuth client file are
962
971
  // the MCP-Auth-Proxy's, and those two secrets are all its SA reads.
@@ -1001,6 +1010,7 @@ describe("removeRetiredIamBindings (#613)", () => {
1001
1010
  "absent",
1002
1011
  "absent",
1003
1012
  "absent",
1013
+ "absent",
1004
1014
  ]);
1005
1015
  expect(calls).toContain(REMOVE);
1006
1016
  // The admin's own binding on the same secret is never touched.
@@ -1020,6 +1030,7 @@ describe("removeRetiredIamBindings (#613)", () => {
1020
1030
  "absent",
1021
1031
  "absent",
1022
1032
  "absent",
1033
+ "absent",
1023
1034
  ]);
1024
1035
  expect(calls.filter((c) => c.includes("remove-iam-policy-binding"))).toEqual([]);
1025
1036
  });
@@ -1037,6 +1048,7 @@ describe("removeRetiredIamBindings (#613)", () => {
1037
1048
  "absent",
1038
1049
  "absent",
1039
1050
  "absent",
1051
+ "absent",
1040
1052
  ]);
1041
1053
  expect(calls).toContain(
1042
1054
  `secrets remove-iam-policy-binding caelo-production-gateway-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
@@ -1064,6 +1076,7 @@ describe("removeRetiredIamBindings (#613)", () => {
1064
1076
  "absent",
1065
1077
  "removed",
1066
1078
  "removed",
1079
+ "absent",
1067
1080
  "removed",
1068
1081
  ]);
1069
1082
  for (const secret of ["mcp-oauth-seal-key", "operator-oauth-client"]) {
@@ -1076,6 +1089,21 @@ describe("removeRetiredIamBindings (#613)", () => {
1076
1089
  expect(calls.some((c) => c.includes("remove") && c.includes("allUsers"))).toBe(false);
1077
1090
  });
1078
1091
 
1092
+ it("#1330: takes the live gateway SA off the staging gate key", async () => {
1093
+ const { run, calls } = fakeGcloud({
1094
+ "secrets get-iam-policy caelo-production-staging-gate-secret": [
1095
+ policy([{ role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] }]),
1096
+ ],
1097
+ });
1098
+ const outcomes = await removeRetiredIamBindings(firebase, { run, sleep: async () => {} });
1099
+ expect(outcomes.map((o) => o.status)[4]).toBe("removed");
1100
+ expect(calls).toContain(
1101
+ `secrets remove-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
1102
+ );
1103
+ // The admin keeps it: it signs the open links and publishes the key.
1104
+ expect(calls.some((c) => c.includes("remove") && c.includes(RUN_SA))).toBe(false);
1105
+ });
1106
+
1079
1107
  it("reports a failed removal instead of throwing", async () => {
1080
1108
  const { run } = fakeGcloud({
1081
1109
  "secrets get-iam-policy caelo-production-postgres-password": [