agentgate 0.1.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.
Files changed (40) hide show
  1. package/README.md +216 -0
  2. package/package.json +63 -0
  3. package/public/favicon.svg +48 -0
  4. package/public/icons/bluesky.svg +1 -0
  5. package/public/icons/fitbit.svg +16 -0
  6. package/public/icons/github.svg +1 -0
  7. package/public/icons/google-calendar.svg +1 -0
  8. package/public/icons/jira.svg +1 -0
  9. package/public/icons/linkedin.svg +1 -0
  10. package/public/icons/mastodon.svg +1 -0
  11. package/public/icons/reddit.svg +1 -0
  12. package/public/icons/youtube.svg +1 -0
  13. package/public/logo.svg +52 -0
  14. package/public/style.css +584 -0
  15. package/src/cli.js +77 -0
  16. package/src/index.js +344 -0
  17. package/src/lib/db.js +325 -0
  18. package/src/lib/hsyncManager.js +57 -0
  19. package/src/lib/queueExecutor.js +362 -0
  20. package/src/routes/bluesky.js +130 -0
  21. package/src/routes/calendar.js +120 -0
  22. package/src/routes/fitbit.js +127 -0
  23. package/src/routes/github.js +72 -0
  24. package/src/routes/jira.js +77 -0
  25. package/src/routes/linkedin.js +137 -0
  26. package/src/routes/mastodon.js +91 -0
  27. package/src/routes/queue.js +186 -0
  28. package/src/routes/reddit.js +138 -0
  29. package/src/routes/ui/bluesky.js +66 -0
  30. package/src/routes/ui/calendar.js +120 -0
  31. package/src/routes/ui/fitbit.js +122 -0
  32. package/src/routes/ui/github.js +60 -0
  33. package/src/routes/ui/index.js +35 -0
  34. package/src/routes/ui/jira.js +72 -0
  35. package/src/routes/ui/linkedin.js +120 -0
  36. package/src/routes/ui/mastodon.js +140 -0
  37. package/src/routes/ui/reddit.js +120 -0
  38. package/src/routes/ui/youtube.js +120 -0
  39. package/src/routes/ui.js +1077 -0
  40. package/src/routes/youtube.js +119 -0
