claude-code-limiter-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/Caddyfile +12 -0
- package/Dockerfile +57 -0
- package/LICENSE +21 -0
- package/bin/server.js +51 -0
- package/docker-compose.yml +46 -0
- package/package.json +27 -0
- package/src/dashboard/css/style.css +1374 -0
- package/src/dashboard/index.html +118 -0
- package/src/dashboard/js/app.js +1650 -0
- package/src/dashboard/js/charts.js +388 -0
- package/src/dashboard/js/ws.js +172 -0
- package/src/server/db.js +484 -0
- package/src/server/index.js +100 -0
- package/src/server/routes/admin-api.js +386 -0
- package/src/server/routes/hook-api.js +312 -0
- package/src/server/services/auth.js +174 -0
- package/src/server/services/limiter.js +226 -0
- package/src/server/services/usage.js +87 -0
- package/src/server/ws.js +74 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const router = express.Router();
|
|
5
|
+
const db = require('../db');
|
|
6
|
+
const { verifyPassword, hashPassword, createJWT, adminAuth } = require('../services/auth');
|
|
7
|
+
const { getUsageSummary, getCreditBalance } = require('../services/usage');
|
|
8
|
+
const { broadcast } = require('../ws');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* POST /api/admin/login
|
|
12
|
+
* Verify admin password, return JWT.
|
|
13
|
+
* Body: { password }
|
|
14
|
+
*/
|
|
15
|
+
router.post('/login', (req, res) => {
|
|
16
|
+
try {
|
|
17
|
+
const { password } = req.body;
|
|
18
|
+
if (!password) {
|
|
19
|
+
return res.status(400).json({ error: 'Password required' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const team = db.getDefaultTeam();
|
|
23
|
+
if (!team) {
|
|
24
|
+
return res.status(500).json({ error: 'No team configured' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!verifyPassword(password, team.admin_password)) {
|
|
28
|
+
return res.status(401).json({ error: 'Invalid password' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const token = createJWT({ teamId: team.id });
|
|
32
|
+
|
|
33
|
+
// Set cookie and return token
|
|
34
|
+
res.cookie('jwt', token, {
|
|
35
|
+
httpOnly: true,
|
|
36
|
+
sameSite: 'lax',
|
|
37
|
+
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
res.json({
|
|
41
|
+
token,
|
|
42
|
+
team: {
|
|
43
|
+
id: team.id,
|
|
44
|
+
name: team.name,
|
|
45
|
+
credit_weights: JSON.parse(team.credit_weights),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error('POST /login error:', err);
|
|
50
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// All routes below require admin auth
|
|
55
|
+
router.use(adminAuth);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* GET /api/admin/users
|
|
59
|
+
* List all users with live usage.
|
|
60
|
+
*/
|
|
61
|
+
router.get('/users', (req, res) => {
|
|
62
|
+
try {
|
|
63
|
+
const team = req.team;
|
|
64
|
+
const creditWeights = JSON.parse(team.credit_weights);
|
|
65
|
+
const users = db.getAllUsers(team.id);
|
|
66
|
+
|
|
67
|
+
const result = users.map(user => {
|
|
68
|
+
const limits = db.getLimitRules(user.id);
|
|
69
|
+
const usageSummary = getUsageSummary(user.id);
|
|
70
|
+
|
|
71
|
+
// Credit info
|
|
72
|
+
const creditRule = limits.find(r => r.type === 'credits');
|
|
73
|
+
let creditBalance = null;
|
|
74
|
+
let creditBudget = null;
|
|
75
|
+
if (creditRule) {
|
|
76
|
+
const cb = getCreditBalance(user.id, creditWeights, creditRule.window, creditRule.value);
|
|
77
|
+
creditBalance = cb.balance;
|
|
78
|
+
creditBudget = cb.budget;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
id: user.id,
|
|
83
|
+
slug: user.slug,
|
|
84
|
+
name: user.name,
|
|
85
|
+
status: user.status,
|
|
86
|
+
killed_at: user.killed_at,
|
|
87
|
+
last_seen: user.last_seen,
|
|
88
|
+
created_at: user.created_at,
|
|
89
|
+
limits,
|
|
90
|
+
usage: usageSummary,
|
|
91
|
+
credit_balance: creditBalance,
|
|
92
|
+
credit_budget: creditBudget,
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
res.json({ users: result });
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error('GET /users error:', err);
|
|
99
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* POST /api/admin/users
|
|
105
|
+
* Create a user and generate an install code.
|
|
106
|
+
* Body: { name, slug, limits? }
|
|
107
|
+
*/
|
|
108
|
+
router.post('/users', (req, res) => {
|
|
109
|
+
try {
|
|
110
|
+
const team = req.team;
|
|
111
|
+
const { name, slug, limits } = req.body;
|
|
112
|
+
|
|
113
|
+
if (!name || !slug) {
|
|
114
|
+
return res.status(400).json({ error: 'name and slug are required' });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check slug uniqueness
|
|
118
|
+
const existing = db.getUserBySlug(team.id, slug);
|
|
119
|
+
if (existing) {
|
|
120
|
+
return res.status(409).json({ error: `User with slug "${slug}" already exists` });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Create user
|
|
124
|
+
const user = db.createUser({ teamId: team.id, slug, name });
|
|
125
|
+
|
|
126
|
+
// Create limit rules if provided
|
|
127
|
+
if (limits && Array.isArray(limits)) {
|
|
128
|
+
for (const rule of limits) {
|
|
129
|
+
db.createLimitRule({
|
|
130
|
+
userId: user.id,
|
|
131
|
+
type: rule.type,
|
|
132
|
+
model: rule.model,
|
|
133
|
+
window: rule.window,
|
|
134
|
+
value: rule.value,
|
|
135
|
+
schedule_start: rule.schedule_start,
|
|
136
|
+
schedule_end: rule.schedule_end,
|
|
137
|
+
schedule_tz: rule.schedule_tz,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Generate install code
|
|
143
|
+
const installCode = db.createInstallCode(user.id);
|
|
144
|
+
|
|
145
|
+
// Get the full user with limits
|
|
146
|
+
const fullLimits = db.getLimitRules(user.id);
|
|
147
|
+
|
|
148
|
+
res.status(201).json({
|
|
149
|
+
user: {
|
|
150
|
+
id: user.id,
|
|
151
|
+
slug: user.slug,
|
|
152
|
+
name: user.name,
|
|
153
|
+
status: user.status,
|
|
154
|
+
auth_token: user.auth_token,
|
|
155
|
+
created_at: user.created_at,
|
|
156
|
+
},
|
|
157
|
+
limits: fullLimits,
|
|
158
|
+
install_code: installCode,
|
|
159
|
+
});
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error('POST /users error:', err);
|
|
162
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* PUT /api/admin/users/:id
|
|
168
|
+
* Update a user (name, slug, status, limits).
|
|
169
|
+
* Body: { name?, slug?, status?, limits? }
|
|
170
|
+
*/
|
|
171
|
+
router.put('/users/:id', (req, res) => {
|
|
172
|
+
try {
|
|
173
|
+
const userId = req.params.id;
|
|
174
|
+
const user = db.getUser(userId);
|
|
175
|
+
|
|
176
|
+
if (!user) {
|
|
177
|
+
return res.status(404).json({ error: 'User not found' });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Verify user belongs to this team
|
|
181
|
+
if (user.team_id !== req.teamId) {
|
|
182
|
+
return res.status(403).json({ error: 'User does not belong to your team' });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const { name, slug, status, limits } = req.body;
|
|
186
|
+
const updates = {};
|
|
187
|
+
if (name !== undefined) updates.name = name;
|
|
188
|
+
if (slug !== undefined) updates.slug = slug;
|
|
189
|
+
if (status !== undefined) updates.status = status;
|
|
190
|
+
|
|
191
|
+
if (Object.keys(updates).length > 0) {
|
|
192
|
+
db.updateUser(userId, updates);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Replace limits if provided
|
|
196
|
+
if (limits !== undefined && Array.isArray(limits)) {
|
|
197
|
+
db.deleteLimitRulesForUser(userId);
|
|
198
|
+
for (const rule of limits) {
|
|
199
|
+
db.createLimitRule({
|
|
200
|
+
userId,
|
|
201
|
+
type: rule.type,
|
|
202
|
+
model: rule.model,
|
|
203
|
+
window: rule.window,
|
|
204
|
+
value: rule.value,
|
|
205
|
+
schedule_start: rule.schedule_start,
|
|
206
|
+
schedule_end: rule.schedule_end,
|
|
207
|
+
schedule_tz: rule.schedule_tz,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const updatedUser = db.getUser(userId);
|
|
213
|
+
const updatedLimits = db.getLimitRules(userId);
|
|
214
|
+
|
|
215
|
+
// Broadcast status change
|
|
216
|
+
if (status && status !== user.status) {
|
|
217
|
+
const eventType = status === 'killed' ? 'user_killed' : 'user_status_change';
|
|
218
|
+
broadcast({
|
|
219
|
+
type: eventType,
|
|
220
|
+
userId: updatedUser.id,
|
|
221
|
+
userName: updatedUser.name,
|
|
222
|
+
oldStatus: user.status,
|
|
223
|
+
newStatus: status,
|
|
224
|
+
timestamp: new Date().toISOString(),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
res.json({
|
|
229
|
+
user: {
|
|
230
|
+
id: updatedUser.id,
|
|
231
|
+
slug: updatedUser.slug,
|
|
232
|
+
name: updatedUser.name,
|
|
233
|
+
status: updatedUser.status,
|
|
234
|
+
killed_at: updatedUser.killed_at,
|
|
235
|
+
last_seen: updatedUser.last_seen,
|
|
236
|
+
created_at: updatedUser.created_at,
|
|
237
|
+
},
|
|
238
|
+
limits: updatedLimits,
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error('PUT /users/:id error:', err);
|
|
242
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* DELETE /api/admin/users/:id
|
|
248
|
+
* Remove a user and all related data.
|
|
249
|
+
*/
|
|
250
|
+
router.delete('/users/:id', (req, res) => {
|
|
251
|
+
try {
|
|
252
|
+
const userId = req.params.id;
|
|
253
|
+
const user = db.getUser(userId);
|
|
254
|
+
|
|
255
|
+
if (!user) {
|
|
256
|
+
return res.status(404).json({ error: 'User not found' });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (user.team_id !== req.teamId) {
|
|
260
|
+
return res.status(403).json({ error: 'User does not belong to your team' });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
db.deleteUser(userId);
|
|
264
|
+
|
|
265
|
+
res.json({ deleted: true, userId });
|
|
266
|
+
} catch (err) {
|
|
267
|
+
console.error('DELETE /users/:id error:', err);
|
|
268
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* GET /api/admin/usage
|
|
274
|
+
* Usage history data for charts.
|
|
275
|
+
* Query: ?user_id=X&window=daily&days=30
|
|
276
|
+
*/
|
|
277
|
+
router.get('/usage', (req, res) => {
|
|
278
|
+
try {
|
|
279
|
+
const team = req.team;
|
|
280
|
+
const { user_id, window: windowType, days } = req.query;
|
|
281
|
+
const numDays = parseInt(days, 10) || 30;
|
|
282
|
+
|
|
283
|
+
if (user_id) {
|
|
284
|
+
// Verify user belongs to team
|
|
285
|
+
const user = db.getUser(user_id);
|
|
286
|
+
if (!user || user.team_id !== team.id) {
|
|
287
|
+
return res.status(404).json({ error: 'User not found' });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const usageSummary = getUsageSummary(user_id);
|
|
291
|
+
|
|
292
|
+
// Get daily breakdown for chart
|
|
293
|
+
const since = new Date(Date.now() - numDays * 24 * 60 * 60 * 1000).toISOString();
|
|
294
|
+
const dailyRows = db.getDb().prepare(
|
|
295
|
+
`SELECT DATE(timestamp) AS day, model, COUNT(*) AS count, SUM(credit_cost) AS credits
|
|
296
|
+
FROM usage_event
|
|
297
|
+
WHERE user_id = ? AND timestamp >= ?
|
|
298
|
+
GROUP BY DATE(timestamp), model
|
|
299
|
+
ORDER BY day ASC`
|
|
300
|
+
).all(user_id, since);
|
|
301
|
+
|
|
302
|
+
res.json({ user_id, summary: usageSummary, daily: dailyRows });
|
|
303
|
+
} else {
|
|
304
|
+
// All users
|
|
305
|
+
const users = db.getAllUsers(team.id);
|
|
306
|
+
const since = new Date(Date.now() - numDays * 24 * 60 * 60 * 1000).toISOString();
|
|
307
|
+
|
|
308
|
+
const dailyRows = db.getDb().prepare(
|
|
309
|
+
`SELECT DATE(e.timestamp) AS day, e.user_id, u.name AS user_name, e.model, COUNT(*) AS count, SUM(e.credit_cost) AS credits
|
|
310
|
+
FROM usage_event e
|
|
311
|
+
JOIN user u ON e.user_id = u.id
|
|
312
|
+
WHERE u.team_id = ? AND e.timestamp >= ?
|
|
313
|
+
GROUP BY DATE(e.timestamp), e.user_id, e.model
|
|
314
|
+
ORDER BY day ASC`
|
|
315
|
+
).all(team.id, since);
|
|
316
|
+
|
|
317
|
+
res.json({ daily: dailyRows });
|
|
318
|
+
}
|
|
319
|
+
} catch (err) {
|
|
320
|
+
console.error('GET /usage error:', err);
|
|
321
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* GET /api/admin/events
|
|
327
|
+
* Recent usage events.
|
|
328
|
+
* Query: ?limit=50&user_id=X
|
|
329
|
+
*/
|
|
330
|
+
router.get('/events', (req, res) => {
|
|
331
|
+
try {
|
|
332
|
+
const team = req.team;
|
|
333
|
+
const limit = parseInt(req.query.limit, 10) || 50;
|
|
334
|
+
const userId = req.query.user_id;
|
|
335
|
+
|
|
336
|
+
const events = db.getRecentEvents({
|
|
337
|
+
userId: userId || null,
|
|
338
|
+
teamId: team.id,
|
|
339
|
+
limit,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
res.json({ events });
|
|
343
|
+
} catch (err) {
|
|
344
|
+
console.error('GET /events error:', err);
|
|
345
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* PUT /api/admin/settings
|
|
351
|
+
* Update team settings.
|
|
352
|
+
* Body: { name?, credit_weights?, admin_password? }
|
|
353
|
+
*/
|
|
354
|
+
router.put('/settings', (req, res) => {
|
|
355
|
+
try {
|
|
356
|
+
const team = req.team;
|
|
357
|
+
const { name, credit_weights, admin_password } = req.body;
|
|
358
|
+
|
|
359
|
+
const updates = {};
|
|
360
|
+
if (name !== undefined) updates.name = name;
|
|
361
|
+
if (credit_weights !== undefined) updates.credit_weights = credit_weights;
|
|
362
|
+
if (admin_password !== undefined) {
|
|
363
|
+
updates.admin_password = hashPassword(admin_password);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (Object.keys(updates).length === 0) {
|
|
367
|
+
return res.status(400).json({ error: 'No fields to update' });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
db.updateTeam(team.id, updates);
|
|
371
|
+
|
|
372
|
+
const updatedTeam = db.getTeam(team.id);
|
|
373
|
+
res.json({
|
|
374
|
+
team: {
|
|
375
|
+
id: updatedTeam.id,
|
|
376
|
+
name: updatedTeam.name,
|
|
377
|
+
credit_weights: JSON.parse(updatedTeam.credit_weights),
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
} catch (err) {
|
|
381
|
+
console.error('PUT /settings error:', err);
|
|
382
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
module.exports = router;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const router = express.Router();
|
|
5
|
+
const db = require('../db');
|
|
6
|
+
const { hookAuth } = require('../services/auth');
|
|
7
|
+
const { evaluateLimits } = require('../services/limiter');
|
|
8
|
+
const { recordEvent, getUsageSummary, getCreditBalance } = require('../services/usage');
|
|
9
|
+
const { broadcast } = require('../ws');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* POST /api/v1/sync
|
|
13
|
+
* SessionStart: sync config, report model/machine, update last_seen.
|
|
14
|
+
* Body: { auth_token, model, hostname?, platform? }
|
|
15
|
+
*/
|
|
16
|
+
router.post('/sync', hookAuth, (req, res) => {
|
|
17
|
+
try {
|
|
18
|
+
const user = req.user;
|
|
19
|
+
const team = req.team;
|
|
20
|
+
const creditWeights = JSON.parse(team.credit_weights);
|
|
21
|
+
|
|
22
|
+
// Update last_seen
|
|
23
|
+
db.updateUser(user.id, { last_seen: new Date().toISOString() });
|
|
24
|
+
|
|
25
|
+
// Get limits and usage
|
|
26
|
+
const limits = db.getLimitRules(user.id);
|
|
27
|
+
const result = evaluateLimits(user, req.body.model || 'default', creditWeights);
|
|
28
|
+
|
|
29
|
+
// Build usage counts for the primary window (daily by default)
|
|
30
|
+
const usageSummary = getUsageSummary(user.id);
|
|
31
|
+
const usage = usageSummary.daily ? usageSummary.daily.counts : {};
|
|
32
|
+
|
|
33
|
+
// Find credit budget if any
|
|
34
|
+
const creditRule = limits.find(r => r.type === 'credits');
|
|
35
|
+
let creditBalance = null;
|
|
36
|
+
let creditBudget = null;
|
|
37
|
+
if (creditRule) {
|
|
38
|
+
const cb = getCreditBalance(user.id, creditWeights, creditRule.window, creditRule.value);
|
|
39
|
+
creditBalance = cb.balance;
|
|
40
|
+
creditBudget = cb.budget;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
broadcast({
|
|
44
|
+
type: 'user_status',
|
|
45
|
+
userId: user.id,
|
|
46
|
+
userName: user.name,
|
|
47
|
+
status: user.status,
|
|
48
|
+
model: req.body.model,
|
|
49
|
+
hostname: req.body.hostname,
|
|
50
|
+
timestamp: new Date().toISOString(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
res.json({
|
|
54
|
+
status: user.status,
|
|
55
|
+
limits: limits.map(sanitizeRule),
|
|
56
|
+
credit_weights: creditWeights,
|
|
57
|
+
usage,
|
|
58
|
+
credit_balance: creditBalance,
|
|
59
|
+
credit_budget: creditBudget,
|
|
60
|
+
message: null,
|
|
61
|
+
});
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error('POST /sync error:', err);
|
|
64
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* POST /api/v1/check
|
|
70
|
+
* UserPromptSubmit: check if prompt is allowed, sync local usage.
|
|
71
|
+
* Body: { auth_token, model, local_usage? }
|
|
72
|
+
*/
|
|
73
|
+
router.post('/check', hookAuth, (req, res) => {
|
|
74
|
+
try {
|
|
75
|
+
const user = req.user;
|
|
76
|
+
const team = req.team;
|
|
77
|
+
const model = req.body.model || 'default';
|
|
78
|
+
const creditWeights = JSON.parse(team.credit_weights);
|
|
79
|
+
|
|
80
|
+
// Update last_seen
|
|
81
|
+
db.updateUser(user.id, { last_seen: new Date().toISOString() });
|
|
82
|
+
|
|
83
|
+
// Evaluate limits
|
|
84
|
+
const result = evaluateLimits(user, model, creditWeights);
|
|
85
|
+
|
|
86
|
+
// Get limits for response
|
|
87
|
+
const limits = db.getLimitRules(user.id);
|
|
88
|
+
|
|
89
|
+
// Build usage
|
|
90
|
+
const usageSummary = getUsageSummary(user.id);
|
|
91
|
+
const usage = usageSummary.daily ? usageSummary.daily.counts : {};
|
|
92
|
+
|
|
93
|
+
// Credit info
|
|
94
|
+
const creditRule = limits.find(r => r.type === 'credits');
|
|
95
|
+
let creditBalance = result.credit_balance;
|
|
96
|
+
let creditBudget = result.credit_budget;
|
|
97
|
+
if (creditRule && creditBalance == null) {
|
|
98
|
+
const cb = getCreditBalance(user.id, creditWeights, creditRule.window, creditRule.value);
|
|
99
|
+
creditBalance = cb.balance;
|
|
100
|
+
creditBudget = cb.budget;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Broadcast event
|
|
104
|
+
if (!result.allowed) {
|
|
105
|
+
broadcast({
|
|
106
|
+
type: 'user_blocked',
|
|
107
|
+
userId: user.id,
|
|
108
|
+
userName: user.name,
|
|
109
|
+
model,
|
|
110
|
+
reason: result.reason,
|
|
111
|
+
timestamp: new Date().toISOString(),
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
broadcast({
|
|
115
|
+
type: 'user_check',
|
|
116
|
+
userId: user.id,
|
|
117
|
+
userName: user.name,
|
|
118
|
+
model,
|
|
119
|
+
timestamp: new Date().toISOString(),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
res.json({
|
|
124
|
+
allowed: result.allowed,
|
|
125
|
+
reason: result.reason || null,
|
|
126
|
+
status: user.status,
|
|
127
|
+
limits: limits.map(sanitizeRule),
|
|
128
|
+
usage,
|
|
129
|
+
credit_balance: creditBalance,
|
|
130
|
+
credit_budget: creditBudget,
|
|
131
|
+
message: null,
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error('POST /check error:', err);
|
|
135
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* POST /api/v1/count
|
|
141
|
+
* Stop: record a completed turn.
|
|
142
|
+
* Body: { auth_token, model, timestamp? }
|
|
143
|
+
*/
|
|
144
|
+
router.post('/count', hookAuth, (req, res) => {
|
|
145
|
+
try {
|
|
146
|
+
const user = req.user;
|
|
147
|
+
const team = req.team;
|
|
148
|
+
const model = req.body.model || 'default';
|
|
149
|
+
const timestamp = req.body.timestamp || new Date().toISOString();
|
|
150
|
+
const creditWeights = JSON.parse(team.credit_weights);
|
|
151
|
+
|
|
152
|
+
// Calculate credit cost
|
|
153
|
+
const creditCost = creditWeights[model] || creditWeights['default'] || 1;
|
|
154
|
+
|
|
155
|
+
// Record the event
|
|
156
|
+
recordEvent(user.id, model, creditCost, timestamp, 'hook');
|
|
157
|
+
|
|
158
|
+
// Recalculate credit balance
|
|
159
|
+
const limits = db.getLimitRules(user.id);
|
|
160
|
+
const creditRule = limits.find(r => r.type === 'credits');
|
|
161
|
+
let newBalance = null;
|
|
162
|
+
if (creditRule) {
|
|
163
|
+
const cb = getCreditBalance(user.id, creditWeights, creditRule.window, creditRule.value);
|
|
164
|
+
newBalance = cb.balance;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Broadcast
|
|
168
|
+
broadcast({
|
|
169
|
+
type: 'user_counted',
|
|
170
|
+
userId: user.id,
|
|
171
|
+
userName: user.name,
|
|
172
|
+
model,
|
|
173
|
+
creditCost,
|
|
174
|
+
timestamp,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
res.json({
|
|
178
|
+
recorded: true,
|
|
179
|
+
new_balance: newBalance,
|
|
180
|
+
});
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.error('POST /count error:', err);
|
|
183
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* GET /api/v1/status
|
|
189
|
+
* CLI status command: full dashboard data.
|
|
190
|
+
*/
|
|
191
|
+
router.get('/status', hookAuth, (req, res) => {
|
|
192
|
+
try {
|
|
193
|
+
const user = req.user;
|
|
194
|
+
const team = req.team;
|
|
195
|
+
const creditWeights = JSON.parse(team.credit_weights);
|
|
196
|
+
|
|
197
|
+
const limits = db.getLimitRules(user.id);
|
|
198
|
+
const usageSummary = getUsageSummary(user.id);
|
|
199
|
+
|
|
200
|
+
// Credit info
|
|
201
|
+
const creditRule = limits.find(r => r.type === 'credits');
|
|
202
|
+
let creditBalance = null;
|
|
203
|
+
let creditBudget = null;
|
|
204
|
+
if (creditRule) {
|
|
205
|
+
const cb = getCreditBalance(user.id, creditWeights, creditRule.window, creditRule.value);
|
|
206
|
+
creditBalance = cb.balance;
|
|
207
|
+
creditBudget = cb.budget;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Per-model limits with current counts
|
|
211
|
+
const perModelStatus = {};
|
|
212
|
+
const perModelRules = limits.filter(r => r.type === 'per_model');
|
|
213
|
+
for (const rule of perModelRules) {
|
|
214
|
+
const mdl = rule.model || 'all';
|
|
215
|
+
if (!perModelStatus[mdl]) perModelStatus[mdl] = {};
|
|
216
|
+
const windowUsage = usageSummary[rule.window];
|
|
217
|
+
const count = windowUsage ? (windowUsage.counts[mdl] || 0) : 0;
|
|
218
|
+
perModelStatus[mdl][rule.window] = {
|
|
219
|
+
used: count,
|
|
220
|
+
limit: rule.value,
|
|
221
|
+
remaining: rule.value === -1 ? -1 : Math.max(0, rule.value - count),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
res.json({
|
|
226
|
+
user: {
|
|
227
|
+
id: user.id,
|
|
228
|
+
name: user.name,
|
|
229
|
+
slug: user.slug,
|
|
230
|
+
status: user.status,
|
|
231
|
+
},
|
|
232
|
+
credit_weights: creditWeights,
|
|
233
|
+
credit_balance: creditBalance,
|
|
234
|
+
credit_budget: creditBudget,
|
|
235
|
+
limits: limits.map(sanitizeRule),
|
|
236
|
+
per_model: perModelStatus,
|
|
237
|
+
usage: usageSummary,
|
|
238
|
+
});
|
|
239
|
+
} catch (err) {
|
|
240
|
+
console.error('GET /status error:', err);
|
|
241
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* POST /api/v1/register
|
|
247
|
+
* Exchange an install code for auth_token + config.
|
|
248
|
+
* Body: { code }
|
|
249
|
+
*/
|
|
250
|
+
router.post('/register', (req, res) => {
|
|
251
|
+
try {
|
|
252
|
+
const { code } = req.body;
|
|
253
|
+
if (!code) {
|
|
254
|
+
return res.status(400).json({ error: 'Install code required' });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const installCode = db.useInstallCode(code);
|
|
258
|
+
if (!installCode) {
|
|
259
|
+
return res.status(404).json({ error: 'Invalid or already used install code' });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const user = db.getUser(installCode.user_id);
|
|
263
|
+
if (!user) {
|
|
264
|
+
return res.status(404).json({ error: 'User not found for this install code' });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const team = db.getTeam(user.team_id);
|
|
268
|
+
const creditWeights = JSON.parse(team.credit_weights);
|
|
269
|
+
const limits = db.getLimitRules(user.id);
|
|
270
|
+
|
|
271
|
+
res.json({
|
|
272
|
+
auth_token: user.auth_token,
|
|
273
|
+
user: {
|
|
274
|
+
id: user.id,
|
|
275
|
+
name: user.name,
|
|
276
|
+
slug: user.slug,
|
|
277
|
+
status: user.status,
|
|
278
|
+
},
|
|
279
|
+
limits: limits.map(sanitizeRule),
|
|
280
|
+
credit_weights: creditWeights,
|
|
281
|
+
});
|
|
282
|
+
} catch (err) {
|
|
283
|
+
console.error('POST /register error:', err);
|
|
284
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* GET /api/v1/health
|
|
290
|
+
* Simple health check for Docker/load balancer.
|
|
291
|
+
*/
|
|
292
|
+
router.get('/health', (req, res) => {
|
|
293
|
+
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Strip internal fields from a limit rule for API responses.
|
|
298
|
+
*/
|
|
299
|
+
function sanitizeRule(rule) {
|
|
300
|
+
return {
|
|
301
|
+
id: rule.id,
|
|
302
|
+
type: rule.type,
|
|
303
|
+
model: rule.model,
|
|
304
|
+
window: rule.window,
|
|
305
|
+
value: rule.value,
|
|
306
|
+
schedule_start: rule.schedule_start,
|
|
307
|
+
schedule_end: rule.schedule_end,
|
|
308
|
+
schedule_tz: rule.schedule_tz,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = router;
|