@wootsup/mcp 0.1.0 → 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 (131) hide show
  1. package/CHANGELOG.md +148 -83
  2. package/README.md +31 -27
  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.js +97 -22
  11. package/dist/index.js.map +1 -1
  12. package/dist/install-skill.js +1 -1
  13. package/dist/modules/apimapper/cache.js +25 -17
  14. package/dist/modules/apimapper/cache.js.map +1 -1
  15. package/dist/modules/apimapper/client.d.ts +62 -1
  16. package/dist/modules/apimapper/client.js +555 -291
  17. package/dist/modules/apimapper/client.js.map +1 -1
  18. package/dist/modules/apimapper/connections.js +230 -75
  19. package/dist/modules/apimapper/connections.js.map +1 -1
  20. package/dist/modules/apimapper/credential-sanitizer.d.ts +5 -0
  21. package/dist/modules/apimapper/credential-sanitizer.js +60 -1
  22. package/dist/modules/apimapper/credential-sanitizer.js.map +1 -1
  23. package/dist/modules/apimapper/credentials.js +19 -47
  24. package/dist/modules/apimapper/credentials.js.map +1 -1
  25. package/dist/modules/apimapper/diagnose.js +21 -2
  26. package/dist/modules/apimapper/diagnose.js.map +1 -1
  27. package/dist/modules/apimapper/flows.js +60 -77
  28. package/dist/modules/apimapper/flows.js.map +1 -1
  29. package/dist/modules/apimapper/gateway/advanced-tool.js +56 -5
  30. package/dist/modules/apimapper/gateway/advanced-tool.js.map +1 -1
  31. package/dist/modules/apimapper/gateway/essentials.d.ts +1 -1
  32. package/dist/modules/apimapper/gateway/essentials.js +8 -1
  33. package/dist/modules/apimapper/gateway/essentials.js.map +1 -1
  34. package/dist/modules/apimapper/get-skill.d.ts +1 -1
  35. package/dist/modules/apimapper/get-skill.js +44 -6
  36. package/dist/modules/apimapper/get-skill.js.map +1 -1
  37. package/dist/modules/apimapper/graph.js +40 -36
  38. package/dist/modules/apimapper/graph.js.map +1 -1
  39. package/dist/modules/apimapper/index.js +2 -0
  40. package/dist/modules/apimapper/index.js.map +1 -1
  41. package/dist/modules/apimapper/library.js +425 -83
  42. package/dist/modules/apimapper/library.js.map +1 -1
  43. package/dist/modules/apimapper/license.js +12 -36
  44. package/dist/modules/apimapper/license.js.map +1 -1
  45. package/dist/modules/apimapper/local-sources.js +20 -34
  46. package/dist/modules/apimapper/local-sources.js.map +1 -1
  47. package/dist/modules/apimapper/misc.js +13 -27
  48. package/dist/modules/apimapper/misc.js.map +1 -1
  49. package/dist/modules/apimapper/onboarding.d.ts +30 -1
  50. package/dist/modules/apimapper/onboarding.js +114 -19
  51. package/dist/modules/apimapper/onboarding.js.map +1 -1
  52. package/dist/modules/apimapper/schema.js +9 -18
  53. package/dist/modules/apimapper/schema.js.map +1 -1
  54. package/dist/modules/apimapper/settings.js +49 -52
  55. package/dist/modules/apimapper/settings.js.map +1 -1
  56. package/dist/modules/apimapper/sites-tools.d.ts +29 -0
  57. package/dist/modules/apimapper/sites-tools.js +165 -0
  58. package/dist/modules/apimapper/sites-tools.js.map +1 -0
  59. package/dist/modules/apimapper/tool-result.d.ts +46 -0
  60. package/dist/modules/apimapper/tool-result.js +63 -0
  61. package/dist/modules/apimapper/tool-result.js.map +1 -0
  62. package/dist/modules/apimapper/toolslist-size.d.ts +11 -10
  63. package/dist/modules/apimapper/toolslist-size.js +16 -14
  64. package/dist/modules/apimapper/toolslist-size.js.map +1 -1
  65. package/dist/modules/apimapper/types.d.ts +21 -0
  66. package/dist/modules/apimapper/types.js.map +1 -1
  67. package/dist/modules/apimapper/whitelist-drift.d.ts +85 -0
  68. package/dist/modules/apimapper/whitelist-drift.js +360 -0
  69. package/dist/modules/apimapper/whitelist-drift.js.map +1 -0
  70. package/dist/modules/apimapper/workflows.js +82 -27
  71. package/dist/modules/apimapper/workflows.js.map +1 -1
  72. package/dist/modules/apimapper/yootheme-binding.d.ts +35 -0
  73. package/dist/modules/apimapper/yootheme-binding.js +186 -0
  74. package/dist/modules/apimapper/yootheme-binding.js.map +1 -0
  75. package/dist/platform/index.d.ts +56 -0
  76. package/dist/platform/index.js +151 -2
  77. package/dist/platform/index.js.map +1 -1
  78. package/dist/setup/detect-clients.d.ts +40 -1
  79. package/dist/setup/detect-clients.js +148 -1
  80. package/dist/setup/detect-clients.js.map +1 -1
  81. package/dist/setup/probe-handshake.js +40 -7
  82. package/dist/setup/probe-handshake.js.map +1 -1
  83. package/dist/setup/remove-config.d.ts +8 -0
  84. package/dist/setup/remove-config.js +145 -0
  85. package/dist/setup/remove-config.js.map +1 -0
  86. package/dist/setup/uninstall.d.ts +34 -0
  87. package/dist/setup/uninstall.js +147 -0
  88. package/dist/setup/uninstall.js.map +1 -0
  89. package/dist/setup-cli.d.ts +7 -0
  90. package/dist/setup-cli.js +29 -1
  91. package/dist/setup-cli.js.map +1 -1
  92. package/dist/sites/loader.d.ts +41 -0
  93. package/dist/sites/loader.js +119 -0
  94. package/dist/sites/loader.js.map +1 -0
  95. package/dist/sites/schema.d.ts +69 -0
  96. package/dist/sites/schema.js +71 -0
  97. package/dist/sites/schema.js.map +1 -0
  98. package/dist/sites/secret-resolver.d.ts +47 -0
  99. package/dist/sites/secret-resolver.js +150 -0
  100. package/dist/sites/secret-resolver.js.map +1 -0
  101. package/dist/skill-instructions.d.ts +1 -1
  102. package/dist/skill-instructions.js +5 -0
  103. package/dist/skill-instructions.js.map +1 -1
  104. package/dist/transports/stdio.js +4 -4
  105. package/dist/transports/stdio.js.map +1 -1
  106. package/dist/uninstall-skill.d.ts +27 -0
  107. package/dist/uninstall-skill.js +89 -0
  108. package/dist/uninstall-skill.js.map +1 -0
  109. package/docs/architecture.md +21 -21
  110. package/docs/customgraph-internal-migration.md +4 -4
  111. package/docs/security.md +2 -21
  112. package/docs/tools.md +40 -12
  113. package/manifest.json +77 -79
  114. package/package.json +68 -65
  115. package/skills/apimapper/SKILL.md +53 -7
  116. package/skills/apimapper/reference/conditional-style-multi-items.md +114 -0
  117. package/skills/apimapper/reference/jmespath-pitfalls.md +108 -0
  118. package/skills/apimapper/reference/joomla.md +1 -1
  119. package/skills/apimapper/reference/library-template-discovery.md +65 -0
  120. package/skills/apimapper/reference/merge-two-sources-on-key.md +99 -0
  121. package/skills/apimapper/reference/troubleshooting.md +20 -0
  122. package/skills/apimapper/reference/yootheme.md +1 -1
  123. package/dist/auth/oauth-provider.d.ts +0 -68
  124. package/dist/auth/oauth-provider.js +0 -232
  125. package/dist/auth/oauth-provider.js.map +0 -1
  126. package/dist/server-http.d.ts +0 -22
  127. package/dist/server-http.js +0 -159
  128. package/dist/server-http.js.map +0 -1
  129. package/dist/transports/http.d.ts +0 -29
  130. package/dist/transports/http.js +0 -267
  131. 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
