@wickedevolutions/abilities-mcp 1.3.1 → 1.5.1

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 (40) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +88 -17
  3. package/abilities-mcp.js +182 -113
  4. package/lib/auth/bridge-identity-provider.js +34 -0
  5. package/lib/auth/browser-launcher.js +67 -0
  6. package/lib/auth/config-migration.js +322 -0
  7. package/lib/auth/dcr-client.js +123 -0
  8. package/lib/auth/discovery-client.js +273 -0
  9. package/lib/auth/errors.js +114 -0
  10. package/lib/auth/events.js +55 -0
  11. package/lib/auth/fresh-each-time-identity.js +101 -0
  12. package/lib/auth/http-json.js +151 -0
  13. package/lib/auth/index.js +88 -0
  14. package/lib/auth/keychain-secret-store.js +98 -0
  15. package/lib/auth/loopback-server.js +249 -0
  16. package/lib/auth/memory-secret-store.js +0 -0
  17. package/lib/auth/oauth-client.js +357 -0
  18. package/lib/auth/pkce.js +93 -0
  19. package/lib/auth/schema-v2.js +110 -0
  20. package/lib/auth/secret-store.js +78 -0
  21. package/lib/auth/token-manager.js +378 -0
  22. package/lib/cli/commands/add-site.js +226 -0
  23. package/lib/cli/commands/force-downgrade.js +93 -0
  24. package/lib/cli/commands/list-sites.js +93 -0
  25. package/lib/cli/commands/reauth.js +108 -0
  26. package/lib/cli/commands/revoke.js +127 -0
  27. package/lib/cli/commands/self-check.js +158 -0
  28. package/lib/cli/commands/test.js +174 -0
  29. package/lib/cli/commands/upgrade-auth.js +259 -0
  30. package/lib/cli/config-store.js +161 -0
  31. package/lib/cli/context.js +102 -0
  32. package/lib/cli/errors.js +227 -0
  33. package/lib/cli/index.js +173 -0
  34. package/lib/cli/output.js +175 -0
  35. package/lib/cli/parse-args.js +80 -0
  36. package/lib/config.js +248 -19
  37. package/lib/connection-pool.js +214 -11
  38. package/lib/router.js +29 -11
  39. package/lib/transports/oauth-http-transport.js +601 -0
  40. package/package.json +7 -2
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const { AUTH_STATUS } = require('../../auth');
4
+ const { readConfig } = require('../config-store');
5
+ const { renderSiteTable, expiresLabel, shortScopes } = require('../output');
6
+ const { CliError, EXIT_CONFIG } = require('../errors');
7
+
8
+ /**
9
+ * `list-sites` — print a table of all sites + auth status.
10
+ *
11
+ * Columns chosen to match the F.5 example output:
12
+ * SITE URL AUTH USER SCOPES EXPIRES
13
+ * Phase 5 adds a STATUS column (active / expired / revoked / pending-reauth)
14
+ * because F.5 mandates the enum but the example didn't show it.
15
+ *
16
+ * H.2.3 mandates that `force-downgrade` actions are surfaced in `list-sites`
17
+ * for 30 days. We render an annotation line under the affected site row.
18
+ *
19
+ * Spec references:
20
+ * - "CLI surface" main spec section (column choice)
21
+ * - F.5 (auth_status enum + apppassword fallback presence)
22
+ * - I.3 (status labels)
23
+ * - H.2.3 (force-downgrade audit surfacing)
24
+ *
25
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
26
+ * @license GPL-2.0-or-later
27
+ */
28
+
29
+ const STATUS_BADGE = Object.freeze({
30
+ [AUTH_STATUS.ACTIVE]: 'active',
31
+ [AUTH_STATUS.EXPIRED]: 'expired',
32
+ [AUTH_STATUS.REVOKED]: 'revoked',
33
+ [AUTH_STATUS.PENDING_REAUTH]: 'pending-reauth',
34
+ });
35
+
36
+ async function run(args, ctx) {
37
+ let config;
38
+ try { config = readConfig(ctx.configPath); }
39
+ catch (err) {
40
+ if (err instanceof CliError && err.exitCode === EXIT_CONFIG && /not found/i.test(err.message)) {
41
+ return {
42
+ exitCode: 0,
43
+ lines: [
44
+ '(no sites configured — run: abilities-mcp add-site <url>)',
45
+ ],
46
+ };
47
+ }
48
+ throw err;
49
+ }
50
+
51
+ const nowMs = ctx.now ? ctx.now() : Date.now();
52
+ const rows = [];
53
+ for (const [siteId, site] of Object.entries(config.sites)) {
54
+ const auth = site.auth;
55
+ const isOAuth = auth.method === 'oauth';
56
+ const user = isOAuth ? (auth.user_login || '—') : (auth.username || '—');
57
+ const scopesShort = isOAuth ? shortScopes(auth.scopes) : '(full)';
58
+ const expires = isOAuth ? expiresLabel(auth.access_token_expires_at, nowMs) : '—';
59
+
60
+ let statusBadge = STATUS_BADGE[site.auth_status] || site.auth_status || '?';
61
+ // Decorate the badge with the apppassword-fallback signal — operators
62
+ // mid-upgrade will want to see it.
63
+ if (isOAuth && auth.apppassword_fallback) {
64
+ statusBadge += ' +apppassword-fallback';
65
+ }
66
+
67
+ let downgradeAnnotation = null;
68
+ if (site.force_downgrade) {
69
+ const expiresAt = Date.parse(site.force_downgrade.expires_at);
70
+ if (!Number.isNaN(expiresAt) && expiresAt > nowMs) {
71
+ const at = Date.parse(site.force_downgrade.at);
72
+ const days = Number.isNaN(at) ? '?' : Math.max(0, Math.floor((nowMs - at) / (24 * 3600 * 1000)));
73
+ downgradeAnnotation = `⚠ force-downgrade ${days}d ago${site.force_downgrade.reason ? ` (${site.force_downgrade.reason})` : ''}`;
74
+ }
75
+ }
76
+
77
+ rows.push({
78
+ siteId,
79
+ url: site.url || '—',
80
+ authMethod: auth.method,
81
+ user,
82
+ scopesShort,
83
+ expires,
84
+ statusBadge,
85
+ downgradeAnnotation,
86
+ });
87
+ }
88
+
89
+ const lines = renderSiteTable(rows);
90
+ return { exitCode: 0, lines };
91
+ }
92
+
93
+ module.exports = { run };
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ OAuthClient,
5
+ TokenManager,
6
+ AUTH_STATUS,
7
+ DEFAULT_SCOPE,
8
+ } = require('../../auth');
9
+ const { CliError, EXIT_USAGE, fromAuthError } = require('../errors');
10
+ const { subscribeProgress } = require('../output');
11
+ const { readConfig, writeConfig } = require('../config-store');
12
+
13
+ /**
14
+ * `reauth <site_id>` — re-run the OAuth flow for an existing site.
15
+ *
16
+ * Reuses the existing site URL and existing capability pin; mints a fresh
17
+ * client_id (FreshEachTimeIdentityProvider in v1.0); replaces the access /
18
+ * refresh tokens in keychain.
19
+ *
20
+ * Spec references:
21
+ * - "CLI surface" main spec section
22
+ * - F.5 (auth_status enum)
23
+ * - H.2.3 (capabilityPin firstSeenAt is preserved across reauths)
24
+ *
25
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
26
+ * @license GPL-2.0-or-later
27
+ */
28
+
29
+ async function run(args, ctx) {
30
+ const siteId = args._ && args._[0];
31
+ if (!siteId) {
32
+ throw new CliError('reauth requires a site_id argument', {
33
+ exitCode: EXIT_USAGE,
34
+ nextAction: 'Run: abilities-mcp reauth <site_id>',
35
+ });
36
+ }
37
+ const config = readConfig(ctx.configPath);
38
+ const site = config.sites[siteId];
39
+ if (!site) {
40
+ throw new CliError(`reauth: unknown site_id "${siteId}"`, {
41
+ exitCode: EXIT_USAGE,
42
+ nextAction: `Run: abilities-mcp list-sites to see configured sites`,
43
+ });
44
+ }
45
+ if (site.auth.method !== 'oauth') {
46
+ throw new CliError(`reauth: site "${siteId}" uses ${site.auth.method}, not OAuth`, {
47
+ exitCode: EXIT_USAGE,
48
+ nextAction: `Run: abilities-mcp upgrade-auth ${siteId} to migrate to OAuth`,
49
+ });
50
+ }
51
+
52
+ const out = [];
53
+ out.push(`Re-running OAuth flow for site "${siteId}" (${site.url})…`);
54
+
55
+ const clientName = `${ctx.userLabel}'s Operator (${ctx.hostnameLabel})`;
56
+ const OAuthClientCls = (ctx.deps && ctx.deps.OAuthClient) || OAuthClient;
57
+ const oauth = new OAuthClientCls({
58
+ siteUrl: site.url,
59
+ clientName,
60
+ softwareVersion: ctx.softwareVersion,
61
+ scope: args.scope || (Array.isArray(site.auth.scopes) ? site.auth.scopes : DEFAULT_SCOPE),
62
+ identityProvider: ctx.identityProvider,
63
+ allowInsecure: ctx.allowInsecure,
64
+ capabilityPin: site.oauth_capability_pinned ? {
65
+ firstSeenAt: site.oauth_capability_pinned.first_seen_at,
66
+ } : null,
67
+ deps: ctx.deps && ctx.deps.oauthClientDeps,
68
+ });
69
+ subscribeProgress(oauth, out);
70
+
71
+ let result;
72
+ try { result = await oauth.run(); }
73
+ catch (err) { throw fromAuthError(err, { siteId }); }
74
+
75
+ const tm = new TokenManager({ secretStore: ctx.secretStore, allowInsecure: ctx.allowInsecure });
76
+ const persisted = await tm.persistTokens({ siteId, tokens: result.tokens });
77
+
78
+ // Carry forward apppassword_fallback so an in-progress upgrade-auth
79
+ // (Step 2 done, Step 4 not yet run) is not silently undone by a reauth.
80
+ // Operators remove the fallback only via `upgrade-auth --confirm`.
81
+ const carryFallback = site.auth.apppassword_fallback || null;
82
+ site.auth = {
83
+ method: 'oauth',
84
+ client_id: result.clientId,
85
+ user_login: site.auth.user_login || ctx.userLabel || 'operator',
86
+ scopes: result.scopes,
87
+ access_token_expires_at: persisted.accessTokenExpiresAt,
88
+ refresh_token_expires_at: persisted.refreshTokenExpiresAt,
89
+ access_token_ref: persisted.accessTokenRef,
90
+ refresh_token_ref: persisted.refreshTokenRef,
91
+ };
92
+ if (carryFallback) site.auth.apppassword_fallback = carryFallback;
93
+
94
+ site.auth_status = AUTH_STATUS.ACTIVE;
95
+ site.oauth_capability_pinned = {
96
+ first_seen_at: result.capabilityPin.firstSeenAt,
97
+ last_confirmed_at: result.capabilityPin.lastConfirmedAt,
98
+ };
99
+ if (result.prMetadata && result.prMetadata.resource) {
100
+ site.mcp_resource = result.prMetadata.resource;
101
+ }
102
+ await writeConfig(ctx.configPath, config);
103
+
104
+ out.push(`✓ Site "${siteId}" re-authorized. Granted scopes: ${result.scopes.join(', ')}.`);
105
+ return { exitCode: 0, lines: out };
106
+ }
107
+
108
+ module.exports = { run };
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ const { TokenManager, AUTH_STATUS } = require('../../auth');
4
+ const { resolveRef, parseRef } = require('../../auth/secret-store');
5
+ const { discover } = require('../../auth/discovery-client');
6
+ const { CliError, EXIT_USAGE, fromAuthError } = require('../errors');
7
+ const { readConfig, writeConfig } = require('../config-store');
8
+
9
+ /**
10
+ * `revoke <site_id>` — local + remote revocation.
11
+ *
12
+ * Per the sub-issue: "local keychain cleanup + remote /oauth/revoke call."
13
+ *
14
+ * Behavior:
15
+ * 1. Resolve the site's revocation_endpoint by re-running discovery (the
16
+ * site config does not stash the AS metadata — we re-fetch).
17
+ * 2. POST refresh_token to /oauth/revoke (if present), then access_token.
18
+ * RFC 7009: revoking refresh also revokes derived access tokens, but we
19
+ * send both for adapters that don't cascade.
20
+ * 3. Delete the keychain entries for this site.
21
+ * 4. Mark auth_status = "revoked" in wp-sites.json so list-sites surfaces it.
22
+ *
23
+ * Errors are tolerated where the spirit is "best effort":
24
+ * - 4xx on revoke is treated as success (server has a final say).
25
+ * - 5xx / network error stops the flow before we delete keychain so the
26
+ * operator can retry without losing the token.
27
+ *
28
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
29
+ * @license GPL-2.0-or-later
30
+ */
31
+
32
+ async function run(args, ctx) {
33
+ const siteId = args._ && args._[0];
34
+ if (!siteId) {
35
+ throw new CliError('revoke requires a site_id argument', {
36
+ exitCode: EXIT_USAGE,
37
+ nextAction: 'Run: abilities-mcp revoke <site_id>',
38
+ });
39
+ }
40
+ const config = readConfig(ctx.configPath);
41
+ const site = config.sites[siteId];
42
+ if (!site) {
43
+ throw new CliError(`revoke: unknown site_id "${siteId}"`, {
44
+ exitCode: EXIT_USAGE,
45
+ nextAction: 'Run: abilities-mcp list-sites to see configured sites',
46
+ });
47
+ }
48
+ if (site.auth.method !== 'oauth') {
49
+ throw new CliError(`revoke: site "${siteId}" uses ${site.auth.method}, not OAuth`, {
50
+ exitCode: EXIT_USAGE,
51
+ nextAction: `App Password sites are revoked by deleting the password in WordPress; use clear-keychain ${siteId} to remove the local copy`,
52
+ });
53
+ }
54
+
55
+ const out = [];
56
+ out.push(`Revoking OAuth tokens for site "${siteId}"…`);
57
+
58
+ // Re-discover to get the revocation_endpoint. Use the existing capability
59
+ // pin so a 404 on a previously-OAuth site still fails loud.
60
+ const discoverFn = (ctx.deps && ctx.deps.discover) || discover;
61
+ let asMetadata;
62
+ try {
63
+ const discovered = await discoverFn(site.url, {
64
+ pinned: !!site.oauth_capability_pinned,
65
+ pinnedFirstSeenAt: site.oauth_capability_pinned && site.oauth_capability_pinned.first_seen_at,
66
+ allowInsecure: ctx.allowInsecure,
67
+ });
68
+ asMetadata = discovered.asMetadata;
69
+ } catch (err) {
70
+ throw fromAuthError(err, { siteId });
71
+ }
72
+ const revocationEndpoint = asMetadata && asMetadata.revocation_endpoint;
73
+ if (!revocationEndpoint) {
74
+ out.push(' (Adapter does not advertise revocation_endpoint — skipping remote revocation, deleting local keychain only.)');
75
+ }
76
+
77
+ const tm = new TokenManager({ secretStore: ctx.secretStore, allowInsecure: ctx.allowInsecure });
78
+ if (revocationEndpoint) {
79
+ // Revoke refresh first (cascades on a compliant adapter), then access.
80
+ const tokenRefs = [
81
+ { kind: 'refresh_token', ref: site.auth.refresh_token_ref },
82
+ { kind: 'access_token', ref: site.auth.access_token_ref },
83
+ ];
84
+ for (const { kind, ref } of tokenRefs) {
85
+ if (!ref) continue;
86
+ let token;
87
+ try { token = await resolveRef(ctx.secretStore, ref); }
88
+ catch {
89
+ out.push(` (No ${kind} present in keychain — skipping.)`);
90
+ continue;
91
+ }
92
+ try {
93
+ const res = await tm.revoke({
94
+ revocationEndpoint,
95
+ token,
96
+ clientId: site.auth.client_id,
97
+ tokenTypeHint: kind,
98
+ });
99
+ out.push(` Remote revocation (${kind}): HTTP ${res.statusCode}`);
100
+ } catch (err) {
101
+ // 5xx → bail before we delete keychain (operator can retry).
102
+ throw fromAuthError(err, { siteId });
103
+ }
104
+ }
105
+ }
106
+
107
+ // Delete keychain entries. Per F.5, account names follow `<siteId>/<kind>`.
108
+ const accounts = [];
109
+ for (const ref of [site.auth.access_token_ref, site.auth.refresh_token_ref]) {
110
+ if (!ref) continue;
111
+ try { accounts.push(parseRef(ref).account); } catch { /* ignore malformed */ }
112
+ }
113
+ for (const account of accounts) {
114
+ await ctx.secretStore.delete('abilities-mcp', account).catch(() => {});
115
+ }
116
+
117
+ // Mark auth_status revoked rather than deleting the site — so the operator
118
+ // sees a record in list-sites and can choose to remove it later.
119
+ site.auth_status = AUTH_STATUS.REVOKED;
120
+ await writeConfig(ctx.configPath, config);
121
+
122
+ out.push(`✓ Site "${siteId}" revoked. Tokens deleted from keychain; auth_status = revoked.`);
123
+ out.push(` Run: abilities-mcp reauth ${siteId} to start a fresh authorization.`);
124
+ return { exitCode: 0, lines: out };
125
+ }
126
+
127
+ module.exports = { run };
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ const { URL } = require('node:url');
4
+
5
+ const { TokenManager, AUTH_STATUS } = require('../../auth');
6
+ const { discover } = require('../../auth/discovery-client');
7
+ const { request } = require('../../auth/http-json');
8
+ const { CliError, EXIT_USAGE, fromAuthError } = require('../errors');
9
+ const { readConfig } = require('../config-store');
10
+
11
+ /**
12
+ * `self-check <site_id>` — Authorization-header survival probe (H.2.6).
13
+ *
14
+ * Hits `/wp-json/abilities-mcp-adapter/v1/oauth/echo-headers` with the OAuth
15
+ * bearer; that adapter endpoint returns presence/absence of expected headers
16
+ * (never the values). Reports whether the Authorization header survived the
17
+ * trip — many shared hosts strip it on FastCGI/PHP-FPM unless an .htaccess
18
+ * snippet recovers it.
19
+ *
20
+ * Per H.2.6: "the debug endpoint is OAuth-token-protected (so it's not a
21
+ * fingerprint surface) and returns only the **presence/absence** of expected
22
+ * headers, never the values."
23
+ *
24
+ * Spec is silent on whether self-check takes a site_id. The natural reading
25
+ * is yes (it requires an OAuth token, which lives per-site).
26
+ *
27
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
28
+ * @license GPL-2.0-or-later
29
+ */
30
+
31
+ const ECHO_HEADERS_PATH = '/wp-json/abilities-mcp-adapter/v1/oauth/echo-headers';
32
+
33
+ async function run(args, ctx) {
34
+ const siteId = args._ && args._[0];
35
+ if (!siteId) {
36
+ throw new CliError('self-check requires a site_id argument', {
37
+ exitCode: EXIT_USAGE,
38
+ nextAction: 'Run: abilities-mcp self-check <site_id>',
39
+ });
40
+ }
41
+ const config = readConfig(ctx.configPath);
42
+ const site = config.sites[siteId];
43
+ if (!site) {
44
+ throw new CliError(`self-check: unknown site_id "${siteId}"`, {
45
+ exitCode: EXIT_USAGE,
46
+ nextAction: 'Run: abilities-mcp list-sites to see configured sites',
47
+ });
48
+ }
49
+ if (site.auth.method !== 'oauth') {
50
+ throw new CliError(`self-check: site "${siteId}" uses ${site.auth.method}, not OAuth`, {
51
+ exitCode: EXIT_USAGE,
52
+ nextAction: 'self-check probes the OAuth bearer path — only OAuth sites are supported',
53
+ });
54
+ }
55
+
56
+ const out = [];
57
+ out.push(`Probing Authorization-header survival on site "${siteId}"…`);
58
+
59
+ // Re-discover purely to find the token endpoint (for a possible refresh).
60
+ const discoverFn = (ctx.deps && ctx.deps.discover) || discover;
61
+ let asMetadata;
62
+ try {
63
+ const discovered = await discoverFn(site.url, {
64
+ pinned: !!site.oauth_capability_pinned,
65
+ pinnedFirstSeenAt: site.oauth_capability_pinned && site.oauth_capability_pinned.first_seen_at,
66
+ allowInsecure: ctx.allowInsecure,
67
+ });
68
+ asMetadata = discovered.asMetadata;
69
+ } catch (err) {
70
+ throw fromAuthError(err, { siteId });
71
+ }
72
+
73
+ const tm = new TokenManager({ secretStore: ctx.secretStore, allowInsecure: ctx.allowInsecure });
74
+ const siteAuthState = {
75
+ siteId,
76
+ tokenEndpoint: asMetadata.token_endpoint,
77
+ clientId: site.auth.client_id,
78
+ accessTokenRef: site.auth.access_token_ref,
79
+ refreshTokenRef: site.auth.refresh_token_ref,
80
+ accessTokenExpiresAt: site.auth.access_token_expires_at,
81
+ refreshTokenExpiresAt: site.auth.refresh_token_expires_at,
82
+ authStatus: site.auth_status || AUTH_STATUS.ACTIVE,
83
+ };
84
+ let accessToken;
85
+ try {
86
+ const tok = await tm.getAccessToken(siteAuthState);
87
+ accessToken = tok.accessToken;
88
+ } catch (err) {
89
+ throw fromAuthError(err, { siteId });
90
+ }
91
+
92
+ const url = new URL(ECHO_HEADERS_PATH, site.url).toString();
93
+ const requestFn = (ctx.deps && ctx.deps.request) || request;
94
+ let res;
95
+ try {
96
+ res = await requestFn({
97
+ url,
98
+ method: 'GET',
99
+ headers: { 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/json' },
100
+ allowInsecure: ctx.allowInsecure,
101
+ });
102
+ } catch (err) {
103
+ throw new CliError(`self-check: probe to ${url} failed: ${err.message}`, {
104
+ exitCode: 4,
105
+ nextAction: 'Verify the site is reachable and the adapter exposes /oauth/echo-headers',
106
+ cause: err,
107
+ });
108
+ }
109
+
110
+ if (res.statusCode === 404) {
111
+ return {
112
+ exitCode: 0,
113
+ lines: out.concat([
114
+ `(Adapter does not expose ${ECHO_HEADERS_PATH} on this site.)`,
115
+ ' This endpoint ships with abilities-mcp-adapter v1.5.0+. Older adapters cannot self-check.',
116
+ ]),
117
+ };
118
+ }
119
+ if (res.statusCode === 401 || res.statusCode === 403) {
120
+ throw new CliError(
121
+ `self-check: bearer rejected (HTTP ${res.statusCode}) reaching ${url}`,
122
+ {
123
+ exitCode: 4,
124
+ nextAction: `Run: abilities-mcp reauth ${siteId}`,
125
+ }
126
+ );
127
+ }
128
+ if (res.statusCode < 200 || res.statusCode >= 300 || !res.json) {
129
+ throw new CliError(
130
+ `self-check: adapter returned HTTP ${res.statusCode} (no JSON body)`,
131
+ {
132
+ exitCode: 4,
133
+ nextAction: 'Inspect the adapter logs for the failing request',
134
+ }
135
+ );
136
+ }
137
+
138
+ // The adapter's response shape (H.2.6): presence flags only.
139
+ const auth = !!(res.json.authorization_present || res.json.headers && res.json.headers.authorization);
140
+ out.push(` Authorization header: ${auth ? '✓ Detected' : '⚠ NOT detected'}`);
141
+ // Common companion fields adapters may include.
142
+ if (typeof res.json.host_software === 'string') {
143
+ out.push(` Adapter reports host: ${res.json.host_software}`);
144
+ }
145
+ if (Array.isArray(res.json.recovery_hints) && res.json.recovery_hints.length) {
146
+ out.push(' Recovery hints from adapter:');
147
+ for (const h of res.json.recovery_hints) out.push(` - ${h}`);
148
+ }
149
+ if (!auth) {
150
+ out.push('');
151
+ out.push('Your hosting may be stripping the Authorization header on FastCGI/PHP-FPM.');
152
+ out.push('Apache: ship the .htaccess RewriteRule from H.2.6.');
153
+ out.push('LiteSpeed / Nginx: see the operator setup guide.');
154
+ }
155
+ return { exitCode: auth ? 0 : 4, lines: out };
156
+ }
157
+
158
+ module.exports = { run, ECHO_HEADERS_PATH };
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ const { TokenManager, AUTH_STATUS } = require('../../auth');
4
+ const { resolveRef } = require('../../auth/secret-store');
5
+ const { request } = require('../../auth/http-json');
6
+ const { CliError, EXIT_USAGE, fromAuthError } = require('../errors');
7
+ const { readConfig, writeConfig } = require('../config-store');
8
+ const { shortScopes } = require('../output');
9
+
10
+ /**
11
+ * `test <site_id>` — ping the adapter and report granted scopes.
12
+ *
13
+ * Behavior:
14
+ * 1. Resolve a current access token via TokenManager (refreshing if within
15
+ * the 300s window per Appendix H.2.1).
16
+ * 2. Hit the resource (`mcp_resource` from F.5 / Phase 4 PR metadata) with
17
+ * a tiny MCP `initialize` request — that is the lightest call WordPress
18
+ * will accept and works on every adapter version.
19
+ * 3. Report status, scopes, expiry.
20
+ *
21
+ * On token refresh: the new tokens are written to keychain by TokenManager,
22
+ * and we persist the updated `access_token_expires_at` to wp-sites.json so
23
+ * subsequent calls don't refresh again.
24
+ *
25
+ * Spec references:
26
+ * - "CLI surface" main spec section — "ping ability + show granted scopes"
27
+ *
28
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
29
+ * @license GPL-2.0-or-later
30
+ */
31
+
32
+ async function run(args, ctx) {
33
+ const siteId = args._ && args._[0];
34
+ if (!siteId) {
35
+ throw new CliError('test requires a site_id argument', {
36
+ exitCode: EXIT_USAGE,
37
+ nextAction: 'Run: abilities-mcp test <site_id>',
38
+ });
39
+ }
40
+ const config = readConfig(ctx.configPath);
41
+ const site = config.sites[siteId];
42
+ if (!site) {
43
+ throw new CliError(`test: unknown site_id "${siteId}"`, {
44
+ exitCode: EXIT_USAGE,
45
+ nextAction: 'Run: abilities-mcp list-sites to see configured sites',
46
+ });
47
+ }
48
+ if (site.auth.method !== 'oauth') {
49
+ throw new CliError(`test: site "${siteId}" uses ${site.auth.method}, not OAuth`, {
50
+ exitCode: EXIT_USAGE,
51
+ nextAction: 'This subcommand currently exercises OAuth bearer authentication only',
52
+ });
53
+ }
54
+
55
+ const out = [];
56
+ out.push(`Testing site "${siteId}" (${site.url})…`);
57
+
58
+ // We need the token endpoint for refresh and the resource for the ping.
59
+ // The token endpoint is not stored in v2 config — we resolve it from the
60
+ // cached metadata only when needed. Phase 4 keeps the AS metadata
61
+ // ephemeral, so we re-discover here.
62
+ const { discover } = require('../../auth/discovery-client');
63
+ const discoverFn = (ctx.deps && ctx.deps.discover) || discover;
64
+ let asMetadata, prMetadata;
65
+ try {
66
+ const discovered = await discoverFn(site.url, {
67
+ pinned: !!site.oauth_capability_pinned,
68
+ pinnedFirstSeenAt: site.oauth_capability_pinned && site.oauth_capability_pinned.first_seen_at,
69
+ allowInsecure: ctx.allowInsecure,
70
+ });
71
+ asMetadata = discovered.asMetadata;
72
+ prMetadata = discovered.prMetadata;
73
+ } catch (err) {
74
+ throw fromAuthError(err, { siteId });
75
+ }
76
+
77
+ const tm = new TokenManager({ secretStore: ctx.secretStore, allowInsecure: ctx.allowInsecure });
78
+ const siteAuthState = {
79
+ siteId,
80
+ tokenEndpoint: asMetadata.token_endpoint,
81
+ clientId: site.auth.client_id,
82
+ accessTokenRef: site.auth.access_token_ref,
83
+ refreshTokenRef: site.auth.refresh_token_ref,
84
+ accessTokenExpiresAt: site.auth.access_token_expires_at,
85
+ refreshTokenExpiresAt: site.auth.refresh_token_expires_at,
86
+ authStatus: site.auth_status || AUTH_STATUS.ACTIVE,
87
+ };
88
+
89
+ let accessToken;
90
+ try {
91
+ const tok = await tm.getAccessToken(siteAuthState);
92
+ accessToken = tok.accessToken;
93
+ if (tok.refreshed && tok.updatedAuth) {
94
+ site.auth.access_token_expires_at = tok.updatedAuth.accessTokenExpiresAt;
95
+ site.auth.refresh_token_ref = tok.updatedAuth.refreshTokenRef;
96
+ site.auth_status = AUTH_STATUS.ACTIVE;
97
+ await writeConfig(ctx.configPath, config);
98
+ out.push(' (Refreshed access token before testing.)');
99
+ }
100
+ } catch (err) {
101
+ // RefreshError carries an updatedAuth on 4xx — persist it so future
102
+ // CLI calls see auth_status = expired immediately.
103
+ if (err && err.updatedAuth) {
104
+ site.auth_status = err.updatedAuth.authStatus;
105
+ try { await writeConfig(ctx.configPath, config); } catch { /* best effort */ }
106
+ }
107
+ throw fromAuthError(err, { siteId });
108
+ }
109
+
110
+ const resource = site.mcp_resource || (prMetadata && prMetadata.resource);
111
+ if (!resource) {
112
+ throw new CliError(`test: no MCP resource URL known for site "${siteId}"`, {
113
+ exitCode: EXIT_USAGE,
114
+ nextAction: `Run: abilities-mcp reauth ${siteId} to refresh the resource pointer`,
115
+ });
116
+ }
117
+
118
+ // Smallest MCP request we can send — initialize. The adapter answers with
119
+ // server capabilities, which is enough proof that the bearer survived.
120
+ const requestFn = (ctx.deps && ctx.deps.request) || request;
121
+ const body = JSON.stringify({
122
+ jsonrpc: '2.0',
123
+ id: 1,
124
+ method: 'initialize',
125
+ params: {
126
+ protocolVersion: '2024-11-05',
127
+ capabilities: {},
128
+ clientInfo: { name: 'abilities-mcp test', version: ctx.softwareVersion },
129
+ },
130
+ });
131
+ let res;
132
+ try {
133
+ res = await requestFn({
134
+ url: resource,
135
+ method: 'POST',
136
+ headers: {
137
+ 'Authorization': `Bearer ${accessToken}`,
138
+ 'Content-Type': 'application/json',
139
+ 'Accept': 'application/json, text/event-stream',
140
+ },
141
+ body,
142
+ allowInsecure: ctx.allowInsecure,
143
+ });
144
+ } catch (err) {
145
+ throw new CliError(`test: ping to ${resource} failed: ${err.message}`, {
146
+ exitCode: 4,
147
+ nextAction: 'Verify the site is reachable and the adapter is running',
148
+ cause: err,
149
+ });
150
+ }
151
+
152
+ if (res.statusCode === 401 || res.statusCode === 403) {
153
+ throw new CliError(
154
+ `test: bearer rejected (HTTP ${res.statusCode}) — token may be revoked or scope is missing`,
155
+ {
156
+ exitCode: 4,
157
+ nextAction: `Run: abilities-mcp reauth ${siteId}`,
158
+ }
159
+ );
160
+ }
161
+ if (res.statusCode < 200 || res.statusCode >= 300) {
162
+ throw new CliError(`test: adapter returned HTTP ${res.statusCode}`, {
163
+ exitCode: 4,
164
+ nextAction: 'Inspect the adapter logs for the failing request',
165
+ });
166
+ }
167
+
168
+ out.push(`✓ Reachable: ${resource} (HTTP ${res.statusCode})`);
169
+ out.push(` Granted scopes: ${shortScopes(site.auth.scopes)}`);
170
+ out.push(` Authenticated as: ${site.auth.user_login || '(unknown)'}`);
171
+ return { exitCode: 0, lines: out };
172
+ }
173
+
174
+ module.exports = { run };