@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,88 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Public surface for `lib/auth/`.
5
+ *
6
+ * Every export here maps 1:1 to a future CLI subcommand or is a building
7
+ * block consumed by the state machine. The module is callable from any
8
+ * consumer (CLI today, GUI tomorrow). It contains zero CLI dependencies —
9
+ * no console.log, no process.exit, no readline.
10
+ *
11
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
12
+ * @license GPL-2.0-or-later
13
+ */
14
+
15
+ const events = require('./events');
16
+ const errors = require('./errors');
17
+ const secretStore = require('./secret-store');
18
+ const { MemorySecretStore } = require('./memory-secret-store');
19
+ const { KeychainSecretStore } = require('./keychain-secret-store');
20
+ const bridgeIdentity = require('./bridge-identity-provider');
21
+ const { FreshEachTimeIdentityProvider } = require('./fresh-each-time-identity');
22
+ const pkce = require('./pkce');
23
+ const discoveryClient = require('./discovery-client');
24
+ const dcrClient = require('./dcr-client');
25
+ const { LoopbackServer, PORT_MIN, PORT_MAX } = require('./loopback-server');
26
+ const { openBrowser } = require('./browser-launcher');
27
+ const { OAuthClient, DEFAULT_SCOPE } = require('./oauth-client');
28
+ const { TokenManager, REFRESH_WINDOW_SECONDS, HTTP_TIMEOUT_MS, MAX_RETRIES } = require('./token-manager');
29
+ const schemaV2 = require('./schema-v2');
30
+ const configMigration = require('./config-migration');
31
+
32
+ module.exports = {
33
+ // Constants and enums
34
+ STATES: events.STATES,
35
+ TERMINAL_STATES: events.TERMINAL_STATES,
36
+ AUTH_STATUS: events.AUTH_STATUS,
37
+ EVENTS: events.EVENTS,
38
+ DEFAULT_SCOPE,
39
+ REFRESH_WINDOW_SECONDS,
40
+ HTTP_TIMEOUT_MS,
41
+ MAX_RETRIES,
42
+ PORT_MIN,
43
+ PORT_MAX,
44
+ IDENTITY_BUNDLE_VERSION: bridgeIdentity.IDENTITY_BUNDLE_VERSION,
45
+ SCHEMA_VERSION: schemaV2.SCHEMA_VERSION,
46
+ AUTH_METHODS: schemaV2.AUTH_METHODS,
47
+ VALID_AUTH_STATUS: schemaV2.VALID_AUTH_STATUS,
48
+
49
+ // Errors
50
+ AuthError: errors.AuthError,
51
+ DiscoveryError: errors.DiscoveryError,
52
+ CapabilityPinningError: errors.CapabilityPinningError,
53
+ RegistrationError: errors.RegistrationError,
54
+ TokenExchangeError: errors.TokenExchangeError,
55
+ StateMismatchError: errors.StateMismatchError,
56
+ UserDeniedError: errors.UserDeniedError,
57
+ RefreshError: errors.RefreshError,
58
+ SecretStoreError: errors.SecretStoreError,
59
+ MigrationError: errors.MigrationError,
60
+
61
+ // Secret store
62
+ SecretStore: {
63
+ KEYCHAIN_REF_SCHEME: secretStore.KEYCHAIN_REF_SCHEME,
64
+ makeRef: secretStore.makeRef,
65
+ parseRef: secretStore.parseRef,
66
+ resolveRef: secretStore.resolveRef,
67
+ },
68
+ MemorySecretStore,
69
+ KeychainSecretStore,
70
+
71
+ // Identity
72
+ FreshEachTimeIdentityProvider,
73
+
74
+ // Primitives
75
+ pkce,
76
+ discoveryClient,
77
+ dcrClient,
78
+ LoopbackServer,
79
+ openBrowser,
80
+
81
+ // Orchestration
82
+ OAuthClient,
83
+ TokenManager,
84
+
85
+ // Config schema
86
+ schemaV2,
87
+ configMigration,
88
+ };
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ const { SecretStoreError } = require('./errors');
4
+
5
+ /**
6
+ * KeychainSecretStore — keytar-backed SecretStore.
7
+ *
8
+ * keytar wraps macOS Keychain, Windows Credential Manager, and Linux libsecret.
9
+ * It is declared as an `optionalDependency` so a failed native build does not
10
+ * break `npm install` for env-var-only operators. If keytar is unavailable at
11
+ * runtime, every method on this store throws `SecretStoreError` with code
12
+ * `keytar_unavailable` — callers can detect that and fall back to a different
13
+ * store (e.g. MemorySecretStore for tests, or surface to the user).
14
+ *
15
+ * Implements the SecretStore interface defined in `secret-store.js`.
16
+ *
17
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
18
+ * @license GPL-2.0-or-later
19
+ */
20
+
21
+ class KeychainSecretStore {
22
+ /**
23
+ * @param {object} [opts]
24
+ * @param {object} [opts.keytar] Inject a keytar module — primarily for tests.
25
+ * When omitted, keytar is required lazily on
26
+ * first use.
27
+ */
28
+ constructor(opts = {}) {
29
+ this._injected = opts.keytar || null;
30
+ this._keytar = null;
31
+ this._loadAttempted = false;
32
+ this._loadError = null;
33
+ }
34
+
35
+ _load() {
36
+ if (this._keytar) return this._keytar;
37
+ if (this._loadAttempted) {
38
+ if (this._loadError) {
39
+ throw new SecretStoreError(
40
+ `OS keychain unavailable: ${this._loadError.message}`,
41
+ { code: 'keytar_unavailable', cause: this._loadError }
42
+ );
43
+ }
44
+ return this._keytar;
45
+ }
46
+ this._loadAttempted = true;
47
+ if (this._injected) {
48
+ this._keytar = this._injected;
49
+ return this._keytar;
50
+ }
51
+ try {
52
+ // eslint-disable-next-line global-require
53
+ this._keytar = require('keytar');
54
+ return this._keytar;
55
+ } catch (err) {
56
+ this._loadError = err;
57
+ throw new SecretStoreError(
58
+ `OS keychain unavailable: ${err.message}`,
59
+ { code: 'keytar_unavailable', cause: err }
60
+ );
61
+ }
62
+ }
63
+
64
+ /** @returns {Promise<boolean>} true if keytar can be loaded on this host. */
65
+ async isAvailable() {
66
+ try {
67
+ this._load();
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ async get(service, account) {
75
+ const keytar = this._load();
76
+ return keytar.getPassword(service, account);
77
+ }
78
+
79
+ async set(service, account, secret) {
80
+ if (typeof secret !== 'string') {
81
+ throw new TypeError('SecretStore.set: secret must be a string');
82
+ }
83
+ const keytar = this._load();
84
+ await keytar.setPassword(service, account, secret);
85
+ }
86
+
87
+ async delete(service, account) {
88
+ const keytar = this._load();
89
+ return keytar.deletePassword(service, account);
90
+ }
91
+
92
+ async findAll(service) {
93
+ const keytar = this._load();
94
+ return keytar.findCredentials(service);
95
+ }
96
+ }
97
+
98
+ module.exports = { KeychainSecretStore };
@@ -0,0 +1,249 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+ const { randomInt } = require('node:crypto');
5
+ const { URL } = require('node:url');
6
+
7
+ const { safeStateEquals } = require('./pkce');
8
+ const { StateMismatchError, UserDeniedError, AuthError } = require('./errors');
9
+
10
+ /**
11
+ * Loopback callback server for the OAuth authorization-code flow.
12
+ *
13
+ * Per Appendix H.4.5 (binding):
14
+ * - Bind on 127.0.0.1, random high port in [49152, 65535].
15
+ * - SO_REUSEADDR=false. In Node, that maps to `listen({ exclusive: true })`
16
+ * — the server will not be shared with other workers and will fail with
17
+ * EADDRINUSE if the port is already bound.
18
+ * - State token validates the callback (H.3.5: timingSafeEqual on equal
19
+ * length, mismatch → reject without exchanging code).
20
+ *
21
+ * Public API:
22
+ * const server = new LoopbackServer({ expectedState });
23
+ * const { port, redirectUri } = await server.start();
24
+ * // → operator browser flow happens
25
+ * const callback = await server.waitForCallback({ timeoutMs });
26
+ * // callback = { code, state } | throws StateMismatchError | UserDeniedError | AuthError
27
+ * await server.stop();
28
+ *
29
+ * The server responds to the browser with a small HTML success or error page
30
+ * depending on the outcome. The callback Promise resolves AFTER the response
31
+ * is flushed so the operator's browser shows the page even if the caller
32
+ * stops the server immediately.
33
+ *
34
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
35
+ * @license GPL-2.0-or-later
36
+ */
37
+
38
+ const PORT_MIN = 49_152;
39
+ const PORT_MAX = 65_535;
40
+ const DEFAULT_TIMEOUT_MS = 5 * 60_000; // 5 min for operator to complete browser flow
41
+ const DEFAULT_BIND_RETRIES = 5;
42
+
43
+ const SUCCESS_HTML = `<!DOCTYPE html>
44
+ <html><head><meta charset="utf-8"><title>Authorization complete</title>
45
+ <style>body{font:16px/1.5 -apple-system,system-ui,sans-serif;max-width:36rem;margin:6rem auto;padding:0 1.5rem;color:#111}h1{margin:0 0 1rem}p{margin:.25rem 0}</style>
46
+ </head><body><h1>Authorization complete</h1>
47
+ <p>You can close this tab and return to your terminal.</p></body></html>`;
48
+
49
+ const DENIED_HTML = `<!DOCTYPE html>
50
+ <html><head><meta charset="utf-8"><title>Authorization denied</title>
51
+ <style>body{font:16px/1.5 -apple-system,system-ui,sans-serif;max-width:36rem;margin:6rem auto;padding:0 1.5rem;color:#111}h1{margin:0 0 1rem;color:#a00}p{margin:.25rem 0}</style>
52
+ </head><body><h1>Authorization denied</h1>
53
+ <p>The site reported that authorization was denied. You can close this tab.</p></body></html>`;
54
+
55
+ const ERROR_HTML = `<!DOCTYPE html>
56
+ <html><head><meta charset="utf-8"><title>Authorization error</title>
57
+ <style>body{font:16px/1.5 -apple-system,system-ui,sans-serif;max-width:36rem;margin:6rem auto;padding:0 1.5rem;color:#111}h1{margin:0 0 1rem;color:#a00}p{margin:.25rem 0}</style>
58
+ </head><body><h1>Authorization error</h1>
59
+ <p>An unexpected response was received. Check your terminal for details.</p></body></html>`;
60
+
61
+ class LoopbackServer {
62
+ /**
63
+ * @param {object} args
64
+ * @param {string} args.expectedState Bridge-generated state token
65
+ * @param {string} [args.callbackPath] Defaults to '/callback'
66
+ * @param {number} [args.bindRetries]
67
+ * @param {(min:number,max:number)=>number} [args.portFn] Test seam.
68
+ */
69
+ constructor(args) {
70
+ if (!args || typeof args.expectedState !== 'string' || args.expectedState.length === 0) {
71
+ throw new Error('LoopbackServer requires expectedState');
72
+ }
73
+ this._expectedState = args.expectedState;
74
+ this._callbackPath = args.callbackPath || '/callback';
75
+ this._bindRetries = args.bindRetries ?? DEFAULT_BIND_RETRIES;
76
+ this._portFn = args.portFn || (() => randomInt(PORT_MIN, PORT_MAX + 1));
77
+ this._server = null;
78
+ this._port = null;
79
+ this._callbackPromise = null;
80
+ this._resolveCallback = null;
81
+ this._rejectCallback = null;
82
+ this._stopped = false;
83
+ }
84
+
85
+ /** @returns {{port: number, redirectUri: string}} */
86
+ async start() {
87
+ let lastErr;
88
+ for (let attempt = 0; attempt <= this._bindRetries; attempt++) {
89
+ const port = this._portFn(PORT_MIN, PORT_MAX);
90
+ try {
91
+ await this._listen(port);
92
+ this._port = port;
93
+ return { port, redirectUri: this.redirectUri };
94
+ } catch (err) {
95
+ lastErr = err;
96
+ if (err && err.code !== 'EADDRINUSE') break;
97
+ }
98
+ }
99
+ throw new AuthError(
100
+ `Loopback server failed to bind on a free port after ${this._bindRetries + 1} attempts`,
101
+ { code: 'loopback_bind_failed', cause: lastErr }
102
+ );
103
+ }
104
+
105
+ get redirectUri() {
106
+ if (this._port == null) throw new Error('LoopbackServer not started');
107
+ return `http://127.0.0.1:${this._port}${this._callbackPath}`;
108
+ }
109
+
110
+ /**
111
+ * Wait for the OAuth provider to redirect the operator's browser to our
112
+ * callback URL. Resolves with `{ code, state }` once a valid callback
113
+ * arrives, or rejects with a typed error.
114
+ *
115
+ * @param {object} [opts]
116
+ * @param {number} [opts.timeoutMs]
117
+ * @returns {Promise<{code: string, state: string}>}
118
+ */
119
+ waitForCallback(opts = {}) {
120
+ if (this._callbackPromise) return this._callbackPromise;
121
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
122
+ this._callbackPromise = new Promise((resolve, reject) => {
123
+ let timer = null;
124
+ const settle = (fn, value) => {
125
+ if (timer) clearTimeout(timer);
126
+ timer = null;
127
+ this._resolveCallback = null;
128
+ this._rejectCallback = null;
129
+ fn(value);
130
+ };
131
+ this._resolveCallback = (value) => settle(resolve, value);
132
+ this._rejectCallback = (err) => settle(reject, err);
133
+ if (timeoutMs > 0) {
134
+ timer = setTimeout(() => {
135
+ // The settle path clears `timer` to avoid double-clear here.
136
+ this._reject(new AuthError(
137
+ `Loopback callback timed out after ${timeoutMs}ms`,
138
+ { code: 'loopback_timeout', state: 'awaiting_consent' }
139
+ ));
140
+ }, timeoutMs);
141
+ if (timer.unref) timer.unref();
142
+ }
143
+ });
144
+ return this._callbackPromise;
145
+ }
146
+
147
+ async stop() {
148
+ this._stopped = true;
149
+ if (!this._server) return;
150
+ await new Promise((resolve) => this._server.close(() => resolve()));
151
+ this._server = null;
152
+ }
153
+
154
+ // ---------------------------------------------------------------------
155
+
156
+ _listen(port) {
157
+ return new Promise((resolve, reject) => {
158
+ const server = http.createServer((req, res) => this._onRequest(req, res));
159
+ server.on('error', (err) => {
160
+ if (this._port == null) reject(err);
161
+ });
162
+ // exclusive: true → SO_REUSEADDR=false in Node's worker model. Per
163
+ // Appendix H.4.5 we do not share the port with other listeners.
164
+ server.listen({ host: '127.0.0.1', port, exclusive: true }, () => {
165
+ this._server = server;
166
+ resolve();
167
+ });
168
+ });
169
+ }
170
+
171
+ _onRequest(req, res) {
172
+ if (this._stopped) {
173
+ res.statusCode = 503;
174
+ res.end();
175
+ return;
176
+ }
177
+ let parsed;
178
+ try { parsed = new URL(req.url, `http://127.0.0.1:${this._port}`); }
179
+ catch {
180
+ this._respond(res, 400, ERROR_HTML);
181
+ return;
182
+ }
183
+ if (parsed.pathname !== this._callbackPath) {
184
+ this._respond(res, 404, ERROR_HTML);
185
+ return;
186
+ }
187
+
188
+ const params = parsed.searchParams;
189
+ const error = params.get('error');
190
+ const errorDescription = params.get('error_description');
191
+ const code = params.get('code');
192
+ const receivedState = params.get('state');
193
+
194
+ if (error) {
195
+ this._respond(res, 200, error === 'access_denied' ? DENIED_HTML : ERROR_HTML);
196
+ const Cls = error === 'access_denied' ? UserDeniedError : AuthError;
197
+ this._reject(new Cls(
198
+ errorDescription || `Authorization server returned error: ${error}`,
199
+ { code: error, state: 'awaiting_consent' }
200
+ ));
201
+ return;
202
+ }
203
+
204
+ if (!code || !receivedState) {
205
+ this._respond(res, 400, ERROR_HTML);
206
+ this._reject(new AuthError(
207
+ 'Loopback callback missing code or state',
208
+ { code: 'invalid_callback', state: 'awaiting_consent' }
209
+ ));
210
+ return;
211
+ }
212
+
213
+ if (!safeStateEquals(this._expectedState, receivedState)) {
214
+ this._respond(res, 400, ERROR_HTML);
215
+ this._reject(new StateMismatchError(undefined, { state: 'awaiting_consent' }));
216
+ return;
217
+ }
218
+
219
+ this._respond(res, 200, SUCCESS_HTML);
220
+ this._resolve({ code, state: receivedState });
221
+ }
222
+
223
+ _respond(res, status, html) {
224
+ res.statusCode = status;
225
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
226
+ res.setHeader('Cache-Control', 'no-store');
227
+ res.end(html);
228
+ }
229
+
230
+ _resolve(value) {
231
+ if (this._resolveCallback) {
232
+ const fn = this._resolveCallback;
233
+ this._resolveCallback = null;
234
+ this._rejectCallback = null;
235
+ fn(value);
236
+ }
237
+ }
238
+
239
+ _reject(err) {
240
+ if (this._rejectCallback) {
241
+ const fn = this._rejectCallback;
242
+ this._resolveCallback = null;
243
+ this._rejectCallback = null;
244
+ fn(err);
245
+ }
246
+ }
247
+ }
248
+
249
+ module.exports = { LoopbackServer, PORT_MIN, PORT_MAX, DEFAULT_TIMEOUT_MS };
Binary file