@wootsup/mcp 0.1.0-rc.9 → 0.3.0

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 (240) hide show
  1. package/CHANGELOG.md +148 -83
  2. package/README.md +36 -32
  3. package/SECURITY.md +15 -6
  4. package/dist/auth/keychain.d.ts +27 -1
  5. package/dist/auth/keychain.js +48 -2
  6. package/dist/auth/keychain.js.map +1 -1
  7. package/dist/cli-hint.d.ts +22 -0
  8. package/dist/cli-hint.js +55 -0
  9. package/dist/cli-hint.js.map +1 -0
  10. package/dist/index.d.ts +19 -0
  11. package/dist/index.js +163 -22
  12. package/dist/index.js.map +1 -1
  13. package/dist/install-skill.js +1 -1
  14. package/dist/modules/apimapper/cache.d.ts +2 -2
  15. package/dist/modules/apimapper/cache.js +119 -29
  16. package/dist/modules/apimapper/cache.js.map +1 -1
  17. package/dist/modules/apimapper/client.d.ts +102 -1
  18. package/dist/modules/apimapper/client.js +631 -297
  19. package/dist/modules/apimapper/client.js.map +1 -1
  20. package/dist/modules/apimapper/connections-format.d.ts +51 -0
  21. package/dist/modules/apimapper/connections-format.js +261 -0
  22. package/dist/modules/apimapper/connections-format.js.map +1 -0
  23. package/dist/modules/apimapper/connections-trim.d.ts +82 -0
  24. package/dist/modules/apimapper/connections-trim.js +224 -0
  25. package/dist/modules/apimapper/connections-trim.js.map +1 -0
  26. package/dist/modules/apimapper/connections.d.ts +14 -2
  27. package/dist/modules/apimapper/connections.js +612 -153
  28. package/dist/modules/apimapper/connections.js.map +1 -1
  29. package/dist/modules/apimapper/credential-sanitizer.d.ts +5 -0
  30. package/dist/modules/apimapper/credential-sanitizer.js +60 -1
  31. package/dist/modules/apimapper/credential-sanitizer.js.map +1 -1
  32. package/dist/modules/apimapper/credentials-format.d.ts +21 -0
  33. package/dist/modules/apimapper/credentials-format.js +145 -0
  34. package/dist/modules/apimapper/credentials-format.js.map +1 -0
  35. package/dist/modules/apimapper/credentials.d.ts +12 -2
  36. package/dist/modules/apimapper/credentials.js +226 -73
  37. package/dist/modules/apimapper/credentials.js.map +1 -1
  38. package/dist/modules/apimapper/diagnose.d.ts +54 -2
  39. package/dist/modules/apimapper/diagnose.js +213 -12
  40. package/dist/modules/apimapper/diagnose.js.map +1 -1
  41. package/dist/modules/apimapper/elicitation.d.ts +54 -0
  42. package/dist/modules/apimapper/elicitation.js +90 -0
  43. package/dist/modules/apimapper/elicitation.js.map +1 -0
  44. package/dist/modules/apimapper/flows-format.d.ts +50 -0
  45. package/dist/modules/apimapper/flows-format.js +318 -0
  46. package/dist/modules/apimapper/flows-format.js.map +1 -0
  47. package/dist/modules/apimapper/flows.d.ts +13 -2
  48. package/dist/modules/apimapper/flows.js +312 -122
  49. package/dist/modules/apimapper/flows.js.map +1 -1
  50. package/dist/modules/apimapper/gateway/advanced-tool.d.ts +9 -0
  51. package/dist/modules/apimapper/gateway/advanced-tool.js +265 -0
  52. package/dist/modules/apimapper/gateway/advanced-tool.js.map +1 -0
  53. package/dist/modules/apimapper/gateway/capturing-server.d.ts +81 -0
  54. package/dist/modules/apimapper/gateway/capturing-server.js +87 -0
  55. package/dist/modules/apimapper/gateway/capturing-server.js.map +1 -0
  56. package/dist/modules/apimapper/gateway/essentials.d.ts +4 -0
  57. package/dist/modules/apimapper/gateway/essentials.js +35 -0
  58. package/dist/modules/apimapper/gateway/essentials.js.map +1 -0
  59. package/dist/modules/apimapper/gateway/test-support.d.ts +17 -0
  60. package/dist/modules/apimapper/gateway/test-support.js +43 -0
  61. package/dist/modules/apimapper/gateway/test-support.js.map +1 -0
  62. package/dist/modules/apimapper/get-skill.d.ts +3 -3
  63. package/dist/modules/apimapper/get-skill.js +47 -7
  64. package/dist/modules/apimapper/get-skill.js.map +1 -1
  65. package/dist/modules/apimapper/graph-builder.js +1 -1
  66. package/dist/modules/apimapper/graph-builder.js.map +1 -1
  67. package/dist/modules/apimapper/graph.d.ts +2 -2
  68. package/dist/modules/apimapper/graph.js +170 -35
  69. package/dist/modules/apimapper/graph.js.map +1 -1
  70. package/dist/modules/apimapper/index.d.ts +17 -1
  71. package/dist/modules/apimapper/index.js +68 -17
  72. package/dist/modules/apimapper/index.js.map +1 -1
  73. package/dist/modules/apimapper/inspect.d.ts +3 -2
  74. package/dist/modules/apimapper/inspect.js +97 -13
  75. package/dist/modules/apimapper/inspect.js.map +1 -1
  76. package/dist/modules/apimapper/library.d.ts +2 -2
  77. package/dist/modules/apimapper/library.js +665 -80
  78. package/dist/modules/apimapper/library.js.map +1 -1
  79. package/dist/modules/apimapper/license-format.d.ts +22 -0
  80. package/dist/modules/apimapper/license-format.js +149 -0
  81. package/dist/modules/apimapper/license-format.js.map +1 -0
  82. package/dist/modules/apimapper/license.d.ts +16 -2
  83. package/dist/modules/apimapper/license.js +62 -38
  84. package/dist/modules/apimapper/license.js.map +1 -1
  85. package/dist/modules/apimapper/local-sources.d.ts +2 -2
  86. package/dist/modules/apimapper/local-sources.js +44 -30
  87. package/dist/modules/apimapper/local-sources.js.map +1 -1
  88. package/dist/modules/apimapper/misc.d.ts +30 -2
  89. package/dist/modules/apimapper/misc.js +114 -49
  90. package/dist/modules/apimapper/misc.js.map +1 -1
  91. package/dist/modules/apimapper/node-schema.d.ts +52 -0
  92. package/dist/modules/apimapper/node-schema.js +70 -2
  93. package/dist/modules/apimapper/node-schema.js.map +1 -1
  94. package/dist/modules/apimapper/normalizers.d.ts +1 -0
  95. package/dist/modules/apimapper/normalizers.js +51 -0
  96. package/dist/modules/apimapper/normalizers.js.map +1 -1
  97. package/dist/modules/apimapper/onboarding.d.ts +78 -3
  98. package/dist/modules/apimapper/onboarding.js +428 -26
  99. package/dist/modules/apimapper/onboarding.js.map +1 -1
  100. package/dist/modules/apimapper/read-cache.d.ts +31 -2
  101. package/dist/modules/apimapper/read-cache.js +20 -6
  102. package/dist/modules/apimapper/read-cache.js.map +1 -1
  103. package/dist/modules/apimapper/render/_shared.d.ts +24 -0
  104. package/dist/modules/apimapper/render/_shared.js +84 -0
  105. package/dist/modules/apimapper/render/_shared.js.map +1 -0
  106. package/dist/modules/apimapper/render/dag.d.ts +18 -0
  107. package/dist/modules/apimapper/render/dag.js +70 -0
  108. package/dist/modules/apimapper/render/dag.js.map +1 -0
  109. package/dist/modules/apimapper/render/index.d.ts +2 -0
  110. package/dist/modules/apimapper/render/index.js +112 -0
  111. package/dist/modules/apimapper/render/index.js.map +1 -0
  112. package/dist/modules/apimapper/render/renderers/chart-bar.d.ts +2 -0
  113. package/dist/modules/apimapper/render/renderers/chart-bar.js +70 -0
  114. package/dist/modules/apimapper/render/renderers/chart-bar.js.map +1 -0
  115. package/dist/modules/apimapper/render/renderers/chart-line.d.ts +2 -0
  116. package/dist/modules/apimapper/render/renderers/chart-line.js +71 -0
  117. package/dist/modules/apimapper/render/renderers/chart-line.js.map +1 -0
  118. package/dist/modules/apimapper/render/renderers/diff.d.ts +2 -0
  119. package/dist/modules/apimapper/render/renderers/diff.js +154 -0
  120. package/dist/modules/apimapper/render/renderers/diff.js.map +1 -0
  121. package/dist/modules/apimapper/render/renderers/flow-diagram.d.ts +1 -0
  122. package/dist/modules/apimapper/render/renderers/flow-diagram.js +180 -0
  123. package/dist/modules/apimapper/render/renderers/flow-diagram.js.map +1 -0
  124. package/dist/modules/apimapper/render/renderers/json-tree.d.ts +2 -0
  125. package/dist/modules/apimapper/render/renderers/json-tree.js +87 -0
  126. package/dist/modules/apimapper/render/renderers/json-tree.js.map +1 -0
  127. package/dist/modules/apimapper/render/renderers/schema-diagram.d.ts +2 -0
  128. package/dist/modules/apimapper/render/renderers/schema-diagram.js +83 -0
  129. package/dist/modules/apimapper/render/renderers/schema-diagram.js.map +1 -0
  130. package/dist/modules/apimapper/render/renderers/table.d.ts +2 -0
  131. package/dist/modules/apimapper/render/renderers/table.js +75 -0
  132. package/dist/modules/apimapper/render/renderers/table.js.map +1 -0
  133. package/dist/modules/apimapper/render/schemas.d.ts +23 -0
  134. package/dist/modules/apimapper/render/schemas.js +56 -0
  135. package/dist/modules/apimapper/render/schemas.js.map +1 -0
  136. package/dist/modules/apimapper/render/secret-masking.d.ts +5 -0
  137. package/dist/modules/apimapper/render/secret-masking.js +51 -0
  138. package/dist/modules/apimapper/render/secret-masking.js.map +1 -0
  139. package/dist/modules/apimapper/render/sidecar.d.ts +21 -0
  140. package/dist/modules/apimapper/render/sidecar.js +66 -0
  141. package/dist/modules/apimapper/render/sidecar.js.map +1 -0
  142. package/dist/modules/apimapper/render/token-cap.d.ts +21 -0
  143. package/dist/modules/apimapper/render/token-cap.js +57 -0
  144. package/dist/modules/apimapper/render/token-cap.js.map +1 -0
  145. package/dist/modules/apimapper/schema.d.ts +2 -2
  146. package/dist/modules/apimapper/schema.js +92 -33
  147. package/dist/modules/apimapper/schema.js.map +1 -1
  148. package/dist/modules/apimapper/settings-format.d.ts +23 -0
  149. package/dist/modules/apimapper/settings-format.js +135 -0
  150. package/dist/modules/apimapper/settings-format.js.map +1 -0
  151. package/dist/modules/apimapper/settings.d.ts +2 -2
  152. package/dist/modules/apimapper/settings.js +100 -42
  153. package/dist/modules/apimapper/settings.js.map +1 -1
  154. package/dist/modules/apimapper/sites-tools.d.ts +29 -0
  155. package/dist/modules/apimapper/sites-tools.js +165 -0
  156. package/dist/modules/apimapper/sites-tools.js.map +1 -0
  157. package/dist/modules/apimapper/skill-resources.d.ts +2 -2
  158. package/dist/modules/apimapper/skill-resources.js.map +1 -1
  159. package/dist/modules/apimapper/token-baseline.harness.d.ts +91 -0
  160. package/dist/modules/apimapper/token-baseline.harness.js +291 -0
  161. package/dist/modules/apimapper/token-baseline.harness.js.map +1 -0
  162. package/dist/modules/apimapper/tool-result.d.ts +46 -0
  163. package/dist/modules/apimapper/tool-result.js +63 -0
  164. package/dist/modules/apimapper/tool-result.js.map +1 -0
  165. package/dist/modules/apimapper/toolslist-size.d.ts +56 -0
  166. package/dist/modules/apimapper/toolslist-size.js +192 -0
  167. package/dist/modules/apimapper/toolslist-size.js.map +1 -0
  168. package/dist/modules/apimapper/types.d.ts +44 -8
  169. package/dist/modules/apimapper/types.js +26 -1
  170. package/dist/modules/apimapper/types.js.map +1 -1
  171. package/dist/modules/apimapper/use-profile.d.ts +21 -0
  172. package/dist/modules/apimapper/use-profile.js +56 -2
  173. package/dist/modules/apimapper/use-profile.js.map +1 -1
  174. package/dist/modules/apimapper/whitelist-drift.d.ts +85 -0
  175. package/dist/modules/apimapper/whitelist-drift.js +360 -0
  176. package/dist/modules/apimapper/whitelist-drift.js.map +1 -0
  177. package/dist/modules/apimapper/workflows.d.ts +2 -2
  178. package/dist/modules/apimapper/workflows.js +202 -20
  179. package/dist/modules/apimapper/workflows.js.map +1 -1
  180. package/dist/modules/apimapper/yootheme-binding.d.ts +35 -0
  181. package/dist/modules/apimapper/yootheme-binding.js +186 -0
  182. package/dist/modules/apimapper/yootheme-binding.js.map +1 -0
  183. package/dist/platform/index.d.ts +56 -0
  184. package/dist/platform/index.js +195 -7
  185. package/dist/platform/index.js.map +1 -1
  186. package/dist/setup/detect-clients.d.ts +40 -1
  187. package/dist/setup/detect-clients.js +148 -1
  188. package/dist/setup/detect-clients.js.map +1 -1
  189. package/dist/setup/probe-handshake.js +40 -7
  190. package/dist/setup/probe-handshake.js.map +1 -1
  191. package/dist/setup/remove-config.d.ts +8 -0
  192. package/dist/setup/remove-config.js +145 -0
  193. package/dist/setup/remove-config.js.map +1 -0
  194. package/dist/setup/uninstall.d.ts +34 -0
  195. package/dist/setup/uninstall.js +147 -0
  196. package/dist/setup/uninstall.js.map +1 -0
  197. package/dist/setup-cli.d.ts +60 -0
  198. package/dist/setup-cli.js +155 -5
  199. package/dist/setup-cli.js.map +1 -1
  200. package/dist/sites/loader.d.ts +41 -0
  201. package/dist/sites/loader.js +119 -0
  202. package/dist/sites/loader.js.map +1 -0
  203. package/dist/sites/schema.d.ts +69 -0
  204. package/dist/sites/schema.js +71 -0
  205. package/dist/sites/schema.js.map +1 -0
  206. package/dist/sites/secret-resolver.d.ts +47 -0
  207. package/dist/sites/secret-resolver.js +150 -0
  208. package/dist/sites/secret-resolver.js.map +1 -0
  209. package/dist/skill-instructions.d.ts +1 -1
  210. package/dist/skill-instructions.js +5 -0
  211. package/dist/skill-instructions.js.map +1 -1
  212. package/dist/transports/stdio.js +4 -4
  213. package/dist/transports/stdio.js.map +1 -1
  214. package/dist/uninstall-skill.d.ts +27 -0
  215. package/dist/uninstall-skill.js +89 -0
  216. package/dist/uninstall-skill.js.map +1 -0
  217. package/docs/architecture.md +22 -22
  218. package/docs/customgraph-internal-migration.md +4 -4
  219. package/docs/security.md +2 -21
  220. package/docs/tools.md +40 -12
  221. package/manifest.json +77 -70
  222. package/package.json +68 -60
  223. package/skills/apimapper/SKILL.md +53 -7
  224. package/skills/apimapper/reference/conditional-style-multi-items.md +114 -0
  225. package/skills/apimapper/reference/jmespath-pitfalls.md +108 -0
  226. package/skills/apimapper/reference/joomla.md +1 -1
  227. package/skills/apimapper/reference/library-template-discovery.md +65 -0
  228. package/skills/apimapper/reference/merge-two-sources-on-key.md +99 -0
  229. package/skills/apimapper/reference/render.md +132 -0
  230. package/skills/apimapper/reference/troubleshooting.md +21 -1
  231. package/skills/apimapper/reference/yootheme.md +1 -1
  232. package/dist/auth/oauth-provider.d.ts +0 -68
  233. package/dist/auth/oauth-provider.js +0 -232
  234. package/dist/auth/oauth-provider.js.map +0 -1
  235. package/dist/server-http.d.ts +0 -22
  236. package/dist/server-http.js +0 -159
  237. package/dist/server-http.js.map +0 -1
  238. package/dist/transports/http.d.ts +0 -29
  239. package/dist/transports/http.js +0 -267
  240. package/dist/transports/http.js.map +0 -1
