@wickedevolutions/abilities-mcp 1.3.1 → 1.5.3

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 (41) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +88 -17
  3. package/abilities-mcp.js +191 -114
  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 +265 -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 +328 -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-source-line.js +85 -0
  37. package/lib/config.js +282 -22
  38. package/lib/connection-pool.js +214 -11
  39. package/lib/router.js +29 -11
  40. package/lib/transports/oauth-http-transport.js +601 -0
  41. package/package.json +8 -2
@@ -0,0 +1,378 @@
1
+ 'use strict';
2
+
3
+ const { postForm } = require('./http-json');
4
+ const { resolveRef, parseRef, makeRef } = require('./secret-store');
5
+ const { RefreshError, AuthError } = require('./errors');
6
+ const { AUTH_STATUS } = require('./events');
7
+
8
+ /**
9
+ * TokenManager — runs the bridge's pre-call refresh + retry policy and the
10
+ * OAuth-capability pin bookkeeping.
11
+ *
12
+ * Binding rules (Appendix H.2.1):
13
+ * - HTTP timeout: 30s read/write.
14
+ * - Retry policy: on network error or 5xx, retry up to 2 times with the
15
+ * SAME refresh token. The adapter's 30s grace window honors this.
16
+ * - NEVER retry on 4xx — the server has decided.
17
+ * - Persist intent-to-refresh to keychain BEFORE sending the request. Mark
18
+ * refresh complete only after 200 received and new tokens persisted. On
19
+ * crash mid-flight, the old refresh token is still in keychain; the next
20
+ * call retries with it.
21
+ *
22
+ * Refresh window:
23
+ * - Refresh when access_token_expires_at is within 300 seconds (Appendix
24
+ * "Token refresh" + H.4.5 clock-skew row).
25
+ *
26
+ * On auth failure (4xx from /oauth/token):
27
+ * - Set auth_status to "expired".
28
+ * - Surface a reauth hint via the returned Result object (callers wire
29
+ * this into CLI / GUI messaging — no console output here).
30
+ * - Other sites are unaffected.
31
+ *
32
+ * Capability pinning (Appendix H.2.3):
33
+ * - On every successful discovery, callers should refresh
34
+ * `oauth_capability_pinned.last_confirmed_at`.
35
+ * - On 404 against a pinned site, throw CapabilityPinningError (handled
36
+ * by discovery-client); this module exposes helpers to update the pin.
37
+ *
38
+ * The TokenManager does not perform discovery — discovery happens during
39
+ * add-site / reauth (oauth-client.js). The TokenManager only refreshes
40
+ * tokens against an already-known token endpoint.
41
+ *
42
+ * @typedef {object} TokenSet
43
+ * @property {string} access_token
44
+ * @property {string} refresh_token
45
+ * @property {number} expires_in seconds
46
+ * @property {string} [token_type]
47
+ * @property {string} [scope]
48
+ *
49
+ * @typedef {object} SiteAuthState
50
+ * @property {string} siteId
51
+ * @property {string} tokenEndpoint
52
+ * @property {string} clientId
53
+ * @property {string} accessTokenRef keychain://...
54
+ * @property {string} refreshTokenRef keychain://...
55
+ * @property {string} accessTokenExpiresAt ISO 8601
56
+ * @property {string} refreshTokenExpiresAt ISO 8601
57
+ * @property {string} authStatus 'active' | 'expired' | 'revoked' | 'pending-reauth'
58
+ *
59
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
60
+ * @license GPL-2.0-or-later
61
+ */
62
+
63
+ const REFRESH_WINDOW_SECONDS = 300;
64
+ const HTTP_TIMEOUT_MS = 30_000;
65
+ const MAX_RETRIES = 2;
66
+ const SECRET_SERVICE = 'abilities-mcp';
67
+
68
+ class TokenManager {
69
+ /**
70
+ * @param {object} args
71
+ * @param {object} args.secretStore SecretStore instance
72
+ * @param {object} [args.deps]
73
+ * @param {Function} [args.deps.postForm]
74
+ * @param {(ms:number)=>Promise<void>} [args.deps.sleep]
75
+ * @param {()=>number} [args.deps.now] Defaults to Date.now
76
+ */
77
+ constructor(args) {
78
+ if (!args || !args.secretStore) {
79
+ throw new Error('TokenManager requires secretStore');
80
+ }
81
+ this._store = args.secretStore;
82
+ this._allowInsecure = !!args.allowInsecure;
83
+ const deps = args.deps || {};
84
+ this._postForm = deps.postForm || postForm;
85
+ this._sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
86
+ this._now = deps.now || (() => Date.now());
87
+ }
88
+
89
+ // ---------------------------------------------------------------------
90
+ // Bearer token resolution
91
+ // ---------------------------------------------------------------------
92
+
93
+ /**
94
+ * Returns a usable access token for `siteAuth`, refreshing if within the
95
+ * refresh window or if explicitly forced.
96
+ *
97
+ * @param {SiteAuthState} siteAuth
98
+ * @param {object} [opts]
99
+ * @param {boolean} [opts.forceRefresh]
100
+ * @returns {Promise<{accessToken:string, refreshed:boolean, updatedAuth?:SiteAuthState, tokens?:TokenSet}>}
101
+ */
102
+ async getAccessToken(siteAuth, opts = {}) {
103
+ const needsRefresh = opts.forceRefresh || this._isWithinRefreshWindow(siteAuth);
104
+ if (!needsRefresh) {
105
+ const accessToken = await resolveRef(this._store, siteAuth.accessTokenRef);
106
+ return { accessToken, refreshed: false };
107
+ }
108
+ const refreshed = await this.refresh(siteAuth);
109
+ return {
110
+ accessToken: refreshed.tokens.access_token,
111
+ refreshed: true,
112
+ updatedAuth: refreshed.updatedAuth,
113
+ tokens: refreshed.tokens,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Returns true if the access token will expire within REFRESH_WINDOW_SECONDS.
119
+ * @param {SiteAuthState} siteAuth
120
+ */
121
+ _isWithinRefreshWindow(siteAuth) {
122
+ if (!siteAuth.accessTokenExpiresAt) return true;
123
+ const expiresAt = Date.parse(siteAuth.accessTokenExpiresAt);
124
+ if (Number.isNaN(expiresAt)) return true;
125
+ const msUntilExpiry = expiresAt - this._now();
126
+ return msUntilExpiry <= REFRESH_WINDOW_SECONDS * 1000;
127
+ }
128
+
129
+ // ---------------------------------------------------------------------
130
+ // Refresh
131
+ // ---------------------------------------------------------------------
132
+
133
+ /**
134
+ * Refresh the access token.
135
+ *
136
+ * Retry semantics now live entirely on the server (adapter v1.4.2 ships
137
+ * encrypt-at-rest grace-window retry per Wicked-Evolutions/abilities-mcp-adapter#61).
138
+ * A retry within 30 seconds of a successful rotation returns the original
139
+ * plaintext pair from a server-stored encrypted blob — the bridge does not
140
+ * need a mid-flight crash-recovery marker. We just retry on transport
141
+ * failures and 5xx; the server is idempotent within the grace window.
142
+ *
143
+ * @param {SiteAuthState} siteAuth
144
+ * @returns {Promise<{tokens: TokenSet, updatedAuth: SiteAuthState}>}
145
+ */
146
+ async refresh(siteAuth) {
147
+ if (siteAuth.authStatus === AUTH_STATUS.EXPIRED) {
148
+ throw new RefreshError(
149
+ `Refresh token expired for site "${siteAuth.siteId}". ` +
150
+ `Run: abilities-mcp reauth ${siteAuth.siteId}`,
151
+ { code: 'reauth_required', state: 'refreshing' }
152
+ );
153
+ }
154
+ if (siteAuth.authStatus === AUTH_STATUS.REVOKED) {
155
+ throw new RefreshError(
156
+ `Refresh token revoked for site "${siteAuth.siteId}".`,
157
+ { code: 'revoked', state: 'refreshing' }
158
+ );
159
+ }
160
+
161
+ if (!siteAuth.refreshTokenRef) {
162
+ throw new RefreshError('No refresh token configured for site', {
163
+ code: 'no_refresh_token', state: 'refreshing',
164
+ });
165
+ }
166
+ const refreshToken = await resolveRef(this._store, siteAuth.refreshTokenRef);
167
+
168
+ let res;
169
+ let attempt = 0;
170
+ let lastNetworkError = null;
171
+ /* eslint-disable no-constant-condition */
172
+ while (true) {
173
+ try {
174
+ res = await this._postForm(siteAuth.tokenEndpoint, {
175
+ grant_type: 'refresh_token',
176
+ refresh_token: refreshToken,
177
+ client_id: siteAuth.clientId,
178
+ }, { timeoutMs: HTTP_TIMEOUT_MS, allowInsecure: this._allowInsecure });
179
+ } catch (err) {
180
+ // Network error path.
181
+ lastNetworkError = err;
182
+ if (attempt < MAX_RETRIES) {
183
+ attempt++;
184
+ await this._sleep(this._backoffMs(attempt));
185
+ continue;
186
+ }
187
+ throw new RefreshError(
188
+ `Refresh failed after ${MAX_RETRIES + 1} network-error attempts: ${err.message}`,
189
+ { code: 'network_error', state: 'refreshing', cause: err }
190
+ );
191
+ }
192
+ // 5xx → retry with same refresh token.
193
+ if (res.statusCode >= 500 && res.statusCode <= 599) {
194
+ if (attempt < MAX_RETRIES) {
195
+ attempt++;
196
+ await this._sleep(this._backoffMs(attempt));
197
+ continue;
198
+ }
199
+ throw new RefreshError(
200
+ `Refresh failed after ${MAX_RETRIES + 1} attempts (last status ${res.statusCode})`,
201
+ { code: 'server_error', state: 'refreshing', cause: { statusCode: res.statusCode, body: res.body } }
202
+ );
203
+ }
204
+ // 4xx → never retry.
205
+ if (res.statusCode >= 400 && res.statusCode <= 499) {
206
+ const oauthError = res.json && res.json.error ? res.json.error : 'invalid_grant';
207
+ const description = res.json && res.json.error_description;
208
+ // Mark the site as expired so the caller can route the operator to reauth.
209
+ const updatedAuth = { ...siteAuth, authStatus: AUTH_STATUS.EXPIRED };
210
+ const err = new RefreshError(
211
+ `Refresh rejected (${oauthError}${description ? ': ' + description : ''}). ` +
212
+ `Run: abilities-mcp reauth ${siteAuth.siteId}`,
213
+ {
214
+ code: oauthError,
215
+ state: 'refreshing',
216
+ cause: { statusCode: res.statusCode, body: res.body },
217
+ }
218
+ );
219
+ err.updatedAuth = updatedAuth;
220
+ err.reauthHint = { siteId: siteAuth.siteId, command: `abilities-mcp reauth ${siteAuth.siteId}` };
221
+ throw err;
222
+ }
223
+ // 2xx
224
+ break;
225
+ }
226
+
227
+ if (!res.json || typeof res.json.access_token !== 'string') {
228
+ throw new RefreshError('Token endpoint returned 2xx without access_token', {
229
+ code: 'malformed_response', state: 'refreshing',
230
+ cause: { body: res.body },
231
+ });
232
+ }
233
+
234
+ // Success — persist new tokens.
235
+ const tokens = res.json;
236
+ const accessAccount = parseRef(siteAuth.accessTokenRef).account;
237
+ await this._store.set(SECRET_SERVICE, accessAccount, tokens.access_token);
238
+
239
+ let refreshAccount = parseRef(siteAuth.refreshTokenRef).account;
240
+ let refreshTokenRef = siteAuth.refreshTokenRef;
241
+ if (typeof tokens.refresh_token === 'string' && tokens.refresh_token !== refreshToken) {
242
+ // Rotation — write the new refresh token under the same account.
243
+ await this._store.set(SECRET_SERVICE, refreshAccount, tokens.refresh_token);
244
+ refreshTokenRef = makeRef(SECRET_SERVICE, refreshAccount);
245
+ }
246
+
247
+ const expiresInSec = typeof tokens.expires_in === 'number' ? tokens.expires_in : null;
248
+ const updatedAuth = {
249
+ ...siteAuth,
250
+ accessTokenRef: makeRef(SECRET_SERVICE, accessAccount),
251
+ refreshTokenRef,
252
+ accessTokenExpiresAt: expiresInSec
253
+ ? new Date(this._now() + expiresInSec * 1000).toISOString()
254
+ : siteAuth.accessTokenExpiresAt,
255
+ authStatus: AUTH_STATUS.ACTIVE,
256
+ };
257
+
258
+ return { tokens, updatedAuth };
259
+ }
260
+
261
+ _backoffMs(attempt) {
262
+ // Modest backoff so the second retry stays inside the adapter's 30s
263
+ // grace window (H.2.1). 500ms then 1500ms.
264
+ return attempt === 1 ? 500 : 1500;
265
+ }
266
+
267
+ // ---------------------------------------------------------------------
268
+ // Persist new token set (used after add-site / reauth completes)
269
+ // ---------------------------------------------------------------------
270
+
271
+ /**
272
+ * Store a freshly-issued token set in the secret store and return refs +
273
+ * computed expiry timestamps suitable for writing into wp-sites.json v2.
274
+ *
275
+ * @param {object} args
276
+ * @param {string} args.siteId
277
+ * @param {TokenSet} args.tokens
278
+ * @returns {Promise<{
279
+ * accessTokenRef:string, refreshTokenRef:string,
280
+ * accessTokenExpiresAt:string, refreshTokenExpiresAt:string,
281
+ * }>}
282
+ */
283
+ async persistTokens(args) {
284
+ if (!args || !args.siteId || !args.tokens) {
285
+ throw new Error('persistTokens requires { siteId, tokens }');
286
+ }
287
+ const { siteId, tokens } = args;
288
+ if (typeof tokens.access_token !== 'string') {
289
+ throw new Error('tokens.access_token is required');
290
+ }
291
+
292
+ const accessAccount = `${siteId}/access`;
293
+ const refreshAccount = `${siteId}/refresh`;
294
+ await this._store.set(SECRET_SERVICE, accessAccount, tokens.access_token);
295
+
296
+ let refreshTokenRef = null;
297
+ if (typeof tokens.refresh_token === 'string') {
298
+ await this._store.set(SECRET_SERVICE, refreshAccount, tokens.refresh_token);
299
+ refreshTokenRef = makeRef(SECRET_SERVICE, refreshAccount);
300
+ }
301
+
302
+ const nowMs = this._now();
303
+ const accessSec = typeof tokens.expires_in === 'number' ? tokens.expires_in : 24 * 3600;
304
+ // Refresh expiry is not part of the standard token response. Fall back to
305
+ // 90 days (the adapter's default per design doc decision #5).
306
+ const refreshSec = typeof tokens.refresh_expires_in === 'number'
307
+ ? tokens.refresh_expires_in
308
+ : 90 * 24 * 3600;
309
+
310
+ return {
311
+ accessTokenRef: makeRef(SECRET_SERVICE, accessAccount),
312
+ refreshTokenRef,
313
+ accessTokenExpiresAt: new Date(nowMs + accessSec * 1000).toISOString(),
314
+ refreshTokenExpiresAt: new Date(nowMs + refreshSec * 1000).toISOString(),
315
+ };
316
+ }
317
+
318
+ // ---------------------------------------------------------------------
319
+ // Capability pinning helpers (H.2.3)
320
+ // ---------------------------------------------------------------------
321
+
322
+ /**
323
+ * Build the pin object to write under `oauth_capability_pinned`.
324
+ * @param {object} [existing] Existing pin (for refresh)
325
+ * @returns {{firstSeenAt:string, lastConfirmedAt:string}}
326
+ */
327
+ buildPin(existing) {
328
+ const now = new Date(this._now()).toISOString();
329
+ if (existing && existing.firstSeenAt) {
330
+ return { firstSeenAt: existing.firstSeenAt, lastConfirmedAt: now };
331
+ }
332
+ return { firstSeenAt: now, lastConfirmedAt: now };
333
+ }
334
+
335
+ // ---------------------------------------------------------------------
336
+ // Revocation
337
+ // ---------------------------------------------------------------------
338
+
339
+ /**
340
+ * Server-side revocation (RFC 7009) of a token. Network/5xx errors raise.
341
+ * 4xx other than `unsupported_token_type` are treated as success — the
342
+ * server has a final say.
343
+ * @param {object} args
344
+ * @param {string} args.revocationEndpoint
345
+ * @param {string} args.token
346
+ * @param {string} args.clientId
347
+ * @param {string} [args.tokenTypeHint]
348
+ */
349
+ async revoke(args) {
350
+ if (!args || !args.revocationEndpoint) {
351
+ throw new AuthError('revoke requires revocationEndpoint', { code: 'missing_endpoint' });
352
+ }
353
+ const params = {
354
+ token: args.token,
355
+ client_id: args.clientId,
356
+ };
357
+ if (args.tokenTypeHint) params.token_type_hint = args.tokenTypeHint;
358
+ const res = await this._postForm(args.revocationEndpoint, params, {
359
+ timeoutMs: HTTP_TIMEOUT_MS,
360
+ allowInsecure: this._allowInsecure,
361
+ });
362
+ if (res.statusCode >= 500) {
363
+ throw new AuthError(`Revocation failed with ${res.statusCode}`, {
364
+ code: 'revocation_failed',
365
+ cause: { statusCode: res.statusCode, body: res.body },
366
+ });
367
+ }
368
+ return { statusCode: res.statusCode };
369
+ }
370
+ }
371
+
372
+ module.exports = {
373
+ TokenManager,
374
+ REFRESH_WINDOW_SECONDS,
375
+ HTTP_TIMEOUT_MS,
376
+ MAX_RETRIES,
377
+ SECRET_SERVICE,
378
+ };
@@ -0,0 +1,226 @@
1
+ 'use strict';
2
+
3
+ const { URL } = require('node:url');
4
+
5
+ const {
6
+ OAuthClient,
7
+ TokenManager,
8
+ AUTH_STATUS,
9
+ DEFAULT_SCOPE,
10
+ } = require('../../auth');
11
+ const { makeRef } = require('../../auth/secret-store');
12
+ const { CliError, EXIT_USAGE, EXIT_CONFIG, fromAuthError } = require('../errors');
13
+ const { subscribeProgress } = require('../output');
14
+ const { readConfig, writeConfig, freshConfig } = require('../config-store');
15
+
16
+ /**
17
+ * `add-site <url>` — register a new site with the bridge using OAuth (default)
18
+ * or App Password (`--apppassword`).
19
+ *
20
+ * 1:1 mapping:
21
+ * - OAuth path → new OAuthClient(...).run() → TokenManager.persistTokens(...)
22
+ * - apppassword → ctx.secretStore.set(<service>, <siteId>/apppassword, <pw>)
23
+ *
24
+ * Spec references:
25
+ * - "CLI surface" main spec section
26
+ * - I.5 (default scope, --scope override)
27
+ * - I.6 (config schema v1 source — handled in migration, not here)
28
+ * - F.5 (v2 site shape)
29
+ *
30
+ * Site-id derivation (CLI gap-fill — not in spec): hostname with the leading
31
+ * www. stripped and the TLD removed. Operators can override with --site-id=ID.
32
+ *
33
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
34
+ * @license GPL-2.0-or-later
35
+ */
36
+
37
+ const SECRET_SERVICE = 'abilities-mcp';
38
+
39
+ function deriveSiteId(siteUrl) {
40
+ let host;
41
+ try { host = new URL(siteUrl).hostname; }
42
+ catch { return null; }
43
+ const trimmed = host.replace(/^www\./, '');
44
+ const dot = trimmed.indexOf('.');
45
+ return dot > 0 ? trimmed.slice(0, dot) : trimmed;
46
+ }
47
+
48
+ /**
49
+ * @param {object} args
50
+ * @param {string[]} args._ positional args (first should be URL)
51
+ * @param {boolean} [args.apppassword]
52
+ * @param {string} [args.username] for --apppassword
53
+ * @param {string} [args.password] for --apppassword
54
+ * @param {string} [args.scope]
55
+ * @param {string} [args['site-id']]
56
+ * @param {boolean} [args.force] overwrite existing site of same id
57
+ * @param {object} ctx
58
+ * @returns {Promise<{exitCode:number, lines:string[]}>}
59
+ */
60
+ async function run(args, ctx) {
61
+ const url = args._ && args._[0];
62
+ if (typeof url !== 'string' || url.length === 0) {
63
+ throw new CliError('add-site requires a site URL argument', {
64
+ exitCode: EXIT_USAGE,
65
+ nextAction: 'Run: abilities-mcp add-site <url> [--apppassword]',
66
+ });
67
+ }
68
+ let parsedUrl;
69
+ try { parsedUrl = new URL(url); }
70
+ catch (err) {
71
+ throw new CliError(`add-site: invalid URL: ${url}`, {
72
+ exitCode: EXIT_USAGE,
73
+ nextAction: 'Provide a full https://... URL',
74
+ cause: err,
75
+ });
76
+ }
77
+ if (parsedUrl.protocol !== 'https:' && !ctx.allowInsecure) {
78
+ throw new CliError(`add-site: HTTPS required (got ${parsedUrl.protocol})`, {
79
+ exitCode: EXIT_USAGE,
80
+ nextAction: 'Provide an https:// URL or pass --allow-insecure for localhost dev',
81
+ });
82
+ }
83
+
84
+ const explicitId = args['site-id'];
85
+ const siteId = explicitId || deriveSiteId(url);
86
+ if (!siteId) {
87
+ throw new CliError('add-site: cannot derive a site_id from the URL', {
88
+ exitCode: EXIT_USAGE,
89
+ nextAction: 'Pass --site-id=<id> explicitly',
90
+ });
91
+ }
92
+
93
+ // Load (or create) the config and check for collisions.
94
+ let config;
95
+ try { config = readConfig(ctx.configPath); }
96
+ catch (err) {
97
+ if (err instanceof CliError && err.exitCode === EXIT_CONFIG && /not found/i.test(err.message)) {
98
+ config = freshConfig();
99
+ } else {
100
+ throw err;
101
+ }
102
+ }
103
+ if (config.sites[siteId] && !args.force) {
104
+ throw new CliError(`add-site: site_id "${siteId}" already exists in ${ctx.configPath}`, {
105
+ exitCode: EXIT_USAGE,
106
+ nextAction: `Run: abilities-mcp reauth ${siteId} (or pass --force / --site-id=<other> to overwrite)`,
107
+ });
108
+ }
109
+
110
+ const out = [];
111
+ let exitCode = 0;
112
+
113
+ if (args.apppassword) {
114
+ if (!args.username) {
115
+ throw new CliError('add-site --apppassword requires --username=<wp_user>', {
116
+ exitCode: EXIT_USAGE,
117
+ nextAction: 'Run: abilities-mcp add-site <url> --apppassword --username=<u> --password=<p>',
118
+ });
119
+ }
120
+ if (!args.password) {
121
+ throw new CliError('add-site --apppassword requires --password=<application_password>', {
122
+ exitCode: EXIT_USAGE,
123
+ nextAction: 'Generate an Application Password in WP admin → Users → Profile and pass it via --password',
124
+ });
125
+ }
126
+ const account = `${siteId}/apppassword`;
127
+ await ctx.secretStore.set(SECRET_SERVICE, account, args.password);
128
+ config.sites[siteId] = {
129
+ url: parsedUrl.origin,
130
+ label: args.label || parsedUrl.hostname,
131
+ auth: {
132
+ method: 'apppassword',
133
+ username: args.username,
134
+ password_ref: makeRef(SECRET_SERVICE, account),
135
+ },
136
+ auth_status: AUTH_STATUS.ACTIVE,
137
+ };
138
+ if (!config.defaultSite) config.defaultSite = siteId;
139
+ await writeConfig(ctx.configPath, config);
140
+ out.push(`✓ Site "${siteId}" configured with App Password authentication.`);
141
+ out.push(` Config: ${ctx.configPath}`);
142
+ return { exitCode, lines: out };
143
+ }
144
+
145
+ // OAuth path — full authorization-code + PKCE flow via OAuthClient.
146
+ const clientName = `${ctx.userLabel}'s Operator (${ctx.hostnameLabel})`;
147
+ const oauth = (ctx.deps && ctx.deps.OAuthClient)
148
+ ? new ctx.deps.OAuthClient({
149
+ siteUrl: parsedUrl.origin,
150
+ clientName,
151
+ softwareVersion: ctx.softwareVersion,
152
+ scope: args.scope || DEFAULT_SCOPE,
153
+ identityProvider: ctx.identityProvider,
154
+ allowInsecure: ctx.allowInsecure,
155
+ deps: ctx.deps && ctx.deps.oauthClientDeps,
156
+ })
157
+ : new OAuthClient({
158
+ siteUrl: parsedUrl.origin,
159
+ clientName,
160
+ softwareVersion: ctx.softwareVersion,
161
+ scope: args.scope || DEFAULT_SCOPE,
162
+ identityProvider: ctx.identityProvider,
163
+ allowInsecure: ctx.allowInsecure,
164
+ deps: ctx.deps && ctx.deps.oauthClientDeps,
165
+ });
166
+
167
+ out.push(`Adding site "${siteId}" (${parsedUrl.origin}) via OAuth…`);
168
+ subscribeProgress(oauth, out);
169
+
170
+ let result;
171
+ try { result = await oauth.run(); }
172
+ catch (err) { throw fromAuthError(err, { siteId }); }
173
+
174
+ // Persist tokens to keychain via TokenManager, then write the v2 site.
175
+ const tm = new TokenManager({ secretStore: ctx.secretStore, allowInsecure: ctx.allowInsecure });
176
+ const persisted = await tm.persistTokens({ siteId, tokens: result.tokens });
177
+
178
+ config.sites[siteId] = {
179
+ url: parsedUrl.origin,
180
+ label: args.label || parsedUrl.hostname,
181
+ auth: {
182
+ method: 'oauth',
183
+ client_id: result.clientId,
184
+ user_login: _userLoginFromTokens(result, ctx),
185
+ scopes: result.scopes,
186
+ access_token_expires_at: persisted.accessTokenExpiresAt,
187
+ refresh_token_expires_at: persisted.refreshTokenExpiresAt,
188
+ access_token_ref: persisted.accessTokenRef,
189
+ refresh_token_ref: persisted.refreshTokenRef,
190
+ },
191
+ auth_status: AUTH_STATUS.ACTIVE,
192
+ oauth_capability_pinned: {
193
+ first_seen_at: result.capabilityPin.firstSeenAt,
194
+ last_confirmed_at: result.capabilityPin.lastConfirmedAt,
195
+ },
196
+ };
197
+ // Adapter-derived MCP endpoint for runtime HTTP calls. Stored alongside
198
+ // OAuth state so the existing transport layer keeps working.
199
+ if (result.prMetadata && result.prMetadata.resource) {
200
+ config.sites[siteId].mcp_resource = result.prMetadata.resource;
201
+ }
202
+ if (!config.defaultSite) config.defaultSite = siteId;
203
+ await writeConfig(ctx.configPath, config);
204
+
205
+ out.push(`✓ Site "${siteId}" configured. Granted scopes: ${result.scopes.join(', ')}.`);
206
+ out.push(` Config: ${ctx.configPath}`);
207
+ return { exitCode, lines: out };
208
+ }
209
+
210
+ /**
211
+ * Best-effort user_login extraction. The token endpoint may include the
212
+ * username in a non-standard claim; if absent we fall back to the OS user as
213
+ * a placeholder operators can edit. The runtime does not depend on this
214
+ * value — it is display-only per F.5.
215
+ */
216
+ function _userLoginFromTokens(result, ctx) {
217
+ if (result.tokens && typeof result.tokens.user_login === 'string') {
218
+ return result.tokens.user_login;
219
+ }
220
+ if (result.tokens && typeof result.tokens.username === 'string') {
221
+ return result.tokens.username;
222
+ }
223
+ return ctx.userLabel || 'operator';
224
+ }
225
+
226
+ module.exports = { run, deriveSiteId };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const { CliError, EXIT_USAGE } = require('../errors');
4
+ const { readConfig, writeConfig } = require('../config-store');
5
+
6
+ /**
7
+ * `force-downgrade <site_id> --i-understand-the-risk` — override OAuth
8
+ * capability pinning (Appendix H.2.3).
9
+ *
10
+ * H.2.3 states force-downgrade is "a deliberate, audit-logged action" and that
11
+ * the action is "surfaced in `list-sites` output for the next 30 days." The
12
+ * spec leaves the audit-log schema to the implementer.
13
+ *
14
+ * Phase 5 design choice: store the audit record on the site itself rather
15
+ * than in a separate log file. The record has three fields:
16
+ * force_downgrade.at ISO 8601, when the operator ran the command
17
+ * force_downgrade.expires_at at + 30 days (ISO)
18
+ * force_downgrade.reason free-form string from --reason=<...>
19
+ *
20
+ * `list-sites` reads `force_downgrade.expires_at` and surfaces an annotation
21
+ * line under the affected site row until expiry. A separate log file would
22
+ * be redundant — the site config already lives in version-control-friendly
23
+ * JSON, which is the most accessible audit surface on a developer's laptop.
24
+ *
25
+ * Effect on runtime: clears `oauth_capability_pinned` so subsequent discovery
26
+ * misses no longer raise `CapabilityPinningError`. The site config keeps its
27
+ * OAuth `auth.method` so the operator can return to OAuth simply by running
28
+ * `add-site` or `reauth` (which re-pin on success).
29
+ *
30
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
31
+ * @license GPL-2.0-or-later
32
+ */
33
+
34
+ const AUDIT_RETENTION_DAYS = 30;
35
+
36
+ async function run(args, ctx) {
37
+ const siteId = args._ && args._[0];
38
+ if (!siteId) {
39
+ throw new CliError('force-downgrade requires a site_id argument', {
40
+ exitCode: EXIT_USAGE,
41
+ nextAction: 'Run: abilities-mcp force-downgrade <site_id> --i-understand-the-risk',
42
+ });
43
+ }
44
+ if (!args['i-understand-the-risk']) {
45
+ throw new CliError(
46
+ 'force-downgrade requires --i-understand-the-risk (Appendix H.2.3 — deliberate override)',
47
+ {
48
+ exitCode: EXIT_USAGE,
49
+ nextAction: 'Re-run with --i-understand-the-risk if you have verified the site genuinely no longer supports OAuth',
50
+ }
51
+ );
52
+ }
53
+ const config = readConfig(ctx.configPath);
54
+ const site = config.sites[siteId];
55
+ if (!site) {
56
+ throw new CliError(`force-downgrade: unknown site_id "${siteId}"`, {
57
+ exitCode: EXIT_USAGE,
58
+ nextAction: 'Run: abilities-mcp list-sites to see configured sites',
59
+ });
60
+ }
61
+ if (!site.oauth_capability_pinned) {
62
+ return {
63
+ exitCode: 0,
64
+ lines: [
65
+ `Site "${siteId}" is not OAuth-pinned — nothing to override.`,
66
+ `(Idempotent — no audit record written.)`,
67
+ ],
68
+ };
69
+ }
70
+
71
+ const nowMs = ctx.now ? ctx.now() : Date.now();
72
+ const at = new Date(nowMs).toISOString();
73
+ const expiresAt = new Date(nowMs + AUDIT_RETENTION_DAYS * 24 * 3600 * 1000).toISOString();
74
+ const reason = typeof args.reason === 'string' && args.reason.length > 0
75
+ ? args.reason
76
+ : 'no reason given';
77
+
78
+ delete site.oauth_capability_pinned;
79
+ site.force_downgrade = { at, expires_at: expiresAt, reason };
80
+ await writeConfig(ctx.configPath, config);
81
+
82
+ return {
83
+ exitCode: 0,
84
+ lines: [
85
+ `⚠ force-downgrade recorded for site "${siteId}".`,
86
+ ` Reason: ${reason}`,
87
+ ` Audit record expires: ${expiresAt} (visible in list-sites for ${AUDIT_RETENTION_DAYS} days).`,
88
+ ` OAuth capability pin cleared — silent App Password fallback is now allowed for this site.`,
89
+ ],
90
+ };
91
+ }
92
+
93
+ module.exports = { run, AUDIT_RETENTION_DAYS };