@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
@@ -0,0 +1,690 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #806 — remote MCP's provisioning: the contract pieces (identity env,
5
+ * secrets, IAM), the gateway's secret file + timeout planning, and the CLI
6
+ * steps (identity resolution, LB path rules, Cloud Armor rule).
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import { GCP_PUBLIC_BASE_PATHS } from "@caelo-cms/shared";
11
+ import type { GcloudResult } from "./gcloud.js";
12
+ import type { GcloudRunner } from "./gcloud-retry.js";
13
+ import {
14
+ applyRemoteMcpIdentityEnv,
15
+ ensureMcpArmorRule,
16
+ type LiveUrlMap,
17
+ planUrlMapPaths,
18
+ type RemoteMcpTarget,
19
+ resolveGoogleOrgDomain,
20
+ resolveRemoteMcpIdentity,
21
+ } from "./remote-mcp.js";
22
+ import {
23
+ GENERAL_RATE_LIMIT_PRIORITY,
24
+ isMcpArmorRule,
25
+ MCP_ARMOR_PATH_PATTERN,
26
+ MCP_ARMOR_RULE,
27
+ mcpArmorRuleArgs,
28
+ mcpArmorRuleFlags,
29
+ OWASP_RULE_PRIORITIES,
30
+ } from "./remote-mcp-edge.js";
31
+ import { initialSecretValue } from "./runtime-secrets.js";
32
+ import {
33
+ CLI_GENERATED_SECRETS,
34
+ GATEWAY_TIMEOUT_SECONDS,
35
+ GATEWAY_TRUSTED_PROXY_HOPS,
36
+ GENERATED_SECRET_FORMAT,
37
+ OPERATOR_OAUTH_CLIENT_FILE,
38
+ REMOTE_MCP_ENV,
39
+ RETIRED_RUNTIME_SECRETS,
40
+ RETIRED_SECRET_FILES,
41
+ RETIRED_SERVICE_ENV,
42
+ remoteMcpIdentity,
43
+ remoteMcpIdentityEnv,
44
+ SERVICE_SECRET_FILES,
45
+ secretFileVolumesTemplate,
46
+ stackIamInvariants,
47
+ } from "./stack-contract.js";
48
+ import { deleteRetiredSecrets, planRequestTimeout, planSecretFiles } from "./stack-converge.js";
49
+
50
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "" }) as GcloudResult;
51
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr }) as GcloudResult;
52
+
53
+ /** A gcloud fake answering by the longest matching command prefix. */
54
+ function fakeGcloud(answers: Record<string, GcloudResult>) {
55
+ const calls: string[] = [];
56
+ const run: GcloudRunner = async (args) => {
57
+ const line = args.join(" ");
58
+ calls.push(line);
59
+ const key = Object.keys(answers)
60
+ .filter((k) => line.startsWith(k))
61
+ .sort((a, b) => b.length - a.length)[0];
62
+ return key ? (answers[key] as GcloudResult) : fail(`unexpected: ${line}`);
63
+ };
64
+ return { run, calls };
65
+ }
66
+
67
+ describe("remote-MCP identity (contract)", () => {
68
+ it("gcp: the site's own host, admin.<domain>, the LB backend's audience", () => {
69
+ expect(
70
+ remoteMcpIdentity({
71
+ provider: "gcp",
72
+ domain: "acme.com",
73
+ projectNumber: "42",
74
+ adminBackendServiceId: "123456789",
75
+ }),
76
+ ).toEqual({
77
+ adminUrl: "https://admin.acme.com",
78
+ mcpPublicUrl: "https://acme.com",
79
+ iapAudience: "/projects/42/global/backendServices/123456789",
80
+ });
81
+ });
82
+
83
+ it("gcp-firebase: the gateway's and admin's Cloud Run URLs, the service's audience", () => {
84
+ expect(
85
+ remoteMcpIdentity({
86
+ provider: "gcp-firebase",
87
+ projectNumber: "42",
88
+ region: "europe-west1",
89
+ adminServiceName: "caelo-production-admin-aaa",
90
+ adminServiceUrl: "https://caelo-production-admin-aaa-xyz.a.run.app/",
91
+ gatewayServiceUrl: "https://caelo-production-gateway-bbb-xyz.a.run.app",
92
+ }),
93
+ ).toEqual({
94
+ adminUrl: "https://caelo-production-admin-aaa-xyz.a.run.app",
95
+ mcpPublicUrl: "https://caelo-production-gateway-bbb-xyz.a.run.app",
96
+ iapAudience: "/projects/42/locations/europe-west1/services/caelo-production-admin-aaa",
97
+ });
98
+ });
99
+
100
+ it("puts the broker on the path its CAELO_TRUSTED_PROXY_HOPS describes (per-client rate limits)", () => {
101
+ // The broker's per-address buckets read the client from X-Forwarded-For
102
+ // with the deployment's hop count (api-gateway client-ip.ts). gcp: the
103
+ // broker answers on the site host, through the LB (client + LB = 2).
104
+ // gcp-firebase: on the gateway's own run.app URL, never through the
105
+ // Firebase Hosting rewrite, so Cloud Run's front end appends the real
106
+ // client (1). A broker URL on the Hosting path would pool every client
107
+ // behind one CDN edge address.
108
+ expect(GATEWAY_TRUSTED_PROXY_HOPS.gcp).toBe(2);
109
+ expect(
110
+ remoteMcpIdentity({
111
+ provider: "gcp",
112
+ domain: "acme.com",
113
+ projectNumber: "42",
114
+ adminBackendServiceId: "1",
115
+ }).mcpPublicUrl,
116
+ ).toBe("https://acme.com");
117
+ expect(GATEWAY_TRUSTED_PROXY_HOPS["gcp-firebase"]).toBe(1);
118
+ const firebase = remoteMcpIdentity({
119
+ provider: "gcp-firebase",
120
+ projectNumber: "42",
121
+ region: "europe-west1",
122
+ adminServiceName: "a",
123
+ adminServiceUrl: "https://a-xyz.a.run.app",
124
+ gatewayServiceUrl: "https://g-xyz.a.run.app",
125
+ });
126
+ expect(new URL(firebase.mcpPublicUrl).hostname.endsWith(".run.app")).toBe(true);
127
+ });
128
+
129
+ it("gives the admin the audience, public URL and its own URL, and the gateway the admin + public URL", () => {
130
+ const env = remoteMcpIdentityEnv({ adminUrl: "A", mcpPublicUrl: "P", iapAudience: "AUD" });
131
+ expect(env.admin).toEqual([
132
+ { name: REMOTE_MCP_ENV.iapAudience, value: "AUD" },
133
+ { name: REMOTE_MCP_ENV.mcpPublicUrl, value: "P" },
134
+ // #914 — Google sign-in's self-check opens the admin signed out.
135
+ { name: REMOTE_MCP_ENV.adminUrl, value: "A" },
136
+ ]);
137
+ expect(env.gateway).toEqual([
138
+ { name: REMOTE_MCP_ENV.adminUrl, value: "A" },
139
+ { name: REMOTE_MCP_ENV.mcpPublicUrl, value: "P" },
140
+ ]);
141
+ expect(REMOTE_MCP_ENV).toEqual({
142
+ adminUrl: "CAELO_ADMIN_URL",
143
+ mcpPublicUrl: "CAELO_MCP_PUBLIC_URL",
144
+ iapAudience: "CAELO_IAP_AUDIENCE",
145
+ googleOrgDomain: "CAELO_GOOGLE_ORG_DOMAIN",
146
+ });
147
+ });
148
+
149
+ it("#914 — gives the admin the organisation domain only when it was read", () => {
150
+ const known = remoteMcpIdentityEnv({
151
+ adminUrl: "A",
152
+ mcpPublicUrl: "P",
153
+ iapAudience: "AUD",
154
+ googleOrgDomain: "acme.com",
155
+ });
156
+ expect(known.admin).toContainEqual({ name: "CAELO_GOOGLE_ORG_DOMAIN", value: "acme.com" });
157
+ expect(known.gateway.map((e) => e.name)).not.toContain("CAELO_GOOGLE_ORG_DOMAIN");
158
+ const unknown = remoteMcpIdentityEnv({ adminUrl: "A", mcpPublicUrl: "P", iapAudience: "AUD" });
159
+ expect(unknown.admin.map((e) => e.name)).not.toContain("CAELO_GOOGLE_ORG_DOMAIN");
160
+ });
161
+ });
162
+
163
+ describe("remote-MCP secrets + IAM (contract)", () => {
164
+ it("names a first-value format for every CLI-generated secret", () => {
165
+ expect(Object.keys(GENERATED_SECRET_FORMAT).sort()).toEqual([...CLI_GENERATED_SECRETS].sort());
166
+ });
167
+
168
+ it("starts the OAuth client as the {} placeholder and the seal key as 32 random bytes", () => {
169
+ expect(initialSecretValue("operator-oauth-client")).toBe("{}");
170
+ const a = initialSecretValue("mcp-oauth-seal-key");
171
+ expect(Buffer.from(a, "base64")).toHaveLength(32);
172
+ expect(initialSecretValue("mcp-oauth-seal-key")).not.toBe(a);
173
+ expect(initialSecretValue("internal-secret", () => "hex")).toBe("hex");
174
+ });
175
+
176
+ it("lets the admin add versions to the OAuth client secret only — never read, never another secret", () => {
177
+ for (const provider of ["gcp", "gcp-firebase"] as const) {
178
+ const adders = stackIamInvariants(provider).filter(
179
+ (i) => i.role === "roles/secretmanager.secretVersionAdder",
180
+ );
181
+ expect(adders).toEqual([
182
+ expect.objectContaining({
183
+ stackResource: "admin-operator-oauth-client-version-adder",
184
+ member: "run-sa",
185
+ target: { kind: "secret", name: "operator-oauth-client" },
186
+ onFailure: "warn",
187
+ }),
188
+ ]);
189
+ const adminReads = stackIamInvariants(provider).filter(
190
+ (i) =>
191
+ i.member === "run-sa" &&
192
+ i.role === "roles/secretmanager.secretAccessor" &&
193
+ i.target.kind === "secret" &&
194
+ (i.target.name.startsWith("mcp-oauth") || i.target.name === "operator-oauth-client"),
195
+ );
196
+ expect(adminReads).toEqual([]);
197
+ }
198
+ });
199
+
200
+ it("mounts the OAuth client on the gateway only, at latest, as the file its env names", () => {
201
+ expect(SERVICE_SECRET_FILES.admin).toEqual([]);
202
+ expect(SERVICE_SECRET_FILES.gateway).toEqual([OPERATOR_OAUTH_CLIENT_FILE]);
203
+ expect(secretFileVolumesTemplate("gateway", "production")).toEqual({
204
+ volumes: [
205
+ {
206
+ name: "operator-oauth-client",
207
+ secret: {
208
+ secret: "caelo-production-operator-oauth-client",
209
+ items: [{ version: "latest", path: "client.json" }],
210
+ },
211
+ },
212
+ ],
213
+ volumeMounts: [
214
+ { name: "operator-oauth-client", mountPath: "/secrets/operator-oauth-client" },
215
+ ],
216
+ });
217
+ expect(secretFileVolumesTemplate("admin", "production")).toEqual({
218
+ volumes: [],
219
+ volumeMounts: [],
220
+ });
221
+ });
222
+ });
223
+
224
+ describe("planSecretFiles / planRequestTimeout (upgrade)", () => {
225
+ const empty = { executionEnvironment: null, volumes: [], mounts: [] };
226
+
227
+ it("mounts a missing secret file with the documented --update-secrets path form", () => {
228
+ expect(planSecretFiles(empty, [OPERATOR_OAUTH_CLIENT_FILE], "production")).toEqual({
229
+ ok: true,
230
+ flags: [
231
+ "--update-secrets=/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
232
+ ],
233
+ changes: [
234
+ "secret file /secrets/operator-oauth-client/client.json: (none) → caelo-production-operator-oauth-client:latest",
235
+ ],
236
+ });
237
+ });
238
+
239
+ it("leaves the file the stack (or an earlier upgrade) mounted alone, whatever its volume is called", () => {
240
+ for (const name of ["operator-oauth-client", "secret-1a2b3c"]) {
241
+ const live = {
242
+ executionEnvironment: null,
243
+ volumes: [
244
+ {
245
+ name,
246
+ secret: {
247
+ secretName: "projects/42/secrets/caelo-production-operator-oauth-client",
248
+ items: [{ key: "latest", path: "client.json" }],
249
+ },
250
+ },
251
+ ],
252
+ mounts: [{ name, mountPath: "/secrets/operator-oauth-client" }],
253
+ };
254
+ expect(planSecretFiles(live, [OPERATOR_OAUTH_CLIENT_FILE], "production")).toEqual({
255
+ ok: true,
256
+ flags: [],
257
+ changes: [],
258
+ });
259
+ }
260
+ });
261
+
262
+ it("#914 — moves the gateway from the retired mcp-oauth-client mount to the renamed secret in one roll", () => {
263
+ const live = {
264
+ executionEnvironment: null,
265
+ volumes: [
266
+ {
267
+ name: "mcp-oauth-client",
268
+ secret: {
269
+ secretName: "caelo-production-mcp-oauth-client",
270
+ items: [{ key: "latest", path: "client.json" }],
271
+ },
272
+ },
273
+ ],
274
+ mounts: [{ name: "mcp-oauth-client", mountPath: "/secrets/mcp-oauth-client" }],
275
+ };
276
+ expect(RETIRED_SECRET_FILES.gateway.map((f) => f.mountDir)).toEqual([
277
+ "/secrets/mcp-oauth-client",
278
+ ]);
279
+ const plan = planSecretFiles(
280
+ live,
281
+ [OPERATOR_OAUTH_CLIENT_FILE],
282
+ "production",
283
+ RETIRED_SECRET_FILES.gateway,
284
+ );
285
+ expect(plan.ok && plan.flags).toEqual([
286
+ "--remove-secrets=/secrets/mcp-oauth-client/client.json",
287
+ "--update-secrets=/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
288
+ ]);
289
+ // Converged: nothing left to remove or mount.
290
+ const after = {
291
+ executionEnvironment: null,
292
+ volumes: [
293
+ {
294
+ name: "operator-oauth-client",
295
+ secret: {
296
+ secretName: "caelo-production-operator-oauth-client",
297
+ items: [{ key: "latest", path: "client.json" }],
298
+ },
299
+ },
300
+ ],
301
+ mounts: [{ name: "operator-oauth-client", mountPath: "/secrets/operator-oauth-client" }],
302
+ };
303
+ expect(
304
+ planSecretFiles(
305
+ after,
306
+ [OPERATOR_OAUTH_CLIENT_FILE],
307
+ "production",
308
+ RETIRED_SECRET_FILES.gateway,
309
+ ),
310
+ ).toEqual({ ok: true, flags: [], changes: [] });
311
+ });
312
+
313
+ it("#914 — deletes the retired secret after the roll; an absent one is fine", async () => {
314
+ const install = {
315
+ provider: "gcp-firebase" as const,
316
+ projectId: "acme",
317
+ region: "europe-west1",
318
+ env: "production",
319
+ };
320
+ const present = fakeGcloud({
321
+ "secrets describe caelo-production-mcp-oauth-client": ok("projects/1/secrets/x\n"),
322
+ "secrets delete caelo-production-mcp-oauth-client": ok(),
323
+ });
324
+ expect(await deleteRetiredSecrets(install, { run: present.run })).toEqual([
325
+ {
326
+ id: "secret caelo-production-mcp-oauth-client",
327
+ status: "removed",
328
+ why: "the Google sign-in client moved to operator-oauth-client (#914)",
329
+ },
330
+ ]);
331
+ expect(present.calls).toContain(
332
+ "secrets delete caelo-production-mcp-oauth-client --project=acme --quiet",
333
+ );
334
+ const absent = fakeGcloud({
335
+ "secrets describe caelo-production-mcp-oauth-client": fail("NOT_FOUND: Secret not found"),
336
+ });
337
+ const outcomes = await deleteRetiredSecrets(install, { run: absent.run });
338
+ expect(outcomes.map((o) => o.status)).toEqual(["absent"]);
339
+ expect(absent.calls.some((c) => c.startsWith("secrets delete"))).toBe(false);
340
+ const denied = fakeGcloud({
341
+ "secrets describe caelo-production-mcp-oauth-client": ok("x"),
342
+ "secrets delete caelo-production-mcp-oauth-client": fail("PERMISSION_DENIED"),
343
+ });
344
+ expect((await deleteRetiredSecrets(install, { run: denied.run }))[0]).toMatchObject({
345
+ status: "failed",
346
+ error: "PERMISSION_DENIED",
347
+ });
348
+ });
349
+
350
+ it("#914 — retires the old secret and the old env names", () => {
351
+ expect(RETIRED_RUNTIME_SECRETS.map((s) => s.name)).toEqual(["mcp-oauth-client"]);
352
+ expect(RETIRED_SERVICE_ENV.gateway).toContain("CAELO_MCP_OAUTH_CLIENT_FILE");
353
+ expect(RETIRED_SERVICE_ENV.admin).toContain("CAELO_MCP_OAUTH_CLIENT_STORE");
354
+ expect(CLI_GENERATED_SECRETS).not.toContain("mcp-oauth-client" as never);
355
+ });
356
+
357
+ it("refuses something else mounted where the client belongs (a pinned version included)", () => {
358
+ const live = {
359
+ executionEnvironment: null,
360
+ volumes: [
361
+ {
362
+ name: "v",
363
+ secret: {
364
+ secretName: "caelo-production-operator-oauth-client",
365
+ items: [{ key: "3", path: "client.json" }],
366
+ },
367
+ },
368
+ ],
369
+ mounts: [{ name: "v", mountPath: "/secrets/operator-oauth-client" }],
370
+ };
371
+ const plan = planSecretFiles(live, [OPERATOR_OAUTH_CLIENT_FILE], "production");
372
+ expect(plan.ok).toBe(false);
373
+ if (!plan.ok)
374
+ expect(plan.error).toContain("--remove-volume-mount=/secrets/operator-oauth-client");
375
+ });
376
+
377
+ it("raises the gateway's request timeout to the contract's", () => {
378
+ expect(GATEWAY_TIMEOUT_SECONDS).toBe(3600);
379
+ expect(planRequestTimeout(null, 3600).ok && planRequestTimeout(null, 3600)).toEqual({
380
+ ok: true,
381
+ flags: ["--timeout=3600"],
382
+ changes: ["request timeout: (default 300)s → 3600s"],
383
+ });
384
+ expect(planRequestTimeout(60, 3600)).toMatchObject({ flags: ["--timeout=3600"] });
385
+ expect(planRequestTimeout(3600, 3600)).toEqual({ ok: true, flags: [], changes: [] });
386
+ });
387
+ });
388
+
389
+ const firebase: RemoteMcpTarget = {
390
+ provider: "gcp-firebase",
391
+ projectId: "acme",
392
+ region: "europe-west1",
393
+ env: "production",
394
+ domain: "acme.com",
395
+ services: { admin: "caelo-production-admin-aaa", gateway: "caelo-production-gateway-bbb" },
396
+ };
397
+
398
+ const ANCESTORS_IN_ORG = ok(
399
+ JSON.stringify([
400
+ { id: "acme", type: "project" },
401
+ { id: "555", type: "folder" },
402
+ { id: "123456", type: "organization" },
403
+ ]),
404
+ );
405
+
406
+ describe("resolveRemoteMcpIdentity", () => {
407
+ it("gcp-firebase: reads the project number, both services' URLs and the organisation", async () => {
408
+ const { run } = fakeGcloud({
409
+ "projects describe acme": ok("42\n"),
410
+ "projects get-ancestors acme": ANCESTORS_IN_ORG,
411
+ "organizations describe 123456": ok("Acme.com\n"),
412
+ "run services describe caelo-production-admin-aaa": ok(
413
+ "https://caelo-production-admin-aaa-xyz.a.run.app\n",
414
+ ),
415
+ "run services describe caelo-production-gateway-bbb": ok(
416
+ "https://caelo-production-gateway-bbb-xyz.a.run.app\n",
417
+ ),
418
+ });
419
+ const r = await resolveRemoteMcpIdentity(firebase, { run });
420
+ expect(r).toEqual({
421
+ ok: true,
422
+ identity: {
423
+ adminUrl: "https://caelo-production-admin-aaa-xyz.a.run.app",
424
+ mcpPublicUrl: "https://caelo-production-gateway-bbb-xyz.a.run.app",
425
+ iapAudience: "/projects/42/locations/europe-west1/services/caelo-production-admin-aaa",
426
+ googleOrgDomain: "acme.com",
427
+ },
428
+ });
429
+ });
430
+
431
+ it("#914 — an unreadable organisation is reported, not fatal, and sets no domain", async () => {
432
+ const { run } = fakeGcloud({
433
+ "projects describe acme": ok("42\n"),
434
+ "projects get-ancestors acme": ANCESTORS_IN_ORG,
435
+ "organizations describe 123456": fail("PERMISSION_DENIED: organizations.get"),
436
+ "run services describe caelo-production-admin-aaa": ok("https://a-xyz.a.run.app\n"),
437
+ "run services describe caelo-production-gateway-bbb": ok("https://g-xyz.a.run.app\n"),
438
+ });
439
+ const r = await resolveRemoteMcpIdentity(firebase, { run });
440
+ expect(r.ok).toBe(true);
441
+ if (!r.ok) return;
442
+ expect(r.identity.googleOrgDomain).toBeUndefined();
443
+ expect(r.orgDomainError).toContain("Organization Viewer");
444
+ });
445
+
446
+ it("gcp: reads the IAP-enabled admin backend service's numeric id", async () => {
447
+ const { run, calls } = fakeGcloud({
448
+ "projects describe acme": ok("42"),
449
+ "compute backend-services list": ok("987654321\n"),
450
+ });
451
+ const r = await resolveRemoteMcpIdentity({ ...firebase, provider: "gcp" }, { run });
452
+ expect(r.ok && r.identity.iapAudience).toBe("/projects/42/global/backendServices/987654321");
453
+ expect(
454
+ calls.some((c) =>
455
+ c.includes("--filter=name~^caelo-production-admin-backend AND iap.enabled=true"),
456
+ ),
457
+ ).toBe(true);
458
+ });
459
+
460
+ it("fails loudly instead of guessing when the backend is ambiguous", async () => {
461
+ const { run } = fakeGcloud({
462
+ "projects describe acme": ok("42"),
463
+ "compute backend-services list": ok("1\n2\n"),
464
+ });
465
+ const r = await resolveRemoteMcpIdentity({ ...firebase, provider: "gcp" }, { run });
466
+ expect(r.ok).toBe(false);
467
+ });
468
+ });
469
+
470
+ describe("resolveGoogleOrgDomain (#914)", () => {
471
+ it("reads the organisation's domain through the project's ancestors", async () => {
472
+ const { run, calls } = fakeGcloud({
473
+ "projects get-ancestors acme": ANCESTORS_IN_ORG,
474
+ "organizations describe 123456": ok("acme.com\n"),
475
+ });
476
+ expect(await resolveGoogleOrgDomain("acme", { run })).toEqual({ ok: true, domain: "acme.com" });
477
+ expect(calls).toContain("organizations describe 123456 --format=value(displayName)");
478
+ });
479
+
480
+ it("answers `none` for a project in no organisation", async () => {
481
+ const { run } = fakeGcloud({
482
+ "projects get-ancestors acme": ok(JSON.stringify([{ id: "acme", type: "project" }])),
483
+ });
484
+ expect(await resolveGoogleOrgDomain("acme", { run })).toEqual({ ok: true, domain: "none" });
485
+ });
486
+
487
+ it("fails naming the missing role when the organisation cannot be read", async () => {
488
+ const { run } = fakeGcloud({
489
+ "projects get-ancestors acme": ANCESTORS_IN_ORG,
490
+ "organizations describe 123456": ok("not a domain\n"),
491
+ });
492
+ const r = await resolveGoogleOrgDomain("acme", { run });
493
+ expect(r.ok).toBe(false);
494
+ if (!r.ok) expect(r.error).toContain("Organization Viewer");
495
+ });
496
+ });
497
+
498
+ describe("applyRemoteMcpIdentityEnv", () => {
499
+ const identity = {
500
+ adminUrl: "https://a.run.app",
501
+ mcpPublicUrl: "https://g.run.app",
502
+ iapAudience: "/projects/42/locations/europe-west1/services/caelo-production-admin-aaa",
503
+ };
504
+ const serviceJson = (env: { name: string; value: string }[]) =>
505
+ ok(JSON.stringify({ spec: { template: { spec: { containers: [{ env }] } } } }));
506
+
507
+ it("updates only a service whose vars differ", async () => {
508
+ const { run, calls } = fakeGcloud({
509
+ "run services describe caelo-production-admin-aaa": serviceJson([
510
+ { name: "CAELO_IAP_AUDIENCE", value: identity.iapAudience },
511
+ { name: "CAELO_MCP_PUBLIC_URL", value: identity.mcpPublicUrl },
512
+ { name: "CAELO_ADMIN_URL", value: identity.adminUrl },
513
+ ]),
514
+ "run services describe caelo-production-gateway-bbb": serviceJson([]),
515
+ "run services update caelo-production-gateway-bbb": ok(),
516
+ });
517
+ const r = await applyRemoteMcpIdentityEnv(firebase, identity, { run });
518
+ expect(r.ok).toBe(true);
519
+ expect(calls.filter((c) => c.startsWith("run services update"))).toEqual([
520
+ "run services update caelo-production-gateway-bbb --region=europe-west1 --project=acme --update-env-vars=CAELO_ADMIN_URL=https://a.run.app,CAELO_MCP_PUBLIC_URL=https://g.run.app --quiet",
521
+ ]);
522
+ });
523
+ });
524
+
525
+ describe("gcp load balancer (remote-mcp-edge)", () => {
526
+ it("evaluates the /api/mcp/* rule before the general ban and the OWASP packs", () => {
527
+ expect(MCP_ARMOR_RULE.priority).toBeLessThan(GENERAL_RATE_LIMIT_PRIORITY);
528
+ for (const p of OWASP_RULE_PRIORITIES) expect(MCP_ARMOR_RULE.priority).toBeLessThan(p);
529
+ // A throttle (no ban) whose conforming action is `allow` — terminal.
530
+ expect(MCP_ARMOR_RULE.action).toBe("throttle");
531
+ expect(MCP_ARMOR_RULE.conformAction).toBe("allow");
532
+ expect(MCP_ARMOR_RULE.expression).toBe(`request.path.matches('${MCP_ARMOR_PATH_PATTERN}')`);
533
+ });
534
+
535
+ it("covers exactly the broker's paths; a traversal never borrows the MCP rule", async () => {
536
+ // RE2 and JS agree on this pattern (anchors, alternation, classes only).
537
+ const re = new RegExp(MCP_ARMOR_PATH_PATTERN);
538
+ for (const p of [
539
+ "/api/mcp/chat",
540
+ "/api/mcp/admin",
541
+ "/api/mcp/oauth/authorize",
542
+ "/api/mcp/oauth/google-callback",
543
+ "/api/mcp/oauth/token",
544
+ "/api/mcp/oauth/register",
545
+ "/api/mcp/oauth/status",
546
+ "/api/mcp/oauth/protected-resource/chat",
547
+ ]) {
548
+ expect(re.test(p)).toBe(true);
549
+ }
550
+ for (const p of [
551
+ "/api/mcp/../forms/submit",
552
+ "/api/mcp/%2e%2e/comments/post",
553
+ "/api/mcp/chat/../../forms/submit",
554
+ "/api/mcp/chat/x",
555
+ "/api/mcp/oauth/token/../../../forms",
556
+ "/api/mcp/",
557
+ "/api/forms/submit",
558
+ ]) {
559
+ expect(re.test(p)).toBe(false);
560
+ }
561
+ // Every /api/mcp/ route the gateway documents — the consent page's
562
+ // POST /api/mcp/oauth/authorize included — is covered.
563
+ const routes = await Bun.file(
564
+ new URL("../../../apps/api-gateway/src/mcp/routes.ts", import.meta.url),
565
+ ).text();
566
+ const documented = [...routes.matchAll(/^ \* {3}[A-Z|]+ +(\/api\/mcp\/[^\s]+)/gm)]
567
+ .map((m) => m[1] as string)
568
+ .flatMap((p) =>
569
+ p.includes("{") ? ["chat", "admin"].map((s) => p.replace(/\{(chat,admin|s)\}/, s)) : [p],
570
+ );
571
+ expect(documented).toContain("/api/mcp/oauth/authorize");
572
+ expect(documented.length).toBeGreaterThanOrEqual(8);
573
+ for (const p of documented) expect(re.test(p)).toBe(true);
574
+ // Why it matters: the gateway's URL parser resolves such a path to a public write.
575
+ expect(new URL("https://h/api/mcp/%2e%2e/comments/post").pathname).toBe("/api/comments/post");
576
+ });
577
+
578
+ it("throttles at the edge exactly where the gateway's own proxy bucket does (#899)", async () => {
579
+ // Both key on the client address (Cloud Armor: the LB's view; the
580
+ // gateway: CAELO_TRUSTED_PROXY_HOPS). Neither may be the tighter one by
581
+ // accident — a lower edge limit would 429 traffic the broker allows.
582
+ const src = await Bun.file(
583
+ new URL("../../../apps/api-gateway/src/mcp/rate-limit.ts", import.meta.url),
584
+ ).text();
585
+ const proxy = /proxy: \{ capacity: (\d+), windowSeconds: (\d+) \}/.exec(src);
586
+ expect(proxy).not.toBeNull();
587
+ expect([Number(proxy?.[1]), Number(proxy?.[2])]).toEqual([
588
+ MCP_ARMOR_RULE.count,
589
+ MCP_ARMOR_RULE.intervalSec,
590
+ ]);
591
+ });
592
+
593
+ it("the stack shape and the gcloud flags describe the same rule", () => {
594
+ const args = mcpArmorRuleArgs();
595
+ expect(
596
+ isMcpArmorRule({ ...args, match: { expr: { expression: args.match.expr.expression } } }),
597
+ ).toBe(true);
598
+ expect(mcpArmorRuleFlags()).toEqual([
599
+ `--description=${MCP_ARMOR_RULE.description}`,
600
+ `--expression=request.path.matches('${MCP_ARMOR_PATH_PATTERN}')`,
601
+ "--action=throttle",
602
+ "--rate-limit-threshold-count=600",
603
+ "--rate-limit-threshold-interval-sec=60",
604
+ "--conform-action=allow",
605
+ "--exceed-action=deny-429",
606
+ "--enforce-on-key=IP",
607
+ ]);
608
+ });
609
+
610
+ it("the gcp stack declares the rule from the shared module", async () => {
611
+ const src = await Bun.file(new URL("../stacks/gcp/index.ts", import.meta.url)).text();
612
+ expect(src).toContain("mcpArmorRuleArgs(),");
613
+ expect(src.indexOf("mcpArmorRuleArgs(),")).toBeLessThan(
614
+ src.indexOf("evaluatePreconfiguredWaf"),
615
+ );
616
+ });
617
+
618
+ const map = (paths: string[], extra: object[] = []): LiveUrlMap => ({
619
+ name: "caelo-production-url-map-abc",
620
+ fingerprint: "fp",
621
+ hostRules: [
622
+ { hosts: ["acme.com"], pathMatcher: "public" },
623
+ { hosts: ["admin.acme.com"], pathMatcher: "admin" },
624
+ ],
625
+ pathMatchers: [
626
+ {
627
+ name: "public",
628
+ pathRules: [
629
+ { paths, service: "projects/acme/global/backendServices/gw" },
630
+ ...(extra as never[]),
631
+ ],
632
+ },
633
+ { name: "admin", pathRules: [] },
634
+ ],
635
+ });
636
+
637
+ it("adds the discovery paths to the /api/* rule and keeps the admin's redirects", () => {
638
+ const redirect = { paths: ["/old"], urlRedirect: { pathRedirect: "/new" } };
639
+ const plan = planUrlMapPaths([map(["/api/*"], [redirect])], "acme.com");
640
+ if (!plan.ok) throw new Error(plan.error);
641
+ expect(plan.added).toEqual(GCP_PUBLIC_BASE_PATHS.filter((p) => p !== "/api/*"));
642
+ const rules = plan.map.pathMatchers?.[0]?.pathRules ?? [];
643
+ expect(rules[0]?.paths).toEqual([...GCP_PUBLIC_BASE_PATHS]);
644
+ expect(rules[1]).toEqual(redirect);
645
+ expect(plan.map.fingerprint).toBe("fp");
646
+ });
647
+
648
+ it("changes nothing on a map that already routes them", () => {
649
+ const plan = planUrlMapPaths([map([...GCP_PUBLIC_BASE_PATHS])], "acme.com");
650
+ expect(plan.ok && plan.added).toEqual([]);
651
+ });
652
+
653
+ it("refuses a map without the gateway rule instead of inventing one", () => {
654
+ const plan = planUrlMapPaths([map(["/other/*"])], "acme.com");
655
+ expect(plan.ok).toBe(false);
656
+ expect(planUrlMapPaths([], "acme.com").ok).toBe(false);
657
+ });
658
+
659
+ it("creates the Cloud Armor rule when missing, leaves it alone when in shape", async () => {
660
+ const policyJson = (rules: object[]) => ok(JSON.stringify({ rules }));
661
+ const missing = fakeGcloud({
662
+ "compute security-policies list": ok("caelo-production-waf-1a2b\n"),
663
+ "compute security-policies describe": policyJson([{ priority: 1000 }]),
664
+ "compute security-policies rules create 900": ok(),
665
+ });
666
+ expect(await ensureMcpArmorRule({ projectId: "acme", env: "production" }, missing)).toEqual({
667
+ ok: true,
668
+ policy: "caelo-production-waf-1a2b",
669
+ action: "created",
670
+ });
671
+ expect(missing.calls.at(-1)).toContain("--security-policy=caelo-production-waf-1a2b");
672
+
673
+ const args = mcpArmorRuleArgs();
674
+ const present = fakeGcloud({
675
+ "compute security-policies list": ok("caelo-production-waf-1a2b\n"),
676
+ "compute security-policies describe": policyJson([{ ...args }]),
677
+ });
678
+ const r = await ensureMcpArmorRule({ projectId: "acme", env: "production" }, present);
679
+ expect(r.ok && r.action).toBe("present");
680
+ expect(present.calls.some((c) => c.includes("rules "))).toBe(false);
681
+
682
+ const drifted = fakeGcloud({
683
+ "compute security-policies list": ok("caelo-production-waf-1a2b\n"),
684
+ "compute security-policies describe": policyJson([{ ...args, action: "allow" }]),
685
+ "compute security-policies rules update 900": ok(),
686
+ });
687
+ const d = await ensureMcpArmorRule({ projectId: "acme", env: "production" }, drifted);
688
+ expect(d.ok && d.action).toBe("updated");
689
+ });
690
+ });