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,780 @@
1
+ 'use strict';
2
+
3
+ // Shared state + cross-cutting helpers for the HTTP server.
4
+ //
5
+ // The original single-file src/index.js (later src/http-server.js) held
6
+ // every route handler, the shared in-memory state (SSE clients, running
7
+ // chats, access-attempt tracking, the live model cache) and the helpers
8
+ // those handlers all reach for (sendJSON, cookie/origin authorization,
9
+ // settings redaction, model resolution). This module owns that shared
10
+ // surface so the per-domain handler modules stay focused and no file in
11
+ // the repo stays above ~2 000 lines.
12
+ //
13
+ // It deliberately does NOT require any handler module (no circular
14
+ // deps); it only requires the domain modules (settings, ai, auth, ...)
15
+ // that the helpers delegate to.
16
+
17
+ const http = require('http');
18
+ const url = require('url');
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const os = require('os');
22
+ const crypto = require('crypto');
23
+ const { spawn } = require('node:child_process');
24
+ const { qs, safeDecode, projectModelRecord, firstStringValue, errCodeToHttpStatus } = require('./util.js');
25
+ const settings = require('./settings.js');
26
+ const projects = require('./projects.js');
27
+ const ai = require('./ai.js');
28
+ const auth = require('./auth.js');
29
+ const accessAuth = require('./access-auth.js');
30
+ const qr = require('./qr.js');
31
+ const oauthAnthropic = require('./oauth-anthropic.js');
32
+ const oauthCopilot = require('./oauth-github-copilot.js');
33
+ const oauthOpenRouter = require('./oauth-openrouter.js');
34
+
35
+ // Register each per-provider exchange function with the auth skeleton.
36
+ // Idempotent; safe to call from require-time side effects because
37
+ // auth.registerExchange overwrites cleanly. This is the require-time
38
+ // registration that used to live at the top of src/index.js (pre-split);
39
+ // the refactor that split http-server.js into handler modules dropped it,
40
+ // which made every POST /api/auth/sign-in/<provider> return 501
41
+ // "<Provider> OAuth is not registered in this build". Without it the
42
+ // OAuth sign-in button can never start a flow, loopback or domain-served.
43
+ oauthAnthropic.register();
44
+ oauthCopilot.register();
45
+ oauthOpenRouter.register();
46
+ const chats = require('./chats.js');
47
+ const messages = require('./messages.js');
48
+ const trace = require('./trace.js');
49
+ const inspector = require('./inspector.js');
50
+ const inspectorProfiles = require('./inspectorProfiles.js');
51
+ const prompts = require('./prompts.js');
52
+ const promptProfiles = require('./promptProfiles.js');
53
+ const tags = require('./tags.js');
54
+ const agentFiles = require('./agentFiles.js');
55
+ const agentSkills = require('./agentSkills.js');
56
+ const agentFeatures = require('./agentFeatures.js');
57
+ const agents = require('./agents.js');
58
+ const mcp = require('./mcp.js');
59
+ const usage = require('./usage.js');
60
+ const push = require('./push.js');
61
+ const shellTool = require('./tools/shell.js');
62
+ const files = require('./files.js');
63
+
64
+ const DEFAULT_PORT = 5732;
65
+ const SESSION_COOKIE = 'mouaif_session';
66
+ const ACCESS_COOKIE = 'mouaif_access';
67
+ // The mobile UI lives at frontend/ (repo root, not under src/). Vite
68
+ // builds it into frontend/dist/. The server serves that directory at
69
+ // the root /; it falls back to the pre-build frontend/ source for the
70
+ // dev cycle (no Vite build run yet).
71
+ const WEB_DIR = path.join(__dirname, '..', 'frontend');
72
+ const WEB_DIST = path.join(WEB_DIR, 'dist');
73
+
74
+ // ---- Socket + connection tracking --------------------------------------
75
+
76
+ // Track every open socket so the graceful-restart path can force-close
77
+ // keep-alive / SSE connections that would otherwise keep server.close()
78
+ // from resolving. Keyed by the socket object; value is always true.
79
+ const openSockets = new Set();
80
+
81
+ function trackSocket(socket) {
82
+ openSockets.add(socket);
83
+ socket.on('close', () => openSockets.delete(socket));
84
+ }
85
+
86
+ function destroyOpenSockets() {
87
+ for (const s of openSockets) {
88
+ try { s.destroy(); } catch (_) { /* best-effort */ }
89
+ }
90
+ openSockets.clear();
91
+ }
92
+
93
+ // Store connected SSE clients
94
+ const sseClients = new Set();
95
+
96
+ // Chats with an in-flight streaming run. handleChatStream registers a
97
+ // chat here for the lifetime of its SSE response; GET /api/chats/:id
98
+ // surfaces it as a response-only `running` flag so a client that
99
+ // reloads mid-run can re-enter its busy/streaming state instead of
100
+ // showing the transcript frozen. In-memory (not persisted): a process
101
+ // restart ends every run anyway, so nothing survives to clear.
102
+ const runningChats = new Set();
103
+ const runningChatCancels = new Map();
104
+ const accessAttempts = new Map();
105
+ const liveChat = require('./live-chat.js');
106
+ function runningKey(projectDir, chatId) {
107
+ return String(projectDir) + '::' + String(chatId);
108
+ }
109
+
110
+ // In-memory store for REST demo
111
+ const store = { message: 'Hello from mouaif!', timestamp: new Date().toISOString() };
112
+
113
+ function sendJSON(res, status, data) {
114
+ res.writeHead(status, { 'Content-Type': 'application/json' });
115
+ res.end(JSON.stringify(data));
116
+ }
117
+
118
+ // Settings responses are consumed by the browser, so secrets must never be
119
+ // serialized back after they have been stored. The UI only needs to know
120
+ // whether a key exists in order to render its masked "key: •••" hint.
121
+ function connectionForClient(connection) {
122
+ if (!connection || typeof connection !== 'object') return connection;
123
+ const safe = { ...connection };
124
+ safe.hasApiKey = typeof safe.apiKey === 'string' && safe.apiKey.length > 0;
125
+ delete safe.apiKey;
126
+ return safe;
127
+ }
128
+
129
+ function modelForClient(model) {
130
+ return connectionForClient(model);
131
+ }
132
+
133
+ // A redacted settings snapshot contains `hasApiKey`, which is a
134
+ // response-only marker. Never persist it if a client round-trips the
135
+ // snapshot through a generic patch endpoint. Existing secrets are
136
+ // preserved unless the client explicitly submits a new `apiKey`.
137
+ // Shared by PUT /api/settings/app and PUT /api/settings/project so the
138
+ // redaction contract is symmetric across scopes.
139
+ function sanitizeClientEntries(patch, key, existing) {
140
+ if (!patch || !Array.isArray(patch[key])) return;
141
+ const prior = Array.isArray(existing) ? existing : [];
142
+ patch[key] = patch[key].map((entry) => {
143
+ if (!entry || typeof entry !== 'object') return entry;
144
+ const clean = { ...entry };
145
+ delete clean.hasApiKey;
146
+ if (!Object.prototype.hasOwnProperty.call(clean, 'apiKey')) {
147
+ const prev = prior.find(x => x && x.id === clean.id);
148
+ if (prev && typeof prev.apiKey === 'string') clean.apiKey = prev.apiKey;
149
+ }
150
+ return clean;
151
+ });
152
+ }
153
+
154
+ // The app-level store accumulates server-only bookkeeping that the browser
155
+ // has no business seeing: in-flight OAuth flows (`authPending`, which carry a
156
+ // PKCE `codeVerifier` and CSRF `state` — real secrets), the CDP debugger URL,
157
+ // and anything a future feature stashes there. Rather than blocklist each new
158
+ // leak, we allowlist the exact keys the web UI consumes. Everything else is
159
+ // dropped before it ever hits the wire.
160
+ const CLIENT_SETTINGS_KEYS = Object.freeze([
161
+ 'providers', // app-level provider connections (apiKey redacted below)
162
+ 'models', // user-defined models
163
+ 'projects', // registered project cards
164
+ 'promptSize', // default prompt-size profile
165
+ 'enterForNewline', // composer keyboard default (Enter newline vs send)
166
+ 'autoRetry', // auto-retry failed turns before the stream starts
167
+ 'fileOrbButton', // composer file button renders as the animated glass orb
168
+ // Which optional composer tools are drawn. Same class as the orb: app-level
169
+ // display preferences. A key that is stored but not allowlisted is dropped
170
+ // from every /api/settings response, which looks exactly like a toggle that
171
+ // will not save — and here it would be a button the user hides and the
172
+ // composer keeps showing. See docs/features/composer-tool-buttons.md.
173
+ 'dictationButton', // the composer microphone button (`false` hides it)
174
+ 'imageButton', // the composer image-attachment button (`false` hides it)
175
+ // The remembered dictation choice: `{ modelId, providerId }`, written by the
176
+ // dictation page and read by every surface that dictates — the page's picker
177
+ // and the composer microphone. No secret, but it must be allowlisted here:
178
+ // a key that is stored and then stripped on the way out is a key the UI can
179
+ // never read back, which made the pick look unsaved and left the microphone
180
+ // saying "No dictation model yet" however often one was chosen.
181
+ // See docs/features/dictation.md.
182
+ 'dictation',
183
+ 'prompts', // app-level custom prompts
184
+ 'githubCopilot', // { clientId } for the custom OAuth app
185
+ 'modelPricing', // per-model cost table
186
+ 'authAccounts', // non-secret OAuth account index
187
+ 'tools', // per-project tool config (e.g. tools.shell.enabled) — non-secret
188
+ 'toolFeedbackMaxBytes', // model-facing tool-result byte cap
189
+ 'toolOutput', // per-project tool output profile { size, structure } — non-secret
190
+ 'notifications', // browser notification event preferences
191
+ 'flags' // server-side feature toggles (non-secret)
192
+ ]);
193
+
194
+ // Keys that POST /api/settings/app/reset is allowed to drop from the app
195
+ // store. The DEFAULTS keys are the baseline; the extras are additive app
196
+ // keys that have no in-code default (their absence IS the default) but
197
+ // that the UI must still be able to clear — otherwise "reset" silently
198
+ // can't reach settings like the custom pricing table or the remembered
199
+ // dictation model.
200
+ const RESETTABLE_APP_KEYS = Object.freeze(
201
+ new Set([...Object.keys(settings.DEFAULTS), 'modelPricing', 'githubCopilot', 'dictation'])
202
+ );
203
+
204
+ // Allowlist projection for the APP-level store only. Project payloads must
205
+ // use projectForClient() below — see the note there for why.
206
+ function settingsForClient(value) {
207
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
208
+ const safe = {};
209
+ for (const key of CLIENT_SETTINGS_KEYS) {
210
+ if (Object.prototype.hasOwnProperty.call(value, key)) safe[key] = value[key];
211
+ }
212
+ if (Array.isArray(safe.providers)) safe.providers = safe.providers.map(connectionForClient);
213
+ if (Array.isArray(safe.models)) safe.models = safe.models.map(modelForClient);
214
+ return safe;
215
+ }
216
+ // Project settings are user-authored and open-ended: `<projectDir>/.mouaif.json`
217
+ // is hand-editable and every feature that lands there adds a key (`name`,
218
+ // `agents`, `skills`, `agentFiles`, `hideFileContent`, `tags`,
219
+ // `customActions`, `totalCost`, ...). Filtering them through the app-level
220
+ // allowlist above dropped all of those from the wire, which made the
221
+ // Technical-details raw editor incomplete and froze project-scoped UI state
222
+ // (e.g. the hidden-file count, the agent-files / skills switches) at its
223
+ // default. So project payloads are sent whole and only two things are
224
+ // removed: secrets (no project file may carry an `apiKey` — see
225
+ // docs/decisions.md §3) and the internal `__dbBacked` storage marker.
226
+ function projectForClient(value) {
227
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
228
+ const safe = { ...value };
229
+ delete safe.__dbBacked;
230
+ if (Array.isArray(safe.providers)) safe.providers = safe.providers.map(connectionForClient);
231
+ if (Array.isArray(safe.models)) safe.models = safe.models.map(modelForClient);
232
+ return safe;
233
+ }
234
+
235
+ // ---- SSE ----------------------------------------------------------------
236
+
237
+ function handleSSE(req, res) {
238
+ res.writeHead(200, {
239
+ 'Content-Type': 'text/event-stream',
240
+ 'Cache-Control': 'no-cache',
241
+ Connection: 'keep-alive'
242
+ });
243
+
244
+ // Send initial connection event
245
+ res.write(`data: ${JSON.stringify({ type: 'connected', message: 'SSE connection established' })}\n\n`);
246
+
247
+ sseClients.add(res);
248
+
249
+ // Heartbeat every 30s to keep connection alive
250
+ const heartbeat = setInterval(() => {
251
+ res.write(': heartbeat\n\n');
252
+ }, 30000);
253
+
254
+ req.on('close', () => {
255
+ sseClients.delete(res);
256
+ clearInterval(heartbeat);
257
+ });
258
+ }
259
+
260
+ // Broadcast an event to all SSE clients
261
+ function broadcast(event, data) {
262
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
263
+ for (const client of sseClients) {
264
+ client.write(payload);
265
+ }
266
+ }
267
+
268
+ // ---- Request authorization helpers -------------------------------------
269
+
270
+ function parseCookies(header) {
271
+ const out = {};
272
+ for (const part of String(header || '').split(';')) {
273
+ const at = part.indexOf('=');
274
+ if (at <= 0) continue;
275
+ const key = part.slice(0, at).trim();
276
+ const value = part.slice(at + 1).trim();
277
+ if (key) out[key] = value;
278
+ }
279
+ return out;
280
+ }
281
+
282
+ function requestOrigin(req) {
283
+ const raw = typeof req.headers.origin === 'string' ? req.headers.origin : '';
284
+ if (!raw) return '';
285
+ try { return new URL(raw).origin; } catch { return null; }
286
+ }
287
+
288
+ function normalizePublicOrigin(value) {
289
+ if (!value) return null;
290
+ try {
291
+ const parsed = new URL(String(value));
292
+ if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) return null;
293
+ return parsed.origin;
294
+ } catch { return null; }
295
+ }
296
+
297
+ function expectedOrigin(req, publicOrigin) {
298
+ if (publicOrigin) return publicOrigin;
299
+ const host = typeof req.headers.host === 'string' ? req.headers.host.trim() : '';
300
+ if (!host || /[\r\n]/.test(host)) return null;
301
+ const protocol = req.socket && req.socket.encrypted ? 'https://' : 'http://';
302
+ try { return new URL(protocol + host).origin; } catch { return null; }
303
+ }
304
+
305
+ function sessionCookie(sessionToken, secure) {
306
+ return SESSION_COOKIE + '=' + sessionToken + '; Path=/; HttpOnly; SameSite=Strict' + (secure ? '; Secure' : '');
307
+ }
308
+
309
+ function accessCookie(token, secure, maxAge) {
310
+ return ACCESS_COOKIE + '=' + (token || '') + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' + Math.max(0, Math.floor(maxAge || 0)) + (secure ? '; Secure' : '');
311
+ }
312
+
313
+ function requestHasBrowserOrigin(req) {
314
+ return typeof req.headers.origin === 'string' || typeof req.headers['sec-fetch-site'] === 'string';
315
+ }
316
+
317
+ function authorizeBrowserRequest(req, res, sessionToken, publicOrigin) {
318
+ const origin = requestOrigin(req);
319
+ if (!origin) return true;
320
+ const expected = expectedOrigin(req, publicOrigin);
321
+ if (!expected || origin !== expected) {
322
+ sendJSON(res, 403, { error: 'Cross-origin requests are not allowed', code: 'EORIGIN' });
323
+ return false;
324
+ }
325
+ const cookies = parseCookies(req.headers.cookie);
326
+ const actual = cookies[SESSION_COOKIE] || '';
327
+ const valid = actual.length === sessionToken.length
328
+ && crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(sessionToken));
329
+ if (!valid) {
330
+ if (!actual) {
331
+ sendJSON(res, 401, { error: 'Browser session is missing or expired', code: 'ESESSION' });
332
+ return false;
333
+ }
334
+ // A server restart generates a new in-memory browser session token.
335
+ // The already-open mobile UI still has the old HttpOnly cookie and
336
+ // would otherwise get stuck with ESESSION until the user reloads
337
+ // /. Same-origin Origin validation above is the CSRF boundary; for
338
+ // same-origin browser traffic with a stale cookie, mint the fresh cookie
339
+ // and let the request continue.
340
+ res.setHeader('Set-Cookie', sessionCookie(sessionToken, !!expected && expected.startsWith('https://')));
341
+ }
342
+ return true;
343
+ }
344
+
345
+ function authorizeAccessRequest(req, res, authEnabled) {
346
+ if (!authEnabled) return true;
347
+ // Access can be turned off from inside the app (one-time disable code).
348
+ // serverConfig.authEnabled was resolved at worker start; re-checking the
349
+ // store here makes the change effective immediately, without waiting for a
350
+ // restart — the /#/disable-access page is the point of no return.
351
+ if (accessAuth.disabled()) return true;
352
+ if (!accessAuth.configured()) {
353
+ // Preserve the existing loopback CLI/API workflow until the user opts in;
354
+ // browser traffic is held at setup so the web UI cannot expose app data.
355
+ if (!requestHasBrowserOrigin(req)) return true;
356
+ sendJSON(res, 401, { error: 'Complete access setup first', code: 'EAUTH_SETUP_REQUIRED' });
357
+ return false;
358
+ }
359
+ const token = parseCookies(req.headers.cookie)[ACCESS_COOKIE] || '';
360
+ if (accessAuth.session(token)) return true;
361
+ // Non-browser CLI/API clients can use standard HTTP Basic auth instead of
362
+ // first creating a cookie session.
363
+ const authorization = String(req.headers.authorization || '');
364
+ if (authorization.startsWith('Basic ')) {
365
+ try {
366
+ const decoded = Buffer.from(authorization.slice(6), 'base64').toString('utf8');
367
+ const split = decoded.indexOf(':');
368
+ if (split >= 0 && accessAuth.verifyPassword(decoded.slice(0, split), decoded.slice(split + 1))) return true;
369
+ } catch (_) { /* malformed Basic header falls through to 401 */ }
370
+ }
371
+ res.setHeader('WWW-Authenticate', 'Basic realm="mouaif", charset="UTF-8"');
372
+ const accept = String(req.headers.accept || '');
373
+ if (req.method === 'GET' && accept.includes('text/html')) {
374
+ res.writeHead(302, { Location: '/#/login' });
375
+ res.end();
376
+ return false;
377
+ }
378
+ sendJSON(res, 401, { error: 'Sign in is required', code: 'EAUTH_REQUIRED' });
379
+ return false;
380
+ }
381
+
382
+ function readJsonBody(req) {
383
+ return new Promise((resolve, reject) => {
384
+ let body = '';
385
+ req.on('data', chunk => { body += chunk; });
386
+ req.on('end', () => {
387
+ if (!body) return resolve({});
388
+ try { resolve(JSON.parse(body)); }
389
+ catch (e) { reject(Object.assign(new Error('Invalid JSON'), { status: 400 })); }
390
+ });
391
+ req.on('error', reject);
392
+ });
393
+ }
394
+
395
+ // readJsonOr400(req, res) — readJsonBody with the uniform error guard
396
+ // that every POST/PUT/PATCH handler used to repeat inline:
397
+ // try { body = await readJsonBody(req); }
398
+ // catch (e) { return sendJSON(res, e.status || 400, { error: e.message }); }
399
+ // Returns the parsed body, or null after it has already sent a 400
400
+ // response. Callers can `const body = await readJsonOr400(req, res); if (!body) return;`
401
+ async function readJsonOr400(req, res) {
402
+ try {
403
+ return await readJsonBody(req);
404
+ } catch (e) {
405
+ sendJSON(res, e.status || 400, { error: e.message });
406
+ return null;
407
+ }
408
+ }
409
+
410
+ // ---- AI model resolution ------------------------------------------------
411
+
412
+ function resolveModel(modelId, projectDir, providerId) {
413
+ if (!modelId || typeof modelId !== 'string') {
414
+ const e = new Error('modelId is required');
415
+ e.code = 'EBADINPUT';
416
+ throw e;
417
+ }
418
+ const resolved = settings.getResolved(projectDir || null);
419
+ const list = Array.isArray(resolved.models) ? resolved.models : [];
420
+ const wantedProvider = typeof providerId === 'string' ? providerId.trim() : '';
421
+ let m = list.find(x => x && x.id === modelId && (!wantedProvider || x.provider === wantedProvider));
422
+ let liveCatalogModel = false;
423
+ if (!m) {
424
+ // Live catalog entries are intentionally not persisted into the
425
+ // project's optional models array. The browser submits providerId so
426
+ // the server can build the same minimal model record on demand.
427
+ if (wantedProvider && ai.ENDPOINTS[wantedProvider]) {
428
+ m = { id: modelId, provider: wantedProvider };
429
+ liveCatalogModel = true;
430
+ } else {
431
+ const e = new Error('Model not found: ' + modelId);
432
+ e.code = 'EMODEL_NOT_FOUND';
433
+ throw e;
434
+ }
435
+ }
436
+ // Project models contain identity/selection metadata only. The app-level
437
+ // provider connection exclusively owns transport and credentials.
438
+ const app = settings.getApp();
439
+ const providers = Array.isArray(app.providers) ? app.providers : [];
440
+ const connection = providers.find(p => p && p.id === m.provider);
441
+ if (!connection && liveCatalogModel) {
442
+ const e = new Error('Provider connection not found: ' + m.provider);
443
+ e.code = 'EPROVIDER_NOT_FOUND';
444
+ throw e;
445
+ }
446
+
447
+ // Live-catalog models (OpenRouter etc.) carry per-model pricing from the
448
+ // upstream /models list, which the client fetched into MODEL_LIST_CACHE
449
+ // before the user picked the model. Fold that pricing onto the record so
450
+ // the cost line uses the provider's real numbers instead of the
451
+ // best-effort built-in table. Project-level records keep their own
452
+ // `pricing` (most specific) — only the live path is enriched here.
453
+ if (liveCatalogModel && !m.pricing) {
454
+ const cached = MODEL_LIST_CACHE.get(modelListCacheKey(m.provider, credHashFor(m.provider), 'chat'));
455
+ if (cached && Array.isArray(cached.models)) {
456
+ const live = cached.models.find((x) => x && x.id === modelId);
457
+ if (live && live.pricing && typeof live.pricing === 'object') {
458
+ m = Object.assign({}, m, { pricing: live.pricing });
459
+ }
460
+ }
461
+ }
462
+
463
+ // Never let committed project JSON redirect a global credential to an
464
+ // attacker-controlled endpoint or replace auth/account/header policy.
465
+ const safeModel = projectModelRecord(m);
466
+
467
+ const hydrated = Object.assign({}, connection || {}, safeModel, { provider: m.provider });
468
+ if (!hydrated.auth) hydrated.auth = 'apikey';
469
+ return hydrated;
470
+ }
471
+
472
+ // credHashFor(provider) — the same bucket key used by the /api/ai/models/live
473
+ // cache. Reuses credentialForProvider so resolveModel and the live endpoint
474
+ // agree on which cache entry is current.
475
+ function credHashFor(provider) {
476
+ let cred = null;
477
+ try { cred = credentialForProvider(provider); }
478
+ catch { /* listModels will surface ENO_APIKEY if the provider requires a credential */ }
479
+ return cred ? hashShort(cred) : '-';
480
+ }
481
+
482
+ function credentialForProvider(provider) {
483
+ const app = settings.getApp();
484
+ const conn = (Array.isArray(app.providers) ? app.providers : []).find((p) => p && p.id === provider);
485
+ if (!conn) return null;
486
+ if (conn.apiKey) return conn.apiKey;
487
+ if (conn.auth === 'oauth') {
488
+ const token = auth.tokenForModel({ provider, auth: 'oauth', oauthAccount: conn.oauthAccount });
489
+ const parsedToken = token ? JSON.parse(token) : null;
490
+ return parsedToken && parsedToken.accessToken ? parsedToken.accessToken : null;
491
+ }
492
+ return null;
493
+ }
494
+
495
+ // In-memory cache for /api/ai/models/live. Keyed by
496
+ // `${provider}:${credHash}` so a key rotation invalidates the entry.
497
+ // Cleared on process restart; the chat UI also has its own explicit
498
+ // "refresh" button that bypasses the cache (via cache-buster).
499
+ const MODEL_LIST_CACHE = new Map();
500
+ const MODEL_LIST_TTL_MS = 60 * 60 * 1000; // 1 hour
501
+ const MODEL_LIST_TIMEOUT_MS = 8000; // 8 s
502
+
503
+ // hashShort(s) — cheap 32-bit FNV-1a. Used to bucket per-credential
504
+ // cache entries without leaking the actual key.
505
+ function hashShort(s) {
506
+ let h = 0x811c9dc5;
507
+ for (let i = 0; i < s.length; i++) {
508
+ h ^= s.charCodeAt(i);
509
+ h = (h * 0x01000193) >>> 0;
510
+ }
511
+ return h.toString(16);
512
+ }
513
+
514
+ // modelListCacheKey(provider, credHash, purpose) — the cache key for one slice
515
+ // of a provider's model list.
516
+ //
517
+ // The same provider is asked more than one question — the chat catalog and the
518
+ // speech-to-text catalog — and the answers must not share an entry: serving
519
+ // the chat list to the dictation picker is what made every OpenRouter
520
+ // dictation attempt fail with `400 Model … does not exist`. The chat slice
521
+ // keeps the two-part key its existing callers (resolveModel, the test seeding
522
+ // hook, the live-list route) already use; any other slice appends its name.
523
+ function modelListCacheKey(provider, credHash, purpose) {
524
+ const p = purpose && purpose !== 'chat' ? ':' + purpose : '';
525
+ return provider + ':' + credHash + p;
526
+ }
527
+ // seedModelListCache(provider, models) — test-only hook: pre-fill the
528
+ // in-memory live model cache so resolveModel can pick up per-model
529
+ // pricing without a live upstream call.
530
+ function seedModelListCache(provider, models) {
531
+ if (provider) MODEL_LIST_CACHE.set(modelListCacheKey(provider, credHashFor(provider), 'chat'), { models, fetchedAt: Date.now() });
532
+ }
533
+
534
+ // ---- App access authentication helpers ----------------------------------
535
+
536
+ function accessRequestOrigin(req, publicOrigin) {
537
+ const origin = expectedOrigin(req, publicOrigin);
538
+ if (!origin) throw Object.assign(new Error('Could not determine the app origin'), { code: 'EORIGIN' });
539
+ return origin;
540
+ }
541
+
542
+ function accessAttemptKey(req) {
543
+ return String(req.socket && req.socket.remoteAddress || 'unknown');
544
+ }
545
+
546
+ function checkAccessAttempts(req) {
547
+ const key = accessAttemptKey(req);
548
+ const now = Date.now();
549
+ const recent = (accessAttempts.get(key) || []).filter((at) => now - at < 60_000);
550
+ accessAttempts.set(key, recent);
551
+ return recent.length < 10;
552
+ }
553
+
554
+ function recordAccessFailure(req) {
555
+ const key = accessAttemptKey(req);
556
+ const recent = accessAttempts.get(key) || [];
557
+ recent.push(Date.now());
558
+ accessAttempts.set(key, recent);
559
+ }
560
+
561
+ function clearAccessFailures(req) {
562
+ accessAttempts.delete(accessAttemptKey(req));
563
+ }
564
+
565
+ function publicAccessStatus(authEnabled = true) {
566
+ const account = accessAuth.user();
567
+ const disabled = accessAuth.disabled();
568
+ // `armed` is whether the process was started with an auth flag at all,
569
+ // independent of the in-app disable switch. The UI uses it to decide
570
+ // between "protection is off because you turned it off" and "this server
571
+ // was never asked to protect anything".
572
+ const armed = !!authEnabled || disabled;
573
+ return {
574
+ armed,
575
+ enabled: !!authEnabled && !disabled,
576
+ disabled,
577
+ configured: !!account,
578
+ user: account ? account.username : null,
579
+ passkeyCount: account ? accessAuth.passkeys().length : 0
580
+ };
581
+ }
582
+
583
+ // ---- OAuth callback helpers --------------------------------------------
584
+
585
+ function htmlPage(title, body) {
586
+ const safe = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
587
+ return '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + safe(title) + '</title><style>body{font:16px/1.5 system-ui,sans-serif;background:#111;color:#eee;margin:0;padding:24px;max-width:480px}h1{font-size:1.1rem;margin:0 0 12px}p{color:#aaa;margin:0 0 12px}.ok{color:#7bd88f}.err{color:#ff8a8a}</style></head><body><h1>' + safe(title) + '</h1>' + body + '</body></html>';
588
+ }
589
+
590
+ // Return-page snippet for OAuth callback pages so the page gets the user
591
+ // back into the app after sign-in completes. Critical on iOS PWA standalone
592
+ // mode where there is no tab bar and the user cannot manually "close this
593
+ // tab".
594
+ //
595
+ // When `closePopup` is set (success pages) the JS runs immediately (no 2s
596
+ // wait):
597
+ // - popup flow (window.open without noopener): close the popup via
598
+ // window.close(); the opener app polls the account list and reports
599
+ // success itself.
600
+ // - full-page flow (iOS PWA redirect-back): replace the current page
601
+ // with the app so the user lands straight back in the UI.
602
+ // Error pages pass no `closePopup`: the instant close would hide the
603
+ // failure message before the user could read it, so they keep the plain 2s
604
+ // meta refresh instead.
605
+ // The meta refresh is a no-JS fallback (identical destination), and a
606
+ // manual "Return to the app" link is always available as a tap target.
607
+ function redirectMeta(url, closePopup) {
608
+ const safe = String(url).replace(/["<>]/g, '');
609
+ let js = '';
610
+ if (closePopup) {
611
+ js = '<script>'
612
+ + 'try{'
613
+ + 'if(window.opener&&window.opener!==window&&!window.opener.closed){'
614
+ + 'window.close();'
615
+ + '}else{'
616
+ + 'location.replace(' + JSON.stringify(safe) + ');'
617
+ + '}'
618
+ + '}catch(e){location.replace(' + JSON.stringify(safe) + ');}'
619
+ + '</script>';
620
+ }
621
+ return '<meta http-equiv="refresh" content="2; url=' + safe + '">'
622
+ + js
623
+ + '<p>Redirecting back to the app… <a href="' + safe + '" style="color:#7bd88f">Return now</a>.</p>';
624
+ }
625
+
626
+ async function finishOAuth({ provider, state, code, errorParam, format }) {
627
+ // If provider wasn't in the URL query, try to extract it from the
628
+ // state prefix. Some OAuth providers (OpenRouter) don't forward
629
+ // query params from the callback_url, so the ?provider=openrouter
630
+ // param is lost. By encoding the provider as a prefix in the state
631
+ // (e.g. "openrouter:<random>"), we recover it.
632
+ if (!provider && typeof state === 'string' && state.includes(':')) {
633
+ const colonIdx = state.indexOf(':');
634
+ const candidate = state.slice(0, colonIdx);
635
+ if (auth.SUPPORTED_PROVIDERS.includes(candidate)) {
636
+ provider = candidate;
637
+ }
638
+ }
639
+ if (!provider || !auth.SUPPORTED_PROVIDERS.includes(provider)) {
640
+ return { status: 400, error: 'Unknown or missing provider', code: 'EBADPROVIDER' };
641
+ }
642
+ if (errorParam) {
643
+ return { status: 400, error: errorParam, code: 'EPROVIDER_ERROR' };
644
+ }
645
+ if (!state || !code) {
646
+ return { status: 400, error: 'Missing state or code', code: 'EBADINPUT' };
647
+ }
648
+ const pending = auth.consumePending(provider, state);
649
+ if (!pending) {
650
+ return { status: 400, error: 'No pending sign-in matches this state. Start the sign-in again from the app.', code: 'ENOPENDING' };
651
+ }
652
+ const exchange = auth.getExchange(provider);
653
+ if (!exchange) {
654
+ return { status: 501, error: 'Sign-in for ' + provider + ' is not configured in this build.', code: 'ENOEXCHANGE' };
655
+ }
656
+ let out;
657
+ try {
658
+ out = await exchange({ pending, code });
659
+ } catch (e) {
660
+ return { status: 500, error: e.message || 'exchange threw', code: e.code || 'EEXCHANGE' };
661
+ }
662
+ if (!out || out.error) {
663
+ return { status: 400, error: (out && out.error) || 'exchange returned no token', code: 'EEXCHANGE' };
664
+ }
665
+ try {
666
+ const blob = JSON.stringify({
667
+ accessToken: out.accessToken,
668
+ refreshToken: out.refreshToken || null,
669
+ expiresAt: out.expiresAt || null,
670
+ scope: out.scope || pending.scopes || null
671
+ });
672
+ const account = out.account || pending.accountHint || 'default';
673
+ await auth.setToken(provider, account, blob);
674
+ return { status: 200, account };
675
+ } catch (e) {
676
+ return { status: 500, error: e.message, code: e.code || 'EKEYRING' };
677
+ }
678
+ }
679
+
680
+ function xyToText(xy) {
681
+ const map = {
682
+ M: 'Modified', A: 'Added', D: 'Deleted', R: 'Renamed', C: 'Copied',
683
+ U: 'Unmerged', '?': 'Untracked', '!': 'Ignored', T: 'Type changed'
684
+ };
685
+ return map[xy[1] !== ' ' ? xy[1] : xy[0]] || 'Changed';
686
+ }
687
+
688
+ // firstStringValue() is shared from src/util.js.
689
+
690
+ module.exports = {
691
+ // constants
692
+ DEFAULT_PORT,
693
+ SESSION_COOKIE,
694
+ ACCESS_COOKIE,
695
+ WEB_DIR,
696
+ WEB_DIST,
697
+ CLIENT_SETTINGS_KEYS,
698
+ RESETTABLE_APP_KEYS,
699
+ MODEL_LIST_TTL_MS,
700
+ MODEL_LIST_TIMEOUT_MS,
701
+ // state
702
+ openSockets,
703
+ sseClients,
704
+ runningChats,
705
+ runningChatCancels,
706
+ accessAttempts,
707
+ liveChat,
708
+ store,
709
+ MODEL_LIST_CACHE,
710
+ modelListCacheKey,
711
+ // domain modules (re-exported so handlers + http-server share one instance)
712
+ settings,
713
+ projects,
714
+ ai,
715
+ auth,
716
+ accessAuth,
717
+ qr,
718
+ oauthAnthropic,
719
+ oauthCopilot,
720
+ oauthOpenRouter,
721
+ chats,
722
+ messages,
723
+ trace,
724
+ inspector,
725
+ inspectorProfiles,
726
+ prompts,
727
+ promptProfiles,
728
+ tags,
729
+ agentFiles,
730
+ agentSkills,
731
+ agentFeatures,
732
+ agents,
733
+ mcp,
734
+ usage,
735
+ push,
736
+ shellTool,
737
+ files,
738
+ // helpers
739
+ trackSocket,
740
+ destroyOpenSockets,
741
+ runningKey,
742
+ sendJSON,
743
+ connectionForClient,
744
+ modelForClient,
745
+ sanitizeClientEntries,
746
+ settingsForClient,
747
+ projectForClient,
748
+ handleSSE,
749
+ broadcast,
750
+ parseCookies,
751
+ requestOrigin,
752
+ normalizePublicOrigin,
753
+ expectedOrigin,
754
+ sessionCookie,
755
+ accessCookie,
756
+ requestHasBrowserOrigin,
757
+ authorizeBrowserRequest,
758
+ authorizeAccessRequest,
759
+ readJsonBody,
760
+ readJsonOr400,
761
+ resolveModel,
762
+ credHashFor,
763
+ credentialForProvider,
764
+ hashShort,
765
+ seedModelListCache,
766
+ accessRequestOrigin,
767
+ accessAttemptKey,
768
+ checkAccessAttempts,
769
+ recordAccessFailure,
770
+ clearAccessFailures,
771
+ publicAccessStatus,
772
+ htmlPage,
773
+ redirectMeta,
774
+ finishOAuth,
775
+ xyToText,
776
+ firstStringValue,
777
+ qs,
778
+ safeDecode,
779
+ errCodeToHttpStatus
780
+ };