@caelo-cms/provisioning 0.10.55 → 0.10.58

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 (221) hide show
  1. package/dist/agent-db.js +97 -24
  2. package/dist/cli-routing.js +1 -1
  3. package/dist/cli-routing.js.map +1 -1
  4. package/dist/cli.js +4 -1
  5. package/dist/cli.js.map +1 -1
  6. package/dist/compose.js +5 -0
  7. package/dist/compose.js.map +1 -1
  8. package/dist/converge-checkpoint.d.ts +66 -0
  9. package/dist/converge-checkpoint.d.ts.map +1 -0
  10. package/dist/converge-checkpoint.js +61 -0
  11. package/dist/converge-checkpoint.js.map +1 -0
  12. package/dist/converge-job-gcp.d.ts +46 -0
  13. package/dist/converge-job-gcp.d.ts.map +1 -0
  14. package/dist/converge-job-gcp.js +368 -0
  15. package/dist/converge-job-gcp.js.map +1 -0
  16. package/dist/converge-job-setup.d.ts +62 -0
  17. package/dist/converge-job-setup.d.ts.map +1 -0
  18. package/dist/converge-job-setup.js +228 -0
  19. package/dist/converge-job-setup.js.map +1 -0
  20. package/dist/converge-job.d.ts +141 -0
  21. package/dist/converge-job.d.ts.map +1 -0
  22. package/dist/converge-job.js +325 -0
  23. package/dist/converge-job.js.map +1 -0
  24. package/dist/gcp-names.d.ts +9 -0
  25. package/dist/gcp-names.d.ts.map +1 -1
  26. package/dist/gcp-names.js +13 -0
  27. package/dist/gcp-names.js.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/infra-contract.d.ts +1 -1
  33. package/dist/infra-contract.d.ts.map +1 -1
  34. package/dist/infra-contract.js +33 -1
  35. package/dist/infra-contract.js.map +1 -1
  36. package/dist/install-job.d.ts +11 -3
  37. package/dist/install-job.d.ts.map +1 -1
  38. package/dist/install-job.js +22 -12
  39. package/dist/install-job.js.map +1 -1
  40. package/dist/lifecycle.d.ts +13 -1
  41. package/dist/lifecycle.d.ts.map +1 -1
  42. package/dist/lifecycle.js +115 -58
  43. package/dist/lifecycle.js.map +1 -1
  44. package/dist/operator-access.d.ts +4 -0
  45. package/dist/operator-access.d.ts.map +1 -1
  46. package/dist/operator-access.js +43 -7
  47. package/dist/operator-access.js.map +1 -1
  48. package/dist/release-contract.d.ts +3 -2
  49. package/dist/release-contract.d.ts.map +1 -1
  50. package/dist/release-contract.js +5 -2
  51. package/dist/release-contract.js.map +1 -1
  52. package/dist/release-manifest.d.ts +2 -0
  53. package/dist/release-manifest.d.ts.map +1 -1
  54. package/dist/release-manifest.js.map +1 -1
  55. package/dist/remote-mcp-edge.d.ts +114 -0
  56. package/dist/remote-mcp-edge.d.ts.map +1 -0
  57. package/dist/remote-mcp-edge.js +116 -0
  58. package/dist/remote-mcp-edge.js.map +1 -0
  59. package/dist/remote-mcp.d.ts +139 -0
  60. package/dist/remote-mcp.d.ts.map +1 -0
  61. package/dist/remote-mcp.js +434 -0
  62. package/dist/remote-mcp.js.map +1 -0
  63. package/dist/runtime-secrets.d.ts +12 -0
  64. package/dist/runtime-secrets.d.ts.map +1 -1
  65. package/dist/runtime-secrets.js +32 -4
  66. package/dist/runtime-secrets.js.map +1 -1
  67. package/dist/seal-key-rotation.d.ts +38 -0
  68. package/dist/seal-key-rotation.d.ts.map +1 -0
  69. package/dist/seal-key-rotation.js +206 -0
  70. package/dist/seal-key-rotation.js.map +1 -0
  71. package/dist/stack-contract.d.ts +206 -3
  72. package/dist/stack-contract.d.ts.map +1 -1
  73. package/dist/stack-contract.js +219 -4
  74. package/dist/stack-contract.js.map +1 -1
  75. package/dist/stack-converge.d.ts +53 -1
  76. package/dist/stack-converge.d.ts.map +1 -1
  77. package/dist/stack-converge.js +124 -4
  78. package/dist/stack-converge.js.map +1 -1
  79. package/dist/state-lock-lease.d.ts +142 -0
  80. package/dist/state-lock-lease.d.ts.map +1 -0
  81. package/dist/state-lock-lease.js +347 -0
  82. package/dist/state-lock-lease.js.map +1 -0
  83. package/dist/state-store.d.ts +66 -9
  84. package/dist/state-store.d.ts.map +1 -1
  85. package/dist/state-store.js +249 -30
  86. package/dist/state-store.js.map +1 -1
  87. package/dist/upgrade/app-roll.d.ts +36 -7
  88. package/dist/upgrade/app-roll.d.ts.map +1 -1
  89. package/dist/upgrade/app-roll.js +53 -19
  90. package/dist/upgrade/app-roll.js.map +1 -1
  91. package/dist/upgrade/audit-job-roll.d.ts.map +1 -1
  92. package/dist/upgrade/audit-job-roll.js +3 -1
  93. package/dist/upgrade/audit-job-roll.js.map +1 -1
  94. package/dist/upgrade/converge.d.ts.map +1 -1
  95. package/dist/upgrade/converge.js +125 -12
  96. package/dist/upgrade/converge.js.map +1 -1
  97. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  98. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  99. package/dist/upgrade/gcp-lookup.js +46 -7
  100. package/dist/upgrade/gcp-lookup.js.map +1 -1
  101. package/dist/upgrade/record-rolled.d.ts +40 -0
  102. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  103. package/dist/upgrade/record-rolled.js +46 -0
  104. package/dist/upgrade/record-rolled.js.map +1 -0
  105. package/dist/upgrade/upgrade.d.ts +45 -1
  106. package/dist/upgrade/upgrade.d.ts.map +1 -1
  107. package/dist/upgrade/upgrade.js +60 -17
  108. package/dist/upgrade/upgrade.js.map +1 -1
  109. package/dist/upgrader-assess.d.ts +15 -1
  110. package/dist/upgrader-assess.d.ts.map +1 -1
  111. package/dist/upgrader-assess.js +36 -6
  112. package/dist/upgrader-assess.js.map +1 -1
  113. package/dist/upgrader-gcp.d.ts +15 -0
  114. package/dist/upgrader-gcp.d.ts.map +1 -1
  115. package/dist/upgrader-gcp.js +42 -7
  116. package/dist/upgrader-gcp.js.map +1 -1
  117. package/dist/upgrader-iam-preflight.d.ts +123 -0
  118. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  119. package/dist/upgrader-iam-preflight.js +414 -0
  120. package/dist/upgrader-iam-preflight.js.map +1 -0
  121. package/dist/upgrader-job.d.ts +33 -9
  122. package/dist/upgrader-job.d.ts.map +1 -1
  123. package/dist/upgrader-job.js +169 -27
  124. package/dist/upgrader-job.js.map +1 -1
  125. package/dist/upgrader-records.d.ts +58 -0
  126. package/dist/upgrader-records.d.ts.map +1 -0
  127. package/dist/upgrader-records.js +87 -0
  128. package/dist/upgrader-records.js.map +1 -0
  129. package/dist/upgrader-reporter.d.ts +3 -2
  130. package/dist/upgrader-reporter.d.ts.map +1 -1
  131. package/dist/upgrader-reporter.js +10 -2
  132. package/dist/upgrader-reporter.js.map +1 -1
  133. package/dist/upgrader-setup.d.ts +25 -3
  134. package/dist/upgrader-setup.d.ts.map +1 -1
  135. package/dist/upgrader-setup.js +123 -11
  136. package/dist/upgrader-setup.js.map +1 -1
  137. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  138. package/dist/wizards/gcp-cost.js +11 -3
  139. package/dist/wizards/gcp-cost.js.map +1 -1
  140. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  141. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  142. package/dist/wizards/gcp-pulumi.js +14 -2
  143. package/dist/wizards/gcp-pulumi.js.map +1 -1
  144. package/dist/wizards/gcp.d.ts.map +1 -1
  145. package/dist/wizards/gcp.js +106 -4
  146. package/dist/wizards/gcp.js.map +1 -1
  147. package/package.json +2 -2
  148. package/src/agent/run.test.ts +14 -1
  149. package/src/audit-job-setup.test.ts +7 -1
  150. package/src/cli-routing.ts +1 -1
  151. package/src/cli.ts +4 -1
  152. package/src/compose.ts +5 -0
  153. package/src/converge-checkpoint.ts +75 -0
  154. package/src/converge-entrypoint.test.ts +213 -0
  155. package/src/converge-job-gcp.test.ts +222 -0
  156. package/src/converge-job-gcp.ts +443 -0
  157. package/src/converge-job-setup.test.ts +152 -0
  158. package/src/converge-job-setup.ts +268 -0
  159. package/src/converge-job.test.ts +486 -0
  160. package/src/converge-job.ts +542 -0
  161. package/src/gateway-env-isolation.test.ts +8 -1
  162. package/src/gcp-names.ts +15 -0
  163. package/src/gcp-url-map.test.ts +9 -1
  164. package/src/index.ts +8 -0
  165. package/src/infra-contract.test.ts +8 -4
  166. package/src/infra-contract.ts +33 -1
  167. package/src/install-job.test.ts +3 -1
  168. package/src/install-job.ts +31 -14
  169. package/src/lifecycle.ts +137 -71
  170. package/src/operator-access.test.ts +24 -1
  171. package/src/operator-access.ts +44 -6
  172. package/src/release-contract.ts +5 -2
  173. package/src/release-manifest.test.ts +42 -0
  174. package/src/release-manifest.ts +2 -0
  175. package/src/remote-mcp-edge.ts +137 -0
  176. package/src/remote-mcp.test.ts +690 -0
  177. package/src/remote-mcp.ts +524 -0
  178. package/src/runtime-secrets.test.ts +31 -6
  179. package/src/runtime-secrets.ts +41 -3
  180. package/src/seal-key-rotation.test.ts +131 -0
  181. package/src/seal-key-rotation.ts +258 -0
  182. package/src/stack-contract-parity.test.ts +20 -3
  183. package/src/stack-contract.test.ts +32 -2
  184. package/src/stack-contract.ts +363 -4
  185. package/src/stack-converge-secrets.test.ts +4 -0
  186. package/src/stack-converge.test.ts +21 -5
  187. package/src/stack-converge.ts +162 -7
  188. package/src/state-attach.test.ts +7 -1
  189. package/src/state-lock-lease.test.ts +302 -0
  190. package/src/state-lock-lease.ts +423 -0
  191. package/src/state-lock.test.ts +568 -0
  192. package/src/state-store.ts +347 -27
  193. package/src/upgrade/app-roll.ts +102 -21
  194. package/src/upgrade/audit-job-roll.ts +3 -1
  195. package/src/upgrade/converge.ts +148 -10
  196. package/src/upgrade/gcp-lookup.test.ts +80 -0
  197. package/src/upgrade/gcp-lookup.ts +65 -8
  198. package/src/upgrade/record-rolled.test.ts +105 -0
  199. package/src/upgrade/record-rolled.ts +91 -0
  200. package/src/upgrade/upgrade.test.ts +203 -3
  201. package/src/upgrade/upgrade.ts +120 -24
  202. package/src/upgrader-assess.test.ts +92 -3
  203. package/src/upgrader-assess.ts +58 -9
  204. package/src/upgrader-gcp.ts +47 -8
  205. package/src/upgrader-iam-preflight.test.ts +393 -0
  206. package/src/upgrader-iam-preflight.ts +510 -0
  207. package/src/upgrader-job.test.ts +586 -15
  208. package/src/upgrader-job.ts +237 -53
  209. package/src/upgrader-records.ts +151 -0
  210. package/src/upgrader-reporter.test.ts +6 -0
  211. package/src/upgrader-reporter.ts +7 -2
  212. package/src/upgrader-setup.test.ts +3 -3
  213. package/src/upgrader-setup.ts +128 -11
  214. package/src/wizards/gcp-cost.ts +12 -3
  215. package/src/wizards/gcp-pulumi.test.ts +15 -0
  216. package/src/wizards/gcp-pulumi.ts +18 -2
  217. package/src/wizards/gcp.ts +134 -4
  218. package/stacks/aws/index.ts +10 -0
  219. package/stacks/azure/index.ts +9 -0
  220. package/stacks/gcp/index.ts +47 -7
  221. package/stacks/gcp-firebase/index.ts +38 -6
