@caelo-cms/provisioning 0.10.57 → 0.10.59

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 (68) hide show
  1. package/dist/agent-db.js +76 -5
  2. package/dist/compose.js +1 -1
  3. package/dist/infra-contract.d.ts +1 -1
  4. package/dist/infra-contract.d.ts.map +1 -1
  5. package/dist/infra-contract.js +12 -1
  6. package/dist/infra-contract.js.map +1 -1
  7. package/dist/operator-access.d.ts +4 -0
  8. package/dist/operator-access.d.ts.map +1 -1
  9. package/dist/operator-access.js +37 -11
  10. package/dist/operator-access.js.map +1 -1
  11. package/dist/remote-mcp.d.ts +22 -0
  12. package/dist/remote-mcp.d.ts.map +1 -1
  13. package/dist/remote-mcp.js +59 -4
  14. package/dist/remote-mcp.js.map +1 -1
  15. package/dist/runtime-secrets.js +2 -2
  16. package/dist/runtime-secrets.js.map +1 -1
  17. package/dist/stack-contract.d.ts +54 -15
  18. package/dist/stack-contract.d.ts.map +1 -1
  19. package/dist/stack-contract.js +80 -33
  20. package/dist/stack-contract.js.map +1 -1
  21. package/dist/stack-converge.d.ts +17 -2
  22. package/dist/stack-converge.d.ts.map +1 -1
  23. package/dist/stack-converge.js +52 -2
  24. package/dist/stack-converge.js.map +1 -1
  25. package/dist/upgrade/app-roll.d.ts.map +1 -1
  26. package/dist/upgrade/app-roll.js +3 -3
  27. package/dist/upgrade/app-roll.js.map +1 -1
  28. package/dist/upgrade/audit-job-roll.d.ts.map +1 -1
  29. package/dist/upgrade/audit-job-roll.js +3 -1
  30. package/dist/upgrade/audit-job-roll.js.map +1 -1
  31. package/dist/upgrade/converge.d.ts.map +1 -1
  32. package/dist/upgrade/converge.js +16 -5
  33. package/dist/upgrade/converge.js.map +1 -1
  34. package/dist/wizards/gcp-cost.js +1 -1
  35. package/dist/wizards/gcp-cost.js.map +1 -1
  36. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  37. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  38. package/dist/wizards/gcp-pulumi.js +14 -2
  39. package/dist/wizards/gcp-pulumi.js.map +1 -1
  40. package/dist/wizards/gcp.js +9 -2
  41. package/dist/wizards/gcp.js.map +1 -1
  42. package/package.json +2 -2
  43. package/src/audit-job-setup.test.ts +7 -1
  44. package/src/compose.ts +1 -1
  45. package/src/gateway-env-isolation.test.ts +1 -1
  46. package/src/infra-contract.test.ts +4 -3
  47. package/src/infra-contract.ts +12 -1
  48. package/src/operator-access.test.ts +11 -2
  49. package/src/operator-access.ts +38 -10
  50. package/src/remote-mcp.test.ts +196 -23
  51. package/src/remote-mcp.ts +68 -4
  52. package/src/runtime-secrets.test.ts +3 -3
  53. package/src/runtime-secrets.ts +2 -2
  54. package/src/stack-contract.test.ts +10 -8
  55. package/src/stack-contract.ts +110 -39
  56. package/src/stack-converge-secrets.test.ts +3 -2
  57. package/src/stack-converge.test.ts +4 -4
  58. package/src/stack-converge.ts +61 -0
  59. package/src/upgrade/app-roll.ts +8 -2
  60. package/src/upgrade/audit-job-roll.ts +3 -1
  61. package/src/upgrade/converge.ts +20 -4
  62. package/src/upgrade/upgrade.test.ts +1 -1
  63. package/src/wizards/gcp-cost.ts +1 -1
  64. package/src/wizards/gcp-pulumi.test.ts +15 -0
  65. package/src/wizards/gcp-pulumi.ts +18 -2
  66. package/src/wizards/gcp.ts +11 -2
  67. package/stacks/gcp/index.ts +9 -8
  68. package/stacks/gcp-firebase/index.ts +9 -8
