agent-relay 1.2.3 → 1.3.1

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 (189) 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/chunks/532-bace199897eeab37.js +9 -0
  114. package/dist/dashboard/out/_next/static/chunks/766-b54f0853794b78c3.js +1 -0
  115. package/dist/dashboard/out/_next/static/chunks/83-b51836037078006c.js +1 -0
  116. package/dist/dashboard/out/_next/static/chunks/891-6cd50de1224f70bb.js +1 -0
  117. package/dist/dashboard/out/_next/static/chunks/899-fc02ed79e3de4302.js +1 -0
  118. package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/{page-3fdfa60e53f2810d.js → page-8553743baca53a00.js} +1 -1
  119. package/dist/dashboard/out/_next/static/chunks/app/app/page-c617745b81344f4f.js +1 -0
  120. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-f829604fb75a831a.js +1 -0
  121. package/dist/dashboard/out/_next/static/chunks/app/{page-77e9c65420a06cfb.js → page-dc786c183425c2ac.js} +1 -1
  122. package/dist/dashboard/out/_next/static/chunks/app/providers/page-84322991d7244499.js +1 -0
  123. package/dist/dashboard/out/_next/static/chunks/app/providers/setup/[provider]/page-05606941a8e2be83.js +1 -0
  124. package/dist/dashboard/out/_next/static/chunks/{main-ed4e1fb6f29c34cf.js → main-2ee6beb2ae96d210.js} +1 -1
  125. package/dist/dashboard/out/_next/static/css/48a8fbe3e659080e.css +1 -0
  126. package/dist/dashboard/out/_next/static/css/fe4b28883eeff359.css +1 -0
  127. package/dist/dashboard/out/_next/static/sDcbGRTYLcpPvyTs_rsNb/_ssgManifest.js +1 -0
  128. package/dist/dashboard/out/app/onboarding.html +1 -1
  129. package/dist/dashboard/out/app/onboarding.txt +3 -3
  130. package/dist/dashboard/out/app.html +1 -1
  131. package/dist/dashboard/out/app.txt +3 -3
  132. package/dist/dashboard/out/apple-icon.png +0 -0
  133. package/dist/dashboard/out/connect-repos.html +1 -1
  134. package/dist/dashboard/out/connect-repos.txt +2 -2
  135. package/dist/dashboard/out/history.html +1 -1
  136. package/dist/dashboard/out/history.txt +2 -2
  137. package/dist/dashboard/out/index.html +1 -1
  138. package/dist/dashboard/out/index.txt +3 -3
  139. package/dist/dashboard/out/login.html +2 -2
  140. package/dist/dashboard/out/login.txt +2 -2
  141. package/dist/dashboard/out/metrics.html +1 -1
  142. package/dist/dashboard/out/metrics.txt +3 -3
  143. package/dist/dashboard/out/pricing.html +2 -2
  144. package/dist/dashboard/out/pricing.txt +3 -3
  145. package/dist/dashboard/out/providers/setup/claude.html +1 -0
  146. package/dist/dashboard/out/providers/setup/claude.txt +8 -0
  147. package/dist/dashboard/out/providers/setup/codex.html +1 -0
  148. package/dist/dashboard/out/providers/setup/codex.txt +8 -0
  149. package/dist/dashboard/out/providers.html +1 -1
  150. package/dist/dashboard/out/providers.txt +3 -3
  151. package/dist/dashboard/out/signup.html +2 -2
  152. package/dist/dashboard/out/signup.txt +2 -2
  153. package/dist/dashboard-server/server.js +316 -12
  154. package/dist/dashboard-server/user-bridge.d.ts +103 -0
  155. package/dist/dashboard-server/user-bridge.js +189 -0
  156. package/dist/protocol/channels.d.ts +205 -0
  157. package/dist/protocol/channels.js +154 -0
  158. package/dist/protocol/types.d.ts +13 -1
  159. package/dist/resiliency/provider-context.js +2 -0
  160. package/dist/shared/cli-auth-config.d.ts +19 -0
  161. package/dist/shared/cli-auth-config.js +58 -2
  162. package/dist/utils/agent-config.js +1 -1
  163. package/dist/wrapper/auth-detection.d.ts +49 -0
  164. package/dist/wrapper/auth-detection.js +192 -0
  165. package/dist/wrapper/base-wrapper.d.ts +153 -0
  166. package/dist/wrapper/base-wrapper.js +393 -0
  167. package/dist/wrapper/client.d.ts +7 -1
  168. package/dist/wrapper/client.js +3 -0
  169. package/dist/wrapper/index.d.ts +1 -0
  170. package/dist/wrapper/index.js +4 -3
  171. package/dist/wrapper/pty-wrapper.d.ts +62 -84
  172. package/dist/wrapper/pty-wrapper.js +154 -180
  173. package/dist/wrapper/tmux-wrapper.d.ts +41 -66
  174. package/dist/wrapper/tmux-wrapper.js +90 -134
  175. package/package.json +4 -2
  176. package/scripts/postinstall.js +11 -155
  177. package/scripts/test-interactive-terminal.sh +248 -0
  178. package/dist/cloud/vault/index.d.ts +0 -76
  179. package/dist/cloud/vault/index.js +0 -219
  180. package/dist/dashboard/out/_next/static/chunks/699-3b1cd6618a45d259.js +0 -1
  181. package/dist/dashboard/out/_next/static/chunks/724-2dae7627550ab88f.js +0 -9
  182. package/dist/dashboard/out/_next/static/chunks/766-1f2dd8cb7f766b0b.js +0 -1
  183. package/dist/dashboard/out/_next/static/chunks/app/app/page-e6381e5a6e1fbcfd.js +0 -1
  184. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-67a3e98d9a43a6ed.js +0 -1
  185. package/dist/dashboard/out/_next/static/chunks/app/providers/page-e88bc117ef7671c3.js +0 -1
  186. package/dist/dashboard/out/_next/static/css/29852f26181969a0.css +0 -1
  187. package/dist/dashboard/out/_next/static/css/7c3ae9e8617d42a5.css +0 -1
  188. package/dist/dashboard/out/_next/static/wPgKJtcOmTFLpUncDg16A/_ssgManifest.js +0 -1
  189. /package/dist/dashboard/out/_next/static/{wPgKJtcOmTFLpUncDg16A → sDcbGRTYLcpPvyTs_rsNb}/_buildManifest.js +0 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Admin API Routes
