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,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const bcrypt = require('bcryptjs');
|
|
5
|
+
const jwt = require('jsonwebtoken');
|
|
6
|
+
const db = require('../db');
|
|
7
|
+
|
|
8
|
+
// JWT secret: from env or auto-generated per process lifetime
|
|
9
|
+
let jwtSecret = null;
|
|
10
|
+
function getJWTSecret() {
|
|
11
|
+
if (jwtSecret) return jwtSecret;
|
|
12
|
+
jwtSecret = process.env.JWT_SECRET || crypto.randomUUID();
|
|
13
|
+
if (!process.env.JWT_SECRET) {
|
|
14
|
+
console.warn('WARNING: JWT_SECRET not set. Generated a random secret. Sessions will not persist across restarts.');
|
|
15
|
+
}
|
|
16
|
+
return jwtSecret;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---- Password helpers ----
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hash a plain-text password.
|
|
23
|
+
* @param {string} plain
|
|
24
|
+
* @returns {string} bcrypt hash
|
|
25
|
+
*/
|
|
26
|
+
function hashPassword(plain) {
|
|
27
|
+
return bcrypt.hashSync(plain, 10);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Verify a plain-text password against a bcrypt hash.
|
|
32
|
+
* @param {string} plain
|
|
33
|
+
* @param {string} hash
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
function verifyPassword(plain, hash) {
|
|
37
|
+
return bcrypt.compareSync(plain, hash);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- Token helpers ----
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Generate a unique token (UUID v4).
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function generateToken() {
|
|
47
|
+
return crypto.randomUUID();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- JWT helpers ----
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create a JWT for the admin session.
|
|
54
|
+
* @param {object} payload - e.g., { teamId: '...' }
|
|
55
|
+
* @param {string} [expiresIn] - default '24h'
|
|
56
|
+
* @returns {string} signed JWT
|
|
57
|
+
*/
|
|
58
|
+
function createJWT(payload, expiresIn) {
|
|
59
|
+
return jwt.sign(payload, getJWTSecret(), { expiresIn: expiresIn || '24h' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Verify and decode a JWT.
|
|
64
|
+
* @param {string} token
|
|
65
|
+
* @returns {object|null} decoded payload, or null if invalid
|
|
66
|
+
*/
|
|
67
|
+
function verifyJWT(token) {
|
|
68
|
+
try {
|
|
69
|
+
return jwt.verify(token, getJWTSecret());
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- Middleware ----
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Express middleware: Admin authentication.
|
|
79
|
+
* Checks JWT from Authorization header (Bearer <token>) or cookie (jwt=<token>).
|
|
80
|
+
* Sets req.team on success.
|
|
81
|
+
*/
|
|
82
|
+
function adminAuth(req, res, next) {
|
|
83
|
+
let token = null;
|
|
84
|
+
|
|
85
|
+
// Check Authorization header
|
|
86
|
+
const authHeader = req.headers.authorization;
|
|
87
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
88
|
+
token = authHeader.slice(7);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fallback: check cookie
|
|
92
|
+
if (!token && req.headers.cookie) {
|
|
93
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
94
|
+
token = cookies.jwt || cookies.token;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!token) {
|
|
98
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const decoded = verifyJWT(token);
|
|
102
|
+
if (!decoded || !decoded.teamId) {
|
|
103
|
+
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const team = db.getTeam(decoded.teamId);
|
|
107
|
+
if (!team) {
|
|
108
|
+
return res.status(401).json({ error: 'Team not found' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
req.team = team;
|
|
112
|
+
req.teamId = decoded.teamId;
|
|
113
|
+
next();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Express middleware: Hook authentication.
|
|
118
|
+
* Checks auth_token from Authorization: Bearer header or request body.
|
|
119
|
+
* Sets req.user on success.
|
|
120
|
+
*/
|
|
121
|
+
function hookAuth(req, res, next) {
|
|
122
|
+
let authToken = null;
|
|
123
|
+
|
|
124
|
+
// Check Authorization header
|
|
125
|
+
const authHeader = req.headers.authorization;
|
|
126
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
127
|
+
authToken = authHeader.slice(7);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Fallback: check request body
|
|
131
|
+
if (!authToken && req.body && req.body.auth_token) {
|
|
132
|
+
authToken = req.body.auth_token;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!authToken) {
|
|
136
|
+
return res.status(401).json({ error: 'auth_token required' });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const user = db.getUserByToken(authToken);
|
|
140
|
+
if (!user) {
|
|
141
|
+
return res.status(401).json({ error: 'Invalid auth_token' });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Attach user and their team
|
|
145
|
+
req.user = user;
|
|
146
|
+
req.team = db.getTeam(user.team_id);
|
|
147
|
+
next();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse a Cookie header string into an object.
|
|
152
|
+
*/
|
|
153
|
+
function parseCookies(cookieHeader) {
|
|
154
|
+
const cookies = {};
|
|
155
|
+
if (!cookieHeader) return cookies;
|
|
156
|
+
cookieHeader.split(';').forEach(pair => {
|
|
157
|
+
const [name, ...rest] = pair.trim().split('=');
|
|
158
|
+
if (name) {
|
|
159
|
+
cookies[name.trim()] = decodeURIComponent(rest.join('=').trim());
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
return cookies;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
hashPassword,
|
|
167
|
+
verifyPassword,
|
|
168
|
+
generateToken,
|
|
169
|
+
createJWT,
|
|
170
|
+
verifyJWT,
|
|
171
|
+
adminAuth,
|
|
172
|
+
hookAuth,
|
|
173
|
+
getJWTSecret,
|
|
174
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const db = require('../db');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Evaluate all limit rules for a user/model combination.
|
|
7
|
+
*
|
|
8
|
+
* Evaluation order (first deny wins):
|
|
9
|
+
* 1. Is user killed/paused? -> BLOCK
|
|
10
|
+
* 2. Is model in allowed time window? -> if no, BLOCK
|
|
11
|
+
* 3. Is per-model cap exceeded? -> if yes, BLOCK
|
|
12
|
+
* 4. Is credit budget exceeded? -> if yes, BLOCK
|
|
13
|
+
* 5. ALLOW
|
|
14
|
+
*
|
|
15
|
+
* @param {object} user - The user row from the database.
|
|
16
|
+
* @param {string} model - The model being used (opus, sonnet, haiku, default).
|
|
17
|
+
* @param {object} creditWeights - { opus: 10, sonnet: 3, haiku: 1 }
|
|
18
|
+
* @returns {{ allowed: boolean, reason?: string, usage: object, credit_balance?: number, credit_budget?: number }}
|
|
19
|
+
*/
|
|
20
|
+
function evaluateLimits(user, model, creditWeights) {
|
|
21
|
+
// 1. Check user status
|
|
22
|
+
if (user.status === 'killed') {
|
|
23
|
+
return {
|
|
24
|
+
allowed: false,
|
|
25
|
+
reason: 'Your Claude Code access has been revoked by the admin. Contact your admin to restore access.',
|
|
26
|
+
usage: {},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (user.status === 'paused') {
|
|
30
|
+
return {
|
|
31
|
+
allowed: false,
|
|
32
|
+
reason: 'Your Claude Code access has been paused by the admin. Contact your admin to resume.',
|
|
33
|
+
usage: {},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const rules = db.getLimitRules(user.id);
|
|
38
|
+
if (rules.length === 0) {
|
|
39
|
+
// No rules = unlimited
|
|
40
|
+
return { allowed: true, usage: {} };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Gather usage data we will need
|
|
44
|
+
const usageCache = {}; // key: `${model}:${window}` -> count
|
|
45
|
+
const creditCache = {}; // key: window -> totalCredits
|
|
46
|
+
|
|
47
|
+
function getModelUsage(mdl, windowType) {
|
|
48
|
+
const key = `${mdl}:${windowType}`;
|
|
49
|
+
if (usageCache[key] !== undefined) return usageCache[key];
|
|
50
|
+
const windowStart = db.calculateWindowStart(windowType);
|
|
51
|
+
const rows = db.getDb().prepare(
|
|
52
|
+
'SELECT COUNT(*) AS count FROM usage_event WHERE user_id = ? AND model = ? AND timestamp >= ?'
|
|
53
|
+
).get(user.id, mdl, windowStart);
|
|
54
|
+
usageCache[key] = rows.count;
|
|
55
|
+
return rows.count;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getCreditUsage(windowType) {
|
|
59
|
+
if (creditCache[windowType] !== undefined) return creditCache[windowType];
|
|
60
|
+
const windowStart = db.calculateWindowStart(windowType);
|
|
61
|
+
const row = db.getDb().prepare(
|
|
62
|
+
'SELECT COALESCE(SUM(credit_cost), 0) AS total FROM usage_event WHERE user_id = ? AND timestamp >= ?'
|
|
63
|
+
).get(user.id, windowStart);
|
|
64
|
+
creditCache[windowType] = row.total;
|
|
65
|
+
return row.total;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Collect all usage for the response
|
|
69
|
+
const usageSummary = {};
|
|
70
|
+
let creditBalance = null;
|
|
71
|
+
let creditBudget = null;
|
|
72
|
+
|
|
73
|
+
// 2. Check time_of_day rules
|
|
74
|
+
const timeRules = rules.filter(r => r.type === 'time_of_day');
|
|
75
|
+
for (const rule of timeRules) {
|
|
76
|
+
// Only applies to the specified model, or all if model is null
|
|
77
|
+
if (rule.model && rule.model !== model) continue;
|
|
78
|
+
|
|
79
|
+
const inWindow = isInTimeWindow(rule.schedule_start, rule.schedule_end, rule.schedule_tz);
|
|
80
|
+
if (!inWindow) {
|
|
81
|
+
const tz = rule.schedule_tz || 'UTC';
|
|
82
|
+
const currentTime = getCurrentTimeInTZ(tz);
|
|
83
|
+
const modelName = rule.model || 'This model';
|
|
84
|
+
return {
|
|
85
|
+
allowed: false,
|
|
86
|
+
reason: `${capitalize(modelName)} is only available ${rule.schedule_start} - ${rule.schedule_end} (${tz}).\nCurrent time: ${currentTime}. Try another model instead.`,
|
|
87
|
+
usage: usageSummary,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Check per_model rules
|
|
93
|
+
const perModelRules = rules.filter(r => r.type === 'per_model');
|
|
94
|
+
for (const rule of perModelRules) {
|
|
95
|
+
// Skip rules that don't apply to this model
|
|
96
|
+
if (rule.model && rule.model !== model) continue;
|
|
97
|
+
|
|
98
|
+
const targetModel = rule.model || model;
|
|
99
|
+
const count = getModelUsage(targetModel, rule.window);
|
|
100
|
+
const limit = rule.value;
|
|
101
|
+
|
|
102
|
+
// Track in usage summary
|
|
103
|
+
if (!usageSummary[rule.window]) usageSummary[rule.window] = {};
|
|
104
|
+
usageSummary[rule.window][targetModel] = { used: count, limit };
|
|
105
|
+
|
|
106
|
+
if (limit === -1) continue; // unlimited
|
|
107
|
+
if (limit === 0 || count >= limit) {
|
|
108
|
+
const windowLabel = windowToLabel(rule.window);
|
|
109
|
+
return {
|
|
110
|
+
allowed: false,
|
|
111
|
+
reason: `${capitalize(windowLabel)} ${targetModel} limit reached for ${user.name}.\nUsed ${count}/${limit} prompts.\n\nSwitch to another model or try again later.`,
|
|
112
|
+
usage: usageSummary,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 4. Check credit budget rules
|
|
118
|
+
const creditRules = rules.filter(r => r.type === 'credits');
|
|
119
|
+
for (const rule of creditRules) {
|
|
120
|
+
const budget = rule.value;
|
|
121
|
+
if (budget === -1) continue; // unlimited
|
|
122
|
+
|
|
123
|
+
const usedCredits = getCreditUsage(rule.window);
|
|
124
|
+
const balance = Math.max(0, budget - usedCredits);
|
|
125
|
+
|
|
126
|
+
creditBalance = balance;
|
|
127
|
+
creditBudget = budget;
|
|
128
|
+
|
|
129
|
+
if (!usageSummary[rule.window]) usageSummary[rule.window] = {};
|
|
130
|
+
usageSummary[rule.window]._credits = { used: usedCredits, budget, balance };
|
|
131
|
+
|
|
132
|
+
// Check if the next prompt would exceed the budget
|
|
133
|
+
const modelCost = creditWeights[model] || creditWeights['default'] || 1;
|
|
134
|
+
if (balance < modelCost) {
|
|
135
|
+
const windowLabel = windowToLabel(rule.window);
|
|
136
|
+
return {
|
|
137
|
+
allowed: false,
|
|
138
|
+
reason: `${capitalize(windowLabel)} credit budget exhausted for ${user.name}.\nUsed ${usedCredits}/${budget} credits (${balance} remaining).\n${capitalize(model)} costs ${modelCost} credits per prompt.\n\nTry a cheaper model or wait for the window to reset.`,
|
|
139
|
+
usage: usageSummary,
|
|
140
|
+
credit_balance: balance,
|
|
141
|
+
credit_budget: budget,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 5. ALLOW
|
|
147
|
+
// Populate usage for the response even on allow
|
|
148
|
+
const defaultWindow = creditRules.length > 0 ? creditRules[0].window : (perModelRules.length > 0 ? perModelRules[0].window : 'daily');
|
|
149
|
+
if (Object.keys(usageSummary).length === 0) {
|
|
150
|
+
const windowStart = db.calculateWindowStart(defaultWindow);
|
|
151
|
+
const data = db.getUsageWithCredits(user.id, windowStart);
|
|
152
|
+
usageSummary[defaultWindow] = data.counts;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
allowed: true,
|
|
157
|
+
usage: usageSummary,
|
|
158
|
+
credit_balance: creditBalance,
|
|
159
|
+
credit_budget: creditBudget,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if the current time falls within a schedule window in a given timezone.
|
|
165
|
+
* @param {string} startTime - "HH:MM" format
|
|
166
|
+
* @param {string} endTime - "HH:MM" format
|
|
167
|
+
* @param {string} [tz] - IANA timezone (e.g., "America/New_York")
|
|
168
|
+
* @returns {boolean}
|
|
169
|
+
*/
|
|
170
|
+
function isInTimeWindow(startTime, endTime, tz) {
|
|
171
|
+
if (!startTime || !endTime) return true; // No schedule = always allowed
|
|
172
|
+
|
|
173
|
+
const timeZone = tz || 'UTC';
|
|
174
|
+
const now = new Date();
|
|
175
|
+
|
|
176
|
+
// Get current time in the target timezone
|
|
177
|
+
const parts = db.getDatePartsInTZ(now, timeZone);
|
|
178
|
+
const currentMinutes = parts.hour * 60 + parts.minute;
|
|
179
|
+
|
|
180
|
+
const [startH, startM] = startTime.split(':').map(Number);
|
|
181
|
+
const [endH, endM] = endTime.split(':').map(Number);
|
|
182
|
+
const startMinutes = startH * 60 + startM;
|
|
183
|
+
const endMinutes = endH * 60 + endM;
|
|
184
|
+
|
|
185
|
+
if (startMinutes <= endMinutes) {
|
|
186
|
+
// Same-day window (e.g., 09:00 - 18:00)
|
|
187
|
+
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
|
188
|
+
} else {
|
|
189
|
+
// Overnight window (e.g., 22:00 - 06:00)
|
|
190
|
+
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get the current time formatted in a given timezone.
|
|
196
|
+
*/
|
|
197
|
+
function getCurrentTimeInTZ(tz) {
|
|
198
|
+
const now = new Date();
|
|
199
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
200
|
+
timeZone: tz,
|
|
201
|
+
hour: 'numeric',
|
|
202
|
+
minute: '2-digit',
|
|
203
|
+
hour12: true,
|
|
204
|
+
});
|
|
205
|
+
return formatter.format(now);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function windowToLabel(window) {
|
|
209
|
+
const labels = {
|
|
210
|
+
daily: 'daily',
|
|
211
|
+
weekly: 'weekly',
|
|
212
|
+
monthly: 'monthly',
|
|
213
|
+
sliding_24h: 'sliding 24-hour',
|
|
214
|
+
};
|
|
215
|
+
return labels[window] || window;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function capitalize(str) {
|
|
219
|
+
if (!str) return '';
|
|
220
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = {
|
|
224
|
+
evaluateLimits,
|
|
225
|
+
isInTimeWindow,
|
|
226
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const db = require('../db');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Record a usage event (one completed turn).
|
|
7
|
+
* @param {string} userId
|
|
8
|
+
* @param {string} model - opus | sonnet | haiku | default
|
|
9
|
+
* @param {number} creditCost - cost from credit_weights
|
|
10
|
+
* @param {string} [timestamp] - ISO string, defaults to now
|
|
11
|
+
* @param {string} [source] - 'hook' | 'server', defaults to 'hook'
|
|
12
|
+
*/
|
|
13
|
+
function recordEvent(userId, model, creditCost, timestamp, source) {
|
|
14
|
+
return db.recordUsage({
|
|
15
|
+
userId,
|
|
16
|
+
model,
|
|
17
|
+
creditCost,
|
|
18
|
+
timestamp: timestamp || new Date().toISOString(),
|
|
19
|
+
source: source || 'hook',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get usage counts by model for a user within a window.
|
|
25
|
+
* @param {string} userId
|
|
26
|
+
* @param {string} windowType - daily | weekly | monthly | sliding_24h
|
|
27
|
+
* @param {string} [tz] - IANA timezone
|
|
28
|
+
* @returns {{ opus: number, sonnet: number, haiku: number, default: number }}
|
|
29
|
+
*/
|
|
30
|
+
function getUsageByWindow(userId, windowType, tz) {
|
|
31
|
+
const windowStart = db.calculateWindowStart(windowType, tz);
|
|
32
|
+
return db.getUsage(userId, windowStart);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get a full usage summary for a user across all windows.
|
|
37
|
+
* @param {string} userId
|
|
38
|
+
* @param {string} [tz] - IANA timezone
|
|
39
|
+
* @returns {{ daily: object, weekly: object, monthly: object, sliding_24h: object }}
|
|
40
|
+
*/
|
|
41
|
+
function getUsageSummary(userId, tz) {
|
|
42
|
+
const windows = ['daily', 'weekly', 'monthly', 'sliding_24h'];
|
|
43
|
+
const summary = {};
|
|
44
|
+
for (const w of windows) {
|
|
45
|
+
const data = db.getUsageForWindow(userId, w, tz);
|
|
46
|
+
summary[w] = {
|
|
47
|
+
counts: data.counts,
|
|
48
|
+
totalCredits: data.totalCredits,
|
|
49
|
+
windowStart: data.windowStart,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return summary;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get remaining credit balance for a user.
|
|
57
|
+
* @param {string} userId
|
|
58
|
+
* @param {object} creditWeights - { opus: 10, sonnet: 3, haiku: 1 }
|
|
59
|
+
* @param {string} windowType - daily | weekly | monthly | sliding_24h
|
|
60
|
+
* @param {number} budget - total credit budget
|
|
61
|
+
* @param {string} [tz] - IANA timezone
|
|
62
|
+
* @returns {{ balance: number, used: number, budget: number }}
|
|
63
|
+
*/
|
|
64
|
+
function getCreditBalance(userId, creditWeights, windowType, budget, tz) {
|
|
65
|
+
const windowStart = db.calculateWindowStart(windowType, tz);
|
|
66
|
+
const data = db.getUsageWithCredits(userId, windowStart);
|
|
67
|
+
const used = data.totalCredits;
|
|
68
|
+
const balance = Math.max(0, budget - used);
|
|
69
|
+
return { balance, used, budget };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Delete usage events older than N days.
|
|
74
|
+
* @param {number} days
|
|
75
|
+
* @returns {{ changes: number }}
|
|
76
|
+
*/
|
|
77
|
+
function cleanupOldEvents(days) {
|
|
78
|
+
return db.cleanupOldEvents(days || 90);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
recordEvent,
|
|
83
|
+
getUsageByWindow,
|
|
84
|
+
getUsageSummary,
|
|
85
|
+
getCreditBalance,
|
|
86
|
+
cleanupOldEvents,
|
|
87
|
+
};
|
package/src/server/ws.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { WebSocketServer } = require('ws');
|
|
4
|
+
|
|
5
|
+
let wss = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Set up a WebSocket server on the given HTTP server.
|
|
9
|
+
* Listens on the /ws path.
|
|
10
|
+
* @param {http.Server} server
|
|
11
|
+
*/
|
|
12
|
+
function setupWebSocket(server) {
|
|
13
|
+
wss = new WebSocketServer({ noServer: true });
|
|
14
|
+
|
|
15
|
+
// Handle upgrade requests for /ws path
|
|
16
|
+
server.on('upgrade', (request, socket, head) => {
|
|
17
|
+
const { pathname } = new URL(request.url, `http://${request.headers.host}`);
|
|
18
|
+
if (pathname === '/ws') {
|
|
19
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
20
|
+
wss.emit('connection', ws, request);
|
|
21
|
+
});
|
|
22
|
+
} else {
|
|
23
|
+
socket.destroy();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
wss.on('connection', (ws) => {
|
|
28
|
+
// Send a welcome message
|
|
29
|
+
ws.send(JSON.stringify({
|
|
30
|
+
type: 'connected',
|
|
31
|
+
timestamp: new Date().toISOString(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
ws.on('error', (err) => {
|
|
35
|
+
console.error('WebSocket client error:', err.message);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
console.log('WebSocket server ready on /ws');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Broadcast an event to all connected dashboard clients.
|
|
44
|
+
* @param {object} event - Event object with at least a `type` field.
|
|
45
|
+
* Supported types: user_check, user_blocked, user_counted, user_killed, user_status_change
|
|
46
|
+
*/
|
|
47
|
+
function broadcast(event) {
|
|
48
|
+
if (!wss) return;
|
|
49
|
+
|
|
50
|
+
const message = JSON.stringify(event);
|
|
51
|
+
for (const client of wss.clients) {
|
|
52
|
+
if (client.readyState === 1) { // WebSocket.OPEN
|
|
53
|
+
client.send(message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get the number of connected clients.
|
|
60
|
+
*/
|
|
61
|
+
function getClientCount() {
|
|
62
|
+
if (!wss) return 0;
|
|
63
|
+
let count = 0;
|
|
64
|
+
for (const client of wss.clients) {
|
|
65
|
+
if (client.readyState === 1) count++;
|
|
66
|
+
}
|
|
67
|
+
return count;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
setupWebSocket,
|
|
72
|
+
broadcast,
|
|
73
|
+
getClientCount,
|
|
74
|
+
};
|