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,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
+ };
@@ -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
+ };