3
+ *
4
+ * Administrative endpoints for managing workspaces at scale.
5
+ * Protected by admin secret (ADMIN_API_SECRET environment variable).
6
+ */
7
+ import { Router } from 'express';
8
+ import { getConfig } from '../config.js';
9
+ import { getProvisioner, WorkspaceProvisioner } from '../provisioner/index.js';
10
+ export const adminRouter = Router();
11
+ /**
12
+ * Middleware to authenticate admin requests
13
+ * Requires ADMIN_API_SECRET header to match environment variable
14
+ */
15
+ async function requireAdminAuth(req, res, next) {
16
+ const authHeader = req.headers['x-admin-secret'] || req.headers.authorization;
17
+ const adminSecret = process.env.ADMIN_API_SECRET;
18
+ if (!adminSecret) {
19
+ res.status(503).json({ error: 'Admin API not configured' });
20
+ return;
21
+ }
22
+ // Support both x-admin-secret header and Bearer token
23
+ const providedSecret = authHeader?.toString().replace('Bearer ', '');
24
+ if (!providedSecret || providedSecret !== adminSecret) {
25
+ res.status(401).json({ error: 'Invalid admin credentials' });
26
+ return;
27
+ }
28
+ next();
29
+ }
30
+ // Apply admin auth to all routes
31
+ adminRouter.use(requireAdminAuth);
32
+ /**
33
+ * POST /api/admin/workspaces/update-image
34
+ *
35
+ * Gracefully update workspace images across all or specific workspaces.
36
+ *
37
+ * Request body:
38
+ * - image: New Docker image to deploy (required)
39
+ * - workspaceIds?: Array of specific workspace IDs to update
40
+ * - userIds?: Array of user IDs whose workspaces to update
41
+ * - force?: Force update even if agents are active (default: false)
42
+ * - skipRestart?: Update config without restarting (default: false)
43
+ * - batchSize?: Number of concurrent updates (default: 5)
44
+ *
45
+ * Response:
46
+ * - summary: { total, updated, pendingRestart, skippedActiveAgents, skippedNotRunning, errors }
47
+ * - results: Array of per-workspace results
48
+ */
49
+ adminRouter.post('/workspaces/update-image', async (req, res) => {
50
+ const { image, workspaceIds, userIds, force = false, skipRestart = false, batchSize = 5, } = req.body;
51
+ if (!image) {
52
+ res.status(400).json({ error: 'image is required' });
53
+ return;
54
+ }
55
+ console.log(`[admin] Starting workspace image update to ${image}`, {
56
+ workspaceIds: workspaceIds?.length ?? 'all',
57
+ userIds: userIds?.length ?? 'all',
58
+ force,
59
+ skipRestart,
60
+ batchSize,
61
+ });
62
+ try {
63
+ const provisioner = getProvisioner();
64
+ const result = await provisioner.gracefulUpdateAllImages(image, {
65
+ workspaceIds,
66
+ userIds,
67
+ force,
68
+ skipRestart,
69
+ batchSize,
70
+ });
71
+ res.json(result);
72
+ }
73
+ catch (error) {
74
+ console.error('[admin] Error updating workspace images:', error);
75
+ res.status(500).json({
76
+ error: 'Failed to update workspace images',
77
+ details: error.message,
78
+ });
79
+ }
80
+ });
81
+ /**
82
+ * POST /api/admin/workspaces/:id/update-image
83
+ *
84
+ * Gracefully update a single workspace's image.
85
+ *
86
+ * Request body:
87
+ * - image: New Docker image to deploy (required)
88
+ * - force?: Force update even if agents are active (default: false)
89
+ * - skipRestart?: Update config without restarting (default: false)
90
+ *
91
+ * Response:
92
+ * - result: Update result code
93
+ * - workspaceId: Workspace ID
94
+ * - machineState?: Current machine state
95
+ * - agentCount?: Number of active agents (if applicable)
96
+ * - error?: Error message (if applicable)
97
+ */
98
+ adminRouter.post('/workspaces/:id/update-image', async (req, res) => {
99
+ const { id } = req.params;
100
+ const { image, force = false, skipRestart = false, } = req.body;
101
+ if (!image) {
102
+ res.status(400).json({ error: 'image is required' });
103
+ return;
104
+ }
105
+ console.log(`[admin] Updating workspace ${id} image to ${image}`, { force, skipRestart });
106
+ try {
107
+ const provisioner = getProvisioner();
108
+ const result = await provisioner.gracefulUpdateImage(id, image, {
109
+ force,
110
+ skipRestart,
111
+ });
112
+ // Return appropriate status code based on result
113
+ if (result.result === WorkspaceProvisioner.UpdateResult.ERROR) {
114
+ res.status(500).json(result);
115
+ }
116
+ else if (result.result === WorkspaceProvisioner.UpdateResult.NOT_SUPPORTED) {
117
+ res.status(400).json(result);
118
+ }
119
+ else {
120
+ res.json(result);
121
+ }
122
+ }
123
+ catch (error) {
124
+ console.error(`[admin] Error updating workspace ${id}:`, error);
125
+ res.status(500).json({
126
+ error: 'Failed to update workspace image',
127
+ details: error.message,
128
+ });
129
+ }
130
+ });
131
+ /**
132
+ * GET /api/admin/workspaces/:id/agents
133
+ *
134
+ * Check active agents in a workspace.
135
+ * Useful for pre-flight checks before updates.
136
+ *
137
+ * Response:
138
+ * - hasActiveAgents: boolean
139
+ * - agentCount: number
140
+ * - agents: Array of { name, status }
141
+ */
142
+ adminRouter.get('/workspaces/:id/agents', async (req, res) => {
143
+ const { id } = req.params;
144
+ try {
145
+ // Query workspace directly from DB and check agents via daemon API
146
+ const workspace = await (await import('../db/index.js')).db.workspaces.findById(id);
147
+ if (!workspace) {
148
+ res.status(404).json({ error: 'Workspace not found' });
149
+ return;
150
+ }
151
+ if (workspace.computeProvider !== 'fly') {
152
+ res.status(400).json({ error: 'Only Fly.io workspaces support agent checking' });
153
+ return;
154
+ }
155
+ // Query the workspace daemon directly
156
+ const baseUrl = workspace.publicUrl;
157
+ if (!baseUrl) {
158
+ res.json({ hasActiveAgents: false, agentCount: 0, agents: [] });
159
+ return;
160
+ }
161
+ try {
162
+ const response = await fetch(`${baseUrl}/api/agents`, {
163
+ method: 'GET',
164
+ headers: { 'Accept': 'application/json' },
165
+ signal: AbortSignal.timeout(10_000),
166
+ });
167
+ if (!response.ok) {
168
+ res.json({ hasActiveAgents: false, agentCount: 0, agents: [], error: `Daemon returned ${response.status}` });
169
+ return;
170
+ }
171
+ const data = await response.json();
172
+ const agents = data.agents || [];
173
+ const activeAgents = agents.filter(a => a.status === 'running' || a.activityState === 'active' || a.activityState === 'idle');
174
+ res.json({
175
+ hasActiveAgents: activeAgents.length > 0,
176
+ agentCount: activeAgents.length,
177
+ agents: agents.map(a => ({ name: a.name, status: a.status || a.activityState || 'unknown' })),
178
+ });
179
+ }
180
+ catch (error) {
181
+ // Workspace might be stopped
182
+ res.json({
183
+ hasActiveAgents: false,
184
+ agentCount: 0,
185
+ agents: [],
186
+ error: `Could not reach workspace: ${error.message}`,
187
+ });
188
+ }
189
+ }
190
+ catch (error) {
191
+ console.error(`[admin] Error checking agents for workspace ${id}:`, error);
192
+ res.status(500).json({
193
+ error: 'Failed to check workspace agents',
194
+ details: error.message,
195
+ });
196
+ }
197
+ });
198
+ /**
199
+ * GET /api/admin/health
200
+ *
201
+ * Health check for admin API.
202
+ */
203
+ adminRouter.get('/health', (_req, res) => {
204
+ res.json({
205
+ status: 'ok',
206
+ timestamp: new Date().toISOString(),
207
+ config: {
208
+ computeProvider: getConfig().compute.provider,
209
+ },
210
+ });
211
+ });
212
+ //# sourceMappingURL=admin.js.map
@@ -105,6 +105,13 @@ authRouter.get('/session', async (req, res) => {
105
105
  message: 'User account not found. Please log in again.',
106
106
  });
