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
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// HTTP server core — routes + handlers.
|
|
4
|
+
//
|
|
5
|
+
// The original single-file src/index.js was split so no file in the
|
|
6
|
+
// repo stays above ~2 000 lines. This module owns the request routing
|
|
7
|
+
// and the server bootstrap (createServer); the handlers live in focused
|
|
8
|
+
// sub-modules:
|
|
9
|
+
//
|
|
10
|
+
// server-shared.js — shared state + cross-cutting helpers
|
|
11
|
+
// (sendJSON, cookies/origin auth, settings
|
|
12
|
+
// redaction, model resolution, SSE, ...)
|
|
13
|
+
// server-handlers-settings.js — /api/settings/*
|
|
14
|
+
// server-handlers-chats.js — /api/chats/* + the SSE stream loop
|
|
15
|
+
// server-handlers-projects.js — /api/projects/*, /api/file(s), tags
|
|
16
|
+
// server-handlers-ai.js — /api/ai/*
|
|
17
|
+
// server-handlers-auth.js — /api/auth/* + /oauth/callback
|
|
18
|
+
// server-handlers-access.js — /api/access/*
|
|
19
|
+
// server-handlers-push.js — /api/push/*
|
|
20
|
+
// server-handlers-git.js — /api/git/*
|
|
21
|
+
// server-handlers-tools.js — /api/tools/*
|
|
22
|
+
// server-handlers-prompts.js — /api/prompts/*, /api/features, /api/agents/*
|
|
23
|
+
// server-handlers-misc.js — /api/mcp/*, /api/restart, /api/inspector/*,
|
|
24
|
+
// /api/tools/authorization
|
|
25
|
+
// server-web-static.js — static frontend serving (at /)
|
|
26
|
+
//
|
|
27
|
+
// src/index.js is now a thin facade that re-exports this module.
|
|
28
|
+
|
|
29
|
+
const http = require('http');
|
|
30
|
+
const url = require('url');
|
|
31
|
+
const crypto = require('crypto');
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
DEFAULT_PORT,
|
|
35
|
+
SESSION_COOKIE,
|
|
36
|
+
ACCESS_COOKIE,
|
|
37
|
+
sessionCookie,
|
|
38
|
+
expectedOrigin,
|
|
39
|
+
requestOrigin,
|
|
40
|
+
parseCookies,
|
|
41
|
+
requestHasBrowserOrigin,
|
|
42
|
+
authorizeBrowserRequest,
|
|
43
|
+
authorizeAccessRequest,
|
|
44
|
+
handleSSE,
|
|
45
|
+
sendJSON,
|
|
46
|
+
broadcast,
|
|
47
|
+
store,
|
|
48
|
+
normalizePublicOrigin,
|
|
49
|
+
trackSocket,
|
|
50
|
+
destroyOpenSockets,
|
|
51
|
+
settings,
|
|
52
|
+
projects,
|
|
53
|
+
ai,
|
|
54
|
+
auth,
|
|
55
|
+
oauthAnthropic,
|
|
56
|
+
oauthCopilot,
|
|
57
|
+
chats,
|
|
58
|
+
resolveModel,
|
|
59
|
+
seedModelListCache,
|
|
60
|
+
accessAuth,
|
|
61
|
+
push,
|
|
62
|
+
inspector,
|
|
63
|
+
usage,
|
|
64
|
+
promptProfiles
|
|
65
|
+
} = require('./server-shared.js');
|
|
66
|
+
|
|
67
|
+
const { handleSettings } = require('./server-handlers-settings.js');
|
|
68
|
+
const { handleChats } = require('./server-handlers-chats.js');
|
|
69
|
+
const { handleProjects, handleFileEditor } = require('./server-handlers-projects.js');
|
|
70
|
+
const { handleAI } = require('./server-handlers-ai.js');
|
|
71
|
+
const { handleTranscribe } = require('./server-handlers-transcribe.js');
|
|
72
|
+
const { handleAuth, handleOAuthCallback, handleOAuthCallbackPost } = require('./server-handlers-auth.js');
|
|
73
|
+
const { handleAccess } = require('./server-handlers-access.js');
|
|
74
|
+
const { handlePush } = require('./server-handlers-push.js');
|
|
75
|
+
const { handleGit, handleGitInfo, handleGitLog, handleGitCommitFiles } = require('./server-handlers-git.js');
|
|
76
|
+
const { handleTools } = require('./server-handlers-tools.js');
|
|
77
|
+
const { handleActions } = require('./server-handlers-actions.js');
|
|
78
|
+
const { handlePrompts, handleFeatures, handleAgents } = require('./server-handlers-prompts.js');
|
|
79
|
+
const { handleMcp, handleRestart, handleInspector, handleToolAuthorization } = require('./server-handlers-misc.js');
|
|
80
|
+
const { handleMcpOAuth, handleMcpOAuthCallback } = require('./server-handlers-mcp-oauth.js');
|
|
81
|
+
const { serveWebFile, serveWebRequest } = require('./server-web-static.js');
|
|
82
|
+
|
|
83
|
+
// ---- Route table --------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
// handleRequest — public entry point for a request. It delegates to
|
|
86
|
+
// dispatchRequest and makes the handler failure path total: a handler
|
|
87
|
+
// that throws synchronously, or rejects asynchronously, is logged and
|
|
88
|
+
// answered instead of escaping. The route table calls its async handlers
|
|
89
|
+
// without awaiting each one (`return handleChats(...)`), so a rejected
|
|
90
|
+
// handler promise becomes an unhandledRejection, and Node's default
|
|
91
|
+
// `--unhandled-rejections=throw` turns that into a process exit. Any
|
|
92
|
+
// single bad request was therefore a crash: `GET /api/chats/%zz` raised
|
|
93
|
+
// a URIError out of decodeURIComponent (now safeDecode) with nothing to
|
|
94
|
+
// catch it.
|
|
95
|
+
function handleRequest(req, res, activePort = DEFAULT_PORT, sessionToken = '', lifecycle = {}, serverConfig = {}) {
|
|
96
|
+
try {
|
|
97
|
+
const pending = dispatchRequest(req, res, activePort, sessionToken, lifecycle, serverConfig);
|
|
98
|
+
if (pending && typeof pending.then === 'function') pending.catch((e) => failRequest(res, e, req));
|
|
99
|
+
} catch (e) {
|
|
100
|
+
failRequest(res, e, req);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function failRequest(res, e, req) {
|
|
105
|
+
console.error('[mouaif] request failed:', (req && req.method) + ' ' + (req && req.url), (e && e.stack) || e);
|
|
106
|
+
if (res.headersSent) {
|
|
107
|
+
// The handler already started responding (an SSE stream, most
|
|
108
|
+
// likely). The only way left to signal the failure is to drop the
|
|
109
|
+
// connection so the client sees a truncation instead of hanging.
|
|
110
|
+
try { res.destroy(); } catch { /* already gone */ }
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try { sendJSON(res, 500, { error: 'Internal server error', code: 'EINTERNAL' }); } catch { /* already gone */ }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function dispatchRequest(req, res, activePort = DEFAULT_PORT, sessionToken = '', lifecycle = {}, serverConfig = {}) {
|
|
117
|
+
const parsed = url.parse(req.url, true);
|
|
118
|
+
const urlPath = parsed.pathname;
|
|
119
|
+
const method = req.method;
|
|
120
|
+
|
|
121
|
+
// The UI and API are deliberately same-origin. A browser first loads
|
|
122
|
+
// /, which receives an HttpOnly SameSite cookie. API/SSE requests
|
|
123
|
+
// carrying an Origin must present that cookie and match Host exactly.
|
|
124
|
+
// Requests without Origin remain available to local CLI clients and tests;
|
|
125
|
+
// the CLI binds to loopback unless the user explicitly opts into a remote
|
|
126
|
+
// host. No Access-Control-Allow-Origin header is emitted.
|
|
127
|
+
if (method === 'OPTIONS') {
|
|
128
|
+
return sendJSON(res, 403, { error: 'Cross-origin preflight is not allowed', code: 'EORIGIN' });
|
|
129
|
+
}
|
|
130
|
+
// `/oauth/callback` is exempt from the same-origin browser gate: the OAuth
|
|
131
|
+
// provider (openrouter.ai, anthropic.com, github.com, ...) redirects the
|
|
132
|
+
// user's browser back here in a top-level navigation, so the Origin header
|
|
133
|
+
// is legitimately the provider's, not ours. Rejecting on that mismatch is
|
|
134
|
+
// what broke iOS PWA sign-in ("shows in provider" = the redirect-back
|
|
135
|
+
// arrived in the web preview tab, and the app never got the token). The
|
|
136
|
+
// callback is safe to admit because it is not authenticated: it only
|
|
137
|
+
// exchanges a one-time code bound to a pending `state` + PKCE verifier that
|
|
138
|
+
// the user started in the app, then writes the result into the keyring.
|
|
139
|
+
// The session/CSRF gates below still protect every `/api/*` route.
|
|
140
|
+
const browserProtected = urlPath === '/events'
|
|
141
|
+
|| urlPath.startsWith('/api/');
|
|
142
|
+
if (browserProtected && requestHasBrowserOrigin(req) && !authorizeBrowserRequest(req, res, sessionToken, serverConfig.publicOrigin)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Access setup/login endpoints must remain reachable before a user has a
|
|
147
|
+
// session. They still pass the same-origin/CSRF check above in browsers.
|
|
148
|
+
if (urlPath.startsWith('/api/access/')) {
|
|
149
|
+
return handleAccess(req, res, parsed, serverConfig);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Static assets and the Preact shell stay public so they can render the
|
|
153
|
+
// login/setup view. Everything containing app data is authenticated.
|
|
154
|
+
const accessProtected = urlPath === '/events'
|
|
155
|
+
|| urlPath === '/oauth/callback'
|
|
156
|
+
|| urlPath.startsWith('/api/')
|
|
157
|
+
|| urlPath === '/data';
|
|
158
|
+
// The OAuth loopback callback is the one exception to the access gate: a
|
|
159
|
+
// browser redirected back from the IdP has no reason to hold an access
|
|
160
|
+
// cookie (iOS PWA popup flow, fresh Safari context, or the popup being
|
|
161
|
+
// denied entirely). It must be reachable to complete the code exchange;
|
|
162
|
+
// `finishOAuth` still validates the state against a pending record the
|
|
163
|
+
// user created inside the authenticated app.
|
|
164
|
+
if (accessProtected && urlPath !== '/oauth/callback' && !authorizeAccessRequest(req, res, serverConfig.authEnabled)) return;
|
|
165
|
+
|
|
166
|
+
// SSE endpoint
|
|
167
|
+
if (urlPath === '/events' && method === 'GET') {
|
|
168
|
+
return handleSSE(req, res);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Static UI (mobile PWA bundle), served at the root. Prefers the Vite
|
|
172
|
+
// build at frontend/dist/; falls back to frontend/ when the build hasn't
|
|
173
|
+
// run yet (e.g. during development before `npm run build:web`). This lets
|
|
174
|
+
// the repo keep working in either state without breaking.
|
|
175
|
+
if (urlPath === '/') {
|
|
176
|
+
const servedOrigin = expectedOrigin(req, serverConfig.publicOrigin);
|
|
177
|
+
res.setHeader('Set-Cookie', sessionCookie(sessionToken, !!servedOrigin && servedOrigin.startsWith('https://')));
|
|
178
|
+
return serveWebFile(res, 'index.html', { preferDist: true });
|
|
179
|
+
}
|
|
180
|
+
// Legacy /web/ alias: the app now lives at the root; a simple redirect
|
|
181
|
+
// keeps old bookmarks and installed PWAs working. The browser preserves
|
|
182
|
+
// the hash fragment across the redirect, so /web/#/chat/<id> lands on
|
|
183
|
+
// /#/chat/<id>. No static bundle is served under /web/ anymore.
|
|
184
|
+
if (urlPath === '/web' || urlPath === '/web/') {
|
|
185
|
+
res.writeHead(301, { Location: '/' });
|
|
186
|
+
res.end();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Browser auto-requests a favicon. Serve the real one now that
|
|
191
|
+
// we've generated a 32x32 PNG (no more 204 stub).
|
|
192
|
+
if (urlPath === '/favicon.ico' && method === 'GET') {
|
|
193
|
+
return serveWebFile(res, 'icons/favicon-32.png', { preferDist: true });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Settings API
|
|
197
|
+
if (urlPath.startsWith('/api/settings')) {
|
|
198
|
+
return handleSettings(req, res, parsed);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Projects API (folder picker + registered projects)
|
|
202
|
+
if (urlPath.startsWith('/api/projects')) {
|
|
203
|
+
return handleProjects(req, res, parsed);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// File editor (in-app CodeMirror popup). See handleFileEditor for the
|
|
207
|
+
// contract; dispatched here as a top-level route so /api/file,
|
|
208
|
+
// /api/files, and the image-preview /api/file-media are all
|
|
209
|
+
// first-class (they do not start with /api/projects).
|
|
210
|
+
if (urlPath === '/api/file' || urlPath === '/api/files' || urlPath === '/api/file-media') {
|
|
211
|
+
return handleFileEditor(req, res, parsed);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// AI proxy (server-side call to upstream providers; SSE stream back)
|
|
215
|
+
//
|
|
216
|
+
// Dictation's transcription proxy is mounted *before* the generic /api/ai/
|
|
217
|
+
// branch: it is a different product with a different shape (one JSON-response
|
|
218
|
+
// round-trip, one audio payload) and its own handler module, so keeping it a
|
|
219
|
+
// sibling of `/api/ai/chat` rather than a case inside handleAI keeps both
|
|
220
|
+
// readable. See src/server-handlers-transcribe.js.
|
|
221
|
+
if (urlPath === '/api/ai/transcribe' || urlPath.startsWith('/api/ai/transcribe/')) {
|
|
222
|
+
return handleTranscribe(req, res, parsed);
|
|
223
|
+
}
|
|
224
|
+
if (urlPath.startsWith('/api/ai/')) {
|
|
225
|
+
return handleAI(req, res, parsed);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Auth API (account list, sign-out, status polling, sign-in)
|
|
229
|
+
if (urlPath.startsWith('/api/auth/')) {
|
|
230
|
+
return handleAuth(req, res, parsed, serverConfig);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Usage / pricing (model id list, built-in pricing table).
|
|
234
|
+
// The chat UI never holds pricing data; the cost is computed
|
|
235
|
+
// server-side per the stream and shipped on the `done` event.
|
|
236
|
+
// The only read endpoint the chat UI uses here is /builtin, for
|
|
237
|
+
// the SettingsPricing view's "known model ids" hint.
|
|
238
|
+
if (urlPath === '/api/usage/builtin' && method === 'GET') {
|
|
239
|
+
const table = usage.BUILTIN_PRICING || {};
|
|
240
|
+
return sendJSON(res, 200, {
|
|
241
|
+
ids: Object.keys(table).sort(),
|
|
242
|
+
// Echo the table itself so the Settings view (or any future
|
|
243
|
+
// "I want to see what default prices are" UI) doesn't have to
|
|
244
|
+
// duplicate the data. Pricing values are public — no secrets
|
|
245
|
+
// are exposed here.
|
|
246
|
+
table
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Prompt-size profiles. Static read-only endpoint that lists the
|
|
251
|
+
// three profiles (very-small | average | extensive) so the chat
|
|
252
|
+
// UI and the Settings view can render a picker without hard-coding
|
|
253
|
+
// the labels or descriptions. The actual profile text is consumed
|
|
254
|
+
// server-side at stream time; only the metadata is exposed here.
|
|
255
|
+
if (urlPath === '/api/prompt-profiles' && method === 'GET') {
|
|
256
|
+
return sendJSON(res, 200, {
|
|
257
|
+
profiles: promptProfiles.listProfiles(),
|
|
258
|
+
default: promptProfiles.DEFAULT_PROFILE
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Agent features — structured state of every mouaif feature for a
|
|
263
|
+
// project. Returns the same data the `list_features` tool provides.
|
|
264
|
+
// Requires ?projectDir=<abs>.
|
|
265
|
+
if (urlPath === '/api/features' && method === 'GET') {
|
|
266
|
+
return handleFeatures(req, res, parsed);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// MCP's callback is public like the provider callback; one-shot state +
|
|
270
|
+
// PKCE binds it to a sign-in begun through an authenticated API request.
|
|
271
|
+
if (urlPath === '/oauth/mcp/callback') return handleMcpOAuthCallback(req, res, parsed);
|
|
272
|
+
|
|
273
|
+
// OAuth loopback callback (provider redirects here after login)
|
|
274
|
+
if (urlPath === '/oauth/callback' && method === 'GET') {
|
|
275
|
+
return handleOAuthCallback(req, res, parsed);
|
|
276
|
+
}
|
|
277
|
+
// No-browser fallback: the UI POSTs { provider, state, code } and
|
|
278
|
+
// gets a JSON response. Same exchange as the GET path.
|
|
279
|
+
if (urlPath === '/oauth/callback' && method === 'POST') {
|
|
280
|
+
return handleOAuthCallbackPost(req, res, parsed);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Chats (per-project chat list). The projectDir is part of the URL so
|
|
284
|
+
// the routes are stable and cacheable; for the common case the
|
|
285
|
+
// mobile UI ships it as a query string. PUT and DELETE carry it in
|
|
286
|
+
// the JSON body.
|
|
287
|
+
if (urlPath === '/api/chats' || urlPath.startsWith('/api/chats/')) {
|
|
288
|
+
return handleChats(req, res, parsed, sessionToken, lifecycle);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Prompts (custom per-project prompts)
|
|
292
|
+
if (urlPath === '/api/prompts' || urlPath.startsWith('/api/prompts/')) {
|
|
293
|
+
return handlePrompts(req, res, parsed);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Project agents (project-scoped Markdown definitions + configuration)
|
|
297
|
+
if (urlPath === '/api/agents' || urlPath.startsWith('/api/agents/')) {
|
|
298
|
+
return handleAgents(req, res, parsed);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Tool Authorization API
|
|
302
|
+
if (urlPath.startsWith('/api/tools/authorization')) {
|
|
303
|
+
return handleToolAuthorization(req, res, parsed);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Git — direct git command execution (status, diff, log, add, commit).
|
|
307
|
+
// Uses the project directory as working dir. No model round-trip.
|
|
308
|
+
// GET /api/git/info?projectDir=<abs> returns parsed status + recent
|
|
309
|
+
// commits + per-section diffs for the Git modal in the chat view.
|
|
310
|
+
if (urlPath === '/api/git/info' && method === 'GET') {
|
|
311
|
+
return handleGitInfo(req, res, parsed);
|
|
312
|
+
}
|
|
313
|
+
if (urlPath === '/api/git/commits' && method === 'GET') {
|
|
314
|
+
return handleGitLog(req, res, parsed);
|
|
315
|
+
}
|
|
316
|
+
if (urlPath === '/api/git/commit-files' && method === 'GET') {
|
|
317
|
+
return handleGitCommitFiles(req, res, parsed);
|
|
318
|
+
}
|
|
319
|
+
if (urlPath === '/api/git' && method === 'POST') {
|
|
320
|
+
return handleGit(req, res, parsed);
|
|
321
|
+
}
|
|
322
|
+
// Project custom actions — named CLI/MCP shortcuts configured in project
|
|
323
|
+
// settings and launched directly from the composer.
|
|
324
|
+
if (urlPath === '/api/actions' || urlPath.startsWith('/api/actions/')) {
|
|
325
|
+
return handleActions(req, res, parsed);
|
|
326
|
+
}
|
|
327
|
+
// Tools — the native shell tool's direct REST surface (also the
|
|
328
|
+
// /shell composer command). Model-initiated calls run inside the
|
|
329
|
+
// AI client's tool loop and do not hit this endpoint.
|
|
330
|
+
if (urlPath === '/api/tools/shell' || urlPath.startsWith('/api/tools/')) {
|
|
331
|
+
return handleTools(req, res, parsed);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// MCP (Model Context Protocol) — per-project server registry +
|
|
335
|
+
// lifecycle + tool dispatch. Routes are mounted in handleMcp below.
|
|
336
|
+
if (urlPath === '/api/mcp' || urlPath.startsWith('/api/mcp/')) {
|
|
337
|
+
if (/^\/api\/mcp\/servers\/[^/]+\/oauth(?:\/start)?$/.test(urlPath)) return handleMcpOAuth(req, res, parsed, serverConfig);
|
|
338
|
+
return handleMcp(req, res, parsed);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Server lifecycle — graceful restart. Stops MCP children, closes the
|
|
342
|
+
// listening socket, then exits. When possible the CLI relaunches in-process;
|
|
343
|
+
// otherwise an external supervisor may relaunch after exit code 0.
|
|
344
|
+
if (urlPath === '/api/restart') {
|
|
345
|
+
return handleRestart(req, res, parsed, lifecycle);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Inspector — REST surface for the CDP bridge. The WebSocket proxy
|
|
349
|
+
// at /api/inspector/proxy is handled in the server's 'upgrade'
|
|
350
|
+
// event (see createServer below), not here.
|
|
351
|
+
if (urlPath.startsWith('/api/inspector/')) {
|
|
352
|
+
return handleInspector(req, res, parsed);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Root serves the mobile UI directly (served above in the static
|
|
356
|
+
// branch). The old 302 / -> /web/ redirect is gone — the app now
|
|
357
|
+
// lives at the root; /web/ is only a legacy alias.
|
|
358
|
+
|
|
359
|
+
// REST: GET /data
|
|
360
|
+
if (urlPath === '/data' && method === 'GET') {
|
|
361
|
+
return sendJSON(res, 200, store);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// REST: POST /data
|
|
365
|
+
//
|
|
366
|
+
// Legacy KV demo surface. It merges client JSON into the in-memory
|
|
367
|
+
// `store`, so the payload is validated before it lands: a non-object
|
|
368
|
+
// body, a body over the cap, or a `__proto__`-style key is refused
|
|
369
|
+
// rather than merged. Without that, `Object.assign(store, parsedBody)`
|
|
370
|
+
// let any no-Origin client replace the object's prototype and grow the
|
|
371
|
+
// buffer without limit.
|
|
372
|
+
if (urlPath === '/data' && method === 'POST') {
|
|
373
|
+
const MAX_BODY = 256 * 1024;
|
|
374
|
+
const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
375
|
+
let body = '';
|
|
376
|
+
let tooLarge = false;
|
|
377
|
+
req.on('data', chunk => {
|
|
378
|
+
if (tooLarge) return;
|
|
379
|
+
body += chunk;
|
|
380
|
+
if (body.length > MAX_BODY) { tooLarge = true; body = ''; }
|
|
381
|
+
});
|
|
382
|
+
req.on('end', () => {
|
|
383
|
+
if (tooLarge) return sendJSON(res, 413, { error: 'Payload too large', code: 'ETOOLARGE', maxBytes: MAX_BODY });
|
|
384
|
+
let parsedBody;
|
|
385
|
+
try {
|
|
386
|
+
parsedBody = JSON.parse(body || '{}');
|
|
387
|
+
} catch (e) {
|
|
388
|
+
return sendJSON(res, 400, { error: 'Invalid JSON' });
|
|
389
|
+
}
|
|
390
|
+
if (!parsedBody || typeof parsedBody !== 'object' || Array.isArray(parsedBody)) {
|
|
391
|
+
return sendJSON(res, 400, { error: 'Body must be a JSON object', code: 'EBADINPUT' });
|
|
392
|
+
}
|
|
393
|
+
const clean = {};
|
|
394
|
+
for (const [key, value] of Object.entries(parsedBody)) {
|
|
395
|
+
if (BLOCKED_KEYS.has(key)) continue;
|
|
396
|
+
clean[key] = value;
|
|
397
|
+
}
|
|
398
|
+
Object.assign(store, clean);
|
|
399
|
+
store.timestamp = new Date().toISOString();
|
|
400
|
+
broadcast('data-update', store);
|
|
401
|
+
sendJSON(res, 200, { ok: true, data: store });
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Push notification API — managed by the browser push subsystem.
|
|
407
|
+
if (urlPath.startsWith('/api/push/')) {
|
|
408
|
+
return handlePush(req, res, parsed, sessionToken, expectedOrigin(req, serverConfig.publicOrigin));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Static frontend fallback. Every API/SSE/REST route is dispatched
|
|
412
|
+
// above; anything left on a GET is a frontend asset (manifest, sw.js,
|
|
413
|
+
// icons, hashed /assets/*) or a deep-link path, so serve from
|
|
414
|
+
// frontend/dist/ (or the source tree during development). For a
|
|
415
|
+
// GET with no known asset extension the hash router owns routing, so
|
|
416
|
+
// we serve index.html (SPA fallback) — but only for HTML-accepting
|
|
417
|
+
// navigations, never for API-looking paths.
|
|
418
|
+
if (method === 'GET') {
|
|
419
|
+
// Never treat an unknown /api/* or /oauth path as a frontend asset.
|
|
420
|
+
if (urlPath === '/api' || urlPath.startsWith('/api/')
|
|
421
|
+
|| urlPath === '/oauth' || urlPath.startsWith('/oauth/')
|
|
422
|
+
|| urlPath === '/events' || urlPath === '/data') {
|
|
423
|
+
return sendJSON(res, 404, { error: 'Not found' });
|
|
424
|
+
}
|
|
425
|
+
return serveWebRequest(res, urlPath === '/' ? '' : urlPath.slice(1));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 404
|
|
429
|
+
sendJSON(res, 404, { error: 'Not found' });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function createServer(port = DEFAULT_PORT, options = {}) {
|
|
433
|
+
const requestedPublicOrigin = options && typeof options === 'object'
|
|
434
|
+
? (options.publicOrigin || process.env.MOUAIF_PUBLIC_ORIGIN || '')
|
|
435
|
+
: (process.env.MOUAIF_PUBLIC_ORIGIN || '');
|
|
436
|
+
const publicOrigin = normalizePublicOrigin(requestedPublicOrigin);
|
|
437
|
+
if (requestedPublicOrigin && !publicOrigin) {
|
|
438
|
+
throw Object.assign(new Error('publicOrigin must be an http(s) origin without a path'), { code: 'EBAD_PUBLIC_ORIGIN' });
|
|
439
|
+
}
|
|
440
|
+
// Run any pending database migrations before serving requests.
|
|
441
|
+
try { settings.runMigrations(); } catch (e) { console.warn('[mouaif] migrations failed:', e.message); }
|
|
442
|
+
try { accessAuth.ensureTables(); } catch (e) { console.warn('[mouaif] access auth init failed:', e.message); }
|
|
443
|
+
// Initialise push notification tables and VAPID keys.
|
|
444
|
+
try { push.ensureTable(); } catch (e) { console.warn('[mouaif] push table init failed:', e.message); }
|
|
445
|
+
try { push.ensureVapidKeys(publicOrigin); } catch (e) { console.warn('[mouaif] VAPID key init failed:', e.message); }
|
|
446
|
+
// Drop OAuth flows the user abandoned (closed the tab mid-sign-in). They
|
|
447
|
+
// are never consumed and would otherwise accumulate PKCE verifiers in the
|
|
448
|
+
// app store forever. Best-effort: a failure here must not stop the server.
|
|
449
|
+
try {
|
|
450
|
+
const pruned = auth.prunePending();
|
|
451
|
+
if (pruned > 0) console.log(`[mouaif] pruned ${pruned} stale OAuth flow${pruned === 1 ? '' : 's'}`);
|
|
452
|
+
} catch (e) {
|
|
453
|
+
console.warn('[mouaif] could not prune stale OAuth flows:', e.message);
|
|
454
|
+
}
|
|
455
|
+
const sessionToken = crypto.randomBytes(32).toString('base64url');
|
|
456
|
+
const lifecycle = (options && typeof options === 'object') ? (options.lifecycle || {}) : {};
|
|
457
|
+
const serverConfig = {
|
|
458
|
+
publicOrigin,
|
|
459
|
+
// The flag the process was started with. The store's in-app disable
|
|
460
|
+
// switch is layered on top at every gate (publicAccessStatus /
|
|
461
|
+
// authorizeAccessRequest) so turning access off or back on takes effect
|
|
462
|
+
// without a restart.
|
|
463
|
+
authEnabled: options.authEnabled === true
|
|
464
|
+
};
|
|
465
|
+
const server = http.createServer((req, res) => {
|
|
466
|
+
// Bind port to the request handler
|
|
467
|
+
handleRequest(req, res, port, sessionToken, lifecycle, serverConfig);
|
|
468
|
+
});
|
|
469
|
+
// Track connections so the graceful-restart path can force-close
|
|
470
|
+
// SSE / keep-alive sockets that would otherwise hang server.close().
|
|
471
|
+
server.on('connection', trackSocket);
|
|
472
|
+
server.on('secureConnection', trackSocket);
|
|
473
|
+
// WebSocket upgrade routing. Only /api/inspector/proxy is upgraded;
|
|
474
|
+
// any other upgrade is rejected so the rest of the server stays
|
|
475
|
+
// untouched. The noServer WebSocketServer gives us manual
|
|
476
|
+
// handleUpgrade() so we can decide per-request.
|
|
477
|
+
const wss = inspector.makeNoServerWss();
|
|
478
|
+
server.on('upgrade', (req, socket, head) => {
|
|
479
|
+
const u = req.url || '';
|
|
480
|
+
if (u.startsWith('/api/inspector/proxy')) {
|
|
481
|
+
const origin = requestOrigin(req);
|
|
482
|
+
const expected = expectedOrigin(req, publicOrigin);
|
|
483
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
484
|
+
const actual = cookies[SESSION_COOKIE] || '';
|
|
485
|
+
const validToken = actual.length === sessionToken.length
|
|
486
|
+
&& crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(sessionToken));
|
|
487
|
+
const accessToken = cookies[ACCESS_COOKIE] || '';
|
|
488
|
+
const validAccess = !serverConfig.authEnabled || accessAuth.disabled() || !!accessAuth.session(accessToken);
|
|
489
|
+
if (!origin || origin !== expected || !validToken || !validAccess) {
|
|
490
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
491
|
+
socket.end();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
// The inspector module does the heavy lifting. We pass `server`
|
|
495
|
+
// so it can complete the upgrade on the browser side via
|
|
496
|
+
// server.handleUpgrade().
|
|
497
|
+
inspector.handleProxy(req, socket, head, { server, wss, debuggerUrl: inspector.getDebuggerUrl() })
|
|
498
|
+
.catch((e) => {
|
|
499
|
+
// Already-closed sockets are normal; the only way to surface
|
|
500
|
+
// a true error here is to write a 500-style HTTP response on
|
|
501
|
+
// the raw socket.
|
|
502
|
+
try {
|
|
503
|
+
socket.write('HTTP/1.1 500 Internal\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
504
|
+
socket.end();
|
|
505
|
+
} catch { /* ignore */ }
|
|
506
|
+
});
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
// Reject anything else.
|
|
510
|
+
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
511
|
+
socket.end();
|
|
512
|
+
});
|
|
513
|
+
// Stash the wss so the inspector module can find it; not strictly
|
|
514
|
+
// required today but lets a future commit add server-initiated
|
|
515
|
+
// pushes (e.g. browser-driven "fetch the next page of history").
|
|
516
|
+
void wss;
|
|
517
|
+
return server;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
module.exports = {
|
|
521
|
+
createServer,
|
|
522
|
+
handleRequest,
|
|
523
|
+
broadcast,
|
|
524
|
+
destroyOpenSockets,
|
|
525
|
+
DEFAULT_PORT,
|
|
526
|
+
settings,
|
|
527
|
+
projects,
|
|
528
|
+
ai,
|
|
529
|
+
auth,
|
|
530
|
+
oauthAnthropic,
|
|
531
|
+
oauthCopilot,
|
|
532
|
+
chats,
|
|
533
|
+
resolveModel,
|
|
534
|
+
seedModelListCache
|
|
535
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Server entry point — thin facade.
|
|
4
|
+
//
|
|
5
|
+
// The HTTP server implementation lives in src/http-server.js; this
|
|
6
|
+
// module exists so the public `require('../src/index.js')` surface
|
|
7
|
+
// (used by bin/mouaif.js and the test scripts) stays stable:
|
|
8
|
+
//
|
|
9
|
+
// const { createServer, destroyOpenSockets, DEFAULT_PORT } = require('../src/index.js');
|
|
10
|
+
//
|
|
11
|
+
// The original single-file src/index.js was split into focused
|
|
12
|
+
// sub-modules (see src/http-server.js header comment) so no file in
|
|
13
|
+
// the repo stays above ~2 000 lines.
|
|
14
|
+
|
|
15
|
+
module.exports = require('./http-server.js');
|