@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,137 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #806 — what the `gcp` stack's load balancer does for remote MCP, declared
5
+ * once for the stack (new installs, imported from `dist/`) and for
6
+ * `cms-provision upgrade` (remote-mcp.ts `ensureRemoteMcpEdge`, existing
7
+ * installs). Pure module: no CLI or Pulumi imports.
8
+ *
9
+ * Routing: the broker's discovery documents live at the host root
10
+ * (`/.well-known/oauth-*`); the URL map sends those prefixes to the gateway
11
+ * together with `/api/*` (`GCP_PUBLIC_BASE_PATHS` in @caelo-cms/shared).
12
+ *
13
+ * Cloud Armor: the stack's policy bans an IP for 10 minutes after 100
14
+ * requests a minute and denies anything the OWASP sqli/xss rule packs flag.
15
+ * Both are wrong for `/api/mcp/*`:
16
+ *
17
+ * - MCP bodies carry HTML legitimately (module markup, page content the
18
+ * agent writes through Caelo's tools), which the xss/sqli packs flag.
19
+ * The broker accepts only bearer-authenticated JSON-RPC that the
20
+ * IAP-gated admin validates with Zod; Cloud Armor's signature match
21
+ * adds false positives there, not protection.
22
+ * - claude.ai and ChatGPT call from their own shared egress IPs, so every
23
+ * user of an install arrives from a handful of addresses. One agent
24
+ * session issues a JSON-RPC POST per tool call (plus initialize and
25
+ * notifications) — a busy Power-MCP agent sustains 1–3 a second. A
26
+ * 100/min ban would cut every user behind the same connector for 10
27
+ * minutes after one busy session.
28
+ *
29
+ * So the MCP paths ({@link MCP_ARMOR_PATH_PATTERN}) get their own rule, evaluated before the general ones
30
+ * (lower priority number = earlier; the first matching rule decides): a
31
+ * per-IP *throttle* of 600 requests a minute — about five busy agent
32
+ * sessions per egress IP — answering 429 above it without a ban, so a burst
33
+ * slows down instead of locking everyone out. Below the threshold the
34
+ * rule's action is `allow`, which ends evaluation: the OWASP packs and the
35
+ * general ban do not run for these paths. Abuse stays bounded by the
36
+ * gateway's max instances (100) and by IAP, which rejects every request
37
+ * without a valid token of an allowlisted user before it reaches the admin.
38
+ */
39
+
40
+ /**
41
+ * The paths the MCP rule covers, as an anchored RE2 pattern (Cloud Armor's
42
+ * `matches`): the two MCP endpoints and the broker's `oauth/<name>` and
43
+ * `oauth/<name>/<surface>` routes — lowercase letters and hyphens only.
44
+ *
45
+ * Deliberately not a prefix match. A `startsWith('/api/mcp/')` rule also
46
+ * matches `/api/mcp/../forms/submit` or `/api/mcp/%2e%2e/comments`, which
47
+ * the gateway's URL parser normalises to `/api/forms/submit` — so any
48
+ * public write could borrow the MCP rule's 600/min throttle and skip the
49
+ * OWASP packs and the ban. No dot, no percent sign and no extra segment can
50
+ * match this pattern, so a traversal falls through to the general rules.
51
+ * The dotted metadata aliases (`/.well-known/…`, `oauth/.well-known/…`) are
52
+ * one-off GETs per connection and stay under the general rules.
53
+ */
54
+ export const MCP_ARMOR_PATH_PATTERN = "^/api/mcp/(chat|admin|oauth/[a-z-]+(/[a-z-]+)?)$";
55
+
56
+ /** The general per-IP ban rule and the OWASP rule packs the MCP rule must precede. */
57
+ export const GENERAL_RATE_LIMIT_PRIORITY = 1000;
58
+ export const OWASP_RULE_PRIORITIES: readonly number[] = [2000, 2001];
59
+
60
+ /** The `/api/mcp/*` rule of the gateway's Cloud Armor policy (see the module comment). */
61
+ export const MCP_ARMOR_RULE = {
62
+ priority: 900,
63
+ description: "Caelo remote MCP: per-IP throttle sized for agent traffic; skips the OWASP packs",
64
+ expression: `request.path.matches('${MCP_ARMOR_PATH_PATTERN}')`,
65
+ action: "throttle",
66
+ /** Requests per {@link MCP_ARMOR_RULE.intervalSec} per source IP. */
67
+ count: 600,
68
+ intervalSec: 60,
69
+ conformAction: "allow",
70
+ exceedAction: "deny(429)",
71
+ enforceOnKey: "IP",
72
+ } as const;
73
+
74
+ /** {@link MCP_ARMOR_RULE} in the shape `gcp.compute.SecurityPolicy` `rules` take. */
75
+ export function mcpArmorRuleArgs() {
76
+ const r = MCP_ARMOR_RULE;
77
+ return {
78
+ action: r.action,
79
+ priority: r.priority,
80
+ description: r.description,
81
+ match: { expr: { expression: r.expression } },
82
+ rateLimitOptions: {
83
+ rateLimitThreshold: { count: r.count, intervalSec: r.intervalSec },
84
+ conformAction: r.conformAction,
85
+ exceedAction: r.exceedAction,
86
+ enforceOnKey: r.enforceOnKey,
87
+ },
88
+ };
89
+ }
90
+
91
+ /**
92
+ * The `gcloud compute security-policies rules create|update` flags for
93
+ * {@link MCP_ARMOR_RULE} (without the verb, priority and policy).
94
+ */
95
+ export function mcpArmorRuleFlags(): string[] {
96
+ const r = MCP_ARMOR_RULE;
97
+ return [
98
+ `--description=${r.description}`,
99
+ `--expression=${r.expression}`,
100
+ `--action=${r.action}`,
101
+ `--rate-limit-threshold-count=${r.count}`,
102
+ `--rate-limit-threshold-interval-sec=${r.intervalSec}`,
103
+ `--conform-action=${r.conformAction}`,
104
+ // gcloud spells deny(429) as deny-429.
105
+ "--exceed-action=deny-429",
106
+ `--enforce-on-key=${r.enforceOnKey}`,
107
+ ];
108
+ }
109
+
110
+ /** A Cloud Armor rule as `gcloud compute security-policies describe --format=json` lists it. */
111
+ export interface LiveArmorRule {
112
+ readonly priority?: number;
113
+ readonly action?: string;
114
+ readonly match?: { readonly expr?: { readonly expression?: string } };
115
+ readonly rateLimitOptions?: {
116
+ readonly rateLimitThreshold?: { readonly count?: number; readonly intervalSec?: number };
117
+ readonly conformAction?: string;
118
+ readonly exceedAction?: string;
119
+ readonly enforceOnKey?: string;
120
+ };
121
+ }
122
+
123
+ /** Whether a live rule is exactly {@link MCP_ARMOR_RULE}. */
124
+ export function isMcpArmorRule(rule: LiveArmorRule): boolean {
125
+ const r = MCP_ARMOR_RULE;
126
+ const o = rule.rateLimitOptions;
127
+ return (
128
+ rule.priority === r.priority &&
129
+ rule.action === r.action &&
130
+ rule.match?.expr?.expression === r.expression &&
131
+ o?.rateLimitThreshold?.count === r.count &&
132
+ o?.rateLimitThreshold?.intervalSec === r.intervalSec &&
133
+ o?.conformAction === r.conformAction &&
134
+ o?.exceedAction === r.exceedAction &&
135
+ (o?.enforceOnKey ?? "ALL") === r.enforceOnKey
136
+ );
137
+ }
@@ -0,0 +1,517 @@
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
+ resolveRemoteMcpIdentity,
20
+ } from "./remote-mcp.js";
21
+ import {
22
+ GENERAL_RATE_LIMIT_PRIORITY,
23
+ isMcpArmorRule,
24
+ MCP_ARMOR_PATH_PATTERN,
25
+ MCP_ARMOR_RULE,
26
+ mcpArmorRuleArgs,
27
+ mcpArmorRuleFlags,
28
+ OWASP_RULE_PRIORITIES,
29
+ } from "./remote-mcp-edge.js";
30
+ import { initialSecretValue } from "./runtime-secrets.js";
31
+ import {
32
+ CLI_GENERATED_SECRETS,
33
+ GATEWAY_TIMEOUT_SECONDS,
34
+ GATEWAY_TRUSTED_PROXY_HOPS,
35
+ GENERATED_SECRET_FORMAT,
36
+ MCP_OAUTH_CLIENT_FILE,
37
+ REMOTE_MCP_ENV,
38
+ remoteMcpIdentity,
39
+ remoteMcpIdentityEnv,
40
+ SERVICE_SECRET_FILES,
41
+ secretFileVolumesTemplate,
42
+ stackIamInvariants,
43
+ } from "./stack-contract.js";
44
+ import { planRequestTimeout, planSecretFiles } from "./stack-converge.js";
45
+
46
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "" }) as GcloudResult;
47
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr }) as GcloudResult;
48
+
49
+ /** A gcloud fake answering by the longest matching command prefix. */
50
+ function fakeGcloud(answers: Record<string, GcloudResult>) {
51
+ const calls: string[] = [];
52
+ const run: GcloudRunner = async (args) => {
53
+ const line = args.join(" ");
54
+ calls.push(line);
55
+ const key = Object.keys(answers)
56
+ .filter((k) => line.startsWith(k))
57
+ .sort((a, b) => b.length - a.length)[0];
58
+ return key ? (answers[key] as GcloudResult) : fail(`unexpected: ${line}`);
59
+ };
60
+ return { run, calls };
61
+ }
62
+
63
+ describe("remote-MCP identity (contract)", () => {
64
+ it("gcp: the site's own host, admin.<domain>, the LB backend's audience", () => {
65
+ expect(
66
+ remoteMcpIdentity({
67
+ provider: "gcp",
68
+ domain: "acme.com",
69
+ projectNumber: "42",
70
+ adminBackendServiceId: "123456789",
71
+ }),
72
+ ).toEqual({
73
+ adminUrl: "https://admin.acme.com",
74
+ mcpPublicUrl: "https://acme.com",
75
+ iapAudience: "/projects/42/global/backendServices/123456789",
76
+ });
77
+ });
78
+
79
+ it("gcp-firebase: the gateway's and admin's Cloud Run URLs, the service's audience", () => {
80
+ expect(
81
+ remoteMcpIdentity({
82
+ provider: "gcp-firebase",
83
+ projectNumber: "42",
84
+ region: "europe-west1",
85
+ adminServiceName: "caelo-production-admin-aaa",
86
+ adminServiceUrl: "https://caelo-production-admin-aaa-xyz.a.run.app/",
87
+ gatewayServiceUrl: "https://caelo-production-gateway-bbb-xyz.a.run.app",
88
+ }),
89
+ ).toEqual({
90
+ adminUrl: "https://caelo-production-admin-aaa-xyz.a.run.app",
91
+ mcpPublicUrl: "https://caelo-production-gateway-bbb-xyz.a.run.app",
92
+ iapAudience: "/projects/42/locations/europe-west1/services/caelo-production-admin-aaa",
93
+ });
94
+ });
95
+
96
+ it("puts the broker on the path its CAELO_TRUSTED_PROXY_HOPS describes (per-client rate limits)", () => {
97
+ // The broker's per-address buckets read the client from X-Forwarded-For
98
+ // with the deployment's hop count (api-gateway client-ip.ts). gcp: the
99
+ // broker answers on the site host, through the LB (client + LB = 2).
100
+ // gcp-firebase: on the gateway's own run.app URL, never through the
101
+ // Firebase Hosting rewrite, so Cloud Run's front end appends the real
102
+ // client (1). A broker URL on the Hosting path would pool every client
103
+ // behind one CDN edge address.
104
+ expect(GATEWAY_TRUSTED_PROXY_HOPS.gcp).toBe(2);
105
+ expect(
106
+ remoteMcpIdentity({
107
+ provider: "gcp",
108
+ domain: "acme.com",
109
+ projectNumber: "42",
110
+ adminBackendServiceId: "1",
111
+ }).mcpPublicUrl,
112
+ ).toBe("https://acme.com");
113
+ expect(GATEWAY_TRUSTED_PROXY_HOPS["gcp-firebase"]).toBe(1);
114
+ const firebase = remoteMcpIdentity({
115
+ provider: "gcp-firebase",
116
+ projectNumber: "42",
117
+ region: "europe-west1",
118
+ adminServiceName: "a",
119
+ adminServiceUrl: "https://a-xyz.a.run.app",
120
+ gatewayServiceUrl: "https://g-xyz.a.run.app",
121
+ });
122
+ expect(new URL(firebase.mcpPublicUrl).hostname.endsWith(".run.app")).toBe(true);
123
+ });
124
+
125
+ it("gives the admin the audience + public URL and the gateway the admin + public URL", () => {
126
+ const env = remoteMcpIdentityEnv({ adminUrl: "A", mcpPublicUrl: "P", iapAudience: "AUD" });
127
+ expect(env.admin).toEqual([
128
+ { name: REMOTE_MCP_ENV.iapAudience, value: "AUD" },
129
+ { name: REMOTE_MCP_ENV.mcpPublicUrl, value: "P" },
130
+ ]);
131
+ expect(env.gateway).toEqual([
132
+ { name: REMOTE_MCP_ENV.adminUrl, value: "A" },
133
+ { name: REMOTE_MCP_ENV.mcpPublicUrl, value: "P" },
134
+ ]);
135
+ expect(REMOTE_MCP_ENV).toEqual({
136
+ adminUrl: "CAELO_ADMIN_URL",
137
+ mcpPublicUrl: "CAELO_MCP_PUBLIC_URL",
138
+ iapAudience: "CAELO_IAP_AUDIENCE",
139
+ });
140
+ });
141
+ });
142
+
143
+ describe("remote-MCP secrets + IAM (contract)", () => {
144
+ it("names a first-value format for every CLI-generated secret", () => {
145
+ expect(Object.keys(GENERATED_SECRET_FORMAT).sort()).toEqual([...CLI_GENERATED_SECRETS].sort());
146
+ });
147
+
148
+ it("starts the OAuth client as the {} placeholder and the seal key as 32 random bytes", () => {
149
+ expect(initialSecretValue("mcp-oauth-client")).toBe("{}");
150
+ const a = initialSecretValue("mcp-oauth-seal-key");
151
+ expect(Buffer.from(a, "base64")).toHaveLength(32);
152
+ expect(initialSecretValue("mcp-oauth-seal-key")).not.toBe(a);
153
+ expect(initialSecretValue("internal-secret", () => "hex")).toBe("hex");
154
+ });
155
+
156
+ it("lets the admin add versions to the OAuth client secret only — never read, never another secret", () => {
157
+ for (const provider of ["gcp", "gcp-firebase"] as const) {
158
+ const adders = stackIamInvariants(provider).filter(
159
+ (i) => i.role === "roles/secretmanager.secretVersionAdder",
160
+ );
161
+ expect(adders).toEqual([
162
+ expect.objectContaining({
163
+ stackResource: "admin-mcp-oauth-client-version-adder",
164
+ member: "run-sa",
165
+ target: { kind: "secret", name: "mcp-oauth-client" },
166
+ onFailure: "warn",
167
+ }),
168
+ ]);
169
+ const adminReads = stackIamInvariants(provider).filter(
170
+ (i) =>
171
+ i.member === "run-sa" &&
172
+ i.role === "roles/secretmanager.secretAccessor" &&
173
+ i.target.kind === "secret" &&
174
+ i.target.name.startsWith("mcp-oauth"),
175
+ );
176
+ expect(adminReads).toEqual([]);
177
+ }
178
+ });
179
+
180
+ it("mounts the OAuth client on the gateway only, at latest, as the file its env names", () => {
181
+ expect(SERVICE_SECRET_FILES.admin).toEqual([]);
182
+ expect(SERVICE_SECRET_FILES.gateway).toEqual([MCP_OAUTH_CLIENT_FILE]);
183
+ expect(secretFileVolumesTemplate("gateway", "production")).toEqual({
184
+ volumes: [
185
+ {
186
+ name: "mcp-oauth-client",
187
+ secret: {
188
+ secret: "caelo-production-mcp-oauth-client",
189
+ items: [{ version: "latest", path: "client.json" }],
190
+ },
191
+ },
192
+ ],
193
+ volumeMounts: [{ name: "mcp-oauth-client", mountPath: "/secrets/mcp-oauth-client" }],
194
+ });
195
+ expect(secretFileVolumesTemplate("admin", "production")).toEqual({
196
+ volumes: [],
197
+ volumeMounts: [],
198
+ });
199
+ });
200
+ });
201
+
202
+ describe("planSecretFiles / planRequestTimeout (upgrade)", () => {
203
+ const empty = { executionEnvironment: null, volumes: [], mounts: [] };
204
+
205
+ it("mounts a missing secret file with the documented --update-secrets path form", () => {
206
+ expect(planSecretFiles(empty, [MCP_OAUTH_CLIENT_FILE], "production")).toEqual({
207
+ ok: true,
208
+ flags: [
209
+ "--update-secrets=/secrets/mcp-oauth-client/client.json=caelo-production-mcp-oauth-client:latest",
210
+ ],
211
+ changes: [
212
+ "secret file /secrets/mcp-oauth-client/client.json: (none) → caelo-production-mcp-oauth-client:latest",
213
+ ],
214
+ });
215
+ });
216
+
217
+ it("leaves the file the stack (or an earlier upgrade) mounted alone, whatever its volume is called", () => {
218
+ for (const name of ["mcp-oauth-client", "secret-1a2b3c"]) {
219
+ const live = {
220
+ executionEnvironment: null,
221
+ volumes: [
222
+ {
223
+ name,
224
+ secret: {
225
+ secretName: "projects/42/secrets/caelo-production-mcp-oauth-client",
226
+ items: [{ key: "latest", path: "client.json" }],
227
+ },
228
+ },
229
+ ],
230
+ mounts: [{ name, mountPath: "/secrets/mcp-oauth-client" }],
231
+ };
232
+ expect(planSecretFiles(live, [MCP_OAUTH_CLIENT_FILE], "production")).toEqual({
233
+ ok: true,
234
+ flags: [],
235
+ changes: [],
236
+ });
237
+ }
238
+ });
239
+
240
+ it("refuses something else mounted where the client belongs (a pinned version included)", () => {
241
+ const live = {
242
+ executionEnvironment: null,
243
+ volumes: [
244
+ {
245
+ name: "v",
246
+ secret: {
247
+ secretName: "caelo-production-mcp-oauth-client",
248
+ items: [{ key: "3", path: "client.json" }],
249
+ },
250
+ },
251
+ ],
252
+ mounts: [{ name: "v", mountPath: "/secrets/mcp-oauth-client" }],
253
+ };
254
+ const plan = planSecretFiles(live, [MCP_OAUTH_CLIENT_FILE], "production");
255
+ expect(plan.ok).toBe(false);
256
+ if (!plan.ok) expect(plan.error).toContain("--remove-volume-mount=/secrets/mcp-oauth-client");
257
+ });
258
+
259
+ it("raises the gateway's request timeout to the contract's", () => {
260
+ expect(GATEWAY_TIMEOUT_SECONDS).toBe(3600);
261
+ expect(planRequestTimeout(null, 3600).ok && planRequestTimeout(null, 3600)).toEqual({
262
+ ok: true,
263
+ flags: ["--timeout=3600"],
264
+ changes: ["request timeout: (default 300)s → 3600s"],
265
+ });
266
+ expect(planRequestTimeout(60, 3600)).toMatchObject({ flags: ["--timeout=3600"] });
267
+ expect(planRequestTimeout(3600, 3600)).toEqual({ ok: true, flags: [], changes: [] });
268
+ });
269
+ });
270
+
271
+ const firebase: RemoteMcpTarget = {
272
+ provider: "gcp-firebase",
273
+ projectId: "acme",
274
+ region: "europe-west1",
275
+ env: "production",
276
+ domain: "acme.com",
277
+ services: { admin: "caelo-production-admin-aaa", gateway: "caelo-production-gateway-bbb" },
278
+ };
279
+
280
+ describe("resolveRemoteMcpIdentity", () => {
281
+ it("gcp-firebase: reads the project number and both services' URLs", async () => {
282
+ const { run } = fakeGcloud({
283
+ "projects describe acme": ok("42\n"),
284
+ "run services describe caelo-production-admin-aaa": ok(
285
+ "https://caelo-production-admin-aaa-xyz.a.run.app\n",
286
+ ),
287
+ "run services describe caelo-production-gateway-bbb": ok(
288
+ "https://caelo-production-gateway-bbb-xyz.a.run.app\n",
289
+ ),
290
+ });
291
+ const r = await resolveRemoteMcpIdentity(firebase, { run });
292
+ expect(r).toEqual({
293
+ ok: true,
294
+ identity: {
295
+ adminUrl: "https://caelo-production-admin-aaa-xyz.a.run.app",
296
+ mcpPublicUrl: "https://caelo-production-gateway-bbb-xyz.a.run.app",
297
+ iapAudience: "/projects/42/locations/europe-west1/services/caelo-production-admin-aaa",
298
+ },
299
+ });
300
+ });
301
+
302
+ it("gcp: reads the IAP-enabled admin backend service's numeric id", async () => {
303
+ const { run, calls } = fakeGcloud({
304
+ "projects describe acme": ok("42"),
305
+ "compute backend-services list": ok("987654321\n"),
306
+ });
307
+ const r = await resolveRemoteMcpIdentity({ ...firebase, provider: "gcp" }, { run });
308
+ expect(r.ok && r.identity.iapAudience).toBe("/projects/42/global/backendServices/987654321");
309
+ expect(
310
+ calls.some((c) =>
311
+ c.includes("--filter=name~^caelo-production-admin-backend AND iap.enabled=true"),
312
+ ),
313
+ ).toBe(true);
314
+ });
315
+
316
+ it("fails loudly instead of guessing when the backend is ambiguous", async () => {
317
+ const { run } = fakeGcloud({
318
+ "projects describe acme": ok("42"),
319
+ "compute backend-services list": ok("1\n2\n"),
320
+ });
321
+ const r = await resolveRemoteMcpIdentity({ ...firebase, provider: "gcp" }, { run });
322
+ expect(r.ok).toBe(false);
323
+ });
324
+ });
325
+
326
+ describe("applyRemoteMcpIdentityEnv", () => {
327
+ const identity = {
328
+ adminUrl: "https://a.run.app",
329
+ mcpPublicUrl: "https://g.run.app",
330
+ iapAudience: "/projects/42/locations/europe-west1/services/caelo-production-admin-aaa",
331
+ };
332
+ const serviceJson = (env: { name: string; value: string }[]) =>
333
+ ok(JSON.stringify({ spec: { template: { spec: { containers: [{ env }] } } } }));
334
+
335
+ it("updates only a service whose vars differ", async () => {
336
+ const { run, calls } = fakeGcloud({
337
+ "run services describe caelo-production-admin-aaa": serviceJson([
338
+ { name: "CAELO_IAP_AUDIENCE", value: identity.iapAudience },
339
+ { name: "CAELO_MCP_PUBLIC_URL", value: identity.mcpPublicUrl },
340
+ ]),
341
+ "run services describe caelo-production-gateway-bbb": serviceJson([]),
342
+ "run services update caelo-production-gateway-bbb": ok(),
343
+ });
344
+ const r = await applyRemoteMcpIdentityEnv(firebase, identity, { run });
345
+ expect(r.ok).toBe(true);
346
+ expect(calls.filter((c) => c.startsWith("run services update"))).toEqual([
347
+ "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",
348
+ ]);
349
+ });
350
+ });
351
+
352
+ describe("gcp load balancer (remote-mcp-edge)", () => {
353
+ it("evaluates the /api/mcp/* rule before the general ban and the OWASP packs", () => {
354
+ expect(MCP_ARMOR_RULE.priority).toBeLessThan(GENERAL_RATE_LIMIT_PRIORITY);
355
+ for (const p of OWASP_RULE_PRIORITIES) expect(MCP_ARMOR_RULE.priority).toBeLessThan(p);
356
+ // A throttle (no ban) whose conforming action is `allow` — terminal.
357
+ expect(MCP_ARMOR_RULE.action).toBe("throttle");
358
+ expect(MCP_ARMOR_RULE.conformAction).toBe("allow");
359
+ expect(MCP_ARMOR_RULE.expression).toBe(`request.path.matches('${MCP_ARMOR_PATH_PATTERN}')`);
360
+ });
361
+
362
+ it("covers exactly the broker's paths; a traversal never borrows the MCP rule", async () => {
363
+ // RE2 and JS agree on this pattern (anchors, alternation, classes only).
364
+ const re = new RegExp(MCP_ARMOR_PATH_PATTERN);
365
+ for (const p of [
366
+ "/api/mcp/chat",
367
+ "/api/mcp/admin",
368
+ "/api/mcp/oauth/authorize",
369
+ "/api/mcp/oauth/google-callback",
370
+ "/api/mcp/oauth/token",
371
+ "/api/mcp/oauth/register",
372
+ "/api/mcp/oauth/status",
373
+ "/api/mcp/oauth/protected-resource/chat",
374
+ ]) {
375
+ expect(re.test(p)).toBe(true);
376
+ }
377
+ for (const p of [
378
+ "/api/mcp/../forms/submit",
379
+ "/api/mcp/%2e%2e/comments/post",
380
+ "/api/mcp/chat/../../forms/submit",
381
+ "/api/mcp/chat/x",
382
+ "/api/mcp/oauth/token/../../../forms",
383
+ "/api/mcp/",
384
+ "/api/forms/submit",
385
+ ]) {
386
+ expect(re.test(p)).toBe(false);
387
+ }
388
+ // Every /api/mcp/ route the gateway documents — the consent page's
389
+ // POST /api/mcp/oauth/authorize included — is covered.
390
+ const routes = await Bun.file(
391
+ new URL("../../../apps/api-gateway/src/mcp/routes.ts", import.meta.url),
392
+ ).text();
393
+ const documented = [...routes.matchAll(/^ \* {3}[A-Z|]+ +(\/api\/mcp\/[^\s]+)/gm)]
394
+ .map((m) => m[1] as string)
395
+ .flatMap((p) =>
396
+ p.includes("{") ? ["chat", "admin"].map((s) => p.replace(/\{(chat,admin|s)\}/, s)) : [p],
397
+ );
398
+ expect(documented).toContain("/api/mcp/oauth/authorize");
399
+ expect(documented.length).toBeGreaterThanOrEqual(8);
400
+ for (const p of documented) expect(re.test(p)).toBe(true);
401
+ // Why it matters: the gateway's URL parser resolves such a path to a public write.
402
+ expect(new URL("https://h/api/mcp/%2e%2e/comments/post").pathname).toBe("/api/comments/post");
403
+ });
404
+
405
+ it("throttles at the edge exactly where the gateway's own proxy bucket does (#899)", async () => {
406
+ // Both key on the client address (Cloud Armor: the LB's view; the
407
+ // gateway: CAELO_TRUSTED_PROXY_HOPS). Neither may be the tighter one by
408
+ // accident — a lower edge limit would 429 traffic the broker allows.
409
+ const src = await Bun.file(
410
+ new URL("../../../apps/api-gateway/src/mcp/rate-limit.ts", import.meta.url),
411
+ ).text();
412
+ const proxy = /proxy: \{ capacity: (\d+), windowSeconds: (\d+) \}/.exec(src);
413
+ expect(proxy).not.toBeNull();
414
+ expect([Number(proxy?.[1]), Number(proxy?.[2])]).toEqual([
415
+ MCP_ARMOR_RULE.count,
416
+ MCP_ARMOR_RULE.intervalSec,
417
+ ]);
418
+ });
419
+
420
+ it("the stack shape and the gcloud flags describe the same rule", () => {
421
+ const args = mcpArmorRuleArgs();
422
+ expect(
423
+ isMcpArmorRule({ ...args, match: { expr: { expression: args.match.expr.expression } } }),
424
+ ).toBe(true);
425
+ expect(mcpArmorRuleFlags()).toEqual([
426
+ `--description=${MCP_ARMOR_RULE.description}`,
427
+ `--expression=request.path.matches('${MCP_ARMOR_PATH_PATTERN}')`,
428
+ "--action=throttle",
429
+ "--rate-limit-threshold-count=600",
430
+ "--rate-limit-threshold-interval-sec=60",
431
+ "--conform-action=allow",
432
+ "--exceed-action=deny-429",
433
+ "--enforce-on-key=IP",
434
+ ]);
435
+ });
436
+
437
+ it("the gcp stack declares the rule from the shared module", async () => {
438
+ const src = await Bun.file(new URL("../stacks/gcp/index.ts", import.meta.url)).text();
439
+ expect(src).toContain("mcpArmorRuleArgs(),");
440
+ expect(src.indexOf("mcpArmorRuleArgs(),")).toBeLessThan(
441
+ src.indexOf("evaluatePreconfiguredWaf"),
442
+ );
443
+ });
444
+
445
+ const map = (paths: string[], extra: object[] = []): LiveUrlMap => ({
446
+ name: "caelo-production-url-map-abc",
447
+ fingerprint: "fp",
448
+ hostRules: [
449
+ { hosts: ["acme.com"], pathMatcher: "public" },
450
+ { hosts: ["admin.acme.com"], pathMatcher: "admin" },
451
+ ],
452
+ pathMatchers: [
453
+ {
454
+ name: "public",
455
+ pathRules: [
456
+ { paths, service: "projects/acme/global/backendServices/gw" },
457
+ ...(extra as never[]),
458
+ ],
459
+ },
460
+ { name: "admin", pathRules: [] },
461
+ ],
462
+ });
463
+
464
+ it("adds the discovery paths to the /api/* rule and keeps the admin's redirects", () => {
465
+ const redirect = { paths: ["/old"], urlRedirect: { pathRedirect: "/new" } };
466
+ const plan = planUrlMapPaths([map(["/api/*"], [redirect])], "acme.com");
467
+ if (!plan.ok) throw new Error(plan.error);
468
+ expect(plan.added).toEqual(GCP_PUBLIC_BASE_PATHS.filter((p) => p !== "/api/*"));
469
+ const rules = plan.map.pathMatchers?.[0]?.pathRules ?? [];
470
+ expect(rules[0]?.paths).toEqual([...GCP_PUBLIC_BASE_PATHS]);
471
+ expect(rules[1]).toEqual(redirect);
472
+ expect(plan.map.fingerprint).toBe("fp");
473
+ });
474
+
475
+ it("changes nothing on a map that already routes them", () => {
476
+ const plan = planUrlMapPaths([map([...GCP_PUBLIC_BASE_PATHS])], "acme.com");
477
+ expect(plan.ok && plan.added).toEqual([]);
478
+ });
479
+
480
+ it("refuses a map without the gateway rule instead of inventing one", () => {
481
+ const plan = planUrlMapPaths([map(["/other/*"])], "acme.com");
482
+ expect(plan.ok).toBe(false);
483
+ expect(planUrlMapPaths([], "acme.com").ok).toBe(false);
484
+ });
485
+
486
+ it("creates the Cloud Armor rule when missing, leaves it alone when in shape", async () => {
487
+ const policyJson = (rules: object[]) => ok(JSON.stringify({ rules }));
488
+ const missing = fakeGcloud({
489
+ "compute security-policies list": ok("caelo-production-waf-1a2b\n"),
490
+ "compute security-policies describe": policyJson([{ priority: 1000 }]),
491
+ "compute security-policies rules create 900": ok(),
492
+ });
493
+ expect(await ensureMcpArmorRule({ projectId: "acme", env: "production" }, missing)).toEqual({
494
+ ok: true,
495
+ policy: "caelo-production-waf-1a2b",
496
+ action: "created",
497
+ });
498
+ expect(missing.calls.at(-1)).toContain("--security-policy=caelo-production-waf-1a2b");
499
+
500
+ const args = mcpArmorRuleArgs();
501
+ const present = fakeGcloud({
502
+ "compute security-policies list": ok("caelo-production-waf-1a2b\n"),
503
+ "compute security-policies describe": policyJson([{ ...args }]),
504
+ });
505
+ const r = await ensureMcpArmorRule({ projectId: "acme", env: "production" }, present);
506
+ expect(r.ok && r.action).toBe("present");
507
+ expect(present.calls.some((c) => c.includes("rules "))).toBe(false);
508
+
509
+ const drifted = fakeGcloud({
510
+ "compute security-policies list": ok("caelo-production-waf-1a2b\n"),
511
+ "compute security-policies describe": policyJson([{ ...args, action: "allow" }]),
512
+ "compute security-policies rules update 900": ok(),
513
+ });
514
+ const d = await ensureMcpArmorRule({ projectId: "acme", env: "production" }, drifted);
515
+ expect(d.ok && d.action).toBe("updated");
516
+ });
517
+ });