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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- package/src/util.js +151 -0
package/src/oauth-mcp.js
ADDED
|
@@ -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;
|