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,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');