trickle-backend 0.1.66 → 0.1.67

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.
@@ -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
  }
@@ -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
- const projectId = `${req.keyId}:${project}`;
163
- const rows = connection_1.db.prepare("SELECT filename, content FROM project_data WHERE project_id = ?").all(projectId);
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
- const rows = connection_1.db.prepare(`
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
- res.json({
185
- projects: rows.map((r) => ({
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
- const proj = connection_1.db.prepare("SELECT name FROM projects WHERE id = ? AND owner_key_id = ?").get(projectId, req.keyId);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-backend",
3
- "version": "0.1.66",
3
+ "version": "0.1.67",
4
4
  "main": "dist/index.js",
5
5
  "scripts": {
6
6
  "build": "tsc",
@@ -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
  }
@@ -208,12 +208,30 @@ router.get("/pull", requireAuth, (req: AuthedRequest, res: Response) => {
208
208
  return;
209
209
  }
210
210
 
211
- const projectId = `${req.keyId}:${project}`;
212
-
213
- const rows = db.prepare(
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
- const rows = db.prepare(`
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
- res.json({
243
- projects: rows.map((r: any) => ({
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
- const proj = db.prepare(
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 {