@pixelbyte-software/pixcode 1.51.3 → 1.51.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/assets/{index-17CwxHSZ.js → index-CpbSjzK7.js} +179 -182
  2. package/dist/assets/index-oDq76nSq.css +32 -0
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/cli.js +1 -1
  5. package/dist-server/server/cli.js.map +1 -1
  6. package/dist-server/server/database/db.js +14 -2
  7. package/dist-server/server/database/db.js.map +1 -1
  8. package/dist-server/server/index.js +592 -55
  9. package/dist-server/server/index.js.map +1 -1
  10. package/dist-server/server/middleware/auth.js +35 -7
  11. package/dist-server/server/middleware/auth.js.map +1 -1
  12. package/dist-server/server/modules/providers/provider.routes.js +33 -17
  13. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  14. package/dist-server/server/routes/auth.js +12 -5
  15. package/dist-server/server/routes/auth.js.map +1 -1
  16. package/dist-server/server/routes/commands.js +20 -9
  17. package/dist-server/server/routes/commands.js.map +1 -1
  18. package/dist-server/server/routes/git.js +12 -0
  19. package/dist-server/server/routes/git.js.map +1 -1
  20. package/dist-server/server/routes/live-view.js +30 -7
  21. package/dist-server/server/routes/live-view.js.map +1 -1
  22. package/dist-server/server/routes/messages.js +30 -0
  23. package/dist-server/server/routes/messages.js.map +1 -1
  24. package/dist-server/server/routes/network.js +3 -2
  25. package/dist-server/server/routes/network.js.map +1 -1
  26. package/dist-server/server/routes/platformization.js +7 -6
  27. package/dist-server/server/routes/platformization.js.map +1 -1
  28. package/dist-server/server/routes/projects.js +4 -3
  29. package/dist-server/server/routes/projects.js.map +1 -1
  30. package/dist-server/server/routes/remote.js +2 -1
  31. package/dist-server/server/routes/remote.js.map +1 -1
  32. package/dist-server/server/services/platformization.js +131 -2
  33. package/dist-server/server/services/platformization.js.map +1 -1
  34. package/package.json +1 -1
  35. package/scripts/update-git-install.mjs +15 -10
  36. package/server/cli.js +4 -4
  37. package/server/database/db.js +39 -26
  38. package/server/index.js +618 -55
  39. package/server/middleware/auth.js +59 -20
  40. package/server/modules/providers/provider.routes.ts +91 -53
  41. package/server/routes/auth.js +25 -17
  42. package/server/routes/commands.js +43 -32
  43. package/server/routes/git.js +24 -9
  44. package/server/routes/live-view.js +47 -24
  45. package/server/routes/messages.js +47 -12
  46. package/server/routes/network.js +9 -8
  47. package/server/routes/platformization.js +22 -21
  48. package/server/routes/projects.js +7 -6
  49. package/server/routes/remote.js +6 -5
  50. package/server/services/platformization.js +169 -31
  51. package/dist/assets/index-B9N-gfOQ.css +0 -32
@@ -5,13 +5,14 @@ import express from 'express';
5
5
 
6
6
  import { extractProjectDirectory } from '../projects.js';
7
7
  import { getTunnelState } from '../services/external-access.js';
8
- import {
9
- getLiveViewSessionByShareId,
10
- getLiveViewState,
11
- restartLiveView,
12
- startLiveView,
13
- stopLiveView,
14
- } from '../services/live-view.js';
8
+ import {
9
+ getLiveViewSessionByShareId,
10
+ getLiveViewState,
11
+ restartLiveView,
12
+ startLiveView,
13
+ stopLiveView,
14
+ } from '../services/live-view.js';
15
+ import { userHasProjectPathAccess } from '../services/platformization.js';
15
16
 
16
17
  const router = express.Router();
17
18
 
@@ -210,20 +211,36 @@ function sendLiveViewDiagnostic(req, res, session, statusCode, options = {}) {
210
211
  res.json(payload);
211
212
  }
212
213
 
