clay-server 2.8.2 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/sessions.js CHANGED
@@ -2,10 +2,13 @@ var fs = require("fs");
2
2
  var path = require("path");
3
3
  var config = require("./config");
4
4
  var utils = require("./utils");
5
+ var users = require("./users");
5
6
 
6
7
  function createSessionManager(opts) {
7
8
  var cwd = opts.cwd;
8
9
  var send = opts.send; // function(obj) - broadcast to all clients
10
+ var sendTo = opts.sendTo || null; // function(ws, obj) - send to specific client
11
+ var sendEach = opts.sendEach || null; // function(fn) - call fn(ws) for each connected client
9
12
  var sendAndRecord = null; // set after init via setSendAndRecord
10
13
 
11
14
  // --- Multi-session state ---
@@ -80,6 +83,8 @@ function createSessionManager(opts) {
80
83
  title: session.title,
81
84
  createdAt: session.createdAt,
82
85
  };
86
+ if (session.ownerId) metaObj.ownerId = session.ownerId;
87
+ if (session.sessionVisibility) metaObj.sessionVisibility = session.sessionVisibility;
83
88
  if (session.lastRewindUuid) metaObj.lastRewindUuid = session.lastRewindUuid;
84
89
  if (session.loop) metaObj.loop = session.loop;
85
90
  var meta = JSON.stringify(metaObj);
@@ -87,7 +92,11 @@ function createSessionManager(opts) {
87
92
  for (var i = 0; i < session.history.length; i++) {
88
93
  lines.push(JSON.stringify(session.history[i]));
89
94
  }
90
- fs.writeFileSync(sessionFilePath(session.cliSessionId), lines.join("\n") + "\n");
95
+ var sfPath = sessionFilePath(session.cliSessionId);
96
+ fs.writeFileSync(sfPath, lines.join("\n") + "\n");
97
+ if (process.platform !== "win32") {
98
+ try { fs.chmodSync(sfPath, 0o600); } catch (chmodErr) {}
99
+ }
91
100
  } catch(e) {
92
101
  console.error("[session] Failed to save session file:", e.message);
93
102
  }
@@ -97,7 +106,11 @@ function createSessionManager(opts) {
97
106
  if (!session.cliSessionId) return;
98
107
  session.lastActivity = Date.now();
99
108
  try {
100
- fs.appendFileSync(sessionFilePath(session.cliSessionId), JSON.stringify(obj) + "\n");
109
+ var afPath = sessionFilePath(session.cliSessionId);
110
+ fs.appendFileSync(afPath, JSON.stringify(obj) + "\n");
111
+ if (process.platform !== "win32") {
112
+ try { fs.chmodSync(afPath, 0o600); } catch (chmodErr) {}
113
+ }
101
114
  } catch(e) {
102
115
  console.error("[session] Failed to append to session file:", e.message);
103
116
  }
@@ -159,6 +172,8 @@ function createSessionManager(opts) {
159
172
  lastRewindUuid: m.lastRewindUuid || null,
160
173
  };
161
174
  if (m.loop) session.loop = m.loop;
175
+ if (m.ownerId) session.ownerId = m.ownerId;
176
+ session.sessionVisibility = m.sessionVisibility || "shared";
162
177
  sessions.set(localId, session);
163
178
  }
164
179
  }
@@ -176,32 +191,65 @@ function createSessionManager(opts) {
176
191
  resolveLoopInfo = fn;
177
192
  }
178
193
 