@@ -25,8 +25,10 @@
25
25
  import { createHttpClient } from "@getimo/mcp-toolkit";
26
26
  import { Agent, setGlobalDispatcher } from "undici";
27
27
  import { sanitizeSecrets } from "./credential-sanitizer.js";
28
- import { getCached, setCached, isCacheableRequest, invalidateByPath, } from "./read-cache.js";
29
- import { WordPressPlatform, JoomlaPlatform } from "../../platform/index.js";
28
+ import { getCached, setCached, isCacheableRequest, invalidateByPath, clearCache, } from "./read-cache.js";
29
+ import { WordPressPlatform, JoomlaPlatform, isPlatformResponseError, isJoomlaUnsupportedPathError } from "../../platform/index.js";
30
+ import { loadSitesRegistry } from "../../sites/loader.js";
31
+ import { resolveSiteBearer } from "../../sites/secret-resolver.js";
30
32
  // Perf-#1 (2026-05-19): HTTP keep-alive connection pool.
31
33
  //
32
34
  // A typical customer flow fires 9-10 sequential tool calls against the same
@@ -58,8 +60,44 @@ if (process.env.APIMAPPER_TOKEN && !process.env.APIMAPPER_WP_APP_PASS) {
58
60
  if (process.env.APIMAPPER_SITE_URL && !process.env.APIMAPPER_WP_BASE) {
59
61
  process.env.APIMAPPER_WP_BASE = process.env.APIMAPPER_SITE_URL;
60
62
  }
61
- export const WP_BASE = (process.env.APIMAPPER_WP_BASE || "https://dev.wootsup.com/wordpress").replace(/\/$/, "");
62
- export const WP_USER = process.env.APIMAPPER_WP_USER || "getimo";
63
+ // M-9 (2026-05-28): accept the documented `APIMAPPER_BASE_URL` alias.
64
+ // See src/index.ts for the rationale — both ESM entrypoints bridge the
65
+ // same set of aliases so module-evaluation order doesn't matter.
66
+ if (process.env.APIMAPPER_BASE_URL && !process.env.APIMAPPER_WP_BASE) {
67
+ process.env.APIMAPPER_WP_BASE = process.env.APIMAPPER_BASE_URL;
68
+ }
69
+ /**
70
+ * WordPress / Joomla site base URL.
71
+ *
72
+ * Env-var precedence: `APIMAPPER_WP_BASE` → `APIMAPPER_SITE_URL` (alias
73
+ * written by the DXT setup wizard) → empty string.
74
+ *
75
+ * The SITE_URL → WP_BASE bridge is handled exactly once, above, via
76
+ * `process.env.APIMAPPER_WP_BASE = process.env.APIMAPPER_SITE_URL`. This
77
+ * keeps a single source of truth in `process.env` so any consumer
78
+ * (including child processes spawned later, R7 verify scripts, and
79
+ * legacy CustomGraph internal code) sees the same value — not just
80
+ * this module's exported `WP_BASE` constant.
81
+ *
82
+ * Empty default is intentional (F-43, 2026-05-19): a freshly-installed
83
+ * customer who has not run the setup wizard must NOT silently inherit
84
+ * the WootsUp dev URL — that leaked the vendor's setup hostname into
85
+ * customer error logs and confused triage. In production (NODE_ENV
86
+ * === "production") an empty value causes a fail-fast exit below.
87
+ *
88
+ * Trailing slash is normalised away so downstream `${WP_BASE}/...` joins
89
+ * never produce double-slashes.
90
+ */
91
+ export const WP_BASE = (process.env.APIMAPPER_WP_BASE || "").replace(/\/$/, "");
92
+ /**
93
+ * WordPress username for legacy Application-Password Basic auth.
94
+ *
95
+ * Empty default is intentional (F-44, 2026-05-19): the previous "getimo"
96
+ * default leaked a personal username into customer 401 logs. Customers
97
+ * using the modern Bearer-token flow (`amk_live_...`) don't need this
98
+ * value at all; legacy users must set `APIMAPPER_WP_USER` explicitly.
99
+ */
100
+ export const WP_USER = process.env.APIMAPPER_WP_USER || "";
63
101
  export const WP_APP_PASS = process.env.APIMAPPER_WP_APP_PASS || "";
64
102
  // Auth-shape detection: the credential the customer pastes during setup can
65
103
  // be either a WordPress Application Password (legacy / power-user path) or
@@ -73,12 +111,22 @@ const BASIC_TOKEN = !IS_MCP_BEARER && WP_APP_PASS
73
111
  ? Buffer.from(`${WP_USER}:${WP_APP_PASS}`, "utf8").toString("base64")
74
112
  : "";
75
113
  const BEARER_TOKEN = IS_MCP_BEARER ? WP_APP_PASS : "";
76
- if (!BASIC_TOKEN && !BEARER_TOKEN && process.env.NODE_ENV === "production") {
77
- // Fail-fast: prevents silent unauthenticated client opaque 401s downstream.
78
- // In test/dev we keep the client buildable so unit tests don't require a vault.
79
- console.error("[apimapper-mcp] ERROR: APIMAPPER_TOKEN is not set. Run `npx -y @wootsup/mcp setup` " +
80
- "to generate a key and configure your AI client. Refusing to start in production.");
81
- process.exit(2);
114
+ // F-43+F-44 production fail-fast (2026-05-19): two independent guards. The
115
+ // WP_BASE check runs first and short-circuits via process.exit(2) so we
116
+ // never log two errors for one mis-configured install. In test/dev we keep
117
+ // the client buildable so unit tests don't require a vault.
118
+ if (process.env.NODE_ENV === "production") {
119
+ if (!WP_BASE) {
120
+ console.error("[apimapper-mcp] ERROR: APIMAPPER_WP_BASE (or APIMAPPER_SITE_URL) is not set. " +
121
+ "Run `npx -y @wootsup/mcp setup` to configure your AI client. Refusing to start in production.");
122
+ process.exit(2);
123
+ }
124
+ if (!BASIC_TOKEN && !BEARER_TOKEN) {
125
+ // Fail-fast: prevents silent unauthenticated client → opaque 401s downstream.
126
+ console.error("[apimapper-mcp] ERROR: APIMAPPER_TOKEN is not set. Run `npx -y @wootsup/mcp setup` " +
127
+ "to generate a key and configure your AI client. Refusing to start in production.");
128
+ process.exit(2);
129
+ }
82
130
  }
83
131
  const rawApi = createHttpClient({
84
132
  baseUrl: `${WP_BASE}/wp-json/api-mapper/v1`,
@@ -113,12 +161,41 @@ function classify(status) {
113
161
  return "auth";
114
162
  if (status === 404)
115
163
  return "not_found";
164
+ // M1 (MCP-relay audit) — 409 is a distinct, recoverable outcome: the
165
+ // library-first guard block and optimistic-lock conflicts both use it. Map
166
+ // it to its own code so callers can branch on "conflict" without re-parsing
167
+ // the status. hintFor() falls through to HEALTH_HINT for it (no auth/retry
168
+ // recovery applies to a deliberate guard block — the structured errorBody
169
+ // carries the actionable next step instead).
170
+ if (status === 409)
171
+ return "conflict";
116
172
  if (status === 429)
117
173
  return "rate_limit";
118
174
  if (status >= 500)
119
175
  return "server";
120
176
  return "unknown";
121
177
  }
178
+ /**
179
+ * Recover the upstream HTTP status carried inside a Joomla com_ajax failure
180
+ * envelope. com_ajax always returns HTTP 200 and signals the real status in a
181
+ * `code` field on the `{success:false, error, code}` body (mirrors what the
182
+ * admin-ui Joomla client reads — `resultObj.code === 409` / `=== 404`). Both a
183
+ * numeric `code` (`409`) and a stringified numeric `code` (`"409"`) are
184
+ * accepted; non-numeric / absent values yield `undefined` so the caller keeps
185
+ * the transport status (or falls through to `"unknown"`). This is the single
186
+ * point that lets `classify()` restore 409/404/422/429/502 fidelity on Joomla.
187
+ */
188
+ function numericCodeFromBody(body) {
189
+ if (!body || typeof body !== "object")
190
+ return undefined;
191
+ const raw = body.code;
192
+ if (typeof raw === "number" && Number.isFinite(raw))
193
+ return raw;
194
+ if (typeof raw === "string" && /^\d+$/.test(raw.trim())) {
195
+ return Number.parseInt(raw, 10);
196
+ }
197
+ return undefined;
198
+ }
122
199
  const DEFAULT_TIMEOUT_MS = 45_000;
123
200
  const MIN_TIMEOUT_MS = 1_000;
124
201
  const MAX_TIMEOUT_MS = 300_000;
@@ -126,6 +203,140 @@ function resolveTimeout(opts) {
126
203
  const t = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
127
204
  return Math.max(MIN_TIMEOUT_MS, Math.min(MAX_TIMEOUT_MS, t));
128
205
  }
206
+ async function performFetch(cfg) {
207
+ const { url, headers, init, opts, parseResponse, preferMessageKey = true, attachErrorBody = false, textSuccessFalseGuard = false, } = cfg;
208
+ let status;
209
+ const timeoutMs = resolveTimeout(opts);
210
+ try {
211
+ const res = await fetch(url, {
212
+ ...init,
213
+ headers,
214
+ signal: AbortSignal.timeout(timeoutMs),
215
+ });
216
+ status = res.status;
217
+ const text = await res.text();
218
+ if (!res.ok) {
219
+ let parsed = text;
220
+ try {
221
+ parsed = JSON.parse(text);
222
+ }
223
+ catch {
224
+ // keep as text
225
+ }
226
+ const errMsg = preferMessageKey && typeof parsed === "object" && parsed && "message" in parsed
227
+ ? String(parsed.message)
228
+ : typeof parsed === "object" && parsed && "error" in parsed
229
+ ? String(parsed.error)
230
+ : `HTTP ${res.status}`;
231
+ const errorBody = typeof parsed === "object" && parsed !== null
232
+ ? parsed
233
+ : undefined;
234
+ return {
235
+ success: false,
236
+ error: sanitizeErrorString(errMsg),
237
+ status,
238
+ errorCode: classify(status),
239
+ // W2-3: the WP path ALWAYS carried an `errorBody` key on a non-2xx
240
+ // response (the value is `undefined` when the body wasn't JSON). The
241
+ // Joomla + absolute paths never set it. `attachErrorBody` reproduces
242
+ // that key-presence difference exactly.
243
+ ...(attachErrorBody ? { errorBody } : {}),
244
+ };
245
+ }
246
+ if (!text)
247
+ return { success: true, data: {}, status };
248
+ let data;
249
+ try {
250
+ data = JSON.parse(text);
251
+ }
252
+ catch {
253
+ // Non-JSON body. The absolute path additionally treats a text body that
254
+ // contains `success:false` as a payload failure under unwrapInnerSuccess.
255
+ if (textSuccessFalseGuard &&
256
+ opts.unwrapInnerSuccess &&
257
+ /success["']?\s*:\s*false/i.test(text)) {
258
+ return {
259
+ success: false,
260
+ error: sanitizeErrorString(`text response indicates success:false — ${text.slice(0, 200)}`),
261
+ status,
262
+ errorCode: "unknown",
263
+ payloadFailed: true,
264
+ };
265
+ }
266
+ // Otherwise pass through as text.
267
+ return { success: true, data: text, status };
268
+ }
269
+ // Run the (platform-specific or identity) envelope unwrap. A Joomla
270
+ // success:false envelope throws a PlatformResponseError carrying the parsed
271
+ // body; we translate that to a structured failure so callers see a uniform
272
+ // shape regardless of platform.
273
+ let unwrapped;
274
+ try {
275
+ unwrapped = parseResponse(data, init.method ?? "GET");
276
+ }
277
+ catch (e) {
278
+ // errorCode fidelity (WP↔Joomla parity fix 2026-06-07): a Joomla
279
+ // com_ajax failure throws a PlatformResponseError carrying the parsed
280
+ // envelope. com_ajax always returns HTTP 200, so the transport `status`
281
+ // is 200 — useless for classification. The REAL upstream status lives in
282
+ // the envelope `code` field. Recover it and run it through the SAME
283
+ // classify() the WordPress non-2xx path uses, restoring 409("conflict") /
284
+ // 404 / 422 / 429 / 502 fidelity on Joomla (fixes the live "guard 409
285
+ // shows as unknown on Joomla" loss). When the envelope carries no numeric
286
+ // code we keep "unknown" (no regression for handlers that omit it) and
287
+ // preserve the transport `status`.
288
+ const errorBody = isPlatformResponseError(e) ? e.body : undefined;
289
+ const envelopeStatus = numericCodeFromBody(errorBody);
290
+ return {
291
+ success: false,
292
+ error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
293
+ status: envelopeStatus ?? status,
294
+ errorCode: envelopeStatus !== undefined ? classify(envelopeStatus) : "unknown",
295
+ payloadFailed: true,
296
+ errorBody,
297
+ };
298
+ }
299
+ // unwrapInnerSuccess: treat 200 + `{success:false}` payload as an error
300
+ // (WP REST sometimes returns inner-success envelopes for connection_test,
301
+ // license_activate, etc.).
302
+ if (opts.unwrapInnerSuccess &&
303
+ unwrapped &&
304
+ typeof unwrapped === "object" &&
305
+ "success" in unwrapped) {
306
+ const inner = unwrapped.success;
307
+ if (inner === false) {
308
+ const innerErr = unwrapped.error !== undefined
309
+ ? String(unwrapped.error)
310
+ : "operation reported success:false in payload";
311
+ return {
312
+ success: false,
313
+ error: sanitizeErrorString(innerErr),
314
+ status,
315
+ errorCode: "unknown",
316
+ payloadFailed: true,
317
+ };
318
+ }
319
+ }
320
+ const out = opts.sanitize ? sanitizeSecrets(unwrapped) : unwrapped;
321
+ return { success: true, data: out, status };
322
+ }
323
+ catch (e) {
324
+ // AbortSignal.timeout fires "TimeoutError" or "AbortError" depending on the
325
+ // Node version — classify both as network/timeout.
326
+ if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) {
327
+ return {
328
+ success: false,
329
+ error: `request timed out after ${Math.round(timeoutMs / 1000)}s`,
330
+ errorCode: "network",
331
+ };
332
+ }
333
+ return {
334
+ success: false,
335
+ error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
336
+ errorCode: "network",
337
+ };
338
+ }
339
+ }
129
340
  /**
130
341
  * Create a Platform-aware HTTP client. The Platform handles URL construction,
131
342
  * auth header injection, and response envelope unwrap (e.g. Joomla's
@@ -137,109 +348,26 @@ export function createPlatformClient(platform) {
137
348
  return {
138
349
  platform,
139
350
  request: async (action, init = {}, opts = {}) => {
140
- let status;
141
- const timeoutMs = resolveTimeout(opts);
142
- try {
143
- const url = platform.buildUrl(action);
144
- const res = await fetch(url, {
145
- ...init,
146
- headers: {
147
- Accept: "application/json",
148
- "Content-Type": "application/json",
149
- "X-Requested-With": "XMLHttpRequest",
150
- ...platform.buildAuthHeaders(init.method ?? "GET"),
151
- ...init.headers,
152
- },
153
- signal: AbortSignal.timeout(timeoutMs),
154
- });
155
- status = res.status;
156
- const text = await res.text();
157
- if (!res.ok) {
158
- let parsed = text;
159
- try {
160
- parsed = JSON.parse(text);
161
- }
162
- catch {
163
- // keep as text
164
- }
165
- const errMsg = typeof parsed === "object" && parsed && "message" in parsed
166
- ? String(parsed.message)
167
- : typeof parsed === "object" && parsed && "error" in parsed
168
- ? String(parsed.error)
169
- : `HTTP ${res.status}`;
170
- return {
171
- success: false,
172
- error: sanitizeErrorString(errMsg),
173
- status,
174
- errorCode: classify(status),
175
- };
176
- }
177
- if (!text)
178
- return { success: true, data: {}, status };
179
- let data;
180
- try {
181
- data = JSON.parse(text);
182
- }
183
- catch {
184
- // Non-JSON body — pass through as text.
185
- return { success: true, data: text, status };
186
- }
187
- // Run the platform-specific envelope unwrap. Joomla throws on
188
- // success:false; we translate that to a structured failure so
189
- // tools see a uniform shape regardless of platform.
190
- let unwrapped;
191
- try {
192
- unwrapped = platform.parseResponse(data, init.method ?? "GET");
193
- }
194
- catch (e) {
195
- return {
196
- success: false,
197
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
198
- status,
199
- errorCode: "unknown",
200
- payloadFailed: true,
201
- };
202
- }
203
- // unwrapInnerSuccess for WordPress: treat 200 + `{success:false}`
204
- // payload as error (WP REST sometimes returns inner-success envelopes
205
- // for connection_test, license_activate, etc.).
206
- if (opts.unwrapInnerSuccess &&
207
- unwrapped &&
208
- typeof unwrapped === "object" &&
209
- "success" in unwrapped) {
210
- const inner = unwrapped.success;
211
- if (inner === false) {
212
- const innerErr = unwrapped.error !== undefined
213
- ? String(unwrapped.error)
214
- : "operation reported success:false in payload";
215
- return {
216
- success: false,
217
- error: sanitizeErrorString(innerErr),
218
- status,
219
- errorCode: "unknown",
220
- payloadFailed: true,
221
- };
222
- }
223
- }
224
- const out = opts.sanitize ? sanitizeSecrets(unwrapped) : unwrapped;
225
- return { success: true, data: out, status };
226
- }
227
- catch (e) {
228
- // AbortSignal.timeout fires "TimeoutError" or "AbortError" depending on
229
- // Node version — classify both as network/timeout.
230
- if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) {
231
- return {
232
- success: false,
233
- error: `request timed out after ${Math.round(timeoutMs / 1000)}s`,
234
- errorCode: "network",
235
- };
236
- }
237
- return {
238
- success: false,
239
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
240
- errorCode: "network",
241
- };
242
- }
351
+ // A3 dedup: the WP path builds its URL + auth headers, then defers the
352
+ // shared fetch → parse → unwrap → sanitize pipeline to performFetch.
353
+ // attachErrorBody:true preserves the W2-3 errorBody passthrough; the
354
+ // parseResponse is the platform's own envelope unwrap (Joomla throws a
355
+ // PlatformResponseError on success:false, threaded into errorBody by the
356
+ // shared core).
357
+ return performFetch({
358
+ url: platform.buildUrl(action),
359
+ headers: {
360
+ Accept: "application/json",
361
+ "Content-Type": "application/json",
362
+ "X-Requested-With": "XMLHttpRequest",
363
+ ...platform.buildAuthHeaders(init.method ?? "GET"),
364
+ ...init.headers,
365
+ },
366
+ init,
367
+ opts,
368
+ parseResponse: (data, method) => platform.parseResponse(data, method),
369
+ attachErrorBody: true,
370
+ });
243
371
  },
244
372
  };
245
373
  }
@@ -271,24 +399,314 @@ class WordPressBasicAuthPlatform extends WordPressPlatform {
271
399
  return {};
272
400
  }
273
401
  }
274
- const PLATFORM_KIND = (process.env.APIMAPPER_PLATFORM ?? "wordpress");
275
- const legacyPlatform = PLATFORM_KIND === "joomla"
276
- ? new JoomlaPlatform({
277
- // Joomla uses the same APIMAPPER_WP_BASE env var (or APIMAPPER_SITE_URL
278
- // alias bridged by the DXT bootstrap in src/index.ts). The token is the
279
- // raw amk_live_... bearer the setup CLI stored in the keychain. We
280
- // accept the WP_USER:WP_APP_PASS pair too split on the colon and use
281
- // the password as the bearer for ergonomic parity with the WP env vars.
282
- baseUrl: WP_BASE,
283
- token: process.env.APIMAPPER_TOKEN
284
- ?? (BASIC_TOKEN ? Buffer.from(BASIC_TOKEN, "base64").toString().split(":")[1] ?? "" : ""),
285
- })
286
- : new WordPressBasicAuthPlatform({
287
- baseUrl: WP_BASE,
288
- basicToken: BASIC_TOKEN,
289
- bearerToken: BEARER_TOKEN,
290
- });
291
- const legacyClient = createPlatformClient(legacyPlatform);
402
+ // A1-P2 (W1-polish): Narrow the platform-kind selection with a case-strict
403
+ // `=== "joomla"` check instead of an unsafe `as` cast on the raw env value.
404
+ // The cast silently coerced any string ("WORDPRESS", "standalone", typos
405
+ // like "jooma") into the union type, even though the runtime ternary below
406
+ // only branches on the exact literal "joomla". Aligning the type with the
407
+ // actual decision keeps a future widening (.toLowerCase(), trim, etc.) from
408
+ // flipping behaviour silently anything that isn't the literal "joomla"
409
+ // must continue to default to WordPress.
410
+ //
411
+ // Phase 1 (2026-06-03): PLATFORM_KIND remains the EXPLICIT env→kind mapping
412
+ // (the "hint"). It is read by the build-dxt grep gate (a literal
413
+ // `process.env.APIMAPPER_PLATFORM` consumer must live in src/) and by 6
414
+ // client.test.ts assertions. The NETWORK auto-detect (probePlatform +
415
+ // resolveLegacy below) layers ON TOP of it: it is consulted ONLY when the env
416
+ // var is neither the literal "wordpress" nor "joomla" (i.e. unset or "auto").
417
+ export const PLATFORM_KIND = process.env.APIMAPPER_PLATFORM === "joomla" ? "joomla" : "wordpress";
418
+ /**
419
+ * The Bearer token the legacy platform clients authenticate with. The setup
420
+ * CLI stores the raw `amk_live_…` MCP key; legacy App-Password users instead
421
+ * set WP_USER:WP_APP_PASS, in which case we recover the password half from the
422
+ * pre-computed BASIC_TOKEN. Computed once — both the identity probe and the
423
+ * resolved platform reuse it so the wire-level auth is identical.
424
+ */
425
+ const LEGACY_BEARER = process.env.APIMAPPER_TOKEN
426
+ ?? (BASIC_TOKEN ? Buffer.from(BASIC_TOKEN, "base64").toString().split(":")[1] ?? "" : "");
427
+ /**
428
+ * Build the Authorization header the identity probe sends. Prefers the modern
429
+ * Bearer token (amk_live_… / recovered App-Password) and falls back to the
430
+ * legacy Basic header so a pure WP_USER:WP_APP_PASS install still probes
431
+ * authenticated. Empty object when no credential is configured (the probe then
432
+ * relies on a public identity endpoint, degrading to the WordPress fallback).
433
+ */
434
+ function probeAuthHeader() {
435
+ if (LEGACY_BEARER)
436
+ return { Authorization: `Bearer ${LEGACY_BEARER}` };
437
+ if (BASIC_TOKEN)
438
+ return { Authorization: `Basic ${BASIC_TOKEN}` };
439
+ return {};
440
+ }
441
+ const PROBE_TIMEOUT_MS = 8_000;
442
+ /**
443
+ * Network platform auto-detect. Concurrently probes the WordPress REST identity
444
+ * endpoint and the Joomla com_ajax getIdentity task with the same auth header,
445
+ * and decides which CMS is actually running at WP_BASE:
446
+ *
447
+ * - Joomla wins if its probe is HTTP 200 AND the body parses to
448
+ * `{success:true, data:[{success:true, …}]}` (or `data[0].platform` set).
449
+ * - WordPress wins if its probe is HTTP 200 AND the body is a JSON object
450
+ * that is NOT a `{success:false}` error envelope.
451
+ * - If both look OK (shouldn't happen on a single CMS), prefer the one whose
452
+ * reported identity `.platform` matches its own kind; else WordPress.
453
+ * - If neither answers → return "wordpress" (graceful fallback — no worse
454
+ * than the pre-Phase-1 silent default).
455
+ *
456
+ * All fetch errors are swallowed → fallback. The token is sent on the wire but
457
+ * never logged. Short timeout so a blocked probe host can't stall startup.
458
+ *
459
+ * Phase 3 (2026-06-03): accepts optional `(baseUrl, token)` overrides so a
460
+ * per-site probe (sites-file path) can target the entry's own URL + token. The
461
+ * no-arg call preserves the env-path behaviour exactly (module WP_BASE +
462
+ * probeAuthHeader()).
463
+ */
464
+ export async function probePlatform(baseUrl, token) {
465
+ const base = (baseUrl ?? WP_BASE).replace(/\/$/, "");
466
+ if (!base)
467
+ return "wordpress";
468
+ const headers = {
469
+ Accept: "application/json",
470
+ "X-Requested-With": "XMLHttpRequest",
471
+ ...(token ? { Authorization: `Bearer ${token}` } : probeAuthHeader()),
472
+ };
473
+ const wpUrl = `${base}/wp-json/api-mapper/v1/identity`;
474
+ const joomlaUrl = `${base}/index.php?option=com_ajax&plugin=apimapper&task=getIdentity&format=json`;
475
+ async function probe(url) {
476
+ try {
477
+ const res = await fetch(url, {
478
+ method: "GET",
479
+ headers,
480
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
481
+ });
482
+ if (!res.ok)
483
+ return { ok: false, body: undefined };
484
+ const text = await res.text();
485
+ let body;
486
+ try {
487
+ body = JSON.parse(text);
488
+ }
489
+ catch {
490
+ return { ok: false, body: undefined };
491
+ }
492
+ return { ok: true, body };
493
+ }
494
+ catch {
495
+ // Network error / timeout / abort → treat as "no answer".
496
+ return null;
497
+ }
498
+ }
499
+ const [wp, joomla] = await Promise.all([probe(wpUrl), probe(joomlaUrl)]);
500
+ const joomlaLooksOk = (() => {
501
+ if (!joomla?.ok)
502
+ return false;
503
+ const b = joomla.body;
504
+ if (!b || typeof b !== "object" || !("success" in b))
505
+ return false;
506
+ if (b.success !== true)
507
+ return false;
508
+ const data = b.data;
509
+ if (!Array.isArray(data) || data.length === 0)
510
+ return false;
511
+ const first = data[0];
512
+ if (!first || typeof first !== "object")
513
+ return false;
514
+ const f = first;
515
+ return f.success === true || f.platform !== undefined;
516
+ })();
517
+ const wpLooksOk = (() => {
518
+ if (!wp?.ok)
519
+ return false;
520
+ const b = wp.body;
521
+ if (!b || typeof b !== "object")
522
+ return false;
523
+ // A WP identity object is a plain object; explicitly reject an error
524
+ // envelope ({success:false}) so a Joomla 200 success:false leaking through
525
+ // a misconfigured route doesn't masquerade as a WordPress identity.
526
+ if ("success" in b && b.success === false)
527
+ return false;
528
+ return true;
529
+ })();
530
+ if (joomlaLooksOk && wpLooksOk) {
531
+ // Both answered (one CMS shouldn't expose both). Disambiguate by the
532
+ // self-reported platform, else fall back to WordPress.
533
+ const data = (joomla?.body).data;
534
+ const jPlatform = data?.[0]?.platform;
535
+ if (jPlatform === "joomla")
536
+ return "joomla";
537
+ return "wordpress";
538
+ }
539
+ if (joomlaLooksOk)
540
+ return "joomla";
541
+ if (wpLooksOk)
542
+ return "wordpress";
543
+ return "wordpress";
544
+ }
545
+ /**
546
+ * Build the legacy Platform + PlatformClient for a resolved kind. Reproduces
547
+ * exactly the per-kind construction the old module-eval consts performed.
548
+ */
549
+ function buildLegacy(kind) {
550
+ const platform = kind === "joomla"
551
+ ? new JoomlaPlatform({
552
+ // Joomla uses the same APIMAPPER_WP_BASE env var (or
553
+ // APIMAPPER_SITE_URL alias bridged by the DXT bootstrap). The token
554
+ // is the raw amk_live_… bearer; we also accept the WP_USER:WP_APP_PASS
555
+ // pair (the password half recovered into LEGACY_BEARER).
556
+ baseUrl: WP_BASE,
557
+ token: LEGACY_BEARER,
558
+ })
559
+ : new WordPressBasicAuthPlatform({
560
+ baseUrl: WP_BASE,
561
+ basicToken: BASIC_TOKEN,
562
+ bearerToken: BEARER_TOKEN,
563
+ });
564
+ return { platform, client: createPlatformClient(platform) };
565
+ }
566
+ // ── Phase 3: sites-file multi-site (active-site routing) ───────────────
567
+ //
568
+ // When APIMAPPER_SITES_FILE points at a non-empty sites.json, resolveLegacy()
569
+ // resolves the ACTIVE site's {baseUrl, token, platform} from the file rather
570
+ // than the env single-site vars. The active selection is an in-memory pointer
571
+ // (`_activeSiteId`) that `setActiveSite()` flips; when unset the file's default
572
+ // entry is used. Switching the pointer resets the resolution memo so the very
573
+ // next `request()` retargets the new site. The keychain ProfileStore path
574
+ // (src/auth/profiles.ts) stays the single-machine default — the two mechanisms
575
+ // coexist; the sites-file wins ONLY when the env var is set + the file loads
576
+ // non-empty.
577
+ /** Memoized sites-file registry. `null` = no usable sites-file (env path). */
578
+ let _sitesRegistry;
579
+ /**
580
+ * Lazily load + memoize the sites-file registry from APIMAPPER_SITES_FILE.
581
+ * Returns `null` when the env var is unset/empty, the file is absent, or it has
582
+ * zero sites — in all those cases the caller falls through to the env path.
583
+ * A malformed / schema-invalid file throws `SitesFileError` (loud-fail at first
584
+ * use rather than silently degrading multi-site to single-site).
585
+ */
586
+ export function getSitesRegistry() {
587
+ if (_sitesRegistry !== undefined)
588
+ return _sitesRegistry;
589
+ _sitesRegistry = loadSitesRegistry(process.env.APIMAPPER_SITES_FILE);
590
+ return _sitesRegistry;
591
+ }
592
+ /** In-memory active-site pointer. Only meaningful when a sites-file is loaded. */
593
+ let _activeSiteId;
594
+ /**
595
+ * The currently-active site_id, or `null` when none has been explicitly set
596
+ * (the file's default entry is then used) or when no sites-file is loaded.
597
+ */
598
+ export function getActiveSiteId() {
599
+ return _activeSiteId ?? null;
600
+ }
601
+ /**
602
+ * Switch the active site. Validates that `id` exists in the loaded sites-file
603
+ * registry, updates the in-memory pointer, and resets the resolution memo so
604
+ * the next `request()` re-resolves (URL + token + platform) against the new
605
+ * site. Throws when no sites-file is loaded or the id is unknown — the pointer
606
+ * is NOT changed on error.
607
+ */
608
+ export function setActiveSite(id) {
609
+ const reg = getSitesRegistry();
610
+ if (!reg) {
611
+ throw new Error("setActiveSite: no sites-file loaded (APIMAPPER_SITES_FILE unset or empty). " +
612
+ "Multi-site switching via a sites-file is unavailable.");
613
+ }
614
+ if (!reg.has(id)) {
615
+ throw new Error(`setActiveSite: unknown site_id "${id}". Known: ${reg.listIds().join(", ") || "(none)"}.`);
616
+ }
617
+ _activeSiteId = id;
618
+ _legacyResolution = undefined;
619
+ // The read-cache is keyed by path+method, NOT by site — switching the active
620
+ // backend would otherwise serve the previous site's cached GET responses.
621
+ // Flush it so reads after a switch hit the newly-targeted site.
622
+ clearCache();
623
+ }
624
+ /**
625
+ * Build the legacy Platform + client for a single sites-file entry. The entry's
626
+ * own url + resolved token + platform hint drive construction; an `auto` hint
627
+ * triggers a per-entry network probe against the entry's URL.
628
+ */
629
+ async function buildLegacyForSite(entry) {
630
+ const { token } = await resolveSiteBearer(entry);
631
+ const baseUrl = entry.url.replace(/\/$/, "");
632
+ const kind = entry.platform === "joomla"
633
+ ? "joomla"
634
+ : entry.platform === "wordpress"
635
+ ? "wordpress"
636
+ : await probePlatform(baseUrl, token);
637
+ const platform = kind === "joomla"
638
+ ? new JoomlaPlatform({ baseUrl, token })
639
+ : // Sites-file always carries a Bearer (amk_…) token — use the Bearer
640
+ // branch of the WP platform (no legacy Basic App-Password here).
641
+ new WordPressBasicAuthPlatform({ baseUrl, basicToken: "", bearerToken: token });
642
+ return { platform, client: createPlatformClient(platform) };
643
+ }
644
+ /**
645
+ * Lazy, memoized platform resolution for the legacy `request()` path.
646
+ *
647
+ * Replaces the former module-eval `legacyPlatform`/`legacyClient` consts so the
648
+ * network probe (which is async) can run on first use without blocking ESM
649
+ * evaluation. Resolution order:
650
+ *
651
+ * 1. **sites-file** (APIMAPPER_SITES_FILE set + loads non-empty): the active
652
+ * entry = the in-memory active-site pointer if set, else the file's default
653
+ * entry. Its url/token drive the client; platform = the entry's explicit
654
+ * hint or (when `auto`) a per-entry probe of the entry's URL.
655
+ * 2. explicit env `APIMAPPER_PLATFORM === "joomla"` → Joomla, NO probe.
656
+ * 3. explicit env `APIMAPPER_PLATFORM === "wordpress"` → WordPress, NO probe.
657
+ * 4. anything else (unset / "auto") → `await probePlatform()` against WP_BASE.
658
+ *
659
+ * The result is memoized for the lifetime of the module instance — a second
660
+ * `request()` reuses it without re-probing. `setActiveSite()` and
661
+ * `resetPlatformResolution()` clear the memo. Tests reset via
662
+ * `__resetPlatformResolutionForTests()` (an alias of resetPlatformResolution).
663
+ */
664
+ let _legacyResolution;
665
+ function resolveLegacy() {
666
+ if (_legacyResolution)
667
+ return _legacyResolution;
668
+ _legacyResolution = (async () => {
669
+ const reg = getSitesRegistry();
670
+ if (reg) {
671
+ const entry = (_activeSiteId ? reg.get(_activeSiteId) : null) ?? reg.getDefault();
672
+ return buildLegacyForSite(entry);
673
+ }
674
+ const env = process.env.APIMAPPER_PLATFORM;
675
+ const kind = env === "joomla"
676
+ ? "joomla"
677
+ : env === "wordpress"
678
+ ? "wordpress"
679
+ : await probePlatform();
680
+ return buildLegacy(kind);
681
+ })();
682
+ return _legacyResolution;
683
+ }
684
+ /**
685
+ * Drop the memoized platform resolution (and the cached sites-file registry +
686
+ * active-site pointer) so the next `request()` re-resolves from scratch —
687
+ * re-reading APIMAPPER_SITES_FILE, re-running the env-check / network probe.
688
+ *
689
+ * Production callers: invoke after deliberately changing the active selection
690
+ * outside `setActiveSite()` (the tool wiring uses `setActiveSite`, which already
691
+ * resets the memo). Safe to call any time.
692
+ */
693
+ export function resetPlatformResolution() {
694
+ _legacyResolution = undefined;
695
+ _sitesRegistry = undefined;
696
+ _activeSiteId = undefined;
697
+ // Drop any cached GET responses too — a full re-resolution may re-target a
698
+ // different backend (sites-file reload / active-site change), and the
699
+ // read-cache is not site-aware.
700
+ clearCache();
701
+ }
702
+ /**
703
+ * Test-only alias of {@link resetPlatformResolution}, kept for the existing
704
+ * Phase-1 client tests that reference it by this name. New code should call
705
+ * `resetPlatformResolution()`.
706
+ */
707
+ export function __resetPlatformResolutionForTests() {
708
+ resetPlatformResolution();
709
+ }
292
710
  /**
293
711
  * Issue a request through the toolkit HTTP client + capture HTTP status.
294
712
  *
@@ -310,28 +728,39 @@ export async function request(path, init = {}, opts = {}) {
310
728
  const upperMethod = method.toUpperCase();
311
729
  const cacheEligible = !opts.noCache && isCacheableRequest(upperMethod, path);
312
730
  if (cacheEligible) {
313
- const hit = getCached(upperMethod, path);
731
+ // F-02 (2026-05-19): the sanitize flag participates in the cache key.
732
+ // Without this, a sanitized response could be served to a raw-mode
733
+ // caller (or vice versa) on the same URL — Defense-in-Depth gap A5.
734
+ const hit = getCached(upperMethod, path, { sanitize: opts.sanitize });
314
735
  if (hit)
315
736
  return hit;
316
737
  }
317
738
  // Strip the leading "/" — Platform.buildUrl() takes a bare action name.
318
739
  // Also tolerate the existing escape-hatch where `path` starts with "http"
319
740
  // (absolute URL); fall back to raw fetch in that case.
741
+ //
742
+ // Phase 1 (2026-06-03): the legacy platform is now resolved lazily (env-hint
743
+ // first, network probe when unset/auto) + memoized. resolveLegacy() awaits at
744
+ // most one probe round-trip on the very first request; subsequent calls reuse
745
+ // the cached resolution.
320
746
  let response;
321
747
  if (path.startsWith("http")) {
322
748
  response = await rawAbsoluteRequest(path, init, opts);
323
749
  }
324
- else if (legacyPlatform instanceof JoomlaPlatform) {
325
- // Joomla branch — translate REST path → com_ajax task + params.
326
- response = await joomlaRequest(legacyPlatform, path, init, opts);
327
- }
328
750
  else {
329
- const action = path.replace(/^\/+/, "");
330
- response = await legacyClient.request(action, init, opts);
751
+ const { platform, client } = await resolveLegacy();
752
+ if (platform instanceof JoomlaPlatform) {
753
+ // Joomla branch — translate REST path → com_ajax task + params.
754
+ response = await joomlaRequest(platform, path, init, opts);
755
+ }
756
+ else {
757
+ const action = path.replace(/^\/+/, "");
758
+ response = await client.request(action, init, opts);
759
+ }
331
760
  }
332
761
  // Cache successful responses; never cache errors so the next call retries.
333
762
  if (cacheEligible && response.success) {
334
- setCached(upperMethod, path, response);
763
+ setCached(upperMethod, path, response, { sanitize: opts.sanitize });
335
764
  }
336
765
  // Mutation invalidation: any non-GET that succeeded clears the read-cache
337
766
  // for the touched path-prefix. Conservative — better to over-invalidate
@@ -352,86 +781,51 @@ async function joomlaRequest(platform, path, init, opts) {
352
781
  translated = platform.translateRestPath(path, init.method ?? "GET");
353
782
  }
354
783
  catch (e) {
355
- return {
356
- success: false,
357
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
358
- errorCode: "unknown",
359
- };
360
- }
361
- let status;
362
- const timeoutMs = resolveTimeout(opts);
363
- try {
364
- const url = platform.buildUrl(translated.action, translated.params);
365
- const res = await fetch(url, {
366
- ...init,
367
- headers: {
368
- Accept: "application/json",
369
- "Content-Type": "application/json",
370
- "X-Requested-With": "XMLHttpRequest",
371
- ...platform.buildAuthHeaders(init.method ?? "GET"),
372
- ...init.headers,
373
- },
374
- signal: AbortSignal.timeout(timeoutMs),
375
- });
376
- status = res.status;
377
- const text = await res.text();
378
- if (!res.ok) {
379
- let parsed = text;
380
- try {
381
- parsed = JSON.parse(text);
382
- }
383
- catch {
384
- /* keep as text */
385
- }
386
- const errMsg = typeof parsed === "object" && parsed && "error" in parsed
387
- ? String(parsed.error)
388
- : `HTTP ${res.status}`;
784
+ // HIGH cross-platform finding (Wave-B audit, 2026-06-03): distinguish a
785
+ // genuinely WordPress-only feature from an unknown/typo path. For WP-only
786
+ // paths translateRestPath raises `JoomlaUnsupportedPathError`; surface it
787
+ // as a clean structured result (`errorCode: "not_found"` + `errorBody`
788
+ // carrying `unsupported: true`) so the tool's errorResult reads as
789
+ // "feature not available on Joomla" instead of leaking the opaque
790
+ // "no joomla mapping … (unknown rest path)" internal string.
791
+ if (isJoomlaUnsupportedPathError(e)) {
389
792
  return {
390
793
  success: false,
391
- error: sanitizeErrorString(errMsg),
392
- status,
393
- errorCode: classify(status),
394
- };
395
- }
396
- if (!text)
397
- return { success: true, data: {}, status };
398
- let data;
399
- try {
400
- data = JSON.parse(text);
401
- }
402
- catch {
403
- return { success: true, data: text, status };
404
- }
405
- let unwrapped;
406
- try {
407
- unwrapped = platform.parseResponse(data, init.method ?? "GET");
408
- }
409
- catch (e) {
410
- return {
411
- success: false,
412
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
413
- status,
414
- errorCode: "unknown",
415
- payloadFailed: true,
416
- };
417
- }
418
- const out = opts.sanitize ? sanitizeSecrets(unwrapped) : unwrapped;
419
- return { success: true, data: out, status };
420
- }
421
- catch (e) {
422
- if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) {
423
- return {
424
- success: false,
425
- error: `request timed out after ${Math.round(timeoutMs / 1000)}s`,
426
- errorCode: "network",
794
+ error: sanitizeErrorString(e.message),
795
+ errorCode: "not_found",
796
+ errorBody: {
797
+ unsupported: true,
798
+ platform: "joomla",
799
+ path: e.path,
800
+ },
427
801
  };
428
802
  }
429
803
  return {
430
804
  success: false,
431
805
  error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
432
- errorCode: "network",
806
+ errorCode: "unknown",
433
807
  };
434
808
  }
809
+ // A3 dedup: Joomla builds its translated com_ajax URL + auth headers, then
810
+ // defers to the shared core. preferMessageKey:false mirrors the original
811
+ // Joomla branch that reads only the `error` key; attachErrorBody stays off
812
+ // (the non-2xx errorBody passthrough is WP-only). The success:false com_ajax
813
+ // envelope is raised as a PlatformResponseError by parseResponse and threaded
814
+ // into errorBody by performFetch — identical to the WP path.
815
+ return performFetch({
816
+ url: platform.buildUrl(translated.action, translated.params),
817
+ headers: {
818
+ Accept: "application/json",
819
+ "Content-Type": "application/json",
820
+ "X-Requested-With": "XMLHttpRequest",
821
+ ...platform.buildAuthHeaders(init.method ?? "GET"),
822
+ ...init.headers,
823
+ },
824
+ init,
825
+ opts,
826
+ parseResponse: (data, method) => platform.parseResponse(data, method),
827
+ preferMessageKey: false,
828
+ });
435
829
  }
