clay-server 2.13.0-beta.2 → 2.13.0-beta.4

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
@@ -402,6 +402,91 @@ function createServer(opts) {
402
402
  var getRemovedProjects = opts.getRemovedProjects || function () { return []; };
403
403
 
404
404
  var authToken = pinHash || null;
405
+
406
+ // --- Admin password recovery (in-memory, one-time) ---
407
+ var recovery = null; // { urlPath, password }
408
+
409
+ function setRecovery(urlPath, password) {
410
+ recovery = { urlPath: urlPath, password: password };
411
+ }
412
+
413
+ function clearRecovery() {
414
+ recovery = null;
415
+ }
416
+
417
+ function recoveryPageHtml() {
418
+ return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
419
+ + '<title>Admin Password Recovery</title>'
420
+ + '<style>'
421
+ + '*{margin:0;padding:0;box-sizing:border-box}'
422
+ + 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0a0a0a;color:#e5e5e5;display:flex;align-items:center;justify-content:center;min-height:100vh}'
423
+ + '.card{background:#171717;border:1px solid #262626;border-radius:12px;padding:32px;width:100%;max-width:380px}'
424
+ + 'h1{font-size:18px;font-weight:600;margin-bottom:4px}'
425
+ + '.sub{font-size:13px;color:#737373;margin-bottom:24px}'
426
+ + 'label{display:block;font-size:13px;color:#a3a3a3;margin-bottom:6px}'
427
+ + 'input{width:100%;padding:10px 12px;background:#0a0a0a;border:1px solid #333;border-radius:8px;color:#e5e5e5;font-size:14px;outline:none;margin-bottom:16px}'
428
+ + 'input:focus{border-color:#7c3aed}'
429
+ + 'button{width:100%;padding:10px;background:#7c3aed;color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer}'
430
+ + 'button:hover{background:#6d28d9}'
431
+ + 'button:disabled{opacity:.5;cursor:not-allowed}'
432
+ + '.error{color:#ef4444;font-size:13px;margin-bottom:12px;display:none}'
433
+ + '.success{text-align:center;color:#22c55e;font-size:15px}'
434
+ + '.hidden{display:none}'
435
+ + '</style></head><body>'
436
+ + '<div class="card">'
437
+ + '<div id="step-verify">'
438
+ + '<h1>Admin Recovery</h1>'
439
+ + '<p class="sub">Enter the recovery password shown in your terminal.</p>'
440
+ + '<div id="err-verify" class="error"></div>'
441
+ + '<label for="recovery-pw">Recovery password</label>'
442
+ + '<input id="recovery-pw" type="text" autocomplete="off" spellcheck="false" autofocus>'
443
+ + '<button id="btn-verify">Verify</button>'
444
+ + '</div>'
445
+ + '<div id="step-reset" class="hidden">'
446
+ + '<h1>Reset Admin PIN</h1>'
447
+ + '<p class="sub">Enter a new 6-digit PIN for the admin account.</p>'
448
+ + '<div id="err-reset" class="error"></div>'
449
+ + '<label for="new-pin">New PIN</label>'
450
+ + '<input id="new-pin" type="password" maxlength="6" pattern="\\d{6}" inputmode="numeric" placeholder="6 digits">'
451
+ + '<label for="confirm-pin">Confirm PIN</label>'
452
+ + '<input id="confirm-pin" type="password" maxlength="6" pattern="\\d{6}" inputmode="numeric" placeholder="6 digits">'
453
+ + '<button id="btn-reset">Reset PIN</button>'
454
+ + '</div>'
455
+ + '<div id="step-done" class="hidden">'
456
+ + '<p class="success">PIN has been reset successfully. You can now log in with your new PIN.</p>'
457
+ + '</div>'
458
+ + '</div>'
459
+ + '<script>'
460
+ + 'var pw="";\n'
461
+ + 'document.getElementById("btn-verify").onclick=function(){\n'
462
+ + ' var el=document.getElementById("recovery-pw");\n'
463
+ + ' pw=el.value.trim();\n'
464
+ + ' if(!pw)return;\n'
465
+ + ' this.disabled=true;\n'
466
+ + ' fetch(location.pathname,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({step:"verify",password:pw})})\n'
467
+ + ' .then(function(r){return r.json()}).then(function(d){\n'
468
+ + ' if(d.ok){document.getElementById("step-verify").classList.add("hidden");document.getElementById("step-reset").classList.remove("hidden");document.getElementById("new-pin").focus()}\n'
469
+ + ' else{var e=document.getElementById("err-verify");e.textContent=d.error||"Invalid password";e.style.display="block";document.getElementById("btn-verify").disabled=false}\n'
470
+ + ' }).catch(function(){document.getElementById("btn-verify").disabled=false})\n'
471
+ + '};\n'
472
+ + 'document.getElementById("recovery-pw").addEventListener("keydown",function(e){if(e.key==="Enter")document.getElementById("btn-verify").click()});\n'
473
+ + 'document.getElementById("btn-reset").onclick=function(){\n'
474
+ + ' var pin=document.getElementById("new-pin").value;\n'
475
+ + ' var confirm=document.getElementById("confirm-pin").value;\n'
476
+ + ' var errEl=document.getElementById("err-reset");\n'
477
+ + ' if(!/^\\d{6}$/.test(pin)){errEl.textContent="PIN must be exactly 6 digits";errEl.style.display="block";return}\n'
478
+ + ' if(pin!==confirm){errEl.textContent="PINs do not match";errEl.style.display="block";return}\n'
479
+ + ' this.disabled=true;errEl.style.display="none";\n'
480
+ + ' fetch(location.pathname,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({step:"reset",password:pw,pin:pin})})\n'
481
+ + ' .then(function(r){return r.json()}).then(function(d){\n'
482
+ + ' if(d.ok){document.getElementById("step-reset").classList.add("hidden");document.getElementById("step-done").classList.remove("hidden")}\n'
483
+ + ' else{errEl.textContent=d.error||"Failed";errEl.style.display="block";document.getElementById("btn-reset").disabled=false}\n'
484
+ + ' }).catch(function(){document.getElementById("btn-reset").disabled=false})\n'
485
+ + '};\n'
486
+ + 'document.getElementById("confirm-pin").addEventListener("keydown",function(e){if(e.key==="Enter")document.getElementById("btn-reset").click()});\n'
487
+ + '</script></body></html>';
488
+ }
489
+
405
490
  var realVersion = require("../package.json").version;
406
491
  var currentVersion = debug ? "0.0.9" : realVersion;
407
492
 
@@ -457,6 +542,70 @@ function createServer(opts) {
457
542
  setSecurityHeaders(res);
458
543
  var fullUrl = req.url.split("?")[0];
459
544
 
545
+ // --- Admin password recovery ---
546
+ if (recovery && fullUrl === "/recover/" + recovery.urlPath) {
547
+ if (req.method === "GET") {
548
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
549
+ res.end(recoveryPageHtml());
550
+ return;
551
+ }
552
+ if (req.method === "POST") {
553
+ var ip = req.socket.remoteAddress || "";
554
+ var remaining = checkPinRateLimit(ip);
555
+ if (remaining !== null) {
556
+ res.writeHead(429, { "Content-Type": "application/json" });
557
+ res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
558
+ return;
559
+ }
560
+ var body = "";
561
+ req.on("data", function (chunk) { body += chunk; });
562
+ req.on("end", function () {
563
+ try {
564
+ var data = JSON.parse(body);
565
+ if (data.step === "verify") {
566
+ if (!data.password || data.password !== recovery.password) {
567
+ recordPinFailure(ip);
568
+ res.writeHead(401, { "Content-Type": "application/json" });
569
+ res.end('{"error":"Invalid recovery password"}');
570
+ return;
571
+ }
572
+ clearPinFailures(ip);
573
+ res.writeHead(200, { "Content-Type": "application/json" });
574
+ res.end('{"ok":true}');
575
+ } else if (data.step === "reset") {
576
+ if (!data.password || data.password !== recovery.password) {
577
+ res.writeHead(401, { "Content-Type": "application/json" });
578
+ res.end('{"error":"Invalid recovery password"}');
579
+ return;
580
+ }
581
+ if (!data.pin || !/^\d{6}$/.test(data.pin)) {
582
+ res.writeHead(400, { "Content-Type": "application/json" });
583
+ res.end('{"error":"PIN must be exactly 6 digits"}');
584
+ return;
585
+ }
586
+ var admin = users.findAdmin();
587
+ if (!admin) {
588
+ res.writeHead(400, { "Content-Type": "application/json" });
589
+ res.end('{"error":"No admin account found"}');
590
+ return;
591
+ }
592
+ users.updateUserPin(admin.id, data.pin);
593
+ recovery = null;
594
+ res.writeHead(200, { "Content-Type": "application/json" });
595
+ res.end('{"ok":true}');
596
+ } else {
597
+ res.writeHead(400, { "Content-Type": "application/json" });
598
+ res.end('{"error":"Invalid step"}');
599
+ }
600
+ } catch (e) {
601
+ res.writeHead(400, { "Content-Type": "application/json" });
602
+ res.end('{"error":"Invalid request"}');
603
+ }
604
+ });
605
+ return;
606
+ }
607
+ }
608
+
460
609
  // Global auth endpoint
461
610
  if (req.method === "POST" && req.url === "/auth") {
462
611
  var ip = req.socket.remoteAddress || "";
@@ -1238,6 +1387,42 @@ function createServer(opts) {
1238
1387
  return;
1239
1388
  }
1240
1389
 
1390
+ // Update user permissions (admin only)
1391
+ if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/permissions$/)) {
1392
+ if (!users.isMultiUser()) {
1393
+ res.writeHead(404, { "Content-Type": "application/json" });
1394
+ res.end('{"error":"Not found"}');
1395
+ return;
1396
+ }
1397
+ var mu = getMultiUserFromReq(req);
1398
+ if (!mu || mu.role !== "admin") {
1399
+ res.writeHead(403, { "Content-Type": "application/json" });
1400
+ res.end('{"error":"Admin access required"}');
1401
+ return;
1402
+ }
1403
+ var urlParts = fullUrl.split("/");
1404
+ var targetUserId = urlParts[4]; // /api/admin/users/{userId}/permissions
1405
+ var body = "";
1406
+ req.on("data", function(chunk) { body += chunk; });
1407
+ req.on("end", function() {
1408
+ try {
1409
+ var parsed = JSON.parse(body);
1410
+ var result = users.updateUserPermissions(targetUserId, parsed.permissions || {});
1411
+ if (result.error) {
1412
+ res.writeHead(400, { "Content-Type": "application/json" });
1413
+ res.end(JSON.stringify({ error: result.error }));
1414
+ } else {
1415
+ res.writeHead(200, { "Content-Type": "application/json" });
1416
+ res.end(JSON.stringify({ ok: true, permissions: result.permissions }));
1417
+ }
1418
+ } catch (e) {
1419
+ res.writeHead(400, { "Content-Type": "application/json" });
1420
+ res.end('{"error":"Invalid request body"}');
1421
+ }
1422
+ });
1423
+ return;
1424
+ }
1425
+
1241
1426
  // Create invite (admin only)
1242
1427
  if (req.method === "POST" && fullUrl === "/api/admin/invites") {
1243
1428
  if (!users.isMultiUser()) {
@@ -1498,6 +1683,11 @@ function createServer(opts) {
1498
1683
  return;
1499
1684
  }
1500
1685
  var projSlug = fullUrl.split("/")[4];
1686
+ if (projSlug.indexOf("--") !== -1) {
1687
+ res.writeHead(400, { "Content-Type": "application/json" });
1688
+ res.end('{"error":"Worktree projects inherit parent visibility"}');
1689
+ return;
1690
+ }
1501
1691
  var body = "";
1502
1692
  req.on("data", function (chunk) { body += chunk; });
1503
1693
  req.on("end", function () {
@@ -1543,6 +1733,11 @@ function createServer(opts) {
1543
1733
  return;
1544
1734
  }
1545
1735
  var projSlug = fullUrl.split("/")[4];
1736
+ if (projSlug.indexOf("--") !== -1) {
1737
+ res.writeHead(400, { "Content-Type": "application/json" });
1738
+ res.end('{"error":"Worktree projects inherit parent settings"}');
1739
+ return;
1740
+ }
1546
1741
  var body = "";
1547
1742
  req.on("data", function (chunk) { body += chunk; });
1548
1743
  req.on("end", function () {
@@ -1590,6 +1785,11 @@ function createServer(opts) {
1590
1785
  return;
1591
1786
  }
1592
1787
  var projSlug = fullUrl.split("/")[4];
1788
+ if (projSlug.indexOf("--") !== -1) {
1789
+ res.writeHead(400, { "Content-Type": "application/json" });
1790
+ res.end('{"error":"Worktree projects inherit parent settings"}');
1791
+ return;
1792
+ }
1593
1793
  var body = "";
1594
1794
  req.on("data", function (chunk) { body += chunk; });
1595
1795
  req.on("end", function () {
@@ -1651,6 +1851,97 @@ function createServer(opts) {
1651
1851
  return;
1652
1852
  }
1653
1853
 
1854
+ // Command palette: cross-project session search
1855
+ if (req.method === "GET" && fullUrl === "/api/palette/search") {
1856
+ var paletteUser = null;
1857
+ if (users.isMultiUser()) {
1858
+ paletteUser = getMultiUserFromReq(req);
1859
+ if (!paletteUser) {
1860
+ res.writeHead(401, { "Content-Type": "application/json" });
1861
+ res.end('{"error":"unauthorized"}');
1862
+ return;
1863
+ }
1864
+ }
1865
+ var pqs = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
1866
+ var pQuery = new URLSearchParams(pqs).get("q") || "";
1867
+ var pResults = [];
1868
+ projects.forEach(function (pCtx, pSlug) {
1869
+ var status = pCtx.getStatus();
1870
+ if (status.isMate) return;
1871
+ if (status.isWorktree) return;
1872
+ // Access check
1873
+ if (paletteUser && onGetProjectAccess) {
1874
+ var pAccess = onGetProjectAccess(pSlug);
1875
+ if (pAccess && !pAccess.error && !users.canAccessProject(paletteUser.id, pAccess)) return;
1876
+ }
1877
+ var sm = pCtx.sm;
1878
+ sm.sessions.forEach(function (session) {
1879
+ if (session.hidden) return;
1880
+ // Session access check
1881
+ if (paletteUser) {
1882
+ if (users.isMultiUser()) {
1883
+ var sAccess = onGetProjectAccess ? onGetProjectAccess(pSlug) : null;
1884
+ if (!users.canAccessSession(paletteUser.id, session, sAccess)) return;
1885
+ }
1886
+ } else {
1887
+ // Single-user: skip sessions with ownerId
1888
+ if (session.ownerId) return;
1889
+ }
1890
+ if (!pQuery) {
1891
+ // Recent mode: return all sessions sorted by lastActivity
1892
+ pResults.push({
1893
+ projectSlug: pSlug,
1894
+ projectTitle: status.title || status.project,
1895
+ projectIcon: status.icon || null,
1896
+ sessionId: session.localId,
1897
+ sessionTitle: session.title || "New Session",
1898
+ lastActivity: session.lastActivity || session.createdAt || 0,
1899
+ matchType: null,
1900
+ snippet: null
1901
+ });
1902
+ } else {
1903
+ // Search mode: match title and content
1904
+ var q = pQuery.toLowerCase();
1905
+ var titleMatch = (session.title || "New Session").toLowerCase().indexOf(q) !== -1;
1906
+ var contentSnippet = null;
1907
+ for (var hi = 0; hi < session.history.length; hi++) {
1908
+ var entry = session.history[hi];
1909
+ if ((entry.type === "delta" || entry.type === "user_message") && entry.text) {
1910
+ var lowerText = entry.text.toLowerCase();
1911
+ var matchIdx = lowerText.indexOf(q);
1912
+ if (matchIdx !== -1) {
1913
+ var snippetStart = Math.max(0, matchIdx - 20);
1914
+ var snippetEnd = Math.min(entry.text.length, matchIdx + pQuery.length + 20);
1915
+ contentSnippet = (snippetStart > 0 ? "..." : "") +
1916
+ entry.text.substring(snippetStart, snippetEnd) +
1917
+ (snippetEnd < entry.text.length ? "..." : "");
1918
+ break;
1919
+ }
1920
+ }
1921
+ }
1922
+ if (titleMatch || contentSnippet) {
1923
+ pResults.push({
1924
+ projectSlug: pSlug,
1925
+ projectTitle: status.title || status.project,
1926
+ projectIcon: status.icon || null,
1927
+ sessionId: session.localId,
1928
+ sessionTitle: session.title || "New Session",
1929
+ lastActivity: session.lastActivity || session.createdAt || 0,
1930
+ matchType: titleMatch && contentSnippet ? "both" : titleMatch ? "title" : "content",
1931
+ snippet: contentSnippet
1932
+ });
1933
+ }
1934
+ }
1935
+ });
1936
+ });
1937
+ // Sort by lastActivity descending, limit to 30
1938
+ pResults.sort(function (a, b) { return b.lastActivity - a.lastActivity; });
1939
+ if (pResults.length > 30) pResults = pResults.slice(0, 30);
1940
+ res.writeHead(200, { "Content-Type": "application/json" });
1941
+ res.end(JSON.stringify({ results: pResults }));
1942
+ return;
1943
+ }
1944
+
1654
1945
  // Multi-user info endpoint (who am I?)
1655
1946
  if (req.method === "GET" && fullUrl === "/api/me") {
1656
1947
  if (!users.isMultiUser()) {
@@ -1665,12 +1956,28 @@ function createServer(opts) {
1665
1956
  return;
1666
1957
  }
1667
1958
  var meResp = { 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 } };
1959
+ meResp.permissions = users.getEffectivePermissions(mu, osUsers);
1668
1960
  if (mu.mustChangePin) meResp.mustChangePin = true;
1669
1961
  res.writeHead(200, { "Content-Type": "application/json" });
1670
1962
  res.end(JSON.stringify(meResp));
1671
1963
  return;
1672
1964
  }
1673
1965
 
1966
+ // Skills proxy: permission gate
1967
+ if (fullUrl === "/api/skills" || fullUrl.startsWith("/api/skills/") || fullUrl.startsWith("/api/skills?")) {
1968
+ if (users.isMultiUser()) {
1969
+ var skMu = getMultiUserFromReq(req);
1970
+ if (skMu) {
1971
+ var skPerms = users.getEffectivePermissions(skMu, osUsers);
1972
+ if (!skPerms.skills) {
1973
+ res.writeHead(403, { "Content-Type": "application/json" });
1974
+ res.end('{"error":"Skills access is not permitted"}');
1975
+ return;
1976
+ }
1977
+ }
1978
+ }
1979
+ }
1980
+
1674
1981
  // Skills proxy: leaderboard list
1675
1982
  if (req.method === "GET" && fullUrl === "/api/skills") {
1676
1983
  var qs = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
@@ -1776,17 +2083,32 @@ function createServer(opts) {
1776
2083
  if (projects.size > 0) {
1777
2084
  var targetSlug = null;
1778
2085
  var reqUser = users.isMultiUser() ? getMultiUserFromReq(req) : null;
1779
- projects.forEach(function (ctx, s) {
1780
- if (targetSlug) return;
2086
+ // Check for last-visited project cookie
2087
+ var lastProject = parseCookies(req)["clay_last_project"];
2088
+ if (lastProject && projects.has(lastProject)) {
1781
2089
  if (reqUser && onGetProjectAccess) {
1782
- var access = onGetProjectAccess(s);
1783
- if (access && !access.error && users.canAccessProject(reqUser.id, access)) {
1784
- targetSlug = s;
2090
+ var lpAccess = onGetProjectAccess(lastProject);
2091
+ if (lpAccess && !lpAccess.error && users.canAccessProject(reqUser.id, lpAccess)) {
2092
+ targetSlug = lastProject;
1785
2093
  }
1786
2094
  } else {
1787
- targetSlug = s;
2095
+ targetSlug = lastProject;
1788
2096
  }
1789
- });
2097
+ }
2098
+ // Fall back to first accessible project
2099
+ if (!targetSlug) {
2100
+ projects.forEach(function (ctx, s) {
2101
+ if (targetSlug) return;
2102
+ if (reqUser && onGetProjectAccess) {
2103
+ var access = onGetProjectAccess(s);
2104
+ if (access && !access.error && users.canAccessProject(reqUser.id, access)) {
2105
+ targetSlug = s;
2106
+ }
2107
+ } else {
2108
+ targetSlug = s;
2109
+ }
2110
+ });
2111
+ }
1790
2112
  if (targetSlug) {
1791
2113
  res.writeHead(302, { "Location": "/p/" + targetSlug + "/" });
1792
2114
  res.end();
@@ -1858,6 +2180,9 @@ function createServer(opts) {
1858
2180
  return;
1859
2181
  }
1860
2182
 
2183
+ // Set last-visited project cookie for root redirect
2184
+ res.setHeader("Set-Cookie", "clay_last_project=" + slug + "; Path=/; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : ""));
2185
+
1861
2186
  // Multi-user: check project access for HTTP requests
1862
2187
  if (users.isMultiUser() && onGetProjectAccess) {
1863
2188
  var httpUser = getMultiUserFromReq(req);
@@ -2668,6 +2993,8 @@ function createServer(opts) {
2668
2993
  setProjectTitle: setProjectTitle,
2669
2994
  setProjectIcon: setProjectIcon,
2670
2995
  setAuthToken: setAuthToken,
2996
+ setRecovery: setRecovery,
2997
+ clearRecovery: clearRecovery,
2671
2998
  broadcastAll: broadcastAll,
2672
2999
  destroyAll: destroyAll,
2673
3000
  };
package/lib/sessions.js CHANGED
@@ -360,6 +360,11 @@ function createSessionManager(opts) {
360
360
  targetWs._clayActiveSession = localId;
361
361
  // Clear unread for this session (multi-user)
362
362
  if (targetWs._clayUnread) targetWs._clayUnread[localId] = 0;
363
+ } else if (sendEach) {
364
+ // No specific target: update all connected clients (server-initiated switch)
365
+ sendEach(function (ws) {
366
+ ws._clayActiveSession = localId;
367
+ });
363
368
  }
364
369
  // Clear unread for single-user mode
365
370
  singleUserUnread[localId] = 0;
package/lib/users.js CHANGED
@@ -6,6 +6,29 @@ var { CONFIG_DIR } = require("./config");
6
6
 
7
7
  var USERS_FILE = path.join(CONFIG_DIR, "users.json");
8
8
 
9
+ // --- Per-user RBAC permissions (default values for regular users) ---
10
+ var DEFAULT_PERMISSIONS = {
11
+ terminal: false,
12
+ fileBrowser: false,
13
+ createProject: true,
14
+ deleteProject: false,
15
+ skills: true,
16
+ sessionDelete: false,
17
+ scheduledTasks: false,
18
+ projectSettings: false,
19
+ };
20
+
21
+ var ALL_PERMISSIONS = {
22
+ terminal: true,
23
+ fileBrowser: true,
24
+ createProject: true,
25
+ deleteProject: true,
26
+ skills: true,
27
+ sessionDelete: true,
28
+ scheduledTasks: true,
29
+ projectSettings: true,
30
+ };
31
+
9
32
  // --- Default data ---
10
33
 
11
34
  function defaultData() {
@@ -223,6 +246,7 @@ function getAllUsers() {
223
246
  createdAt: u.createdAt,
224
247
  profile: u.profile,
225
248
  linuxUser: u.linuxUser || null,
249
+ permissions: u.permissions || null,
226
250
  };
227
251
  });
228
252
  }
@@ -539,6 +563,43 @@ function removeDmHidden(userId, targetUserId) {
539
563
  return [];
540
564
  }
541
565
 
566
+ // --- RBAC permissions ---
567
+
568
+ function getEffectivePermissions(user, osUsersMode) {
569
+ // OS-mode users with linuxUser are exempt from RBAC
570
+ if (osUsersMode && user && user.linuxUser) return ALL_PERMISSIONS;
571
+ // Admin always has full permissions
572
+ if (user && user.role === "admin") return ALL_PERMISSIONS;
573
+ // Merge stored permissions with defaults (handles missing keys for forward-compat)
574
+ var stored = (user && user.permissions) || {};
575
+ var result = {};
576
+ var keys = Object.keys(DEFAULT_PERMISSIONS);
577
+ for (var i = 0; i < keys.length; i++) {
578
+ var k = keys[i];
579
+ result[k] = stored[k] !== undefined ? stored[k] : DEFAULT_PERMISSIONS[k];
580
+ }
581
+ return result;
582
+ }
583
+
584
+ function updateUserPermissions(userId, permissions) {
585
+ var data = loadUsers();
586
+ for (var i = 0; i < data.users.length; i++) {
587
+ if (data.users[i].id === userId) {
588
+ // Validate: only allow known permission keys with boolean values
589
+ var clean = {};
590
+ var keys = Object.keys(DEFAULT_PERMISSIONS);
591
+ for (var j = 0; j < keys.length; j++) {
592
+ var k = keys[j];
593
+ clean[k] = permissions[k] === true;
594
+ }
595
+ data.users[i].permissions = clean;
596
+ saveUsers(data);
597
+ return { ok: true, permissions: clean };
598
+ }
599
+ }
600
+ return { error: "User not found" };
601
+ }
602
+
542
603
  // --- Project access helpers ---
543
604
 
544
605
  function canAccessProject(userId, project) {
@@ -621,6 +682,9 @@ module.exports = {
621
682
  updateLinuxUser: updateLinuxUser,
622
683
  generatePin: generatePin,
623
684
  createUserByAdmin: createUserByAdmin,
685
+ DEFAULT_PERMISSIONS: DEFAULT_PERMISSIONS,
686
+ getEffectivePermissions: getEffectivePermissions,
687
+ updateUserPermissions: updateUserPermissions,
624
688
  getDmFavorites: getDmFavorites,
625
689
  addDmFavorite: addDmFavorite,
626
690
  removeDmFavorite: removeDmFavorite,
package/lib/worktree.js CHANGED
@@ -75,9 +75,9 @@ function scanWorktrees(projectPath) {
75
75
 
76
76
  // Create a new worktree inside the parent project directory
77
77
  // Returns { ok, path, error }
78
- function createWorktree(projectPath, branchName, baseBranch) {
78
+ function createWorktree(projectPath, branchName, dirName, baseBranch) {
79
79
  var resolvedParent = path.resolve(projectPath);
80
- var wtPath = path.join(resolvedParent, branchName);
80
+ var wtPath = path.join(resolvedParent, dirName || branchName);
81
81
  var base = baseBranch || "main";
82
82
  // Try creating with -b (new branch)
83
83
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.13.0-beta.2",
3
+ "version": "2.13.0-beta.4",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",