@glwhappen/web-code 1.32.0 → 1.32.2

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 (74) hide show
  1. package/dist/assets/index-C8RwnUp1.css +32 -0
  2. package/dist/assets/{index-u6XmIqLb.js → index-SzVXQJtA.js} +264 -264
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/cursor-cli.js +33 -15
  5. package/dist-server/server/cursor-cli.js.map +1 -1
  6. package/dist-server/server/gemini-cli.js +48 -29
  7. package/dist-server/server/gemini-cli.js.map +1 -1
  8. package/dist-server/server/index.js +20 -17
  9. package/dist-server/server/index.js.map +1 -1
  10. package/dist-server/server/modules/database/index.js +1 -0
  11. package/dist-server/server/modules/database/index.js.map +1 -1
  12. package/dist-server/server/modules/database/migrations.js +3 -1
  13. package/dist-server/server/modules/database/migrations.js.map +1 -1
  14. package/dist-server/server/modules/database/repositories/push-subscriptions.js +11 -5
  15. package/dist-server/server/modules/database/repositories/push-subscriptions.js.map +1 -1
  16. package/dist-server/server/modules/database/repositories/queued-messages.db.integration.test.js +73 -0
  17. package/dist-server/server/modules/database/repositories/queued-messages.db.integration.test.js.map +1 -0
  18. package/dist-server/server/modules/database/repositories/queued-messages.db.js +80 -0
  19. package/dist-server/server/modules/database/repositories/queued-messages.db.js.map +1 -0
  20. package/dist-server/server/modules/database/schema.js +18 -0
  21. package/dist-server/server/modules/database/schema.js.map +1 -1
  22. package/dist-server/server/modules/projects/index.js +1 -0
  23. package/dist-server/server/modules/projects/index.js.map +1 -1
  24. package/dist-server/server/modules/projects/projects.routes.js +14 -0
  25. package/dist-server/server/modules/projects/projects.routes.js.map +1 -1
  26. package/dist-server/server/modules/projects/services/project-authorization.service.js +54 -0
  27. package/dist-server/server/modules/projects/services/project-authorization.service.js.map +1 -0
  28. package/dist-server/server/modules/projects/services/project-clone.service.js +13 -4
  29. package/dist-server/server/modules/projects/services/project-clone.service.js.map +1 -1
  30. package/dist-server/server/modules/projects/services/project-management.service.js +4 -2
  31. package/dist-server/server/modules/projects/services/project-management.service.js.map +1 -1
  32. package/dist-server/server/modules/projects/tests/project-authorization.service.test.js +51 -0
  33. package/dist-server/server/modules/projects/tests/project-authorization.service.test.js.map +1 -0
  34. package/dist-server/server/modules/projects/tests/project-clone.service.test.js +10 -1
  35. package/dist-server/server/modules/projects/tests/project-clone.service.test.js.map +1 -1
  36. package/dist-server/server/modules/projects/tests/project-management.service.test.js +31 -10
  37. package/dist-server/server/modules/projects/tests/project-management.service.test.js.map +1 -1
  38. package/dist-server/server/modules/providers/provider.routes.js +87 -0
  39. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  40. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +77 -13
  41. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  42. package/dist-server/server/routes/agent.js +35 -7
  43. package/dist-server/server/routes/agent.js.map +1 -1
  44. package/dist-server/server/routes/settings.js +1 -1
  45. package/dist-server/server/routes/settings.js.map +1 -1
  46. package/dist-server/server/services/notification-orchestrator.js +1 -1
  47. package/dist-server/server/services/notification-orchestrator.js.map +1 -1
  48. package/dist-server/server/shared/utils.js +60 -9
  49. package/dist-server/server/shared/utils.js.map +1 -1
  50. package/package.json +1 -1
  51. package/server/cursor-cli.js +33 -15
  52. package/server/gemini-cli.js +48 -28
  53. package/server/index.js +21 -17
  54. package/server/modules/database/index.ts +1 -0
  55. package/server/modules/database/migrations.ts +3 -0
  56. package/server/modules/database/repositories/push-subscriptions.ts +14 -5
  57. package/server/modules/database/repositories/queued-messages.db.integration.test.ts +84 -0
  58. package/server/modules/database/repositories/queued-messages.db.ts +154 -0
  59. package/server/modules/database/schema.ts +19 -0
  60. package/server/modules/projects/index.ts +3 -0
  61. package/server/modules/projects/projects.routes.ts +16 -0
  62. package/server/modules/projects/services/project-authorization.service.ts +70 -0
  63. package/server/modules/projects/services/project-clone.service.ts +18 -4
  64. package/server/modules/projects/services/project-management.service.ts +12 -3
  65. package/server/modules/projects/tests/project-authorization.service.test.ts +68 -0
  66. package/server/modules/projects/tests/project-clone.service.test.ts +11 -1
  67. package/server/modules/projects/tests/project-management.service.test.ts +38 -10
  68. package/server/modules/providers/provider.routes.ts +109 -0
  69. package/server/modules/websocket/services/chat-websocket.service.ts +87 -19
  70. package/server/routes/agent.js +34 -7
  71. package/server/routes/settings.js +1 -1
  72. package/server/services/notification-orchestrator.js +1 -1
  73. package/server/shared/utils.ts +70 -9
  74. package/dist/assets/index-Ct6oPUQk.css +0 -32
