exflower-license-server 1.0.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.
- package/Dockerfile +43 -0
- package/README.md +72 -0
- package/admin/index.html +570 -0
- package/db.js +107 -0
- package/docker-compose.yml +28 -0
- package/index.js +41 -0
- package/package.json +38 -0
- package/routes.js +388 -0
- package/utils.js +20 -0
package/Dockerfile
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# ExFlower License Server Docker Image
|
|
2
|
+
#
|
|
3
|
+
# Build:
|
|
4
|
+
# docker build -t exflower/license-server:latest .
|
|
5
|
+
#
|
|
6
|
+
# Run:
|
|
7
|
+
# docker run -d -p 4001:4001 \
|
|
8
|
+
# -e ADMIN_TOKEN=your-secure-token \
|
|
9
|
+
# -v license-data:/app/data \
|
|
10
|
+
# exflower/license-server:latest
|
|
11
|
+
|
|
12
|
+
FROM node:18-alpine
|
|
13
|
+
|
|
14
|
+
# Install build tools for native modules (better-sqlite3)
|
|
15
|
+
RUN apk add --no-cache python3 make g++
|
|
16
|
+
|
|
17
|
+
WORKDIR /app
|
|
18
|
+
|
|
19
|
+
# Install dependencies
|
|
20
|
+
COPY package.json package-lock.json ./
|
|
21
|
+
RUN npm ci --production
|
|
22
|
+
|
|
23
|
+
# Copy application files
|
|
24
|
+
COPY . .
|
|
25
|
+
|
|
26
|
+
# Create data directory
|
|
27
|
+
RUN mkdir -p /app/data
|
|
28
|
+
|
|
29
|
+
# Expose default port
|
|
30
|
+
EXPOSE 4001
|
|
31
|
+
|
|
32
|
+
# Volume for persistent database
|
|
33
|
+
VOLUME ["/app/data"]
|
|
34
|
+
|
|
35
|
+
# Health check
|
|
36
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
|
37
|
+
CMD node -e "require('http').get('http://localhost:4001/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"
|
|
38
|
+
|
|
39
|
+
# Default environment
|
|
40
|
+
ENV PORT=4001
|
|
41
|
+
ENV NODE_ENV=production
|
|
42
|
+
|
|
43
|
+
CMD ["node", "index.js"]
|
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# ExFlower License Server
|
|
2
|
+
|
|
3
|
+
ExFlower 在线授权验证服务。管理 License Key、企业授权、实例追踪。
|
|
4
|
+
|
|
5
|
+
## 快速开始
|
|
6
|
+
|
|
7
|
+
### 方式一:npm 全局安装
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# 安装
|
|
11
|
+
npm install -g exflower-license-server
|
|
12
|
+
|
|
13
|
+
# 运行
|
|
14
|
+
exflower-license-server
|
|
15
|
+
|
|
16
|
+
# 或带参数
|
|
17
|
+
ADMIN_TOKEN=your-secret-token PORT=4001 exflower-license-server
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### 方式二:Docker
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# 使用 docker-compose(推荐)
|
|
24
|
+
docker-compose up -d
|
|
25
|
+
|
|
26
|
+
# 或直接使用 Docker
|
|
27
|
+
docker run -d -p 4001:4001 \
|
|
28
|
+
-e ADMIN_TOKEN=your-secret-token \
|
|
29
|
+
-v license-data:/app/data \
|
|
30
|
+
exflower/license-server:latest
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 方式三:源码运行
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cd license-server
|
|
37
|
+
npm install
|
|
38
|
+
npm start
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 环境变量
|
|
42
|
+
|
|
43
|
+
| 变量 | 默认值 | 说明 |
|
|
44
|
+
|------|--------|------|
|
|
45
|
+
| `PORT` | `4001` | 服务端口 |
|
|
46
|
+
| `ADMIN_TOKEN` | `exflower-admin-2026` | 管理后台鉴权 Token |
|
|
47
|
+
| `LICENSE_DATA_DIR` | `./data` | 数据库持久化目录 |
|
|
48
|
+
| `NODE_ENV` | `production` | 运行环境 |
|
|
49
|
+
|
|
50
|
+
## 管理后台
|
|
51
|
+
|
|
52
|
+
启动后访问 `http://localhost:4001/admin`,使用 `ADMIN_TOKEN` 登录。
|
|
53
|
+
|
|
54
|
+
## API 端点
|
|
55
|
+
|
|
56
|
+
| 端点 | 方法 | 说明 |
|
|
57
|
+
|------|------|------|
|
|
58
|
+
| `/health` | GET | 健康检查 |
|
|
59
|
+
| `/api/license/verify` | POST | 验证 License Key |
|
|
60
|
+
| `/api/license/heartbeat` | POST | 心跳上报 |
|
|
61
|
+
| `/api/admin/companies` | GET/POST | 企业管理 |
|
|
62
|
+
| `/api/admin/instances` | GET | 实例列表 |
|
|
63
|
+
|
|
64
|
+
## 构建 Docker 镜像
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
docker build -t exflower/license-server:latest .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
package/admin/index.html
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>ExFlower License Admin</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
11
|
+
background: #f5f5f7;
|
|
12
|
+
color: #1d1d1f;
|
|
13
|
+
line-height: 1.5;
|
|
14
|
+
}
|
|
15
|
+
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
16
|
+
header {
|
|
17
|
+
background: #fff;
|
|
18
|
+
border-bottom: 1px solid #e5e5e7;
|
|
19
|
+
padding: 20px;
|
|
20
|
+
margin-bottom: 20px;
|
|
21
|
+
}
|
|
22
|
+
h1 { font-size: 20px; font-weight: 600; }
|
|
23
|
+
.stats {
|
|
24
|
+
display: grid;
|
|
25
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
26
|
+
gap: 16px;
|
|
27
|
+
margin-bottom: 24px;
|
|
28
|
+
}
|
|
29
|
+
.stat-card {
|
|
30
|
+
background: #fff;
|
|
31
|
+
border-radius: 12px;
|
|
32
|
+
padding: 20px;
|
|
33
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
|
34
|
+
}
|
|
35
|
+
.stat-card .value {
|
|
36
|
+
font-size: 32px;
|
|
37
|
+
font-weight: 700;
|
|
38
|
+
color: #0071e3;
|
|
39
|
+
}
|
|
40
|
+
.stat-card .label {
|
|
41
|
+
font-size: 13px;
|
|
42
|
+
color: #86868b;
|
|
43
|
+
margin-top: 4px;
|
|
44
|
+
}
|
|
45
|
+
.section {
|
|
46
|
+
background: #fff;
|
|
47
|
+
border-radius: 12px;
|
|
48
|
+
padding: 20px;
|
|
49
|
+
margin-bottom: 20px;
|
|
50
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
|
51
|
+
}
|
|
52
|
+
.section h2 {
|
|
53
|
+
font-size: 16px;
|
|
54
|
+
font-weight: 600;
|
|
55
|
+
margin-bottom: 16px;
|
|
56
|
+
}
|
|
57
|
+
table {
|
|
58
|
+
width: 100%;
|
|
59
|
+
border-collapse: collapse;
|
|
60
|
+
font-size: 13px;
|
|
61
|
+
}
|
|
62
|
+
th, td {
|
|
63
|
+
text-align: left;
|
|
64
|
+
padding: 10px 12px;
|
|
65
|
+
border-bottom: 1px solid #f0f0f2;
|
|
66
|
+
}
|
|
67
|
+
th {
|
|
68
|
+
font-weight: 500;
|
|
69
|
+
color: #86868b;
|
|
70
|
+
font-size: 12px;
|
|
71
|
+
text-transform: uppercase;
|
|
72
|
+
letter-spacing: 0.5px;
|
|
73
|
+
}
|
|
74
|
+
.badge {
|
|
75
|
+
display: inline-block;
|
|
76
|
+
padding: 2px 8px;
|
|
77
|
+
border-radius: 4px;
|
|
78
|
+
font-size: 11px;
|
|
79
|
+
font-weight: 500;
|
|
80
|
+
}
|
|
81
|
+
.badge-active { background: #e8f5e9; color: #2e7d32; }
|
|
82
|
+
.badge-suspended { background: #ffebee; color: #c62828; }
|
|
83
|
+
.badge-expired { background: #fff3e0; color: #ef6c00; }
|
|
84
|
+
.btn {
|
|
85
|
+
padding: 6px 12px;
|
|
86
|
+
border: none;
|
|
87
|
+
border-radius: 6px;
|
|
88
|
+
font-size: 12px;
|
|
89
|
+
cursor: pointer;
|
|
90
|
+
transition: opacity 0.15s;
|
|
91
|
+
}
|
|
92
|
+
.btn:hover { opacity: 0.85; }
|
|
93
|
+
.btn-primary { background: #0071e3; color: #fff; }
|
|
94
|
+
.btn-danger { background: #ff3b30; color: #fff; }
|
|
95
|
+
.btn-small { padding: 4px 8px; font-size: 11px; }
|
|
96
|
+
.form-row {
|
|
97
|
+
display: flex;
|
|
98
|
+
gap: 12px;
|
|
99
|
+
margin-bottom: 12px;
|
|
100
|
+
align-items: center;
|
|
101
|
+
}
|
|
102
|
+
.form-row input, .form-row select {
|
|
103
|
+
padding: 8px 12px;
|
|
104
|
+
border: 1px solid #d2d2d7;
|
|
105
|
+
border-radius: 8px;
|
|
106
|
+
font-size: 13px;
|
|
107
|
+
outline: none;
|
|
108
|
+
}
|
|
109
|
+
.form-row input:focus { border-color: #0071e3; }
|
|
110
|
+
.form-row input[name="name"] { flex: 1; }
|
|
111
|
+
.form-row input[name="max_instances"],
|
|
112
|
+
.form-row input[name="max_agents"],
|
|
113
|
+
.form-row input[name="max_crews"] { width: 80px; }
|
|
114
|
+
.license-key {
|
|
115
|
+
font-family: 'SF Mono', Monaco, monospace;
|
|
116
|
+
font-size: 12px;
|
|
117
|
+
background: #f5f5f7;
|
|
118
|
+
padding: 4px 8px;
|
|
119
|
+
border-radius: 4px;
|
|
120
|
+
cursor: pointer;
|
|
121
|
+
}
|
|
122
|
+
.license-key:hover { background: #e5e5e7; }
|
|
123
|
+
.toast {
|
|
124
|
+
position: fixed;
|
|
125
|
+
bottom: 20px;
|
|
126
|
+
right: 20px;
|
|
127
|
+
background: #1d1d1f;
|
|
128
|
+
color: #fff;
|
|
129
|
+
padding: 12px 20px;
|
|
130
|
+
border-radius: 8px;
|
|
131
|
+
font-size: 13px;
|
|
132
|
+
opacity: 0;
|
|
133
|
+
transition: opacity 0.3s;
|
|
134
|
+
pointer-events: none;
|
|
135
|
+
}
|
|
136
|
+
.toast.show { opacity: 1; }
|
|
137
|
+
.tabs {
|
|
138
|
+
display: flex;
|
|
139
|
+
gap: 4px;
|
|
140
|
+
margin-bottom: 16px;
|
|
141
|
+
border-bottom: 1px solid #e5e5e7;
|
|
142
|
+
padding-bottom: 8px;
|
|
143
|
+
}
|
|
144
|
+
.tab {
|
|
145
|
+
padding: 8px 16px;
|
|
146
|
+
border: none;
|
|
147
|
+
background: none;
|
|
148
|
+
font-size: 13px;
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
color: #86868b;
|
|
151
|
+
border-radius: 6px;
|
|
152
|
+
}
|
|
153
|
+
.tab.active { color: #0071e3; background: #f0f7ff; font-weight: 500; }
|
|
154
|
+
.hidden { display: none; }
|
|
155
|
+
.template-type-agent { background: #e3f2fd; color: #1565c0; }
|
|
156
|
+
.template-type-crew { background: #f3e5f5; color: #6a1b9a; }
|
|
157
|
+
.template-type-excard { background: #e8f5e9; color: #2e7d32; }
|
|
158
|
+
.template-type-workflow { background: #fff3e0; color: #ef6c00; }
|
|
159
|
+
textarea {
|
|
160
|
+
width: 100%;
|
|
161
|
+
padding: 8px 12px;
|
|
162
|
+
border: 1px solid #d2d2d7;
|
|
163
|
+
border-radius: 8px;
|
|
164
|
+
font-size: 13px;
|
|
165
|
+
font-family: 'SF Mono', Monaco, monospace;
|
|
166
|
+
resize: vertical;
|
|
167
|
+
}
|
|
168
|
+
.modal-overlay {
|
|
169
|
+
position: fixed;
|
|
170
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
171
|
+
background: rgba(0,0,0,0.4);
|
|
172
|
+
display: none;
|
|
173
|
+
align-items: center;
|
|
174
|
+
justify-content: center;
|
|
175
|
+
z-index: 100;
|
|
176
|
+
}
|
|
177
|
+
.modal-overlay.show { display: flex; }
|
|
178
|
+
.modal {
|
|
179
|
+
background: #fff;
|
|
180
|
+
border-radius: 12px;
|
|
181
|
+
padding: 24px;
|
|
182
|
+
width: 90%;
|
|
183
|
+
max-width: 600px;
|
|
184
|
+
max-height: 80vh;
|
|
185
|
+
overflow-y: auto;
|
|
186
|
+
}
|
|
187
|
+
.modal h3 { margin-bottom: 16px; }
|
|
188
|
+
.modal .form-group { margin-bottom: 12px; }
|
|
189
|
+
.modal label { display: block; font-size: 12px; color: #86868b; margin-bottom: 4px; }
|
|
190
|
+
.modal input, .modal select, .modal textarea {
|
|
191
|
+
width: 100%;
|
|
192
|
+
padding: 8px 12px;
|
|
193
|
+
border: 1px solid #d2d2d7;
|
|
194
|
+
border-radius: 8px;
|
|
195
|
+
font-size: 13px;
|
|
196
|
+
}
|
|
197
|
+
.modal-actions {
|
|
198
|
+
display: flex;
|
|
199
|
+
gap: 8px;
|
|
200
|
+
justify-content: flex-end;
|
|
201
|
+
margin-top: 16px;
|
|
202
|
+
}
|
|
203
|
+
</style>
|
|
204
|
+
</head>
|
|
205
|
+
<body>
|
|
206
|
+
<header>
|
|
207
|
+
<div class="container">
|
|
208
|
+
<h1>ExFlower License Admin</h1>
|
|
209
|
+
</div>
|
|
210
|
+
</header>
|
|
211
|
+
|
|
212
|
+
<div class="container">
|
|
213
|
+
<div class="stats">
|
|
214
|
+
<div class="stat-card">
|
|
215
|
+
<div class="value" id="stat-companies">-</div>
|
|
216
|
+
<div class="label">授权公司</div>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="stat-card">
|
|
219
|
+
<div class="value" id="stat-instances">-</div>
|
|
220
|
+
<div class="label">活跃实例</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div class="tabs">
|
|
225
|
+
<button class="tab active" onclick="showTab('companies')">公司管理</button>
|
|
226
|
+
<button class="tab" onclick="showTab('instances')">实例监控</button>
|
|
227
|
+
<button class="tab" onclick="showTab('templates')">模板管理</button>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div id="tab-companies" class="section">
|
|
231
|
+
<h2>创建新公司</h2>
|
|
232
|
+
<div class="form-row">
|
|
233
|
+
<input type="text" name="name" placeholder="公司名称" id="new-name">
|
|
234
|
+
<input type="number" name="max_instances" placeholder="实例数" value="1" id="new-instances">
|
|
235
|
+
<input type="number" name="max_agents" placeholder="Agent数" value="5" id="new-agents">
|
|
236
|
+
<input type="number" name="max_crews" placeholder="Crew数" value="3" id="new-crews">
|
|
237
|
+
<button class="btn btn-primary" onclick="createCompany()">创建</button>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<h2 style="margin-top:24px">公司列表</h2>
|
|
241
|
+
<table>
|
|
242
|
+
<thead>
|
|
243
|
+
<tr>
|
|
244
|
+
<th>公司</th>
|
|
245
|
+
<th>License Key</th>
|
|
246
|
+
<th>限制</th>
|
|
247
|
+
<th>状态</th>
|
|
248
|
+
<th>到期日</th>
|
|
249
|
+
<th>活跃实例</th>
|
|
250
|
+
<th>操作</th>
|
|
251
|
+
</tr>
|
|
252
|
+
</thead>
|
|
253
|
+
<tbody id="companies-body"></tbody>
|
|
254
|
+
</table>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<div id="tab-instances" class="section hidden">
|
|
258
|
+
<h2>实例监控</h2>
|
|
259
|
+
<table>
|
|
260
|
+
<thead>
|
|
261
|
+
<tr>
|
|
262
|
+
<th>公司</th>
|
|
263
|
+
<th>实例ID</th>
|
|
264
|
+
<th>主机名</th>
|
|
265
|
+
<th>版本</th>
|
|
266
|
+
<th>首次上线</th>
|
|
267
|
+
<th>最后心跳</th>
|
|
268
|
+
<th>操作</th>
|
|
269
|
+
</tr>
|
|
270
|
+
</thead>
|
|
271
|
+
<tbody id="instances-body"></tbody>
|
|
272
|
+
</table>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<div id="tab-templates" class="section hidden">
|
|
276
|
+
<h2>模板管理</h2>
|
|
277
|
+
<div class="form-row">
|
|
278
|
+
<button class="btn btn-primary" onclick="openTemplateModal()">+ 上传模板</button>
|
|
279
|
+
</div>
|
|
280
|
+
<table>
|
|
281
|
+
<thead>
|
|
282
|
+
<tr>
|
|
283
|
+
<th>类型</th>
|
|
284
|
+
<th>名称</th>
|
|
285
|
+
<th>公司</th>
|
|
286
|
+
<th>版本</th>
|
|
287
|
+
<th>创建时间</th>
|
|
288
|
+
<th>操作</th>
|
|
289
|
+
</tr>
|
|
290
|
+
</thead>
|
|
291
|
+
<tbody id="templates-body"></tbody>
|
|
292
|
+
</table>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<div class="modal-overlay" id="template-modal">
|
|
297
|
+
<div class="modal">
|
|
298
|
+
<h3 id="modal-title">上传模板</h3>
|
|
299
|
+
<div class="form-group">
|
|
300
|
+
<label>类型</label>
|
|
301
|
+
<select id="tmpl-type">
|
|
302
|
+
<option value="agent">Agent</option>
|
|
303
|
+
<option value="crew">Crew</option>
|
|
304
|
+
<option value="excard">ExCard</option>
|
|
305
|
+
<option value="workflow">Workflow</option>
|
|
306
|
+
</select>
|
|
307
|
+
</div>
|
|
308
|
+
<div class="form-group">
|
|
309
|
+
<label>名称</label>
|
|
310
|
+
<input type="text" id="tmpl-name" placeholder="模板名称">
|
|
311
|
+
</div>
|
|
312
|
+
<div class="form-group">
|
|
313
|
+
<label>描述</label>
|
|
314
|
+
<input type="text" id="tmpl-desc" placeholder="简短描述">
|
|
315
|
+
</div>
|
|
316
|
+
<div class="form-group">
|
|
317
|
+
<label>所属公司 (留空 = 全局模板)</label>
|
|
318
|
+
<select id="tmpl-company">
|
|
319
|
+
<option value="">全局模板</option>
|
|
320
|
+
</select>
|
|
321
|
+
</div>
|
|
322
|
+
<div class="form-group">
|
|
323
|
+
<label>内容 (JSON)</label>
|
|
324
|
+
<textarea id="tmpl-json" rows="8" placeholder='{"id": "...", ...}'></textarea>
|
|
325
|
+
</div>
|
|
326
|
+
<div class="form-group">
|
|
327
|
+
<label>Markdown 内容 (可选,Agent/ExCard 需要)</label>
|
|
328
|
+
<textarea id="tmpl-md" rows="6" placeholder="YAML frontmatter + Markdown body"></textarea>
|
|
329
|
+
</div>
|
|
330
|
+
<div class="modal-actions">
|
|
331
|
+
<button class="btn" onclick="closeTemplateModal()">取消</button>
|
|
332
|
+
<button class="btn btn-primary" onclick="saveTemplate()">保存</button>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
<div class="toast" id="toast"></div>
|
|
338
|
+
|
|
339
|
+
<script>
|
|
340
|
+
const ADMIN_TOKEN = prompt('请输入 Admin Token:') || '';
|
|
341
|
+
const API_BASE = '';
|
|
342
|
+
|
|
343
|
+
function toast(msg) {
|
|
344
|
+
const el = document.getElementById('toast');
|
|
345
|
+
el.textContent = msg;
|
|
346
|
+
el.classList.add('show');
|
|
347
|
+
setTimeout(() => el.classList.remove('show'), 2000);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function api(method, path, body) {
|
|
351
|
+
const opts = {
|
|
352
|
+
method,
|
|
353
|
+
headers: {
|
|
354
|
+
'Content-Type': 'application/json',
|
|
355
|
+
'X-Admin-Token': ADMIN_TOKEN
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
if (body) opts.body = JSON.stringify(body);
|
|
359
|
+
const res = await fetch(`${API_BASE}${path}`, opts);
|
|
360
|
+
return res.json();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function loadStats() {
|
|
364
|
+
const res = await api('GET', '/health');
|
|
365
|
+
document.getElementById('stat-companies').textContent = res.total_companies || 0;
|
|
366
|
+
document.getElementById('stat-instances').textContent = res.active_instances || 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function loadCompanies() {
|
|
370
|
+
const res = await api('GET', '/api/admin/companies');
|
|
371
|
+
const tbody = document.getElementById('companies-body');
|
|
372
|
+
tbody.innerHTML = '';
|
|
373
|
+
|
|
374
|
+
for (const c of res.companies || []) {
|
|
375
|
+
const tr = document.createElement('tr');
|
|
376
|
+
const statusClass = c.status === 'active' ? 'badge-active' : c.status === 'suspended' ? 'badge-suspended' : 'badge-expired';
|
|
377
|
+
const expired = c.expires_at && new Date(c.expires_at) < new Date();
|
|
378
|
+
const finalStatus = expired ? 'expired' : c.status;
|
|
379
|
+
const finalClass = expired ? 'badge-expired' : statusClass;
|
|
380
|
+
|
|
381
|
+
tr.innerHTML = `
|
|
382
|
+
<td><strong>${c.name}</strong></td>
|
|
383
|
+
<td><span class="license-key" onclick="navigator.clipboard.writeText('${c.license_key}');toast('已复制')">${c.license_key}</span></td>
|
|
384
|
+
<td>${c.max_instances}实例 / ${c.max_agents}Agent / ${c.max_crews}Crew</td>
|
|
385
|
+
<td><span class="badge ${finalClass}">${finalStatus}</span></td>
|
|
386
|
+
<td>${c.expires_at ? c.expires_at.split('T')[0] : '永久'}</td>
|
|
387
|
+
<td>${c.active_instances || 0}</td>
|
|
388
|
+
<td>
|
|
389
|
+
<button class="btn btn-small ${c.status === 'active' ? 'btn-danger' : 'btn-primary'}" onclick="toggleStatus('${c.id}', '${c.status}')">
|
|
390
|
+
${c.status === 'active' ? '暂停' : '恢复'}
|
|
391
|
+
</button>
|
|
392
|
+
<button class="btn btn-small btn-danger" onclick="deleteCompany('${c.id}')">删除</button>
|
|
393
|
+
</td>
|
|
394
|
+
`;
|
|
395
|
+
tbody.appendChild(tr);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function loadInstances() {
|
|
400
|
+
const res = await api('GET', '/api/admin/instances');
|
|
401
|
+
const tbody = document.getElementById('instances-body');
|
|
402
|
+
tbody.innerHTML = '';
|
|
403
|
+
|
|
404
|
+
for (const i of res.instances || []) {
|
|
405
|
+
const tr = document.createElement('tr');
|
|
406
|
+
const daysSince = Math.floor((Date.now() - new Date(i.last_heartbeat)) / 86400000);
|
|
407
|
+
const hbColor = daysSince > 3 ? '#ff3b30' : daysSince > 1 ? '#ff9500' : '#34c759';
|
|
408
|
+
|
|
409
|
+
tr.innerHTML = `
|
|
410
|
+
<td>${i.company_name}</td>
|
|
411
|
+
<td><code>${i.instance_id}</code></td>
|
|
412
|
+
<td>${i.hostname || '-'}</td>
|
|
413
|
+
<td>${i.version || '-'}</td>
|
|
414
|
+
<td>${i.first_seen ? i.first_seen.split('T')[0] : '-'}</td>
|
|
415
|
+
<td style="color:${hbColor}">${i.last_heartbeat ? i.last_heartbeat.split('T').join(' ') : '-'}</td>
|
|
416
|
+
<td><button class="btn btn-small btn-danger" onclick="deleteInstance('${i.id}')">删除</button></td>
|
|
417
|
+
`;
|
|
418
|
+
tbody.appendChild(tr);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function createCompany() {
|
|
423
|
+
const name = document.getElementById('new-name').value.trim();
|
|
424
|
+
if (!name) return toast('请输入公司名称');
|
|
425
|
+
|
|
426
|
+
await api('POST', '/api/admin/companies', {
|
|
427
|
+
name,
|
|
428
|
+
max_instances: parseInt(document.getElementById('new-instances').value) || 1,
|
|
429
|
+
max_agents: parseInt(document.getElementById('new-agents').value) || 5,
|
|
430
|
+
max_crews: parseInt(document.getElementById('new-crews').value) || 3,
|
|
431
|
+
expires_at: new Date(Date.now() + 365 * 86400000).toISOString()
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
document.getElementById('new-name').value = '';
|
|
435
|
+
toast('公司创建成功');
|
|
436
|
+
loadCompanies();
|
|
437
|
+
loadStats();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function toggleStatus(id, current) {
|
|
441
|
+
const next = current === 'active' ? 'suspended' : 'active';
|
|
442
|
+
await api('PATCH', `/api/admin/companies/${id}`, { status: next });
|
|
443
|
+
toast(next === 'active' ? '已恢复授权' : '已暂停授权');
|
|
444
|
+
loadCompanies();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function deleteCompany(id) {
|
|
448
|
+
if (!confirm('确定删除此公司?所有实例记录也将被清除。')) return;
|
|
449
|
+
await api('DELETE', `/api/admin/companies/${id}`);
|
|
450
|
+
toast('已删除');
|
|
451
|
+
loadCompanies();
|
|
452
|
+
loadStats();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function deleteInstance(id) {
|
|
456
|
+
if (!confirm('确定删除此实例记录?')) return;
|
|
457
|
+
await api('DELETE', `/api/admin/instances/${id}`);
|
|
458
|
+
toast('已删除');
|
|
459
|
+
loadInstances();
|
|
460
|
+
loadStats();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function loadTemplates() {
|
|
464
|
+
const res = await api('GET', '/api/admin/templates');
|
|
465
|
+
const tbody = document.getElementById('templates-body');
|
|
466
|
+
tbody.innerHTML = '';
|
|
467
|
+
|
|
468
|
+
for (const t of res.templates || []) {
|
|
469
|
+
const tr = document.createElement('tr');
|
|
470
|
+
const typeBadge = `badge template-type-${t.type}`;
|
|
471
|
+
tr.innerHTML = `
|
|
472
|
+
<td><span class="badge ${typeBadge}">${t.type}</span></td>
|
|
473
|
+
<td><strong>${t.name}</strong><br><span style="color:#86868b;font-size:11px">${t.description || ''}</span></td>
|
|
474
|
+
<td>${t.company_id ? t.company_id.slice(0, 8) + '...' : '全局'}</td>
|
|
475
|
+
<td>${t.version || '1.0'}</td>
|
|
476
|
+
<td>${t.created_at ? t.created_at.split('T')[0] : '-'}</td>
|
|
477
|
+
<td>
|
|
478
|
+
<button class="btn btn-small btn-danger" onclick="deleteTemplate('${t.id}')">删除</button>
|
|
479
|
+
</td>
|
|
480
|
+
`;
|
|
481
|
+
tbody.appendChild(tr);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function populateCompanySelect() {
|
|
486
|
+
const res = await api('GET', '/api/admin/companies');
|
|
487
|
+
const sel = document.getElementById('tmpl-company');
|
|
488
|
+
sel.innerHTML = '<option value="">全局模板</option>';
|
|
489
|
+
for (const c of res.companies || []) {
|
|
490
|
+
const opt = document.createElement('option');
|
|
491
|
+
opt.value = c.id;
|
|
492
|
+
opt.textContent = c.name;
|
|
493
|
+
sel.appendChild(opt);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function openTemplateModal() {
|
|
498
|
+
document.getElementById('modal-title').textContent = '上传模板';
|
|
499
|
+
document.getElementById('tmpl-type').value = 'agent';
|
|
500
|
+
document.getElementById('tmpl-name').value = '';
|
|
501
|
+
document.getElementById('tmpl-desc').value = '';
|
|
502
|
+
document.getElementById('tmpl-json').value = '';
|
|
503
|
+
document.getElementById('tmpl-md').value = '';
|
|
504
|
+
populateCompanySelect();
|
|
505
|
+
document.getElementById('template-modal').classList.add('show');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function closeTemplateModal() {
|
|
509
|
+
document.getElementById('template-modal').classList.remove('show');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function saveTemplate() {
|
|
513
|
+
const type = document.getElementById('tmpl-type').value;
|
|
514
|
+
const name = document.getElementById('tmpl-name').value.trim();
|
|
515
|
+
const desc = document.getElementById('tmpl-desc').value.trim();
|
|
516
|
+
const companyId = document.getElementById('tmpl-company').value || undefined;
|
|
517
|
+
const jsonRaw = document.getElementById('tmpl-json').value.trim();
|
|
518
|
+
const md = document.getElementById('tmpl-md').value.trim() || undefined;
|
|
519
|
+
|
|
520
|
+
if (!name) return toast('请输入模板名称');
|
|
521
|
+
if (!jsonRaw) return toast('请输入 JSON 内容');
|
|
522
|
+
|
|
523
|
+
let contentJson;
|
|
524
|
+
try {
|
|
525
|
+
contentJson = JSON.parse(jsonRaw);
|
|
526
|
+
} catch (e) {
|
|
527
|
+
return toast('JSON 格式错误: ' + e.message);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
await api('POST', '/api/admin/templates', {
|
|
531
|
+
company_id: companyId,
|
|
532
|
+
type,
|
|
533
|
+
name,
|
|
534
|
+
description: desc,
|
|
535
|
+
content_json: contentJson,
|
|
536
|
+
content_md: md,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
closeTemplateModal();
|
|
540
|
+
toast('模板上传成功');
|
|
541
|
+
loadTemplates();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function deleteTemplate(id) {
|
|
545
|
+
if (!confirm('确定删除此模板?')) return;
|
|
546
|
+
await api('DELETE', `/api/admin/templates/${id}`);
|
|
547
|
+
toast('已删除');
|
|
548
|
+
loadTemplates();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function showTab(name) {
|
|
552
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
553
|
+
event.target.classList.add('active');
|
|
554
|
+
document.getElementById('tab-companies').classList.toggle('hidden', name !== 'companies');
|
|
555
|
+
document.getElementById('tab-instances').classList.toggle('hidden', name !== 'instances');
|
|
556
|
+
document.getElementById('tab-templates').classList.toggle('hidden', name !== 'templates');
|
|
557
|
+
if (name === 'instances') loadInstances();
|
|
558
|
+
if (name === 'templates') loadTemplates();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function init() {
|
|
562
|
+
await loadStats();
|
|
563
|
+
await loadCompanies();
|
|
564
|
+
setInterval(() => { loadStats(); loadCompanies(); }, 30000);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
init();
|
|
568
|
+
</script>
|
|
569
|
+
</body>
|
|
570
|
+
</html>
|
package/db.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* License Server Database
|
|
3
|
+
* SQLite storage for companies and instance tracking
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const Database = require('better-sqlite3');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
|
|
10
|
+
const DATA_DIR = process.env.LICENSE_DATA_DIR || path.join(__dirname, 'data');
|
|
11
|
+
const DB_FILE = path.join(DATA_DIR, 'license.db');
|
|
12
|
+
|
|
13
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
14
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const db = new Database(DB_FILE);
|
|
18
|
+
db.pragma('journal_mode = WAL');
|
|
19
|
+
|
|
20
|
+
// Initialize tables
|
|
21
|
+
db.exec(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS companies (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
name TEXT NOT NULL,
|
|
25
|
+
license_key TEXT UNIQUE NOT NULL,
|
|
26
|
+
max_instances INTEGER DEFAULT 1,
|
|
27
|
+
max_agents INTEGER DEFAULT 5,
|
|
28
|
+
max_crews INTEGER DEFAULT 3,
|
|
29
|
+
status TEXT DEFAULT 'active',
|
|
30
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
31
|
+
expires_at DATETIME
|
|
32
|
+
)
|
|
33
|
+
`);
|
|
34
|
+
|
|
35
|
+
db.exec(`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS instances (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
company_id TEXT NOT NULL,
|
|
39
|
+
instance_id TEXT NOT NULL,
|
|
40
|
+
hostname TEXT,
|
|
41
|
+
ip TEXT,
|
|
42
|
+
version TEXT,
|
|
43
|
+
first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
44
|
+
last_heartbeat DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
45
|
+
status TEXT DEFAULT 'active',
|
|
46
|
+
UNIQUE(company_id, instance_id)
|
|
47
|
+
)
|
|
48
|
+
`);
|
|
49
|
+
|
|
50
|
+
db.exec(`
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_instances_company ON instances(company_id)
|
|
52
|
+
`);
|
|
53
|
+
|
|
54
|
+
db.exec(`
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_instances_heartbeat ON instances(last_heartbeat)
|
|
56
|
+
`);
|
|
57
|
+
|
|
58
|
+
// ── Templates (auto-migration for existing deployments) ──
|
|
59
|
+
db.exec(`
|
|
60
|
+
CREATE TABLE IF NOT EXISTS templates (
|
|
61
|
+
id TEXT PRIMARY KEY,
|
|
62
|
+
company_id TEXT, -- null = global template (visible to all)
|
|
63
|
+
type TEXT NOT NULL, -- 'agent' | 'crew' | 'excard' | 'workflow'
|
|
64
|
+
name TEXT NOT NULL,
|
|
65
|
+
description TEXT,
|
|
66
|
+
content_json TEXT NOT NULL,
|
|
67
|
+
content_md TEXT,
|
|
68
|
+
tags TEXT, -- JSON array of strings
|
|
69
|
+
version TEXT DEFAULT '1.0',
|
|
70
|
+
is_public BOOLEAN DEFAULT 1, -- 1 = company members can see, 0 = admin only
|
|
71
|
+
created_by TEXT,
|
|
72
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
73
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
74
|
+
)
|
|
75
|
+
`);
|
|
76
|
+
db.exec(`
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_templates_company ON templates(company_id)
|
|
78
|
+
`);
|
|
79
|
+
db.exec(`
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_templates_type ON templates(type)
|
|
81
|
+
`);
|
|
82
|
+
|
|
83
|
+
// Seed demo data if empty
|
|
84
|
+
const count = db.prepare('SELECT COUNT(*) as c FROM companies').get().c;
|
|
85
|
+
if (count === 0) {
|
|
86
|
+
const { generateLicenseKey } = require('./utils');
|
|
87
|
+
const demoKey = generateLicenseKey();
|
|
88
|
+
db.prepare(`
|
|
89
|
+
INSERT INTO companies (id, name, license_key, max_instances, max_agents, max_crews, expires_at)
|
|
90
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+1 year'))
|
|
91
|
+
`).run('demo-001', '演示公司', demoKey, 2, 10, 5);
|
|
92
|
+
console.log('[LicenseServer] Demo company created with key:', demoKey);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function queryAll(sql, params = []) {
|
|
96
|
+
return db.prepare(sql).all(...params);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function queryGet(sql, params = []) {
|
|
100
|
+
return db.prepare(sql).get(...params);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function queryRun(sql, params = []) {
|
|
104
|
+
return db.prepare(sql).run(...params);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { queryAll, queryGet, queryRun };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
version: '3.8'
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
license-server:
|
|
5
|
+
build: .
|
|
6
|
+
image: exflower/license-server:latest
|
|
7
|
+
container_name: exflower-license-server
|
|
8
|
+
restart: unless-stopped
|
|
9
|
+
ports:
|
|
10
|
+
- "4001:4001"
|
|
11
|
+
environment:
|
|
12
|
+
# 管理后台 Token(必须修改)
|
|
13
|
+
- ADMIN_TOKEN=change-me-to-a-secure-token
|
|
14
|
+
# 服务端口
|
|
15
|
+
- PORT=4001
|
|
16
|
+
# 数据持久化目录
|
|
17
|
+
- LICENSE_DATA_DIR=/app/data
|
|
18
|
+
volumes:
|
|
19
|
+
- license-data:/app/data
|
|
20
|
+
healthcheck:
|
|
21
|
+
test: ["CMD", "node", "-e", "require('http').get('http://localhost:4001/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"]
|
|
22
|
+
interval: 30s
|
|
23
|
+
timeout: 5s
|
|
24
|
+
retries: 3
|
|
25
|
+
start_period: 5s
|
|
26
|
+
|
|
27
|
+
volumes:
|
|
28
|
+
license-data:
|
package/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ExFlower License Server
|
|
4
|
+
* 在线授权验证服务
|
|
5
|
+
*
|
|
6
|
+
* 环境变量:
|
|
7
|
+
* PORT - 服务端口 (默认 4001)
|
|
8
|
+
* ADMIN_TOKEN - 管理后台鉴权 Token (默认 exflower-admin-2026)
|
|
9
|
+
*
|
|
10
|
+
* 启动:
|
|
11
|
+
* cd license-server && npm install && npm start
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const express = require('express');
|
|
15
|
+
const cors = require('cors');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { setupRoutes } = require('./routes');
|
|
18
|
+
|
|
19
|
+
const app = express();
|
|
20
|
+
const PORT = process.env.PORT || 4001;
|
|
21
|
+
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'exflower-admin-2026';
|
|
22
|
+
|
|
23
|
+
app.use(cors());
|
|
24
|
+
app.use(express.json());
|
|
25
|
+
|
|
26
|
+
// Admin dashboard (static HTML)
|
|
27
|
+
app.use('/admin', express.static(path.join(__dirname, 'admin')));
|
|
28
|
+
app.get('/admin', (req, res) => {
|
|
29
|
+
res.sendFile(path.join(__dirname, 'admin', 'index.html'));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
setupRoutes(app);
|
|
33
|
+
|
|
34
|
+
app.listen(PORT, () => {
|
|
35
|
+
console.log(`╔══════════════════════════════════════════════════╗`);
|
|
36
|
+
console.log(`║ ExFlower License Server ║`);
|
|
37
|
+
console.log(`║ Port: ${PORT.toString().padEnd(42)} ║`);
|
|
38
|
+
console.log(`║ Admin Dashboard: http://localhost:${PORT}/admin ║`);
|
|
39
|
+
console.log(`║ Admin Token: ${ADMIN_TOKEN.padEnd(34)} ║`);
|
|
40
|
+
console.log(`╚══════════════════════════════════════════════════╝`);
|
|
41
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "exflower-license-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "ExFlower License Server - 在线授权验证服务",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"exflower-license-server": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"db.js",
|
|
12
|
+
"routes.js",
|
|
13
|
+
"utils.js",
|
|
14
|
+
"admin/**/*",
|
|
15
|
+
"Dockerfile",
|
|
16
|
+
"docker-compose.yml",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"start": "node index.js",
|
|
21
|
+
"dev": "node index.js"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"better-sqlite3": "^11.10.0",
|
|
25
|
+
"cors": "^2.8.5",
|
|
26
|
+
"express": "^4.18.2"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.0.0"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"license",
|
|
33
|
+
"exflower",
|
|
34
|
+
"authorization"
|
|
35
|
+
],
|
|
36
|
+
"author": "ExFlower",
|
|
37
|
+
"license": "MIT"
|
|
38
|
+
}
|
package/routes.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* License Server API Routes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { queryAll, queryGet, queryRun } = require('./db');
|
|
6
|
+
const { generateLicenseKey, generateId } = require('./utils');
|
|
7
|
+
|
|
8
|
+
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'exflower-admin-2026';
|
|
9
|
+
|
|
10
|
+
function requireAdmin(req, res, next) {
|
|
11
|
+
const token = req.headers['x-admin-token'];
|
|
12
|
+
if (token !== ADMIN_TOKEN) {
|
|
13
|
+
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
|
14
|
+
}
|
|
15
|
+
next();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function setupRoutes(app) {
|
|
19
|
+
// ── License Verification ──
|
|
20
|
+
app.post('/api/license/verify', (req, res) => {
|
|
21
|
+
const { license_key, instance_id, hostname, ip, version } = req.body;
|
|
22
|
+
|
|
23
|
+
if (!license_key || !instance_id) {
|
|
24
|
+
return res.status(400).json({
|
|
25
|
+
success: false,
|
|
26
|
+
valid: false,
|
|
27
|
+
message: 'license_key and instance_id are required'
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const company = queryGet(
|
|
32
|
+
'SELECT * FROM companies WHERE license_key = ?',
|
|
33
|
+
[license_key]
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (!company) {
|
|
37
|
+
return res.status(404).json({
|
|
38
|
+
success: false,
|
|
39
|
+
valid: false,
|
|
40
|
+
message: 'Invalid license key'
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (company.status === 'suspended') {
|
|
45
|
+
return res.status(403).json({
|
|
46
|
+
success: false,
|
|
47
|
+
valid: false,
|
|
48
|
+
message: 'License has been suspended'
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (company.expires_at && new Date(company.expires_at) < new Date()) {
|
|
53
|
+
return res.status(403).json({
|
|
54
|
+
success: false,
|
|
55
|
+
valid: false,
|
|
56
|
+
message: 'License has expired'
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check instance count
|
|
61
|
+
const activeInstances = queryGet(
|
|
62
|
+
`SELECT COUNT(*) as c FROM instances
|
|
63
|
+
WHERE company_id = ? AND status = 'active'
|
|
64
|
+
AND last_heartbeat > datetime('now', '-7 days')`,
|
|
65
|
+
[company.id]
|
|
66
|
+
).c;
|
|
67
|
+
|
|
68
|
+
const existingInstance = queryGet(
|
|
69
|
+
'SELECT * FROM instances WHERE company_id = ? AND instance_id = ?',
|
|
70
|
+
[company.id, instance_id]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// If this is a new instance and limit reached
|
|
74
|
+
if (!existingInstance && activeInstances >= company.max_instances) {
|
|
75
|
+
return res.status(403).json({
|
|
76
|
+
success: false,
|
|
77
|
+
valid: false,
|
|
78
|
+
message: `Instance limit reached (${company.max_instances} max)`,
|
|
79
|
+
instances: activeInstances,
|
|
80
|
+
max_instances: company.max_instances
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Upsert instance record
|
|
85
|
+
if (existingInstance) {
|
|
86
|
+
queryRun(
|
|
87
|
+
`UPDATE instances
|
|
88
|
+
SET hostname = ?, ip = ?, version = ?, last_heartbeat = datetime('now'), status = 'active'
|
|
89
|
+
WHERE id = ?`,
|
|
90
|
+
[hostname || existingInstance.hostname, ip, version, existingInstance.id]
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
queryRun(
|
|
94
|
+
`INSERT INTO instances (id, company_id, instance_id, hostname, ip, version)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
96
|
+
[generateId(), company.id, instance_id, hostname, ip, version]
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
res.json({
|
|
101
|
+
success: true,
|
|
102
|
+
valid: true,
|
|
103
|
+
company: {
|
|
104
|
+
id: company.id,
|
|
105
|
+
name: company.name
|
|
106
|
+
},
|
|
107
|
+
limits: {
|
|
108
|
+
max_instances: company.max_instances,
|
|
109
|
+
max_agents: company.max_agents,
|
|
110
|
+
max_crews: company.max_crews
|
|
111
|
+
},
|
|
112
|
+
expires_at: company.expires_at
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── Heartbeat ──
|
|
117
|
+
app.post('/api/license/heartbeat', (req, res) => {
|
|
118
|
+
const { license_key, instance_id } = req.body;
|
|
119
|
+
|
|
120
|
+
if (!license_key || !instance_id) {
|
|
121
|
+
return res.status(400).json({ success: false, valid: false, message: 'Missing fields' });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const company = queryGet('SELECT id, status, expires_at FROM companies WHERE license_key = ?', [license_key]);
|
|
125
|
+
if (!company) {
|
|
126
|
+
return res.status(404).json({ success: false, valid: false, message: 'Invalid license' });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (company.status === 'suspended') {
|
|
130
|
+
return res.status(403).json({ success: false, valid: false, message: 'License suspended' });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (company.expires_at && new Date(company.expires_at) < new Date()) {
|
|
134
|
+
return res.status(403).json({ success: false, valid: false, message: 'License expired' });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const instance = queryGet(
|
|
138
|
+
'SELECT id FROM instances WHERE company_id = ? AND instance_id = ?',
|
|
139
|
+
[company.id, instance_id]
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (instance) {
|
|
143
|
+
queryRun(
|
|
144
|
+
'UPDATE instances SET last_heartbeat = datetime("now") WHERE id = ?',
|
|
145
|
+
[instance.id]
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
res.json({ success: true, valid: true });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── Admin: Companies ──
|
|
153
|
+
app.get('/api/admin/companies', requireAdmin, (req, res) => {
|
|
154
|
+
const companies = queryAll(`
|
|
155
|
+
SELECT c.*,
|
|
156
|
+
(SELECT COUNT(*) FROM instances WHERE company_id = c.id AND status = 'active' AND last_heartbeat > datetime('now', '-7 days')) as active_instances
|
|
157
|
+
FROM companies c
|
|
158
|
+
ORDER BY c.created_at DESC
|
|
159
|
+
`);
|
|
160
|
+
res.json({ success: true, companies });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
app.post('/api/admin/companies', requireAdmin, (req, res) => {
|
|
164
|
+
const { name, max_instances, max_agents, max_crews, expires_at } = req.body;
|
|
165
|
+
if (!name) {
|
|
166
|
+
return res.status(400).json({ success: false, message: 'Company name is required' });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const key = generateLicenseKey();
|
|
170
|
+
const id = generateId();
|
|
171
|
+
|
|
172
|
+
const defaultExpires = new Date();
|
|
173
|
+
defaultExpires.setFullYear(defaultExpires.getFullYear() + 1);
|
|
174
|
+
const expires = expires_at || defaultExpires.toISOString().slice(0, 10);
|
|
175
|
+
|
|
176
|
+
queryRun(
|
|
177
|
+
`INSERT INTO companies (id, name, license_key, max_instances, max_agents, max_crews, expires_at, status)
|
|
178
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
179
|
+
[id, name, key, max_instances || 1, max_agents || 5, max_crews || 3, expires, 'active']
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
res.json({ success: true, company: { id, name, license_key: key } });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
app.patch('/api/admin/companies/:id', requireAdmin, (req, res) => {
|
|
186
|
+
const { status, max_instances, max_agents, max_crews, expires_at } = req.body;
|
|
187
|
+
const company = queryGet('SELECT * FROM companies WHERE id = ?', [req.params.id]);
|
|
188
|
+
if (!company) return res.status(404).json({ success: false, message: 'Company not found' });
|
|
189
|
+
|
|
190
|
+
const updates = [];
|
|
191
|
+
const params = [];
|
|
192
|
+
if (status !== undefined) { updates.push('status = ?'); params.push(status); }
|
|
193
|
+
if (max_instances !== undefined) { updates.push('max_instances = ?'); params.push(max_instances); }
|
|
194
|
+
if (max_agents !== undefined) { updates.push('max_agents = ?'); params.push(max_agents); }
|
|
195
|
+
if (max_crews !== undefined) { updates.push('max_crews = ?'); params.push(max_crews); }
|
|
196
|
+
if (expires_at !== undefined) { updates.push('expires_at = ?'); params.push(expires_at); }
|
|
197
|
+
|
|
198
|
+
if (updates.length > 0) {
|
|
199
|
+
queryRun(`UPDATE companies SET ${updates.join(', ')} WHERE id = ?`, [...params, req.params.id]);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
res.json({ success: true });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
app.delete('/api/admin/companies/:id', requireAdmin, (req, res) => {
|
|
206
|
+
queryRun('DELETE FROM instances WHERE company_id = ?', [req.params.id]);
|
|
207
|
+
queryRun('DELETE FROM companies WHERE id = ?', [req.params.id]);
|
|
208
|
+
res.json({ success: true });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── Admin: Instances ──
|
|
212
|
+
app.get('/api/admin/instances', requireAdmin, (req, res) => {
|
|
213
|
+
const instances = queryAll(`
|
|
214
|
+
SELECT i.*, c.name as company_name, c.license_key
|
|
215
|
+
FROM instances i
|
|
216
|
+
JOIN companies c ON i.company_id = c.id
|
|
217
|
+
ORDER BY i.last_heartbeat DESC
|
|
218
|
+
`);
|
|
219
|
+
res.json({ success: true, instances });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
app.delete('/api/admin/instances/:id', requireAdmin, (req, res) => {
|
|
223
|
+
queryRun('DELETE FROM instances WHERE id = ?', [req.params.id]);
|
|
224
|
+
res.json({ success: true });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── Templates (client-facing) ──
|
|
228
|
+
// List templates visible to the authenticated company
|
|
229
|
+
app.get('/api/templates', (req, res) => {
|
|
230
|
+
const licenseKey = req.headers['x-license-key'];
|
|
231
|
+
if (!licenseKey) {
|
|
232
|
+
return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
|
|
236
|
+
if (!company) {
|
|
237
|
+
return res.status(404).json({ success: false, message: 'Invalid license key' });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const { type } = req.query;
|
|
241
|
+
let sql = `
|
|
242
|
+
SELECT id, type, name, description, tags, version, created_at, updated_at
|
|
243
|
+
FROM templates
|
|
244
|
+
WHERE company_id IS NULL OR company_id = ?
|
|
245
|
+
`;
|
|
246
|
+
const params = [company.id];
|
|
247
|
+
if (type) {
|
|
248
|
+
sql += ' AND type = ?';
|
|
249
|
+
params.push(type);
|
|
250
|
+
}
|
|
251
|
+
sql += ' ORDER BY type, name';
|
|
252
|
+
|
|
253
|
+
const templates = queryAll(sql, params);
|
|
254
|
+
res.json({ success: true, templates });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Get a single template (full content)
|
|
258
|
+
app.get('/api/templates/:id', (req, res) => {
|
|
259
|
+
const licenseKey = req.headers['x-license-key'];
|
|
260
|
+
if (!licenseKey) {
|
|
261
|
+
return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
|
|
265
|
+
if (!company) {
|
|
266
|
+
return res.status(404).json({ success: false, message: 'Invalid license key' });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const template = queryGet(
|
|
270
|
+
'SELECT * FROM templates WHERE id = ? AND (company_id IS NULL OR company_id = ?)',
|
|
271
|
+
[req.params.id, company.id]
|
|
272
|
+
);
|
|
273
|
+
if (!template) {
|
|
274
|
+
return res.status(404).json({ success: false, message: 'Template not found' });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Parse tags JSON
|
|
278
|
+
try { template.tags = JSON.parse(template.tags || '[]'); } catch { template.tags = []; }
|
|
279
|
+
|
|
280
|
+
res.json({ success: true, template });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── Admin: Templates ──
|
|
284
|
+
app.get('/api/admin/templates', requireAdmin, (req, res) => {
|
|
285
|
+
const { company_id, type } = req.query;
|
|
286
|
+
let sql = 'SELECT * FROM templates WHERE 1=1';
|
|
287
|
+
const params = [];
|
|
288
|
+
if (company_id) {
|
|
289
|
+
sql += ' AND company_id = ?';
|
|
290
|
+
params.push(company_id);
|
|
291
|
+
}
|
|
292
|
+
if (type) {
|
|
293
|
+
sql += ' AND type = ?';
|
|
294
|
+
params.push(type);
|
|
295
|
+
}
|
|
296
|
+
sql += ' ORDER BY created_at DESC';
|
|
297
|
+
|
|
298
|
+
const templates = queryAll(sql, params);
|
|
299
|
+
// Parse tags JSON for each
|
|
300
|
+
for (const t of templates) {
|
|
301
|
+
try { t.tags = JSON.parse(t.tags || '[]'); } catch { t.tags = []; }
|
|
302
|
+
}
|
|
303
|
+
res.json({ success: true, templates });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
app.post('/api/admin/templates', requireAdmin, (req, res) => {
|
|
307
|
+
const { company_id, type, name, description, content_json, content_md, tags, version } = req.body;
|
|
308
|
+
|
|
309
|
+
if (!type || !name || !content_json) {
|
|
310
|
+
return res.status(400).json({ success: false, message: 'type, name, content_json are required' });
|
|
311
|
+
}
|
|
312
|
+
if (!['agent', 'crew', 'excard', 'workflow'].includes(type)) {
|
|
313
|
+
return res.status(400).json({ success: false, message: 'type must be agent, crew, excard, or workflow' });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// If company_id is provided, verify it exists
|
|
317
|
+
if (company_id) {
|
|
318
|
+
const company = queryGet('SELECT id FROM companies WHERE id = ?', [company_id]);
|
|
319
|
+
if (!company) {
|
|
320
|
+
return res.status(404).json({ success: false, message: 'Company not found' });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const id = generateId();
|
|
325
|
+
queryRun(
|
|
326
|
+
`INSERT INTO templates (id, company_id, type, name, description, content_json, content_md, tags, version, created_at, updated_at)
|
|
327
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`,
|
|
328
|
+
[
|
|
329
|
+
id,
|
|
330
|
+
company_id || null,
|
|
331
|
+
type,
|
|
332
|
+
name,
|
|
333
|
+
description || '',
|
|
334
|
+
typeof content_json === 'string' ? content_json : JSON.stringify(content_json),
|
|
335
|
+
content_md || null,
|
|
336
|
+
tags ? JSON.stringify(tags) : '[]',
|
|
337
|
+
version || '1.0',
|
|
338
|
+
]
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
res.json({ success: true, template: { id, company_id: company_id || null, type, name } });
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
app.patch('/api/admin/templates/:id', requireAdmin, (req, res) => {
|
|
345
|
+
const template = queryGet('SELECT * FROM templates WHERE id = ?', [req.params.id]);
|
|
346
|
+
if (!template) return res.status(404).json({ success: false, message: 'Template not found' });
|
|
347
|
+
|
|
348
|
+
const { company_id, type, name, description, content_json, content_md, tags, version, is_public } = req.body;
|
|
349
|
+
const updates = [];
|
|
350
|
+
const params = [];
|
|
351
|
+
|
|
352
|
+
if (company_id !== undefined) { updates.push('company_id = ?'); params.push(company_id || null); }
|
|
353
|
+
if (type !== undefined) { updates.push('type = ?'); params.push(type); }
|
|
354
|
+
if (name !== undefined) { updates.push('name = ?'); params.push(name); }
|
|
355
|
+
if (description !== undefined) { updates.push('description = ?'); params.push(description); }
|
|
356
|
+
if (content_json !== undefined) { updates.push('content_json = ?'); params.push(typeof content_json === 'string' ? content_json : JSON.stringify(content_json)); }
|
|
357
|
+
if (content_md !== undefined) { updates.push('content_md = ?'); params.push(content_md || null); }
|
|
358
|
+
if (tags !== undefined) { updates.push('tags = ?'); params.push(JSON.stringify(tags)); }
|
|
359
|
+
if (version !== undefined) { updates.push('version = ?'); params.push(version); }
|
|
360
|
+
if (is_public !== undefined) { updates.push('is_public = ?'); params.push(is_public ? 1 : 0); }
|
|
361
|
+
|
|
362
|
+
if (updates.length === 0) {
|
|
363
|
+
return res.status(400).json({ success: false, message: 'No fields to update' });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
updates.push('updated_at = datetime("now")');
|
|
367
|
+
queryRun(`UPDATE templates SET ${updates.join(', ')} WHERE id = ?`, [...params, req.params.id]);
|
|
368
|
+
|
|
369
|
+
res.json({ success: true });
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
app.delete('/api/admin/templates/:id', requireAdmin, (req, res) => {
|
|
373
|
+
queryRun('DELETE FROM templates WHERE id = ?', [req.params.id]);
|
|
374
|
+
res.json({ success: true });
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ── Health ──
|
|
378
|
+
app.get('/health', (req, res) => {
|
|
379
|
+
const stats = queryGet(`
|
|
380
|
+
SELECT
|
|
381
|
+
(SELECT COUNT(*) FROM companies) as total_companies,
|
|
382
|
+
(SELECT COUNT(*) FROM instances WHERE status = 'active' AND last_heartbeat > datetime('now', '-7 days')) as active_instances
|
|
383
|
+
`);
|
|
384
|
+
res.json({ status: 'ok', ...stats });
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
module.exports = { setupRoutes };
|
package/utils.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* License Server Utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
|
|
7
|
+
function generateLicenseKey() {
|
|
8
|
+
const prefix = 'EXF';
|
|
9
|
+
const segments = [];
|
|
10
|
+
for (let i = 0; i < 4; i++) {
|
|
11
|
+
segments.push(crypto.randomBytes(3).toString('hex').toUpperCase());
|
|
12
|
+
}
|
|
13
|
+
return `${prefix}-${segments.join('-')}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function generateId() {
|
|
17
|
+
return crypto.randomUUID();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { generateLicenseKey, generateId };
|