@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.
- package/CHANGELOG.md +148 -83
- package/README.md +36 -32
- package/SECURITY.md +15 -6
- package/dist/auth/keychain.d.ts +27 -1
- package/dist/auth/keychain.js +48 -2
- package/dist/auth/keychain.js.map +1 -1
- package/dist/cli-hint.d.ts +22 -0
- package/dist/cli-hint.js +55 -0
- package/dist/cli-hint.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +163 -22
- package/dist/index.js.map +1 -1
- package/dist/install-skill.js +1 -1
- package/dist/modules/apimapper/cache.d.ts +2 -2
- package/dist/modules/apimapper/cache.js +119 -29
- package/dist/modules/apimapper/cache.js.map +1 -1
- package/dist/modules/apimapper/client.d.ts +102 -1
- package/dist/modules/apimapper/client.js +631 -297
- package/dist/modules/apimapper/client.js.map +1 -1
- package/dist/modules/apimapper/connections-format.d.ts +51 -0
- package/dist/modules/apimapper/connections-format.js +261 -0
- package/dist/modules/apimapper/connections-format.js.map +1 -0
- package/dist/modules/apimapper/connections-trim.d.ts +82 -0
- package/dist/modules/apimapper/connections-trim.js +224 -0
- package/dist/modules/apimapper/connections-trim.js.map +1 -0
- package/dist/modules/apimapper/connections.d.ts +14 -2
- package/dist/modules/apimapper/connections.js +612 -153
- package/dist/modules/apimapper/connections.js.map +1 -1
- package/dist/modules/apimapper/credential-sanitizer.d.ts +5 -0
- package/dist/modules/apimapper/credential-sanitizer.js +60 -1
- package/dist/modules/apimapper/credential-sanitizer.js.map +1 -1
- package/dist/modules/apimapper/credentials-format.d.ts +21 -0
- package/dist/modules/apimapper/credentials-format.js +145 -0
- package/dist/modules/apimapper/credentials-format.js.map +1 -0
- package/dist/modules/apimapper/credentials.d.ts +12 -2
- package/dist/modules/apimapper/credentials.js +226 -73
- package/dist/modules/apimapper/credentials.js.map +1 -1
- package/dist/modules/apimapper/diagnose.d.ts +54 -2
- package/dist/modules/apimapper/diagnose.js +213 -12
- package/dist/modules/apimapper/diagnose.js.map +1 -1
- package/dist/modules/apimapper/elicitation.d.ts +54 -0
- package/dist/modules/apimapper/elicitation.js +90 -0
- package/dist/modules/apimapper/elicitation.js.map +1 -0
- package/dist/modules/apimapper/flows-format.d.ts +50 -0
- package/dist/modules/apimapper/flows-format.js +318 -0
- package/dist/modules/apimapper/flows-format.js.map +1 -0
- package/dist/modules/apimapper/flows.d.ts +13 -2
- package/dist/modules/apimapper/flows.js +312 -122
- package/dist/modules/apimapper/flows.js.map +1 -1
- package/dist/modules/apimapper/gateway/advanced-tool.d.ts +9 -0
- package/dist/modules/apimapper/gateway/advanced-tool.js +265 -0
- package/dist/modules/apimapper/gateway/advanced-tool.js.map +1 -0
- package/dist/modules/apimapper/gateway/capturing-server.d.ts +81 -0
- package/dist/modules/apimapper/gateway/capturing-server.js +87 -0
- package/dist/modules/apimapper/gateway/capturing-server.js.map +1 -0
- package/dist/modules/apimapper/gateway/essentials.d.ts +4 -0
- package/dist/modules/apimapper/gateway/essentials.js +35 -0
- package/dist/modules/apimapper/gateway/essentials.js.map +1 -0
- package/dist/modules/apimapper/gateway/test-support.d.ts +17 -0
- package/dist/modules/apimapper/gateway/test-support.js +43 -0
- package/dist/modules/apimapper/gateway/test-support.js.map +1 -0
- package/dist/modules/apimapper/get-skill.d.ts +3 -3
- package/dist/modules/apimapper/get-skill.js +47 -7
- package/dist/modules/apimapper/get-skill.js.map +1 -1
- package/dist/modules/apimapper/graph-builder.js +1 -1
- package/dist/modules/apimapper/graph-builder.js.map +1 -1
- package/dist/modules/apimapper/graph.d.ts +2 -2
- package/dist/modules/apimapper/graph.js +170 -35
- package/dist/modules/apimapper/graph.js.map +1 -1
- package/dist/modules/apimapper/index.d.ts +17 -1
- package/dist/modules/apimapper/index.js +68 -17
- package/dist/modules/apimapper/index.js.map +1 -1
- package/dist/modules/apimapper/inspect.d.ts +3 -2
- package/dist/modules/apimapper/inspect.js +97 -13
- package/dist/modules/apimapper/inspect.js.map +1 -1
- package/dist/modules/apimapper/library.d.ts +2 -2
- package/dist/modules/apimapper/library.js +665 -80
- package/dist/modules/apimapper/library.js.map +1 -1
- package/dist/modules/apimapper/license-format.d.ts +22 -0
- package/dist/modules/apimapper/license-format.js +149 -0
- package/dist/modules/apimapper/license-format.js.map +1 -0
- package/dist/modules/apimapper/license.d.ts +16 -2
- package/dist/modules/apimapper/license.js +62 -38
- package/dist/modules/apimapper/license.js.map +1 -1
- package/dist/modules/apimapper/local-sources.d.ts +2 -2
- package/dist/modules/apimapper/local-sources.js +44 -30
- package/dist/modules/apimapper/local-sources.js.map +1 -1
- package/dist/modules/apimapper/misc.d.ts +30 -2
- package/dist/modules/apimapper/misc.js +114 -49
- package/dist/modules/apimapper/misc.js.map +1 -1
- package/dist/modules/apimapper/node-schema.d.ts +52 -0
- package/dist/modules/apimapper/node-schema.js +70 -2
- package/dist/modules/apimapper/node-schema.js.map +1 -1
- package/dist/modules/apimapper/normalizers.d.ts +1 -0
- package/dist/modules/apimapper/normalizers.js +51 -0
- package/dist/modules/apimapper/normalizers.js.map +1 -1
- package/dist/modules/apimapper/onboarding.d.ts +78 -3
- package/dist/modules/apimapper/onboarding.js +428 -26
- package/dist/modules/apimapper/onboarding.js.map +1 -1
- package/dist/modules/apimapper/read-cache.d.ts +31 -2
- package/dist/modules/apimapper/read-cache.js +20 -6
- package/dist/modules/apimapper/read-cache.js.map +1 -1
- package/dist/modules/apimapper/render/_shared.d.ts +24 -0
- package/dist/modules/apimapper/render/_shared.js +84 -0
- package/dist/modules/apimapper/render/_shared.js.map +1 -0
- package/dist/modules/apimapper/render/dag.d.ts +18 -0
- package/dist/modules/apimapper/render/dag.js +70 -0
- package/dist/modules/apimapper/render/dag.js.map +1 -0
- package/dist/modules/apimapper/render/index.d.ts +2 -0
- package/dist/modules/apimapper/render/index.js +112 -0
- package/dist/modules/apimapper/render/index.js.map +1 -0
- package/dist/modules/apimapper/render/renderers/chart-bar.d.ts +2 -0
- package/dist/modules/apimapper/render/renderers/chart-bar.js +70 -0
- package/dist/modules/apimapper/render/renderers/chart-bar.js.map +1 -0
- package/dist/modules/apimapper/render/renderers/chart-line.d.ts +2 -0
- package/dist/modules/apimapper/render/renderers/chart-line.js +71 -0
- package/dist/modules/apimapper/render/renderers/chart-line.js.map +1 -0
- package/dist/modules/apimapper/render/renderers/diff.d.ts +2 -0
- package/dist/modules/apimapper/render/renderers/diff.js +154 -0
- package/dist/modules/apimapper/render/renderers/diff.js.map +1 -0
- package/dist/modules/apimapper/render/renderers/flow-diagram.d.ts +1 -0
- package/dist/modules/apimapper/render/renderers/flow-diagram.js +180 -0
- package/dist/modules/apimapper/render/renderers/flow-diagram.js.map +1 -0
- package/dist/modules/apimapper/render/renderers/json-tree.d.ts +2 -0
- package/dist/modules/apimapper/render/renderers/json-tree.js +87 -0
- package/dist/modules/apimapper/render/renderers/json-tree.js.map +1 -0
- package/dist/modules/apimapper/render/renderers/schema-diagram.d.ts +2 -0
- package/dist/modules/apimapper/render/renderers/schema-diagram.js +83 -0
- package/dist/modules/apimapper/render/renderers/schema-diagram.js.map +1 -0
- package/dist/modules/apimapper/render/renderers/table.d.ts +2 -0
- package/dist/modules/apimapper/render/renderers/table.js +75 -0
- package/dist/modules/apimapper/render/renderers/table.js.map +1 -0
- package/dist/modules/apimapper/render/schemas.d.ts +23 -0
- package/dist/modules/apimapper/render/schemas.js +56 -0
- package/dist/modules/apimapper/render/schemas.js.map +1 -0
- package/dist/modules/apimapper/render/secret-masking.d.ts +5 -0
- package/dist/modules/apimapper/render/secret-masking.js +51 -0
- package/dist/modules/apimapper/render/secret-masking.js.map +1 -0
- package/dist/modules/apimapper/render/sidecar.d.ts +21 -0
- package/dist/modules/apimapper/render/sidecar.js +66 -0
- package/dist/modules/apimapper/render/sidecar.js.map +1 -0
- package/dist/modules/apimapper/render/token-cap.d.ts +21 -0
- package/dist/modules/apimapper/render/token-cap.js +57 -0
- package/dist/modules/apimapper/render/token-cap.js.map +1 -0
- package/dist/modules/apimapper/schema.d.ts +2 -2
- package/dist/modules/apimapper/schema.js +92 -33
- package/dist/modules/apimapper/schema.js.map +1 -1
- package/dist/modules/apimapper/settings-format.d.ts +23 -0
- package/dist/modules/apimapper/settings-format.js +135 -0
- package/dist/modules/apimapper/settings-format.js.map +1 -0
- package/dist/modules/apimapper/settings.d.ts +2 -2
- package/dist/modules/apimapper/settings.js +100 -42
- package/dist/modules/apimapper/settings.js.map +1 -1
- package/dist/modules/apimapper/sites-tools.d.ts +29 -0
- package/dist/modules/apimapper/sites-tools.js +165 -0
- package/dist/modules/apimapper/sites-tools.js.map +1 -0
- package/dist/modules/apimapper/skill-resources.d.ts +2 -2
- package/dist/modules/apimapper/skill-resources.js.map +1 -1
- package/dist/modules/apimapper/token-baseline.harness.d.ts +91 -0
- package/dist/modules/apimapper/token-baseline.harness.js +291 -0
- package/dist/modules/apimapper/token-baseline.harness.js.map +1 -0
- package/dist/modules/apimapper/tool-result.d.ts +46 -0
- package/dist/modules/apimapper/tool-result.js +63 -0
- package/dist/modules/apimapper/tool-result.js.map +1 -0
- package/dist/modules/apimapper/toolslist-size.d.ts +56 -0
- package/dist/modules/apimapper/toolslist-size.js +192 -0
- package/dist/modules/apimapper/toolslist-size.js.map +1 -0
- package/dist/modules/apimapper/types.d.ts +44 -8
- package/dist/modules/apimapper/types.js +26 -1
- package/dist/modules/apimapper/types.js.map +1 -1
- package/dist/modules/apimapper/use-profile.d.ts +21 -0
- package/dist/modules/apimapper/use-profile.js +56 -2
- package/dist/modules/apimapper/use-profile.js.map +1 -1
- package/dist/modules/apimapper/whitelist-drift.d.ts +85 -0
- package/dist/modules/apimapper/whitelist-drift.js +360 -0
- package/dist/modules/apimapper/whitelist-drift.js.map +1 -0
- package/dist/modules/apimapper/workflows.d.ts +2 -2
- package/dist/modules/apimapper/workflows.js +202 -20
- package/dist/modules/apimapper/workflows.js.map +1 -1
- package/dist/modules/apimapper/yootheme-binding.d.ts +35 -0
- package/dist/modules/apimapper/yootheme-binding.js +186 -0
- package/dist/modules/apimapper/yootheme-binding.js.map +1 -0
- package/dist/platform/index.d.ts +56 -0
- package/dist/platform/index.js +195 -7
- package/dist/platform/index.js.map +1 -1
- package/dist/setup/detect-clients.d.ts +40 -1
- package/dist/setup/detect-clients.js +148 -1
- package/dist/setup/detect-clients.js.map +1 -1
- package/dist/setup/probe-handshake.js +40 -7
- package/dist/setup/probe-handshake.js.map +1 -1
- package/dist/setup/remove-config.d.ts +8 -0
- package/dist/setup/remove-config.js +145 -0
- package/dist/setup/remove-config.js.map +1 -0
- package/dist/setup/uninstall.d.ts +34 -0
- package/dist/setup/uninstall.js +147 -0
- package/dist/setup/uninstall.js.map +1 -0
- package/dist/setup-cli.d.ts +60 -0
- package/dist/setup-cli.js +155 -5
- package/dist/setup-cli.js.map +1 -1
- package/dist/sites/loader.d.ts +41 -0
- package/dist/sites/loader.js +119 -0
- package/dist/sites/loader.js.map +1 -0
- package/dist/sites/schema.d.ts +69 -0
- package/dist/sites/schema.js +71 -0
- package/dist/sites/schema.js.map +1 -0
- package/dist/sites/secret-resolver.d.ts +47 -0
- package/dist/sites/secret-resolver.js +150 -0
- package/dist/sites/secret-resolver.js.map +1 -0
- package/dist/skill-instructions.d.ts +1 -1
- package/dist/skill-instructions.js +5 -0
- package/dist/skill-instructions.js.map +1 -1
- package/dist/transports/stdio.js +4 -4
- package/dist/transports/stdio.js.map +1 -1
- package/dist/uninstall-skill.d.ts +27 -0
- package/dist/uninstall-skill.js +89 -0
- package/dist/uninstall-skill.js.map +1 -0
- package/docs/architecture.md +22 -22
- package/docs/customgraph-internal-migration.md +4 -4
- package/docs/security.md +2 -21
- package/docs/tools.md +40 -12
- package/manifest.json +77 -70
- package/package.json +68 -60
- package/skills/apimapper/SKILL.md +53 -7
- package/skills/apimapper/reference/conditional-style-multi-items.md +114 -0
- package/skills/apimapper/reference/jmespath-pitfalls.md +108 -0
- package/skills/apimapper/reference/joomla.md +1 -1
- package/skills/apimapper/reference/library-template-discovery.md +65 -0
- package/skills/apimapper/reference/merge-two-sources-on-key.md +99 -0
- package/skills/apimapper/reference/render.md +132 -0
- package/skills/apimapper/reference/troubleshooting.md +21 -1
- package/skills/apimapper/reference/yootheme.md +1 -1
- package/dist/auth/oauth-provider.d.ts +0 -68
- package/dist/auth/oauth-provider.js +0 -232
- package/dist/auth/oauth-provider.js.map +0 -1
- package/dist/server-http.d.ts +0 -22
- package/dist/server-http.js +0 -159
- package/dist/server-http.js.map +0 -1
- package/dist/transports/http.d.ts +0 -29
- package/dist/transports/http.js +0 -267
- 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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
|
330
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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(
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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: "
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
},
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
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":
|