agentgate 0.3.2 → 0.5.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/README.md +118 -4
- package/package.json +3 -2
- package/public/mobile.css +178 -0
- package/public/style.css +129 -0
- package/src/index.js +304 -51
- package/src/lib/agentNotifier.js +82 -1
- package/src/lib/db.js +868 -9
- package/src/lib/socketManager.js +73 -0
- package/src/routes/agents.js +117 -3
- package/src/routes/linkedin.js +30 -10
- package/src/routes/memento.js +106 -0
- package/src/routes/queue.js +238 -4
- package/src/routes/services.js +87 -0
- package/src/routes/ui/access.js +290 -0
- package/src/routes/ui/auth.js +149 -0
- package/src/routes/ui/calendar.js +65 -14
- package/src/routes/ui/fitbit.js +63 -14
- package/src/routes/ui/home.js +313 -0
- package/src/routes/ui/index.js +52 -35
- package/src/routes/ui/keys.js +852 -0
- package/src/routes/ui/linkedin.js +75 -19
- package/src/routes/ui/mastodon.js +70 -18
- package/src/routes/ui/mementos.js +363 -0
- package/src/routes/ui/messages.js +588 -0
- package/src/routes/ui/queue.js +599 -0
- package/src/routes/ui/reddit.js +63 -14
- package/src/routes/ui/services.js +46 -0
- package/src/routes/ui/settings.js +59 -0
- package/src/routes/ui/shared.js +269 -0
- package/src/routes/ui/youtube.js +63 -14
- package/src/routes/ui-new.js +196 -0
- package/src/routes/webhooks.js +247 -0
- package/src/routes/ui.js +0 -1901
package/src/routes/queue.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
|
-
import { createQueueEntry, getQueueEntry, getAccountCredentials, listQueueEntriesBySubmitter } from '../lib/db.js';
|
|
2
|
+
import { createQueueEntry, getQueueEntry, getAccountCredentials, listQueueEntriesBySubmitter, updateQueueStatus, getSharedQueueVisibility, listAllQueueEntries, getAgentWithdrawEnabled, checkServiceAccess, checkBypassAuth, addQueueWarning, getQueueWarnings } from '../lib/db.js';
|
|
3
|
+
import { notifyAgentQueueWarning } from '../lib/agentNotifier.js';
|
|
4
|
+
import { emitCountUpdate, emitEvent } from '../lib/socketManager.js';
|
|
5
|
+
import { executeQueueEntry } from '../lib/queueExecutor.js';
|
|
3
6
|
|
|
4
7
|
const router = Router();
|
|
5
8
|
|
|
@@ -11,7 +14,7 @@ const VALID_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
|
11
14
|
|
|
12
15
|
// Submit a batch of write requests for approval
|
|
13
16
|
// POST /api/queue/:service/:accountName/submit
|
|
14
|
-
router.post('/:service/:accountName/submit', (req, res) => {
|
|
17
|
+
router.post('/:service/:accountName/submit', async (req, res) => {
|
|
15
18
|
try {
|
|
16
19
|
const { service, accountName } = req.params;
|
|
17
20
|
const { requests, comment } = req.body;
|
|
@@ -33,6 +36,19 @@ router.post('/:service/:accountName/submit', (req, res) => {
|
|
|
33
36
|
});
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
// Check service access
|
|
40
|
+
const agentName = req.apiKeyInfo?.name;
|
|
41
|
+
if (agentName) {
|
|
42
|
+
const access = checkServiceAccess(service, accountName, agentName);
|
|
43
|
+
if (!access.allowed) {
|
|
44
|
+
return res.status(403).json({
|
|
45
|
+
error: `Agent '${agentName}' does not have access to service '${service}/${accountName}'`,
|
|
46
|
+
reason: access.reason
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
36
52
|
// Validate requests array
|
|
37
53
|
if (!Array.isArray(requests) || requests.length === 0) {
|
|
38
54
|
return res.status(400).json({
|
|
@@ -66,9 +82,45 @@ router.post('/:service/:accountName/submit', (req, res) => {
|
|
|
66
82
|
// Get the API key name for audit trail
|
|
67
83
|
const submittedBy = req.apiKeyInfo?.name || 'unknown';
|
|
68
84
|
|
|
85
|
+
// Check if agent has bypass_auth enabled for this service
|
|
86
|
+
const hasBypass = agentName && checkBypassAuth(service, accountName, agentName);
|
|
87
|
+
|
|
69
88
|
// Create the queue entry
|
|
70
89
|
const entry = createQueueEntry(service, accountName, requests, comment, submittedBy);
|
|
71
90
|
|
|
91
|
+
// If bypass enabled, execute immediately
|
|
92
|
+
if (hasBypass) {
|
|
93
|
+
try {
|
|
94
|
+
updateQueueStatus(entry.id, 'approved');
|
|
95
|
+
await executeQueueEntry({ ...entry, status: 'approved' });
|
|
96
|
+
const updatedEntry = getQueueEntry(entry.id);
|
|
97
|
+
|
|
98
|
+
emitCountUpdate();
|
|
99
|
+
|
|
100
|
+
return res.status(200).json({
|
|
101
|
+
id: entry.id,
|
|
102
|
+
status: updatedEntry.status,
|
|
103
|
+
message: 'Request executed immediately (bypass_auth enabled)',
|
|
104
|
+
bypassed: true,
|
|
105
|
+
results: updatedEntry.results
|
|
106
|
+
});
|
|
107
|
+
} catch (err) {
|
|
108
|
+
updateQueueStatus(entry.id, 'failed', { results: [{ error: err.message }] });
|
|
109
|
+
emitCountUpdate();
|
|
110
|
+
|
|
111
|
+
return res.status(500).json({
|
|
112
|
+
id: entry.id,
|
|
113
|
+
status: 'failed',
|
|
114
|
+
message: 'Bypass execution failed',
|
|
115
|
+
bypassed: true,
|
|
116
|
+
error: err.message
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Emit real-time update
|
|
122
|
+
emitCountUpdate();
|
|
123
|
+
|
|
72
124
|
res.status(201).json({
|
|
73
125
|
id: entry.id,
|
|
74
126
|
status: entry.status,
|
|
@@ -88,10 +140,14 @@ router.post('/:service/:accountName/submit', (req, res) => {
|
|
|
88
140
|
router.get('/list', (req, res) => {
|
|
89
141
|
try {
|
|
90
142
|
const submittedBy = req.apiKeyInfo?.name || 'unknown';
|
|
91
|
-
const
|
|
143
|
+
const sharedVisibility = getSharedQueueVisibility();
|
|
144
|
+
const entries = sharedVisibility
|
|
145
|
+
? listAllQueueEntries()
|
|
146
|
+
: listQueueEntriesBySubmitter(submittedBy);
|
|
92
147
|
|
|
93
148
|
res.json({
|
|
94
149
|
count: entries.length,
|
|
150
|
+
shared_visibility: sharedVisibility,
|
|
95
151
|
entries: entries
|
|
96
152
|
});
|
|
97
153
|
} catch (error) {
|
|
@@ -115,10 +171,14 @@ router.get('/:service/:accountName/list', (req, res) => {
|
|
|
115
171
|
});
|
|
116
172
|
}
|
|
117
173
|
|
|
118
|
-
const
|
|
174
|
+
const sharedVisibility = getSharedQueueVisibility();
|
|
175
|
+
const entries = sharedVisibility
|
|
176
|
+
? listAllQueueEntries(service, accountName)
|
|
177
|
+
: listQueueEntriesBySubmitter(submittedBy, service, accountName);
|
|
119
178
|
|
|
120
179
|
res.json({
|
|
121
180
|
count: entries.length,
|
|
181
|
+
shared_visibility: sharedVisibility,
|
|
122
182
|
entries: entries
|
|
123
183
|
});
|
|
124
184
|
} catch (error) {
|
|
@@ -183,4 +243,178 @@ router.get('/:service/:accountName/status/:id', (req, res) => {
|
|
|
183
243
|
}
|
|
184
244
|
});
|
|
185
245
|
|
|
246
|
+
|
|
247
|
+
// Withdraw a pending queue item (agent can only withdraw their own submissions)
|
|
248
|
+
// DELETE /api/queue/:service/:accountName/status/:id
|
|
249
|
+
router.delete('/:service/:accountName/status/:id', (req, res) => {
|
|
250
|
+
try {
|
|
251
|
+
// Check if withdraw is enabled
|
|
252
|
+
if (!getAgentWithdrawEnabled()) {
|
|
253
|
+
return res.status(403).json({
|
|
254
|
+
error: 'Disabled',
|
|
255
|
+
message: 'Agent withdraw is not enabled. Ask admin to enable agent_withdraw_enabled setting.'
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const { id } = req.params;
|
|
260
|
+
const agentName = req.apiKeyInfo?.name || 'unknown'; // Set by auth middleware
|
|
261
|
+
|
|
262
|
+
const entry = getQueueEntry(id);
|
|
263
|
+
|
|
264
|
+
if (!entry) {
|
|
265
|
+
return res.status(404).json({
|
|
266
|
+
error: 'Not found',
|
|
267
|
+
message: 'Queue entry not found'
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Verify the requesting agent is the submitter
|
|
272
|
+
if (entry.submitted_by !== agentName) {
|
|
273
|
+
return res.status(403).json({
|
|
274
|
+
error: 'Forbidden',
|
|
275
|
+
message: 'You can only withdraw your own submissions'
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Only allow withdrawal of pending items
|
|
280
|
+
if (entry.status !== 'pending') {
|
|
281
|
+
return res.status(400).json({
|
|
282
|
+
error: 'Cannot withdraw',
|
|
283
|
+
message: `Cannot withdraw entry with status "${entry.status}". Only pending items can be withdrawn.`
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Get optional reason from request body
|
|
288
|
+
const { reason } = req.body || {};
|
|
289
|
+
|
|
290
|
+
// Update status to withdrawn (with optional reason)
|
|
291
|
+
updateQueueStatus(id, 'withdrawn', {
|
|
292
|
+
reviewed_at: new Date().toISOString().replace('T', ' ').replace('Z', ''),
|
|
293
|
+
rejection_reason: reason || null
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Emit real-time update
|
|
297
|
+
emitCountUpdate();
|
|
298
|
+
|
|
299
|
+
res.json({
|
|
300
|
+
success: true,
|
|
301
|
+
message: 'Queue entry withdrawn',
|
|
302
|
+
id: id,
|
|
303
|
+
reason: reason || null
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
} catch (error) {
|
|
307
|
+
res.status(500).json({
|
|
308
|
+
error: 'Failed to withdraw',
|
|
309
|
+
message: error.message
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Add a warning to a queue item (peer review)
|
|
315
|
+
// POST /api/queue/:service/:accountName/:id/warn
|
|
316
|
+
router.post('/:service/:accountName/:id/warn', async (req, res) => {
|
|
317
|
+
try {
|
|
318
|
+
const { id } = req.params;
|
|
319
|
+
const { message } = req.body;
|
|
320
|
+
const agentName = req.apiKeyInfo?.name;
|
|
321
|
+
|
|
322
|
+
if (!agentName) {
|
|
323
|
+
return res.status(401).json({
|
|
324
|
+
error: 'Unauthorized',
|
|
325
|
+
message: 'Agent authentication required'
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
|
330
|
+
return res.status(400).json({
|
|
331
|
+
error: 'Invalid request',
|
|
332
|
+
message: 'Warning message is required'
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const entry = getQueueEntry(id);
|
|
337
|
+
|
|
338
|
+
if (!entry) {
|
|
339
|
+
return res.status(404).json({
|
|
340
|
+
error: 'Not found',
|
|
341
|
+
message: 'Queue entry not found'
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Only allow warnings on pending items
|
|
346
|
+
if (entry.status !== 'pending') {
|
|
347
|
+
return res.status(400).json({
|
|
348
|
+
error: 'Cannot warn',
|
|
349
|
+
message: `Cannot add warning to entry with status "${entry.status}". Only pending items can be warned.`
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Cannot warn your own items (should withdraw instead)
|
|
354
|
+
if (entry.submitted_by === agentName) {
|
|
355
|
+
return res.status(403).json({
|
|
356
|
+
error: 'Forbidden',
|
|
357
|
+
message: 'Cannot warn your own submission. Use withdraw instead.'
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Add the warning
|
|
362
|
+
const warningId = addQueueWarning(id, agentName, message.trim());
|
|
363
|
+
|
|
364
|
+
// Notify the submitting agent
|
|
365
|
+
if (entry.submitted_by) {
|
|
366
|
+
notifyAgentQueueWarning(entry, agentName, message.trim()).catch(err => {
|
|
367
|
+
console.error('Failed to notify agent of warning:', err.message);
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Emit socket event for real-time UI update
|
|
372
|
+
const warnings = getQueueWarnings(id);
|
|
373
|
+
emitEvent('queueItemUpdate', {
|
|
374
|
+
id,
|
|
375
|
+
type: 'warning_added',
|
|
376
|
+
warningCount: warnings.length,
|
|
377
|
+
warnings
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
res.json({
|
|
381
|
+
success: true,
|
|
382
|
+
message: 'Warning added',
|
|
383
|
+
warning_id: warningId,
|
|
384
|
+
queue_id: id
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
} catch (error) {
|
|
388
|
+
res.status(500).json({
|
|
389
|
+
error: 'Failed to add warning',
|
|
390
|
+
message: error.message
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Get warnings for a queue item
|
|
396
|
+
// GET /api/queue/:service/:accountName/:id/warnings
|
|
397
|
+
router.get('/:service/:accountName/:id/warnings', (req, res) => {
|
|
398
|
+
try {
|
|
399
|
+
const { id } = req.params;
|
|
400
|
+
|
|
401
|
+
const entry = getQueueEntry(id);
|
|
402
|
+
if (!entry) {
|
|
403
|
+
return res.status(404).json({
|
|
404
|
+
error: 'Not found',
|
|
405
|
+
message: 'Queue entry not found'
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const warnings = getQueueWarnings(id);
|
|
410
|
+
res.json({ warnings });
|
|
411
|
+
|
|
412
|
+
} catch (error) {
|
|
413
|
+
res.status(500).json({
|
|
414
|
+
error: 'Failed to get warnings',
|
|
415
|
+
message: error.message
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
186
420
|
export default router;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import {
|
|
3
|
+
listServicesWithAccess,
|
|
4
|
+
checkServiceAccess,
|
|
5
|
+
checkBypassAuth
|
|
6
|
+
} from '../lib/db.js';
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
|
|
10
|
+
// GET /api/services - List services with access info (filtered by agent access)
|
|
11
|
+
// Includes bypass_auth status for the calling agent
|
|
12
|
+
router.get('/', (req, res) => {
|
|
13
|
+
const agentName = req.apiKeyInfo?.name;
|
|
14
|
+
const allServices = listServicesWithAccess();
|
|
15
|
+
|
|
16
|
+
// Filter to only show services the agent has access to
|
|
17
|
+
// And include bypass_auth status for this agent
|
|
18
|
+
const accessibleServices = allServices
|
|
19
|
+
.filter(svc => {
|
|
20
|
+
const access = checkServiceAccess(svc.service, svc.account_name, agentName);
|
|
21
|
+
return access.allowed;
|
|
22
|
+
})
|
|
23
|
+
.map(svc => ({
|
|
24
|
+
...svc,
|
|
25
|
+
bypass_auth: agentName ? checkBypassAuth(svc.service, svc.account_name, agentName) : false
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
res.json({ services: accessibleServices });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// GET /api/services/:service/:account/access - Get YOUR access info for a service/account
|
|
32
|
+
// SECURITY: Only returns the calling agent's own access info, NOT other agents
|
|
33
|
+
router.get('/:service/:account/access', (req, res) => {
|
|
34
|
+
const { service, account } = req.params;
|
|
35
|
+
const agentName = req.apiKeyInfo?.name;
|
|
36
|
+
|
|
37
|
+
// Check if agent has access to this service
|
|
38
|
+
const accessCheck = checkServiceAccess(service, account, agentName);
|
|
39
|
+
if (!accessCheck.allowed) {
|
|
40
|
+
return res.status(403).json({
|
|
41
|
+
error: `You do not have access to ${service}/${account}`,
|
|
42
|
+
reason: accessCheck.reason
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// SECURITY FIX: Only return the calling agent's own access info
|
|
47
|
+
// Do NOT return the full agent list (that would leak other agents' info)
|
|
48
|
+
const agentBypass = agentName ? checkBypassAuth(service, account, agentName) : false;
|
|
49
|
+
|
|
50
|
+
res.json({
|
|
51
|
+
service,
|
|
52
|
+
account_name: account,
|
|
53
|
+
your_access: {
|
|
54
|
+
allowed: true,
|
|
55
|
+
bypass_auth: agentBypass
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// NOTE: Configuration endpoints (PUT access mode, POST agents, PUT bypass)
|
|
61
|
+
// have been REMOVED from the API for security.
|
|
62
|
+
// All access configuration must be done through the Admin UI at /ui/access
|
|
63
|
+
// which requires admin authentication.
|
|
64
|
+
|
|
65
|
+
// GET /api/services/:service/:account/access/agents/:agentName/bypass - Check bypass_auth (read-only)
|
|
66
|
+
// Agents can check their own bypass status
|
|
67
|
+
router.get('/:service/:account/access/agents/:agentName/bypass', (req, res) => {
|
|
68
|
+
const { service, account, agentName } = req.params;
|
|
69
|
+
const callingAgent = req.apiKeyInfo?.name;
|
|
70
|
+
|
|
71
|
+
// Agents can only check their own bypass status
|
|
72
|
+
if (callingAgent && callingAgent.toLowerCase() !== agentName.toLowerCase()) {
|
|
73
|
+
return res.status(403).json({
|
|
74
|
+
error: 'You can only check your own bypass status'
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const hasBypass = checkBypassAuth(service, account, agentName);
|
|
79
|
+
res.json({
|
|
80
|
+
service,
|
|
81
|
+
account,
|
|
82
|
+
agent: agentName,
|
|
83
|
+
bypass_auth: hasBypass
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export default router;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Service Access Control routes
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import {
|
|
4
|
+
listServicesWithAccess,
|
|
5
|
+
listApiKeys,
|
|
6
|
+
getServiceAccess,
|
|
7
|
+
setServiceAccessMode,
|
|
8
|
+
setServiceAgentAccess,
|
|
9
|
+
setBypassAuth,
|
|
10
|
+
checkBypassAuth
|
|
11
|
+
} from '../../lib/db.js';
|
|
12
|
+
import { escapeHtml, simpleNavHeader, socketScript, localizeScript, renderAvatar } from './shared.js';
|
|
13
|
+
|
|
14
|
+
const router = Router();
|
|
15
|
+
|
|
16
|
+
// Access Control page
|
|
17
|
+
router.get('/', (req, res) => {
|
|
18
|
+
const services = listServicesWithAccess();
|
|
19
|
+
const agents = listApiKeys();
|
|
20
|
+
res.send(renderAccessPage(services, agents));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Update access mode for a service
|
|
24
|
+
router.post('/:service/:account/mode', (req, res) => {
|
|
25
|
+
const { service, account } = req.params;
|
|
26
|
+
const { mode } = req.body;
|
|
27
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
setServiceAccessMode(service, account, mode);
|
|
31
|
+
if (wantsJson) {
|
|
32
|
+
return res.json({ success: true, mode });
|
|
33
|
+
}
|
|
34
|
+
res.redirect('/ui/access');
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (wantsJson) {
|
|
37
|
+
return res.status(400).json({ error: err.message });
|
|
38
|
+
}
|
|
39
|
+
res.status(400).send(err.message);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Toggle agent access for a service
|
|
44
|
+
router.post('/:service/:account/agent/:agentName', (req, res) => {
|
|
45
|
+
const { service, account, agentName } = req.params;
|
|
46
|
+
const { allowed, bypass_auth } = req.body;
|
|
47
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
48
|
+
|
|
49
|
+
// Update access
|
|
50
|
+
setServiceAgentAccess(service, account, agentName, allowed !== 'false', bypass_auth === 'true');
|
|
51
|
+
|
|
52
|
+
if (wantsJson) {
|
|
53
|
+
return res.json({ success: true });
|
|
54
|
+
}
|
|
55
|
+
res.redirect('/ui/access');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Toggle bypass_auth for an agent
|
|
59
|
+
router.post('/:service/:account/agent/:agentName/bypass', (req, res) => {
|
|
60
|
+
const { service, account, agentName } = req.params;
|
|
61
|
+
const { enabled } = req.body;
|
|
62
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
63
|
+
|
|
64
|
+
setBypassAuth(service, account, agentName, enabled === 'true' || enabled === true);
|
|
65
|
+
|
|
66
|
+
if (wantsJson) {
|
|
67
|
+
const hasBypass = checkBypassAuth(service, account, agentName);
|
|
68
|
+
return res.json({ success: true, bypass_auth: hasBypass });
|
|
69
|
+
}
|
|
70
|
+
res.redirect('/ui/access');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function renderAccessPage(services, agents) {
|
|
74
|
+
const renderServiceCard = (svc) => {
|
|
75
|
+
const access = getServiceAccess(svc.service, svc.account_name);
|
|
76
|
+
const agentRows = agents.map(agent => {
|
|
77
|
+
const agentAccess = access.agents.find(a => a.name.toLowerCase() === agent.name.toLowerCase());
|
|
78
|
+
const isAllowed = agentAccess ? agentAccess.allowed : (access.access_mode === 'all');
|
|
79
|
+
const hasBypass = agentAccess?.bypass_auth || false;
|
|
80
|
+
|
|
81
|
+
return `
|
|
82
|
+
<tr class="agent-row" data-service="${escapeHtml(svc.service)}" data-account="${escapeHtml(svc.account_name)}" data-agent="${escapeHtml(agent.name)}">
|
|
83
|
+
<td>
|
|
84
|
+
<div class="agent-with-avatar">
|
|
85
|
+
${renderAvatar(agent.name, { size: 28 })}
|
|
86
|
+
<span>${escapeHtml(agent.name)}</span>
|
|
87
|
+
</div>
|
|
88
|
+
</td>
|
|
89
|
+
<td>
|
|
90
|
+
<label class="toggle">
|
|
91
|
+
<input type="checkbox" class="access-toggle" ${isAllowed ? 'checked' : ''}>
|
|
92
|
+
<span class="toggle-slider"></span>
|
|
93
|
+
</label>
|
|
94
|
+
</td>
|
|
95
|
+
<td>
|
|
96
|
+
<label class="toggle ${!isAllowed ? 'disabled' : ''}">
|
|
97
|
+
<input type="checkbox" class="bypass-toggle" ${hasBypass ? 'checked' : ''} ${!isAllowed ? 'disabled' : ''}>
|
|
98
|
+
<span class="toggle-slider bypass"></span>
|
|
99
|
+
</label>
|
|
100
|
+
${hasBypass ? '<span class="bypass-badge">⚡ Bypass</span>' : ''}
|
|
101
|
+
</td>
|
|
102
|
+
</tr>
|
|
103
|
+
`;
|
|
104
|
+
}).join('');
|
|
105
|
+
|
|
106
|
+
return `
|
|
107
|
+
<div class="card service-card" data-service="${escapeHtml(svc.service)}" data-account="${escapeHtml(svc.account_name)}">
|
|
108
|
+
<div class="service-header">
|
|
109
|
+
<h3>${escapeHtml(svc.service)} / ${escapeHtml(svc.account_name)}</h3>
|
|
110
|
+
<select class="mode-select" data-service="${escapeHtml(svc.service)}" data-account="${escapeHtml(svc.account_name)}">
|
|
111
|
+
<option value="all" ${access.access_mode === 'all' ? 'selected' : ''}>All agents</option>
|
|
112
|
+
<option value="allowlist" ${access.access_mode === 'allowlist' ? 'selected' : ''}>Allowlist only</option>
|
|
113
|
+
<option value="none" ${access.access_mode === 'none' ? 'selected' : ''}>No agents</option>
|
|
114
|
+
</select>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<table class="access-table">
|
|
118
|
+
<thead>
|
|
119
|
+
<tr>
|
|
120
|
+
<th>Agent</th>
|
|
121
|
+
<th>Access</th>
|
|
122
|
+
<th>Bypass Queue</th>
|
|
123
|
+
</tr>
|
|
124
|
+
</thead>
|
|
125
|
+
<tbody>
|
|
126
|
+
${agentRows}
|
|
127
|
+
</tbody>
|
|
128
|
+
</table>
|
|
129
|
+
</div>
|
|
130
|
+
`;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return `<!DOCTYPE html>
|
|
134
|
+
<html>
|
|
135
|
+
<head>
|
|
136
|
+
<title>agentgate - Access Control</title>
|
|
137
|
+
<link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
|
|
138
|
+
<link rel="stylesheet" href="/public/style.css">
|
|
139
|
+
<script src="/socket.io/socket.io.js"></script>
|
|
140
|
+
<style>
|
|
141
|
+
.service-card { margin-bottom: 24px; }
|
|
142
|
+
.service-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
|
143
|
+
.service-header h3 { margin: 0; }
|
|
144
|
+
.mode-select { padding: 8px 12px; border-radius: 6px; background: #1f2937; border: 1px solid #374151; color: #f3f4f6; cursor: pointer; }
|
|
145
|
+
.mode-select:focus { border-color: #6366f1; outline: none; }
|
|
146
|
+
|
|
147
|
+
.access-table { width: 100%; border-collapse: collapse; }
|
|
148
|
+
.access-table th, .access-table td { padding: 12px; text-align: left; border-bottom: 1px solid #374151; }
|
|
149
|
+
.access-table th { font-weight: 600; color: #9ca3af; font-size: 14px; }
|
|
150
|
+
|
|
151
|
+
.agent-with-avatar { display: flex; align-items: center; gap: 10px; }
|
|
152
|
+
|
|
153
|
+
/* Toggle switch */
|
|
154
|
+
.toggle { position: relative; display: inline-block; width: 44px; height: 24px; }
|
|
155
|
+
.toggle.disabled { opacity: 0.5; pointer-events: none; }
|
|
156
|
+
.toggle input { opacity: 0; width: 0; height: 0; }
|
|
157
|
+
.toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #374151; transition: 0.3s; border-radius: 24px; }
|
|
158
|
+
.toggle-slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; }
|
|
159
|
+
.toggle input:checked + .toggle-slider { background-color: #10b981; }
|
|
160
|
+
.toggle input:checked + .toggle-slider.bypass { background-color: #f59e0b; }
|
|
161
|
+
.toggle input:checked + .toggle-slider:before { transform: translateX(20px); }
|
|
162
|
+
|
|
163
|
+
.bypass-badge { font-size: 11px; background: rgba(245, 158, 11, 0.2); color: #fbbf24; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
|
|
164
|
+
|
|
165
|
+
.no-services { text-align: center; padding: 40px; color: #9ca3af; }
|
|
166
|
+
|
|
167
|
+
.info-box { background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 8px; padding: 16px; margin-bottom: 24px; }
|
|
168
|
+
.info-box h4 { margin: 0 0 8px 0; color: #60a5fa; }
|
|
169
|
+
.info-box p { margin: 0; color: #9ca3af; font-size: 14px; }
|
|
170
|
+
.info-box ul { margin: 8px 0 0 0; padding-left: 20px; color: #9ca3af; font-size: 14px; }
|
|
171
|
+
</style>
|
|
172
|
+
</head>
|
|
173
|
+
<body>
|
|
174
|
+
<div>
|
|
175
|
+
${simpleNavHeader()}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<div class="info-box">
|
|
179
|
+
<h4>🔐 Service Access Control</h4>
|
|
180
|
+
<p>Manage which agents can access which services, and enable queue bypass for trusted agents.</p>
|
|
181
|
+
<ul>
|
|
182
|
+
<li><strong>Access</strong> - Whether the agent can use this service</li>
|
|
183
|
+
<li><strong>Bypass Queue</strong> - Skip approval queue and execute immediately (⚡ use with caution!)</li>
|
|
184
|
+
</ul>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
${services.length === 0 ? `
|
|
188
|
+
<div class="card no-services">
|
|
189
|
+
<p>No services configured yet.</p>
|
|
190
|
+
<p>Connect a service (GitHub, Bluesky, etc.) from the <a href="/ui">home page</a> to manage access.</p>
|
|
191
|
+
</div>
|
|
192
|
+
` : services.map(renderServiceCard).join('')}
|
|
193
|
+
|
|
194
|
+
<script>
|
|
195
|
+
// Mode select change
|
|
196
|
+
document.querySelectorAll('.mode-select').forEach(select => {
|
|
197
|
+
select.addEventListener('change', async function() {
|
|
198
|
+
const service = this.dataset.service;
|
|
199
|
+
const account = this.dataset.account;
|
|
200
|
+
const mode = this.value;
|
|
201
|
+
const originalValue = this.dataset.originalMode || this.value;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const res = await fetch('/ui/access/' + encodeURIComponent(service) + '/' + encodeURIComponent(account) + '/mode', {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
207
|
+
body: JSON.stringify({ mode: mode })
|
|
208
|
+
});
|
|
209
|
+
if (!res.ok) throw new Error('Failed to update mode');
|
|
210
|
+
this.dataset.originalMode = mode;
|
|
211
|
+
} catch (err) {
|
|
212
|
+
console.error('Failed to update mode:', err);
|
|
213
|
+
this.value = originalValue; // Revert on error
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Access toggle
|
|
219
|
+
document.querySelectorAll('.access-toggle').forEach(toggle => {
|
|
220
|
+
toggle.addEventListener('change', async function() {
|
|
221
|
+
const row = this.closest('.agent-row');
|
|
222
|
+
const service = row.dataset.service;
|
|
223
|
+
const account = row.dataset.account;
|
|
224
|
+
const agent = row.dataset.agent;
|
|
225
|
+
const allowed = this.checked;
|
|
226
|
+
|
|
227
|
+
// Also get bypass status
|
|
228
|
+
const bypassToggle = row.querySelector('.bypass-toggle');
|
|
229
|
+
const bypass = bypassToggle ? bypassToggle.checked : false;
|
|
230
|
+
|
|
231
|
+
// Enable/disable bypass toggle based on access
|
|
232
|
+
if (bypassToggle) {
|
|
233
|
+
bypassToggle.disabled = !allowed;
|
|
234
|
+
bypassToggle.closest('.toggle').classList.toggle('disabled', !allowed);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
await fetch('/ui/access/' + encodeURIComponent(service) + '/' + encodeURIComponent(account) + '/agent/' + encodeURIComponent(agent), {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
241
|
+
body: JSON.stringify({ allowed: allowed.toString(), bypass_auth: bypass.toString() })
|
|
242
|
+
});
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.error('Failed to update access:', err);
|
|
245
|
+
this.checked = !allowed; // Revert on error
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Bypass toggle
|
|
251
|
+
document.querySelectorAll('.bypass-toggle').forEach(toggle => {
|
|
252
|
+
toggle.addEventListener('change', async function() {
|
|
253
|
+
const row = this.closest('.agent-row');
|
|
254
|
+
const service = row.dataset.service;
|
|
255
|
+
const account = row.dataset.account;
|
|
256
|
+
const agent = row.dataset.agent;
|
|
257
|
+
const enabled = this.checked;
|
|
258
|
+
|
|
259
|
+
// Update badge
|
|
260
|
+
const badge = row.querySelector('.bypass-badge');
|
|
261
|
+
if (enabled && !badge) {
|
|
262
|
+
const td = this.closest('td');
|
|
263
|
+
const span = document.createElement('span');
|
|
264
|
+
span.className = 'bypass-badge';
|
|
265
|
+
span.textContent = '⚡ Bypass';
|
|
266
|
+
td.appendChild(span);
|
|
267
|
+
} else if (!enabled && badge) {
|
|
268
|
+
badge.remove();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
await fetch('/ui/access/' + encodeURIComponent(service) + '/' + encodeURIComponent(account) + '/agent/' + encodeURIComponent(agent) + '/bypass', {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
275
|
+
body: JSON.stringify({ enabled: enabled })
|
|
276
|
+
});
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.error('Failed to update bypass:', err);
|
|
279
|
+
this.checked = !enabled; // Revert on error
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
</script>
|
|
284
|
+
${socketScript()}
|
|
285
|
+
${localizeScript()}
|
|
286
|
+
</body>
|
|
287
|
+
</html>`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export default router;
|