436
830
  /**
437
831
  * Absolute-URL escape hatch: callers occasionally pass a fully-qualified
@@ -440,92 +834,25 @@ async function joomlaRequest(platform, path, init, opts) {
440
834
  * to raw fetch but preserve the same auth + sanitisation semantics.
441
835
  */
442
836
  async function rawAbsoluteRequest(url, init, opts) {
443
- let status;
444
- const timeoutMs = resolveTimeout(opts);
445
- try {
446
- const res = await fetch(url, {
447
- ...init,
448
- headers: {
449
- Accept: "application/json",
450
- "Content-Type": "application/json",
451
- "X-Requested-With": "XMLHttpRequest",
452
- ...(BASIC_TOKEN ? { Authorization: `Basic ${BASIC_TOKEN}` } : {}),
453
- ...init.headers,
454
- },
455
- signal: AbortSignal.timeout(timeoutMs),
456
- });
457
- status = res.status;
458
- const text = await res.text();
459
- if (!res.ok) {
460
- let parsed = text;
461
- try {
462
- parsed = JSON.parse(text);
463
- }
464
- catch {
465
- /* keep as text */
466
- }
467
- const errMsg = typeof parsed === "object" && parsed && "message" in parsed
468
- ? String(parsed.message)
469
- : typeof parsed === "object" && parsed && "error" in parsed
470
- ? String(parsed.error)
471
- : `HTTP ${res.status}`;
472
- return {
473
- success: false,
474
- error: sanitizeErrorString(errMsg),
475
- status,
476
- errorCode: classify(status),
477
- };
478
- }
479
- if (!text)
480
- return { success: true, data: {}, status };
481
- let data;
482
- try {
483
- data = JSON.parse(text);
484
- }
485
- catch {
486
- if (opts.unwrapInnerSuccess && /success["']?\s*:\s*false/i.test(text)) {
487
- return {
488
- success: false,
489
- error: sanitizeErrorString(`text response indicates success:false — ${text.slice(0, 200)}`),
490
- status,
491
- errorCode: "unknown",
492
- payloadFailed: true,
493
- };
494
- }
495
- return { success: true, data: text, status };
496
- }
497
- if (opts.unwrapInnerSuccess && data && typeof data === "object" && "success" in data) {
498
- const inner = data.success;
499
- if (inner === false) {
500
- const innerErr = data.error !== undefined
501
- ? String(data.error)
502
- : "operation reported success:false in payload";
503
- return {
504
- success: false,
505
- error: sanitizeErrorString(innerErr),
506
- status,
507
- errorCode: "unknown",
508
- payloadFailed: true,
509
- };
510
- }
511
- }
512
- const out = opts.sanitize ? sanitizeSecrets(data) : data;
513
- return { success: true, data: out, status };
514
- }
515
- catch (e) {
516
- if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) {
517
- return {
518
- success: false,
519
- error: `request timed out after ${Math.round(timeoutMs / 1000)}s`,
520
- errorCode: "network",
521
- };
522
- }
523
- return {
524
- success: false,
525
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
526
- errorCode: "network",
527
- };
528
- }
837
+ // A3 dedup: the absolute path keeps its own Basic-auth header set (it can't
838
+ // use a Platform because the host may not match WP_BASE) and an IDENTITY
839
+ // parseResponse (no envelope to unwrap). textSuccessFalseGuard:true preserves
840
+ // the path-unique non-JSON `success:false` text guard. The unwrapInnerSuccess
841
+ // JSON check then runs on the identity-passed data, exactly as before.
842
+ return performFetch({
843
+ url,
844
+ headers: {
845
+ Accept: "application/json",
846
+ "Content-Type": "application/json",
847
+ "X-Requested-With": "XMLHttpRequest",
848
+ ...(BASIC_TOKEN ? { Authorization: `Basic ${BASIC_TOKEN}` } : {}),
849
+ ...init.headers,
850
+ },
851
+ init,
852
+ opts,
853
+ parseResponse: (data) => data,
854
+ textSuccessFalseGuard: true,
855
+ });
529
856
  }
