@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,357 @@
1
+ 'use strict';
2
+
3
+ const { EventEmitter } = require('node:events');
4
+
5
+ const { STATES, TERMINAL_STATES, EVENTS } = require('./events');
6
+ const { discover } = require('./discovery-client');
7
+ const { register } = require('./dcr-client');
8
+ const { LoopbackServer } = require('./loopback-server');
9
+ const { openBrowser } = require('./browser-launcher');
10
+ const { generatePkce, generateState } = require('./pkce');
11
+ const { postForm } = require('./http-json');
12
+ const {
13
+ AuthError,
14
+ TokenExchangeError,
15
+ } = require('./errors');
16
+
17
+ /**
18
+ * OAuthClient — event-emitting state machine for the authorization-code +
19
+ * PKCE flow. Defined in the design doc's "Architectural constraint: ship
20
+ * CLI, architect for console" section as binding for v1.0.
21
+ *
22
+ * States (binding):
23
+ * idle → discovering → registering → awaiting_consent → exchanging
24
+ * → complete | failed
25
+ *
26
+ * Events emitted (see lib/auth/events.js):
27
+ * 'state' every transition, payload `{ from, to, data }`
28
+ * 'progress' sub-step info (e.g. discovery probe results)
29
+ * 'complete' terminal success
30
+ * 'failed' terminal failure
31
+ * plus one event per state name for observers that prefer named handlers.
32
+ *
33
+ * Public API:
34
+ * const client = new OAuthClient({ siteUrl, identityProvider, scope, ... });
35
+ * client.on('state', ({ from, to, data }) => { ... });
36
+ * const result = await client.run();
37
+ * // result = { tokens, scopes, clientId, asMetadata, prMetadata, capabilityPin }
38
+ *
39
+ * Constraints (from issue #12 and the sprint plan):
40
+ * - No console.* / process.* writes anywhere in this module.
41
+ * - Public methods map 1:1 to future CLI subcommands.
42
+ * - The state machine is the entire flow — no one-shot helpers that bypass
43
+ * it for "convenience."
44
+ *
45
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
46
+ * @license GPL-2.0-or-later
47
+ */
48
+
49
+ const DEFAULT_SCOPE = 'abilities:read abilities:write';
50
+ const DEFAULT_LOOPBACK_TIMEOUT_MS = 5 * 60_000;
51
+
52
+ class OAuthClient extends EventEmitter {
53
+ /**
54
+ * @param {object} args
55
+ * @param {string} args.siteUrl Canonical site URL
56
+ * @param {string} args.clientName e.g. "<user>'s Operator (host.local)"
57
+ * @param {string} args.softwareVersion Bridge package version
58
+ * @param {string|string[]} [args.scope] Default 'abilities:read abilities:write'
59
+ * @param {string} [args.resource] RFC 8707 resource indicator. If omitted, derived from prMetadata.
60
+ * @param {object} args.identityProvider BridgeIdentityProvider
61
+ * @param {object} [args.capabilityPin] { firstSeenAt: ISO } — pass when site is OAuth-pinned
62
+ * @param {boolean} [args.allowInsecure]
63
+ * @param {object} [args.loopback] LoopbackServer override (DI for tests)
64
+ * @param {object} [args.deps] DI of low-level helpers (tests)
65
+ * @param {number} [args.loopbackTimeoutMs]
66
+ * @param {number} [args.timeoutMs] HTTP timeout for non-loopback calls
67
+ */
68
+ constructor(args) {
69
+ super();
70
+ if (!args || typeof args.siteUrl !== 'string') {
71
+ throw new Error('OAuthClient requires siteUrl');
72
+ }
73
+ if (typeof args.clientName !== 'string' || !args.clientName) {
74
+ throw new Error('OAuthClient requires clientName');
75
+ }
76
+ if (typeof args.softwareVersion !== 'string' || !args.softwareVersion) {
77
+ throw new Error('OAuthClient requires softwareVersion');
78
+ }
79
+ if (!args.identityProvider || typeof args.identityProvider.getClientId !== 'function') {
80
+ throw new Error('OAuthClient requires identityProvider with BridgeIdentityProvider shape');
81
+ }
82
+
83
+ this.siteUrl = args.siteUrl;
84
+ this.clientName = args.clientName;
85
+ this.softwareVersion = args.softwareVersion;
86
+ this.scope = args.scope || DEFAULT_SCOPE;
87
+ this.resource = args.resource || null;
88
+ this.identityProvider = args.identityProvider;
89
+ this.capabilityPin = args.capabilityPin || null;
90
+ this.allowInsecure = !!args.allowInsecure;
91
+ this.loopbackTimeoutMs = args.loopbackTimeoutMs || DEFAULT_LOOPBACK_TIMEOUT_MS;
92
+ this.timeoutMs = args.timeoutMs;
93
+
94
+ // Dependency injection seams — production callers don't pass these.
95
+ const deps = args.deps || {};
96
+ this._discover = deps.discover || discover;
97
+ this._register = deps.register || register;
98
+ this._postForm = deps.postForm || postForm;
99
+ this._openBrowser = deps.openBrowser || openBrowser;
100
+ this._LoopbackServer = deps.LoopbackServer || LoopbackServer;
101
+ this._loopbackOverride = args.loopback || null;
102
+
103
+ this.state = STATES.IDLE;
104
+ this._lastError = null;
105
+ }
106
+
107
+ // ---------------------------------------------------------------------
108
+ // State helpers
109
+ // ---------------------------------------------------------------------
110
+
111
+ _transition(to, data) {
112
+ const from = this.state;
113
+ this.state = to;
114
+ const payload = { from, to, data: data || null };
115
+ this.emit(EVENTS.STATE, payload);
116
+ this.emit(to, payload);
117
+ }
118
+
119
+ _progress(message, data) {
120
+ this.emit(EVENTS.PROGRESS, { state: this.state, message, data: data || null });
121
+ }
122
+
123
+ _fail(err) {
124
+ this._lastError = err;
125
+ this._transition(STATES.FAILED, { error: err });
126
+ this.emit(EVENTS.FAILED, { error: err, state: STATES.FAILED });
127
+ }
128
+
129
+ // ---------------------------------------------------------------------
130
+ // Public API
131
+ // ---------------------------------------------------------------------
132
+
133
+ /**
134
+ * Run the full state machine. Resolves with the result on success, throws
135
+ * on failure (the 'failed' event is emitted first so subscribers see the
136
+ * terminal state before the rejection propagates).
137
+ *
138
+ * @returns {Promise<{
139
+ * tokens: object,
140
+ * scopes: string[],
141
+ * clientId: string,
142
+ * asMetadata: object,
143
+ * prMetadata: object|null,
144
+ * capabilityPin: {firstSeenAt: string, lastConfirmedAt: string},
145
+ * resource: string|null,
146
+ * }>}
147
+ */
148
+ async run() {
149
+ if (TERMINAL_STATES.has(this.state)) {
150
+ throw new Error(`OAuthClient.run() called on terminal state: ${this.state}`);
151
+ }
152
+ try {
153
+ const discovered = await this._runDiscover();
154
+ const registered = await this._runRegister(discovered);
155
+ const consent = await this._runAwaitConsent(discovered, registered);
156
+ const exchanged = await this._runExchange(discovered, registered, consent);
157
+ const result = this._buildResult(discovered, registered, exchanged);
158
+ this._transition(STATES.COMPLETE, result);
159
+ this.emit(EVENTS.COMPLETE, result);
160
+ return result;
161
+ } catch (err) {
162
+ const wrapped = err instanceof AuthError ? err : new AuthError(err.message, { cause: err, state: this.state });
163
+ this._fail(wrapped);
164
+ throw wrapped;
165
+ }
166
+ }
167
+
168
+ // ---------------------------------------------------------------------
169
+ // State implementations
170
+ // ---------------------------------------------------------------------
171
+
172
+ async _runDiscover() {
173
+ this._transition(STATES.DISCOVERING, { siteUrl: this.siteUrl });
174
+ const pinnedFirstSeenAt = this.capabilityPin && this.capabilityPin.firstSeenAt
175
+ ? this.capabilityPin.firstSeenAt
176
+ : null;
177
+ const discovered = await this._discover(this.siteUrl, {
178
+ pinned: !!this.capabilityPin,
179
+ pinnedFirstSeenAt,
180
+ allowInsecure: this.allowInsecure,
181
+ timeoutMs: this.timeoutMs,
182
+ });
183
+ this._progress('discovery_succeeded', {
184
+ asMetadataUrl: discovered.asMetadataUrl,
185
+ probeResults: discovered.probeResults,
186
+ });
187
+ return discovered;
188
+ }
189
+
190
+ async _runRegister(discovered) {
191
+ this._transition(STATES.REGISTERING, {
192
+ registrationEndpoint: discovered.asMetadata.registration_endpoint,
193
+ });
194
+
195
+ // H-8: never reuse a persisted client_id from this code path.
196
+ //
197
+ // The previous early-return looked up identityProvider.getClientId() and
198
+ // returned the persisted client_id without checking whether the registered
199
+ // loopback redirect_uri's port matched the live loopback port. v1.0 was
200
+ // safe by accident because FreshEachTimeIdentityProvider.getClientId()
201
+ // always returns null. v1.1 (Option C, persistent client_id per Appendix
202
+ // H.3.2) would have surfaced the bug: a stale persisted client_id whose
203
+ // server-side registered redirect_uri pinned a port no longer in use
204
+ // would cause the next /oauth/authorize to fail redirect_uri_valid().
205
+ //
206
+ // Defensive guard: clear any persisted client_id before DCR. v1.0
207
+ // FreshEachTime is a no-op; v1.1+ implementations of clearClientId() get
208
+ // a chance to remove a stale entry before we mint a new one. Reuse paths
209
+ // that *already know* their loopback port matches the registration must
210
+ // not flow through _runRegister — they need their own short-circuit at
211
+ // the OAuthClient.run() level.
212
+ await this.identityProvider.clearClientId(this.siteUrl);
213
+
214
+ if (!discovered.asMetadata.registration_endpoint) {
215
+ throw new AuthError('Authorization server metadata missing registration_endpoint', {
216
+ code: 'no_registration_endpoint', state: STATES.REGISTERING,
217
+ });
218
+ }
219
+
220
+ // We need a redirect_uri to register. Spin up the loopback server now
221
+ // so its port is known to DCR (even though we don't await callbacks
222
+ // until later).
223
+ const expectedState = generateState();
224
+ const loopback = this._loopbackOverride || new this._LoopbackServer({ expectedState });
225
+ await loopback.start();
226
+ this._loopback = loopback;
227
+ this._expectedState = expectedState;
228
+
229
+ let registration;
230
+ try {
231
+ registration = await this._register({
232
+ registrationEndpoint: discovered.asMetadata.registration_endpoint,
233
+ clientName: this.clientName,
234
+ redirectUri: loopback.redirectUri,
235
+ scope: this.scope,
236
+ softwareVersion: this.softwareVersion,
237
+ allowInsecure: this.allowInsecure,
238
+ timeoutMs: this.timeoutMs,
239
+ });
240
+ } catch (err) {
241
+ await loopback.stop().catch(() => {});
242
+ throw err;
243
+ }
244
+
245
+ await this.identityProvider.persistClientId(this.siteUrl, registration.clientId);
246
+ this._progress('registered', { clientId: registration.clientId });
247
+ return registration;
248
+ }
249
+
250
+ async _runAwaitConsent(discovered, registered) {
251
+ if (!this._loopback) {
252
+ throw new AuthError('Internal error: loopback server not started', { code: 'internal_error' });
253
+ }
254
+ const pkce = generatePkce();
255
+ this._pkce = pkce;
256
+
257
+ const resource = this.resource
258
+ || (discovered.prMetadata && discovered.prMetadata.resource)
259
+ || null;
260
+
261
+ const authorizeUrl = new URL(discovered.asMetadata.authorization_endpoint);
262
+ authorizeUrl.searchParams.set('response_type', 'code');
263
+ authorizeUrl.searchParams.set('client_id', registered.clientId);
264
+ authorizeUrl.searchParams.set('redirect_uri', this._loopback.redirectUri);
265
+ authorizeUrl.searchParams.set('scope', Array.isArray(this.scope) ? this.scope.join(' ') : this.scope);
266
+ authorizeUrl.searchParams.set('state', this._expectedState);
267
+ authorizeUrl.searchParams.set('code_challenge', pkce.challenge);
268
+ authorizeUrl.searchParams.set('code_challenge_method', pkce.method);
269
+ if (resource) authorizeUrl.searchParams.set('resource', resource);
270
+
271
+ this._transition(STATES.AWAITING_CONSENT, {
272
+ authorizeUrl: authorizeUrl.toString(),
273
+ redirectUri: this._loopback.redirectUri,
274
+ });
275
+
276
+ // Launch browser as a side-effect and wait for the loopback callback.
277
+ // We swallow browser-launch errors because the caller may already have
278
+ // displayed the URL for manual paste (e.g. headless SSH session).
279
+ this._openBrowser(authorizeUrl.toString()).catch((err) => {
280
+ this._progress('browser_launch_failed', { error: err.message });
281
+ });
282
+
283
+ let callback;
284
+ try {
285
+ callback = await this._loopback.waitForCallback({ timeoutMs: this.loopbackTimeoutMs });
286
+ } finally {
287
+ await this._loopback.stop().catch(() => {});
288
+ }
289
+ this._progress('callback_received', { codePresent: !!callback.code });
290
+ return { ...callback, resource };
291
+ }
292
+
293
+ async _runExchange(discovered, registered, consent) {
294
+ this._transition(STATES.EXCHANGING, { tokenEndpoint: discovered.asMetadata.token_endpoint });
295
+
296
+ const params = {
297
+ grant_type: 'authorization_code',
298
+ code: consent.code,
299
+ redirect_uri: this._loopback ? this._loopback.redirectUri : undefined,
300
+ client_id: registered.clientId,
301
+ code_verifier: this._pkce.verifier,
302
+ };
303
+ if (consent.resource) params.resource = consent.resource;
304
+ // Strip undefined.
305
+ for (const k of Object.keys(params)) if (params[k] === undefined) delete params[k];
306
+
307
+ let res;
308
+ try {
309
+ res = await this._postForm(discovered.asMetadata.token_endpoint, params, {
310
+ allowInsecure: this.allowInsecure,
311
+ timeoutMs: this.timeoutMs,
312
+ });
313
+ } catch (err) {
314
+ throw new TokenExchangeError(`Token exchange request failed: ${err.message}`, {
315
+ cause: err, state: STATES.EXCHANGING,
316
+ });
317
+ }
318
+ if (res.statusCode < 200 || res.statusCode >= 300 || !res.json) {
319
+ throw new TokenExchangeError(`Token endpoint returned ${res.statusCode}`, {
320
+ cause: { statusCode: res.statusCode, body: res.body, json: res.json },
321
+ state: STATES.EXCHANGING,
322
+ });
323
+ }
324
+ if (typeof res.json.access_token !== 'string') {
325
+ throw new TokenExchangeError('Token response missing access_token', {
326
+ cause: res.json, state: STATES.EXCHANGING,
327
+ });
328
+ }
329
+ return res.json;
330
+ }
331
+
332
+ _buildResult(discovered, registered, tokens) {
333
+ const now = new Date().toISOString();
334
+ const grantedScope = typeof tokens.scope === 'string'
335
+ ? tokens.scope.split(/\s+/).filter(Boolean)
336
+ : (Array.isArray(this.scope) ? this.scope : String(this.scope).split(/\s+/).filter(Boolean));
337
+
338
+ return {
339
+ tokens,
340
+ scopes: grantedScope,
341
+ clientId: registered.clientId,
342
+ asMetadata: discovered.asMetadata,
343
+ asMetadataUrl: discovered.asMetadataUrl,
344
+ prMetadata: discovered.prMetadata,
345
+ prMetadataUrl: discovered.prMetadataUrl,
346
+ capabilityPin: {
347
+ firstSeenAt: this.capabilityPin && this.capabilityPin.firstSeenAt
348
+ ? this.capabilityPin.firstSeenAt
349
+ : now,
350
+ lastConfirmedAt: now,
351
+ },
352
+ resource: this.resource || (discovered.prMetadata && discovered.prMetadata.resource) || null,
353
+ };
354
+ }
355
+ }
356
+
357
+ module.exports = { OAuthClient, DEFAULT_SCOPE };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const { randomBytes, createHash, timingSafeEqual: cryptoTimingSafeEqual } = require('node:crypto');
4
+
5
+ /**
6
+ * PKCE (RFC 7636) and CSRF state primitives.
7
+ *
8
+ * Per design doc:
9
+ * - PKCE method MUST be S256 (Appendix D.1, H.3.6, discovery metadata).
10
+ * - state = bin2hex(random_bytes(16)) → 128 bits of entropy (Appendix H.3.5).
11
+ * - State comparison on loopback callback uses timingSafeEqual (H.3.5, H.4.5).
12
+ *
13
+ * The verifier byte length is implementer's choice within RFC 7636 (43–128
14
+ * chars after base64url). We use 32 bytes → 43-char base64url string, the
15
+ * common choice.
16
+ *
17
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
18
+ * @license GPL-2.0-or-later
19
+ */
20
+
21
+ const VERIFIER_BYTES = 32; // → 43-char base64url verifier (RFC 7636 §4.1)
22
+ const STATE_BYTES = 16; // → 32-char hex state (128 bits, per H.3.5)
23
+ const STATE_MAX_LENGTH = 256; // server enforces; mirrored here for safety
24
+
25
+ function base64url(buf) {
26
+ return Buffer.from(buf).toString('base64')
27
+ .replace(/\+/g, '-')
28
+ .replace(/\//g, '_')
29
+ .replace(/=+$/g, '');
30
+ }
31
+
32
+ /**
33
+ * Generate a fresh PKCE pair.
34
+ * @returns {{verifier: string, challenge: string, method: 'S256'}}
35
+ */
36
+ function generatePkce() {
37
+ const verifierBytes = randomBytes(VERIFIER_BYTES);
38
+ const verifier = base64url(verifierBytes);
39
+ const challenge = base64url(createHash('sha256').update(verifier).digest());
40
+ return { verifier, challenge, method: 'S256' };
41
+ }
42
+
43
+ /**
44
+ * Compute the S256 challenge for a given verifier — useful for tests.
45
+ * @param {string} verifier
46
+ * @returns {string}
47
+ */
48
+ function challengeFromVerifier(verifier) {
49
+ return base64url(createHash('sha256').update(verifier).digest());
50
+ }
51
+
52
+ /**
53
+ * Generate a fresh CSRF state token. 128 bits of entropy, hex-encoded.
54
+ * @returns {string}
55
+ */
56
+ function generateState() {
57
+ return randomBytes(STATE_BYTES).toString('hex');
58
+ }
59
+
60
+ /**
61
+ * Constant-time equality check on two strings. Used to compare the loopback
62
+ * callback's `state` query param against the bridge-generated value (H.3.5,
63
+ * H.4.5). Returns false on any error including length mismatch.
64
+ *
65
+ * @param {string} expected
66
+ * @param {string} received
67
+ * @returns {boolean}
68
+ */
69
+ function safeStateEquals(expected, received) {
70
+ if (typeof expected !== 'string' || typeof received !== 'string') return false;
71
+ if (expected.length === 0 || received.length === 0) return false;
72
+ if (expected.length > STATE_MAX_LENGTH || received.length > STATE_MAX_LENGTH) return false;
73
+ if (expected.length !== received.length) {
74
+ // Avoid throwing in timingSafeEqual on length mismatch — but burn a few
75
+ // CPU cycles so timing doesn't trivially leak the length difference.
76
+ const filler = Buffer.alloc(expected.length, 0);
77
+ try { cryptoTimingSafeEqual(filler, filler); } catch { /* ignore */ }
78
+ return false;
79
+ }
80
+ const a = Buffer.from(expected, 'utf8');
81
+ const b = Buffer.from(received, 'utf8');
82
+ return cryptoTimingSafeEqual(a, b);
83
+ }
84
+
85
+ module.exports = {
86
+ generatePkce,
87
+ challengeFromVerifier,
88
+ generateState,
89
+ safeStateEquals,
90
+ VERIFIER_BYTES,
91
+ STATE_BYTES,
92
+ STATE_MAX_LENGTH,
93
+ };
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ const { AUTH_STATUS } = require('./events');
4
+
5
+ /**
6
+ * wp-sites.json schema v2 — defined in design doc Appendix F.5 with
7
+ * amendments from H.2.3 (oauth_capability_pinned).
8
+ *
9
+ * The bridge supports both `oauth` and `apppassword` per site. OAuth always
10
+ * takes precedence; `apppassword_fallback` fires only when OAuth discovery
11
+ * returns 404 (no pin) — silent fallback for reverse compatibility. A pinned
12
+ * site that loses OAuth fails loud (H.2.3 — handled in discovery-client).
13
+ *
14
+ * This module ships a minimal validator. The bridge's existing config.js
15
+ * does richer transport-specific validation; v2 adds:
16
+ * - schema_version === 2 sentinel
17
+ * - auth.method ∈ {'oauth','apppassword'} per site
18
+ * - auth_status ∈ {'active','expired','revoked','pending-reauth'}
19
+ * - oauth_capability_pinned shape (when present)
20
+ *
21
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
22
+ * @license GPL-2.0-or-later
23
+ */
24
+
25
+ const SCHEMA_VERSION = 2;
26
+ const AUTH_METHODS = Object.freeze(['oauth', 'apppassword']);
27
+ const VALID_AUTH_STATUS = Object.freeze(Object.values(AUTH_STATUS));
28
+
29
+ /**
30
+ * @param {object} config Parsed wp-sites.json
31
+ * @returns {{ok: true} | {ok: false, errors: string[]}}
32
+ */
33
+ function validate(config) {
34
+ const errors = [];
35
+ if (!config || typeof config !== 'object') {
36
+ return { ok: false, errors: ['config is not an object'] };
37
+ }
38
+ if (config.schema_version !== SCHEMA_VERSION) {
39
+ errors.push(`schema_version must be ${SCHEMA_VERSION}, got ${JSON.stringify(config.schema_version)}`);
40
+ }
41
+ if (!config.sites || typeof config.sites !== 'object') {
42
+ return { ok: false, errors: errors.concat(['sites object is missing']) };
43
+ }
44
+ for (const [siteId, site] of Object.entries(config.sites)) {
45
+ const prefix = `sites.${siteId}`;
46
+ if (!site || typeof site !== 'object') {
47
+ errors.push(`${prefix} is not an object`);
48
+ continue;
49
+ }
50
+ if (typeof site.url !== 'string' || site.url.length === 0) {
51
+ errors.push(`${prefix}.url is missing`);
52
+ }
53
+ if (!site.auth || typeof site.auth !== 'object') {
54
+ errors.push(`${prefix}.auth is missing`);
55
+ continue;
56
+ }
57
+ if (!AUTH_METHODS.includes(site.auth.method)) {
58
+ errors.push(`${prefix}.auth.method must be one of ${AUTH_METHODS.join(', ')}`);
59
+ }
60
+ if (site.auth_status && !VALID_AUTH_STATUS.includes(site.auth_status)) {
61
+ errors.push(`${prefix}.auth_status must be one of ${VALID_AUTH_STATUS.join(', ')}`);
62
+ }
63
+ if (site.auth.method === 'oauth') {
64
+ for (const k of ['client_id', 'access_token_ref', 'refresh_token_ref']) {
65
+ if (typeof site.auth[k] !== 'string') {
66
+ errors.push(`${prefix}.auth.${k} is required for OAuth sites`);
67
+ }
68
+ }
69
+ } else if (site.auth.method === 'apppassword') {
70
+ if (typeof site.auth.username !== 'string') {
71
+ errors.push(`${prefix}.auth.username is required for App Password sites`);
72
+ }
73
+ if (typeof site.auth.password_ref !== 'string') {
74
+ errors.push(`${prefix}.auth.password_ref is required for App Password sites`);
75
+ }
76
+ }
77
+ if (site.oauth_capability_pinned) {
78
+ const pin = site.oauth_capability_pinned;
79
+ if (typeof pin !== 'object'
80
+ || typeof pin.first_seen_at !== 'string'
81
+ || typeof pin.last_confirmed_at !== 'string') {
82
+ errors.push(`${prefix}.oauth_capability_pinned must be { first_seen_at, last_confirmed_at }`);
83
+ }
84
+ }
85
+ }
86
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
87
+ }
88
+
89
+ /**
90
+ * Build a minimal v2 config skeleton.
91
+ * @param {object} [opts]
92
+ * @param {string} [opts.defaultSite]
93
+ * @returns {object}
94
+ */
95
+ function emptyConfig(opts = {}) {
96
+ return {
97
+ $schema: 'https://wickedevolutions.com/schemas/abilities-mcp/wp-sites/v2.json',
98
+ schema_version: SCHEMA_VERSION,
99
+ defaultSite: opts.defaultSite,
100
+ sites: {},
101
+ };
102
+ }
103
+
104
+ module.exports = {
105
+ SCHEMA_VERSION,
106
+ AUTH_METHODS,
107
+ VALID_AUTH_STATUS,
108
+ validate,
109
+ emptyConfig,
110
+ };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SecretStore — interface for persisting tokens, refresh tokens, App Passwords,
5
+ * and (future, see Appendix H.3.2) bridge identity material.
6
+ *
7
+ * The interface is a JSDoc typedef — there is no abstract base class. Any
8
+ * object implementing the four methods below counts as a SecretStore.
9
+ *
10
+ * @typedef {object} SecretStore
11
+ * @property {(service: string, account: string) => Promise<string|null>} get
12
+ * @property {(service: string, account: string, secret: string) => Promise<void>} set
13
+ * @property {(service: string, account: string) => Promise<boolean>} delete
14
+ * @property {(service: string) => Promise<Array<{account: string, password: string}>>} findAll
15
+ *
16
+ * Service / account naming convention:
17
+ * service = 'abilities-mcp'
18
+ * account = '<siteId>/<kind>' where kind is 'access' | 'refresh' | 'apppassword' | 'apppassword-legacy' | 'client_id'
19
+ *
20
+ * Keychain references in wp-sites.json take the form:
21
+ * keychain://<service>/<account>
22
+ *
23
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
24
+ * @license GPL-2.0-or-later
25
+ */
26
+
27
+ const KEYCHAIN_REF_SCHEME = 'keychain://';
28
+
29
+ /**
30
+ * Build a `keychain://service/account` reference for storage in wp-sites.json.
31
+ * @param {string} service
32
+ * @param {string} account
33
+ * @returns {string}
34
+ */
35
+ function makeRef(service, account) {
36
+ if (!service || !account) {
37
+ throw new Error('makeRef requires both service and account');
38
+ }
39
+ return `${KEYCHAIN_REF_SCHEME}${service}/${account}`;
40
+ }
41
+
42
+ /**
43
+ * Parse a keychain reference back into service + account.
44
+ * @param {string} ref
45
+ * @returns {{service: string, account: string}}
46
+ */
47
+ function parseRef(ref) {
48
+ if (typeof ref !== 'string' || !ref.startsWith(KEYCHAIN_REF_SCHEME)) {
49
+ throw new Error(`Not a keychain reference: ${ref}`);
50
+ }
51
+ const remainder = ref.slice(KEYCHAIN_REF_SCHEME.length);
52
+ const slashIdx = remainder.indexOf('/');
53
+ if (slashIdx <= 0) {
54
+ throw new Error(`Malformed keychain reference (expected service/account): ${ref}`);
55
+ }
56
+ return {
57
+ service: remainder.slice(0, slashIdx),
58
+ account: remainder.slice(slashIdx + 1),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Resolve a keychain reference through a SecretStore. Returns the secret value
64
+ * or throws if not found.
65
+ * @param {SecretStore} store
66
+ * @param {string} ref
67
+ * @returns {Promise<string>}
68
+ */
69
+ async function resolveRef(store, ref) {
70
+ const { service, account } = parseRef(ref);
71
+ const value = await store.get(service, account);
72
+ if (value === null || value === undefined) {
73
+ throw new Error(`Keychain reference not found: ${ref}`);
74
+ }
75
+ return value;
76
+ }
77
+
78
+ module.exports = { KEYCHAIN_REF_SCHEME, makeRef, parseRef, resolveRef };