mouaif 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1,216 @@
1
+ 'use strict';
2
+
3
+ // MCP OAuth is independent of AI-provider accounts. Credentials are scoped to
4
+ // the saved server identity and stored only in the OS keychain.
5
+ const crypto = require('node:crypto');
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+ const { Entry } = require('@napi-rs/keyring');
9
+ const CALLBACK_PATH = '/oauth/mcp/callback';
10
+ const PENDING_TTL = 10 * 60 * 1000;
11
+ const error = (code, message) => Object.assign(new Error(message), { code });
12
+
13
+ function identity({ entry, scope, projectDir }) {
14
+ let project = scope === 'app' ? 'app' : path.resolve(projectDir);
15
+ if (scope !== 'app') { try { project = fs.realpathSync(project); } catch { /* not yet created */ } }
16
+ return crypto.createHash('sha256').update(JSON.stringify([
17
+ project, entry.id, entry.url, entry.oauth || null
18
+ ])).digest('hex');
19
+ }
20
+
21
+ function safeUrl(value) {
22
+ const url = new URL(value);
23
+ if (url.username || url.password || !['http:', 'https:'].includes(url.protocol)) {
24
+ throw error('EBADINPUT', 'OAuth URLs must be HTTP(S) URLs without credentials.');
25
+ }
26
+ if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
27
+ throw error('EBADINPUT', 'OAuth requires HTTPS (HTTP is allowed only on loopback).');
28
+ }
29
+ return url;
30
+ }
31
+
32
+ const vault = {
33
+ read(key) {
34
+ try {
35
+ const text = new Entry('mouaif/mcp-oauth', key).getPassword();
36
+ return text ? JSON.parse(text) : {};
37
+ } catch (e) {
38
+ if (/no matching entry|not found|No such file/i.test(e.message || '')) return {};
39
+ throw error('EKEYRING', 'MCP OAuth needs an available OS keychain.');
40
+ }
41
+ },
42
+ write(key, value) {
43
+ try { new Entry('mouaif/mcp-oauth', key).setPassword(JSON.stringify(value)); }
44
+ catch { throw error('EKEYRING', 'Could not save MCP OAuth credentials in the OS keychain.'); }
45
+ },
46
+ remove(key) {
47
+ try { new Entry('mouaif/mcp-oauth', key).deletePassword(); }
48
+ catch (e) {
49
+ if (!/no matching entry|not found|No such file/i.test(e.message || '')) {
50
+ throw error('EKEYRING', 'Could not remove MCP OAuth credentials from the OS keychain.');
51
+ }
52
+ }
53
+ }
54
+ };
55
+
56
+ // Never follow redirects carrying OAuth credentials. Enforce TLS for discovered
57
+ // endpoints too, not just the configured MCP URL. Bound every network request.
58
+ async function oauthFetch(input, init = {}) {
59
+ safeUrl(typeof input === 'string' || input instanceof URL ? input : input.url);
60
+ const controller = new AbortController();
61
+ const abort = () => controller.abort();
62
+ if (init.signal?.aborted) abort();
63
+ init.signal?.addEventListener('abort', abort, { once: true });
64
+ const timer = setTimeout(abort, 30000);
65
+ try { return await fetch(input, { ...init, redirect: 'error', signal: controller.signal }); }
66
+ finally { clearTimeout(timer); init.signal?.removeEventListener('abort', abort); }
67
+ }
68
+
69
+ function createManager({ storage = vault, resolveContext } = {}) {
70
+ const pending = new Map();
71
+ const generations = new Map();
72
+ function prune() {
73
+ for (const [state, item] of pending) if (item.expiresAt <= Date.now()) pending.delete(state);
74
+ }
75
+ function cancel(key) {
76
+ generations.set(key, (generations.get(key) || 0) + 1);
77
+ for (const [state, item] of pending) if (item.key === key) pending.delete(state);
78
+ }
79
+ function checkContext(context) {
80
+ if (context.entry.transport !== 'http' || !context.entry.oauth?.enabled) {
81
+ throw error('EBADINPUT', 'Save this HTTP server with OAuth enabled first.');
82
+ }
83
+ safeUrl(context.entry.url);
84
+ }
85
+ function provider(context, flow) {
86
+ checkContext(context);
87
+ const key = identity(context);
88
+ const generation = flow ? flow.generation : (generations.get(key) || 0);
89
+ function active() {
90
+ const current = resolveContext(context.projectDir, context.entry.id);
91
+ if (!current || identity(current) !== key || generation !== (generations.get(key) || 0) || (flow && flow.expiresAt <= Date.now())) {
92
+ throw error('EMCP_AUTH', 'Sign-in was cancelled or expired. Start again.');
93
+ }
94
+ }
95
+ function read() { active(); return storage.read(key) || {}; }
96
+ function update(patch) { storage.write(key, { ...read(), ...patch }); }
97
+ const saved = read();
98
+ const redirectUrl = flow?.redirectUrl || saved.redirectUrl;
99
+ if (!redirectUrl) throw error('EMCP_AUTH', 'Sign in to this MCP server in Settings first.');
100
+ const configuredClient = context.entry.oauth.clientId;
101
+ return {
102
+ redirectUrl,
103
+ clientMetadata: {
104
+ client_name: 'mouaif', redirect_uris: [redirectUrl],
105
+ grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'],
106
+ token_endpoint_auth_method: 'none',
107
+ ...(context.entry.oauth.scope ? { scope: context.entry.oauth.scope } : {})
108
+ },
109
+ state() {
110
+ active();
111
+ if (!flow) throw error('EMCP_AUTH', 'MCP authorization expired. Sign in again in Settings.');
112
+ return flow.state;
113
+ },
114
+ clientInformation: () => configuredClient ? { client_id: configuredClient } : read().client,
115
+ saveClientInformation: (client) => update({ client }),
116
+ tokens: () => read().tokens,
117
+ saveTokens(tokens) {
118
+ const previous = read().tokens;
119
+ update({ tokens: { ...tokens, ...(tokens.refresh_token ? {} : previous?.refresh_token ? { refresh_token: previous.refresh_token } : {}) }, redirectUrl });
120
+ },
121
+ saveCodeVerifier(verifier) {
122
+ active();
123
+ if (!flow) throw error('EMCP_AUTH', 'Sign in again in Settings.');
124
+ flow.verifier = verifier;
125
+ },
126
+ codeVerifier() {
127
+ active();
128
+ if (!flow?.verifier) throw error('EMCP_AUTH', 'Sign-in expired. Start again.');
129
+ return flow.verifier;
130
+ },
131
+ redirectToAuthorization(url) {
132
+ active();
133
+ if (!flow) throw error('EMCP_AUTH', 'Sign in again in Settings.');
134
+ flow.authorizationUrl = safeUrl(url).href;
135
+ },
136
+ discoveryState: () => flow?.discovery,
137
+ saveDiscoveryState: (discovery) => { if (flow) flow.discovery = discovery; },
138
+ invalidateCredentials(scope) {
139
+ if (scope === 'all') update({ client: undefined, tokens: undefined });
140
+ else if (scope === 'client') update({ client: undefined });
141
+ else if (scope === 'tokens') update({ tokens: undefined });
142
+ if (flow && (scope === 'all' || scope === 'verifier')) flow.verifier = undefined;
143
+ if (flow && (scope === 'all' || scope === 'discovery')) flow.discovery = undefined;
144
+ }
145
+ };
146
+ }
147
+ async function begin(context, origin) {
148
+ checkContext(context);
149
+ prune();
150
+ if (pending.size >= 100) throw error('EMCP_AUTH', 'Too many pending sign-ins. Try again later.');
151
+ const key = identity(context);
152
+ cancel(key);
153
+ const redirectUrl = new URL(CALLBACK_PATH, safeUrl(origin)).href;
154
+ const saved = storage.read(key) || {};
155
+ // A dynamically registered client is bound to its original redirect URI.
156
+ storage.write(key, { ...saved, tokens: undefined, redirectUrl,
157
+ client: saved.redirectUrl === redirectUrl ? saved.client : undefined });
158
+ const state = crypto.randomBytes(32).toString('base64url');
159
+ const flow = { key, state, context, redirectUrl, generation: generations.get(key), expiresAt: Date.now() + PENDING_TTL };
160
+ pending.set(state, flow);
161
+ try {
162
+ const { auth, extractWWWAuthenticateParams } = require('@modelcontextprotocol/sdk/client/auth.js');
163
+ // Some servers advertise protected-resource metadata only through the
164
+ // challenge rather than a conventional well-known path. Probe without
165
+ // tokens or custom headers; GET is safe and a 405 still permits discovery.
166
+ const probe = await oauthFetch(context.entry.url, { headers: { Accept: 'application/json, text/event-stream' } });
167
+ const challenge = extractWWWAuthenticateParams(probe);
168
+ await probe.body?.cancel();
169
+ const result = await auth(provider(context, flow), {
170
+ serverUrl: context.entry.url, fetchFn: oauthFetch,
171
+ resourceMetadataUrl: challenge.resourceMetadataUrl, scope: challenge.scope
172
+ });
173
+ if (result !== 'REDIRECT' || !flow.authorizationUrl) throw error('EMCP_AUTH', 'Server did not provide an OAuth sign-in URL.');
174
+ return { authorizationUrl: flow.authorizationUrl, redirectUrl, expiresAt: flow.expiresAt };
175
+ } catch (e) {
176
+ pending.delete(state);
177
+ if (e.code === 'EKEYRING' || e.code === 'EBADINPUT') throw e;
178
+ // SDK errors may contain raw upstream response bodies (including secrets).
179
+ throw error('EMCP_AUTH', 'Could not start OAuth. Check server OAuth support and the client ID/redirect URI.');
180
+ }
181
+ }
182
+ async function finish(state, code, denied) {
183
+ prune();
184
+ const flow = typeof state === 'string' && pending.get(state);
185
+ if (!flow) throw error('EMCP_AUTH', 'Unknown, expired, or already used sign-in. Start again in Settings.');
186
+ pending.delete(state); // one-shot, including provider denials and failed exchanges
187
+ if (denied) throw error('EMCP_AUTH', 'Sign-in was declined. You can try again in Settings.');
188
+ if (typeof code !== 'string' || !code || code.length > 8192) throw error('EBADINPUT', 'Missing or invalid authorization code.');
189
+ const current = resolveContext(flow.context.projectDir, flow.context.entry.id);
190
+ if (!current || identity(current) !== flow.key) throw error('EMCP_AUTH', 'Server configuration changed. Start sign-in again.');
191
+ try {
192
+ const { auth } = require('@modelcontextprotocol/sdk/client/auth.js');
193
+ await auth(provider(current, flow), { serverUrl: current.entry.url, authorizationCode: code, fetchFn: oauthFetch });
194
+ return { ok: true };
195
+ } catch (e) {
196
+ if (e.code === 'EKEYRING') throw e;
197
+ throw error('EMCP_AUTH', 'OAuth exchange failed. Start sign-in again and check the server configuration.');
198
+ }
199
+ }
200
+ function status(context) {
201
+ checkContext(context);
202
+ prune();
203
+ const key = identity(context);
204
+ const saved = storage.read(key) || {};
205
+ return { connected: !!saved.tokens?.access_token, pending: [...pending.values()].some(item => item.key === key), redirectUrl: saved.redirectUrl || null };
206
+ }
207
+ function clear(context) {
208
+ const key = identity(context);
209
+ cancel(key);
210
+ storage.remove(key);
211
+ }
212
+ return { begin, finish, provider, status, clear };
213
+ }
214
+
215
+ const manager = createManager({ resolveContext: (dir, id) => require('./mcp.js').getOAuthContext(dir, id) });
216
+ module.exports = { ...manager, createManager, identity, safeUrl, oauthFetch, CALLBACK_PATH };
@@ -0,0 +1,285 @@
1
+ 'use strict';
2
+
3
+ // OpenRouter OAuth (PKCE) — the per-provider sign-in flow for the
4
+ // `openrouter` AI client provider.
5
+ //
6
+ // Implements docs/decisions.md section 12 (one provider per commit).
7
+ // This module registers an exchange function with src/auth.js that
8
+ // is invoked from the loopback callback (`/oauth/callback`) after the
9
+ // user authorises the app in their browser.
10
+ //
11
+ // OpenRouter's flow is a pure PKCE exchange — there is no client_id
12
+ // or client_secret to register, no separate app dashboard for a
13
+ // per-install app, and no refresh tokens. The shape mirrors
14
+ // https://openrouter.ai/docs/guides/overview/auth/oauth:
15
+ //
16
+ // 1. The mobile UI opens
17
+ // https://openrouter.ai/auth?callback_url=<our-loopback>&code_challenge=<sha256(verifier)>&code_challenge_method=S256
18
+ // in the user's browser. The user signs in to OpenRouter and
19
+ // grants the requested scope (none — the PKCE flow does not
20
+ // pass a `scope` parameter; OpenRouter grants "manage your own
21
+ // API keys" by default).
22
+ //
23
+ // 2. OpenRouter redirects the user's browser back to our loopback
24
+ // URL with a one-time `code` query parameter.
25
+ //
26
+ // 3. Our server POSTs
27
+ // https://openrouter.ai/api/v1/auth/keys
28
+ // with `{ code, code_verifier, code_challenge_method: 'S256' }`
29
+ // and receives `{ key }` — a user-controlled OpenRouter API
30
+ // key. The key is what `Authorization: Bearer <key>` expects on
31
+ // every chat-completions call. The mobile UI then uses that key
32
+ // exactly the way a manually pasted key is used; from the AI
33
+ // client's perspective there is no OAuth token, just an OpenRouter
34
+ // API key whose origin happens to be a sign-in.
35
+ //
36
+ // Storage: the keychain holds the API key as the `accessToken` field
37
+ // of a standard OAuth blob, plus `expiresAt: null` and
38
+ // `refreshToken: null`. The `account` slot is the key's first 16
39
+ // chars (`sk-or-v1-xxxxxxxx...`) so the UI picker can show one row
40
+ // per signed-in key without leaking the full secret.
41
+ //
42
+ // Refresh: OpenRouter does not issue refresh tokens. A leaked or
43
+ // revoked key is irrecoverable — the user signs in again, which
44
+ // issues a new key. The refresher registered with auth.registerRefresher
45
+ // is therefore a no-op (it returns the existing blob verbatim with
46
+ // `expiresAt: null` so the AI client's proactive-refresh code path is
47
+ // a clean no-op rather than a typed error).
48
+ //
49
+ // Public surface:
50
+ //
51
+ // const oauthOpenRouter = require('mouaif/src/oauth-openrouter.js');
52
+ //
53
+ // oauthOpenRouter.register(); // call once at server startup
54
+ // oauthOpenRouter.buildAuthorizeUrl({ callbackUrl, state, verifier });
55
+ // await oauthOpenRouter.exchangeAuthorizationCode({ code, verifier, callbackUrl });
56
+ // // Internal — the AI client uses the key as a plain Bearer token;
57
+ // // src/ai.js does not need any per-provider helper for OpenRouter.
58
+
59
+ const crypto = require('crypto');
60
+ const auth = require('./auth.js');
61
+
62
+ // OpenRouter's PKCE endpoints. Documented at
63
+ // https://openrouter.ai/docs/guides/overview/auth/oauth. Both are
64
+ // public; there is no per-app identity, so no client_id constant
65
+ // exists in this module.
66
+ const AUTHORIZE_URL = 'https://openrouter.ai/auth';
67
+ const KEYS_URL = 'https://openrouter.ai/api/v1/auth/keys';
68
+
69
+ function randomUrlSafe(n) {
70
+ return crypto.randomBytes(n).toString('base64url');
71
+ }
72
+
73
+ function pkceChallengeS256(verifier) {
74
+ return crypto.createHash('sha256').update(verifier).digest('base64url');
75
+ }
76
+
77
+ // Public — stable helpers used by the mobile UI and tests.
78
+ function newState() { return randomUrlSafe(32); }
79
+ function newVerifier() { return randomUrlSafe(64); }
80
+ function challengeFor(verifier) { return pkceChallengeS256(verifier); }
81
+
82
+ // Build the /auth URL the user's browser should open. Pure: takes the
83
+ // loopback URL + state + verifier and returns a fully-formed URL
84
+ // string. The mobile UI uses this; the server's handleOAuthCallback
85
+ // is the one that does the *exchange*.
86
+ //
87
+ // `callbackUrl` is the loopback URL (e.g. http://127.0.0.1:5732/oauth/callback?provider=openrouter).
88
+ // We pass it through as the `callback_url` query param verbatim —
89
+ // OpenRouter echoes it back on the redirect so the resulting `code`
90
+ // arrives at the same handler the Anthropic / GitHub flows use.
91
+ function buildAuthorizeUrl(opts) {
92
+ const {
93
+ callbackUrl,
94
+ state,
95
+ verifier,
96
+ codeChallengeMethod = 'S256'
97
+ } = opts || {};
98
+
99
+ if (!callbackUrl) throw new Error('callbackUrl is required');
100
+ if (!state) throw new Error('state is required');
101
+ if (!verifier) throw new Error('verifier is required');
102
+ if (codeChallengeMethod !== 'S256' && codeChallengeMethod !== 'plain') {
103
+ throw new Error('codeChallengeMethod must be "S256" or "plain"');
104
+ }
105
+
106
+ const u = new URL(AUTHORIZE_URL);
107
+ u.searchParams.set('callback_url', callbackUrl);
108
+ u.searchParams.set('code_challenge', pkceChallengeS256(verifier));
109
+ u.searchParams.set('code_challenge_method', codeChallengeMethod);
110
+ // `state` is forwarded so the loopback handler can match the
111
+ // callback to a pending record. OpenRouter echoes it back on the
112
+ // redirect.
113
+ u.searchParams.set('state', state);
114
+ return u.toString();
115
+ }
116
+
117
+ // POST the authorization_code grant. Per OpenRouter's docs, the
118
+ // exchange endpoint accepts application/json with the `code`,
119
+ // `code_verifier`, and `code_challenge_method` fields. Response is
120
+ // `{ key }` — a user-controlled OpenRouter API key.
121
+ async function exchangeAuthorizationCode(opts) {
122
+ const {
123
+ keysUrl = KEYS_URL,
124
+ code,
125
+ verifier,
126
+ codeChallengeMethod = 'S256',
127
+ fetchImpl = globalThis.fetch
128
+ } = opts || {};
129
+
130
+ if (!code) throw new Error('code is required');
131
+ if (!verifier) throw new Error('verifier is required');
132
+ if (typeof fetchImpl !== 'function') throw new Error('global fetch is not available');
133
+
134
+ const res = await fetchImpl(keysUrl, {
135
+ method: 'POST',
136
+ headers: { 'Content-Type': 'application/json' },
137
+ body: JSON.stringify({
138
+ code,
139
+ code_verifier: verifier,
140
+ code_challenge_method: codeChallengeMethod
141
+ })
142
+ });
143
+ return readKeyResponse(res);
144
+ }
145
+
146
+ async function readKeyResponse(res) {
147
+ const text = await res.text().catch(() => '');
148
+ if (!res.ok) {
149
+ let parsed = null;
150
+ try { parsed = JSON.parse(text); } catch { /* not JSON */ }
151
+ if (parsed && parsed.error) {
152
+ // OpenRouter returns `{ error: { message, code } }` for a
153
+ // rejected sign-in. Older OAuth-style errors would carry the
154
+ // standard `error` string + `error_description`; we handle both.
155
+ const errObj = parsed.error;
156
+ const errMsg = (errObj && typeof errObj === 'object' && errObj.message)
157
+ ? errObj.message
158
+ : (typeof errObj === 'string' ? errObj : JSON.stringify(errObj));
159
+ const e = new Error('OpenRouter sign-in: ' + errMsg + (parsed.error_description ? ' (' + parsed.error_description + ')' : ''));
160
+ e.code = 'EOAUTH';
161
+ e.oauthError = errMsg;
162
+ e.oauthDescription = parsed.error_description || null;
163
+ throw e;
164
+ }
165
+ const e = new Error('OpenRouter sign-in failed: HTTP ' + res.status + ' ' + res.statusText);
166
+ e.code = 'EUPSTREAM';
167
+ e.status = res.status;
168
+ e.body = text.slice(0, 2000);
169
+ throw e;
170
+ }
171
+ let json;
172
+ try { json = JSON.parse(text); }
173
+ catch (err) {
174
+ const e = new Error('OpenRouter sign-in returned non-JSON: ' + err.message);
175
+ e.code = 'EPARSE';
176
+ throw e;
177
+ }
178
+ if (!json.key || typeof json.key !== 'string') {
179
+ const e = new Error('OpenRouter sign-in returned 200 with no `key`');
180
+ e.code = 'ETOKEN';
181
+ throw e;
182
+ }
183
+ return { key: json.key };
184
+ }
185
+
186
+ // ---- Account naming ----------------------------------------------------
187
+
188
+ // The PKCE flow hands back a single string (the API key), not a
189
+ // structured account object. We synthesise a short, recognisable
190
+ // label from the key's prefix so the OAuth account picker can show
191
+ // one row per signed-in key without revealing the full secret. The
192
+ // key starts with "sk-or-v1-" by convention; we keep the first 16
193
+ // chars which is unique enough to be useful but not a leak. Empty
194
+ // / short keys fall through to "default" so the picker always has
195
+ // a value.
196
+ function accountForKey(key) {
197
+ if (typeof key !== 'string' || !key) return 'default';
198
+ const trimmed = key.trim();
199
+ if (!trimmed) return 'default';
200
+ return trimmed.length <= 16 ? trimmed : trimmed.slice(0, 16);
201
+ }
202
+
203
+ // ---- Auth subsystem wiring --------------------------------------------
204
+
205
+ // Exchange function registered with auth.registerExchange. Mirrors
206
+ // the shape src/oauth-anthropic.js and src/oauth-github-copilot.js
207
+ // expect: ({ pending, code }) -> { accessToken, refreshToken?,
208
+ // expiresAt?, scope?, account }. The OpenRouter `key` lands in the
209
+ // `accessToken` field; the AI client treats it as a Bearer credential
210
+ // at chat time (see src/ai.js → ENDPOINTS.openrouter.authHeader).
211
+ async function exchange({ pending, code }) {
212
+ if (!pending || !pending.codeVerifier) {
213
+ const e = new Error('openrouter pending record is missing codeVerifier — start a new sign-in');
214
+ e.code = 'EBADINPUT';
215
+ throw e;
216
+ }
217
+ const out = await exchangeAuthorizationCode({
218
+ code,
219
+ verifier: pending.codeVerifier,
220
+ // OpenRouter does not echo a `state` query param back to the
221
+ // callback; the loopback handler in src/index.js carries the
222
+ // `state` through `pending.state`, so it does not need to be
223
+ // sent to the exchange endpoint.
224
+ });
225
+ return {
226
+ // Map the OpenRouter API key to the standard OAuth blob. The AI
227
+ // client reads `accessToken`; the model record's `auth: 'oauth'`
228
+ // makes requireApiKey() take the OAuth branch, which then uses
229
+ // the same Bearer-header path the apikey branch uses.
230
+ accessToken: out.key,
231
+ refreshToken: null, // OpenRouter does not issue refresh tokens
232
+ expiresAt: null, // OpenRouter keys do not expire unless revoked
233
+ scope: 'openrouter',
234
+ account: accountForKey(out.key)
235
+ };
236
+ }
237
+
238
+ // Refresher registered with auth.registerRefresher. OpenRouter keys
239
+ // cannot be refreshed — a revocation requires a fresh sign-in. The
240
+ // AI client calls this just before a request when the stored
241
+ // `expiresAt` is within OAUTH_REFRESH_LEAD_MS, but `expiresAt` is
242
+ // always null for OpenRouter, so this is never called in practice.
243
+ // We register a no-op refresher so the auth subsystem's refresh path
244
+ // is a clean no-op (returns the existing blob verbatim) rather than
245
+ // a typed ENOREFRESHER error.
246
+ async function refresh({ provider, account, scope }) {
247
+ if (provider !== 'openrouter') {
248
+ const e = new Error('openrouter refresher called for provider "' + provider + '"');
249
+ e.code = 'EBADINPUT';
250
+ throw e;
251
+ }
252
+ return {
253
+ accessToken: '',
254
+ refreshToken: null,
255
+ expiresAt: null,
256
+ scope: scope || 'openrouter',
257
+ account: account || null
258
+ };
259
+ }
260
+
261
+ let _registered = false;
262
+ function register() {
263
+ if (_registered) return;
264
+ if (!auth.getExchange('openrouter')) auth.registerExchange('openrouter', exchange);
265
+ if (!auth.getRefresher('openrouter')) auth.registerRefresher('openrouter', refresh);
266
+ _registered = true;
267
+ }
268
+
269
+ module.exports = {
270
+ // introspection
271
+ AUTHORIZE_URL,
272
+ KEYS_URL,
273
+ // helpers
274
+ newState,
275
+ newVerifier,
276
+ challengeFor,
277
+ buildAuthorizeUrl,
278
+ exchangeAuthorizationCode,
279
+ accountForKey,
280
+ // registration
281
+ register,
282
+ // exposed for tests
283
+ exchange,
284
+ refresh
285
+ };
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ // Tiny helper for src/mcp.js to get the current mouaif version without
4
+ // dragging in the full package.json (which would couple every require
5
+ // of mcp.js to the package layout).
6
+ //
7
+ // The version comes from a build-time stamp; in this repo we read the
8
+ // package.json once at require time. A future revision can swap this
9
+ // for a generated file to avoid the cost.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ let _version = '0.0.0';
15
+ try {
16
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
17
+ if (pkg && typeof pkg.version === 'string') _version = pkg.version;
18
+ } catch { /* swallow; default version is fine for non-fatal uses */ }
19
+
20
+ module.exports = _version;