@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.
Files changed (51) hide show
  1. package/dist/assets/{index-u6XmIqLb.js → index-CfT-2Nkf.js} +2 -2
  2. package/dist/index.html +1 -1
  3. package/dist-server/server/cursor-cli.js +33 -15
  4. package/dist-server/server/cursor-cli.js.map +1 -1
  5. package/dist-server/server/gemini-cli.js +48 -29
  6. package/dist-server/server/gemini-cli.js.map +1 -1
  7. package/dist-server/server/index.js +20 -17
  8. package/dist-server/server/index.js.map +1 -1
  9. package/dist-server/server/modules/database/repositories/push-subscriptions.js +11 -5
  10. package/dist-server/server/modules/database/repositories/push-subscriptions.js.map +1 -1
  11. package/dist-server/server/modules/projects/projects.routes.js +14 -0
  12. package/dist-server/server/modules/projects/projects.routes.js.map +1 -1
  13. package/dist-server/server/modules/projects/services/project-authorization.service.js +54 -0
  14. package/dist-server/server/modules/projects/services/project-authorization.service.js.map +1 -0
  15. package/dist-server/server/modules/projects/services/project-clone.service.js +13 -4
  16. package/dist-server/server/modules/projects/services/project-clone.service.js.map +1 -1
  17. package/dist-server/server/modules/projects/services/project-management.service.js +4 -2
  18. package/dist-server/server/modules/projects/services/project-management.service.js.map +1 -1
  19. package/dist-server/server/modules/projects/tests/project-authorization.service.test.js +51 -0
  20. package/dist-server/server/modules/projects/tests/project-authorization.service.test.js.map +1 -0
  21. package/dist-server/server/modules/projects/tests/project-clone.service.test.js +10 -1
  22. package/dist-server/server/modules/projects/tests/project-clone.service.test.js.map +1 -1
  23. package/dist-server/server/modules/projects/tests/project-management.service.test.js +31 -10
  24. package/dist-server/server/modules/projects/tests/project-management.service.test.js.map +1 -1
  25. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +77 -13
  26. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  27. package/dist-server/server/routes/agent.js +35 -7
  28. package/dist-server/server/routes/agent.js.map +1 -1
  29. package/dist-server/server/routes/settings.js +1 -1
  30. package/dist-server/server/routes/settings.js.map +1 -1
  31. package/dist-server/server/services/notification-orchestrator.js +1 -1
  32. package/dist-server/server/services/notification-orchestrator.js.map +1 -1
  33. package/dist-server/server/shared/utils.js +60 -9
  34. package/dist-server/server/shared/utils.js.map +1 -1
  35. package/package.json +1 -1
  36. package/server/cursor-cli.js +33 -15
  37. package/server/gemini-cli.js +48 -28
  38. package/server/index.js +21 -17
  39. package/server/modules/database/repositories/push-subscriptions.ts +14 -5
  40. package/server/modules/projects/projects.routes.ts +16 -0
  41. package/server/modules/projects/services/project-authorization.service.ts +70 -0
  42. package/server/modules/projects/services/project-clone.service.ts +18 -4
  43. package/server/modules/projects/services/project-management.service.ts +12 -3
  44. package/server/modules/projects/tests/project-authorization.service.test.ts +68 -0
  45. package/server/modules/projects/tests/project-clone.service.test.ts +11 -1
  46. package/server/modules/projects/tests/project-management.service.test.ts +38 -10
  47. package/server/modules/websocket/services/chat-websocket.service.ts +87 -19
  48. package/server/routes/agent.js +34 -7
  49. package/server/routes/settings.js +1 -1
  50. package/server/services/notification-orchestrator.js +1 -1
  51. 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
- const expandWorkspacePath = (inputPath) => {
293
- if (!inputPath) return inputPath;
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 WORKSPACES_ROOT;
298
+ return root;
296
299
  }
297
300
  if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
298
- return path.join(WORKSPACES_ROOT, inputPath.slice(2));
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] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
310
- // Default to home directory if no path provided
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 - ensure path is within allowed workspace root
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
- // Add common directories if browsing home directory
358
+ // At the user's root, surface common project folders (if present) first.
356
359
  const suggestions = [];
357
- let resolvedWorkspaceRoot = defaultRoot;
360
+ let resolvedWorkspaceRoot = userWorkspaceRoot;
358
361
  try {
359
- resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
362
+ resolvedWorkspaceRoot = await fsPromises.realpath(userWorkspaceRoot);
360
363
  } catch (error) {
361
- // Use default root as-is if realpath fails
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 expandedPath = expandWorkspacePath(folderPath);
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
- /** Deletes one subscription by endpoint. */
45
- deletePushSubscription(endpoint: string): void {
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(endpoint);
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
- const pathValidation = await dependencies.validatePath(normalizedWorkspacePath);
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 { AppError, normalizeProjectPath, validateWorkspacePath } from '@/shared/utils.js';
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 pathValidation = await dependencies.validatePath(normalizedPath);
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: {