@@ -159,12 +161,41 @@ function classify(status) {
159
161
  return "auth";
160
162
  if (status === 404)
161
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";
162
172
  if (status === 429)
163
173
  return "rate_limit";
164
174
  if (status >= 500)
165
175
  return "server";
166
176
  return "unknown";
167
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
+ }
168
199
  const DEFAULT_TIMEOUT_MS = 45_000;
169
200
  const MIN_TIMEOUT_MS = 1_000;
170
201
  const MAX_TIMEOUT_MS = 300_000;
@@ -172,6 +203,140 @@ function resolveTimeout(opts) {
172
203
  const t = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
173
204
  return Math.max(MIN_TIMEOUT_MS, Math.min(MAX_TIMEOUT_MS, t));
174
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
+ }
175
340
  /**
176
341
  * Create a Platform-aware HTTP client. The Platform handles URL construction,
177
342
  * auth header injection, and response envelope unwrap (e.g. Joomla's
@@ -183,115 +348,26 @@ export function createPlatformClient(platform) {
183
348
  return {
184
349
  platform,
185
350
  request: async (action, init = {}, opts = {}) => {
186
- let status;
187
- const timeoutMs = resolveTimeout(opts);
188
- try {
189
- const url = platform.buildUrl(action);
190
- const res = await fetch(url, {
191
- ...init,
192
- headers: {
193
- Accept: "application/json",
194
- "Content-Type": "application/json",
195
- "X-Requested-With": "XMLHttpRequest",
196
- ...platform.buildAuthHeaders(init.method ?? "GET"),
197
- ...init.headers,
198
- },
199
- signal: AbortSignal.timeout(timeoutMs),
200
- });
201
- status = res.status;
202
- const text = await res.text();
203
- if (!res.ok) {
204
- let parsed = text;
205
- try {
206
- parsed = JSON.parse(text);
207
- }
208
- catch {
209
- // keep as text
210
- }
211
- const errMsg = typeof parsed === "object" && parsed && "message" in parsed
212
- ? String(parsed.message)
213
- : typeof parsed === "object" && parsed && "error" in parsed
214
- ? String(parsed.error)
215
- : `HTTP ${res.status}`;
216
- // W2-3: preserve the parsed JSON error body when present so callers
217
- // can branch on domain-specific fields (e.g. compiler_error_code).
218
- const errorBody = typeof parsed === "object" && parsed !== null
219
- ? parsed
220
- : undefined;
221
- return {
222
- success: false,
223
- error: sanitizeErrorString(errMsg),
224
- status,
225
- errorCode: classify(status),
226
- errorBody,
227
- };
228
- }
229
- if (!text)
230
- return { success: true, data: {}, status };
231
- let data;
232
- try {
233
- data = JSON.parse(text);
234
- }
235
- catch {
236
- // Non-JSON body — pass through as text.
237
- return { success: true, data: text, status };
238
- }
239
- // Run the platform-specific envelope unwrap. Joomla throws on
240
- // success:false; we translate that to a structured failure so
241
- // tools see a uniform shape regardless of platform.
242
- let unwrapped;
243
- try {
244
- unwrapped = platform.parseResponse(data, init.method ?? "GET");
245
- }
246
- catch (e) {
247
- return {
248
- success: false,
249
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
250
- status,
251
- errorCode: "unknown",
252
- payloadFailed: true,
253
- };
254
- }
255
- // unwrapInnerSuccess for WordPress: treat 200 + `{success:false}`
256
- // payload as error (WP REST sometimes returns inner-success envelopes
257
- // for connection_test, license_activate, etc.).
258
- if (opts.unwrapInnerSuccess &&
259
- unwrapped &&
260
- typeof unwrapped === "object" &&
261
- "success" in unwrapped) {
262
- const inner = unwrapped.success;
263
- if (inner === false) {
264
- const innerErr = unwrapped.error !== undefined
265
- ? String(unwrapped.error)
266
- : "operation reported success:false in payload";
267
- return {
268
- success: false,
269
- error: sanitizeErrorString(innerErr),
270
- status,
271
- errorCode: "unknown",
272
- payloadFailed: true,
273
- };
274
- }
275
- }
276
- const out = opts.sanitize ? sanitizeSecrets(unwrapped) : unwrapped;
277
- return { success: true, data: out, status };
278
- }
279
- catch (e) {
280
- // AbortSignal.timeout fires "TimeoutError" or "AbortError" depending on
281
- // Node version — classify both as network/timeout.
282
- if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) {
283
- return {
284
- success: false,
285
- error: `request timed out after ${Math.round(timeoutMs / 1000)}s`,
286
- errorCode: "network",
287
- };
288
- }
289
- return {
290
- success: false,
291
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
292
- errorCode: "network",
293
- };
294
- }
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
+ });
295
371
  },
296
372
  };
297
373
  }
@@ -331,24 +407,306 @@ class WordPressBasicAuthPlatform extends WordPressPlatform {
331
407
  // actual decision keeps a future widening (.toLowerCase(), trim, etc.) from
332
408
  // flipping behaviour silently — anything that isn't the literal "joomla"
333
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").
334
417
  export const PLATFORM_KIND = process.env.APIMAPPER_PLATFORM === "joomla" ? "joomla" : "wordpress";
335
- const legacyPlatform = PLATFORM_KIND === "joomla"
336
- ? new JoomlaPlatform({
337
- // Joomla uses the same APIMAPPER_WP_BASE env var (or APIMAPPER_SITE_URL
338
- // alias bridged by the DXT bootstrap in src/index.ts). The token is the
339
- // raw amk_live_... bearer the setup CLI stored in the keychain. We
340
- // accept the WP_USER:WP_APP_PASS pair too split on the colon and use
341
- // the password as the bearer for ergonomic parity with the WP env vars.
342
- baseUrl: WP_BASE,
343
- token: process.env.APIMAPPER_TOKEN
344
- ?? (BASIC_TOKEN ? Buffer.from(BASIC_TOKEN, "base64").toString().split(":")[1] ?? "" : ""),
345
- })
346
- : new WordPressBasicAuthPlatform({
347
- baseUrl: WP_BASE,
348
- basicToken: BASIC_TOKEN,
349
- bearerToken: BEARER_TOKEN,
350
- });
351
- const legacyClient = createPlatformClient(legacyPlatform);
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
+ }
352
710
  /**
353
711
  * Issue a request through the toolkit HTTP client + capture HTTP status.
354
712
  *
@@ -380,17 +738,25 @@ export async function request(path, init = {}, opts = {}) {
380
738
  // Strip the leading "/" — Platform.buildUrl() takes a bare action name.
381
739
  // Also tolerate the existing escape-hatch where `path` starts with "http"
382
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.
383
746
  let response;
384
747
  if (path.startsWith("http")) {
385
748
  response = await rawAbsoluteRequest(path, init, opts);
386
749
  }
387
- else if (legacyPlatform instanceof JoomlaPlatform) {
388
- // Joomla branch — translate REST path → com_ajax task + params.
389
- response = await joomlaRequest(legacyPlatform, path, init, opts);
390
- }
391
750
  else {
392
- const action = path.replace(/^\/+/, "");
393
- 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
+ }
394
760
  }
395
761
  // Cache successful responses; never cache errors so the next call retries.
396
762
  if (cacheEligible && response.success) {
@@ -415,86 +781,51 @@ async function joomlaRequest(platform, path, init, opts) {
415
781
  translated = platform.translateRestPath(path, init.method ?? "GET");
416
782
  }
417
783
  catch (e) {
418
- return {
419
- success: false,
420
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
421
- errorCode: "unknown",
422
- };
423
- }
424
- let status;
425
- const timeoutMs = resolveTimeout(opts);
426
- try {
427
- const url = platform.buildUrl(translated.action, translated.params);
428
- const res = await fetch(url, {
429
- ...init,
430
- headers: {
431
- Accept: "application/json",
432
- "Content-Type": "application/json",
433
- "X-Requested-With": "XMLHttpRequest",
434
- ...platform.buildAuthHeaders(init.method ?? "GET"),
435
- ...init.headers,
436
- },
437
- signal: AbortSignal.timeout(timeoutMs),
438
- });
439
- status = res.status;
440
- const text = await res.text();
441
- if (!res.ok) {
442
- let parsed = text;
443
- try {
444
- parsed = JSON.parse(text);
445
- }
446
- catch {
447
- /* keep as text */
448
- }
449
- const errMsg = typeof parsed === "object" && parsed && "error" in parsed
450
- ? String(parsed.error)
451
- : `HTTP ${res.status}`;
452
- return {
453
- success: false,
454
- error: sanitizeErrorString(errMsg),
455
- status,
456
- errorCode: classify(status),
457
- };
458
- }
459
- if (!text)
460
- return { success: true, data: {}, status };
461
- let data;
462
- try {
463
- data = JSON.parse(text);
464
- }
465
- catch {
466
- return { success: true, data: text, status };
467
- }
468
- let unwrapped;
469
- try {
470
- unwrapped = platform.parseResponse(data, init.method ?? "GET");
471
- }
472
- catch (e) {
473
- return {
474
- success: false,
475
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
476
- status,
477
- errorCode: "unknown",
478
- payloadFailed: true,
479
- };
480
- }
481
- const out = opts.sanitize ? sanitizeSecrets(unwrapped) : unwrapped;
482
- return { success: true, data: out, status };
483
- }
484
- catch (e) {
485
- if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) {
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)) {
486
792
  return {
487
793
  success: false,
488
- error: `request timed out after ${Math.round(timeoutMs / 1000)}s`,
489
- errorCode: "network",
794
+ error: sanitizeErrorString(e.message),
795
+ errorCode: "not_found",
796
+ errorBody: {
797
+ unsupported: true,
798
+ platform: "joomla",
799
+ path: e.path,
800
+ },
490
801
  };
491
802
  }