@@ -37,6 +37,7 @@
37
37
  import type { SupportedProvider } from "./adapter.js";
38
38
  import {
39
39
  auditJobResource,
40
+ convergeJobResource,
40
41
  gatewayServiceAccountEmail,
41
42
  gcpBucketName,
42
43
  gcpNamePrefix,
@@ -170,6 +171,13 @@ export const OPERATOR_ACCESS_JOB_ENV_VAR = "CAELO_OPERATOR_ACCESS_JOB";
170
171
  */
171
172
  export const UPGRADER_JOB_ENV_VAR = "CAELO_UPGRADER_JOB";
172
173
 
174
+ /**
175
+ * The admin env var naming the converge job (#819) — what "Install update"
176
+ * starts for a release that changes the cloud setup. `upgrade` leaves it
177
+ * untouched when it could not set the job up.
178
+ */
179
+ export const CONVERGE_JOB_ENV_VAR = "CAELO_CONVERGE_JOB";
180
+
173
181
  /**
174
182
  * #875 — the admin env vars naming the parallel quality-audit job and the
175
183
  * bucket its tasks write results into (admin-core quality/
@@ -251,7 +259,22 @@ export type RuntimeSecret =
251
259
  | "secret-kek"
252
260
  | "internal-secret"
253
261
  | "tool-approval-secret"
254
- | "staging-gate-secret";
262
+ | "staging-gate-secret"
263
+ /**
264
+ * #914 — the install's Google sign-in client: JSON
265
+ * `{"client_id","client_secret"}`, `{}` while it is not set up. One client
266
+ * for IAP browser login, IAP programmatic access and the remote-MCP broker
267
+ * (#806 introduced it as `mcp-oauth-client`). The Owner writes it from
268
+ * Settings → Google sign-in (the admin may add versions, never read them);
269
+ * the gateway reads it from a file, the operator-access sync job reads it
270
+ * to hand it to IAP.
271
+ */
272
+ | "operator-oauth-client"
273
+ /**
274
+ * #806 — the broker's AES-256-GCM key (base64, 32 bytes) that seals its
275
+ * stateless OAuth state, codes and refresh tokens. Gateway only.
276
+ */
277
+ | "mcp-oauth-seal-key";
255
278
 
256
279
  /** The Postgres roles Caelo's services log in as, with their passwords. */
257
280
  export type DatabaseRole = "admin_role" | "public_role";
@@ -285,8 +308,55 @@ export const CLI_GENERATED_SECRETS = [
285
308
  // session cookies, derives the crawler credentials. Read by the admin
286
309
  // (issues) and the gateway (verifies).
287
310
  "staging-gate-secret",
311
+ // #914 / #806 — the Google sign-in client starts as the `{}` placeholder
312
+ // (not configured; the Owner fills it under Settings → Google sign-in),
313
+ // the broker's seal key as 32 random bytes in base64
314
+ // ({@link GENERATED_SECRET_FORMAT}).
315
+ "operator-oauth-client",
316
+ "mcp-oauth-seal-key",
288
317
  ] as const satisfies readonly RuntimeSecret[];
289
318
 
319
+ /** A CLI-generated secret's first value: random hex, random base64, or a fixed placeholder. */
320
+ export type GeneratedSecretFormat =
321
+ | { readonly kind: "hex32" }
322
+ | { readonly kind: "base64-32" }
323
+ | { readonly kind: "placeholder"; readonly value: string };
324
+
325
+ /**
326
+ * How each CLI-generated secret's first version is made. Every secret not
327
+ * listed is 32 random bytes as hex — the shape the stacks always generated.
328
+ */
329
+ export const GENERATED_SECRET_FORMAT: Readonly<
330
+ Record<(typeof CLI_GENERATED_SECRETS)[number], GeneratedSecretFormat>
331
+ > = {
332
+ "internal-secret": { kind: "hex32" },
333
+ "tool-approval-secret": { kind: "hex32" },
334
+ "public-role-password": { kind: "hex32" },
335
+ "staging-gate-secret": { kind: "hex32" },
336
+ // The gateway's seal key contract (#806): base64 of exactly 32 bytes.
337
+ "mcp-oauth-seal-key": { kind: "base64-32" },
338
+ // "Not configured" until the Owner pastes a client (#914). A version must
339
+ // exist from the start: Cloud Run refuses a revision whose secret volume
340
+ // points at a secret without an enabled version.
341
+ "operator-oauth-client": { kind: "placeholder", value: "{}" },
342
+ };
343
+
344
+ /**
345
+ * Secrets an older contract created that nothing reads any more. Upgrade
346
+ * deletes each once both services rolled onto revisions that no longer
347
+ * reference it (stack-converge.ts `deleteRetiredSecrets`); deleting a
348
+ * secret drops its IAM bindings with it. Pre-1.0 delete-don't-migrate: a
349
+ * renamed secret is created fresh, never copied.
350
+ */
351
+ export const RETIRED_RUNTIME_SECRETS: readonly { readonly name: string; readonly why: string }[] = [
352
+ {
353
+ // #914 — renamed to `operator-oauth-client`; nobody had configured
354
+ // remote MCP when the rename shipped, so there is nothing to carry over.
355
+ name: "mcp-oauth-client",
356
+ why: "the Google sign-in client moved to operator-oauth-client (#914)",
357
+ },
358
+ ];
359
+
290
360
  /**
291
361
  * Each service's Secret Manager-backed env vars and the secret each reads.
292
362
  * The services' run SAs get `secretAccessor` on exactly these secrets
@@ -316,12 +386,119 @@ export const SERVICE_SECRET_ENV: Readonly<
316
386
  PUBLIC_DATABASE_PASSWORD: "public-role-password",
317
387
  // #650 — verifies staging-host requests (api-gateway staging-proxy.ts).
318
388
  CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
389
+ // #806 — seals the remote-MCP broker's state, codes and refresh tokens.
390
+ CAELO_MCP_OAUTH_SEAL_KEY: "mcp-oauth-seal-key",
319
391
  },
320
392
  };
321
393
 
322
- /** The secrets `service` reads (deduplicated, table order). */
394
+ // ===========================================================================
395
+ // Secret files (#806)
396
+ // ===========================================================================
397
+
398
+ /** Env var naming the file that holds the install's Google sign-in client (#914). */
399
+ export const OPERATOR_OAUTH_CLIENT_FILE_ENV = "CAELO_OPERATOR_OAUTH_CLIENT_FILE";
400
+
401
+ /**
402
+ * A Secret Manager secret mounted into a service as one file, at `latest`.
403
+ *
404
+ * Why a file and not an env var: Cloud Run resolves a secret env var once,
405
+ * at instance start, but reads a secret volume from Secret Manager each
406
+ * time the file is read ("When reading a volume, Cloud Run always fetches
407
+ * the secret value from the Secret Manager to use the value with the latest
408
+ * version" — cloud.google.com/run/docs/configuring/services/secrets). So the
409
+ * client the Owner saves under Settings → Google sign-in reaches the running gateway
410
+ * (which re-reads it within 60 s) without a new revision, and the admin
411
+ * needs no right to deploy the gateway.
412
+ */
413
+ export interface SecretFileContract {
414
+ readonly secret: RuntimeSecret;
415
+ /** Volume name the stacks give it (gcloud names the ones upgrade adds itself). */
416
+ readonly volumeName: string;
417
+ /** Directory the volume is mounted at; must not exist in the image. */
418
+ readonly mountDir: string;
419
+ readonly fileName: string;
420
+ /** Always `latest`: a pinned version would never see the Owner's new client. */
421
+ readonly version: "latest";
422
+ }
423
+
424
+ /** The gateway's Google sign-in client file, read by the remote-MCP broker (#806, #914). */
425
+ export const OPERATOR_OAUTH_CLIENT_FILE: SecretFileContract = {
426
+ secret: "operator-oauth-client",
427
+ volumeName: "operator-oauth-client",
428
+ mountDir: "/secrets/operator-oauth-client",
429
+ fileName: "client.json",
430
+ version: "latest",
431
+ };
432
+
433
+ /** Full path of a secret file inside the container. */
434
+ export function secretFilePath(file: SecretFileContract): string {
435
+ return `${file.mountDir}/${file.fileName}`;
436
+ }
437
+
438
+ /** Each service's secret files. The admin has none. */
439
+ export const SERVICE_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly SecretFileContract[]>> = {
440
+ admin: [],
441
+ gateway: [OPERATOR_OAUTH_CLIENT_FILE],
442
+ };
443
+
444
+ /** A secret file an older contract mounted; upgrade unmounts it in the roll. */
445
+ export interface RetiredSecretFile {
446
+ readonly mountDir: string;
447
+ readonly fileName: string;
448
+ readonly why: string;
449
+ }
450
+
451
+ /**
452
+ * Secret files older contracts mounted that nothing reads any more, per
453
+ * service. The roll removes each mount (`--remove-secrets=<path>`) in the
454
+ * same revision that mounts its replacement.
455
+ */
456
+ export const RETIRED_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly RetiredSecretFile[]>> = {
457
+ admin: [],
458
+ gateway: [
459
+ {
460
+ mountDir: "/secrets/mcp-oauth-client",
461
+ fileName: "client.json",
462
+ why: "the Google sign-in client is mounted from operator-oauth-client now (#914)",
463
+ },
464
+ ],
465
+ };
466
+
467
+ /**
468
+ * The secret files of `service` in the shape `gcp.cloudrunv2.Service`
469
+ * takes: `volumes` on the template, `volumeMounts` on the container.
470
+ */
471
+ export function secretFileVolumesTemplate(
472
+ service: CloudRunSlug,
473
+ env: string,
474
+ ): {
475
+ readonly volumes: {
476
+ name: string;
477
+ secret: { secret: string; items: { version: string; path: string }[] };
478
+ }[];
479
+ readonly volumeMounts: { name: string; mountPath: string }[];
480
+ } {
481
+ const files = SERVICE_SECRET_FILES[service];
482
+ return {
483
+ volumes: files.map((f) => ({
484
+ name: f.volumeName,
485
+ secret: {
486
+ secret: gcpSecretId(env, f.secret),
487
+ items: [{ version: f.version, path: f.fileName }],
488
+ },
489
+ })),
490
+ volumeMounts: files.map((f) => ({ name: f.volumeName, mountPath: f.mountDir })),
491
+ };
492
+ }
493
+
494
+ /** The secrets `service` reads, as env or as files (deduplicated, table order). */
323
495
  export function serviceSecrets(service: CloudRunSlug): RuntimeSecret[] {
324
- return [...new Set(Object.values(SERVICE_SECRET_ENV[service]))];
496
+ return [
497
+ ...new Set([
498
+ ...Object.values(SERVICE_SECRET_ENV[service]),
499
+ ...SERVICE_SECRET_FILES[service].map((f) => f.secret),
500
+ ]),
501
+ ];
325
502
  }
326
503
 
327
504
  /**
@@ -332,8 +509,11 @@ export function serviceSecrets(service: CloudRunSlug): RuntimeSecret[] {
332
509
  * credential is in its env (apps/api-gateway `adminDatabaseAccessInEnv`).
333
510
  */
334
511
  export const RETIRED_SERVICE_ENV: Readonly<Record<CloudRunSlug, readonly string[]>> = {
335
- admin: [],
512
+ // #914 — the sign-in client's store was renamed with its secret.
513
+ admin: ["CAELO_MCP_OAUTH_CLIENT_STORE"],
336
514
  gateway: [
515
+ // #914 — renamed to CAELO_OPERATOR_OAUTH_CLIENT_FILE with its secret.
516
+ "CAELO_MCP_OAUTH_CLIENT_FILE",
337
517
  "CAELO_SECRET_KEK",
338
518
  "ADMIN_DATABASE_URL",
339
519
  "ADMIN_DATABASE_PASSWORD",
@@ -411,10 +591,159 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
411
591
  ...commonEnv(inputs),
412
592
  { name: TRUSTED_PROXY_HOPS_ENV, value: String(GATEWAY_TRUSTED_PROXY_HOPS[inputs.provider]) },
413
593
  { name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
594
+ // #806 — where the remote-MCP broker reads its Google OAuth client
595
+ // (the secret volume of SERVICE_SECRET_FILES).
596
+ { name: OPERATOR_OAUTH_CLIENT_FILE_ENV, value: secretFilePath(OPERATOR_OAUTH_CLIENT_FILE) },
414
597
  ...secretEnv("gateway", inputs.env),
415
598
  ];
416
599
  }
417
600
 
601
+ /**
602
+ * #806 — the gateway's request timeout, in seconds: Cloud Run's maximum.
603
+ * The gateway proxies remote-MCP requests to the admin, and a `caelo_chat`
604
+ * turn streams for as long as the admin's own chat stream may (the admin
605
+ * runs at 3600 s for the same reason). Visitor endpoints are unaffected: a
606
+ * timeout is a ceiling, not a reservation, and Cloud Run bills request time,
607
+ * not the allowed maximum. On `gcp` the load balancer in front adds no
608
+ * shorter limit — the backend-service timeout does not apply to serverless
609
+ * NEGs, which get a fixed 60 minutes
610
+ * (cloud.google.com/load-balancing/docs/negs/serverless-neg-concepts).
611
+ */
612
+ export const GATEWAY_TIMEOUT_SECONDS = 3600;
613
+
614
+ /** Admin env var naming the Secret Manager secret the Owner writes the Google sign-in client to. */
615
+ export const OPERATOR_OAUTH_CLIENT_STORE_ENV = "CAELO_OPERATOR_OAUTH_CLIENT_STORE";
616
+
617
+ /** `projects/<project>/secrets/<id>` of the Google sign-in client secret (#914). */
618
+ export function operatorOAuthClientSecretResource(projectId: string, env: string): string {
619
+ return `projects/${projectId}/secrets/${gcpSecretId(env, "operator-oauth-client")}`;
620
+ }
621
+
622
+ // ===========================================================================
623
+ // Remote MCP identity env (#806)
624
+ // ===========================================================================
625
+
626
+ /**
627
+ * The remote-MCP env vars that name generated identities of the install:
628
+ * the admin's URL, the gateway's public origin, and the audience of the IAP
629
+ * JWTs the admin verifies.
630
+ *
631
+ * They are not part of {@link adminEnvContract} / {@link gatewayEnvContract}
632
+ * because the stacks cannot declare them: on `gcp-firebase` each service's
633
+ * value names a Cloud Run service Pulumi auto-names (the admin's audience is
634
+ * its own name; the gateway's public URL is its own URL; the admin's URL is
635
+ * a service created after the gateway), and on `gcp` the audience is the
636
+ * numeric id of a backend service created after the admin. A resource
637
+ * cannot take its own (or a later resource's) output as input. So the CLI
638
+ * reads the live identities after deploy and applies these vars with gcloud
639
+ * — the wizard after every `pulumi up`, `upgrade` after every roll
640
+ * (remote-mcp.ts `ensureRemoteMcpIdentityEnv`). A roll that does not set
641
+ * them leaves them as they are (planEnvUpdate never removes a var the
642
+ * contract does not own).
643
+ *
644
+ * #914 adds two the admin's Google sign-in needs: its own URL (the
645
+ * self-check opens it signed out) and the project's Google organisation
646
+ * domain, which the admin's service account cannot read (organisation
647
+ * metadata needs a grant on the organisation) but the operator's identity
648
+ * usually can.
649
+ */
650
+ export interface RemoteMcpIdentity {
651
+ /** Where the gateway proxies `/api/mcp/<surface>` to (IAP in front). */
652
+ readonly adminUrl: string;
653
+ /** The broker's public origin; no trailing slash. */
654
+ readonly mcpPublicUrl: string;
655
+ /** The `aud` of the admin's IAP JWTs. */
656
+ readonly iapAudience: string;
657
+ /**
658
+ * #914 — the project's Google organisation domain, `none` when the project
659
+ * is in no organisation, undefined when it could not be read (the var is
660
+ * then left as it is and the admin reports the organisation as unknown).
661
+ */
662
+ readonly googleOrgDomain?: string;
663
+ }
664
+
665
+ /** Env var names of {@link RemoteMcpIdentity}. */
666
+ export const REMOTE_MCP_ENV = {
667
+ adminUrl: "CAELO_ADMIN_URL",
668
+ mcpPublicUrl: "CAELO_MCP_PUBLIC_URL",
669
+ iapAudience: "CAELO_IAP_AUDIENCE",
670
+ googleOrgDomain: "CAELO_GOOGLE_ORG_DOMAIN",
671
+ } as const;
672
+
673
+ /** What {@link remoteMcpIdentity} derives the identity from — all read from the live install. */
674
+ export type RemoteMcpIdentityInputs =
675
+ | {
676
+ readonly provider: "gcp";
677
+ readonly domain: string;
678
+ readonly projectNumber: string;
679
+ /** Numeric `id` of the admin's IAP-enabled backend service. */
680
+ readonly adminBackendServiceId: string;
681
+ }
682
+ | {
683
+ readonly provider: "gcp-firebase";
684
+ readonly projectNumber: string;
685
+ readonly region: string;
686
+ readonly adminServiceName: string;
687
+ /** The services' `status.url` (`https://<name>-<hash>-<r>.a.run.app`). */
688
+ readonly adminServiceUrl: string;
689
+ readonly gatewayServiceUrl: string;
690
+ };
691
+
692
+ /**
693
+ * The install's remote-MCP identity.
694
+ *
695
+ * - `gcp`: the broker answers on the site's own host through the load
696
+ * balancer (`/api/*` and the `/.well-known/oauth-*` path rules), the admin
697
+ * on `admin.<domain>` behind the LB's IAP; the IAP audience is
698
+ * `/projects/<number>/global/backendServices/<id>`.
699
+ * - `gcp-firebase`: the broker answers on the gateway's own Cloud Run URL
700
+ * (Firebase Hosting rewrites cut requests at 60 s and buffer streams), the
701
+ * admin on its Cloud Run URL behind native IAP — whatever custom domain
702
+ * is mapped to it, IAP and its audience are the service's; the audience
703
+ * is `/projects/<number>/locations/<region>/services/<name>`.
704
+ *
705
+ * Audience formats: cloud.google.com/iap/docs/signed-headers-howto.
706
+ */
707
+ export function remoteMcpIdentity(i: RemoteMcpIdentityInputs): RemoteMcpIdentity {
708
+ const origin = (url: string) => url.replace(/\/+$/, "");
709
+ if (i.provider === "gcp") {
710
+ return {
711
+ adminUrl: `https://admin.${i.domain}`,
712
+ mcpPublicUrl: publicSiteUrl(i.domain),
713
+ iapAudience: `/projects/${i.projectNumber}/global/backendServices/${i.adminBackendServiceId}`,
714
+ };
715
+ }
716
+ return {
717
+ adminUrl: origin(i.adminServiceUrl),
718
+ mcpPublicUrl: origin(i.gatewayServiceUrl),
719
+ iapAudience: `/projects/${i.projectNumber}/locations/${i.region}/services/${i.adminServiceName}`,
720
+ };
721
+ }
722
+
723
+ /** {@link RemoteMcpIdentity} as each service's env vars. */
724
+ export function remoteMcpIdentityEnv(
725
+ id: RemoteMcpIdentity,
726
+ ): Record<CloudRunSlug, CloudRunEnvVar[]> {
727
+ return {
728
+ admin: [
729
+ { name: REMOTE_MCP_ENV.iapAudience, value: id.iapAudience },
730
+ // The setup pages show the redirect URI and the MCP URLs from it.
731
+ { name: REMOTE_MCP_ENV.mcpPublicUrl, value: id.mcpPublicUrl },
732
+ // #914 — Google sign-in's self-check opens the admin signed out.
733
+ { name: REMOTE_MCP_ENV.adminUrl, value: id.adminUrl },
734
+ // #914 — who IAP admits without the Google sign-in client
735
+ // (admin-core security/identity-gate.ts).
736
+ ...(id.googleOrgDomain
737
+ ? [{ name: REMOTE_MCP_ENV.googleOrgDomain, value: id.googleOrgDomain }]
738
+ : []),
739
+ ],
740
+ gateway: [
741
+ { name: REMOTE_MCP_ENV.adminUrl, value: id.adminUrl },
742
+ { name: REMOTE_MCP_ENV.mcpPublicUrl, value: id.mcpPublicUrl },
743
+ ],
744
+ };
745
+ }
746
+
418
747
  /**
419
748
  * The env of the admin service.
420
749
  *
@@ -452,10 +781,20 @@ export function adminEnvContract<V>(inputs: AdminEnvInputs<V>): CloudRunEnvVar<V
452
781
  // #696 — the job "Install update" starts (upgrader-setup.ts); the admin
453
782
  // may only run it, never override it.
454
783
  { name: UPGRADER_JOB_ENV_VAR, value: upgraderJobResource(projectId, inputs.region, env) },
784
+ // #819 — the job "Install update" starts for a release that changes the
785
+ // cloud setup (converge-job-setup.ts); start-only as well.
786
+ { name: CONVERGE_JOB_ENV_VAR, value: convergeJobResource(projectId, inputs.region, env) },
455
787
  // #875 — the job the quality audit fans its Lighthouse runs out to, and
456
788
  // where the tasks write their results (audit-job-setup.ts).
457
789
  { name: AUDIT_JOB_ENV_VAR, value: auditJobResource(projectId, inputs.region, env) },
458
790
  { name: AUDIT_RESULTS_BUCKET_ENV_VAR, value: gcpBucketName(projectId, env, "audit-results") },
791
+ // #914 — where Settings → Google sign-in writes the install's Google
792
+ // sign-in client (add-version only, see the run SA's secretVersionAdder
793
+ // invariant).
794
+ {
795
+ name: OPERATOR_OAUTH_CLIENT_STORE_ENV,
796
+ value: operatorOAuthClientSecretResource(projectId, env),
797
+ },
459
798
  ];
460
799
  if (inputs.provider === "gcp") {
461
800
  // v0.2.78 — the GCS StaticPublisher: Stage uploads to staging,
@@ -542,6 +881,7 @@ export interface IamInvariant {
542
881
  }
543
882
 
544
883
  const SECRET_ACCESSOR = "roles/secretmanager.secretAccessor";
884
+ const SECRET_VERSION_ADDER = "roles/secretmanager.secretVersionAdder";
545
885
 
546
886
  /** One `secretAccessor` binding a stack declares for a service's run SA. */
547
887
  export interface RuntimeSecretBinding {
@@ -577,6 +917,24 @@ function secretAccessors(): IamInvariant[] {
577
917
  }));
578
918
  }
