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.
- package/.trajectories/agent-relay-322-324.md +17 -0
- package/.trajectories/completed/2026-01/traj_03zupyv1s7b9.json +49 -0
- package/.trajectories/completed/2026-01/traj_03zupyv1s7b9.md +31 -0
- package/.trajectories/completed/2026-01/traj_0zacdjl1g4ht.json +125 -0
- package/.trajectories/completed/2026-01/traj_0zacdjl1g4ht.md +62 -0
- package/.trajectories/completed/2026-01/traj_33iuy72sezbk.json +49 -0
- package/.trajectories/completed/2026-01/traj_33iuy72sezbk.md +31 -0
- package/.trajectories/completed/2026-01/traj_5ammh5qtvklq.json +77 -0
- package/.trajectories/completed/2026-01/traj_5ammh5qtvklq.md +42 -0
- package/.trajectories/completed/2026-01/traj_6mieijqyvaag.json +77 -0
- package/.trajectories/completed/2026-01/traj_6mieijqyvaag.md +42 -0
- package/.trajectories/completed/2026-01/traj_78ffm31jn3uk.json +77 -0
- package/.trajectories/completed/2026-01/traj_78ffm31jn3uk.md +42 -0
- package/.trajectories/completed/2026-01/traj_94gnp3k30goq.json +66 -0
- package/.trajectories/completed/2026-01/traj_94gnp3k30goq.md +36 -0
- package/.trajectories/completed/2026-01/traj_avqeghu6pz5a.json +40 -0
- package/.trajectories/completed/2026-01/traj_avqeghu6pz5a.md +22 -0
- package/.trajectories/completed/2026-01/traj_dcsp9s8y01ra.json +121 -0
- package/.trajectories/completed/2026-01/traj_dcsp9s8y01ra.md +29 -0
- package/.trajectories/completed/2026-01/traj_fhx9irlckht6.json +53 -0
- package/.trajectories/completed/2026-01/traj_fhx9irlckht6.md +32 -0
- package/.trajectories/completed/2026-01/traj_fqduidx3xbtp.json +101 -0
- package/.trajectories/completed/2026-01/traj_fqduidx3xbtp.md +52 -0
- package/.trajectories/completed/2026-01/traj_hf81ey93uz6t.json +49 -0
- package/.trajectories/completed/2026-01/traj_hf81ey93uz6t.md +31 -0
- package/.trajectories/completed/2026-01/traj_hfmki2jr9d4r.json +65 -0
- package/.trajectories/completed/2026-01/traj_hfmki2jr9d4r.md +37 -0
- package/.trajectories/completed/2026-01/traj_lq450ly148uw.json +49 -0
- package/.trajectories/completed/2026-01/traj_lq450ly148uw.md +31 -0
- package/.trajectories/completed/2026-01/traj_multi_server_arch.md +101 -0
- package/.trajectories/completed/2026-01/traj_psd9ob0j2ru3.json +27 -0
- package/.trajectories/completed/2026-01/traj_psd9ob0j2ru3.md +14 -0
- package/.trajectories/completed/2026-01/traj_ub8csuv3lcv4.json +53 -0
- package/.trajectories/completed/2026-01/traj_ub8csuv3lcv4.md +32 -0
- package/.trajectories/completed/2026-01/traj_uc29tlso8i9s.json +186 -0
- package/.trajectories/completed/2026-01/traj_uc29tlso8i9s.md +86 -0
- package/.trajectories/completed/2026-01/traj_ui9b4tqxoa7j.json +77 -0
- package/.trajectories/completed/2026-01/traj_ui9b4tqxoa7j.md +42 -0
- package/.trajectories/completed/2026-01/traj_v9dkdoxylyid.json +89 -0
- package/.trajectories/completed/2026-01/traj_v9dkdoxylyid.md +47 -0
- package/.trajectories/completed/2026-01/traj_xy9vifpqet80.json +65 -0
- package/.trajectories/completed/2026-01/traj_xy9vifpqet80.md +37 -0
- package/.trajectories/completed/2026-01/traj_y7aiwijyfmmv.json +49 -0
- package/.trajectories/completed/2026-01/traj_y7aiwijyfmmv.md +31 -0
- package/.trajectories/consolidate-settings-panel.md +24 -0
- package/.trajectories/gh-cli-user-token.md +26 -0
- package/.trajectories/index.json +155 -1
- package/deploy/workspace/codex.config.toml +15 -0
- package/deploy/workspace/entrypoint.sh +167 -7
- package/deploy/workspace/git-credential-relay +17 -2
- package/dist/bridge/spawner.d.ts +7 -0
- package/dist/bridge/spawner.js +40 -9
- package/dist/bridge/types.d.ts +2 -0
- package/dist/cli/index.js +210 -168
- package/dist/cloud/api/admin.d.ts +8 -0
- package/dist/cloud/api/admin.js +212 -0
- package/dist/cloud/api/auth.js +8 -0
- package/dist/cloud/api/billing.d.ts +0 -10
- package/dist/cloud/api/billing.js +248 -58
- package/dist/cloud/api/codex-auth-helper.d.ts +10 -4
- package/dist/cloud/api/codex-auth-helper.js +215 -8
- package/dist/cloud/api/coordinators.js +402 -0
- package/dist/cloud/api/daemons.js +15 -11
- package/dist/cloud/api/git.js +104 -17
- package/dist/cloud/api/github-app.js +42 -8
- package/dist/cloud/api/nango-auth.js +297 -16
- package/dist/cloud/api/onboarding.js +97 -33
- package/dist/cloud/api/providers.js +12 -16
- package/dist/cloud/api/repos.js +200 -124
- package/dist/cloud/api/test-helpers.js +40 -0
- package/dist/cloud/api/usage.js +13 -0
- package/dist/cloud/api/webhooks.js +1 -1
- package/dist/cloud/api/workspaces.d.ts +18 -0
- package/dist/cloud/api/workspaces.js +945 -15
- package/dist/cloud/config.d.ts +8 -0
- package/dist/cloud/config.js +15 -0
- package/dist/cloud/db/drizzle.d.ts +5 -2
- package/dist/cloud/db/drizzle.js +27 -20
- package/dist/cloud/db/schema.d.ts +19 -51
- package/dist/cloud/db/schema.js +5 -4
- package/dist/cloud/index.d.ts +0 -1
- package/dist/cloud/index.js +0 -1
- package/dist/cloud/provisioner/index.d.ts +93 -1
- package/dist/cloud/provisioner/index.js +608 -63
- package/dist/cloud/server.js +156 -16
- package/dist/cloud/services/compute-enforcement.d.ts +57 -0
- package/dist/cloud/services/compute-enforcement.js +175 -0
- package/dist/cloud/services/index.d.ts +2 -0
- package/dist/cloud/services/index.js +4 -0
- package/dist/cloud/services/intro-expiration.d.ts +55 -0
- package/dist/cloud/services/intro-expiration.js +211 -0
- package/dist/cloud/services/nango.d.ts +14 -0
- package/dist/cloud/services/nango.js +74 -14
- package/dist/cloud/services/ssh-security.d.ts +31 -0
- package/dist/cloud/services/ssh-security.js +63 -0
- package/dist/continuity/manager.d.ts +5 -0
- package/dist/continuity/manager.js +56 -2
- package/dist/daemon/api.d.ts +2 -0
- package/dist/daemon/api.js +214 -5
- package/dist/daemon/cli-auth.d.ts +13 -1
- package/dist/daemon/cli-auth.js +166 -47
- package/dist/daemon/connection.d.ts +7 -1
- package/dist/daemon/connection.js +15 -0
- package/dist/daemon/orchestrator.d.ts +2 -0
- package/dist/daemon/orchestrator.js +26 -0
- package/dist/daemon/repo-manager.d.ts +116 -0
- package/dist/daemon/repo-manager.js +384 -0
- package/dist/daemon/router.d.ts +60 -1
- package/dist/daemon/router.js +281 -20
- package/dist/daemon/user-directory.d.ts +111 -0
- package/dist/daemon/user-directory.js +233 -0
- package/dist/dashboard/out/404.html +1 -1
- package/dist/dashboard/out/_next/static/chunks/532-bace199897eeab37.js +9 -0
- package/dist/dashboard/out/_next/static/chunks/766-b54f0853794b78c3.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/83-b51836037078006c.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/891-6cd50de1224f70bb.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/899-fc02ed79e3de4302.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/{page-3fdfa60e53f2810d.js → page-8553743baca53a00.js} +1 -1
- package/dist/dashboard/out/_next/static/chunks/app/app/page-c617745b81344f4f.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/metrics/page-f829604fb75a831a.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/{page-77e9c65420a06cfb.js → page-dc786c183425c2ac.js} +1 -1
- package/dist/dashboard/out/_next/static/chunks/app/providers/page-84322991d7244499.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/providers/setup/[provider]/page-05606941a8e2be83.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/{main-ed4e1fb6f29c34cf.js → main-2ee6beb2ae96d210.js} +1 -1
- package/dist/dashboard/out/_next/static/css/48a8fbe3e659080e.css +1 -0
- package/dist/dashboard/out/_next/static/css/fe4b28883eeff359.css +1 -0
- package/dist/dashboard/out/_next/static/sDcbGRTYLcpPvyTs_rsNb/_ssgManifest.js +1 -0
- package/dist/dashboard/out/app/onboarding.html +1 -1
- package/dist/dashboard/out/app/onboarding.txt +3 -3
- package/dist/dashboard/out/app.html +1 -1
- package/dist/dashboard/out/app.txt +3 -3
- package/dist/dashboard/out/apple-icon.png +0 -0
- package/dist/dashboard/out/connect-repos.html +1 -1
- package/dist/dashboard/out/connect-repos.txt +2 -2
- package/dist/dashboard/out/history.html +1 -1
- package/dist/dashboard/out/history.txt +2 -2
- package/dist/dashboard/out/index.html +1 -1
- package/dist/dashboard/out/index.txt +3 -3
- package/dist/dashboard/out/login.html +2 -2
- package/dist/dashboard/out/login.txt +2 -2
- package/dist/dashboard/out/metrics.html +1 -1
- package/dist/dashboard/out/metrics.txt +3 -3
- package/dist/dashboard/out/pricing.html +2 -2
- package/dist/dashboard/out/pricing.txt +3 -3
- package/dist/dashboard/out/providers/setup/claude.html +1 -0
- package/dist/dashboard/out/providers/setup/claude.txt +8 -0
- package/dist/dashboard/out/providers/setup/codex.html +1 -0
- package/dist/dashboard/out/providers/setup/codex.txt +8 -0
- package/dist/dashboard/out/providers.html +1 -1
- package/dist/dashboard/out/providers.txt +3 -3
- package/dist/dashboard/out/signup.html +2 -2
- package/dist/dashboard/out/signup.txt +2 -2
- package/dist/dashboard-server/server.js +316 -12
- package/dist/dashboard-server/user-bridge.d.ts +103 -0
- package/dist/dashboard-server/user-bridge.js +189 -0
- package/dist/protocol/channels.d.ts +205 -0
- package/dist/protocol/channels.js +154 -0
- package/dist/protocol/types.d.ts +13 -1
- package/dist/resiliency/provider-context.js +2 -0
- package/dist/shared/cli-auth-config.d.ts +19 -0
- package/dist/shared/cli-auth-config.js +58 -2
- package/dist/utils/agent-config.js +1 -1
- package/dist/wrapper/auth-detection.d.ts +49 -0
- package/dist/wrapper/auth-detection.js +192 -0
- package/dist/wrapper/base-wrapper.d.ts +153 -0
- package/dist/wrapper/base-wrapper.js +393 -0
- package/dist/wrapper/client.d.ts +7 -1
- package/dist/wrapper/client.js +3 -0
- package/dist/wrapper/index.d.ts +1 -0
- package/dist/wrapper/index.js +4 -3
- package/dist/wrapper/pty-wrapper.d.ts +62 -84
- package/dist/wrapper/pty-wrapper.js +154 -180
- package/dist/wrapper/tmux-wrapper.d.ts +41 -66
- package/dist/wrapper/tmux-wrapper.js +90 -134
- package/package.json +4 -2
- package/scripts/postinstall.js +11 -155
- package/scripts/test-interactive-terminal.sh +248 -0
- package/dist/cloud/vault/index.d.ts +0 -76
- package/dist/cloud/vault/index.js +0 -219
- package/dist/dashboard/out/_next/static/chunks/699-3b1cd6618a45d259.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/724-2dae7627550ab88f.js +0 -9
- package/dist/dashboard/out/_next/static/chunks/766-1f2dd8cb7f766b0b.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/app/page-e6381e5a6e1fbcfd.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/metrics/page-67a3e98d9a43a6ed.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/providers/page-e88bc117ef7671c3.js +0 -1
- package/dist/dashboard/out/_next/static/css/29852f26181969a0.css +0 -1
- package/dist/dashboard/out/_next/static/css/7c3ae9e8617d42a5.css +0 -1
- package/dist/dashboard/out/_next/static/wPgKJtcOmTFLpUncDg16A/_ssgManifest.js +0 -1
- /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
|
package/dist/cloud/api/auth.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
77
|
-
// Save customer ID to
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
129
|
-
// Save customer ID to
|
|
276
|
+
await billing.getOrCreateCustomer(user.id, user.email || '', user.githubUsername);
|
|
277
|
+
// Save customer ID to database
|
|
130
278
|
if (!user.stripeCustomerId) {
|
|
131
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|