@agent-relay/cloud 0.1.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 (269) hide show
  1. package/dist/api/admin.d.ts +8 -0
  2. package/dist/api/admin.d.ts.map +1 -0
  3. package/dist/api/admin.js +225 -0
  4. package/dist/api/admin.js.map +1 -0
  5. package/dist/api/auth.d.ts +20 -0
  6. package/dist/api/auth.d.ts.map +1 -0
  7. package/dist/api/auth.js +136 -0
  8. package/dist/api/auth.js.map +1 -0
  9. package/dist/api/billing.d.ts +7 -0
  10. package/dist/api/billing.d.ts.map +1 -0
  11. package/dist/api/billing.js +564 -0
  12. package/dist/api/billing.js.map +1 -0
  13. package/dist/api/cli-pty-runner.d.ts +53 -0
  14. package/dist/api/cli-pty-runner.d.ts.map +1 -0
  15. package/dist/api/cli-pty-runner.js +193 -0
  16. package/dist/api/cli-pty-runner.js.map +1 -0
  17. package/dist/api/codex-auth-helper.d.ts +21 -0
  18. package/dist/api/codex-auth-helper.d.ts.map +1 -0
  19. package/dist/api/codex-auth-helper.js +327 -0
  20. package/dist/api/codex-auth-helper.js.map +1 -0
  21. package/dist/api/consensus.d.ts +13 -0
  22. package/dist/api/consensus.d.ts.map +1 -0
  23. package/dist/api/consensus.js +261 -0
  24. package/dist/api/consensus.js.map +1 -0
  25. package/dist/api/coordinators.d.ts +8 -0
  26. package/dist/api/coordinators.d.ts.map +1 -0
  27. package/dist/api/coordinators.js +750 -0
  28. package/dist/api/coordinators.js.map +1 -0
  29. package/dist/api/daemons.d.ts +12 -0
  30. package/dist/api/daemons.d.ts.map +1 -0
  31. package/dist/api/daemons.js +535 -0
  32. package/dist/api/daemons.js.map +1 -0
  33. package/dist/api/generic-webhooks.d.ts +8 -0
  34. package/dist/api/generic-webhooks.d.ts.map +1 -0
  35. package/dist/api/generic-webhooks.js +129 -0
  36. package/dist/api/generic-webhooks.js.map +1 -0
  37. package/dist/api/git.d.ts +8 -0
  38. package/dist/api/git.d.ts.map +1 -0
  39. package/dist/api/git.js +269 -0
  40. package/dist/api/git.js.map +1 -0
  41. package/dist/api/github-app.d.ts +11 -0
  42. package/dist/api/github-app.d.ts.map +1 -0
  43. package/dist/api/github-app.js +223 -0
  44. package/dist/api/github-app.js.map +1 -0
  45. package/dist/api/middleware/planLimits.d.ts +43 -0
  46. package/dist/api/middleware/planLimits.d.ts.map +1 -0
  47. package/dist/api/middleware/planLimits.js +202 -0
  48. package/dist/api/middleware/planLimits.js.map +1 -0
  49. package/dist/api/monitoring.d.ts +11 -0
  50. package/dist/api/monitoring.d.ts.map +1 -0
  51. package/dist/api/monitoring.js +578 -0
  52. package/dist/api/monitoring.js.map +1 -0
  53. package/dist/api/nango-auth.d.ts +9 -0
  54. package/dist/api/nango-auth.d.ts.map +1 -0
  55. package/dist/api/nango-auth.js +674 -0
  56. package/dist/api/nango-auth.js.map +1 -0
  57. package/dist/api/onboarding.d.ts +15 -0
  58. package/dist/api/onboarding.d.ts.map +1 -0
  59. package/dist/api/onboarding.js +679 -0
  60. package/dist/api/onboarding.js.map +1 -0
  61. package/dist/api/policy.d.ts +8 -0
  62. package/dist/api/policy.d.ts.map +1 -0
  63. package/dist/api/policy.js +229 -0
  64. package/dist/api/policy.js.map +1 -0
  65. package/dist/api/provider-env.d.ts +14 -0
  66. package/dist/api/provider-env.d.ts.map +1 -0
  67. package/dist/api/provider-env.js +75 -0
  68. package/dist/api/provider-env.js.map +1 -0
  69. package/dist/api/providers.d.ts +7 -0
  70. package/dist/api/providers.d.ts.map +1 -0
  71. package/dist/api/providers.js +564 -0
  72. package/dist/api/providers.js.map +1 -0
  73. package/dist/api/repos.d.ts +8 -0
  74. package/dist/api/repos.d.ts.map +1 -0
  75. package/dist/api/repos.js +577 -0
  76. package/dist/api/repos.js.map +1 -0
  77. package/dist/api/sessions.d.ts +11 -0
  78. package/dist/api/sessions.d.ts.map +1 -0
  79. package/dist/api/sessions.js +302 -0
  80. package/dist/api/sessions.js.map +1 -0
  81. package/dist/api/teams.d.ts +7 -0
  82. package/dist/api/teams.d.ts.map +1 -0
  83. package/dist/api/teams.js +281 -0
  84. package/dist/api/teams.js.map +1 -0
  85. package/dist/api/test-helpers.d.ts +10 -0
  86. package/dist/api/test-helpers.d.ts.map +1 -0
  87. package/dist/api/test-helpers.js +745 -0
  88. package/dist/api/test-helpers.js.map +1 -0
  89. package/dist/api/usage.d.ts +7 -0
  90. package/dist/api/usage.d.ts.map +1 -0
  91. package/dist/api/usage.js +111 -0
  92. package/dist/api/usage.js.map +1 -0
  93. package/dist/api/webhooks.d.ts +8 -0
  94. package/dist/api/webhooks.d.ts.map +1 -0
  95. package/dist/api/webhooks.js +645 -0
  96. package/dist/api/webhooks.js.map +1 -0
  97. package/dist/api/workspaces.d.ts +25 -0
  98. package/dist/api/workspaces.d.ts.map +1 -0
  99. package/dist/api/workspaces.js +1799 -0
  100. package/dist/api/workspaces.js.map +1 -0
  101. package/dist/billing/index.d.ts +9 -0
  102. package/dist/billing/index.d.ts.map +1 -0
  103. package/dist/billing/index.js +9 -0
  104. package/dist/billing/index.js.map +1 -0
  105. package/dist/billing/plans.d.ts +39 -0
  106. package/dist/billing/plans.d.ts.map +1 -0
  107. package/dist/billing/plans.js +245 -0
  108. package/dist/billing/plans.js.map +1 -0
  109. package/dist/billing/service.d.ts +80 -0
  110. package/dist/billing/service.d.ts.map +1 -0
  111. package/dist/billing/service.js +388 -0
  112. package/dist/billing/service.js.map +1 -0
  113. package/dist/billing/types.d.ts +141 -0
  114. package/dist/billing/types.d.ts.map +1 -0
  115. package/dist/billing/types.js +7 -0
  116. package/dist/billing/types.js.map +1 -0
  117. package/dist/config.d.ts +5 -0
  118. package/dist/config.d.ts.map +1 -0
  119. package/dist/config.js +5 -0
  120. package/dist/config.js.map +1 -0
  121. package/dist/db/bulk-ingest.d.ts +89 -0
  122. package/dist/db/bulk-ingest.d.ts.map +1 -0
  123. package/dist/db/bulk-ingest.js +268 -0
  124. package/dist/db/bulk-ingest.js.map +1 -0
  125. package/dist/db/drizzle.d.ts +256 -0
  126. package/dist/db/drizzle.d.ts.map +1 -0
  127. package/dist/db/drizzle.js +1286 -0
  128. package/dist/db/drizzle.js.map +1 -0
  129. package/dist/db/index.d.ts +55 -0
  130. package/dist/db/index.d.ts.map +1 -0
  131. package/dist/db/index.js +68 -0
  132. package/dist/db/index.js.map +1 -0
  133. package/dist/db/schema.d.ts +4873 -0
  134. package/dist/db/schema.d.ts.map +1 -0
  135. package/dist/db/schema.js +620 -0
  136. package/dist/db/schema.js.map +1 -0
  137. package/dist/index.d.ts +11 -0
  138. package/dist/index.d.ts.map +1 -0
  139. package/dist/index.js +38 -0
  140. package/dist/index.js.map +1 -0
  141. package/dist/provisioner/index.d.ts +207 -0
  142. package/dist/provisioner/index.d.ts.map +1 -0
  143. package/dist/provisioner/index.js +2114 -0
  144. package/dist/provisioner/index.js.map +1 -0
  145. package/dist/server.d.ts +17 -0
  146. package/dist/server.d.ts.map +1 -0
  147. package/dist/server.js +1924 -0
  148. package/dist/server.js.map +1 -0
  149. package/dist/services/auto-scaler.d.ts +152 -0
  150. package/dist/services/auto-scaler.d.ts.map +1 -0
  151. package/dist/services/auto-scaler.js +439 -0
  152. package/dist/services/auto-scaler.js.map +1 -0
  153. package/dist/services/capacity-manager.d.ts +148 -0
  154. package/dist/services/capacity-manager.d.ts.map +1 -0
  155. package/dist/services/capacity-manager.js +449 -0
  156. package/dist/services/capacity-manager.js.map +1 -0
  157. package/dist/services/ci-agent-spawner.d.ts +49 -0
  158. package/dist/services/ci-agent-spawner.d.ts.map +1 -0
  159. package/dist/services/ci-agent-spawner.js +373 -0
  160. package/dist/services/ci-agent-spawner.js.map +1 -0
  161. package/dist/services/cloud-message-bus.d.ts +28 -0
  162. package/dist/services/cloud-message-bus.d.ts.map +1 -0
  163. package/dist/services/cloud-message-bus.js +19 -0
  164. package/dist/services/cloud-message-bus.js.map +1 -0
  165. package/dist/services/compute-enforcement.d.ts +57 -0
  166. package/dist/services/compute-enforcement.d.ts.map +1 -0
  167. package/dist/services/compute-enforcement.js +175 -0
  168. package/dist/services/compute-enforcement.js.map +1 -0
  169. package/dist/services/coordinator.d.ts +62 -0
  170. package/dist/services/coordinator.d.ts.map +1 -0
  171. package/dist/services/coordinator.js +389 -0
  172. package/dist/services/coordinator.js.map +1 -0
  173. package/dist/services/index.d.ts +17 -0
  174. package/dist/services/index.d.ts.map +1 -0
  175. package/dist/services/index.js +25 -0
  176. package/dist/services/index.js.map +1 -0
  177. package/dist/services/intro-expiration.d.ts +60 -0
  178. package/dist/services/intro-expiration.d.ts.map +1 -0
  179. package/dist/services/intro-expiration.js +252 -0
  180. package/dist/services/intro-expiration.js.map +1 -0
  181. package/dist/services/mention-handler.d.ts +65 -0
  182. package/dist/services/mention-handler.d.ts.map +1 -0
  183. package/dist/services/mention-handler.js +405 -0
  184. package/dist/services/mention-handler.js.map +1 -0
  185. package/dist/services/nango.d.ts +201 -0
  186. package/dist/services/nango.d.ts.map +1 -0
  187. package/dist/services/nango.js +392 -0
  188. package/dist/services/nango.js.map +1 -0
  189. package/dist/services/persistence.d.ts +131 -0
  190. package/dist/services/persistence.d.ts.map +1 -0
  191. package/dist/services/persistence.js +200 -0
  192. package/dist/services/persistence.js.map +1 -0
  193. package/dist/services/planLimits.d.ts +147 -0
  194. package/dist/services/planLimits.d.ts.map +1 -0
  195. package/dist/services/planLimits.js +335 -0
  196. package/dist/services/planLimits.js.map +1 -0
  197. package/dist/services/presence-registry.d.ts +56 -0
  198. package/dist/services/presence-registry.d.ts.map +1 -0
  199. package/dist/services/presence-registry.js +91 -0
  200. package/dist/services/presence-registry.js.map +1 -0
  201. package/dist/services/scaling-orchestrator.d.ts +159 -0
  202. package/dist/services/scaling-orchestrator.d.ts.map +1 -0
  203. package/dist/services/scaling-orchestrator.js +502 -0
  204. package/dist/services/scaling-orchestrator.js.map +1 -0
  205. package/dist/services/scaling-policy.d.ts +121 -0
  206. package/dist/services/scaling-policy.d.ts.map +1 -0
  207. package/dist/services/scaling-policy.js +415 -0
  208. package/dist/services/scaling-policy.js.map +1 -0
  209. package/dist/services/ssh-security.d.ts +31 -0
  210. package/dist/services/ssh-security.d.ts.map +1 -0
  211. package/dist/services/ssh-security.js +63 -0
  212. package/dist/services/ssh-security.js.map +1 -0
  213. package/dist/services/workspace-keepalive.d.ts +76 -0
  214. package/dist/services/workspace-keepalive.d.ts.map +1 -0
  215. package/dist/services/workspace-keepalive.js +234 -0
  216. package/dist/services/workspace-keepalive.js.map +1 -0
  217. package/dist/shims/consensus.d.ts +23 -0
  218. package/dist/shims/consensus.d.ts.map +1 -0
  219. package/dist/shims/consensus.js +5 -0
  220. package/dist/shims/consensus.js.map +1 -0
  221. package/dist/webhooks/index.d.ts +24 -0
  222. package/dist/webhooks/index.d.ts.map +1 -0
  223. package/dist/webhooks/index.js +29 -0
  224. package/dist/webhooks/index.js.map +1 -0
  225. package/dist/webhooks/parsers/github.d.ts +8 -0
  226. package/dist/webhooks/parsers/github.d.ts.map +1 -0
  227. package/dist/webhooks/parsers/github.js +234 -0
  228. package/dist/webhooks/parsers/github.js.map +1 -0
  229. package/dist/webhooks/parsers/index.d.ts +23 -0
  230. package/dist/webhooks/parsers/index.d.ts.map +1 -0
  231. package/dist/webhooks/parsers/index.js +30 -0
  232. package/dist/webhooks/parsers/index.js.map +1 -0
  233. package/dist/webhooks/parsers/linear.d.ts +9 -0
  234. package/dist/webhooks/parsers/linear.d.ts.map +1 -0
  235. package/dist/webhooks/parsers/linear.js +258 -0
  236. package/dist/webhooks/parsers/linear.js.map +1 -0
  237. package/dist/webhooks/parsers/slack.d.ts +9 -0
  238. package/dist/webhooks/parsers/slack.d.ts.map +1 -0
  239. package/dist/webhooks/parsers/slack.js +214 -0
  240. package/dist/webhooks/parsers/slack.js.map +1 -0
  241. package/dist/webhooks/responders/github.d.ts +8 -0
  242. package/dist/webhooks/responders/github.d.ts.map +1 -0
  243. package/dist/webhooks/responders/github.js +73 -0
  244. package/dist/webhooks/responders/github.js.map +1 -0
  245. package/dist/webhooks/responders/index.d.ts +23 -0
  246. package/dist/webhooks/responders/index.d.ts.map +1 -0
  247. package/dist/webhooks/responders/index.js +30 -0
  248. package/dist/webhooks/responders/index.js.map +1 -0
  249. package/dist/webhooks/responders/linear.d.ts +9 -0
  250. package/dist/webhooks/responders/linear.d.ts.map +1 -0
  251. package/dist/webhooks/responders/linear.js +149 -0
  252. package/dist/webhooks/responders/linear.js.map +1 -0
  253. package/dist/webhooks/responders/slack.d.ts +20 -0
  254. package/dist/webhooks/responders/slack.d.ts.map +1 -0
  255. package/dist/webhooks/responders/slack.js +178 -0
  256. package/dist/webhooks/responders/slack.js.map +1 -0
  257. package/dist/webhooks/router.d.ts +25 -0
  258. package/dist/webhooks/router.d.ts.map +1 -0
  259. package/dist/webhooks/router.js +504 -0
  260. package/dist/webhooks/router.js.map +1 -0
  261. package/dist/webhooks/rules-engine.d.ts +24 -0
  262. package/dist/webhooks/rules-engine.d.ts.map +1 -0
  263. package/dist/webhooks/rules-engine.js +287 -0
  264. package/dist/webhooks/rules-engine.js.map +1 -0
  265. package/dist/webhooks/types.d.ts +186 -0
  266. package/dist/webhooks/types.d.ts.map +1 -0
  267. package/dist/webhooks/types.js +8 -0
  268. package/dist/webhooks/types.js.map +1 -0
  269. package/package.json +55 -0