@@ -72,7 +72,8 @@ describe("adminEnvContract", () => {
72
72
  MEDIA_ROOT_DIR: "/app/apps/admin/data/media",
73
73
  CAELO_MCP_IAP_SERVICE_ACCOUNT: "caelo-mcp@acme.iam.gserviceaccount.com",
74
74
  // #806 — where Settings → MCP → Remote writes the OAuth client.
75
- CAELO_MCP_OAUTH_CLIENT_STORE: "projects/acme/secrets/caelo-production-mcp-oauth-client",
75
+ CAELO_OPERATOR_OAUTH_CLIENT_STORE:
76
+ "projects/acme/secrets/caelo-production-operator-oauth-client",
76
77
  CAELO_OPERATOR_ACCESS_JOB:
77
78
  "projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
78
79
  CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
@@ -107,7 +108,8 @@ describe("adminEnvContract", () => {
107
108
  MEDIA_ROOT_DIR: "/app/apps/admin/data/media",
108
109
  CAELO_MCP_IAP_SERVICE_ACCOUNT: "caelo-mcp@acme.iam.gserviceaccount.com",
109
110
  // #806 — where Settings → MCP → Remote writes the OAuth client.
110
- CAELO_MCP_OAUTH_CLIENT_STORE: "projects/acme/secrets/caelo-production-mcp-oauth-client",
111
+ CAELO_OPERATOR_OAUTH_CLIENT_STORE:
112
+ "projects/acme/secrets/caelo-production-operator-oauth-client",
111
113
  CAELO_OPERATOR_ACCESS_JOB:
112
114
  "projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
113
115
  CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
@@ -174,7 +176,7 @@ describe("gatewayEnvContract", () => {
174
176
  PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-role-password:latest",
175
177
  CAELO_STAGING_GATE_SECRET: "secret:caelo-production-staging-gate-secret:latest",
176
178
  // #806 — the remote-MCP broker: its client file and its seal key.
177
- CAELO_MCP_OAUTH_CLIENT_FILE: "/secrets/mcp-oauth-client/client.json",
179
+ CAELO_OPERATOR_OAUTH_CLIENT_FILE: "/secrets/operator-oauth-client/client.json",
178
180
  CAELO_MCP_OAUTH_SEAL_KEY: "secret:caelo-production-mcp-oauth-seal-key:latest",
179
181
  });
180
182
  });
@@ -212,7 +214,7 @@ describe("gatewayEnvContract", () => {
212
214
  "staging-gate-secret",
213
215
  // #806 — the broker's seal key (env) and OAuth client (file).
214
216
  "mcp-oauth-seal-key",
215
- "mcp-oauth-client",
217
+ "operator-oauth-client",
216
218
  ]);
217
219
  for (const v of gateway) {
218
220
  expect(v.name).not.toMatch(/KEK|INTERNAL|TOOL_APPROVAL|PUBLIC_ADMIN/);
@@ -318,8 +320,8 @@ describe("runtime secrets", () => {
318
320
  // The secret file counts like an env secret: the SA reads it.
319
321
  {
320
322
  service: "gateway",
321
- secret: "mcp-oauth-client",
322
- stackResource: "gateway-mcp-oauth-client-binding",
323
+ secret: "operator-oauth-client",
324
+ stackResource: "gateway-operator-oauth-client-binding",
323
325
  },
324
326
  ]);
325
327
  });
@@ -377,14 +379,14 @@ describe("stackIamInvariants", () => {
377
379
  }
378
380
  });
379
381
 