492
803
  return {
493
804
  success: false,
494
805
  error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
495
- errorCode: "network",
806
+ errorCode: "unknown",
496
807
  };
497
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
+ });
498
829
  }
499
830
  /**
500
831
  * Absolute-URL escape hatch: callers occasionally pass a fully-qualified
@@ -503,92 +834,25 @@ async function joomlaRequest(platform, path, init, opts) {
503
834
  * to raw fetch but preserve the same auth + sanitisation semantics.
504
835
  */
505
836
  async function rawAbsoluteRequest(url, init, opts) {
506
- let status;
507
- const timeoutMs = resolveTimeout(opts);
508
- try {
509
- const res = await fetch(url, {
510
- ...init,
511
- headers: {
512
- Accept: "application/json",
513
- "Content-Type": "application/json",
514
- "X-Requested-With": "XMLHttpRequest",
515
- ...(BASIC_TOKEN ? { Authorization: `Basic ${BASIC_TOKEN}` } : {}),
516
- ...init.headers,
517
- },
518
- signal: AbortSignal.timeout(timeoutMs),
519
- });
520
- status = res.status;
521
- const text = await res.text();
522
- if (!res.ok) {
523
- let parsed = text;
524
- try {
525
- parsed = JSON.parse(text);
526
- }
527
- catch {
528
- /* keep as text */
529
- }
530
- const errMsg = typeof parsed === "object" && parsed && "message" in parsed
531
- ? String(parsed.message)
532
- : typeof parsed === "object" && parsed && "error" in parsed
533
- ? String(parsed.error)
534
- : `HTTP ${res.status}`;
535
- return {
536
- success: false,
537
- error: sanitizeErrorString(errMsg),
538
- status,
539
- errorCode: classify(status),
540
- };
541
- }
542
- if (!text)
543
- return { success: true, data: {}, status };
544
- let data;
545
- try {
546
- data = JSON.parse(text);
547
- }
548
- catch {
549
- if (opts.unwrapInnerSuccess && /success["']?\s*:\s*false/i.test(text)) {
550
- return {
551
- success: false,
552
- error: sanitizeErrorString(`text response indicates success:false — ${text.slice(0, 200)}`),
553
- status,
554
- errorCode: "unknown",
555
- payloadFailed: true,
556
- };
557
- }
558
- return { success: true, data: text, status };
559
- }
560
- if (opts.unwrapInnerSuccess && data && typeof data === "object" && "success" in data) {
561
- const inner = data.success;
562
- if (inner === false) {
563
- const innerErr = data.error !== undefined
564
- ? String(data.error)
565
- : "operation reported success:false in payload";
566
- return {
567
- success: false,
568
- error: sanitizeErrorString(innerErr),
569
- status,
570
- errorCode: "unknown",
571
- payloadFailed: true,
572
- };
573
- }
574
- }
575
- const out = opts.sanitize ? sanitizeSecrets(data) : data;
576
- return { success: true, data: out, status };
577
- }
578
- catch (e) {
579
- if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) {
580
- return {
581
- success: false,
582
- error: `request timed out after ${Math.round(timeoutMs / 1000)}s`,
583
- errorCode: "network",
584
- };
585
- }
586
- return {
587
- success: false,
588
- error: sanitizeErrorString(e instanceof Error ? e.message : String(e)),
589
- errorCode: "network",
590
- };
591
- }
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
+ });
592
856
  }
593
857
  /**
594
858
  * Map an `errorCode` to a focused hint string for the caller.