579
919
 
920
+ /** Bindings both GCP stacks declare beyond the secret accessors and telemetry. */
921
+ const GCP_COMMON: readonly IamInvariant[] = [
922
+ {
923
+ stackResource: "admin-operator-oauth-client-version-adder",
924
+ // Why the admin may write a secret at all: the Owner pastes the
925
+ // install's Google sign-in client under Settings → Google sign-in, and
926
+ // the admin stores it where the gateway and the operator-access sync job
927
+ // read it. secretVersionAdder is add-version only (no access, no
928
+ // destroy, no IAM), bound on this one secret only — the admin can
929
+ // replace the client, never read it or any other secret.
930
+ role: SECRET_VERSION_ADDER,
931
+ member: "run-sa",
932
+ target: { kind: "secret", name: "operator-oauth-client" },
933
+ onFailure: "warn",
934
+ why: "the Owner's Google sign-in setup (Settings → Google sign-in) stores the OAuth client; without it only that setup fails",
935
+ },
936
+ ];
937
+
580
938
  /** v0.6.6 — a custom runtime SA gets no telemetry roles implicitly. */
581
939
  const TELEMETRY: readonly IamInvariant[] = [
582
940
  {
@@ -743,6 +1101,7 @@ export const RETIRED_IAM_BINDINGS: readonly RetiredIamBinding[] = [
743
1101
  export function stackIamInvariants(provider: GcpProvider): IamInvariant[] {
744
1102
  return [
745
1103
  ...secretAccessors(),
1104
+ ...GCP_COMMON,
746
1105
  ...(provider === "gcp" ? GCP_ONLY : GCP_FIREBASE_ONLY),
747
1106
  ...TELEMETRY,
748
1107
  ];
@@ -342,6 +342,10 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
342
342
  expect(plan.ok && plan.services.gateway.changes.map((c) => c.name).sort()).toEqual([
343
343
  "ADMIN_DATABASE_PASSWORD",
344
344
  "ADMIN_DATABASE_URL",
345
+ // #806 — the remote-MCP broker's seal key and (#914) the Google
346
+ // sign-in client file path.
347
+ "CAELO_MCP_OAUTH_SEAL_KEY",
348
+ "CAELO_OPERATOR_OAUTH_CLIENT_FILE",
345
349
  // #650 — the staging gate key arrives in the same roll.
346
350
  "CAELO_STAGING_GATE_SECRET",
347
351
  // #845 — and which forwarded address is the visitor.
@@ -312,13 +312,14 @@ describe("planContractEnv", () => {
312
312
  // #650 — the same roll converges the protected staging domain's host
313
313
  // and gate key onto an install that predates it.
314
314
  expect(plan.services.admin.flags).toEqual([
315
- "--update-env-vars=CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_AUDIT_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-audit,CAELO_AUDIT_RESULTS_BUCKET=acme-caelo-production-audit-results,CAELO_STAGING_HOST=staging.acme.com",
315
+ "--update-env-vars=CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_CONVERGE_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-converge,CAELO_AUDIT_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-audit,CAELO_AUDIT_RESULTS_BUCKET=acme-caelo-production-audit-results,CAELO_OPERATOR_OAUTH_CLIENT_STORE=projects/acme/secrets/caelo-production-operator-oauth-client,CAELO_STAGING_HOST=staging.acme.com",
316
316
  "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
317
317
  ]);
318
318
  expect(plan.services.gateway.flags).toEqual([
319
319
  // The gateway learns which forwarded address is the visitor (#845).
320
- "--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1",
321
- "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
320
+ // #806 — and where the remote-MCP broker's client file and seal key are.
321
+ "--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1,CAELO_OPERATOR_OAUTH_CLIENT_FILE=/secrets/operator-oauth-client/client.json",
322
+ "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest,CAELO_MCP_OAUTH_SEAL_KEY=caelo-production-mcp-oauth-seal-key:latest",
322
323
  ]);
323
324
  });
324
325
 
@@ -474,7 +475,11 @@ describe("ensureStackInvariants", () => {
474
475
  ]),
475
476
  ],
476
477
  "secrets get-iam-policy": [
477
- policy([{ role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] }]),
478
+ policy([
479
+ { role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] },
480
+ // #806 — the admin adds remote-MCP OAuth client versions.
481
+ { role: "roles/secretmanager.secretVersionAdder", members: [RUN_SA] },
482
+ ]),
478
483
  ],
479
484
  "storage buckets get-iam-policy": [
480
485
  policy([{ role: "roles/storage.objectAdmin", members: [RUN_SA] }]),
@@ -673,7 +678,10 @@ describe("ensureStackInvariants", () => {
673
678
  ]),
674
679
  ],
675
680
  "secrets get-iam-policy": [
676
- policy([{ role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] }]),
681
+ policy([
682
+ { role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] },
683
+ { role: "roles/secretmanager.secretVersionAdder", members: [RUN_SA] },
684
+ ]),
677
685
  ],
678
686
  "storage buckets get-iam-policy": [
679
687
  policy([
@@ -779,6 +787,14 @@ describe("ensureStackInvariants", () => {
779
787
  expect(gatewayGrants).toEqual([
780
788
  `secrets add-iam-policy-binding caelo-production-public-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
781
789
  `secrets add-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
790
+ // #806 — the remote-MCP broker's seal key and OAuth client file.
791
+ `secrets add-iam-policy-binding caelo-production-mcp-oauth-seal-key --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
792
+ `secrets add-iam-policy-binding caelo-production-operator-oauth-client --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
793
+ ]);
794
+ // #806 — the admin may add versions to the OAuth client secret, and to no other.
795
+ const adders = calls.filter((c) => c.includes("roles/secretmanager.secretVersionAdder"));
796
+ expect(adders).toEqual([
797
+ `secrets add-iam-policy-binding caelo-production-operator-oauth-client --project=acme --member=${RUN_SA} --role=roles/secretmanager.secretVersionAdder --quiet --format=none`,
782
798
  ]);
783
799
  for (const secret of ["internal-secret", "tool-approval-secret", "secret-kek"]) {
784
800
  expect(calls).toContain(