@caelo-cms/provisioning 0.10.55 → 0.10.57

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 (209) hide show
  1. package/dist/agent-db.js +76 -19
  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 +22 -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.map +1 -1
  45. package/dist/operator-access.js +13 -3
  46. package/dist/operator-access.js.map +1 -1
  47. package/dist/release-contract.d.ts +3 -2
  48. package/dist/release-contract.d.ts.map +1 -1
  49. package/dist/release-contract.js +5 -2
  50. package/dist/release-contract.js.map +1 -1
  51. package/dist/release-manifest.d.ts +2 -0
  52. package/dist/release-manifest.d.ts.map +1 -1
  53. package/dist/release-manifest.js.map +1 -1
  54. package/dist/remote-mcp-edge.d.ts +114 -0
  55. package/dist/remote-mcp-edge.d.ts.map +1 -0
  56. package/dist/remote-mcp-edge.js +116 -0
  57. package/dist/remote-mcp-edge.js.map +1 -0
  58. package/dist/remote-mcp.d.ts +117 -0
  59. package/dist/remote-mcp.d.ts.map +1 -0
  60. package/dist/remote-mcp.js +379 -0
  61. package/dist/remote-mcp.js.map +1 -0
  62. package/dist/runtime-secrets.d.ts +12 -0
  63. package/dist/runtime-secrets.d.ts.map +1 -1
  64. package/dist/runtime-secrets.js +32 -4
  65. package/dist/runtime-secrets.js.map +1 -1
  66. package/dist/seal-key-rotation.d.ts +38 -0
  67. package/dist/seal-key-rotation.d.ts.map +1 -0
  68. package/dist/seal-key-rotation.js +206 -0
  69. package/dist/seal-key-rotation.js.map +1 -0
  70. package/dist/stack-contract.d.ts +167 -3
  71. package/dist/stack-contract.d.ts.map +1 -1
  72. package/dist/stack-contract.js +171 -3
  73. package/dist/stack-contract.js.map +1 -1
  74. package/dist/stack-converge.d.ts +38 -1
  75. package/dist/stack-converge.d.ts.map +1 -1
  76. package/dist/stack-converge.js +74 -4
  77. package/dist/stack-converge.js.map +1 -1
  78. package/dist/state-lock-lease.d.ts +142 -0
  79. package/dist/state-lock-lease.d.ts.map +1 -0
  80. package/dist/state-lock-lease.js +347 -0
  81. package/dist/state-lock-lease.js.map +1 -0
  82. package/dist/state-store.d.ts +66 -9
  83. package/dist/state-store.d.ts.map +1 -1
  84. package/dist/state-store.js +249 -30
  85. package/dist/state-store.js.map +1 -1
  86. package/dist/upgrade/app-roll.d.ts +36 -7
  87. package/dist/upgrade/app-roll.d.ts.map +1 -1
  88. package/dist/upgrade/app-roll.js +53 -19
  89. package/dist/upgrade/app-roll.js.map +1 -1
  90. package/dist/upgrade/converge.d.ts.map +1 -1
  91. package/dist/upgrade/converge.js +111 -9
  92. package/dist/upgrade/converge.js.map +1 -1
  93. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  94. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  95. package/dist/upgrade/gcp-lookup.js +46 -7
  96. package/dist/upgrade/gcp-lookup.js.map +1 -1
  97. package/dist/upgrade/record-rolled.d.ts +40 -0
  98. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  99. package/dist/upgrade/record-rolled.js +46 -0
  100. package/dist/upgrade/record-rolled.js.map +1 -0
  101. package/dist/upgrade/upgrade.d.ts +45 -1
  102. package/dist/upgrade/upgrade.d.ts.map +1 -1
  103. package/dist/upgrade/upgrade.js +60 -17
  104. package/dist/upgrade/upgrade.js.map +1 -1
  105. package/dist/upgrader-assess.d.ts +15 -1
  106. package/dist/upgrader-assess.d.ts.map +1 -1
  107. package/dist/upgrader-assess.js +36 -6
  108. package/dist/upgrader-assess.js.map +1 -1
  109. package/dist/upgrader-gcp.d.ts +15 -0
  110. package/dist/upgrader-gcp.d.ts.map +1 -1
  111. package/dist/upgrader-gcp.js +42 -7
  112. package/dist/upgrader-gcp.js.map +1 -1
  113. package/dist/upgrader-iam-preflight.d.ts +123 -0
  114. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  115. package/dist/upgrader-iam-preflight.js +414 -0
  116. package/dist/upgrader-iam-preflight.js.map +1 -0
  117. package/dist/upgrader-job.d.ts +33 -9
  118. package/dist/upgrader-job.d.ts.map +1 -1
  119. package/dist/upgrader-job.js +169 -27
  120. package/dist/upgrader-job.js.map +1 -1
  121. package/dist/upgrader-records.d.ts +58 -0
  122. package/dist/upgrader-records.d.ts.map +1 -0
  123. package/dist/upgrader-records.js +87 -0
  124. package/dist/upgrader-records.js.map +1 -0
  125. package/dist/upgrader-reporter.d.ts +3 -2
  126. package/dist/upgrader-reporter.d.ts.map +1 -1
  127. package/dist/upgrader-reporter.js +10 -2
  128. package/dist/upgrader-reporter.js.map +1 -1
  129. package/dist/upgrader-setup.d.ts +25 -3
  130. package/dist/upgrader-setup.d.ts.map +1 -1
  131. package/dist/upgrader-setup.js +123 -11
  132. package/dist/upgrader-setup.js.map +1 -1
  133. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  134. package/dist/wizards/gcp-cost.js +11 -3
  135. package/dist/wizards/gcp-cost.js.map +1 -1
  136. package/dist/wizards/gcp.d.ts.map +1 -1
  137. package/dist/wizards/gcp.js +99 -4
  138. package/dist/wizards/gcp.js.map +1 -1
  139. package/package.json +2 -2
  140. package/src/agent/run.test.ts +14 -1
  141. package/src/cli-routing.ts +1 -1
  142. package/src/cli.ts +4 -1
  143. package/src/compose.ts +5 -0
  144. package/src/converge-checkpoint.ts +75 -0
  145. package/src/converge-entrypoint.test.ts +213 -0
  146. package/src/converge-job-gcp.test.ts +222 -0
  147. package/src/converge-job-gcp.ts +443 -0
  148. package/src/converge-job-setup.test.ts +152 -0
  149. package/src/converge-job-setup.ts +268 -0
  150. package/src/converge-job.test.ts +486 -0
  151. package/src/converge-job.ts +542 -0
  152. package/src/gateway-env-isolation.test.ts +8 -1
  153. package/src/gcp-names.ts +15 -0
  154. package/src/gcp-url-map.test.ts +9 -1
  155. package/src/index.ts +8 -0
  156. package/src/infra-contract.test.ts +7 -4
  157. package/src/infra-contract.ts +22 -1
  158. package/src/install-job.test.ts +3 -1
  159. package/src/install-job.ts +31 -14
  160. package/src/lifecycle.ts +137 -71
  161. package/src/operator-access.test.ts +14 -0
  162. package/src/operator-access.ts +13 -3
  163. package/src/release-contract.ts +5 -2
  164. package/src/release-manifest.test.ts +42 -0
  165. package/src/release-manifest.ts +2 -0
  166. package/src/remote-mcp-edge.ts +137 -0
  167. package/src/remote-mcp.test.ts +517 -0
  168. package/src/remote-mcp.ts +460 -0
  169. package/src/runtime-secrets.test.ts +31 -6
  170. package/src/runtime-secrets.ts +41 -3
  171. package/src/seal-key-rotation.test.ts +131 -0
  172. package/src/seal-key-rotation.ts +258 -0
  173. package/src/stack-contract-parity.test.ts +20 -3
  174. package/src/stack-contract.test.ts +30 -2
  175. package/src/stack-contract.ts +291 -3
  176. package/src/stack-converge-secrets.test.ts +3 -0
  177. package/src/stack-converge.test.ts +21 -5
  178. package/src/stack-converge.ts +101 -7
  179. package/src/state-attach.test.ts +7 -1
  180. package/src/state-lock-lease.test.ts +302 -0
  181. package/src/state-lock-lease.ts +423 -0
  182. package/src/state-lock.test.ts +568 -0
  183. package/src/state-store.ts +347 -27
  184. package/src/upgrade/app-roll.ts +96 -21
  185. package/src/upgrade/converge.ts +130 -8
  186. package/src/upgrade/gcp-lookup.test.ts +80 -0
  187. package/src/upgrade/gcp-lookup.ts +65 -8
  188. package/src/upgrade/record-rolled.test.ts +105 -0
  189. package/src/upgrade/record-rolled.ts +91 -0
  190. package/src/upgrade/upgrade.test.ts +203 -3
  191. package/src/upgrade/upgrade.ts +120 -24
  192. package/src/upgrader-assess.test.ts +92 -3
  193. package/src/upgrader-assess.ts +58 -9
  194. package/src/upgrader-gcp.ts +47 -8
  195. package/src/upgrader-iam-preflight.test.ts +393 -0
  196. package/src/upgrader-iam-preflight.ts +510 -0
  197. package/src/upgrader-job.test.ts +586 -15
  198. package/src/upgrader-job.ts +237 -53
  199. package/src/upgrader-records.ts +151 -0
  200. package/src/upgrader-reporter.test.ts +6 -0
  201. package/src/upgrader-reporter.ts +7 -2
  202. package/src/upgrader-setup.test.ts +3 -3
  203. package/src/upgrader-setup.ts +128 -11
  204. package/src/wizards/gcp-cost.ts +12 -3
  205. package/src/wizards/gcp.ts +125 -4
  206. package/stacks/aws/index.ts +10 -0
  207. package/stacks/azure/index.ts +9 -0
  208. package/stacks/gcp/index.ts +46 -7
  209. package/stacks/gcp-firebase/index.ts +37 -6
