@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
@@ -4,8 +4,10 @@ import { userDb, appConfigDb, apiKeysDb } from '../database/db.js';
4
4
  import { IS_PLATFORM } from '../constants/config.js';
5
5
 
6
6
  // Use env var if set, otherwise auto-generate a unique secret per installation
7
- const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
8
- const isPixcodeApiKey = (token) => typeof token === 'string' && (token.startsWith('px_') || token.startsWith('ck_'));
7
+ const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
8
+ const isPixcodeApiKey = (token) => typeof token === 'string' && (token.startsWith('px_') || token.startsWith('ck_'));
9
+ const ADMIN_ROLES = new Set(['owner', 'admin']);
10
+ const PLATFORM_AUTH_BYPASS_ENABLED = IS_PLATFORM && process.env.PIXCODE_ALLOW_PLATFORM_AUTH_BYPASS === '1';
9
11
 
10
12
  // Optional API key middleware
11
13
  const validateApiKey = (req, res, next) => {
@@ -22,9 +24,9 @@ const validateApiKey = (req, res, next) => {
22
24
  };
23
25
 
24
26
  // JWT authentication middleware
25
- const authenticateToken = async (req, res, next) => {
27
+ const authenticateToken = async (req, res, next) => {
26
28
  // Platform mode: use single database user
27
- if (IS_PLATFORM) {
29
+ if (PLATFORM_AUTH_BYPASS_ENABLED) {
28
30
  try {
29
31
  const user = userDb.getFirstUser();
30
32
  if (!user) {
@@ -101,15 +103,50 @@ const authenticateToken = async (req, res, next) => {
101
103
  console.error('Token verification error:', error);
102
104
  return res.status(403).json({ error: 'Invalid token' });
103
105
  }
104
- };
106
+ };
107
+
108
+ const requireAdmin = (req, res, next) => {
109
+ if (!req.user) {
110
+ return res.status(401).json({ error: 'Access denied. No authenticated user.' });
111
+ }
112
+
113
+ if (!ADMIN_ROLES.has(req.user.role)) {
114
+ return res.status(403).json({ error: 'Admin access required.' });
115
+ }
116
+
117
+ if (
118
+ req.user.api_key_id &&
119
+ !req.user.api_key_scopes?.includes('admin') &&
120
+ !req.user.api_key_scopes?.includes('system') &&
121
+ !req.user.api_key_scopes?.includes('*')
122
+ ) {
123
+ return res.status(403).json({ error: 'API key lacks admin scope.' });
124
+ }
125
+
126
+ next();
127
+ };
128
+
129
+ const requireApiScope = (scope) => (req, res, next) => {
130
+ if (!req.user?.api_key_id) {
131
+ return next();
132
+ }
133
+
134
+ const scopes = Array.isArray(req.user.api_key_scopes) ? req.user.api_key_scopes : [];
135
+ if (scopes.includes('*') || scopes.includes(scope)) {
136
+ return next();
137
+ }
138
+
139
+ return res.status(403).json({ error: `API key lacks required scope: ${scope}` });
140
+ };
105
141
 
106
142
  // Generate JWT token
107
143
  const generateToken = (user) => {
108
144
  return jwt.sign(
109
145
  {
110
- userId: user.id,
111
- username: user.username
112
- },
146
+ userId: user.id,
147
+ username: user.username,
148
+ role: user.role || null,
149
+ },
113
150
  JWT_SECRET,
114
151
  { expiresIn: '7d' }
115
152
  );
@@ -118,12 +155,12 @@ const generateToken = (user) => {
118
155
  // WebSocket authentication function
119
156
  const authenticateWebSocket = (token) => {
120
157
  // Platform mode: bypass token validation, return first user
121
- if (IS_PLATFORM) {
158
+ if (PLATFORM_AUTH_BYPASS_ENABLED) {
122
159
  try {
123
- const user = userDb.getFirstUser();
124
- if (user) {
125
- return { id: user.id, userId: user.id, username: user.username };
126
- }
160
+ const user = userDb.getFirstUser();
161
+ if (user) {
162
+ return { id: user.id, userId: user.id, username: user.username, role: user.role || null };
163
+ }
127
164
  return null;
128
165
  } catch (error) {
129
166
  console.error('Platform mode WebSocket error:', error);
@@ -143,9 +180,9 @@ const authenticateWebSocket = (token) => {
143
180
 
144
181
  if (isPixcodeApiKey(token)) {
145
182
  try {
146
- const user = apiKeysDb.validateApiKey(token);
147
- if (!user) return null;
148
- return { userId: user.id, username: user.username };
183
+ const user = apiKeysDb.validateApiKey(token);
184
+ if (!user) return null;
185
+ return { id: user.id, userId: user.id, username: user.username, role: user.role || null };
149
186
  } catch (error) {
150
187
  console.error('WebSocket API key validation error:', error);
151
188
  return null;
@@ -159,7 +196,7 @@ const authenticateWebSocket = (token) => {
159
196
  if (!user) {
160
197
  return null;
161
198
  }
162
- return { userId: user.id, username: user.username };
199
+ return { id: user.id, userId: user.id, username: user.username, role: user.role || null };
163
200
  } catch (error) {
164
201
  console.error('WebSocket token verification error:', error);
165
202
  return null;
@@ -167,9 +204,11 @@ const authenticateWebSocket = (token) => {
167
204
  };
168
205
 
169
206
  export {
170
- validateApiKey,
171
- authenticateToken,
172
- generateToken,
207
+ validateApiKey,
208
+ authenticateToken,
209
+ requireAdmin,
210
+ requireApiScope,
211
+ generateToken,
173
212
  authenticateWebSocket,
174
213
  JWT_SECRET
175
214
  };
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable import-x/order */
2
- import express, { type Request, type Response } from 'express';
2
+ import express, { type NextFunction, type Request, type Response } from 'express';
3
3
 
4
4
  import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
5
5
  import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
@@ -101,7 +101,28 @@ const PROVIDER_MANUAL_INSTALL: Partial<Record<LLMProvider, {
101
101
  },
102
102
  };
103
103
 
104
- const router = express.Router();
104
+ const router = express.Router();
105
+ const ADMIN_ROLES = new Set(['owner', 'admin']);
106
+
107
+ function requireProviderAdmin(req: Request, res: Response, next: NextFunction) {
108
+ const user = (req as Request & { user?: { role?: string; api_key_id?: number; api_key_scopes?: string[] } }).user;
109
+ if (!user || !ADMIN_ROLES.has(user.role || '')) {
110
+ res.status(403).json({ error: 'Admin access required.' });
111
+ return;
112
+ }
113
+
114
+ if (
115
+ user.api_key_id &&
116
+ !user.api_key_scopes?.includes('admin') &&
117
+ !user.api_key_scopes?.includes('system') &&
118
+ !user.api_key_scopes?.includes('*')
119
+ ) {
120
+ res.status(403).json({ error: 'API key lacks admin scope.' });
121
+ return;
122
+ }
123
+
124
+ next();
125
+ }
105
126
 
106
127
  const readPathParam = (value: unknown, name: string): string => {
107
128
  if (typeof value === 'string') {
@@ -260,9 +281,10 @@ router.get(
260
281
  }),
261
282
  );
262
283
 
263
- router.get(
264
- '/:provider/mcp/servers',
265
- asyncHandler(async (req: Request, res: Response) => {
284
+ router.get(
285
+ '/:provider/mcp/servers',
286
+ requireProviderAdmin,
287
+ asyncHandler(async (req: Request, res: Response) => {
266
288
  const provider = parseProvider(req.params.provider);
267
289
  const workspacePath = readOptionalQueryString(req.query.workspacePath);
268
290
  const scope = parseMcpScope(req.query.scope);
@@ -278,9 +300,10 @@ router.get(
278
300
  }),
279
301
  );
280
302
 
281
- router.post(
282
- '/:provider/mcp/servers',
283
- asyncHandler(async (req: Request, res: Response) => {
303
+ router.post(
304
+ '/:provider/mcp/servers',
305
+ requireProviderAdmin,
306
+ asyncHandler(async (req: Request, res: Response) => {
284
307
  const provider = parseProvider(req.params.provider);
285
308
  const payload = parseMcpUpsertPayload(req.body);
286
309
  const server = await providerMcpService.upsertProviderMcpServer(provider, payload);
@@ -288,9 +311,10 @@ router.post(
288
311
  }),
289
312
  );
290
313
 
291
- router.delete(
292
- '/:provider/mcp/servers/:name',
293
- asyncHandler(async (req: Request, res: Response) => {
314
+ router.delete(
315
+ '/:provider/mcp/servers/:name',
316
+ requireProviderAdmin,
317
+ asyncHandler(async (req: Request, res: Response) => {
294
318
  const provider = parseProvider(req.params.provider);
295
319
  const scope = parseMcpScope(req.query.scope);
296
320
  const workspacePath = readOptionalQueryString(req.query.workspacePath);
@@ -308,9 +332,10 @@ router.delete(
308
332
  * Summary for every provider (hasKey + baseUrl + updatedAt). Used by the
309
333
  * Settings UI to pre-fill the "API Key" tab.
310
334
  */
311
- router.get(
312
- '/credentials',
313
- asyncHandler(async (_req: Request, res: Response) => {
335
+ router.get(
336
+ '/credentials',
337
+ requireProviderAdmin,
338
+ asyncHandler(async (_req: Request, res: Response) => {
314
339
  const summaries = await listProviderCredentialSummaries();
315
340
  res.json(createApiSuccessResponse(summaries));
316
341
  }),
@@ -322,9 +347,10 @@ router.get(
322
347
  * ~/.pixcode/provider-credentials.json and applies them to process.env
323
348
  * so the next CLI spawn/SDK call picks them up. Empty apiKey clears.
324
349
  */
325
- router.post(
326
- '/:provider/auth/api-key',
327
- asyncHandler(async (req: Request, res: Response) => {
350
+ router.post(
351
+ '/:provider/auth/api-key',
352
+ requireProviderAdmin,
353
+ asyncHandler(async (req: Request, res: Response) => {
328
354
  const provider = parseProvider(req.params.provider);
329
355
  if (!(provider in PROVIDER_ENV_VARS)) {
330
356
  throw new AppError(`Provider "${provider}" does not accept API-key auth.`, {
@@ -357,9 +383,10 @@ router.post(
357
383
  * GET to the VPS-side 127.0.0.1:PORT so the CLI's local handler completes
358
384
  * the token exchange.
359
385
  */
360
- router.post(
361
- '/:provider/oauth-paste',
362
- asyncHandler(async (req: Request, res: Response) => {
386
+ router.post(
387
+ '/:provider/oauth-paste',
388
+ requireProviderAdmin,
389
+ asyncHandler(async (req: Request, res: Response) => {
363
390
  parseProvider(req.params.provider); // validate id but we don't use it further
364
391
  const body = (req.body ?? {}) as Record<string, unknown>;
365
392
  const raw = typeof body.callbackUrl === 'string' ? body.callbackUrl.trim() : '';
@@ -440,9 +467,10 @@ router.get(
440
467
  }),
441
468
  );
442
469
 
443
- router.delete(
444
- '/:provider/models/cache',
445
- asyncHandler(async (req: Request, res: Response) => {
470
+ router.delete(
471
+ '/:provider/models/cache',
472
+ requireProviderAdmin,
473
+ asyncHandler(async (req: Request, res: Response) => {
446
474
  const provider = parseProvider(req.params.provider);
447
475
  await clearProviderModelRegistryCache(provider);
448
476
  res.json(createApiSuccessResponse({ cleared: true, provider }));
@@ -459,9 +487,10 @@ router.delete(
459
487
  * proxies, service-worker reloads, or Vite HMR and short-circuit
460
488
  * an in-flight install. The child now outlives the request.
461
489
  */
462
- router.post(
463
- '/:provider/install',
464
- asyncHandler(async (req: Request, res: Response) => {
490
+ router.post(
491
+ '/:provider/install',
492
+ requireProviderAdmin,
493
+ asyncHandler(async (req: Request, res: Response) => {
465
494
  const parsed = parseProvider(req.params.provider);
466
495
  const packageName = PROVIDER_INSTALL_PACKAGES[parsed];
467
496
  const installCmd = PROVIDER_INSTALL_COMMANDS[parsed];
@@ -502,9 +531,10 @@ router.post(
502
531
  * ?token=... as a fallback auth channel (same pattern the search
503
532
  * endpoint uses).
504
533
  */
505
- router.get(
506
- '/:provider/install/:jobId/stream',
507
- asyncHandler(async (req: Request, res: Response) => {
534
+ router.get(
535
+ '/:provider/install/:jobId/stream',
536
+ requireProviderAdmin,
537
+ asyncHandler(async (req: Request, res: Response) => {
508
538
  const parsed = parseProvider(req.params.provider);
509
539
  const jobId = readPathParam(req.params.jobId, 'jobId');
510
540
  const job = getInstallJob(jobId);
@@ -582,9 +612,10 @@ router.get(
582
612
  }),
583
613
  );
584
614
 
585
- router.delete(
586
- '/:provider/install/:jobId',
587
- asyncHandler(async (req: Request, res: Response) => {
615
+ router.delete(
616
+ '/:provider/install/:jobId',
617
+ requireProviderAdmin,
618
+ asyncHandler(async (req: Request, res: Response) => {
588
619
  const parsed = parseProvider(req.params.provider);
589
620
  const jobId = readPathParam(req.params.jobId, 'jobId');
590
621
  const job = getInstallJob(jobId);
@@ -599,9 +630,10 @@ router.delete(
599
630
  }),
600
631
  );
601
632
 
602
- router.post(
603
- '/mcp/servers/global',
604
- asyncHandler(async (req: Request, res: Response) => {
633
+ router.post(
634
+ '/mcp/servers/global',
635
+ requireProviderAdmin,
636
+ asyncHandler(async (req: Request, res: Response) => {
605
637
  const payload = parseMcpUpsertPayload(req.body);
606
638
  if (payload.scope === 'local') {
607
639
  throw new AppError('Global MCP add supports only "user" or "project" scopes.', {
@@ -729,9 +761,10 @@ async function buildProviderPluginState(provider: string) {
729
761
  };
730
762
  }
731
763
 
732
- router.get(
733
- '/plugin-state',
734
- asyncHandler(async (_req: Request, res: Response) => {
764
+ router.get(
765
+ '/plugin-state',
766
+ requireProviderAdmin,
767
+ asyncHandler(async (_req: Request, res: Response) => {
735
768
  const providers = await Promise.all(
736
769
  Object.keys(PROVIDER_CONFIG_FILES).map((provider) => buildProviderPluginState(provider)),
737
770
  );
@@ -739,17 +772,19 @@ router.get(
739
772
  }),
740
773
  );
741
774
 
742
- router.get(
743
- '/plugin-state/:provider',
744
- asyncHandler(async (req: Request, res: Response) => {
775
+ router.get(
776
+ '/plugin-state/:provider',
777
+ requireProviderAdmin,
778
+ asyncHandler(async (req: Request, res: Response) => {
745
779
  const provider = parseProvider(req.params.provider);
746
780
  res.json(createApiSuccessResponse(await buildProviderPluginState(provider)));
747
781
  }),
748
782
  );
749
783
 
750
- router.get(
751
- '/:provider/config-files',
752
- asyncHandler(async (req: Request, res: Response) => {
784
+ router.get(
785
+ '/:provider/config-files',
786
+ requireProviderAdmin,
787
+ asyncHandler(async (req: Request, res: Response) => {
753
788
  const provider = String(req.params.provider);
754
789
  const list = PROVIDER_CONFIG_FILES[provider];
755
790
  if (!list) {
@@ -795,9 +830,10 @@ router.get(
795
830
  }),
796
831
  );
797
832
 
798
- router.get(
799
- '/:provider/config-files/:fileId',
800
- asyncHandler(async (req: Request, res: Response) => {
833
+ router.get(
834
+ '/:provider/config-files/:fileId',
835
+ requireProviderAdmin,
836
+ asyncHandler(async (req: Request, res: Response) => {
801
837
  const provider = String(req.params.provider);
802
838
  const fileId = String(req.params.fileId);
803
839
  const { descriptor, absolutePath } = resolveConfigFile(provider, fileId);
@@ -852,9 +888,10 @@ router.get(
852
888
  }),
853
889
  );
854
890
 
855
- router.put(
856
- '/:provider/config-files/:fileId',
857
- asyncHandler(async (req: Request, res: Response) => {
891
+ router.put(
892
+ '/:provider/config-files/:fileId',
893
+ requireProviderAdmin,
894
+ asyncHandler(async (req: Request, res: Response) => {
858
895
  const provider = String(req.params.provider);
859
896
  const fileId = String(req.params.fileId);
860
897
  const { descriptor, absolutePath } = resolveConfigFile(provider, fileId);
@@ -903,9 +940,10 @@ router.put(
903
940
  }),
904
941
  );
905
942
 
906
- router.post(
907
- '/:provider/config-files/:fileId/validate',
908
- asyncHandler(async (req: Request, res: Response) => {
943
+ router.post(
944
+ '/:provider/config-files/:fileId/validate',
945
+ requireProviderAdmin,
946
+ asyncHandler(async (req: Request, res: Response) => {
909
947
  const provider = String(req.params.provider);
910
948
  const fileId = String(req.params.fileId);
911
949
  const { descriptor, absolutePath } = resolveConfigFile(provider, fileId);
@@ -12,7 +12,15 @@ import {
12
12
  saveRemoteConnectionConfig,
13
13
  } from '../services/remote-connection.js';
14
14
 
15
- const router = express.Router();
15
+ const router = express.Router();
16
+
17
+ function publicUser(user) {
18
+ return {
19
+ id: user.id,
20
+ username: user.username,
21
+ role: user.role || 'member',
22
+ };
23
+ }
16
24
 
17
25
  // Check auth status and setup requirements
18
26
  router.get('/status', async (req, res) => {
@@ -60,19 +68,19 @@ router.post('/register', async (req, res) => {
60
68
  // Use a transaction to prevent race conditions
61
69
  db.prepare('BEGIN').run();
62
70
  try {
63
- // Check if users already exist (only allow one user)
64
- const hasUsers = userDb.hasUsers();
65
- if (hasUsers) {
66
- db.prepare('ROLLBACK').run();
67
- return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
68
- }
71
+ // Check if users already exist. Additional accounts are created by admins.
72
+ const hasUsers = userDb.hasUsers();
73
+ if (hasUsers) {
74
+ db.prepare('ROLLBACK').run();
75
+ return res.status(403).json({ error: 'Initial admin already exists. Ask an admin to create another account.' });
76
+ }
69
77
 
70
78
  // Hash password
71
79
  const saltRounds = 12;
72
80
  const passwordHash = await bcrypt.hash(password, saltRounds);
73
81
 
74
82
  // Create user
75
- const user = userDb.createUser(username, passwordHash);
83
+ const user = userDb.createUser(username, passwordHash, { role: 'admin' });
76
84
 
77
85
  // Generate token
78
86
  const token = generateToken(user);
@@ -83,10 +91,10 @@ router.post('/register', async (req, res) => {
83
91
  userDb.updateLastLogin(user.id);
84
92
 
85
93
  res.json({
86
- success: true,
87
- user: { id: user.id, username: user.username },
88
- token
89
- });
94
+ success: true,
95
+ user: publicUser(user),
96
+ token
97
+ });
90
98
  } catch (error) {
91
99
  db.prepare('ROLLBACK').run();
92
100
  throw error;
@@ -130,11 +138,11 @@ router.post('/login', async (req, res) => {
130
138
  // Update last login
131
139
  userDb.updateLastLogin(user.id);
132
140
 
133
- res.json({
134
- success: true,
135
- user: { id: user.id, username: user.username },
136
- token
137
- });
141
+ res.json({
142
+ success: true,
143
+ user: publicUser(user),
144
+ token
145
+ });
138
146
 
139
147
  } catch (error) {
140
148
  console.error('Login error:', error);
@@ -10,8 +10,9 @@ import {
10
10
  getProviderModelRegistryEntry,
11
11
  getStaticProviderModels,
12
12
  } from '../services/model-registry.js';
13
- import { parseFrontmatter } from '../utils/frontmatter.js';
14
- import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
13
+ import { parseFrontmatter } from '../utils/frontmatter.js';
14
+ import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
15
+ import { isAdminUser, userHasProjectPathAccess } from '../services/platformization.js';
15
16
 
16
17
  const __dirname = getModuleDir(import.meta.url);
17
18
  // This route reads the top-level package.json for the status command, so it needs the real
@@ -421,31 +422,37 @@ Custom commands can be created in:
421
422
  * POST /api/commands/list
422
423
  * List all available commands from project and user directories
423
424
  */
424
- router.post('/list', async (req, res) => {
425
- try {
426
- const { projectPath } = req.body;
427
- const allCommands = [...builtInCommands];
428
-
429
- // Scan project-level commands (.claude/commands/)
430
- if (projectPath) {
431
- const projectCommandsDir = path.join(projectPath, '.claude', 'commands');
432
- const projectCommands = await scanCommandsDirectory(
433
- projectCommandsDir,
434
- projectCommandsDir,
425
+ router.post('/list', async (req, res) => {
426
+ try {
427
+ const { projectPath } = req.body;
428
+ const allCommands = [...builtInCommands];
429
+
430
+ // Scan project-level commands (.claude/commands/)
431
+ if (projectPath) {
432
+ const resolvedProjectPath = path.resolve(projectPath);
433
+ if (!userHasProjectPathAccess(req.user, { fullPath: resolvedProjectPath, path: resolvedProjectPath, projectPath: resolvedProjectPath }, resolvedProjectPath, 'viewFiles')) {
434
+ return res.status(403).json({ error: 'Project access denied.' });
435
+ }
436
+ const projectCommandsDir = path.join(resolvedProjectPath, '.claude', 'commands');
437
+ const projectCommands = await scanCommandsDirectory(
438
+ projectCommandsDir,
439
+ projectCommandsDir,
435
440
  'project'
436
441
  );
437
442
  allCommands.push(...projectCommands);
438
443
  }
439
444
 
440
- // Scan user-level commands (~/.claude/commands/)
441
- const homeDir = os.homedir();
442
- const userCommandsDir = path.join(homeDir, '.claude', 'commands');
443
- const userCommands = await scanCommandsDirectory(
444
- userCommandsDir,
445
- userCommandsDir,
446
- 'user'
447
- );
448
- allCommands.push(...userCommands);
445
+ // User-level commands belong to the server OS account; expose them only to admins.
446
+ if (isAdminUser(req.user)) {
447
+ const homeDir = os.homedir();
448
+ const userCommandsDir = path.join(homeDir, '.claude', 'commands');
449
+ const userCommands = await scanCommandsDirectory(
450
+ userCommandsDir,
451
+ userCommandsDir,
452
+ 'user'
453
+ );
454
+ allCommands.push(...userCommands);
455
+ }
449
456
 
450
457
  // Separate built-in and custom commands
451
458
  const customCommands = allCommands.filter(cmd => cmd.namespace !== 'builtin');
@@ -509,20 +516,24 @@ router.post('/execute', async (req, res) => {
509
516
  });
510
517
  }
511
518
 
512
- // Load command content
513
- // Security: validate commandPath is within allowed directories
514
- {
515
- const resolvedPath = path.resolve(commandPath);
516
- const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands'));
517
- const projectBase = context?.projectPath
518
- ? path.resolve(path.join(context.projectPath, '.claude', 'commands'))
519
- : null;
519
+ // Load command content
520
+ // Security: validate commandPath is within allowed directories
521
+ {
522
+ const resolvedPath = path.resolve(commandPath);
523
+ const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands'));
524
+ const contextProjectPath = typeof context?.projectPath === 'string' ? path.resolve(context.projectPath) : null;
525
+ if (contextProjectPath && !userHasProjectPathAccess(req.user, { fullPath: contextProjectPath, path: contextProjectPath, projectPath: contextProjectPath }, resolvedPath, 'viewFiles')) {
526
+ return res.status(403).json({ error: 'Project access denied.' });
527
+ }
528
+ const projectBase = contextProjectPath
529
+ ? path.resolve(path.join(contextProjectPath, '.claude', 'commands'))
530
+ : null;
520
531
  const isUnder = (base) => {
521
532
  const rel = path.relative(base, resolvedPath);
522
533
  return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
523
534
  };
524
- if (!(isUnder(userBase) || (projectBase && isUnder(projectBase)))) {
525
- return res.status(403).json({
535
+ if (!((isAdminUser(req.user) && isUnder(userBase)) || (projectBase && isUnder(projectBase)))) {
536
+ return res.status(403).json({
526
537
  error: 'Access denied',
527
538
  message: 'Command must be in .claude/commands directory'
528
539
  });
@@ -4,16 +4,17 @@ import { promises as fs } from 'fs';
4
4
 
5
5
  import express from 'express';
6
6
 
7
- import { extractProjectDirectory } from '../projects.js';
8
- import { queryClaudeSDK } from '../claude-sdk.js';
9
- import { spawnCursor } from '../cursor-cli.js';
7
+ import { extractProjectDirectory } from '../projects.js';
8
+ import { queryClaudeSDK } from '../claude-sdk.js';
9
+ import { spawnCursor } from '../cursor-cli.js';
10
+ import { userHasProjectAccess } from '../services/platformization.js';
10
11
 
11
- const router = express.Router();
12
+ const router = express.Router();
12
13
  const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
13
14
  const FILESYSTEM_SCAN_MAX_FILES = 5_000;
14
15
  const FILESYSTEM_SCAN_MAX_DEPTH = 10;
15
- const filesystemChangeSnapshots = new Map();
16
- const FILESYSTEM_SCAN_EXCLUDED_DIRS = new Set([
16
+ const filesystemChangeSnapshots = new Map();
17
+ const FILESYSTEM_SCAN_EXCLUDED_DIRS = new Set([
17
18
  '.git',
18
19
  '.hg',
19
20
  '.svn',
@@ -28,9 +29,23 @@ const FILESYSTEM_SCAN_EXCLUDED_DIRS = new Set([
28
29
  '.turbo',
29
30
  '.cache',
30
31
  '.pixcode-dev',
31
- ]);
32
-
33
- function isNotGitRepositoryMessage(message = '') {
32
+ ]);
33
+
34
+ router.use((req, res, next) => {
35
+ const project = req.query.project || req.body?.project;
36
+ if (!project) {
37
+ return next();
38
+ }
39
+
40
+ const capability = req.method === 'GET' ? 'viewFiles' : 'editFiles';
41
+ if (!userHasProjectAccess(req.user, { name: String(project), projectName: String(project) }, capability)) {
42
+ return res.status(403).json({ error: 'Project access denied.' });
43
+ }
44
+
45
+ next();
46
+ });
47
+
48
+ function isNotGitRepositoryMessage(message = '') {
34
49
  return message.includes('Not a git repository')
35
50
  || message.includes('not a git repository')
36
51
  || message.includes('Project directory is not a git repository');