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,484 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const Database = require('better-sqlite3');
6
+ const { v4: uuidv4 } = require('uuid');
7
+ const bcrypt = require('bcryptjs');
8
+
9
+ let db = null;
10
+
11
+ /**
12
+ * Initialize the database: open/create, set up tables, indexes.
13
+ * @param {string} [dbPath] - Optional path to the SQLite file.
14
+ */
15
+ function init(dbPath) {
16
+ if (db) return db;
17
+
18
+ if (!dbPath) {
19
+ const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', '..', 'data');
20
+ dbPath = path.join(DATA_DIR, 'limiter.db');
21
+ }
22
+
23
+ const dataDir = path.dirname(dbPath);
24
+ if (!fs.existsSync(dataDir)) {
25
+ fs.mkdirSync(dataDir, { recursive: true });
26
+ }
27
+
28
+ db = new Database(dbPath);
29
+ db.pragma('journal_mode = WAL');
30
+ db.pragma('foreign_keys = ON');
31
+
32
+ // Create tables
33
+ db.exec(`
34
+ CREATE TABLE IF NOT EXISTS team (
35
+ id TEXT PRIMARY KEY,
36
+ name TEXT NOT NULL,
37
+ admin_password TEXT NOT NULL,
38
+ credit_weights TEXT NOT NULL DEFAULT '{"opus":10,"sonnet":3,"haiku":1}',
39
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS user (
43
+ id TEXT PRIMARY KEY,
44
+ team_id TEXT NOT NULL REFERENCES team(id),
45
+ slug TEXT NOT NULL,
46
+ name TEXT NOT NULL,
47
+ auth_token TEXT NOT NULL UNIQUE,
48
+ status TEXT NOT NULL DEFAULT 'active',
49
+ killed_at DATETIME,
50
+ last_seen DATETIME,
51
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
52
+ UNIQUE(team_id, slug)
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS limit_rule (
56
+ id TEXT PRIMARY KEY,
57
+ user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
58
+ type TEXT NOT NULL,
59
+ model TEXT,
60
+ window TEXT,
61
+ value INTEGER,
62
+ schedule_start TEXT,
63
+ schedule_end TEXT,
64
+ schedule_tz TEXT,
65
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
66
+ );
67
+
68
+ CREATE TABLE IF NOT EXISTS usage_event (
69
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
70
+ user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
71
+ model TEXT NOT NULL,
72
+ credit_cost INTEGER NOT NULL,
73
+ timestamp DATETIME NOT NULL,
74
+ source TEXT NOT NULL DEFAULT 'hook'
75
+ );
76
+
77
+ CREATE TABLE IF NOT EXISTS install_code (
78
+ code TEXT PRIMARY KEY,
79
+ user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
80
+ used BOOLEAN DEFAULT 0,
81
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
82
+ );
83
+
84
+ CREATE INDEX IF NOT EXISTS idx_usage_user_ts ON usage_event(user_id, timestamp);
85
+ CREATE INDEX IF NOT EXISTS idx_usage_model_ts ON usage_event(user_id, model, timestamp);
86
+ CREATE INDEX IF NOT EXISTS idx_user_auth_token ON user(auth_token);
87
+ CREATE INDEX IF NOT EXISTS idx_user_team ON user(team_id);
88
+ CREATE INDEX IF NOT EXISTS idx_limit_rule_user ON limit_rule(user_id);
89
+ CREATE INDEX IF NOT EXISTS idx_install_code_user ON install_code(user_id);
90
+ `);
91
+
92
+ console.log(`Database initialized at ${dbPath}`);
93
+ return db;
94
+ }
95
+
96
+ /**
97
+ * Seed a default team if none exists.
98
+ * @param {string} [adminPassword] - Plain-text admin password.
99
+ */
100
+ function seed(adminPassword) {
101
+ const conn = getDb();
102
+ const row = conn.prepare('SELECT COUNT(*) AS count FROM team').get();
103
+ if (row.count > 0) return;
104
+
105
+ const password = adminPassword || process.env.ADMIN_PASSWORD || 'changeme';
106
+ const hash = bcrypt.hashSync(password, 10);
107
+ const teamId = uuidv4();
108
+
109
+ conn.prepare(
110
+ 'INSERT INTO team (id, name, admin_password, credit_weights) VALUES (?, ?, ?, ?)'
111
+ ).run(teamId, 'Default Team', hash, JSON.stringify({ opus: 10, sonnet: 3, haiku: 1 }));
112
+
113
+ console.log(`Default team created (id: ${teamId})`);
114
+ if (password === 'changeme') {
115
+ console.warn('WARNING: Using default admin password "changeme". Set ADMIN_PASSWORD env var for production.');
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Get the raw database instance.
121
+ */
122
+ function getDb() {
123
+ if (!db) throw new Error('Database not initialized. Call init() first.');
124
+ return db;
125
+ }
126
+
127
+ /**
128
+ * Close the database connection.
129
+ */
130
+ function close() {
131
+ if (db) {
132
+ db.close();
133
+ db = null;
134
+ }
135
+ }
136
+
137
+ // --------------- Team helpers ---------------
138
+
139
+ function getTeam(teamId) {
140
+ return getDb().prepare('SELECT * FROM team WHERE id = ?').get(teamId);
141
+ }
142
+
143
+ function getDefaultTeam() {
144
+ return getDb().prepare('SELECT * FROM team ORDER BY created_at ASC LIMIT 1').get();
145
+ }
146
+
147
+ function updateTeam(teamId, fields) {
148
+ const sets = [];
149
+ const values = [];
150
+ if (fields.name !== undefined) { sets.push('name = ?'); values.push(fields.name); }
151
+ if (fields.credit_weights !== undefined) { sets.push('credit_weights = ?'); values.push(typeof fields.credit_weights === 'string' ? fields.credit_weights : JSON.stringify(fields.credit_weights)); }
152
+ if (fields.admin_password !== undefined) { sets.push('admin_password = ?'); values.push(fields.admin_password); }
153
+ if (sets.length === 0) return;
154
+ values.push(teamId);
155
+ getDb().prepare(`UPDATE team SET ${sets.join(', ')} WHERE id = ?`).run(...values);
156
+ }
157
+
158
+ // --------------- User helpers ---------------
159
+
160
+ function getUser(userId) {
161
+ return getDb().prepare('SELECT * FROM user WHERE id = ?').get(userId);
162
+ }
163
+
164
+ function getUserByToken(authToken) {
165
+ return getDb().prepare('SELECT * FROM user WHERE auth_token = ?').get(authToken);
166
+ }
167
+
168
+ function getUserBySlug(teamId, slug) {
169
+ return getDb().prepare('SELECT * FROM user WHERE team_id = ? AND slug = ?').get(teamId, slug);
170
+ }
171
+
172
+ function getAllUsers(teamId) {
173
+ return getDb().prepare('SELECT * FROM user WHERE team_id = ? ORDER BY created_at ASC').all(teamId);
174
+ }
175
+
176
+ function createUser({ teamId, slug, name }) {
177
+ const id = uuidv4();
178
+ const authToken = uuidv4();
179
+ getDb().prepare(
180
+ 'INSERT INTO user (id, team_id, slug, name, auth_token) VALUES (?, ?, ?, ?, ?)'
181
+ ).run(id, teamId, slug, name, authToken);
182
+ return getUser(id);
183
+ }
184
+
185
+ function updateUser(userId, fields) {
186
+ const sets = [];
187
+ const values = [];
188
+ if (fields.name !== undefined) { sets.push('name = ?'); values.push(fields.name); }
189
+ if (fields.slug !== undefined) { sets.push('slug = ?'); values.push(fields.slug); }
190
+ if (fields.status !== undefined) {
191
+ sets.push('status = ?');
192
+ values.push(fields.status);
193
+ if (fields.status === 'killed') {
194
+ sets.push('killed_at = ?');
195
+ values.push(new Date().toISOString());
196
+ } else if (fields.status === 'active') {
197
+ sets.push('killed_at = NULL');
198
+ }
199
+ }
200
+ if (fields.last_seen !== undefined) { sets.push('last_seen = ?'); values.push(fields.last_seen); }
201
+ if (sets.length === 0) return;
202
+ values.push(userId);
203
+ getDb().prepare(`UPDATE user SET ${sets.join(', ')} WHERE id = ?`).run(...values);
204
+ }
205
+
206
+ function deleteUser(userId) {
207
+ // Foreign keys with ON DELETE CASCADE handle related rows
208
+ getDb().prepare('DELETE FROM user WHERE id = ?').run(userId);
209
+ }
210
+
211
+ // --------------- Limit Rule helpers ---------------
212
+
213
+ function getLimitRules(userId) {
214
+ return getDb().prepare('SELECT * FROM limit_rule WHERE user_id = ? ORDER BY created_at ASC').all(userId);
215
+ }
216
+
217
+ function createLimitRule({ userId, type, model, window, value, schedule_start, schedule_end, schedule_tz }) {
218
+ const id = uuidv4();
219
+ getDb().prepare(
220
+ 'INSERT INTO limit_rule (id, user_id, type, model, window, value, schedule_start, schedule_end, schedule_tz) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
221
+ ).run(id, userId, type, model || null, window || null, value != null ? value : null, schedule_start || null, schedule_end || null, schedule_tz || null);
222
+ return getDb().prepare('SELECT * FROM limit_rule WHERE id = ?').get(id);
223
+ }
224
+
225
+ function deleteLimitRule(ruleId) {
226
+ getDb().prepare('DELETE FROM limit_rule WHERE id = ?').run(ruleId);
227
+ }
228
+
229
+ function deleteLimitRulesForUser(userId) {
230
+ getDb().prepare('DELETE FROM limit_rule WHERE user_id = ?').run(userId);
231
+ }
232
+
233
+ // --------------- Usage Event helpers ---------------
234
+
235
+ function recordUsage({ userId, model, creditCost, timestamp, source }) {
236
+ const ts = timestamp || new Date().toISOString();
237
+ const src = source || 'hook';
238
+ return getDb().prepare(
239
+ 'INSERT INTO usage_event (user_id, model, credit_cost, timestamp, source) VALUES (?, ?, ?, ?, ?)'
240
+ ).run(userId, model, creditCost, ts, src);
241
+ }
242
+
243
+ /**
244
+ * Get usage event count by model for a user since a given timestamp.
245
+ * Returns { opus: N, sonnet: N, haiku: N, default: N }
246
+ */
247
+ function getUsage(userId, since) {
248
+ const rows = getDb().prepare(
249
+ 'SELECT model, COUNT(*) AS count FROM usage_event WHERE user_id = ? AND timestamp >= ? GROUP BY model'
250
+ ).all(userId, since);
251
+
252
+ const result = { opus: 0, sonnet: 0, haiku: 0, default: 0 };
253
+ for (const row of rows) {
254
+ result[row.model] = row.count;
255
+ }
256
+ return result;
257
+ }
258
+
259
+ /**
260
+ * Get usage with credit sums for a user since a given timestamp.
261
+ */
262
+ function getUsageWithCredits(userId, since) {
263
+ const rows = getDb().prepare(
264
+ 'SELECT model, COUNT(*) AS count, SUM(credit_cost) AS total_credits FROM usage_event WHERE user_id = ? AND timestamp >= ? GROUP BY model'
265
+ ).all(userId, since);
266
+
267
+ const counts = { opus: 0, sonnet: 0, haiku: 0, default: 0 };
268
+ let totalCredits = 0;
269
+ for (const row of rows) {
270
+ counts[row.model] = row.count;
271
+ totalCredits += row.total_credits;
272
+ }
273
+ return { counts, totalCredits };
274
+ }
275
+
276
+ /**
277
+ * Get usage for a specific window type.
278
+ * @param {string} userId
279
+ * @param {string} windowType - daily | weekly | monthly | sliding_24h
280
+ * @param {string} [tz] - IANA timezone (default UTC)
281
+ * @returns {{ counts: object, totalCredits: number, windowStart: string }}
282
+ */
283
+ function getUsageForWindow(userId, windowType, tz) {
284
+ const windowStart = calculateWindowStart(windowType, tz);
285
+ const data = getUsageWithCredits(userId, windowStart);
286
+ return { ...data, windowStart };
287
+ }
288
+
289
+ /**
290
+ * Calculate the start of a window in ISO string.
291
+ */
292
+ function calculateWindowStart(windowType, tz) {
293
+ const now = new Date();
294
+
295
+ if (windowType === 'sliding_24h') {
296
+ return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
297
+ }
298
+
299
+ // For daily/weekly/monthly we compute midnight in the given timezone
300
+ // then convert back to UTC ISO string for the DB query.
301
+ const timeZone = tz || 'UTC';
302
+
303
+ // Get current date parts in the target timezone
304
+ const parts = getDatePartsInTZ(now, timeZone);
305
+
306
+ let year = parts.year;
307
+ let month = parts.month; // 1-based
308
+ let day = parts.day;
309
+
310
+ if (windowType === 'daily') {
311
+ // midnight today in the timezone
312
+ } else if (windowType === 'weekly') {
313
+ // Monday of this week
314
+ const dayOfWeek = parts.dayOfWeek; // 0=Sun, 1=Mon, ...
315
+ const daysToSubtract = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
316
+ const dt = new Date(year, month - 1, day - daysToSubtract);
317
+ year = dt.getFullYear();
318
+ month = dt.getMonth() + 1;
319
+ day = dt.getDate();
320
+ } else if (windowType === 'monthly') {
321
+ day = 1;
322
+ }
323
+
324
+ // Build a date string in the target timezone and convert to UTC
325
+ // Create the local midnight in the target timezone
326
+ const localMidnight = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T00:00:00`;
327
+
328
+ // Use a trick: compute the UTC offset for this date in this timezone
329
+ const utcDate = localDateToUTC(localMidnight, timeZone);
330
+ return utcDate.toISOString();
331
+ }
332
+
333
+ /**
334
+ * Get date components in a given timezone.
335
+ */
336
+ function getDatePartsInTZ(date, timeZone) {
337
+ const formatter = new Intl.DateTimeFormat('en-US', {
338
+ timeZone,
339
+ year: 'numeric',
340
+ month: 'numeric',
341
+ day: 'numeric',
342
+ weekday: 'short',
343
+ hour: 'numeric',
344
+ minute: 'numeric',
345
+ hour12: false,
346
+ });
347
+ const parts = formatter.formatToParts(date);
348
+ const map = {};
349
+ for (const p of parts) {
350
+ map[p.type] = p.value;
351
+ }
352
+ const dayOfWeekMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
353
+ return {
354
+ year: parseInt(map.year, 10),
355
+ month: parseInt(map.month, 10),
356
+ day: parseInt(map.day, 10),
357
+ hour: parseInt(map.hour, 10),
358
+ minute: parseInt(map.minute, 10),
359
+ dayOfWeek: dayOfWeekMap[map.weekday] ?? 0,
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Convert a local date string (without TZ info) in a given timezone to a UTC Date object.
365
+ */
366
+ function localDateToUTC(localDateStr, timeZone) {
367
+ // Parse the local date string
368
+ const [datePart, timePart] = localDateStr.split('T');
369
+ const [year, month, day] = datePart.split('-').map(Number);
370
+ const [hour, minute, second] = (timePart || '00:00:00').split(':').map(Number);
371
+
372
+ // Create a date in UTC first, then figure out the offset for this timezone
373
+ const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second || 0));
374
+
375
+ // Get what the local time would be at this UTC time in the target timezone
376
+ const inTZ = getDatePartsInTZ(utcGuess, timeZone);
377
+
378
+ // The offset in minutes: inTZ time - utcGuess time
379
+ const utcMinutes = utcGuess.getUTCHours() * 60 + utcGuess.getUTCMinutes();
380
+ const tzMinutes = inTZ.hour * 60 + inTZ.minute;
381
+
382
+ // Day difference handling
383
+ let offsetMinutes = tzMinutes - utcMinutes;
384
+
385
+ // Handle day boundary: if the tz date differs from UTC date, adjust
386
+ const utcDay = utcGuess.getUTCDate();
387
+ if (inTZ.day > utcDay) {
388
+ offsetMinutes += 24 * 60;
389
+ } else if (inTZ.day < utcDay) {
390
+ offsetMinutes -= 24 * 60;
391
+ }
392
+
393
+ // The actual UTC time = local time - offset
394
+ return new Date(utcGuess.getTime() - offsetMinutes * 60 * 1000);
395
+ }
396
+
397
+ /**
398
+ * Get recent events for a user or all users.
399
+ */
400
+ function getRecentEvents({ userId, limit, teamId }) {
401
+ const lim = limit || 50;
402
+ if (userId) {
403
+ return getDb().prepare(
404
+ 'SELECT e.*, u.name AS user_name, u.slug AS user_slug FROM usage_event e JOIN user u ON e.user_id = u.id WHERE e.user_id = ? ORDER BY e.timestamp DESC LIMIT ?'
405
+ ).all(userId, lim);
406
+ }
407
+ if (teamId) {
408
+ return getDb().prepare(
409
+ 'SELECT e.*, u.name AS user_name, u.slug AS user_slug FROM usage_event e JOIN user u ON e.user_id = u.id WHERE u.team_id = ? ORDER BY e.timestamp DESC LIMIT ?'
410
+ ).all(teamId, lim);
411
+ }
412
+ return getDb().prepare(
413
+ 'SELECT e.*, u.name AS user_name, u.slug AS user_slug FROM usage_event e JOIN user u ON e.user_id = u.id ORDER BY e.timestamp DESC LIMIT ?'
414
+ ).all(lim);
415
+ }
416
+
417
+ /**
418
+ * Delete events older than N days.
419
+ */
420
+ function cleanupOldEvents(days) {
421
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
422
+ return getDb().prepare('DELETE FROM usage_event WHERE timestamp < ?').run(cutoff);
423
+ }
424
+
425
+ // --------------- Install Code helpers ---------------
426
+
427
+ function createInstallCode(userId) {
428
+ // Generate a human-readable code: CLM-<slug-prefix>-<random>
429
+ const user = getUser(userId);
430
+ const prefix = user ? user.slug.substring(0, 8) : 'user';
431
+ const random = uuidv4().substring(0, 6);
432
+ const code = `CLM-${prefix}-${random}`;
433
+
434
+ getDb().prepare(
435
+ 'INSERT INTO install_code (code, user_id) VALUES (?, ?)'
436
+ ).run(code, userId);
437
+
438
+ return code;
439
+ }
440
+
441
+ function useInstallCode(code) {
442
+ const row = getDb().prepare('SELECT * FROM install_code WHERE code = ? AND used = 0').get(code);
443
+ if (!row) return null;
444
+
445
+ getDb().prepare('UPDATE install_code SET used = 1 WHERE code = ?').run(code);
446
+ return row;
447
+ }
448
+
449
+ module.exports = {
450
+ init,
451
+ seed,
452
+ close,
453
+ getDb,
454
+ // Team
455
+ getTeam,
456
+ getDefaultTeam,
457
+ updateTeam,
458
+ // User
459
+ getUser,
460
+ getUserByToken,
461
+ getUserBySlug,
462
+ getAllUsers,
463
+ createUser,
464
+ updateUser,
465
+ deleteUser,
466
+ // Limit Rules
467
+ getLimitRules,
468
+ createLimitRule,
469
+ deleteLimitRule,
470
+ deleteLimitRulesForUser,
471
+ // Usage
472
+ recordUsage,
473
+ getUsage,
474
+ getUsageWithCredits,
475
+ getUsageForWindow,
476
+ getRecentEvents,
477
+ cleanupOldEvents,
478
+ // Install Codes
479
+ createInstallCode,
480
+ useInstallCode,
481
+ // Helpers (exported for services)
482
+ calculateWindowStart,
483
+ getDatePartsInTZ,
484
+ };
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const express = require('express');
5
+ const http = require('http');
6
+ const db = require('./db');
7
+ const hookApi = require('./routes/hook-api');
8
+ const adminApi = require('./routes/admin-api');
9
+ const { setupWebSocket } = require('./ws');
10
+
11
+ const app = express();
12
+ const server = http.createServer(app);
13
+
14
+ // --- Middleware ---
15
+ app.use(express.json());
16
+
17
+ // CORS for dashboard (same-origin by default, but allow configurable origins)
18
+ app.use((req, res, next) => {
19
+ const origin = req.headers.origin;
20
+ if (origin) {
21
+ res.setHeader('Access-Control-Allow-Origin', origin);
22
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
23
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
24
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
25
+ }
26
+ if (req.method === 'OPTIONS') {
27
+ return res.sendStatus(204);
28
+ }
29
+ next();
30
+ });
31
+
32
+ // --- Static files: serve dashboard ---
33
+ const dashboardDir = path.join(__dirname, '..', 'dashboard');
34
+ app.use('/dashboard', express.static(dashboardDir));
35
+
36
+ // --- API Routes ---
37
+
38
+ // Hook API (per-user auth via auth_token)
39
+ app.use('/api/v1', hookApi);
40
+
41
+ // Admin API (JWT session auth)
42
+ app.use('/api/admin', adminApi);
43
+
44
+ // --- Health check ---
45
+ app.get('/health', (req, res) => {
46
+ res.json({
47
+ status: 'ok',
48
+ timestamp: new Date().toISOString(),
49
+ version: '1.0.0',
50
+ });
51
+ });
52
+
53
+ // --- Redirect root to dashboard ---
54
+ app.get('/', (req, res) => {
55
+ res.redirect('/dashboard');
56
+ });
57
+
58
+ // --- WebSocket setup ---
59
+ setupWebSocket(server);
60
+
61
+ // --- Error handling middleware ---
62
+ app.use((err, req, res, next) => {
63
+ console.error('Unhandled error:', err);
64
+ res.status(500).json({ error: 'Internal server error' });
65
+ });
66
+
67
+ // --- Start function ---
68
+ function start(port) {
69
+ // Determine data directory
70
+ const dataDir = process.env.DATA_DIR || path.join(__dirname, '..', '..', 'data');
71
+ const dbPath = path.join(dataDir, 'limiter.db');
72
+
73
+ // Initialize database
74
+ db.init(dbPath);
75
+
76
+ // Seed default team
77
+ const adminPassword = process.env.ADMIN_PASSWORD;
78
+ db.seed(adminPassword);
79
+
80
+ server.listen(port, () => {
81
+ console.log(`Claude Code Limiter server listening on port ${port}`);
82
+ console.log(`Dashboard: http://localhost:${port}/dashboard`);
83
+ console.log(`Hook API: http://localhost:${port}/api/v1`);
84
+ console.log(`Admin API: http://localhost:${port}/api/admin`);
85
+ console.log(`WebSocket: ws://localhost:${port}/ws`);
86
+ });
87
+ }
88
+
89
+ // If run directly: node src/server/index.js
90
+ if (require.main === module) {
91
+ const PORT = parseInt(process.env.PORT, 10) || 3000;
92
+
93
+ if (!process.env.ADMIN_PASSWORD) {
94
+ console.warn('WARNING: ADMIN_PASSWORD not set. Using default "changeme".');
95
+ }
96
+
97
+ start(PORT);
98
+ }
99
+
100
+ module.exports = { app, server, start };