agent-relay 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/.trajectories/agent-relay-322-324.md +17 -0
  2. package/.trajectories/completed/2026-01/traj_03zupyv1s7b9.json +49 -0
  3. package/.trajectories/completed/2026-01/traj_03zupyv1s7b9.md +31 -0
  4. package/.trajectories/completed/2026-01/traj_0zacdjl1g4ht.json +125 -0
  5. package/.trajectories/completed/2026-01/traj_0zacdjl1g4ht.md +62 -0
  6. package/.trajectories/completed/2026-01/traj_33iuy72sezbk.json +49 -0
  7. package/.trajectories/completed/2026-01/traj_33iuy72sezbk.md +31 -0
  8. package/.trajectories/completed/2026-01/traj_5ammh5qtvklq.json +77 -0
  9. package/.trajectories/completed/2026-01/traj_5ammh5qtvklq.md +42 -0
  10. package/.trajectories/completed/2026-01/traj_6mieijqyvaag.json +77 -0
  11. package/.trajectories/completed/2026-01/traj_6mieijqyvaag.md +42 -0
  12. package/.trajectories/completed/2026-01/traj_78ffm31jn3uk.json +77 -0
  13. package/.trajectories/completed/2026-01/traj_78ffm31jn3uk.md +42 -0
  14. package/.trajectories/completed/2026-01/traj_94gnp3k30goq.json +66 -0
  15. package/.trajectories/completed/2026-01/traj_94gnp3k30goq.md +36 -0
  16. package/.trajectories/completed/2026-01/traj_avqeghu6pz5a.json +40 -0
  17. package/.trajectories/completed/2026-01/traj_avqeghu6pz5a.md +22 -0
  18. package/.trajectories/completed/2026-01/traj_dcsp9s8y01ra.json +121 -0
  19. package/.trajectories/completed/2026-01/traj_dcsp9s8y01ra.md +29 -0
  20. package/.trajectories/completed/2026-01/traj_fhx9irlckht6.json +53 -0
  21. package/.trajectories/completed/2026-01/traj_fhx9irlckht6.md +32 -0
  22. package/.trajectories/completed/2026-01/traj_fqduidx3xbtp.json +101 -0
  23. package/.trajectories/completed/2026-01/traj_fqduidx3xbtp.md +52 -0
  24. package/.trajectories/completed/2026-01/traj_hf81ey93uz6t.json +49 -0
  25. package/.trajectories/completed/2026-01/traj_hf81ey93uz6t.md +31 -0
  26. package/.trajectories/completed/2026-01/traj_hfmki2jr9d4r.json +65 -0
  27. package/.trajectories/completed/2026-01/traj_hfmki2jr9d4r.md +37 -0
  28. package/.trajectories/completed/2026-01/traj_lq450ly148uw.json +49 -0
  29. package/.trajectories/completed/2026-01/traj_lq450ly148uw.md +31 -0
  30. package/.trajectories/completed/2026-01/traj_multi_server_arch.md +101 -0
  31. package/.trajectories/completed/2026-01/traj_psd9ob0j2ru3.json +27 -0
  32. package/.trajectories/completed/2026-01/traj_psd9ob0j2ru3.md +14 -0
  33. package/.trajectories/completed/2026-01/traj_ub8csuv3lcv4.json +53 -0
  34. package/.trajectories/completed/2026-01/traj_ub8csuv3lcv4.md +32 -0
  35. package/.trajectories/completed/2026-01/traj_uc29tlso8i9s.json +186 -0
  36. package/.trajectories/completed/2026-01/traj_uc29tlso8i9s.md +86 -0
  37. package/.trajectories/completed/2026-01/traj_ui9b4tqxoa7j.json +77 -0
  38. package/.trajectories/completed/2026-01/traj_ui9b4tqxoa7j.md +42 -0
  39. package/.trajectories/completed/2026-01/traj_v9dkdoxylyid.json +89 -0
  40. package/.trajectories/completed/2026-01/traj_v9dkdoxylyid.md +47 -0
  41. package/.trajectories/completed/2026-01/traj_xy9vifpqet80.json +65 -0
  42. package/.trajectories/completed/2026-01/traj_xy9vifpqet80.md +37 -0
  43. package/.trajectories/completed/2026-01/traj_y7aiwijyfmmv.json +49 -0
  44. package/.trajectories/completed/2026-01/traj_y7aiwijyfmmv.md +31 -0
  45. package/.trajectories/consolidate-settings-panel.md +24 -0
  46. package/.trajectories/gh-cli-user-token.md +26 -0
  47. package/.trajectories/index.json +155 -1
  48. package/deploy/workspace/codex.config.toml +15 -0
  49. package/deploy/workspace/entrypoint.sh +167 -7
  50. package/deploy/workspace/git-credential-relay +17 -2
  51. package/dist/bridge/spawner.d.ts +7 -0
  52. package/dist/bridge/spawner.js +40 -9
  53. package/dist/bridge/types.d.ts +2 -0
  54. package/dist/cli/index.js +210 -168
  55. package/dist/cloud/api/admin.d.ts +8 -0
  56. package/dist/cloud/api/admin.js +212 -0
  57. package/dist/cloud/api/auth.js +8 -0
  58. package/dist/cloud/api/billing.d.ts +0 -10
  59. package/dist/cloud/api/billing.js +248 -58
  60. package/dist/cloud/api/codex-auth-helper.d.ts +10 -4
  61. package/dist/cloud/api/codex-auth-helper.js +215 -8
  62. package/dist/cloud/api/coordinators.js +402 -0
  63. package/dist/cloud/api/daemons.js +15 -11
  64. package/dist/cloud/api/git.js +104 -17
  65. package/dist/cloud/api/github-app.js +42 -8
  66. package/dist/cloud/api/nango-auth.js +297 -16
  67. package/dist/cloud/api/onboarding.js +97 -33
  68. package/dist/cloud/api/providers.js +12 -16
  69. package/dist/cloud/api/repos.js +200 -124
  70. package/dist/cloud/api/test-helpers.js +40 -0
  71. package/dist/cloud/api/usage.js +13 -0
  72. package/dist/cloud/api/webhooks.js +1 -1
  73. package/dist/cloud/api/workspaces.d.ts +18 -0
  74. package/dist/cloud/api/workspaces.js +945 -15
  75. package/dist/cloud/config.d.ts +8 -0
  76. package/dist/cloud/config.js +15 -0
  77. package/dist/cloud/db/drizzle.d.ts +5 -2
  78. package/dist/cloud/db/drizzle.js +27 -20
  79. package/dist/cloud/db/schema.d.ts +19 -51
  80. package/dist/cloud/db/schema.js +5 -4
  81. package/dist/cloud/index.d.ts +0 -1
  82. package/dist/cloud/index.js +0 -1
  83. package/dist/cloud/provisioner/index.d.ts +93 -1
  84. package/dist/cloud/provisioner/index.js +608 -63
  85. package/dist/cloud/server.js +156 -16
  86. package/dist/cloud/services/compute-enforcement.d.ts +57 -0
  87. package/dist/cloud/services/compute-enforcement.js +175 -0
  88. package/dist/cloud/services/index.d.ts +2 -0
  89. package/dist/cloud/services/index.js +4 -0
  90. package/dist/cloud/services/intro-expiration.d.ts +55 -0
  91. package/dist/cloud/services/intro-expiration.js +211 -0
  92. package/dist/cloud/services/nango.d.ts +14 -0
  93. package/dist/cloud/services/nango.js +74 -14
  94. package/dist/cloud/services/ssh-security.d.ts +31 -0
  95. package/dist/cloud/services/ssh-security.js +63 -0
  96. package/dist/continuity/manager.d.ts +5 -0
  97. package/dist/continuity/manager.js +56 -2
  98. package/dist/daemon/api.d.ts +2 -0
  99. package/dist/daemon/api.js +214 -5
  100. package/dist/daemon/cli-auth.d.ts +13 -1
  101. package/dist/daemon/cli-auth.js +166 -47
  102. package/dist/daemon/connection.d.ts +7 -1
  103. package/dist/daemon/connection.js +15 -0
  104. package/dist/daemon/orchestrator.d.ts +2 -0
  105. package/dist/daemon/orchestrator.js +26 -0
  106. package/dist/daemon/repo-manager.d.ts +116 -0
  107. package/dist/daemon/repo-manager.js +384 -0
  108. package/dist/daemon/router.d.ts +60 -1
  109. package/dist/daemon/router.js +281 -20
  110. package/dist/daemon/user-directory.d.ts +111 -0
  111. package/dist/daemon/user-directory.js +233 -0
  112. package/dist/dashboard/out/404.html +1 -1
  113. package/dist/dashboard/out/_next/static/T1tgCqVWHFIkV7ClEtzD7/_ssgManifest.js +1 -0
  114. package/dist/dashboard/out/_next/static/chunks/532-bace199897eeab37.js +9 -0
  115. package/dist/dashboard/out/_next/static/chunks/766-b54f0853794b78c3.js +1 -0
  116. package/dist/dashboard/out/_next/static/chunks/83-b51836037078006c.js +1 -0
  117. package/dist/dashboard/out/_next/static/chunks/891-6cd50de1224f70bb.js +1 -0
  118. package/dist/dashboard/out/_next/static/chunks/899-bb19a9b3d9b39ea6.js +1 -0
  119. package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/page-8939b0fc700f7eca.js +1 -0
  120. package/dist/dashboard/out/_next/static/chunks/app/app/page-5af1b6b439858aa6.js +1 -0
  121. package/dist/dashboard/out/_next/static/chunks/app/connect-repos/page-f45ecbc3e06134fc.js +1 -0
  122. package/dist/dashboard/out/_next/static/chunks/app/history/{page-abb9ab2d329f56e9.js → page-8c8bed33beb2bf1c.js} +1 -1
  123. package/dist/dashboard/out/_next/static/chunks/app/layout-2433bb48965f4333.js +1 -0
  124. package/dist/dashboard/out/_next/static/chunks/app/login/{page-c22d080201cbd9fb.js → page-16f3b49e55b1e0ed.js} +1 -1
  125. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-ac39dc0cc3c26fa7.js +1 -0
  126. package/dist/dashboard/out/_next/static/chunks/app/{page-77e9c65420a06cfb.js → page-4a5938c18a11a654.js} +1 -1
  127. package/dist/dashboard/out/_next/static/chunks/app/pricing/page-982a7000fee44014.js +1 -0
  128. package/dist/dashboard/out/_next/static/chunks/app/providers/page-ac3a6ac433fd6001.js +1 -0
  129. package/dist/dashboard/out/_next/static/chunks/app/providers/setup/[provider]/page-09f9caae98a18c09.js +1 -0
  130. package/dist/dashboard/out/_next/static/chunks/app/signup/{page-68d34f50baa8ab6b.js → page-547dd0ca55ecd0ba.js} +1 -1
  131. package/dist/dashboard/out/_next/static/chunks/{main-ed4e1fb6f29c34cf.js → main-2ee6beb2ae96d210.js} +1 -1
  132. package/dist/dashboard/out/_next/static/chunks/{main-app-6e8e8d3ef4e0192a.js → main-app-5d692157a8eb1fd9.js} +1 -1
  133. package/dist/dashboard/out/_next/static/css/85d2af9c7ac74d62.css +1 -0
  134. package/dist/dashboard/out/_next/static/css/fe4b28883eeff359.css +1 -0
  135. package/dist/dashboard/out/app/onboarding.html +1 -1
  136. package/dist/dashboard/out/app/onboarding.txt +3 -3
  137. package/dist/dashboard/out/app.html +1 -1
  138. package/dist/dashboard/out/app.txt +3 -3
  139. package/dist/dashboard/out/apple-icon.png +0 -0
  140. package/dist/dashboard/out/connect-repos.html +1 -1
  141. package/dist/dashboard/out/connect-repos.txt +3 -3
  142. package/dist/dashboard/out/history.html +1 -1
  143. package/dist/dashboard/out/history.txt +3 -3
  144. package/dist/dashboard/out/index.html +1 -1
  145. package/dist/dashboard/out/index.txt +3 -3
  146. package/dist/dashboard/out/login.html +2 -2
  147. package/dist/dashboard/out/login.txt +3 -3
  148. package/dist/dashboard/out/metrics.html +1 -1
  149. package/dist/dashboard/out/metrics.txt +3 -3
  150. package/dist/dashboard/out/pricing.html +2 -2
  151. package/dist/dashboard/out/pricing.txt +3 -3
  152. package/dist/dashboard/out/providers/setup/claude.html +1 -0
  153. package/dist/dashboard/out/providers/setup/claude.txt +8 -0
  154. package/dist/dashboard/out/providers/setup/codex.html +1 -0
  155. package/dist/dashboard/out/providers/setup/codex.txt +8 -0
  156. package/dist/dashboard/out/providers.html +1 -1
  157. package/dist/dashboard/out/providers.txt +3 -3
  158. package/dist/dashboard/out/signup.html +2 -2
  159. package/dist/dashboard/out/signup.txt +3 -3
  160. package/dist/dashboard-server/server.js +316 -12
  161. package/dist/dashboard-server/user-bridge.d.ts +103 -0
  162. package/dist/dashboard-server/user-bridge.js +189 -0
  163. package/dist/protocol/channels.d.ts +205 -0
  164. package/dist/protocol/channels.js +154 -0
  165. package/dist/protocol/types.d.ts +13 -1
  166. package/dist/resiliency/provider-context.js +2 -0
  167. package/dist/shared/cli-auth-config.d.ts +19 -0
  168. package/dist/shared/cli-auth-config.js +58 -2
  169. package/dist/utils/agent-config.js +1 -1
  170. package/dist/wrapper/auth-detection.d.ts +49 -0
  171. package/dist/wrapper/auth-detection.js +192 -0
  172. package/dist/wrapper/base-wrapper.d.ts +153 -0
  173. package/dist/wrapper/base-wrapper.js +393 -0
  174. package/dist/wrapper/client.d.ts +7 -1
  175. package/dist/wrapper/client.js +3 -0
  176. package/dist/wrapper/index.d.ts +1 -0
  177. package/dist/wrapper/index.js +4 -3
  178. package/dist/wrapper/pty-wrapper.d.ts +62 -84
  179. package/dist/wrapper/pty-wrapper.js +154 -180
  180. package/dist/wrapper/tmux-wrapper.d.ts +41 -66
  181. package/dist/wrapper/tmux-wrapper.js +90 -134
  182. package/package.json +4 -2
  183. package/scripts/postinstall.js +11 -155
  184. package/scripts/test-interactive-terminal.sh +248 -0
  185. package/dist/cloud/vault/index.d.ts +0 -76
  186. package/dist/cloud/vault/index.js +0 -219
  187. package/dist/dashboard/out/_next/static/chunks/699-3b1cd6618a45d259.js +0 -1
  188. package/dist/dashboard/out/_next/static/chunks/724-2dae7627550ab88f.js +0 -9
  189. package/dist/dashboard/out/_next/static/chunks/766-1f2dd8cb7f766b0b.js +0 -1
  190. package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/page-3fdfa60e53f2810d.js +0 -1
  191. package/dist/dashboard/out/_next/static/chunks/app/app/page-e6381e5a6e1fbcfd.js +0 -1
  192. package/dist/dashboard/out/_next/static/chunks/app/connect-repos/page-3538dfe0ffe984b8.js +0 -1
  193. package/dist/dashboard/out/_next/static/chunks/app/layout-c0d118c0f92d969c.js +0 -1
  194. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-67a3e98d9a43a6ed.js +0 -1
  195. package/dist/dashboard/out/_next/static/chunks/app/pricing/page-b08ed1c34d14434a.js +0 -1
  196. package/dist/dashboard/out/_next/static/chunks/app/providers/page-e88bc117ef7671c3.js +0 -1
  197. package/dist/dashboard/out/_next/static/css/29852f26181969a0.css +0 -1
  198. package/dist/dashboard/out/_next/static/css/7c3ae9e8617d42a5.css +0 -1
  199. package/dist/dashboard/out/_next/static/wPgKJtcOmTFLpUncDg16A/_ssgManifest.js +0 -1
  200. /package/dist/dashboard/out/_next/static/{wPgKJtcOmTFLpUncDg16A → T1tgCqVWHFIkV7ClEtzD7}/_buildManifest.js +0 -0
