clay-server 2.27.0-beta.8 → 2.27.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 (72) hide show
  1. package/README.md +10 -0
  2. package/lib/daemon-projects.js +164 -0
  3. package/lib/daemon.js +13 -126
  4. package/lib/mates-identity.js +132 -0
  5. package/lib/mates-knowledge.js +113 -0
  6. package/lib/mates-prompts.js +398 -0
  7. package/lib/mates.js +40 -599
  8. package/lib/project-connection.js +2 -0
  9. package/lib/project-debate.js +19 -12
  10. package/lib/project-http.js +4 -2
  11. package/lib/project-loop.js +110 -48
  12. package/lib/project-mate-interaction.js +4 -0
  13. package/lib/project-notifications.js +210 -0
  14. package/lib/project-sessions.js +5 -2
  15. package/lib/project-user-message.js +2 -1
  16. package/lib/project.js +26 -2
  17. package/lib/public/app.js +1193 -8521
  18. package/lib/public/css/command-palette.css +14 -0
  19. package/lib/public/css/loop.css +301 -0
  20. package/lib/public/css/notifications-center.css +190 -0
  21. package/lib/public/css/rewind.css +6 -0
  22. package/lib/public/index.html +89 -35
  23. package/lib/public/modules/app-connection.js +160 -0
  24. package/lib/public/modules/app-cursors.js +473 -0
  25. package/lib/public/modules/app-debate-ui.js +389 -0
  26. package/lib/public/modules/app-dm.js +627 -0
  27. package/lib/public/modules/app-favicon.js +212 -0
  28. package/lib/public/modules/app-header.js +229 -0
  29. package/lib/public/modules/app-home-hub.js +600 -0
  30. package/lib/public/modules/app-loop-ui.js +589 -0
  31. package/lib/public/modules/app-loop-wizard.js +439 -0
  32. package/lib/public/modules/app-messages.js +1560 -0
  33. package/lib/public/modules/app-misc.js +299 -0
  34. package/lib/public/modules/app-notifications.js +372 -0
  35. package/lib/public/modules/app-panels.js +888 -0
  36. package/lib/public/modules/app-projects.js +798 -0
  37. package/lib/public/modules/app-rate-limit.js +451 -0
  38. package/lib/public/modules/app-rendering.js +597 -0
  39. package/lib/public/modules/app-skills-install.js +234 -0
  40. package/lib/public/modules/command-palette.js +27 -4
  41. package/lib/public/modules/input.js +31 -20
  42. package/lib/public/modules/scheduler-config.js +1532 -0
  43. package/lib/public/modules/scheduler-history.js +79 -0
  44. package/lib/public/modules/scheduler.js +33 -1554
  45. package/lib/public/modules/session-search.js +13 -1
  46. package/lib/public/modules/sidebar-mates.js +812 -0
  47. package/lib/public/modules/sidebar-mobile.js +1269 -0
  48. package/lib/public/modules/sidebar-projects.js +1449 -0
  49. package/lib/public/modules/sidebar-sessions.js +986 -0
  50. package/lib/public/modules/sidebar.js +232 -4591
  51. package/lib/public/modules/store.js +27 -0
  52. package/lib/public/modules/ws-ref.js +7 -0
  53. package/lib/public/style.css +1 -0
  54. package/lib/sdk-bridge.js +96 -717
  55. package/lib/sdk-message-processor.js +587 -0
  56. package/lib/sdk-message-queue.js +42 -0
  57. package/lib/sdk-skill-discovery.js +131 -0
  58. package/lib/server-admin.js +712 -0
  59. package/lib/server-auth.js +737 -0
  60. package/lib/server-dm.js +221 -0
  61. package/lib/server-mates.js +281 -0
  62. package/lib/server-palette.js +110 -0
  63. package/lib/server-settings.js +479 -0
  64. package/lib/server-skills.js +280 -0
  65. package/lib/server.js +246 -2755
  66. package/lib/sessions.js +11 -4
  67. package/lib/users-auth.js +146 -0
  68. package/lib/users-permissions.js +118 -0
  69. package/lib/users-preferences.js +210 -0
  70. package/lib/users.js +48 -398
  71. package/lib/ws-schema.js +498 -0
  72. package/package.json +1 -1
package/lib/server.js CHANGED
@@ -1,15 +1,20 @@
1
1
  var http = require("http");
2
- var crypto = require("crypto");
3
2
  var fs = require("fs");
4
3
  var path = require("path");
5
4
  var { WebSocketServer } = require("ws");
6
- var { pinPageHtml, setupPageHtml, adminSetupPageHtml, multiUserLoginPageHtml, smtpLoginPageHtml, invitePageHtml, smtpInvitePageHtml, noProjectsPageHtml } = require("./pages");
5
+ var pages = require("./pages");
7
6
  var smtp = require("./smtp");
8
7
  var { createProjectContext } = require("./project");
9
8
  var users = require("./users");
10
9
  var dm = require("./dm");
11
10
  var mates = require("./mates");
12
- var sessionSearch = require("./session-search");
11
+ var serverAuth = require("./server-auth");
12
+ var serverSkills = require("./server-skills");
13
+ var serverDm = require("./server-dm");
14
+ var serverMates = require("./server-mates");
15
+ var serverAdmin = require("./server-admin");
16
+ var serverSettings = require("./server-settings");
17
+ var serverPalette = require("./server-palette");
13
18
 
14
19
  var { CONFIG_DIR } = require("./config");
15
20
  var { provisionLinuxUser } = require("./os-users");
@@ -21,23 +26,7 @@ var publicDir = path.join(__dirname, "public");
21
26
  var bundledThemesDir = path.join(__dirname, "themes");
22
27
  var userThemesDir = path.join(CONFIG_DIR, "themes");
23
28
 
24
- // --- Skills proxy cache & helpers ---
25
- var skillsCache = {};
26
-
27
- function httpGet(url) {
28
- return new Promise(function (resolve, reject) {
29
- var mod = url.startsWith("https") ? https : http;
30
- mod.get(url, { headers: { "User-Agent": "Clay/1.0" } }, function (resp) {
31
- if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) {
32
- return httpGet(resp.headers.location).then(resolve, reject);
33
- }
34
- var chunks = [];
35
- resp.on("data", function (c) { chunks.push(c); });
36
- resp.on("end", function () { resolve(Buffer.concat(chunks).toString("utf8")); });
37
- resp.on("error", reject);
38
- }).on("error", reject);
39
- });
40
- }
29
+ // --- HTTP helpers (used by skills proxy and extension download) ---
41
30
 
42
31
  function httpGetBinary(url) {
43
32
  return new Promise(function (resolve, reject) {
@@ -57,135 +46,6 @@ function httpGetBinary(url) {
57
46
  });
58
47
  }
59
48
 
60
- function fetchSkillsPage(url) {
61
- return httpGet(url).then(function (html) {
62
- // Data is inside self.__next_f.push() with escaped quotes: \"initialSkills\":[{\"source\":...}]
63
- var marker = 'initialSkills';
64
- var idx = html.indexOf(marker);
65
- if (idx < 0) return { skills: [] };
66
-
67
- // Find the start of the array: look for \\\":[
68
- var arrStart = html.indexOf(':[', idx);
69
- if (arrStart < 0) return { skills: [] };
70
- arrStart += 1; // point to '['
71
-
72
- // Find matching ']' — track bracket depth
73
- var depth = 0;
74
- var arrEnd = -1;
75
- for (var i = arrStart; i < html.length; i++) {
76
- var ch = html[i];
77
- if (ch === '[') depth++;
78
- else if (ch === ']') {
79
- depth--;
80
- if (depth === 0) { arrEnd = i + 1; break; }
81
- }
82
- }
83
- if (arrEnd < 0) return { skills: [] };
84
-
85
- var raw = html.substring(arrStart, arrEnd);
86
- // Unescape: \\\" → " and \\\\ → backslash
87
- var unescaped = raw.replace(/\\\\"/g, '__BSLASH_QUOTE__').replace(/\\"/g, '"').replace(/__BSLASH_QUOTE__/g, '\\"');
88
-
89
- try {
90
- return { skills: JSON.parse(unescaped) };
91
- } catch (e) {
92
- return { skills: [] };
93
- }
94
- });
95
- }
96
-
97
- function fetchSkillDetail(url) {
98
- return httpGet(url).then(function (html) {
99
- var result = {};
100
-
101
- // Title: "skill-name by owner/repo"
102
- var titleMatch = html.match(/<title>([^<]+)<\/title>/);
103
- if (titleMatch) {
104
- var parts = titleMatch[1].split(" by ");
105
- result.name = parts[0].trim();
106
- }
107
-
108
- // Description from meta
109
- var descMatch = html.match(/meta name="description" content="([^"]+)"/);
110
- if (descMatch) result.description = descMatch[1];
111
-
112
- // Install command
113
- var cmdMatch = html.match(/npx skills add [^ ]+ --skill [^ "<]+/);
114
- if (cmdMatch) result.command = cmdMatch[0];
115
-
116
- // Weekly installs: "Weekly Installs</span></div><div ...>VALUE</div>"
117
- var wiMatch = html.match(/Weekly Installs<\/span><\/div><div[^>]*>([\d,.]+K?)<\/div>/);
118
- if (wiMatch) result.weeklyInstalls = wiMatch[1];
119
-
120
- // GitHub Stars: after SVG icon, inside <span>X.XK</span>
121
- var gsIdx = html.indexOf("GitHub Stars");
122
- if (gsIdx > 0) {
123
- var gsRegion = html.substring(gsIdx, gsIdx + 1000);
124
- var gsVal = gsRegion.match(/<span>(\d[\d,.]*K?)<\/span>/);
125
- if (gsVal) result.githubStars = gsVal[1];
126
- }
127
-
128
- // First Seen
129
- var fsMatch = html.match(/First Seen<\/span><\/div><div[^>]*>([^<]+)<\/div>/);
130
- if (fsMatch) result.firstSeen = fsMatch[1].trim();
131
-
132
- // Repository: from title "by owner/repo"
133
- if (titleMatch) {
134
- var byParts = titleMatch[1].split(" by ");
135
- if (byParts[1]) result.repository = byParts[1].trim();
136
- }
137
-
138
- // Security audits: "text-foreground truncate">NAME</span><span ...>STATUS</span>"
139
- var audits = [];
140
- var auditRegex = /class="text-sm font-medium text-foreground truncate">([^<]+)<\/span><span class="[^"]*">(\w+)<\/span>/g;
141
- var am;
142
- while ((am = auditRegex.exec(html)) !== null) {
143
- audits.push({ name: am[1], status: am[2].toLowerCase() });
144
- }
145
- if (audits.length) result.audits = audits;
146
-
147
- // Installed on: "text-foreground">NAME</span><span class="text-muted-foreground font-mono">COUNT</span>
148
- var ioIdx = html.indexOf("Installed On");
149
- if (ioIdx > 0) {
150
- var ioRegion = html.substring(ioIdx, ioIdx + 3000);
151
- var platforms = [];
152
- var platRegex = /text-foreground">([^<]+)<\/span><span class="text-muted-foreground font-mono">([\d,.]+K?)<\/span>/g;
153
- var pm;
154
- while ((pm = platRegex.exec(ioRegion)) !== null) {
155
- platforms.push({ name: pm[1], installs: pm[2] });
156
- }
157
- if (platforms.length) result.installedOn = platforms;
158
- }
159
-
160
- // SKILL.md content: rendered HTML inside the main content area
161
- var skillMdIdx = html.indexOf("SKILL.md");
162
- if (skillMdIdx > 0) {
163
- // Find the prose content div after SKILL.md marker
164
- var proseIdx = html.indexOf("prose", skillMdIdx);
165
- if (proseIdx > 0) {
166
- var proseStart = html.indexOf(">", proseIdx) + 1;
167
- // Find the closing of the prose div (heuristic: next major section boundary)
168
- var endMarkers = ["<div class=\"bg-background", "<div class=\"sticky"];
169
- var proseEnd = html.length;
170
- for (var em = 0; em < endMarkers.length; em++) {
171
- var endIdx = html.indexOf(endMarkers[em], proseStart);
172
- if (endIdx > 0 && endIdx < proseEnd) proseEnd = endIdx;
173
- }
174
- var rawMd = html.substring(proseStart, proseEnd);
175
- // Rebase relative URLs to absolute (skills.sh base)
176
- result.skillMd = rawMd
177
- .replace(/src="(?!https?:\/\/|data:)([^"]+)"/g, function (m, p) {
178
- return 'src="' + new URL(p, url).href + '"';
179
- })
180
- .replace(/href="(?!https?:\/\/|mailto:|#)([^"]+)"/g, function (m, p) {
181
- return 'href="' + new URL(p, url).href + '"';
182
- });
183
- }
184
- }
185
-
186
- return result;
187
- });
188
- }
189
49
 
190
50
  var MIME_TYPES = {
191
51
  ".html": "text/html",
@@ -202,134 +62,8 @@ var MIME_TYPES = {
202
62
  ".ico": "image/x-icon",
203
63
  };
204
64
 
205
- function generateAuthToken(pin) {
206
- var salt = crypto.randomBytes(16).toString("hex");
207
- var hash = crypto.scryptSync("clay:" + pin, salt, 64).toString("hex");
208
- return salt + ":" + hash;
209
- }
210
-
211
- function verifyPin(pin, storedHash) {
212
- if (!storedHash) return false;
213
- // New scrypt format: salt_hex:hash_hex (contains colon)
214
- if (storedHash.indexOf(":") !== -1) {
215
- var parts = storedHash.split(":");
216
- var salt = parts[0];
217
- var hash = parts[1];
218
- var derived = crypto.scryptSync("clay:" + pin, salt, 64).toString("hex");
219
- return crypto.timingSafeEqual(Buffer.from(derived, "hex"), Buffer.from(hash, "hex"));
220
- }
221
- // Legacy SHA256 format (no colon)
222
- var legacyHash = crypto.createHash("sha256").update("clay:" + pin).digest("hex");
223
- var match = crypto.timingSafeEqual(Buffer.from(legacyHash, "hex"), Buffer.from(storedHash, "hex"));
224
- return match;
225
- }
226
-
227
- function parseCookies(req) {
228
- var cookies = {};
229
- var header = req.headers.cookie || "";
230
- header.split(";").forEach(function (part) {
231
- var pair = part.trim().split("=");
232
- if (pair.length === 2) cookies[pair[0]] = pair[1];
233
- });
234
- return cookies;
235
- }
236
-
237
- function isAuthed(req, authToken) {
238
- if (!authToken) return true;
239
- var cookies = parseCookies(req);
240
- return cookies["relay_auth"] === authToken;
241
- }
242
-
243
- // --- Multi-user auth helpers ---
244
- // Multi-user auth tokens: persisted to disk so they survive restarts
245
- var _isDevMode = require("./config").isDevMode;
246
- var TOKENS_FILE = path.join(CONFIG_DIR, _isDevMode ? "auth-tokens-dev.json" : "auth-tokens.json");
247
- var MULTI_USER_COOKIE = _isDevMode ? "relay_auth_user_dev" : "relay_auth_user";
248
- var multiUserTokens = {}; // token → userId
249
-
250
- function loadTokens() {
251
- try {
252
- var raw = fs.readFileSync(TOKENS_FILE, "utf8");
253
- var data = JSON.parse(raw);
254
- if (data && typeof data === "object") {
255
- multiUserTokens = data;
256
- }
257
- } catch (e) {
258
- multiUserTokens = {};
259
- }
260
- }
261
-
262
- function saveTokens() {
263
- try {
264
- fs.mkdirSync(path.dirname(TOKENS_FILE), { recursive: true });
265
- var tmpPath = TOKENS_FILE + ".tmp";
266
- fs.writeFileSync(tmpPath, JSON.stringify(multiUserTokens));
267
- fs.renameSync(tmpPath, TOKENS_FILE);
268
- } catch (e) {}
269
- }
270
-
271
- loadTokens();
272
-
273
- function createMultiUserSession(userId, tlsOptions) {
274
- var token = users.generateUserAuthToken(userId);
275
- multiUserTokens[token] = userId;
276
- saveTokens();
277
- var cookie = MULTI_USER_COOKIE + "=" + token + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : "");
278
- return { token: token, cookie: cookie };
279
- }
280
-
281
- function getMultiUserFromReq(req) {
282
- var cookies = parseCookies(req);
283
- var token = cookies[MULTI_USER_COOKIE];
284
- if (!token) return null;
285
- var userId = multiUserTokens[token];
286
- if (!userId) return null;
287
- var user = users.findUserById(userId);
288
- return user || null;
289
- }
290
-
291
- function isMultiUserAuthed(req) {
292
- return !!getMultiUserFromReq(req);
293
- }
294
-
295
- function revokeUserTokens(userId) {
296
- var changed = false;
297
- for (var token in multiUserTokens) {
298
- if (multiUserTokens[token] === userId) {
299
- delete multiUserTokens[token];
300
- changed = true;
301
- }
302
- }
303
- if (changed) saveTokens();
304
- }
305
-
306
- // --- PIN rate limiting ---
307
- var pinAttempts = {}; // ip → { count, lastAttempt }
308
- var PIN_MAX_ATTEMPTS = 5;
309
- var PIN_LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
310
-
311
- function checkPinRateLimit(ip) {
312
- var entry = pinAttempts[ip];
313
- if (!entry) return null;
314
- if (entry.count >= PIN_MAX_ATTEMPTS) {
315
- var elapsed = Date.now() - entry.lastAttempt;
316
- if (elapsed < PIN_LOCKOUT_MS) {
317
- return Math.ceil((PIN_LOCKOUT_MS - elapsed) / 1000);
318
- }
319
- delete pinAttempts[ip];
320
- }
321
- return null;
322
- }
323
-
324
- function recordPinFailure(ip) {
325
- if (!pinAttempts[ip]) pinAttempts[ip] = { count: 0, lastAttempt: 0 };
326
- pinAttempts[ip].count++;
327
- pinAttempts[ip].lastAttempt = Date.now();
328
- }
329
-
330
- function clearPinFailures(ip) {
331
- delete pinAttempts[ip];
332
- }
65
+ var generateAuthToken = serverAuth.generateAuthToken;
66
+ var verifyPin = serverAuth.verifyPin;
333
67
 
