@caelo-cms/provisioning 0.11.9 → 0.11.10

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 (261) hide show
  1. package/dist/admin-domain.d.ts +10 -1
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +13 -3
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/adopt-existing.d.ts +6 -1
  6. package/dist/adopt-existing.d.ts.map +1 -1
  7. package/dist/adopt-existing.js +86 -0
  8. package/dist/adopt-existing.js.map +1 -1
  9. package/dist/agent/command.d.ts +20 -0
  10. package/dist/agent/command.d.ts.map +1 -1
  11. package/dist/agent/command.js +32 -9
  12. package/dist/agent/command.js.map +1 -1
  13. package/dist/agent/run.d.ts +2 -0
  14. package/dist/agent/run.d.ts.map +1 -1
  15. package/dist/agent/run.js +5 -1
  16. package/dist/agent/run.js.map +1 -1
  17. package/dist/agent-db.js +44 -2
  18. package/dist/aws-task-roles.d.ts +4 -2
  19. package/dist/aws-task-roles.d.ts.map +1 -1
  20. package/dist/aws-task-roles.js +4 -1
  21. package/dist/aws-task-roles.js.map +1 -1
  22. package/dist/caddy.d.ts +21 -4
  23. package/dist/caddy.d.ts.map +1 -1
  24. package/dist/caddy.js +27 -3
  25. package/dist/caddy.js.map +1 -1
  26. package/dist/cli.js +12 -3
  27. package/dist/cli.js.map +1 -1
  28. package/dist/cloudsql-tls.d.ts +63 -0
  29. package/dist/cloudsql-tls.d.ts.map +1 -0
  30. package/dist/cloudsql-tls.js +289 -0
  31. package/dist/cloudsql-tls.js.map +1 -0
  32. package/dist/compose.d.ts +29 -6
  33. package/dist/compose.d.ts.map +1 -1
  34. package/dist/compose.js +95 -23
  35. package/dist/compose.js.map +1 -1
  36. package/dist/converge-checkpoint.d.ts +1 -0
  37. package/dist/converge-checkpoint.d.ts.map +1 -1
  38. package/dist/converge-checkpoint.js +8 -1
  39. package/dist/converge-checkpoint.js.map +1 -1
  40. package/dist/converge-job-gcp.d.ts.map +1 -1
  41. package/dist/converge-job-gcp.js +2 -11
  42. package/dist/converge-job-gcp.js.map +1 -1
  43. package/dist/deployed-release.d.ts +4 -1
  44. package/dist/deployed-release.d.ts.map +1 -1
  45. package/dist/deployed-release.js +18 -8
  46. package/dist/deployed-release.js.map +1 -1
  47. package/dist/gcp-names.d.ts +21 -0
  48. package/dist/gcp-names.d.ts.map +1 -1
  49. package/dist/gcp-names.js +24 -0
  50. package/dist/gcp-names.js.map +1 -1
  51. package/dist/gcp-url-map.d.ts.map +1 -1
  52. package/dist/gcp-url-map.js +6 -2
  53. package/dist/gcp-url-map.js.map +1 -1
  54. package/dist/index.d.ts +1 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/infra-contract.d.ts +1 -1
  59. package/dist/infra-contract.d.ts.map +1 -1
  60. package/dist/infra-contract.js +24 -1
  61. package/dist/infra-contract.js.map +1 -1
  62. package/dist/install-state.d.ts +6 -0
  63. package/dist/install-state.d.ts.map +1 -1
  64. package/dist/install-state.js +17 -4
  65. package/dist/install-state.js.map +1 -1
  66. package/dist/lifecycle.d.ts.map +1 -1
  67. package/dist/lifecycle.js +17 -8
  68. package/dist/lifecycle.js.map +1 -1
  69. package/dist/mcp-auth-proxy-setup.d.ts +48 -0
  70. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -0
  71. package/dist/mcp-auth-proxy-setup.js +280 -0
  72. package/dist/mcp-auth-proxy-setup.js.map +1 -0
  73. package/dist/release-manifest.d.ts +25 -1
  74. package/dist/release-manifest.d.ts.map +1 -1
  75. package/dist/release-manifest.js +36 -3
  76. package/dist/release-manifest.js.map +1 -1
  77. package/dist/remote-mcp.d.ts +55 -18
  78. package/dist/remote-mcp.d.ts.map +1 -1
  79. package/dist/remote-mcp.js +307 -77
  80. package/dist/remote-mcp.js.map +1 -1
  81. package/dist/runtime-secrets.d.ts +9 -0
  82. package/dist/runtime-secrets.d.ts.map +1 -1
  83. package/dist/runtime-secrets.js +21 -10
  84. package/dist/runtime-secrets.js.map +1 -1
  85. package/dist/seal-key-rotation.d.ts +7 -7
  86. package/dist/seal-key-rotation.d.ts.map +1 -1
  87. package/dist/seal-key-rotation.js +22 -22
  88. package/dist/seal-key-rotation.js.map +1 -1
  89. package/dist/self-hosted-db-tls.d.ts +37 -0
  90. package/dist/self-hosted-db-tls.d.ts.map +1 -0
  91. package/dist/self-hosted-db-tls.js +220 -0
  92. package/dist/self-hosted-db-tls.js.map +1 -0
  93. package/dist/self-hosted-release.d.ts +3 -1
  94. package/dist/self-hosted-release.d.ts.map +1 -1
  95. package/dist/self-hosted-release.js +33 -9
  96. package/dist/self-hosted-release.js.map +1 -1
  97. package/dist/self-hosted-roles.d.ts +34 -0
  98. package/dist/self-hosted-roles.d.ts.map +1 -1
  99. package/dist/self-hosted-roles.js +52 -0
  100. package/dist/self-hosted-roles.js.map +1 -1
  101. package/dist/self-hosted-start.d.ts +0 -21
  102. package/dist/self-hosted-start.d.ts.map +1 -1
  103. package/dist/self-hosted-start.js +39 -1
  104. package/dist/self-hosted-start.js.map +1 -1
  105. package/dist/self-hosted-upgrade.d.ts +15 -6
  106. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  107. package/dist/self-hosted-upgrade.js +72 -23
  108. package/dist/self-hosted-upgrade.js.map +1 -1
  109. package/dist/stack-contract.d.ts +170 -25
  110. package/dist/stack-contract.d.ts.map +1 -1
  111. package/dist/stack-contract.js +314 -37
  112. package/dist/stack-contract.js.map +1 -1
  113. package/dist/stack-converge.d.ts +31 -0
  114. package/dist/stack-converge.d.ts.map +1 -1
  115. package/dist/stack-converge.js +71 -30
  116. package/dist/stack-converge.js.map +1 -1
  117. package/dist/upgrade/app-roll.d.ts +25 -4
  118. package/dist/upgrade/app-roll.d.ts.map +1 -1
  119. package/dist/upgrade/app-roll.js +63 -17
  120. package/dist/upgrade/app-roll.js.map +1 -1
  121. package/dist/upgrade/converge.d.ts.map +1 -1
  122. package/dist/upgrade/converge.js +99 -12
  123. package/dist/upgrade/converge.js.map +1 -1
  124. package/dist/upgrade/health.d.ts +31 -7
  125. package/dist/upgrade/health.d.ts.map +1 -1
  126. package/dist/upgrade/health.js +41 -17
  127. package/dist/upgrade/health.js.map +1 -1
  128. package/dist/upgrade/image-forms.d.ts +1 -1
  129. package/dist/upgrade/image-forms.d.ts.map +1 -1
  130. package/dist/upgrade/image-forms.js +24 -7
  131. package/dist/upgrade/image-forms.js.map +1 -1
  132. package/dist/upgrade/release-images.d.ts +12 -3
  133. package/dist/upgrade/release-images.d.ts.map +1 -1
  134. package/dist/upgrade/release-images.js +13 -4
  135. package/dist/upgrade/release-images.js.map +1 -1
  136. package/dist/upgrade/upgrade.d.ts +13 -3
  137. package/dist/upgrade/upgrade.d.ts.map +1 -1
  138. package/dist/upgrade/upgrade.js +34 -2
  139. package/dist/upgrade/upgrade.js.map +1 -1
  140. package/dist/upgrader-gcp.d.ts +12 -0
  141. package/dist/upgrader-gcp.d.ts.map +1 -1
  142. package/dist/upgrader-gcp.js +49 -1
  143. package/dist/upgrader-gcp.js.map +1 -1
  144. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  145. package/dist/upgrader-iam-preflight.js +6 -1
  146. package/dist/upgrader-iam-preflight.js.map +1 -1
  147. package/dist/upgrader-job.d.ts +1 -1
  148. package/dist/upgrader-job.d.ts.map +1 -1
  149. package/dist/upgrader-job.js +20 -14
  150. package/dist/upgrader-job.js.map +1 -1
  151. package/dist/upgrader-records.d.ts +1 -1
  152. package/dist/upgrader-records.d.ts.map +1 -1
  153. package/dist/upgrader-records.js +26 -7
  154. package/dist/upgrader-records.js.map +1 -1
  155. package/dist/upgrader-setup.d.ts.map +1 -1
  156. package/dist/upgrader-setup.js +10 -5
  157. package/dist/upgrader-setup.js.map +1 -1
  158. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  159. package/dist/wizards/gcp-cost.js +22 -7
  160. package/dist/wizards/gcp-cost.js.map +1 -1
  161. package/dist/wizards/gcp.d.ts.map +1 -1
  162. package/dist/wizards/gcp.js +43 -17
  163. package/dist/wizards/gcp.js.map +1 -1
  164. package/package.json +2 -2
  165. package/src/admin-domain.test.ts +9 -8
  166. package/src/admin-domain.ts +13 -3
  167. package/src/admin-memory.test.ts +1 -0
  168. package/src/adopt-existing.test.ts +39 -0
  169. package/src/adopt-existing.ts +83 -1
  170. package/src/agent/command.test.ts +49 -1
  171. package/src/agent/command.ts +36 -9
  172. package/src/agent/run.test.ts +19 -0
  173. package/src/agent/run.ts +11 -2
  174. package/src/aws-task-roles.ts +5 -2
  175. package/src/caddy-redirects.docker.test.ts +2 -0
  176. package/src/caddy-redirects.test.ts +3 -0
  177. package/src/caddy-validate.test.ts +7 -1
  178. package/src/caddy.test.ts +39 -1
  179. package/src/caddy.ts +36 -4
  180. package/src/checkpoint-secrets.test.ts +14 -6
  181. package/src/cli.ts +12 -1
  182. package/src/cloudsql-tls.test.ts +225 -0
  183. package/src/cloudsql-tls.ts +352 -0
  184. package/src/compose.ts +125 -24
  185. package/src/converge-checkpoint.ts +8 -1
  186. package/src/converge-job-gcp.ts +2 -11
  187. package/src/database-tls-builders.test.ts +55 -0
  188. package/src/deployed-release.test.ts +104 -40
  189. package/src/deployed-release.ts +30 -9
  190. package/src/gateway-env-isolation.test.ts +10 -5
  191. package/src/gcp-names.ts +31 -0
  192. package/src/gcp-url-map.test.ts +6 -8
  193. package/src/gcp-url-map.ts +6 -2
  194. package/src/index.test.ts +7 -1
  195. package/src/index.ts +1 -0
  196. package/src/infra-contract.test.ts +5 -3
  197. package/src/infra-contract.ts +24 -1
  198. package/src/install-state.ts +26 -4
  199. package/src/lifecycle.ts +20 -11
  200. package/src/mcp-auth-proxy-setup.test.ts +305 -0
  201. package/src/mcp-auth-proxy-setup.ts +374 -0
  202. package/src/mcp-iap-stacks.test.ts +1 -1
  203. package/src/media-volume.test.ts +1 -1
  204. package/src/operator-access-stacks.test.ts +1 -1
  205. package/src/operator-access.test.ts +5 -4
  206. package/src/pool-budget-stacks.test.ts +1 -1
  207. package/src/release-manifest.test.ts +25 -0
  208. package/src/release-manifest.ts +59 -4
  209. package/src/remote-mcp-stack-config.test.ts +19 -9
  210. package/src/remote-mcp.test.ts +252 -57
  211. package/src/remote-mcp.ts +336 -77
  212. package/src/runtime-secrets.test.ts +20 -7
  213. package/src/runtime-secrets.ts +50 -9
  214. package/src/seal-key-rotation.test.ts +2 -2
  215. package/src/seal-key-rotation.ts +25 -25
  216. package/src/self-hosted-compose.test.ts +108 -5
  217. package/src/self-hosted-db-tls.test.ts +111 -0
  218. package/src/self-hosted-db-tls.ts +287 -0
  219. package/src/self-hosted-release-resolve.test.ts +71 -0
  220. package/src/self-hosted-release.ts +41 -11
  221. package/src/self-hosted-roles.test.ts +54 -0
  222. package/src/self-hosted-roles.ts +58 -0
  223. package/src/self-hosted-start.test.ts +34 -2
  224. package/src/self-hosted-start.ts +20 -2
  225. package/src/self-hosted-upgrade.test.ts +103 -14
  226. package/src/self-hosted-upgrade.ts +115 -29
  227. package/src/stack-contract-parity.test.ts +79 -12
  228. package/src/stack-contract.test.ts +169 -29
  229. package/src/stack-contract.ts +362 -42
  230. package/src/stack-converge-secrets.test.ts +26 -13
  231. package/src/stack-converge.test.ts +199 -26
  232. package/src/stack-converge.ts +92 -30
  233. package/src/state-store.test.ts +2 -1
  234. package/src/upgrade/app-roll.ts +97 -19
  235. package/src/upgrade/converge.ts +120 -11
  236. package/src/upgrade/health.test.ts +63 -25
  237. package/src/upgrade/health.ts +54 -22
  238. package/src/upgrade/image-forms.test.ts +34 -0
  239. package/src/upgrade/image-forms.ts +23 -8
  240. package/src/upgrade/release-images.test.ts +5 -1
  241. package/src/upgrade/release-images.ts +22 -6
  242. package/src/upgrade/upgrade.test.ts +353 -44
  243. package/src/upgrade/upgrade.ts +55 -6
  244. package/src/upgrader-gcp.ts +65 -3
  245. package/src/upgrader-iam-preflight.test.ts +19 -0
  246. package/src/upgrader-iam-preflight.ts +6 -0
  247. package/src/upgrader-job.test.ts +101 -27
  248. package/src/upgrader-job.ts +27 -14
  249. package/src/upgrader-records.test.ts +97 -12
  250. package/src/upgrader-records.ts +28 -11
  251. package/src/upgrader-setup.test.ts +2 -0
  252. package/src/upgrader-setup.ts +10 -4
  253. package/src/wizards/gcp-cost.test.ts +50 -0
  254. package/src/wizards/gcp-cost.ts +24 -7
  255. package/src/wizards/gcp.ts +55 -14
  256. package/stacks/aws/index.ts +32 -14
  257. package/stacks/azure/index.ts +57 -18
  258. package/stacks/gcp/index.ts +83 -23
  259. package/stacks/gcp-firebase/index.ts +162 -33
  260. package/stacks/self-hosted/README.md +2 -1
  261. package/stacks/self-hosted/index.ts +11 -1