@@ -2,6 +2,7 @@
2
2
  * Workspaces API Routes
3
3
  *
4
4
  * One-click workspace provisioning and management.
5
+ * Includes auto-access based on GitHub repo permissions.
5
6
  */
6
7
  import { Router } from 'express';
7
8
  import { requireAuth } from './auth.js';
@@ -9,19 +10,359 @@ import { db } from '../db/index.js';
9
10
  import { getProvisioner, getProvisioningStage } from '../provisioner/index.js';
10
11
  import { checkWorkspaceLimit } from './middleware/planLimits.js';
11
12
  import { getConfig } from '../config.js';
13
+ import { nangoService } from '../services/nango.js';
14
+ // Simple in-memory cache for workspace access checks
15
+ // Key: `${userId}:${workspaceId}`
16
+ const workspaceAccessCache = new Map();
17
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
18
+ function getCachedAccess(userId, workspaceId) {
19
+ const key = `${userId}:${workspaceId}`;
20
+ const cached = workspaceAccessCache.get(key);
21
+ if (!cached)
22
+ return null;
23
+ // Check if expired
24
+ if (Date.now() - cached.cachedAt > CACHE_TTL_MS) {
25
+ workspaceAccessCache.delete(key);
26
+ return null;
27
+ }
28
+ return cached;
29
+ }
30
+ function setCachedAccess(userId, workspaceId, access) {
31
+ const key = `${userId}:${workspaceId}`;
32
+ workspaceAccessCache.set(key, { ...access, cachedAt: Date.now() });
33
+ }
34
+ function _invalidateCachedAccess(userId, workspaceId) {
35
+ if (workspaceId) {
36
+ workspaceAccessCache.delete(`${userId}:${workspaceId}`);
37
+ }
38
+ else {
39
+ // Invalidate all cache entries for this user
40
+ for (const key of workspaceAccessCache.keys()) {
41
+ if (key.startsWith(`${userId}:`)) {
42
+ workspaceAccessCache.delete(key);
43
+ }
44
+ }
45
+ }
46
+ }
47
+ // Cache keyed by nangoConnectionId
48
+ const userReposCache = new Map();
49
+ const USER_REPOS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes - hard expiry
50
+ const STALE_WHILE_REVALIDATE_MS = 5 * 60 * 1000; // Trigger background refresh after 5 minutes
51
+ const MAX_CACHE_ENTRIES = 500; // Prevent unbounded growth
52
+ /**
53
+ * Evict oldest cache entries if we exceed the limit
54
+ */
55
+ function evictOldestCacheEntries() {
56
+ if (userReposCache.size <= MAX_CACHE_ENTRIES)
57
+ return;
58
+ // Convert to array, sort by cachedAt (oldest first), delete oldest entries
59
+ const entries = Array.from(userReposCache.entries())
60
+ .sort((a, b) => a[1].cachedAt - b[1].cachedAt);
61
+ const toEvict = entries.slice(0, entries.length - MAX_CACHE_ENTRIES);
62
+ for (const [key] of toEvict) {
63
+ console.log(`[repos-cache] Evicting oldest cache entry: ${key.substring(0, 8)}`);
64
+ userReposCache.delete(key);
65
+ }
66
+ }
67
+ /**
68
+ * Background refresh function that paginates through ALL user repos
69
+ */
70
+ async function refreshUserReposInBackground(nangoConnectionId) {
71
+ const cached = userReposCache.get(nangoConnectionId);
72
+ // Don't start if refresh already in progress
73
+ if (cached?.refreshInProgress) {
74
+ console.log(`[repos-cache] Background refresh already in progress for ${nangoConnectionId.substring(0, 8)}`);
75
+ return;
76
+ }
77
+ // Mark as refreshing
78
+ if (cached) {
79
+ cached.refreshInProgress = true;
80
+ }
81
+ else {
82
+ // Create placeholder entry
83
+ userReposCache.set(nangoConnectionId, {
84
+ repositories: [],
85
+ cachedAt: Date.now(),
86
+ isComplete: false,
87
+ refreshInProgress: true,
88
+ });
89
+ }
90
+ console.log(`[repos-cache] Starting background refresh for ${nangoConnectionId.substring(0, 8)}`);
91
+ try {
92
+ const allRepos = [];
93
+ let page = 1;
94
+ let hasMore = true;
95
+ const MAX_PAGES = 20; // Safety limit: 20 pages * 100 repos = 2000 repos max
96
+ while (hasMore && page <= MAX_PAGES) {
97
+ const result = await nangoService.listUserAccessibleRepos(nangoConnectionId, {
98
+ perPage: 100,
99
+ page,
100
+ type: 'all',
101
+ });
102
+ allRepos.push(...result.repositories.map(r => ({
103
+ fullName: r.fullName,
104
+ permissions: r.permissions,
105
+ })));
106
+ hasMore = result.hasMore;
107
+ page++;
108
+ // Small delay between pages to avoid rate limiting
109
+ if (hasMore) {
110
+ await new Promise(resolve => setTimeout(resolve, 100));
111
+ }
112
+ }
113
+ console.log(`[repos-cache] Background refresh complete for ${nangoConnectionId.substring(0, 8)}: ${allRepos.length} repos across ${page - 1} pages`);
114
+ userReposCache.set(nangoConnectionId, {
115
+ repositories: allRepos,
116
+ cachedAt: Date.now(),
117
+ isComplete: true,
118
+ refreshInProgress: false,
119
+ });
120
+ evictOldestCacheEntries();
121
+ }
122
+ catch (err) {
123
+ console.error(`[repos-cache] Background refresh failed for ${nangoConnectionId.substring(0, 8)}:`, err);
124
+ // Mark refresh as done even on error, keep existing data if any
125
+ const existing = userReposCache.get(nangoConnectionId);
126
+ if (existing) {
127
+ existing.refreshInProgress = false;
128
+ }
129
+ }
130
+ }
131
+ /**
132
+ * Get cached user repos, triggering background refresh if stale
133
+ * Returns null if no cache exists (caller should fetch first page synchronously)
134
+ */
135
+ function getCachedUserRepos(nangoConnectionId) {
136
+ const cached = userReposCache.get(nangoConnectionId);
137
+ if (!cached)
138
+ return null;
139
+ const age = Date.now() - cached.cachedAt;
140
+ // If expired, delete and return null
141
+ if (age > USER_REPOS_CACHE_TTL_MS) {
142
+ console.log(`[repos-cache] Cache expired for ${nangoConnectionId.substring(0, 8)}`);
143
+ userReposCache.delete(nangoConnectionId);
144
+ return null;
145
+ }
146
+ // If stale but valid, trigger background refresh
147
+ if (age > STALE_WHILE_REVALIDATE_MS && !cached.refreshInProgress) {
148
+ console.log(`[repos-cache] Cache stale for ${nangoConnectionId.substring(0, 8)}, triggering background refresh`);
149
+ // Fire and forget - don't await
150
+ refreshUserReposInBackground(nangoConnectionId).catch(() => { });
151
+ }
152
+ return cached;
153
+ }
154
+ // Track in-flight initializations to prevent duplicate API calls
155
+ const initializingConnections = new Set();
156
+ /**
157
+ * Initialize cache with first page and trigger background refresh for rest
158
+ * Returns the first page of repos immediately
159
+ */
160
+ async function initializeUserReposCache(nangoConnectionId) {
161
+ // Check if another request is already initializing this connection
162
+ if (initializingConnections.has(nangoConnectionId)) {
163
+ console.log(`[repos-cache] Another request is initializing ${nangoConnectionId.substring(0, 8)}, waiting...`);
164
+ // Wait a bit and check cache again
165
+ await new Promise(resolve => setTimeout(resolve, 500));
166
+ const cached = userReposCache.get(nangoConnectionId);
167
+ if (cached) {
168
+ return cached.repositories;
169
+ }
170
+ // Still no cache, fall through to initialize (previous request may have failed)
171
+ }
172
+ initializingConnections.add(nangoConnectionId);
173
+ try {
174
+ // Fetch first page synchronously
175
+ const firstPage = await nangoService.listUserAccessibleRepos(nangoConnectionId, {
176
+ perPage: 100,
177
+ page: 1,
178
+ type: 'all',
179
+ });
180
+ const repos = firstPage.repositories.map(r => ({
181
+ fullName: r.fullName,
182
+ permissions: r.permissions,
183
+ }));
184
+ // Store first page immediately
185
+ userReposCache.set(nangoConnectionId, {
186
+ repositories: repos,
187
+ cachedAt: Date.now(),
188
+ isComplete: !firstPage.hasMore,
189
+ refreshInProgress: firstPage.hasMore, // Will be refreshing if there's more
190
+ });
191
+ evictOldestCacheEntries();
192
+ // If there are more pages, trigger background refresh to get the rest
193
+ if (firstPage.hasMore) {
194
+ console.log(`[repos-cache] First page has ${repos.length} repos, more available - triggering background pagination`);
195
+ // Fire and forget - reuse the shared background refresh function
196
+ // But start from page 2 with the existing repos
197
+ (async () => {
198
+ try {
199
+ const allRepos = [...repos];
200
+ let page = 2;
201
+ let hasMore = true;
202
+ const MAX_PAGES = 20;
203
+ while (hasMore && page <= MAX_PAGES) {
204
+ const result = await nangoService.listUserAccessibleRepos(nangoConnectionId, {
205
+ perPage: 100,
206
+ page,
207
+ type: 'all',
208
+ });
209
+ allRepos.push(...result.repositories.map(r => ({
210
+ fullName: r.fullName,
211
+ permissions: r.permissions,
212
+ })));
213
+ hasMore = result.hasMore;
214
+ page++;
215
+ if (hasMore) {
216
+ await new Promise(resolve => setTimeout(resolve, 100));
217
+ }
218
+ }
219
+ console.log(`[repos-cache] Background pagination complete: ${allRepos.length} total repos`);
220
+ userReposCache.set(nangoConnectionId, {
221
+ repositories: allRepos,
222
+ cachedAt: Date.now(),
223
+ isComplete: true,
224
+ refreshInProgress: false,
225
+ });
226
+ evictOldestCacheEntries();
227
+ }
228
+ catch (err) {
229
+ console.error('[repos-cache] Background pagination failed:', err);
230
+ const existing = userReposCache.get(nangoConnectionId);
231
+ if (existing) {
232
+ existing.refreshInProgress = false;
233
+ }
234
+ }
235
+ })();
236
+ }
237
+ return repos;
238
+ }
239
+ finally {
240
+ initializingConnections.delete(nangoConnectionId);
241
+ }
242
+ }
243
+ // ============================================================================
244
+ // Workspace Access Middleware
245
+ // ============================================================================
246
+ /**
247
+ * Check if user has access to a workspace via:
248
+ * 1. Workspace ownership (userId matches)
249
+ * 2. Explicit workspace_members record
250
+ * 3. GitHub repo access (just-in-time check via Nango)
251
+ */
252
+ export async function checkWorkspaceAccess(userId, workspaceId) {
253
+ // Check cache first
254
+ const cached = getCachedAccess(userId, workspaceId);
255
+ if (cached) {
256
+ return { hasAccess: cached.hasAccess, accessType: cached.accessType, permission: cached.permission };
257
+ }
258
+ // 1. Check if user is workspace owner
259
+ const workspace = await db.workspaces.findById(workspaceId);
260
+ if (!workspace) {
261
+ return { hasAccess: false, accessType: 'none' };
262
+ }
263
+ if (workspace.userId === userId) {
264
+ setCachedAccess(userId, workspaceId, { hasAccess: true, accessType: 'owner', permission: 'admin' });
265
+ return { hasAccess: true, accessType: 'owner', permission: 'admin' };
266
+ }
267
+ // 2. Check explicit workspace_members
268
+ const member = await db.workspaceMembers.findMembership(workspaceId, userId);
269
+ if (member && member.acceptedAt) {
270
+ const permission = member.role === 'admin' ? 'admin' : member.role === 'member' ? 'write' : 'read';
271
+ setCachedAccess(userId, workspaceId, { hasAccess: true, accessType: 'member', permission });
272
+ return { hasAccess: true, accessType: 'member', permission };
273
+ }
274
+ // 3. Check GitHub repo access (just-in-time)
275
+ const user = await db.users.findById(userId);
276
+ if (!user?.nangoConnectionId) {
277
+ setCachedAccess(userId, workspaceId, { hasAccess: false, accessType: 'none' });
278
+ return { hasAccess: false, accessType: 'none' };
279
+ }
280
+ const repos = await db.repositories.findByWorkspaceId(workspaceId);
281
+ if (repos.length === 0) {
282
+ setCachedAccess(userId, workspaceId, { hasAccess: false, accessType: 'none' });
283
+ return { hasAccess: false, accessType: 'none' };
284
+ }
285
+ // Check if user has access to ANY repo in this workspace
286
+ for (const repo of repos) {
287
+ try {
288
+ const [owner, repoName] = repo.githubFullName.split('/');
289
+ const accessResult = await nangoService.checkUserRepoAccess(user.nangoConnectionId, owner, repoName);
290
+ if (accessResult.hasAccess && accessResult.permission && accessResult.permission !== 'none') {
291
+ setCachedAccess(userId, workspaceId, {
292
+ hasAccess: true,
293
+ accessType: 'contributor',
294
+ permission: accessResult.permission
295
+ });
296
+ return { hasAccess: true, accessType: 'contributor', permission: accessResult.permission };
297
+ }
298
+ }
299
+ catch (err) {
300
+ // Continue to next repo on error
301
+ console.warn(`[workspace-access] Failed to check repo access for ${repo.githubFullName}:`, err);
302
+ }
303
+ }
304
+ // No access found
305
+ setCachedAccess(userId, workspaceId, { hasAccess: false, accessType: 'none' });
306
+ return { hasAccess: false, accessType: 'none' };
307
+ }
308
+ /**
309
+ * Middleware to require workspace access.
310
+ * Checks ownership, membership, or GitHub repo access.
311
+ */
312
+ export function requireWorkspaceAccess(req, res, next) {
313
+ const userId = req.session.userId;
314
+ const workspaceId = req.params.id || req.params.workspaceId;
315
+ if (!userId) {
316
+ res.status(401).json({ error: 'Authentication required' });
317
+ return;
318
+ }
319
+ if (!workspaceId) {
320
+ res.status(400).json({ error: 'Workspace ID required' });
321
+ return;
322
+ }
323
+ checkWorkspaceAccess(userId, workspaceId)
324
+ .then((result) => {
325
+ if (result.hasAccess) {
326
+ // Attach access info to request for downstream use
327
+ req.workspaceAccess = {
328
+ accessType: result.accessType,
329
+ permission: result.permission,
330
+ };
331
+ next();
332
+ }
333
+ else {
334
+ res.status(403).json({ error: 'No access to this workspace' });
335
+ }
336
+ })
337
+ .catch((err) => {
338
+ console.error('[workspace-access] Error checking access:', err);
339
+ res.status(500).json({ error: 'Failed to check workspace access' });
340
+ });
341
+ }
12
342
  export const workspacesRouter = Router();
