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.
@@ -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;