@pixelbyte-software/pixcode 1.51.4 → 1.51.6

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 (42) hide show
  1. package/dist/assets/index-Bbmw_Gy1.css +32 -0
  2. package/dist/assets/{index-HfGHXhD6.js → index-DodOzOl5.js} +182 -184
  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/index.js +530 -34
  7. package/dist-server/server/index.js.map +1 -1
  8. package/dist-server/server/middleware/auth.js +20 -3
  9. package/dist-server/server/middleware/auth.js.map +1 -1
  10. package/dist-server/server/modules/providers/provider.routes.js +33 -17
  11. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  12. package/dist-server/server/routes/commands.js +20 -9
  13. package/dist-server/server/routes/commands.js.map +1 -1
  14. package/dist-server/server/routes/live-view.js +30 -7
  15. package/dist-server/server/routes/live-view.js.map +1 -1
  16. package/dist-server/server/routes/messages.js +30 -0
  17. package/dist-server/server/routes/messages.js.map +1 -1
  18. package/dist-server/server/routes/network.js +3 -2
  19. package/dist-server/server/routes/network.js.map +1 -1
  20. package/dist-server/server/routes/platformization.js +17 -1
  21. package/dist-server/server/routes/platformization.js.map +1 -1
  22. package/dist-server/server/routes/projects.js +4 -3
  23. package/dist-server/server/routes/projects.js.map +1 -1
  24. package/dist-server/server/routes/remote.js +2 -1
  25. package/dist-server/server/routes/remote.js.map +1 -1
  26. package/dist-server/server/services/platformization.js +168 -1
  27. package/dist-server/server/services/platformization.js.map +1 -1
  28. package/package.json +1 -1
  29. package/scripts/update-git-install.mjs +15 -10
  30. package/server/cli.js +4 -4
  31. package/server/index.js +552 -34
  32. package/server/middleware/auth.js +26 -2
  33. package/server/modules/providers/provider.routes.ts +91 -53
  34. package/server/routes/commands.js +43 -32
  35. package/server/routes/live-view.js +47 -24
  36. package/server/routes/messages.js +47 -12
  37. package/server/routes/network.js +9 -8
  38. package/server/routes/platformization.js +27 -9
  39. package/server/routes/projects.js +7 -6
  40. package/server/routes/remote.js +6 -5
  41. package/server/services/platformization.js +196 -24
  42. package/dist/assets/index-B9N-gfOQ.css +0 -32
@@ -7,6 +7,7 @@ import { IS_PLATFORM } from '../constants/config.js';
7
7
  const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
8
8
  const isPixcodeApiKey = (token) => typeof token === 'string' && (token.startsWith('px_') || token.startsWith('ck_'));
9
9
  const ADMIN_ROLES = new Set(['owner', 'admin']);
10
+ const PLATFORM_AUTH_BYPASS_ENABLED = IS_PLATFORM && process.env.PIXCODE_ALLOW_PLATFORM_AUTH_BYPASS === '1';
10
11
 
11
12
  // Optional API key middleware
12
13
  const validateApiKey = (req, res, next) => {
@@ -25,7 +26,7 @@ const validateApiKey = (req, res, next) => {
25
26
  // JWT authentication middleware
26
27
  const authenticateToken = async (req, res, next) => {
27
28
  // Platform mode: use single database user
28
- if (IS_PLATFORM) {
29
+ if (PLATFORM_AUTH_BYPASS_ENABLED) {
29
30
  try {
30
31
  const user = userDb.getFirstUser();
31
32
  if (!user) {
@@ -113,8 +114,30 @@ const requireAdmin = (req, res, next) => {
113
114
  return res.status(403).json({ error: 'Admin access required.' });
114
115
  }
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
+
116
126
  next();
117
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
+ };
118
141
 
119
142
  // Generate JWT token
120
143
  const generateToken = (user) => {
@@ -132,7 +155,7 @@ const generateToken = (user) => {
132
155
  // WebSocket authentication function
133
156
  const authenticateWebSocket = (token) => {
134
157
  // Platform mode: bypass token validation, return first user
135
- if (IS_PLATFORM) {
158
+ if (PLATFORM_AUTH_BYPASS_ENABLED) {
136
159
  try {
137
160
  const user = userDb.getFirstUser();
138
161
  if (user) {
@@ -184,6 +207,7 @@ export {
184
207
  validateApiKey,
185
208
  authenticateToken,
186
209
  requireAdmin,
210
+ requireApiScope,
187
211
  generateToken,
188
212
  authenticateWebSocket,
189
213
  JWT_SECRET
@@ -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);
@@ -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
  });
@@ -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 });