194
+ function mapSessionForClient(s, clientActiveId) {
195
+ var loop = s.loop ? Object.assign({}, s.loop) : null;
196
+ if (loop && loop.loopId && resolveLoopInfo) {
197
+ var info = resolveLoopInfo(loop.loopId);
198
+ if (info) {
199
+ if (info.name) loop.name = info.name;
200
+ if (info.source) loop.source = info.source;
201
+ }
202
+ }
203
+ var isActive = (typeof clientActiveId === "number") ? s.localId === clientActiveId : s.localId === activeSessionId;
204
+ return {
205
+ id: s.localId,
206
+ cliSessionId: s.cliSessionId || null,
207
+ title: s.title || "New Session",
208
+ active: isActive,
209
+ isProcessing: s.isProcessing,
210
+ lastActivity: s.lastActivity || s.createdAt || 0,
211
+ loop: loop,
212
+ ownerId: s.ownerId || null,
213
+ sessionVisibility: s.sessionVisibility || "shared",
214
+ };
215
+ }
216
+
217
+ function getVisibleSessions() {
218
+ var multiUser = users.isMultiUser();
219
+ return [...sessions.values()].filter(function (s) {
220
+ if (s.hidden) return false;
221
+ if (!multiUser) {
222
+ // Single-user mode: only show sessions without ownerId
223
+ return !s.ownerId;
224
+ }
225
+ // Multi-user mode: include all sessions (per-user filtering done by canAccessSession)
226
+ return true;
227
+ });
228
+ }
229
+
179
230
  function broadcastSessionList() {
180
- send({
181
- type: "session_list",
182
- sessions: [...sessions.values()].filter(function(s) { return !s.hidden; }).map(function(s) {
183
- var loop = s.loop ? Object.assign({}, s.loop) : null;
184
- if (loop && loop.loopId && resolveLoopInfo) {
185
- var info = resolveLoopInfo(loop.loopId);
186
- if (info) {
187
- if (info.name) loop.name = info.name;
188
- if (info.source) loop.source = info.source;
189
- }
231
+ var allVisible = getVisibleSessions();
232
+ if (sendEach) {
233
+ // Per-client filtering (multi-user mode)
234
+ sendEach(function (ws, filterFn) {
235
+ var filtered = filterFn ? allVisible.filter(filterFn) : allVisible;
236
+ var clientActiveId = ws._clayActiveSession;
237
+ if (ws.readyState === 1) {
238
+ ws.send(JSON.stringify({
239
+ type: "session_list",
240
+ sessions: filtered.map(function (s) { return mapSessionForClient(s, clientActiveId); }),
241
+ }));
190
242
  }
191
- return {
192
- id: s.localId,
193
- cliSessionId: s.cliSessionId || null,
194
- title: s.title || "New Session",
195
- active: s.localId === activeSessionId,
196
- isProcessing: s.isProcessing,
197
- lastActivity: s.lastActivity || s.createdAt || 0,
198
- loop: loop,
199
- };
200
- }),
201
- });
243
+ });
244
+ } else {
245
+ send({
246
+ type: "session_list",
247
+ sessions: allVisible.map(function (s) { return mapSessionForClient(s); }),
248
+ });
249
+ }
202
250
  }
203
251
 
204
- function createSession() {
252
+ function createSession(sessionOpts, targetWs) {
205
253
  var localId = nextLocalId++;
206
254
  var session = {
207
255
  localId: localId,
@@ -219,9 +267,11 @@ function createSessionManager(opts) {
219
267
  lastActivity: Date.now(),
220
268
  history: [],
221
269
  messageUUIDs: [],
270
+ ownerId: (sessionOpts && sessionOpts.ownerId) || null,
271
+ sessionVisibility: (sessionOpts && sessionOpts.sessionVisibility) || "shared",
222
272
  };
223
273
  sessions.set(localId, session);
224
- switchSession(localId);
274
+ switchSession(localId, targetWs);
225
275
  return session;
226
276
  }
227
277
 
@@ -234,7 +284,8 @@ function createSessionManager(opts) {
234
284
  return 0;
235
285
  }
236
286
 
237
- function replayHistory(session, fromIndex) {
287
+ function replayHistory(session, fromIndex, targetWs) {
288
+ var _send = (targetWs && sendTo) ? function (obj) { sendTo(targetWs, obj); } : send;
238
289
  var total = session.history.length;
239
290
  if (typeof fromIndex !== "number") {
240
291
  if (total <= HISTORY_PAGE_SIZE) {
@@ -244,10 +295,10 @@ function createSessionManager(opts) {
244
295
  }
245
296
  }
246
297
 
247
- send({ type: "history_meta", total: total, from: fromIndex });
298
+ _send({ type: "history_meta", total: total, from: fromIndex });
248
299
 
249
300
  for (var i = fromIndex; i < total; i++) {
250
- send(session.history[i]);
301
+ _send(session.history[i]);
251
302
  }
252
303
 
253
304
  // Find the last result message in the full history for accurate context data
@@ -266,27 +317,31 @@ function createSessionManager(opts) {
266
317
  }
267
318
  }
268
319
 
269
- send({ type: "history_done", lastUsage: lastUsage, lastModelUsage: lastModelUsage, lastCost: lastCost, lastStreamInputTokens: lastStreamInputTokens });
320
+ _send({ type: "history_done", lastUsage: lastUsage, lastModelUsage: lastModelUsage, lastCost: lastCost, lastStreamInputTokens: lastStreamInputTokens });
270
321
  }
271
322
 
272
- function switchSession(localId) {
323
+ function switchSession(localId, targetWs) {
273
324
  var session = sessions.get(localId);
274
325
  if (!session) return;
275
326
 
276
327
  activeSessionId = localId;
277
- send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null });
328
+
329
+ // In multi-user mode with a specific client, only send to that client
330
+ var _send = (targetWs && sendTo) ? function (obj) { sendTo(targetWs, obj); } : send;
331
+
332
+ _send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null });
278
333
  broadcastSessionList();
279
- replayHistory(session);
334
+ replayHistory(session, undefined, targetWs);
280
335
 
281
336
  if (session.isProcessing) {
282
- send({ type: "status", status: "processing" });
337
+ _send({ type: "status", status: "processing" });
283
338
  }
284
339
 
285
340
  // Re-send any pending permission requests
286
341
  var pendingIds = Object.keys(session.pendingPermissions);
287
342
  for (var i = 0; i < pendingIds.length; i++) {
288
343
  var p = session.pendingPermissions[pendingIds[i]];
289
- send({
344
+ _send({
290
345
  type: "permission_request_pending",
291
346
  requestId: p.requestId,
292
347
  toolName: p.toolName,
@@ -297,7 +352,7 @@ function createSessionManager(opts) {
297
352
  }
298
353
  }
299
354
 
300
- function deleteSession(localId) {
355
+ function deleteSession(localId, targetWs) {
301
356
  var session = sessions.get(localId);
302
357
  if (!session) return;
303
358
 
@@ -317,9 +372,9 @@ function createSessionManager(opts) {
317
372
  if (activeSessionId === localId) {
318
373
  var remaining = [...sessions.keys()];
319
374
  if (remaining.length > 0) {
320
- switchSession(remaining[remaining.length - 1]);
375
+ switchSession(remaining[remaining.length - 1], targetWs);
321
376
  } else {
322
- createSession();
377
+ createSession(null, targetWs);
323
378
  }
324
379
  } else {
325
380
  broadcastSessionList();
@@ -344,7 +399,23 @@ function createSessionManager(opts) {
344
399
  function doSendAndRecord(session, obj) {
345
400
  session.history.push(obj);
346
401
  appendToSessionFile(session, obj);
347
- if (session.localId === activeSessionId) {
402
+ if (sendEach) {
403
+ // Multi-user: send to clients whose active session matches this one
404
+ var data = JSON.stringify(obj);
405
+ var ioData = null;
406
+ sendEach(function (ws) {
407
+ if (ws._clayActiveSession === session.localId) {
408
+ if (ws.readyState === 1) ws.send(data);
409
+ } else if (session.isProcessing && !session._ioThrottle) {
410
+ if (!ioData) ioData = JSON.stringify({ type: "session_io", id: session.localId });
411
+ if (ws.readyState === 1) ws.send(ioData);
412
+ }
413
+ });
414
+ if (session.isProcessing && !session._ioThrottle && ioData) {
415
+ session._ioThrottle = true;
416
+ setTimeout(function () { session._ioThrottle = false; }, 80);
417
+ }
418
+ } else if (session.localId === activeSessionId) {
348
419
  send(obj);
349
420
  } else if (session.isProcessing && !session._ioThrottle) {
350
421
  session._ioThrottle = true;
@@ -353,7 +424,7 @@ function createSessionManager(opts) {
353
424
  }
354
425
  }
355
426
 
356
- function resumeSession(cliSessionId, opts) {
427
+ function resumeSession(cliSessionId, opts, targetWs) {
357
428
  // If a session with this cliSessionId already exists, just switch to it
358
429
  var existing = null;
359
430
  sessions.forEach(function (s) {
@@ -361,7 +432,7 @@ function createSessionManager(opts) {
361
432
  });
362
433
  if (existing) {
363
434
  existing.lastActivity = Date.now();
364
- switchSession(existing.localId);
435
+ switchSession(existing.localId, targetWs);
365
436
  return existing;
366
437
  }
367
438
 
@@ -386,7 +457,7 @@ function createSessionManager(opts) {
386
457
  };
387
458
  sessions.set(localId, session);
388
459
  saveSessionFile(session);
389
- switchSession(localId);
460
+ switchSession(localId, targetWs);
390
461
  return session;
391
462
  }
392
463
 
@@ -460,6 +531,21 @@ function createSessionManager(opts) {
460
531
  replayHistory: replayHistory,
461
532
  searchSessions: searchSessions,
462
533
  setResolveLoopInfo: setResolveLoopInfo,
534
+ setSessionVisibility: function (localId, visibility) {
535
+ var session = sessions.get(localId);
536
+ if (!session) return { error: "Session not found" };
537
+ session.sessionVisibility = visibility;
538
+ saveSessionFile(session);
539
+ broadcastSessionList();
540
+ return { ok: true };
541
+ },
542
+ setSessionOwner: function (localId, ownerId) {
543
+ var session = sessions.get(localId);
544
+ if (!session) return { error: "Session not found" };
545
+ session.ownerId = ownerId;
546
+ saveSessionFile(session);
547
+ return { ok: true };
548
+ },
463
549
  };
464
550
  }
465
551
 
package/lib/smtp.js ADDED
@@ -0,0 +1,221 @@
1
+ var nodemailer = require("nodemailer");
2
+ var crypto = require("crypto");
3
+
4
+ // --- OTP configuration ---
5
+ var OTP_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes
6
+ var OTP_MAX_ATTEMPTS = 3;
7
+ var OTP_COOLDOWN_MS = 60 * 1000; // 1 request per minute per email
8
+
9
+ // --- In-memory OTP store ---
10
+ var otpStore = {}; // email → { code, expiresAt, attempts, createdAt }
11
+
12
+ // --- Transporter cache ---
13
+ var transporter = null;
14
+
15
+ // --- Users module (lazy-loaded to avoid circular deps) ---
16
+ var _users = null;
17
+ function getUsers() {
18
+ if (!_users) _users = require("./users");
19
+ return _users;
20
+ }
21
+
22
+ // --- SMTP config helpers ---
23
+
24
+ function getSmtpConfig() {
25
+ var data = getUsers().loadUsers();
26
+ return data.smtp || null;
27
+ }
28
+
29
+ function saveSmtpConfig(config) {
30
+ var data = getUsers().loadUsers();
31
+ data.smtp = config;
32
+ getUsers().saveUsers(data);
33
+ resetTransporter();
34
+ }
35
+
36
+ function isSmtpConfigured() {
37
+ var cfg = getSmtpConfig();
38
+ return !!(cfg && cfg.host && cfg.user && cfg.pass && cfg.from);
39
+ }
40
+
41
+ function isEmailLoginEnabled() {
42
+ var cfg = getSmtpConfig();
43
+ return isSmtpConfigured() && !!(cfg && cfg.emailLoginEnabled);
44
+ }
45
+
46
+ // --- Transporter management ---
47
+
48
+ function createTransporter(cfg) {
49
+ return nodemailer.createTransport({
50
+ host: cfg.host,
51
+ port: cfg.port || 587,
52
+ secure: !!cfg.secure,
53
+ auth: { user: cfg.user, pass: cfg.pass },
54
+ connectionTimeout: 10000,
55
+ greetingTimeout: 10000,
56
+ });
57
+ }
58
+
59
+ function getTransporter() {
60
+ if (transporter) return transporter;
61
+ var cfg = getSmtpConfig();
62
+ if (!cfg) return null;
63
+ transporter = createTransporter(cfg);
64
+ return transporter;
65
+ }
66
+
67
+ function resetTransporter() {
68
+ if (transporter) {
69
+ try { transporter.close(); } catch (e) {}
70
+ }
71
+ transporter = null;
72
+ }
73
+
74
+ // --- Test connection ---
75
+
76
+ function testConnection(cfg) {
77
+ var t = createTransporter(cfg);
78
+ return t.verify().then(function () {
79
+ try { t.close(); } catch (e) {}
80
+ return { ok: true };
81
+ }).catch(function (err) {
82
+ try { t.close(); } catch (e) {}
83
+ return { ok: false, error: err.message || "Connection failed" };
84
+ });
85
+ }
86
+
87
+ function sendTestEmail(cfg, toEmail) {
88
+ var t = createTransporter(cfg);
89
+ return t.sendMail({
90
+ from: cfg.from,
91
+ to: toEmail,
92
+ subject: "Clay SMTP Test",
93
+ html: '<div style="font-family:system-ui,sans-serif;max-width:400px;margin:0 auto;padding:24px">' +
94
+ '<h2 style="color:#DA7756;margin:0 0 16px">Clay</h2>' +
95
+ '<p style="color:#333;margin:0">SMTP is configured correctly. You will receive login codes and invite emails at this address.</p>' +
96
+ '</div>',
97
+ }).then(function (info) {
98
+ try { t.close(); } catch (e) {}
99
+ return { ok: true, messageId: info.messageId };
100
+ }).catch(function (err) {
101
+ try { t.close(); } catch (e) {}
102
+ throw err;
103
+ });
104
+ }
105
+
106
+ // --- Send email ---
107
+
108
+ function sendMail(to, subject, html) {
109
+ var t = getTransporter();
110
+ if (!t) return Promise.reject(new Error("SMTP not configured"));
111
+ var cfg = getSmtpConfig();
112
+ return t.sendMail({
113
+ from: cfg.from,
114
+ to: to,
115
+ subject: subject,
116
+ html: html,
117
+ });
118
+ }
119
+
120
+ // --- OTP generation and verification ---
121
+
122
+ function generateOtp() {
123
+ var bytes = crypto.randomBytes(3);
124
+ var num = ((bytes[0] << 16) | (bytes[1] << 8) | bytes[2]) % 1000000;
125
+ return String(num).padStart(6, "0");
126
+ }
127
+
128
+ function requestOtp(email) {
129
+ var key = email.toLowerCase();
130
+ var existing = otpStore[key];
131
+ if (existing && (Date.now() - existing.createdAt) < OTP_COOLDOWN_MS) {
132
+ var wait = Math.ceil((OTP_COOLDOWN_MS - (Date.now() - existing.createdAt)) / 1000);
133
+ return { error: "Please wait " + wait + " seconds before requesting a new code", retryAfter: wait };
134
+ }
135
+ var code = generateOtp();
136
+ otpStore[key] = {
137
+ code: code,
138
+ expiresAt: Date.now() + OTP_EXPIRY_MS,
139
+ attempts: 0,
140
+ createdAt: Date.now(),
141
+ };
142
+ return { ok: true, code: code };
143
+ }
144
+
145
+ function verifyOtp(email, code) {
146
+ var key = email.toLowerCase();
147
+ var entry = otpStore[key];
148
+ if (!entry) return { valid: false, error: "No code requested" };
149
+ if (Date.now() > entry.expiresAt) {
150
+ delete otpStore[key];
151
+ return { valid: false, error: "Code expired" };
152
+ }
153
+ entry.attempts++;
154
+ if (entry.attempts > OTP_MAX_ATTEMPTS) {
155
+ delete otpStore[key];
156
+ return { valid: false, error: "Too many attempts" };
157
+ }
158
+ // Timing-safe comparison
159
+ var a = Buffer.from(entry.code);
160
+ var b = Buffer.from(String(code).padStart(6, "0"));
161
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
162
+ var left = OTP_MAX_ATTEMPTS - entry.attempts;
163
+ return { valid: false, error: "Invalid code", attemptsLeft: left };
164
+ }
165
+ delete otpStore[key];
166
+ return { valid: true };
167
+ }
168
+
169
+ // --- Email templates ---
170
+
171
+ function sendOtpEmail(email, code) {
172
+ var subject = "Your Clay login code: " + code;
173
+ var html = '<div style="font-family:system-ui,sans-serif;max-width:400px;margin:0 auto;padding:24px">' +
174
+ '<h2 style="color:#DA7756;margin:0 0 16px">Clay</h2>' +
175
+ '<p style="color:#333;margin:0 0 16px">Your verification code is:</p>' +
176
+ '<div style="font-size:32px;font-weight:700;letter-spacing:8px;padding:16px 0;text-align:center;' +
177
+ 'background:#f5f5f5;border-radius:8px;color:#333;font-family:monospace">' + code + '</div>' +
178
+ '<p style="color:#999;font-size:13px;margin:16px 0 0">This code expires in 10 minutes. If you didn\'t request this, you can ignore this email.</p>' +
179
+ '</div>';
180
+ return sendMail(email, subject, html);
181
+ }
182
+
183
+ function sendInviteEmail(email, inviteUrl, inviterName) {
184
+ var subject = "You've been invited to Clay";
185
+ var html = '<div style="font-family:system-ui,sans-serif;max-width:400px;margin:0 auto;padding:24px">' +
186
+ '<h2 style="color:#DA7756;margin:0 0 16px">Clay</h2>' +
187
+ '<p style="color:#333;margin:0 0 16px">' + (inviterName || "An admin") + ' has invited you to join Clay.</p>' +
188
+ '<p style="margin:0 0 16px"><a href="' + inviteUrl + '" style="display:inline-block;padding:12px 24px;' +
189
+ 'background:#DA7756;color:#fff;text-decoration:none;border-radius:8px;font-weight:600">Accept Invite</a></p>' +
190
+ '<p style="color:#999;font-size:13px;margin:0">This invite link expires in 24 hours.</p>' +
191
+ '</div>';
192
+ return sendMail(email, subject, html);
193
+ }
194
+
195
+ // --- Cleanup expired OTPs ---
196
+
197
+ setInterval(function () {
198
+ var now = Date.now();
199
+ var keys = Object.keys(otpStore);
200
+ for (var i = 0; i < keys.length; i++) {
201
+ if (now > otpStore[keys[i]].expiresAt) {
202
+ delete otpStore[keys[i]];
203
+ }
204
+ }
205
+ }, 60000);
206
+
207
+ module.exports = {
208
+ getSmtpConfig: getSmtpConfig,
209
+ saveSmtpConfig: saveSmtpConfig,
210
+ isSmtpConfigured: isSmtpConfigured,
211
+ isEmailLoginEnabled: isEmailLoginEnabled,
212
+ testConnection: testConnection,
213
+ sendTestEmail: sendTestEmail,
214
+ sendMail: sendMail,
215
+ resetTransporter: resetTransporter,
216
+ generateOtp: generateOtp,
217
+ requestOtp: requestOtp,
218
+ verifyOtp: verifyOtp,
219
+ sendOtpEmail: sendOtpEmail,
220
+ sendInviteEmail: sendInviteEmail,
221
+ };