@@ -0,0 +1,84 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { closeConnection } from '@/modules/database/connection.js';
8
+ import { initializeDatabase } from '@/modules/database/init-db.js';
9
+ import { queuedMessagesDb } from '@/modules/database/repositories/queued-messages.db.js';
10
+ import { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
11
+ import { userDb } from '@/modules/database/repositories/users.js';
12
+
13
+ async function withIsolatedDatabase(
14
+ runTest: (userId: number) => void | Promise<void>,
15
+ ): Promise<void> {
16
+ const previousDatabasePath = process.env.DATABASE_PATH;
17
+ const tempDirectory = await mkdtemp(path.join(tmpdir(), 'queued-messages-db-'));
18
+ const databasePath = path.join(tempDirectory, 'auth.db');
19
+
20
+ closeConnection();
21
+ process.env.DATABASE_PATH = databasePath;
22
+ await initializeDatabase();
23
+
24
+ const created = userDb.createUser('test-user', 'hash');
25
+ const userId = Number(created.id);
26
+
27
+ try {
28
+ await runTest(userId);
29
+ } finally {
30
+ closeConnection();
31
+ if (previousDatabasePath === undefined) {
32
+ delete process.env.DATABASE_PATH;
33
+ } else {
34
+ process.env.DATABASE_PATH = previousDatabasePath;
35
+ }
36
+ await rm(tempDirectory, { recursive: true, force: true });
37
+ }
38
+ }
39
+
40
+ test('queuedMessagesDb persists queued messages in insertion order', async () => {
41
+ await withIsolatedDatabase((userId) => {
42
+ sessionsDb.createSession(userId, 'session-1', 'claude', '/workspace/demo-project', 'Demo');
43
+
44
+ const first = queuedMessagesDb.create(userId, 'session-1', {
45
+ provider: 'claude',
46
+ content: 'first queued prompt',
47
+ permissionMode: 'default',
48
+ model: 'claude-sonnet',
49
+ });
50
+ const second = queuedMessagesDb.create(userId, 'session-1', {
51
+ provider: 'codex',
52
+ content: 'second queued prompt',
53
+ permissionMode: 'auto',
54
+ model: 'gpt-5.2',
55
+ });
56
+
57
+ const messages = queuedMessagesDb.listBySession(userId, 'session-1');
58
+
59
+ assert.deepEqual(messages.map((message) => message.id), [first.id, second.id]);
60
+ assert.deepEqual(messages.map((message) => message.content), ['first queued prompt', 'second queued prompt']);
61
+ });
62
+ });
63
+
64
+ test('queuedMessagesDb updates and deletes individual queued messages', async () => {
65
+ await withIsolatedDatabase((userId) => {
66
+ sessionsDb.createSession(userId, 'session-1', 'claude', '/workspace/demo-project', 'Demo');
67
+ const message = queuedMessagesDb.create(userId, 'session-1', {
68
+ provider: 'claude',
69
+ content: 'old content',
70
+ });
71
+
72
+ const updated = queuedMessagesDb.update(userId, 'session-1', message.id, {
73
+ content: 'new content',
74
+ permissionMode: 'acceptEdits',
75
+ metadata: { source: 'test' },
76
+ });
77
+
78
+ assert.equal(updated?.content, 'new content');
79
+ assert.equal(updated?.permissionMode, 'acceptEdits');
80
+ assert.deepEqual(updated?.metadata, { source: 'test' });
81
+ assert.equal(queuedMessagesDb.delete(userId, 'session-1', message.id), true);
82
+ assert.deepEqual(queuedMessagesDb.listBySession(userId, 'session-1'), []);
83
+ });
84
+ });
@@ -0,0 +1,154 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { getConnection } from '@/modules/database/connection.js';
4
+
5
+ export type QueuedMessageRow = {
6
+ id: string;
7
+ user_id: number;
8
+ session_id: string;
9
+ provider: string;
10
+ content: string;
11
+ permission_mode: string | null;
12
+ model: string | null;
13
+ metadata_json: string | null;
14
+ created_at: string;
15
+ updated_at: string;
16
+ };
17
+
18
+ export type QueuedMessage = {
19
+ id: string;
20
+ userId: number;
21
+ sessionId: string;
22
+ provider: string;
23
+ content: string;
24
+ permissionMode: string | null;
25
+ model: string | null;
26
+ metadata: unknown;
27
+ createdAt: string;
28
+ updatedAt: string;
29
+ };
30
+
31
+ type CreateQueuedMessageInput = {
32
+ provider: string;
33
+ content: string;
34
+ permissionMode?: string | null;
35
+ model?: string | null;
36
+ metadata?: unknown;
37
+ };
38
+
39
+ type UpdateQueuedMessageInput = Partial<Pick<CreateQueuedMessageInput, 'content' | 'permissionMode' | 'model' | 'metadata'>>;
40
+
41
+ function serializeMetadata(metadata: unknown): string | null {
42
+ if (metadata === undefined || metadata === null) {
43
+ return null;
44
+ }
45
+ return JSON.stringify(metadata);
46
+ }
47
+
48
+ function parseMetadata(metadataJson: string | null): unknown {
49
+ if (!metadataJson) {
50
+ return null;
51
+ }
52
+
53
+ try {
54
+ return JSON.parse(metadataJson);
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function mapQueuedMessage(row: QueuedMessageRow): QueuedMessage {
61
+ return {
62
+ id: row.id,
63
+ userId: row.user_id,
64
+ sessionId: row.session_id,
65
+ provider: row.provider,
66
+ content: row.content,
67
+ permissionMode: row.permission_mode,
68
+ model: row.model,
69
+ metadata: parseMetadata(row.metadata_json),
70
+ createdAt: row.created_at,
71
+ updatedAt: row.updated_at,
72
+ };
73
+ }
74
+
75
+ export const queuedMessagesDb = {
76
+ listBySession(userId: number, sessionId: string): QueuedMessage[] {
77
+ const db = getConnection();
78
+ const rows = db
79
+ .prepare(
80
+ `SELECT id, user_id, session_id, provider, content, permission_mode, model, metadata_json, created_at, updated_at
81
+ FROM queued_messages
82
+ WHERE user_id = ? AND session_id = ?
83
+ ORDER BY rowid ASC`
84
+ )
85
+ .all(userId, sessionId) as QueuedMessageRow[];
86
+
87
+ return rows.map(mapQueuedMessage);
88
+ },
89
+
90
+ create(userId: number, sessionId: string, input: CreateQueuedMessageInput): QueuedMessage {
91
+ const db = getConnection();
92
+ const id = randomUUID();
93
+ db.prepare(
94
+ `INSERT INTO queued_messages (id, user_id, session_id, provider, content, permission_mode, model, metadata_json)
95
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
96
+ ).run(
97
+ id,
98
+ userId,
99
+ sessionId,
100
+ input.provider,
101
+ input.content,
102
+ input.permissionMode ?? null,
103
+ input.model ?? null,
104
+ serializeMetadata(input.metadata),
105
+ );
106
+
107
+ return this.getById(userId, sessionId, id)!;
108
+ },
109
+
110
+ getById(userId: number, sessionId: string, id: string): QueuedMessage | null {
111
+ const db = getConnection();
112
+ const row = db
113
+ .prepare(
114
+ `SELECT id, user_id, session_id, provider, content, permission_mode, model, metadata_json, created_at, updated_at
115
+ FROM queued_messages
116
+ WHERE user_id = ? AND session_id = ? AND id = ?
117
+ LIMIT 1`
118
+ )
119
+ .get(userId, sessionId, id) as QueuedMessageRow | undefined;
120
+
121
+ return row ? mapQueuedMessage(row) : null;
122
+ },
123
+
124
+ update(userId: number, sessionId: string, id: string, input: UpdateQueuedMessageInput): QueuedMessage | null {
125
+ const existing = this.getById(userId, sessionId, id);
126
+ if (!existing) {
127
+ return null;
128
+ }
129
+
130
+ const db = getConnection();
131
+ db.prepare(
132
+ `UPDATE queued_messages
133
+ SET content = ?, permission_mode = ?, model = ?, metadata_json = ?, updated_at = CURRENT_TIMESTAMP
134
+ WHERE user_id = ? AND session_id = ? AND id = ?`
135
+ ).run(
136
+ input.content ?? existing.content,
137
+ input.permissionMode ?? existing.permissionMode,
138
+ input.model ?? existing.model,
139
+ input.metadata === undefined ? serializeMetadata(existing.metadata) : serializeMetadata(input.metadata),
140
+ userId,
141
+ sessionId,
142
+ id,
143
+ );
144
+
145
+ return this.getById(userId, sessionId, id);
146
+ },
147
+
148
+ delete(userId: number, sessionId: string, id: string): boolean {
149
+ const db = getConnection();
150
+ return db
151
+ .prepare('DELETE FROM queued_messages WHERE user_id = ? AND session_id = ? AND id = ?')
152
+ .run(userId, sessionId, id).changes > 0;
153
+ },
154
+ };
@@ -102,6 +102,22 @@ CREATE TABLE IF NOT EXISTS sessions (
102
102
  );
103
103
  `;
104
104
 
105
+ export const QUEUED_MESSAGES_TABLE_SCHEMA_SQL = `
106
+ CREATE TABLE IF NOT EXISTS queued_messages (
107
+ id TEXT PRIMARY KEY NOT NULL,
108
+ user_id INTEGER NOT NULL,
109
+ session_id TEXT NOT NULL,
110
+ provider TEXT NOT NULL,
111
+ content TEXT NOT NULL,
112
+ permission_mode TEXT,
113
+ model TEXT,
114
+ metadata_json TEXT,
115
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
116
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
117
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
118
+ );
119
+ `;
120
+
105
121
  export const LAST_SCANNED_AT_SQL = `
106
122
  CREATE TABLE IF NOT EXISTS scan_state (
107
123
  id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -153,6 +169,9 @@ CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id);
153
169
  -- NOTE: This index is created in migrations after sessions is rebuilt to include project_path.
154
170
  -- Creating it here can fail on upgraded installs where the legacy sessions table has no project_path.
155
171
 
172
+ ${QUEUED_MESSAGES_TABLE_SCHEMA_SQL}
173
+ CREATE INDEX IF NOT EXISTS idx_queued_messages_session ON queued_messages(user_id, session_id, created_at);
174
+
156
175
  ${LAST_SCANNED_AT_SQL}
157
176
 
158
177
  ${APP_CONFIG_TABLE_SCHEMA_SQL}
@@ -1,3 +1,6 @@
1
+ export {
2
+ assertUserOwnsProjectPath,
3
+ } from './services/project-authorization.service.js';
1
4
  export {
2
5
  generateDisplayName,
3
6
  getProjectsWithSessions,
@@ -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);