380
- it("grants the gateway SA only its own role password, the staging gate key, remote MCP's two secrets + telemetry roles", () => {
382
+ it("grants the gateway SA only its own role password, the staging gate key, the sign-in client, the seal key + telemetry roles", () => {
381
383
  for (const provider of ["gcp", "gcp-firebase"] as const) {
382
384
  const gateway = stackIamInvariants(provider).filter((i) => i.member === "gateway-sa");
383
385
  expect(gateway.map((i) => `${i.role} ${JSON.stringify(i.target)}`).sort()).toEqual([
384
386
  'roles/logging.logWriter {"kind":"project"}',
385
387
  'roles/monitoring.metricWriter {"kind":"project"}',
386
- 'roles/secretmanager.secretAccessor {"kind":"secret","name":"mcp-oauth-client"}',
387
388
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"mcp-oauth-seal-key"}',
389
+ 'roles/secretmanager.secretAccessor {"kind":"secret","name":"operator-oauth-client"}',
388
390
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"public-role-password"}',
389
391
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"staging-gate-secret"}',
390
392
  ]);
@@ -261,12 +261,15 @@ export type RuntimeSecret =
261
261
  | "tool-approval-secret"
262
262
  | "staging-gate-secret"
263
263
  /**
264
- * #806 — the Google OAuth client the remote-MCP broker signs users in
265
- * with: JSON `{"client_id","client_secret"}`, `{}` while remote MCP is not
266
- * set up. The Owner writes it from Settings → MCP → Remote (the admin may
267
- * add versions, never read them); the gateway reads it from a file.
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.
268
271
  */
269
- | "mcp-oauth-client"
272
+ | "operator-oauth-client"
270
273
  /**
271
274
  * #806 — the broker's AES-256-GCM key (base64, 32 bytes) that seals its
272
275
  * stateless OAuth state, codes and refresh tokens. Gateway only.
@@ -305,10 +308,11 @@ export const CLI_GENERATED_SECRETS = [
305
308
  // session cookies, derives the crawler credentials. Read by the admin
306
309
  // (issues) and the gateway (verifies).
307
310
  "staging-gate-secret",
308
- // #806 — remote MCP. The client starts as the `{}` placeholder (not
309
- // configured; the Owner fills it under Settings → MCP), the seal key as
310
- // 32 random bytes in base64 ({@link GENERATED_SECRET_FORMAT}).
311
- "mcp-oauth-client",
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",
312
316
  "mcp-oauth-seal-key",
313
317
  ] as const satisfies readonly RuntimeSecret[];
314
318
 
@@ -331,12 +335,28 @@ export const GENERATED_SECRET_FORMAT: Readonly<
331
335
  "staging-gate-secret": { kind: "hex32" },
332
336
  // The gateway's seal key contract (#806): base64 of exactly 32 bytes.
333
337
  "mcp-oauth-seal-key": { kind: "base64-32" },
334
- // "Not configured" until the Owner pastes a client (#806). A version must
338
+ // "Not configured" until the Owner pastes a client (#914). A version must
335
339
  // exist from the start: Cloud Run refuses a revision whose secret volume
336
340
  // points at a secret without an enabled version.
337
- "mcp-oauth-client": { kind: "placeholder", value: "{}" },
341
+ "operator-oauth-client": { kind: "placeholder", value: "{}" },
338
342
  };
339
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
+
340
360
  /**
341
361
  * Each service's Secret Manager-backed env vars and the secret each reads.
342
362
  * The services' run SAs get `secretAccessor` on exactly these secrets
@@ -375,8 +395,8 @@ export const SERVICE_SECRET_ENV: Readonly<
375
395
  // Secret files (#806)
376
396
  // ===========================================================================
377
397
 
378
- /** Env var naming the file that holds the remote-MCP Google OAuth client. */
379
- export const MCP_OAUTH_CLIENT_FILE_ENV = "CAELO_MCP_OAUTH_CLIENT_FILE";
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";
380
400
 
381
401
  /**
382
402
  * A Secret Manager secret mounted into a service as one file, at `latest`.
@@ -386,7 +406,7 @@ export const MCP_OAUTH_CLIENT_FILE_ENV = "CAELO_MCP_OAUTH_CLIENT_FILE";
386
406
  * time the file is read ("When reading a volume, Cloud Run always fetches
387
407
  * the secret value from the Secret Manager to use the value with the latest
388
408
  * version" — cloud.google.com/run/docs/configuring/services/secrets). So the
389
- * client the Owner saves under Settings → MCP reaches the running gateway
409
+ * client the Owner saves under Settings → Google sign-in reaches the running gateway
390
410
  * (which re-reads it within 60 s) without a new revision, and the admin
391
411
  * needs no right to deploy the gateway.
392
412
  */
@@ -401,11 +421,11 @@ export interface SecretFileContract {
401
421
  readonly version: "latest";
402
422
  }
403
423
 
404
- /** The gateway's remote-MCP OAuth client file (#806). */
405
- export const MCP_OAUTH_CLIENT_FILE: SecretFileContract = {
406
- secret: "mcp-oauth-client",
407
- volumeName: "mcp-oauth-client",
408
- mountDir: "/secrets/mcp-oauth-client",
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",
409
429
  fileName: "client.json",
410
430
  version: "latest",
411
431
  };
@@ -418,7 +438,30 @@ export function secretFilePath(file: SecretFileContract): string {
418
438
  /** Each service's secret files. The admin has none. */
419
439
  export const SERVICE_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly SecretFileContract[]>> = {
420
440
  admin: [],
421
- gateway: [MCP_OAUTH_CLIENT_FILE],
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
+ ],
422
465
  };
423
466
 
424
467
  /**
@@ -466,8 +509,11 @@ export function serviceSecrets(service: CloudRunSlug): RuntimeSecret[] {
466
509
  * credential is in its env (apps/api-gateway `adminDatabaseAccessInEnv`).
467
510
  */
468
511
  export const RETIRED_SERVICE_ENV: Readonly<Record<CloudRunSlug, readonly string[]>> = {
469
- admin: [],
512
+ // #914 — the sign-in client's store was renamed with its secret.
513
+ admin: ["CAELO_MCP_OAUTH_CLIENT_STORE"],
470
514
  gateway: [
515
+ // #914 — renamed to CAELO_OPERATOR_OAUTH_CLIENT_FILE with its secret.
516
+ "CAELO_MCP_OAUTH_CLIENT_FILE",
471
517
  "CAELO_SECRET_KEK",
472
518
  "ADMIN_DATABASE_URL",
473
519
  "ADMIN_DATABASE_PASSWORD",
@@ -547,7 +593,7 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
547
593
  { name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
548
594
  // #806 — where the remote-MCP broker reads its Google OAuth client
549
595
  // (the secret volume of SERVICE_SECRET_FILES).
550
- { name: MCP_OAUTH_CLIENT_FILE_ENV, value: secretFilePath(MCP_OAUTH_CLIENT_FILE) },
596
+ { name: OPERATOR_OAUTH_CLIENT_FILE_ENV, value: secretFilePath(OPERATOR_OAUTH_CLIENT_FILE) },
551
597
  ...secretEnv("gateway", inputs.env),
552
598
  ];
553
599
  }
@@ -565,12 +611,12 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
565
611
  */
566
612
  export const GATEWAY_TIMEOUT_SECONDS = 3600;
567
613
 
568
- /** Admin env var naming the Secret Manager secret the Owner writes the OAuth client to. */
569
- export const MCP_OAUTH_CLIENT_STORE_ENV = "CAELO_MCP_OAUTH_CLIENT_STORE";
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";
570
616
 
571
- /** `projects/<project>/secrets/<id>` of the remote-MCP OAuth client secret. */
572
- export function mcpOAuthClientSecretResource(projectId: string, env: string): string {
573
- return `projects/${projectId}/secrets/${gcpSecretId(env, "mcp-oauth-client")}`;
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")}`;
574
620
  }
575
621
 
576
622
  // ===========================================================================
@@ -594,6 +640,12 @@ export function mcpOAuthClientSecretResource(projectId: string, env: string): st
594
640
  * (remote-mcp.ts `ensureRemoteMcpIdentityEnv`). A roll that does not set
595
641
  * them leaves them as they are (planEnvUpdate never removes a var the
596
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.
597
649
  */
598
650
  export interface RemoteMcpIdentity {
599
651
  /** Where the gateway proxies `/api/mcp/<surface>` to (IAP in front). */
@@ -602,6 +654,12 @@ export interface RemoteMcpIdentity {
602
654
  readonly mcpPublicUrl: string;
603
655
  /** The `aud` of the admin's IAP JWTs. */
604
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;
605
663
  }
606
664
 
607
665
  /** Env var names of {@link RemoteMcpIdentity}. */
@@ -609,6 +667,7 @@ export const REMOTE_MCP_ENV = {
609
667
  adminUrl: "CAELO_ADMIN_URL",
610
668
  mcpPublicUrl: "CAELO_MCP_PUBLIC_URL",
611
669
  iapAudience: "CAELO_IAP_AUDIENCE",
670
+ googleOrgDomain: "CAELO_GOOGLE_ORG_DOMAIN",
612
671
  } as const;
613
672
 
614
673
  /** What {@link remoteMcpIdentity} derives the identity from — all read from the live install. */
@@ -668,8 +727,15 @@ export function remoteMcpIdentityEnv(
668
727
  return {
669
728
  admin: [
670
729
  { name: REMOTE_MCP_ENV.iapAudience, value: id.iapAudience },
671
- // The setup page shows the redirect URI and the MCP URLs from it.
730
+ // The setup pages show the redirect URI and the MCP URLs from it.
672
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
+ : []),
673
739
  ],
674
740
  gateway: [
675
741
  { name: REMOTE_MCP_ENV.adminUrl, value: id.adminUrl },
@@ -722,9 +788,13 @@ export function adminEnvContract<V>(inputs: AdminEnvInputs<V>): CloudRunEnvVar<V
722
788
  // where the tasks write their results (audit-job-setup.ts).
723
789
  { name: AUDIT_JOB_ENV_VAR, value: auditJobResource(projectId, inputs.region, env) },
724
790
  { name: AUDIT_RESULTS_BUCKET_ENV_VAR, value: gcpBucketName(projectId, env, "audit-results") },
725
- // #806 — where Settings → MCP → Remote writes the Google OAuth client
726
- // (add-version only, see the run SA's secretVersionAdder invariant).
727
- { name: MCP_OAUTH_CLIENT_STORE_ENV, value: mcpOAuthClientSecretResource(projectId, env) },
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
+ },
728
798
  ];
729
799
  if (inputs.provider === "gcp") {
730
800
  // v0.2.78 — the GCS StaticPublisher: Stage uploads to staging,
@@ -850,17 +920,18 @@ function secretAccessors(): IamInvariant[] {
850
920
  /** Bindings both GCP stacks declare beyond the secret accessors and telemetry. */
851
921
  const GCP_COMMON: readonly IamInvariant[] = [
852
922
  {
853
- stackResource: "admin-mcp-oauth-client-version-adder",
854
- // Why the admin may write a secret at all: the Owner pastes the remote-
855
- // MCP OAuth client under Settings → MCP → Remote, and the admin stores
856
- // it where the gateway reads it. secretVersionAdder is add-version only
857
- // (no access, no destroy, no IAM), bound on this one secret only — the
858
- // admin can replace the client, never read it or any other secret.
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.
859
930
  role: SECRET_VERSION_ADDER,
860
931
  member: "run-sa",
861
- target: { kind: "secret", name: "mcp-oauth-client" },
932
+ target: { kind: "secret", name: "operator-oauth-client" },
862
933
  onFailure: "warn",
863
- why: "the Owner's remote-MCP setup (Settings → MCP → Remote) stores the OAuth client; without it only that setup fails",
934
+ why: "the Owner's Google sign-in setup (Settings → Google sign-in) stores the OAuth client; without it only that setup fails",
864
935
  },
865
936
  ];
866
937
 
@@ -342,9 +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 client file path and seal key.
346
- "CAELO_MCP_OAUTH_CLIENT_FILE",
345
+ // #806 — the remote-MCP broker's seal key and (#914) the Google
346
+ // sign-in client file path.
347
347
  "CAELO_MCP_OAUTH_SEAL_KEY",
348
+ "CAELO_OPERATOR_OAUTH_CLIENT_FILE",
348
349
  // #650 — the staging gate key arrives in the same roll.
349
350
  "CAELO_STAGING_GATE_SECRET",
350
351
  // #845 — and which forwarded address is the visitor.
@@ -312,13 +312,13 @@ 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_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_MCP_OAUTH_CLIENT_STORE=projects/acme/secrets/caelo-production-mcp-oauth-client,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
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_MCP_OAUTH_CLIENT_FILE=/secrets/mcp-oauth-client/client.json",
321
+ "--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1,CAELO_OPERATOR_OAUTH_CLIENT_FILE=/secrets/operator-oauth-client/client.json",
322
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",
323
323
  ]);
324
324
  });
@@ -789,12 +789,12 @@ describe("ensureStackInvariants", () => {
789
789
  `secrets add-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
790
790
  // #806 — the remote-MCP broker's seal key and OAuth client file.
791
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-mcp-oauth-client --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
793
  ]);
794
794
  // #806 — the admin may add versions to the OAuth client secret, and to no other.
795
795
  const adders = calls.filter((c) => c.includes("roles/secretmanager.secretVersionAdder"));
796
796
  expect(adders).toEqual([
797
- `secrets add-iam-policy-binding caelo-production-mcp-oauth-client --project=acme --member=${RUN_SA} --role=roles/secretmanager.secretVersionAdder --quiet --format=none`,
797
+ `secrets add-iam-policy-binding caelo-production-operator-oauth-client --project=acme --member=${RUN_SA} --role=roles/secretmanager.secretVersionAdder --quiet --format=none`,
798
798
  ]);
799
799
  for (const secret of ["internal-secret", "tool-approval-secret", "secret-kek"]) {
800
800
  expect(calls).toContain(
@@ -43,8 +43,10 @@ import {
43
43
  type MediaVolumeContract,
44
44
  memoryQuantityMiB,
45
45
  RETIRED_IAM_BINDINGS,
46
+ RETIRED_RUNTIME_SECRETS,
46
47
  RETIRED_SERVICE_ENV,
47
48
  type RetiredIamBinding,
49
+ type RetiredSecretFile,
48
50
  type RuntimeEnvInputs,
49
51
  type SecretFileContract,
50
52
  type ServiceEnvInputs,
@@ -682,6 +684,53 @@ export async function removeRetiredIamBindings(
682
684
  return outcomes;
683
685
  }
684
686
 
687
+ /**
688
+ * Delete the secrets older contracts created and nothing reads any more
689
+ * ({@link RETIRED_RUNTIME_SECRETS}) — run by upgrade AFTER both services
690
+ * rolled onto revisions that no longer reference them (Cloud Run would
691
+ * refuse a revision whose secret is gone). Deleting a secret drops its IAM
692
+ * bindings with it. An absent secret is `absent`; never throws for a
693
+ * gcloud failure.
694
+ */
695
+ export async function deleteRetiredSecrets(
696
+ install: Pick<InstallTarget, "projectId" | "env">,
697
+ deps: {
698
+ run?: GcloudRunner;
699
+ retired?: readonly { readonly name: string; readonly why: string }[];
700
+ } = {},
701
+ ): Promise<RetiredBindingOutcome[]> {
702
+ const run = deps.run ?? defaultGcloud;
703
+ const outcomes: RetiredBindingOutcome[] = [];
704
+ for (const s of deps.retired ?? RETIRED_RUNTIME_SECRETS) {
705
+ const id = `secret ${gcpSecretId(install.env, s.name)}`;
706
+ const project = `--project=${install.projectId}`;
707
+ const describe = await run([
708
+ "secrets",
709
+ "describe",
710
+ gcpSecretId(install.env, s.name),
711
+ project,
712
+ "--format=value(name)",
713
+ ]);
714
+ if (!describe.ok && RESOURCE_MISSING.test(describe.stderr)) {
715
+ outcomes.push({ id, status: "absent", why: s.why });
716
+ continue;
717
+ }
718
+ const del = await run([
719
+ "secrets",
720
+ "delete",
721
+ gcpSecretId(install.env, s.name),
722
+ project,
723
+ "--quiet",
724
+ ]);
725
+ outcomes.push(
726
+ del.ok || RESOURCE_MISSING.test(del.stderr)
727
+ ? { id, status: del.ok ? "removed" : "absent", why: s.why }
728
+ : { id, status: "failed", why: s.why, error: del.stderr.trim() },
729
+ );
730
+ }
731
+ return outcomes;
732
+ }
733
+
685
734
  /** The memory limit of the service's container (`512Mi`, `2Gi`), or null
686
735
  * when the service declares none (Cloud Run then runs it with 512Mi). */
687
736
  export function liveContainerMemory(serviceJson: string): string | null {
@@ -895,9 +944,21 @@ export function planSecretFiles(
895
944
  live: LiveVolumes,
896
945
  files: readonly SecretFileContract[],
897
946
  env: string,
947
+ retired: readonly RetiredSecretFile[] = [],
898
948
  ): RollAdditionsPlan {
899
949
  const flags: string[] = [];
900
950
  const changes: string[] = [];
951
+ // #914 — mounts an older contract made and nothing reads any more
952
+ // (RETIRED_SECRET_FILES) go in the same revision that mounts their
953
+ // replacement. `--remove-secrets=<dir>/<file>` drops the file, and the
954
+ // mount with it when it was the volume's only file (gcloud
955
+ // `_PruneManagedVolumeMapping`). Repeated `--remove-secrets` /
956
+ // `--update-secrets` flags merge (gcloud map flags use UpdateAction).
957
+ for (const old of retired) {
958
+ if (!live.mounts.some((m) => m.mountPath === old.mountDir)) continue;
959
+ flags.push(`--remove-secrets=${old.mountDir}/${old.fileName}`);
960
+ changes.push(`secret file ${old.mountDir}/${old.fileName}: removed (${old.why})`);
961
+ }
901
962
  for (const file of files) {
902
963
  const secretId = gcpSecretId(env, file.secret);
903
964
  const mount = live.mounts.find((m) => m.mountPath === file.mountDir);
@@ -31,6 +31,7 @@ import {
31
31
  adminMediaVolume,
32
32
  type CloudRunEnvVar,
33
33
  GATEWAY_TIMEOUT_SECONDS,
34
+ RETIRED_SECRET_FILES,
34
35
  SERVICE_SECRET_FILES,
35
36
  } from "../stack-contract.js";
36
37
  import {
@@ -197,12 +198,17 @@ export async function planAppRoll(
197
198
  );
198
199
  if (!media.ok)
199
200
  return { ok: false, error: `the admin's media volume can't be set up: ${media.error}` };
200
- // #806 — each service's secret files (the gateway's remote-MCP OAuth
201
+ // #806/#914 — each service's secret files (the gateway's Google sign-in
201
202
  // client). A file the roll cannot mount fails the plan: the new gateway
202
203
  // reads its path from the env contract.
203
204
  const secretFiles = {} as Record<RollSlug, { flags: string[]; changes: string[] }>;
204
205
  for (const s of services) {
205
- const files = planSecretFiles(s.liveVolumes, SERVICE_SECRET_FILES[s.slug], install.env);
206
+ const files = planSecretFiles(
207
+ s.liveVolumes,
208
+ SERVICE_SECRET_FILES[s.slug],
209
+ install.env,
210
+ RETIRED_SECRET_FILES[s.slug],
211
+ );
206
212
  if (!files.ok) {
207
213
  return { ok: false, error: `the ${s.slug}'s secret files can't be mounted: ${files.error}` };
208
214
  }
@@ -37,7 +37,9 @@ export async function describeAuditJobImage(
37
37
  "--format=value(spec.template.spec.template.spec.containers[0].image)",
38
38
  ]);
39
39
  if (!r.ok) {
40
- return /NOT_FOUND|not found|could not be found/i.test(r.stderr)
40
+ // gcloud's own wording for a missing job is "Cannot find job [<name>]."
41
+ // (no NOT_FOUND code), so match that too.
42
+ return /NOT_FOUND|not found|could not be found|cannot find/i.test(r.stderr)
41
43
  ? { ok: true, image: null }
42
44
  : { ok: false, error: r.stderr.trim() };
43
45
  }
@@ -57,6 +57,7 @@ import {
57
57
  UPGRADER_JOB_ENV_VAR,
58
58
  } from "../stack-contract.js";
59
59
  import {
60
+ deleteRetiredSecrets,
60
61
  ensureStackInvariants,
61
62
  liveEnvHasInlinePassword,
62
63
  removeRetiredIamBindings,
@@ -364,12 +365,22 @@ export function startConverge(
364
365
  runDeps,
365
366
  );
366
367
  if (resolved.ok) {
367
- s.done(`Remote MCP can be set up under Settings → MCP (${resolved.identity.mcpPublicUrl})`);
368
+ s.done(
369
+ `Google sign-in and remote MCP can be set up under Settings → Google sign-in (${resolved.identity.mcpPublicUrl})`,
370
+ );
371
+ if (resolved.orgDomainError) {
372
+ // #914 — the admin then refuses new users until Google sign-in is
373
+ // set up (it cannot tell who IAP's built-in sign-in admits). Not a
374
+ // converge failure: the Owner's sign-in setup resolves it.
375
+ r.warn(
376
+ `Could not read this project's Google organisation (${resolved.orgDomainError}). Until Google sign-in is set up under Settings → Google sign-in, the admin refuses to add users; or re-run upgrade as an account with the Organization Viewer role.`,
377
+ );
378
+ }
368
379
  return remoteMcpIdentityEnv(resolved.identity);
369
380
  }
370
381
  s.warned(`Remote-MCP env not resolved: ${resolved.error}`);
371
382
  r.warn(
372
- "The upgrade continues; Settings → MCP reports remote MCP as not prepared on this install until this succeeds. Re-run upgrade once the cause is fixed.",
383
+ "The upgrade continues; Settings → Google sign-in and Settings → MCP report this install as not prepared until this succeeds. Re-run upgrade once the cause is fixed.",
373
384
  );
374
385
  incomplete = true;
375
386
  return null;
@@ -602,13 +613,18 @@ export function startConverge(
602
613
  // #613 — with both services on their new revisions, drop the bindings
603
614
  // nothing reads through any more (above all the gateway SA's access to
604
615
  // admin_role's password). Loud on failure.
605
- const retired = await removeRetiredIamBindings(target, runDeps);
616
+ const retired = [
617
+ ...(await removeRetiredIamBindings(target, runDeps)),
618
+ // #914 — secrets nothing references any more (the renamed Google
619
+ // sign-in client); their IAM bindings go with them.
620
+ ...(await deleteRetiredSecrets(target, runDeps)),
621
+ ];
606
622
  for (const o of retired) {
607
623
  if (o.status === "removed") r.info(` removed: ${o.id} (${o.why})`);
608
624
  if (o.status === "failed") {
609
625
  incomplete = true;
610
626
  r.error(
611
- ` NOT removed: ${o.id} — ${o.why}\n ${o.error ?? ""}\n Re-run upgrade with an account that may change IAM, or remove the binding by hand.`,
627
+ ` NOT removed: ${o.id} — ${o.why}\n ${o.error ?? ""}\n Re-run upgrade with an account that may change IAM and secrets, or remove it by hand.`,
612
628
  );
613
629
  }
614
630
  }
@@ -684,7 +684,7 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
684
684
  expect(gateway).toContain(`CAELO_ADMIN_URL=https://${ADMIN}-xyz.a.run.app`);
685
685
  expect(gateway).toContain(`CAELO_MCP_PUBLIC_URL=https://${GATEWAY}-xyz.a.run.app`);
686
686
  expect(gateway).toContain(
687
- "--update-secrets=/secrets/mcp-oauth-client/client.json=caelo-production-mcp-oauth-client:latest",
687
+ "--update-secrets=/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
688
688
  );
689
689
  expect(gateway).toContain("--timeout=3600");
690
690
  expect(admin).not.toContain("--timeout");
@@ -166,7 +166,7 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
166
166
  name: "Secret Manager (runtime secrets)",
167
167
  monthlyUsd: 0.2,
168
168
  notes:
169
- "6 active versions free, then ~$0.06 each; remote MCP (#806) adds the OAuth client and its seal key",
169
+ "6 active versions free, then ~$0.06 each; the Google sign-in client (#914) and the remote-MCP seal key (#806)",
170
170
  },
171
171
  {
172
172
  name: "BigQuery edge log sink",
@@ -7,6 +7,7 @@ import {
7
7
  alignedStackSettings,
8
8
  assertStackRegion,
9
9
  deployedStackRegion,
10
+ presentImageOverrides,
10
11
  pulumiGcpEnv,
11
12
  resolveSecretReplication,
12
13
  stateOnlyEnv,
@@ -244,3 +245,17 @@ describe("stateOnlyEnv (#696)", () => {
244
245
  expect(env.PULUMI_BACKEND_URL).toBe("gs://acme-caelo-ops/pulumi");
245
246
  });
246
247
  });
248
+
249
+ describe("presentImageOverrides", () => {
250
+ it("reads the settings file as-is, so a stack missing its required keys still pins (regression: `pulumi config` refused to list it and every upgrade from a new CLI version failed)", () => {
251
+ const freshBunxSettings = { "caelo-gcp-firebase:image-digest-admin": "sha256:a" };
252
+ expect(presentImageOverrides("gcp-firebase", freshBunxSettings)).toEqual([]);
253
+ expect(presentImageOverrides("gcp", { "caelo-gcp:image-admin": "x:1" })).toEqual([
254
+ "caelo-gcp:image-admin",
255
+ ]);
256
+ });
257
+
258
+ it("finds nothing in a settings file without config", () => {
259
+ expect(presentImageOverrides("gcp", undefined)).toEqual([]);
260
+ });
261
+ });
@@ -150,12 +150,28 @@ export async function writeImageDigestsToStack(inputs: {
150
150
  }): Promise<{ removedOverrides: string[] }> {
151
151
  const stack = await selectInstallStack(inputs.backend, inputs.provider);
152
152
  await stack.setAllConfig(imageDigestConfig(inputs.provider, inputs.digests));
153
- const config = await stack.getAllConfig();
154
- const removedOverrides = imageOverrideKeys(inputs.provider).filter((k) => k in config);
153
+ // Read the settings file, not `getAllConfig()`: `pulumi config` refuses
154
+ // to list a stack whose required keys (domain, project, …) are unset, and
155
+ // the settings file in a freshly unpacked `bunx` package holds only what
156
+ // this call just wrote — so every upgrade from a new CLI version failed.
157
+ const settings = await stack.workspace.stackSettings(stack.name);
158
+ const removedOverrides = presentImageOverrides(inputs.provider, settings.config);
155
159
  if (removedOverrides.length > 0) await stack.removeAllConfig(removedOverrides);
156
160
  return { removedOverrides };
157
161
  }
158
162
 
163
+ /**
164
+ * The image-override keys set in a stack settings file's `config`.
165
+ *
166
+ * @param config the settings file's `config` map (absent when the file has none)
167
+ */
168
+ export function presentImageOverrides(
169
+ provider: "gcp" | "gcp-firebase",
170
+ config: Record<string, unknown> | undefined,
171
+ ): string[] {
172
+ return imageOverrideKeys(provider).filter((k) => config !== undefined && k in config);
173
+ }
174
+
159
175
  /**
160
176
  * One output of the install's stack, secrets included (the Automation API's
161
177
  * `pulumi stack output --show-secrets`). Read at the moment of use, never