@@ -0,0 +1,1799 @@
1
+ /**
2
+ * Workspaces API Routes
3
+ *
4
+ * One-click workspace provisioning and management.
5
+ * Includes auto-access based on GitHub repo permissions.
6
+ */
7
+ import { Router } from 'express';
8
+ import { requireAuth } from './auth.js';
9
+ import { db } from '../db/index.js';
10
+ import { getProvisioner, getProvisioningStage } from '../provisioner/index.js';
11
+ import { checkWorkspaceLimit } from './middleware/planLimits.js';
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
+ }
342
+ export const workspacesRouter = Router();
343
+ // All routes require authentication
344
+ workspacesRouter.use(requireAuth);
345
+ /**
346
+ * GET /api/workspaces
347
+ * List user's workspaces (owned + member workspaces)
348
+ */
349
+ workspacesRouter.get('/', async (req, res) => {
350
+ const userId = req.session.userId;
351
+ try {
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());
364
+ res.json({
365
+ workspaces: allWorkspaces.map((w) => ({
366
+ id: w.id,
367
+ name: w.name,
368
+ status: w.status,
369
+ publicUrl: w.publicUrl,
370
+ providers: w.config.providers,
371
+ repositories: w.config.repositories,
372
+ createdAt: w.createdAt,
373
+ isOwner: w.userId === userId, // Flag to indicate ownership
374
+ })),
375
+ });
376
+ }
377
+ catch (error) {
378
+ console.error('Error listing workspaces:', error);
379
+ res.status(500).json({ error: 'Failed to list workspaces' });
380
+ }
381
+ });
382
+ /**
383
+ * POST /api/workspaces
384
+ * Create (provision) a new workspace
385
+ */
386
+ workspacesRouter.post('/', checkWorkspaceLimit, async (req, res) => {
387
+ const userId = req.session.userId;
388
+ const { name, providers, repositories, supervisorEnabled, maxAgents } = req.body;
389
+ // Validation
390
+ if (!name || typeof name !== 'string') {
391
+ return res.status(400).json({ error: 'Name is required' });
392
+ }
393
+ if (!providers || !Array.isArray(providers) || providers.length === 0) {
394
+ return res.status(400).json({ error: 'At least one provider is required' });
395
+ }
396
+ if (!repositories || !Array.isArray(repositories)) {
397
+ return res.status(400).json({ error: 'Repositories array is required' });
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
+ }
426
+ // Verify user has credentials for all providers
427
+ const credentials = await db.credentials.findByUserId(userId);
428
+ const connectedProviders = new Set(credentials.map((c) => c.provider));
429
+ for (const provider of providers) {
430
+ if (!connectedProviders.has(provider)) {
431
+ return res.status(400).json({
432
+ error: `Provider ${provider} not connected. Please connect it first.`,
433
+ });
434
+ }
435
+ }
436
+ try {
437
+ const provisioner = getProvisioner();
438
+ const result = await provisioner.provision({
439
+ userId,
440
+ name,
441
+ providers,
442
+ repositories,
443
+ supervisorEnabled,
444
+ maxAgents,
445
+ });
446
+ if (result.status === 'error') {
447
+ return res.status(500).json({
448
+ error: 'Failed to provision workspace',
449
+ details: result.error,
450
+ });
451
+ }
452
+ res.status(201).json({
453
+ workspaceId: result.workspaceId,
454
+ status: result.status,
455
+ publicUrl: result.publicUrl,
456
+ });
457
+ }
458
+ catch (error) {
459
+ console.error('Error creating workspace:', error);
460
+ res.status(500).json({ error: 'Failed to create workspace' });
461
+ }
462
+ });
463
+ /**
464
+ * GET /api/workspaces/summary
465
+ * Get summary of all user workspaces for dashboard status indicator (owned + member workspaces)
466
+ * NOTE: This route MUST be before /:id to avoid being caught by parameterized route
467
+ */
468
+ workspacesRouter.get('/summary', async (req, res) => {
469
+ const userId = req.session.userId;
470
+ try {
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];
482
+ const provisioner = getProvisioner();
483
+ // Get live status for each workspace
484
+ const workspaceSummaries = await Promise.all(workspaces.map(async (w) => {
485
+ let liveStatus = w.status;
486
+ try {
487
+ liveStatus = await provisioner.getStatus(w.id);
488
+ }
489
+ catch {
490
+ // Fall back to DB status
491
+ }
492
+ return {
493
+ id: w.id,
494
+ name: w.name,
495
+ status: liveStatus,
496
+ publicUrl: w.publicUrl,
497
+ isStopped: liveStatus === 'stopped',
498
+ isRunning: liveStatus === 'running',
499
+ isProvisioning: liveStatus === 'provisioning',
500
+ hasError: liveStatus === 'error',
501
+ };
502
+ }));
503
+ // Overall status for quick dashboard indicator
504
+ const hasRunningWorkspace = workspaceSummaries.some(w => w.isRunning);
505
+ const hasStoppedWorkspace = workspaceSummaries.some(w => w.isStopped);
506
+ const hasProvisioningWorkspace = workspaceSummaries.some(w => w.isProvisioning);
507
+ res.json({
508
+ workspaces: workspaceSummaries,
509
+ summary: {
510
+ total: workspaceSummaries.length,
511
+ running: workspaceSummaries.filter(w => w.isRunning).length,
512
+ stopped: workspaceSummaries.filter(w => w.isStopped).length,
513
+ provisioning: workspaceSummaries.filter(w => w.isProvisioning).length,
514
+ error: workspaceSummaries.filter(w => w.hasError).length,
515
+ },
516
+ overallStatus: hasRunningWorkspace
517
+ ? 'ready'
518
+ : hasProvisioningWorkspace
519
+ ? 'provisioning'
520
+ : hasStoppedWorkspace
521
+ ? 'stopped'
522
+ : workspaceSummaries.length === 0
523
+ ? 'none'
524
+ : 'error',
525
+ });
526
+ }
527
+ catch (error) {
528
+ console.error('Error getting workspace summary:', error);
529
+ res.status(500).json({ error: 'Failed to get workspace summary' });
530
+ }
531
+ });
532
+ /**
533
+ * GET /api/workspaces/primary
534
+ * Get the user's primary workspace (first/default) with live status (owned + member workspaces)
535
+ * Used by dashboard to show quick status indicator
536
+ * NOTE: This route MUST be before /:id to avoid being caught by parameterized route
537
+ */
538
+ workspacesRouter.get('/primary', async (req, res) => {
539
+ const userId = req.session.userId;
540
+ try {
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];
552
+ if (workspaces.length === 0) {
553
+ return res.json({
554
+ exists: false,
555
+ message: 'No workspace found. Connect a repository to auto-provision one.',
556
+ });
557
+ }
558
+ const primary = workspaces[0];
559
+ const provisioner = getProvisioner();
560
+ let liveStatus = primary.status;
561
+ try {
562
+ liveStatus = await provisioner.getStatus(primary.id);
563
+ }
564
+ catch {
565
+ // Fall back to DB status
566
+ }
567
+ res.json({
568
+ exists: true,
569
+ workspace: {
570
+ id: primary.id,
571
+ name: primary.name,
572
+ status: liveStatus,
573
+ publicUrl: primary.publicUrl,
574
+ isStopped: liveStatus === 'stopped',
575
+ isRunning: liveStatus === 'running',
576
+ isProvisioning: liveStatus === 'provisioning',
577
+ hasError: liveStatus === 'error',
578
+ config: {
579
+ providers: primary.config.providers || [],
580
+ repositories: primary.config.repositories || [],
581
+ },
582
+ },
583
+ // Quick messages for UI
584
+ statusMessage: liveStatus === 'running'
585
+ ? 'Workspace is running'
586
+ : liveStatus === 'stopped'
587
+ ? 'Workspace is idle (will start automatically when needed)'
588
+ : liveStatus === 'provisioning'
589
+ ? 'Workspace is being provisioned...'
590
+ : 'Workspace has an error',
591
+ actionNeeded: liveStatus === 'stopped'
592
+ ? 'wakeup'
593
+ : liveStatus === 'error'
594
+ ? 'check_error'
595
+ : null,
596
+ });
597
+ }
598
+ catch (error) {
599
+ console.error('Error getting primary workspace:', error);
600
+ res.status(500).json({ error: 'Failed to get primary workspace' });
601
+ }
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
+ });
731
+ /**
732
+ * GET /api/workspaces/:id
733
+ * Get workspace details
734
+ * Uses requireWorkspaceAccess middleware for auto-access via GitHub repos
735
+ */
736
+ workspacesRouter.get('/:id', requireWorkspaceAccess, async (req, res) => {
737
+ const id = req.params.id;
738
+ const _workspaceAccess = req.workspaceAccess;
739
+ try {
740
+ const workspace = await db.workspaces.findById(id);
741
+ if (!workspace) {
742
+ return res.status(404).json({ error: 'Workspace not found' });
743
+ }
744
+ // Get repositories assigned to this workspace
745
+ const repositories = await db.repositories.findByWorkspaceId(id);
746
+ res.json({
747
+ id: workspace.id,
748
+ name: workspace.name,
749
+ status: workspace.status,
750
+ publicUrl: workspace.publicUrl,
751
+ computeProvider: workspace.computeProvider,
752
+ config: workspace.config,
753
+ errorMessage: workspace.errorMessage,
754
+ repositories: repositories.map((r) => ({
755
+ id: r.id,
756
+ fullName: r.githubFullName,
757
+ syncStatus: r.syncStatus,
758
+ lastSyncedAt: r.lastSyncedAt,
759
+ })),
760
+ createdAt: workspace.createdAt,
761
+ updatedAt: workspace.updatedAt,
762
+ });
763
+ }
764
+ catch (error) {
765
+ console.error('Error getting workspace:', error);
766
+ res.status(500).json({ error: 'Failed to get workspace' });
767
+ }
768
+ });
769
+ /**
770
+ * GET /api/workspaces/:id/status
771
+ * Get current workspace status (polls compute provider)
772
+ */
773
+ workspacesRouter.get('/:id/status', async (req, res) => {
774
+ const userId = req.session.userId;
775
+ const id = req.params.id;
776
+ try {
777
+ const workspace = await db.workspaces.findById(id);
778
+ if (!workspace) {
779
+ return res.status(404).json({ error: 'Workspace not found' });
780
+ }
781
+ if (workspace.userId !== userId) {
782
+ return res.status(403).json({ error: 'Unauthorized' });
783
+ }
784
+ const provisioner = getProvisioner();
785
+ const status = await provisioner.getStatus(id);
786
+ // Include provisioning progress info if it exists (even after status changes to 'running')
787
+ // This allows the frontend to see all stages including 'complete'
788
+ const provisioningProgress = getProvisioningStage(id);
789
+ res.json({
790
+ status,
791
+ provisioning: provisioningProgress ? {
792
+ stage: provisioningProgress.stage,
793
+ startedAt: provisioningProgress.startedAt,
794
+ elapsedMs: Date.now() - provisioningProgress.startedAt,
795
+ } : null,
796
+ });
797
+ }
798
+ catch (error) {
799
+ console.error('Error getting workspace status:', error);
800
+ res.status(500).json({ error: 'Failed to get status' });
801
+ }
802
+ });
803
+ /**
804
+ * POST /api/workspaces/:id/restart
805
+ * Restart a workspace
806
+ */
807
+ workspacesRouter.post('/:id/restart', async (req, res) => {
808
+ const userId = req.session.userId;
809
+ const id = req.params.id;
810
+ try {
811
+ const workspace = await db.workspaces.findById(id);
812
+ if (!workspace) {
813
+ return res.status(404).json({ error: 'Workspace not found' });
814
+ }
815
+ if (workspace.userId !== userId) {
816
+ return res.status(403).json({ error: 'Unauthorized' });
817
+ }
818
+ const provisioner = getProvisioner();
819
+ await provisioner.restart(id);
820
+ res.json({ success: true, message: 'Workspace restarting' });
821
+ }
822
+ catch (error) {
823
+ console.error('Error restarting workspace:', error);
824
+ res.status(500).json({ error: 'Failed to restart workspace' });
825
+ }
826
+ });
827
+ /**
828
+ * POST /api/workspaces/:id/stop
829
+ * Stop a workspace
830
+ */
831
+ workspacesRouter.post('/:id/stop', async (req, res) => {
832
+ const userId = req.session.userId;
833
+ const id = req.params.id;
834
+ try {
835
+ const workspace = await db.workspaces.findById(id);
836
+ if (!workspace) {
837
+ return res.status(404).json({ error: 'Workspace not found' });
838
+ }
839
+ if (workspace.userId !== userId) {
840
+ return res.status(403).json({ error: 'Unauthorized' });
841
+ }
842
+ const provisioner = getProvisioner();
843
+ await provisioner.stop(id);
844
+ res.json({ success: true, message: 'Workspace stopped' });
845
+ }
846
+ catch (error) {
847
+ console.error('Error stopping workspace:', error);
848
+ res.status(500).json({ error: 'Failed to stop workspace' });
849
+ }
850
+ });
851
+ /**
852
+ * DELETE /api/workspaces/:id
853
+ * Delete (deprovision) a workspace
854
+ */
855
+ workspacesRouter.delete('/:id', async (req, res) => {
856
+ const userId = req.session.userId;
857
+ const id = req.params.id;
858
+ try {
859
+ const workspace = await db.workspaces.findById(id);
860
+ if (!workspace) {
861
+ return res.status(404).json({ error: 'Workspace not found' });
862
+ }
863
+ if (workspace.userId !== userId) {
864
+ return res.status(403).json({ error: 'Unauthorized' });
865
+ }
866
+ const provisioner = getProvisioner();
867
+ await provisioner.deprovision(id);
868
+ res.json({ success: true, message: 'Workspace deleted' });
869
+ }
870
+ catch (error) {
871
+ console.error('Error deleting workspace:', error);
872
+ res.status(500).json({ error: 'Failed to delete workspace' });
873
+ }
874
+ });
875
+ /**
876
+ * POST /api/workspaces/:id/repos
877
+ * Add repositories to a workspace
878
+ */
879
+ workspacesRouter.post('/:id/repos', async (req, res) => {
880
+ const userId = req.session.userId;
881
+ const id = req.params.id;
882
+ const { repositoryIds } = req.body;
883
+ if (!repositoryIds || !Array.isArray(repositoryIds)) {
884
+ return res.status(400).json({ error: 'repositoryIds array is required' });
885
+ }
886
+ try {
887
+ const workspace = await db.workspaces.findById(id);
888
+ if (!workspace) {
889
+ return res.status(404).json({ error: 'Workspace not found' });
890
+ }
891
+ if (workspace.userId !== userId) {
892
+ return res.status(403).json({ error: 'Unauthorized' });
893
+ }
894
+ const reposToAssign = [];
895
+ const repoFullNames = [];
896
+ for (const repoId of repositoryIds) {
897
+ const repo = await db.repositories.findById(repoId);
898
+ if (!repo || repo.userId !== userId) {
899
+ return res.status(404).json({ error: 'Repository not found' });
900
+ }
901
+ if (repo.workspaceId && repo.workspaceId !== id) {
902
+ return res.status(409).json({
903
+ error: 'Repository already linked to another workspace',
904
+ workspaceId: repo.workspaceId,
905
+ });
906
+ }
907
+ if (!repo.installationId) {
908
+ return res.status(400).json({
909
+ error: 'Repository not authorized via GitHub App',
910
+ message: 'Install the GitHub App for this repository before adding it to a workspace.',
911
+ });
912
+ }
913
+ reposToAssign.push(repo.id);
914
+ repoFullNames.push(repo.githubFullName);
915
+ }
916
+ // Assign repositories to workspace
917
+ for (const repoId of reposToAssign) {
918
+ await db.repositories.assignToWorkspace(repoId, id);
919
+ }
920
+ // Update workspace config repositories list
921
+ const existingRepos = workspace.config.repositories ?? [];
922
+ const updatedRepos = Array.from(new Set([...existingRepos, ...repoFullNames]));
923
+ if (updatedRepos.length !== existingRepos.length) {
924
+ await db.workspaces.updateConfig(id, {
925
+ ...workspace.config,
926
+ repositories: updatedRepos,
927
+ });
928
+ }
929
+ res.json({ success: true, message: 'Repositories added' });
930
+ }
931
+ catch (error) {
932
+ console.error('Error adding repos to workspace:', error);
933
+ res.status(500).json({ error: 'Failed to add repositories' });
934
+ }
935
+ });
936
+ /**
937
+ * GET /api/workspaces/:id/repos
938
+ * List repositories linked to a workspace
939
+ */
940
+ workspacesRouter.get('/:id/repos', async (req, res) => {
941
+ const userId = req.session.userId;
942
+ const id = req.params.id;
943
+ try {
944
+ const workspace = await db.workspaces.findById(id);
945
+ if (!workspace) {
946
+ return res.status(404).json({ error: 'Workspace not found' });
947
+ }
948
+ // Check access (owner, member, or contributor)
949
+ const accessResult = await checkWorkspaceAccess(userId, id);
950
+ if (!accessResult.hasAccess) {
951
+ return res.status(403).json({ error: 'Unauthorized' });
952
+ }
953
+ // Get repos linked to this workspace
954
+ const repos = await db.repositories.findByWorkspaceId(id);
955
+ res.json({
956
+ repositories: repos.map(r => ({
957
+ id: r.id,
958
+ githubFullName: r.githubFullName,
959
+ defaultBranch: r.defaultBranch,
960
+ isPrivate: r.isPrivate,
961
+ syncStatus: r.syncStatus,
962
+ lastSyncedAt: r.lastSyncedAt,
963
+ })),
964
+ });
965
+ }
966
+ catch (error) {
967
+ console.error('Error listing workspace repos:', error);
968
+ res.status(500).json({ error: 'Failed to list repositories' });
969
+ }
970
+ });
971
+ /**
972
+ * GET /api/workspaces/:id/repo-collaborators
973
+ * Get all collaborators from repos linked to this workspace
974
+ * These are users who have access via GitHub repo permissions (grandfathered in)
975
+ */
976
+ workspacesRouter.get('/:id/repo-collaborators', async (req, res) => {
977
+ const userId = req.session.userId;
978
+ const id = req.params.id;
979
+ try {
980
+ const workspace = await db.workspaces.findById(id);
981
+ if (!workspace) {
982
+ return res.status(404).json({ error: 'Workspace not found' });
983
+ }
984
+ // Check access (owner, member, or contributor)
985
+ const accessResult = await checkWorkspaceAccess(userId, id);
986
+ if (!accessResult.hasAccess) {
987
+ return res.status(403).json({ error: 'Unauthorized' });
988
+ }
989
+ // Get repos linked to this workspace
990
+ const repos = await db.repositories.findByWorkspaceId(id);
991
+ if (repos.length === 0) {
992
+ return res.json({ collaborators: [] });
993
+ }
994
+ // Find a repo with a Nango connection (GitHub App)
995
+ const repoWithConnection = repos.find(r => r.nangoConnectionId);
996
+ if (!repoWithConnection?.nangoConnectionId) {
997
+ return res.json({
998
+ collaborators: [],
999
+ message: 'GitHub App not connected for this workspace',
1000
+ });
1001
+ }
1002
+ // Get the workspace owner for filtering
1003
+ const owner = await db.users.findById(workspace.userId);
1004
+ // Fetch collaborators for each repo and deduplicate
1005
+ const collaboratorsMap = new Map();
1006
+ // Get existing workspace members to exclude them
1007
+ const existingMembers = await db.workspaceMembers.findByWorkspaceId(id);
1008
+ // Also get the workspace owner's GitHub ID to exclude
1009
+ const ownerGithubId = owner?.githubId ? Number(owner.githubId) : null;
1010
+ for (const repo of repos) {
1011
+ // Use this repo's connection if it has one, otherwise use the shared connection
1012
+ const connectionId = repo.nangoConnectionId || repoWithConnection.nangoConnectionId;
1013
+ if (!connectionId)
1014
+ continue;
1015
+ try {
1016
+ const [repoOwner, repoName] = repo.githubFullName.split('/');
1017
+ const collabs = await nangoService.listRepoCollaborators(connectionId, repoOwner, repoName);
1018
+ for (const collab of collabs) {
1019
+ // Skip the workspace owner
1020
+ if (ownerGithubId && collab.id === ownerGithubId) {
1021
+ continue;
1022
+ }
1023
+ const existing = collaboratorsMap.get(collab.id);
1024
+ if (existing) {
1025
+ // Add this repo to their list
1026
+ if (!existing.repos.includes(repo.githubFullName)) {
1027
+ existing.repos.push(repo.githubFullName);
1028
+ }
1029
+ // Upgrade permission if this repo gives higher access
1030
+ if (collab.permission === 'admin' && existing.permission !== 'admin') {
1031
+ existing.permission = 'admin';
1032
+ }
1033
+ else if (collab.permission === 'write' && existing.permission === 'read') {
1034
+ existing.permission = 'write';
1035
+ }
1036
+ }
1037
+ else {
1038
+ collaboratorsMap.set(collab.id, {
1039
+ id: collab.id,
1040
+ login: collab.login,
1041
+ avatarUrl: collab.avatarUrl,
1042
+ permission: collab.permission,
1043
+ repos: [repo.githubFullName],
1044
+ });
1045
+ }
1046
+ }
1047
+ }
1048
+ catch (err) {
1049
+ console.warn(`[workspace-collaborators] Failed to fetch collaborators for ${repo.githubFullName}:`, err);
1050
+ // Continue with other repos
1051
+ }
1052
+ }
1053
+ // Filter out users who are already workspace members
1054
+ // We need to check by GitHub username since we don't have their user IDs
1055
+ const workspaceMemberUsernames = new Set();
1056
+ for (const member of existingMembers) {
1057
+ const memberUser = await db.users.findById(member.userId);
1058
+ if (memberUser?.githubUsername) {
1059
+ workspaceMemberUsernames.add(memberUser.githubUsername.toLowerCase());
1060
+ }
1061
+ }
1062
+ // Also add workspace owner
1063
+ if (owner?.githubUsername) {
1064
+ workspaceMemberUsernames.add(owner.githubUsername.toLowerCase());
1065
+ }
1066
+ const collaborators = Array.from(collaboratorsMap.values())
1067
+ .filter(c => !workspaceMemberUsernames.has(c.login.toLowerCase()))
1068
+ .sort((a, b) => {
1069
+ // Sort by permission level (admin > write > read), then by username
1070
+ const permOrder = { admin: 0, write: 1, read: 2, none: 3 };
1071
+ if (permOrder[a.permission] !== permOrder[b.permission]) {
1072
+ return permOrder[a.permission] - permOrder[b.permission];
1073
+ }
1074
+ return a.login.localeCompare(b.login);
1075
+ });
1076
+ res.json({
1077
+ collaborators,
1078
+ totalRepos: repos.length,
1079
+ });
1080
+ }
1081
+ catch (error) {
1082
+ console.error('Error fetching repo collaborators:', error);
1083
+ res.status(500).json({ error: 'Failed to fetch collaborators' });
1084
+ }
1085
+ });
1086
+ /**
1087
+ * DELETE /api/workspaces/:id/repos/:repoId
1088
+ * Remove a repository from a workspace
1089
+ */
1090
+ workspacesRouter.delete('/:id/repos/:repoId', async (req, res) => {
1091
+ const userId = req.session.userId;
1092
+ const id = req.params.id;
1093
+ const repoId = req.params.repoId;
1094
+ try {
1095
+ const workspace = await db.workspaces.findById(id);
1096
+ if (!workspace) {
1097
+ return res.status(404).json({ error: 'Workspace not found' });
1098
+ }
1099
+ // Only owner can remove repos
1100
+ if (workspace.userId !== userId) {
1101
+ return res.status(403).json({ error: 'Only workspace owner can remove repositories' });
1102
+ }
1103
+ // Unlink repo from workspace (set workspaceId to null)
1104
+ await db.repositories.assignToWorkspace(repoId, null);
1105
+ // Also update workspace config to remove from repositories array
1106
+ const currentRepos = workspace.config.repositories || [];
1107
+ const repo = await db.repositories.findById(repoId);
1108
+ if (repo) {
1109
+ const updatedRepos = currentRepos.filter(r => r.toLowerCase() !== repo.githubFullName.toLowerCase());
1110
+ await db.workspaces.update(id, {
1111
+ config: { ...workspace.config, repositories: updatedRepos },
1112
+ });
1113
+ }
1114
+ res.json({ success: true, message: 'Repository removed from workspace' });
1115
+ }
1116
+ catch (error) {
1117
+ console.error('Error removing repo from workspace:', error);
1118
+ res.status(500).json({ error: 'Failed to remove repository' });
1119
+ }
1120
+ });
1121
+ /**
1122
+ * PATCH /api/workspaces/:id
1123
+ * Update workspace settings (name, etc.)
1124
+ */
1125
+ workspacesRouter.patch('/:id', async (req, res) => {
1126
+ const userId = req.session.userId;
1127
+ const id = req.params.id;
1128
+ const { name } = req.body;
1129
+ try {
1130
+ const workspace = await db.workspaces.findById(id);
1131
+ if (!workspace) {
1132
+ return res.status(404).json({ error: 'Workspace not found' });
1133
+ }
1134
+ // Only owner can rename
1135
+ if (workspace.userId !== userId) {
1136
+ return res.status(403).json({ error: 'Only workspace owner can update settings' });
1137
+ }
1138
+ // Validate name if provided
1139
+ if (name !== undefined) {
1140
+ if (typeof name !== 'string' || name.trim().length === 0) {
1141
+ return res.status(400).json({ error: 'Name must be a non-empty string' });
1142
+ }
1143
+ if (name.length > 100) {
1144
+ return res.status(400).json({ error: 'Name must be 100 characters or less' });
1145
+ }
1146
+ }
1147
+ // Update workspace
1148
+ await db.workspaces.update(id, {
1149
+ ...(name && { name: name.trim() }),
1150
+ });
1151
+ const updated = await db.workspaces.findById(id);
1152
+ res.json({
1153
+ success: true,
1154
+ workspace: {
1155
+ id: updated.id,
1156
+ name: updated.name,
1157
+ status: updated.status,
1158
+ publicUrl: updated.publicUrl,
1159
+ },
1160
+ });
1161
+ }
1162
+ catch (error) {
1163
+ console.error('Error updating workspace:', error);
1164
+ res.status(500).json({ error: 'Failed to update workspace' });
1165
+ }
1166
+ });
1167
+ /**
1168
+ * POST /api/workspaces/:id/autoscale
1169
+ * Trigger auto-scaling based on current agent count
1170
+ * Supports both user session auth and workspace token auth
1171
+ * Called by workspace container when spawning new agents
1172
+ */
1173
+ workspacesRouter.post('/:id/autoscale', async (req, res) => {
1174
+ const id = req.params.id;
1175
+ const { agentCount } = req.body;
1176
+ if (typeof agentCount !== 'number' || agentCount < 0) {
1177
+ return res.status(400).json({ error: 'agentCount must be a non-negative number' });
1178
+ }
1179
+ try {
1180
+ const workspace = await db.workspaces.findById(id);
1181
+ if (!workspace) {
1182
+ return res.status(404).json({ error: 'Workspace not found' });
1183
+ }
1184
+ // Verify auth: either user session or workspace token
1185
+ const userId = req.session?.userId;
1186
+ const authHeader = req.get('authorization');
1187
+ if (userId) {
1188
+ // User session auth
1189
+ if (workspace.userId !== userId) {
1190
+ return res.status(403).json({ error: 'Unauthorized' });
1191
+ }
1192
+ }
1193
+ else if (authHeader?.startsWith('Bearer ')) {
1194
+ // Workspace token auth (for calls from within the workspace)
1195
+ const crypto = await import('crypto');
1196
+ const config = getConfig();
1197
+ const providedToken = authHeader.slice(7);
1198
+ const expectedToken = crypto.default
1199
+ .createHmac('sha256', config.sessionSecret)
1200
+ .update(`workspace:${id}`)
1201
+ .digest('hex');
1202
+ const isValid = crypto.default.timingSafeEqual(Buffer.from(providedToken), Buffer.from(expectedToken));
1203
+ if (!isValid) {
1204
+ return res.status(401).json({ error: 'Invalid workspace token' });
1205
+ }
1206
+ }
1207
+ else {
1208
+ return res.status(401).json({ error: 'Authentication required' });
1209
+ }
1210
+ const provisioner = getProvisioner();
1211
+ const currentTier = await provisioner.getCurrentTier(id);
1212
+ const recommendedTier = provisioner.getRecommendedTier(agentCount);
1213
+ // Check if scaling is needed
1214
+ if (recommendedTier.memoryMb <= currentTier.memoryMb) {
1215
+ return res.json({
1216
+ scaled: false,
1217
+ currentTier: currentTier.name,
1218
+ message: 'Current tier is sufficient',
1219
+ });
1220
+ }
1221
+ // Perform the scale-up (respects plan limits)
1222
+ const result = await provisioner.autoScale(id, agentCount);
1223
+ res.json({
1224
+ scaled: result.scaled,
1225
+ previousTier: result.currentTier || currentTier.name,
1226
+ newTier: result.targetTier || currentTier.name,
1227
+ reason: result.reason,
1228
+ message: result.scaled
1229
+ ? `Scaled up to ${result.targetTier} tier`
1230
+ : result.reason || 'Scaling not required',
1231
+ });
1232
+ }
1233
+ catch (error) {
1234
+ console.error('Error auto-scaling workspace:', error);
1235
+ res.status(500).json({ error: 'Failed to auto-scale workspace' });
1236
+ }
1237
+ });
1238
+ /**
1239
+ * POST /api/workspaces/:id/domain
1240
+ * Add or update custom domain (Premium feature - Team/Enterprise only)
1241
+ */
1242
+ workspacesRouter.post('/:id/domain', async (req, res) => {
1243
+ const userId = req.session.userId;
1244
+ const id = req.params.id;
1245
+ const { domain } = req.body;
1246
+ if (!domain || typeof domain !== 'string') {
1247
+ return res.status(400).json({ error: 'Domain is required' });
1248
+ }
1249
+ // Basic domain validation
1250
+ const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
1251
+ if (!domainRegex.test(domain)) {
1252
+ return res.status(400).json({ error: 'Invalid domain format' });
1253
+ }
1254
+ try {
1255
+ const workspace = await db.workspaces.findById(id);
1256
+ if (!workspace) {
1257
+ return res.status(404).json({ error: 'Workspace not found' });
1258
+ }
1259
+ if (workspace.userId !== userId) {
1260
+ return res.status(403).json({ error: 'Unauthorized' });
1261
+ }
1262
+ // Check if user has premium plan (Team/Enterprise)
1263
+ const user = await db.users.findById(userId);
1264
+ const hasPremium = user?.plan === 'team' || user?.plan === 'enterprise';
1265
+ if (!hasPremium) {
1266
+ return res.status(402).json({
1267
+ error: 'Custom domains require Team or Enterprise plan',
1268
+ upgrade: '/settings/billing',
1269
+ });
1270
+ }
1271
+ // Check if domain is already in use
1272
+ const existing = await db.workspaces.findByCustomDomain(domain);
1273
+ if (existing && existing.id !== id) {
1274
+ return res.status(409).json({ error: 'Domain already in use' });
1275
+ }
1276
+ // Set the custom domain (pending verification)
1277
+ await db.workspaces.setCustomDomain(id, domain, 'pending');
1278
+ // Return DNS instructions
1279
+ res.json({
1280
+ success: true,
1281
+ domain,
1282
+ status: 'pending',
1283
+ instructions: {
1284
+ type: 'CNAME',
1285
+ name: domain,
1286
+ value: workspace.publicUrl?.replace('https://', '') || `${id}.agent-relay.com`,
1287
+ ttl: 300,
1288
+ },
1289
+ verifyEndpoint: `/api/workspaces/${id}/domain/verify`,
1290
+ message: 'Add the CNAME record to your DNS, then call the verify endpoint',
1291
+ });
1292
+ }
1293
+ catch (error) {
1294
+ console.error('Error setting custom domain:', error);
1295
+ res.status(500).json({ error: 'Failed to set custom domain' });
1296
+ }
1297
+ });
1298
+ /**
1299
+ * POST /api/workspaces/:id/domain/verify
1300
+ * Verify custom domain DNS is configured correctly
1301
+ */
1302
+ workspacesRouter.post('/:id/domain/verify', async (req, res) => {
1303
+ const userId = req.session.userId;
1304
+ const id = req.params.id;
1305
+ try {
1306
+ const workspace = await db.workspaces.findById(id);
1307
+ if (!workspace) {
1308
+ return res.status(404).json({ error: 'Workspace not found' });
1309
+ }
1310
+ if (workspace.userId !== userId) {
1311
+ return res.status(403).json({ error: 'Unauthorized' });
1312
+ }
1313
+ if (!workspace.customDomain) {
1314
+ return res.status(400).json({ error: 'No custom domain configured' });
1315
+ }
1316
+ // Verify DNS resolution
1317
+ const dns = await import('dns').then(m => m.promises);
1318
+ try {
1319
+ const records = await dns.resolveCname(workspace.customDomain);
1320
+ const expectedTarget = workspace.publicUrl?.replace('https://', '') || `${id}.agent-relay.com`;
1321
+ if (records.some(r => r.includes(expectedTarget) || r.includes('agentrelay'))) {
1322
+ // DNS is configured, now provision SSL cert
1323
+ await db.workspaces.updateCustomDomainStatus(id, 'verifying');
1324
+ // Trigger SSL cert provisioning on compute provider
1325
+ // For Railway/Fly, this is automatic once domain is added
1326
+ await provisionDomainSSL(workspace);
1327
+ await db.workspaces.updateCustomDomainStatus(id, 'active');
1328
+ res.json({
1329
+ success: true,
1330
+ status: 'active',
1331
+ domain: workspace.customDomain,
1332
+ message: 'Custom domain verified and SSL certificate provisioned',
1333
+ });
1334
+ }
1335
+ else {
1336
+ res.status(400).json({
1337
+ success: false,
1338
+ status: 'pending',
1339
+ error: 'DNS not configured correctly',
1340
+ expected: expectedTarget,
1341
+ found: records,
1342
+ });
1343
+ }
1344
+ }
1345
+ catch (_dnsError) {
1346
+ res.status(400).json({
1347
+ success: false,
1348
+ status: 'pending',
1349
+ error: 'Could not resolve domain. DNS may not be configured yet.',
1350
+ });
1351
+ }
1352
+ }
1353
+ catch (error) {
1354
+ console.error('Error verifying domain:', error);
1355
+ res.status(500).json({ error: 'Failed to verify domain' });
1356
+ }
1357
+ });
1358
+ /**
1359
+ * DELETE /api/workspaces/:id/domain
1360
+ * Remove custom domain
1361
+ */
1362
+ workspacesRouter.delete('/:id/domain', async (req, res) => {
1363
+ const userId = req.session.userId;
1364
+ const id = req.params.id;
1365
+ try {
1366
+ const workspace = await db.workspaces.findById(id);
1367
+ if (!workspace) {
1368
+ return res.status(404).json({ error: 'Workspace not found' });
1369
+ }
1370
+ if (workspace.userId !== userId) {
1371
+ return res.status(403).json({ error: 'Unauthorized' });
1372
+ }
1373
+ // Remove from compute provider
1374
+ if (workspace.customDomain) {
1375
+ await removeDomainFromCompute(workspace);
1376
+ }
1377
+ await db.workspaces.removeCustomDomain(id);
1378
+ res.json({ success: true, message: 'Custom domain removed' });
1379
+ }
1380
+ catch (error) {
1381
+ console.error('Error removing domain:', error);
1382
+ res.status(500).json({ error: 'Failed to remove domain' });
1383
+ }
1384
+ });
1385
+ /**
1386
+ * Helper: Provision SSL for custom domain on compute provider
1387
+ */
1388
+ async function provisionDomainSSL(workspace) {
1389
+ const config = (await import('../config.js')).getConfig();
1390
+ if (workspace.computeProvider === 'fly' && config.compute.fly) {
1391
+ // Fly.io: Add certificate
1392
+ await fetch(`https://api.machines.dev/v1/apps/ar-${workspace.id.substring(0, 8)}/certificates`, {
1393
+ method: 'POST',
1394
+ headers: {
1395
+ Authorization: `Bearer ${config.compute.fly.apiToken}`,
1396
+ 'Content-Type': 'application/json',
1397
+ },
1398
+ body: JSON.stringify({ hostname: workspace.customDomain }),
1399
+ });
1400
+ }
1401
+ else if (workspace.computeProvider === 'railway' && config.compute.railway) {
1402
+ // Railway: Add custom domain via GraphQL
1403
+ await fetch('https://backboard.railway.app/graphql/v2', {
1404
+ method: 'POST',
1405
+ headers: {
1406
+ Authorization: `Bearer ${config.compute.railway.apiToken}`,
1407
+ 'Content-Type': 'application/json',
1408
+ },
1409
+ body: JSON.stringify({
1410
+ query: `
1411
+ mutation AddCustomDomain($input: CustomDomainCreateInput!) {
1412
+ customDomainCreate(input: $input) { id }
1413
+ }
1414
+ `,
1415
+ variables: {
1416
+ input: {
1417
+ projectId: workspace.computeId,
1418
+ domain: workspace.customDomain,
1419
+ },
1420
+ },
1421
+ }),
1422
+ });
1423
+ }
1424
+ // Docker: Would need reverse proxy config (Caddy/nginx)
1425
+ }
1426
+ /**
1427
+ * Helper: Remove custom domain from compute provider
1428
+ */
1429
+ async function removeDomainFromCompute(workspace) {
1430
+ const config = (await import('../config.js')).getConfig();
1431
+ if (workspace.computeProvider === 'fly' && config.compute.fly) {
1432
+ await fetch(`https://api.machines.dev/v1/apps/ar-${workspace.id.substring(0, 8)}/certificates/${workspace.customDomain}`, {
1433
+ method: 'DELETE',
1434
+ headers: { Authorization: `Bearer ${config.compute.fly.apiToken}` },
1435
+ });
1436
+ }
1437
+ // Railway and Docker: similar cleanup
1438
+ }
1439
+ /**
1440
+ * POST /api/workspaces/:id/proxy/*
1441
+ * Proxy API requests to the workspace container
1442
+ * This allows the dashboard to make REST calls through the cloud server
1443
+ */
1444
+ workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
1445
+ const userId = req.session.userId;
1446
+ const id = req.params.id;
1447
+ // Express 5 wildcard params return an array of path segments, not a slash-separated string
1448
+ const proxyPathParam = req.params.proxyPath;
1449
+ const proxyPath = Array.isArray(proxyPathParam) ? proxyPathParam.join('/') : proxyPathParam;
1450
+ try {
1451
+ const workspace = await db.workspaces.findById(id);
1452
+ if (!workspace) {
1453
+ return res.status(404).json({ error: 'Workspace not found' });
1454
+ }
1455
+ // Check if user is owner or has workspace membership
1456
+ if (workspace.userId !== userId) {
1457
+ // Check workspace membership
1458
+ const membership = await db.workspaceMembers.findMembership(id, userId);
1459
+ if (!membership || !membership.acceptedAt) {
1460
+ return res.status(403).json({ error: 'Unauthorized - not a workspace member' });
1461
+ }
1462
+ // Viewers can only proxy read-only requests
1463
+ if (membership.role === 'viewer' && req.method !== 'GET') {
1464
+ return res.status(403).json({ error: 'Viewers can only make read-only requests' });
1465
+ }
1466
+ // Members and admins can read and write
1467
+ // For now, allow all proxy requests for members and admins
1468
+ }
1469
+ if (workspace.status !== 'running' || !workspace.publicUrl) {
1470
+ return res.status(400).json({ error: 'Workspace is not running' });
1471
+ }
1472
+ // Determine the internal URL for proxying
1473
+ // When running inside Docker or Fly.io, use internal networking
1474
+ let targetBaseUrl = workspace.publicUrl;
1475
+ const runningInDocker = process.env.RUNNING_IN_DOCKER === 'true';
1476
+ const runningOnFly = !!process.env.FLY_APP_NAME;
1477
+ if (runningOnFly && targetBaseUrl.includes('.fly.dev')) {
1478
+ // Use Fly.io internal networking (.internal uses IPv6, works by default)
1479
+ // ar-583f273b.fly.dev -> http://ar-583f273b.internal:3888
1480
+ const appName = targetBaseUrl.match(/https?:\/\/([^.]+)\.fly\.dev/)?.[1];
1481
+ if (appName) {
1482
+ targetBaseUrl = `http://${appName}.internal:3888`;
1483
+ }
1484
+ }
1485
+ else if (runningInDocker && workspace.computeId && targetBaseUrl.includes('localhost')) {
1486
+ // Replace localhost URL with container name for Docker networking
1487
+ // workspace.computeId is the container name (e.g., "ar-abc12345")
1488
+ // The workspace port is 3888 inside the container
1489
+ targetBaseUrl = `http://${workspace.computeId}:3888`;
1490
+ }
1491
+ // Preserve query string when proxying - this is critical for API calls like
1492
+ // /trajectory/steps?trajectoryId=xxx which need the query params forwarded
1493
+ const queryString = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
1494
+ const targetUrl = `${targetBaseUrl}/api/${proxyPath}${queryString}`;
1495
+ console.log(`[workspace-proxy] ${req.method} ${targetUrl}`);
1496
+ // Store targetUrl for error handling
1497
+ req._proxyTargetUrl = targetUrl;
1498
+ // Add timeout to prevent hanging requests
1499
+ // 45s timeout to accommodate Fly.io machine cold starts (can take 20-30s)
1500
+ const controller = new AbortController();
1501
+ const timeout = setTimeout(() => controller.abort(), 45000);
1502
+ const fetchOptions = {
1503
+ method: req.method,
1504
+ headers: {
1505
+ 'Content-Type': 'application/json',
1506
+ },
1507
+ signal: controller.signal,
1508
+ };
1509
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1510
+ fetchOptions.body = JSON.stringify(req.body);
1511
+ }
1512
+ let proxyRes;
1513
+ try {
1514
+ proxyRes = await fetch(targetUrl, fetchOptions);
1515
+ }
1516
+ finally {
1517
+ clearTimeout(timeout);
1518
+ }
1519
+ console.log(`[workspace-proxy] Response: ${proxyRes.status} ${proxyRes.statusText}`);
1520
+ // Handle non-JSON responses gracefully
1521
+ const contentType = proxyRes.headers.get('content-type');
1522
+ if (contentType?.includes('application/json')) {
1523
+ const data = await proxyRes.json();
1524
+ res.status(proxyRes.status).json(data);
1525
+ }
1526
+ else {
1527
+ const text = await proxyRes.text();
1528
+ res.status(proxyRes.status).send(text);
1529
+ }
1530
+ }
1531
+ catch (error) {
1532
+ const targetUrl = req._proxyTargetUrl || 'unknown';
1533
+ console.error('[workspace-proxy] Error proxying to:', targetUrl);
1534
+ console.error('[workspace-proxy] Error details:', error);
1535
+ // Check for timeout/abort errors
1536
+ if (error instanceof Error && error.name === 'AbortError') {
1537
+ res.status(504).json({
1538
+ error: 'Workspace request timed out',
1539
+ details: 'The workspace did not respond within 45 seconds',
1540
+ targetUrl: targetUrl,
1541
+ });
1542
+ return;
1543
+ }
1544
+ // Check for connection refused (workspace not running)
1545
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1546
+ if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('fetch failed')) {
1547
+ res.status(503).json({
1548
+ error: 'Workspace is not reachable',
1549
+ details: 'The workspace container may not be running or accepting connections',
1550
+ targetUrl: targetUrl,
1551
+ });
1552
+ return;
1553
+ }
1554
+ res.status(500).json({
1555
+ error: 'Failed to proxy request to workspace',
1556
+ details: errorMessage,
1557
+ targetUrl: targetUrl, // Include target URL for debugging
1558
+ });
1559
+ }
1560
+ });
1561
+ // ============================================================================
1562
+ // Agent Management (proxied to workspace daemon)
1563
+ // ============================================================================
1564
+ /**
1565
+ * POST /api/workspaces/:id/agents
1566
+ * Spawn an agent in the workspace
1567
+ * Proxies to workspace daemon's /workspaces/:id/agents endpoint
1568
+ */
1569
+ workspacesRouter.post('/:id/agents', async (req, res) => {
1570
+ const userId = req.session.userId;
1571
+ const id = req.params.id;
1572
+ const { name, provider, task, temporary: _temporary, interactive } = req.body;
1573
+ if (!userId) {
1574
+ return res.status(401).json({ error: 'Not authenticated' });
1575
+ }
1576
+ if (!name) {
1577
+ return res.status(400).json({ error: 'Agent name is required' });
1578
+ }
1579
+ try {
1580
+ // Find workspace and verify access
1581
+ const workspace = await db.workspaces.findById(id);
1582
+ if (!workspace) {
1583
+ return res.status(404).json({ error: 'Workspace not found' });
1584
+ }
1585
+ // Check access
1586
+ const accessResult = await checkWorkspaceAccess(userId, id);
1587
+ if (!accessResult.hasAccess) {
1588
+ return res.status(403).json({ error: 'Access denied to this workspace' });
1589
+ }
1590
+ // Ensure workspace is running
1591
+ if (workspace.status !== 'running' || !workspace.publicUrl) {
1592
+ return res.status(400).json({
1593
+ error: 'Workspace is not running',
1594
+ status: workspace.status,
1595
+ });
1596
+ }
1597
+ // Proxy to workspace dashboard server's /api/spawn endpoint
1598
+ // The dashboard server expects 'cli' field (not 'provider')
1599
+ const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawn`;
1600
+ console.log(`[workspaces] Proxying agent spawn to: ${targetUrl}`);
1601
+ // Map provider ID to CLI command
1602
+ // Some providers have different CLI names than their provider ID
1603
+ const PROVIDER_CLI_MAP = {
1604
+ anthropic: 'claude',
1605
+ claude: 'claude',
1606
+ codex: 'codex',
1607
+ openai: 'codex',
1608
+ cursor: 'agent', // Cursor CLI installs as 'agent'
1609
+ droid: 'droid',
1610
+ opencode: 'opencode',
1611
+ gemini: 'gemini',
1612
+ google: 'gemini',
1613
+ };
1614
+ const cli = PROVIDER_CLI_MAP[provider || ''] || provider || 'claude';
1615
+ const response = await fetch(targetUrl, {
1616
+ method: 'POST',
1617
+ headers: { 'Content-Type': 'application/json' },
1618
+ body: JSON.stringify({
1619
+ name,
1620
+ cli, // Use mapped CLI command
1621
+ task: task || '', // Empty task = interactive mode, user responds to prompts
1622
+ interactive: interactive ?? true, // Default to interactive for setup flows
1623
+ userId,
1624
+ }),
1625
+ signal: AbortSignal.timeout(30000),
1626
+ });
1627
+ const data = await response.json();
1628
+ if (!response.ok) {
1629
+ return res.status(response.status).json(data);
1630
+ }
1631
+ res.status(201).json(data);
1632
+ }
1633
+ catch (error) {
1634
+ console.error('[workspaces] Agent spawn error:', error);
1635
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1636
+ if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('fetch failed')) {
1637
+ return res.status(503).json({
1638
+ error: 'Workspace is not reachable',
1639
+ details: 'The workspace container may not be running',
1640
+ });
1641
+ }
1642
+ res.status(500).json({
1643
+ error: 'Failed to spawn agent',
1644
+ details: errorMessage,
1645
+ });
1646
+ }
1647
+ });
1648
+ /**
1649
+ * GET /api/workspaces/:id/agents
1650
+ * List agents in the workspace
1651
+ */
1652
+ workspacesRouter.get('/:id/agents', async (req, res) => {
1653
+ const userId = req.session.userId;
1654
+ const id = req.params.id;
1655
+ if (!userId) {
1656
+ return res.status(401).json({ error: 'Not authenticated' });
1657
+ }
1658
+ try {
1659
+ const workspace = await db.workspaces.findById(id);
1660
+ if (!workspace) {
1661
+ return res.status(404).json({ error: 'Workspace not found' });
1662
+ }
1663
+ const accessResult = await checkWorkspaceAccess(userId, id);
1664
+ if (!accessResult.hasAccess) {
1665
+ return res.status(403).json({ error: 'Access denied' });
1666
+ }
1667
+ if (workspace.status !== 'running' || !workspace.publicUrl) {
1668
+ return res.status(200).json({ agents: [], workspaceId: id });
1669
+ }
1670
+ // Use dashboard server's /api/spawned endpoint
1671
+ const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawned`;
1672
+ const response = await fetch(targetUrl, {
1673
+ signal: AbortSignal.timeout(10000),
1674
+ });
1675
+ if (!response.ok) {
1676
+ return res.status(200).json({ agents: [], workspaceId: id });
1677
+ }
1678
+ const data = await response.json();
1679
+ // Transform to expected format
1680
+ res.json({ agents: data.agents || [], workspaceId: id });
1681
+ }
1682
+ catch (error) {
1683
+ console.error('[workspaces] List agents error:', error);
1684
+ res.status(200).json({ agents: [], workspaceId: id });
1685
+ }
1686
+ });
1687
+ /**
1688
+ * DELETE /api/workspaces/:id/agents/:agentName
1689
+ * Stop an agent in the workspace
1690
+ */
1691
+ workspacesRouter.delete('/:id/agents/:agentName', async (req, res) => {
1692
+ const userId = req.session.userId;
1693
+ const id = req.params.id;
1694
+ const agentName = req.params.agentName;
1695
+ if (!userId) {
1696
+ return res.status(401).json({ error: 'Not authenticated' });
1697
+ }
1698
+ try {
1699
+ const workspace = await db.workspaces.findById(id);
1700
+ if (!workspace) {
1701
+ return res.status(404).json({ error: 'Workspace not found' });
1702
+ }
1703
+ const accessResult = await checkWorkspaceAccess(userId, id);
1704
+ if (!accessResult.hasAccess) {
1705
+ return res.status(403).json({ error: 'Access denied' });
1706
+ }
1707
+ if (workspace.status !== 'running' || !workspace.publicUrl) {
1708
+ return res.status(400).json({ error: 'Workspace is not running' });
1709
+ }
1710
+ // Use dashboard server's /api/spawned/:name endpoint
1711
+ const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawned/${encodeURIComponent(agentName)}`;
1712
+ const response = await fetch(targetUrl, {
1713
+ method: 'DELETE',
1714
+ signal: AbortSignal.timeout(10000),
1715
+ });
1716
+ if (response.status === 204) {
1717
+ return res.status(204).send();
1718
+ }
1719
+ const data = await response.json();
1720
+ res.status(response.status).json(data);
1721
+ }
1722
+ catch (error) {
1723
+ console.error('[workspaces] Stop agent error:', error);
1724
+ res.status(500).json({ error: 'Failed to stop agent' });
1725
+ }
1726
+ });
1727
+ /**
1728
+ * POST /api/workspaces/quick
1729
+ * Quick provision: one-click with defaults
1730
+ * Providers are optional - can be connected after workspace creation via CLI login
1731
+ */
1732
+ workspacesRouter.post('/quick', checkWorkspaceLimit, async (req, res) => {
1733
+ const userId = req.session.userId;
1734
+ const { name, repositoryFullName } = req.body;
1735
+ if (!repositoryFullName) {
1736
+ return res.status(400).json({ error: 'Repository name is required' });
1737
+ }
1738
+ try {
1739
+ // Check if a workspace already exists for this repo
1740
+ // If so, check if user has access and return it instead of creating a duplicate
1741
+ const existingRepos = await db.repositories.findByGithubFullName(repositoryFullName);
1742
+ for (const existingRepo of existingRepos) {
1743
+ if (existingRepo.workspaceId) {
1744
+ // Check if user has access to this workspace
1745
+ const accessResult = await checkWorkspaceAccess(userId, existingRepo.workspaceId);
1746
+ if (accessResult.hasAccess) {
1747
+ const existingWorkspace = await db.workspaces.findById(existingRepo.workspaceId);
1748
+ if (existingWorkspace) {
1749
+ console.log(`[workspaces/quick] User ${userId.substring(0, 8)} has access to existing workspace ${existingWorkspace.id.substring(0, 8)} for repo ${repositoryFullName}`);
1750
+ return res.status(200).json({
1751
+ workspaceId: existingWorkspace.id,
1752
+ status: existingWorkspace.status,
1753
+ publicUrl: existingWorkspace.publicUrl,
1754
+ existingWorkspace: true,
1755
+ accessType: accessResult.accessType,
1756
+ message: `You already have ${accessResult.accessType} access to a workspace for this repository.`,
1757
+ });
1758
+ }
1759
+ }
1760
+ }
1761
+ }
1762
+ // Get user's connected providers (optional now)
1763
+ const credentials = await db.credentials.findByUserId(userId);
1764
+ const providers = credentials
1765
+ .filter((c) => c.provider !== 'github')
1766
+ .map((c) => c.provider);
1767
+ // Create workspace with defaults
1768
+ const provisioner = getProvisioner();
1769
+ const workspaceName = name || `Workspace for ${repositoryFullName}`;
1770
+ const result = await provisioner.provision({
1771
+ userId,
1772
+ name: workspaceName,
1773
+ providers: providers.length > 0 ? providers : [], // Empty is OK now
1774
+ repositories: [repositoryFullName],
1775
+ supervisorEnabled: true,
1776
+ maxAgents: 10,
1777
+ });
1778
+ if (result.status === 'error') {
1779
+ return res.status(500).json({
1780
+ error: 'Failed to provision workspace',
1781
+ details: result.error,
1782
+ });
1783
+ }
1784
+ res.status(201).json({
1785
+ workspaceId: result.workspaceId,
1786
+ status: result.status,
1787
+ publicUrl: result.publicUrl,
1788
+ providersConnected: providers.length > 0,
1789
+ message: providers.length > 0
1790
+ ? 'Workspace provisioned successfully!'
1791
+ : 'Workspace provisioned! Connect an AI provider to start using agents.',
1792
+ });
1793
+ }
1794
+ catch (error) {
1795
+ console.error('Error quick provisioning:', error);
1796
+ res.status(500).json({ error: 'Failed to provision workspace' });
1797
+ }
1798
+ });
1799
+ //# sourceMappingURL=workspaces.js.map