334
68
  function serveStatic(urlPath, res) {
335
69
  if (urlPath === "/") urlPath = "/index.html";
@@ -426,118 +160,71 @@ function createServer(opts) {
426
160
  var onUserDeleted = opts.onUserDeleted || null;
427
161
  var getRemovedProjects = opts.getRemovedProjects || function () { return []; };
428
162
 
429
- var authToken = pinHash || null;
430
-
431
- // --- Admin password recovery (in-memory, one-time) ---
432
- var recovery = null; // { urlPath, password }
433
-
434
- function setRecovery(urlPath, password) {
435
- recovery = { urlPath: urlPath, password: password };
436
- }
437
-
438
- function clearRecovery() {
439
- recovery = null;
440
- }
441
-
442
- function recoveryPageHtml() {
443
- return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
444
- + '<title>Admin Password Recovery</title>'
445
- + '<style>'
446
- + '*{margin:0;padding:0;box-sizing:border-box}'
447
- + 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0a0a0a;color:#e5e5e5;display:flex;align-items:center;justify-content:center;min-height:100vh}'
448
- + '.card{background:#171717;border:1px solid #262626;border-radius:12px;padding:32px;width:100%;max-width:380px}'
449
- + 'h1{font-size:18px;font-weight:600;margin-bottom:4px}'
450
- + '.sub{font-size:13px;color:#737373;margin-bottom:24px}'
451
- + 'label{display:block;font-size:13px;color:#a3a3a3;margin-bottom:6px}'
452
- + 'input{width:100%;padding:10px 12px;background:#0a0a0a;border:1px solid #333;border-radius:8px;color:#e5e5e5;font-size:14px;outline:none;margin-bottom:16px}'
453
- + 'input:focus{border-color:#7c3aed}'
454
- + 'button{width:100%;padding:10px;background:#7c3aed;color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer}'
455
- + 'button:hover{background:#6d28d9}'
456
- + 'button:disabled{opacity:.5;cursor:not-allowed}'
457
- + '.error{color:#ef4444;font-size:13px;margin-bottom:12px;display:none}'
458
- + '.success{text-align:center;color:#22c55e;font-size:15px}'
459
- + '.hidden{display:none}'
460
- + '</style></head><body>'
461
- + '<div class="card">'
462
- + '<div id="step-verify">'
463
- + '<h1>Admin Recovery</h1>'
464
- + '<p class="sub">Enter the recovery password shown in your terminal.</p>'
465
- + '<div id="err-verify" class="error"></div>'
466
- + '<label for="recovery-pw">Recovery password</label>'
467
- + '<input id="recovery-pw" type="text" autocomplete="off" spellcheck="false" autofocus>'
468
- + '<button id="btn-verify">Verify</button>'
469
- + '</div>'
470
- + '<div id="step-reset" class="hidden">'
471
- + '<h1>Reset Admin PIN</h1>'
472
- + '<p class="sub">Enter a new 6-digit PIN for the admin account.</p>'
473
- + '<div id="err-reset" class="error"></div>'
474
- + '<label for="new-pin">New PIN</label>'
475
- + '<input id="new-pin" type="password" maxlength="6" pattern="\\d{6}" inputmode="numeric" placeholder="6 digits">'
476
- + '<label for="confirm-pin">Confirm PIN</label>'
477
- + '<input id="confirm-pin" type="password" maxlength="6" pattern="\\d{6}" inputmode="numeric" placeholder="6 digits">'
478
- + '<button id="btn-reset">Reset PIN</button>'
479
- + '</div>'
480
- + '<div id="step-done" class="hidden">'
481
- + '<p class="success">PIN has been reset successfully. You can now log in with your new PIN.</p>'
482
- + '</div>'
483
- + '</div>'
484
- + '<script>'
485
- + 'var pw="";\n'
486
- + 'document.getElementById("btn-verify").onclick=function(){\n'
487
- + ' var el=document.getElementById("recovery-pw");\n'
488
- + ' pw=el.value.trim();\n'
489
- + ' if(!pw)return;\n'
490
- + ' this.disabled=true;\n'
491
- + ' fetch(location.pathname,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({step:"verify",password:pw})})\n'
492
- + ' .then(function(r){return r.json()}).then(function(d){\n'
493
- + ' if(d.ok){document.getElementById("step-verify").classList.add("hidden");document.getElementById("step-reset").classList.remove("hidden");document.getElementById("new-pin").focus()}\n'
494
- + ' else{var e=document.getElementById("err-verify");e.textContent=d.error||"Invalid password";e.style.display="block";document.getElementById("btn-verify").disabled=false}\n'
495
- + ' }).catch(function(){document.getElementById("btn-verify").disabled=false})\n'
496
- + '};\n'
497
- + 'document.getElementById("recovery-pw").addEventListener("keydown",function(e){if(e.key==="Enter")document.getElementById("btn-verify").click()});\n'
498
- + 'document.getElementById("btn-reset").onclick=function(){\n'
499
- + ' var pin=document.getElementById("new-pin").value;\n'
500
- + ' var confirm=document.getElementById("confirm-pin").value;\n'
501
- + ' var errEl=document.getElementById("err-reset");\n'
502
- + ' if(!/^\\d{6}$/.test(pin)){errEl.textContent="PIN must be exactly 6 digits";errEl.style.display="block";return}\n'
503
- + ' if(pin!==confirm){errEl.textContent="PINs do not match";errEl.style.display="block";return}\n'
504
- + ' this.disabled=true;errEl.style.display="none";\n'
505
- + ' fetch(location.pathname,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({step:"reset",password:pw,pin:pin})})\n'
506
- + ' .then(function(r){return r.json()}).then(function(d){\n'
507
- + ' if(d.ok){document.getElementById("step-reset").classList.add("hidden");document.getElementById("step-done").classList.remove("hidden")}\n'
508
- + ' else{errEl.textContent=d.error||"Failed";errEl.style.display="block";document.getElementById("btn-reset").disabled=false}\n'
509
- + ' }).catch(function(){document.getElementById("btn-reset").disabled=false})\n'
510
- + '};\n'
511
- + 'document.getElementById("confirm-pin").addEventListener("keydown",function(e){if(e.key==="Enter")document.getElementById("btn-reset").click()});\n'
512
- + '</script></body></html>';
513
- }
163
+ // --- Auth module ---
164
+ var auth = serverAuth.attachAuth({
165
+ users: users,
166
+ smtp: smtp,
167
+ pages: pages,
168
+ tlsOptions: tlsOptions,
169
+ osUsers: osUsers,
170
+ pinHash: pinHash,
171
+ provisionLinuxUser: provisionLinuxUser,
172
+ onUpgradePin: onUpgradePin,
173
+ onUserProvisioned: onUserProvisioned,
174
+ });
175
+ var getMultiUserFromReq = auth.getMultiUserFromReq;
176
+ var isRequestAuthed = auth.isRequestAuthed;
177
+ var parseCookies = auth.parseCookies;
514
178
 
515
179
  var realVersion = require("../package.json").version;
516
180
  var currentVersion = debug ? "0.0.9" : realVersion;
517
181
 
518
182
  var caContent = caPath ? (function () { try { return fs.readFileSync(caPath); } catch (e) { return null; } })() : null;
519
- var pinPage = pinPageHtml();
520
- var adminSetupPage = adminSetupPageHtml();
521
- var loginPage = multiUserLoginPageHtml();
522
- var smtpLoginPage = smtpLoginPageHtml();
523
-
524
- // Multi-user auth: determine which page to show for unauthenticated requests
525
- function getAuthPage() {
526
- if (!users.isMultiUser()) return pinPage;
527
- if (!users.hasAdmin()) return adminSetupPage;
528
- if (smtp.isEmailLoginEnabled()) return smtpLoginPage;
529
- return loginPage;
530
- }
531
-
532
- // Unified auth check: works in both single-user and multi-user mode
533
- function isRequestAuthed(req) {
534
- if (users.isMultiUser()) return isMultiUserAuthed(req);
535
- return isAuthed(req, authToken);
536
- }
537
183
 
538
184
  // --- Project registry ---
539
185
  var projects = new Map(); // slug → projectContext
540
186
 
187
+ // --- Admin module ---
188
+ var admin = serverAdmin.attachAdmin({
189
+ users: users,
190
+ smtp: smtp,
191
+ getMultiUserFromReq: getMultiUserFromReq,
192
+ projects: projects,
193
+ osUsers: osUsers,
194
+ tlsOptions: tlsOptions,
195
+ portNum: portNum,
196
+ provisionLinuxUser: provisionLinuxUser,
197
+ onUserProvisioned: onUserProvisioned,
198
+ onUserDeleted: onUserDeleted,
199
+ revokeUserTokens: auth.revokeUserTokens,
200
+ onSetProjectVisibility: onSetProjectVisibility,
201
+ onSetProjectAllowedUsers: onSetProjectAllowedUsers,
202
+ onGetProjectAccess: onGetProjectAccess,
203
+ onProjectOwnerChanged: onProjectOwnerChanged,
204
+ });
205
+
206
+ var skills = serverSkills.attachSkills({
207
+ users: users,
208
+ osUsers: osUsers,
209
+ getMultiUserFromReq: getMultiUserFromReq,
210
+ });
211
+
212
+ var settings = serverSettings.attachSettings({
213
+ users: users,
214
+ mates: mates,
215
+ getMultiUserFromReq: getMultiUserFromReq,
216
+ projects: projects,
217
+ opts: opts,
218
+ CONFIG_DIR: CONFIG_DIR,
219
+ });
220
+
221
+ var palette = serverPalette.attachPalette({
222
+ users: users,
223
+ projects: projects,
224
+ getMultiUserFromReq: getMultiUserFromReq,
225
+ onGetProjectAccess: onGetProjectAccess,
226
+ });
227
+
541
228
  // --- Push module (global) ---
542
229
  var pushModule = null;
543
230
  try {
@@ -545,6 +232,13 @@ function createServer(opts) {
545
232
  pushModule = initPush();
546
233
  } catch (e) {}
547
234
 
235
+ // --- Notifications module (global singleton, shared by all projects) ---
236
+ var { attachNotifications: _attachNotifications } = require("./project-notifications");
237
+ var _globalNotifications = _attachNotifications({
238
+ broadcastAll: function (msg) { broadcastAll(msg); },
239
+ pushModule: pushModule,
240
+ });
241
+
548
242
  // --- Security headers ---
549
243
  var securityHeaders = {
550
244
  "X-Content-Type-Options": "nosniff",
@@ -567,546 +261,88 @@ function createServer(opts) {
567
261
  setSecurityHeaders(res);
568
262
  var fullUrl = req.url.split("?")[0];
569
263
 
570
- // --- Admin password recovery ---
571
- if (recovery && fullUrl === "/recover/" + recovery.urlPath) {
572
- if (req.method === "GET") {
573
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
574
- res.end(recoveryPageHtml());
575
- return;
576
- }
577
- if (req.method === "POST") {
578
- var ip = req.socket.remoteAddress || "";
579
- var remaining = checkPinRateLimit(ip);
580
- if (remaining !== null) {
581
- res.writeHead(429, { "Content-Type": "application/json" });
582
- res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
583
- return;
584
- }
585
- var body = "";
586
- req.on("data", function (chunk) { body += chunk; });
587
- req.on("end", function () {
588
- try {
589
- var data = JSON.parse(body);
590
- if (data.step === "verify") {
591
- if (!data.password || data.password !== recovery.password) {
592
- recordPinFailure(ip);
593
- res.writeHead(401, { "Content-Type": "application/json" });
594
- res.end('{"error":"Invalid recovery password"}');
595
- return;
596
- }
597
- clearPinFailures(ip);
598
- res.writeHead(200, { "Content-Type": "application/json" });
599
- res.end('{"ok":true}');
600
- } else if (data.step === "reset") {
601
- if (!data.password || data.password !== recovery.password) {
602
- res.writeHead(401, { "Content-Type": "application/json" });
603
- res.end('{"error":"Invalid recovery password"}');
604
- return;
605
- }
606
- if (!data.pin || !/^\d{6}$/.test(data.pin)) {
607
- res.writeHead(400, { "Content-Type": "application/json" });
608
- res.end('{"error":"PIN must be exactly 6 digits"}');
609
- return;
610
- }
611
- var admin = users.findAdmin();
612
- if (!admin) {
613
- res.writeHead(400, { "Content-Type": "application/json" });
614
- res.end('{"error":"No admin account found"}');
615
- return;
616
- }
617
- users.updateUserPin(admin.id, data.pin);
618
- recovery = null;
619
- res.writeHead(200, { "Content-Type": "application/json" });
620
- res.end('{"ok":true}');
621
- } else {
622
- res.writeHead(400, { "Content-Type": "application/json" });
623
- res.end('{"error":"Invalid step"}');
624
- }
625
- } catch (e) {
626
- res.writeHead(400, { "Content-Type": "application/json" });
627
- res.end('{"error":"Invalid request"}');
628
- }
629
- });
630
- return;
631
- }
264
+ // --- Auth routes (delegated to server-auth) ---
265
+ if (auth.handleRequest(req, res, fullUrl)) return;
266
+ // CA certificate download
267
+ if (req.url === "/ca/download" && req.method === "GET" && caContent) {
268
+ res.writeHead(200, {
269
+ "Content-Type": "application/x-pem-file",
270
+ "Content-Disposition": 'attachment; filename="clay-ca.pem"',
271
+ });
272
+ res.end(caContent);
273
+ return;
632
274
  }
633
275
 
634
- // Global auth endpoint
635
- if (req.method === "POST" && req.url === "/auth") {
636
- var ip = req.socket.remoteAddress || "";
637
- var remaining = checkPinRateLimit(ip);
638
- if (remaining !== null) {
639
- res.writeHead(429, { "Content-Type": "application/json" });
640
- res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
276
+ // Chrome extension download (proxy from GitHub)
277
+ if (fullUrl === "/api/extension/download" && req.method === "GET") {
278
+ if (!isRequestAuthed(req)) {
279
+ res.writeHead(401, { "Content-Type": "application/json" });
280
+ res.end(JSON.stringify({ error: "Unauthorized" }));
641
281
  return;
642
282
  }
643
- var body = "";
644
- req.on("data", function (chunk) { body += chunk; });
645
- req.on("end", function () {
646
- try {
647
- var data = JSON.parse(body);
648
- if (authToken && verifyPin(data.pin, authToken)) {
649
- clearPinFailures(ip);
650
- // Auto-upgrade legacy SHA256 hash to scrypt
651
- if (authToken.indexOf(":") === -1) {
652
- var upgraded = generateAuthToken(data.pin);
653
- authToken = upgraded;
654
- if (typeof onUpgradePin === "function") {
655
- onUpgradePin(upgraded);
656
- }
657
- }
658
- res.writeHead(200, {
659
- "Set-Cookie": "relay_auth=" + authToken + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : ""),
660
- "Content-Type": "application/json",
661
- });
662
- res.end('{"ok":true}');
663
- } else {
664
- recordPinFailure(ip);
665
- var attemptsLeft = PIN_MAX_ATTEMPTS - (pinAttempts[ip] ? pinAttempts[ip].count : 0);
666
- res.writeHead(401, { "Content-Type": "application/json" });
667
- res.end(JSON.stringify({ ok: false, attemptsLeft: Math.max(attemptsLeft, 0) }));
668
- }
669
- } catch (e) {
670
- res.writeHead(400);
671
- res.end("Bad request");
672
- }
283
+ var archiveUrl = "https://github.com/chadbyte/clay-chrome/archive/refs/heads/main.zip";
284
+ httpGetBinary(archiveUrl).then(function (buf) {
285
+ res.writeHead(200, {
286
+ "Content-Type": "application/zip",
287
+ "Content-Disposition": 'attachment; filename="clay-chrome-extension.zip"',
288
+ "Content-Length": buf.length,
289
+ });
290
+ res.end(buf);
291
+ }).catch(function (err) {
292
+ res.writeHead(502, { "Content-Type": "application/json" });
293
+ res.end(JSON.stringify({ error: "Failed to download extension: " + (err.message || "unknown error") }));
673
294
  });
674
295
  return;
675
296
  }
676
297
 
677
- // --- Multi-user auth endpoints ---
678
-
679
- // Admin setup (first-time multi-user setup)
680
- if (req.method === "POST" && fullUrl === "/auth/setup") {
681
- if (!users.isMultiUser()) {
682
- res.writeHead(400, { "Content-Type": "application/json" });
683
- res.end('{"error":"Multi-user mode is not enabled"}');
684
- return;
685
- }
686
- if (users.hasAdmin()) {
687
- res.writeHead(400, { "Content-Type": "application/json" });
688
- res.end('{"error":"Admin already exists"}');
689
- return;
690
- }
691
- var body = "";
692
- req.on("data", function (chunk) { body += chunk; });
693
- req.on("end", function () {
694
- try {
695
- var data = JSON.parse(body);
696
- if (!users.validateSetupCode(data.setupCode)) {
697
- res.writeHead(401, { "Content-Type": "application/json" });
698
- res.end('{"error":"Invalid setup code"}');
699
- return;
700
- }
701
- if (!data.username || data.username.trim().length < 1 || data.username.length > 100) {
702
- res.writeHead(400, { "Content-Type": "application/json" });
703
- res.end('{"error":"Username is required"}');
704
- return;
705
- }
706
- if (!data.pin || !/^\d{6}$/.test(data.pin)) {
707
- res.writeHead(400, { "Content-Type": "application/json" });
708
- res.end('{"error":"PIN must be exactly 6 digits"}');
709
- return;
710
- }
711
- // Migrate existing profile.json to admin profile
712
- var adminProfile = undefined;
713
- try {
714
- var existingProfile = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, "profile.json"), "utf8"));
715
- adminProfile = {
716
- name: data.displayName || data.username,
717
- lang: existingProfile.lang || "en-US",
718
- avatarColor: existingProfile.avatarColor || "#7c3aed",
719
- avatarStyle: existingProfile.avatarStyle || "thumbs",
720
- avatarSeed: existingProfile.avatarSeed || crypto.randomBytes(4).toString("hex"),
721
- };
722
- } catch (e) {}
723
- var result = users.createAdmin({
724
- username: data.username.trim(),
725
- displayName: data.displayName || data.username.trim(),
726
- pin: data.pin,
727
- profile: adminProfile,
728
- });
729
- if (result.error) {
730
- res.writeHead(400, { "Content-Type": "application/json" });
731
- res.end(JSON.stringify({ error: result.error }));
732
- return;
733
- }
734
- // Auto-provision Linux account if OS users mode is enabled
735
- if (osUsers && !result.user.linuxUser) {
736
- var provision = provisionLinuxUser(result.user.username);
737
- if (provision.ok) {
738
- users.updateLinuxUser(result.user.id, provision.linuxUser);
739
- if (onUserProvisioned) onUserProvisioned(result.user.id, provision.linuxUser);
740
- }
741
- }
742
- users.clearSetupCode();
743
- var session = createMultiUserSession(result.user.id, tlsOptions);
744
- res.writeHead(200, {
745
- "Set-Cookie": session.cookie,
746
- "Content-Type": "application/json",
747
- });
748
- res.end(JSON.stringify({ ok: true, user: { id: result.user.id, username: result.user.username, role: result.user.role } }));
749
- } catch (e) {
750
- res.writeHead(400, { "Content-Type": "application/json" });
751
- res.end('{"error":"Invalid request"}');
752
- }
298
+ // CORS preflight for cross-origin requests (HTTP onboarding → HTTPS)
299
+ if (req.method === "OPTIONS") {
300
+ res.writeHead(204, {
301
+ "Access-Control-Allow-Origin": "*",
302
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
303
+ "Access-Control-Allow-Headers": "Content-Type",
304
+ "Access-Control-Max-Age": "86400",
753
305
  });
306
+ res.end();
754
307
  return;
755
308
  }
756
309
 
757
- // Multi-user login
758
- if (req.method === "POST" && fullUrl === "/auth/login") {
759
- if (!users.isMultiUser()) {
760
- res.writeHead(400, { "Content-Type": "application/json" });
761
- res.end('{"error":"Multi-user mode is not enabled"}');
762
- return;
763
- }
764
- var ip = req.socket.remoteAddress || "";
765
- var remaining = checkPinRateLimit(ip);
766
- if (remaining !== null) {
767
- res.writeHead(429, { "Content-Type": "application/json" });
768
- res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
769
- return;
770
- }
771
- var body = "";
772
- req.on("data", function (chunk) { body += chunk; });
773
- req.on("end", function () {
774
- try {
775
- var data = JSON.parse(body);
776
- var user = users.authenticateUser(data.username, data.pin);
777
- if (!user) {
778
- recordPinFailure(ip);
779
- var attemptsLeft = PIN_MAX_ATTEMPTS - (pinAttempts[ip] ? pinAttempts[ip].count : 0);
780
- res.writeHead(401, { "Content-Type": "application/json" });
781
- res.end(JSON.stringify({ ok: false, error: "Invalid username or PIN", attemptsLeft: Math.max(attemptsLeft, 0) }));
782
- return;
783
- }
784
- clearPinFailures(ip);
785
- var session = createMultiUserSession(user.id, tlsOptions);
786
- var loginResp = { ok: true, user: { id: user.id, username: user.username, role: user.role } };
787
- if (user.mustChangePin) loginResp.mustChangePin = true;
788
- res.writeHead(200, {
789
- "Set-Cookie": session.cookie,
790
- "Content-Type": "application/json",
791
- });
792
- res.end(JSON.stringify(loginResp));
793
- } catch (e) {
794
- res.writeHead(400, { "Content-Type": "application/json" });
795
- res.end('{"error":"Invalid request"}');
796
- }
310
+ // Setup page
311
+ if (fullUrl === "/setup" && req.method === "GET") {
312
+ var host = req.headers.host || "localhost";
313
+ var hostname = host.split(":")[0];
314
+ var protocol = tlsOptions ? "https" : "http";
315
+ var setupUrl = protocol + "://" + hostname + ":" + portNum;
316
+ var lanMode = /[?&]mode=lan/.test(req.url);
317
+ res.writeHead(200, {
318
+ "Content-Type": "text/html; charset=utf-8",
319
+ "Access-Control-Allow-Origin": "*",
797
320
  });
321
+ res.end(pages.setupPageHtml(setupUrl, setupUrl, !!caContent, lanMode));
798
322
  return;
799
323
  }
800
324
 
801
- // Request OTP code (SMTP login)
802
- if (req.method === "POST" && fullUrl === "/auth/request-otp") {
803
- if (!users.isMultiUser() || !smtp.isEmailLoginEnabled()) {
804
- res.writeHead(400, { "Content-Type": "application/json" });
805
- res.end('{"error":"OTP login not available"}');
806
- return;
807
- }
808
- var ip = req.socket.remoteAddress || "";
809
- var remaining = checkPinRateLimit(ip);
810
- if (remaining !== null) {
811
- res.writeHead(429, { "Content-Type": "application/json" });
812
- res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
813
- return;
814
- }
815
- var body = "";
816
- req.on("data", function (chunk) { body += chunk; });
817
- req.on("end", function () {
818
- try {
819
- var data = JSON.parse(body);
820
- if (!data.email) {
821
- res.writeHead(400, { "Content-Type": "application/json" });
822
- res.end('{"error":"Email is required"}');
823
- return;
824
- }
825
- var user = users.findUserByEmail(data.email);
826
- if (!user) {
827
- // Don't reveal whether user exists — still say ok
828
- res.writeHead(200, { "Content-Type": "application/json" });
829
- res.end('{"ok":true}');
830
- return;
831
- }
832
- var result = smtp.requestOtp(data.email);
833
- if (result.error) {
834
- res.writeHead(429, { "Content-Type": "application/json" });
835
- res.end(JSON.stringify({ error: result.error }));
836
- return;
837
- }
838
- smtp.sendOtpEmail(data.email, result.code).then(function () {
839
- res.writeHead(200, { "Content-Type": "application/json" });
840
- res.end('{"ok":true}');
841
- }).catch(function () {
842
- res.writeHead(500, { "Content-Type": "application/json" });
843
- res.end('{"error":"Failed to send email"}');
844
- });
845
- } catch (e) {
846
- res.writeHead(400, { "Content-Type": "application/json" });
847
- res.end('{"error":"Invalid request"}');
848
- }
325
+ // PWA install guide (builtin cert mode, no CA step needed)
326
+ if (fullUrl === "/pwa" && req.method === "GET") {
327
+ var host = req.headers.host || "localhost";
328
+ var hostname = host.split(":")[0];
329
+ var protocol = tlsOptions ? "https" : "http";
330
+ var pwaUrl = protocol + "://" + hostname + ":" + portNum;
331
+ res.writeHead(200, {
332
+ "Content-Type": "text/html; charset=utf-8",
849
333
  });
334
+ res.end(pages.setupPageHtml(pwaUrl, pwaUrl, false, true));
850
335
  return;
851
336
  }
852
337
 
853
- // Verify OTP code (SMTP login)
854
- if (req.method === "POST" && fullUrl === "/auth/verify-otp") {
855
- if (!users.isMultiUser() || !smtp.isEmailLoginEnabled()) {
856
- res.writeHead(400, { "Content-Type": "application/json" });
857
- res.end('{"error":"OTP login not available"}');
858
- return;
859
- }
860
- var ip = req.socket.remoteAddress || "";
861
- var remaining = checkPinRateLimit(ip);
862
- if (remaining !== null) {
863
- res.writeHead(429, { "Content-Type": "application/json" });
864
- res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
865
- return;
866
- }
867
- var body = "";
868
- req.on("data", function (chunk) { body += chunk; });
869
- req.on("end", function () {
870
- try {
871
- var data = JSON.parse(body);
872
- if (!data.email || !data.code) {
873
- res.writeHead(400, { "Content-Type": "application/json" });
874
- res.end('{"error":"Email and code are required"}');
875
- return;
876
- }
877
- var otpResult = smtp.verifyOtp(data.email, data.code);
878
- if (!otpResult.valid) {
879
- recordPinFailure(ip);
880
- res.writeHead(401, { "Content-Type": "application/json" });
881
- res.end(JSON.stringify({ ok: false, error: otpResult.error, attemptsLeft: otpResult.attemptsLeft }));
882
- return;
883
- }
884
- var user = users.findUserByEmail(data.email);
885
- if (!user) {
886
- res.writeHead(401, { "Content-Type": "application/json" });
887
- res.end('{"ok":false,"error":"Account not found"}');
888
- return;
889
- }
890
- clearPinFailures(ip);
891
- var session = createMultiUserSession(user.id, tlsOptions);
892
- res.writeHead(200, {
893
- "Set-Cookie": session.cookie,
894
- "Content-Type": "application/json",
895
- });
896
- res.end(JSON.stringify({ ok: true, user: { id: user.id, username: user.username, role: user.role } }));
897
- } catch (e) {
898
- res.writeHead(400, { "Content-Type": "application/json" });
899
- res.end('{"error":"Invalid request"}');
900
- }
901
- });
902
- return;
903
- }
904
-
905
- // Invite registration
906
- if (req.method === "POST" && fullUrl === "/auth/register") {
907
- if (!users.isMultiUser()) {
908
- res.writeHead(400, { "Content-Type": "application/json" });
909
- res.end('{"error":"Multi-user mode is not enabled"}');
910
- return;
911
- }
912
- var body = "";
913
- req.on("data", function (chunk) { body += chunk; });
914
- req.on("end", function () {
915
- try {
916
- var data = JSON.parse(body);
917
- var validation = users.validateInvite(data.inviteCode);
918
- if (!validation.valid) {
919
- res.writeHead(400, { "Content-Type": "application/json" });
920
- res.end(JSON.stringify({ error: validation.error }));
921
- return;
922
- }
923
- if (!data.username || data.username.trim().length < 1 || data.username.length > 100) {
924
- res.writeHead(400, { "Content-Type": "application/json" });
925
- res.end('{"error":"Username is required"}');
926
- return;
927
- }
928
- var result;
929
- if (smtp.isEmailLoginEnabled() && !data.pin) {
930
- // SMTP mode: username + email required, no PIN
931
- if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
932
- res.writeHead(400, { "Content-Type": "application/json" });
933
- res.end('{"error":"A valid email address is required"}');
934
- return;
935
- }
936
- result = users.createUserWithoutPin({
937
- username: data.username.trim(),
938
- email: data.email,
939
- displayName: data.displayName || data.username.trim(),
940
- role: "user",
941
- });
942
- } else {
943
- // PIN mode: username + PIN, no email required
944
- if (!data.pin || !/^\d{6}$/.test(data.pin)) {
945
- res.writeHead(400, { "Content-Type": "application/json" });
946
- res.end('{"error":"PIN must be exactly 6 digits"}');
947
- return;
948
- }
949
- result = users.createUser({
950
- username: data.username.trim(),
951
- email: data.email || null,
952
- displayName: data.displayName || data.username.trim(),
953
- pin: data.pin,
954
- role: "user",
955
- });
956
- }
957
- if (result.error) {
958
- res.writeHead(400, { "Content-Type": "application/json" });
959
- res.end(JSON.stringify({ error: result.error }));
960
- return;
961
- }
962
- // Auto-provision Linux account if OS users mode is enabled
963
- if (osUsers && !result.user.linuxUser) {
964
- var provision = provisionLinuxUser(result.user.username);
965
- if (provision.ok) {
966
- users.updateLinuxUser(result.user.id, provision.linuxUser);
967
- if (onUserProvisioned) onUserProvisioned(result.user.id, provision.linuxUser);
968
- }
969
- }
970
- users.markInviteUsed(data.inviteCode);
971
- var session = createMultiUserSession(result.user.id, tlsOptions);
972
- res.writeHead(200, {
973
- "Set-Cookie": session.cookie,
974
- "Content-Type": "application/json",
975
- });
976
- res.end(JSON.stringify({ ok: true, user: { id: result.user.id, username: result.user.username, role: result.user.role } }));
977
- } catch (e) {
978
- res.writeHead(400, { "Content-Type": "application/json" });
979
- res.end('{"error":"Invalid request"}');
980
- }
981
- });
982
- return;
983
- }
984
-
985
- // Logout
986
- if (req.method === "POST" && fullUrl === "/auth/logout") {
987
- if (users.isMultiUser()) {
988
- var cookies = parseCookies(req);
989
- var token = cookies[MULTI_USER_COOKIE];
990
- if (token && multiUserTokens[token]) {
991
- delete multiUserTokens[token];
992
- saveTokens();
993
- }
994
- res.writeHead(200, {
995
- "Set-Cookie": MULTI_USER_COOKIE + "=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" + (tlsOptions ? "; Secure" : ""),
996
- "Content-Type": "application/json",
997
- });
998
- } else {
999
- res.writeHead(200, {
1000
- "Set-Cookie": "relay_auth=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" + (tlsOptions ? "; Secure" : ""),
1001
- "Content-Type": "application/json",
1002
- });
1003
- }
1004
- res.end('{"ok":true}');
1005
- return;
1006
- }
1007
-
1008
- // Invite page (magic link)
1009
- if (req.method === "GET" && fullUrl.indexOf("/invite/") === 0) {
1010
- var inviteCode = fullUrl.substring("/invite/".length);
1011
- if (!users.isMultiUser()) {
1012
- res.writeHead(404, { "Content-Type": "text/plain" });
1013
- res.end("Not found");
1014
- return;
1015
- }
1016
- var validation = users.validateInvite(inviteCode);
1017
- if (!validation.valid) {
1018
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1019
- res.end('<!DOCTYPE html><html><head><title>Clay</title>' +
1020
- '<style>body{background:#2F2E2B;color:#E8E5DE;font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}' +
1021
- '.c{text-align:center;max-width:360px;padding:20px}h1{color:#DA7756;margin-bottom:16px}p{color:#908B81}</style></head>' +
1022
- '<body><div class="c"><h1>Clay</h1><p>' + (validation.error === "Invite expired" ? "This invite link has expired." : validation.error === "Invite already used" ? "This invite link has already been used." : "Invalid invite link.") + '</p></div></body></html>');
1023
- return;
1024
- }
1025
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1026
- res.end(smtp.isEmailLoginEnabled() ? smtpInvitePageHtml(inviteCode) : invitePageHtml(inviteCode));
1027
- return;
1028
- }
1029
-
1030
- // CA certificate download
1031
- if (req.url === "/ca/download" && req.method === "GET" && caContent) {
1032
- res.writeHead(200, {
1033
- "Content-Type": "application/x-pem-file",
1034
- "Content-Disposition": 'attachment; filename="clay-ca.pem"',
1035
- });
1036
- res.end(caContent);
1037
- return;
1038
- }
1039
-
1040
- // Chrome extension download (proxy from GitHub)
1041
- if (fullUrl === "/api/extension/download" && req.method === "GET") {
1042
- if (!isRequestAuthed(req)) {
1043
- res.writeHead(401, { "Content-Type": "application/json" });
1044
- res.end(JSON.stringify({ error: "Unauthorized" }));
1045
- return;
1046
- }
1047
- var archiveUrl = "https://github.com/chadbyte/clay-chrome/archive/refs/heads/main.zip";
1048
- httpGetBinary(archiveUrl).then(function (buf) {
1049
- res.writeHead(200, {
1050
- "Content-Type": "application/zip",
1051
- "Content-Disposition": 'attachment; filename="clay-chrome-extension.zip"',
1052
- "Content-Length": buf.length,
1053
- });
1054
- res.end(buf);
1055
- }).catch(function (err) {
1056
- res.writeHead(502, { "Content-Type": "application/json" });
1057
- res.end(JSON.stringify({ error: "Failed to download extension: " + (err.message || "unknown error") }));
1058
- });
1059
- return;
1060
- }
1061
-
1062
- // CORS preflight for cross-origin requests (HTTP onboarding → HTTPS)
1063
- if (req.method === "OPTIONS") {
1064
- res.writeHead(204, {
1065
- "Access-Control-Allow-Origin": "*",
1066
- "Access-Control-Allow-Methods": "GET, OPTIONS",
1067
- "Access-Control-Allow-Headers": "Content-Type",
1068
- "Access-Control-Max-Age": "86400",
1069
- });
1070
- res.end();
1071
- return;
1072
- }
1073
-
1074
- // Setup page
1075
- if (fullUrl === "/setup" && req.method === "GET") {
1076
- var host = req.headers.host || "localhost";
1077
- var hostname = host.split(":")[0];
1078
- var protocol = tlsOptions ? "https" : "http";
1079
- var setupUrl = protocol + "://" + hostname + ":" + portNum;
1080
- var lanMode = /[?&]mode=lan/.test(req.url);
1081
- res.writeHead(200, {
1082
- "Content-Type": "text/html; charset=utf-8",
1083
- "Access-Control-Allow-Origin": "*",
1084
- });
1085
- res.end(setupPageHtml(setupUrl, setupUrl, !!caContent, lanMode));
1086
- return;
1087
- }
1088
-
1089
- // PWA install guide (builtin cert mode, no CA step needed)
1090
- if (fullUrl === "/pwa" && req.method === "GET") {
1091
- var host = req.headers.host || "localhost";
1092
- var hostname = host.split(":")[0];
1093
- var protocol = tlsOptions ? "https" : "http";
1094
- var pwaUrl = protocol + "://" + hostname + ":" + portNum;
1095
- res.writeHead(200, {
1096
- "Content-Type": "text/html; charset=utf-8",
1097
- });
1098
- res.end(setupPageHtml(pwaUrl, pwaUrl, false, true));
1099
- return;
1100
- }
1101
-
1102
- // Global push endpoints (used by setup page)
1103
- if (req.method === "GET" && fullUrl === "/api/vapid-public-key" && pushModule) {
1104
- res.writeHead(200, { "Content-Type": "application/json" });
1105
- res.end(JSON.stringify({ publicKey: pushModule.publicKey }));
1106
- return;
1107
- }
1108
-
1109
- if (req.method === "POST" && fullUrl === "/api/push-subscribe" && pushModule) {
338
+ // Global push endpoints (used by setup page)
339
+ if (req.method === "GET" && fullUrl === "/api/vapid-public-key" && pushModule) {
340
+ res.writeHead(200, { "Content-Type": "application/json" });
341
+ res.end(JSON.stringify({ publicKey: pushModule.publicKey }));
342
+ return;
343
+ }
344
+
345
+ if (req.method === "POST" && fullUrl === "/api/push-subscribe" && pushModule) {
1110
346
  var body = "";
1111
347
  req.on("data", function (chunk) { body += chunk; });
1112
348
  req.on("end", function () {
@@ -1117,1315 +353,88 @@ function createServer(opts) {
1117
353
  pushModule.addSubscription(sub, parsed.replaceEndpoint, _httpPushUser ? _httpPushUser.id : null);
1118
354
  res.writeHead(200, { "Content-Type": "application/json" });
1119
355
  res.end('{"ok":true}');
1120
- } catch (e) {
1121
- res.writeHead(400);
1122
- res.end("Bad request");
1123
- }
1124
- });
1125
- return;
1126
- }
1127
-
1128
- // Health check endpoint
1129
- // Unauthenticated: minimal liveness info only
1130
- // Authenticated: full system details (memory, pid, version, sessions)
1131
- if (req.method === "GET" && fullUrl === "/api/health") {
1132
- var health = {
1133
- status: "ok",
1134
- timestamp: new Date().toISOString(),
1135
- };
1136
- if (isRequestAuthed(req)) {
1137
- var mem = process.memoryUsage();
1138
- var activeSessions = 0;
1139
- projects.forEach(function (ctx) {
1140
- if (ctx && ctx.clients) {
1141
- activeSessions += ctx.clients.size || 0;
1142
- }
1143
- });
1144
- health.uptime = process.uptime();
1145
- health.version = pkg.version;
1146
- health.node = process.version;
1147
- health.sessions = activeSessions;
1148
- health.projects = projects.size;
1149
- health.memory = {
1150
- rss: mem.rss,
1151
- heapUsed: mem.heapUsed,
1152
- heapTotal: mem.heapTotal,
1153
- };
1154
- health.pid = process.pid;
1155
- }
1156
- res.writeHead(200, { "Content-Type": "application/json" });
1157
- res.end(JSON.stringify(health));
1158
- return;
1159
- }
1160
-
1161
- // Theme list: bundled (lib/themes/) + user (~/.clay/themes/)
1162
- if (req.method === "GET" && fullUrl === "/api/themes") {
1163
- var bundled = {};
1164
- var custom = {};
1165
- // Read bundled themes
1166
- try {
1167
- var bFiles = fs.readdirSync(bundledThemesDir);
1168
- for (var i = 0; i < bFiles.length; i++) {
1169
- if (!bFiles[i].endsWith(".json")) continue;
1170
- try {
1171
- var raw = fs.readFileSync(path.join(bundledThemesDir, bFiles[i]), "utf8");
1172
- var id = bFiles[i].replace(/\.json$/, "");
1173
- bundled[id] = JSON.parse(raw);
1174
- } catch (e) {}
1175
- }
1176
- } catch (e) {}
1177
- // Read user themes (override bundled if same id)
1178
- try {
1179
- var uFiles = fs.readdirSync(userThemesDir);
1180
- for (var j = 0; j < uFiles.length; j++) {
1181
- if (!uFiles[j].endsWith(".json")) continue;
1182
- try {
1183
- var uRaw = fs.readFileSync(path.join(userThemesDir, uFiles[j]), "utf8");
1184
- var uid = uFiles[j].replace(/\.json$/, "");
1185
- custom[uid] = JSON.parse(uRaw);
1186
- } catch (e) {}
1187
- }
1188
- } catch (e) {}
1189
- res.writeHead(200, { "Content-Type": "application/json" });
1190
- res.end(JSON.stringify({ bundled: bundled, custom: custom }));
1191
- return;
1192
- }
1193
-
1194
- // User profile — user-scoped in multi-user mode, global in single-user mode
1195
- var profilePath = path.join(CONFIG_DIR, "profile.json");
1196
-
1197
- if (req.method === "GET" && fullUrl === "/api/profile") {
1198
- if (users.isMultiUser()) {
1199
- var mu = getMultiUserFromReq(req);
1200
- if (!mu) {
1201
- res.writeHead(401, { "Content-Type": "application/json" });
1202
- res.end('{"error":"unauthorized"}');
1203
- return;
1204
- }
1205
- var profile = mu.profile || { name: "", lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "", avatarCustom: "" };
1206
- profile.username = mu.username;
1207
- profile.userId = mu.id;
1208
- profile.role = mu.role;
1209
- profile.autoContinueOnRateLimit = !!mu.autoContinueOnRateLimit;
1210
- profile.chatLayout = mu.chatLayout || "channel";
1211
- profile.mateOnboardingShown = !!mu.mateOnboardingShown;
1212
- res.writeHead(200, { "Content-Type": "application/json" });
1213
- res.end(JSON.stringify(profile));
1214
- return;
1215
- }
1216
- var profile = { name: "", lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "", avatarCustom: "" };
1217
- try {
1218
- var raw = fs.readFileSync(profilePath, "utf8");
1219
- var saved = JSON.parse(raw);
1220
- if (saved.name !== undefined) profile.name = saved.name;
1221
- if (saved.lang) profile.lang = saved.lang;
1222
- if (saved.avatarColor) profile.avatarColor = saved.avatarColor;
1223
- if (saved.avatarStyle) profile.avatarStyle = saved.avatarStyle;
1224
- if (saved.avatarSeed) profile.avatarSeed = saved.avatarSeed;
1225
- if (saved.avatarCustom) profile.avatarCustom = saved.avatarCustom;
1226
- } catch (e) { /* file doesn't exist yet */ }
1227
- // Single-user settings from daemon config
1228
- if (typeof opts.onGetDaemonConfig === "function") {
1229
- var dc = opts.onGetDaemonConfig();
1230
- profile.autoContinueOnRateLimit = !!dc.autoContinueOnRateLimit;
1231
- profile.chatLayout = dc.chatLayout || "channel";
1232
- profile.mateOnboardingShown = !!dc.mateOnboardingShown;
1233
- }
1234
- // Check if custom avatar file exists
1235
- try {
1236
- var avatarFiles = fs.readdirSync(path.join(CONFIG_DIR, "avatars"));
1237
- for (var afi = 0; afi < avatarFiles.length; afi++) {
1238
- if (avatarFiles[afi].startsWith("default.")) {
1239
- profile.avatarCustom = "/api/avatar/default?v=" + fs.statSync(path.join(CONFIG_DIR, "avatars", avatarFiles[afi])).mtimeMs;
1240
- break;
1241
- }
1242
- }
1243
- } catch (e) {}
1244
- res.writeHead(200, { "Content-Type": "application/json" });
1245
- res.end(JSON.stringify(profile));
1246
- return;
1247
- }
1248
-
1249
- if (req.method === "PUT" && fullUrl === "/api/profile") {
1250
- var body = "";
1251
- req.on("data", function (chunk) { body += chunk; });
1252
- req.on("end", function () {
1253
- try {
1254
- var data = JSON.parse(body);
1255
- var profile = {};
1256
- if (typeof data.name === "string") profile.name = data.name.substring(0, 50);
1257
- if (typeof data.lang === "string") profile.lang = data.lang.substring(0, 10);
1258
- if (typeof data.avatarColor === "string" && /^#[0-9a-fA-F]{6}$/.test(data.avatarColor)) {
1259
- profile.avatarColor = data.avatarColor;
1260
- }
1261
- if (typeof data.avatarStyle === "string") profile.avatarStyle = data.avatarStyle.substring(0, 30);
1262
- if (typeof data.avatarSeed === "string") profile.avatarSeed = data.avatarSeed.substring(0, 30);
1263
- if (typeof data.avatarCustom === "string") profile.avatarCustom = data.avatarCustom;
1264
- if (data.avatarCustom === null || data.avatarCustom === "") profile.avatarCustom = undefined;
1265
- if (users.isMultiUser()) {
1266
- var mu = getMultiUserFromReq(req);
1267
- if (!mu) {
1268
- res.writeHead(401, { "Content-Type": "application/json" });
1269
- res.end('{"error":"unauthorized"}');
1270
- return;
1271
- }
1272
- users.updateUserProfile(mu.id, profile);
1273
- // Broadcast updated avatar/presence to all projects
1274
- projects.forEach(function (pCtx) {
1275
- pCtx.refreshUserProfile(mu.id);
1276
- });
1277
- } else {
1278
- fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2), "utf8");
1279
- if (process.platform !== "win32") {
1280
- try { fs.chmodSync(profilePath, 0o600); } catch (chmodErr) {}
1281
- }
1282
- }
1283
- res.writeHead(200, { "Content-Type": "application/json" });
1284
- res.end(JSON.stringify(profile));
1285
- } catch (e) {
1286
- res.writeHead(400, { "Content-Type": "application/json" });
1287
- res.end(JSON.stringify({ error: "Invalid request" }));
1288
- }
1289
- });
1290
- return;
1291
- }
1292
-
1293
- // Upload custom avatar image
1294
- if (req.method === "POST" && fullUrl === "/api/avatar") {
1295
- var chunks = [];
1296
- var totalSize = 0;
1297
- var maxSize = 2 * 1024 * 1024; // 2MB
1298
- req.on("data", function (chunk) {
1299
- totalSize += chunk.length;
1300
- if (totalSize <= maxSize) chunks.push(chunk);
1301
- });
1302
- req.on("end", function () {
1303
- if (totalSize > maxSize) {
1304
- res.writeHead(413, { "Content-Type": "application/json" });
1305
- res.end('{"error":"File too large (max 2MB)"}');
1306
- return;
1307
- }
1308
- var raw = Buffer.concat(chunks);
1309
- // Detect content type from magic bytes
1310
- var ct = null;
1311
- if (raw[0] === 0xFF && raw[1] === 0xD8) ct = "image/jpeg";
1312
- else if (raw[0] === 0x89 && raw[1] === 0x50) ct = "image/png";
1313
- else if (raw[0] === 0x47 && raw[1] === 0x49) ct = "image/gif";
1314
- else if (raw[0] === 0x52 && raw[1] === 0x49) ct = "image/webp";
1315
- if (!ct) {
1316
- res.writeHead(400, { "Content-Type": "application/json" });
1317
- res.end('{"error":"Unsupported image format"}');
1318
- return;
1319
- }
1320
- var ext = ct.split("/")[1] === "jpeg" ? "jpg" : ct.split("/")[1];
1321
- var avatarDir = path.join(CONFIG_DIR, "avatars");
1322
- fs.mkdirSync(avatarDir, { recursive: true });
1323
-
1324
- var userId = "default";
1325
- if (users.isMultiUser()) {
1326
- var mu = getMultiUserFromReq(req);
1327
- if (!mu) {
1328
- res.writeHead(401, { "Content-Type": "application/json" });
1329
- res.end('{"error":"unauthorized"}');
1330
- return;
1331
- }
1332
- userId = mu.id;
1333
- }
1334
- var filename = userId + "." + ext;
1335
- // Remove old avatar files for this user
1336
- try {
1337
- var existing = fs.readdirSync(avatarDir);
1338
- for (var ei = 0; ei < existing.length; ei++) {
1339
- if (existing[ei].startsWith(userId + ".")) {
1340
- fs.unlinkSync(path.join(avatarDir, existing[ei]));
1341
- }
1342
- }
1343
- } catch (e) {}
1344
- var avatarFilePath = path.join(avatarDir, filename);
1345
- fs.writeFileSync(avatarFilePath, raw);
1346
- try { fs.chmodSync(avatarFilePath, 0o644); } catch (e) {}
1347
- try { fs.chmodSync(avatarDir, 0o755); } catch (e) {}
1348
- res.writeHead(200, { "Content-Type": "application/json" });
1349
- res.end(JSON.stringify({ ok: true, avatar: "/api/avatar/" + userId + "?v=" + Date.now() }));
1350
- });
1351
- return;
1352
- }
1353
-
1354
- // Serve custom avatar image
1355
- if (req.method === "GET" && fullUrl.startsWith("/api/avatar/")) {
1356
- var avatarUserId = fullUrl.split("/api/avatar/")[1].split("?")[0];
1357
- var avatarDir = path.join(CONFIG_DIR, "avatars");
1358
- try {
1359
- var files = fs.readdirSync(avatarDir);
1360
- var match = null;
1361
- for (var fi = 0; fi < files.length; fi++) {
1362
- if (files[fi].startsWith(avatarUserId + ".")) {
1363
- match = files[fi];
1364
- break;
1365
- }
1366
- }
1367
- if (match) {
1368
- var ext = match.split(".").pop();
1369
- var ctMap = { jpg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp" };
1370
- res.writeHead(200, {
1371
- "Content-Type": ctMap[ext] || "application/octet-stream",
1372
- "Cache-Control": "public, max-age=31536000, immutable",
1373
- });
1374
- res.end(fs.readFileSync(path.join(avatarDir, match)));
1375
- return;
1376
- }
1377
- } catch (e) {}
1378
- res.writeHead(404, { "Content-Type": "application/json" });
1379
- res.end('{"error":"not found"}');
1380
- return;
1381
- }
1382
-
1383
- // Upload custom avatar for a mate
1384
- if (req.method === "POST" && fullUrl.startsWith("/api/mate-avatar/")) {
1385
- var mateIdFromUrl = fullUrl.split("/api/mate-avatar/")[1].split("?")[0];
1386
- if (!mateIdFromUrl) {
1387
- res.writeHead(400, { "Content-Type": "application/json" });
1388
- res.end('{"error":"Missing mate ID"}');
1389
- return;
1390
- }
1391
- var chunks = [];
1392
- var totalSize = 0;
1393
- var maxSize = 2 * 1024 * 1024; // 2MB
1394
- req.on("data", function (chunk) {
1395
- totalSize += chunk.length;
1396
- if (totalSize <= maxSize) chunks.push(chunk);
1397
- });
1398
- req.on("end", function () {
1399
- if (totalSize > maxSize) {
1400
- res.writeHead(413, { "Content-Type": "application/json" });
1401
- res.end('{"error":"File too large (max 2MB)"}');
1402
- return;
1403
- }
1404
- var raw = Buffer.concat(chunks);
1405
- var ct = null;
1406
- if (raw[0] === 0xFF && raw[1] === 0xD8) ct = "image/jpeg";
1407
- else if (raw[0] === 0x89 && raw[1] === 0x50) ct = "image/png";
1408
- else if (raw[0] === 0x47 && raw[1] === 0x49) ct = "image/gif";
1409
- else if (raw[0] === 0x52 && raw[1] === 0x49) ct = "image/webp";
1410
- if (!ct) {
1411
- res.writeHead(400, { "Content-Type": "application/json" });
1412
- res.end('{"error":"Unsupported image format"}');
1413
- return;
1414
- }
1415
- var userId = null;
1416
- if (users.isMultiUser()) {
1417
- var mu = getMultiUserFromReq(req);
1418
- if (!mu) {
1419
- res.writeHead(401, { "Content-Type": "application/json" });
1420
- res.end('{"error":"unauthorized"}');
1421
- return;
1422
- }
1423
- userId = mu.id;
1424
- }
1425
- var mateCtx = mates.buildMateCtx(userId);
1426
- var mate = mates.getMate(mateCtx, mateIdFromUrl);
1427
- if (!mate) {
1428
- res.writeHead(404, { "Content-Type": "application/json" });
1429
- res.end('{"error":"Mate not found"}');
1430
- return;
1431
- }
1432
- var ext = ct.split("/")[1] === "jpeg" ? "jpg" : ct.split("/")[1];
1433
- var avatarDir = path.join(CONFIG_DIR, "mate-avatars");
1434
- fs.mkdirSync(avatarDir, { recursive: true });
1435
- var filename = mateIdFromUrl + "." + ext;
1436
- // Remove old avatar files for this mate
1437
- try {
1438
- var existing = fs.readdirSync(avatarDir);
1439
- for (var ei = 0; ei < existing.length; ei++) {
1440
- if (existing[ei].startsWith(mateIdFromUrl + ".")) {
1441
- fs.unlinkSync(path.join(avatarDir, existing[ei]));
1442
- }
1443
- }
1444
- } catch (e) {}
1445
- var mateAvatarFilePath = path.join(avatarDir, filename);
1446
- fs.writeFileSync(mateAvatarFilePath, raw);
1447
- try { fs.chmodSync(mateAvatarFilePath, 0o644); } catch (e) {}
1448
- try { fs.chmodSync(avatarDir, 0o755); } catch (e) {}
1449
- var avatarPath = "/api/mate-avatar/" + mateIdFromUrl + "?v=" + Date.now();
1450
- // Update mate profile with custom avatar URL
1451
- var profile = mate.profile || {};
1452
- profile.avatarCustom = avatarPath;
1453
- mates.updateMate(mateCtx, mateIdFromUrl, { profile: profile });
1454
- res.writeHead(200, { "Content-Type": "application/json" });
1455
- res.end(JSON.stringify({ ok: true, avatar: avatarPath }));
1456
- });
1457
- return;
1458
- }
1459
-
1460
- // Serve custom mate avatar image
1461
- if (req.method === "GET" && fullUrl.startsWith("/api/mate-avatar/")) {
1462
- var mateAvatarId = fullUrl.split("/api/mate-avatar/")[1].split("?")[0];
1463
- var mateAvatarDir = path.join(CONFIG_DIR, "mate-avatars");
1464
- try {
1465
- var files = fs.readdirSync(mateAvatarDir);
1466
- var match = null;
1467
- for (var fi = 0; fi < files.length; fi++) {
1468
- if (files[fi].startsWith(mateAvatarId + ".")) {
1469
- match = files[fi];
1470
- break;
1471
- }
1472
- }
1473
- if (match) {
1474
- var ext = match.split(".").pop();
1475
- var ctMap = { jpg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp" };
1476
- res.writeHead(200, {
1477
- "Content-Type": ctMap[ext] || "application/octet-stream",
1478
- "Cache-Control": "public, max-age=31536000, immutable",
1479
- });
1480
- res.end(fs.readFileSync(path.join(mateAvatarDir, match)));
1481
- return;
1482
- }
1483
- } catch (e) {}
1484
- res.writeHead(404, { "Content-Type": "application/json" });
1485
- res.end('{"error":"not found"}');
1486
- return;
1487
- }
1488
-
1489
- // Change own PIN (multi-user mode)
1490
- if (req.method === "PUT" && fullUrl === "/api/user/pin") {
1491
- if (!users.isMultiUser()) {
1492
- res.writeHead(404, { "Content-Type": "application/json" });
1493
- res.end('{"error":"Not found"}');
1494
- return;
1495
- }
1496
- var mu = getMultiUserFromReq(req);
1497
- if (!mu) {
1498
- res.writeHead(401, { "Content-Type": "application/json" });
1499
- res.end('{"error":"unauthorized"}');
1500
- return;
1501
- }
1502
- var body = "";
1503
- req.on("data", function (chunk) { body += chunk; });
1504
- req.on("end", function () {
1505
- try {
1506
- var data = JSON.parse(body);
1507
- if (!data.newPin || typeof data.newPin !== "string" || !/^\d{6}$/.test(data.newPin)) {
1508
- res.writeHead(400, { "Content-Type": "application/json" });
1509
- res.end('{"error":"PIN must be exactly 6 digits"}');
1510
- return;
1511
- }
1512
- var result = users.updateUserPin(mu.id, data.newPin);
1513
- if (result.error) {
1514
- res.writeHead(400, { "Content-Type": "application/json" });
1515
- res.end(JSON.stringify({ error: result.error }));
1516
- return;
1517
- }
1518
- res.writeHead(200, { "Content-Type": "application/json" });
1519
- res.end('{"ok":true}');
1520
- } catch (e) {
1521
- res.writeHead(400, { "Content-Type": "application/json" });
1522
- res.end('{"error":"Invalid request"}');
1523
- }
1524
- });
1525
- return;
1526
- }
1527
-
1528
- // PUT /api/user/auto-continue
1529
- if (req.method === "PUT" && fullUrl === "/api/user/auto-continue") {
1530
- var mu = getMultiUserFromReq(req);
1531
- if (!mu) {
1532
- // Single-user: use daemon config fallback
1533
- var body = "";
1534
- req.on("data", function (chunk) { body += chunk; });
1535
- req.on("end", function () {
1536
- try {
1537
- var data = JSON.parse(body);
1538
- if (typeof opts.onSetAutoContinue === "function") {
1539
- opts.onSetAutoContinue(!!data.enabled);
1540
- }
1541
- res.writeHead(200, { "Content-Type": "application/json" });
1542
- res.end(JSON.stringify({ ok: true, autoContinueOnRateLimit: !!data.enabled }));
1543
- } catch (e) {
1544
- res.writeHead(400, { "Content-Type": "application/json" });
1545
- res.end('{"error":"Invalid request"}');
1546
- }
1547
- });
1548
- return;
1549
- }
1550
- var body = "";
1551
- req.on("data", function (chunk) { body += chunk; });
1552
- req.on("end", function () {
1553
- try {
1554
- var data = JSON.parse(body);
1555
- var result = users.setAutoContinue(mu.id, !!data.enabled);
1556
- if (result.error) {
1557
- res.writeHead(400, { "Content-Type": "application/json" });
1558
- res.end(JSON.stringify({ error: result.error }));
1559
- return;
1560
- }
1561
- res.writeHead(200, { "Content-Type": "application/json" });
1562
- res.end(JSON.stringify({ ok: true, autoContinueOnRateLimit: result.autoContinueOnRateLimit }));
1563
- } catch (e) {
1564
- res.writeHead(400, { "Content-Type": "application/json" });
1565
- res.end('{"error":"Invalid request"}');
1566
- }
1567
- });
1568
- return;
1569
- }
1570
-
1571
- // PUT /api/user/chat-layout
1572
- if (req.method === "PUT" && fullUrl === "/api/user/chat-layout") {
1573
- var mu = getMultiUserFromReq(req);
1574
- if (!mu) {
1575
- // Single-user: save to daemon config
1576
- var body = "";
1577
- req.on("data", function (chunk) { body += chunk; });
1578
- req.on("end", function () {
1579
- try {
1580
- var data = JSON.parse(body);
1581
- var val = (data.layout === "bubble") ? "bubble" : "channel";
1582
- if (typeof opts.onSetChatLayout === "function") {
1583
- opts.onSetChatLayout(val);
1584
- }
1585
- res.writeHead(200, { "Content-Type": "application/json" });
1586
- res.end(JSON.stringify({ ok: true, chatLayout: val }));
1587
- } catch (e) {
1588
- res.writeHead(400, { "Content-Type": "application/json" });
1589
- res.end('{"error":"Invalid request"}');
1590
- }
1591
- });
1592
- return;
1593
- }
1594
- var body = "";
1595
- req.on("data", function (chunk) { body += chunk; });
1596
- req.on("end", function () {
1597
- try {
1598
- var data = JSON.parse(body);
1599
- var result = users.setChatLayout(mu.id, data.layout);
1600
- if (result.error) {
1601
- res.writeHead(400, { "Content-Type": "application/json" });
1602
- res.end(JSON.stringify({ error: result.error }));
1603
- return;
1604
- }
1605
- res.writeHead(200, { "Content-Type": "application/json" });
1606
- res.end(JSON.stringify({ ok: true, chatLayout: result.chatLayout }));
1607
- } catch (e) {
1608
- res.writeHead(400, { "Content-Type": "application/json" });
1609
- res.end('{"error":"Invalid request"}');
1610
- }
1611
- });
1612
- return;
1613
- }
1614
-
1615
- // POST /api/user/mate-onboarded
1616
- if (req.method === "POST" && fullUrl === "/api/user/mate-onboarded") {
1617
- var mu = getMultiUserFromReq(req);
1618
- if (!mu) {
1619
- // Single-user: save to daemon config
1620
- if (typeof opts.onSetMateOnboarded === "function") {
1621
- opts.onSetMateOnboarded();
1622
- }
1623
- res.writeHead(200, { "Content-Type": "application/json" });
1624
- res.end('{"ok":true}');
1625
- } else {
1626
- users.setMateOnboarded(mu.id);
1627
- res.writeHead(200, { "Content-Type": "application/json" });
1628
- res.end('{"ok":true}');
1629
- }
1630
- return;
1631
- }
1632
-
1633
- // GET /api/user/auto-continue
1634
- if (req.method === "GET" && fullUrl === "/api/user/auto-continue") {
1635
- var mu = getMultiUserFromReq(req);
1636
- if (!mu) {
1637
- // Single-user: read from daemon config
1638
- var enabled = false;
1639
- if (typeof opts.onGetDaemonConfig === "function") {
1640
- var dc = opts.onGetDaemonConfig();
1641
- enabled = !!dc.autoContinueOnRateLimit;
1642
- }
1643
- res.writeHead(200, { "Content-Type": "application/json" });
1644
- res.end(JSON.stringify({ autoContinueOnRateLimit: enabled }));
1645
- return;
1646
- }
1647
- var val = users.getAutoContinue(mu.id);
1648
- res.writeHead(200, { "Content-Type": "application/json" });
1649
- res.end(JSON.stringify({ autoContinueOnRateLimit: val }));
1650
- return;
1651
- }
1652
-
1653
- // --- Admin API endpoints (multi-user mode only) ---
1654
-
1655
- // List all users (admin only)
1656
- if (req.method === "GET" && fullUrl === "/api/admin/users") {
1657
- if (!users.isMultiUser()) {
1658
- res.writeHead(404, { "Content-Type": "application/json" });
1659
- res.end('{"error":"Not found"}');
1660
- return;
1661
- }
1662
- var mu = getMultiUserFromReq(req);
1663
- if (!mu) {
1664
- res.writeHead(401, { "Content-Type": "application/json" });
1665
- res.end('{"error":"Authentication required"}');
1666
- return;
1667
- }
1668
- // Admins get full user list; project owners get limited list (id, displayName, username)
1669
- if (mu.role === "admin") {
1670
- res.writeHead(200, { "Content-Type": "application/json" });
1671
- res.end(JSON.stringify({ users: users.getAllUsers() }));
1672
- } else {
1673
- var allU = users.getAllUsers();
1674
- var safeUsers = allU.map(function (u) {
1675
- return { id: u.id, displayName: u.displayName, username: u.username };
1676
- });
1677
- res.writeHead(200, { "Content-Type": "application/json" });
1678
- res.end(JSON.stringify({ users: safeUsers }));
1679
- }
1680
- return;
1681
- }
1682
-
1683
- // Remove user (admin only)
1684
- if (req.method === "DELETE" && fullUrl.indexOf("/api/admin/users/") === 0) {
1685
- if (!users.isMultiUser()) {
1686
- res.writeHead(404, { "Content-Type": "application/json" });
1687
- res.end('{"error":"Not found"}');
1688
- return;
1689
- }
1690
- var mu = getMultiUserFromReq(req);
1691
- if (!mu || mu.role !== "admin") {
1692
- res.writeHead(403, { "Content-Type": "application/json" });
1693
- res.end('{"error":"Admin access required"}');
1694
- return;
1695
- }
1696
- var targetUserId = fullUrl.substring("/api/admin/users/".length);
1697
- if (targetUserId === mu.id) {
1698
- res.writeHead(400, { "Content-Type": "application/json" });
1699
- res.end('{"error":"Cannot remove yourself"}');
1700
- return;
1701
- }
1702
- // Look up the user before deletion to get linuxUser for deactivation
1703
- var targetUser = users.findUserById(targetUserId);
1704
- var targetLinuxUser = targetUser ? targetUser.linuxUser : null;
1705
- var result = users.removeUser(targetUserId);
1706
- if (result.error) {
1707
- res.writeHead(404, { "Content-Type": "application/json" });
1708
- res.end(JSON.stringify({ error: result.error }));
1709
- return;
1710
- }
1711
- // Remove auth tokens for deleted user
1712
- revokeUserTokens(targetUserId);
1713
- // Deactivate the Linux account if applicable
1714
- if (onUserDeleted && targetLinuxUser) {
1715
- onUserDeleted(targetUserId, targetLinuxUser);
1716
- }
1717
- res.writeHead(200, { "Content-Type": "application/json" });
1718
- res.end('{"ok":true}');
1719
- return;
1720
- }
1721
-
1722
- // Create user (admin only) — generates a temporary PIN that must be changed on first login
1723
- if (req.method === "POST" && fullUrl === "/api/admin/users") {
1724
- if (!users.isMultiUser()) {
1725
- res.writeHead(404, { "Content-Type": "application/json" });
1726
- res.end('{"error":"Not found"}');
1727
- return;
1728
- }
1729
- var mu = getMultiUserFromReq(req);
1730
- if (!mu || mu.role !== "admin") {
1731
- res.writeHead(403, { "Content-Type": "application/json" });
1732
- res.end('{"error":"Admin access required"}');
1733
- return;
1734
- }
1735
- var body = "";
1736
- req.on("data", function (chunk) { body += chunk; });
1737
- req.on("end", function () {
1738
- try {
1739
- var data = JSON.parse(body);
1740
- if (!data.username || typeof data.username !== "string" || data.username.trim().length < 1) {
1741
- res.writeHead(400, { "Content-Type": "application/json" });
1742
- res.end('{"error":"Username is required"}');
1743
- return;
1744
- }
1745
- var result = users.createUserByAdmin({
1746
- username: data.username.trim(),
1747
- displayName: data.displayName ? data.displayName.trim() : data.username.trim(),
1748
- email: data.email ? data.email.trim() : null,
1749
- role: data.role === "admin" ? "admin" : "user",
1750
- });
1751
- if (result.error) {
1752
- res.writeHead(400, { "Content-Type": "application/json" });
1753
- res.end(JSON.stringify({ error: result.error }));
1754
- return;
1755
- }
1756
- // Auto-provision Linux account if OS users mode is enabled
1757
- if (osUsers && !result.user.linuxUser) {
1758
- var provision = provisionLinuxUser(result.user.username);
1759
- if (provision.ok) {
1760
- users.updateLinuxUser(result.user.id, provision.linuxUser);
1761
- if (onUserProvisioned) onUserProvisioned(result.user.id, provision.linuxUser);
1762
- }
1763
- }
1764
- res.writeHead(200, { "Content-Type": "application/json" });
1765
- res.end(JSON.stringify({
1766
- ok: true,
1767
- user: {
1768
- id: result.user.id,
1769
- username: result.user.username,
1770
- displayName: result.user.displayName,
1771
- role: result.user.role,
1772
- },
1773
- tempPin: result.tempPin,
1774
- }));
1775
- } catch (e) {
1776
- res.writeHead(400, { "Content-Type": "application/json" });
1777
- res.end('{"error":"Invalid request"}');
1778
- }
1779
- });
1780
- return;
1781
- }
1782
-
1783
- // Reset user PIN (admin only) — generates a new temp PIN
1784
- if (req.method === "POST" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/reset-pin$/)) {
1785
- if (!users.isMultiUser()) {
1786
- res.writeHead(404, { "Content-Type": "application/json" });
1787
- res.end('{"error":"Not found"}');
1788
- return;
1789
- }
1790
- var mu = getMultiUserFromReq(req);
1791
- if (!mu || mu.role !== "admin") {
1792
- res.writeHead(403, { "Content-Type": "application/json" });
1793
- res.end('{"error":"Admin access required"}');
1794
- return;
1795
- }
1796
- var urlParts = fullUrl.split("/");
1797
- var targetUserId = urlParts[4]; // /api/admin/users/{userId}/reset-pin
1798
- var targetUser = users.findUserById(targetUserId);
1799
- if (!targetUser) {
1800
- res.writeHead(404, { "Content-Type": "application/json" });
1801
- res.end('{"error":"User not found"}');
1802
- return;
1803
- }
1804
- var newPin = users.generatePin();
1805
- var pinResult = users.updateUserPin(targetUserId, newPin);
1806
- if (pinResult.error) {
1807
- res.writeHead(400, { "Content-Type": "application/json" });
1808
- res.end(JSON.stringify({ error: pinResult.error }));
1809
- return;
1810
- }
1811
- // Mark as must change on next login
1812
- var data = users.loadUsers();
1813
- for (var i = 0; i < data.users.length; i++) {
1814
- if (data.users[i].id === targetUserId) {
1815
- data.users[i].mustChangePin = true;
1816
- users.saveUsers(data);
1817
- break;
1818
- }
1819
- }
1820
- res.writeHead(200, { "Content-Type": "application/json" });
1821
- res.end(JSON.stringify({ ok: true, tempPin: newPin }));
1822
- return;
1823
- }
1824
-
1825
- // Set Linux user mapping (admin only, OS-level multi-user)
1826
- if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/linux-user$/)) {
1827
- if (!users.isMultiUser()) {
1828
- res.writeHead(404, { "Content-Type": "application/json" });
1829
- res.end('{"error":"Not found"}');
1830
- return;
1831
- }
1832
- var mu = getMultiUserFromReq(req);
1833
- if (!mu || mu.role !== "admin") {
1834
- res.writeHead(403, { "Content-Type": "application/json" });
1835
- res.end('{"error":"Admin access required"}');
1836
- return;
1837
- }
1838
- var urlParts = fullUrl.split("/");
1839
- var targetUserId = urlParts[4]; // /api/admin/users/{userId}/linux-user
1840
- var body = "";
1841
- req.on("data", function(chunk) { body += chunk; });
1842
- req.on("end", function() {
1843
- try {
1844
- var parsed = JSON.parse(body);
1845
- var result = users.updateLinuxUser(targetUserId, parsed.linuxUser || null);
1846
- if (result.error) {
1847
- res.writeHead(400, { "Content-Type": "application/json" });
1848
- res.end(JSON.stringify({ error: result.error }));
1849
- } else {
1850
- res.writeHead(200, { "Content-Type": "application/json" });
1851
- res.end('{"ok":true}');
1852
- }
1853
- } catch (e) {
1854
- res.writeHead(400, { "Content-Type": "application/json" });
1855
- res.end('{"error":"Invalid request body"}');
1856
- }
1857
- });
1858
- return;
1859
- }
1860
-
1861
- // Update user permissions (admin only)
1862
- if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/permissions$/)) {
1863
- if (!users.isMultiUser()) {
1864
- res.writeHead(404, { "Content-Type": "application/json" });
1865
- res.end('{"error":"Not found"}');
1866
- return;
1867
- }
1868
- var mu = getMultiUserFromReq(req);
1869
- if (!mu || mu.role !== "admin") {
1870
- res.writeHead(403, { "Content-Type": "application/json" });
1871
- res.end('{"error":"Admin access required"}');
1872
- return;
1873
- }
1874
- var urlParts = fullUrl.split("/");
1875
- var targetUserId = urlParts[4]; // /api/admin/users/{userId}/permissions
1876
- var body = "";
1877
- req.on("data", function(chunk) { body += chunk; });
1878
- req.on("end", function() {
1879
- try {
1880
- var parsed = JSON.parse(body);
1881
- var result = users.updateUserPermissions(targetUserId, parsed.permissions || {});
1882
- if (result.error) {
1883
- res.writeHead(400, { "Content-Type": "application/json" });
1884
- res.end(JSON.stringify({ error: result.error }));
1885
- } else {
1886
- res.writeHead(200, { "Content-Type": "application/json" });
1887
- res.end(JSON.stringify({ ok: true, permissions: result.permissions }));
1888
- }
1889
- } catch (e) {
1890
- res.writeHead(400, { "Content-Type": "application/json" });
1891
- res.end('{"error":"Invalid request body"}');
1892
- }
1893
- });
1894
- return;
1895
- }
1896
-
1897
- // Create invite (admin only)
1898
- if (req.method === "POST" && fullUrl === "/api/admin/invites") {
1899
- if (!users.isMultiUser()) {
1900
- res.writeHead(404, { "Content-Type": "application/json" });
1901
- res.end('{"error":"Not found"}');
1902
- return;
1903
- }
1904
- var mu = getMultiUserFromReq(req);
1905
- if (!mu || mu.role !== "admin") {
1906
- res.writeHead(403, { "Content-Type": "application/json" });
1907
- res.end('{"error":"Admin access required"}');
1908
- return;
1909
- }
1910
- var invite = users.createInvite(mu.id);
1911
- var proto = tlsOptions ? "https" : "http";
1912
- var host = req.headers.host || ("localhost:" + portNum);
1913
- var inviteUrl = proto + "://" + host + "/invite/" + invite.code;
1914
- res.writeHead(200, { "Content-Type": "application/json" });
1915
- res.end(JSON.stringify({ ok: true, invite: invite, url: inviteUrl }));
1916
- return;
1917
- }
1918
-
1919
- // List invites (admin only)
1920
- if (req.method === "GET" && fullUrl === "/api/admin/invites") {
1921
- if (!users.isMultiUser()) {
1922
- res.writeHead(404, { "Content-Type": "application/json" });
1923
- res.end('{"error":"Not found"}');
1924
- return;
1925
- }
1926
- var mu = getMultiUserFromReq(req);
1927
- if (!mu || mu.role !== "admin") {
1928
- res.writeHead(403, { "Content-Type": "application/json" });
1929
- res.end('{"error":"Admin access required"}');
1930
- return;
1931
- }
1932
- res.writeHead(200, { "Content-Type": "application/json" });
1933
- res.end(JSON.stringify({ invites: users.getInvites() }));
1934
- return;
1935
- }
1936
-
1937
- // Revoke invite (admin only)
1938
- if (req.method === "DELETE" && fullUrl.indexOf("/api/admin/invites/") === 0) {
1939
- if (!users.isMultiUser()) {
1940
- res.writeHead(404, { "Content-Type": "application/json" });
1941
- res.end('{"error":"Not found"}');
1942
- return;
1943
- }
1944
- var mu = getMultiUserFromReq(req);
1945
- if (!mu || mu.role !== "admin") {
1946
- res.writeHead(403, { "Content-Type": "application/json" });
1947
- res.end('{"error":"Admin access required"}');
1948
- return;
1949
- }
1950
- var inviteCode = decodeURIComponent(fullUrl.replace("/api/admin/invites/", ""));
1951
- if (!inviteCode) {
1952
- res.writeHead(400, { "Content-Type": "application/json" });
1953
- res.end('{"error":"Invite code is required"}');
1954
- return;
1955
- }
1956
- var result = users.revokeInvite(inviteCode);
1957
- if (result.error) {
1958
- res.writeHead(404, { "Content-Type": "application/json" });
1959
- res.end(JSON.stringify({ error: result.error }));
1960
- return;
1961
- }
1962
- res.writeHead(200, { "Content-Type": "application/json" });
1963
- res.end('{"ok":true}');
1964
- return;
1965
- }
1966
-
1967
- // Send invite via email (admin only)
1968
- if (req.method === "POST" && fullUrl === "/api/admin/invites/email") {
1969
- if (!users.isMultiUser() || !smtp.isSmtpConfigured()) {
1970
- res.writeHead(400, { "Content-Type": "application/json" });
1971
- res.end('{"error":"SMTP not configured"}');
1972
- return;
1973
- }
1974
- var mu = getMultiUserFromReq(req);
1975
- if (!mu || mu.role !== "admin") {
1976
- res.writeHead(403, { "Content-Type": "application/json" });
1977
- res.end('{"error":"Admin access required"}');
1978
- return;
1979
- }
1980
- var body = "";
1981
- req.on("data", function (chunk) { body += chunk; });
1982
- req.on("end", function () {
1983
- try {
1984
- var data = JSON.parse(body);
1985
- if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
1986
- res.writeHead(400, { "Content-Type": "application/json" });
1987
- res.end('{"error":"Valid email is required"}');
1988
- return;
1989
- }
1990
- var invite = users.createInvite(mu.id, data.email);
1991
- var proto = tlsOptions ? "https" : "http";
1992
- var host = req.headers.host || ("localhost:" + portNum);
1993
- var inviteUrl = proto + "://" + host + "/invite/" + invite.code;
1994
- smtp.sendInviteEmail(data.email, inviteUrl, mu.displayName || mu.username).then(function () {
1995
- res.writeHead(200, { "Content-Type": "application/json" });
1996
- res.end(JSON.stringify({ ok: true, invite: invite, url: inviteUrl }));
1997
- }).catch(function (err) {
1998
- res.writeHead(500, { "Content-Type": "application/json" });
1999
- res.end(JSON.stringify({ error: "Failed to send email: " + (err.message || "unknown error") }));
2000
- });
2001
- } catch (e) {
2002
- res.writeHead(400, { "Content-Type": "application/json" });
2003
- res.end('{"error":"Invalid request"}');
2004
- }
2005
- });
2006
- return;
2007
- }
2008
-
2009
- // Get SMTP config (admin only)
2010
- if (req.method === "GET" && fullUrl === "/api/admin/smtp") {
2011
- if (!users.isMultiUser()) {
2012
- res.writeHead(404, { "Content-Type": "application/json" });
2013
- res.end('{"error":"Not found"}');
2014
- return;
2015
- }
2016
- var mu = getMultiUserFromReq(req);
2017
- if (!mu || mu.role !== "admin") {
2018
- res.writeHead(403, { "Content-Type": "application/json" });
2019
- res.end('{"error":"Admin access required"}');
2020
- return;
2021
- }
2022
- var cfg = smtp.getSmtpConfig();
2023
- if (cfg) {
2024
- res.writeHead(200, { "Content-Type": "application/json" });
2025
- res.end(JSON.stringify({ smtp: { host: cfg.host, port: cfg.port, secure: cfg.secure, user: cfg.user, pass: "••••••••", from: cfg.from, emailLoginEnabled: !!cfg.emailLoginEnabled } }));
2026
- } else {
2027
- res.writeHead(200, { "Content-Type": "application/json" });
2028
- res.end('{"smtp":null}');
2029
- }
2030
- return;
2031
- }
2032
-
2033
- // Save SMTP config (admin only)
2034
- if (req.method === "POST" && fullUrl === "/api/admin/smtp") {
2035
- if (!users.isMultiUser()) {
2036
- res.writeHead(404, { "Content-Type": "application/json" });
2037
- res.end('{"error":"Not found"}');
2038
- return;
2039
- }
2040
- var mu = getMultiUserFromReq(req);
2041
- if (!mu || mu.role !== "admin") {
2042
- res.writeHead(403, { "Content-Type": "application/json" });
2043
- res.end('{"error":"Admin access required"}');
2044
- return;
2045
- }
2046
- var body = "";
2047
- req.on("data", function (chunk) { body += chunk; });
2048
- req.on("end", function () {
2049
- try {
2050
- var data = JSON.parse(body);
2051
- // Allow clearing SMTP config by sending empty fields
2052
- if (!data.host && !data.user && !data.pass && !data.from) {
2053
- smtp.saveSmtpConfig(null);
2054
- res.writeHead(200, { "Content-Type": "application/json" });
2055
- res.end('{"ok":true}');
2056
- return;
2057
- }
2058
- if (!data.host || !data.user || !data.pass || !data.from) {
2059
- res.writeHead(400, { "Content-Type": "application/json" });
2060
- res.end('{"error":"Host, user, password, and from address are required"}');
2061
- return;
2062
- }
2063
- // If password is masked, keep existing
2064
- var existingCfg = smtp.getSmtpConfig();
2065
- var pass = data.pass;
2066
- if (pass === "••••••••" && existingCfg) {
2067
- pass = existingCfg.pass;
2068
- }
2069
- smtp.saveSmtpConfig({
2070
- host: data.host,
2071
- port: parseInt(data.port, 10) || 587,
2072
- secure: !!data.secure,
2073
- user: data.user,
2074
- pass: pass,
2075
- from: data.from,
2076
- emailLoginEnabled: !!data.emailLoginEnabled,
2077
- });
2078
- res.writeHead(200, { "Content-Type": "application/json" });
2079
- res.end('{"ok":true}');
2080
- } catch (e) {
2081
- res.writeHead(400, { "Content-Type": "application/json" });
2082
- res.end('{"error":"Invalid request"}');
2083
- }
2084
- });
2085
- return;
2086
- }
2087
-
2088
- // Test SMTP connection (admin only)
2089
- if (req.method === "POST" && fullUrl === "/api/admin/smtp/test") {
2090
- if (!users.isMultiUser()) {
2091
- res.writeHead(404, { "Content-Type": "application/json" });
2092
- res.end('{"error":"Not found"}');
2093
- return;
2094
- }
2095
- var mu = getMultiUserFromReq(req);
2096
- if (!mu || mu.role !== "admin") {
2097
- res.writeHead(403, { "Content-Type": "application/json" });
2098
- res.end('{"error":"Admin access required"}');
2099
- return;
2100
- }
2101
- var body = "";
2102
- req.on("data", function (chunk) { body += chunk; });
2103
- req.on("end", function () {
2104
- try {
2105
- var data = JSON.parse(body);
2106
- // Use provided config or fall back to saved
2107
- var existingCfg = smtp.getSmtpConfig();
2108
- var pass = data.pass;
2109
- if (pass === "••••••••" && existingCfg) {
2110
- pass = existingCfg.pass;
2111
- }
2112
- var cfg = {
2113
- host: data.host || (existingCfg && existingCfg.host),
2114
- port: parseInt(data.port, 10) || (existingCfg && existingCfg.port) || 587,
2115
- secure: data.secure !== undefined ? !!data.secure : (existingCfg && !!existingCfg.secure),
2116
- user: data.user || (existingCfg && existingCfg.user),
2117
- pass: pass || (existingCfg && existingCfg.pass),
2118
- from: data.from || (existingCfg && existingCfg.from),
2119
- };
2120
- if (!cfg.host || !cfg.user || !cfg.pass || !cfg.from) {
2121
- res.writeHead(400, { "Content-Type": "application/json" });
2122
- res.end('{"error":"SMTP configuration is incomplete"}');
2123
- return;
2124
- }
2125
- var testTo = mu.email || cfg.from;
2126
- smtp.sendTestEmail(cfg, testTo).then(function (result) {
2127
- res.writeHead(200, { "Content-Type": "application/json" });
2128
- res.end(JSON.stringify({ ok: true, message: "Test email sent to " + testTo }));
2129
- }).catch(function (err) {
2130
- res.writeHead(500, { "Content-Type": "application/json" });
2131
- res.end(JSON.stringify({ ok: false, error: err.message || "Connection failed" }));
2132
- });
2133
- } catch (e) {
2134
- res.writeHead(400, { "Content-Type": "application/json" });
2135
- res.end('{"error":"Invalid request"}');
2136
- }
2137
- });
2138
- return;
2139
- }
2140
-
2141
- // --- Project access control (admin only, multi-user) ---
2142
-
2143
- // Set project visibility (admin only)
2144
- if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/visibility$/.test(fullUrl)) {
2145
- if (!users.isMultiUser()) {
2146
- res.writeHead(404, { "Content-Type": "application/json" });
2147
- res.end('{"error":"Not found"}');
2148
- return;
2149
- }
2150
- var mu = getMultiUserFromReq(req);
2151
- var _visSlug = fullUrl.split("/")[4];
2152
- var _visAccess = onGetProjectAccess ? onGetProjectAccess(_visSlug) : null;
2153
- var _isOwner = mu && _visAccess && _visAccess.ownerId && mu.id === _visAccess.ownerId;
2154
- if (!mu || (mu.role !== "admin" && !_isOwner)) {
2155
- res.writeHead(403, { "Content-Type": "application/json" });
2156
- res.end('{"error":"Admin or project owner access required"}');
2157
- return;
2158
- }
2159
- var projSlug = fullUrl.split("/")[4];
2160
- if (projSlug.indexOf("--") !== -1) {
2161
- res.writeHead(400, { "Content-Type": "application/json" });
2162
- res.end('{"error":"Worktree projects inherit parent visibility"}');
2163
- return;
2164
- }
2165
- var body = "";
2166
- req.on("data", function (chunk) { body += chunk; });
2167
- req.on("end", function () {
2168
- try {
2169
- var data = JSON.parse(body);
2170
- if (data.visibility !== "public" && data.visibility !== "private") {
2171
- res.writeHead(400, { "Content-Type": "application/json" });
2172
- res.end('{"error":"Visibility must be public or private"}');
2173
- return;
2174
- }
2175
- if (!onSetProjectVisibility) {
2176
- res.writeHead(500, { "Content-Type": "application/json" });
2177
- res.end('{"error":"Visibility handler not configured"}');
2178
- return;
2179
- }
2180
- var result = onSetProjectVisibility(projSlug, data.visibility);
2181
- if (result && result.error) {
2182
- res.writeHead(404, { "Content-Type": "application/json" });
2183
- res.end(JSON.stringify({ error: result.error }));
2184
- return;
2185
- }
2186
- res.writeHead(200, { "Content-Type": "application/json" });
2187
- res.end('{"ok":true}');
2188
- } catch (e) {
2189
- res.writeHead(400, { "Content-Type": "application/json" });
2190
- res.end('{"error":"Invalid request"}');
2191
- }
2192
- });
2193
- return;
2194
- }
2195
-
2196
- // Set project owner (admin only)
2197
- if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/owner$/.test(fullUrl)) {
2198
- if (!users.isMultiUser()) {
2199
- res.writeHead(404, { "Content-Type": "application/json" });
2200
- res.end('{"error":"Not found"}');
2201
- return;
2202
- }
2203
- var mu = getMultiUserFromReq(req);
2204
- if (!mu || mu.role !== "admin") {
2205
- res.writeHead(403, { "Content-Type": "application/json" });
2206
- res.end('{"error":"Admin access required"}');
2207
- return;
2208
- }
2209
- var projSlug = fullUrl.split("/")[4];
2210
- if (projSlug.indexOf("--") !== -1) {
2211
- res.writeHead(400, { "Content-Type": "application/json" });
2212
- res.end('{"error":"Worktree projects inherit parent settings"}');
2213
- return;
2214
- }
2215
- var body = "";
2216
- req.on("data", function (chunk) { body += chunk; });
2217
- req.on("end", function () {
2218
- try {
2219
- var data = JSON.parse(body);
2220
- var targetCtx = projects.get(projSlug);
2221
- if (!targetCtx) {
2222
- res.writeHead(404, { "Content-Type": "application/json" });
2223
- res.end('{"error":"Project not found"}');
2224
- return;
2225
- }
2226
- var ownerId = data.userId || null;
2227
- targetCtx.setProjectOwner(ownerId);
2228
- if (onProjectOwnerChanged) {
2229
- onProjectOwnerChanged(projSlug, ownerId);
2230
- }
2231
- // Broadcast to project clients
2232
- var ownerName = null;
2233
- if (ownerId) {
2234
- var ownerUser = users.findUserById(ownerId);
2235
- ownerName = ownerUser ? (ownerUser.displayName || ownerUser.username) : ownerId;
2236
- }
2237
- targetCtx.send({ type: "project_owner_changed", ownerId: ownerId, ownerName: ownerName });
2238
- res.writeHead(200, { "Content-Type": "application/json" });
2239
- res.end('{"ok":true}');
2240
- } catch (e) {
2241
- res.writeHead(400, { "Content-Type": "application/json" });
2242
- res.end('{"error":"Invalid request"}');
2243
- }
2244
- });
2245
- return;
2246
- }
2247
-
2248
- // Set project allowed users (admin only)
2249
- if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/users$/.test(fullUrl)) {
2250
- if (!users.isMultiUser()) {
2251
- res.writeHead(404, { "Content-Type": "application/json" });
2252
- res.end('{"error":"Not found"}');
2253
- return;
2254
- }
2255
- var mu = getMultiUserFromReq(req);
2256
- var _usrSlug = fullUrl.split("/")[4];
2257
- var _usrAccess = onGetProjectAccess ? onGetProjectAccess(_usrSlug) : null;
2258
- var _isOwnerU = mu && _usrAccess && _usrAccess.ownerId && mu.id === _usrAccess.ownerId;
2259
- if (!mu || (mu.role !== "admin" && !_isOwnerU)) {
2260
- res.writeHead(403, { "Content-Type": "application/json" });
2261
- res.end('{"error":"Admin or project owner access required"}');
2262
- return;
2263
- }
2264
- var projSlug = fullUrl.split("/")[4];
2265
- if (projSlug.indexOf("--") !== -1) {
2266
- res.writeHead(400, { "Content-Type": "application/json" });
2267
- res.end('{"error":"Worktree projects inherit parent settings"}');
2268
- return;
2269
- }
2270
- var body = "";
2271
- req.on("data", function (chunk) { body += chunk; });
2272
- req.on("end", function () {
2273
- try {
2274
- var data = JSON.parse(body);
2275
- if (!Array.isArray(data.allowedUsers)) {
2276
- res.writeHead(400, { "Content-Type": "application/json" });
2277
- res.end('{"error":"allowedUsers must be an array"}');
2278
- return;
2279
- }
2280
- if (!onSetProjectAllowedUsers) {
2281
- res.writeHead(500, { "Content-Type": "application/json" });
2282
- res.end('{"error":"AllowedUsers handler not configured"}');
2283
- return;
2284
- }
2285
- var result = onSetProjectAllowedUsers(projSlug, data.allowedUsers);
2286
- if (result && result.error) {
2287
- res.writeHead(404, { "Content-Type": "application/json" });
2288
- res.end(JSON.stringify({ error: result.error }));
2289
- return;
2290
- }
2291
- res.writeHead(200, { "Content-Type": "application/json" });
2292
- res.end('{"ok":true}');
2293
- } catch (e) {
2294
- res.writeHead(400, { "Content-Type": "application/json" });
2295
- res.end('{"error":"Invalid request"}');
2296
- }
2297
- });
2298
- return;
2299
- }
2300
-
2301
- // Get project access info (admin or project owner)
2302
- if (req.method === "GET" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/access$/.test(fullUrl)) {
2303
- if (!users.isMultiUser()) {
2304
- res.writeHead(404, { "Content-Type": "application/json" });
2305
- res.end('{"error":"Not found"}');
2306
- return;
2307
- }
2308
- var mu = getMultiUserFromReq(req);
2309
- var _accSlug = fullUrl.split("/")[4];
2310
- var _accAccess = onGetProjectAccess ? onGetProjectAccess(_accSlug) : null;
2311
- var _isOwnerA = mu && _accAccess && _accAccess.ownerId && mu.id === _accAccess.ownerId;
2312
- if (!mu || (mu.role !== "admin" && !_isOwnerA)) {
2313
- res.writeHead(403, { "Content-Type": "application/json" });
2314
- res.end('{"error":"Admin or project owner access required"}');
2315
- return;
2316
- }
2317
- var projSlug = fullUrl.split("/")[4];
2318
- if (!onGetProjectAccess) {
2319
- res.writeHead(500, { "Content-Type": "application/json" });
2320
- res.end('{"error":"Access handler not configured"}');
2321
- return;
2322
- }
2323
- var access = onGetProjectAccess(projSlug);
2324
- if (access && access.error) {
2325
- res.writeHead(404, { "Content-Type": "application/json" });
2326
- res.end(JSON.stringify({ error: access.error }));
2327
- return;
2328
- }
2329
- res.writeHead(200, { "Content-Type": "application/json" });
2330
- res.end(JSON.stringify(access));
356
+ } catch (e) {
357
+ res.writeHead(400);
358
+ res.end("Bad request");
359
+ }
360
+ });
2331
361
  return;
2332
362
  }
2333
363
 
2334
- // Command palette: cross-project session search
2335
- if (req.method === "GET" && fullUrl === "/api/palette/search") {
2336
- var paletteUser = null;
2337
- if (users.isMultiUser()) {
2338
- paletteUser = getMultiUserFromReq(req);
2339
- if (!paletteUser) {
2340
- res.writeHead(401, { "Content-Type": "application/json" });
2341
- res.end('{"error":"unauthorized"}');
2342
- return;
2343
- }
2344
- }
2345
- var pqs = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
2346
- var pQuery = new URLSearchParams(pqs).get("q") || "";
2347
- var pResults = [];
2348
-
2349
- if (!pQuery) {
2350
- // Recent mode: return all sessions sorted by lastActivity
2351
- projects.forEach(function (pCtx, pSlug) {
2352
- var status = pCtx.getStatus();
2353
- if (status.isWorktree) return;
2354
- if (paletteUser && onGetProjectAccess) {
2355
- var pAccess = onGetProjectAccess(pSlug);
2356
- if (pAccess && !pAccess.error && !users.canAccessProject(paletteUser.id, pAccess)) return;
2357
- }
2358
- pCtx.sm.sessions.forEach(function (session) {
2359
- if (session.hidden) return;
2360
- if (paletteUser) {
2361
- if (users.isMultiUser()) {
2362
- var sAccess = onGetProjectAccess ? onGetProjectAccess(pSlug) : null;
2363
- if (!users.canAccessSession(paletteUser.id, session, sAccess)) return;
2364
- }
2365
- } else {
2366
- if (session.ownerId) return;
2367
- }
2368
- var pItem = {
2369
- projectSlug: pSlug,
2370
- projectTitle: status.title || status.project,
2371
- projectIcon: status.icon || null,
2372
- sessionId: session.localId,
2373
- sessionTitle: session.title || "New Session",
2374
- lastActivity: session.lastActivity || session.createdAt || 0,
2375
- matchType: null,
2376
- snippet: null
2377
- };
2378
- if (status.isMate) {
2379
- pItem.isMate = true;
2380
- pItem.mateId = status.mateId || null;
2381
- }
2382
- pResults.push(pItem);
2383
- });
2384
- });
2385
- pResults.sort(function (a, b) { return b.lastActivity - a.lastActivity; });
2386
- if (pResults.length > 30) pResults = pResults.slice(0, 30);
2387
- } else {
2388
- // Search mode: BM25 ranked search across all sessions
2389
- var projectSessions = [];
2390
- projects.forEach(function (pCtx, pSlug) {
2391
- var status = pCtx.getStatus();
2392
- if (status.isWorktree) return;
2393
- if (paletteUser && onGetProjectAccess) {
2394
- var pAccess = onGetProjectAccess(pSlug);
2395
- if (pAccess && !pAccess.error && !users.canAccessProject(paletteUser.id, pAccess)) return;
2396
- }
2397
- var accessibleSessions = [];
2398
- pCtx.sm.sessions.forEach(function (session) {
2399
- if (session.hidden) return;
2400
- if (paletteUser) {
2401
- if (users.isMultiUser()) {
2402
- var sAccess = onGetProjectAccess ? onGetProjectAccess(pSlug) : null;
2403
- if (!users.canAccessSession(paletteUser.id, session, sAccess)) return;
2404
- }
2405
- } else {
2406
- if (session.ownerId) return;
2407
- }
2408
- accessibleSessions.push(session);
2409
- });
2410
- if (accessibleSessions.length > 0) {
2411
- projectSessions.push({
2412
- projectSlug: pSlug,
2413
- projectTitle: status.title || status.project,
2414
- projectIcon: status.icon || null,
2415
- isMate: status.isMate || false,
2416
- mateId: status.mateId || null,
2417
- sessions: accessibleSessions
2418
- });
364
+ // Health check endpoint
365
+ // Unauthenticated: minimal liveness info only
366
+ // Authenticated: full system details (memory, pid, version, sessions)
367
+ if (req.method === "GET" && fullUrl === "/api/health") {
368
+ var health = {
369
+ status: "ok",
370
+ timestamp: new Date().toISOString(),
371
+ };
372
+ if (isRequestAuthed(req)) {
373
+ var mem = process.memoryUsage();
374
+ var activeSessions = 0;
375
+ projects.forEach(function (ctx) {
376
+ if (ctx && ctx.clients) {
377
+ activeSessions += ctx.clients.size || 0;
2419
378
  }
2420
379
  });
2421
- pResults = sessionSearch.searchPalette(projectSessions, pQuery, { maxResults: 30 });
380
+ health.uptime = process.uptime();
381
+ health.version = pkg.version;
382
+ health.node = process.version;
383
+ health.sessions = activeSessions;
384
+ health.projects = projects.size;
385
+ health.memory = {
386
+ rss: mem.rss,
387
+ heapUsed: mem.heapUsed,
388
+ heapTotal: mem.heapTotal,
389
+ };
390
+ health.pid = process.pid;
2422
391
  }
392
+ res.writeHead(200, { "Content-Type": "application/json" });
393
+ res.end(JSON.stringify(health));
394
+ return;
395
+ }
2423
396
 
397
+ // Theme list: bundled (lib/themes/) + user (~/.clay/themes/)
398
+ if (req.method === "GET" && fullUrl === "/api/themes") {
399
+ var bundled = {};
400
+ var custom = {};
401
+ // Read bundled themes
402
+ try {
403
+ var bFiles = fs.readdirSync(bundledThemesDir);
404
+ for (var i = 0; i < bFiles.length; i++) {
405
+ if (!bFiles[i].endsWith(".json")) continue;
406
+ try {
407
+ var raw = fs.readFileSync(path.join(bundledThemesDir, bFiles[i]), "utf8");
408
+ var id = bFiles[i].replace(/\.json$/, "");
409
+ bundled[id] = JSON.parse(raw);
410
+ } catch (e) {}
411
+ }
412
+ } catch (e) {}
413
+ // Read user themes (override bundled if same id)
414
+ try {
415
+ var uFiles = fs.readdirSync(userThemesDir);
416
+ for (var j = 0; j < uFiles.length; j++) {
417
+ if (!uFiles[j].endsWith(".json")) continue;
418
+ try {
419
+ var uRaw = fs.readFileSync(path.join(userThemesDir, uFiles[j]), "utf8");
420
+ var uid = uFiles[j].replace(/\.json$/, "");
421
+ custom[uid] = JSON.parse(uRaw);
422
+ } catch (e) {}
423
+ }
424
+ } catch (e) {}
2424
425
  res.writeHead(200, { "Content-Type": "application/json" });
2425
- res.end(JSON.stringify({ results: pResults }));
426
+ res.end(JSON.stringify({ bundled: bundled, custom: custom }));
2426
427
  return;
2427
428
  }
2428
429
 
430
+ if (settings.handleRequest(req, res, fullUrl)) return;
431
+
432
+ // --- Admin API endpoints (multi-user mode only) ---
433
+ if (admin.handleRequest(req, res, fullUrl)) return;
434
+
435
+ // --- Palette search (delegated to server-palette) ---
436
+ if (palette.handleRequest(req, res, fullUrl)) return;
437
+
2429
438
  // Multi-user info endpoint (who am I?)
2430
439
  if (req.method === "GET" && fullUrl === "/api/me") {
2431
440
  if (!users.isMultiUser()) {
@@ -2447,121 +456,14 @@ function createServer(opts) {
2447
456
  return;
2448
457
  }
2449
458
 
2450
- // Skills proxy: permission gate
2451
- if (fullUrl === "/api/skills" || fullUrl.startsWith("/api/skills/") || fullUrl.startsWith("/api/skills?")) {
2452
- if (users.isMultiUser()) {
2453
- var skMu = getMultiUserFromReq(req);
2454
- if (skMu) {
2455
- var skPerms = users.getEffectivePermissions(skMu, osUsers);
2456
- if (!skPerms.skills) {
2457
- res.writeHead(403, { "Content-Type": "application/json" });
2458
- res.end('{"error":"Skills access is not permitted"}');
2459
- return;
2460
- }
2461
- }
2462
- }
2463
- }
2464
-
2465
- // Skills proxy: leaderboard list
2466
- if (req.method === "GET" && fullUrl === "/api/skills") {
2467
- var qs = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
2468
- var tabParam = new URLSearchParams(qs).get("tab") || "all";
2469
- var tabPath = tabParam === "trending" ? "/trending" : tabParam === "hot" ? "/hot" : "/";
2470
- var cacheKey = "skills_" + tabParam;
2471
- var cached = skillsCache[cacheKey];
2472
- if (cached && Date.now() - cached.ts < 300000) {
2473
- res.writeHead(200, { "Content-Type": "application/json" });
2474
- res.end(cached.data);
2475
- return;
2476
- }
2477
- fetchSkillsPage("https://skills.sh" + tabPath).then(function (data) {
2478
- var json = JSON.stringify(data);
2479
- skillsCache[cacheKey] = { ts: Date.now(), data: json };
2480
- res.writeHead(200, { "Content-Type": "application/json" });
2481
- res.end(json);
2482
- }).catch(function (err) {
2483
- res.writeHead(502, { "Content-Type": "application/json" });
2484
- res.end(JSON.stringify({ error: "Failed to fetch skills: " + (err.message || err) }));
2485
- });
2486
- return;
2487
- }
2488
-
2489
- // Skills proxy: search
2490
- if (req.method === "GET" && fullUrl.startsWith("/api/skills/search")) {
2491
- var sqsRaw = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
2492
- var searchQ = new URLSearchParams(sqsRaw).get("q") || "";
2493
- if (!searchQ) {
2494
- res.writeHead(400, { "Content-Type": "application/json" });
2495
- res.end('{"error":"missing q param"}');
2496
- return;
2497
- }
2498
- var searchCacheKey = "search_" + searchQ.toLowerCase();
2499
- var searchCached = skillsCache[searchCacheKey];
2500
- if (searchCached && Date.now() - searchCached.ts < 300000) {
2501
- res.writeHead(200, { "Content-Type": "application/json" });
2502
- res.end(searchCached.data);
2503
- return;
2504
- }
2505
- // Reuse cached "all" tab data if available, otherwise fetch
2506
- var allCached = skillsCache["skills_all"];
2507
- var allPromise = (allCached && Date.now() - allCached.ts < 300000)
2508
- ? Promise.resolve(JSON.parse(allCached.data))
2509
- : fetchSkillsPage("https://skills.sh/");
2510
- allPromise.then(function (data) {
2511
- var q = searchQ.toLowerCase();
2512
- var filtered = (data.skills || []).filter(function (s) {
2513
- var name = (s.name || "").toLowerCase();
2514
- var source = (s.source || "").toLowerCase();
2515
- var skillId = (s.skillId || "").toLowerCase();
2516
- return name.indexOf(q) >= 0 || source.indexOf(q) >= 0 || skillId.indexOf(q) >= 0;
2517
- });
2518
- var json = JSON.stringify({ skills: filtered });
2519
- skillsCache[searchCacheKey] = { ts: Date.now(), data: json };
2520
- res.writeHead(200, { "Content-Type": "application/json" });
2521
- res.end(json);
2522
- }).catch(function (err) {
2523
- res.writeHead(502, { "Content-Type": "application/json" });
2524
- res.end(JSON.stringify({ error: "Failed to search skills: " + (err.message || err) }));
2525
- });
2526
- return;
2527
- }
2528
-
2529
- // Skills proxy: skill detail
2530
- if (req.method === "GET" && fullUrl.startsWith("/api/skills/detail")) {
2531
- var qs2 = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
2532
- var params2 = new URLSearchParams(qs2);
2533
- var detailSource = params2.get("source");
2534
- var detailSkill = params2.get("skill");
2535
- if (!detailSource || !detailSkill) {
2536
- res.writeHead(400, { "Content-Type": "application/json" });
2537
- res.end('{"error":"missing source or skill param"}');
2538
- return;
2539
- }
2540
- var detailCacheKey = "detail_" + detailSource + "_" + detailSkill;
2541
- var detailCached = skillsCache[detailCacheKey];
2542
- if (detailCached && Date.now() - detailCached.ts < 300000) {
2543
- res.writeHead(200, { "Content-Type": "application/json" });
2544
- res.end(detailCached.data);
2545
- return;
2546
- }
2547
- var detailUrl = "https://skills.sh/" + encodeURIComponent(detailSource).replace(/%2F/g, "/") + "/" + encodeURIComponent(detailSkill);
2548
- fetchSkillDetail(detailUrl).then(function (data) {
2549
- var json = JSON.stringify(data);
2550
- skillsCache[detailCacheKey] = { ts: Date.now(), data: json };
2551
- res.writeHead(200, { "Content-Type": "application/json" });
2552
- res.end(json);
2553
- }).catch(function (err) {
2554
- res.writeHead(502, { "Content-Type": "application/json" });
2555
- res.end(JSON.stringify({ error: "Failed to fetch skill detail: " + (err.message || err) }));
2556
- });
2557
- return;
2558
- }
459
+ // --- Skills routes (delegated to server-skills) ---
460
+ if (skills.handleRequest(req, res, fullUrl)) return;
2559
461
 
2560
462
  // Root path — redirect to first accessible project
2561
463
  if (fullUrl === "/" && req.method === "GET") {
2562
464
  if (!isRequestAuthed(req)) {
2563
465
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2564
- res.end(getAuthPage());
466
+ res.end(auth.getAuthPage());
2565
467
  return;
2566
468
  }
2567
469
  if (projects.size > 0) {
@@ -2602,7 +504,7 @@ function createServer(opts) {
2602
504
  // No accessible projects — show info page
2603
505
  if (users.isMultiUser()) {
2604
506
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2605
- res.end(noProjectsPageHtml());
507
+ res.end(pages.noProjectsPageHtml());
2606
508
  return;
2607
509
  }
2608
510
  res.writeHead(200, { "Content-Type": "text/plain" });
@@ -2660,7 +562,7 @@ function createServer(opts) {
2660
562
  // Auth check for project routes
2661
563
  if (!isRequestAuthed(req)) {
2662
564
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2663
- res.end(getAuthPage());
565
+ res.end(auth.getAuthPage());
2664
566
  return;
2665
567
  }
2666
568
 
@@ -2739,7 +641,7 @@ function createServer(opts) {
2739
641
  var httpSetupUrl = "http://" + hostname + ":" + (portNum + 1);
2740
642
  var lanMode = /[?&]mode=lan/.test(req.url);
2741
643
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2742
- res.end(setupPageHtml(httpsSetupUrl, httpSetupUrl, !!caContent, lanMode));
644
+ res.end(pages.setupPageHtml(httpsSetupUrl, httpSetupUrl, !!caContent, lanMode));
2743
645
  return;
2744
646
  }
2745
647
 
@@ -3091,6 +993,9 @@ function createServer(opts) {
3091
993
  onShutdown: onShutdown,
3092
994
  onRestart: onRestart,
3093
995
  onDmMessage: handleDmMessage,
996
+ broadcastAll: broadcastAll,
997
+ notificationsModule: _globalNotifications,
998
+ getProject: function (s) { return projects.get(s) || null; },
3094
999
  });
3095
1000
  projects.set(slug, ctx);
3096
1001
  ctx.warmup();
@@ -3099,446 +1004,26 @@ function createServer(opts) {
3099
1004
  return true;
3100
1005
  }
3101
1006
 
3102
- // --- DM message handler (server-level, cross-project) ---
3103
- function handleDmMessage(ws, msg) {
3104
- if (!users.isMultiUser() || !ws._clayUser) return;
3105
- var userId = ws._clayUser.id;
3106
-
3107
- if (msg.type === "dm_list") {
3108
- var dmList = dm.getDmList(userId);
3109
- // Enrich with user info
3110
- for (var i = 0; i < dmList.length; i++) {
3111
- var otherUser = users.findUserById(dmList[i].otherUserId);
3112
- if (otherUser) {
3113
- var p = otherUser.profile || {};
3114
- dmList[i].otherUser = {
3115
- id: otherUser.id,
3116
- displayName: p.name || otherUser.displayName || otherUser.username,
3117
- username: otherUser.username,
3118
- avatarStyle: p.avatarStyle || "thumbs",
3119
- avatarSeed: p.avatarSeed || otherUser.username,
3120
- avatarColor: p.avatarColor || "#7c3aed",
3121
- avatarCustom: p.avatarCustom || "",
3122
- };
3123
- }
3124
- }
3125
- // Include mates in the list
3126
- var mateCtx = mates.buildMateCtx(userId);
3127
- var mateList = mates.getAllMates(mateCtx);
3128
- ws.send(JSON.stringify({ type: "dm_list", dms: dmList, mates: mateList }));
3129
- return;
3130
- }
3131
-
3132
- if (msg.type === "dm_open") {
3133
- if (!msg.targetUserId) return;
3134
-
3135
- // Check if target is a mate
3136
- var mateCtx2 = mates.buildMateCtx(userId);
3137
- if (mates.isMate(mateCtx2, msg.targetUserId)) {
3138
- var mate = mates.getMate(mateCtx2, msg.targetUserId);
3139
- if (!mate) return;
3140
- // Ensure mate project is registered (survives server restarts)
3141
- var mateSlug2 = "mate-" + mate.id;
3142
- if (!projects.has(mateSlug2)) {
3143
- var mateDir2 = mates.getMateDir(mateCtx2, mate.id);
3144
- fs.mkdirSync(mateDir2, { recursive: true });
3145
- var mateName2 = (mate.profile && mate.profile.displayName) || mate.name || "New Mate";
3146
- addProject(mateDir2, mateSlug2, mateName2, null, mate.createdBy || userId, null, { isMate: true, mateDisplayName: mateName2 });
3147
- }
3148
- var mp = mate.profile || {};
3149
- ws.send(JSON.stringify({
3150
- type: "dm_history",
3151
- dmKey: "mate:" + mate.id,
3152
- messages: dm.loadHistory("mate:" + mate.id),
3153
- isMate: true,
3154
- projectSlug: mateSlug2,
3155
- targetUser: {
3156
- id: mate.id,
3157
- displayName: mp.displayName || mate.name || "New Mate",
3158
- username: mate.id,
3159
- avatarStyle: mp.avatarStyle || "bottts",
3160
- avatarSeed: mp.avatarSeed || mate.id,
3161
- avatarColor: mp.avatarColor || "#6c5ce7",
3162
- avatarCustom: mp.avatarCustom || "",
3163
- isMate: true,
3164
- primary: !!mate.primary,
3165
- mateStatus: mate.status,
3166
- seedData: mate.seedData || {},
3167
- },
3168
- }));
3169
- return;
3170
- }
3171
-
3172
- var result = dm.openDm(userId, msg.targetUserId);
3173
- var targetUser = users.findUserById(msg.targetUserId);
3174
- var tp = targetUser ? (targetUser.profile || {}) : {};
3175
- ws.send(JSON.stringify({
3176
- type: "dm_history",
3177
- dmKey: result.dmKey,
3178
- messages: result.messages,
3179
- targetUser: targetUser ? {
3180
- id: targetUser.id,
3181
- displayName: tp.name || targetUser.displayName || targetUser.username,
3182
- username: targetUser.username,
3183
- avatarStyle: tp.avatarStyle || "thumbs",
3184
- avatarSeed: tp.avatarSeed || targetUser.username,
3185
- avatarColor: tp.avatarColor || "#7c3aed",
3186
- avatarCustom: tp.avatarCustom || "",
3187
- } : null,
3188
- }));
3189
- return;
3190
- }
3191
-
3192
- if (msg.type === "dm_typing") {
3193
- // Relay typing indicator to DM partner
3194
- var dmKey = msg.dmKey;
3195
- if (!dmKey) return;
3196
- var parts = dmKey.split(":");
3197
- if (parts.indexOf(userId) === -1) return;
3198
- var targetId = parts[0] === userId ? parts[1] : parts[0];
3199
- projects.forEach(function (ctx) {
3200
- ctx.forEachClient(function (otherWs) {
3201
- if (otherWs === ws) return;
3202
- if (!otherWs._clayUser || otherWs._clayUser.id !== targetId) return;
3203
- if (otherWs.readyState !== 1) return;
3204
- otherWs.send(JSON.stringify({ type: "dm_typing", dmKey: dmKey, userId: userId, typing: !!msg.typing }));
3205
- });
3206
- });
3207
- return;
3208
- }
3209
-
3210
- if (msg.type === "dm_send") {
3211
- if (!msg.dmKey || !msg.text) return;
3212
- var parts = msg.dmKey.split(":");
3213
-
3214
- // Handle mate DM: dmKey is "mate:mate_xxx"
3215
- var mateCtx3 = mates.buildMateCtx(userId);
3216
- if (parts[0] === "mate" && mates.isMate(mateCtx3, parts[1])) {
3217
- var mate = mates.getMate(mateCtx3, parts[1]);
3218
- if (!mate) return;
3219
- // Verify sender is the mate's creator
3220
- if (mate.createdBy !== userId) return;
3221
- var message = dm.sendMessage(msg.dmKey, userId, msg.text);
3222
- ws.send(JSON.stringify({ type: "dm_message", dmKey: msg.dmKey, message: message }));
3223
- return;
3224
- }
3225
-
3226
- // Regular DM: verify sender is a participant
3227
- if (parts.indexOf(userId) === -1) return;
3228
- var message = dm.sendMessage(msg.dmKey, userId, msg.text);
3229
- // Send confirmation to sender
3230
- ws.send(JSON.stringify({ type: "dm_message", dmKey: msg.dmKey, message: message }));
3231
- // Broadcast to target user's connections across all projects
3232
- var targetId = parts[0] === userId ? parts[1] : parts[0];
3233
- projects.forEach(function (ctx) {
3234
- ctx.forEachClient(function (otherWs) {
3235
- if (otherWs === ws) return;
3236
- if (!otherWs._clayUser || otherWs._clayUser.id !== targetId) return;
3237
- if (otherWs.readyState !== 1) return;
3238
- otherWs.send(JSON.stringify({ type: "dm_message", dmKey: msg.dmKey, message: message }));
3239
- });
3240
- });
3241
- // Send push notification to target user
3242
- if (pushModule && pushModule.sendPushToUser) {
3243
- var senderName = ws._clayUser ? (ws._clayUser.displayName || ws._clayUser.username || "Someone") : "Someone";
3244
- var preview = (msg.text || "").substring(0, 140);
3245
- pushModule.sendPushToUser(targetId, {
3246
- type: "dm",
3247
- title: senderName,
3248
- body: preview,
3249
- tag: "dm-" + msg.dmKey,
3250
- dmKey: msg.dmKey,
3251
- });
3252
- }
3253
- return;
3254
- }
3255
-
3256
- if (msg.type === "dm_add_favorite") {
3257
- if (!msg.targetUserId) return;
3258
- users.removeDmHidden(userId, msg.targetUserId);
3259
- var updatedFavorites = users.addDmFavorite(userId, msg.targetUserId);
3260
- var allUsersList = users.getAllUsers().map(function (u) {
3261
- var p = u.profile || {};
3262
- return {
3263
- id: u.id,
3264
- displayName: p.name || u.displayName || u.username,
3265
- username: u.username,
3266
- role: u.role,
3267
- avatarStyle: p.avatarStyle || "thumbs",
3268
- avatarSeed: p.avatarSeed || u.username,
3269
- avatarColor: p.avatarColor || "#7c3aed",
3270
- avatarCustom: p.avatarCustom || "",
3271
- };
3272
- });
3273
- ws.send(JSON.stringify({
3274
- type: "dm_favorites_updated",
3275
- dmFavorites: updatedFavorites,
3276
- allUsers: allUsersList,
3277
- }));
3278
- return;
3279
- }
3280
-
3281
- if (msg.type === "dm_remove_favorite") {
3282
- if (!msg.targetUserId) return;
3283
- users.addDmHidden(userId, msg.targetUserId);
3284
- var updatedFavorites = users.removeDmFavorite(userId, msg.targetUserId);
3285
- ws.send(JSON.stringify({
3286
- type: "dm_favorites_updated",
3287
- dmFavorites: updatedFavorites,
3288
- }));
3289
- return;
3290
- }
3291
-
3292
- // --- Mate handlers ---
3293
-
3294
- if (msg.type === "mate_create") {
3295
- if (!msg.seedData) return;
3296
- try {
3297
- var mateCtx4 = mates.buildMateCtx(userId);
3298
- var mate = mates.createMate(mateCtx4, msg.seedData);
3299
- // Register mate as a project
3300
- var mateDir = mates.getMateDir(mateCtx4, mate.id);
3301
- var mateSlug = "mate-" + mate.id;
3302
- var mateName = (mate.profile && mate.profile.displayName) || mate.name || "New Mate";
3303
- addProject(mateDir, mateSlug, mateName, null, mate.createdBy, null, { isMate: true, mateDisplayName: mateName });
3304
- // Auto-add to favorites so it shows in sidebar
3305
- users.addDmFavorite(userId, mate.id);
3306
- ws.send(JSON.stringify({ type: "mate_created", mate: mate, projectSlug: mateSlug }));
3307
- } catch (e) {
3308
- ws.send(JSON.stringify({ type: "mate_error", error: "Failed to create mate: " + e.message }));
3309
- }
3310
- return;
3311
- }
3312
-
3313
- if (msg.type === "mate_list") {
3314
- var mateCtx5 = mates.buildMateCtx(userId);
3315
- // Backfill built-in mates for existing users
3316
- try {
3317
- var deletedKeys = users.getDeletedBuiltinKeys(userId);
3318
- var newBuiltins = mates.ensureBuiltinMates(mateCtx5, deletedKeys);
3319
- for (var bi = 0; bi < newBuiltins.length; bi++) {
3320
- var nb = newBuiltins[bi];
3321
- var nbSlug = "mate-" + nb.id;
3322
- var nbDir = mates.getMateDir(mateCtx5, nb.id);
3323
- var nbName = (nb.profile && nb.profile.displayName) || nb.name || "New Mate";
3324
- addProject(nbDir, nbSlug, nbName, null, nb.createdBy || userId, null, { isMate: true, mateDisplayName: nbName });
3325
- users.addDmFavorite(userId, nb.id);
3326
- }
3327
- } catch (e) {
3328
- console.error("[server] Failed to ensure built-in mates:", e.message);
3329
- }
3330
- // Auto-sync primary mates (Ally) with latest definition
3331
- try { mates.syncPrimaryMates(mateCtx5); } catch (e) {}
3332
- // Ensure core built-in mates are in favorites (unless user explicitly removed them)
3333
- // Only auto-favorite the core 3: Ally (chief of staff), Arch (architect), Buzz (marketer)
3334
- var coreMateKeys = ["ally", "arch", "buzz"];
3335
- var mateList = mates.getAllMates(mateCtx5);
3336
- var currentFavs = users.getDmFavorites(userId);
3337
- var hiddenIds = users.getDmHidden(userId);
3338
- for (var bfi = 0; bfi < mateList.length; bfi++) {
3339
- if (mateList[bfi].builtinKey && coreMateKeys.indexOf(mateList[bfi].builtinKey) !== -1 && currentFavs.indexOf(mateList[bfi].id) === -1 && hiddenIds.indexOf(mateList[bfi].id) === -1) {
3340
- users.addDmFavorite(userId, mateList[bfi].id);
3341
- }
3342
- }
3343
- // Ensure all mate projects are registered (survives server restarts)
3344
- for (var mi = 0; mi < mateList.length; mi++) {
3345
- var m = mateList[mi];
3346
- var mSlug = "mate-" + m.id;
3347
- if (!projects.has(mSlug)) {
3348
- var mDir = mates.getMateDir(mateCtx5, m.id);
3349
- fs.mkdirSync(mDir, { recursive: true });
3350
- var mName = (m.profile && m.profile.displayName) || m.name || "New Mate";
3351
- addProject(mDir, mSlug, mName, null, m.createdBy || userId, null, { isMate: true, mateDisplayName: mName });
3352
- }
3353
- }
3354
- // Include deleted built-in mates for re-add UI
3355
- var builtinDefs2 = require("./builtin-mates");
3356
- var missingKeys2 = mates.getMissingBuiltinKeys(mateCtx5);
3357
- var availableBuiltins2 = [];
3358
- for (var abk2 = 0; abk2 < missingKeys2.length; abk2++) {
3359
- var bDef2 = builtinDefs2.getBuiltinByKey(missingKeys2[abk2]);
3360
- if (bDef2) {
3361
- availableBuiltins2.push({
3362
- key: bDef2.key,
3363
- displayName: bDef2.displayName,
3364
- bio: bDef2.bio,
3365
- avatarCustom: bDef2.avatarCustom || "",
3366
- avatarStyle: bDef2.avatarStyle || "bottts",
3367
- avatarColor: bDef2.avatarColor || "",
3368
- });
3369
- }
3370
- }
3371
- ws.send(JSON.stringify({ type: "mate_list", mates: mateList, availableBuiltins: availableBuiltins2 }));
3372
- return;
3373
- }
3374
-
3375
- if (msg.type === "mate_delete") {
3376
- if (!msg.mateId) return;
3377
- var mateCtx6 = mates.buildMateCtx(userId);
3378
- // Track deleted built-in mate key so it doesn't auto-recreate
3379
- var mateToDelete = mates.getMate(mateCtx6, msg.mateId);
3380
- if (mateToDelete && mateToDelete.builtinKey) {
3381
- users.addDeletedBuiltinKey(userId, mateToDelete.builtinKey);
3382
- }
3383
- var result = mates.deleteMate(mateCtx6, msg.mateId);
3384
- if (result.error) {
3385
- ws.send(JSON.stringify({ type: "mate_error", error: result.error }));
3386
- } else {
3387
- removeProject("mate-" + msg.mateId);
3388
- // Build updated available builtins list
3389
- var builtinDefs3 = require("./builtin-mates");
3390
- var missingKeys3 = mates.getMissingBuiltinKeys(mateCtx6);
3391
- var availableBuiltins3 = [];
3392
- for (var abk3 = 0; abk3 < missingKeys3.length; abk3++) {
3393
- var bDef3 = builtinDefs3.getBuiltinByKey(missingKeys3[abk3]);
3394
- if (bDef3) {
3395
- availableBuiltins3.push({
3396
- key: bDef3.key,
3397
- displayName: bDef3.displayName,
3398
- bio: bDef3.bio,
3399
- avatarCustom: bDef3.avatarCustom || "",
3400
- avatarStyle: bDef3.avatarStyle || "bottts",
3401
- avatarColor: bDef3.avatarColor || "",
3402
- });
3403
- }
3404
- }
3405
- ws.send(JSON.stringify({ type: "mate_deleted", mateId: msg.mateId, availableBuiltins: availableBuiltins3 }));
3406
- // Broadcast to all clients so strips update
3407
- projects.forEach(function (ctx) {
3408
- ctx.forEachClient(function (otherWs) {
3409
- if (otherWs === ws) return;
3410
- if (otherWs.readyState !== 1) return;
3411
- otherWs.send(JSON.stringify({ type: "mate_deleted", mateId: msg.mateId, availableBuiltins: availableBuiltins3 }));
3412
- });
3413
- });
3414
- }
3415
- return;
3416
- }
3417
-
3418
- if (msg.type === "mate_readd_builtin") {
3419
- if (!msg.builtinKey) return;
3420
- try {
3421
- var mateCtxR = mates.buildMateCtx(userId);
3422
- var missingKeys = mates.getMissingBuiltinKeys(mateCtxR);
3423
- if (missingKeys.indexOf(msg.builtinKey) === -1) {
3424
- ws.send(JSON.stringify({ type: "mate_error", error: "This built-in mate already exists" }));
3425
- return;
3426
- }
3427
- var newMate = mates.createBuiltinMate(mateCtxR, msg.builtinKey);
3428
- users.removeDeletedBuiltinKey(userId, msg.builtinKey);
3429
- var updatedFavsR = users.addDmFavorite(userId, newMate.id);
3430
- var readdSlug = "mate-" + newMate.id;
3431
- var readdDir = mates.getMateDir(mateCtxR, newMate.id);
3432
- var readdName = (newMate.profile && newMate.profile.displayName) || newMate.name || "New Mate";
3433
- addProject(readdDir, readdSlug, readdName, null, newMate.createdBy || userId, null, { isMate: true, mateDisplayName: readdName });
3434
- // Build updated available builtins
3435
- var builtinDefsR = require("./builtin-mates");
3436
- var missingKeysR = mates.getMissingBuiltinKeys(mateCtxR);
3437
- var availableBuiltinsR = [];
3438
- for (var abkR = 0; abkR < missingKeysR.length; abkR++) {
3439
- var bDefR = builtinDefsR.getBuiltinByKey(missingKeysR[abkR]);
3440
- if (bDefR) {
3441
- availableBuiltinsR.push({ key: bDefR.key, displayName: bDefR.displayName, bio: bDefR.bio, avatarCustom: bDefR.avatarCustom || "", avatarStyle: bDefR.avatarStyle || "bottts", avatarColor: bDefR.avatarColor || "" });
3442
- }
3443
- }
3444
- ws.send(JSON.stringify({ type: "mate_created", mate: newMate, projectSlug: readdSlug, availableBuiltins: availableBuiltinsR, dmFavorites: updatedFavsR }));
3445
- } catch (e) {
3446
- ws.send(JSON.stringify({ type: "mate_error", error: "Failed to re-add built-in mate: " + e.message }));
3447
- }
3448
- return;
3449
- }
3450
-
3451
- if (msg.type === "mate_list_available_builtins") {
3452
- var mateCtxAB = mates.buildMateCtx(userId);
3453
- var missingBuiltinKeys = mates.getMissingBuiltinKeys(mateCtxAB);
3454
- var builtinDefs = require("./builtin-mates");
3455
- var availableBuiltins = [];
3456
- for (var abk = 0; abk < missingBuiltinKeys.length; abk++) {
3457
- var bDef = builtinDefs.getBuiltinByKey(missingBuiltinKeys[abk]);
3458
- if (bDef) {
3459
- availableBuiltins.push({
3460
- key: bDef.key,
3461
- displayName: bDef.displayName,
3462
- bio: bDef.bio,
3463
- avatarColor: bDef.avatarColor,
3464
- avatarStyle: bDef.avatarStyle,
3465
- avatarCustom: bDef.avatarCustom || "",
3466
- });
3467
- }
3468
- }
3469
- ws.send(JSON.stringify({ type: "mate_available_builtins", builtins: availableBuiltins }));
3470
- return;
3471
- }
3472
-
3473
- if (msg.type === "mate_update") {
3474
- if (!msg.mateId || !msg.updates) return;
3475
- var mateCtx7 = mates.buildMateCtx(userId);
3476
- var updated = mates.updateMate(mateCtx7, msg.mateId, msg.updates);
3477
- if (updated) {
3478
- ws.send(JSON.stringify({ type: "mate_updated", mate: updated }));
3479
- // Broadcast update
3480
- projects.forEach(function (ctx) {
3481
- ctx.forEachClient(function (otherWs) {
3482
- if (otherWs === ws) return;
3483
- if (otherWs.readyState !== 1) return;
3484
- otherWs.send(JSON.stringify({ type: "mate_updated", mate: updated }));
3485
- });
3486
- });
3487
- // Re-enforce team sections across all mate projects so roster stays current
3488
- refreshTeamSections(mateCtx7);
3489
- } else {
3490
- ws.send(JSON.stringify({ type: "mate_error", error: "Mate not found" }));
3491
- }
3492
- return;
3493
- }
3494
- }
1007
+ // --- DM message handler (delegated to server-dm + server-mates inline) ---
1008
+ var dmHandler = serverDm.attachDm({
1009
+ users: users,
1010
+ dm: dm,
1011
+ mates: mates,
1012
+ projects: projects,
1013
+ pushModule: pushModule,
1014
+ addProject: addProject,
1015
+ });
3495
1016
 
3496
- /**
3497
- * Re-enforce team sections on all mate projects so the roster stays current
3498
- * after a mate name/bio/status change.
3499
- */
3500
- function refreshTeamSections(mateCtx) {
3501
- try {
3502
- var allMates = mates.getAllMates(mateCtx);
3503
- // Collect non-mate projects for registry injection
3504
- var projList = [];
3505
- projects.forEach(function (pCtx) {
3506
- var st = pCtx.getStatus();
3507
- if (!st.isMate && !st.isWorktree) projList.push(st);
3508
- });
3509
- for (var ri = 0; ri < allMates.length; ri++) {
3510
- var mDir = mates.getMateDir(mateCtx, allMates[ri].id);
3511
- var claudePath = path.join(mDir, "CLAUDE.md");
3512
- try {
3513
- mates.enforceAllSections(claudePath, { ctx: mateCtx, mateId: allMates[ri].id, projects: projList });
3514
- } catch (e) {}
3515
- }
3516
- } catch (e) {
3517
- console.error("[mates] refreshTeamSections failed:", e.message);
3518
- }
1017
+ // --- Mate handler ---
1018
+ // Forward reference: mateHandler is set up after removeProject is defined
1019
+ var mateHandler = null;
1020
+ function scheduleRegistryRefresh() {
1021
+ if (mateHandler) mateHandler.scheduleRegistryRefresh();
3519
1022
  }
3520
1023
 
3521
- // Debounced project registry refresh for all mates
3522
- var _registryRefreshTimer = null;
3523
- function scheduleRegistryRefresh() {
3524
- if (_registryRefreshTimer) clearTimeout(_registryRefreshTimer);
3525
- _registryRefreshTimer = setTimeout(function () {
3526
- _registryRefreshTimer = null;
3527
- // Refresh for all known user contexts
3528
- try {
3529
- var allCtxs = {};
3530
- projects.forEach(function (pCtx) {
3531
- var st = pCtx.getStatus();
3532
- if (st.projectOwnerId && !allCtxs[st.projectOwnerId]) {
3533
- allCtxs[st.projectOwnerId] = mates.buildMateCtx(st.projectOwnerId);
3534
- }
3535
- });
3536
- var ctxKeys = Object.keys(allCtxs);
3537
- for (var ci = 0; ci < ctxKeys.length; ci++) {
3538
- refreshTeamSections(allCtxs[ctxKeys[ci]]);
3539
- }
3540
- } catch (e) {}
3541
- }, 2000);
1024
+ function handleDmMessage(ws, msg) {
1025
+ if (dmHandler.handleMessage(ws, msg)) return;
1026
+ if (mateHandler && mateHandler.handleMessage(ws, msg)) return;
3542
1027
  }
3543
1028
 
3544
1029
  function removeProject(slug) {
@@ -3551,6 +1036,16 @@ function createServer(opts) {
3551
1036
  return true;
3552
1037
  }
3553
1038
 
1039
+ // Now that addProject and removeProject are defined, initialize mateHandler
1040
+ mateHandler = serverMates.attachMates({
1041
+ users: users,
1042
+ mates: mates,
1043
+ projects: projects,
1044
+ addProject: addProject,
1045
+ removeProject: removeProject,
1046
+ onGetProjectAccess: onGetProjectAccess,
1047
+ });
1048
+
3554
1049
  function getProjects() {
3555
1050
  var list = [];
3556
1051
  projects.forEach(function (ctx) {
@@ -3589,10 +1084,6 @@ function createServer(opts) {
3589
1084
  return true;
3590
1085
  }
3591
1086
 
3592
- function setAuthToken(hash) {
3593
- authToken = hash;
3594
- }
3595
-
3596
1087
  // Collect all unique users across all projects (for topbar server-wide presence)
3597
1088
  function getServerUsers() {
3598
1089
  var seen = {};
@@ -3767,9 +1258,9 @@ function createServer(opts) {
3767
1258
  reorderProjects: reorderProjects,
3768
1259
  setProjectTitle: setProjectTitle,
3769
1260
  setProjectIcon: setProjectIcon,
3770
- setAuthToken: setAuthToken,
3771
- setRecovery: setRecovery,
3772
- clearRecovery: clearRecovery,
1261
+ setAuthToken: auth.setAuthToken,
1262
+ setRecovery: auth.setRecovery,
1263
+ clearRecovery: auth.clearRecovery,
3773
1264
  broadcastAll: broadcastAll,
3774
1265
  destroyAll: destroyAll,
3775
1266
  };