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/server.js CHANGED
@@ -3,8 +3,10 @@ var crypto = require("crypto");
3
3
  var fs = require("fs");
4
4
  var path = require("path");
5
5
  var { WebSocketServer } = require("ws");
6
- var { pinPageHtml, setupPageHtml } = require("./pages");
6
+ var { pinPageHtml, setupPageHtml, adminSetupPageHtml, multiUserLoginPageHtml, smtpLoginPageHtml, invitePageHtml, smtpInvitePageHtml, noProjectsPageHtml } = require("./pages");
7
+ var smtp = require("./smtp");
7
8
  var { createProjectContext } = require("./project");
9
+ var users = require("./users");
8
10
 
9
11
  var { CONFIG_DIR } = require("./config");
10
12
 
@@ -178,7 +180,25 @@ var MIME_TYPES = {
178
180
  };
179
181
 
180
182
  function generateAuthToken(pin) {
181
- return crypto.createHash("sha256").update("clay:" + pin).digest("hex");
183
+ var salt = crypto.randomBytes(16).toString("hex");
184
+ var hash = crypto.scryptSync("clay:" + pin, salt, 64).toString("hex");
185
+ return salt + ":" + hash;
186
+ }
187
+
188
+ function verifyPin(pin, storedHash) {
189
+ if (!storedHash) return false;
190
+ // New scrypt format: salt_hex:hash_hex (contains colon)
191
+ if (storedHash.indexOf(":") !== -1) {
192
+ var parts = storedHash.split(":");
193
+ var salt = parts[0];
194
+ var hash = parts[1];
195
+ var derived = crypto.scryptSync("clay:" + pin, salt, 64).toString("hex");
196
+ return crypto.timingSafeEqual(Buffer.from(derived, "hex"), Buffer.from(hash, "hex"));
197
+ }
198
+ // Legacy SHA256 format (no colon)
199
+ var legacyHash = crypto.createHash("sha256").update("clay:" + pin).digest("hex");
200
+ var match = crypto.timingSafeEqual(Buffer.from(legacyHash, "hex"), Buffer.from(storedHash, "hex"));
201
+ return match;
182
202
  }
183
203
 
184
204
  function parseCookies(req) {
@@ -197,6 +217,31 @@ function isAuthed(req, authToken) {
197
217
  return cookies["relay_auth"] === authToken;
198
218
  }
199
219
 
220
+ // --- Multi-user auth helpers ---
221
+ // Multi-user auth tokens: userId:randomToken stored in memory
222
+ var multiUserTokens = {}; // token → userId
223
+
224
+ function createMultiUserSession(userId, tlsOptions) {
225
+ var token = users.generateUserAuthToken(userId);
226
+ multiUserTokens[token] = userId;
227
+ var cookie = "relay_auth_user=" + token + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : "");
228
+ return { token: token, cookie: cookie };
229
+ }
230
+
231
+ function getMultiUserFromReq(req) {
232
+ var cookies = parseCookies(req);
233
+ var token = cookies["relay_auth_user"];
234
+ if (!token) return null;
235
+ var userId = multiUserTokens[token];
236
+ if (!userId) return null;
237
+ var user = users.findUserById(userId);
238
+ return user || null;
239
+ }
240
+
241
+ function isMultiUserAuthed(req) {
242
+ return !!getMultiUserFromReq(req);
243
+ }
244
+
200
245
  // --- PIN rate limiting ---
201
246
  var pinAttempts = {}; // ip → { count, lastAttempt }
202
247
  var PIN_MAX_ATTEMPTS = 5;
