@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
@@ -29,6 +29,7 @@ import {
29
29
  type CloudRunSlug,
30
30
  DATABASE_ROLE_SECRET,
31
31
  type DatabaseRole,
32
+ GENERATED_SECRET_FORMAT,
32
33
  type RuntimeSecret,
33
34
  SERVICE_SECRET_ENV,
34
35
  serviceSecrets,
@@ -47,6 +48,26 @@ export function generateSecretValue(): string {
47
48
  return randomBytes(32).toString("hex");
48
49
  }
49
50
 
51
+ /**
52
+ * The first value of a CLI-generated secret, in its format
53
+ * ({@link GENERATED_SECRET_FORMAT}). `generate` makes the hex ones (tests
54
+ * inject it); a placeholder is the fixed value its format names.
55
+ */
56
+ export function initialSecretValue(
57
+ name: (typeof CLI_GENERATED_SECRETS)[number],
58
+ generate: () => string = generateSecretValue,
59
+ ): string {
60
+ const format = GENERATED_SECRET_FORMAT[name];
61
+ switch (format.kind) {
62
+ case "hex32":
63
+ return generate();
64
+ case "base64-32":
65
+ return randomBytes(32).toString("base64");
66
+ case "placeholder":
67
+ return format.value;
68
+ }
69
+ }
70
+
50
71
  export interface EnsureOutcome {
51
72
  readonly id: string;
52
73
  readonly status: "present" | "applied" | "failed";
@@ -282,7 +303,7 @@ export async function ensureGeneratedSecrets(
282
303
  continue;
283
304
  }
284
305
  if (!describe.ok) {
285
- const value = install.seed?.[name] || generate();
306
+ const value = install.seed?.[name] || initialSecretValue(name, generate);
286
307
  const createWith = (replication: SecretReplication) =>
287
308
  run(
288
309
  [
@@ -341,7 +362,12 @@ export async function ensureGeneratedSecrets(
341
362
  outcomes.push({ id, status: "present" });
342
363
  continue;
343
364
  }
344
- const add = await addVersion(run, secretId, project, install.seed?.[name] || generate());
365
+ const add = await addVersion(
366
+ run,
367
+ secretId,
368
+ project,
369
+ install.seed?.[name] || initialSecretValue(name, generate),
370
+ );
345
371
  outcomes.push(add.ok ? { id, status: "applied" } : { id, status: "failed", error: add.error });
346
372
  }
347
373
  return outcomes;
@@ -375,6 +401,13 @@ export const ROTATABLE_SECRETS = [
375
401
  ] as const satisfies readonly RuntimeSecret[];
376
402
  export type RotatableSecret = (typeof ROTATABLE_SECRETS)[number];
377
403
 
404
+ /**
405
+ * #806 — the remote-MCP seal key rotates in two steps with an overlap
406
+ * window (`rotate-secret mcp-oauth-seal-key`, then `--finish`), not through
407
+ * {@link rotateRuntimeSecret}: see seal-key-rotation.ts.
408
+ */
409
+ export const SEAL_KEY_SECRET = "mcp-oauth-seal-key" satisfies RuntimeSecret;
410
+
378
411
  /**
379
412
  * Why `name` cannot be rotated, or `null` when it can.
380
413
  *
@@ -384,10 +417,15 @@ export type RotatableSecret = (typeof ROTATABLE_SECRETS)[number];
384
417
  */
385
418
  export function rotationRefusal(name: string): string | null {
386
419
  if ((ROTATABLE_SECRETS as readonly string[]).includes(name)) return null;
420
+ // #806 — two-step rotation with an overlap window (seal-key-rotation.ts).
421
+ if (name === SEAL_KEY_SECRET) return null;
422
+ if (name === "operator-oauth-client") {
423
+ return "operator-oauth-client is the install's Google sign-in client (IAP sign-in and remote MCP); the Owner replaces it under Settings → Google sign-in (create a new client secret in the Google console first).";
424
+ }
387
425
  if (name === "secret-kek") {
388
426
  return "secret-kek encrypts the AI provider keys and other at-rest secrets in cms_admin; a new KEK would make them unreadable. Rotating it needs a re-encryption step that does not exist yet.";
389
427
  }
390
- return `${name} is not a runtime secret of this install. Rotatable: ${ROTATABLE_SECRETS.join(", ")}.`;
428
+ return `${name} is not a runtime secret of this install. Rotatable: ${[...ROTATABLE_SECRETS, SEAL_KEY_SECRET].join(", ")}.`;
391
429
  }
392
430
 
393
431
  /** The deployed install `rotateRuntimeSecret` works on. */
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import type { GcloudRunner } from "./gcloud-retry.js";
5
+ import { rotationRefusal } from "./runtime-secrets.js";
6
+ import {
7
+ finishSealKeyRotation,
8
+ SEAL_KEY_PREVIOUS_ENV,
9
+ SEAL_KEY_ROTATION_WINDOW_DAYS,
10
+ SEAL_ROTATION_LABEL,
11
+ startSealKeyRotation,
12
+ } from "./seal-key-rotation.js";
13
+
14
+ const T = {
15
+ projectId: "acme",
16
+ region: "europe-west1",
17
+ env: "production",
18
+ gatewayService: "caelo-production-gateway-abc",
19
+ };
20
+ const DAY = 24 * 60 * 60 * 1000;
21
+ const NOW = Date.UTC(2026, 9, 20);
22
+
23
+ function service(opts: { previous?: boolean; startedAt?: number }) {
24
+ const env: unknown[] = [
25
+ {
26
+ name: "CAELO_MCP_OAUTH_SEAL_KEY",
27
+ valueFrom: { secretKeyRef: { name: "caelo-production-mcp-oauth-seal-key", key: "latest" } },
28
+ },
29
+ ];
30
+ if (opts.previous) {
31
+ env.push({
32
+ name: SEAL_KEY_PREVIOUS_ENV,
33
+ valueFrom: { secretKeyRef: { name: "caelo-production-mcp-oauth-seal-key", key: "3" } },
34
+ });
35
+ }
36
+ return JSON.stringify({
37
+ metadata: {
38
+ labels: opts.startedAt === undefined ? {} : { [SEAL_ROTATION_LABEL]: String(opts.startedAt) },
39
+ },
40
+ spec: { template: { spec: { containers: [{ env }] } } },
41
+ });
42
+ }
43
+
44
+ function fake(svc: string, versionName = "projects/1/secrets/s/versions/3") {
45
+ const calls: { line: string; stdin?: string }[] = [];
46
+ const run: GcloudRunner = async (args, opts) => {
47
+ const line = args.join(" ");
48
+ calls.push({ line, ...(opts?.stdin !== undefined ? { stdin: opts.stdin } : {}) });
49
+ const ok = (stdout = "") => ({ ok: true, stdout, stderr: "", exitCode: 0 });
50
+ if (line.startsWith("run services describe")) return ok(svc);
51
+ if (line.startsWith("secrets versions describe latest")) return ok(`${versionName}\n`);
52
+ return ok();
53
+ };
54
+ return { run, calls };
55
+ }
56
+
57
+ describe("seal-key rotation (#806)", () => {
58
+ it("is offered by rotate-secret (two steps), not refused", () => {
59
+ expect(rotationRefusal("mcp-oauth-seal-key")).toBeNull();
60
+ });
61
+
62
+ it("start: adds a version, keeps the new key at latest and pins the old version as previous", async () => {
63
+ const f = fake(service({}));
64
+ const r = await startSealKeyRotation(T, { run: f.run, nowMs: () => NOW, generate: () => "K" });
65
+ expect(r.ok).toBe(true);
66
+ expect(f.calls.find((c) => c.line.startsWith("secrets versions add"))?.stdin).toBe("K");
67
+ const update = f.calls.find((c) => c.line.startsWith("run services update "))?.line ?? "";
68
+ expect(update).toContain(
69
+ `--update-secrets=CAELO_MCP_OAUTH_SEAL_KEY=caelo-production-mcp-oauth-seal-key:latest,${SEAL_KEY_PREVIOUS_ENV}=caelo-production-mcp-oauth-seal-key:3`,
70
+ );
71
+ expect(update).toContain(`--update-labels=${SEAL_ROTATION_LABEL}=${NOW}`);
72
+ expect(f.calls.at(-1)?.line).toContain("update-traffic caelo-production-gateway-abc");
73
+ });
74
+
75
+ it("start: refuses while a rotation is in progress, before touching the secret", async () => {
76
+ const f = fake(service({ previous: true, startedAt: NOW - DAY }));
77
+ const r = await startSealKeyRotation(T, { run: f.run, nowMs: () => NOW });
78
+ expect(r.ok).toBe(false);
79
+ expect(r.error).toContain("already in progress");
80
+ expect(f.calls.some((c) => c.line.startsWith("secrets versions add"))).toBe(false);
81
+ });
82
+
83
+ it("start: refuses an unreadable version instead of pinning a guess", async () => {
84
+ const f = fake(service({}), "something-else");
85
+ const r = await startSealKeyRotation(T, { run: f.run, nowMs: () => NOW });
86
+ expect(r.ok).toBe(false);
87
+ expect(f.calls.some((c) => c.line.startsWith("secrets versions add"))).toBe(false);
88
+ });
89
+
90
+ it("finish: refuses inside the refresh-token window", async () => {
91
+ const f = fake(service({ previous: true, startedAt: NOW - 29 * DAY }));
92
+ const r = await finishSealKeyRotation(T, { run: f.run, nowMs: () => NOW });
93
+ expect(r.ok).toBe(false);
94
+ expect(r.error).toContain(`${SEAL_KEY_ROTATION_WINDOW_DAYS}-day lifetime`);
95
+ expect(f.calls.some((c) => c.line.startsWith("run services update "))).toBe(false);
96
+ });
97
+
98
+ it("finish --now: removes the previous key inside the window (a leaked key)", async () => {
99
+ const f = fake(service({ previous: true, startedAt: NOW - DAY }));
100
+ const r = await finishSealKeyRotation(T, { run: f.run, nowMs: () => NOW }, { now: true });
101
+ expect(r.ok).toBe(true);
102
+ expect(f.calls.some((c) => c.line.includes(`--remove-secrets=${SEAL_KEY_PREVIOUS_ENV}`))).toBe(
103
+ true,
104
+ );
105
+ });
106
+
107
+ it("finish: after the window, removes the previous key and the label", async () => {
108
+ const f = fake(service({ previous: true, startedAt: NOW - 31 * DAY }));
109
+ const r = await finishSealKeyRotation(T, { run: f.run, nowMs: () => NOW });
110
+ expect(r.ok).toBe(true);
111
+ const update = f.calls.find((c) => c.line.startsWith("run services update "))?.line ?? "";
112
+ expect(update).toContain(`--remove-secrets=${SEAL_KEY_PREVIOUS_ENV}`);
113
+ expect(update).toContain(`--remove-labels=${SEAL_ROTATION_LABEL}`);
114
+ });
115
+
116
+ it("finish: refuses when no rotation is in progress", async () => {
117
+ const f = fake(service({}));
118
+ const r = await finishSealKeyRotation(T, { run: f.run, nowMs: () => NOW });
119
+ expect(r.ok).toBe(false);
120
+ expect(r.error).toContain("No seal-key rotation is in progress");
121
+ });
122
+
123
+ it("keeps the window equal to the broker's refresh-token lifetime", async () => {
124
+ const src = await Bun.file(
125
+ new URL("../../../apps/api-gateway/src/mcp/token.ts", import.meta.url),
126
+ ).text();
127
+ expect(src).toContain(
128
+ `REFRESH_MAX_LIFETIME_SECONDS = ${SEAL_KEY_ROTATION_WINDOW_DAYS} * 24 * 60 * 60`,
129
+ );
130
+ });
131
+ });
@@ -0,0 +1,258 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Issue #806 — rotating the remote-MCP broker's seal key without signing
5
+ * every connected MCP client out.
6
+ *
7
+ * The broker seals its OAuth state, codes and refresh tokens under
8
+ * `CAELO_MCP_OAUTH_SEAL_KEY` (api-gateway mcp/seal.ts, envelope v2 with a
9
+ * key id). While `CAELO_MCP_OAUTH_SEAL_KEY_PREVIOUS` is set it also opens
10
+ * values sealed under that key, and seals everything new under the current
11
+ * one. Refresh tokens live at most {@link SEAL_KEY_ROTATION_WINDOW_DAYS}
12
+ * days (mcp/token.ts), so after that window nothing sealed under the old key
13
+ * is still valid and the previous key can go.
14
+ *
15
+ * Two steps, both `cms-provision rotate-secret mcp-oauth-seal-key`:
16
+ *
17
+ * start — add a new version to the secret, then roll the gateway with
18
+ * the current key at `latest` and the previous key PINNED to
19
+ * the version that was latest before (a pinned version never
20
+ * follows the new one), labelled with the start time.
21
+ * finish — (`--finish`) once the window has passed: drop the previous key
22
+ * and the label and roll again. `--finish --now` skips the
23
+ * window — for a leaked key, where signing every client out is
24
+ * the point.
25
+ *
26
+ * Refused instead of guessed (CLAUDE.md §2): starting while a rotation is in
27
+ * progress (a second start would pin the CURRENT key as "previous" and drop
28
+ * the real previous one early), and finishing before the window ends.
29
+ *
30
+ * A `cms-provision upgrade` whose `pulumi up` rewrites the gateway template
31
+ * during the window drops the previous key (the stack does not declare it):
32
+ * clients sealed under it sign in again. That fails safe — no token lives
33
+ * longer than intended — and is documented in the rotate output.
34
+ */
35
+
36
+ import { gcloud as defaultGcloud } from "./gcloud.js";
37
+ import type { GcloudRunner } from "./gcloud-retry.js";
38
+ import { gcpSecretId } from "./gcp-names.js";
39
+ import { initialSecretValue } from "./runtime-secrets.js";
40
+ import { liveContainerEnv } from "./stack-converge.js";
41
+
42
+ /** The gateway env var holding the previous key during a rotation. */
43
+ export const SEAL_KEY_PREVIOUS_ENV = "CAELO_MCP_OAUTH_SEAL_KEY_PREVIOUS";
44
+ /** The gateway env var holding the current key (stack-contract SERVICE_SECRET_ENV). */
45
+ export const SEAL_KEY_ENV = "CAELO_MCP_OAUTH_SEAL_KEY";
46
+ /** Label on the gateway service: when the rotation started (epoch ms). */
47
+ export const SEAL_ROTATION_LABEL = "caelo-seal-rotation-started";
48
+ /**
49
+ * Days the previous key must stay: the broker's refresh-token lifetime
50
+ * (api-gateway mcp/token.ts REFRESH_MAX_LIFETIME_SECONDS, 30 days).
51
+ */
52
+ export const SEAL_KEY_ROTATION_WINDOW_DAYS = 30;
53
+
54
+ const DAY_MS = 24 * 60 * 60 * 1000;
55
+
56
+ /** The deployed gateway a rotation works on. */
57
+ export interface SealRotationTarget {
58
+ readonly projectId: string;
59
+ readonly region: string;
60
+ readonly env: string;
61
+ /** Deployed (Pulumi-suffixed) gateway Cloud Run service name. */
62
+ readonly gatewayService: string;
63
+ }
64
+
65
+ export interface SealRotationReport {
66
+ readonly ok: boolean;
67
+ readonly steps: string[];
68
+ readonly error?: string;
69
+ }
70
+
71
+ export interface SealRotationDeps {
72
+ readonly run?: GcloudRunner;
73
+ readonly nowMs?: () => number;
74
+ /** The new key (default: 32 random bytes, base64 — the secret's format). */
75
+ readonly generate?: () => string;
76
+ }
77
+
78
+ interface GatewayState {
79
+ readonly previousPinned: boolean;
80
+ readonly startedAtMs: number | null;
81
+ }
82
+
83
+ async function gatewayState(
84
+ run: GcloudRunner,
85
+ t: SealRotationTarget,
86
+ ): Promise<{ ok: true; state: GatewayState } | { ok: false; error: string }> {
87
+ const r = await run([
88
+ "run",
89
+ "services",
90
+ "describe",
91
+ t.gatewayService,
92
+ "--region",
93
+ t.region,
94
+ `--project=${t.projectId}`,
95
+ "--format=json",
96
+ ]);
97
+ if (!r.ok) return { ok: false, error: `read the gateway service: ${r.stderr.trim()}` };
98
+ const env = liveContainerEnv(r.stdout);
99
+ const labels =
100
+ (JSON.parse(r.stdout) as { metadata?: { labels?: Record<string, string> } }).metadata?.labels ??
101
+ {};
102
+ const raw = labels[SEAL_ROTATION_LABEL];
103
+ const started = raw !== undefined && /^\d+$/.test(raw) ? Number(raw) : null;
104
+ const current = env.get(SEAL_KEY_ENV);
105
+ if (current?.kind !== "secret") {
106
+ return {
107
+ ok: false,
108
+ error: `${SEAL_KEY_ENV} on ${t.gatewayService} is not a Secret Manager reference — run \`cms-provision upgrade\` first (it installs remote MCP's secrets).`,
109
+ };
110
+ }
111
+ return {
112
+ ok: true,
113
+ state: { previousPinned: env.has(SEAL_KEY_PREVIOUS_ENV), startedAtMs: started },
114
+ };
115
+ }
116
+
117
+ function rollFlip(t: SealRotationTarget): string[] {
118
+ return [
119
+ "run",
120
+ "services",
121
+ "update-traffic",
122
+ t.gatewayService,
123
+ "--region",
124
+ t.region,
125
+ `--project=${t.projectId}`,
126
+ "--to-latest",
127
+ "--quiet",
128
+ ];
129
+ }
130
+
131
+ /** Start a rotation: new key at `latest`, the old one pinned as previous. */
132
+ export async function startSealKeyRotation(
133
+ t: SealRotationTarget,
134
+ deps: SealRotationDeps = {},
135
+ ): Promise<SealRotationReport> {
136
+ const run = deps.run ?? defaultGcloud;
137
+ const now = (deps.nowMs ?? Date.now)();
138
+ const generate = deps.generate ?? (() => initialSecretValue("mcp-oauth-seal-key"));
139
+ const secretId = gcpSecretId(t.env, "mcp-oauth-seal-key");
140
+ const project = `--project=${t.projectId}`;
141
+ const steps: string[] = [];
142
+
143
+ const live = await gatewayState(run, t);
144
+ if (!live.ok) return { ok: false, steps, error: live.error };
145
+ if (live.state.previousPinned) {
146
+ const since = live.state.startedAtMs;
147
+ return {
148
+ ok: false,
149
+ steps,
150
+ error: `A seal-key rotation is already in progress${since ? ` (started ${new Date(since).toISOString()})` : ""}. Finish it first with \`cms-provision rotate-secret mcp-oauth-seal-key --finish\` once ${SEAL_KEY_ROTATION_WINDOW_DAYS} days have passed; starting again now would drop the previous key early.`,
151
+ };
152
+ }
153
+
154
+ const latest = await run([
155
+ "secrets",
156
+ "versions",
157
+ "describe",
158
+ "latest",
159
+ `--secret=${secretId}`,
160
+ project,
161
+ "--format=value(name)",
162
+ ]);
163
+ const version = /\/versions\/(\d+)\s*$/.exec(latest.stdout.trim())?.[1];
164
+ if (!latest.ok || !version) {
165
+ return {
166
+ ok: false,
167
+ steps,
168
+ error: `find the current version of ${secretId}: ${latest.ok ? `unexpected answer "${latest.stdout.trim()}"` : latest.stderr.trim()}`,
169
+ };
170
+ }
171
+
172
+ const add = await run(["secrets", "versions", "add", secretId, project, "--data-file=-"], {
173
+ stdin: generate(),
174
+ });
175
+ if (!add.ok) {
176
+ return { ok: false, steps, error: `add a version to ${secretId}: ${add.stderr.trim()}` };
177
+ }
178
+ steps.push(`added a new version to ${secretId} (the previous key is version ${version})`);
179
+
180
+ const update = [
181
+ "run",
182
+ "services",
183
+ "update",
184
+ t.gatewayService,
185
+ "--region",
186
+ t.region,
187
+ project,
188
+ `--update-secrets=${SEAL_KEY_ENV}=${secretId}:latest,${SEAL_KEY_PREVIOUS_ENV}=${secretId}:${version}`,
189
+ `--update-labels=${SEAL_ROTATION_LABEL}=${now}`,
190
+ "--quiet",
191
+ ];
192
+ const rolled = await run(update);
193
+ const flipped = rolled.ok ? await run(rollFlip(t)) : rolled;
194
+ if (!flipped.ok) {
195
+ return {
196
+ ok: false,
197
+ steps,
198
+ error: `The new key is stored but the gateway does not use it yet. Do not rotate again — roll the gateway:\n gcloud ${update.join(" ")}\n gcloud ${rollFlip(t).join(" ")}\n(${flipped.stderr.trim()})`,
199
+ };
200
+ }
201
+ steps.push(
202
+ `rolled the gateway onto the new key; tokens sealed under version ${version} keep working`,
203
+ `finish after ${new Date(now + SEAL_KEY_ROTATION_WINDOW_DAYS * DAY_MS).toISOString()} with: cms-provision rotate-secret mcp-oauth-seal-key --finish`,
204
+ "a `cms-provision upgrade` before then drops the previous key early: clients sealed under it sign in again",
205
+ );
206
+ return { ok: true, steps };
207
+ }
208
+
209
+ /** Finish a rotation: drop the previous key once the window has passed. */
210
+ export async function finishSealKeyRotation(
211
+ t: SealRotationTarget,
212
+ deps: SealRotationDeps = {},
213
+ opts: { readonly now?: boolean } = {},
214
+ ): Promise<SealRotationReport> {
215
+ const run = deps.run ?? defaultGcloud;
216
+ const now = (deps.nowMs ?? Date.now)();
217
+ const steps: string[] = [];
218
+ const live = await gatewayState(run, t);
219
+ if (!live.ok) return { ok: false, steps, error: live.error };
220
+ if (!live.state.previousPinned) {
221
+ return {
222
+ ok: false,
223
+ steps,
224
+ error: `No seal-key rotation is in progress on ${t.gatewayService} (${SEAL_KEY_PREVIOUS_ENV} is not set). Start one with \`cms-provision rotate-secret mcp-oauth-seal-key\`.`,
225
+ };
226
+ }
227
+ const started = live.state.startedAtMs;
228
+ const due = started === null ? null : started + SEAL_KEY_ROTATION_WINDOW_DAYS * DAY_MS;
229
+ if (!opts.now && (due === null || now < due)) {
230
+ return {
231
+ ok: false,
232
+ steps,
233
+ error:
234
+ due === null
235
+ ? `The gateway has ${SEAL_KEY_PREVIOUS_ENV} but no ${SEAL_ROTATION_LABEL} label, so the rotation's start is unknown. Wait ${SEAL_KEY_ROTATION_WINDOW_DAYS} days from when it started, then run --finish --now.`
236
+ : `The previous key still opens refresh tokens until ${new Date(due).toISOString()} (${SEAL_KEY_ROTATION_WINDOW_DAYS}-day lifetime). Finishing now would sign those clients out; run --finish after that time, or --finish --now if the old key leaked.`,
237
+ };
238
+ }
239
+ const update = [
240
+ "run",
241
+ "services",
242
+ "update",
243
+ t.gatewayService,
244
+ "--region",
245
+ t.region,
246
+ `--project=${t.projectId}`,
247
+ `--remove-secrets=${SEAL_KEY_PREVIOUS_ENV}`,
248
+ `--remove-labels=${SEAL_ROTATION_LABEL}`,
249
+ "--quiet",
250
+ ];
251
+ const rolled = await run(update);
252
+ const flipped = rolled.ok ? await run(rollFlip(t)) : rolled;
253
+ if (!flipped.ok) {
254
+ return { ok: false, steps, error: `roll the gateway: ${flipped.stderr.trim()}` };
255
+ }
256
+ steps.push("removed the previous seal key from the gateway; the rotation is complete");
257
+ return { ok: true, steps };
258
+ }
@@ -146,11 +146,28 @@ describe.each(["gcp", "gcp-firebase"] as const)("%s stack ↔ upgrade parity", (
146
146
  expect(src).not.toMatch(/serviceName: "gateway",[^}]*mediaVolume:/);
147
147
  // No volume, mount or execution environment besides the contract's.
148
148
  expect(src.match(/executionEnvironment:/g)).toHaveLength(1);
149
- expect(src).toContain("executionEnvironment: args.mediaVolume.executionEnvironment,");
149
+ expect(src).toContain("? { executionEnvironment: args.mediaVolume.executionEnvironment }");
150
150
  expect(src.match(/volumes:/g)).toHaveLength(1);
151
- expect(src).toContain("volumes: [...args.mediaVolume.volumes],");
151
+ expect(src).toMatch(
152
+ /volumes: \[\s*\.\.\.\(args\.mediaVolume\?\.volumes \?\? \[\]\),\s*\.\.\.secretFileVolumesTemplate\(args\.serviceName, env\)\.volumes,\s*\]/,
153
+ );
152
154
  expect(src.match(/volumeMounts:/g)).toHaveLength(1);
153
- expect(src).toContain("volumeMounts: [...args.mediaVolume.volumeMounts]");
155
+ expect(src).toMatch(
156
+ /volumeMounts: \[\s*\.\.\.\(args\.mediaVolume\?\.volumeMounts \?\? \[\]\),\s*\.\.\.secretFileVolumesTemplate\(args\.serviceName, env\)\.volumeMounts,\s*\]/,
157
+ );
158
+ });
159
+
160
+ it("#806 — mounts each service's secret files from the contract upgrade applies", () => {
161
+ // secretFileVolumesTemplate(service) is SERVICE_SECRET_FILES — the same
162
+ // table stack-converge.ts planSecretFiles adds to existing installs.
163
+ expect(src).toContain("secretFileVolumesTemplate,");
164
+ expect(src.match(/secretFileVolumesTemplate\(args\.serviceName, env\)/g)).toHaveLength(2);
165
+ });
166
+
167
+ it("#806 — gives the gateway the contract's request timeout upgrade applies", () => {
168
+ expect(src).toMatch(
169
+ /serviceName: "gateway",[\s\S]*?timeout: `\$\{GATEWAY_TIMEOUT_SECONDS\}s`,[\s\S]*?contractEnv: gatewayEnvContract\(/,
170
+ );
154
171
  });
155
172
 
156
173
  it("sets no env var outside the contract (C1: no secret value in a plain var)", () => {
@@ -71,9 +71,13 @@ describe("adminEnvContract", () => {
71
71
  CAELO_GENERATOR_CLI: "/app/apps/static-generator/src/cli.ts",
72
72
  MEDIA_ROOT_DIR: "/app/apps/admin/data/media",
73
73
  CAELO_MCP_IAP_SERVICE_ACCOUNT: "caelo-mcp@acme.iam.gserviceaccount.com",
74
+ // #806 — where Settings → MCP → Remote writes the OAuth client.
75
+ CAELO_OPERATOR_OAUTH_CLIENT_STORE:
76
+ "projects/acme/secrets/caelo-production-operator-oauth-client",
74
77
  CAELO_OPERATOR_ACCESS_JOB:
75
78
  "projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
76
79
  CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
80
+ CAELO_CONVERGE_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-converge",
77
81
  CAELO_AUDIT_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-audit",
78
82
  CAELO_AUDIT_RESULTS_BUCKET: "acme-caelo-production-audit-results",
79
83
  CAELO_STATIC_BUCKET: "acme-caelo-production-static",
@@ -103,9 +107,13 @@ describe("adminEnvContract", () => {
103
107
  CAELO_GENERATOR_CLI: "/app/apps/static-generator/src/cli.ts",
104
108
  MEDIA_ROOT_DIR: "/app/apps/admin/data/media",
105
109
  CAELO_MCP_IAP_SERVICE_ACCOUNT: "caelo-mcp@acme.iam.gserviceaccount.com",
110
+ // #806 — where Settings → MCP → Remote writes the OAuth client.
111
+ CAELO_OPERATOR_OAUTH_CLIENT_STORE:
112
+ "projects/acme/secrets/caelo-production-operator-oauth-client",
106
113
  CAELO_OPERATOR_ACCESS_JOB:
107
114
  "projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
108
115
  CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
116
+ CAELO_CONVERGE_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-converge",
109
117
  CAELO_AUDIT_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-audit",
110
118
  CAELO_AUDIT_RESULTS_BUCKET: "acme-caelo-production-audit-results",
111
119
  CAELO_FIREBASE_SITE: "caelo-production-site-abc123",
@@ -167,6 +175,9 @@ describe("gatewayEnvContract", () => {
167
175
  PUBLIC_DATABASE_URL: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
168
176
  PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-role-password:latest",
169
177
  CAELO_STAGING_GATE_SECRET: "secret:caelo-production-staging-gate-secret:latest",
178
+ // #806 — the remote-MCP broker: its client file and its seal key.
179
+ CAELO_OPERATOR_OAUTH_CLIENT_FILE: "/secrets/operator-oauth-client/client.json",
180
+ CAELO_MCP_OAUTH_SEAL_KEY: "secret:caelo-production-mcp-oauth-seal-key:latest",
170
181
  });
171
182
  });
172
183
 
@@ -198,7 +209,13 @@ describe("gatewayEnvContract", () => {
198
209
  });
199
210
 
200
211
  it("the gateway's run SA reads no admin-only secret (admin password, KEK, internal, tool approval)", () => {
201
- expect(serviceSecrets("gateway")).toEqual(["public-role-password", "staging-gate-secret"]);
212
+ expect(serviceSecrets("gateway")).toEqual([
213
+ "public-role-password",
214
+ "staging-gate-secret",
215
+ // #806 — the broker's seal key (env) and OAuth client (file).
216
+ "mcp-oauth-seal-key",
217
+ "operator-oauth-client",
218
+ ]);
202
219
  for (const v of gateway) {
203
220
  expect(v.name).not.toMatch(/KEK|INTERNAL|TOOL_APPROVAL|PUBLIC_ADMIN/);
204
221
  }
@@ -295,6 +312,17 @@ describe("runtime secrets", () => {
295
312
  secret: "staging-gate-secret",
296
313
  stackResource: "gateway-staging-gate-secret-binding",
297
314
  },
315
+ {
316
+ service: "gateway",
317
+ secret: "mcp-oauth-seal-key",
318
+ stackResource: "gateway-mcp-oauth-seal-key-binding",
319
+ },
320
+ // The secret file counts like an env secret: the SA reads it.
321
+ {
322
+ service: "gateway",
323
+ secret: "operator-oauth-client",
324
+ stackResource: "gateway-operator-oauth-client-binding",
325
+ },
298
326
  ]);
299
327
  });
300
328
 
@@ -351,12 +379,14 @@ describe("stackIamInvariants", () => {
351
379
  }
352
380
  });
353
381
 
354
- it("grants the gateway SA only its own role password, the staging gate key + 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", () => {
355
383
  for (const provider of ["gcp", "gcp-firebase"] as const) {
356
384
  const gateway = stackIamInvariants(provider).filter((i) => i.member === "gateway-sa");
357
385
  expect(gateway.map((i) => `${i.role} ${JSON.stringify(i.target)}`).sort()).toEqual([
358
386
  'roles/logging.logWriter {"kind":"project"}',
359
387
  'roles/monitoring.metricWriter {"kind":"project"}',
388
+ 'roles/secretmanager.secretAccessor {"kind":"secret","name":"mcp-oauth-seal-key"}',
389
+ 'roles/secretmanager.secretAccessor {"kind":"secret","name":"operator-oauth-client"}',
360
390
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"public-role-password"}',
361
391
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"staging-gate-secret"}',
362
392
  ]);