213
- async function resolveProjectPath(projectName) {
214
+ async function resolveProjectPath(projectName) {
214
215
  const projectPath = await extractProjectDirectory(projectName);
215
216
  if (!projectPath || typeof projectPath !== 'string') {
216
217
  const error = new Error('Project path could not be resolved.');
217
218
  error.statusCode = 404;
218
219
  throw error;
219
220
  }
220
- return projectPath;
221
- }
222
-
223
- router.get('/:projectName/status', async (req, res) => {
224
- try {
225
- const { projectName } = req.params;
226
- const projectPath = await resolveProjectPath(projectName);
221
+ return projectPath;
222
+ }
223
+
224
+ function requireLiveViewProjectAccess(capability) {
225
+ return async (req, res, next) => {
226
+ try {
227
+ const { projectName } = req.params;
228
+ const projectPath = await resolveProjectPath(projectName);
229
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectPath, path: projectPath }, projectPath, capability)) {
230
+ return res.status(403).json({ error: 'Project access denied.' });
231
+ }
232
+ req.projectPath = projectPath;
233
+ return next();
234
+ } catch (error) {
235
+ return res.status(error.statusCode || 500).json({ error: error.message || 'Failed to resolve project access' });
236
+ }
237
+ };
238
+ }
239
+
240
+ router.get('/:projectName/status', requireLiveViewProjectAccess('viewFiles'), async (req, res) => {
241
+ try {
242
+ const { projectName } = req.params;
243
+ const projectPath = req.projectPath;
227
244
  const state = await getLiveViewState(projectName, projectPath);
228
245
  const urls = buildUrls(req, state.session);
229
246
  const tunnel = getTunnelState();
@@ -238,10 +255,13 @@ router.get('/:projectName/status', async (req, res) => {
238
255
  }
239
256
  });
240
257
 
241
- router.post('/:projectName/start', async (req, res) => {
242
- try {
243
- const { projectName } = req.params;
244
- const projectPath = await resolveProjectPath(projectName);
258
+ router.post('/:projectName/start', requireLiveViewProjectAccess('useShell'), async (req, res) => {
259
+ try {
260
+ const { projectName } = req.params;
261
+ const projectPath = req.projectPath;
262
+ if (req.body?.customCommand && !['admin', 'owner'].includes(req.user?.role)) {
263
+ return res.status(403).json({ error: 'Custom Live View commands require admin access.' });
264
+ }
245
265
  const session = await startLiveView(projectName, projectPath, req.body || {});
246
266
  const state = await getLiveViewState(projectName, projectPath);
247
267
  const urls = buildUrls(req, session);
@@ -259,10 +279,13 @@ router.post('/:projectName/start', async (req, res) => {
259
279
  }
260
280
  });
261
281
 
262
- router.post('/:projectName/restart', async (req, res) => {
263
- try {
264
- const { projectName } = req.params;
265
- const projectPath = await resolveProjectPath(projectName);
282
+ router.post('/:projectName/restart', requireLiveViewProjectAccess('useShell'), async (req, res) => {
283
+ try {
284
+ const { projectName } = req.params;
285
+ const projectPath = req.projectPath;
286
+ if (req.body?.customCommand && !['admin', 'owner'].includes(req.user?.role)) {
287
+ return res.status(403).json({ error: 'Custom Live View commands require admin access.' });
288
+ }
266
289
  const session = await restartLiveView(projectName, projectPath, req.body || {});
267
290
  const state = await getLiveViewState(projectName, projectPath);
268
291
  const urls = buildUrls(req, session);
@@ -279,7 +302,7 @@ router.post('/:projectName/restart', async (req, res) => {
279
302
  }
280
303
  });
281
304
 
282
- router.post('/:projectName/stop', async (req, res) => {
305
+ router.post('/:projectName/stop', requireLiveViewProjectAccess('useShell'), async (req, res) => {
283
306
  try {
284
307
  const session = await stopLiveView(req.params.projectName);
285
308
  const urls = buildUrls(req, session);
@@ -9,11 +9,30 @@
9
9
  * @module routes/messages
10
10
  */
11
11
 
12
- import express from 'express';
12
+ import path from 'node:path';
13
+
14
+ import express from 'express';
15
+
16
+ import { sessionsService } from '../modules/providers/services/sessions.service.js';
17
+ import { userHasProjectAccess } from '../services/platformization.js';
18
+ import { appConfigDb } from '../database/db.js';
13
19
 
14
- import { sessionsService } from '../modules/providers/services/sessions.service.js';
15
-
16
- const router = express.Router();
20
+ const router = express.Router();
21
+ const SESSION_OWNERSHIP_KEY = 'session_ownership';
22
+
23
+ function readSessionOwnership() {
24
+ try {
25
+ return JSON.parse(appConfigDb.get(SESSION_OWNERSHIP_KEY) || '{}');
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function canUserReadSession(user, provider, sessionId) {
32
+ if (['admin', 'owner'].includes(user?.role)) return true;
33
+ const owner = readSessionOwnership()[`${provider || 'claude'}:${sessionId}`];
34
+ return Boolean(owner?.userId && Number(owner.userId) === Number(user?.id ?? user?.userId));
35
+ }
17
36
 
18
37
  /**
19
38
  * GET /api/sessions/:sessionId/messages
@@ -31,8 +50,8 @@ router.get('/:sessionId/messages', async (req, res) => {
31
50
  try {
32
51
  const { sessionId } = req.params;
33
52
  const provider = String(req.query.provider || 'claude').trim().toLowerCase();
34
- const projectName = req.query.projectName || '';
35
- const projectPath = req.query.projectPath || '';
53
+ const projectName = req.query.projectName || '';
54
+ const projectPath = req.query.projectPath || '';
36
55
  const limitParam = req.query.limit;
37
56
  const limit = limitParam !== undefined && limitParam !== null && limitParam !== ''
38
57
  ? parseInt(limitParam, 10)
@@ -40,12 +59,28 @@ router.get('/:sessionId/messages', async (req, res) => {
40
59
  const offset = parseInt(req.query.offset || '0', 10);
41
60
 
42
61
  const availableProviders = sessionsService.listProviderIds();
43
- if (!availableProviders.includes(provider)) {
44
- const available = availableProviders.join(', ');
45
- return res.status(400).json({ error: `Unknown provider: ${provider}. Available: ${available}` });
46
- }
47
-
48
- const result = await sessionsService.fetchHistory(provider, sessionId, {
62
+ if (!availableProviders.includes(provider)) {
63
+ const available = availableProviders.join(', ');
64
+ return res.status(400).json({ error: `Unknown provider: ${provider}. Available: ${available}` });
65
+ }
66
+
67
+ if (!projectName && !projectPath) {
68
+ return res.status(400).json({ error: 'projectName or projectPath is required' });
69
+ }
70
+
71
+ const projectRef = projectName
72
+ ? { name: String(projectName), projectName: String(projectName) }
73
+ : { fullPath: path.resolve(String(projectPath)), path: path.resolve(String(projectPath)), projectPath: path.resolve(String(projectPath)) };
74
+
75
+ if (!userHasProjectAccess(req.user, projectRef, 'viewFiles')) {
76
+ return res.status(403).json({ error: 'Project access denied.' });
77
+ }
78
+
79
+ if (!canUserReadSession(req.user, provider, sessionId)) {
80
+ return res.status(403).json({ error: 'Session access denied.' });
81
+ }
82
+
83
+ const result = await sessionsService.fetchHistory(provider, sessionId, {
49
84
  projectName,
50
85
  projectPath,
51
86
  limit,
@@ -2,12 +2,13 @@ import os from 'os';
2
2
 
3
3
  import express from 'express';
4
4
 
5
- import {
6
- getTunnelState,
7
- getUpnpState,
8
- startTunnel,
9
- stopTunnel,
10
- } from '../services/external-access.js';
5
+ import {
6
+ getTunnelState,
7
+ getUpnpState,
8
+ startTunnel,
9
+ stopTunnel,
10
+ } from '../services/external-access.js';
11
+ import { requireAdmin } from '../middleware/auth.js';
11
12
 
12
13
  const router = express.Router();
13
14
 
@@ -93,7 +94,7 @@ router.delete('/upnp', (_req, res) => {
93
94
  });
94
95
  });
95
96
 
96
- router.post('/tunnel', async (req, res) => {
97
+ router.post('/tunnel', requireAdmin, async (req, res) => {
97
98
  const port = resolveServerPort();
98
99
  try {
99
100
  const state = await startTunnel({ port, persistPreference: true });
@@ -112,7 +113,7 @@ router.post('/tunnel', async (req, res) => {
112
113
  }
113
114
  });
114
115
 
115
- router.delete('/tunnel', async (req, res) => {
116
+ router.delete('/tunnel', requireAdmin, async (req, res) => {
116
117
  try {
117
118
  const state = await stopTunnel({ persistPreference: true });
118
119
  res.json({ success: true, tunnel: state });
@@ -1,6 +1,7 @@
1
- import express from 'express';
2
-
3
- import {
1
+ import express from 'express';
2
+
3
+ import { requireAdmin } from '../middleware/auth.js';
4
+ import {
4
5
  checkRemoteAccessHealth,
5
6
  createAdminUser,
6
7
  createEvaluationRun,
@@ -66,20 +67,20 @@ router.patch('/team/members/:id', (req, res) => {
66
67
  res.json({ success: true, member });
67
68
  });
68
69
 
69
- router.get('/admin/users', (_req, res) => {
70
- res.json({ success: true, users: getPlatformizationState().adminUsers });
71
- });
72
-
73
- router.post('/admin/users', async (req, res) => {
74
- try {
75
- res.status(201).json({ success: true, user: await createAdminUser(req.body || {}, userId(req)) });
76
- } catch (error) {
70
+ router.get('/admin/users', requireAdmin, (_req, res) => {
71
+ res.json({ success: true, users: getPlatformizationState().adminUsers });
72
+ });
73
+
74
+ router.post('/admin/users', requireAdmin, async (req, res) => {
75
+ try {
76
+ res.status(201).json({ success: true, user: await createAdminUser(req.body || {}, userId(req)) });
77
+ } catch (error) {
77
78
  handleError(res, error);
78
79
  }
79
80
  });
80
81
 
81
- router.patch('/admin/users/:id', (req, res) => {
82
- const user = updateAdminUser(req.params.id, req.body || {}, userId(req));
82
+ router.patch('/admin/users/:id', requireAdmin, (req, res) => {
83
+ const user = updateAdminUser(req.params.id, req.body || {}, userId(req));
83
84
  if (!user) {
84
85
  res.status(404).json({ success: false, error: 'Admin user not found.' });
85
86
  return;
@@ -87,20 +88,20 @@ router.patch('/admin/users/:id', (req, res) => {
87
88
  res.json({ success: true, user });
88
89
  });
89
90
 
90
- router.get('/project-collaborators', (_req, res) => {
91
- res.json({ success: true, collaborators: getPlatformizationState().projectCollaborators });
92
- });
93
-
94
- router.post('/project-collaborators', (req, res) => {
95
- try {
91
+ router.get('/project-collaborators', requireAdmin, (_req, res) => {
92
+ res.json({ success: true, collaborators: getPlatformizationState().projectCollaborators });
93
+ });
94
+
95
+ router.post('/project-collaborators', requireAdmin, (req, res) => {
96
+ try {
96
97
  res.status(201).json({ success: true, collaborator: createProjectCollaborator(req.body || {}, userId(req)) });
97
98
  } catch (error) {
98
99
  handleError(res, error);
99
100
  }
100
101
  });
101
102
 
102
- router.patch('/project-collaborators/:id', (req, res) => {
103
- const collaborator = updateProjectCollaborator(req.params.id, req.body || {}, userId(req));
103
+ router.patch('/project-collaborators/:id', requireAdmin, (req, res) => {
104
+ const collaborator = updateProjectCollaborator(req.params.id, req.body || {}, userId(req));
104
105
  if (!collaborator) {
105
106
  res.status(404).json({ success: false, error: 'Project collaborator not found.' });
106
107
  return;
@@ -3,9 +3,10 @@ import path from 'path';
3
3
  import { spawn } from 'child_process';
4
4
  import os from 'os';
5
5
 
6
- import express from 'express';
7
-
8
- import { addProjectManually, extractProjectDirectory } from '../projects.js';
6
+ import express from 'express';
7
+
8
+ import { addProjectManually, extractProjectDirectory } from '../projects.js';
9
+ import { requireAdmin } from '../middleware/auth.js';
9
10
 
10
11
  const router = express.Router();
11
12
 
@@ -302,7 +303,7 @@ async function projectHasAnySessions(workspacePath) {
302
303
  * composer and surface a "directory deleted" warning instead of letting
303
304
  * the user fire prompts into a void.
304
305
  */
305
- router.get('/:projectName/dir-status', async (req, res) => {
306
+ router.get('/:projectName/dir-status', requireAdmin, async (req, res) => {
306
307
  const { projectName } = req.params;
307
308
  try {
308
309
  const actualPath = await extractProjectDirectory(projectName);
@@ -340,7 +341,7 @@ router.get('/:projectName/dir-status', async (req, res) => {
340
341
  * matches ChatGPT's "New chat" which reuses the empty canvas until the
341
342
  * user actually commits a message.
342
343
  */
343
- router.post('/quick-start', async (req, res) => {
344
+ router.post('/quick-start', requireAdmin, async (req, res) => {
344
345
  try {
345
346
  await fs.mkdir(WORKSPACES_BASE, { recursive: true });
346
347
 
@@ -424,7 +425,7 @@ router.post('/quick-start', async (req, res) => {
424
425
  * - githubTokenId?: number (optional, ID of stored token)
425
426
  * - newGithubToken?: string (optional, one-time token)
426
427
  */
427
- router.post('/create-workspace', async (req, res) => {
428
+ router.post('/create-workspace', requireAdmin, async (req, res) => {
428
429
  try {
429
430
  const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken, subfolderName } = req.body;
430
431
 
@@ -5,10 +5,11 @@ import {
5
5
  getPublicRemoteConnectionConfig,
6
6
  saveRemoteConnectionConfig,
7
7
  } from '../services/remote-connection.js';
8
- import {
9
- buildControlRoomSnapshot,
10
- buildMobileConsoleLayout,
11
- } from '../services/control-room.js';
8
+ import {
9
+ buildControlRoomSnapshot,
10
+ buildMobileConsoleLayout,
11
+ } from '../services/control-room.js';
12
+ import { requireAdmin } from '../middleware/auth.js';
12
13
 
13
14
  const router = express.Router();
14
15
 
@@ -16,7 +17,7 @@ router.get('/config', (req, res) => {
16
17
  res.json({ success: true, connection: getPublicRemoteConnectionConfig() });
17
18
  });
18
19
 
19
- router.put('/config', (req, res) => {
20
+ router.put('/config', requireAdmin, (req, res) => {
20
21
  try {
21
22
  const connection = saveRemoteConnectionConfig(req.body || {});
22
23
  res.json({ success: true, connection });
@@ -1,6 +1,7 @@
1
- import crypto from 'node:crypto';
2
- import os from 'node:os';
3
- import { execFile } from 'node:child_process';
1
+ import crypto from 'node:crypto';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { execFile } from 'node:child_process';
4
5
  import { promisify } from 'node:util';
5
6
 
6
7
  import bcrypt from 'bcryptjs';
@@ -116,10 +117,14 @@ function writeStore(store) {
116
117
  appConfigDb.set(CONFIG_KEY, JSON.stringify(store));
117
118
  }
118
119
 
119
- function compact(text, max = 120) {
120
- const value = String(text || '').replace(/\s+/g, ' ').trim();
121
- return value.length > max ? value.slice(0, max).replace(/[-_\s]+$/g, '') : value;
122
- }
120
+ function compact(text, max = 120) {
121
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
122
+ return value.length > max ? value.slice(0, max).replace(/[-_\s]+$/g, '') : value;
123
+ }
124
+
125
+ function compactProjectIdentifier(value) {
126
+ return String(value || '').replace(/\s+/g, ' ').trim();
127
+ }
123
128
 
124
129
  function slugify(value) {
125
130
  const slug = compact(value, 72)
@@ -140,9 +145,132 @@ function addAudit(store, action, actorId, details = {}) {
140
145
  store.auditLog = store.auditLog.slice(0, 250);
141
146
  }
142
147
 
143
- function normalizeRole(role) {
144
- return TEAM_ROLES[role] ? role : 'viewer';
145
- }
148
+ function normalizeRole(role) {
149
+ return TEAM_ROLES[role] ? role : 'viewer';
150
+ }
151
+
152
+ export function isAdminUser(user = {}) {
153
+ return user?.role === 'admin' || user?.role === 'owner';
154
+ }
155
+
156
+ function resolveUser(input = {}) {
157
+ const users = userDb.listUsers();
158
+ const userId = Number(input.userId);
159
+ if (Number.isFinite(userId)) {
160
+ return users.find((user) => user.id === userId && user.is_active) || null;
161
+ }
162
+
163
+ const userRef = compact(input.userRef || input.email || input.username || '').toLowerCase();
164
+ if (!userRef) return null;
165
+ return users.find((user) => user.is_active && String(user.username).toLowerCase() === userRef) || null;
166
+ }
167
+
168
+ function projectMatches(collaborator, project = {}) {
169
+ const projectName = compactProjectIdentifier(project.name || project.projectName || project);
170
+ const projectPath = compactProjectIdentifier(project.fullPath || project.path || project.projectPath || '');
171
+
172
+ return Boolean(
173
+ (projectName && collaborator.projectName === projectName) ||
174
+ (projectPath && collaborator.projectPath === projectPath)
175
+ );
176
+ }
177
+
178
+ function isPathInside(basePath, targetPath) {
179
+ const relative = path.relative(path.resolve(basePath), path.resolve(targetPath));
180
+ return relative === '' || (relative && !relative.startsWith('..') && !path.isAbsolute(relative));
181
+ }
182
+
183
+ function normalizeAllowedRoots(input) {
184
+ const roots = Array.isArray(input) ? input : [];
185
+ const normalized = roots
186
+ .filter((entry) => typeof entry === 'string')
187
+ .map((entry) => entry.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/g, ''))
188
+ .map((entry) => entry || '.')
189
+ .filter((entry) => !entry.includes('..'));
190
+ return Array.from(new Set(normalized.length > 0 ? normalized : ['.']));
191
+ }
192
+
193
+ function collaboratorAllowedRoots(collaborator) {
194
+ return normalizeAllowedRoots(collaborator.allowedRoots || collaborator.allowedFolders || ['.']);
195
+ }
196
+
197
+ export function userHasProjectAccess(user, project, capability = 'viewFiles') {
198
+ if (isAdminUser(user)) return true;
199
+ if (!user?.id && !user?.userId) return false;
200
+
201
+ const userId = Number(user.id ?? user.userId);
202
+ const username = String(user.username || '').toLowerCase();
203
+ const store = readStore();
204
+
205
+ return store.projectCollaborators.some((collaborator) => {
206
+ if (collaborator.status === 'disabled') return false;
207
+ if (!projectMatches(collaborator, project)) return false;
208
+
209
+ const sameUser = Number(collaborator.userId) === userId ||
210
+ String(collaborator.userRef || '').toLowerCase() === username;
211
+ if (!sameUser) return false;
212
+
213
+ if (capability === 'viewFiles') {
214
+ return collaborator.capabilities?.viewFiles !== false;
215
+ }
216
+
217
+ return collaborator.capabilities?.[capability] === true;
218
+ });
219
+ }
220
+
221
+ export function getProjectAccessForUser(user, project, capability = 'viewFiles') {
222
+ if (isAdminUser(user)) {
223
+ return { unrestricted: true, allowedRoots: ['.'] };
224
+ }
225
+ if (!user?.id && !user?.userId) return { unrestricted: false, allowedRoots: [] };
226
+
227
+ const userId = Number(user.id ?? user.userId);
228
+ const username = String(user.username || '').toLowerCase();
229
+ const store = readStore();
230
+ const allowedRoots = [];
231
+
232
+ for (const collaborator of store.projectCollaborators) {
233
+ if (collaborator.status === 'disabled') continue;
234
+ if (!projectMatches(collaborator, project)) continue;
235
+ const sameUser = Number(collaborator.userId) === userId ||
236
+ String(collaborator.userRef || '').toLowerCase() === username;
237
+ if (!sameUser) continue;
238
+ const capabilityAllowed = capability === 'viewFiles'
239
+ ? collaborator.capabilities?.viewFiles !== false
240
+ : collaborator.capabilities?.[capability] === true;
241
+ if (!capabilityAllowed) continue;
242
+ allowedRoots.push(...collaboratorAllowedRoots(collaborator));
243
+ }
244
+
245
+ return { unrestricted: false, allowedRoots: Array.from(new Set(allowedRoots)) };
246
+ }
247
+
248
+ export function userHasProjectPathAccess(user, project, targetPath, capability = 'viewFiles') {
249
+ if (isAdminUser(user)) return true;
250
+ const projectPath = project?.fullPath || project?.path || project?.projectPath;
251
+ if (!projectPath || !targetPath) return false;
252
+ const access = getProjectAccessForUser(user, project, capability);
253
+ return access.allowedRoots.some((root) => {
254
+ const allowedPath = root === '.' ? projectPath : path.resolve(projectPath, root);
255
+ return isPathInside(allowedPath, targetPath);
256
+ });
257
+ }
258
+
259
+ export function filterFileTreeForUser(files = [], user, project, capability = 'viewFiles') {
260
+ if (isAdminUser(user)) return files;
261
+ const projectPath = project?.fullPath || project?.path || project?.projectPath;
262
+ if (!projectPath) return [];
263
+ return files.filter((entry) => {
264
+ const entryPath = entry?.path || entry?.fullPath || entry?.relativePath || '';
265
+ const absoluteEntryPath = path.isAbsolute(entryPath) ? entryPath : path.resolve(projectPath, entryPath);
266
+ return userHasProjectPathAccess(user, { ...project, fullPath: projectPath }, absoluteEntryPath, capability);
267
+ });
268
+ }
269
+
270
+ export function filterProjectsForUser(projects = [], user) {
271
+ if (isAdminUser(user)) return projects;
272
+ return projects.filter((project) => userHasProjectAccess(user, project, 'viewFiles'));
273
+ }
146
274
 
147
275
  function normalizeScope(scope) {
148
276
  return SECRET_SCOPES.includes(scope) ? scope : 'project';
@@ -338,13 +466,18 @@ export function updateAdminUser(userId, patch = {}, actorId = null) {
338
466
  };
339
467
  }
340
468
 
341
- export function createProjectCollaborator(input = {}, actorId = null) {
342
- const projectName = compact(input.projectName || input.project || '');
343
- const projectPath = input.projectPath || null;
344
- const userRef = compact(input.userRef || input.email || input.username || '');
345
- if (!projectName || !userRef) {
346
- throw new Error('Project collaborator requires a project name and user reference.');
347
- }
469
+ export function createProjectCollaborator(input = {}, actorId = null) {
470
+ const projectName = compactProjectIdentifier(input.projectName || input.project || '');
471
+ const projectPath = input.projectPath || null;
472
+ const targetUser = resolveUser(input);
473
+ const userRef = compact(input.userRef || input.email || input.username || targetUser?.username || '');
474
+ if (!projectName || !userRef) {
475
+ throw new Error('Project collaborator requires a project name and user reference.');
476
+ }
477
+
478
+ if (!targetUser) {
479
+ throw new Error('Create the user account before assigning project access.');
480
+ }
348
481
 
349
482
  const role = ['partner', 'worker', 'reviewer', 'viewer'].includes(input.role) ? input.role : 'worker';
350
483
  const capabilities = {
@@ -357,16 +490,18 @@ export function createProjectCollaborator(input = {}, actorId = null) {
357
490
  manageProjectSettings: role === 'partner',
358
491
  };
359
492
  const collaborator = {
360
- id: crypto.randomUUID(),
361
- projectName,
362
- projectPath,
363
- userRef,
493
+ id: crypto.randomUUID(),
494
+ projectName,
495
+ projectPath,
496
+ userId: targetUser.id,
497
+ userRef,
364
498
  role,
365
- capabilities: {
366
- ...capabilities,
367
- ...(input.capabilities && typeof input.capabilities === 'object' ? input.capabilities : {}),
368
- },
369
- status: input.status || 'active',
499
+ capabilities: {
500
+ ...capabilities,
501
+ ...(input.capabilities && typeof input.capabilities === 'object' ? input.capabilities : {}),
502
+ },
503
+ allowedRoots: normalizeAllowedRoots(input.allowedRoots || input.allowedFolders || ['.']),
504
+ status: input.status || 'active',
370
505
  createdAt: nowIso(),
371
506
  updatedAt: nowIso(),
372
507
  };
@@ -386,11 +521,14 @@ export function updateProjectCollaborator(collaboratorId, patch = {}, actorId =
386
521
  ...collaborator,
387
522
  ...patch,
388
523
  id: collaborator.id,
389
- capabilities: {
390
- ...collaborator.capabilities,
391
- ...(patch.capabilities && typeof patch.capabilities === 'object' ? patch.capabilities : {}),
392
- },
393
- updatedAt: nowIso(),
524
+ capabilities: {
525
+ ...collaborator.capabilities,
526
+ ...(patch.capabilities && typeof patch.capabilities === 'object' ? patch.capabilities : {}),
527
+ },
528
+ allowedRoots: patch.allowedRoots || patch.allowedFolders
529
+ ? normalizeAllowedRoots(patch.allowedRoots || patch.allowedFolders)
530
+ : collaboratorAllowedRoots(collaborator),
531
+ updatedAt: nowIso(),
394
532
  };
395
533
  return updated;
396
534
  });