@@ -302,6 +347,10 @@ function createServer(opts) {
302
347
  var onSetPin = opts.onSetPin || null;
303
348
  var onSetKeepAwake = opts.onSetKeepAwake || null;
304
349
  var onShutdown = opts.onShutdown || null;
350
+ var onUpgradePin = opts.onUpgradePin || null;
351
+ var onSetProjectVisibility = opts.onSetProjectVisibility || null;
352
+ var onSetProjectAllowedUsers = opts.onSetProjectAllowedUsers || null;
353
+ var onGetProjectAccess = opts.onGetProjectAccess || null;
305
354
 
306
355
  var authToken = pinHash || null;
307
356
  var realVersion = require("../package.json").version;
@@ -309,6 +358,23 @@ function createServer(opts) {
309
358
 
310
359
  var caContent = caPath ? (function () { try { return fs.readFileSync(caPath); } catch (e) { return null; } })() : null;
311
360
  var pinPage = pinPageHtml();
361
+ var adminSetupPage = adminSetupPageHtml();
362
+ var loginPage = multiUserLoginPageHtml();
363
+ var smtpLoginPage = smtpLoginPageHtml();
364
+
365
+ // Multi-user auth: determine which page to show for unauthenticated requests
366
+ function getAuthPage() {
367
+ if (!users.isMultiUser()) return pinPage;
368
+ if (!users.hasAdmin()) return adminSetupPage;
369
+ if (smtp.isEmailLoginEnabled()) return smtpLoginPage;
370
+ return loginPage;
371
+ }
372
+
373
+ // Unified auth check: works in both single-user and multi-user mode
374
+ function isRequestAuthed(req) {
375
+ if (users.isMultiUser()) return isMultiUserAuthed(req);
376
+ return isAuthed(req, authToken);
377
+ }
312
378
 
313
379
  // --- Project registry ---
314
380
  var projects = new Map(); // slug → projectContext
@@ -320,8 +386,26 @@ function createServer(opts) {
320
386
  pushModule = initPush();
321
387
  } catch (e) {}
322
388
 
389
+ // --- Security headers ---
390
+ var securityHeaders = {
391
+ "X-Content-Type-Options": "nosniff",
392
+ "X-Frame-Options": "DENY",
393
+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://esm.sh; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data: blob: https://cdn.jsdelivr.net https://api.dicebear.com; connect-src 'self' ws: wss: https://cdn.jsdelivr.net https://esm.sh; font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net;",
394
+ };
395
+ if (tlsOptions) {
396
+ securityHeaders["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains";
397
+ }
398
+
399
+ function setSecurityHeaders(res) {
400
+ var keys = Object.keys(securityHeaders);
401
+ for (var i = 0; i < keys.length; i++) {
402
+ res.setHeader(keys[i], securityHeaders[keys[i]]);
403
+ }
404
+ }
405
+
323
406
  // --- HTTP handler ---
324
407
  var appHandler = function (req, res) {
408
+ setSecurityHeaders(res);
325
409
  var fullUrl = req.url.split("?")[0];
326
410
 
327
411
  // Global auth endpoint
@@ -338,8 +422,16 @@ function createServer(opts) {
338
422
  req.on("end", function () {
339
423
  try {
340
424
  var data = JSON.parse(body);
341
- if (authToken && generateAuthToken(data.pin) === authToken) {
425
+ if (authToken && verifyPin(data.pin, authToken)) {
342
426
  clearPinFailures(ip);
427
+ // Auto-upgrade legacy SHA256 hash to scrypt
428
+ if (authToken.indexOf(":") === -1) {
429
+ var upgraded = generateAuthToken(data.pin);
430
+ authToken = upgraded;
431
+ if (typeof onUpgradePin === "function") {
432
+ onUpgradePin(upgraded);
433
+ }
434
+ }
343
435
  res.writeHead(200, {
344
436
  "Set-Cookie": "relay_auth=" + authToken + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : ""),
345
437
  "Content-Type": "application/json",
@@ -359,6 +451,318 @@ function createServer(opts) {
359
451
  return;
360
452
  }
361
453
 
454
+ // --- Multi-user auth endpoints ---
455
+
456
+ // Admin setup (first-time multi-user setup)
457
+ if (req.method === "POST" && fullUrl === "/auth/setup") {
458
+ if (!users.isMultiUser()) {
459
+ res.writeHead(400, { "Content-Type": "application/json" });
460
+ res.end('{"error":"Multi-user mode is not enabled"}');
461
+ return;
462
+ }
463
+ if (users.hasAdmin()) {
464
+ res.writeHead(400, { "Content-Type": "application/json" });
465
+ res.end('{"error":"Admin already exists"}');
466
+ return;
467
+ }
468
+ var body = "";
469
+ req.on("data", function (chunk) { body += chunk; });
470
+ req.on("end", function () {
471
+ try {
472
+ var data = JSON.parse(body);
473
+ if (!users.validateSetupCode(data.setupCode)) {
474
+ res.writeHead(401, { "Content-Type": "application/json" });
475
+ res.end('{"error":"Invalid setup code"}');
476
+ return;
477
+ }
478
+ if (!data.username || data.username.trim().length < 1 || data.username.length > 100) {
479
+ res.writeHead(400, { "Content-Type": "application/json" });
480
+ res.end('{"error":"Username is required"}');
481
+ return;
482
+ }
483
+ if (!data.pin || !/^\d{6}$/.test(data.pin)) {
484
+ res.writeHead(400, { "Content-Type": "application/json" });
485
+ res.end('{"error":"PIN must be exactly 6 digits"}');
486
+ return;
487
+ }
488
+ // Migrate existing profile.json to admin profile
489
+ var adminProfile = undefined;
490
+ try {
491
+ var existingProfile = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, "profile.json"), "utf8"));
492
+ adminProfile = {
493
+ name: data.displayName || data.username,
494
+ lang: existingProfile.lang || "en-US",
495
+ avatarColor: existingProfile.avatarColor || "#7c3aed",
496
+ avatarStyle: existingProfile.avatarStyle || "thumbs",
497
+ avatarSeed: existingProfile.avatarSeed || crypto.randomBytes(4).toString("hex"),
498
+ };
499
+ } catch (e) {}
500
+ var result = users.createAdmin({
501
+ username: data.username.trim(),
502
+ displayName: data.displayName || data.username.trim(),
503
+ pin: data.pin,
504
+ profile: adminProfile,
505
+ });
506
+ if (result.error) {
507
+ res.writeHead(400, { "Content-Type": "application/json" });
508
+ res.end(JSON.stringify({ error: result.error }));
509
+ return;
510
+ }
511
+ users.clearSetupCode();
512
+ var session = createMultiUserSession(result.user.id, tlsOptions);
513
+ res.writeHead(200, {
514
+ "Set-Cookie": session.cookie,
515
+ "Content-Type": "application/json",
516
+ });
517
+ res.end(JSON.stringify({ ok: true, user: { id: result.user.id, username: result.user.username, role: result.user.role } }));
518
+ } catch (e) {
519
+ res.writeHead(400, { "Content-Type": "application/json" });
520
+ res.end('{"error":"Invalid request"}');
521
+ }
522
+ });
523
+ return;
524
+ }
525
+
526
+ // Multi-user login
527
+ if (req.method === "POST" && fullUrl === "/auth/login") {
528
+ if (!users.isMultiUser()) {
529
+ res.writeHead(400, { "Content-Type": "application/json" });
530
+ res.end('{"error":"Multi-user mode is not enabled"}');
531
+ return;
532
+ }
533
+ var ip = req.socket.remoteAddress || "";
534
+ var remaining = checkPinRateLimit(ip);
535
+ if (remaining !== null) {
536
+ res.writeHead(429, { "Content-Type": "application/json" });
537
+ res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
538
+ return;
539
+ }
540
+ var body = "";
541
+ req.on("data", function (chunk) { body += chunk; });
542
+ req.on("end", function () {
543
+ try {
544
+ var data = JSON.parse(body);
545
+ var user = users.authenticateUser(data.username, data.pin);
546
+ if (!user) {
547
+ recordPinFailure(ip);
548
+ var attemptsLeft = PIN_MAX_ATTEMPTS - (pinAttempts[ip] ? pinAttempts[ip].count : 0);
549
+ res.writeHead(401, { "Content-Type": "application/json" });
550
+ res.end(JSON.stringify({ ok: false, error: "Invalid username or PIN", attemptsLeft: Math.max(attemptsLeft, 0) }));
551
+ return;
552
+ }
553
+ clearPinFailures(ip);
554
+ var session = createMultiUserSession(user.id, tlsOptions);
555
+ res.writeHead(200, {
556
+ "Set-Cookie": session.cookie,
557
+ "Content-Type": "application/json",
558
+ });
559
+ res.end(JSON.stringify({ ok: true, user: { id: user.id, username: user.username, role: user.role } }));
560
+ } catch (e) {
561
+ res.writeHead(400, { "Content-Type": "application/json" });
562
+ res.end('{"error":"Invalid request"}');
563
+ }
564
+ });
565
+ return;
566
+ }
567
+
568
+ // Request OTP code (SMTP login)
569
+ if (req.method === "POST" && fullUrl === "/auth/request-otp") {
570
+ if (!users.isMultiUser() || !smtp.isEmailLoginEnabled()) {
571
+ res.writeHead(400, { "Content-Type": "application/json" });
572
+ res.end('{"error":"OTP login not available"}');
573
+ return;
574
+ }
575
+ var ip = req.socket.remoteAddress || "";
576
+ var remaining = checkPinRateLimit(ip);
577
+ if (remaining !== null) {
578
+ res.writeHead(429, { "Content-Type": "application/json" });
579
+ res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
580
+ return;
581
+ }
582
+ var body = "";
583
+ req.on("data", function (chunk) { body += chunk; });
584
+ req.on("end", function () {
585
+ try {
586
+ var data = JSON.parse(body);
587
+ if (!data.email) {
588
+ res.writeHead(400, { "Content-Type": "application/json" });
589
+ res.end('{"error":"Email is required"}');
590
+ return;
591
+ }
592
+ var user = users.findUserByEmail(data.email);
593
+ if (!user) {
594
+ // Don't reveal whether user exists — still say ok
595
+ res.writeHead(200, { "Content-Type": "application/json" });
596
+ res.end('{"ok":true}');
597
+ return;
598
+ }
599
+ var result = smtp.requestOtp(data.email);
600
+ if (result.error) {
601
+ res.writeHead(429, { "Content-Type": "application/json" });
602
+ res.end(JSON.stringify({ error: result.error }));
603
+ return;
604
+ }
605
+ smtp.sendOtpEmail(data.email, result.code).then(function () {
606
+ res.writeHead(200, { "Content-Type": "application/json" });
607
+ res.end('{"ok":true}');
608
+ }).catch(function () {
609
+ res.writeHead(500, { "Content-Type": "application/json" });
610
+ res.end('{"error":"Failed to send email"}');
611
+ });
612
+ } catch (e) {
613
+ res.writeHead(400, { "Content-Type": "application/json" });
614
+ res.end('{"error":"Invalid request"}');
615
+ }
616
+ });
617
+ return;
618
+ }
619
+
620
+ // Verify OTP code (SMTP login)
621
+ if (req.method === "POST" && fullUrl === "/auth/verify-otp") {
622
+ if (!users.isMultiUser() || !smtp.isEmailLoginEnabled()) {
623
+ res.writeHead(400, { "Content-Type": "application/json" });
624
+ res.end('{"error":"OTP login not available"}');
625
+ return;
626
+ }
627
+ var ip = req.socket.remoteAddress || "";
628
+ var remaining = checkPinRateLimit(ip);
629
+ if (remaining !== null) {
630
+ res.writeHead(429, { "Content-Type": "application/json" });
631
+ res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
632
+ return;
633
+ }
634
+ var body = "";
635
+ req.on("data", function (chunk) { body += chunk; });
636
+ req.on("end", function () {
637
+ try {
638
+ var data = JSON.parse(body);
639
+ if (!data.email || !data.code) {
640
+ res.writeHead(400, { "Content-Type": "application/json" });
641
+ res.end('{"error":"Email and code are required"}');
642
+ return;
643
+ }
644
+ var otpResult = smtp.verifyOtp(data.email, data.code);
645
+ if (!otpResult.valid) {
646
+ recordPinFailure(ip);
647
+ res.writeHead(401, { "Content-Type": "application/json" });
648
+ res.end(JSON.stringify({ ok: false, error: otpResult.error, attemptsLeft: otpResult.attemptsLeft }));
649
+ return;
650
+ }
651
+ var user = users.findUserByEmail(data.email);
652
+ if (!user) {
653
+ res.writeHead(401, { "Content-Type": "application/json" });
654
+ res.end('{"ok":false,"error":"Account not found"}');
655
+ return;
656
+ }
657
+ clearPinFailures(ip);
658
+ var session = createMultiUserSession(user.id, tlsOptions);
659
+ res.writeHead(200, {
660
+ "Set-Cookie": session.cookie,
661
+ "Content-Type": "application/json",
662
+ });
663
+ res.end(JSON.stringify({ ok: true, user: { id: user.id, username: user.username, role: user.role } }));
664
+ } catch (e) {
665
+ res.writeHead(400, { "Content-Type": "application/json" });
666
+ res.end('{"error":"Invalid request"}');
667
+ }
668
+ });
669
+ return;
670
+ }
671
+
672
+ // Invite registration
673
+ if (req.method === "POST" && fullUrl === "/auth/register") {
674
+ if (!users.isMultiUser()) {
675
+ res.writeHead(400, { "Content-Type": "application/json" });
676
+ res.end('{"error":"Multi-user mode is not enabled"}');
677
+ return;
678
+ }
679
+ var body = "";
680
+ req.on("data", function (chunk) { body += chunk; });
681
+ req.on("end", function () {
682
+ try {
683
+ var data = JSON.parse(body);
684
+ var validation = users.validateInvite(data.inviteCode);
685
+ if (!validation.valid) {
686
+ res.writeHead(400, { "Content-Type": "application/json" });
687
+ res.end(JSON.stringify({ error: validation.error }));
688
+ return;
689
+ }
690
+ if (!data.username || data.username.trim().length < 1 || data.username.length > 100) {
691
+ res.writeHead(400, { "Content-Type": "application/json" });
692
+ res.end('{"error":"Username is required"}');
693
+ return;
694
+ }
695
+ var result;
696
+ if (smtp.isEmailLoginEnabled() && !data.pin) {
697
+ // SMTP mode: username + email required, no PIN
698
+ if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
699
+ res.writeHead(400, { "Content-Type": "application/json" });
700
+ res.end('{"error":"A valid email address is required"}');
701
+ return;
702
+ }
703
+ result = users.createUserWithoutPin({
704
+ username: data.username.trim(),
705
+ email: data.email,
706
+ displayName: data.displayName || data.username.trim(),
707
+ role: "user",
708
+ });
709
+ } else {
710
+ // PIN mode: username + PIN, no email required
711
+ if (!data.pin || !/^\d{6}$/.test(data.pin)) {
712
+ res.writeHead(400, { "Content-Type": "application/json" });
713
+ res.end('{"error":"PIN must be exactly 6 digits"}');
714
+ return;
715
+ }
716
+ result = users.createUser({
717
+ username: data.username.trim(),
718
+ email: data.email || null,
719
+ displayName: data.displayName || data.username.trim(),
720
+ pin: data.pin,
721
+ role: "user",
722
+ });
723
+ }
724
+ if (result.error) {
725
+ res.writeHead(400, { "Content-Type": "application/json" });
726
+ res.end(JSON.stringify({ error: result.error }));
727
+ return;
728
+ }
729
+ users.markInviteUsed(data.inviteCode);
730
+ var session = createMultiUserSession(result.user.id, tlsOptions);
731
+ res.writeHead(200, {
732
+ "Set-Cookie": session.cookie,
733
+ "Content-Type": "application/json",
734
+ });
735
+ res.end(JSON.stringify({ ok: true, user: { id: result.user.id, username: result.user.username, role: result.user.role } }));
736
+ } catch (e) {
737
+ res.writeHead(400, { "Content-Type": "application/json" });
738
+ res.end('{"error":"Invalid request"}');
739
+ }
740
+ });
741
+ return;
742
+ }
743
+
744
+ // Invite page (magic link)
745
+ if (req.method === "GET" && fullUrl.indexOf("/invite/") === 0) {
746
+ var inviteCode = fullUrl.substring("/invite/".length);
747
+ if (!users.isMultiUser()) {
748
+ res.writeHead(404, { "Content-Type": "text/plain" });
749
+ res.end("Not found");
750
+ return;
751
+ }
752
+ var validation = users.validateInvite(inviteCode);
753
+ if (!validation.valid) {
754
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
755
+ res.end('<!DOCTYPE html><html><head><title>Clay</title>' +
756
+ '<style>body{background:#2F2E2B;color:#E8E5DE;font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}' +
757
+ '.c{text-align:center;max-width:360px;padding:20px}h1{color:#DA7756;margin-bottom:16px}p{color:#908B81}</style></head>' +
758
+ '<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>');
759
+ return;
760
+ }
761
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
762
+ res.end(smtp.isEmailLoginEnabled() ? smtpInvitePageHtml(inviteCode) : invitePageHtml(inviteCode));
763
+ return;
764
+ }
765
+
362
766
  // CA certificate download
363
767
  if (req.url === "/ca/download" && req.method === "GET" && caContent) {
364
768
  res.writeHead(200, {
@@ -454,10 +858,25 @@ function createServer(opts) {
454
858
  return;
455
859
  }
456
860
 
457
- // User profile
861
+ // User profile — user-scoped in multi-user mode, global in single-user mode
458
862
  var profilePath = path.join(CONFIG_DIR, "profile.json");
459
863
 
460
864
  if (req.method === "GET" && fullUrl === "/api/profile") {
865
+ if (users.isMultiUser()) {
866
+ var mu = getMultiUserFromReq(req);
867
+ if (!mu) {
868
+ res.writeHead(401, { "Content-Type": "application/json" });
869
+ res.end('{"error":"unauthorized"}');
870
+ return;
871
+ }
872
+ var profile = mu.profile || { name: "", lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "" };
873
+ profile.username = mu.username;
874
+ profile.userId = mu.id;
875
+ profile.role = mu.role;
876
+ res.writeHead(200, { "Content-Type": "application/json" });
877
+ res.end(JSON.stringify(profile));
878
+ return;
879
+ }
461
880
  var profile = { name: "", lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "" };
462
881
  try {
463
882
  var raw = fs.readFileSync(profilePath, "utf8");
@@ -487,7 +906,24 @@ function createServer(opts) {
487
906
  }
488
907
  if (typeof data.avatarStyle === "string") profile.avatarStyle = data.avatarStyle.substring(0, 30);
489
908
  if (typeof data.avatarSeed === "string") profile.avatarSeed = data.avatarSeed.substring(0, 30);
490
- fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2), "utf8");
909
+ if (users.isMultiUser()) {
910
+ var mu = getMultiUserFromReq(req);
911
+ if (!mu) {
912
+ res.writeHead(401, { "Content-Type": "application/json" });
913
+ res.end('{"error":"unauthorized"}');
914
+ return;
915
+ }
916
+ users.updateUserProfile(mu.id, profile);
917
+ // Broadcast updated avatar/presence to all projects
918
+ projects.forEach(function (pCtx) {
919
+ pCtx.refreshUserProfile(mu.id);
920
+ });
921
+ } else {
922
+ fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2), "utf8");
923
+ if (process.platform !== "win32") {
924
+ try { fs.chmodSync(profilePath, 0o600); } catch (chmodErr) {}
925
+ }
926
+ }
491
927
  res.writeHead(200, { "Content-Type": "application/json" });
492
928
  res.end(JSON.stringify(profile));
493
929
  } catch (e) {
@@ -498,6 +934,440 @@ function createServer(opts) {
498
934
  return;
499
935
  }
500
936
 
937
+ // --- Admin API endpoints (multi-user mode only) ---
938
+
939
+ // List all users (admin only)
940
+ if (req.method === "GET" && fullUrl === "/api/admin/users") {
941
+ if (!users.isMultiUser()) {
942
+ res.writeHead(404, { "Content-Type": "application/json" });
943
+ res.end('{"error":"Not found"}');
944
+ return;
945
+ }
946
+ var mu = getMultiUserFromReq(req);
947
+ if (!mu || mu.role !== "admin") {
948
+ res.writeHead(403, { "Content-Type": "application/json" });
949
+ res.end('{"error":"Admin access required"}');
950
+ return;
951
+ }
952
+ res.writeHead(200, { "Content-Type": "application/json" });
953
+ res.end(JSON.stringify({ users: users.getAllUsers() }));
954
+ return;
955
+ }
956
+
957
+ // Remove user (admin only)
958
+ if (req.method === "DELETE" && fullUrl.indexOf("/api/admin/users/") === 0) {
959
+ if (!users.isMultiUser()) {
960
+ res.writeHead(404, { "Content-Type": "application/json" });
961
+ res.end('{"error":"Not found"}');
962
+ return;
963
+ }
964
+ var mu = getMultiUserFromReq(req);
965
+ if (!mu || mu.role !== "admin") {
966
+ res.writeHead(403, { "Content-Type": "application/json" });
967
+ res.end('{"error":"Admin access required"}');
968
+ return;
969
+ }
970
+ var targetUserId = fullUrl.substring("/api/admin/users/".length);
971
+ if (targetUserId === mu.id) {
972
+ res.writeHead(400, { "Content-Type": "application/json" });
973
+ res.end('{"error":"Cannot remove yourself"}');
974
+ return;
975
+ }
976
+ var result = users.removeUser(targetUserId);
977
+ if (result.error) {
978
+ res.writeHead(404, { "Content-Type": "application/json" });
979
+ res.end(JSON.stringify({ error: result.error }));
980
+ return;
981
+ }
982
+ res.writeHead(200, { "Content-Type": "application/json" });
983
+ res.end('{"ok":true}');
984
+ return;
985
+ }
986
+
987
+ // Create invite (admin only)
988
+ if (req.method === "POST" && fullUrl === "/api/admin/invites") {
989
+ if (!users.isMultiUser()) {
990
+ res.writeHead(404, { "Content-Type": "application/json" });
991
+ res.end('{"error":"Not found"}');
992
+ return;
993
+ }
994
+ var mu = getMultiUserFromReq(req);
995
+ if (!mu || mu.role !== "admin") {
996
+ res.writeHead(403, { "Content-Type": "application/json" });
997
+ res.end('{"error":"Admin access required"}');
998
+ return;
999
+ }
1000
+ var invite = users.createInvite(mu.id);
1001
+ var proto = tlsOptions ? "https" : "http";
1002
+ var host = req.headers.host || ("localhost:" + portNum);
1003
+ var inviteUrl = proto + "://" + host + "/invite/" + invite.code;
1004
+ res.writeHead(200, { "Content-Type": "application/json" });
1005
+ res.end(JSON.stringify({ ok: true, invite: invite, url: inviteUrl }));
1006
+ return;
1007
+ }
1008
+
1009
+ // List invites (admin only)
1010
+ if (req.method === "GET" && fullUrl === "/api/admin/invites") {
1011
+ if (!users.isMultiUser()) {
1012
+ res.writeHead(404, { "Content-Type": "application/json" });
1013
+ res.end('{"error":"Not found"}');
1014
+ return;
1015
+ }
1016
+ var mu = getMultiUserFromReq(req);
1017
+ if (!mu || mu.role !== "admin") {
1018
+ res.writeHead(403, { "Content-Type": "application/json" });
1019
+ res.end('{"error":"Admin access required"}');
1020
+ return;
1021
+ }
1022
+ res.writeHead(200, { "Content-Type": "application/json" });
1023
+ res.end(JSON.stringify({ invites: users.getInvites() }));
1024
+ return;
1025
+ }
1026
+
1027
+ // Revoke invite (admin only)
1028
+ if (req.method === "DELETE" && fullUrl.indexOf("/api/admin/invites/") === 0) {
1029
+ if (!users.isMultiUser()) {
1030
+ res.writeHead(404, { "Content-Type": "application/json" });
1031
+ res.end('{"error":"Not found"}');
1032
+ return;
1033
+ }
1034
+ var mu = getMultiUserFromReq(req);
1035
+ if (!mu || mu.role !== "admin") {
1036
+ res.writeHead(403, { "Content-Type": "application/json" });
1037
+ res.end('{"error":"Admin access required"}');
1038
+ return;
1039
+ }
1040
+ var inviteCode = decodeURIComponent(fullUrl.replace("/api/admin/invites/", ""));
1041
+ if (!inviteCode) {
1042
+ res.writeHead(400, { "Content-Type": "application/json" });
1043
+ res.end('{"error":"Invite code is required"}');
1044
+ return;
1045
+ }
1046
+ var result = users.revokeInvite(inviteCode);
1047
+ if (result.error) {
1048
+ res.writeHead(404, { "Content-Type": "application/json" });
1049
+ res.end(JSON.stringify({ error: result.error }));
1050
+ return;
1051
+ }
1052
+ res.writeHead(200, { "Content-Type": "application/json" });
1053
+ res.end('{"ok":true}');
1054
+ return;
1055
+ }
1056
+
1057
+ // Send invite via email (admin only)
1058
+ if (req.method === "POST" && fullUrl === "/api/admin/invites/email") {
1059
+ if (!users.isMultiUser() || !smtp.isSmtpConfigured()) {
1060
+ res.writeHead(400, { "Content-Type": "application/json" });
1061
+ res.end('{"error":"SMTP not configured"}');
1062
+ return;
1063
+ }
1064
+ var mu = getMultiUserFromReq(req);
1065
+ if (!mu || mu.role !== "admin") {
1066
+ res.writeHead(403, { "Content-Type": "application/json" });
1067
+ res.end('{"error":"Admin access required"}');
1068
+ return;
1069
+ }
1070
+ var body = "";
1071
+ req.on("data", function (chunk) { body += chunk; });
1072
+ req.on("end", function () {
1073
+ try {
1074
+ var data = JSON.parse(body);
1075
+ if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
1076
+ res.writeHead(400, { "Content-Type": "application/json" });
1077
+ res.end('{"error":"Valid email is required"}');
1078
+ return;
1079
+ }
1080
+ var invite = users.createInvite(mu.id, data.email);
1081
+ var proto = tlsOptions ? "https" : "http";
1082
+ var host = req.headers.host || ("localhost:" + portNum);
1083
+ var inviteUrl = proto + "://" + host + "/invite/" + invite.code;
1084
+ smtp.sendInviteEmail(data.email, inviteUrl, mu.displayName || mu.username).then(function () {
1085
+ res.writeHead(200, { "Content-Type": "application/json" });
1086
+ res.end(JSON.stringify({ ok: true, invite: invite, url: inviteUrl }));
1087
+ }).catch(function (err) {
1088
+ res.writeHead(500, { "Content-Type": "application/json" });
1089
+ res.end(JSON.stringify({ error: "Failed to send email: " + (err.message || "unknown error") }));
1090
+ });
1091
+ } catch (e) {
1092
+ res.writeHead(400, { "Content-Type": "application/json" });
1093
+ res.end('{"error":"Invalid request"}');
1094
+ }
1095
+ });
1096
+ return;
1097
+ }
1098
+
1099
+ // Get SMTP config (admin only)
1100
+ if (req.method === "GET" && fullUrl === "/api/admin/smtp") {
1101
+ if (!users.isMultiUser()) {
1102
+ res.writeHead(404, { "Content-Type": "application/json" });
1103
+ res.end('{"error":"Not found"}');
1104
+ return;
1105
+ }
1106
+ var mu = getMultiUserFromReq(req);
1107
+ if (!mu || mu.role !== "admin") {
1108
+ res.writeHead(403, { "Content-Type": "application/json" });
1109
+ res.end('{"error":"Admin access required"}');
1110
+ return;
1111
+ }
1112
+ var cfg = smtp.getSmtpConfig();
1113
+ if (cfg) {
1114
+ res.writeHead(200, { "Content-Type": "application/json" });
1115
+ res.end(JSON.stringify({ smtp: { host: cfg.host, port: cfg.port, secure: cfg.secure, user: cfg.user, pass: "••••••••", from: cfg.from, emailLoginEnabled: !!cfg.emailLoginEnabled } }));
1116
+ } else {
1117
+ res.writeHead(200, { "Content-Type": "application/json" });
1118
+ res.end('{"smtp":null}');
1119
+ }
1120
+ return;
1121
+ }
1122
+
1123
+ // Save SMTP config (admin only)
1124
+ if (req.method === "POST" && fullUrl === "/api/admin/smtp") {
1125
+ if (!users.isMultiUser()) {
1126
+ res.writeHead(404, { "Content-Type": "application/json" });
1127
+ res.end('{"error":"Not found"}');
1128
+ return;
1129
+ }
1130
+ var mu = getMultiUserFromReq(req);
1131
+ if (!mu || mu.role !== "admin") {
1132
+ res.writeHead(403, { "Content-Type": "application/json" });
1133
+ res.end('{"error":"Admin access required"}');
1134
+ return;
1135
+ }
1136
+ var body = "";
1137
+ req.on("data", function (chunk) { body += chunk; });
1138
+ req.on("end", function () {
1139
+ try {
1140
+ var data = JSON.parse(body);
1141
+ // Allow clearing SMTP config by sending empty fields
1142
+ if (!data.host && !data.user && !data.pass && !data.from) {
1143
+ smtp.saveSmtpConfig(null);
1144
+ res.writeHead(200, { "Content-Type": "application/json" });
1145
+ res.end('{"ok":true}');
1146
+ return;
1147
+ }
1148
+ if (!data.host || !data.user || !data.pass || !data.from) {
1149
+ res.writeHead(400, { "Content-Type": "application/json" });
1150
+ res.end('{"error":"Host, user, password, and from address are required"}');
1151
+ return;
1152
+ }
1153
+ // If password is masked, keep existing
1154
+ var existingCfg = smtp.getSmtpConfig();
1155
+ var pass = data.pass;
1156
+ if (pass === "••••••••" && existingCfg) {
1157
+ pass = existingCfg.pass;
1158
+ }
1159
+ smtp.saveSmtpConfig({
1160
+ host: data.host,
1161
+ port: parseInt(data.port, 10) || 587,
1162
+ secure: !!data.secure,
1163
+ user: data.user,
1164
+ pass: pass,
1165
+ from: data.from,
1166
+ emailLoginEnabled: !!data.emailLoginEnabled,
1167
+ });
1168
+ res.writeHead(200, { "Content-Type": "application/json" });
1169
+ res.end('{"ok":true}');
1170
+ } catch (e) {
1171
+ res.writeHead(400, { "Content-Type": "application/json" });
1172
+ res.end('{"error":"Invalid request"}');
1173
+ }
1174
+ });
1175
+ return;
1176
+ }
1177
+
1178
+ // Test SMTP connection (admin only)
1179
+ if (req.method === "POST" && fullUrl === "/api/admin/smtp/test") {
1180
+ if (!users.isMultiUser()) {
1181
+ res.writeHead(404, { "Content-Type": "application/json" });
1182
+ res.end('{"error":"Not found"}');
1183
+ return;
1184
+ }
1185
+ var mu = getMultiUserFromReq(req);
1186
+ if (!mu || mu.role !== "admin") {
1187
+ res.writeHead(403, { "Content-Type": "application/json" });
1188
+ res.end('{"error":"Admin access required"}');
1189
+ return;
1190
+ }
1191
+ var body = "";
1192
+ req.on("data", function (chunk) { body += chunk; });
1193
+ req.on("end", function () {
1194
+ try {
1195
+ var data = JSON.parse(body);
1196
+ // Use provided config or fall back to saved
1197
+ var existingCfg = smtp.getSmtpConfig();
1198
+ var pass = data.pass;
1199
+ if (pass === "••••••••" && existingCfg) {
1200
+ pass = existingCfg.pass;
1201
+ }
1202
+ var cfg = {
1203
+ host: data.host || (existingCfg && existingCfg.host),
1204
+ port: parseInt(data.port, 10) || (existingCfg && existingCfg.port) || 587,
1205
+ secure: data.secure !== undefined ? !!data.secure : (existingCfg && !!existingCfg.secure),
1206
+ user: data.user || (existingCfg && existingCfg.user),
1207
+ pass: pass || (existingCfg && existingCfg.pass),
1208
+ from: data.from || (existingCfg && existingCfg.from),
1209
+ };
1210
+ if (!cfg.host || !cfg.user || !cfg.pass || !cfg.from) {
1211
+ res.writeHead(400, { "Content-Type": "application/json" });
1212
+ res.end('{"error":"SMTP configuration is incomplete"}');
1213
+ return;
1214
+ }
1215
+ var testTo = mu.email || cfg.from;
1216
+ smtp.sendTestEmail(cfg, testTo).then(function (result) {
1217
+ res.writeHead(200, { "Content-Type": "application/json" });
1218
+ res.end(JSON.stringify({ ok: true, message: "Test email sent to " + testTo }));
1219
+ }).catch(function (err) {
1220
+ res.writeHead(500, { "Content-Type": "application/json" });
1221
+ res.end(JSON.stringify({ ok: false, error: err.message || "Connection failed" }));
1222
+ });
1223
+ } catch (e) {
1224
+ res.writeHead(400, { "Content-Type": "application/json" });
1225
+ res.end('{"error":"Invalid request"}');
1226
+ }
1227
+ });
1228
+ return;
1229
+ }
1230
+
1231
+ // --- Project access control (admin only, multi-user) ---
1232
+
1233
+ // Set project visibility (admin only)
1234
+ if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/visibility$/.test(fullUrl)) {
1235
+ if (!users.isMultiUser()) {
1236
+ res.writeHead(404, { "Content-Type": "application/json" });
1237
+ res.end('{"error":"Not found"}');
1238
+ return;
1239
+ }
1240
+ var mu = getMultiUserFromReq(req);
1241
+ if (!mu || mu.role !== "admin") {
1242
+ res.writeHead(403, { "Content-Type": "application/json" });
1243
+ res.end('{"error":"Admin access required"}');
1244
+ return;
1245
+ }
1246
+ var projSlug = fullUrl.split("/")[4];
1247
+ var body = "";
1248
+ req.on("data", function (chunk) { body += chunk; });
1249
+ req.on("end", function () {
1250
+ try {
1251
+ var data = JSON.parse(body);
1252
+ if (data.visibility !== "public" && data.visibility !== "private") {
1253
+ res.writeHead(400, { "Content-Type": "application/json" });
1254
+ res.end('{"error":"Visibility must be public or private"}');
1255
+ return;
1256
+ }
1257
+ if (!onSetProjectVisibility) {
1258
+ res.writeHead(500, { "Content-Type": "application/json" });
1259
+ res.end('{"error":"Visibility handler not configured"}');
1260
+ return;
1261
+ }
1262
+ var result = onSetProjectVisibility(projSlug, data.visibility);
1263
+ if (result && result.error) {
1264
+ res.writeHead(404, { "Content-Type": "application/json" });
1265
+ res.end(JSON.stringify({ error: result.error }));
1266
+ return;
1267
+ }
1268
+ res.writeHead(200, { "Content-Type": "application/json" });
1269
+ res.end('{"ok":true}');
1270
+ } catch (e) {
1271
+ res.writeHead(400, { "Content-Type": "application/json" });
1272
+ res.end('{"error":"Invalid request"}');
1273
+ }
1274
+ });
1275
+ return;
1276
+ }
1277
+
1278
+ // Set project allowed users (admin only)
1279
+ if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/users$/.test(fullUrl)) {
1280
+ if (!users.isMultiUser()) {
1281
+ res.writeHead(404, { "Content-Type": "application/json" });
1282
+ res.end('{"error":"Not found"}');
1283
+ return;
1284
+ }
1285
+ var mu = getMultiUserFromReq(req);
1286
+ if (!mu || mu.role !== "admin") {
1287
+ res.writeHead(403, { "Content-Type": "application/json" });
1288
+ res.end('{"error":"Admin access required"}');
1289
+ return;
1290
+ }
1291
+ var projSlug = fullUrl.split("/")[4];
1292
+ var body = "";
1293
+ req.on("data", function (chunk) { body += chunk; });
1294
+ req.on("end", function () {
1295
+ try {
1296
+ var data = JSON.parse(body);
1297
+ if (!Array.isArray(data.allowedUsers)) {
1298
+ res.writeHead(400, { "Content-Type": "application/json" });
1299
+ res.end('{"error":"allowedUsers must be an array"}');
1300
+ return;
1301
+ }
1302
+ if (!onSetProjectAllowedUsers) {
1303
+ res.writeHead(500, { "Content-Type": "application/json" });
1304
+ res.end('{"error":"AllowedUsers handler not configured"}');
1305
+ return;
1306
+ }
1307
+ var result = onSetProjectAllowedUsers(projSlug, data.allowedUsers);
1308
+ if (result && result.error) {
1309
+ res.writeHead(404, { "Content-Type": "application/json" });
1310
+ res.end(JSON.stringify({ error: result.error }));
1311
+ return;
1312
+ }
1313
+ res.writeHead(200, { "Content-Type": "application/json" });
1314
+ res.end('{"ok":true}');
1315
+ } catch (e) {
1316
+ res.writeHead(400, { "Content-Type": "application/json" });
1317
+ res.end('{"error":"Invalid request"}');
1318
+ }
1319
+ });
1320
+ return;
1321
+ }
1322
+
1323
+ // Get project access info (admin only)
1324
+ if (req.method === "GET" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/access$/.test(fullUrl)) {
1325
+ if (!users.isMultiUser()) {
1326
+ res.writeHead(404, { "Content-Type": "application/json" });
1327
+ res.end('{"error":"Not found"}');
1328
+ return;
1329
+ }
1330
+ var mu = getMultiUserFromReq(req);
1331
+ if (!mu || mu.role !== "admin") {
1332
+ res.writeHead(403, { "Content-Type": "application/json" });
1333
+ res.end('{"error":"Admin access required"}');
1334
+ return;
1335
+ }
1336
+ var projSlug = fullUrl.split("/")[4];
1337
+ if (!onGetProjectAccess) {
1338
+ res.writeHead(500, { "Content-Type": "application/json" });
1339
+ res.end('{"error":"Access handler not configured"}');
1340
+ return;
1341
+ }
1342
+ var access = onGetProjectAccess(projSlug);
1343
+ if (access && access.error) {
1344
+ res.writeHead(404, { "Content-Type": "application/json" });
1345
+ res.end(JSON.stringify({ error: access.error }));
1346
+ return;
1347
+ }
1348
+ res.writeHead(200, { "Content-Type": "application/json" });
1349
+ res.end(JSON.stringify(access));
1350
+ return;
1351
+ }
1352
+
1353
+ // Multi-user info endpoint (who am I?)
1354
+ if (req.method === "GET" && fullUrl === "/api/me") {
1355
+ if (!users.isMultiUser()) {
1356
+ res.writeHead(200, { "Content-Type": "application/json" });
1357
+ res.end('{"multiUser":false}');
1358
+ return;
1359
+ }
1360
+ var mu = getMultiUserFromReq(req);
1361
+ if (!mu) {
1362
+ res.writeHead(401, { "Content-Type": "application/json" });
1363
+ res.end('{"error":"unauthorized"}');
1364
+ return;
1365
+ }
1366
+ res.writeHead(200, { "Content-Type": "application/json" });
1367
+ res.end(JSON.stringify({ multiUser: true, smtpEnabled: smtp.isSmtpConfigured(), emailLoginEnabled: smtp.isEmailLoginEnabled(), user: { id: mu.id, username: mu.username, email: mu.email || null, displayName: mu.displayName, role: mu.role } }));
1368
+ return;
1369
+ }
1370
+
501
1371
  // Skills proxy: leaderboard list
502
1372
  if (req.method === "GET" && fullUrl === "/api/skills") {
503
1373
  var qs = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
@@ -593,17 +1463,37 @@ function createServer(opts) {
593
1463
  return;
594
1464
  }
595
1465
 
596
- // Root path — redirect to first project
1466
+ // Root path — redirect to first accessible project
597
1467
  if (fullUrl === "/" && req.method === "GET") {
598
- if (!isAuthed(req, authToken)) {
1468
+ if (!isRequestAuthed(req)) {
599
1469
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
600
- res.end(pinPage);
1470
+ res.end(getAuthPage());
601
1471
  return;
602
1472
  }
603
1473
  if (projects.size > 0) {
604
- var slug = projects.keys().next().value;
605
- res.writeHead(302, { "Location": "/p/" + slug + "/" });
606
- res.end();
1474
+ var targetSlug = null;
1475
+ var reqUser = users.isMultiUser() ? getMultiUserFromReq(req) : null;
1476
+ projects.forEach(function (ctx, s) {
1477
+ if (targetSlug) return;
1478
+ if (reqUser && onGetProjectAccess) {
1479
+ var access = onGetProjectAccess(s);
1480
+ if (access && !access.error && users.canAccessProject(reqUser.id, access)) {
1481
+ targetSlug = s;
1482
+ }
1483
+ } else {
1484
+ targetSlug = s;
1485
+ }
1486
+ });
1487
+ if (targetSlug) {
1488
+ res.writeHead(302, { "Location": "/p/" + targetSlug + "/" });
1489
+ res.end();
1490
+ return;
1491
+ }
1492
+ }
1493
+ // No accessible projects — show info page
1494
+ if (users.isMultiUser()) {
1495
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1496
+ res.end(noProjectsPageHtml());
607
1497
  return;
608
1498
  }
609
1499
  res.writeHead(200, { "Content-Type": "text/plain" });
@@ -611,21 +1501,22 @@ function createServer(opts) {
611
1501
  return;
612
1502
  }
613
1503
 
614
- // Global info endpoint (auth required)
1504
+ // Global info endpoint (projects only for authenticated requests)
615
1505
  if (req.method === "GET" && req.url === "/info") {
616
- if (!isAuthed(req, authToken)) {
1506
+ if (!isRequestAuthed(req)) {
617
1507
  res.writeHead(401, {
618
1508
  "Content-Type": "application/json",
619
1509
  "Access-Control-Allow-Origin": "*",
620
1510
  });
621
- res.end('{"error":"unauthorized"}');
1511
+ res.end(JSON.stringify({ version: currentVersion, authenticated: false }));
622
1512
  return;
623
1513
  }
624
1514
  var projectList = [];
625
1515
  projects.forEach(function (ctx, slug) {
626
1516
  projectList.push({ slug: slug, project: ctx.project });
627
1517
  });
628
- res.end(JSON.stringify({ projects: projectList, version: currentVersion }));
1518
+ res.writeHead(200, { "Content-Type": "application/json" });
1519
+ res.end(JSON.stringify({ projects: projectList, version: currentVersion, authenticated: true }));
629
1520
  return;
630
1521
  }
631
1522
 
@@ -658,12 +1549,25 @@ function createServer(opts) {
658
1549
  }
659
1550
 
660
1551
  // Auth check for project routes
661
- if (!isAuthed(req, authToken)) {
1552
+ if (!isRequestAuthed(req)) {
662
1553
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
663
- res.end(pinPage);
1554
+ res.end(getAuthPage());
664
1555
  return;
665
1556
  }
666
1557
 
1558
+ // Multi-user: check project access for HTTP requests
1559
+ if (users.isMultiUser() && onGetProjectAccess) {
1560
+ var httpUser = getMultiUserFromReq(req);
1561
+ if (httpUser) {
1562
+ var httpAccess = onGetProjectAccess(slug);
1563
+ if (httpAccess && !httpAccess.error && !users.canAccessProject(httpUser.id, httpAccess)) {
1564
+ res.writeHead(302, { "Location": "/" });
1565
+ res.end();
1566
+ return;
1567
+ }
1568
+ }
1569
+ }
1570
+
667
1571
  // Strip prefix for project-scoped handling
668
1572
  var projectUrl = stripPrefix(req.url.split("?")[0], slug);
669
1573
  // Re-attach query string for API routes
@@ -777,7 +1681,7 @@ function createServer(opts) {
777
1681
  }
778
1682
  }
779
1683
 
780
- if (!isAuthed(req, authToken)) {
1684
+ if (!isRequestAuthed(req)) {
781
1685
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
782
1686
  socket.destroy();
783
1687
  return;
@@ -796,8 +1700,49 @@ function createServer(opts) {
796
1700
  return;
797
1701
  }
798
1702
 
1703
+ // Attach user info to the WS connection for multi-user filtering
1704
+ var wsUser = null;
1705
+ if (users.isMultiUser()) {
1706
+ wsUser = getMultiUserFromReq(req);
1707
+ // Check project access for multi-user mode
1708
+ if (wsUser && onGetProjectAccess) {
1709
+ var projectAccess = onGetProjectAccess(wsSlug);
1710
+ if (projectAccess && !projectAccess.error) {
1711
+ if (!users.canAccessProject(wsUser.id, projectAccess)) {
1712
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1713
+ socket.destroy();
1714
+ return;
1715
+ }
1716
+ }
1717
+ }
1718
+ }
1719
+
799
1720
  wss.handleUpgrade(req, socket, head, function (ws) {
800
- ctx.handleConnection(ws);
1721
+ // Apply rate limiting to WS messages
1722
+ var msgCount = 0;
1723
+ var msgWindowStart = Date.now();
1724
+ var WS_RATE_LIMIT = 60; // messages per second
1725
+ var origEmit = ws.emit;
1726
+ ws.emit = function (event) {
1727
+ if (event === "message") {
1728
+ var now = Date.now();
1729
+ if (now - msgWindowStart >= 1000) {
1730
+ msgCount = 0;
1731
+ msgWindowStart = now;
1732
+ }
1733
+ msgCount++;
1734
+ if (msgCount > WS_RATE_LIMIT) {
1735
+ try {
1736
+ ws.send(JSON.stringify({ type: "error", message: "Rate limit exceeded. Connection will be closed." }));
1737
+ ws.close(1008, "Rate limit exceeded");
1738
+ } catch (e) {}
1739
+ return false;
1740
+ }
1741
+ }
1742
+ return origEmit.apply(ws, arguments);
1743
+ };
1744
+ ws._clayUser = wsUser; // attach user context
1745
+ ctx.handleConnection(ws, wsUser);
801
1746
  });
802
1747
  });
803
1748
 
@@ -807,11 +1752,34 @@ function createServer(opts) {
807
1752
  if (processingUpdateTimer) clearTimeout(processingUpdateTimer);
808
1753
  processingUpdateTimer = setTimeout(function () {
809
1754
  processingUpdateTimer = null;
810
- broadcastAll({
811
- type: "projects_updated",
812
- projects: getProjects(),
813
- projectCount: projects.size,
814
- });
1755
+ if (users.isMultiUser() && onGetProjectAccess) {
1756
+ // Per-client filtered project list
1757
+ var allProjectsList = getProjects();
1758
+ projects.forEach(function (ctx) {
1759
+ ctx.forEachClient(function (ws) {
1760
+ var wsUser = ws._clayUser;
1761
+ var filtered = allProjectsList;
1762
+ if (wsUser) {
1763
+ filtered = allProjectsList.filter(function (p) {
1764
+ var access = onGetProjectAccess(p.slug);
1765
+ if (!access || access.error) return true;
1766
+ return users.canAccessProject(wsUser.id, access);
1767
+ });
1768
+ }
1769
+ ws.send(JSON.stringify({
1770
+ type: "projects_updated",
1771
+ projects: filtered,
1772
+ projectCount: filtered.length,
1773
+ }));
1774
+ });
1775
+ });
1776
+ } else {
1777
+ broadcastAll({
1778
+ type: "projects_updated",
1779
+ projects: getProjects(),
1780
+ projectCount: projects.size,
1781
+ });
1782
+ }
815
1783
  }, 200);
816
1784
  }
817
1785
 
@@ -829,9 +1797,16 @@ function createServer(opts) {
829
1797
  currentVersion: currentVersion,
830
1798
  lanHost: lanHost,
831
1799
  getProjectCount: function () { return projects.size; },
832
- getProjectList: function () {
1800
+ getProjectList: function (userId) {
833
1801
  var list = [];
834
- projects.forEach(function (ctx) { list.push(ctx.getStatus()); });
1802
+ projects.forEach(function (ctx, s) {
1803
+ var status = ctx.getStatus();
1804
+ if (userId && users.isMultiUser() && onGetProjectAccess) {
1805
+ var access = onGetProjectAccess(s);
1806
+ if (access && !access.error && !users.canAccessProject(userId, access)) return;
1807
+ }
1808
+ list.push(status);
1809
+ });
835
1810
  return list;
836
1811
  },
837
1812
  getHubSchedules: function () {
@@ -896,6 +1871,7 @@ function createServer(opts) {
896
1871
  if (!ctx) return 0;
897
1872
  return ctx.getSchedules().length;
898
1873
  },
1874
+ onPresenceChange: broadcastPresenceChange,
899
1875
  onProcessingChanged: broadcastProcessingChange,
900
1876
  onAddProject: onAddProject,
901
1877
  onRemoveProject: onRemoveProject,
@@ -974,6 +1950,78 @@ function createServer(opts) {
974
1950
  authToken = hash;
975
1951
  }
976
1952
 
1953
+ // Collect all unique users across all projects (for topbar server-wide presence)
1954
+ function getServerUsers() {
1955
+ var seen = {};
1956
+ var list = [];
1957
+ projects.forEach(function (ctx) {
1958
+ ctx.forEachClient(function (ws) {
1959
+ if (!ws._clayUser) return;
1960
+ var u = ws._clayUser;
1961
+ if (seen[u.id]) return;
1962
+ seen[u.id] = true;
1963
+ var p = u.profile || {};
1964
+ list.push({
1965
+ id: u.id,
1966
+ displayName: p.name || u.displayName || u.username,
1967
+ username: u.username,
1968
+ avatarStyle: p.avatarStyle || "thumbs",
1969
+ avatarSeed: p.avatarSeed || u.username,
1970
+ });
1971
+ });
1972
+ });
1973
+ return list;
1974
+ }
1975
+
1976
+ // Debounced broadcast of projects_updated when presence changes
1977
+ // Sends per-user filtered project lists + server-wide user list
1978
+ var presenceTimer = null;
1979
+ function broadcastPresenceChange() {
1980
+ if (presenceTimer) return;
1981
+ presenceTimer = setTimeout(function () {
1982
+ presenceTimer = null;
1983
+ if (!users.isMultiUser()) {
1984
+ broadcastAll({
1985
+ type: "projects_updated",
1986
+ projects: getProjects(),
1987
+ projectCount: projects.size,
1988
+ });
1989
+ return;
1990
+ }
1991
+ var serverUsers = getServerUsers();
1992
+ // Build per-user filtered lists, send individually
1993
+ var sentUsers = {};
1994
+ projects.forEach(function (ctx) {
1995
+ ctx.forEachClient(function (ws) {
1996
+ var userId = ws._clayUser ? ws._clayUser.id : null;
1997
+ var key = userId || "__anon__";
1998
+ if (sentUsers[key]) {
1999
+ // Already computed for this user, just send the cached msg
2000
+ ws.send(sentUsers[key]);
2001
+ return;
2002
+ }
2003
+ var filteredProjects = [];
2004
+ projects.forEach(function (pCtx, s) {
2005
+ var status = pCtx.getStatus();
2006
+ if (userId && onGetProjectAccess) {
2007
+ var access = onGetProjectAccess(s);
2008
+ if (access && !access.error && !users.canAccessProject(userId, access)) return;
2009
+ }
2010
+ filteredProjects.push(status);
2011
+ });
2012
+ var msgStr = JSON.stringify({
2013
+ type: "projects_updated",
2014
+ projects: filteredProjects,
2015
+ projectCount: projects.size,
2016
+ serverUsers: serverUsers,
2017
+ });
2018
+ sentUsers[key] = msgStr;
2019
+ ws.send(msgStr);
2020
+ });
2021
+ });
2022
+ }, 300);
2023
+ }
2024
+
977
2025
  function broadcastAll(msg) {
978
2026
  projects.forEach(function (ctx) {
979
2027
  ctx.send(msg);
@@ -1004,4 +2052,4 @@ function createServer(opts) {
1004
2052
  };
1005
2053
  }
1006
2054
 
1007
- module.exports = { createServer: createServer, generateAuthToken: generateAuthToken };
2055
+ module.exports = { createServer: createServer, generateAuthToken: generateAuthToken, verifyPin: verifyPin };