@@ -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,12 @@ 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_MCP_OAUTH_CLIENT_STORE: "projects/acme/secrets/caelo-production-mcp-oauth-client",
74
76
  CAELO_OPERATOR_ACCESS_JOB:
75
77
  "projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
76
78
  CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
79
+ CAELO_CONVERGE_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-converge",
77
80
  CAELO_AUDIT_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-audit",
78
81
  CAELO_AUDIT_RESULTS_BUCKET: "acme-caelo-production-audit-results",
79
82
  CAELO_STATIC_BUCKET: "acme-caelo-production-static",
@@ -103,9 +106,12 @@ describe("adminEnvContract", () => {
103
106
  CAELO_GENERATOR_CLI: "/app/apps/static-generator/src/cli.ts",
104
107
  MEDIA_ROOT_DIR: "/app/apps/admin/data/media",
105
108
  CAELO_MCP_IAP_SERVICE_ACCOUNT: "caelo-mcp@acme.iam.gserviceaccount.com",
109
+ // #806 — where Settings → MCP → Remote writes the OAuth client.
110
+ CAELO_MCP_OAUTH_CLIENT_STORE: "projects/acme/secrets/caelo-production-mcp-oauth-client",
106
111
  CAELO_OPERATOR_ACCESS_JOB:
107
112
  "projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
108
113
  CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
114
+ CAELO_CONVERGE_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-converge",
109
115
  CAELO_AUDIT_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-audit",
110
116
  CAELO_AUDIT_RESULTS_BUCKET: "acme-caelo-production-audit-results",
111
117
  CAELO_FIREBASE_SITE: "caelo-production-site-abc123",
@@ -167,6 +173,9 @@ describe("gatewayEnvContract", () => {
167
173
  PUBLIC_DATABASE_URL: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
168
174
  PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-role-password:latest",
169
175
  CAELO_STAGING_GATE_SECRET: "secret:caelo-production-staging-gate-secret:latest",
176
+ // #806 — the remote-MCP broker: its client file and its seal key.
177
+ CAELO_MCP_OAUTH_CLIENT_FILE: "/secrets/mcp-oauth-client/client.json",
178
+ CAELO_MCP_OAUTH_SEAL_KEY: "secret:caelo-production-mcp-oauth-seal-key:latest",
170
179
  });
171
180
  });