package/src/index.js ADDED
@@ -0,0 +1,344 @@
1
+ import express from 'express';
2
+ import cookieParser from 'cookie-parser';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+ import { validateApiKey, getAccountsByService, getCookieSecret } from './lib/db.js';
6
+ import { connectHsync } from './lib/hsyncManager.js';
7
+ import githubRoutes, { serviceInfo as githubInfo } from './routes/github.js';
8
+ import blueskyRoutes, { serviceInfo as blueskyInfo } from './routes/bluesky.js';
9
+ import redditRoutes, { serviceInfo as redditInfo } from './routes/reddit.js';
10
+ import calendarRoutes, { serviceInfo as calendarInfo } from './routes/calendar.js';
11
+ import mastodonRoutes, { serviceInfo as mastodonInfo } from './routes/mastodon.js';
12
+ import linkedinRoutes, { serviceInfo as linkedinInfo } from './routes/linkedin.js';
13
+ import youtubeRoutes, { serviceInfo as youtubeInfo } from './routes/youtube.js';
14
+ import jiraRoutes, { serviceInfo as jiraInfo } from './routes/jira.js';
15
+ import fitbitRoutes, { serviceInfo as fitbitInfo } from './routes/fitbit.js';
16
+ import queueRoutes from './routes/queue.js';
17
+ import uiRoutes from './routes/ui.js';
18
+
19
+ // Aggregate service metadata from all routes
20
+ const SERVICE_REGISTRY = {
21
+ [githubInfo.key]: githubInfo,
22
+ [blueskyInfo.key]: blueskyInfo,
23
+ [mastodonInfo.key]: mastodonInfo,
24
+ [redditInfo.key]: redditInfo,
25
+ [calendarInfo.key]: calendarInfo,
26
+ [youtubeInfo.key]: youtubeInfo,
27
+ [linkedinInfo.key]: linkedinInfo,
28
+ [jiraInfo.key]: jiraInfo,
29
+ [fitbitInfo.key]: fitbitInfo
30
+ };
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ const app = express();
34
+ const PORT = process.env.PORT || 3050;
35
+ const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
36
+
37
+ app.use(express.json());
38
+ app.use(express.urlencoded({ extended: true }));
39
+ app.use(cookieParser(getCookieSecret()));
40
+ app.use('/public', express.static(join(__dirname, '../public')));
41
+
42
+ // API key auth middleware for /api routes
43
+ async function apiKeyAuth(req, res, next) {
44
+ const authHeader = req.headers.authorization;
45
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
46
+ return res.status(401).json({ error: 'Missing or invalid Authorization header' });
47
+ }
48
+
49
+ const key = authHeader.slice(7);
50
+ const valid = await validateApiKey(key);
51
+ if (!valid) {
52
+ return res.status(401).json({ error: 'Invalid API key' });
53
+ }
54
+
55
+ req.apiKeyInfo = valid;
56
+ next();
57
+ }
58
+
59
+ // Read-only enforcement - only allow GET requests to API
60
+ function readOnlyEnforce(req, res, next) {
61
+ if (req.method !== 'GET') {
62
+ return res.status(405).json({ error: 'Only GET requests allowed (read-only access)' });
63
+ }
64
+ next();
65
+ }
66
+
67
+ // API routes - require auth and read-only
68
+ // Pattern: /api/{service}/{accountName}/...
69
+ app.use('/api/github', apiKeyAuth, readOnlyEnforce, githubRoutes);
70
+ app.use('/api/bluesky', apiKeyAuth, readOnlyEnforce, blueskyRoutes);
71
+ app.use('/api/reddit', apiKeyAuth, readOnlyEnforce, redditRoutes);
72
+ app.use('/api/calendar', apiKeyAuth, readOnlyEnforce, calendarRoutes);
73
+ app.use('/api/mastodon', apiKeyAuth, readOnlyEnforce, mastodonRoutes);
74
+ app.use('/api/linkedin', apiKeyAuth, readOnlyEnforce, linkedinRoutes);
75
+ app.use('/api/youtube', apiKeyAuth, readOnlyEnforce, youtubeRoutes);
76
+ app.use('/api/jira', apiKeyAuth, readOnlyEnforce, jiraRoutes);
77
+ app.use('/api/fitbit', apiKeyAuth, readOnlyEnforce, fitbitRoutes);
78
+
79
+ // Queue routes - require auth but allow POST for submitting write requests
80
+ // Pattern: /api/queue/{service}/{accountName}/submit
81
+ app.use('/api/queue', apiKeyAuth, queueRoutes);
82
+
83
+ // UI routes - no API key needed (local admin access)
84
+ app.use('/ui', uiRoutes);
85
+
86
+ // Agent readme endpoint - requires auth
87
+ app.get('/api/readme', apiKeyAuth, (req, res) => {
88
+ const accountsByService = getAccountsByService();
89
+
90
+ // Build services object from registry
91
+ const services = {};
92
+ for (const [key, info] of Object.entries(SERVICE_REGISTRY)) {
93
+ const dbKey = info.dbKey || key;
94
+ services[key] = {
95
+ accounts: accountsByService[dbKey] || [],
96
+ authType: info.authType,
97
+ description: info.description
98
+ };
99
+ }
100
+
101
+ // Build endpoints object from registry
102
+ const endpoints = {};
103
+ for (const [key, info] of Object.entries(SERVICE_REGISTRY)) {
104
+ endpoints[key] = {
105
+ base: `/api/${key}/{accountName}`,
106
+ description: info.description,
107
+ docs: info.docs,
108
+ examples: info.examples
109
+ };
110
+ if (info.writeGuidelines) {
111
+ endpoints[key].writeGuidelines = info.writeGuidelines;
112
+ }
113
+ }
114
+
115
+ res.json({
116
+ name: 'agentgate',
117
+ description: 'API gateway for personal data with human-in-the-loop write approval. Read requests (GET) execute immediately. Write requests (POST/PUT/DELETE) are queued for human approval before execution.',
118
+ urlPattern: '/api/{service}/{accountName}/...',
119
+ services,
120
+ endpoints,
121
+ auth: {
122
+ type: 'bearer',
123
+ header: 'Authorization: Bearer {your_api_key}'
124
+ },
125
+ writeQueue: {
126
+ description: 'For write operations (POST/PUT/DELETE), you must submit requests to the write queue. A human will review and approve or reject your request. You cannot execute write operations directly.',
127
+ workflow: [
128
+ '1. Submit your write request(s) with a comment explaining your intent',
129
+ '2. Poll the status endpoint to check if approved/rejected',
130
+ '3. If rejected, check rejection_reason and adjust your approach',
131
+ '4. If approved and completed, results contain the API responses',
132
+ '5. If failed, results show which request failed and why'
133
+ ],
134
+ importantNotes: [
135
+ 'Always include a clear comment explaining WHY you want to make these changes',
136
+ 'Include markdown links to relevant resources (issues, PRs, docs) so the reviewer has context',
137
+ 'Batch requests execute in order and stop on first failure',
138
+ 'You cannot approve your own requests - a human must review them',
139
+ 'Be patient - approval requires human action'
140
+ ],
141
+ commentFormat: {
142
+ description: 'Comments support markdown. Include links to help the reviewer understand context.',
143
+ example: 'Closing issue [#42](https://github.com/owner/repo/issues/42) as completed. See related PR [#45](https://github.com/owner/repo/pull/45) for the fix.'
144
+ },
145
+ submit: {
146
+ method: 'POST',
147
+ path: '/api/queue/{service}/{accountName}/submit',
148
+ body: {
149
+ requests: '[{ method: "POST"|"PUT"|"PATCH"|"DELETE", path: "/api/path", body?: {}, headers?: {} }, ...]',
150
+ comment: 'Required: Explain what you are trying to do and why'
151
+ },
152
+ response: '{ id: "queue_entry_id", status: "pending" }'
153
+ },
154
+ checkStatus: {
155
+ method: 'GET',
156
+ path: '/api/queue/{service}/{accountName}/status/{id}',
157
+ responses: {
158
+ pending: '{ id, status: "pending", submitted_at }',
159
+ rejected: '{ id, status: "rejected", rejection_reason: "why it was rejected", reviewed_at }',
160
+ completed: '{ id, status: "completed", results: [{ ok: true, status: 200, body: {...} }, ...], completed_at }',
161
+ failed: '{ id, status: "failed", results: [{ ok: true, ... }, { ok: false, status: 404, body: {...} }], completed_at }'
162
+ }
163
+ },
164
+ listMyRequests: {
165
+ description: 'List all queue entries you have submitted. Returns summary info only (no full request bodies or results). Use checkStatus with a specific ID to get full details.',
166
+ methods: [
167
+ { method: 'GET', path: '/api/queue/list', description: 'List all your submissions across all services' },
168
+ { method: 'GET', path: '/api/queue/{service}/{accountName}/list', description: 'List your submissions for a specific service/account' }
169
+ ],
170
+ response: '{ count: number, entries: [{ id, service, account_name, comment, status, rejection_reason?, submitted_at, reviewed_at?, completed_at? }, ...] }'
171
+ },
172
+ statuses: {
173
+ pending: 'Waiting for human approval',
174
+ approved: 'Approved, about to execute',
175
+ executing: 'Currently running the requests',
176
+ completed: 'All requests succeeded',
177
+ failed: 'One or more requests failed (check results)',
178
+ rejected: 'Human rejected the request (check rejection_reason)'
179
+ },
180
+ example: {
181
+ submit: {
182
+ method: 'POST',
183
+ url: '/api/queue/github/personal/submit',
184
+ body: {
185
+ requests: [
186
+ { method: 'POST', path: '/repos/owner/repo/issues', body: { title: 'Bug report', body: 'Description here' } }
187
+ ],
188
+ comment: 'Creating an issue to track the bug we discussed in the conversation'
189
+ }
190
+ },
191
+ checkStatus: {
192
+ method: 'GET',
193
+ url: '/api/queue/github/personal/status/{id_from_submit_response}'
194
+ }
195
+ }
196
+ },
197
+ skill: {
198
+ description: 'Generate a SKILL.md file for OpenClaw/AgentSkills compatible systems',
199
+ endpoint: 'GET /api/skill',
200
+ docs: 'https://docs.openclaw.ai/tools/skills',
201
+ queryParams: {
202
+ base_url: 'Override the base URL in the generated skill (optional)'
203
+ }
204
+ }
205
+ });
206
+ });
207
+
208
+ // Generate SKILL.md for OpenClaw/AgentSkills compatible systems
209
+ // See: https://docs.openclaw.ai/tools/skills
210
+ app.get('/api/skill', apiKeyAuth, (req, res) => {
211
+ const baseUrl = req.query.base_url || BASE_URL;
212
+ const accountsByService = getAccountsByService();
213
+
214
+ // Build list of configured services dynamically
215
+ const configuredServices = [];
216
+ for (const [serviceKey, info] of Object.entries(SERVICE_REGISTRY)) {
217
+ const dbKey = info.dbKey || serviceKey;
218
+ const accounts = accountsByService[dbKey] || [];
219
+ if (accounts.length > 0) {
220
+ configuredServices.push(`- **${info.name}**: ${accounts.join(', ')}`);
221
+ }
222
+ }
223
+
224
+ // Build supported services list for description
225
+ const supportedServices = Object.values(SERVICE_REGISTRY).map(s => s.name).join(', ');
226
+
227
+ // Generate example read endpoints from configured services
228
+ const readExamples = [];
229
+ for (const [serviceKey, info] of Object.entries(SERVICE_REGISTRY)) {
230
+ const dbKey = info.dbKey || serviceKey;
231
+ const accounts = accountsByService[dbKey] || [];
232
+ if (accounts.length > 0 && info.examples && info.examples.length > 0) {
233
+ // Take first example, replace {accountName} with actual account
234
+ const example = info.examples[0].replace('{accountName}', accounts[0]);
235
+ readExamples.push(`- \`${example.replace('GET ', baseUrl)}\``);
236
+ if (readExamples.length >= 3) break;
237
+ }
238
+ }
239
+
240
+ // Collect any write guidelines
241
+ const writeGuidelines = [];
242
+ for (const [_serviceKey, info] of Object.entries(SERVICE_REGISTRY)) {
243
+ if (info.writeGuidelines) {
244
+ writeGuidelines.push(`### ${info.name}\n${info.writeGuidelines.map(g => `- ${g}`).join('\n')}`);
245
+ }
246
+ }
247
+
248
+ const skillMd = `---
249
+ name: agentgate
250
+ description: Access personal data through agentgate API gateway. Supports ${supportedServices}. Read requests execute immediately. Write requests are queued for human approval.
251
+ metadata: { "openclaw": { "emoji": "🚪", "requires": { "env": ["AGENTGATE_API_KEY"] } } }
252
+ ---
253
+
254
+ # agentgate
255
+
256
+ API gateway for accessing personal data with human-in-the-loop write approval.
257
+
258
+ ## Configuration
259
+
260
+ - **Base URL**: \`${baseUrl}\`
261
+ - **API Key**: Use the \`AGENTGATE_API_KEY\` environment variable
262
+
263
+ ## Configured Services
264
+
265
+ ${configuredServices.length > 0 ? configuredServices.join('\n') : '_No services configured yet_'}
266
+
267
+ ## Authentication
268
+
269
+ All requests require the API key in the Authorization header:
270
+
271
+ \`\`\`
272
+ Authorization: Bearer $AGENTGATE_API_KEY
273
+ \`\`\`
274
+
275
+ ## Read Requests (Immediate)
276
+
277
+ Make GET requests to \`${baseUrl}/api/{service}/{accountName}/...\`
278
+
279
+ ${readExamples.length > 0 ? 'Examples:\n' + readExamples.join('\n') : ''}
280
+
281
+ ## Write Requests (Queued for Approval)
282
+
283
+ Write operations (POST/PUT/DELETE) must go through the queue:
284
+
285
+ 1. **Submit request**:
286
+ \`\`\`
287
+ POST ${baseUrl}/api/queue/{service}/{accountName}/submit
288
+ {
289
+ "requests": [{ "method": "POST", "path": "/path", "body": {...} }],
290
+ "comment": "Explain what you're doing and why. Include [links](url) to relevant issues/PRs."
291
+ }
292
+ \`\`\`
293
+
294
+ 2. **Poll for status**:
295
+ \`\`\`
296
+ GET ${baseUrl}/api/queue/{service}/{accountName}/status/{id}
297
+ \`\`\`
298
+
299
+ 3. **Check response**: \`pending\`, \`completed\`, \`failed\`, or \`rejected\` (with reason)
300
+
301
+ ## Important Notes
302
+
303
+ - Always include a clear comment explaining your intent
304
+ - Include markdown links to relevant resources (issues, PRs, docs)
305
+ - Be patient - approval requires human action
306
+
307
+ ${writeGuidelines.length > 0 ? '## Service-Specific Guidelines\n\n' + writeGuidelines.join('\n\n') : ''}
308
+
309
+ ## Full API Documentation
310
+
311
+ For complete endpoint documentation, fetch:
312
+ \`\`\`
313
+ GET ${baseUrl}/api/readme
314
+ \`\`\`
315
+ `;
316
+
317
+ res.type('text/markdown').send(skillMd);
318
+ });
319
+
320
+ // Root redirect to UI
321
+ app.get('/', (req, res) => {
322
+ res.redirect('/ui');
323
+ });
324
+
325
+ const server = app.listen(PORT, async () => {
326
+ console.log(`agentgate gateway running at http://localhost:${PORT}`);
327
+ console.log(`Admin UI: http://localhost:${PORT}/ui`);
328
+
329
+ // Start hsync if configured
330
+ try {
331
+ await connectHsync(PORT);
332
+ } catch (err) {
333
+ console.error('hsync connection error:', err);
334
+ }
335
+ });
336
+
337
+ server.on('error', (err) => {
338
+ if (err.code === 'EADDRINUSE') {
339
+ console.error(`Error: Port ${PORT} is already in use. Is another instance running?`);
340
+ } else {
341
+ console.error('Server error:', err.message);
342
+ }
343
+ process.exit(1);
344
+ });
package/src/lib/db.js ADDED
@@ -0,0 +1,325 @@
1
+ import Database from 'better-sqlite3';
2
+ import { nanoid } from 'nanoid';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+ import bcrypt from 'bcrypt';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const dbPath = join(__dirname, '../../data.db');
9
+
10
+ const db = new Database(dbPath);
11
+
12
+ // Initialize other tables first
13
+ db.exec(`
14
+ CREATE TABLE IF NOT EXISTS service_accounts (
15
+ id TEXT PRIMARY KEY,
16
+ service TEXT NOT NULL,
17
+ name TEXT NOT NULL,
18
+ credentials TEXT NOT NULL,
19
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
20
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
21
+ UNIQUE(service, name)
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS settings (
25
+ key TEXT PRIMARY KEY,
26
+ value TEXT NOT NULL,
27
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS write_queue (
31
+ id TEXT PRIMARY KEY,
32
+ service TEXT NOT NULL,
33
+ account_name TEXT NOT NULL,
34
+ requests TEXT NOT NULL,
35
+ comment TEXT,
36
+ status TEXT NOT NULL DEFAULT 'pending',
37
+ rejection_reason TEXT,
38
+ results TEXT,
39
+ submitted_by TEXT,
40
+ submitted_at TEXT DEFAULT CURRENT_TIMESTAMP,
41
+ reviewed_at TEXT,
42
+ completed_at TEXT
43
+ );
44
+ `);
45
+
46
+ // Initialize api_keys table with migration support for old schema
47
+ // Old schema had: id, name, key, created_at
48
+ // New schema has: id, name, key_prefix, key_hash, created_at
49
+ try {
50
+ const tableInfo = db.prepare('PRAGMA table_info(api_keys)').all();
51
+
52
+ if (tableInfo.length === 0) {
53
+ // Table doesn't exist, create with new schema
54
+ db.exec(`
55
+ CREATE TABLE api_keys (
56
+ id TEXT PRIMARY KEY,
57
+ name TEXT NOT NULL,
58
+ key_prefix TEXT NOT NULL,
59
+ key_hash TEXT NOT NULL,
60
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
61
+ );
62
+ `);
63
+ } else {
64
+ const hasOldSchema = tableInfo.some(col => col.name === 'key') && !tableInfo.some(col => col.name === 'key_hash');
65
+
66
+ if (hasOldSchema) {
67
+ console.log('Migrating api_keys table to new schema...');
68
+ console.log('NOTE: Old API keys cannot be migrated (bcrypt is one-way) and will be removed.');
69
+ console.log('Please create new API keys after migration.');
70
+
71
+ db.exec(`
72
+ DROP TABLE api_keys;
73
+ CREATE TABLE api_keys (
74
+ id TEXT PRIMARY KEY,
75
+ name TEXT NOT NULL,
76
+ key_prefix TEXT NOT NULL,
77
+ key_hash TEXT NOT NULL,
78
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
79
+ );
80
+ `);
81
+ console.log('Migration complete.');
82
+ }
83
+ // else: table exists with new schema, nothing to do
84
+ }
85
+ } catch (err) {
86
+ console.error('Error initializing api_keys table:', err.message);
87
+ }
88
+
89
+ // API Keys
90
+ export async function createApiKey(name) {
91
+ const id = nanoid();
92
+ const key = `rms_${nanoid(32)}`;
93
+ const keyPrefix = key.substring(0, 8) + '...' + key.substring(key.length - 4);
94
+ const keyHash = await bcrypt.hash(key, 10);
95
+ db.prepare('INSERT INTO api_keys (id, name, key_prefix, key_hash) VALUES (?, ?, ?, ?)').run(id, name, keyPrefix, keyHash);
96
+ return { id, name, key, keyPrefix }; // Return full key only at creation
97
+ }
98
+
99
+ export function listApiKeys() {
100
+ return db.prepare('SELECT id, name, key_prefix, created_at FROM api_keys').all();
101
+ }
102
+
103
+ export function deleteApiKey(id) {
104
+ return db.prepare('DELETE FROM api_keys WHERE id = ?').run(id);
105
+ }
106
+
107
+ export async function validateApiKey(key) {
108
+ // Must check all keys since we can't look up by hash directly
109
+ const allKeys = db.prepare('SELECT * FROM api_keys').all();
110
+ for (const row of allKeys) {
111
+ const match = await bcrypt.compare(key, row.key_hash);
112
+ if (match) {
113
+ return { id: row.id, name: row.name };
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ // Service Accounts
120
+ export function setAccountCredentials(service, name, credentials) {
121
+ const id = nanoid();
122
+ const json = JSON.stringify(credentials);
123
+ db.prepare(`
124
+ INSERT INTO service_accounts (id, service, name, credentials)
125
+ VALUES (?, ?, ?, ?)
126
+ ON CONFLICT(service, name) DO UPDATE SET
127
+ credentials = excluded.credentials,
128
+ updated_at = CURRENT_TIMESTAMP
129
+ `).run(id, service, name, json);
130
+ }
131
+
132
+ export function getAccountCredentials(service, name) {
133
+ const row = db.prepare('SELECT credentials FROM service_accounts WHERE service = ? AND name = ?').get(service, name);
134
+ return row ? JSON.parse(row.credentials) : null;
135
+ }
136
+
137
+ export function listAccounts(service) {
138
+ if (service) {
139
+ return db.prepare('SELECT id, service, name, created_at, updated_at FROM service_accounts WHERE service = ?').all(service);
140
+ }
141
+ return db.prepare('SELECT id, service, name, created_at, updated_at FROM service_accounts ORDER BY service, name').all();
142
+ }
143
+
144
+ export function deleteAccount(service, name) {
145
+ return db.prepare('DELETE FROM service_accounts WHERE service = ? AND name = ?').run(service, name);
146
+ }
147
+
148
+ export function deleteAccountById(id) {
149
+ return db.prepare('DELETE FROM service_accounts WHERE id = ?').run(id);
150
+ }
151
+
152
+ // Get all accounts grouped by service (for /api/readme)
153
+ export function getAccountsByService() {
154
+ const rows = db.prepare('SELECT service, name FROM service_accounts ORDER BY service, name').all();
155
+ const grouped = {};
156
+ for (const row of rows) {
157
+ if (!grouped[row.service]) {
158
+ grouped[row.service] = [];
159
+ }
160
+ grouped[row.service].push(row.name);
161
+ }
162
+ return grouped;
163
+ }
164
+
165
+ // Settings (for things like hsync config)
166
+ export function setSetting(key, value) {
167
+ const json = JSON.stringify(value);
168
+ db.prepare(`
169
+ INSERT INTO settings (key, value)
170
+ VALUES (?, ?)
171
+ ON CONFLICT(key) DO UPDATE SET
172
+ value = excluded.value,
173
+ updated_at = CURRENT_TIMESTAMP
174
+ `).run(key, json);
175
+ }
176
+
177
+ export function getSetting(key) {
178
+ const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
179
+ return row ? JSON.parse(row.value) : null;
180
+ }
181
+
182
+ export function deleteSetting(key) {
183
+ return db.prepare('DELETE FROM settings WHERE key = ?').run(key);
184
+ }
185
+
186
+ // Admin Password
187
+ export async function setAdminPassword(password) {
188
+ const hash = await bcrypt.hash(password, 10);
189
+ setSetting('admin_password', hash);
190
+ }
191
+
192
+ export async function verifyAdminPassword(password) {
193
+ const hash = getSetting('admin_password');
194
+ if (!hash) return false;
195
+ return bcrypt.compare(password, hash);
196
+ }
197
+
198
+ export function hasAdminPassword() {
199
+ return getSetting('admin_password') !== null;
200
+ }
201
+
202
+ // Cookie secret (generated once, persisted)
203
+ export function getCookieSecret() {
204
+ let secret = getSetting('cookie_secret');
205
+ if (!secret) {
206
+ secret = nanoid(64);
207
+ setSetting('cookie_secret', secret);
208
+ }
209
+ return secret;
210
+ }
211
+
212
+ // Write Queue
213
+ export function createQueueEntry(service, accountName, requests, comment, submittedBy) {
214
+ const id = nanoid();
215
+ db.prepare(`
216
+ INSERT INTO write_queue (id, service, account_name, requests, comment, submitted_by)
217
+ VALUES (?, ?, ?, ?, ?, ?)
218
+ `).run(id, service, accountName, JSON.stringify(requests), comment || null, submittedBy);
219
+ return { id, status: 'pending' };
220
+ }
221
+
222
+ export function getQueueEntry(id) {
223
+ const row = db.prepare('SELECT * FROM write_queue WHERE id = ?').get(id);
224
+ if (!row) return null;
225
+ return {
226
+ ...row,
227
+ requests: JSON.parse(row.requests),
228
+ results: row.results ? JSON.parse(row.results) : null
229
+ };
230
+ }
231
+
232
+ export function listQueueEntries(status) {
233
+ let rows;
234
+ if (status) {
235
+ rows = db.prepare('SELECT * FROM write_queue WHERE status = ? ORDER BY submitted_at DESC').all(status);
236
+ } else {
237
+ rows = db.prepare('SELECT * FROM write_queue ORDER BY submitted_at DESC').all();
238
+ }
239
+ return rows.map(row => ({
240
+ ...row,
241
+ requests: JSON.parse(row.requests),
242
+ results: row.results ? JSON.parse(row.results) : null
243
+ }));
244
+ }
245
+
246
+ export function updateQueueStatus(id, status, extra = {}) {
247
+ const updates = ['status = ?'];
248
+ const values = [status];
249
+
250
+ if (extra.rejection_reason !== undefined) {
251
+ updates.push('rejection_reason = ?');
252
+ values.push(extra.rejection_reason);
253
+ }
254
+ if (extra.results !== undefined) {
255
+ updates.push('results = ?');
256
+ values.push(JSON.stringify(extra.results));
257
+ }
258
+ if (status === 'approved' || status === 'rejected') {
259
+ updates.push('reviewed_at = CURRENT_TIMESTAMP');
260
+ }
261
+ if (status === 'completed' || status === 'failed') {
262
+ updates.push('completed_at = CURRENT_TIMESTAMP');
263
+ }
264
+
265
+ values.push(id);
266
+ db.prepare(`UPDATE write_queue SET ${updates.join(', ')} WHERE id = ?`).run(...values);
267
+ }
268
+
269
+ export function clearQueueByStatus(status) {
270
+ if (status === 'all') {
271
+ return db.prepare("DELETE FROM write_queue WHERE status IN ('completed', 'failed', 'rejected')").run();
272
+ }
273
+ return db.prepare('DELETE FROM write_queue WHERE status = ?').run(status);
274
+ }
275
+
276
+ export function deleteQueueEntry(id) {
277
+ return db.prepare('DELETE FROM write_queue WHERE id = ?').run(id);
278
+ }
279
+
280
+ // Legacy alias
281
+ export function clearCompletedQueue() {
282
+ return clearQueueByStatus('all');
283
+ }
284
+
285
+ export function getPendingQueueCount() {
286
+ const row = db.prepare("SELECT COUNT(*) as count FROM write_queue WHERE status = 'pending'").get();
287
+ return row.count;
288
+ }
289
+
290
+ export function getQueueCounts() {
291
+ const rows = db.prepare('SELECT status, COUNT(*) as count FROM write_queue GROUP BY status').all();
292
+ const counts = { all: 0, pending: 0, completed: 0, failed: 0, rejected: 0 };
293
+ for (const row of rows) {
294
+ counts[row.status] = row.count;
295
+ counts.all += row.count;
296
+ }
297
+ return counts;
298
+ }
299
+
300
+ // List queue entries by submitter (for agent's own submissions)
301
+ // Returns summary info only - no full request bodies or results
302
+ export function listQueueEntriesBySubmitter(submittedBy, service = null, accountName = null) {
303
+ let sql = `
304
+ SELECT id, service, account_name, comment, status, rejection_reason,
305
+ submitted_at, reviewed_at, completed_at
306
+ FROM write_queue
307
+ WHERE submitted_by = ?
308
+ `;
309
+ const params = [submittedBy];
310
+
311
+ if (service) {
312
+ sql += ' AND service = ?';
313
+ params.push(service);
314
+ }
315
+ if (accountName) {
316
+ sql += ' AND account_name = ?';
317
+ params.push(accountName);
318
+ }
319
+
320
+ sql += ' ORDER BY submitted_at DESC';
321
+
322
+ return db.prepare(sql).all(params);
323
+ }
324
+
325
+ export default db;