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.
- package/README.md +216 -0
- package/package.json +63 -0
- package/public/favicon.svg +48 -0
- package/public/icons/bluesky.svg +1 -0
- package/public/icons/fitbit.svg +16 -0
- package/public/icons/github.svg +1 -0
- package/public/icons/google-calendar.svg +1 -0
- package/public/icons/jira.svg +1 -0
- package/public/icons/linkedin.svg +1 -0
- package/public/icons/mastodon.svg +1 -0
- package/public/icons/reddit.svg +1 -0
- package/public/icons/youtube.svg +1 -0
- package/public/logo.svg +52 -0
- package/public/style.css +584 -0
- package/src/cli.js +77 -0
- package/src/index.js +344 -0
- package/src/lib/db.js +325 -0
- package/src/lib/hsyncManager.js +57 -0
- package/src/lib/queueExecutor.js +362 -0
- package/src/routes/bluesky.js +130 -0
- package/src/routes/calendar.js +120 -0
- package/src/routes/fitbit.js +127 -0
- package/src/routes/github.js +72 -0
- package/src/routes/jira.js +77 -0
- package/src/routes/linkedin.js +137 -0
- package/src/routes/mastodon.js +91 -0
- package/src/routes/queue.js +186 -0
- package/src/routes/reddit.js +138 -0
- package/src/routes/ui/bluesky.js +66 -0
- package/src/routes/ui/calendar.js +120 -0
- package/src/routes/ui/fitbit.js +122 -0
- package/src/routes/ui/github.js +60 -0
- package/src/routes/ui/index.js +35 -0
- package/src/routes/ui/jira.js +72 -0
- package/src/routes/ui/linkedin.js +120 -0
- package/src/routes/ui/mastodon.js +140 -0
- package/src/routes/ui/reddit.js +120 -0
- package/src/routes/ui/youtube.js +120 -0
- package/src/routes/ui.js +1077 -0
- 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;
|