172
181
 
@@ -198,7 +207,13 @@ describe("gatewayEnvContract", () => {
198
207
  });
199
208
 
200
209
  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"]);
210
+ expect(serviceSecrets("gateway")).toEqual([
211
+ "public-role-password",
212
+ "staging-gate-secret",
213
+ // #806 — the broker's seal key (env) and OAuth client (file).
214
+ "mcp-oauth-seal-key",
215
+ "mcp-oauth-client",
216
+ ]);
202
217
  for (const v of gateway) {
203
218
  expect(v.name).not.toMatch(/KEK|INTERNAL|TOOL_APPROVAL|PUBLIC_ADMIN/);
204
219
  }
@@ -295,6 +310,17 @@ describe("runtime secrets", () => {
295
310
  secret: "staging-gate-secret",
296
311
  stackResource: "gateway-staging-gate-secret-binding",
297
312
  },
313
+ {
314
+ service: "gateway",
315
+ secret: "mcp-oauth-seal-key",
316
+ stackResource: "gateway-mcp-oauth-seal-key-binding",
317
+ },
318
+ // The secret file counts like an env secret: the SA reads it.
319
+ {
320
+ service: "gateway",
321
+ secret: "mcp-oauth-client",
322
+ stackResource: "gateway-mcp-oauth-client-binding",
323
+ },
298
324
  ]);
299
325
  });
300
326
 
@@ -351,12 +377,14 @@ describe("stackIamInvariants", () => {
351
377
  }
352
378
  });
353
379
 
354
- it("grants the gateway SA only its own role password, the staging gate key + telemetry roles", () => {
380
+ it("grants the gateway SA only its own role password, the staging gate key, remote MCP's two secrets + telemetry roles", () => {
355
381
  for (const provider of ["gcp", "gcp-firebase"] as const) {
356
382
  const gateway = stackIamInvariants(provider).filter((i) => i.member === "gateway-sa");
357
383
  expect(gateway.map((i) => `${i.role} ${JSON.stringify(i.target)}`).sort()).toEqual([
358
384
  'roles/logging.logWriter {"kind":"project"}',
359
385
  'roles/monitoring.metricWriter {"kind":"project"}',
386
+ 'roles/secretmanager.secretAccessor {"kind":"secret","name":"mcp-oauth-client"}',
387
+ 'roles/secretmanager.secretAccessor {"kind":"secret","name":"mcp-oauth-seal-key"}',
360
388
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"public-role-password"}',
361
389
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"staging-gate-secret"}',
362
390
  ]);