@pixelbyte-software/pixcode 1.51.4 → 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.
- package/dist/assets/{index-HfGHXhD6.js → index-CpbSjzK7.js} +179 -182
- package/dist/assets/index-oDq76nSq.css +32 -0
- package/dist/index.html +2 -2
- package/dist-server/server/cli.js +1 -1
- package/dist-server/server/cli.js.map +1 -1
- package/dist-server/server/index.js +530 -34
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/middleware/auth.js +20 -3
- package/dist-server/server/middleware/auth.js.map +1 -1
- package/dist-server/server/modules/providers/provider.routes.js +33 -17
- package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
- package/dist-server/server/routes/commands.js +20 -9
- package/dist-server/server/routes/commands.js.map +1 -1
- package/dist-server/server/routes/live-view.js +30 -7
- package/dist-server/server/routes/live-view.js.map +1 -1
- package/dist-server/server/routes/messages.js +30 -0
- package/dist-server/server/routes/messages.js.map +1 -1
- package/dist-server/server/routes/network.js +3 -2
- package/dist-server/server/routes/network.js.map +1 -1
- package/dist-server/server/routes/projects.js +4 -3
- package/dist-server/server/routes/projects.js.map +1 -1
- package/dist-server/server/routes/remote.js +2 -1
- package/dist-server/server/routes/remote.js.map +1 -1
- package/dist-server/server/services/platformization.js +73 -0
- package/dist-server/server/services/platformization.js.map +1 -1
- package/package.json +1 -1
- package/scripts/update-git-install.mjs +15 -10
- package/server/cli.js +4 -4
- package/server/index.js +552 -34
- package/server/middleware/auth.js +26 -2
- package/server/modules/providers/provider.routes.ts +91 -53
- package/server/routes/commands.js +43 -32
- package/server/routes/live-view.js +47 -24
- package/server/routes/messages.js +47 -12
- package/server/routes/network.js +9 -8
- package/server/routes/projects.js +7 -6
- package/server/routes/remote.js +6 -5
- package/server/services/platformization.js +86 -13
- 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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
518
|
-
|
|
519
|
-
:
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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,
|
package/server/routes/network.js
CHANGED
|
@@ -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 });
|