@caelo-cms/provisioning 0.11.9 → 0.11.11

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 (352) hide show
  1. package/dist/adapter.d.ts +6 -0
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/admin-domain.d.ts +10 -1
  4. package/dist/admin-domain.d.ts.map +1 -1
  5. package/dist/admin-domain.js +13 -3
  6. package/dist/admin-domain.js.map +1 -1
  7. package/dist/adopt-existing.d.ts +12 -3
  8. package/dist/adopt-existing.d.ts.map +1 -1
  9. package/dist/adopt-existing.js +143 -0
  10. package/dist/adopt-existing.js.map +1 -1
  11. package/dist/agent/command.d.ts +23 -0
  12. package/dist/agent/command.d.ts.map +1 -1
  13. package/dist/agent/command.js +32 -9
  14. package/dist/agent/command.js.map +1 -1
  15. package/dist/agent/run.d.ts +2 -0
  16. package/dist/agent/run.d.ts.map +1 -1
  17. package/dist/agent/run.js +5 -1
  18. package/dist/agent/run.js.map +1 -1
  19. package/dist/agent-db.js +69 -10
  20. package/dist/aws-task-roles.d.ts +4 -2
  21. package/dist/aws-task-roles.d.ts.map +1 -1
  22. package/dist/aws-task-roles.js +4 -1
  23. package/dist/aws-task-roles.js.map +1 -1
  24. package/dist/caddy.d.ts +21 -4
  25. package/dist/caddy.d.ts.map +1 -1
  26. package/dist/caddy.js +27 -3
  27. package/dist/caddy.js.map +1 -1
  28. package/dist/cli-routing.js +1 -1
  29. package/dist/cli-routing.js.map +1 -1
  30. package/dist/cli.js +68 -7
  31. package/dist/cli.js.map +1 -1
  32. package/dist/cloudsql-tls.d.ts +93 -0
  33. package/dist/cloudsql-tls.d.ts.map +1 -0
  34. package/dist/cloudsql-tls.js +295 -0
  35. package/dist/cloudsql-tls.js.map +1 -0
  36. package/dist/compose.d.ts +43 -6
  37. package/dist/compose.d.ts.map +1 -1
  38. package/dist/compose.js +148 -23
  39. package/dist/compose.js.map +1 -1
  40. package/dist/converge-checkpoint.d.ts +1 -0
  41. package/dist/converge-checkpoint.d.ts.map +1 -1
  42. package/dist/converge-checkpoint.js +8 -1
  43. package/dist/converge-checkpoint.js.map +1 -1
  44. package/dist/converge-db-trust.d.ts +44 -0
  45. package/dist/converge-db-trust.d.ts.map +1 -0
  46. package/dist/converge-db-trust.js +158 -0
  47. package/dist/converge-db-trust.js.map +1 -0
  48. package/dist/converge-job-gcp.d.ts +5 -2
  49. package/dist/converge-job-gcp.d.ts.map +1 -1
  50. package/dist/converge-job-gcp.js +11 -13
  51. package/dist/converge-job-gcp.js.map +1 -1
  52. package/dist/converge-job-preflight.d.ts.map +1 -1
  53. package/dist/converge-job-preflight.js +8 -0
  54. package/dist/converge-job-preflight.js.map +1 -1
  55. package/dist/converge-job.d.ts +17 -0
  56. package/dist/converge-job.d.ts.map +1 -1
  57. package/dist/converge-job.js +12 -0
  58. package/dist/converge-job.js.map +1 -1
  59. package/dist/deployed-release.d.ts +4 -1
  60. package/dist/deployed-release.d.ts.map +1 -1
  61. package/dist/deployed-release.js +18 -8
  62. package/dist/deployed-release.js.map +1 -1
  63. package/dist/gcp-names.d.ts +34 -0
  64. package/dist/gcp-names.d.ts.map +1 -1
  65. package/dist/gcp-names.js +45 -0
  66. package/dist/gcp-names.js.map +1 -1
  67. package/dist/gcp-url-map.d.ts.map +1 -1
  68. package/dist/gcp-url-map.js +6 -2
  69. package/dist/gcp-url-map.js.map +1 -1
  70. package/dist/index.d.ts +3 -2
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +2 -1
  73. package/dist/index.js.map +1 -1
  74. package/dist/infra-contract.d.ts +1 -1
  75. package/dist/infra-contract.d.ts.map +1 -1
  76. package/dist/infra-contract.js +45 -1
  77. package/dist/infra-contract.js.map +1 -1
  78. package/dist/install-job.d.ts +4 -2
  79. package/dist/install-job.d.ts.map +1 -1
  80. package/dist/install-job.js +4 -1
  81. package/dist/install-job.js.map +1 -1
  82. package/dist/install-state.d.ts +13 -0
  83. package/dist/install-state.d.ts.map +1 -1
  84. package/dist/install-state.js +17 -4
  85. package/dist/install-state.js.map +1 -1
  86. package/dist/lifecycle.d.ts.map +1 -1
  87. package/dist/lifecycle.js +20 -8
  88. package/dist/lifecycle.js.map +1 -1
  89. package/dist/mcp-auth-proxy-setup.d.ts +48 -0
  90. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -0
  91. package/dist/mcp-auth-proxy-setup.js +280 -0
  92. package/dist/mcp-auth-proxy-setup.js.map +1 -0
  93. package/dist/migration-runner.d.ts.map +1 -1
  94. package/dist/migration-runner.js +8 -3
  95. package/dist/migration-runner.js.map +1 -1
  96. package/dist/migration-targets.d.ts +11 -0
  97. package/dist/migration-targets.d.ts.map +1 -0
  98. package/dist/migration-targets.js +12 -0
  99. package/dist/migration-targets.js.map +1 -0
  100. package/dist/operator-access.d.ts +10 -0
  101. package/dist/operator-access.d.ts.map +1 -1
  102. package/dist/operator-access.js +5 -0
  103. package/dist/operator-access.js.map +1 -1
  104. package/dist/pool-budget-stack.d.ts +13 -1
  105. package/dist/pool-budget-stack.d.ts.map +1 -1
  106. package/dist/pool-budget-stack.js +11 -1
  107. package/dist/pool-budget-stack.js.map +1 -1
  108. package/dist/public-role-isolation.d.ts +7 -3
  109. package/dist/public-role-isolation.d.ts.map +1 -1
  110. package/dist/public-role-isolation.js +25 -13
  111. package/dist/public-role-isolation.js.map +1 -1
  112. package/dist/release-contract.d.ts.map +1 -1
  113. package/dist/release-contract.js +3 -0
  114. package/dist/release-contract.js.map +1 -1
  115. package/dist/release-manifest.d.ts +25 -1
  116. package/dist/release-manifest.d.ts.map +1 -1
  117. package/dist/release-manifest.js +36 -3
  118. package/dist/release-manifest.js.map +1 -1
  119. package/dist/remote-mcp.d.ts +55 -18
  120. package/dist/remote-mcp.d.ts.map +1 -1
  121. package/dist/remote-mcp.js +307 -77
  122. package/dist/remote-mcp.js.map +1 -1
  123. package/dist/runtime-secrets.d.ts +11 -0
  124. package/dist/runtime-secrets.d.ts.map +1 -1
  125. package/dist/runtime-secrets.js +40 -25
  126. package/dist/runtime-secrets.js.map +1 -1
  127. package/dist/scheduler-setup.d.ts +75 -0
  128. package/dist/scheduler-setup.d.ts.map +1 -0
  129. package/dist/scheduler-setup.js +331 -0
  130. package/dist/scheduler-setup.js.map +1 -0
  131. package/dist/seal-key-rotation.d.ts +7 -7
  132. package/dist/seal-key-rotation.d.ts.map +1 -1
  133. package/dist/seal-key-rotation.js +22 -22
  134. package/dist/seal-key-rotation.js.map +1 -1
  135. package/dist/self-hosted-db-tls.d.ts +37 -0
  136. package/dist/self-hosted-db-tls.d.ts.map +1 -0
  137. package/dist/self-hosted-db-tls.js +220 -0
  138. package/dist/self-hosted-db-tls.js.map +1 -0
  139. package/dist/self-hosted-release.d.ts +3 -1
  140. package/dist/self-hosted-release.d.ts.map +1 -1
  141. package/dist/self-hosted-release.js +33 -9
  142. package/dist/self-hosted-release.js.map +1 -1
  143. package/dist/self-hosted-roles.d.ts +43 -2
  144. package/dist/self-hosted-roles.d.ts.map +1 -1
  145. package/dist/self-hosted-roles.js +87 -1
  146. package/dist/self-hosted-roles.js.map +1 -1
  147. package/dist/self-hosted-scheduler-interval.d.ts +21 -0
  148. package/dist/self-hosted-scheduler-interval.d.ts.map +1 -0
  149. package/dist/self-hosted-scheduler-interval.js +68 -0
  150. package/dist/self-hosted-scheduler-interval.js.map +1 -0
  151. package/dist/self-hosted-start.d.ts +0 -21
  152. package/dist/self-hosted-start.d.ts.map +1 -1
  153. package/dist/self-hosted-start.js +39 -1
  154. package/dist/self-hosted-start.js.map +1 -1
  155. package/dist/self-hosted-upgrade.d.ts +19 -7
  156. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  157. package/dist/self-hosted-upgrade.js +78 -25
  158. package/dist/self-hosted-upgrade.js.map +1 -1
  159. package/dist/stack-contract.d.ts +234 -28
  160. package/dist/stack-contract.d.ts.map +1 -1
  161. package/dist/stack-contract.js +397 -39
  162. package/dist/stack-contract.js.map +1 -1
  163. package/dist/stack-converge.d.ts +31 -0
  164. package/dist/stack-converge.d.ts.map +1 -1
  165. package/dist/stack-converge.js +71 -30
  166. package/dist/stack-converge.js.map +1 -1
  167. package/dist/staging-database.d.ts +22 -0
  168. package/dist/staging-database.d.ts.map +1 -0
  169. package/dist/staging-database.js +117 -0
  170. package/dist/staging-database.js.map +1 -0
  171. package/dist/upgrade/app-roll.d.ts +25 -4
  172. package/dist/upgrade/app-roll.d.ts.map +1 -1
  173. package/dist/upgrade/app-roll.js +63 -17
  174. package/dist/upgrade/app-roll.js.map +1 -1
  175. package/dist/upgrade/converge.d.ts +5 -0
  176. package/dist/upgrade/converge.d.ts.map +1 -1
  177. package/dist/upgrade/converge.js +207 -20
  178. package/dist/upgrade/converge.js.map +1 -1
  179. package/dist/upgrade/health.d.ts +31 -7
  180. package/dist/upgrade/health.d.ts.map +1 -1
  181. package/dist/upgrade/health.js +41 -17
  182. package/dist/upgrade/health.js.map +1 -1
  183. package/dist/upgrade/image-forms.d.ts +1 -1
  184. package/dist/upgrade/image-forms.d.ts.map +1 -1
  185. package/dist/upgrade/image-forms.js +24 -7
  186. package/dist/upgrade/image-forms.js.map +1 -1
  187. package/dist/upgrade/release-images.d.ts +12 -3
  188. package/dist/upgrade/release-images.d.ts.map +1 -1
  189. package/dist/upgrade/release-images.js +13 -4
  190. package/dist/upgrade/release-images.js.map +1 -1
  191. package/dist/upgrade/upgrade.d.ts +19 -3
  192. package/dist/upgrade/upgrade.d.ts.map +1 -1
  193. package/dist/upgrade/upgrade.js +37 -2
  194. package/dist/upgrade/upgrade.js.map +1 -1
  195. package/dist/upgrader-gcp.d.ts +13 -0
  196. package/dist/upgrader-gcp.d.ts.map +1 -1
  197. package/dist/upgrader-gcp.js +50 -1
  198. package/dist/upgrader-gcp.js.map +1 -1
  199. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  200. package/dist/upgrader-iam-preflight.js +6 -1
  201. package/dist/upgrader-iam-preflight.js.map +1 -1
  202. package/dist/upgrader-job.d.ts +1 -1
  203. package/dist/upgrader-job.d.ts.map +1 -1
  204. package/dist/upgrader-job.js +20 -14
  205. package/dist/upgrader-job.js.map +1 -1
  206. package/dist/upgrader-records.d.ts +1 -1
  207. package/dist/upgrader-records.d.ts.map +1 -1
  208. package/dist/upgrader-records.js +26 -7
  209. package/dist/upgrader-records.js.map +1 -1
  210. package/dist/upgrader-setup.d.ts.map +1 -1
  211. package/dist/upgrader-setup.js +10 -5
  212. package/dist/upgrader-setup.js.map +1 -1
  213. package/dist/wizard.d.ts +7 -0
  214. package/dist/wizard.d.ts.map +1 -1
  215. package/dist/wizard.js +11 -0
  216. package/dist/wizard.js.map +1 -1
  217. package/dist/wizards/gcp-cost.d.ts +31 -0
  218. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  219. package/dist/wizards/gcp-cost.js +103 -18
  220. package/dist/wizards/gcp-cost.js.map +1 -1
  221. package/dist/wizards/gcp.d.ts.map +1 -1
  222. package/dist/wizards/gcp.js +129 -27
  223. package/dist/wizards/gcp.js.map +1 -1
  224. package/package.json +2 -2
  225. package/src/adapter.ts +6 -0
  226. package/src/admin-domain.test.ts +9 -8
  227. package/src/admin-domain.ts +13 -3
  228. package/src/admin-memory.test.ts +2 -1
  229. package/src/adopt-existing.test.ts +85 -0
  230. package/src/adopt-existing.ts +141 -3
  231. package/src/agent/command.test.ts +49 -1
  232. package/src/agent/command.ts +39 -9
  233. package/src/agent/run.test.ts +19 -0
  234. package/src/agent/run.ts +11 -2
  235. package/src/aws-task-roles.ts +5 -2
  236. package/src/caddy-redirects.docker.test.ts +2 -0
  237. package/src/caddy-redirects.test.ts +4 -1
  238. package/src/caddy-validate.test.ts +7 -1
  239. package/src/caddy.test.ts +39 -1
  240. package/src/caddy.ts +36 -4
  241. package/src/checkpoint-secrets.test.ts +14 -6
  242. package/src/cli-routing.test.ts +1 -1
  243. package/src/cli-routing.ts +1 -1
  244. package/src/cli.ts +71 -5
  245. package/src/cloudsql-tls.test.ts +255 -0
  246. package/src/cloudsql-tls.ts +359 -0
  247. package/src/compose.ts +193 -24
  248. package/src/converge-checkpoint.ts +8 -1
  249. package/src/converge-db-trust.test.ts +237 -0
  250. package/src/converge-db-trust.ts +212 -0
  251. package/src/converge-job-gcp.ts +11 -12
  252. package/src/converge-job-preflight.test.ts +5 -0
  253. package/src/converge-job-preflight.ts +8 -0
  254. package/src/converge-job.test.ts +70 -0
  255. package/src/converge-job.ts +32 -0
  256. package/src/database-tls-builders.test.ts +56 -0
  257. package/src/deployed-release.test.ts +107 -40
  258. package/src/deployed-release.ts +30 -9
  259. package/src/gateway-env-isolation.test.ts +50 -12
  260. package/src/gcp-names.ts +56 -0
  261. package/src/gcp-url-map.test.ts +6 -8
  262. package/src/gcp-url-map.ts +6 -2
  263. package/src/index.test.ts +10 -4
  264. package/src/index.ts +7 -0
  265. package/src/infra-contract.test.ts +7 -3
  266. package/src/infra-contract.ts +45 -1
  267. package/src/install-job.ts +4 -1
  268. package/src/install-state.ts +33 -4
  269. package/src/lifecycle.ts +23 -11
  270. package/src/mcp-auth-proxy-setup.test.ts +305 -0
  271. package/src/mcp-auth-proxy-setup.ts +374 -0
  272. package/src/mcp-iap-stacks.test.ts +1 -1
  273. package/src/media-volume.test.ts +1 -1
  274. package/src/migration-runner.ts +14 -9
  275. package/src/migration-targets.ts +12 -0
  276. package/src/operator-access-stacks.test.ts +1 -1
  277. package/src/operator-access.test.ts +20 -4
  278. package/src/operator-access.ts +14 -0
  279. package/src/pool-budget-stack.ts +15 -0
  280. package/src/pool-budget-stacks.test.ts +39 -14
  281. package/src/public-role-isolation.test.ts +18 -8
  282. package/src/public-role-isolation.ts +31 -17
  283. package/src/release-contract.ts +3 -0
  284. package/src/release-manifest.test.ts +25 -0
  285. package/src/release-manifest.ts +59 -4
  286. package/src/remote-mcp-stack-config.test.ts +19 -9
  287. package/src/remote-mcp.test.ts +252 -57
  288. package/src/remote-mcp.ts +336 -77
  289. package/src/runtime-secrets.test.ts +37 -14
  290. package/src/runtime-secrets.ts +87 -27
  291. package/src/scheduler-setup.test.ts +201 -0
  292. package/src/scheduler-setup.ts +375 -0
  293. package/src/seal-key-rotation.test.ts +2 -2
  294. package/src/seal-key-rotation.ts +25 -25
  295. package/src/self-hosted-backup.test.ts +1 -0
  296. package/src/self-hosted-compose.test.ts +180 -6
  297. package/src/self-hosted-db-tls.test.ts +111 -0
  298. package/src/self-hosted-db-tls.ts +287 -0
  299. package/src/self-hosted-release-resolve.test.ts +71 -0
  300. package/src/self-hosted-release.ts +41 -11
  301. package/src/self-hosted-roles.integration.test.ts +2 -0
  302. package/src/self-hosted-roles.test.ts +87 -3
  303. package/src/self-hosted-roles.ts +101 -3
  304. package/src/self-hosted-scheduler-interval.test.ts +66 -0
  305. package/src/self-hosted-scheduler-interval.ts +79 -0
  306. package/src/self-hosted-start.test.ts +35 -2
  307. package/src/self-hosted-start.ts +20 -2
  308. package/src/self-hosted-upgrade.test.ts +104 -14
  309. package/src/self-hosted-upgrade.ts +123 -31
  310. package/src/stack-contract-parity.test.ts +79 -12
  311. package/src/stack-contract.test.ts +199 -31
  312. package/src/stack-contract.ts +472 -45
  313. package/src/stack-converge-secrets.test.ts +28 -13
  314. package/src/stack-converge.test.ts +203 -28
  315. package/src/stack-converge.ts +92 -30
  316. package/src/staging-database.test.ts +114 -0
  317. package/src/staging-database.ts +148 -0
  318. package/src/state-store.test.ts +2 -1
  319. package/src/upgrade/app-roll.ts +97 -19
  320. package/src/upgrade/converge.ts +249 -19
  321. package/src/upgrade/health.test.ts +63 -25
  322. package/src/upgrade/health.ts +54 -22
  323. package/src/upgrade/image-forms.test.ts +34 -0
  324. package/src/upgrade/image-forms.ts +23 -8
  325. package/src/upgrade/release-images.test.ts +5 -1
  326. package/src/upgrade/release-images.ts +22 -6
  327. package/src/upgrade/upgrade.test.ts +438 -46
  328. package/src/upgrade/upgrade.ts +64 -6
  329. package/src/upgrader-gcp.ts +66 -3
  330. package/src/upgrader-iam-preflight.test.ts +19 -0
  331. package/src/upgrader-iam-preflight.ts +6 -0
  332. package/src/upgrader-job.test.ts +101 -27
  333. package/src/upgrader-job.ts +27 -14
  334. package/src/upgrader-records.test.ts +97 -12
  335. package/src/upgrader-records.ts +28 -11
  336. package/src/upgrader-setup.test.ts +2 -0
  337. package/src/upgrader-setup.ts +10 -4
  338. package/src/wizard.ts +19 -0
  339. package/src/wizards/gcp-cost.test.ts +103 -0
  340. package/src/wizards/gcp-cost.ts +125 -18
  341. package/src/wizards/gcp.ts +164 -22
  342. package/stacks/aws/Pulumi.yaml +1 -1
  343. package/stacks/aws/index.ts +72 -14
  344. package/stacks/azure/index.ts +108 -21
  345. package/stacks/gcp/Pulumi.yaml +1 -1
  346. package/stacks/gcp/README.md +1 -0
  347. package/stacks/gcp/index.ts +133 -24
  348. package/stacks/gcp-firebase/Pulumi.yaml +1 -1
  349. package/stacks/gcp-firebase/index.ts +212 -34
  350. package/stacks/self-hosted/Pulumi.yaml +3 -0
  351. package/stacks/self-hosted/README.md +6 -1
  352. package/stacks/self-hosted/index.ts +40 -5