530
857
  /**
531
858
  * Map an `errorCode` to a focused hint string for the caller.
@@ -534,7 +861,14 @@ async function rawAbsoluteRequest(url, init, opts) {
534
861
  export function hintFor(code) {
535
862
  switch (code) {
536
863
  case "auth":
537
- return "Auth failed. Your MCP key (APIMAPPER_TOKEN) is invalid, revoked, or expired. Generate a new one in API Mapper → ⋮ menu → Settings → MCP Access → New API key, then re-run `npx -y @wootsup/mcp setup` to update your AI client config.";
864
+ return ("Auth failed (HTTP 401/403). Your MCP key (APIMAPPER_TOKEN) is " +
865
+ "invalid, revoked, or expired. Recovery steps: " +
866
+ "(1) Open API Mapper → ⋮ menu → Settings → MCP Access. " +
867
+ "(2) Revoke the old key if you suspect it was leaked. " +
868
+ "(3) Click 'New API key' and copy the value. " +
869
+ "(4) Re-run `npx -y @wootsup/mcp setup` and paste the new key — " +
870
+ "the wizard rewrites your AI client config in place. " +
871
+ "(5) Restart your AI client (Claude Desktop / Cursor / etc.).");
538
872
  case "not_found":
539
873
  return "Resource not found. Use the matching `*_list` tool to find a valid id.";
540
874
  case "rate_limit":