@@ -34,6 +34,7 @@
34
34
  * Pure module — the stacks import it from `dist/`.
35
35
  */
36
36
 
37
+ import { CLOUD_SQL_CA_BUNDLE, isIpLiteral, verifyFullQuery } from "@caelo-cms/shared";
37
38
  import { z } from "zod";
38
39
  import type { SupportedProvider } from "./adapter.js";
39
40
  import {
@@ -44,6 +45,7 @@ import {
44
45
  gcpBucketName,
45
46
  gcpNamePrefix,
46
47
  gcpSecretId,
48
+ mcpAuthProxyServiceAccountEmail,
47
49
  mcpIapServiceAccountEmail,
48
50
  operatorAccessJobResource,
49
51
  runServiceAccountEmail,
@@ -52,7 +54,69 @@ import {
52
54
  } from "./gcp-names.js";
53
55
 
54
56
  export type GcpProvider = "gcp" | "gcp-firebase";
55
- export type CloudRunSlug = "admin" | "gateway";
57
+ /**
58
+ * The install's Cloud Run services, by role: the admin, the visitor data
59
+ * gateway and (#1328) the MCP-Auth-Proxy — remote MCP's sign-in broker and
60
+ * its forwarder to the IAP-protected admin, a service of its own so a hole
61
+ * in a visitor handler can never reach the operators' Google tokens.
62
+ * Which deployed service plays which role is {@link cloudRunServicePrefix}.
63
+ */
64
+ export type CloudRunSlug = "admin" | "gateway" | "mcp-auth-proxy";
65
+
66
+ /** Every {@link CloudRunSlug}, in roll order: the admin first, the proxy last. */
67
+ export const CLOUD_RUN_SLUGS: readonly CloudRunSlug[] = ["admin", "gateway", "mcp-auth-proxy"];
68
+
69
+ /**
70
+ * The name prefix of the Cloud Run service that plays `slug` on an install
71
+ * (Pulumi suffixes the names it generates; the services named explicitly,
72
+ * {@link fixedCloudRunServiceName}, match their prefix exactly).
73
+ *
74
+ * One asymmetry, decided as E13 (a) of the gateway hardening plan (#763):
75
+ * on `gcp-firebase` the MCP-Auth-Proxy is the service that was the gateway
76
+ * before #1328. Its `run.app` URL is the remote-MCP address every connector
77
+ * in claude.ai / ChatGPT holds, and the Google sign-in client's registered
78
+ * redirect URI points at it; neither can move without the Owner
79
+ * re-connecting every client and editing the OAuth client by hand. So that
80
+ * service keeps its name and becomes the proxy, and the visitor gateway is
81
+ * the new `caelo-<env>-data-gateway` (the Firebase `/api/**` rewrite the
82
+ * publisher writes follows the admin's `CAELO_GATEWAY_SERVICE`). On `gcp`
83
+ * the MCP address is the site's own host behind the load balancer, so no
84
+ * service name is pinned there and the proxy is the new service.
85
+ *
86
+ * No prefix is a prefix of another (`…-gateway` does not prefix
87
+ * `…-data-gateway`), so a `metadata.name~^<prefix>` lookup finds one role.
88
+ */
89
+ export function cloudRunServicePrefix(
90
+ provider: GcpProvider,
91
+ env: string,
92
+ slug: CloudRunSlug,
93
+ ): string {
94
+ const prefix = gcpNamePrefix(env);
95
+ if (slug === "admin") return `${prefix}-admin`;
96
+ if (provider === "gcp-firebase") {
97
+ return slug === "gateway" ? `${prefix}-data-gateway` : `${prefix}-gateway`;
98
+ }
99
+ return slug === "gateway" ? `${prefix}-gateway` : `${prefix}-mcp-auth-proxy`;
100
+ }
101
+
102
+ /**
103
+ * The exact name of a service that is created under a fixed name (#1328):
104
+ * the `gcp-firebase` data gateway and the `gcp` MCP-Auth-Proxy. Fixed, not
105
+ * Pulumi-suffixed, because the converge creates them on existing installs
106
+ * with gcloud, and the stack's next `pulumi up` must then find the same
107
+ * service instead of creating a second one. Null for the services Pulumi
108
+ * has always auto-named.
109
+ */
110
+ export function fixedCloudRunServiceName(
111
+ provider: GcpProvider,
112
+ env: string,
113
+ slug: CloudRunSlug,
114
+ ): string | null {
115
+ const fixed =
116
+ (provider === "gcp-firebase" && slug === "gateway") ||
117
+ (provider === "gcp" && slug === "mcp-auth-proxy");
118
+ return fixed ? cloudRunServicePrefix(provider, env, slug) : null;
119
+ }
56
120
 
57
121
  /**
58
122
  * One container env var, in the shape Pulumi's `gcp.cloudrunv2.Service`
@@ -106,10 +170,23 @@ const DATABASE_PORT = 5432;
106
170
  * Password-less connection URLs for a Cloud SQL instance at `host`. The
107
171
  * password reaches the apps separately, as a Secret Manager reference
108
172
  * ({@link SERVICE_SECRET_ENV}).
173
+ *
174
+ * TLS is `verify-full` against Google's Cloud SQL shared-CA bundle, which
175
+ * every image carries (#1338, shared database-tls.ts). The shared CA's
176
+ * server certificate names the instance's DNS name and no IP, so `host`
177
+ * must be that name (cloudsql-tls.ts resolves it in the VPC); an IP is
178
+ * refused here rather than producing URLs no connection can verify.
179
+ *
180
+ * @throws Error when `host` is an IP literal
109
181
  */
110
182
  export function databaseUrls(host: string): DatabaseUrls {
183
+ if (isIpLiteral(host)) {
184
+ throw new Error(
185
+ `the Cloud SQL host is the IP address ${host}, but verify-full connections must use the instance's DNS name (its certificate names no IP, #1338). The converge moves the services onto it: run \`cms-provision upgrade\`.`,
186
+ );
187
+ }
111
188
  const url = (role: string, db: string) =>
112
- `postgresql://${role}@${host}:${DATABASE_PORT}/${db}?sslmode=require`;
189
+ `postgresql://${role}@${host}:${DATABASE_PORT}/${db}?${verifyFullQuery(CLOUD_SQL_CA_BUNDLE)}`;
113
190
  return {
114
191
  admin: url("admin_role", "cms_admin"),
115
192
  publicAdmin: url("admin_role", "cms_public"),
@@ -117,6 +194,58 @@ export function databaseUrls(host: string): DatabaseUrls {
117
194
  };
118
195
  }
119
196
 
197
+ /**
198
+ * #1338 — the Cloud SQL server CA mode every install runs: Google's shared
199
+ * CA. Its server certificate carries the instance's DNS name, so a client
200
+ * can verify the host (`sslmode=verify-full`). The default per-instance CA
201
+ * names no host a private-IP client can dial ("The SAN field contains the
202
+ * hostname (DNS name of the instance) only for instances enabled with
203
+ * Private Service Connect" — docs.cloud.google.com/sql/docs/postgres/authorize-ssl).
204
+ * An instance moves from per-instance to shared, never back.
205
+ */
206
+ export const CLOUD_SQL_SERVER_CA_MODE = "GOOGLE_MANAGED_CAS_CA";
207
+
208
+ /** #1338 — Cloud SQL refuses unencrypted connections: every client uses verify-full. */
209
+ export const CLOUD_SQL_SSL_MODE = "ENCRYPTED_ONLY";
210
+
211
+ /** The `dnsNames[].connectionType` of the name a private-services-access client dials. */
212
+ export const CLOUD_SQL_PSA_CONNECTION_TYPE = "PRIVATE_SERVICES_ACCESS";
213
+
214
+ /**
215
+ * The private Cloud DNS zone that resolves the instance's DNS name to its
216
+ * private IP inside the install's VPC. Cloud SQL assigns the name but does
217
+ * not resolve it: "create the DNS record in a private DNS zone in the
218
+ * corresponding Virtual Private Cloud (VPC) network"
219
+ * (docs.cloud.google.com/sql/docs/postgres/configure-ssl-instance). The zone
220
+ * is the name itself, holding its one A record.
221
+ */
222
+ export function cloudSqlDnsZoneName(env: string): string {
223
+ return `${gcpNamePrefix(env)}-pg-dns`;
224
+ }
225
+
226
+ /**
227
+ * The DNS name a private-IP client dials, from an instance's `dnsNames`,
228
+ * lower-case and without the trailing dot. Null when the instance has none
229
+ * (a per-instance-CA instance before its move to the shared CA).
230
+ */
231
+ export function cloudSqlPrivateDnsName(
232
+ dnsNames: readonly { readonly name?: string; readonly connectionType?: string }[] | undefined,
233
+ ): string | null {
234
+ const entry = (dnsNames ?? []).find((d) => d.connectionType === CLOUD_SQL_PSA_CONNECTION_TYPE);
235
+ const name = entry?.name?.replace(/\.$/, "").toLowerCase();
236
+ return name ? name : null;
237
+ }
238
+
239
+ /** The database URL vars each service carries (the env contract below sets them). */
240
+ export const DATABASE_URL_VARS = {
241
+ admin: ["ADMIN_DATABASE_URL", "PUBLIC_ADMIN_DATABASE_URL"],
242
+ gateway: ["PUBLIC_DATABASE_URL"],
243
+ // #1328 — no database login at all. On gcp-firebase the service still
244
+ // carries the gateway's URL until its roll removes it with the rest of
245
+ // the gateway env (RETIRED_SERVICE_ENV), so the TLS step leaves it alone.
246
+ "mcp-auth-proxy": [],
247
+ } as const;
248
+
120
249
  /** What a running service's env derives from: install metadata + the database. */
121
250
  export interface RuntimeEnvInputs<V = string> extends ServiceEnvInputs {
122
251
  /** From the Cloud SQL private IP — an Output in the stacks. */
@@ -398,6 +527,10 @@ export const SERVICE_SECRET_ENV: Readonly<
398
527
  PUBLIC_DATABASE_PASSWORD: "public-role-password",
399
528
  // #650 — verifies staging-host requests (api-gateway staging-proxy.ts).
400
529
  CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
530
+ },
531
+ // #1328 — with the Google sign-in client file below, the proxy's whole
532
+ // Secret Manager reach: no database password, no other key.
533
+ "mcp-auth-proxy": {
401
534
  // #806 — seals the remote-MCP broker's state, codes and refresh tokens.
402
535
  CAELO_MCP_OAUTH_SEAL_KEY: "mcp-oauth-seal-key",
403
536
  },
@@ -418,9 +551,9 @@ export const OPERATOR_OAUTH_CLIENT_FILE_ENV = "CAELO_OPERATOR_OAUTH_CLIENT_FILE"
418
551
  * time the file is read ("When reading a volume, Cloud Run always fetches
419
552
  * the secret value from the Secret Manager to use the value with the latest
420
553
  * version" — cloud.google.com/run/docs/configuring/services/secrets). So the
421
- * client the Owner saves under Settings → Google sign-in reaches the running gateway
422
- * (which re-reads it within 60 s) without a new revision, and the admin
423
- * needs no right to deploy the gateway.
554
+ * client the Owner saves under Settings → Google sign-in reaches the running
555
+ * MCP-Auth-Proxy (which re-reads it within 60 s) without a new revision, and
556
+ * the admin needs no right to deploy the proxy.
424
557
  */
425
558
  export interface SecretFileContract {
426
559
  readonly secret: RuntimeSecret;
@@ -433,7 +566,7 @@ export interface SecretFileContract {
433
566
  readonly version: "latest";
434
567
  }
435
568
 
436
- /** The gateway's Google sign-in client file, read by the remote-MCP broker (#806, #914). */
569
+ /** The Google sign-in client file the remote-MCP broker reads (#806, #914; in the MCP-Auth-Proxy since #1328). */
437
570
  export const OPERATOR_OAUTH_CLIENT_FILE: SecretFileContract = {
438
571
  secret: "operator-oauth-client",
439
572
  volumeName: "operator-oauth-client",
@@ -447,10 +580,11 @@ export function secretFilePath(file: SecretFileContract): string {
447
580
  return `${file.mountDir}/${file.fileName}`;
448
581
  }
449
582
 
450
- /** Each service's secret files. The admin has none. */
583
+ /** Each service's secret files. Only the MCP-Auth-Proxy has one. */
451
584
  export const SERVICE_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly SecretFileContract[]>> = {
452
585
  admin: [],
453
- gateway: [OPERATOR_OAUTH_CLIENT_FILE],
586
+ gateway: [],
587
+ "mcp-auth-proxy": [OPERATOR_OAUTH_CLIENT_FILE],
454
588
  };
455
589
 
456
590
  /** A secret file an older contract mounted; upgrade unmounts it in the roll. */
@@ -481,6 +615,22 @@ export const RETIRED_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly Retire
481
615
  fileName: "client.json",
482
616
  why: "the Google sign-in client is mounted from operator-oauth-client now (#914)",
483
617
  },
618
+ {
619
+ secret: "operator-oauth-client",
620
+ mountDir: "/secrets/operator-oauth-client",
621
+ fileName: "client.json",
622
+ why: "the remote-MCP broker moved to the MCP-Auth-Proxy (#1328); the visitor gateway must not hold the Google sign-in client",
623
+ },
624
+ ],
625
+ "mcp-auth-proxy": [
626
+ {
627
+ // gcp-firebase: the proxy is the service that was the gateway, which
628
+ // mounted this file until #914.
629
+ secret: "mcp-oauth-client",
630
+ mountDir: "/secrets/mcp-oauth-client",
631
+ fileName: "client.json",
632
+ why: "the Google sign-in client is mounted from operator-oauth-client now (#914)",
633
+ },
484
634
  ],
485
635
  };
486
636
 
@@ -540,6 +690,30 @@ export const RETIRED_SERVICE_ENV: Readonly<Record<CloudRunSlug, readonly string[
540
690
  // #635 — its gateway_role login on cms_admin is gone too.
541
691
  "GATEWAY_DATABASE_URL",
542
692
  "GATEWAY_DATABASE_PASSWORD",
693
+ // #1328 — remote MCP moved to the MCP-Auth-Proxy.
694
+ "CAELO_OPERATOR_OAUTH_CLIENT_FILE",
695
+ "CAELO_MCP_OAUTH_SEAL_KEY",
696
+ "CAELO_MCP_OAUTH_SEAL_KEY_PREVIOUS",
697
+ "CAELO_MCP_PUBLIC_URL",
698
+ "CAELO_ADMIN_URL",
699
+ ],
700
+ // #1328 — on gcp-firebase the proxy is the service that was the gateway:
701
+ // everything that service carried for visitor traffic goes, above all
702
+ // every database login and the staging gate key. The proxy's SA cannot
703
+ // read their secrets, so a revision that kept one would not even deploy.
704
+ "mcp-auth-proxy": [
705
+ "PUBLIC_DATABASE_URL",
706
+ "PUBLIC_DATABASE_PASSWORD",
707
+ "CAELO_STAGING_GATE_SECRET",
708
+ "CAELO_ENV",
709
+ "MEDIA_STORAGE_URL",
710
+ "CAELO_DB_PUBLIC_POOL_MAX",
711
+ "CAELO_MCP_OAUTH_CLIENT_FILE",
712
+ "CAELO_SECRET_KEK",
713
+ "ADMIN_DATABASE_URL",
714
+ "ADMIN_DATABASE_PASSWORD",
715
+ "GATEWAY_DATABASE_URL",
716
+ "GATEWAY_DATABASE_PASSWORD",
543
717
  ],
544
718
  };
545
719
 
@@ -586,10 +760,12 @@ export const GATEWAY_TRUSTED_PROXY_HOPS: Readonly<Record<SupportedProvider, numb
586
760
  // ingress is internal-and-LB only, so nothing reaches it around the LB.
587
761
  gcp: 2,
588
762
  // Cloud Run's front end appends its peer. Behind the Firebase Hosting
589
- // rewrite that peer is the Hosting CDN edge, and the entry Hosting adds
590
- // before it cannot be told apart from a forged one (the service is
591
- // invokable on *.run.app too) — so the edge address is the most that can
592
- // be trusted. Staging (Cloud Run domain mapping) gets the visitor.
763
+ // rewrite that peer is a Google front-end proxy that changes per request,
764
+ // and the visitor entry Hosting writes before it cannot be told apart
765
+ // from a forged one (the service is invokable on *.run.app too) — so the
766
+ // proxy address is the most that can be trusted
767
+ // (docs/dev/firebase-hosting-behaviour.md). Staging (Cloud Run domain
768
+ // mapping) gets the visitor.
593
769
  "gcp-firebase": 1,
594
770
  // Caddy without `trusted_proxies` replaces the header with its peer; the
595
771
  // gateway is reachable only on the Compose network.
@@ -611,25 +787,109 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
611
787
  ...commonEnv(inputs),
612
788
  { name: TRUSTED_PROXY_HOPS_ENV, value: String(GATEWAY_TRUSTED_PROXY_HOPS[inputs.provider]) },
613
789
  { name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
614
- // #806 — where the remote-MCP broker reads its Google OAuth client
615
- // (the secret volume of SERVICE_SECRET_FILES).
616
- { name: OPERATOR_OAUTH_CLIENT_FILE_ENV, value: secretFilePath(OPERATOR_OAUTH_CLIENT_FILE) },
617
790
  ...secretEnv("gateway", inputs.env),
618
791
  ];
619
792
  }
620
793
 
621
794
  /**
622
- * #806 — the gateway's request timeout, in seconds: Cloud Run's maximum.
623
- * The gateway proxies remote-MCP requests to the admin, and a `caelo_chat`
624
- * turn streams for as long as the admin's own chat stream may (the admin
625
- * runs at 3600 s for the same reason). Visitor endpoints are unaffected: a
626
- * timeout is a ceiling, not a reservation, and Cloud Run bills request time,
627
- * not the allowed maximum. On `gcp` the load balancer in front adds no
628
- * shorter limit — the backend-service timeout does not apply to serverless
629
- * NEGs, which get a fixed 60 minutes
795
+ * #1328 — whether remote MCP can exist on a provider, as the MCP-Auth-Proxy
796
+ * reads it. It needs the admin behind Google IAP, which only the Google
797
+ * Cloud installs have. `unavailable` makes the proxy answer one fixed 404
798
+ * to everything but its health check: the service exists on every provider
799
+ * at zero minimum instances (E14 a of #763), its attack surface one branch.
800
+ * The proxy refuses to start without the var — no default (CLAUDE.md §2).
801
+ */
802
+ export const REMOTE_MCP_MODE_ENV = "CAELO_REMOTE_MCP";
803
+
804
+ /** {@link REMOTE_MCP_MODE_ENV} per provider. */
805
+ export const REMOTE_MCP_MODE: Readonly<Record<SupportedProvider, "available" | "unavailable">> = {
806
+ gcp: "available",
807
+ "gcp-firebase": "available",
808
+ "self-hosted": "unavailable",
809
+ aws: "unavailable",
810
+ azure: "unavailable",
811
+ };
812
+
813
+ /**
814
+ * {@link TRUSTED_PROXY_HOPS_ENV} of the MCP-Auth-Proxy per provider: its
815
+ * rate limits key on the client address by the same rule as the gateway's
816
+ * (@caelo-cms/shared client-address.ts), under its own topology.
817
+ */
818
+ export const MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS: Readonly<Record<SupportedProvider, number>> = {
819
+ // Behind the external Application LB (`<client>,<LB IP>`); its ingress
820
+ // is internal-and-LB only.
821
+ gcp: 2,
822
+ // Its own run.app URL with no rewrite in front: Cloud Run's front end
823
+ // appends the client.
824
+ "gcp-firebase": 1,
825
+ // Caddy (its own vhost) replaces the header with its peer.
826
+ "self-hosted": 1,
827
+ // Not deployed yet (#749); the gateway's topology once it is.
828
+ aws: 2,
829
+ // Container Apps ingress appends its peer.
830
+ azure: 1,
831
+ };
832
+
833
+ /**
834
+ * #1328 — points the `gcp-firebase` MCP-Auth-Proxy at the visitor data
835
+ * gateway for the traffic that still reaches the service that used to be
836
+ * the gateway: the Firebase `/api/**` rewrite of the release that is live
837
+ * until the next Publish live, and the staging host's Cloud Run domain
838
+ * mapping (#650), which only the domain's verified owner could move. The
839
+ * proxy streams that traffic to the data gateway unchanged instead of
840
+ * answering it (apps/mcp-auth-proxy interim-forward.ts). gcp-firebase only.
841
+ */
842
+ // FIXME(issue #1331): goes with the staging cutover, once neither a mapping
843
+ // nor a published rewrite can point at the proxy any more.
844
+ export const INTERIM_FORWARD_ORIGIN_ENV = "CAELO_INTERIM_FORWARD_ORIGIN";
845
+
846
+ /**
847
+ * The env of the MCP-Auth-Proxy. No database URL, no pool size, no site
848
+ * settings: the broker reads env plus one secret file and nothing else
849
+ * (apps/mcp-auth-proxy, whose import lint keeps every database client out).
850
+ * The remote-MCP identity env — its public origin, the admin's URL — comes
851
+ * on top from {@link remoteMcpIdentityEnv}, the gcp-firebase forward origin
852
+ * from the stack or the converge ({@link INTERIM_FORWARD_ORIGIN_ENV}).
853
+ */
854
+ export function mcpAuthProxyEnvContract(inputs: {
855
+ readonly provider: SupportedProvider;
856
+ readonly env: string;
857
+ }): CloudRunEnvVar[] {
858
+ return [
859
+ { name: "CAELO_PROVIDER", value: inputs.provider },
860
+ { name: REMOTE_MCP_MODE_ENV, value: REMOTE_MCP_MODE[inputs.provider] },
861
+ {
862
+ name: TRUSTED_PROXY_HOPS_ENV,
863
+ value: String(MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS[inputs.provider]),
864
+ },
865
+ // #806 — where the broker reads the Google sign-in client (the secret
866
+ // volume of SERVICE_SECRET_FILES).
867
+ { name: OPERATOR_OAUTH_CLIENT_FILE_ENV, value: secretFilePath(OPERATOR_OAUTH_CLIENT_FILE) },
868
+ ...secretEnv("mcp-auth-proxy", inputs.env),
869
+ ];
870
+ }
871
+
872
+ /**
873
+ * #806 — the MCP-Auth-Proxy's request timeout, in seconds: Cloud Run's
874
+ * maximum (the gateway's until #1328 moved the broker out). The proxy
875
+ * streams remote-MCP requests to the admin, and a `caelo_chat` turn streams
876
+ * for as long as the admin's own chat stream may (the admin runs at 3600 s
877
+ * for the same reason). A timeout is a ceiling, not a reservation: Cloud
878
+ * Run bills request time, not the allowed maximum. On `gcp` the load
879
+ * balancer in front adds no shorter limit — the backend-service timeout
880
+ * does not apply to serverless NEGs, which get a fixed 60 minutes
630
881
  * (cloud.google.com/load-balancing/docs/negs/serverless-neg-concepts).
631
882
  */
632
- export const GATEWAY_TIMEOUT_SECONDS = 3600;
883
+ export const MCP_AUTH_PROXY_TIMEOUT_SECONDS = 3600;
884
+
885
+ /**
886
+ * #1328 — the MCP-Auth-Proxy's maximum instances. It holds no database
887
+ * pool, so the connection budget does not bound it; ten instances of
888
+ * streaming proxies carry far more agent sessions than an install has
889
+ * operators, and the ceiling caps what a flood of sign-in requests can cost.
890
+ * Always provisioned at minimum 0 (E14 a of #763).
891
+ */
892
+ export const DEFAULT_MCP_AUTH_PROXY_MAX_INSTANCES = 10;
633
893
 
634
894
  /**
635
895
  * Whether each Cloud Run service gets CPU only while it handles a request
@@ -639,15 +899,28 @@ export const GATEWAY_TIMEOUT_SECONDS = 3600;
639
899
  * `resources`, which is how both services ran with always-on CPU without
640
900
  * anyone choosing it — billed for every idle minute an instance stayed up.
641
901
  *
642
- * The gateway only does work inside requests. The admin still runs its
643
- * background work (Tier 5: audit worker, importer, plugin cron, GC) on
644
- * in-process timers that need CPU between requests.
902
+ * The gateway and the MCP-Auth-Proxy only do work inside requests. The
903
+ * admin still runs its background work (Tier 5: audit worker, importer,
904
+ * plugin cron, GC) on in-process timers that need CPU between requests.
645
905
  */
646
906
  export const SERVICE_CPU_IDLE: Readonly<Record<CloudRunSlug, boolean>> = {
647
907
  // FIXME(issue #1140): true once the admin's background work no longer
648
908
  // depends on CPU between requests (jobs, scheduler, plugin host).
649
909
  admin: false,
650
910
  gateway: true,
911
+ "mcp-auth-proxy": true,
912
+ };
913
+
914
+ /**
915
+ * Whether each service egresses into the install's VPC: the admin and the
916
+ * gateway reach Cloud SQL's private IP; the MCP-Auth-Proxy reaches nothing
917
+ * private (#1328) — it calls Google's token endpoint and the admin's public,
918
+ * IAP-gated URL, so it gets no route to the database's network at all.
919
+ */
920
+ export const SERVICE_VPC_EGRESS: Readonly<Record<CloudRunSlug, boolean>> = {
921
+ admin: true,
922
+ gateway: true,
923
+ "mcp-auth-proxy": false,
651
924
  };
652
925
 
653
926
  /** Admin env var naming the Secret Manager secret the Owner writes the Google sign-in client to. */
@@ -696,7 +969,7 @@ export function operatorOAuthClientSecretResource(projectId: string, env: string
696
969
  * usually can.
697
970
  */
698
971
  export interface RemoteMcpIdentity {
699
- /** Where the gateway proxies `/api/mcp/<surface>` to (IAP in front). */
972
+ /** Where the MCP-Auth-Proxy forwards `/api/mcp/<surface>` to (IAP in front). */
700
973
  readonly adminUrl: string;
701
974
  /** The broker's public origin; no trailing slash. */
702
975
  readonly mcpPublicUrl: string;
@@ -734,18 +1007,20 @@ export type RemoteMcpIdentityInputs =
734
1007
  readonly adminServiceName: string;
735
1008
  /** The services' `status.url` (`https://<name>-<hash>-<r>.a.run.app`). */
736
1009
  readonly adminServiceUrl: string;
737
- readonly gatewayServiceUrl: string;
1010
+ /** The MCP-Auth-Proxy's: the service that was the gateway before #1328 (E13 a). */
1011
+ readonly mcpAuthProxyServiceUrl: string;
738
1012
  };
739
1013
 
740
1014
  /**
741
1015
  * The install's remote-MCP identity.
742
1016
  *
743
1017
  * - `gcp`: the broker answers on the site's own host through the load
744
- * balancer (`/api/*` and the `/.well-known/oauth-*` path rules), the admin
745
- * on `admin.<domain>` behind the LB's IAP; the IAP audience is
746
- * `/projects/<number>/global/backendServices/<id>`.
747
- * - `gcp-firebase`: the broker answers on the gateway's own Cloud Run URL
748
- * (Firebase Hosting rewrites cut requests at 60 s and buffer streams), the
1018
+ * balancer (its MCP path rules route to the MCP-Auth-Proxy's backend,
1019
+ * #1328), the admin on `admin.<domain>` behind the LB's IAP; the IAP
1020
+ * audience is `/projects/<number>/global/backendServices/<id>`.
1021
+ * - `gcp-firebase`: the broker answers on the MCP-Auth-Proxy's own Cloud Run
1022
+ * URL — the URL the gateway had before #1328 (E13 a) — never through
1023
+ * Firebase Hosting (rewrites cut requests at 60 s and buffer streams), the
749
1024
  * admin on its Cloud Run URL behind native IAP — whatever custom domain
750
1025
  * is mapped to it, IAP and its audience are the service's; the audience
751
1026
  * is `/projects/<number>/locations/<region>/services/<name>`.
@@ -763,7 +1038,7 @@ export function remoteMcpIdentity(i: RemoteMcpIdentityInputs): RemoteMcpIdentity
763
1038
  }
764
1039
  return {
765
1040
  adminUrl: origin(i.adminServiceUrl),
766
- mcpPublicUrl: origin(i.gatewayServiceUrl),
1041
+ mcpPublicUrl: origin(i.mcpAuthProxyServiceUrl),
767
1042
  iapAudience: `/projects/${i.projectNumber}/locations/${i.region}/services/${i.adminServiceName}`,
768
1043
  };
769
1044
  }
@@ -785,7 +1060,10 @@ export function remoteMcpIdentityEnv(
785
1060
  ? [{ name: REMOTE_MCP_ENV.googleOrgDomain, value: id.googleOrgDomain }]
786
1061
  : []),
787
1062
  ],
788
- gateway: [
1063
+ // #1328 — the broker lives in the MCP-Auth-Proxy; the visitor gateway
1064
+ // gets none of it.
1065
+ gateway: [],
1066
+ "mcp-auth-proxy": [
789
1067
  { name: REMOTE_MCP_ENV.adminUrl, value: id.adminUrl },
790
1068
  { name: REMOTE_MCP_ENV.mcpPublicUrl, value: id.mcpPublicUrl },
791
1069
  ],
@@ -852,7 +1130,7 @@ export function remoteMcpIdentityStackEnv(
852
1130
  raw: string | undefined,
853
1131
  ): Record<CloudRunSlug, CloudRunEnvVar[]> {
854
1132
  const id = parseRemoteMcpIdentityConfig(raw);
855
- return id ? remoteMcpIdentityEnv(id) : { admin: [], gateway: [] };
1133
+ return id ? remoteMcpIdentityEnv(id) : { admin: [], gateway: [], "mcp-auth-proxy": [] };
856
1134
  }
857
1135
 
858
1136
  /** Whether two identities would give the services the same env. */
@@ -991,6 +1269,8 @@ export type IamPrincipal =
991
1269
  | "run-sa"
992
1270
  /** The runtime SA the gateway runs as. */
993
1271
  | "gateway-sa"
1272
+ /** #1328 — the MCP-Auth-Proxy's SA: two secrets, nothing else. */
1273
+ | "mcp-auth-proxy-sa"
994
1274
  /** The `gcp` stack's static-publisher SA. */
995
1275
  | "static-publisher-sa"
996
1276
  /** IAP's Google-managed service agent (forwards IAP traffic to Cloud Run). */
@@ -1041,21 +1321,28 @@ export interface RuntimeSecretBinding {
1041
1321
  * on exactly the secrets its env reads ({@link SERVICE_SECRET_ENV}).
1042
1322
  */
1043
1323
  export function runtimeSecretBindings(): RuntimeSecretBinding[] {
1044
- return (["admin", "gateway"] as const).flatMap((service) =>
1324
+ return CLOUD_RUN_SLUGS.flatMap((service) =>
1045
1325
  serviceSecrets(service).map((secret) => ({
1046
1326
  service,
1047
1327
  secret,
1048
1328
  // The admin's keep the names they had when one SA served both.
1049
- stackResource: service === "admin" ? `${secret}-binding` : `gateway-${secret}-binding`,
1329
+ stackResource: service === "admin" ? `${secret}-binding` : `${service}-${secret}-binding`,
1050
1330
  })),
1051
1331
  );
1052
1332
  }
1053
1333
 
1334
+ /** The runtime SA each Cloud Run service runs as. */
1335
+ export const SERVICE_PRINCIPAL: Readonly<Record<CloudRunSlug, IamPrincipal>> = {
1336
+ admin: "run-sa",
1337
+ gateway: "gateway-sa",
1338
+ "mcp-auth-proxy": "mcp-auth-proxy-sa",
1339
+ };
1340
+
1054
1341
  function secretAccessors(): IamInvariant[] {
1055
1342
  return runtimeSecretBindings().map((b) => ({
1056
1343
  stackResource: b.stackResource,
1057
1344
  role: SECRET_ACCESSOR,
1058
- member: b.service === "admin" ? "run-sa" : "gateway-sa",
1345
+ member: SERVICE_PRINCIPAL[b.service],
1059
1346
  target: { kind: "secret", name: b.secret },
1060
1347
  onFailure: "abort",
1061
1348
  why: `the ${b.service} reads ${b.secret} from Secret Manager at boot`,
@@ -1185,7 +1472,7 @@ const GCP_FIREBASE_ONLY: readonly IamInvariant[] = [
1185
1472
  why: "the admin publishes the site to Firebase Hosting",
1186
1473
  },
1187
1474
  {
1188
- stackResource: "gateway-public-invoker",
1475
+ stackResource: "data-gateway-public-invoker",
1189
1476
  role: "roles/run.invoker",
1190
1477
  member: "allUsers",
1191
1478
  target: { kind: "run-service", service: "gateway" },
@@ -1193,13 +1480,24 @@ const GCP_FIREBASE_ONLY: readonly IamInvariant[] = [
1193
1480
  why: "Firebase Hosting rewrites /api/** to the public gateway",
1194
1481
  },
1195
1482
  {
1196
- stackResource: "gateway-viewer-for-admin-sa",
1483
+ stackResource: "data-gateway-viewer-for-admin-sa",
1197
1484
  role: "roles/run.viewer",
1198
1485
  member: "run-sa",
1199
1486
  target: { kind: "run-service", service: "gateway" },
1200
1487
  onFailure: "abort",
1201
1488
  why: "Firebase Hosting checks run.services.get on the rewrite target at deploy time (v0.6.5; without it every publish 403s)",
1202
1489
  },
1490
+ {
1491
+ // #1328 — the service that was the gateway (E13 a); MCP clients call
1492
+ // its run.app URL without a Google identity of their own. The stack
1493
+ // keeps the binding's old logical name, like the service's.
1494
+ stackResource: "gateway-public-invoker",
1495
+ role: "roles/run.invoker",
1496
+ member: "allUsers",
1497
+ target: { kind: "run-service", service: "mcp-auth-proxy" },
1498
+ onFailure: "abort",
1499
+ why: "MCP clients sign in and call remote MCP at the MCP-Auth-Proxy's run.app URL",
1500
+ },
1203
1501
  {
1204
1502
  stackResource: "admin-iap-sa-invoker",
1205
1503
  role: "roles/run.invoker",
@@ -1240,6 +1538,26 @@ export const RETIRED_IAM_BINDINGS: readonly RetiredIamBinding[] = [
1240
1538
  target: { kind: "secret", name: "gateway-role-password" },
1241
1539
  why: "the gateway has no cms_admin login at all and must not be able to read gateway_role's password (CLAUDE.md §2, #635)",
1242
1540
  },
1541
+ {
1542
+ role: SECRET_ACCESSOR,
1543
+ member: "gateway-sa",
1544
+ target: { kind: "secret", name: "mcp-oauth-seal-key" },
1545
+ why: "the remote-MCP broker moved to the MCP-Auth-Proxy (#1328); a hole in a visitor handler must not open or mint the broker's sealed tokens",
1546
+ },
1547
+ {
1548
+ role: SECRET_ACCESSOR,
1549
+ member: "gateway-sa",
1550
+ target: { kind: "secret", name: "operator-oauth-client" },
1551
+ 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",
1552
+ },
1553
+ {
1554
+ // gcp-firebase: the Firebase rewrite targets the data gateway since
1555
+ // #1328, so the publisher no longer needs to read the proxy.
1556
+ role: "roles/run.viewer",
1557
+ member: "run-sa",
1558
+ target: { kind: "run-service", service: "mcp-auth-proxy" },
1559
+ why: "the Firebase /api/** rewrite targets the data gateway since #1328; the admin needs no read access to the MCP-Auth-Proxy",
1560
+ },
1243
1561
  ];
1244
1562
 
1245
1563
  /** Every IAM binding upgrade ensures on an install of `provider`. */
@@ -1286,6 +1604,8 @@ export function iamMember(
1286
1604
  return `serviceAccount:${runServiceAccountEmail(install.projectId, install.env)}`;
1287
1605
  case "gateway-sa":
1288
1606
  return `serviceAccount:${gatewayServiceAccountEmail(install.projectId, install.env)}`;
1607
+ case "mcp-auth-proxy-sa":
1608
+ return `serviceAccount:${mcpAuthProxyServiceAccountEmail(install.projectId, install.env)}`;
1289
1609
  case "static-publisher-sa":
1290
1610
  return `serviceAccount:${staticPublisherServiceAccountId(install.env)}@${install.projectId}.iam.gserviceaccount.com`;
1291
1611
  case "iap-service-agent":