@@ -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,15 +45,79 @@ import {
44
45
  gcpBucketName,
45
46
  gcpNamePrefix,
46
47
  gcpSecretId,
48
+ mcpAuthProxyServiceAccountEmail,
47
49
  mcpIapServiceAccountEmail,
48
50
  operatorAccessJobResource,
49
51
  runServiceAccountEmail,
52
+ schedulerServiceAccountEmail,
50
53
  staticPublisherServiceAccountId,
51
54
  upgraderJobResource,
52
55
  } from "./gcp-names.js";
53
56
 
54
57
  export type GcpProvider = "gcp" | "gcp-firebase";
55
- export type CloudRunSlug = "admin" | "gateway";
58
+ /**
59
+ * 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}.
64
+ */
65
+ export type CloudRunSlug = "admin" | "gateway" | "mcp-auth-proxy";
66
+
67
+ /** 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"];
69
+
70
+ /**
71
+ * The name prefix of the Cloud Run service that plays `slug` on an install
72
+ * (Pulumi suffixes the names it generates; the services named explicitly,
73
+ * {@link fixedCloudRunServiceName}, match their prefix exactly).
74
+ *
75
+ * One asymmetry, decided as E13 (a) of the gateway hardening plan (#763):
76
+ * on `gcp-firebase` the MCP-Auth-Proxy is the service that was the gateway
77
+ * before #1328. Its `run.app` URL is the remote-MCP address every connector
78
+ * in claude.ai / ChatGPT holds, and the Google sign-in client's registered
79
+ * redirect URI points at it; neither can move without the Owner
80
+ * re-connecting every client and editing the OAuth client by hand. So that
81
+ * service keeps its name and becomes the proxy, and the visitor gateway is
82
+ * the new `caelo-<env>-data-gateway` (the Firebase `/api/**` rewrite the
83
+ * publisher writes follows the admin's `CAELO_GATEWAY_SERVICE`). On `gcp`
84
+ * the MCP address is the site's own host behind the load balancer, so no
85
+ * service name is pinned there and the proxy is the new service.
86
+ *
87
+ * No prefix is a prefix of another (`…-gateway` does not prefix
88
+ * `…-data-gateway`), so a `metadata.name~^<prefix>` lookup finds one role.
89
+ */
90
+ export function cloudRunServicePrefix(
91
+ provider: GcpProvider,
92
+ env: string,
93
+ slug: CloudRunSlug,
94
+ ): string {
95
+ const prefix = gcpNamePrefix(env);
96
+ if (slug === "admin") return `${prefix}-admin`;
97
+ if (provider === "gcp-firebase") {
98
+ return slug === "gateway" ? `${prefix}-data-gateway` : `${prefix}-gateway`;
99
+ }
100
+ return slug === "gateway" ? `${prefix}-gateway` : `${prefix}-mcp-auth-proxy`;
101
+ }
102
+
103
+ /**
104
+ * 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
106
+ * Pulumi-suffixed, because the converge creates them on existing installs
107
+ * with gcloud, and the stack's next `pulumi up` must then find the same
108
+ * service instead of creating a second one. Null for the services Pulumi
109
+ * has always auto-named.
110
+ */
111
+ export function fixedCloudRunServiceName(
112
+ provider: GcpProvider,
113
+ env: string,
114
+ slug: CloudRunSlug,
115
+ ): string | null {
116
+ const fixed =
117
+ (provider === "gcp-firebase" && slug === "gateway") ||
118
+ (provider === "gcp" && slug === "mcp-auth-proxy");
119
+ return fixed ? cloudRunServicePrefix(provider, env, slug) : null;
120
+ }
56
121
 
