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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgate",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "API gateway for AI agents with human-in-the-loop write approval",
6
6
  "main": "src/index.js",
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, getSetting, getMessagingMode } from './lib/db.js';
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
- const config = getSetting('notifications');
218
- const enabled = config?.clawdbot?.enabled || false;
219
- return {
220
- enabled,
221
- description: enabled
222
- ? 'Webhook notifications are ENABLED. You will receive automatic notifications when queue items complete, fail, or are rejected. No need to poll.'
223
- : 'Webhook notifications are NOT configured. You must poll the status endpoint to check request status.',
224
- setup: 'Configure in Admin UI under Advanced → Clawdbot Notifications',
225
- webhookFormat: {
226
- description: 'POST to your webhook URL with JSON body',
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
- events: enabled ? (config.clawdbot.events || ['completed', 'failed']) : ['completed', 'failed', 'rejected'],
234
- compatible: 'OpenClaw/Clawdbot /hooks/wake endpoint'
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 };
@@ -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;
@@ -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 entries = listQueueEntriesBySubmitter(submittedBy);
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 entries = listQueueEntriesBySubmitter(submittedBy, service, accountName);
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;