@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.
- package/dist/assets/index-C8RwnUp1.css +32 -0
- package/dist/assets/{index-u6XmIqLb.js → index-SzVXQJtA.js} +264 -264
- package/dist/index.html +2 -2
- 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/index.js +1 -0
- package/dist-server/server/modules/database/index.js.map +1 -1
- package/dist-server/server/modules/database/migrations.js +3 -1
- package/dist-server/server/modules/database/migrations.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/database/repositories/queued-messages.db.integration.test.js +73 -0
- package/dist-server/server/modules/database/repositories/queued-messages.db.integration.test.js.map +1 -0
- package/dist-server/server/modules/database/repositories/queued-messages.db.js +80 -0
- package/dist-server/server/modules/database/repositories/queued-messages.db.js.map +1 -0
- package/dist-server/server/modules/database/schema.js +18 -0
- package/dist-server/server/modules/database/schema.js.map +1 -1
- package/dist-server/server/modules/projects/index.js +1 -0
- package/dist-server/server/modules/projects/index.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/providers/provider.routes.js +87 -0
- package/dist-server/server/modules/providers/provider.routes.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/index.ts +1 -0
- package/server/modules/database/migrations.ts +3 -0
- package/server/modules/database/repositories/push-subscriptions.ts +14 -5
- package/server/modules/database/repositories/queued-messages.db.integration.test.ts +84 -0
- package/server/modules/database/repositories/queued-messages.db.ts +154 -0
- package/server/modules/database/schema.ts +19 -0
- package/server/modules/projects/index.ts +3 -0
- 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/providers/provider.routes.ts +109 -0
- 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/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}
|
|
@@ -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);
|