@glwhappen/web-code 1.32.0 → 1.32.1
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-u6XmIqLb.js → index-CfT-2Nkf.js} +2 -2
- package/dist/index.html +1 -1
- package/dist-server/server/cursor-cli.js +33 -15
- package/dist-server/server/cursor-cli.js.map +1 -1
- package/dist-server/server/gemini-cli.js +48 -29
- package/dist-server/server/gemini-cli.js.map +1 -1
- package/dist-server/server/index.js +20 -17
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/database/repositories/push-subscriptions.js +11 -5
- package/dist-server/server/modules/database/repositories/push-subscriptions.js.map +1 -1
- package/dist-server/server/modules/projects/projects.routes.js +14 -0
- package/dist-server/server/modules/projects/projects.routes.js.map +1 -1
- package/dist-server/server/modules/projects/services/project-authorization.service.js +54 -0
- package/dist-server/server/modules/projects/services/project-authorization.service.js.map +1 -0
- package/dist-server/server/modules/projects/services/project-clone.service.js +13 -4
- package/dist-server/server/modules/projects/services/project-clone.service.js.map +1 -1
- package/dist-server/server/modules/projects/services/project-management.service.js +4 -2
- package/dist-server/server/modules/projects/services/project-management.service.js.map +1 -1
- package/dist-server/server/modules/projects/tests/project-authorization.service.test.js +51 -0
- package/dist-server/server/modules/projects/tests/project-authorization.service.test.js.map +1 -0
- package/dist-server/server/modules/projects/tests/project-clone.service.test.js +10 -1
- package/dist-server/server/modules/projects/tests/project-clone.service.test.js.map +1 -1
- package/dist-server/server/modules/projects/tests/project-management.service.test.js +31 -10
- package/dist-server/server/modules/projects/tests/project-management.service.test.js.map +1 -1
- package/dist-server/server/modules/websocket/services/chat-websocket.service.js +77 -13
- package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
- package/dist-server/server/routes/agent.js +35 -7
- package/dist-server/server/routes/agent.js.map +1 -1
- package/dist-server/server/routes/settings.js +1 -1
- package/dist-server/server/routes/settings.js.map +1 -1
- package/dist-server/server/services/notification-orchestrator.js +1 -1
- package/dist-server/server/services/notification-orchestrator.js.map +1 -1
- package/dist-server/server/shared/utils.js +60 -9
- package/dist-server/server/shared/utils.js.map +1 -1
- package/package.json +1 -1
- package/server/cursor-cli.js +33 -15
- package/server/gemini-cli.js +48 -28
- package/server/index.js +21 -17
- package/server/modules/database/repositories/push-subscriptions.ts +14 -5
- package/server/modules/projects/projects.routes.ts +16 -0
- package/server/modules/projects/services/project-authorization.service.ts +70 -0
- package/server/modules/projects/services/project-clone.service.ts +18 -4
- package/server/modules/projects/services/project-management.service.ts +12 -3
- package/server/modules/projects/tests/project-authorization.service.test.ts +68 -0
- package/server/modules/projects/tests/project-clone.service.test.ts +11 -1
- package/server/modules/projects/tests/project-management.service.test.ts +38 -10
- package/server/modules/websocket/services/chat-websocket.service.ts +87 -19
- package/server/routes/agent.js +34 -7
- package/server/routes/settings.js +1 -1
- package/server/services/notification-orchestrator.js +1 -1
- package/server/shared/utils.ts +70 -9
package/server/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import express from 'express';
|
|
|
11
11
|
import cors from 'cors';
|
|
12
12
|
import mime from 'mime-types';
|
|
13
13
|
|
|
14
|
-
import { AppError, WORKSPACES_ROOT, validateWorkspacePath } from '@/shared/utils.js';
|
|
14
|
+
import { AppError, WORKSPACES_ROOT, ensureUserWorkspaceRoot, validateWorkspacePath } from '@/shared/utils.js';
|
|
15
15
|
import { closeSessionsWatcher, initializeSessionsWatcher } from '@/modules/providers/index.js';
|
|
16
16
|
import { createWebSocketServer } from '@/modules/websocket/index.js';
|
|
17
17
|
|
|
@@ -289,13 +289,16 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
|
|
|
289
289
|
}
|
|
290
290
|
});
|
|
291
291
|
|
|
292
|
-
|
|
293
|
-
|
|
292
|
+
// `~` expands to the caller's per-user workspace root so the file browser
|
|
293
|
+
// lands in the user's own sandbox instead of the host user's home.
|
|
294
|
+
const expandWorkspacePath = (inputPath, userWorkspaceRoot) => {
|
|
295
|
+
const root = userWorkspaceRoot || WORKSPACES_ROOT;
|
|
296
|
+
if (!inputPath) return root;
|
|
294
297
|
if (inputPath === '~') {
|
|
295
|
-
return
|
|
298
|
+
return root;
|
|
296
299
|
}
|
|
297
300
|
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
|
|
298
|
-
return path.join(
|
|
301
|
+
return path.join(root, inputPath.slice(2));
|
|
299
302
|
}
|
|
300
303
|
return inputPath;
|
|
301
304
|
};
|
|
@@ -305,17 +308,17 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
|
305
308
|
try {
|
|
306
309
|
const { path: dirPath } = req.query;
|
|
307
310
|
|
|
311
|
+
const userWorkspaceRoot = await ensureUserWorkspaceRoot(req.user?.username);
|
|
312
|
+
|
|
308
313
|
console.log('[API] Browse filesystem request for path:', dirPath);
|
|
309
|
-
console.log('[API]
|
|
310
|
-
|
|
311
|
-
const defaultRoot = WORKSPACES_ROOT;
|
|
312
|
-
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
|
|
314
|
+
console.log('[API] User workspace root:', userWorkspaceRoot);
|
|
315
|
+
let targetPath = expandWorkspacePath(dirPath, userWorkspaceRoot);
|
|
313
316
|
|
|
314
317
|
// Resolve and normalize the path
|
|
315
318
|
targetPath = path.resolve(targetPath);
|
|
316
319
|
|
|
317
|
-
// Security check -
|
|
318
|
-
const validation = await validateWorkspacePath(targetPath);
|
|
320
|
+
// Security check - confine browsing to the caller's per-user root
|
|
321
|
+
const validation = await validateWorkspacePath(targetPath, userWorkspaceRoot);
|
|
319
322
|
if (!validation.valid) {
|
|
320
323
|
return res.status(403).json({ error: validation.error });
|
|
321
324
|
}
|
|
@@ -352,13 +355,13 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
|
352
355
|
return a.name.localeCompare(b.name);
|
|
353
356
|
});
|
|
354
357
|
|
|
355
|
-
//
|
|
358
|
+
// At the user's root, surface common project folders (if present) first.
|
|
356
359
|
const suggestions = [];
|
|
357
|
-
let resolvedWorkspaceRoot =
|
|
360
|
+
let resolvedWorkspaceRoot = userWorkspaceRoot;
|
|
358
361
|
try {
|
|
359
|
-
resolvedWorkspaceRoot = await fsPromises.realpath(
|
|
362
|
+
resolvedWorkspaceRoot = await fsPromises.realpath(userWorkspaceRoot);
|
|
360
363
|
} catch (error) {
|
|
361
|
-
// Use
|
|
364
|
+
// Use user root as-is if realpath fails
|
|
362
365
|
}
|
|
363
366
|
if (resolvedPath === resolvedWorkspaceRoot) {
|
|
364
367
|
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
|
@@ -387,9 +390,10 @@ app.post('/api/create-folder', authenticateToken, async (req, res) => {
|
|
|
387
390
|
if (!folderPath) {
|
|
388
391
|
return res.status(400).json({ error: 'Path is required' });
|
|
389
392
|
}
|
|
390
|
-
const
|
|
393
|
+
const userWorkspaceRoot = await ensureUserWorkspaceRoot(req.user?.username);
|
|
394
|
+
const expandedPath = expandWorkspacePath(folderPath, userWorkspaceRoot);
|
|
391
395
|
const resolvedInput = path.resolve(expandedPath);
|
|
392
|
-
const validation = await validateWorkspacePath(resolvedInput);
|
|
396
|
+
const validation = await validateWorkspacePath(resolvedInput, userWorkspaceRoot);
|
|
393
397
|
if (!validation.valid) {
|
|
394
398
|
return res.status(403).json({ error: validation.error });
|
|
395
399
|
}
|
|
@@ -41,10 +41,19 @@ export const pushSubscriptionsDb = {
|
|
|
41
41
|
.all(userId) as PushSubscriptionLookupRow[];
|
|
42
42
|
},
|
|
43
43
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Deletes one subscription, but only when the endpoint belongs to `userId`.
|
|
46
|
+
*
|
|
47
|
+
* Push endpoint URLs are not secret per se, but they appear in service-worker
|
|
48
|
+
* registration and network logs; scoping by user prevents user A from
|
|
49
|
+
* unsubscribing user B by replaying an endpoint they observed.
|
|
50
|
+
*/
|
|
51
|
+
deletePushSubscription(userId: number, endpoint: string): void {
|
|
46
52
|
const db = getConnection();
|
|
47
|
-
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(
|
|
53
|
+
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(
|
|
54
|
+
userId,
|
|
55
|
+
endpoint,
|
|
56
|
+
);
|
|
48
57
|
},
|
|
49
58
|
|
|
50
59
|
/** Deletes all subscriptions for a user. */
|
|
@@ -70,8 +79,8 @@ export const pushSubscriptionsDb = {
|
|
|
70
79
|
getSubscriptions(userId: number): PushSubscriptionLookupRow[] {
|
|
71
80
|
return pushSubscriptionsDb.getPushSubscriptions(userId);
|
|
72
81
|
},
|
|
73
|
-
removeSubscription(endpoint: string): void {
|
|
74
|
-
pushSubscriptionsDb.deletePushSubscription(endpoint);
|
|
82
|
+
removeSubscription(userId: number, endpoint: string): void {
|
|
83
|
+
pushSubscriptionsDb.deletePushSubscription(userId, endpoint);
|
|
75
84
|
},
|
|
76
85
|
removeAllForUser(userId: number): void {
|
|
77
86
|
pushSubscriptionsDb.deletePushSubscriptionsForUser(userId);
|
|
@@ -12,6 +12,7 @@ const router = express.Router();
|
|
|
12
12
|
|
|
13
13
|
type AuthenticatedUser = {
|
|
14
14
|
id?: number | string;
|
|
15
|
+
username?: string;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
function getAuthenticatedUserId(req: express.Request): number {
|
|
@@ -33,6 +34,18 @@ function getAuthenticatedUserId(req: express.Request): number {
|
|
|
33
34
|
return numericId;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
function getAuthenticatedUsername(req: express.Request): string {
|
|
38
|
+
const authenticatedUser = (req as typeof req & { user?: AuthenticatedUser }).user;
|
|
39
|
+
const username = authenticatedUser?.username;
|
|
40
|
+
if (typeof username !== 'string' || username.length === 0) {
|
|
41
|
+
throw new AppError('Authenticated user is required', {
|
|
42
|
+
code: 'AUTHENTICATION_REQUIRED',
|
|
43
|
+
statusCode: 401,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return username;
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
function readQueryStringValue(value: unknown): string {
|
|
37
50
|
if (typeof value === 'string') {
|
|
38
51
|
return value;
|
|
@@ -138,6 +151,7 @@ router.post(
|
|
|
138
151
|
|
|
139
152
|
const projectCreationResult = await createProject({
|
|
140
153
|
userId: getAuthenticatedUserId(req),
|
|
154
|
+
username: getAuthenticatedUsername(req),
|
|
141
155
|
projectPath,
|
|
142
156
|
customName,
|
|
143
157
|
});
|
|
@@ -203,6 +217,7 @@ router.get('/clone-progress', async (req, res) => {
|
|
|
203
217
|
statusCode: 401,
|
|
204
218
|
});
|
|
205
219
|
}
|
|
220
|
+
const username = getAuthenticatedUsername(req);
|
|
206
221
|
cloneOperation = await startCloneProject(
|
|
207
222
|
{
|
|
208
223
|
workspacePath,
|
|
@@ -210,6 +225,7 @@ router.get('/clone-progress', async (req, res) => {
|
|
|
210
225
|
githubTokenId,
|
|
211
226
|
newGithubToken,
|
|
212
227
|
userId,
|
|
228
|
+
username,
|
|
213
229
|
},
|
|
214
230
|
{
|
|
215
231
|
onProgress: (message) => {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { projectsDb } from '@/modules/database/index.js';
|
|
2
|
+
import { AppError, normalizeProjectPath } from '@/shared/utils.js';
|
|
3
|
+
|
|
4
|
+
type AuthorizationDependencies = {
|
|
5
|
+
getProjectPath: (userId: number, projectPath: string) => unknown;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const defaultDependencies: AuthorizationDependencies = {
|
|
9
|
+
getProjectPath: projectsDb.getProjectPath.bind(projectsDb),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function coerceNumericUserId(userId: unknown): number {
|
|
13
|
+
if (userId === null || userId === undefined) {
|
|
14
|
+
throw new AppError('Authenticated user is required', {
|
|
15
|
+
code: 'AUTHENTICATION_REQUIRED',
|
|
16
|
+
statusCode: 401,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const numericId = typeof userId === 'number' ? userId : Number.parseInt(String(userId), 10);
|
|
21
|
+
if (Number.isNaN(numericId)) {
|
|
22
|
+
throw new AppError('Authenticated user is required', {
|
|
23
|
+
code: 'AUTHENTICATION_REQUIRED',
|
|
24
|
+
statusCode: 401,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return numericId;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Confirms that `requestedPath` is a project registered to `userId`, and
|
|
33
|
+
* returns its canonical (normalized) form.
|
|
34
|
+
*
|
|
35
|
+
* Use this anywhere the server is about to act on a path supplied over the
|
|
36
|
+
* wire — spawn a CLI, register a clone target, etc. — so a user with a valid
|
|
37
|
+
* session cannot pivot to another user's project simply by knowing its path.
|
|
38
|
+
*/
|
|
39
|
+
export function assertUserOwnsProjectPath(
|
|
40
|
+
userId: unknown,
|
|
41
|
+
requestedPath: unknown,
|
|
42
|
+
dependencies: AuthorizationDependencies = defaultDependencies,
|
|
43
|
+
): string {
|
|
44
|
+
const numericUserId = coerceNumericUserId(userId);
|
|
45
|
+
|
|
46
|
+
if (typeof requestedPath !== 'string' || requestedPath.trim().length === 0) {
|
|
47
|
+
throw new AppError('A project path is required', {
|
|
48
|
+
code: 'PROJECT_PATH_REQUIRED',
|
|
49
|
+
statusCode: 400,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const normalized = normalizeProjectPath(requestedPath);
|
|
54
|
+
if (!normalized) {
|
|
55
|
+
throw new AppError('A project path is required', {
|
|
56
|
+
code: 'PROJECT_PATH_REQUIRED',
|
|
57
|
+
statusCode: 400,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const project = dependencies.getProjectPath(numericUserId, normalized);
|
|
62
|
+
if (!project) {
|
|
63
|
+
throw new AppError('The requested path is not a project owned by the authenticated user', {
|
|
64
|
+
code: 'PROJECT_NOT_OWNED_BY_USER',
|
|
65
|
+
statusCode: 403,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return normalized;
|
|
70
|
+
}
|
|
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { githubTokensDb } from '@/modules/database/index.js';
|
|
6
6
|
import { createProject } from '@/modules/projects/services/project-management.service.js';
|
|
7
7
|
import type { WorkspacePathValidationResult } from '@/shared/types.js';
|
|
8
|
-
import { AppError, validateWorkspacePath } from '@/shared/utils.js';
|
|
8
|
+
import { AppError, ensureUserWorkspaceRoot, validateWorkspacePath } from '@/shared/utils.js';
|
|
9
9
|
|
|
10
10
|
type CloneProjectInput = {
|
|
11
11
|
workspacePath: string;
|
|
@@ -13,6 +13,7 @@ type CloneProjectInput = {
|
|
|
13
13
|
githubTokenId?: number | null;
|
|
14
14
|
newGithubToken?: string | null;
|
|
15
15
|
userId: number | string;
|
|
16
|
+
username: string;
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
type CloneCompletePayload = {
|
|
@@ -34,8 +35,9 @@ type GitCloneProcess = {
|
|
|
34
35
|
};
|
|
35
36
|
|
|
36
37
|
type CloneProjectDependencies = {
|
|
37
|
-
validatePath: (requestedPath: string) => Promise<WorkspacePathValidationResult>;
|
|
38
|
+
validatePath: (requestedPath: string, workspaceRoot: string) => Promise<WorkspacePathValidationResult>;
|
|
38
39
|
ensureDirectory: (directoryPath: string) => Promise<void>;
|
|
40
|
+
resolveUserWorkspaceRoot: (username: string) => Promise<string>;
|
|
39
41
|
pathExists: (targetPath: string) => Promise<boolean>;
|
|
40
42
|
removePath: (targetPath: string) => Promise<void>;
|
|
41
43
|
getGithubTokenById: (
|
|
@@ -45,6 +47,7 @@ type CloneProjectDependencies = {
|
|
|
45
47
|
spawnGitClone: (cloneUrl: string, clonePath: string) => GitCloneProcess;
|
|
46
48
|
registerProject: (
|
|
47
49
|
userId: number,
|
|
50
|
+
username: string,
|
|
48
51
|
projectPath: string,
|
|
49
52
|
customName: string,
|
|
50
53
|
) => Promise<{ project: Record<string, unknown> }>;
|
|
@@ -115,6 +118,7 @@ const defaultDependencies: CloneProjectDependencies = {
|
|
|
115
118
|
ensureDirectory: async (directoryPath: string): Promise<void> => {
|
|
116
119
|
await mkdir(directoryPath, { recursive: true });
|
|
117
120
|
},
|
|
121
|
+
resolveUserWorkspaceRoot: ensureUserWorkspaceRoot,
|
|
118
122
|
pathExists: defaultPathExists,
|
|
119
123
|
removePath: async (targetPath: string): Promise<void> => {
|
|
120
124
|
await rm(targetPath, { recursive: true, force: true });
|
|
@@ -138,11 +142,13 @@ const defaultDependencies: CloneProjectDependencies = {
|
|
|
138
142
|
}) as unknown as GitCloneProcess,
|
|
139
143
|
registerProject: async (
|
|
140
144
|
userId: number,
|
|
145
|
+
username: string,
|
|
141
146
|
projectPath: string,
|
|
142
147
|
customName: string,
|
|
143
148
|
): Promise<{ project: Record<string, unknown> }> =>
|
|
144
149
|
createProject({
|
|
145
150
|
userId,
|
|
151
|
+
username,
|
|
146
152
|
projectPath,
|
|
147
153
|
customName,
|
|
148
154
|
}) as Promise<{ project: Record<string, unknown> }>,
|
|
@@ -180,7 +186,15 @@ export async function startCloneProject(
|
|
|
180
186
|
});
|
|
181
187
|
}
|
|
182
188
|
|
|
183
|
-
|
|
189
|
+
if (!input.username || typeof input.username !== 'string') {
|
|
190
|
+
throw new AppError('Authenticated user is required', {
|
|
191
|
+
code: 'AUTHENTICATION_REQUIRED',
|
|
192
|
+
statusCode: 401,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const userWorkspaceRoot = await dependencies.resolveUserWorkspaceRoot(input.username);
|
|
197
|
+
const pathValidation = await dependencies.validatePath(normalizedWorkspacePath, userWorkspaceRoot);
|
|
184
198
|
if (!pathValidation.valid || !pathValidation.resolvedPath) {
|
|
185
199
|
throw new AppError(pathValidation.error || 'Invalid workspace path', {
|
|
186
200
|
code: 'INVALID_PROJECT_PATH',
|
|
@@ -264,7 +278,7 @@ export async function startCloneProject(
|
|
|
264
278
|
gitProcess.on('close', async (code) => {
|
|
265
279
|
if (code === 0) {
|
|
266
280
|
try {
|
|
267
|
-
const createdProject = await dependencies.registerProject(numericUserId, clonePath, repoName);
|
|
281
|
+
const createdProject = await dependencies.registerProject(numericUserId, input.username, clonePath, repoName);
|
|
268
282
|
handlers.onComplete({
|
|
269
283
|
project: createdProject.project,
|
|
270
284
|
message: 'Repository cloned successfully',
|
|
@@ -7,17 +7,24 @@ import type {
|
|
|
7
7
|
ProjectRepositoryRow,
|
|
8
8
|
WorkspacePathValidationResult,
|
|
9
9
|
} from '@/shared/types.js';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
AppError,
|
|
12
|
+
ensureUserWorkspaceRoot,
|
|
13
|
+
normalizeProjectPath,
|
|
14
|
+
validateWorkspacePath,
|
|
15
|
+
} from '@/shared/utils.js';
|
|
11
16
|
|
|
12
17
|
type CreateProjectInput = {
|
|
13
18
|
userId: number;
|
|
19
|
+
username: string;
|
|
14
20
|
projectPath: string;
|
|
15
21
|
customName?: string | null;
|
|
16
22
|
};
|
|
17
23
|
|
|
18
24
|
type CreateProjectDependencies = {
|
|
19
|
-
validatePath: (projectPath: string) => Promise<WorkspacePathValidationResult>;
|
|
25
|
+
validatePath: (projectPath: string, workspaceRoot: string) => Promise<WorkspacePathValidationResult>;
|
|
20
26
|
ensureWorkspaceDirectory: (projectPath: string) => Promise<void>;
|
|
27
|
+
resolveUserWorkspaceRoot: (username: string) => Promise<string>;
|
|
21
28
|
persistProjectPath: (userId: number, projectPath: string, customName: string | null) => CreateProjectPathResult;
|
|
22
29
|
getProjectByPath: (userId: number, projectPath: string) => ProjectRepositoryRow | null;
|
|
23
30
|
};
|
|
@@ -57,6 +64,7 @@ const defaultDependencies: CreateProjectDependencies = {
|
|
|
57
64
|
});
|
|
58
65
|
}
|
|
59
66
|
},
|
|
67
|
+
resolveUserWorkspaceRoot: ensureUserWorkspaceRoot,
|
|
60
68
|
persistProjectPath: (userId: number, projectPath: string, customName: string | null): CreateProjectPathResult =>
|
|
61
69
|
projectsDb.createProjectPath(userId, projectPath, customName),
|
|
62
70
|
getProjectByPath: (userId: number, projectPath: string): ProjectRepositoryRow | null =>
|
|
@@ -111,7 +119,8 @@ export async function createProject(
|
|
|
111
119
|
});
|
|
112
120
|
}
|
|
113
121
|
|
|
114
|
-
const
|
|
122
|
+
const userWorkspaceRoot = await dependencies.resolveUserWorkspaceRoot(input.username);
|
|
123
|
+
const pathValidation = await dependencies.validatePath(normalizedPath, userWorkspaceRoot);
|
|
115
124
|
if (!pathValidation.valid || !pathValidation.resolvedPath) {
|
|
116
125
|
throw new AppError('Invalid project path', {
|
|
117
126
|
code: 'INVALID_PROJECT_PATH',
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { assertUserOwnsProjectPath } from '@/modules/projects/services/project-authorization.service.js';
|
|
5
|
+
import { AppError } from '@/shared/utils.js';
|
|
6
|
+
|
|
7
|
+
const TEST_USER_ID = 7;
|
|
8
|
+
const OWNED_PATH = '/workspace/tester/my-project';
|
|
9
|
+
|
|
10
|
+
function buildDependencies(found: boolean) {
|
|
11
|
+
return {
|
|
12
|
+
getProjectPath: () => (found ? { project_id: 'p1' } : null),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('assertUserOwnsProjectPath returns the normalized path when the user owns it', () => {
|
|
17
|
+
const result = assertUserOwnsProjectPath(TEST_USER_ID, `${OWNED_PATH}/`, buildDependencies(true));
|
|
18
|
+
assert.equal(result, OWNED_PATH);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('assertUserOwnsProjectPath throws AUTHENTICATION_REQUIRED when userId is missing', () => {
|
|
22
|
+
assert.throws(
|
|
23
|
+
() => assertUserOwnsProjectPath(undefined, OWNED_PATH, buildDependencies(true)),
|
|
24
|
+
(error: unknown) => {
|
|
25
|
+
assert.ok(error instanceof AppError);
|
|
26
|
+
assert.equal(error.code, 'AUTHENTICATION_REQUIRED');
|
|
27
|
+
assert.equal(error.statusCode, 401);
|
|
28
|
+
return true;
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('assertUserOwnsProjectPath throws PROJECT_PATH_REQUIRED when path is blank', () => {
|
|
34
|
+
assert.throws(
|
|
35
|
+
() => assertUserOwnsProjectPath(TEST_USER_ID, ' ', buildDependencies(true)),
|
|
36
|
+
(error: unknown) => {
|
|
37
|
+
assert.ok(error instanceof AppError);
|
|
38
|
+
assert.equal(error.code, 'PROJECT_PATH_REQUIRED');
|
|
39
|
+
assert.equal(error.statusCode, 400);
|
|
40
|
+
return true;
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('assertUserOwnsProjectPath throws PROJECT_NOT_OWNED_BY_USER when the project is not registered for the user', () => {
|
|
46
|
+
assert.throws(
|
|
47
|
+
() => assertUserOwnsProjectPath(TEST_USER_ID, OWNED_PATH, buildDependencies(false)),
|
|
48
|
+
(error: unknown) => {
|
|
49
|
+
assert.ok(error instanceof AppError);
|
|
50
|
+
assert.equal(error.code, 'PROJECT_NOT_OWNED_BY_USER');
|
|
51
|
+
assert.equal(error.statusCode, 403);
|
|
52
|
+
return true;
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('assertUserOwnsProjectPath coerces stringified userIds', () => {
|
|
58
|
+
let receivedUserId: number | null = null;
|
|
59
|
+
const dependencies = {
|
|
60
|
+
getProjectPath: (userId: number) => {
|
|
61
|
+
receivedUserId = userId;
|
|
62
|
+
return { project_id: 'p1' };
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
assertUserOwnsProjectPath('42', OWNED_PATH, dependencies);
|
|
67
|
+
assert.equal(receivedUserId, 42);
|
|
68
|
+
});
|
|
@@ -13,6 +13,7 @@ function buildDependencies(overrides: Partial<NonNullable<TestDependencies>> = {
|
|
|
13
13
|
return {
|
|
14
14
|
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/root' }),
|
|
15
15
|
ensureDirectory: async () => undefined,
|
|
16
|
+
resolveUserWorkspaceRoot: async () => '/workspace/root',
|
|
16
17
|
pathExists: async () => false,
|
|
17
18
|
removePath: async () => undefined,
|
|
18
19
|
getGithubTokenById: async () => ({ github_token: 'token-value' }),
|
|
@@ -49,6 +50,7 @@ test('startCloneProject rejects when workspace path is missing', async () => {
|
|
|
49
50
|
workspacePath: '',
|
|
50
51
|
githubUrl: 'https://github.com/example/repo',
|
|
51
52
|
userId: 1,
|
|
53
|
+
username: 'tester',
|
|
52
54
|
},
|
|
53
55
|
{
|
|
54
56
|
onProgress: () => undefined,
|
|
@@ -72,6 +74,7 @@ test('startCloneProject rejects when github URL is missing', async () => {
|
|
|
72
74
|
workspacePath: '/workspace/root',
|
|
73
75
|
githubUrl: '',
|
|
74
76
|
userId: 1,
|
|
77
|
+
username: 'tester',
|
|
75
78
|
},
|
|
76
79
|
{
|
|
77
80
|
onProgress: () => undefined,
|
|
@@ -95,6 +98,7 @@ test('startCloneProject rejects github URL values that begin with option prefixe
|
|
|
95
98
|
workspacePath: '/workspace/root',
|
|
96
99
|
githubUrl: '--upload-pack=malicious',
|
|
97
100
|
userId: 1,
|
|
101
|
+
username: 'tester',
|
|
98
102
|
},
|
|
99
103
|
{
|
|
100
104
|
onProgress: () => undefined,
|
|
@@ -119,6 +123,7 @@ test('startCloneProject rejects when selected github token does not exist', asyn
|
|
|
119
123
|
githubUrl: 'https://github.com/example/repo',
|
|
120
124
|
githubTokenId: 12,
|
|
121
125
|
userId: 1,
|
|
126
|
+
username: 'tester',
|
|
122
127
|
},
|
|
123
128
|
{
|
|
124
129
|
onProgress: () => undefined,
|
|
@@ -144,11 +149,14 @@ test('startCloneProject completes and emits complete payload when git exits succ
|
|
|
144
149
|
let capturedProjectPath = '';
|
|
145
150
|
let capturedCustomName = '';
|
|
146
151
|
|
|
152
|
+
let capturedUsername = '';
|
|
153
|
+
|
|
147
154
|
const operation = await startCloneProject(
|
|
148
155
|
{
|
|
149
156
|
workspacePath: '/workspace/root',
|
|
150
157
|
githubUrl: 'https://github.com/example/repo.git',
|
|
151
158
|
userId: 1,
|
|
159
|
+
username: 'tester',
|
|
152
160
|
},
|
|
153
161
|
{
|
|
154
162
|
onProgress: (message) => {
|
|
@@ -160,8 +168,9 @@ test('startCloneProject completes and emits complete payload when git exits succ
|
|
|
160
168
|
},
|
|
161
169
|
buildDependencies({
|
|
162
170
|
spawnGitClone: () => gitProcess as any,
|
|
163
|
-
registerProject: async (userId, projectPath, customName) => {
|
|
171
|
+
registerProject: async (userId, username, projectPath, customName) => {
|
|
164
172
|
capturedUserId = userId;
|
|
173
|
+
capturedUsername = username;
|
|
165
174
|
capturedProjectPath = projectPath;
|
|
166
175
|
capturedCustomName = customName;
|
|
167
176
|
return { project: { projectId: 'project-1', path: projectPath } };
|
|
@@ -174,6 +183,7 @@ test('startCloneProject completes and emits complete payload when git exits succ
|
|
|
174
183
|
|
|
175
184
|
assert.ok(progressMessages.some((message) => message.includes("Cloning into 'repo'")));
|
|
176
185
|
assert.equal(capturedUserId, 1);
|
|
186
|
+
assert.equal(capturedUsername, 'tester');
|
|
177
187
|
assert.equal(capturedCustomName, 'repo');
|
|
178
188
|
assert.equal(path.basename(capturedProjectPath), 'repo');
|
|
179
189
|
assert.notEqual(completePayload, null);
|
|
@@ -5,18 +5,22 @@ import { createProject } from '@/modules/projects/services/project-management.se
|
|
|
5
5
|
import { AppError } from '@/shared/utils.js';
|
|
6
6
|
|
|
7
7
|
const TEST_USER_ID = 1;
|
|
8
|
+
const TEST_USERNAME = 'tester';
|
|
9
|
+
const TEST_USER_WORKSPACE_ROOT = '/workspace/tester';
|
|
8
10
|
|
|
9
11
|
const projectRow = {
|
|
10
12
|
project_id: 'project-1',
|
|
11
|
-
project_path: '/workspace/my-project',
|
|
13
|
+
project_path: '/workspace/tester/my-project',
|
|
12
14
|
custom_project_name: 'my-project',
|
|
13
15
|
isStarred: 0,
|
|
14
16
|
isArchived: 0,
|
|
15
17
|
};
|
|
16
18
|
|
|
19
|
+
const resolveUserWorkspaceRoot = async () => TEST_USER_WORKSPACE_ROOT;
|
|
20
|
+
|
|
17
21
|
test('createProject throws when project path is missing', async () => {
|
|
18
22
|
await assert.rejects(
|
|
19
|
-
async () => createProject({ userId: TEST_USER_ID, projectPath: '' }),
|
|
23
|
+
async () => createProject({ userId: TEST_USER_ID, username: TEST_USERNAME, projectPath: '' }),
|
|
20
24
|
(error: unknown) => {
|
|
21
25
|
assert.ok(error instanceof AppError);
|
|
22
26
|
assert.equal(error.code, 'PROJECT_PATH_REQUIRED');
|
|
@@ -30,10 +34,11 @@ test('createProject throws when path validation fails', async () => {
|
|
|
30
34
|
await assert.rejects(
|
|
31
35
|
async () =>
|
|
32
36
|
createProject(
|
|
33
|
-
{ userId: TEST_USER_ID, projectPath: '/invalid/path' },
|
|
37
|
+
{ userId: TEST_USER_ID, username: TEST_USERNAME, projectPath: '/invalid/path' },
|
|
34
38
|
{
|
|
35
39
|
validatePath: async () => ({ valid: false, error: 'blocked path' }),
|
|
36
40
|
ensureWorkspaceDirectory: async () => undefined,
|
|
41
|
+
resolveUserWorkspaceRoot,
|
|
37
42
|
persistProjectPath: () => ({ outcome: 'created', project: projectRow }),
|
|
38
43
|
getProjectByPath: () => projectRow,
|
|
39
44
|
},
|
|
@@ -52,10 +57,11 @@ test('createProject throws conflict when active project path already exists', as
|
|
|
52
57
|
await assert.rejects(
|
|
53
58
|
async () =>
|
|
54
59
|
createProject(
|
|
55
|
-
{ userId: TEST_USER_ID, projectPath: '/workspace/my-project' },
|
|
60
|
+
{ userId: TEST_USER_ID, username: TEST_USERNAME, projectPath: '/workspace/tester/my-project' },
|
|
56
61
|
{
|
|
57
|
-
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
|
|
62
|
+
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/tester/my-project' }),
|
|
58
63
|
ensureWorkspaceDirectory: async () => undefined,
|
|
64
|
+
resolveUserWorkspaceRoot,
|
|
59
65
|
persistProjectPath: () => ({ outcome: 'active_conflict', project: projectRow }),
|
|
60
66
|
getProjectByPath: () => projectRow,
|
|
61
67
|
},
|
|
@@ -64,21 +70,42 @@ test('createProject throws conflict when active project path already exists', as
|
|
|
64
70
|
assert.ok(error instanceof AppError);
|
|
65
71
|
assert.equal(error.code, 'PROJECT_ALREADY_EXISTS');
|
|
66
72
|
assert.equal(error.statusCode, 409);
|
|
67
|
-
assert.equal(error.details, 'Project path already exists: /workspace/my-project');
|
|
73
|
+
assert.equal(error.details, 'Project path already exists: /workspace/tester/my-project');
|
|
68
74
|
return true;
|
|
69
75
|
},
|
|
70
76
|
);
|
|
71
77
|
});
|
|
72
78
|
|
|
79
|
+
test('createProject passes the per-user workspace root to validatePath', async () => {
|
|
80
|
+
let capturedWorkspaceRoot = '';
|
|
81
|
+
|
|
82
|
+
await createProject(
|
|
83
|
+
{ userId: TEST_USER_ID, username: TEST_USERNAME, projectPath: '/workspace/tester/my-project' },
|
|
84
|
+
{
|
|
85
|
+
validatePath: async (_projectPath, workspaceRoot) => {
|
|
86
|
+
capturedWorkspaceRoot = workspaceRoot;
|
|
87
|
+
return { valid: true, resolvedPath: '/workspace/tester/my-project' };
|
|
88
|
+
},
|
|
89
|
+
ensureWorkspaceDirectory: async () => undefined,
|
|
90
|
+
resolveUserWorkspaceRoot,
|
|
91
|
+
persistProjectPath: () => ({ outcome: 'created', project: projectRow }),
|
|
92
|
+
getProjectByPath: () => projectRow,
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
assert.equal(capturedWorkspaceRoot, TEST_USER_WORKSPACE_ROOT);
|
|
97
|
+
});
|
|
98
|
+
|
|
73
99
|
test('createProject falls back to directory name when custom name is not provided', async () => {
|
|
74
100
|
let capturedUserId = 0;
|
|
75
101
|
let capturedCustomName: string | null = null;
|
|
76
102
|
|
|
77
103
|
const result = await createProject(
|
|
78
|
-
{ userId: TEST_USER_ID, projectPath: '/workspace/my-project', customName: '' },
|
|
104
|
+
{ userId: TEST_USER_ID, username: TEST_USERNAME, projectPath: '/workspace/tester/my-project', customName: '' },
|
|
79
105
|
{
|
|
80
|
-
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
|
|
106
|
+
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/tester/my-project' }),
|
|
81
107
|
ensureWorkspaceDirectory: async () => undefined,
|
|
108
|
+
resolveUserWorkspaceRoot,
|
|
82
109
|
persistProjectPath: (userId, _projectPath, customName) => {
|
|
83
110
|
capturedUserId = userId;
|
|
84
111
|
capturedCustomName = customName;
|
|
@@ -102,10 +129,11 @@ test('createProject falls back to directory name when custom name is not provide
|
|
|
102
129
|
|
|
103
130
|
test('createProject returns archived reuse outcome when archived row is reused', async () => {
|
|
104
131
|
const result = await createProject(
|
|
105
|
-
{ userId: TEST_USER_ID, projectPath: '/workspace/my-project' },
|
|
132
|
+
{ userId: TEST_USER_ID, username: TEST_USERNAME, projectPath: '/workspace/tester/my-project' },
|
|
106
133
|
{
|
|
107
|
-
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
|
|
134
|
+
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/tester/my-project' }),
|
|
108
135
|
ensureWorkspaceDirectory: async () => undefined,
|
|
136
|
+
resolveUserWorkspaceRoot,
|
|
109
137
|
persistProjectPath: () => ({
|
|
110
138
|
outcome: 'reactivated_archived',
|
|
111
139
|
project: {
|