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
@@ -0,0 +1,712 @@
1
+ // Admin API endpoints (multi-user mode only)
2
+ // Extracted from server.js
3
+
4
+ function attachAdmin(ctx) {
5
+ var users = ctx.users;
6
+ var smtp = ctx.smtp;
7
+ var getMultiUserFromReq = ctx.getMultiUserFromReq;
8
+ var projects = ctx.projects;
9
+ var osUsers = ctx.osUsers;
10
+ var tlsOptions = ctx.tlsOptions;
11
+ var portNum = ctx.portNum;
12
+ var provisionLinuxUser = ctx.provisionLinuxUser;
13
+ var onUserProvisioned = ctx.onUserProvisioned;
14
+ var onUserDeleted = ctx.onUserDeleted;
15
+ var revokeUserTokens = ctx.revokeUserTokens;
16
+ var onSetProjectVisibility = ctx.onSetProjectVisibility;
17
+ var onSetProjectAllowedUsers = ctx.onSetProjectAllowedUsers;
18
+ var onGetProjectAccess = ctx.onGetProjectAccess;
19
+ var onProjectOwnerChanged = ctx.onProjectOwnerChanged;
20
+
21
+ function handleRequest(req, res, fullUrl) {
22
+
23
+ // --- Admin API endpoints (multi-user mode only) ---
24
+
25
+ // List all users (admin only)
26
+ if (req.method === "GET" && fullUrl === "/api/admin/users") {
27
+ if (!users.isMultiUser()) {
28
+ res.writeHead(404, { "Content-Type": "application/json" });
29
+ res.end('{"error":"Not found"}');
30
+ return true;
31
+ }
32
+ var mu = getMultiUserFromReq(req);
33
+ if (!mu) {
34
+ res.writeHead(401, { "Content-Type": "application/json" });
35
+ res.end('{"error":"Authentication required"}');
36
+ return true;
37
+ }
38
+ // Admins get full user list; project owners get limited list (id, displayName, username)
39
+ if (mu.role === "admin") {
40
+ res.writeHead(200, { "Content-Type": "application/json" });
41
+ res.end(JSON.stringify({ users: users.getAllUsers() }));
42
+ } else {
43
+ var allU = users.getAllUsers();
44
+ var safeUsers = allU.map(function (u) {
45
+ return { id: u.id, displayName: u.displayName, username: u.username };
46
+ });
47
+ res.writeHead(200, { "Content-Type": "application/json" });
48
+ res.end(JSON.stringify({ users: safeUsers }));
49
+ }
50
+ return true;
51
+ }
52
+
53
+ // Remove user (admin only)
54
+ if (req.method === "DELETE" && fullUrl.indexOf("/api/admin/users/") === 0) {
55
+ if (!users.isMultiUser()) {
56
+ res.writeHead(404, { "Content-Type": "application/json" });
57
+ res.end('{"error":"Not found"}');
58
+ return true;
59
+ }
60
+ var mu = getMultiUserFromReq(req);
61
+ if (!mu || mu.role !== "admin") {
62
+ res.writeHead(403, { "Content-Type": "application/json" });
63
+ res.end('{"error":"Admin access required"}');
64
+ return true;
65
+ }
66
+ var targetUserId = fullUrl.substring("/api/admin/users/".length);
67
+ if (targetUserId === mu.id) {
68
+ res.writeHead(400, { "Content-Type": "application/json" });
69
+ res.end('{"error":"Cannot remove yourself"}');
70
+ return true;
71
+ }
72
+ // Look up the user before deletion to get linuxUser for deactivation
73
+ var targetUser = users.findUserById(targetUserId);
74
+ var targetLinuxUser = targetUser ? targetUser.linuxUser : null;
75
+ var result = users.removeUser(targetUserId);
76
+ if (result.error) {
77
+ res.writeHead(404, { "Content-Type": "application/json" });
78
+ res.end(JSON.stringify({ error: result.error }));
79
+ return true;
80
+ }
81
+ // Remove auth tokens for deleted user
82
+ revokeUserTokens(targetUserId);
83
+ // Deactivate the Linux account if applicable
84
+ if (onUserDeleted && targetLinuxUser) {
85
+ onUserDeleted(targetUserId, targetLinuxUser);
86
+ }
87
+ res.writeHead(200, { "Content-Type": "application/json" });
88
+ res.end('{"ok":true}');
89
+ return true;
90
+ }
91
+
92
+ // Create user (admin only) — generates a temporary PIN that must be changed on first login
93
+ if (req.method === "POST" && fullUrl === "/api/admin/users") {
94
+ if (!users.isMultiUser()) {
95
+ res.writeHead(404, { "Content-Type": "application/json" });
96
+ res.end('{"error":"Not found"}');
97
+ return true;
98
+ }
99
+ var mu = getMultiUserFromReq(req);
100
+ if (!mu || mu.role !== "admin") {
101
+ res.writeHead(403, { "Content-Type": "application/json" });
102
+ res.end('{"error":"Admin access required"}');
103
+ return true;
104
+ }
105
+ var body = "";
106
+ req.on("data", function (chunk) { body += chunk; });
107
+ req.on("end", function () {
108
+ try {
109
+ var data = JSON.parse(body);
110
+ if (!data.username || typeof data.username !== "string" || data.username.trim().length < 1) {
111
+ res.writeHead(400, { "Content-Type": "application/json" });
112
+ res.end('{"error":"Username is required"}');
113
+ return;
114
+ }
115
+ var result = users.createUserByAdmin({
116
+ username: data.username.trim(),
117
+ displayName: data.displayName ? data.displayName.trim() : data.username.trim(),
118
+ email: data.email ? data.email.trim() : null,
119
+ role: data.role === "admin" ? "admin" : "user",
120
+ });
121
+ if (result.error) {
122
+ res.writeHead(400, { "Content-Type": "application/json" });
123
+ res.end(JSON.stringify({ error: result.error }));
124
+ return;
125
+ }
126
+ // Auto-provision Linux account if OS users mode is enabled
127
+ if (osUsers && !result.user.linuxUser) {
128
+ var provision = provisionLinuxUser(result.user.username);
129
+ if (provision.ok) {
130
+ users.updateLinuxUser(result.user.id, provision.linuxUser);
131
+ if (onUserProvisioned) onUserProvisioned(result.user.id, provision.linuxUser);
132
+ }
133
+ }
134
+ res.writeHead(200, { "Content-Type": "application/json" });
135
+ res.end(JSON.stringify({
136
+ ok: true,
137
+ user: {
138
+ id: result.user.id,
139
+ username: result.user.username,
140
+ displayName: result.user.displayName,
141
+ role: result.user.role,
142
+ },
143
+ tempPin: result.tempPin,
144
+ }));
145
+ } catch (e) {
146
+ res.writeHead(400, { "Content-Type": "application/json" });
147
+ res.end('{"error":"Invalid request"}');
148
+ }
149
+ });
150
+ return true;
151
+ }
152
+
153
+ // Reset user PIN (admin only) — generates a new temp PIN
154
+ if (req.method === "POST" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/reset-pin$/)) {
155
+ if (!users.isMultiUser()) {
156
+ res.writeHead(404, { "Content-Type": "application/json" });
157
+ res.end('{"error":"Not found"}');
158
+ return true;
159
+ }
160
+ var mu = getMultiUserFromReq(req);
161
+ if (!mu || mu.role !== "admin") {
162
+ res.writeHead(403, { "Content-Type": "application/json" });
163
+ res.end('{"error":"Admin access required"}');
164
+ return true;
165
+ }
166
+ var urlParts = fullUrl.split("/");
167
+ var targetUserId = urlParts[4]; // /api/admin/users/{userId}/reset-pin
168
+ var targetUser = users.findUserById(targetUserId);
169
+ if (!targetUser) {
170
+ res.writeHead(404, { "Content-Type": "application/json" });
171
+ res.end('{"error":"User not found"}');
172
+ return true;
173
+ }
174
+ var newPin = users.generatePin();
175
+ var pinResult = users.updateUserPin(targetUserId, newPin);
176
+ if (pinResult.error) {
177
+ res.writeHead(400, { "Content-Type": "application/json" });
178
+ res.end(JSON.stringify({ error: pinResult.error }));
179
+ return true;
180
+ }
181
+ // Mark as must change on next login
182
+ var data = users.loadUsers();
183
+ for (var i = 0; i < data.users.length; i++) {
184
+ if (data.users[i].id === targetUserId) {
185
+ data.users[i].mustChangePin = true;
186
+ users.saveUsers(data);
187
+ break;
188
+ }
189
+ }
190
+ res.writeHead(200, { "Content-Type": "application/json" });
191
+ res.end(JSON.stringify({ ok: true, tempPin: newPin }));
192
+ return true;
193
+ }
194
+
195
+ // Set Linux user mapping (admin only, OS-level multi-user)
196
+ if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/linux-user$/)) {
197
+ if (!users.isMultiUser()) {
198
+ res.writeHead(404, { "Content-Type": "application/json" });
199
+ res.end('{"error":"Not found"}');
200
+ return true;
201
+ }
202
+ var mu = getMultiUserFromReq(req);
203
+ if (!mu || mu.role !== "admin") {
204
+ res.writeHead(403, { "Content-Type": "application/json" });
205
+ res.end('{"error":"Admin access required"}');
206
+ return true;
207
+ }
208
+ var urlParts = fullUrl.split("/");
209
+ var targetUserId = urlParts[4]; // /api/admin/users/{userId}/linux-user
210
+ var body = "";
211
+ req.on("data", function(chunk) { body += chunk; });
212
+ req.on("end", function() {
213
+ try {
214
+ var parsed = JSON.parse(body);
215
+ var result = users.updateLinuxUser(targetUserId, parsed.linuxUser || null);
216
+ if (result.error) {
217
+ res.writeHead(400, { "Content-Type": "application/json" });
218
+ res.end(JSON.stringify({ error: result.error }));
219
+ } else {
220
+ res.writeHead(200, { "Content-Type": "application/json" });
221
+ res.end('{"ok":true}');
222
+ }
223
+ } catch (e) {
224
+ res.writeHead(400, { "Content-Type": "application/json" });
225
+ res.end('{"error":"Invalid request body"}');
226
+ }
227
+ });
228
+ return true;
229
+ }
230
+
231
+ // Update user permissions (admin only)
232
+ if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/permissions$/)) {
233
+ if (!users.isMultiUser()) {
234
+ res.writeHead(404, { "Content-Type": "application/json" });
235
+ res.end('{"error":"Not found"}');
236
+ return true;
237
+ }
238
+ var mu = getMultiUserFromReq(req);
239
+ if (!mu || mu.role !== "admin") {
240
+ res.writeHead(403, { "Content-Type": "application/json" });
241
+ res.end('{"error":"Admin access required"}');
242
+ return true;
243
+ }
244
+ var urlParts = fullUrl.split("/");
245
+ var targetUserId = urlParts[4]; // /api/admin/users/{userId}/permissions
246
+ var body = "";
247
+ req.on("data", function(chunk) { body += chunk; });
248
+ req.on("end", function() {
249
+ try {
250
+ var parsed = JSON.parse(body);
251
+ var result = users.updateUserPermissions(targetUserId, parsed.permissions || {});
252
+ if (result.error) {
253
+ res.writeHead(400, { "Content-Type": "application/json" });
254
+ res.end(JSON.stringify({ error: result.error }));
255
+ } else {
256
+ res.writeHead(200, { "Content-Type": "application/json" });
257
+ res.end(JSON.stringify({ ok: true, permissions: result.permissions }));
258
+ }
259
+ } catch (e) {
260
+ res.writeHead(400, { "Content-Type": "application/json" });
261
+ res.end('{"error":"Invalid request body"}');
262
+ }
263
+ });
264
+ return true;
265
+ }
266
+
267
+ // Create invite (admin only)
268
+ if (req.method === "POST" && fullUrl === "/api/admin/invites") {
269
+ if (!users.isMultiUser()) {
270
+ res.writeHead(404, { "Content-Type": "application/json" });
271
+ res.end('{"error":"Not found"}');
272
+ return true;
273
+ }
274
+ var mu = getMultiUserFromReq(req);
275
+ if (!mu || mu.role !== "admin") {
276
+ res.writeHead(403, { "Content-Type": "application/json" });
277
+ res.end('{"error":"Admin access required"}');
278
+ return true;
279
+ }
280
+ var invite = users.createInvite(mu.id);
281
+ var proto = tlsOptions ? "https" : "http";
282
+ var host = req.headers.host || ("localhost:" + portNum);
283
+ var inviteUrl = proto + "://" + host + "/invite/" + invite.code;
284
+ res.writeHead(200, { "Content-Type": "application/json" });
285
+ res.end(JSON.stringify({ ok: true, invite: invite, url: inviteUrl }));
286
+ return true;
287
+ }
288
+
289
+ // List invites (admin only)
290
+ if (req.method === "GET" && fullUrl === "/api/admin/invites") {
291
+ if (!users.isMultiUser()) {
292
+ res.writeHead(404, { "Content-Type": "application/json" });
293
+ res.end('{"error":"Not found"}');
294
+ return true;
295
+ }
296
+ var mu = getMultiUserFromReq(req);
297
+ if (!mu || mu.role !== "admin") {
298
+ res.writeHead(403, { "Content-Type": "application/json" });
299
+ res.end('{"error":"Admin access required"}');
300
+ return true;
301
+ }
302
+ res.writeHead(200, { "Content-Type": "application/json" });
303
+ res.end(JSON.stringify({ invites: users.getInvites() }));
304
+ return true;
305
+ }
306
+
307
+ // Revoke invite (admin only)
308
+ if (req.method === "DELETE" && fullUrl.indexOf("/api/admin/invites/") === 0) {
309
+ if (!users.isMultiUser()) {
310
+ res.writeHead(404, { "Content-Type": "application/json" });
311
+ res.end('{"error":"Not found"}');
312
+ return true;
313
+ }
314
+ var mu = getMultiUserFromReq(req);
315
+ if (!mu || mu.role !== "admin") {
316
+ res.writeHead(403, { "Content-Type": "application/json" });
317
+ res.end('{"error":"Admin access required"}');
318
+ return true;
319
+ }
320
+ var inviteCode = decodeURIComponent(fullUrl.replace("/api/admin/invites/", ""));
321
+ if (!inviteCode) {
322
+ res.writeHead(400, { "Content-Type": "application/json" });
323
+ res.end('{"error":"Invite code is required"}');
324
+ return true;
325
+ }
326
+ var result = users.revokeInvite(inviteCode);
327
+ if (result.error) {
328
+ res.writeHead(404, { "Content-Type": "application/json" });
329
+ res.end(JSON.stringify({ error: result.error }));
330
+ return true;
331
+ }
332
+ res.writeHead(200, { "Content-Type": "application/json" });
333
+ res.end('{"ok":true}');
334
+ return true;
335
+ }
336
+
337
+ // Send invite via email (admin only)
338
+ if (req.method === "POST" && fullUrl === "/api/admin/invites/email") {
339
+ if (!users.isMultiUser() || !smtp.isSmtpConfigured()) {
340
+ res.writeHead(400, { "Content-Type": "application/json" });
341
+ res.end('{"error":"SMTP not configured"}');
342
+ return true;
343
+ }
344
+ var mu = getMultiUserFromReq(req);
345
+ if (!mu || mu.role !== "admin") {
346
+ res.writeHead(403, { "Content-Type": "application/json" });
347
+ res.end('{"error":"Admin access required"}');
348
+ return true;
349
+ }
350
+ var body = "";
351
+ req.on("data", function (chunk) { body += chunk; });
352
+ req.on("end", function () {
353
+ try {
354
+ var data = JSON.parse(body);
355
+ if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
356
+ res.writeHead(400, { "Content-Type": "application/json" });
357
+ res.end('{"error":"Valid email is required"}');
358
+ return;
359
+ }
360
+ var invite = users.createInvite(mu.id, data.email);
361
+ var proto = tlsOptions ? "https" : "http";
362
+ var host = req.headers.host || ("localhost:" + portNum);
363
+ var inviteUrl = proto + "://" + host + "/invite/" + invite.code;
364
+ smtp.sendInviteEmail(data.email, inviteUrl, mu.displayName || mu.username).then(function () {
365
+ res.writeHead(200, { "Content-Type": "application/json" });
366
+ res.end(JSON.stringify({ ok: true, invite: invite, url: inviteUrl }));
367
+ }).catch(function (err) {
368
+ res.writeHead(500, { "Content-Type": "application/json" });
369
+ res.end(JSON.stringify({ error: "Failed to send email: " + (err.message || "unknown error") }));
370
+ });
371
+ } catch (e) {
372
+ res.writeHead(400, { "Content-Type": "application/json" });
373
+ res.end('{"error":"Invalid request"}');
374
+ }
375
+ });
376
+ return true;
377
+ }
378
+
379
+ // Get SMTP config (admin only)
380
+ if (req.method === "GET" && fullUrl === "/api/admin/smtp") {
381
+ if (!users.isMultiUser()) {
382
+ res.writeHead(404, { "Content-Type": "application/json" });
383
+ res.end('{"error":"Not found"}');
384
+ return true;
385
+ }
386
+ var mu = getMultiUserFromReq(req);
387
+ if (!mu || mu.role !== "admin") {
388
+ res.writeHead(403, { "Content-Type": "application/json" });
389
+ res.end('{"error":"Admin access required"}');
390
+ return true;
391
+ }
392
+ var cfg = smtp.getSmtpConfig();
393
+ if (cfg) {
394
+ res.writeHead(200, { "Content-Type": "application/json" });
395
+ res.end(JSON.stringify({ smtp: { host: cfg.host, port: cfg.port, secure: cfg.secure, user: cfg.user, pass: "••••••••", from: cfg.from, emailLoginEnabled: !!cfg.emailLoginEnabled } }));
396
+ } else {
397
+ res.writeHead(200, { "Content-Type": "application/json" });
398
+ res.end('{"smtp":null}');
399
+ }
400
+ return true;
401
+ }
402
+
403
+ // Save SMTP config (admin only)
404
+ if (req.method === "POST" && fullUrl === "/api/admin/smtp") {
405
+ if (!users.isMultiUser()) {
406
+ res.writeHead(404, { "Content-Type": "application/json" });
407
+ res.end('{"error":"Not found"}');
408
+ return true;
409
+ }
410
+ var mu = getMultiUserFromReq(req);
411
+ if (!mu || mu.role !== "admin") {
412
+ res.writeHead(403, { "Content-Type": "application/json" });
413
+ res.end('{"error":"Admin access required"}');
414
+ return true;
415
+ }
416
+ var body = "";
417
+ req.on("data", function (chunk) { body += chunk; });
418
+ req.on("end", function () {
419
+ try {
420
+ var data = JSON.parse(body);
421
+ // Allow clearing SMTP config by sending empty fields
422
+ if (!data.host && !data.user && !data.pass && !data.from) {
423
+ smtp.saveSmtpConfig(null);
424
+ res.writeHead(200, { "Content-Type": "application/json" });
425
+ res.end('{"ok":true}');
426
+ return;
427
+ }
428
+ if (!data.host || !data.user || !data.pass || !data.from) {
429
+ res.writeHead(400, { "Content-Type": "application/json" });
430
+ res.end('{"error":"Host, user, password, and from address are required"}');
431
+ return;
432
+ }
433
+ // If password is masked, keep existing
434
+ var existingCfg = smtp.getSmtpConfig();
435
+ var pass = data.pass;
436
+ if (pass === "••••••••" && existingCfg) {
437
+ pass = existingCfg.pass;
438
+ }
439
+ smtp.saveSmtpConfig({
440
+ host: data.host,
441
+ port: parseInt(data.port, 10) || 587,
442
+ secure: !!data.secure,
443
+ user: data.user,
444
+ pass: pass,
445
+ from: data.from,
446
+ emailLoginEnabled: !!data.emailLoginEnabled,
447
+ });
448
+ res.writeHead(200, { "Content-Type": "application/json" });
449
+ res.end('{"ok":true}');
450
+ } catch (e) {
451
+ res.writeHead(400, { "Content-Type": "application/json" });
452
+ res.end('{"error":"Invalid request"}');
453
+ }
454
+ });
455
+ return true;
456
+ }
457
+
458
+ // Test SMTP connection (admin only)
459
+ if (req.method === "POST" && fullUrl === "/api/admin/smtp/test") {
460
+ if (!users.isMultiUser()) {
461
+ res.writeHead(404, { "Content-Type": "application/json" });
462
+ res.end('{"error":"Not found"}');
463
+ return true;
464
+ }
465
+ var mu = getMultiUserFromReq(req);
466
+ if (!mu || mu.role !== "admin") {
467
+ res.writeHead(403, { "Content-Type": "application/json" });
468
+ res.end('{"error":"Admin access required"}');
469
+ return true;
470
+ }
471
+ var body = "";
472
+ req.on("data", function (chunk) { body += chunk; });
473
+ req.on("end", function () {
474
+ try {
475
+ var data = JSON.parse(body);
476
+ // Use provided config or fall back to saved
477
+ var existingCfg = smtp.getSmtpConfig();
478
+ var pass = data.pass;
479
+ if (pass === "••••••••" && existingCfg) {
480
+ pass = existingCfg.pass;
481
+ }
482
+ var cfg = {
483
+ host: data.host || (existingCfg && existingCfg.host),
484
+ port: parseInt(data.port, 10) || (existingCfg && existingCfg.port) || 587,
485
+ secure: data.secure !== undefined ? !!data.secure : (existingCfg && !!existingCfg.secure),
486
+ user: data.user || (existingCfg && existingCfg.user),
487
+ pass: pass || (existingCfg && existingCfg.pass),
488
+ from: data.from || (existingCfg && existingCfg.from),
489
+ };
490
+ if (!cfg.host || !cfg.user || !cfg.pass || !cfg.from) {
491
+ res.writeHead(400, { "Content-Type": "application/json" });
492
+ res.end('{"error":"SMTP configuration is incomplete"}');
493
+ return;
494
+ }
495
+ var testTo = mu.email || cfg.from;
496
+ smtp.sendTestEmail(cfg, testTo).then(function (result) {
497
+ res.writeHead(200, { "Content-Type": "application/json" });
498
+ res.end(JSON.stringify({ ok: true, message: "Test email sent to " + testTo }));
499
+ }).catch(function (err) {
500
+ res.writeHead(500, { "Content-Type": "application/json" });
501
+ res.end(JSON.stringify({ ok: false, error: err.message || "Connection failed" }));
502
+ });
503
+ } catch (e) {
504
+ res.writeHead(400, { "Content-Type": "application/json" });
505
+ res.end('{"error":"Invalid request"}');
506
+ }
507
+ });
508
+ return true;
509
+ }
510
+
511
+ // --- Project access control (admin only, multi-user) ---
512
+
513
+ // Set project visibility (admin only)
514
+ if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/visibility$/.test(fullUrl)) {
515
+ if (!users.isMultiUser()) {
516
+ res.writeHead(404, { "Content-Type": "application/json" });
517
+ res.end('{"error":"Not found"}');
518
+ return true;
519
+ }
520
+ var mu = getMultiUserFromReq(req);
521
+ var _visSlug = fullUrl.split("/")[4];
522
+ var _visAccess = onGetProjectAccess ? onGetProjectAccess(_visSlug) : null;
523
+ var _isOwner = mu && _visAccess && _visAccess.ownerId && mu.id === _visAccess.ownerId;
524
+ if (!mu || (mu.role !== "admin" && !_isOwner)) {
525
+ res.writeHead(403, { "Content-Type": "application/json" });
526
+ res.end('{"error":"Admin or project owner access required"}');
527
+ return true;
528
+ }
529
+ var projSlug = fullUrl.split("/")[4];
530
+ if (projSlug.indexOf("--") !== -1) {
531
+ res.writeHead(400, { "Content-Type": "application/json" });
532
+ res.end('{"error":"Worktree projects inherit parent visibility"}');
533
+ return true;
534
+ }
535
+ var body = "";
536
+ req.on("data", function (chunk) { body += chunk; });
537
+ req.on("end", function () {
538
+ try {
539
+ var data = JSON.parse(body);
540
+ if (data.visibility !== "public" && data.visibility !== "private") {
541
+ res.writeHead(400, { "Content-Type": "application/json" });
542
+ res.end('{"error":"Visibility must be public or private"}');
543
+ return;
544
+ }
545
+ if (!onSetProjectVisibility) {
546
+ res.writeHead(500, { "Content-Type": "application/json" });
547
+ res.end('{"error":"Visibility handler not configured"}');
548
+ return;
549
+ }
550
+ var result = onSetProjectVisibility(projSlug, data.visibility);
551
+ if (result && result.error) {
552
+ res.writeHead(404, { "Content-Type": "application/json" });
553
+ res.end(JSON.stringify({ error: result.error }));
554
+ return;
555
+ }
556
+ res.writeHead(200, { "Content-Type": "application/json" });
557
+ res.end('{"ok":true}');
558
+ } catch (e) {
559
+ res.writeHead(400, { "Content-Type": "application/json" });
560
+ res.end('{"error":"Invalid request"}');
561
+ }
562
+ });
563
+ return true;
564
+ }
565
+
566
+ // Set project owner (admin only)
567
+ if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/owner$/.test(fullUrl)) {
568
+ if (!users.isMultiUser()) {
569
+ res.writeHead(404, { "Content-Type": "application/json" });
570
+ res.end('{"error":"Not found"}');
571
+ return true;
572
+ }
573
+ var mu = getMultiUserFromReq(req);
574
+ if (!mu || mu.role !== "admin") {
575
+ res.writeHead(403, { "Content-Type": "application/json" });
576
+ res.end('{"error":"Admin access required"}');
577
+ return true;
578
+ }
579
+ var projSlug = fullUrl.split("/")[4];
580
+ if (projSlug.indexOf("--") !== -1) {
581
+ res.writeHead(400, { "Content-Type": "application/json" });
582
+ res.end('{"error":"Worktree projects inherit parent settings"}');
583
+ return true;
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
+ var targetCtx = projects.get(projSlug);
591
+ if (!targetCtx) {
592
+ res.writeHead(404, { "Content-Type": "application/json" });
593
+ res.end('{"error":"Project not found"}');
594
+ return;
595
+ }
596
+ var ownerId = data.userId || null;
597
+ targetCtx.setProjectOwner(ownerId);
598
+ if (onProjectOwnerChanged) {
599
+ onProjectOwnerChanged(projSlug, ownerId);
600
+ }
601
+ // Broadcast to project clients
602
+ var ownerName = null;
603
+ if (ownerId) {
604
+ var ownerUser = users.findUserById(ownerId);
605
+ ownerName = ownerUser ? (ownerUser.displayName || ownerUser.username) : ownerId;
606
+ }
607
+ targetCtx.send({ type: "project_owner_changed", ownerId: ownerId, ownerName: ownerName });
608
+ res.writeHead(200, { "Content-Type": "application/json" });
609
+ res.end('{"ok":true}');
610
+ } catch (e) {
611
+ res.writeHead(400, { "Content-Type": "application/json" });
612
+ res.end('{"error":"Invalid request"}');
613
+ }
614
+ });
615
+ return true;
616
+ }
617
+
618
+ // Set project allowed users (admin only)
619
+ if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/users$/.test(fullUrl)) {
620
+ if (!users.isMultiUser()) {
621
+ res.writeHead(404, { "Content-Type": "application/json" });
622
+ res.end('{"error":"Not found"}');
623
+ return true;
624
+ }
625
+ var mu = getMultiUserFromReq(req);
626
+ var _usrSlug = fullUrl.split("/")[4];
627
+ var _usrAccess = onGetProjectAccess ? onGetProjectAccess(_usrSlug) : null;
628
+ var _isOwnerU = mu && _usrAccess && _usrAccess.ownerId && mu.id === _usrAccess.ownerId;
629
+ if (!mu || (mu.role !== "admin" && !_isOwnerU)) {
630
+ res.writeHead(403, { "Content-Type": "application/json" });
631
+ res.end('{"error":"Admin or project owner access required"}');
632
+ return true;
633
+ }
634
+ var projSlug = fullUrl.split("/")[4];
635
+ if (projSlug.indexOf("--") !== -1) {
636
+ res.writeHead(400, { "Content-Type": "application/json" });
637
+ res.end('{"error":"Worktree projects inherit parent settings"}');
638
+ return true;
639
+ }
640
+ var body = "";
641
+ req.on("data", function (chunk) { body += chunk; });
642
+ req.on("end", function () {
643
+ try {
644
+ var data = JSON.parse(body);
645
+ if (!Array.isArray(data.allowedUsers)) {
646
+ res.writeHead(400, { "Content-Type": "application/json" });
647
+ res.end('{"error":"allowedUsers must be an array"}');
648
+ return;
649
+ }
650
+ if (!onSetProjectAllowedUsers) {
651
+ res.writeHead(500, { "Content-Type": "application/json" });
652
+ res.end('{"error":"AllowedUsers handler not configured"}');
653
+ return;
654
+ }
655
+ var result = onSetProjectAllowedUsers(projSlug, data.allowedUsers);
656
+ if (result && result.error) {
657
+ res.writeHead(404, { "Content-Type": "application/json" });
658
+ res.end(JSON.stringify({ error: result.error }));
659
+ return;
660
+ }
661
+ res.writeHead(200, { "Content-Type": "application/json" });
662
+ res.end('{"ok":true}');
663
+ } catch (e) {
664
+ res.writeHead(400, { "Content-Type": "application/json" });
665
+ res.end('{"error":"Invalid request"}');
666
+ }
667
+ });
668
+ return true;
669
+ }
670
+
671
+ // Get project access info (admin or project owner)
672
+ if (req.method === "GET" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/access$/.test(fullUrl)) {
673
+ if (!users.isMultiUser()) {
674
+ res.writeHead(404, { "Content-Type": "application/json" });
675
+ res.end('{"error":"Not found"}');
676
+ return true;
677
+ }
678
+ var mu = getMultiUserFromReq(req);
679
+ var _accSlug = fullUrl.split("/")[4];
680
+ var _accAccess = onGetProjectAccess ? onGetProjectAccess(_accSlug) : null;
681
+ var _isOwnerA = mu && _accAccess && _accAccess.ownerId && mu.id === _accAccess.ownerId;
682
+ if (!mu || (mu.role !== "admin" && !_isOwnerA)) {
683
+ res.writeHead(403, { "Content-Type": "application/json" });
684
+ res.end('{"error":"Admin or project owner access required"}');
685
+ return true;
686
+ }
687
+ var projSlug = fullUrl.split("/")[4];
688
+ if (!onGetProjectAccess) {
689
+ res.writeHead(500, { "Content-Type": "application/json" });
690
+ res.end('{"error":"Access handler not configured"}');
691
+ return true;
692
+ }
693
+ var access = onGetProjectAccess(projSlug);
694
+ if (access && access.error) {
695
+ res.writeHead(404, { "Content-Type": "application/json" });
696
+ res.end(JSON.stringify({ error: access.error }));
697
+ return true;
698
+ }
699
+ res.writeHead(200, { "Content-Type": "application/json" });
700
+ res.end(JSON.stringify(access));
701
+ return true;
702
+ }
703
+
704
+ return false;
705
+ }
706
+
707
+ return {
708
+ handleRequest: handleRequest,
709
+ };
710
+ }
711
+
712
+ module.exports = { attachAdmin: attachAdmin };