trickle-backend 0.1.66 → 0.1.68
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/db/cloud-migrations.js +30 -0
- package/dist/routes/cloud.js +324 -9
- package/dist/routes/codegen.js +11 -1
- package/package.json +1 -1
- package/src/db/cloud-migrations.ts +30 -0
- package/src/routes/cloud.ts +394 -10
- package/src/routes/codegen.ts +12 -1
|
@@ -58,5 +58,35 @@ function runCloudMigrations(db) {
|
|
|
58
58
|
CREATE INDEX IF NOT EXISTS idx_project_data_project ON project_data(project_id);
|
|
59
59
|
CREATE INDEX IF NOT EXISTS idx_push_history_project ON push_history(project_id);
|
|
60
60
|
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
|
|
61
|
+
|
|
62
|
+
-- Teams: group multiple API keys under one org
|
|
63
|
+
CREATE TABLE IF NOT EXISTS teams (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
name TEXT NOT NULL,
|
|
66
|
+
created_by TEXT NOT NULL,
|
|
67
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
-- Team members: links API keys to teams with roles
|
|
71
|
+
CREATE TABLE IF NOT EXISTS team_members (
|
|
72
|
+
team_id TEXT NOT NULL REFERENCES teams(id),
|
|
73
|
+
key_id TEXT NOT NULL,
|
|
74
|
+
role TEXT NOT NULL DEFAULT 'member',
|
|
75
|
+
invited_by TEXT,
|
|
76
|
+
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
77
|
+
PRIMARY KEY (team_id, key_id)
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
-- Team projects: links projects to teams for shared access
|
|
81
|
+
CREATE TABLE IF NOT EXISTS team_projects (
|
|
82
|
+
team_id TEXT NOT NULL REFERENCES teams(id),
|
|
83
|
+
project_id TEXT NOT NULL REFERENCES projects(id),
|
|
84
|
+
added_by TEXT,
|
|
85
|
+
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
86
|
+
PRIMARY KEY (team_id, project_id)
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_team_members_key ON team_members(key_id);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_team_projects_project ON team_projects(project_id);
|
|
61
91
|
`);
|
|
62
92
|
}
|
package/dist/routes/cloud.js
CHANGED
|
@@ -159,8 +159,23 @@ router.get("/pull", requireAuth, (req, res) => {
|
|
|
159
159
|
res.status(400).json({ error: "project query parameter required" });
|
|
160
160
|
return;
|
|
161
161
|
}
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
// Try own project first, then team access
|
|
163
|
+
let projectId = `${req.keyId}:${project}`;
|
|
164
|
+
let rows = connection_1.db.prepare("SELECT filename, content FROM project_data WHERE project_id = ?").all(projectId);
|
|
165
|
+
// If not found, search team projects by name
|
|
166
|
+
if (rows.length === 0) {
|
|
167
|
+
const teamProject = connection_1.db.prepare(`
|
|
168
|
+
SELECT tp.project_id
|
|
169
|
+
FROM team_projects tp
|
|
170
|
+
JOIN team_members tm ON tm.team_id = tp.team_id AND tm.key_id = ?
|
|
171
|
+
JOIN projects p ON p.id = tp.project_id AND p.name = ?
|
|
172
|
+
LIMIT 1
|
|
173
|
+
`).get(req.keyId, project);
|
|
174
|
+
if (teamProject) {
|
|
175
|
+
projectId = teamProject.project_id;
|
|
176
|
+
rows = connection_1.db.prepare("SELECT filename, content FROM project_data WHERE project_id = ?").all(projectId);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
164
179
|
if (rows.length === 0) {
|
|
165
180
|
res.status(404).json({ error: "No data found for this project" });
|
|
166
181
|
return;
|
|
@@ -173,7 +188,8 @@ router.get("/pull", requireAuth, (req, res) => {
|
|
|
173
188
|
});
|
|
174
189
|
// ── GET /api/v1/projects — List projects ──
|
|
175
190
|
router.get("/projects", requireAuth, (req, res) => {
|
|
176
|
-
|
|
191
|
+
// Own projects
|
|
192
|
+
const ownRows = connection_1.db.prepare(`
|
|
177
193
|
SELECT p.id, p.name, p.created_at, p.updated_at,
|
|
178
194
|
(SELECT COUNT(*) FROM project_data pd WHERE pd.project_id = p.id) as file_count,
|
|
179
195
|
(SELECT SUM(pd.size_bytes) FROM project_data pd WHERE pd.project_id = p.id) as total_bytes
|
|
@@ -181,16 +197,39 @@ router.get("/projects", requireAuth, (req, res) => {
|
|
|
181
197
|
WHERE p.owner_key_id = ?
|
|
182
198
|
ORDER BY p.updated_at DESC
|
|
183
199
|
`).all(req.keyId);
|
|
184
|
-
|
|
185
|
-
|
|
200
|
+
// Team projects (not owned by this key)
|
|
201
|
+
const teamRows = connection_1.db.prepare(`
|
|
202
|
+
SELECT DISTINCT p.id, p.name, p.created_at, p.updated_at, t.name as team_name,
|
|
203
|
+
(SELECT COUNT(*) FROM project_data pd WHERE pd.project_id = p.id) as file_count,
|
|
204
|
+
(SELECT SUM(pd.size_bytes) FROM project_data pd WHERE pd.project_id = p.id) as total_bytes
|
|
205
|
+
FROM team_projects tp
|
|
206
|
+
JOIN team_members tm ON tm.team_id = tp.team_id AND tm.key_id = ?
|
|
207
|
+
JOIN projects p ON p.id = tp.project_id AND p.owner_key_id != ?
|
|
208
|
+
JOIN teams t ON t.id = tp.team_id
|
|
209
|
+
ORDER BY p.updated_at DESC
|
|
210
|
+
`).all(req.keyId, req.keyId);
|
|
211
|
+
const projects = ownRows.map((r) => ({
|
|
212
|
+
id: r.id,
|
|
213
|
+
name: r.name,
|
|
214
|
+
files: r.file_count || 0,
|
|
215
|
+
size: r.total_bytes || 0,
|
|
216
|
+
createdAt: r.created_at,
|
|
217
|
+
updatedAt: r.updated_at,
|
|
218
|
+
owned: true,
|
|
219
|
+
}));
|
|
220
|
+
for (const r of teamRows) {
|
|
221
|
+
projects.push({
|
|
186
222
|
id: r.id,
|
|
187
223
|
name: r.name,
|
|
188
224
|
files: r.file_count || 0,
|
|
189
225
|
size: r.total_bytes || 0,
|
|
190
226
|
createdAt: r.created_at,
|
|
191
227
|
updatedAt: r.updated_at,
|
|
192
|
-
|
|
193
|
-
|
|
228
|
+
owned: false,
|
|
229
|
+
team: r.team_name,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
res.json({ projects });
|
|
194
233
|
});
|
|
195
234
|
// ── POST /api/v1/projects — Create project ──
|
|
196
235
|
router.post("/projects", requireAuth, (req, res) => {
|
|
@@ -264,8 +303,15 @@ router.get("/shared/:id", (req, res) => {
|
|
|
264
303
|
// ── GET /api/v1/dashboard/:projectId — Authenticated dashboard ──
|
|
265
304
|
router.get("/dashboard/:projectId", requireAuth, (req, res) => {
|
|
266
305
|
const projectId = decodeURIComponent(req.params.projectId);
|
|
267
|
-
// Verify ownership
|
|
268
|
-
|
|
306
|
+
// Verify ownership or team access
|
|
307
|
+
let proj = connection_1.db.prepare("SELECT name FROM projects WHERE id = ? AND owner_key_id = ?").get(projectId, req.keyId);
|
|
308
|
+
if (!proj) {
|
|
309
|
+
// Check team access
|
|
310
|
+
const teamAccess = hasTeamAccess(projectId, req.keyId);
|
|
311
|
+
if (teamAccess) {
|
|
312
|
+
proj = connection_1.db.prepare("SELECT name FROM projects WHERE id = ?").get(projectId);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
269
315
|
if (!proj) {
|
|
270
316
|
res.status(404).json({ error: "Project not found" });
|
|
271
317
|
return;
|
|
@@ -281,6 +327,275 @@ router.get("/dashboard/:projectId", requireAuth, (req, res) => {
|
|
|
281
327
|
}
|
|
282
328
|
res.json({ project: proj.name, files, fileCount: rows.length });
|
|
283
329
|
});
|
|
330
|
+
const ROLE_RANK = { owner: 4, admin: 3, member: 2, viewer: 1 };
|
|
331
|
+
function getTeamRole(teamId, keyId) {
|
|
332
|
+
const row = connection_1.db.prepare("SELECT role FROM team_members WHERE team_id = ? AND key_id = ?").get(teamId, keyId);
|
|
333
|
+
return row ? row.role : null;
|
|
334
|
+
}
|
|
335
|
+
function requireTeamRole(teamId, keyId, minRole) {
|
|
336
|
+
const role = getTeamRole(teamId, keyId);
|
|
337
|
+
if (!role)
|
|
338
|
+
return null;
|
|
339
|
+
if (ROLE_RANK[role] < ROLE_RANK[minRole])
|
|
340
|
+
return null;
|
|
341
|
+
return role;
|
|
342
|
+
}
|
|
343
|
+
// ── POST /api/v1/teams — Create a team ──
|
|
344
|
+
router.post("/teams", requireAuth, (req, res) => {
|
|
345
|
+
const { name } = req.body;
|
|
346
|
+
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
|
347
|
+
res.status(400).json({ error: "team name required" });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const teamId = generateId();
|
|
351
|
+
const keyId = req.keyId;
|
|
352
|
+
connection_1.db.transaction(() => {
|
|
353
|
+
connection_1.db.prepare("INSERT INTO teams (id, name, created_by) VALUES (?, ?, ?)").run(teamId, name.trim(), keyId);
|
|
354
|
+
connection_1.db.prepare("INSERT INTO team_members (team_id, key_id, role, invited_by) VALUES (?, ?, 'owner', ?)").run(teamId, keyId, keyId);
|
|
355
|
+
})();
|
|
356
|
+
res.status(201).json({ id: teamId, name: name.trim(), role: "owner" });
|
|
357
|
+
});
|
|
358
|
+
// ── GET /api/v1/teams — List teams for current user ──
|
|
359
|
+
router.get("/teams", requireAuth, (req, res) => {
|
|
360
|
+
const rows = connection_1.db.prepare(`
|
|
361
|
+
SELECT t.id, t.name, t.created_at, tm.role,
|
|
362
|
+
(SELECT COUNT(*) FROM team_members tm2 WHERE tm2.team_id = t.id) as member_count,
|
|
363
|
+
(SELECT COUNT(*) FROM team_projects tp WHERE tp.team_id = t.id) as project_count
|
|
364
|
+
FROM teams t
|
|
365
|
+
JOIN team_members tm ON tm.team_id = t.id AND tm.key_id = ?
|
|
366
|
+
ORDER BY t.name
|
|
367
|
+
`).all(req.keyId);
|
|
368
|
+
res.json({
|
|
369
|
+
teams: rows.map((r) => ({
|
|
370
|
+
id: r.id,
|
|
371
|
+
name: r.name,
|
|
372
|
+
role: r.role,
|
|
373
|
+
members: r.member_count,
|
|
374
|
+
projects: r.project_count,
|
|
375
|
+
createdAt: r.created_at,
|
|
376
|
+
})),
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
// ── GET /api/v1/teams/:id — Get team details ──
|
|
380
|
+
router.get("/teams/:id", requireAuth, (req, res) => {
|
|
381
|
+
const teamId = req.params.id;
|
|
382
|
+
const role = getTeamRole(teamId, req.keyId);
|
|
383
|
+
if (!role) {
|
|
384
|
+
res.status(404).json({ error: "Team not found" });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const team = connection_1.db.prepare("SELECT id, name, created_at FROM teams WHERE id = ?").get(teamId);
|
|
388
|
+
if (!team) {
|
|
389
|
+
res.status(404).json({ error: "Team not found" });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const members = connection_1.db.prepare(`
|
|
393
|
+
SELECT tm.key_id, tm.role, tm.joined_at, ak.key_prefix, ak.name as key_name, ak.owner_email
|
|
394
|
+
FROM team_members tm
|
|
395
|
+
JOIN api_keys ak ON ak.id = tm.key_id
|
|
396
|
+
WHERE tm.team_id = ?
|
|
397
|
+
ORDER BY tm.joined_at
|
|
398
|
+
`).all(teamId);
|
|
399
|
+
const projects = connection_1.db.prepare(`
|
|
400
|
+
SELECT tp.project_id, p.name, p.updated_at,
|
|
401
|
+
(SELECT SUM(pd.size_bytes) FROM project_data pd WHERE pd.project_id = p.id) as total_bytes
|
|
402
|
+
FROM team_projects tp
|
|
403
|
+
JOIN projects p ON p.id = tp.project_id
|
|
404
|
+
WHERE tp.team_id = ?
|
|
405
|
+
ORDER BY p.updated_at DESC
|
|
406
|
+
`).all(teamId);
|
|
407
|
+
res.json({
|
|
408
|
+
id: team.id,
|
|
409
|
+
name: team.name,
|
|
410
|
+
role,
|
|
411
|
+
createdAt: team.created_at,
|
|
412
|
+
members: members.map((m) => ({
|
|
413
|
+
keyId: m.key_id,
|
|
414
|
+
keyPrefix: m.key_prefix,
|
|
415
|
+
keyName: m.key_name,
|
|
416
|
+
email: m.owner_email,
|
|
417
|
+
role: m.role,
|
|
418
|
+
joinedAt: m.joined_at,
|
|
419
|
+
})),
|
|
420
|
+
projects: projects.map((p) => ({
|
|
421
|
+
id: p.project_id,
|
|
422
|
+
name: p.name,
|
|
423
|
+
size: p.total_bytes || 0,
|
|
424
|
+
updatedAt: p.updated_at,
|
|
425
|
+
})),
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
// ── POST /api/v1/teams/:id/members — Add a member (invite) ──
|
|
429
|
+
router.post("/teams/:id/members", requireAuth, (req, res) => {
|
|
430
|
+
const teamId = req.params.id;
|
|
431
|
+
const callerRole = requireTeamRole(teamId, req.keyId, "admin");
|
|
432
|
+
if (!callerRole) {
|
|
433
|
+
res.status(403).json({ error: "Must be admin or owner to invite members" });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const { keyId, role } = req.body;
|
|
437
|
+
if (!keyId) {
|
|
438
|
+
res.status(400).json({ error: "keyId required — the API key ID of the member to add" });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const memberRole = (role || "member");
|
|
442
|
+
if (!ROLE_RANK[memberRole]) {
|
|
443
|
+
res.status(400).json({ error: "Invalid role. Must be: owner, admin, member, or viewer" });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
// Cannot assign role >= your own (except owner can do anything)
|
|
447
|
+
if (callerRole !== "owner" && ROLE_RANK[memberRole] >= ROLE_RANK[callerRole]) {
|
|
448
|
+
res.status(403).json({ error: "Cannot assign a role equal to or higher than your own" });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
// Verify the key exists
|
|
452
|
+
const key = connection_1.db.prepare("SELECT id, key_prefix, name FROM api_keys WHERE id = ? AND revoked = 0").get(keyId);
|
|
453
|
+
if (!key) {
|
|
454
|
+
res.status(404).json({ error: "API key not found or revoked" });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
connection_1.db.prepare("INSERT INTO team_members (team_id, key_id, role, invited_by) VALUES (?, ?, ?, ?)").run(teamId, keyId, memberRole, req.keyId);
|
|
459
|
+
res.status(201).json({
|
|
460
|
+
teamId,
|
|
461
|
+
keyId,
|
|
462
|
+
keyPrefix: key.key_prefix,
|
|
463
|
+
role: memberRole,
|
|
464
|
+
message: `Added ${key.name} (${key.key_prefix}...) as ${memberRole}`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
if (err.message?.includes("UNIQUE")) {
|
|
469
|
+
res.status(409).json({ error: "Member already in team" });
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
// ── PATCH /api/v1/teams/:id/members/:keyId — Change member role ──
|
|
477
|
+
router.patch("/teams/:id/members/:keyId", requireAuth, (req, res) => {
|
|
478
|
+
const teamId = req.params.id;
|
|
479
|
+
const targetKeyId = req.params.keyId;
|
|
480
|
+
const callerRole = requireTeamRole(teamId, req.keyId, "admin");
|
|
481
|
+
if (!callerRole) {
|
|
482
|
+
res.status(403).json({ error: "Must be admin or owner to change roles" });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const { role } = req.body;
|
|
486
|
+
if (!role || !ROLE_RANK[role]) {
|
|
487
|
+
res.status(400).json({ error: "Invalid role. Must be: owner, admin, member, or viewer" });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const newRole = role;
|
|
491
|
+
// Cannot change someone with >= your role (unless you're owner)
|
|
492
|
+
const targetRole = getTeamRole(teamId, targetKeyId);
|
|
493
|
+
if (!targetRole) {
|
|
494
|
+
res.status(404).json({ error: "Member not found in team" });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (callerRole !== "owner") {
|
|
498
|
+
if (ROLE_RANK[targetRole] >= ROLE_RANK[callerRole]) {
|
|
499
|
+
res.status(403).json({ error: "Cannot change role of someone with equal or higher rank" });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (ROLE_RANK[newRole] >= ROLE_RANK[callerRole]) {
|
|
503
|
+
res.status(403).json({ error: "Cannot promote to equal or higher than your own role" });
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
connection_1.db.prepare("UPDATE team_members SET role = ? WHERE team_id = ? AND key_id = ?").run(newRole, teamId, targetKeyId);
|
|
508
|
+
res.json({ teamId, keyId: targetKeyId, role: newRole });
|
|
509
|
+
});
|
|
510
|
+
// ── DELETE /api/v1/teams/:id/members/:keyId — Remove member ──
|
|
511
|
+
router.delete("/teams/:id/members/:keyId", requireAuth, (req, res) => {
|
|
512
|
+
const teamId = req.params.id;
|
|
513
|
+
const targetKeyId = req.params.keyId;
|
|
514
|
+
// Members can remove themselves; admins+ can remove others
|
|
515
|
+
const callerRole = getTeamRole(teamId, req.keyId);
|
|
516
|
+
if (!callerRole) {
|
|
517
|
+
res.status(403).json({ error: "Not a member of this team" });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const isSelf = targetKeyId === req.keyId;
|
|
521
|
+
if (!isSelf) {
|
|
522
|
+
if (ROLE_RANK[callerRole] < ROLE_RANK["admin"]) {
|
|
523
|
+
res.status(403).json({ error: "Must be admin or owner to remove other members" });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const targetRole = getTeamRole(teamId, targetKeyId);
|
|
527
|
+
if (targetRole && callerRole !== "owner" && ROLE_RANK[targetRole] >= ROLE_RANK[callerRole]) {
|
|
528
|
+
res.status(403).json({ error: "Cannot remove someone with equal or higher rank" });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// Cannot remove the last owner
|
|
533
|
+
if (isSelf && callerRole === "owner") {
|
|
534
|
+
const ownerCount = connection_1.db.prepare("SELECT COUNT(*) as c FROM team_members WHERE team_id = ? AND role = 'owner'").get(teamId);
|
|
535
|
+
if (ownerCount.c <= 1) {
|
|
536
|
+
res.status(400).json({ error: "Cannot leave — you are the last owner. Transfer ownership first." });
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
connection_1.db.prepare("DELETE FROM team_members WHERE team_id = ? AND key_id = ?").run(teamId, targetKeyId);
|
|
541
|
+
res.json({ ok: true, message: isSelf ? "Left team" : "Member removed" });
|
|
542
|
+
});
|
|
543
|
+
// ── POST /api/v1/teams/:id/projects — Add project to team ──
|
|
544
|
+
router.post("/teams/:id/projects", requireAuth, (req, res) => {
|
|
545
|
+
const teamId = req.params.id;
|
|
546
|
+
const callerRole = requireTeamRole(teamId, req.keyId, "member");
|
|
547
|
+
if (!callerRole) {
|
|
548
|
+
res.status(403).json({ error: "Must be a team member to add projects" });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const { project } = req.body;
|
|
552
|
+
if (!project) {
|
|
553
|
+
res.status(400).json({ error: "project name required" });
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
// Verify project exists and caller owns it
|
|
557
|
+
const projectId = `${req.keyId}:${project}`;
|
|
558
|
+
const proj = connection_1.db.prepare("SELECT id FROM projects WHERE id = ? AND owner_key_id = ?").get(projectId, req.keyId);
|
|
559
|
+
if (!proj) {
|
|
560
|
+
res.status(404).json({ error: "Project not found or you don't own it" });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
connection_1.db.prepare("INSERT INTO team_projects (team_id, project_id, added_by) VALUES (?, ?, ?)").run(teamId, projectId, req.keyId);
|
|
565
|
+
res.status(201).json({ teamId, projectId, project });
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
if (err.message?.includes("UNIQUE")) {
|
|
569
|
+
res.status(409).json({ error: "Project already in team" });
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
throw err;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
// ── DELETE /api/v1/teams/:id/projects/:projectId — Remove project from team ──
|
|
577
|
+
router.delete("/teams/:id/projects/:projectId", requireAuth, (req, res) => {
|
|
578
|
+
const teamId = req.params.id;
|
|
579
|
+
const callerRole = requireTeamRole(teamId, req.keyId, "admin");
|
|
580
|
+
if (!callerRole) {
|
|
581
|
+
res.status(403).json({ error: "Must be admin or owner to remove projects" });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const projectId = decodeURIComponent(req.params.projectId);
|
|
585
|
+
connection_1.db.prepare("DELETE FROM team_projects WHERE team_id = ? AND project_id = ?").run(teamId, projectId);
|
|
586
|
+
res.json({ ok: true });
|
|
587
|
+
});
|
|
588
|
+
// ── Helper: check if keyId has team access to a project ──
|
|
589
|
+
function hasTeamAccess(projectId, keyId) {
|
|
590
|
+
const row = connection_1.db.prepare(`
|
|
591
|
+
SELECT tp.team_id, tm.role
|
|
592
|
+
FROM team_projects tp
|
|
593
|
+
JOIN team_members tm ON tm.team_id = tp.team_id AND tm.key_id = ?
|
|
594
|
+
WHERE tp.project_id = ?
|
|
595
|
+
LIMIT 1
|
|
596
|
+
`).get(keyId, projectId);
|
|
597
|
+
return row ? { teamId: row.team_id, role: row.role } : null;
|
|
598
|
+
}
|
|
284
599
|
// ── Dashboard HTML generator ──
|
|
285
600
|
function generateDashboardHtml(projectName, files) {
|
|
286
601
|
// Parse data files
|
package/dist/routes/codegen.js
CHANGED
|
@@ -52,7 +52,17 @@ function collectFunctionTypes(opts) {
|
|
|
52
52
|
sampleOutput: snapshot.sample_output ? tryParseJson(snapshot.sample_output) : undefined,
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
// Deduplicate by function name — when the same endpoint is observed
|
|
56
|
+
// across different modules/languages/sessions, keep only the most
|
|
57
|
+
// recently observed entry to avoid duplicate declarations in codegen.
|
|
58
|
+
const deduped = new Map();
|
|
59
|
+
for (const entry of results) {
|
|
60
|
+
const existing = deduped.get(entry.name);
|
|
61
|
+
if (!existing || (entry.observedAt && (!existing.observedAt || entry.observedAt > existing.observedAt))) {
|
|
62
|
+
deduped.set(entry.name, entry);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return Array.from(deduped.values());
|
|
56
66
|
}
|
|
57
67
|
// GET / — generate types for all (or filtered) functions
|
|
58
68
|
router.get("/", (req, res) => {
|
package/package.json
CHANGED
|
@@ -57,5 +57,35 @@ export function runCloudMigrations(db: Database.Database): void {
|
|
|
57
57
|
CREATE INDEX IF NOT EXISTS idx_project_data_project ON project_data(project_id);
|
|
58
58
|
CREATE INDEX IF NOT EXISTS idx_push_history_project ON push_history(project_id);
|
|
59
59
|
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
|
|
60
|
+
|
|
61
|
+
-- Teams: group multiple API keys under one org
|
|
62
|
+
CREATE TABLE IF NOT EXISTS teams (
|
|
63
|
+
id TEXT PRIMARY KEY,
|
|
64
|
+
name TEXT NOT NULL,
|
|
65
|
+
created_by TEXT NOT NULL,
|
|
66
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
-- Team members: links API keys to teams with roles
|
|
70
|
+
CREATE TABLE IF NOT EXISTS team_members (
|
|
71
|
+
team_id TEXT NOT NULL REFERENCES teams(id),
|
|
72
|
+
key_id TEXT NOT NULL,
|
|
73
|
+
role TEXT NOT NULL DEFAULT 'member',
|
|
74
|
+
invited_by TEXT,
|
|
75
|
+
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
76
|
+
PRIMARY KEY (team_id, key_id)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
-- Team projects: links projects to teams for shared access
|
|
80
|
+
CREATE TABLE IF NOT EXISTS team_projects (
|
|
81
|
+
team_id TEXT NOT NULL REFERENCES teams(id),
|
|
82
|
+
project_id TEXT NOT NULL REFERENCES projects(id),
|
|
83
|
+
added_by TEXT,
|
|
84
|
+
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
85
|
+
PRIMARY KEY (team_id, project_id)
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_team_members_key ON team_members(key_id);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_team_projects_project ON team_projects(project_id);
|
|
60
90
|
`);
|
|
61
91
|
}
|
package/src/routes/cloud.ts
CHANGED
|
@@ -208,12 +208,30 @@ router.get("/pull", requireAuth, (req: AuthedRequest, res: Response) => {
|
|
|
208
208
|
return;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
211
|
+
// Try own project first, then team access
|
|
212
|
+
let projectId = `${req.keyId}:${project}`;
|
|
213
|
+
let rows = db.prepare(
|
|
214
214
|
"SELECT filename, content FROM project_data WHERE project_id = ?"
|
|
215
215
|
).all(projectId) as any[];
|
|
216
216
|
|
|
217
|
+
// If not found, search team projects by name
|
|
218
|
+
if (rows.length === 0) {
|
|
219
|
+
const teamProject = db.prepare(`
|
|
220
|
+
SELECT tp.project_id
|
|
221
|
+
FROM team_projects tp
|
|
222
|
+
JOIN team_members tm ON tm.team_id = tp.team_id AND tm.key_id = ?
|
|
223
|
+
JOIN projects p ON p.id = tp.project_id AND p.name = ?
|
|
224
|
+
LIMIT 1
|
|
225
|
+
`).get(req.keyId, project) as any;
|
|
226
|
+
|
|
227
|
+
if (teamProject) {
|
|
228
|
+
projectId = teamProject.project_id;
|
|
229
|
+
rows = db.prepare(
|
|
230
|
+
"SELECT filename, content FROM project_data WHERE project_id = ?"
|
|
231
|
+
).all(projectId) as any[];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
217
235
|
if (rows.length === 0) {
|
|
218
236
|
res.status(404).json({ error: "No data found for this project" });
|
|
219
237
|
return;
|
|
@@ -230,7 +248,8 @@ router.get("/pull", requireAuth, (req: AuthedRequest, res: Response) => {
|
|
|
230
248
|
// ── GET /api/v1/projects — List projects ──
|
|
231
249
|
|
|
232
250
|
router.get("/projects", requireAuth, (req: AuthedRequest, res: Response) => {
|
|
233
|
-
|
|
251
|
+
// Own projects
|
|
252
|
+
const ownRows = db.prepare(`
|
|
234
253
|
SELECT p.id, p.name, p.created_at, p.updated_at,
|
|
235
254
|
(SELECT COUNT(*) FROM project_data pd WHERE pd.project_id = p.id) as file_count,
|
|
236
255
|
(SELECT SUM(pd.size_bytes) FROM project_data pd WHERE pd.project_id = p.id) as total_bytes
|
|
@@ -239,16 +258,42 @@ router.get("/projects", requireAuth, (req: AuthedRequest, res: Response) => {
|
|
|
239
258
|
ORDER BY p.updated_at DESC
|
|
240
259
|
`).all(req.keyId) as any[];
|
|
241
260
|
|
|
242
|
-
|
|
243
|
-
|
|
261
|
+
// Team projects (not owned by this key)
|
|
262
|
+
const teamRows = db.prepare(`
|
|
263
|
+
SELECT DISTINCT p.id, p.name, p.created_at, p.updated_at, t.name as team_name,
|
|
264
|
+
(SELECT COUNT(*) FROM project_data pd WHERE pd.project_id = p.id) as file_count,
|
|
265
|
+
(SELECT SUM(pd.size_bytes) FROM project_data pd WHERE pd.project_id = p.id) as total_bytes
|
|
266
|
+
FROM team_projects tp
|
|
267
|
+
JOIN team_members tm ON tm.team_id = tp.team_id AND tm.key_id = ?
|
|
268
|
+
JOIN projects p ON p.id = tp.project_id AND p.owner_key_id != ?
|
|
269
|
+
JOIN teams t ON t.id = tp.team_id
|
|
270
|
+
ORDER BY p.updated_at DESC
|
|
271
|
+
`).all(req.keyId, req.keyId) as any[];
|
|
272
|
+
|
|
273
|
+
const projects = ownRows.map((r: any) => ({
|
|
274
|
+
id: r.id,
|
|
275
|
+
name: r.name,
|
|
276
|
+
files: r.file_count || 0,
|
|
277
|
+
size: r.total_bytes || 0,
|
|
278
|
+
createdAt: r.created_at,
|
|
279
|
+
updatedAt: r.updated_at,
|
|
280
|
+
owned: true,
|
|
281
|
+
}));
|
|
282
|
+
|
|
283
|
+
for (const r of teamRows) {
|
|
284
|
+
projects.push({
|
|
244
285
|
id: r.id,
|
|
245
286
|
name: r.name,
|
|
246
287
|
files: r.file_count || 0,
|
|
247
288
|
size: r.total_bytes || 0,
|
|
248
289
|
createdAt: r.created_at,
|
|
249
290
|
updatedAt: r.updated_at,
|
|
250
|
-
|
|
251
|
-
|
|
291
|
+
owned: false,
|
|
292
|
+
team: r.team_name,
|
|
293
|
+
} as any);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
res.json({ projects });
|
|
252
297
|
});
|
|
253
298
|
|
|
254
299
|
// ── POST /api/v1/projects — Create project ──
|
|
@@ -350,11 +395,19 @@ router.get("/shared/:id", (req: Request, res: Response) => {
|
|
|
350
395
|
router.get("/dashboard/:projectId", requireAuth, (req: AuthedRequest, res: Response) => {
|
|
351
396
|
const projectId = decodeURIComponent(req.params.projectId);
|
|
352
397
|
|
|
353
|
-
// Verify ownership
|
|
354
|
-
|
|
398
|
+
// Verify ownership or team access
|
|
399
|
+
let proj = db.prepare(
|
|
355
400
|
"SELECT name FROM projects WHERE id = ? AND owner_key_id = ?"
|
|
356
401
|
).get(projectId, req.keyId) as any;
|
|
357
402
|
|
|
403
|
+
if (!proj) {
|
|
404
|
+
// Check team access
|
|
405
|
+
const teamAccess = hasTeamAccess(projectId, req.keyId!);
|
|
406
|
+
if (teamAccess) {
|
|
407
|
+
proj = db.prepare("SELECT name FROM projects WHERE id = ?").get(projectId) as any;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
358
411
|
if (!proj) {
|
|
359
412
|
res.status(404).json({ error: "Project not found" });
|
|
360
413
|
return;
|
|
@@ -377,6 +430,337 @@ router.get("/dashboard/:projectId", requireAuth, (req: AuthedRequest, res: Respo
|
|
|
377
430
|
res.json({ project: proj.name, files, fileCount: rows.length });
|
|
378
431
|
});
|
|
379
432
|
|
|
433
|
+
// ── Team RBAC helpers ──
|
|
434
|
+
|
|
435
|
+
type TeamRole = "owner" | "admin" | "member" | "viewer";
|
|
436
|
+
|
|
437
|
+
const ROLE_RANK: Record<TeamRole, number> = { owner: 4, admin: 3, member: 2, viewer: 1 };
|
|
438
|
+
|
|
439
|
+
function getTeamRole(teamId: string, keyId: string): TeamRole | null {
|
|
440
|
+
const row = db.prepare(
|
|
441
|
+
"SELECT role FROM team_members WHERE team_id = ? AND key_id = ?"
|
|
442
|
+
).get(teamId, keyId) as any;
|
|
443
|
+
return row ? row.role as TeamRole : null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function requireTeamRole(teamId: string, keyId: string, minRole: TeamRole): TeamRole | null {
|
|
447
|
+
const role = getTeamRole(teamId, keyId);
|
|
448
|
+
if (!role) return null;
|
|
449
|
+
if (ROLE_RANK[role] < ROLE_RANK[minRole]) return null;
|
|
450
|
+
return role;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── POST /api/v1/teams — Create a team ──
|
|
454
|
+
|
|
455
|
+
router.post("/teams", requireAuth, (req: AuthedRequest, res: Response) => {
|
|
456
|
+
const { name } = req.body;
|
|
457
|
+
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
|
458
|
+
res.status(400).json({ error: "team name required" });
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const teamId = generateId();
|
|
463
|
+
const keyId = req.keyId!;
|
|
464
|
+
|
|
465
|
+
db.transaction(() => {
|
|
466
|
+
db.prepare(
|
|
467
|
+
"INSERT INTO teams (id, name, created_by) VALUES (?, ?, ?)"
|
|
468
|
+
).run(teamId, name.trim(), keyId);
|
|
469
|
+
|
|
470
|
+
db.prepare(
|
|
471
|
+
"INSERT INTO team_members (team_id, key_id, role, invited_by) VALUES (?, ?, 'owner', ?)"
|
|
472
|
+
).run(teamId, keyId, keyId);
|
|
473
|
+
})();
|
|
474
|
+
|
|
475
|
+
res.status(201).json({ id: teamId, name: name.trim(), role: "owner" });
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// ── GET /api/v1/teams — List teams for current user ──
|
|
479
|
+
|
|
480
|
+
router.get("/teams", requireAuth, (req: AuthedRequest, res: Response) => {
|
|
481
|
+
const rows = db.prepare(`
|
|
482
|
+
SELECT t.id, t.name, t.created_at, tm.role,
|
|
483
|
+
(SELECT COUNT(*) FROM team_members tm2 WHERE tm2.team_id = t.id) as member_count,
|
|
484
|
+
(SELECT COUNT(*) FROM team_projects tp WHERE tp.team_id = t.id) as project_count
|
|
485
|
+
FROM teams t
|
|
486
|
+
JOIN team_members tm ON tm.team_id = t.id AND tm.key_id = ?
|
|
487
|
+
ORDER BY t.name
|
|
488
|
+
`).all(req.keyId) as any[];
|
|
489
|
+
|
|
490
|
+
res.json({
|
|
491
|
+
teams: rows.map((r: any) => ({
|
|
492
|
+
id: r.id,
|
|
493
|
+
name: r.name,
|
|
494
|
+
role: r.role,
|
|
495
|
+
members: r.member_count,
|
|
496
|
+
projects: r.project_count,
|
|
497
|
+
createdAt: r.created_at,
|
|
498
|
+
})),
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// ── GET /api/v1/teams/:id — Get team details ──
|
|
503
|
+
|
|
504
|
+
router.get("/teams/:id", requireAuth, (req: AuthedRequest, res: Response) => {
|
|
505
|
+
const teamId = req.params.id;
|
|
506
|
+
const role = getTeamRole(teamId, req.keyId!);
|
|
507
|
+
if (!role) {
|
|
508
|
+
res.status(404).json({ error: "Team not found" });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const team = db.prepare("SELECT id, name, created_at FROM teams WHERE id = ?").get(teamId) as any;
|
|
513
|
+
if (!team) {
|
|
514
|
+
res.status(404).json({ error: "Team not found" });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const members = db.prepare(`
|
|
519
|
+
SELECT tm.key_id, tm.role, tm.joined_at, ak.key_prefix, ak.name as key_name, ak.owner_email
|
|
520
|
+
FROM team_members tm
|
|
521
|
+
JOIN api_keys ak ON ak.id = tm.key_id
|
|
522
|
+
WHERE tm.team_id = ?
|
|
523
|
+
ORDER BY tm.joined_at
|
|
524
|
+
`).all(teamId) as any[];
|
|
525
|
+
|
|
526
|
+
const projects = db.prepare(`
|
|
527
|
+
SELECT tp.project_id, p.name, p.updated_at,
|
|
528
|
+
(SELECT SUM(pd.size_bytes) FROM project_data pd WHERE pd.project_id = p.id) as total_bytes
|
|
529
|
+
FROM team_projects tp
|
|
530
|
+
JOIN projects p ON p.id = tp.project_id
|
|
531
|
+
WHERE tp.team_id = ?
|
|
532
|
+
ORDER BY p.updated_at DESC
|
|
533
|
+
`).all(teamId) as any[];
|
|
534
|
+
|
|
535
|
+
res.json({
|
|
536
|
+
id: team.id,
|
|
537
|
+
name: team.name,
|
|
538
|
+
role,
|
|
539
|
+
createdAt: team.created_at,
|
|
540
|
+
members: members.map((m: any) => ({
|
|
541
|
+
keyId: m.key_id,
|
|
542
|
+
keyPrefix: m.key_prefix,
|
|
543
|
+
keyName: m.key_name,
|
|
544
|
+
email: m.owner_email,
|
|
545
|
+
role: m.role,
|
|
546
|
+
joinedAt: m.joined_at,
|
|
547
|
+
})),
|
|
548
|
+
projects: projects.map((p: any) => ({
|
|
549
|
+
id: p.project_id,
|
|
550
|
+
name: p.name,
|
|
551
|
+
size: p.total_bytes || 0,
|
|
552
|
+
updatedAt: p.updated_at,
|
|
553
|
+
})),
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// ── POST /api/v1/teams/:id/members — Add a member (invite) ──
|
|
558
|
+
|
|
559
|
+
router.post("/teams/:id/members", requireAuth, (req: AuthedRequest, res: Response) => {
|
|
560
|
+
const teamId = req.params.id;
|
|
561
|
+
const callerRole = requireTeamRole(teamId, req.keyId!, "admin");
|
|
562
|
+
if (!callerRole) {
|
|
563
|
+
res.status(403).json({ error: "Must be admin or owner to invite members" });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const { keyId, role } = req.body;
|
|
568
|
+
if (!keyId) {
|
|
569
|
+
res.status(400).json({ error: "keyId required — the API key ID of the member to add" });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const memberRole = (role || "member") as TeamRole;
|
|
574
|
+
if (!ROLE_RANK[memberRole]) {
|
|
575
|
+
res.status(400).json({ error: "Invalid role. Must be: owner, admin, member, or viewer" });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Cannot assign role >= your own (except owner can do anything)
|
|
580
|
+
if (callerRole !== "owner" && ROLE_RANK[memberRole] >= ROLE_RANK[callerRole]) {
|
|
581
|
+
res.status(403).json({ error: "Cannot assign a role equal to or higher than your own" });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Verify the key exists
|
|
586
|
+
const key = db.prepare("SELECT id, key_prefix, name FROM api_keys WHERE id = ? AND revoked = 0").get(keyId) as any;
|
|
587
|
+
if (!key) {
|
|
588
|
+
res.status(404).json({ error: "API key not found or revoked" });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
try {
|
|
593
|
+
db.prepare(
|
|
594
|
+
"INSERT INTO team_members (team_id, key_id, role, invited_by) VALUES (?, ?, ?, ?)"
|
|
595
|
+
).run(teamId, keyId, memberRole, req.keyId);
|
|
596
|
+
|
|
597
|
+
res.status(201).json({
|
|
598
|
+
teamId,
|
|
599
|
+
keyId,
|
|
600
|
+
keyPrefix: key.key_prefix,
|
|
601
|
+
role: memberRole,
|
|
602
|
+
message: `Added ${key.name} (${key.key_prefix}...) as ${memberRole}`,
|
|
603
|
+
});
|
|
604
|
+
} catch (err: any) {
|
|
605
|
+
if (err.message?.includes("UNIQUE")) {
|
|
606
|
+
res.status(409).json({ error: "Member already in team" });
|
|
607
|
+
} else {
|
|
608
|
+
throw err;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// ── PATCH /api/v1/teams/:id/members/:keyId — Change member role ──
|
|
614
|
+
|
|
615
|
+
router.patch("/teams/:id/members/:keyId", requireAuth, (req: AuthedRequest, res: Response) => {
|
|
616
|
+
const teamId = req.params.id;
|
|
617
|
+
const targetKeyId = req.params.keyId;
|
|
618
|
+
const callerRole = requireTeamRole(teamId, req.keyId!, "admin");
|
|
619
|
+
if (!callerRole) {
|
|
620
|
+
res.status(403).json({ error: "Must be admin or owner to change roles" });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const { role } = req.body;
|
|
625
|
+
if (!role || !ROLE_RANK[role as TeamRole]) {
|
|
626
|
+
res.status(400).json({ error: "Invalid role. Must be: owner, admin, member, or viewer" });
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const newRole = role as TeamRole;
|
|
631
|
+
|
|
632
|
+
// Cannot change someone with >= your role (unless you're owner)
|
|
633
|
+
const targetRole = getTeamRole(teamId, targetKeyId);
|
|
634
|
+
if (!targetRole) {
|
|
635
|
+
res.status(404).json({ error: "Member not found in team" });
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (callerRole !== "owner") {
|
|
640
|
+
if (ROLE_RANK[targetRole] >= ROLE_RANK[callerRole]) {
|
|
641
|
+
res.status(403).json({ error: "Cannot change role of someone with equal or higher rank" });
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (ROLE_RANK[newRole] >= ROLE_RANK[callerRole]) {
|
|
645
|
+
res.status(403).json({ error: "Cannot promote to equal or higher than your own role" });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
db.prepare(
|
|
651
|
+
"UPDATE team_members SET role = ? WHERE team_id = ? AND key_id = ?"
|
|
652
|
+
).run(newRole, teamId, targetKeyId);
|
|
653
|
+
|
|
654
|
+
res.json({ teamId, keyId: targetKeyId, role: newRole });
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// ── DELETE /api/v1/teams/:id/members/:keyId — Remove member ──
|
|
658
|
+
|
|
659
|
+
router.delete("/teams/:id/members/:keyId", requireAuth, (req: AuthedRequest, res: Response) => {
|
|
660
|
+
const teamId = req.params.id;
|
|
661
|
+
const targetKeyId = req.params.keyId;
|
|
662
|
+
|
|
663
|
+
// Members can remove themselves; admins+ can remove others
|
|
664
|
+
const callerRole = getTeamRole(teamId, req.keyId!);
|
|
665
|
+
if (!callerRole) {
|
|
666
|
+
res.status(403).json({ error: "Not a member of this team" });
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const isSelf = targetKeyId === req.keyId;
|
|
671
|
+
if (!isSelf) {
|
|
672
|
+
if (ROLE_RANK[callerRole] < ROLE_RANK["admin"]) {
|
|
673
|
+
res.status(403).json({ error: "Must be admin or owner to remove other members" });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const targetRole = getTeamRole(teamId, targetKeyId);
|
|
677
|
+
if (targetRole && callerRole !== "owner" && ROLE_RANK[targetRole!] >= ROLE_RANK[callerRole]) {
|
|
678
|
+
res.status(403).json({ error: "Cannot remove someone with equal or higher rank" });
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Cannot remove the last owner
|
|
684
|
+
if (isSelf && callerRole === "owner") {
|
|
685
|
+
const ownerCount = db.prepare(
|
|
686
|
+
"SELECT COUNT(*) as c FROM team_members WHERE team_id = ? AND role = 'owner'"
|
|
687
|
+
).get(teamId) as any;
|
|
688
|
+
if (ownerCount.c <= 1) {
|
|
689
|
+
res.status(400).json({ error: "Cannot leave — you are the last owner. Transfer ownership first." });
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
db.prepare("DELETE FROM team_members WHERE team_id = ? AND key_id = ?").run(teamId, targetKeyId);
|
|
695
|
+
res.json({ ok: true, message: isSelf ? "Left team" : "Member removed" });
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// ── POST /api/v1/teams/:id/projects — Add project to team ──
|
|
699
|
+
|
|
700
|
+
router.post("/teams/:id/projects", requireAuth, (req: AuthedRequest, res: Response) => {
|
|
701
|
+
const teamId = req.params.id;
|
|
702
|
+
const callerRole = requireTeamRole(teamId, req.keyId!, "member");
|
|
703
|
+
if (!callerRole) {
|
|
704
|
+
res.status(403).json({ error: "Must be a team member to add projects" });
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const { project } = req.body;
|
|
709
|
+
if (!project) {
|
|
710
|
+
res.status(400).json({ error: "project name required" });
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Verify project exists and caller owns it
|
|
715
|
+
const projectId = `${req.keyId}:${project}`;
|
|
716
|
+
const proj = db.prepare("SELECT id FROM projects WHERE id = ? AND owner_key_id = ?").get(projectId, req.keyId) as any;
|
|
717
|
+
if (!proj) {
|
|
718
|
+
res.status(404).json({ error: "Project not found or you don't own it" });
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
try {
|
|
723
|
+
db.prepare(
|
|
724
|
+
"INSERT INTO team_projects (team_id, project_id, added_by) VALUES (?, ?, ?)"
|
|
725
|
+
).run(teamId, projectId, req.keyId);
|
|
726
|
+
res.status(201).json({ teamId, projectId, project });
|
|
727
|
+
} catch (err: any) {
|
|
728
|
+
if (err.message?.includes("UNIQUE")) {
|
|
729
|
+
res.status(409).json({ error: "Project already in team" });
|
|
730
|
+
} else {
|
|
731
|
+
throw err;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
// ── DELETE /api/v1/teams/:id/projects/:projectId — Remove project from team ──
|
|
737
|
+
|
|
738
|
+
router.delete("/teams/:id/projects/:projectId", requireAuth, (req: AuthedRequest, res: Response) => {
|
|
739
|
+
const teamId = req.params.id;
|
|
740
|
+
const callerRole = requireTeamRole(teamId, req.keyId!, "admin");
|
|
741
|
+
if (!callerRole) {
|
|
742
|
+
res.status(403).json({ error: "Must be admin or owner to remove projects" });
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const projectId = decodeURIComponent(req.params.projectId);
|
|
747
|
+
db.prepare("DELETE FROM team_projects WHERE team_id = ? AND project_id = ?").run(teamId, projectId);
|
|
748
|
+
res.json({ ok: true });
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// ── Helper: check if keyId has team access to a project ──
|
|
752
|
+
|
|
753
|
+
function hasTeamAccess(projectId: string, keyId: string): { teamId: string; role: TeamRole } | null {
|
|
754
|
+
const row = db.prepare(`
|
|
755
|
+
SELECT tp.team_id, tm.role
|
|
756
|
+
FROM team_projects tp
|
|
757
|
+
JOIN team_members tm ON tm.team_id = tp.team_id AND tm.key_id = ?
|
|
758
|
+
WHERE tp.project_id = ?
|
|
759
|
+
LIMIT 1
|
|
760
|
+
`).get(keyId, projectId) as any;
|
|
761
|
+
return row ? { teamId: row.team_id, role: row.role as TeamRole } : null;
|
|
762
|
+
}
|
|
763
|
+
|
|
380
764
|
// ── Dashboard HTML generator ──
|
|
381
765
|
|
|
382
766
|
function generateDashboardHtml(projectName: string, files: Record<string, string>): string {
|
package/src/routes/codegen.ts
CHANGED
|
@@ -72,7 +72,18 @@ function collectFunctionTypes(opts: {
|
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
// Deduplicate by function name — when the same endpoint is observed
|
|
76
|
+
// across different modules/languages/sessions, keep only the most
|
|
77
|
+
// recently observed entry to avoid duplicate declarations in codegen.
|
|
78
|
+
const deduped = new Map<string, FunctionTypeData>();
|
|
79
|
+
for (const entry of results) {
|
|
80
|
+
const existing = deduped.get(entry.name);
|
|
81
|
+
if (!existing || (entry.observedAt && (!existing.observedAt || entry.observedAt > existing.observedAt))) {
|
|
82
|
+
deduped.set(entry.name, entry);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return Array.from(deduped.values());
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
// GET / — generate types for all (or filtered) functions
|