57
122
  /**
58
123
  * One container env var, in the shape Pulumi's `gcp.cloudrunv2.Service`
@@ -97,6 +162,16 @@ export interface DatabaseUrls<V = string> {
97
162
  * state the admin publishes into cms_public.
98
163
  */
99
164
  readonly public: V;
165
+ /**
166
+ * #1329 — admin_role on cms_public_staging: the admin's third pool, for
167
+ * the staging database's plugin schemas and its half of the projection.
168
+ */
169
+ readonly publicStagingAdmin: V;
170
+ /**
171
+ * #1329 — public_staging_role on cms_public_staging, the staging
172
+ * gateway's only pool (served from P3b-2 on). It reaches no live data.
173
+ */
174
+ readonly publicStaging: V;
100
175
  }
101
176
 
102
177
  /** Port Cloud SQL Postgres listens on (private IP). */
@@ -106,17 +181,84 @@ const DATABASE_PORT = 5432;
106
181
  * Password-less connection URLs for a Cloud SQL instance at `host`. The
107
182
  * password reaches the apps separately, as a Secret Manager reference
108
183
  * ({@link SERVICE_SECRET_ENV}).
184
+ *
185
+ * TLS is `verify-full` against Google's Cloud SQL shared-CA bundle, which
186
+ * every image carries (#1338, shared database-tls.ts). The shared CA's
187
+ * server certificate names the instance's DNS name and no IP, so `host`
188
+ * must be that name (cloudsql-tls.ts resolves it in the VPC); an IP is
189
+ * refused here rather than producing URLs no connection can verify.
190
+ *
191
+ * @throws Error when `host` is an IP literal
109
192
  */
110
193
  export function databaseUrls(host: string): DatabaseUrls {
194
+ if (isIpLiteral(host)) {
195
+ throw new Error(
196
+ `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\`.`,
197
+ );
198
+ }
111
199
  const url = (role: string, db: string) =>
112
- `postgresql://${role}@${host}:${DATABASE_PORT}/${db}?sslmode=require`;
200
+ `postgresql://${role}@${host}:${DATABASE_PORT}/${db}?${verifyFullQuery(CLOUD_SQL_CA_BUNDLE)}`;
113
201
  return {
114
202
  admin: url("admin_role", "cms_admin"),
115
203
  publicAdmin: url("admin_role", "cms_public"),
116
204
  public: url("public_role", "cms_public"),
205
+ publicStagingAdmin: url("admin_role", "cms_public_staging"),
206
+ publicStaging: url("public_staging_role", "cms_public_staging"),
117
207
  };
118
208
  }
119
209
 
210
+ /**
211
+ * #1338 — the Cloud SQL server CA mode every install runs: Google's shared
212
+ * CA. Its server certificate carries the instance's DNS name, so a client
213
+ * can verify the host (`sslmode=verify-full`). The default per-instance CA
214
+ * names no host a private-IP client can dial ("The SAN field contains the
215
+ * hostname (DNS name of the instance) only for instances enabled with
216
+ * Private Service Connect" — docs.cloud.google.com/sql/docs/postgres/authorize-ssl).
217
+ * An instance moves from per-instance to shared, never back.
218
+ */
219
+ export const CLOUD_SQL_SERVER_CA_MODE = "GOOGLE_MANAGED_CAS_CA";
220
+
221
+ /** #1338 — Cloud SQL refuses unencrypted connections: every client uses verify-full. */
222
+ export const CLOUD_SQL_SSL_MODE = "ENCRYPTED_ONLY";
223
+
224
+ /** The `dnsNames[].connectionType` of the name a private-services-access client dials. */
225
+ export const CLOUD_SQL_PSA_CONNECTION_TYPE = "PRIVATE_SERVICES_ACCESS";
226
+
227
+ /**
228
+ * The private Cloud DNS zone that resolves the instance's DNS name to its
229
+ * private IP inside the install's VPC. Cloud SQL assigns the name but does
230
+ * not resolve it: "create the DNS record in a private DNS zone in the
231
+ * corresponding Virtual Private Cloud (VPC) network"
232
+ * (docs.cloud.google.com/sql/docs/postgres/configure-ssl-instance). The zone
233
+ * is the name itself, holding its one A record.
234
+ */
235
+ export function cloudSqlDnsZoneName(env: string): string {
236
+ return `${gcpNamePrefix(env)}-pg-dns`;
237
+ }
238
+
239
+ /**
240
+ * The DNS name a private-IP client dials, from an instance's `dnsNames`,
241
+ * lower-case and without the trailing dot. Null when the instance has none
242
+ * (a per-instance-CA instance before its move to the shared CA).
243
+ */
244
+ export function cloudSqlPrivateDnsName(
245
+ dnsNames: readonly { readonly name?: string; readonly connectionType?: string }[] | undefined,
246
+ ): string | null {
247
+ const entry = (dnsNames ?? []).find((d) => d.connectionType === CLOUD_SQL_PSA_CONNECTION_TYPE);
248
+ const name = entry?.name?.replace(/\.$/, "").toLowerCase();
249
+ return name ? name : null;
250
+ }
251
+
252
+ /** The database URL vars each service carries (the env contract below sets them). */
253
+ export const DATABASE_URL_VARS = {
254
+ admin: ["ADMIN_DATABASE_URL", "PUBLIC_ADMIN_DATABASE_URL", "PUBLIC_STAGING_ADMIN_DATABASE_URL"],
255
+ gateway: ["PUBLIC_DATABASE_URL"],
256
+ // #1328 — no database login at all. On gcp-firebase the service still
257
+ // carries the gateway's URL until its roll removes it with the rest of
258
+ // the gateway env (RETIRED_SERVICE_ENV), so the TLS step leaves it alone.
259
+ "mcp-auth-proxy": [],
260
+ } as const;
261
+
120
262
  /** What a running service's env derives from: install metadata + the database. */
121
263
  export interface RuntimeEnvInputs<V = string> extends ServiceEnvInputs {
122
264
  /** From the Cloud SQL private IP — an Output in the stacks. */
@@ -199,6 +341,54 @@ export const CAPTURE_JOB_ENV_VAR = "CAELO_CAPTURE_JOB";
199
341
  export const CAPTURE_BUCKET_ENV_VAR = "CAELO_CAPTURE_BUCKET";
200
342
  export const MEDIA_BUCKET_ENV_VAR = "CAELO_MEDIA_BUCKET";
201
343
 
344
+ /**
345
+ * #1140 — the admin env var naming the service account of the scheduler
346
+ * tick (scheduler-setup.ts): the one IAP identity the admin's
347
+ * `/api/internal/scheduler/tick` accepts. `upgrade` leaves it untouched when
348
+ * it could not set the tick up.
349
+ */
350
+ export const SCHEDULER_SERVICE_ACCOUNT_ENV_VAR = "CAELO_SCHEDULER_SERVICE_ACCOUNT";
351
+
352
+ /**
353
+ * #1140 — how often the one scheduler job calls the admin's tick, in
354
+ * minutes (the `schedulerIntervalMinutes` install knob). Each task's own
355
+ * cadence lives in the admin's due-table; this only bounds how soon a due
356
+ * task runs. 1 is Cloud Scheduler's floor; above 15 Cloud Run has stopped
357
+ * the idle admin, so every tick would pay a cold start without saving
358
+ * anything (maintainer decision on #1140, 2026-10-26).
359
+ */
360
+ export const SCHEDULER_INTERVAL_MINUTES = { default: 5, min: 1, max: 15 } as const;
361
+
362
+ /**
363
+ * Parse the `schedulerIntervalMinutes` knob. Undefined (not configured) is
364
+ * the default; anything else must be an integer in the bounds — an
365
+ * out-of-range or non-numeric value throws (CLAUDE.md §2: no silent
366
+ * substitute).
367
+ *
368
+ * @param raw - the configured value (CLI flag, Pulumi config, install.json).
369
+ * @param source - names where the value came from, for the error.
370
+ */
371
+ export function parseSchedulerIntervalMinutes(
372
+ raw: string | number | undefined,
373
+ source = "schedulerIntervalMinutes",
374
+ ): number {
375
+ if (raw === undefined || raw === "") return SCHEDULER_INTERVAL_MINUTES.default;
376
+ const n = typeof raw === "number" ? raw : /^\s*\d+\s*$/.test(raw) ? Number(raw) : Number.NaN;
377
+ const { min, max } = SCHEDULER_INTERVAL_MINUTES;
378
+ if (!Number.isInteger(n) || n < min || n > max) {
379
+ throw new Error(
380
+ `${source} ${JSON.stringify(raw)} is not a whole number of minutes between ${min} and ${max} (default ${SCHEDULER_INTERVAL_MINUTES.default})`,
381
+ );
382
+ }
383
+ return n;
384
+ }
385
+
386
+ /** The Cloud Scheduler / cron expression of a tick every `minutes` minutes (UTC). */
387
+ export function schedulerCron(minutes: number): string {
388
+ const m = parseSchedulerIntervalMinutes(minutes);
389
+ return m === 1 ? "* * * * *" : `*/${m} * * * *`;
390
+ }
391
+
202
392
  /** Where the admin image ships the static-generator CLI. */
203
393
  const GENERATOR_CLI = "/app/apps/static-generator/src/cli.ts";
204
394
 
@@ -268,6 +458,8 @@ export function adminMediaVolumeTemplate(volume: MediaVolumeContract) {
268
458
  export type RuntimeSecret =
269
459
  | "postgres-password"
270
460
  | "public-role-password"
461
+ /** #1329 — public_staging_role's password, the staging gateway's login. */
462
+ | "public-staging-role-password"
271
463
  | "secret-kek"
272
464
  | "internal-secret"
273
465
  | "tool-approval-secret"
@@ -289,7 +481,7 @@ export type RuntimeSecret =
289
481
  | "mcp-oauth-seal-key";
290
482
 
291
483
  /** The Postgres roles Caelo's services log in as, with their passwords. */
292
- export type DatabaseRole = "admin_role" | "public_role";
484
+ export type DatabaseRole = "admin_role" | "public_role" | "public_staging_role";
293
485
 
294
486
  /**
295
487
  * Each database role's password secret — one per role (#613), so the
@@ -301,6 +493,8 @@ export type DatabaseRole = "admin_role" | "public_role";
301
493
  export const DATABASE_ROLE_SECRET: Readonly<Record<DatabaseRole, RuntimeSecret>> = {
302
494
  admin_role: "postgres-password",
303
495
  public_role: "public-role-password",
496
+ // #1329 — its own, so neither gateway can ever log in as the other.
497
+ public_staging_role: "public-staging-role-password",
304
498
  };
305
499
 
306
500
  /**
@@ -316,6 +510,9 @@ export const CLI_GENERATED_SECRETS = [
316
510
  // #613 — the gateway's own role password. Generated (never seeded from
317
511
  // `postgres-password`), so the gateway never learns admin_role's.
318
512
  "public-role-password",
513
+ // #1329 — the staging gateway's role password, never shared with the
514
+ // live gateway's (a test asserts the two public logins share no secret).
515
+ "public-staging-role-password",
319
516
  // #650 — the staging access gate's key: signs "Open staging" links and
320
517
  // session cookies, derives the crawler credentials. Read by the admin
321
518
  // (issues) and the gateway (verifies).
@@ -328,6 +525,19 @@ export const CLI_GENERATED_SECRETS = [
328
525
  "mcp-oauth-seal-key",
329
526
  ] as const satisfies readonly RuntimeSecret[];
330
527
 
528
+ /**
529
+ * CLI-generated secrets no running service reads yet, each with what reads
530
+ * it until then. Everything else in {@link CLI_GENERATED_SECRETS} must be
531
+ * read by a service (stack-contract.test.ts), so an orphaned secret cannot
532
+ * ship by accident.
533
+ */
534
+ export const SECRETS_SET_UP_AHEAD_OF_THEIR_READER: Readonly<
535
+ 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
+ };
540
+
331
541
  /** A CLI-generated secret's first value: random hex, random base64, or a fixed placeholder. */
332
542
  export type GeneratedSecretFormat =
333
543
  | { readonly kind: "hex32" }
@@ -344,6 +554,7 @@ export const GENERATED_SECRET_FORMAT: Readonly<
344
554
  "internal-secret": { kind: "hex32" },
345
555
  "tool-approval-secret": { kind: "hex32" },
346
556
  "public-role-password": { kind: "hex32" },
557
+ "public-staging-role-password": { kind: "hex32" },
347
558
  "staging-gate-secret": { kind: "hex32" },
348
559
  // The gateway's seal key contract (#806): base64 of exactly 32 bytes.
349
560
  "mcp-oauth-seal-key": { kind: "base64-32" },
@@ -381,6 +592,8 @@ export const SERVICE_SECRET_ENV: Readonly<
381
592
  admin: {
382
593
  ADMIN_DATABASE_PASSWORD: "postgres-password",
383
594
  PUBLIC_ADMIN_DATABASE_PASSWORD: "postgres-password",
595
+ // #1329 — admin_role on the staging database.
596
+ PUBLIC_STAGING_ADMIN_DATABASE_PASSWORD: "postgres-password",
384
597
  // P18 — encrypts AI provider keys and other at-rest secrets in cms_admin.
385
598
  CAELO_SECRET_KEK: "secret-kek",
386
599
  // P15.1 — HMAC key of the /api/internal/* bearer tokens (internal-jwt.ts).
@@ -398,6 +611,10 @@ export const SERVICE_SECRET_ENV: Readonly<
398
611
  PUBLIC_DATABASE_PASSWORD: "public-role-password",
399
612
  // #650 — verifies staging-host requests (api-gateway staging-proxy.ts).
400
613
  CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
614
+ },
615
+ // #1328 — with the Google sign-in client file below, the proxy's whole
616
+ // Secret Manager reach: no database password, no other key.
617
+ "mcp-auth-proxy": {
401
618
  // #806 — seals the remote-MCP broker's state, codes and refresh tokens.
402
619
  CAELO_MCP_OAUTH_SEAL_KEY: "mcp-oauth-seal-key",
403
620
  },
@@ -418,9 +635,9 @@ export const OPERATOR_OAUTH_CLIENT_FILE_ENV = "CAELO_OPERATOR_OAUTH_CLIENT_FILE"
418
635
  * time the file is read ("When reading a volume, Cloud Run always fetches
419
636
  * the secret value from the Secret Manager to use the value with the latest
420
637
  * 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.
638
+ * client the Owner saves under Settings → Google sign-in reaches the running
639
+ * MCP-Auth-Proxy (which re-reads it within 60 s) without a new revision, and
640
+ * the admin needs no right to deploy the proxy.
424
641
  */
425
642
  export interface SecretFileContract {
426
643
  readonly secret: RuntimeSecret;
@@ -433,7 +650,7 @@ export interface SecretFileContract {
433
650
  readonly version: "latest";
434
651
  }
435
652
 
436
- /** The gateway's Google sign-in client file, read by the remote-MCP broker (#806, #914). */
653
+ /** The Google sign-in client file the remote-MCP broker reads (#806, #914; in the MCP-Auth-Proxy since #1328). */
437
654
  export const OPERATOR_OAUTH_CLIENT_FILE: SecretFileContract = {
438
655
  secret: "operator-oauth-client",
439
656
  volumeName: "operator-oauth-client",
@@ -447,10 +664,11 @@ export function secretFilePath(file: SecretFileContract): string {
447
664
  return `${file.mountDir}/${file.fileName}`;
448
665
  }
449
666
 
450
- /** Each service's secret files. The admin has none. */
667
+ /** Each service's secret files. Only the MCP-Auth-Proxy has one. */
451
668
  export const SERVICE_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly SecretFileContract[]>> = {
452
669
  admin: [],
453
- gateway: [OPERATOR_OAUTH_CLIENT_FILE],
670
+ gateway: [],
671
+ "mcp-auth-proxy": [OPERATOR_OAUTH_CLIENT_FILE],
454
672
  };
455
673
 
456
674
  /** A secret file an older contract mounted; upgrade unmounts it in the roll. */
@@ -481,6 +699,22 @@ export const RETIRED_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly Retire
481
699
  fileName: "client.json",
482
700
  why: "the Google sign-in client is mounted from operator-oauth-client now (#914)",
483
701
  },
702
+ {
703
+ secret: "operator-oauth-client",
704
+ mountDir: "/secrets/operator-oauth-client",
705
+ fileName: "client.json",
706
+ why: "the remote-MCP broker moved to the MCP-Auth-Proxy (#1328); the visitor gateway must not hold the Google sign-in client",
707
+ },
708
+ ],
709
+ "mcp-auth-proxy": [
710
+ {
711
+ // gcp-firebase: the proxy is the service that was the gateway, which
712
+ // mounted this file until #914.
713
+ secret: "mcp-oauth-client",
714
+ mountDir: "/secrets/mcp-oauth-client",
715
+ fileName: "client.json",
716
+ why: "the Google sign-in client is mounted from operator-oauth-client now (#914)",
717
+ },
484
718
  ],
485
719
  };
486
720
 
@@ -540,6 +774,30 @@ export const RETIRED_SERVICE_ENV: Readonly<Record<CloudRunSlug, readonly string[
540
774
  // #635 — its gateway_role login on cms_admin is gone too.
541
775
  "GATEWAY_DATABASE_URL",
542
776
  "GATEWAY_DATABASE_PASSWORD",
777
+ // #1328 — remote MCP moved to the MCP-Auth-Proxy.
778
+ "CAELO_OPERATOR_OAUTH_CLIENT_FILE",
779
+ "CAELO_MCP_OAUTH_SEAL_KEY",
780
+ "CAELO_MCP_OAUTH_SEAL_KEY_PREVIOUS",
781
+ "CAELO_MCP_PUBLIC_URL",
782
+ "CAELO_ADMIN_URL",
783
+ ],
784
+ // #1328 — on gcp-firebase the proxy is the service that was the gateway:
785
+ // everything that service carried for visitor traffic goes, above all
786
+ // every database login and the staging gate key. The proxy's SA cannot
787
+ // read their secrets, so a revision that kept one would not even deploy.
788
+ "mcp-auth-proxy": [
789
+ "PUBLIC_DATABASE_URL",
790
+ "PUBLIC_DATABASE_PASSWORD",
791
+ "CAELO_STAGING_GATE_SECRET",
792
+ "CAELO_ENV",
793
+ "MEDIA_STORAGE_URL",
794
+ "CAELO_DB_PUBLIC_POOL_MAX",
795
+ "CAELO_MCP_OAUTH_CLIENT_FILE",
796
+ "CAELO_SECRET_KEK",
797
+ "ADMIN_DATABASE_URL",
798
+ "ADMIN_DATABASE_PASSWORD",
799
+ "GATEWAY_DATABASE_URL",
800
+ "GATEWAY_DATABASE_PASSWORD",
543
801
  ],
544
802
  };
545
803
 
@@ -573,6 +831,15 @@ function commonEnv(inputs: ServiceEnvInputs): CloudRunEnvVar[] {
573
831
  */
574
832
  export const TRUSTED_PROXY_HOPS_ENV = "CAELO_TRUSTED_PROXY_HOPS";
575
833
 
834
+ /**
835
+ * #1329 — the env var naming which environment a gateway serves, `live` or
836
+ * `staging` (apps/api-gateway database-env.ts): its database and login
837
+ * follow from it, and it refuses anything of the other environment's. The
838
+ * gateway refuses to boot without it. Every adapter sets `live` on the
839
+ * gateway it runs today; the staging gateway (P3b-2) gets `staging`.
840
+ */
841
+ export const GATEWAY_ENVIRONMENT_ENV = "CAELO_GATEWAY_ENVIRONMENT";
842
+
576
843
  /**
577
844
  * {@link TRUSTED_PROXY_HOPS_ENV} per provider — a property of each
578
845
  * adapter's topology (CLAUDE.md §11.B), so it is declared here once and every
@@ -586,10 +853,12 @@ export const GATEWAY_TRUSTED_PROXY_HOPS: Readonly<Record<SupportedProvider, numb
586
853
  // ingress is internal-and-LB only, so nothing reaches it around the LB.
587
854
  gcp: 2,
588
855
  // 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.
856
+ // rewrite that peer is a Google front-end proxy that changes per request,
857
+ // and the visitor entry Hosting writes before it cannot be told apart
858
+ // from a forged one (the service is invokable on *.run.app too) — so the
859
+ // proxy address is the most that can be trusted
860
+ // (docs/dev/firebase-hosting-behaviour.md). Staging (Cloud Run domain
861
+ // mapping) gets the visitor.
593
862
  "gcp-firebase": 1,
594
863
  // Caddy without `trusted_proxies` replaces the header with its peer; the
595
864
  // gateway is reachable only on the Compose network.
@@ -609,27 +878,114 @@ export const GATEWAY_TRUSTED_PROXY_HOPS: Readonly<Record<SupportedProvider, numb
609
878
  export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvVar<V | string>[] {
610
879
  return [
611
880
  ...commonEnv(inputs),
881
+ // #1329 — this service is the live site's gateway: it may reach
882
+ // cms_public as public_role and refuses anything of the staging data.
883
+ { name: GATEWAY_ENVIRONMENT_ENV, value: "live" },
612
884
  { name: TRUSTED_PROXY_HOPS_ENV, value: String(GATEWAY_TRUSTED_PROXY_HOPS[inputs.provider]) },
613
885
  { 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
886
  ...secretEnv("gateway", inputs.env),
618
887
  ];
619
888
  }
620
889
 
621
890
  /**
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
891
+ * #1328 — whether remote MCP can exist on a provider, as the MCP-Auth-Proxy
892
+ * reads it. It needs the admin behind Google IAP, which only the Google
893
+ * Cloud installs have. `unavailable` makes the proxy answer one fixed 404
894
+ * to everything but its health check: the service exists on every provider
895
+ * at zero minimum instances (E14 a of #763), its attack surface one branch.
896
+ * The proxy refuses to start without the var — no default (CLAUDE.md §2).
897
+ */
898
+ export const REMOTE_MCP_MODE_ENV = "CAELO_REMOTE_MCP";
899
+
900
+ /** {@link REMOTE_MCP_MODE_ENV} per provider. */
901
+ export const REMOTE_MCP_MODE: Readonly<Record<SupportedProvider, "available" | "unavailable">> = {
902
+ gcp: "available",
903
+ "gcp-firebase": "available",
904
+ "self-hosted": "unavailable",
905
+ aws: "unavailable",
906
+ azure: "unavailable",
907
+ };
908
+
909
+ /**
910
+ * {@link TRUSTED_PROXY_HOPS_ENV} of the MCP-Auth-Proxy per provider: its
911
+ * rate limits key on the client address by the same rule as the gateway's
912
+ * (@caelo-cms/shared client-address.ts), under its own topology.
913
+ */
914
+ export const MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS: Readonly<Record<SupportedProvider, number>> = {
915
+ // Behind the external Application LB (`<client>,<LB IP>`); its ingress
916
+ // is internal-and-LB only.
917
+ gcp: 2,
918
+ // Its own run.app URL with no rewrite in front: Cloud Run's front end
919
+ // appends the client.
920
+ "gcp-firebase": 1,
921
+ // Caddy (its own vhost) replaces the header with its peer.
922
+ "self-hosted": 1,
923
+ // Not deployed yet (#749); the gateway's topology once it is.
924
+ aws: 2,
925
+ // Container Apps ingress appends its peer.
926
+ azure: 1,
927
+ };
928
+
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
+ /**
943
+ * The env of the MCP-Auth-Proxy. No database URL, no pool size, no site
944
+ * settings: the broker reads env plus one secret file and nothing else
945
+ * (apps/mcp-auth-proxy, whose import lint keeps every database client out).
946
+ * 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}).
949
+ */
950
+ export function mcpAuthProxyEnvContract(inputs: {
951
+ readonly provider: SupportedProvider;
952
+ readonly env: string;
953
+ }): CloudRunEnvVar[] {
954
+ return [
955
+ { name: "CAELO_PROVIDER", value: inputs.provider },
956
+ { name: REMOTE_MCP_MODE_ENV, value: REMOTE_MCP_MODE[inputs.provider] },
957
+ {
958
+ name: TRUSTED_PROXY_HOPS_ENV,
959
+ value: String(MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS[inputs.provider]),
960
+ },
961
+ // #806 — where the broker reads the Google sign-in client (the secret
962
+ // volume of SERVICE_SECRET_FILES).
963
+ { name: OPERATOR_OAUTH_CLIENT_FILE_ENV, value: secretFilePath(OPERATOR_OAUTH_CLIENT_FILE) },
964
+ ...secretEnv("mcp-auth-proxy", inputs.env),
965
+ ];
966
+ }
967
+
968
+ /**
969
+ * #806 — the MCP-Auth-Proxy's request timeout, in seconds: Cloud Run's
970
+ * maximum (the gateway's until #1328 moved the broker out). The proxy
971
+ * streams remote-MCP requests to the admin, and a `caelo_chat` turn streams
972
+ * for as long as the admin's own chat stream may (the admin runs at 3600 s
973
+ * for the same reason). A timeout is a ceiling, not a reservation: Cloud
974
+ * Run bills request time, not the allowed maximum. On `gcp` the load
975
+ * balancer in front adds no shorter limit — the backend-service timeout
976
+ * does not apply to serverless NEGs, which get a fixed 60 minutes
630
977
  * (cloud.google.com/load-balancing/docs/negs/serverless-neg-concepts).
631
978
  */
632
- export const GATEWAY_TIMEOUT_SECONDS = 3600;
979
+ export const MCP_AUTH_PROXY_TIMEOUT_SECONDS = 3600;
980
+
981
+ /**
982
+ * #1328 — the MCP-Auth-Proxy's maximum instances. It holds no database
983
+ * pool, so the connection budget does not bound it; ten instances of
984
+ * streaming proxies carry far more agent sessions than an install has
985
+ * operators, and the ceiling caps what a flood of sign-in requests can cost.
986
+ * Always provisioned at minimum 0 (E14 a of #763).
987
+ */
988
+ export const DEFAULT_MCP_AUTH_PROXY_MAX_INSTANCES = 10;
633
989
 
634
990
  /**
635
991
  * Whether each Cloud Run service gets CPU only while it handles a request
@@ -639,15 +995,30 @@ export const GATEWAY_TIMEOUT_SECONDS = 3600;
639
995
  * `resources`, which is how both services ran with always-on CPU without
640
996
  * anyone choosing it — billed for every idle minute an instance stayed up.
641
997
  *
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.
998
+ * The gateway and the MCP-Auth-Proxy only do work inside requests. The
999
+ * admin's periodic work and plugin workers run on the scheduler tick
1000
+ * (#1140), but the quality audit and the site-import crawl still run on
1001
+ * in-process timers that need CPU between requests (#1359, #1360), and the
1002
+ * plugin runtime heartbeat would read an idling instance as down.
645
1003
  */
646
1004
  export const SERVICE_CPU_IDLE: Readonly<Record<CloudRunSlug, boolean>> = {
647
- // FIXME(issue #1140): true once the admin's background work no longer
648
- // depends on CPU between requests (jobs, scheduler, plugin host).
1005
+ // FIXME(issue #1361): true once nothing in the admin depends on CPU
1006
+ // between requests (the category-A jobs, the heartbeat at request start).
649
1007
  admin: false,
650
1008
  gateway: true,
1009
+ "mcp-auth-proxy": true,
1010
+ };
1011
+
1012
+ /**
1013
+ * Whether each service egresses into the install's VPC: the admin and the
1014
+ * gateway reach Cloud SQL's private IP; the MCP-Auth-Proxy reaches nothing
1015
+ * private (#1328) — it calls Google's token endpoint and the admin's public,
1016
+ * IAP-gated URL, so it gets no route to the database's network at all.
1017
+ */
1018
+ export const SERVICE_VPC_EGRESS: Readonly<Record<CloudRunSlug, boolean>> = {
1019
+ admin: true,
1020
+ gateway: true,
1021
+ "mcp-auth-proxy": false,
651
1022
  };
652
1023
 
653
1024
  /** Admin env var naming the Secret Manager secret the Owner writes the Google sign-in client to. */
@@ -696,7 +1067,7 @@ export function operatorOAuthClientSecretResource(projectId: string, env: string
696
1067
  * usually can.
697
1068
  */
698
1069
  export interface RemoteMcpIdentity {
699
- /** Where the gateway proxies `/api/mcp/<surface>` to (IAP in front). */
1070
+ /** Where the MCP-Auth-Proxy forwards `/api/mcp/<surface>` to (IAP in front). */
700
1071
  readonly adminUrl: string;
701
1072
  /** The broker's public origin; no trailing slash. */
702
1073
  readonly mcpPublicUrl: string;
@@ -734,18 +1105,20 @@ export type RemoteMcpIdentityInputs =
734
1105
  readonly adminServiceName: string;
735
1106
  /** The services' `status.url` (`https://<name>-<hash>-<r>.a.run.app`). */
736
1107
  readonly adminServiceUrl: string;
737
- readonly gatewayServiceUrl: string;
1108
+ /** The MCP-Auth-Proxy's: the service that was the gateway before #1328 (E13 a). */
1109
+ readonly mcpAuthProxyServiceUrl: string;
738
1110
  };
739
1111
 
740
1112
  /**
741
1113
  * The install's remote-MCP identity.
742
1114
  *
743
1115
  * - `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
1116
+ * balancer (its MCP path rules route to the MCP-Auth-Proxy's backend,
1117
+ * #1328), the admin on `admin.<domain>` behind the LB's IAP; the IAP
1118
+ * audience is `/projects/<number>/global/backendServices/<id>`.
1119
+ * - `gcp-firebase`: the broker answers on the MCP-Auth-Proxy's own Cloud Run
1120
+ * URL — the URL the gateway had before #1328 (E13 a) — never through
1121
+ * Firebase Hosting (rewrites cut requests at 60 s and buffer streams), the
749
1122
  * admin on its Cloud Run URL behind native IAP — whatever custom domain
750
1123
  * is mapped to it, IAP and its audience are the service's; the audience
751
1124
  * is `/projects/<number>/locations/<region>/services/<name>`.
@@ -763,7 +1136,7 @@ export function remoteMcpIdentity(i: RemoteMcpIdentityInputs): RemoteMcpIdentity
763
1136
  }
764
1137
  return {
765
1138
  adminUrl: origin(i.adminServiceUrl),
766
- mcpPublicUrl: origin(i.gatewayServiceUrl),
1139
+ mcpPublicUrl: origin(i.mcpAuthProxyServiceUrl),
767
1140
  iapAudience: `/projects/${i.projectNumber}/locations/${i.region}/services/${i.adminServiceName}`,
768
1141
  };
769
1142
  }
@@ -785,7 +1158,10 @@ export function remoteMcpIdentityEnv(
785
1158
  ? [{ name: REMOTE_MCP_ENV.googleOrgDomain, value: id.googleOrgDomain }]
786
1159
  : []),
787
1160
  ],
788
- gateway: [
1161
+ // #1328 — the broker lives in the MCP-Auth-Proxy; the visitor gateway
1162
+ // gets none of it.
1163
+ gateway: [],
1164
+ "mcp-auth-proxy": [
789
1165
  { name: REMOTE_MCP_ENV.adminUrl, value: id.adminUrl },
790
1166
  { name: REMOTE_MCP_ENV.mcpPublicUrl, value: id.mcpPublicUrl },
791
1167
  ],
@@ -852,7 +1228,7 @@ export function remoteMcpIdentityStackEnv(
852
1228
  raw: string | undefined,
853
1229
  ): Record<CloudRunSlug, CloudRunEnvVar[]> {
854
1230
  const id = parseRemoteMcpIdentityConfig(raw);
855
- return id ? remoteMcpIdentityEnv(id) : { admin: [], gateway: [] };
1231
+ return id ? remoteMcpIdentityEnv(id) : { admin: [], gateway: [], "mcp-auth-proxy": [] };
856
1232
  }
857
1233
 
858
1234
  /** Whether two identities would give the services the same env. */
@@ -902,6 +1278,9 @@ export function adminEnvContract<V>(inputs: AdminEnvInputs<V>): CloudRunEnvVar<V
902
1278
  // admin_role on cms_public: cross-DB reads, plugin DDL, migrations.
903
1279
  // Not public_role (write-limited).
904
1280
  { name: "PUBLIC_ADMIN_DATABASE_URL", value: inputs.databaseUrls.publicAdmin },
1281
+ // #1329 — admin_role on cms_public_staging: the staging database's
1282
+ // plugin schemas and its half of the projection.
1283
+ { name: "PUBLIC_STAGING_ADMIN_DATABASE_URL", value: inputs.databaseUrls.publicStagingAdmin },
905
1284
  // #551 — the admin seeds site_defaults.site_base_url from it (canonical,
906
1285
  // og:url, sitemap) when it is not configured yet.
907
1286
  { name: "CAELO_SITE_URL", value: publicSiteUrl(domain) },
@@ -933,6 +1312,12 @@ export function adminEnvContract<V>(inputs: AdminEnvInputs<V>): CloudRunEnvVar<V
933
1312
  { name: CAPTURE_JOB_ENV_VAR, value: captureJobResource(projectId, inputs.region, env) },
934
1313
  { name: CAPTURE_BUCKET_ENV_VAR, value: gcpBucketName(projectId, env, "capture") },
935
1314
  { name: MEDIA_BUCKET_ENV_VAR, value: gcpBucketName(projectId, env, "media") },
1315
+ // #1140 — the only IAP identity the admin's scheduler tick endpoint
1316
+ // accepts (scheduler-setup.ts).
1317
+ {
1318
+ name: SCHEDULER_SERVICE_ACCOUNT_ENV_VAR,
1319
+ value: schedulerServiceAccountEmail(projectId, env),
1320
+ },
936
1321
  // #914 — where Settings → Google sign-in writes the install's Google
937
1322
  // sign-in client (add-version only, see the run SA's secretVersionAdder
938
1323
  // invariant).
@@ -991,6 +1376,8 @@ export type IamPrincipal =
991
1376
  | "run-sa"
992
1377
  /** The runtime SA the gateway runs as. */
993
1378
  | "gateway-sa"
1379
+ /** #1328 — the MCP-Auth-Proxy's SA: two secrets, nothing else. */
1380
+ | "mcp-auth-proxy-sa"
994
1381
  /** The `gcp` stack's static-publisher SA. */
995
1382
  | "static-publisher-sa"
996
1383
  /** IAP's Google-managed service agent (forwards IAP traffic to Cloud Run). */
@@ -1041,21 +1428,28 @@ export interface RuntimeSecretBinding {
1041
1428
  * on exactly the secrets its env reads ({@link SERVICE_SECRET_ENV}).
1042
1429
  */
1043
1430
  export function runtimeSecretBindings(): RuntimeSecretBinding[] {
1044
- return (["admin", "gateway"] as const).flatMap((service) =>
1431
+ return CLOUD_RUN_SLUGS.flatMap((service) =>
1045
1432
  serviceSecrets(service).map((secret) => ({
1046
1433
  service,
1047
1434
  secret,
1048
1435
  // The admin's keep the names they had when one SA served both.
1049
- stackResource: service === "admin" ? `${secret}-binding` : `gateway-${secret}-binding`,
1436
+ stackResource: service === "admin" ? `${secret}-binding` : `${service}-${secret}-binding`,
1050
1437
  })),
1051
1438
  );
1052
1439
  }
1053
1440
 
1441
+ /** The runtime SA each Cloud Run service runs as. */
1442
+ export const SERVICE_PRINCIPAL: Readonly<Record<CloudRunSlug, IamPrincipal>> = {
1443
+ admin: "run-sa",
1444
+ gateway: "gateway-sa",
1445
+ "mcp-auth-proxy": "mcp-auth-proxy-sa",
1446
+ };
1447
+
1054
1448
  function secretAccessors(): IamInvariant[] {
1055
1449
  return runtimeSecretBindings().map((b) => ({
1056
1450
  stackResource: b.stackResource,
1057
1451
  role: SECRET_ACCESSOR,
1058
- member: b.service === "admin" ? "run-sa" : "gateway-sa",
1452
+ member: SERVICE_PRINCIPAL[b.service],
1059
1453
  target: { kind: "secret", name: b.secret },
1060
1454
  onFailure: "abort",
1061
1455
  why: `the ${b.service} reads ${b.secret} from Secret Manager at boot`,
@@ -1185,7 +1579,7 @@ const GCP_FIREBASE_ONLY: readonly IamInvariant[] = [
1185
1579
  why: "the admin publishes the site to Firebase Hosting",
1186
1580
  },
1187
1581
  {
1188
- stackResource: "gateway-public-invoker",
1582
+ stackResource: "data-gateway-public-invoker",
1189
1583
  role: "roles/run.invoker",
1190
1584
  member: "allUsers",
1191
1585
  target: { kind: "run-service", service: "gateway" },
@@ -1193,13 +1587,24 @@ const GCP_FIREBASE_ONLY: readonly IamInvariant[] = [
1193
1587
  why: "Firebase Hosting rewrites /api/** to the public gateway",
1194
1588
  },
1195
1589
  {
1196
- stackResource: "gateway-viewer-for-admin-sa",
1590
+ stackResource: "data-gateway-viewer-for-admin-sa",
1197
1591
  role: "roles/run.viewer",
1198
1592
  member: "run-sa",
1199
1593
  target: { kind: "run-service", service: "gateway" },
1200
1594
  onFailure: "abort",
1201
1595
  why: "Firebase Hosting checks run.services.get on the rewrite target at deploy time (v0.6.5; without it every publish 403s)",
1202
1596
  },
1597
+ {
1598
+ // #1328 — the service that was the gateway (E13 a); MCP clients call
1599
+ // its run.app URL without a Google identity of their own. The stack
1600
+ // keeps the binding's old logical name, like the service's.
1601
+ stackResource: "gateway-public-invoker",
1602
+ role: "roles/run.invoker",
1603
+ member: "allUsers",
1604
+ target: { kind: "run-service", service: "mcp-auth-proxy" },
1605
+ onFailure: "abort",
1606
+ why: "MCP clients sign in and call remote MCP at the MCP-Auth-Proxy's run.app URL",
1607
+ },
1203
1608
  {
1204
1609
  stackResource: "admin-iap-sa-invoker",
1205
1610
  role: "roles/run.invoker",
@@ -1240,6 +1645,26 @@ export const RETIRED_IAM_BINDINGS: readonly RetiredIamBinding[] = [
1240
1645
  target: { kind: "secret", name: "gateway-role-password" },
1241
1646
  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
1647
  },
1648
+ {
1649
+ role: SECRET_ACCESSOR,
1650
+ member: "gateway-sa",
1651
+ target: { kind: "secret", name: "mcp-oauth-seal-key" },
1652
+ 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",
1653
+ },
1654
+ {
1655
+ role: SECRET_ACCESSOR,
1656
+ member: "gateway-sa",
1657
+ target: { kind: "secret", name: "operator-oauth-client" },
1658
+ 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
+ },
1660
+ {
1661
+ // gcp-firebase: the Firebase rewrite targets the data gateway since
1662
+ // #1328, so the publisher no longer needs to read the proxy.
1663
+ role: "roles/run.viewer",
1664
+ member: "run-sa",
1665
+ target: { kind: "run-service", service: "mcp-auth-proxy" },
1666
+ why: "the Firebase /api/** rewrite targets the data gateway since #1328; the admin needs no read access to the MCP-Auth-Proxy",
1667
+ },
1243
1668
  ];
1244
1669
 
1245
1670
  /** Every IAM binding upgrade ensures on an install of `provider`. */
@@ -1286,6 +1711,8 @@ export function iamMember(
1286
1711
  return `serviceAccount:${runServiceAccountEmail(install.projectId, install.env)}`;
1287
1712
  case "gateway-sa":
1288
1713
  return `serviceAccount:${gatewayServiceAccountEmail(install.projectId, install.env)}`;
1714
+ case "mcp-auth-proxy-sa":
1715
+ return `serviceAccount:${mcpAuthProxyServiceAccountEmail(install.projectId, install.env)}`;
1289
1716
  case "static-publisher-sa":
1290
1717
  return `serviceAccount:${staticPublisherServiceAccountId(install.env)}@${install.projectId}.iam.gserviceaccount.com`;
1291
1718
  case "iap-service-agent":