107
107
  }
108
+ // Get connected providers
109
+ const credentials = await db.credentials.findByUserId(user.id);
110
+ const connectedProviders = credentials.map((c) => ({
111
+ provider: c.provider,
112
+ email: c.providerAccountEmail,
113
+ connectedAt: c.createdAt,
114
+ }));
108
115
  res.json({
109
116
  authenticated: true,
110
117
  user: {
@@ -114,6 +121,7 @@ authRouter.get('/session', async (req, res) => {
114
121
  avatarUrl: user.avatarUrl,
115
122
  plan: user.plan,
116
123
  },
124
+ connectedProviders,
117
125
  });
118
126
  }
119
127
  catch (error) {
@@ -3,15 +3,5 @@
3
3
  *
4
4
  * REST API for subscription and billing management.
5
5
  */
6
- declare module 'express-session' {
7
- interface SessionData {
8
- user?: {
9
- id: string;
10
- email: string;
11
- name?: string;
12
- stripeCustomerId?: string;
13
- };
14
- }
15
- }
16
6
  export declare const billingRouter: import("express-serve-static-core").Router;
17
7
  //# sourceMappingURL=billing.d.ts.map
@@ -5,25 +5,121 @@
5
5
  */
6
6
  import { Router } from 'express';
7
7
  import { getBillingService, getAllPlans, getPlan, comparePlans } from '../billing/index.js';
8
- import { getConfig } from '../config.js';
8
+ import { getConfig, isAdminUser } from '../config.js';
9
9
  import { db } from '../db/index.js';
10
+ import { requireAuth } from './auth.js';
11
+ import { getProvisioner, RESOURCE_TIERS } from '../provisioner/index.js';
12
+ import { getResourceTierForPlan } from '../services/planLimits.js';
10
13
  export const billingRouter = Router();
11
14
  /**
12
- * Middleware to require authentication
15
+ * Get the count of connected agents in a running workspace
16
+ * Returns 0 if workspace is not reachable or has no agents
13
17
  */
14
- function requireAuth(req, res, next) {
15
- if (!req.session?.user) {
16
- res.status(401).json({ error: 'Authentication required' });
17
- return;
18
+ async function getWorkspaceAgentCount(publicUrl) {
19
+ try {
20
+ const controller = new AbortController();
21
+ const timeout = setTimeout(() => controller.abort(), 5000);
22
+ const response = await fetch(`${publicUrl}/agents`, {
23
+ signal: controller.signal,
24
+ });
25
+ clearTimeout(timeout);
26
+ if (!response.ok)
27
+ return 0;
28
+ const data = await response.json();
29
+ return data.agents?.length ?? 0;
30
+ }
31
+ catch {
32
+ // Workspace not reachable or error - assume no agents
33
+ return 0;
34
+ }
35
+ }
36
+ /**
37
+ * Resize user's workspaces to match their new plan tier
38
+ * Called after plan upgrade/downgrade to adjust compute resources
39
+ *
40
+ * Strategy:
41
+ * - Stopped workspaces: Resize immediately (no disruption)
42
+ * - Running workspaces with no agents: Resize immediately (safe to restart)
43
+ * - Running workspaces with agents: Save config for next restart (no agent disruption)
44
+ *
45
+ * Returns info about which workspaces were deferred so we can inform the user.
46
+ */
47
+ async function resizeWorkspacesForPlan(userId, newPlan) {
48
+ const result = { resized: 0, deferred: [], failed: 0 };
49
+ try {
50
+ const workspaces = await db.workspaces.findByUserId(userId);
51
+ if (workspaces.length === 0)
52
+ return result;
53
+ const provisioner = getProvisioner();
54
+ const targetTierName = getResourceTierForPlan(newPlan);
55
+ const targetTier = RESOURCE_TIERS[targetTierName];
56
+ console.log(`[billing] Upgrading ${workspaces.length} workspace(s) for user ${userId.substring(0, 8)} to ${targetTierName}`);
57
+ for (const workspace of workspaces) {
58
+ if (workspace.status !== 'running' && workspace.status !== 'stopped') {
59
+ console.log(`[billing] Skipping workspace ${workspace.id.substring(0, 8)} (status: ${workspace.status})`);
60
+ continue;
61
+ }
62
+ try {
63
+ let skipRestart = false;
64
+ let agentCount = 0;
65
+ // For running workspaces: check if there are active agents
66
+ if (workspace.status === 'running' && workspace.publicUrl) {
67
+ agentCount = await getWorkspaceAgentCount(workspace.publicUrl);
68
+ if (agentCount > 0) {
69
+ // Has active agents - don't disrupt them
70
+ skipRestart = true;
71
+ console.log(`[billing] Workspace ${workspace.id.substring(0, 8)} has ${agentCount} active agent(s), deferring resize`);
72
+ }
73
+ else {
74
+ // No active agents - safe to restart immediately
75
+ console.log(`[billing] Workspace ${workspace.id.substring(0, 8)} has no active agents, proceeding with immediate resize`);
76
+ }
77
+ }
78
+ await provisioner.resize(workspace.id, targetTier, skipRestart);
79
+ if (skipRestart) {
80
+ console.log(`[billing] Queued resize for workspace ${workspace.id.substring(0, 8)} to ${targetTierName} (will apply on next restart)`);
81
+ result.deferred.push({
82
+ workspaceId: workspace.id,
83
+ workspaceName: workspace.name,
84
+ agentCount,
85
+ });
86
+ }
87
+ else {
88
+ console.log(`[billing] Resized workspace ${workspace.id.substring(0, 8)} to ${targetTierName}`);
89
+ result.resized++;
90
+ }
91
+ }
92
+ catch (error) {
93
+ console.error(`[billing] Failed to resize workspace ${workspace.id}:`, error);
94
+ result.failed++;
95
+ // Continue with other workspaces even if one fails
96
+ }
97
+ }
18
98
  }
19
- next();
99
+ catch (error) {
100
+ console.error('[billing] Failed to resize workspaces:', error);
101
+ }
102
+ return result;
20
103
  }
21
104
  /**
22
105
  * GET /api/billing/plans
23
106
  * Get all available billing plans
24
107
  */
25
108
  billingRouter.get('/plans', (req, res) => {
26
- const plans = getAllPlans();
109
+ const rawPlans = getAllPlans();
110
+ // Transform plans to frontend format
111
+ const plans = rawPlans.map((plan) => ({
112
+ tier: plan.id,
113
+ name: plan.name,
114
+ description: plan.description,
115
+ price: {
116
+ monthly: plan.priceMonthly / 100, // Convert cents to dollars
117
+ yearly: plan.priceYearly / 100,
118
+ },
119
+ features: plan.features,
120
+ limits: plan.limits,
121
+ recommended: plan.id === 'pro',
122
+ }));
27
123
  // Add publishable key for frontend
28
124
  const config = getConfig();
29
125
  res.json({
@@ -68,15 +164,38 @@ billingRouter.get('/compare', (req, res) => {
68
164
  * Get current user's subscription status
69
165
  */
70
166
  billingRouter.get('/subscription', requireAuth, async (req, res) => {
71
- const user = req.session.user;
72
- const billing = getBillingService();
167
+ const userId = req.session.userId;
73
168
  try {
169
+ // Fetch user from database
170
+ const user = await db.users.findById(userId);
171
+ if (!user) {
172
+ return res.status(404).json({ error: 'User not found' });
173
+ }
174
+ // Admin users have special status - show their current plan without Stripe
175
+ if (isAdminUser(user.githubUsername)) {
176
+ return res.json({
177
+ tier: user.plan || 'enterprise',
178
+ subscription: null,
179
+ customer: null,
180
+ isAdmin: true,
181
+ });
182
+ }
183
+ // If user doesn't have a Stripe customer ID, use the database plan value
184
+ // This handles manually-set plans and prevents hanging on Stripe API calls
185
+ if (!user.stripeCustomerId) {
186
+ return res.json({
187
+ tier: user.plan || 'free',
188
+ subscription: null,
189
+ customer: null,
190
+ });
191
+ }
192
+ const billing = getBillingService();
74
193
  // Get or create Stripe customer
75
194
  const customerId = user.stripeCustomerId ||
76
- await billing.getOrCreateCustomer(user.id, user.email, user.name);
77
- // Save customer ID to session if newly created
195
+ await billing.getOrCreateCustomer(user.id, user.email || '', user.githubUsername);
196
+ // Save customer ID to database if newly created
78
197
  if (!user.stripeCustomerId) {
79
- req.session.user.stripeCustomerId = customerId;
198
+ await db.users.update(userId, { stripeCustomerId: customerId });
80
199
  }
81
200
  // Get customer details
82
201
  const customer = await billing.getCustomer(customerId);
@@ -88,8 +207,11 @@ billingRouter.get('/subscription', requireAuth, async (req, res) => {
88
207
  });
89
208
  return;
90
209
  }
210
+ // Use Stripe subscription tier if active, otherwise fall back to database plan value
211
+ // This allows manual plan overrides in the database to take effect
212
+ const tier = customer.subscription?.tier || user.plan || 'free';
91
213
  res.json({
92
- tier: customer.subscription?.tier || 'free',
214
+ tier,
93
215
  subscription: customer.subscription,
94
216
  customer: {
95
217
  id: customer.id,
@@ -110,7 +232,7 @@ billingRouter.get('/subscription', requireAuth, async (req, res) => {
110
232
  * Create a checkout session for subscription
111
233
  */
112
234
  billingRouter.post('/checkout', requireAuth, async (req, res) => {
113
- const user = req.session.user;
235
+ const userId = req.session.userId;
114
236
  const { tier, interval = 'month' } = req.body;
115
237
  if (!tier || !['pro', 'team', 'enterprise'].includes(tier)) {
116
238
  res.status(400).json({ error: 'Invalid tier' });
@@ -120,18 +242,44 @@ billingRouter.post('/checkout', requireAuth, async (req, res) => {
120
242
  res.status(400).json({ error: 'Invalid billing interval' });
121
243
  return;
122
244
  }
123
- const billing = getBillingService();
124
245
  const config = getConfig();
125
246
  try {
247
+ // Fetch user from database
248
+ const user = await db.users.findById(userId);
249
+ if (!user) {
250
+ return res.status(404).json({ error: 'User not found' });
251
+ }
252
+ // Admin users get free upgrades - skip Stripe entirely
253
+ if (isAdminUser(user.githubUsername)) {
254
+ // Update user plan directly
255
+ await db.users.update(userId, { plan: tier });
256
+ console.log(`[billing] Admin user ${user.githubUsername} upgraded to ${tier} (free)`);
257
+ // Resize workspaces to match new plan (wait for result to inform user)
258
+ const resizeResult = await resizeWorkspacesForPlan(userId, tier);
259
+ // Build success URL with deferred workspace info if any
260
+ let successUrl = `${config.appUrl}/billing/success?admin=true`;
261
+ if (resizeResult.deferred.length > 0) {
262
+ // Encode deferred workspaces info for the frontend to display
263
+ const deferredInfo = encodeURIComponent(JSON.stringify(resizeResult.deferred));
264
+ successUrl += `&deferred=${deferredInfo}`;
265
+ }
266
+ // Return a fake session that redirects to success
267
+ return res.json({
268
+ sessionId: 'admin-upgrade',
269
+ checkoutUrl: successUrl,
270
+ resizeResult, // Also include in response for API consumers
271
+ });
272
+ }
273
+ const billing = getBillingService();
126
274
  // Get or create customer
127
275
  const customerId = user.stripeCustomerId ||
128
- await billing.getOrCreateCustomer(user.id, user.email, user.name);
129
- // Save customer ID to session
276
+ await billing.getOrCreateCustomer(user.id, user.email || '', user.githubUsername);
277
+ // Save customer ID to database
130
278
  if (!user.stripeCustomerId) {
131
- req.session.user.stripeCustomerId = customerId;
279
+ await db.users.update(userId, { stripeCustomerId: customerId });
132
280
  }
133
281
  // Create checkout session
134
- const session = await billing.createCheckoutSession(customerId, tier, interval, `${config.publicUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`, `${config.publicUrl}/billing/canceled`);
282
+ const session = await billing.createCheckoutSession(customerId, tier, interval, `${config.appUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`, `${config.appUrl}/billing/canceled`);
135
283
  res.json(session);
136
284
  }
137
285
  catch (error) {
@@ -144,15 +292,19 @@ billingRouter.post('/checkout', requireAuth, async (req, res) => {
144
292
  * Create a billing portal session for managing subscription
145
293
  */
146
294
  billingRouter.post('/portal', requireAuth, async (req, res) => {
147
- const user = req.session.user;
148
- if (!user.stripeCustomerId) {
149
- res.status(400).json({ error: 'No billing account found' });
150
- return;
151
- }
152
- const billing = getBillingService();
153
- const config = getConfig();
295
+ const userId = req.session.userId;
154
296
  try {
155
- const session = await billing.createPortalSession(user.stripeCustomerId, `${config.publicUrl}/billing`);
297
+ const user = await db.users.findById(userId);
298
+ if (!user) {
299
+ return res.status(404).json({ error: 'User not found' });
300
+ }
301
+ if (!user.stripeCustomerId) {
302
+ res.status(400).json({ error: 'No billing account found' });
303
+ return;
304
+ }
305
+ const billing = getBillingService();
306
+ const config = getConfig();
307
+ const session = await billing.createPortalSession(user.stripeCustomerId, `${config.appUrl}/billing`);
156
308
  res.json(session);
157
309
  }
158
310
  catch (error) {
@@ -165,18 +317,22 @@ billingRouter.post('/portal', requireAuth, async (req, res) => {
165
317
  * Change subscription tier
166
318
  */
167
319
  billingRouter.post('/change', requireAuth, async (req, res) => {
168
- const user = req.session.user;
320
+ const userId = req.session.userId;
169
321
  const { tier, interval = 'month' } = req.body;
170
322
  if (!tier || !['free', 'pro', 'team', 'enterprise'].includes(tier)) {
171
323
  res.status(400).json({ error: 'Invalid tier' });
172
324
  return;
173
325
  }
174
- if (!user.stripeCustomerId) {
175
- res.status(400).json({ error: 'No billing account found' });
176
- return;
177
- }
178
- const billing = getBillingService();
179
326
  try {
327
+ const user = await db.users.findById(userId);
328
+ if (!user) {
329
+ return res.status(404).json({ error: 'User not found' });
330
+ }
331
+ if (!user.stripeCustomerId) {
332
+ res.status(400).json({ error: 'No billing account found' });
333
+ return;
334
+ }
335
+ const billing = getBillingService();
180
336
  // Get current subscription
181
337
  const customer = await billing.getCustomer(user.stripeCustomerId);
182
338
  if (!customer?.subscription) {
@@ -203,13 +359,17 @@ billingRouter.post('/change', requireAuth, async (req, res) => {
203
359
  * Cancel subscription at period end
204
360
  */
205
361
  billingRouter.post('/cancel', requireAuth, async (req, res) => {
206
- const user = req.session.user;
207
- if (!user.stripeCustomerId) {
208
- res.status(400).json({ error: 'No billing account found' });
209
- return;
210
- }
211
- const billing = getBillingService();
362
+ const userId = req.session.userId;
212
363
  try {
364
+ const user = await db.users.findById(userId);
365
+ if (!user) {
366
+ return res.status(404).json({ error: 'User not found' });
367
+ }
368
+ if (!user.stripeCustomerId) {
369
+ res.status(400).json({ error: 'No billing account found' });
370
+ return;
371
+ }
372
+ const billing = getBillingService();
213
373
  const customer = await billing.getCustomer(user.stripeCustomerId);
214
374
  if (!customer?.subscription) {
215
375
  res.status(400).json({ error: 'No active subscription' });
@@ -231,13 +391,17 @@ billingRouter.post('/cancel', requireAuth, async (req, res) => {
231
391
  * Resume a canceled subscription
232
392
  */
233
393
  billingRouter.post('/resume', requireAuth, async (req, res) => {
234
- const user = req.session.user;
235
- if (!user.stripeCustomerId) {
236
- res.status(400).json({ error: 'No billing account found' });
237
- return;
238
- }
239
- const billing = getBillingService();
394
+ const userId = req.session.userId;
240
395
  try {
396
+ const user = await db.users.findById(userId);
397
+ if (!user) {
398
+ return res.status(404).json({ error: 'User not found' });
399
+ }
400
+ if (!user.stripeCustomerId) {
401
+ res.status(400).json({ error: 'No billing account found' });
402
+ return;
403
+ }
404
+ const billing = getBillingService();
241
405
  const customer = await billing.getCustomer(user.stripeCustomerId);
242
406
  if (!customer?.subscription) {
243
407
  res.status(400).json({ error: 'No subscription to resume' });
@@ -260,13 +424,17 @@ billingRouter.post('/resume', requireAuth, async (req, res) => {
260
424
  * Get user's invoices
261
425
  */
262
426
  billingRouter.get('/invoices', requireAuth, async (req, res) => {
263
- const user = req.session.user;
264
- if (!user.stripeCustomerId) {
265
- res.json({ invoices: [] });
266
- return;
267
- }
268
- const billing = getBillingService();
427
+ const userId = req.session.userId;
269
428
  try {
429
+ const user = await db.users.findById(userId);
430
+ if (!user) {
431
+ return res.status(404).json({ error: 'User not found' });
432
+ }
433
+ // No Stripe customer = no invoices, skip Stripe call entirely
434
+ if (!user.stripeCustomerId) {
435
+ return res.json({ invoices: [] });
436
+ }
437
+ const billing = getBillingService();
270
438
  const customer = await billing.getCustomer(user.stripeCustomerId);
271
439
  res.json({ invoices: customer?.invoices || [] });
272
440
  }
@@ -280,13 +448,17 @@ billingRouter.get('/invoices', requireAuth, async (req, res) => {
280
448
  * Get upcoming invoice preview
281
449
  */
282
450
  billingRouter.get('/upcoming', requireAuth, async (req, res) => {
283
- const user = req.session.user;
284
- if (!user.stripeCustomerId) {
285
- res.json({ invoice: null });
286
- return;
287
- }
288
- const billing = getBillingService();
451
+ const userId = req.session.userId;
289
452
  try {
453
+ const user = await db.users.findById(userId);
454
+ if (!user) {
455
+ return res.status(404).json({ error: 'User not found' });
456
+ }
457
+ if (!user.stripeCustomerId) {
458
+ res.json({ invoice: null });
459
+ return;
460
+ }
461
+ const billing = getBillingService();
290
462
  const invoice = await billing.getUpcomingInvoice(user.stripeCustomerId);
291
463
  res.json({ invoice });
292
464
  }
@@ -345,6 +517,20 @@ billingRouter.post('/webhook',
345
517
  // Update user's plan in database
346
518
  await db.users.update(billingEvent.userId, { plan: tier });
347
519
  console.log(`Updated user ${billingEvent.userId} plan to: ${tier}`);
520
+ // Resize workspaces to match new plan (async, don't block webhook)
521
+ resizeWorkspacesForPlan(billingEvent.userId, tier).then((result) => {
522
+ if (result.deferred.length > 0) {
523
+ console.log(`[billing] User ${billingEvent.userId} upgrade: ${result.resized} resized, ${result.deferred.length} deferred (have active agents)`);
524
+ result.deferred.forEach((d) => {
525
+ console.log(`[billing] - "${d.workspaceName}" has ${d.agentCount} agent(s), will resize on next restart`);
526
+ });
527
+ }
528
+ else {
529
+ console.log(`[billing] User ${billingEvent.userId} upgrade: all ${result.resized} workspace(s) resized immediately`);
530
+ }
531
+ }).catch((err) => {
532
+ console.error(`Failed to resize workspaces for user ${billingEvent.userId}:`, err);
533
+ });
348
534
  }
349
535
  else {
350
536
  console.warn('Subscription event received without userId:', billingEvent.id);
@@ -356,6 +542,10 @@ billingRouter.post('/webhook',
356
542
  if (billingEvent.userId) {
357
543
  await db.users.update(billingEvent.userId, { plan: 'free' });
358
544
  console.log(`User ${billingEvent.userId} subscription canceled, reset to free plan`);
545
+ // Resize workspaces down to free tier (async)
546
+ resizeWorkspacesForPlan(billingEvent.userId, 'free').catch((err) => {
547
+ console.error(`Failed to resize workspaces for user ${billingEvent.userId}:`, err);
548
+ });
359
549
  }
360
550
  break;
361
551
  }