archicore 0.1.9 → 0.2.1
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/dist/cli/commands/interactive.js +145 -61
- package/dist/github/github-service.d.ts +5 -1
- package/dist/github/github-service.js +21 -3
- package/dist/semantic-memory/embedding-service.d.ts +8 -1
- package/dist/semantic-memory/embedding-service.js +141 -47
- package/dist/server/index.js +66 -1
- package/dist/server/routes/admin.js +149 -1
- package/dist/server/routes/auth.js +46 -0
- package/dist/server/routes/github.js +17 -4
- package/dist/server/services/audit-service.d.ts +88 -0
- package/dist/server/services/audit-service.js +380 -0
- package/dist/server/services/auth-service.d.ts +11 -5
- package/dist/server/services/auth-service.js +299 -52
- package/dist/server/services/cache.d.ts +77 -0
- package/dist/server/services/cache.js +245 -0
- package/dist/server/services/database.d.ts +43 -0
- package/dist/server/services/database.js +221 -0
- package/package.json +17 -2
|
@@ -5,7 +5,9 @@ export class EmbeddingService {
|
|
|
5
5
|
config;
|
|
6
6
|
initialized = false;
|
|
7
7
|
_isAvailable = false;
|
|
8
|
-
|
|
8
|
+
jinaApiKeys = [];
|
|
9
|
+
currentKeyIndex = 0;
|
|
10
|
+
keyFailures = new Map();
|
|
9
11
|
embeddingDimension = 1024; // Jina default, OpenAI small = 1536
|
|
10
12
|
constructor(config) {
|
|
11
13
|
this.config = config;
|
|
@@ -23,16 +25,21 @@ export class EmbeddingService {
|
|
|
23
25
|
}
|
|
24
26
|
try {
|
|
25
27
|
if (this.config.provider === 'jina') {
|
|
26
|
-
// Jina AI - free embeddings
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// Jina AI - free embeddings with key rotation support
|
|
29
|
+
// Support multiple keys: JINA_API_KEYS (comma-separated) or JINA_API_KEY (single)
|
|
30
|
+
const keysEnv = process.env.JINA_API_KEYS || process.env.JINA_API_KEY || '';
|
|
31
|
+
this.jinaApiKeys = keysEnv
|
|
32
|
+
.split(',')
|
|
33
|
+
.map(k => k.trim())
|
|
34
|
+
.filter(k => k.length > 0);
|
|
35
|
+
if (this.jinaApiKeys.length === 0) {
|
|
36
|
+
Logger.warn('JINA_API_KEY(S) not set - semantic search disabled');
|
|
30
37
|
Logger.info('Get free API key at: https://jina.ai/embeddings/');
|
|
31
38
|
return;
|
|
32
39
|
}
|
|
33
40
|
this.embeddingDimension = 1024;
|
|
34
41
|
this._isAvailable = true;
|
|
35
|
-
Logger.success(
|
|
42
|
+
Logger.success(`Jina AI embeddings enabled (${this.jinaApiKeys.length} key${this.jinaApiKeys.length > 1 ? 's' : ''} configured)`);
|
|
36
43
|
}
|
|
37
44
|
else {
|
|
38
45
|
// OpenAI embeddings
|
|
@@ -51,6 +58,45 @@ export class EmbeddingService {
|
|
|
51
58
|
Logger.warn('Embedding init failed: ' + error);
|
|
52
59
|
}
|
|
53
60
|
}
|
|
61
|
+
getCurrentJinaKey() {
|
|
62
|
+
if (this.jinaApiKeys.length === 0)
|
|
63
|
+
return undefined;
|
|
64
|
+
return this.jinaApiKeys[this.currentKeyIndex];
|
|
65
|
+
}
|
|
66
|
+
rotateToNextKey() {
|
|
67
|
+
if (this.jinaApiKeys.length <= 1)
|
|
68
|
+
return false;
|
|
69
|
+
const oldIndex = this.currentKeyIndex;
|
|
70
|
+
this.currentKeyIndex = (this.currentKeyIndex + 1) % this.jinaApiKeys.length;
|
|
71
|
+
// Track failure for old key
|
|
72
|
+
const failure = this.keyFailures.get(oldIndex) || { count: 0, lastFail: new Date() };
|
|
73
|
+
failure.count++;
|
|
74
|
+
failure.lastFail = new Date();
|
|
75
|
+
this.keyFailures.set(oldIndex, failure);
|
|
76
|
+
Logger.warn(`Jina API key ${oldIndex + 1} exhausted/failed, rotating to key ${this.currentKeyIndex + 1}`);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
shouldSkipKey(index) {
|
|
80
|
+
const failure = this.keyFailures.get(index);
|
|
81
|
+
if (!failure)
|
|
82
|
+
return false;
|
|
83
|
+
// Skip key if it failed recently (within 5 minutes) and has many failures
|
|
84
|
+
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
|
85
|
+
return failure.count >= 3 && failure.lastFail > fiveMinutesAgo;
|
|
86
|
+
}
|
|
87
|
+
findWorkingKeyIndex() {
|
|
88
|
+
const startIndex = this.currentKeyIndex;
|
|
89
|
+
for (let i = 0; i < this.jinaApiKeys.length; i++) {
|
|
90
|
+
const index = (startIndex + i) % this.jinaApiKeys.length;
|
|
91
|
+
if (!this.shouldSkipKey(index)) {
|
|
92
|
+
return index;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// All keys are marked as failed, reset failures and try first key
|
|
96
|
+
Logger.warn('All Jina API keys marked as failed, resetting failure counters');
|
|
97
|
+
this.keyFailures.clear();
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
54
100
|
isAvailable() {
|
|
55
101
|
this.ensureInitialized();
|
|
56
102
|
return this._isAvailable;
|
|
@@ -60,7 +106,7 @@ export class EmbeddingService {
|
|
|
60
106
|
if (!this._isAvailable)
|
|
61
107
|
return new Array(this.embeddingDimension).fill(0);
|
|
62
108
|
try {
|
|
63
|
-
if (this.config.provider === 'jina' && this.
|
|
109
|
+
if (this.config.provider === 'jina' && this.jinaApiKeys.length > 0) {
|
|
64
110
|
return await this.generateJinaEmbedding(text);
|
|
65
111
|
}
|
|
66
112
|
else if (this.openai) {
|
|
@@ -73,28 +119,51 @@ export class EmbeddingService {
|
|
|
73
119
|
return new Array(this.embeddingDimension).fill(0);
|
|
74
120
|
}
|
|
75
121
|
}
|
|
76
|
-
async generateJinaEmbedding(text) {
|
|
77
|
-
|
|
122
|
+
async generateJinaEmbedding(text, retryCount = 0) {
|
|
123
|
+
const maxRetries = this.jinaApiKeys.length;
|
|
124
|
+
// Find a working key index before first attempt
|
|
125
|
+
if (retryCount === 0) {
|
|
126
|
+
this.currentKeyIndex = this.findWorkingKeyIndex();
|
|
127
|
+
}
|
|
128
|
+
const apiKey = this.getCurrentJinaKey();
|
|
129
|
+
if (!apiKey)
|
|
78
130
|
throw new Error('Jina API key not set');
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
131
|
+
try {
|
|
132
|
+
const response = await fetch('https://api.jina.ai/v1/embeddings', {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: {
|
|
135
|
+
'Content-Type': 'application/json',
|
|
136
|
+
'Authorization': `Bearer ${apiKey}`
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
model: this.config.model || 'jina-embeddings-v3',
|
|
140
|
+
task: 'text-matching',
|
|
141
|
+
dimensions: 1024,
|
|
142
|
+
input: [text.substring(0, 8000)]
|
|
143
|
+
})
|
|
144
|
+
});
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
const errorText = await response.text();
|
|
147
|
+
// Check for rate limit or auth errors - try next key
|
|
148
|
+
if (response.status === 429 || response.status === 401 || response.status === 403) {
|
|
149
|
+
if (retryCount < maxRetries - 1 && this.rotateToNextKey()) {
|
|
150
|
+
Logger.warn(`Jina API key error (${response.status}), trying next key...`);
|
|
151
|
+
return this.generateJinaEmbedding(text, retryCount + 1);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`Jina API error: ${response.status} ${errorText}`);
|
|
155
|
+
}
|
|
156
|
+
const data = await response.json();
|
|
157
|
+
return data.data[0].embedding;
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
// Network error or other issue - try next key
|
|
161
|
+
if (retryCount < maxRetries - 1 && this.rotateToNextKey()) {
|
|
162
|
+
Logger.warn(`Jina API request failed, trying next key...`);
|
|
163
|
+
return this.generateJinaEmbedding(text, retryCount + 1);
|
|
164
|
+
}
|
|
165
|
+
throw error;
|
|
95
166
|
}
|
|
96
|
-
const data = await response.json();
|
|
97
|
-
return data.data[0].embedding;
|
|
98
167
|
}
|
|
99
168
|
/**
|
|
100
169
|
* Generate embeddings for multiple texts - uses true batch API when available
|
|
@@ -110,30 +179,13 @@ export class EmbeddingService {
|
|
|
110
179
|
Logger.progress(`Generating embeddings for ${texts.length} texts...`);
|
|
111
180
|
try {
|
|
112
181
|
// Use true batch API for Jina (much faster!)
|
|
113
|
-
if (this.config.provider === 'jina' && this.
|
|
182
|
+
if (this.config.provider === 'jina' && this.jinaApiKeys.length > 0) {
|
|
114
183
|
const BATCH_SIZE = 500;
|
|
115
184
|
const allEmbeddings = [];
|
|
116
185
|
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
|
|
117
186
|
const batch = texts.slice(i, i + BATCH_SIZE).map(t => t.substring(0, 8000));
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
headers: {
|
|
121
|
-
'Content-Type': 'application/json',
|
|
122
|
-
'Authorization': `Bearer ${this.jinaApiKey}`
|
|
123
|
-
},
|
|
124
|
-
body: JSON.stringify({
|
|
125
|
-
model: this.config.model || 'jina-embeddings-v3',
|
|
126
|
-
task: 'text-matching',
|
|
127
|
-
dimensions: 1024,
|
|
128
|
-
input: batch
|
|
129
|
-
})
|
|
130
|
-
});
|
|
131
|
-
if (!response.ok) {
|
|
132
|
-
const error = await response.text();
|
|
133
|
-
throw new Error(`Jina API error: ${response.status} ${error}`);
|
|
134
|
-
}
|
|
135
|
-
const data = await response.json();
|
|
136
|
-
allEmbeddings.push(...data.data.map(d => d.embedding));
|
|
187
|
+
const batchEmbeddings = await this.generateJinaBatchWithRetry(batch);
|
|
188
|
+
allEmbeddings.push(...batchEmbeddings);
|
|
137
189
|
if (progressCallback) {
|
|
138
190
|
progressCallback(Math.min(i + BATCH_SIZE, texts.length), texts.length);
|
|
139
191
|
}
|
|
@@ -161,6 +213,48 @@ export class EmbeddingService {
|
|
|
161
213
|
return texts.map(() => new Array(this.embeddingDimension).fill(0));
|
|
162
214
|
}
|
|
163
215
|
}
|
|
216
|
+
async generateJinaBatchWithRetry(batch, retryCount = 0) {
|
|
217
|
+
const maxRetries = this.jinaApiKeys.length;
|
|
218
|
+
const apiKey = this.getCurrentJinaKey();
|
|
219
|
+
if (!apiKey)
|
|
220
|
+
throw new Error('Jina API key not set');
|
|
221
|
+
try {
|
|
222
|
+
const response = await fetch('https://api.jina.ai/v1/embeddings', {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers: {
|
|
225
|
+
'Content-Type': 'application/json',
|
|
226
|
+
'Authorization': `Bearer ${apiKey}`
|
|
227
|
+
},
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
model: this.config.model || 'jina-embeddings-v3',
|
|
230
|
+
task: 'text-matching',
|
|
231
|
+
dimensions: 1024,
|
|
232
|
+
input: batch
|
|
233
|
+
})
|
|
234
|
+
});
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
const errorText = await response.text();
|
|
237
|
+
// Check for rate limit or auth errors - try next key
|
|
238
|
+
if (response.status === 429 || response.status === 401 || response.status === 403) {
|
|
239
|
+
if (retryCount < maxRetries - 1 && this.rotateToNextKey()) {
|
|
240
|
+
Logger.warn(`Jina API batch error (${response.status}), trying next key...`);
|
|
241
|
+
return this.generateJinaBatchWithRetry(batch, retryCount + 1);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
throw new Error(`Jina API error: ${response.status} ${errorText}`);
|
|
245
|
+
}
|
|
246
|
+
const data = await response.json();
|
|
247
|
+
return data.data.map(d => d.embedding);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
// Network error or other issue - try next key
|
|
251
|
+
if (retryCount < maxRetries - 1 && this.rotateToNextKey()) {
|
|
252
|
+
Logger.warn(`Jina API batch request failed, trying next key...`);
|
|
253
|
+
return this.generateJinaBatchWithRetry(batch, retryCount + 1);
|
|
254
|
+
}
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
164
258
|
async generateOpenAIEmbedding(text) {
|
|
165
259
|
if (!this.openai)
|
|
166
260
|
throw new Error('OpenAI client not initialized');
|
package/dist/server/index.js
CHANGED
|
@@ -25,6 +25,9 @@ import { adminRouter } from './routes/admin.js';
|
|
|
25
25
|
import { developerRouter } from './routes/developer.js';
|
|
26
26
|
import { githubRouter } from './routes/github.js';
|
|
27
27
|
import deviceAuthRouter from './routes/device-auth.js';
|
|
28
|
+
import { cache } from './services/cache.js';
|
|
29
|
+
import { db } from './services/database.js';
|
|
30
|
+
import { AuthService } from './services/auth-service.js';
|
|
28
31
|
const __filename = fileURLToPath(import.meta.url);
|
|
29
32
|
const __dirname = path.dirname(__filename);
|
|
30
33
|
// CORS whitelist - настройте под свои домены
|
|
@@ -156,6 +159,7 @@ export class ArchiCoreServer {
|
|
|
156
159
|
this.app.use(express.static(publicPath, {
|
|
157
160
|
maxAge: process.env.NODE_ENV === 'production' ? '1d' : 0,
|
|
158
161
|
etag: true,
|
|
162
|
+
index: false, // Отключаем автоматический index.html - используем явные маршруты
|
|
159
163
|
}));
|
|
160
164
|
}
|
|
161
165
|
setupRoutes() {
|
|
@@ -187,7 +191,54 @@ export class ArchiCoreServer {
|
|
|
187
191
|
const deviceAuthPath = path.join(__dirname, '../../public/device-auth.html');
|
|
188
192
|
res.sendFile(deviceAuthPath);
|
|
189
193
|
});
|
|
190
|
-
//
|
|
194
|
+
// Clean URL routing
|
|
195
|
+
// Landing page (главная)
|
|
196
|
+
this.app.get('/', (_req, res) => {
|
|
197
|
+
res.sendFile(path.join(__dirname, '../../public/landing.html'));
|
|
198
|
+
});
|
|
199
|
+
// Dashboard (после авторизации)
|
|
200
|
+
this.app.get('/dashboard', (_req, res) => {
|
|
201
|
+
res.sendFile(path.join(__dirname, '../../public/index.html'));
|
|
202
|
+
});
|
|
203
|
+
// Add new project page
|
|
204
|
+
this.app.get('/new', (_req, res) => {
|
|
205
|
+
res.sendFile(path.join(__dirname, '../../public/index.html'));
|
|
206
|
+
});
|
|
207
|
+
// Pricing page
|
|
208
|
+
this.app.get('/pricing', (_req, res) => {
|
|
209
|
+
res.sendFile(path.join(__dirname, '../../public/pricing.html'));
|
|
210
|
+
});
|
|
211
|
+
// API Dashboard page
|
|
212
|
+
this.app.get('/api-dashboard', (_req, res) => {
|
|
213
|
+
res.sendFile(path.join(__dirname, '../../public/api-dashboard.html'));
|
|
214
|
+
});
|
|
215
|
+
// Auth page
|
|
216
|
+
this.app.get('/auth', (_req, res) => {
|
|
217
|
+
res.sendFile(path.join(__dirname, '../../public/auth.html'));
|
|
218
|
+
});
|
|
219
|
+
// Login alias
|
|
220
|
+
this.app.get('/login', (_req, res) => {
|
|
221
|
+
res.sendFile(path.join(__dirname, '../../public/auth.html'));
|
|
222
|
+
});
|
|
223
|
+
// Register alias
|
|
224
|
+
this.app.get('/register', (_req, res) => {
|
|
225
|
+
res.redirect('/auth?mode=register');
|
|
226
|
+
});
|
|
227
|
+
// Admin page
|
|
228
|
+
this.app.get('/admin', (_req, res) => {
|
|
229
|
+
res.sendFile(path.join(__dirname, '../../public/admin.html'));
|
|
230
|
+
});
|
|
231
|
+
// Legal pages
|
|
232
|
+
this.app.get('/privacy', (_req, res) => {
|
|
233
|
+
res.sendFile(path.join(__dirname, '../../public/privacy.html'));
|
|
234
|
+
});
|
|
235
|
+
this.app.get('/terms', (_req, res) => {
|
|
236
|
+
res.sendFile(path.join(__dirname, '../../public/terms.html'));
|
|
237
|
+
});
|
|
238
|
+
this.app.get('/security', (_req, res) => {
|
|
239
|
+
res.sendFile(path.join(__dirname, '../../public/security.html'));
|
|
240
|
+
});
|
|
241
|
+
// SPA fallback - все остальные маршруты отдают index.html (dashboard)
|
|
191
242
|
this.app.get('/*splat', (_req, res) => {
|
|
192
243
|
const indexPath = path.join(__dirname, '../../public/index.html');
|
|
193
244
|
res.sendFile(indexPath);
|
|
@@ -208,6 +259,17 @@ export class ArchiCoreServer {
|
|
|
208
259
|
});
|
|
209
260
|
}
|
|
210
261
|
async start() {
|
|
262
|
+
// Initialize database
|
|
263
|
+
try {
|
|
264
|
+
await db.init();
|
|
265
|
+
const authService = AuthService.getInstance();
|
|
266
|
+
await authService.initDatabase();
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
Logger.warn('Database initialization failed, using JSON fallback:', error);
|
|
270
|
+
}
|
|
271
|
+
// Initialize cache
|
|
272
|
+
await cache.connect();
|
|
211
273
|
return new Promise((resolve) => {
|
|
212
274
|
this.server = createServer(this.app);
|
|
213
275
|
const host = this.config.host || '0.0.0.0';
|
|
@@ -221,6 +283,9 @@ export class ArchiCoreServer {
|
|
|
221
283
|
});
|
|
222
284
|
}
|
|
223
285
|
async stop() {
|
|
286
|
+
// Disconnect cache and database
|
|
287
|
+
await cache.disconnect();
|
|
288
|
+
await db.close();
|
|
224
289
|
return new Promise((resolve) => {
|
|
225
290
|
if (this.server) {
|
|
226
291
|
this.server.close(() => {
|
|
@@ -2,14 +2,33 @@
|
|
|
2
2
|
* Admin API Routes for ArchiCore
|
|
3
3
|
*/
|
|
4
4
|
import { Router } from 'express';
|
|
5
|
+
import rateLimit from 'express-rate-limit';
|
|
5
6
|
import { AuthService } from '../services/auth-service.js';
|
|
7
|
+
import { auditService } from '../services/audit-service.js';
|
|
6
8
|
import { authMiddleware, adminMiddleware } from './auth.js';
|
|
7
9
|
import { Logger } from '../../utils/logger.js';
|
|
8
10
|
export const adminRouter = Router();
|
|
9
11
|
const authService = AuthService.getInstance();
|
|
10
|
-
//
|
|
12
|
+
// Stricter rate limiting for admin routes (30 requests per minute)
|
|
13
|
+
const adminRateLimiter = rateLimit({
|
|
14
|
+
windowMs: 60 * 1000, // 1 minute
|
|
15
|
+
max: 30, // 30 requests per minute
|
|
16
|
+
message: { error: 'Too many admin requests, please slow down', retryAfter: 60 },
|
|
17
|
+
standardHeaders: true,
|
|
18
|
+
legacyHeaders: false,
|
|
19
|
+
handler: (_req, res) => {
|
|
20
|
+
Logger.warn('Admin rate limit exceeded');
|
|
21
|
+
res.status(429).json({
|
|
22
|
+
error: 'Too many admin requests',
|
|
23
|
+
message: 'Please wait before making more admin API calls',
|
|
24
|
+
retryAfter: 60
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
// All admin routes require authentication, admin role, and rate limiting
|
|
11
29
|
adminRouter.use(authMiddleware);
|
|
12
30
|
adminRouter.use(adminMiddleware);
|
|
31
|
+
adminRouter.use(adminRateLimiter);
|
|
13
32
|
/**
|
|
14
33
|
* GET /api/admin/users
|
|
15
34
|
* Get all users
|
|
@@ -49,6 +68,8 @@ adminRouter.get('/users/:id', async (req, res) => {
|
|
|
49
68
|
* Update user's subscription tier
|
|
50
69
|
*/
|
|
51
70
|
adminRouter.put('/users/:id/tier', async (req, res) => {
|
|
71
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
|
|
72
|
+
const userAgent = req.headers['user-agent'];
|
|
52
73
|
try {
|
|
53
74
|
const { id } = req.params;
|
|
54
75
|
const { tier } = req.body;
|
|
@@ -57,11 +78,28 @@ adminRouter.put('/users/:id/tier', async (req, res) => {
|
|
|
57
78
|
res.status(400).json({ error: 'Invalid tier. Valid tiers: ' + validTiers.join(', ') });
|
|
58
79
|
return;
|
|
59
80
|
}
|
|
81
|
+
// Get old tier for audit
|
|
82
|
+
const oldUser = await authService.getUser(id);
|
|
83
|
+
const oldTier = oldUser?.tier;
|
|
60
84
|
const updated = await authService.updateUserTier(id, tier);
|
|
61
85
|
if (!updated) {
|
|
62
86
|
res.status(404).json({ error: 'User not found' });
|
|
63
87
|
return;
|
|
64
88
|
}
|
|
89
|
+
// Audit log
|
|
90
|
+
await auditService.log({
|
|
91
|
+
userId: req.user?.id,
|
|
92
|
+
username: req.user?.username,
|
|
93
|
+
action: 'admin.tier_change',
|
|
94
|
+
ip,
|
|
95
|
+
userAgent,
|
|
96
|
+
details: {
|
|
97
|
+
targetUserId: id,
|
|
98
|
+
targetUsername: oldUser?.username,
|
|
99
|
+
oldTier,
|
|
100
|
+
newTier: tier
|
|
101
|
+
}
|
|
102
|
+
});
|
|
65
103
|
res.json({ success: true });
|
|
66
104
|
}
|
|
67
105
|
catch (error) {
|
|
@@ -74,13 +112,30 @@ adminRouter.put('/users/:id/tier', async (req, res) => {
|
|
|
74
112
|
* Delete user
|
|
75
113
|
*/
|
|
76
114
|
adminRouter.delete('/users/:id', async (req, res) => {
|
|
115
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
|
|
116
|
+
const userAgent = req.headers['user-agent'];
|
|
77
117
|
try {
|
|
78
118
|
const { id } = req.params;
|
|
119
|
+
// Get user info before deletion for audit
|
|
120
|
+
const userToDelete = await authService.getUser(id);
|
|
79
121
|
const deleted = await authService.deleteUser(id);
|
|
80
122
|
if (!deleted) {
|
|
81
123
|
res.status(400).json({ error: 'Cannot delete user (admin or not found)' });
|
|
82
124
|
return;
|
|
83
125
|
}
|
|
126
|
+
// Audit log
|
|
127
|
+
await auditService.log({
|
|
128
|
+
userId: req.user?.id,
|
|
129
|
+
username: req.user?.username,
|
|
130
|
+
action: 'admin.user_delete',
|
|
131
|
+
ip,
|
|
132
|
+
userAgent,
|
|
133
|
+
details: {
|
|
134
|
+
deletedUserId: id,
|
|
135
|
+
deletedUsername: userToDelete?.username,
|
|
136
|
+
deletedEmail: userToDelete?.email
|
|
137
|
+
}
|
|
138
|
+
});
|
|
84
139
|
res.json({ success: true });
|
|
85
140
|
}
|
|
86
141
|
catch (error) {
|
|
@@ -120,4 +175,97 @@ adminRouter.get('/stats', async (_req, res) => {
|
|
|
120
175
|
res.status(500).json({ error: 'Failed to get statistics' });
|
|
121
176
|
}
|
|
122
177
|
});
|
|
178
|
+
// ===== AUDIT LOGS =====
|
|
179
|
+
/**
|
|
180
|
+
* GET /api/admin/audit
|
|
181
|
+
* Get audit logs with filtering and pagination
|
|
182
|
+
*/
|
|
183
|
+
adminRouter.get('/audit', async (req, res) => {
|
|
184
|
+
try {
|
|
185
|
+
const { userId, action, severity, success, startDate, endDate, limit = '50', offset = '0' } = req.query;
|
|
186
|
+
// Log that admin is viewing audit logs
|
|
187
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
|
|
188
|
+
const userAgent = req.headers['user-agent'];
|
|
189
|
+
await auditService.log({
|
|
190
|
+
userId: req.user?.id,
|
|
191
|
+
username: req.user?.username,
|
|
192
|
+
action: 'admin.view_logs',
|
|
193
|
+
ip,
|
|
194
|
+
userAgent,
|
|
195
|
+
details: { filters: { userId, action, severity } }
|
|
196
|
+
});
|
|
197
|
+
const result = await auditService.query({
|
|
198
|
+
userId: userId,
|
|
199
|
+
action: action,
|
|
200
|
+
severity: severity,
|
|
201
|
+
success: success ? success === 'true' : undefined,
|
|
202
|
+
startDate: startDate ? new Date(startDate) : undefined,
|
|
203
|
+
endDate: endDate ? new Date(endDate) : undefined,
|
|
204
|
+
limit: parseInt(limit, 10),
|
|
205
|
+
offset: parseInt(offset, 10)
|
|
206
|
+
});
|
|
207
|
+
res.json(result);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
Logger.error('Failed to get audit logs:', error);
|
|
211
|
+
res.status(500).json({ error: 'Failed to get audit logs' });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
/**
|
|
215
|
+
* GET /api/admin/audit/stats
|
|
216
|
+
* Get audit statistics
|
|
217
|
+
*/
|
|
218
|
+
adminRouter.get('/audit/stats', async (req, res) => {
|
|
219
|
+
try {
|
|
220
|
+
const days = parseInt(req.query.days || '7', 10);
|
|
221
|
+
const stats = await auditService.getStats(days);
|
|
222
|
+
res.json(stats);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
Logger.error('Failed to get audit stats:', error);
|
|
226
|
+
res.status(500).json({ error: 'Failed to get audit statistics' });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
/**
|
|
230
|
+
* GET /api/admin/audit/user/:userId
|
|
231
|
+
* Get audit logs for specific user
|
|
232
|
+
*/
|
|
233
|
+
adminRouter.get('/audit/user/:userId', async (req, res) => {
|
|
234
|
+
try {
|
|
235
|
+
const { userId } = req.params;
|
|
236
|
+
const limit = parseInt(req.query.limit || '50', 10);
|
|
237
|
+
const logs = await auditService.getUserLogs(userId, limit);
|
|
238
|
+
res.json({ logs });
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
Logger.error('Failed to get user audit logs:', error);
|
|
242
|
+
res.status(500).json({ error: 'Failed to get user audit logs' });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
/**
|
|
246
|
+
* POST /api/admin/audit/cleanup
|
|
247
|
+
* Clean up old audit logs (retention policy)
|
|
248
|
+
*/
|
|
249
|
+
adminRouter.post('/audit/cleanup', async (req, res) => {
|
|
250
|
+
try {
|
|
251
|
+
const retentionDays = parseInt(req.body.retentionDays || '90', 10);
|
|
252
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
|
|
253
|
+
const userAgent = req.headers['user-agent'];
|
|
254
|
+
// Log cleanup action
|
|
255
|
+
await auditService.log({
|
|
256
|
+
userId: req.user?.id,
|
|
257
|
+
username: req.user?.username,
|
|
258
|
+
action: 'admin.view_logs',
|
|
259
|
+
ip,
|
|
260
|
+
userAgent,
|
|
261
|
+
details: { operation: 'cleanup', retentionDays }
|
|
262
|
+
});
|
|
263
|
+
const removed = await auditService.cleanup(retentionDays);
|
|
264
|
+
res.json({ success: true, removed });
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
Logger.error('Failed to cleanup audit logs:', error);
|
|
268
|
+
res.status(500).json({ error: 'Failed to cleanup audit logs' });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
123
271
|
//# sourceMappingURL=admin.js.map
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { Router } from 'express';
|
|
5
5
|
import { AuthService } from '../services/auth-service.js';
|
|
6
|
+
import { auditService } from '../services/audit-service.js';
|
|
6
7
|
import { Logger } from '../../utils/logger.js';
|
|
7
8
|
export const authRouter = Router();
|
|
8
9
|
const authService = AuthService.getInstance();
|
|
@@ -35,6 +36,8 @@ export async function adminMiddleware(req, res, next) {
|
|
|
35
36
|
* Register new user
|
|
36
37
|
*/
|
|
37
38
|
authRouter.post('/register', async (req, res) => {
|
|
39
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
|
|
40
|
+
const userAgent = req.headers['user-agent'];
|
|
38
41
|
try {
|
|
39
42
|
const { email, username, password } = req.body;
|
|
40
43
|
if (!email || !username || !password) {
|
|
@@ -46,6 +49,17 @@ authRouter.post('/register', async (req, res) => {
|
|
|
46
49
|
return;
|
|
47
50
|
}
|
|
48
51
|
const result = await authService.register(email, username, password);
|
|
52
|
+
// Audit log
|
|
53
|
+
if (result.success && result.user) {
|
|
54
|
+
await auditService.log({
|
|
55
|
+
userId: result.user.id,
|
|
56
|
+
username: result.user.username,
|
|
57
|
+
action: 'auth.register',
|
|
58
|
+
ip,
|
|
59
|
+
userAgent,
|
|
60
|
+
details: { email }
|
|
61
|
+
});
|
|
62
|
+
}
|
|
49
63
|
res.json(result);
|
|
50
64
|
}
|
|
51
65
|
catch (error) {
|
|
@@ -58,6 +72,8 @@ authRouter.post('/register', async (req, res) => {
|
|
|
58
72
|
* Login with email/password
|
|
59
73
|
*/
|
|
60
74
|
authRouter.post('/login', async (req, res) => {
|
|
75
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
|
|
76
|
+
const userAgent = req.headers['user-agent'];
|
|
61
77
|
try {
|
|
62
78
|
const { email, password } = req.body;
|
|
63
79
|
if (!email || !password) {
|
|
@@ -65,10 +81,30 @@ authRouter.post('/login', async (req, res) => {
|
|
|
65
81
|
return;
|
|
66
82
|
}
|
|
67
83
|
const result = await authService.login(email, password);
|
|
84
|
+
// Audit log
|
|
85
|
+
await auditService.log({
|
|
86
|
+
userId: result.user?.id,
|
|
87
|
+
username: result.user?.username,
|
|
88
|
+
action: 'auth.login',
|
|
89
|
+
ip,
|
|
90
|
+
userAgent,
|
|
91
|
+
details: { email },
|
|
92
|
+
success: result.success,
|
|
93
|
+
errorMessage: result.success ? undefined : result.error
|
|
94
|
+
});
|
|
68
95
|
res.json(result);
|
|
69
96
|
}
|
|
70
97
|
catch (error) {
|
|
71
98
|
Logger.error('Login error:', error);
|
|
99
|
+
// Audit failed login attempt
|
|
100
|
+
await auditService.log({
|
|
101
|
+
action: 'auth.login',
|
|
102
|
+
ip,
|
|
103
|
+
userAgent,
|
|
104
|
+
details: { email: req.body.email },
|
|
105
|
+
success: false,
|
|
106
|
+
errorMessage: 'Login failed'
|
|
107
|
+
});
|
|
72
108
|
res.status(500).json({ success: false, error: 'Login failed' });
|
|
73
109
|
}
|
|
74
110
|
});
|
|
@@ -77,11 +113,21 @@ authRouter.post('/login', async (req, res) => {
|
|
|
77
113
|
* Logout current user
|
|
78
114
|
*/
|
|
79
115
|
authRouter.post('/logout', authMiddleware, async (req, res) => {
|
|
116
|
+
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
|
|
117
|
+
const userAgent = req.headers['user-agent'];
|
|
80
118
|
try {
|
|
81
119
|
const token = req.headers.authorization?.substring(7);
|
|
82
120
|
if (token) {
|
|
83
121
|
await authService.logout(token);
|
|
84
122
|
}
|
|
123
|
+
// Audit log
|
|
124
|
+
await auditService.log({
|
|
125
|
+
userId: req.user?.id,
|
|
126
|
+
username: req.user?.username,
|
|
127
|
+
action: 'auth.logout',
|
|
128
|
+
ip,
|
|
129
|
+
userAgent
|
|
130
|
+
});
|
|
85
131
|
res.json({ success: true });
|
|
86
132
|
}
|
|
87
133
|
catch (error) {
|
|
@@ -449,7 +449,7 @@ githubRouter.post('/repositories/:id/analyze', authMiddleware, async (req, res)
|
|
|
449
449
|
githubRouter.post('/webhook', async (req, res) => {
|
|
450
450
|
try {
|
|
451
451
|
const event = req.headers['x-github-event'];
|
|
452
|
-
|
|
452
|
+
const signature = req.headers['x-hub-signature-256'];
|
|
453
453
|
const payload = req.body;
|
|
454
454
|
if (!event || !payload) {
|
|
455
455
|
res.status(400).json({ error: 'Invalid webhook' });
|
|
@@ -467,9 +467,22 @@ githubRouter.post('/webhook', async (req, res) => {
|
|
|
467
467
|
res.status(200).json({ message: 'Repository not connected' });
|
|
468
468
|
return;
|
|
469
469
|
}
|
|
470
|
-
// Verify signature (
|
|
471
|
-
|
|
472
|
-
|
|
470
|
+
// Verify webhook signature (HMAC-SHA256)
|
|
471
|
+
if (repo.webhookSecret) {
|
|
472
|
+
if (!signature) {
|
|
473
|
+
Logger.warn(`Webhook missing signature for ${fullName}`);
|
|
474
|
+
res.status(401).json({ error: 'Missing signature' });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const secret = githubService.getWebhookSecret(repo.webhookSecret);
|
|
478
|
+
const payloadBody = JSON.stringify(payload);
|
|
479
|
+
if (!githubService.verifyWebhookSignature(payloadBody, signature, secret)) {
|
|
480
|
+
Logger.warn(`Invalid webhook signature for ${fullName}`);
|
|
481
|
+
res.status(401).json({ error: 'Invalid signature' });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
Logger.debug(`Webhook signature verified for ${fullName}`);
|
|
485
|
+
}
|
|
473
486
|
Logger.info(`Webhook received: ${event} for ${fullName}`);
|
|
474
487
|
// Handle different events
|
|
475
488
|
switch (event) {
|