agentgate 0.3.2 → 0.4.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/package.json +1 -1
- package/src/index.js +76 -23
- package/src/lib/db.js +43 -1
- package/src/lib/socketManager.js +73 -0
- package/src/routes/agents.js +73 -0
- package/src/routes/queue.js +78 -3
- package/src/routes/ui/auth.js +149 -0
- package/src/routes/ui/keys.js +302 -0
- package/src/routes/ui/messages.js +451 -0
- package/src/routes/ui/queue.js +420 -0
- package/src/routes/ui/settings.js +59 -0
- package/src/routes/ui/shared.js +139 -0
- package/src/routes/ui-new.js +196 -0
- package/src/routes/ui.js +260 -10
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -2,8 +2,9 @@ import express from 'express';
|
|
|
2
2
|
import cookieParser from 'cookie-parser';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { dirname, join } from 'path';
|
|
5
|
-
import { validateApiKey, getAccountsByService, getCookieSecret,
|
|
5
|
+
import { validateApiKey, getAccountsByService, getCookieSecret, getMessagingMode } from './lib/db.js';
|
|
6
6
|
import { connectHsync } from './lib/hsyncManager.js';
|
|
7
|
+
import { initSocket } from './lib/socketManager.js';
|
|
7
8
|
import githubRoutes, { serviceInfo as githubInfo } from './routes/github.js';
|
|
8
9
|
import blueskyRoutes, { serviceInfo as blueskyInfo } from './routes/bluesky.js';
|
|
9
10
|
import redditRoutes, { serviceInfo as redditInfo } from './routes/reddit.js';
|
|
@@ -186,7 +187,19 @@ app.get('/api/readme', apiKeyAuth, (req, res) => {
|
|
|
186
187
|
{ method: 'GET', path: '/api/queue/list', description: 'List all your submissions across all services' },
|
|
187
188
|
{ method: 'GET', path: '/api/queue/{service}/{accountName}/list', description: 'List your submissions for a specific service/account' }
|
|
188
189
|
],
|
|
189
|
-
response: '{ count: number, entries: [{ id, service, account_name, comment, status, rejection_reason?, submitted_at, reviewed_at?, completed_at? }, ...] }'
|
|
190
|
+
response: '{ count: number, shared_visibility: boolean, entries: [{ id, service, account_name, comment, status, submitted_by, rejection_reason?, submitted_at, reviewed_at?, completed_at? }, ...] }',
|
|
191
|
+
sharedVisibility: 'When shared_queue_visibility is enabled by admin, agents can see ALL queue items (not just their own). Response includes shared_visibility: true when active.'
|
|
192
|
+
},
|
|
193
|
+
withdraw: {
|
|
194
|
+
description: 'Withdraw your own pending submission (requires agent_withdraw_enabled setting)',
|
|
195
|
+
method: 'DELETE',
|
|
196
|
+
path: '/api/queue/{service}/{accountName}/status/{id}',
|
|
197
|
+
constraints: [
|
|
198
|
+
'Only the submitting agent can withdraw their own items',
|
|
199
|
+
'Only works for "pending" status - cannot withdraw approved/completed/etc',
|
|
200
|
+
'Requires admin to enable agent_withdraw_enabled setting'
|
|
201
|
+
],
|
|
202
|
+
response: '{ success: true, message: "Queue entry withdrawn", id }'
|
|
190
203
|
},
|
|
191
204
|
statuses: {
|
|
192
205
|
pending: 'Waiting for human approval',
|
|
@@ -194,7 +207,8 @@ app.get('/api/readme', apiKeyAuth, (req, res) => {
|
|
|
194
207
|
executing: 'Currently running the requests',
|
|
195
208
|
completed: 'All requests succeeded',
|
|
196
209
|
failed: 'One or more requests failed (check results)',
|
|
197
|
-
rejected: 'Human rejected the request (check rejection_reason)'
|
|
210
|
+
rejected: 'Human rejected the request (check rejection_reason)',
|
|
211
|
+
withdrawn: 'Agent withdrew their own pending request'
|
|
198
212
|
},
|
|
199
213
|
example: {
|
|
200
214
|
submit: {
|
|
@@ -213,27 +227,50 @@ app.get('/api/readme', apiKeyAuth, (req, res) => {
|
|
|
213
227
|
}
|
|
214
228
|
}
|
|
215
229
|
},
|
|
216
|
-
notifications:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
payload: {
|
|
228
|
-
text: 'Notification message with status, service, result URL, and original comment',
|
|
229
|
-
mode: 'now'
|
|
230
|
-
},
|
|
231
|
-
example: '✅ [agentgate] Queue #abc123 completed\\n→ github/monteslu\\n→ https://github.com/...\\nOriginal: "Create PR"'
|
|
230
|
+
notifications: {
|
|
231
|
+
description: 'Agents receive webhook notifications for queue status updates (completed/failed/rejected) and agent messages. Each agent configures their own webhook.',
|
|
232
|
+
setup: {
|
|
233
|
+
agentgateConfig: {
|
|
234
|
+
description: 'Configure YOUR webhook in agentgate Admin UI',
|
|
235
|
+
steps: [
|
|
236
|
+
'1. Go to Admin UI → API Keys',
|
|
237
|
+
'2. Click "Configure" next to your API key',
|
|
238
|
+
'3. Enter Webhook URL (e.g., https://your-gateway.com/hooks/wake)',
|
|
239
|
+
'4. Enter Authorization Token (bearer token for your gateway)'
|
|
240
|
+
]
|
|
232
241
|
},
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
242
|
+
gatewayConfig: {
|
|
243
|
+
description: 'Your gateway must ALSO have webhooks enabled to receive POSTs',
|
|
244
|
+
openclaw: 'Add to config: { "hooks": { "enabled": true, "token": "your-token" } }',
|
|
245
|
+
note: 'Without this, your gateway returns 405 and notifications fail silently'
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
webhookFormat: {
|
|
249
|
+
description: 'POST to your webhook URL with JSON body',
|
|
250
|
+
payload: {
|
|
251
|
+
text: 'Notification message with status, service, result URL, and original comment',
|
|
252
|
+
mode: 'now'
|
|
253
|
+
},
|
|
254
|
+
example: '✅ [agentgate] Queue #abc123 completed\\n→ github/monteslu\\n→ https://github.com/...\\nOriginal: "Create PR"'
|
|
255
|
+
},
|
|
256
|
+
events: ['completed', 'failed', 'rejected', 'agent_message', 'broadcast', 'message_rejected'],
|
|
257
|
+
troubleshooting: [
|
|
258
|
+
'Check webhook URL/token in API Keys → Configure',
|
|
259
|
+
'Ensure hooks.enabled=true in your gateway config',
|
|
260
|
+
'Test endpoint: curl -X POST <url> -H "Authorization: Bearer <token>" -d \'{"text":"test"}\''
|
|
261
|
+
],
|
|
262
|
+
compatible: 'OpenClaw/Clawdbot /hooks/wake endpoint',
|
|
263
|
+
bestPractice: {
|
|
264
|
+
description: 'Treat notifications as action triggers, not just acknowledgments',
|
|
265
|
+
examples: [
|
|
266
|
+
'Queue completed (PR created) → Request code review from teammate',
|
|
267
|
+
'PR merged → Update docs, notify stakeholders, start next task',
|
|
268
|
+
'Queue rejected → Read reason, fix issue, resubmit',
|
|
269
|
+
'Queue failed → Check error, debug, resubmit',
|
|
270
|
+
'Message received → Respond if needed and act on implied tasks'
|
|
271
|
+
]
|
|
272
|
+
}
|
|
273
|
+
},
|
|
237
274
|
skill: {
|
|
238
275
|
description: 'Generate a SKILL.md file for OpenClaw/AgentSkills compatible systems',
|
|
239
276
|
endpoint: 'GET /api/skill',
|
|
@@ -282,6 +319,18 @@ app.get('/api/readme', apiKeyAuth, (req, res) => {
|
|
|
282
319
|
path: '/api/agents/messageable',
|
|
283
320
|
description: 'Discover which agents you can message',
|
|
284
321
|
response: '{ mode, agents: [{ name }, ...] }'
|
|
322
|
+
},
|
|
323
|
+
broadcast: {
|
|
324
|
+
method: 'POST',
|
|
325
|
+
path: '/api/agents/broadcast',
|
|
326
|
+
description: 'Send a message to ALL agents with webhooks (excluding yourself)',
|
|
327
|
+
body: { message: 'Your broadcast message' },
|
|
328
|
+
response: '{ delivered: ["Agent1", "Agent2"], failed: [{ name: "Agent3", error: "HTTP 500" }], total: 3 }',
|
|
329
|
+
notes: [
|
|
330
|
+
'Broadcasts go directly to agent webhooks - not stored in messages table',
|
|
331
|
+
'Sender is automatically excluded from recipients',
|
|
332
|
+
'Requires messaging mode to be "supervised" or "open" (not "off")'
|
|
333
|
+
]
|
|
285
334
|
}
|
|
286
335
|
},
|
|
287
336
|
modes: {
|
|
@@ -435,6 +484,10 @@ const server = app.listen(PORT, async () => {
|
|
|
435
484
|
console.log(`agentgate gateway running at http://localhost:${PORT}`);
|
|
436
485
|
console.log(`Admin UI: http://localhost:${PORT}/ui`);
|
|
437
486
|
|
|
487
|
+
// Initialize socket.io for real-time updates
|
|
488
|
+
initSocket(server);
|
|
489
|
+
console.log('Socket.io initialized for real-time updates');
|
|
490
|
+
|
|
438
491
|
// Start hsync if configured
|
|
439
492
|
try {
|
|
440
493
|
await connectHsync(PORT);
|
package/src/lib/db.js
CHANGED
|
@@ -406,7 +406,7 @@ export function getPendingQueueCount() {
|
|
|
406
406
|
|
|
407
407
|
export function getQueueCounts() {
|
|
408
408
|
const rows = db.prepare('SELECT status, COUNT(*) as count FROM write_queue GROUP BY status').all();
|
|
409
|
-
const counts = { all: 0, pending: 0, completed: 0, failed: 0, rejected: 0 };
|
|
409
|
+
const counts = { all: 0, pending: 0, completed: 0, failed: 0, rejected: 0, withdrawn: 0 };
|
|
410
410
|
for (const row of rows) {
|
|
411
411
|
counts[row.status] = row.count;
|
|
412
412
|
counts.all += row.count;
|
|
@@ -560,3 +560,45 @@ export function getMessageCounts() {
|
|
|
560
560
|
}
|
|
561
561
|
|
|
562
562
|
export default db;
|
|
563
|
+
|
|
564
|
+
// Shared Queue Visibility helpers
|
|
565
|
+
export function getSharedQueueVisibility() {
|
|
566
|
+
return getSetting('shared_queue_visibility') === true;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function setSharedQueueVisibility(enabled) {
|
|
570
|
+
setSetting('shared_queue_visibility', enabled);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// List all queue entries (for shared visibility mode)
|
|
574
|
+
export function listAllQueueEntries(service = null, accountName = null) {
|
|
575
|
+
let sql = 'SELECT * FROM write_queue';
|
|
576
|
+
const params = [];
|
|
577
|
+
|
|
578
|
+
if (service && accountName) {
|
|
579
|
+
sql += ' WHERE service = ? AND account_name = ?';
|
|
580
|
+
params.push(service, accountName);
|
|
581
|
+
} else if (service) {
|
|
582
|
+
sql += ' WHERE service = ?';
|
|
583
|
+
params.push(service);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
sql += ' ORDER BY submitted_at DESC';
|
|
587
|
+
|
|
588
|
+
const rows = db.prepare(sql).all(...params);
|
|
589
|
+
return rows.map(row => ({
|
|
590
|
+
...row,
|
|
591
|
+
requests: JSON.parse(row.requests),
|
|
592
|
+
results: row.results ? JSON.parse(row.results) : null,
|
|
593
|
+
notified: Boolean(row.notified)
|
|
594
|
+
}));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Agent Withdraw helpers
|
|
598
|
+
export function getAgentWithdrawEnabled() {
|
|
599
|
+
return getSetting('agent_withdraw_enabled') === true;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function setAgentWithdrawEnabled(enabled) {
|
|
603
|
+
setSetting('agent_withdraw_enabled', enabled);
|
|
604
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Server } from 'socket.io';
|
|
2
|
+
import { getQueueCounts, getMessageCounts, getMessagingMode } from './db.js';
|
|
3
|
+
|
|
4
|
+
let io = null;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Initialize socket.io with the HTTP server
|
|
8
|
+
* @param {import('http').Server} server - The HTTP server instance
|
|
9
|
+
*/
|
|
10
|
+
export function initSocket(server) {
|
|
11
|
+
io = new Server(server, {
|
|
12
|
+
cors: {
|
|
13
|
+
origin: '*',
|
|
14
|
+
methods: ['GET', 'POST']
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
io.on('connection', (socket) => {
|
|
19
|
+
// Send current counts immediately on connect
|
|
20
|
+
socket.emit('counts', getCurrentCounts());
|
|
21
|
+
|
|
22
|
+
socket.on('disconnect', () => {
|
|
23
|
+
// Client disconnected
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return io;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get current counts for queue and messages
|
|
32
|
+
*/
|
|
33
|
+
function getCurrentCounts() {
|
|
34
|
+
const queueCounts = getQueueCounts();
|
|
35
|
+
const messageCounts = getMessageCounts();
|
|
36
|
+
const messagingMode = getMessagingMode();
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
queue: {
|
|
40
|
+
pending: queueCounts.pending,
|
|
41
|
+
total: queueCounts.all
|
|
42
|
+
},
|
|
43
|
+
messages: {
|
|
44
|
+
pending: messageCounts.pending,
|
|
45
|
+
unread: messageCounts.delivered,
|
|
46
|
+
total: messageCounts.all
|
|
47
|
+
},
|
|
48
|
+
messagingEnabled: messagingMode !== 'off'
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Emit count update to all connected clients
|
|
54
|
+
* Call this whenever queue or message counts change
|
|
55
|
+
*/
|
|
56
|
+
export function emitCountUpdate() {
|
|
57
|
+
if (io) {
|
|
58
|
+
io.emit('counts', getCurrentCounts());
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Emit a specific event to all connected clients
|
|
64
|
+
* @param {string} event - Event name
|
|
65
|
+
* @param {any} data - Event data
|
|
66
|
+
*/
|
|
67
|
+
export function emitEvent(event, data) {
|
|
68
|
+
if (io) {
|
|
69
|
+
io.emit(event, data);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { io };
|
package/src/routes/agents.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getApiKeyByName
|
|
10
10
|
} from '../lib/db.js';
|
|
11
11
|
import { notifyAgentMessage } from '../lib/agentNotifier.js';
|
|
12
|
+
import { emitCountUpdate } from '../lib/socketManager.js';
|
|
12
13
|
|
|
13
14
|
const router = Router();
|
|
14
15
|
|
|
@@ -63,6 +64,9 @@ router.post('/message', async (req, res) => {
|
|
|
63
64
|
// Use canonical recipient name from database
|
|
64
65
|
const result = createAgentMessage(fromAgent, recipientName, message);
|
|
65
66
|
|
|
67
|
+
// Emit real-time update
|
|
68
|
+
emitCountUpdate();
|
|
69
|
+
|
|
66
70
|
if (mode === 'supervised') {
|
|
67
71
|
return res.json({
|
|
68
72
|
id: result.id,
|
|
@@ -182,4 +186,73 @@ router.get('/messageable', async (req, res) => {
|
|
|
182
186
|
});
|
|
183
187
|
});
|
|
184
188
|
|
|
189
|
+
|
|
190
|
+
// POST /api/agents/broadcast - Broadcast a message to all agents
|
|
191
|
+
router.post('/broadcast', async (req, res) => {
|
|
192
|
+
const { message } = req.body;
|
|
193
|
+
const fromAgent = req.apiKeyName || 'system';
|
|
194
|
+
|
|
195
|
+
if (!message) {
|
|
196
|
+
return res.status(400).json({ error: 'Missing "message" field' });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (message.length > MAX_MESSAGE_LENGTH) {
|
|
200
|
+
return res.status(400).json({
|
|
201
|
+
error: `Message too long. Maximum ${MAX_MESSAGE_LENGTH} bytes allowed.`
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const mode = getMessagingMode();
|
|
206
|
+
if (mode === 'off') {
|
|
207
|
+
return res.status(403).json({ error: 'Agent messaging is disabled' });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Get all agents with webhooks (excluding sender)
|
|
211
|
+
const apiKeys = listApiKeys();
|
|
212
|
+
const recipients = apiKeys.filter(k =>
|
|
213
|
+
k.webhook_url && k.name.toLowerCase() !== fromAgent.toLowerCase()
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (recipients.length === 0) {
|
|
217
|
+
return res.json({ delivered: [], failed: [], total: 0 });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const delivered = [];
|
|
221
|
+
const failed = [];
|
|
222
|
+
|
|
223
|
+
await Promise.all(recipients.map(async (agent) => {
|
|
224
|
+
const payload = {
|
|
225
|
+
type: 'broadcast',
|
|
226
|
+
from: fromAgent,
|
|
227
|
+
message: message,
|
|
228
|
+
timestamp: new Date().toISOString(),
|
|
229
|
+
text: `📢 [agentgate] Broadcast from ${fromAgent}:\n${message.substring(0, 500)}`,
|
|
230
|
+
mode: 'now'
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
235
|
+
if (agent.webhook_token) {
|
|
236
|
+
headers['Authorization'] = `Bearer ${agent.webhook_token}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const response = await fetch(agent.webhook_url, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers,
|
|
242
|
+
body: JSON.stringify(payload)
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (response.ok) {
|
|
246
|
+
delivered.push(agent.name);
|
|
247
|
+
} else {
|
|
248
|
+
failed.push({ name: agent.name, error: `HTTP ${response.status}` });
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
251
|
+
failed.push({ name: agent.name, error: err.message });
|
|
252
|
+
}
|
|
253
|
+
}));
|
|
254
|
+
|
|
255
|
+
return res.json({ delivered, failed, total: recipients.length });
|
|
256
|
+
});
|
|
257
|
+
|
|
185
258
|
export default router;
|
package/src/routes/queue.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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 } from '../lib/db.js';
|
|
3
|
+
import { emitCountUpdate } from '../lib/socketManager.js';
|
|
3
4
|
|
|
4
5
|
const router = Router();
|
|
5
6
|
|
|
@@ -69,6 +70,9 @@ router.post('/:service/:accountName/submit', (req, res) => {
|
|
|
69
70
|
// Create the queue entry
|
|
70
71
|
const entry = createQueueEntry(service, accountName, requests, comment, submittedBy);
|
|
71
72
|
|
|
73
|
+
// Emit real-time update
|
|
74
|
+
emitCountUpdate();
|
|
75
|
+
|
|
72
76
|
res.status(201).json({
|
|
73
77
|
id: entry.id,
|
|
74
78
|
status: entry.status,
|
|
@@ -88,10 +92,14 @@ router.post('/:service/:accountName/submit', (req, res) => {
|
|
|
88
92
|
router.get('/list', (req, res) => {
|
|
89
93
|
try {
|
|
90
94
|
const submittedBy = req.apiKeyInfo?.name || 'unknown';
|
|
91
|
-
const
|
|
95
|
+
const sharedVisibility = getSharedQueueVisibility();
|
|
96
|
+
const entries = sharedVisibility
|
|
97
|
+
? listAllQueueEntries()
|
|
98
|
+
: listQueueEntriesBySubmitter(submittedBy);
|
|
92
99
|
|
|
93
100
|
res.json({
|
|
94
101
|
count: entries.length,
|
|
102
|
+
shared_visibility: sharedVisibility,
|
|
95
103
|
entries: entries
|
|
96
104
|
});
|
|
97
105
|
} catch (error) {
|
|
@@ -115,10 +123,14 @@ router.get('/:service/:accountName/list', (req, res) => {
|
|
|
115
123
|
});
|
|
116
124
|
}
|
|
117
125
|
|
|
118
|
-
const
|
|
126
|
+
const sharedVisibility = getSharedQueueVisibility();
|
|
127
|
+
const entries = sharedVisibility
|
|
128
|
+
? listAllQueueEntries(service, accountName)
|
|
129
|
+
: listQueueEntriesBySubmitter(submittedBy, service, accountName);
|
|
119
130
|
|
|
120
131
|
res.json({
|
|
121
132
|
count: entries.length,
|
|
133
|
+
shared_visibility: sharedVisibility,
|
|
122
134
|
entries: entries
|
|
123
135
|
});
|
|
124
136
|
} catch (error) {
|
|
@@ -183,4 +195,67 @@ router.get('/:service/:accountName/status/:id', (req, res) => {
|
|
|
183
195
|
}
|
|
184
196
|
});
|
|
185
197
|
|
|
198
|
+
|
|
199
|
+
// Withdraw a pending queue item (agent can only withdraw their own submissions)
|
|
200
|
+
// DELETE /api/queue/:service/:accountName/status/:id
|
|
201
|
+
router.delete('/:service/:accountName/status/:id', (req, res) => {
|
|
202
|
+
try {
|
|
203
|
+
// Check if withdraw is enabled
|
|
204
|
+
if (!getAgentWithdrawEnabled()) {
|
|
205
|
+
return res.status(403).json({
|
|
206
|
+
error: 'Disabled',
|
|
207
|
+
message: 'Agent withdraw is not enabled. Ask admin to enable agent_withdraw_enabled setting.'
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const { id } = req.params;
|
|
212
|
+
const agentName = req.apiKeyInfo?.name || 'unknown'; // Set by auth middleware
|
|
213
|
+
|
|
214
|
+
const entry = getQueueEntry(id);
|
|
215
|
+
|
|
216
|
+
if (!entry) {
|
|
217
|
+
return res.status(404).json({
|
|
218
|
+
error: 'Not found',
|
|
219
|
+
message: 'Queue entry not found'
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Verify the requesting agent is the submitter
|
|
224
|
+
if (entry.submitted_by !== agentName) {
|
|
225
|
+
return res.status(403).json({
|
|
226
|
+
error: 'Forbidden',
|
|
227
|
+
message: 'You can only withdraw your own submissions'
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Only allow withdrawal of pending items
|
|
232
|
+
if (entry.status !== 'pending') {
|
|
233
|
+
return res.status(400).json({
|
|
234
|
+
error: 'Cannot withdraw',
|
|
235
|
+
message: `Cannot withdraw entry with status "${entry.status}". Only pending items can be withdrawn.`
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Update status to withdrawn
|
|
240
|
+
updateQueueStatus(id, 'withdrawn', {
|
|
241
|
+
reviewed_at: new Date().toISOString().replace('T', ' ').replace('Z', '')
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Emit real-time update
|
|
245
|
+
emitCountUpdate();
|
|
246
|
+
|
|
247
|
+
res.json({
|
|
248
|
+
success: true,
|
|
249
|
+
message: 'Queue entry withdrawn',
|
|
250
|
+
id: id
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
} catch (error) {
|
|
254
|
+
res.status(500).json({
|
|
255
|
+
error: 'Failed to withdraw',
|
|
256
|
+
message: error.message
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
186
261
|
export default router;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Auth routes - login, logout, password setup
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { setAdminPassword, verifyAdminPassword, hasAdminPassword } from '../../lib/db.js';
|
|
4
|
+
import { AUTH_COOKIE, COOKIE_MAX_AGE, htmlHead } from './shared.js';
|
|
5
|
+
|
|
6
|
+
const router = Router();
|
|
7
|
+
|
|
8
|
+
// Check if user is authenticated
|
|
9
|
+
export function isAuthenticated(req) {
|
|
10
|
+
return req.signedCookies[AUTH_COOKIE] === 'authenticated';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Auth middleware for protected routes
|
|
14
|
+
export function requireAuth(req, res, next) {
|
|
15
|
+
if (req.path === '/login' || req.path === '/setup-password') {
|
|
16
|
+
return next();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!hasAdminPassword()) {
|
|
20
|
+
return res.redirect('/ui/setup-password');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!isAuthenticated(req)) {
|
|
24
|
+
return res.redirect('/ui/login');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
next();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Login page
|
|
31
|
+
router.get('/login', (req, res) => {
|
|
32
|
+
if (!hasAdminPassword()) {
|
|
33
|
+
return res.redirect('/ui/setup-password');
|
|
34
|
+
}
|
|
35
|
+
if (isAuthenticated(req)) {
|
|
36
|
+
return res.redirect('/ui');
|
|
37
|
+
}
|
|
38
|
+
res.send(renderLoginPage());
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Handle login
|
|
42
|
+
router.post('/login', async (req, res) => {
|
|
43
|
+
const { password } = req.body;
|
|
44
|
+
if (!password) {
|
|
45
|
+
return res.send(renderLoginPage('Password required'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const valid = await verifyAdminPassword(password);
|
|
49
|
+
if (!valid) {
|
|
50
|
+
return res.send(renderLoginPage('Invalid password'));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
res.cookie(AUTH_COOKIE, 'authenticated', {
|
|
54
|
+
signed: true,
|
|
55
|
+
httpOnly: true,
|
|
56
|
+
maxAge: COOKIE_MAX_AGE,
|
|
57
|
+
sameSite: 'lax'
|
|
58
|
+
});
|
|
59
|
+
res.redirect('/ui');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Logout
|
|
63
|
+
router.post('/logout', (req, res) => {
|
|
64
|
+
res.clearCookie(AUTH_COOKIE);
|
|
65
|
+
res.redirect('/ui/login');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Password setup page (first time only)
|
|
69
|
+
router.get('/setup-password', (req, res) => {
|
|
70
|
+
if (hasAdminPassword()) {
|
|
71
|
+
return res.redirect('/ui/login');
|
|
72
|
+
}
|
|
73
|
+
res.send(renderSetupPasswordPage());
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Handle password setup
|
|
77
|
+
router.post('/setup-password', async (req, res) => {
|
|
78
|
+
if (hasAdminPassword()) {
|
|
79
|
+
return res.redirect('/ui/login');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { password, confirmPassword } = req.body;
|
|
83
|
+
if (!password || password.length < 4) {
|
|
84
|
+
return res.send(renderSetupPasswordPage('Password must be at least 4 characters'));
|
|
85
|
+
}
|
|
86
|
+
if (password !== confirmPassword) {
|
|
87
|
+
return res.send(renderSetupPasswordPage('Passwords do not match'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await setAdminPassword(password);
|
|
91
|
+
|
|
92
|
+
res.cookie(AUTH_COOKIE, 'authenticated', {
|
|
93
|
+
signed: true,
|
|
94
|
+
httpOnly: true,
|
|
95
|
+
maxAge: COOKIE_MAX_AGE,
|
|
96
|
+
sameSite: 'lax'
|
|
97
|
+
});
|
|
98
|
+
res.redirect('/ui');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Render functions
|
|
102
|
+
function renderLoginPage(error = '') {
|
|
103
|
+
return `${htmlHead('Login')}
|
|
104
|
+
<body>
|
|
105
|
+
<div style="max-width: 400px; margin: 100px auto;">
|
|
106
|
+
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
|
|
107
|
+
<img src="/public/favicon.svg" alt="agentgate" style="height: 48px;">
|
|
108
|
+
<h1 style="margin: 0;">agentgate</h1>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="card">
|
|
111
|
+
<h2 style="margin-top: 0;">Login</h2>
|
|
112
|
+
${error ? `<div class="error">${error}</div>` : ''}
|
|
113
|
+
<form method="POST" action="/ui/login">
|
|
114
|
+
<label>Password</label>
|
|
115
|
+
<input type="password" name="password" required autofocus>
|
|
116
|
+
<button type="submit" class="btn-primary" style="width: 100%;">Login</button>
|
|
117
|
+
</form>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</body>
|
|
121
|
+
</html>`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renderSetupPasswordPage(error = '') {
|
|
125
|
+
return `${htmlHead('Setup')}
|
|
126
|
+
<body>
|
|
127
|
+
<div style="max-width: 400px; margin: 100px auto;">
|
|
128
|
+
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
|
|
129
|
+
<img src="/public/favicon.svg" alt="agentgate" style="height: 48px;">
|
|
130
|
+
<h1 style="margin: 0;">agentgate</h1>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="card">
|
|
133
|
+
<h2 style="margin-top: 0;">Set Admin Password</h2>
|
|
134
|
+
<p class="help">This is your first time running agentgate. Please set an admin password.</p>
|
|
135
|
+
${error ? `<div class="error">${error}</div>` : ''}
|
|
136
|
+
<form method="POST" action="/ui/setup-password">
|
|
137
|
+
<label>Password</label>
|
|
138
|
+
<input type="password" name="password" required autofocus minlength="4">
|
|
139
|
+
<label>Confirm Password</label>
|
|
140
|
+
<input type="password" name="confirmPassword" required minlength="4">
|
|
141
|
+
<button type="submit" class="btn-primary" style="width: 100%;">Set Password</button>
|
|
142
|
+
</form>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</body>
|
|
146
|
+
</html>`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default router;
|