13
343
  // All routes require authentication
14
344
  workspacesRouter.use(requireAuth);
15
345
  /**
16
346
  * GET /api/workspaces
17
- * List user's workspaces
347
+ * List user's workspaces (owned + member workspaces)
18
348
  */
19
349
  workspacesRouter.get('/', async (req, res) => {
20
350
  const userId = req.session.userId;
21
351
  try {
22
- const workspaces = await db.workspaces.findByUserId(userId);
352
+ // Get owned workspaces
353
+ const ownedWorkspaces = await db.workspaces.findByUserId(userId);
354
+ // Get workspaces where user is a member
355
+ const memberships = await db.workspaceMembers.findByUserId(userId);
356
+ const ownedWorkspaceIds = new Set(ownedWorkspaces.map((w) => w.id));
357
+ const memberWorkspaceIds = memberships
358
+ .map((m) => m.workspaceId)
359
+ .filter((wsId) => !ownedWorkspaceIds.has(wsId)); // Exclude owned to prevent duplicates
360
+ // Fetch member workspaces (optimize with Promise.all instead of loop)
361
+ const memberWorkspaces = (await Promise.all(memberWorkspaceIds.map((wsId) => db.workspaces.findById(wsId)))).filter((ws) => ws !== null);
362
+ // Combine and sort by creation date
363
+ const allWorkspaces = [...ownedWorkspaces, ...memberWorkspaces].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
23
364
  res.json({
24
- workspaces: workspaces.map((w) => ({
365
+ workspaces: allWorkspaces.map((w) => ({
25
366
  id: w.id,
26
367
  name: w.name,
27
368
  status: w.status,
@@ -29,6 +370,7 @@ workspacesRouter.get('/', async (req, res) => {
29
370
  providers: w.config.providers,
30
371
  repositories: w.config.repositories,
31
372
  createdAt: w.createdAt,
373
+ isOwner: w.userId === userId, // Flag to indicate ownership
32
374
  })),
33
375
  });
34
376
  }
@@ -54,6 +396,33 @@ workspacesRouter.post('/', checkWorkspaceLimit, async (req, res) => {
54
396
  if (!repositories || !Array.isArray(repositories)) {
55
397
  return res.status(400).json({ error: 'Repositories array is required' });
56
398
  }
399
+ // Check if any of the repos already have a workspace the user can access
400
+ // This prevents creating duplicate workspaces for the same repo
401
+ for (const repoFullName of repositories) {
402
+ const existingRepos = await db.repositories.findByGithubFullName(repoFullName);
403
+ for (const existingRepo of existingRepos) {
404
+ if (existingRepo.workspaceId) {
405
+ const accessResult = await checkWorkspaceAccess(userId, existingRepo.workspaceId);
406
+ if (accessResult.hasAccess) {
407
+ const existingWorkspace = await db.workspaces.findById(existingRepo.workspaceId);
408
+ if (existingWorkspace) {
409
+ console.log(`[workspaces/create] User ${userId.substring(0, 8)} has access to existing workspace ${existingWorkspace.id.substring(0, 8)} for repo ${repoFullName}`);
410
+ return res.status(409).json({
411
+ error: 'A workspace already exists for one of these repositories',
412
+ existingWorkspace: {
413
+ id: existingWorkspace.id,
414
+ name: existingWorkspace.name,
415
+ publicUrl: existingWorkspace.publicUrl,
416
+ accessType: accessResult.accessType,
417
+ },
418
+ conflictingRepo: repoFullName,
419
+ message: `You already have ${accessResult.accessType} access to workspace "${existingWorkspace.name}" which includes ${repoFullName}.`,
420
+ });
421
+ }
422
+ }
423
+ }
424
+ }
425
+ }
57
426
  // Verify user has credentials for all providers
58
427
  const credentials = await db.credentials.findByUserId(userId);
59
428
  const connectedProviders = new Set(credentials.map((c) => c.provider));
@@ -93,13 +462,23 @@ workspacesRouter.post('/', checkWorkspaceLimit, async (req, res) => {
93
462
  });
94
463
  /**
95
464
  * GET /api/workspaces/summary
96
- * Get summary of all user workspaces for dashboard status indicator
465
+ * Get summary of all user workspaces for dashboard status indicator (owned + member workspaces)
97
466
  * NOTE: This route MUST be before /:id to avoid being caught by parameterized route
98
467
  */
99
468
  workspacesRouter.get('/summary', async (req, res) => {
100
469
  const userId = req.session.userId;
101
470
  try {
102
- const workspaces = await db.workspaces.findByUserId(userId);
471
+ // Get owned workspaces
472
+ const ownedWorkspaces = await db.workspaces.findByUserId(userId);
473
+ // Get workspaces where user is a member
474
+ const memberships = await db.workspaceMembers.findByUserId(userId);
475
+ const ownedWorkspaceIds = new Set(ownedWorkspaces.map((w) => w.id));
476
+ const memberWorkspaceIds = memberships
477
+ .map((m) => m.workspaceId)
478
+ .filter((wsId) => !ownedWorkspaceIds.has(wsId));
479
+ // Fetch member workspaces (optimize with Promise.all)
480
+ const memberWorkspaces = (await Promise.all(memberWorkspaceIds.map((wsId) => db.workspaces.findById(wsId)))).filter((ws) => ws !== null);
481
+ const workspaces = [...ownedWorkspaces, ...memberWorkspaces];
103
482
  const provisioner = getProvisioner();
104
483
  // Get live status for each workspace
105
484
  const workspaceSummaries = await Promise.all(workspaces.map(async (w) => {
@@ -152,14 +531,24 @@ workspacesRouter.get('/summary', async (req, res) => {
152
531
  });
153
532
  /**
154
533
  * GET /api/workspaces/primary
155
- * Get the user's primary workspace (first/default) with live status
534
+ * Get the user's primary workspace (first/default) with live status (owned + member workspaces)
156
535
  * Used by dashboard to show quick status indicator
157
536
  * NOTE: This route MUST be before /:id to avoid being caught by parameterized route
158
537
  */
159
538
  workspacesRouter.get('/primary', async (req, res) => {
160
539
  const userId = req.session.userId;
161
540
  try {
162
- const workspaces = await db.workspaces.findByUserId(userId);
541
+ // Get owned workspaces
542
+ const ownedWorkspaces = await db.workspaces.findByUserId(userId);
543
+ // Get workspaces where user is a member
544
+ const memberships = await db.workspaceMembers.findByUserId(userId);
545
+ const ownedWorkspaceIds = new Set(ownedWorkspaces.map((w) => w.id));
546
+ const memberWorkspaceIds = memberships
547
+ .map((m) => m.workspaceId)
548
+ .filter((wsId) => !ownedWorkspaceIds.has(wsId));
549
+ // Fetch member workspaces (optimize with Promise.all)
550
+ const memberWorkspaces = (await Promise.all(memberWorkspaceIds.map((wsId) => db.workspaces.findById(wsId)))).filter((ws) => ws !== null);
551
+ const workspaces = [...ownedWorkspaces, ...memberWorkspaces];
163
552
  if (workspaces.length === 0) {
164
553
  return res.json({
165
554
  exists: false,
@@ -211,21 +600,147 @@ workspacesRouter.get('/primary', async (req, res) => {
211
600
  res.status(500).json({ error: 'Failed to get primary workspace' });
212
601
  }
213
602
  });
603
+ /**
604
+ * GET /api/workspaces/accessible
605
+ * List all workspaces the user can access:
606
+ * - Owned workspaces
607
+ * - Workspaces where user is a member
608
+ * - Workspaces with repos the user has GitHub access to
609
+ * NOTE: This route MUST be before /:id to avoid being caught by parameterized route
610
+ */
611
+ workspacesRouter.get('/accessible', async (req, res) => {
612
+ const userId = req.session.userId;
613
+ try {
614
+ const user = await db.users.findById(userId);
615
+ if (!user) {
616
+ return res.status(404).json({ error: 'User not found' });
617
+ }
618
+ // 1. Get owned workspaces
619
+ const ownedWorkspaces = await db.workspaces.findByUserId(userId);
620
+ // 2. Get workspaces where user is a member (excluding owned ones to prevent duplicates)
621
+ const ownedWorkspaceIds = new Set(ownedWorkspaces.map((w) => w.id));
622
+ const memberships = await db.workspaceMembers.findByUserId(userId);
623
+ const memberWorkspaceIds = memberships
624
+ .map((m) => m.workspaceId)
625
+ .filter((wsId) => !ownedWorkspaceIds.has(wsId)); // Exclude owned workspaces
626
+ // Fetch member workspaces
627
+ const memberWorkspaces = [];
628
+ for (const wsId of memberWorkspaceIds) {
629
+ const ws = await db.workspaces.findById(wsId);
630
+ if (ws)
631
+ memberWorkspaces.push(ws);
632
+ }
633
+ // 3. Get workspaces via GitHub repo access (if user has Nango connection)
634
+ // Uses background caching to handle users with many repos (>100)
635
+ const contributorWorkspaces = [];
636
+ let cacheStatus = 'miss';
637
+ if (user.nangoConnectionId) {
638
+ try {
639
+ console.log(`[workspaces/accessible] Checking GitHub repo access for user ${userId.substring(0, 8)} with nangoConnectionId ${user.nangoConnectionId.substring(0, 8)}...`);
640
+ // Try to get cached repos first
641
+ let userRepos;
642
+ const cached = getCachedUserRepos(user.nangoConnectionId);
643
+ if (cached) {
644
+ userRepos = cached.repositories;
645
+ cacheStatus = 'hit';
646
+ console.log(`[workspaces/accessible] Cache ${cached.isComplete ? 'hit (complete)' : 'hit (partial)'}: ${userRepos.length} repos`);
647
+ }
648
+ else {
649
+ // No cache - initialize with first page and trigger background refresh
650
+ userRepos = await initializeUserReposCache(user.nangoConnectionId);
651
+ cacheStatus = 'initializing';
652
+ console.log(`[workspaces/accessible] Cache miss - initialized with ${userRepos.length} repos (background refresh may add more)`);
653
+ }
654
+ // Get workspaces that aren't owned or membered
655
+ // Reuse ownedWorkspaceIds and add member workspace IDs
656
+ const knownWorkspaceIds = new Set([
657
+ ...ownedWorkspaceIds,
658
+ ...memberWorkspaceIds,
659
+ ]);
660
+ // Get all repo full names from user's accessible repos
661
+ for (const repo of userRepos) {
662
+ // Find repos in our DB that match this full name (case-insensitive)
663
+ const dbRepos = await db.repositories.findByGithubFullName(repo.fullName);
664
+ if (dbRepos.length > 0) {
665
+ console.log(`[workspaces/accessible] Found ${dbRepos.length} DB records for repo ${repo.fullName}`);
666
+ }
667
+ for (const dbRepo of dbRepos) {
668
+ if (dbRepo.workspaceId && !knownWorkspaceIds.has(dbRepo.workspaceId)) {
669
+ const ws = await db.workspaces.findById(dbRepo.workspaceId);
670
+ if (ws) {
671
+ console.log(`[workspaces/accessible] Granting contributor access to workspace ${ws.id.substring(0, 8)} via repo ${repo.fullName}`);
672
+ // Determine permission level
673
+ const permission = repo.permissions.admin
674
+ ? 'admin'
675
+ : repo.permissions.push
676
+ ? 'write'
677
+ : 'read';
678
+ contributorWorkspaces.push({ ...ws, accessPermission: permission });
679
+ knownWorkspaceIds.add(ws.id);
680
+ }
681
+ }
682
+ else if (!dbRepo.workspaceId) {
683
+ console.log(`[workspaces/accessible] Repo ${repo.fullName} found in DB but has no workspaceId`);
684
+ }
685
+ }
686
+ }
687
+ console.log(`[workspaces/accessible] Found ${contributorWorkspaces.length} contributor workspaces (cache: ${cacheStatus})`);
688
+ }
689
+ catch (err) {
690
+ console.warn('[workspaces/accessible] Failed to check GitHub repo access:', err);
691
+ // Continue without contributor workspaces
692
+ }
693
+ }
694
+ else {
695
+ console.log(`[workspaces/accessible] User ${userId.substring(0, 8)} has no nangoConnectionId - skipping GitHub repo access check`);
696
+ }
697
+ // Format response - include all fields the dashboard expects
698
+ const formatWorkspace = (ws, accessType, permission) => ({
699
+ id: ws.id,
700
+ name: ws.name,
701
+ status: ws.status,
702
+ publicUrl: ws.publicUrl,
703
+ providers: ws.config?.providers,
704
+ repositories: ws.config?.repositories,
705
+ accessType,
706
+ permission: permission || (accessType === 'owner' ? 'admin' : 'read'),
707
+ createdAt: ws.createdAt,
708
+ });
709
+ res.json({
710
+ workspaces: [
711
+ ...ownedWorkspaces.map((w) => formatWorkspace(w, 'owner', 'admin')),
712
+ ...memberWorkspaces.map((w) => {
713
+ const membership = memberships.find((m) => m.workspaceId === w.id);
714
+ return formatWorkspace(w, 'member', membership?.role);
715
+ }),
716
+ ...contributorWorkspaces.map((w) => formatWorkspace(w, 'contributor', w.accessPermission)),
717
+ ],
718
+ summary: {
719
+ owned: ownedWorkspaces.length,
720
+ member: memberWorkspaces.length,
721
+ contributor: contributorWorkspaces.length,
722
+ total: ownedWorkspaces.length + memberWorkspaces.length + contributorWorkspaces.length,
723
+ },
724
+ });
725
+ }
726
+ catch (error) {
727
+ console.error('Error getting accessible workspaces:', error);
728
+ res.status(500).json({ error: 'Failed to get accessible workspaces' });
729
+ }
730
+ });
214
731
  /**
215
732
  * GET /api/workspaces/:id
216
733
  * Get workspace details
734
+ * Uses requireWorkspaceAccess middleware for auto-access via GitHub repos
217
735
  */
218
- workspacesRouter.get('/:id', async (req, res) => {
219
- const userId = req.session.userId;
736
+ workspacesRouter.get('/:id', requireWorkspaceAccess, async (req, res) => {
220
737
  const { id } = req.params;
738
+ const _workspaceAccess = req.workspaceAccess;
221
739
  try {
222
740
  const workspace = await db.workspaces.findById(id);
223
741
  if (!workspace) {
224
742
  return res.status(404).json({ error: 'Workspace not found' });
225
743
  }
226
- if (workspace.userId !== userId) {
227
- return res.status(403).json({ error: 'Unauthorized' });
228
- }
229
744
  // Get repositories assigned to this workspace
230
745
  const repositories = await db.repositories.findByWorkspaceId(id);
231
746
  res.json({
@@ -387,6 +902,236 @@ workspacesRouter.post('/:id/repos', async (req, res) => {
387
902
  res.status(500).json({ error: 'Failed to add repositories' });
388
903
  }
389
904
  });
905
+ /**
906
+ * GET /api/workspaces/:id/repos
907
+ * List repositories linked to a workspace
908
+ */
909
+ workspacesRouter.get('/:id/repos', async (req, res) => {
910
+ const userId = req.session.userId;
911
+ const { id } = req.params;
912
+ try {
913
+ const workspace = await db.workspaces.findById(id);
914
+ if (!workspace) {
915
+ return res.status(404).json({ error: 'Workspace not found' });
916
+ }
917
+ // Check access (owner, member, or contributor)
918
+ const accessResult = await checkWorkspaceAccess(userId, id);
919
+ if (!accessResult.hasAccess) {
920
+ return res.status(403).json({ error: 'Unauthorized' });
921
+ }
922
+ // Get repos linked to this workspace
923
+ const repos = await db.repositories.findByWorkspaceId(id);
924
+ res.json({
925
+ repositories: repos.map(r => ({
926
+ id: r.id,
927
+ githubFullName: r.githubFullName,
928
+ defaultBranch: r.defaultBranch,
929
+ isPrivate: r.isPrivate,
930
+ syncStatus: r.syncStatus,
931
+ lastSyncedAt: r.lastSyncedAt,
932
+ })),
933
+ });
934
+ }
935
+ catch (error) {
936
+ console.error('Error listing workspace repos:', error);
937
+ res.status(500).json({ error: 'Failed to list repositories' });
938
+ }
939
+ });
940
+ /**
941
+ * GET /api/workspaces/:id/repo-collaborators
942
+ * Get all collaborators from repos linked to this workspace
943
+ * These are users who have access via GitHub repo permissions (grandfathered in)
944
+ */
945
+ workspacesRouter.get('/:id/repo-collaborators', async (req, res) => {
946
+ const userId = req.session.userId;
947
+ const { id } = req.params;
948
+ try {
949
+ const workspace = await db.workspaces.findById(id);
950
+ if (!workspace) {
951
+ return res.status(404).json({ error: 'Workspace not found' });
952
+ }
953
+ // Check access (owner, member, or contributor)
954
+ const accessResult = await checkWorkspaceAccess(userId, id);
955
+ if (!accessResult.hasAccess) {
956
+ return res.status(403).json({ error: 'Unauthorized' });
957
+ }
958
+ // Get repos linked to this workspace
959
+ const repos = await db.repositories.findByWorkspaceId(id);
960
+ if (repos.length === 0) {
961
+ return res.json({ collaborators: [] });
962
+ }
963
+ // Find a repo with a Nango connection (GitHub App)
964
+ const repoWithConnection = repos.find(r => r.nangoConnectionId);
965
+ if (!repoWithConnection?.nangoConnectionId) {
966
+ return res.json({
967
+ collaborators: [],
968
+ message: 'GitHub App not connected for this workspace',
969
+ });
970
+ }
971
+ // Get the workspace owner for filtering
972
+ const owner = await db.users.findById(workspace.userId);
973
+ // Fetch collaborators for each repo and deduplicate
974
+ const collaboratorsMap = new Map();
975
+ // Get existing workspace members to exclude them
976
+ const existingMembers = await db.workspaceMembers.findByWorkspaceId(id);
977
+ // Also get the workspace owner's GitHub ID to exclude
978
+ const ownerGithubId = owner?.githubId ? Number(owner.githubId) : null;
979
+ for (const repo of repos) {
980
+ // Use this repo's connection if it has one, otherwise use the shared connection
981
+ const connectionId = repo.nangoConnectionId || repoWithConnection.nangoConnectionId;
982
+ if (!connectionId)
983
+ continue;
984
+ try {
985
+ const [repoOwner, repoName] = repo.githubFullName.split('/');
986
+ const collabs = await nangoService.listRepoCollaborators(connectionId, repoOwner, repoName);
987
+ for (const collab of collabs) {
988
+ // Skip the workspace owner
989
+ if (ownerGithubId && collab.id === ownerGithubId) {
990
+ continue;
991
+ }
992
+ const existing = collaboratorsMap.get(collab.id);
993
+ if (existing) {
994
+ // Add this repo to their list
995
+ if (!existing.repos.includes(repo.githubFullName)) {
996
+ existing.repos.push(repo.githubFullName);
997
+ }
998
+ // Upgrade permission if this repo gives higher access
999
+ if (collab.permission === 'admin' && existing.permission !== 'admin') {
1000
+ existing.permission = 'admin';
1001
+ }
1002
+ else if (collab.permission === 'write' && existing.permission === 'read') {
1003
+ existing.permission = 'write';
1004
+ }
1005
+ }
1006
+ else {
1007
+ collaboratorsMap.set(collab.id, {
1008
+ id: collab.id,
1009
+ login: collab.login,
1010
+ avatarUrl: collab.avatarUrl,
1011
+ permission: collab.permission,
1012
+ repos: [repo.githubFullName],
1013
+ });
1014
+ }
1015
+ }
1016
+ }
1017
+ catch (err) {
1018
+ console.warn(`[workspace-collaborators] Failed to fetch collaborators for ${repo.githubFullName}:`, err);
1019
+ // Continue with other repos
1020
+ }
1021
+ }
1022
+ // Filter out users who are already workspace members
1023
+ // We need to check by GitHub username since we don't have their user IDs
1024
+ const workspaceMemberUsernames = new Set();
1025
+ for (const member of existingMembers) {
1026
+ const memberUser = await db.users.findById(member.userId);
1027
+ if (memberUser?.githubUsername) {
1028
+ workspaceMemberUsernames.add(memberUser.githubUsername.toLowerCase());
1029
+ }
1030
+ }
1031
+ // Also add workspace owner
1032
+ if (owner?.githubUsername) {
1033
+ workspaceMemberUsernames.add(owner.githubUsername.toLowerCase());
1034
+ }
1035
+ const collaborators = Array.from(collaboratorsMap.values())
1036
+ .filter(c => !workspaceMemberUsernames.has(c.login.toLowerCase()))
1037
+ .sort((a, b) => {
1038
+ // Sort by permission level (admin > write > read), then by username
1039
+ const permOrder = { admin: 0, write: 1, read: 2, none: 3 };
1040
+ if (permOrder[a.permission] !== permOrder[b.permission]) {
1041
+ return permOrder[a.permission] - permOrder[b.permission];
1042
+ }
1043
+ return a.login.localeCompare(b.login);
1044
+ });
1045
+ res.json({
1046
+ collaborators,
1047
+ totalRepos: repos.length,
1048
+ });
1049
+ }
1050
+ catch (error) {
1051
+ console.error('Error fetching repo collaborators:', error);
1052
+ res.status(500).json({ error: 'Failed to fetch collaborators' });
1053
+ }
1054
+ });
1055
+ /**
1056
+ * DELETE /api/workspaces/:id/repos/:repoId
1057
+ * Remove a repository from a workspace
1058
+ */
1059
+ workspacesRouter.delete('/:id/repos/:repoId', async (req, res) => {
1060
+ const userId = req.session.userId;
1061
+ const { id, repoId } = req.params;
1062
+ try {
1063
+ const workspace = await db.workspaces.findById(id);
1064
+ if (!workspace) {
1065
+ return res.status(404).json({ error: 'Workspace not found' });
1066
+ }
1067
+ // Only owner can remove repos
1068
+ if (workspace.userId !== userId) {
1069
+ return res.status(403).json({ error: 'Only workspace owner can remove repositories' });
1070
+ }
1071
+ // Unlink repo from workspace (set workspaceId to null)
1072
+ await db.repositories.assignToWorkspace(repoId, null);
1073
+ // Also update workspace config to remove from repositories array
1074
+ const currentRepos = workspace.config.repositories || [];
1075
+ const repo = await db.repositories.findById(repoId);
1076
+ if (repo) {
1077
+ const updatedRepos = currentRepos.filter(r => r.toLowerCase() !== repo.githubFullName.toLowerCase());
1078
+ await db.workspaces.update(id, {
1079
+ config: { ...workspace.config, repositories: updatedRepos },
1080
+ });
1081
+ }
1082
+ res.json({ success: true, message: 'Repository removed from workspace' });
1083
+ }
1084
+ catch (error) {
1085
+ console.error('Error removing repo from workspace:', error);
1086
+ res.status(500).json({ error: 'Failed to remove repository' });
1087
+ }
1088
+ });
1089
+ /**
1090
+ * PATCH /api/workspaces/:id
1091
+ * Update workspace settings (name, etc.)
1092
+ */
1093
+ workspacesRouter.patch('/:id', async (req, res) => {
1094
+ const userId = req.session.userId;
1095
+ const { id } = req.params;
1096
+ const { name } = req.body;
1097
+ try {
1098
+ const workspace = await db.workspaces.findById(id);
1099
+ if (!workspace) {
1100
+ return res.status(404).json({ error: 'Workspace not found' });
1101
+ }
1102
+ // Only owner can rename
1103
+ if (workspace.userId !== userId) {
1104
+ return res.status(403).json({ error: 'Only workspace owner can update settings' });
1105
+ }
1106
+ // Validate name if provided
1107
+ if (name !== undefined) {
1108
+ if (typeof name !== 'string' || name.trim().length === 0) {
1109
+ return res.status(400).json({ error: 'Name must be a non-empty string' });
1110
+ }
1111
+ if (name.length > 100) {
1112
+ return res.status(400).json({ error: 'Name must be 100 characters or less' });
1113
+ }
1114
+ }
1115
+ // Update workspace
1116
+ await db.workspaces.update(id, {
1117
+ ...(name && { name: name.trim() }),
1118
+ });
1119
+ const updated = await db.workspaces.findById(id);
1120
+ res.json({
1121
+ success: true,
1122
+ workspace: {
1123
+ id: updated.id,
1124
+ name: updated.name,
1125
+ status: updated.status,
1126
+ publicUrl: updated.publicUrl,
1127
+ },
1128
+ });
1129
+ }
1130
+ catch (error) {
1131
+ console.error('Error updating workspace:', error);
1132
+ res.status(500).json({ error: 'Failed to update workspace' });
1133
+ }
1134
+ });
390
1135
  /**
391
1136
  * POST /api/workspaces/:id/autoscale
392
1137
  * Trigger auto-scaling based on current agent count
@@ -675,8 +1420,19 @@ workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
675
1420
  if (!workspace) {
676
1421
  return res.status(404).json({ error: 'Workspace not found' });
677
1422
  }
1423
+ // Check if user is owner or has workspace membership
678
1424
  if (workspace.userId !== userId) {
679
- return res.status(403).json({ error: 'Unauthorized' });
1425
+ // Check workspace membership
1426
+ const membership = await db.workspaceMembers.findMembership(id, userId);
1427
+ if (!membership || !membership.acceptedAt) {
1428
+ return res.status(403).json({ error: 'Unauthorized - not a workspace member' });
1429
+ }
1430
+ // Viewers can only proxy read-only requests
1431
+ if (membership.role === 'viewer' && req.method !== 'GET') {
1432
+ return res.status(403).json({ error: 'Viewers can only make read-only requests' });
1433
+ }
1434
+ // Members and admins can read and write
1435
+ // For now, allow all proxy requests for members and admins
680
1436
  }
681
1437
  if (workspace.status !== 'running' || !workspace.publicUrl) {
682
1438
  return res.status(400).json({ error: 'Workspace is not running' });
@@ -705,8 +1461,9 @@ workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
705
1461
  // Store targetUrl for error handling
706
1462
  req._proxyTargetUrl = targetUrl;
707
1463
  // Add timeout to prevent hanging requests
1464
+ // 45s timeout to accommodate Fly.io machine cold starts (can take 20-30s)
708
1465
  const controller = new AbortController();
709
- const timeout = setTimeout(() => controller.abort(), 15000); // 15s timeout
1466
+ const timeout = setTimeout(() => controller.abort(), 45000);
710
1467
  const fetchOptions = {
711
1468
  method: req.method,
712
1469
  headers: {
@@ -744,7 +1501,7 @@ workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
744
1501
  if (error instanceof Error && error.name === 'AbortError') {
745
1502
  res.status(504).json({
746
1503
  error: 'Workspace request timed out',
747
- details: 'The workspace did not respond within 15 seconds',
1504
+ details: 'The workspace did not respond within 45 seconds',
748
1505
  targetUrl: targetUrl,
749
1506
  });
750
1507
  return;
@@ -766,6 +1523,156 @@ workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
766
1523
  });
767
1524
  }
768
1525
  });
1526
+ // ============================================================================
1527
+ // Agent Management (proxied to workspace daemon)
1528
+ // ============================================================================
1529
+ /**
1530
+ * POST /api/workspaces/:id/agents
1531
+ * Spawn an agent in the workspace
1532
+ * Proxies to workspace daemon's /workspaces/:id/agents endpoint
1533
+ */
1534
+ workspacesRouter.post('/:id/agents', async (req, res) => {
1535
+ const userId = req.session.userId;
1536
+ const { id } = req.params;
1537
+ const { name, provider, task, temporary, interactive } = req.body;
1538
+ if (!userId) {
1539
+ return res.status(401).json({ error: 'Not authenticated' });
1540
+ }
1541
+ if (!name) {
1542
+ return res.status(400).json({ error: 'Agent name is required' });
1543
+ }
1544
+ try {
1545
+ // Find workspace and verify access
1546
+ const workspace = await db.workspaces.findById(id);
1547
+ if (!workspace) {
1548
+ return res.status(404).json({ error: 'Workspace not found' });
1549
+ }
1550
+ // Check access
1551
+ const accessResult = await checkWorkspaceAccess(userId, id);
1552
+ if (!accessResult.hasAccess) {
1553
+ return res.status(403).json({ error: 'Access denied to this workspace' });
1554
+ }
1555
+ // Ensure workspace is running
1556
+ if (workspace.status !== 'running' || !workspace.publicUrl) {
1557
+ return res.status(400).json({
1558
+ error: 'Workspace is not running',
1559
+ status: workspace.status,
1560
+ });
1561
+ }
1562
+ // Proxy to workspace dashboard server's /api/spawn endpoint
1563
+ // The dashboard server expects 'cli' field (not 'provider')
1564
+ const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawn`;
1565
+ console.log(`[workspaces] Proxying agent spawn to: ${targetUrl}`);
1566
+ const response = await fetch(targetUrl, {
1567
+ method: 'POST',
1568
+ headers: { 'Content-Type': 'application/json' },
1569
+ body: JSON.stringify({
1570
+ name,
1571
+ cli: provider || 'claude', // Map provider to cli
1572
+ task: task || '', // Empty task = interactive mode, user responds to prompts
1573
+ interactive: interactive ?? true, // Default to interactive for setup flows
1574
+ }),
1575
+ signal: AbortSignal.timeout(30000),
1576
+ });
1577
+ const data = await response.json();
1578
+ if (!response.ok) {
1579
+ return res.status(response.status).json(data);
1580
+ }
1581
+ res.status(201).json(data);
1582
+ }
1583
+ catch (error) {
1584
+ console.error('[workspaces] Agent spawn error:', error);
1585
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1586
+ if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('fetch failed')) {
1587
+ return res.status(503).json({
1588
+ error: 'Workspace is not reachable',
1589
+ details: 'The workspace container may not be running',
1590
+ });
1591
+ }
1592
+ res.status(500).json({
1593
+ error: 'Failed to spawn agent',
1594
+ details: errorMessage,
1595
+ });
1596
+ }
1597
+ });
1598
+ /**
1599
+ * GET /api/workspaces/:id/agents
1600
+ * List agents in the workspace
1601
+ */
1602
+ workspacesRouter.get('/:id/agents', async (req, res) => {
1603
+ const userId = req.session.userId;
1604
+ const { id } = req.params;
1605
+ if (!userId) {
1606
+ return res.status(401).json({ error: 'Not authenticated' });
1607
+ }
1608
+ try {
1609
+ const workspace = await db.workspaces.findById(id);
1610
+ if (!workspace) {
1611
+ return res.status(404).json({ error: 'Workspace not found' });
1612
+ }
1613
+ const accessResult = await checkWorkspaceAccess(userId, id);
1614
+ if (!accessResult.hasAccess) {
1615
+ return res.status(403).json({ error: 'Access denied' });
1616
+ }
1617
+ if (workspace.status !== 'running' || !workspace.publicUrl) {
1618
+ return res.status(200).json({ agents: [], workspaceId: id });
1619
+ }
1620
+ // Use dashboard server's /api/spawned endpoint
1621
+ const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawned`;
1622
+ const response = await fetch(targetUrl, {
1623
+ signal: AbortSignal.timeout(10000),
1624
+ });
1625
+ if (!response.ok) {
1626
+ return res.status(200).json({ agents: [], workspaceId: id });
1627
+ }
1628
+ const data = await response.json();
1629
+ // Transform to expected format
1630
+ res.json({ agents: data.agents || [], workspaceId: id });
1631
+ }
1632
+ catch (error) {
1633
+ console.error('[workspaces] List agents error:', error);
1634
+ res.status(200).json({ agents: [], workspaceId: id });
1635
+ }
1636
+ });
1637
+ /**
1638
+ * DELETE /api/workspaces/:id/agents/:agentName
1639
+ * Stop an agent in the workspace
1640
+ */
1641
+ workspacesRouter.delete('/:id/agents/:agentName', async (req, res) => {
1642
+ const userId = req.session.userId;
1643
+ const { id, agentName } = req.params;
1644
+ if (!userId) {
1645
+ return res.status(401).json({ error: 'Not authenticated' });
1646
+ }
1647
+ try {
1648
+ const workspace = await db.workspaces.findById(id);
1649
+ if (!workspace) {
1650
+ return res.status(404).json({ error: 'Workspace not found' });
1651
+ }
1652
+ const accessResult = await checkWorkspaceAccess(userId, id);
1653
+ if (!accessResult.hasAccess) {
1654
+ return res.status(403).json({ error: 'Access denied' });
1655
+ }
1656
+ if (workspace.status !== 'running' || !workspace.publicUrl) {
1657
+ return res.status(400).json({ error: 'Workspace is not running' });
1658
+ }
1659
+ // Use dashboard server's /api/spawned/:name endpoint
1660
+ const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawned/${encodeURIComponent(agentName)}`;
1661
+ const response = await fetch(targetUrl, {
1662
+ method: 'DELETE',
1663
+ signal: AbortSignal.timeout(10000),
1664
+ });
1665
+ if (response.status === 204) {
1666
+ return res.status(204).send();
1667
+ }
1668
+ const data = await response.json();
1669
+ res.status(response.status).json(data);
1670
+ }
1671
+ catch (error) {
1672
+ console.error('[workspaces] Stop agent error:', error);
1673
+ res.status(500).json({ error: 'Failed to stop agent' });
1674
+ }
1675
+ });
769
1676
  /**
770
1677
  * POST /api/workspaces/quick
771
1678
  * Quick provision: one-click with defaults
@@ -778,6 +1685,29 @@ workspacesRouter.post('/quick', checkWorkspaceLimit, async (req, res) => {
778
1685
  return res.status(400).json({ error: 'Repository name is required' });
779
1686
  }
780
1687
  try {
1688
+ // Check if a workspace already exists for this repo
1689
+ // If so, check if user has access and return it instead of creating a duplicate
1690
+ const existingRepos = await db.repositories.findByGithubFullName(repositoryFullName);
1691
+ for (const existingRepo of existingRepos) {
1692
+ if (existingRepo.workspaceId) {
1693
+ // Check if user has access to this workspace
1694
+ const accessResult = await checkWorkspaceAccess(userId, existingRepo.workspaceId);
1695
+ if (accessResult.hasAccess) {
1696
+ const existingWorkspace = await db.workspaces.findById(existingRepo.workspaceId);
1697
+ if (existingWorkspace) {
1698
+ console.log(`[workspaces/quick] User ${userId.substring(0, 8)} has access to existing workspace ${existingWorkspace.id.substring(0, 8)} for repo ${repositoryFullName}`);
1699
+ return res.status(200).json({
1700
+ workspaceId: existingWorkspace.id,
1701
+ status: existingWorkspace.status,
1702
+ publicUrl: existingWorkspace.publicUrl,
1703
+ existingWorkspace: true,
1704
+ accessType: accessResult.accessType,
1705
+ message: `You already have ${accessResult.accessType} access to a workspace for this repository.`,
1706
+ });
1707
+ }
1708
+ }
1709
+ }
1710
+ }
781
1711
  // Get user's connected providers (optional now)
782
1712
  const credentials = await db.credentials.findByUserId(userId);
783
1713
  const providers = credentials