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
@@ -0,0 +1,127 @@
1
+ import { Router } from 'express';
2
+ import { getAccountCredentials, setAccountCredentials } from '../lib/db.js';
3
+
4
+ const router = Router();
5
+ const FITBIT_API = 'https://api.fitbit.com';
6
+ const FITBIT_AUTH = 'https://api.fitbit.com/oauth2/token';
7
+
8
+ // Service metadata - exported for /api/readme and /api/skill
9
+ export const serviceInfo = {
10
+ key: 'fitbit',
11
+ name: 'Fitbit',
12
+ shortDesc: 'Activity, sleep, heart rate, profile',
13
+ description: 'Fitbit API proxy',
14
+ authType: 'oauth',
15
+ docs: 'https://dev.fitbit.com/build/reference/web-api/',
16
+ examples: [
17
+ 'GET /api/fitbit/{accountName}/1/user/-/profile.json',
18
+ 'GET /api/fitbit/{accountName}/1/user/-/activities/date/today.json',
19
+ 'GET /api/fitbit/{accountName}/1/user/-/sleep/date/today.json',
20
+ 'GET /api/fitbit/{accountName}/1/user/-/body/log/weight/date/today.json'
21
+ ]
22
+ };
23
+
24
+ // Get a valid access token, refreshing if needed
25
+ async function getAccessToken(accountName) {
26
+ const creds = getAccountCredentials('fitbit', accountName);
27
+ if (!creds) {
28
+ return null;
29
+ }
30
+
31
+ // If we have an access token and it's not expired, use it
32
+ if (creds.accessToken && creds.expiresAt && Date.now() < creds.expiresAt) {
33
+ return creds.accessToken;
34
+ }
35
+
36
+ // Need to refresh the token
37
+ if (!creds.refreshToken) {
38
+ return null;
39
+ }
40
+
41
+ try {
42
+ const basicAuth = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString('base64');
43
+
44
+ const response = await fetch(FITBIT_AUTH, {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/x-www-form-urlencoded',
48
+ 'Authorization': `Basic ${basicAuth}`
49
+ },
50
+ body: new URLSearchParams({
51
+ grant_type: 'refresh_token',
52
+ refresh_token: creds.refreshToken
53
+ })
54
+ });
55
+
56
+ if (!response.ok) {
57
+ console.error('Fitbit token refresh failed:', await response.text());
58
+ return null;
59
+ }
60
+
61
+ const tokens = await response.json();
62
+
63
+ // Store the new tokens
64
+ setAccountCredentials('fitbit', accountName, {
65
+ ...creds,
66
+ accessToken: tokens.access_token,
67
+ refreshToken: tokens.refresh_token || creds.refreshToken,
68
+ expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000 // 1 min buffer
69
+ });
70
+
71
+ return tokens.access_token;
72
+ } catch (error) {
73
+ console.error('Fitbit token refresh failed:', error);
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // Export for queue executor
79
+ export { getAccessToken };
80
+
81
+ // Proxy GET requests to Fitbit API
82
+ // Route: /api/fitbit/:accountName/*
83
+ router.get('/:accountName/*', async (req, res) => {
84
+ try {
85
+ const { accountName } = req.params;
86
+ const accessToken = await getAccessToken(accountName);
87
+ if (!accessToken) {
88
+ return res.status(401).json({
89
+ error: 'Fitbit account not configured',
90
+ message: `Set up Fitbit account "${accountName}" in the admin UI`
91
+ });
92
+ }
93
+
94
+ const path = req.params[0] || '';
95
+ const queryString = new URLSearchParams(req.query).toString();
96
+ const url = `${FITBIT_API}/${path}${queryString ? '?' + queryString : ''}`;
97
+
98
+ const response = await fetch(url, {
99
+ headers: {
100
+ 'Authorization': `Bearer ${accessToken}`,
101
+ 'Accept': 'application/json'
102
+ }
103
+ });
104
+
105
+ const data = await response.json();
106
+ res.status(response.status).json(data);
107
+ } catch (error) {
108
+ res.status(500).json({ error: 'Fitbit API request failed', message: error.message });
109
+ }
110
+ });
111
+
112
+ // Handle root path for account
113
+ router.get('/:accountName', async (req, res) => {
114
+ res.json({
115
+ service: 'fitbit',
116
+ account: req.params.accountName,
117
+ description: 'Fitbit API proxy. Append API path after account name.',
118
+ examples: [
119
+ `GET /api/fitbit/${req.params.accountName}/1/user/-/profile.json`,
120
+ `GET /api/fitbit/${req.params.accountName}/1/user/-/activities/date/today.json`,
121
+ `GET /api/fitbit/${req.params.accountName}/1/user/-/sleep/date/today.json`,
122
+ `GET /api/fitbit/${req.params.accountName}/1/user/-/body/log/weight/date/today.json`
123
+ ]
124
+ });
125
+ });
126
+
127
+ export default router;
@@ -0,0 +1,72 @@
1
+ import { Router } from 'express';
2
+ import { getAccountCredentials } from '../lib/db.js';
3
+
4
+ const router = Router();
5
+ const GITHUB_API = 'https://api.github.com';
6
+
7
+ // Service metadata - exported for /api/readme and /api/skill
8
+ export const serviceInfo = {
9
+ key: 'github',
10
+ name: 'GitHub',
11
+ shortDesc: 'Repos, issues, PRs, commits',
12
+ description: 'GitHub API proxy',
13
+ authType: 'personal access token',
14
+ docs: 'https://docs.github.com/en/rest',
15
+ examples: [
16
+ 'GET /api/github/{accountName}/users/{username}',
17
+ 'GET /api/github/{accountName}/repos/{owner}/{repo}',
18
+ 'GET /api/github/{accountName}/repos/{owner}/{repo}/commits'
19
+ ],
20
+ writeGuidelines: [
21
+ 'NEVER push directly to main/master branches (except for initial commits on new projects)',
22
+ 'Always create a new branch for changes to existing projects',
23
+ 'Run tests locally before submitting PRs (if tests exist)',
24
+ 'Create a pull request for review',
25
+ 'Workflow: create branch → commit changes → run tests → create PR'
26
+ ]
27
+ };
28
+
29
+ // Proxy all GET requests to GitHub API
30
+ // Route: /api/github/:accountName/*
31
+ // Uses PAT if configured (5000 req/hr), falls back to unauthenticated (60 req/hr)
32
+ router.get('/:accountName/*', async (req, res) => {
33
+ try {
34
+ const { accountName } = req.params;
35
+ const path = req.params[0] || '';
36
+ const queryString = new URLSearchParams(req.query).toString();
37
+ const url = `${GITHUB_API}/${path}${queryString ? '?' + queryString : ''}`;
38
+
39
+ const headers = {
40
+ 'Accept': 'application/vnd.github+json',
41
+ 'User-Agent': 'agentgate-gateway'
42
+ };
43
+
44
+ // Add auth if configured for this account
45
+ const creds = getAccountCredentials('github', accountName);
46
+ if (creds?.token) {
47
+ headers['Authorization'] = `Bearer ${creds.token}`;
48
+ }
49
+
50
+ const response = await fetch(url, { headers });
51
+
52
+ const data = await response.json();
53
+ res.status(response.status).json(data);
54
+ } catch (error) {
55
+ res.status(500).json({ error: 'GitHub API request failed', message: error.message });
56
+ }
57
+ });
58
+
59
+ // Also handle root path for account (e.g., /api/github/personal)
60
+ router.get('/:accountName', async (req, res) => {
61
+ res.json({
62
+ service: 'github',
63
+ account: req.params.accountName,
64
+ description: 'GitHub API proxy. Append path after account name.',
65
+ examples: [
66
+ `GET /api/github/${req.params.accountName}/users/octocat`,
67
+ `GET /api/github/${req.params.accountName}/repos/owner/repo`
68
+ ]
69
+ });
70
+ });
71
+
72
+ export default router;
@@ -0,0 +1,77 @@
1
+ import { Router } from 'express';
2
+ import { getAccountCredentials } from '../lib/db.js';
3
+
4
+ const router = Router();
5
+
6
+ // Service metadata - exported for /api/readme and /api/skill
7
+ export const serviceInfo = {
8
+ key: 'jira',
9
+ name: 'Jira',
10
+ shortDesc: 'Issues, projects, search',
11
+ description: 'Jira API proxy',
12
+ authType: 'api token',
13
+ docs: 'https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/',
14
+ examples: [
15
+ 'GET /api/jira/{accountName}/myself',
16
+ 'GET /api/jira/{accountName}/project',
17
+ 'GET /api/jira/{accountName}/search?jql=assignee=currentUser()',
18
+ 'GET /api/jira/{accountName}/issue/{issueKey}'
19
+ ]
20
+ };
21
+
22
+ // Get Jira config for an account
23
+ function getJiraConfig(accountName) {
24
+ const creds = getAccountCredentials('jira', accountName);
25
+ if (!creds || !creds.domain || !creds.email || !creds.apiToken) {
26
+ return null;
27
+ }
28
+ return creds;
29
+ }
30
+
31
+ // Proxy GET requests to Jira API
32
+ // Route: /api/jira/:accountName/*
33
+ router.get('/:accountName/*', async (req, res) => {
34
+ try {
35
+ const { accountName } = req.params;
36
+ const config = getJiraConfig(accountName);
37
+ if (!config) {
38
+ return res.status(401).json({
39
+ error: 'Jira account not configured',
40
+ message: `Set up Jira account "${accountName}" in the admin UI`
41
+ });
42
+ }
43
+
44
+ const path = req.params[0] || '';
45
+ const queryString = new URLSearchParams(req.query).toString();
46
+ const url = `https://${config.domain}/rest/api/3/${path}${queryString ? '?' + queryString : ''}`;
47
+
48
+ const basicAuth = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
49
+
50
+ const response = await fetch(url, {
51
+ headers: {
52
+ 'Authorization': `Basic ${basicAuth}`,
53
+ 'Accept': 'application/json'
54
+ }
55
+ });
56
+
57
+ const data = await response.json();
58
+ res.status(response.status).json(data);
59
+ } catch (error) {
60
+ res.status(500).json({ error: 'Jira API request failed', message: error.message });
61
+ }
62
+ });
63
+
64
+ // Handle root path for account
65
+ router.get('/:accountName', async (req, res) => {
66
+ res.json({
67
+ service: 'jira',
68
+ account: req.params.accountName,
69
+ description: 'Jira API proxy. Append API path after account name.',
70
+ examples: [
71
+ `GET /api/jira/${req.params.accountName}/myself`,
72
+ `GET /api/jira/${req.params.accountName}/search?jql=assignee=currentUser()`
73
+ ]
74
+ });
75
+ });
76
+
77
+ export default router;
@@ -0,0 +1,137 @@
1
+ import { Router } from 'express';
2
+ import { getAccountCredentials, setAccountCredentials } from '../lib/db.js';
3
+
4
+ const router = Router();
5
+ const LINKEDIN_API = 'https://api.linkedin.com/v2';
6
+
7
+ // Service metadata - exported for /api/readme and /api/skill
8
+ export const serviceInfo = {
9
+ key: 'linkedin',
10
+ name: 'LinkedIn',
11
+ shortDesc: 'Profile (messaging blocked)',
12
+ description: 'LinkedIn API proxy (messaging blocked)',
13
+ authType: 'oauth',
14
+ docs: 'https://learn.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api',
15
+ examples: [
16
+ 'GET /api/linkedin/{accountName}/me',
17
+ 'GET /api/linkedin/{accountName}/userinfo'
18
+ ]
19
+ };
20
+
21
+ // Blocked routes - no messaging
22
+ const BLOCKED_PATTERNS = [
23
+ /^messaging/, // all messaging endpoints
24
+ /^conversations/ // conversations
25
+ ];
26
+
27
+ // Get a valid access token, refreshing if needed
28
+ async function getAccessToken(accountName) {
29
+ const creds = getAccountCredentials('linkedin', accountName);
30
+ if (!creds) {
31
+ return null;
32
+ }
33
+
34
+ // If we have an access token and it's not expired, use it
35
+ if (creds.accessToken && creds.expiresAt && Date.now() < creds.expiresAt) {
36
+ return creds.accessToken;
37
+ }
38
+
39
+ // LinkedIn access tokens last 60 days, refresh tokens 365 days
40
+ // Need to refresh the token
41
+ if (!creds.refreshToken) {
42
+ return null;
43
+ }
44
+
45
+ try {
46
+ const response = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Content-Type': 'application/x-www-form-urlencoded'
50
+ },
51
+ body: new URLSearchParams({
52
+ grant_type: 'refresh_token',
53
+ refresh_token: creds.refreshToken,
54
+ client_id: creds.clientId,
55
+ client_secret: creds.clientSecret
56
+ })
57
+ });
58
+
59
+ if (!response.ok) {
60
+ console.error('LinkedIn token refresh failed:', await response.text());
61
+ return null;
62
+ }
63
+
64
+ const tokens = await response.json();
65
+
66
+ // Store the new tokens
67
+ setAccountCredentials('linkedin', accountName, {
68
+ ...creds,
69
+ accessToken: tokens.access_token,
70
+ refreshToken: tokens.refresh_token || creds.refreshToken,
71
+ expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000 // 1 min buffer
72
+ });
73
+
74
+ return tokens.access_token;
75
+ } catch (error) {
76
+ console.error('LinkedIn token refresh failed:', error);
77
+ return null;
78
+ }
79
+ }
80
+
81
+ // Proxy GET requests to LinkedIn API
82
+ // Route: /api/linkedin/:accountName/*
83
+ router.get('/:accountName/*', async (req, res) => {
84
+ try {
85
+ const { accountName } = req.params;
86
+ const accessToken = await getAccessToken(accountName);
87
+ if (!accessToken) {
88
+ return res.status(401).json({
89
+ error: 'LinkedIn account not configured',
90
+ message: `Set up LinkedIn account "${accountName}" in the admin UI`
91
+ });
92
+ }
93
+
94
+ const path = req.params[0] || '';
95
+
96
+ // Check blocked routes
97
+ for (const pattern of BLOCKED_PATTERNS) {
98
+ if (pattern.test(path)) {
99
+ return res.status(403).json({
100
+ error: 'Route blocked',
101
+ message: 'This endpoint is blocked for privacy (messaging)'
102
+ });
103
+ }
104
+ }
105
+
106
+ const queryString = new URLSearchParams(req.query).toString();
107
+ const url = `${LINKEDIN_API}/${path}${queryString ? '?' + queryString : ''}`;
108
+
109
+ const response = await fetch(url, {
110
+ headers: {
111
+ 'Authorization': `Bearer ${accessToken}`,
112
+ 'X-Restli-Protocol-Version': '2.0.0',
113
+ 'Accept': 'application/json'
114
+ }
115
+ });
116
+
117
+ const data = await response.json();
118
+ res.status(response.status).json(data);
119
+ } catch (error) {
120
+ res.status(500).json({ error: 'LinkedIn API request failed', message: error.message });
121
+ }
122
+ });
123
+
124
+ // Handle root path for account
125
+ router.get('/:accountName', async (req, res) => {
126
+ res.json({
127
+ service: 'linkedin',
128
+ account: req.params.accountName,
129
+ description: 'LinkedIn API proxy (messaging blocked). Append API path after account name.',
130
+ examples: [
131
+ `GET /api/linkedin/${req.params.accountName}/me`,
132
+ `GET /api/linkedin/${req.params.accountName}/userinfo`
133
+ ]
134
+ });
135
+ });
136
+
137
+ export default router;
@@ -0,0 +1,91 @@
1
+ import { Router } from 'express';
2
+ import { getAccountCredentials } from '../lib/db.js';
3
+
4
+ const router = Router();
5
+
6
+ // Service metadata - exported for /api/readme and /api/skill
7
+ export const serviceInfo = {
8
+ key: 'mastodon',
9
+ name: 'Mastodon',
10
+ shortDesc: 'Timeline, notifications, profile (DMs blocked)',
11
+ description: 'Mastodon API proxy (DMs blocked)',
12
+ authType: 'oauth',
13
+ docs: 'https://docs.joinmastodon.org/api/',
14
+ examples: [
15
+ 'GET /api/mastodon/{accountName}/api/v1/timelines/home',
16
+ 'GET /api/mastodon/{accountName}/api/v1/accounts/verify_credentials',
17
+ 'GET /api/mastodon/{accountName}/api/v1/notifications'
18
+ ]
19
+ };
20
+
21
+ // Blocked routes - no DMs or conversations
22
+ const BLOCKED_PATTERNS = [
23
+ /^api\/v1\/conversations/,
24
+ /^api\/v1\/markers/ // read position markers (privacy)
25
+ ];
26
+
27
+ // Get the configured instance and access token for an account
28
+ function getMastodonConfig(accountName) {
29
+ const creds = getAccountCredentials('mastodon', accountName);
30
+ if (!creds || !creds.accessToken || !creds.instance) {
31
+ return null;
32
+ }
33
+ return creds;
34
+ }
35
+
36
+ // Proxy GET requests to Mastodon API
37
+ // Route: /api/mastodon/:accountName/*
38
+ router.get('/:accountName/*', async (req, res) => {
39
+ try {
40
+ const { accountName } = req.params;
41
+ const config = getMastodonConfig(accountName);
42
+ if (!config) {
43
+ return res.status(401).json({
44
+ error: 'Mastodon account not configured',
45
+ message: `Set up Mastodon account "${accountName}" in the admin UI`
46
+ });
47
+ }
48
+
49
+ const path = req.params[0] || '';
50
+
51
+ // Check blocked routes
52
+ for (const pattern of BLOCKED_PATTERNS) {
53
+ if (pattern.test(path)) {
54
+ return res.status(403).json({
55
+ error: 'Route blocked',
56
+ message: 'This endpoint is blocked for privacy (DMs/conversations)'
57
+ });
58
+ }
59
+ }
60
+
61
+ const queryString = new URLSearchParams(req.query).toString();
62
+ const url = `https://${config.instance}/${path}${queryString ? '?' + queryString : ''}`;
63
+
64
+ const response = await fetch(url, {
65
+ headers: {
66
+ 'Authorization': `Bearer ${config.accessToken}`,
67
+ 'Accept': 'application/json'
68
+ }
69
+ });
70
+
71
+ const data = await response.json();
72
+ res.status(response.status).json(data);
73
+ } catch (error) {
74
+ res.status(500).json({ error: 'Mastodon API request failed', message: error.message });
75
+ }
76
+ });
77
+
78
+ // Handle root path for account
79
+ router.get('/:accountName', async (req, res) => {
80
+ res.json({
81
+ service: 'mastodon',
82
+ account: req.params.accountName,
83
+ description: 'Mastodon API proxy (DMs blocked). Append API path after account name.',
84
+ examples: [
85
+ `GET /api/mastodon/${req.params.accountName}/api/v1/timelines/home`,
86
+ `GET /api/mastodon/${req.params.accountName}/api/v1/accounts/verify_credentials`
87
+ ]
88
+ });
89
+ });
90
+
91
+ export default router;
@@ -0,0 +1,186 @@
1
+ import { Router } from 'express';
2
+ import { createQueueEntry, getQueueEntry, getAccountCredentials, listQueueEntriesBySubmitter } from '../lib/db.js';
3
+
4
+ const router = Router();
5
+
6
+ // Valid services that support write operations
7
+ const VALID_SERVICES = ['github', 'bluesky', 'reddit', 'mastodon', 'calendar', 'google_calendar', 'youtube', 'linkedin', 'jira', 'fitbit'];
8
+
9
+ // Valid HTTP methods for write operations
10
+ const VALID_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
11
+
12
+ // Submit a batch of write requests for approval
13
+ // POST /api/queue/:service/:accountName/submit
14
+ router.post('/:service/:accountName/submit', (req, res) => {
15
+ try {
16
+ const { service, accountName } = req.params;
17
+ const { requests, comment } = req.body;
18
+
19
+ // Validate service
20
+ if (!VALID_SERVICES.includes(service)) {
21
+ return res.status(400).json({
22
+ error: 'Invalid service',
23
+ message: `Service must be one of: ${VALID_SERVICES.join(', ')}`
24
+ });
25
+ }
26
+
27
+ // Check account exists
28
+ const creds = getAccountCredentials(service, accountName);
29
+ if (!creds) {
30
+ return res.status(404).json({
31
+ error: 'Account not found',
32
+ message: `No ${service} account named "${accountName}" is configured`
33
+ });
34
+ }
35
+
36
+ // Validate requests array
37
+ if (!Array.isArray(requests) || requests.length === 0) {
38
+ return res.status(400).json({
39
+ error: 'Invalid requests',
40
+ message: 'requests must be a non-empty array'
41
+ });
42
+ }
43
+
44
+ // Validate each request in the batch
45
+ for (let i = 0; i < requests.length; i++) {
46
+ const req_item = requests[i];
47
+
48
+ if (!req_item.method || !VALID_METHODS.includes(req_item.method.toUpperCase())) {
49
+ return res.status(400).json({
50
+ error: 'Invalid request method',
51
+ message: `Request ${i}: method must be one of: ${VALID_METHODS.join(', ')}`
52
+ });
53
+ }
54
+
55
+ if (!req_item.path || typeof req_item.path !== 'string') {
56
+ return res.status(400).json({
57
+ error: 'Invalid request path',
58
+ message: `Request ${i}: path is required and must be a string`
59
+ });
60
+ }
61
+
62
+ // Normalize method to uppercase
63
+ requests[i].method = req_item.method.toUpperCase();
64
+ }
65
+
66
+ // Get the API key name for audit trail
67
+ const submittedBy = req.apiKeyInfo?.name || 'unknown';
68
+
69
+ // Create the queue entry
70
+ const entry = createQueueEntry(service, accountName, requests, comment, submittedBy);
71
+
72
+ res.status(201).json({
73
+ id: entry.id,
74
+ status: entry.status,
75
+ message: 'Request queued for approval'
76
+ });
77
+
78
+ } catch (error) {
79
+ res.status(500).json({
80
+ error: 'Failed to queue request',
81
+ message: error.message
82
+ });
83
+ }
84
+ });
85
+
86
+ // List all queue entries submitted by the requesting API key
87
+ // GET /api/queue/list (all services) or /api/queue/:service/:accountName/list (filtered)
88
+ router.get('/list', (req, res) => {
89
+ try {
90
+ const submittedBy = req.apiKeyInfo?.name || 'unknown';
91
+ const entries = listQueueEntriesBySubmitter(submittedBy);
92
+
93
+ res.json({
94
+ count: entries.length,
95
+ entries: entries
96
+ });
97
+ } catch (error) {
98
+ res.status(500).json({
99
+ error: 'Failed to list queue entries',
100
+ message: error.message
101
+ });
102
+ }
103
+ });
104
+
105
+ router.get('/:service/:accountName/list', (req, res) => {
106
+ try {
107
+ const { service, accountName } = req.params;
108
+ const submittedBy = req.apiKeyInfo?.name || 'unknown';
109
+
110
+ // Validate service
111
+ if (!VALID_SERVICES.includes(service)) {
112
+ return res.status(400).json({
113
+ error: 'Invalid service',
114
+ message: `Service must be one of: ${VALID_SERVICES.join(', ')}`
115
+ });
116
+ }
117
+
118
+ const entries = listQueueEntriesBySubmitter(submittedBy, service, accountName);
119
+
120
+ res.json({
121
+ count: entries.length,
122
+ entries: entries
123
+ });
124
+ } catch (error) {
125
+ res.status(500).json({
126
+ error: 'Failed to list queue entries',
127
+ message: error.message
128
+ });
129
+ }
130
+ });
131
+
132
+ // Check status of a queued request
133
+ // GET /api/queue/:service/:accountName/status/:id
134
+ router.get('/:service/:accountName/status/:id', (req, res) => {
135
+ try {
136
+ const { service, accountName, id } = req.params;
137
+
138
+ const entry = getQueueEntry(id);
139
+
140
+ if (!entry) {
141
+ return res.status(404).json({
142
+ error: 'Not found',
143
+ message: `No queue entry with id "${id}"`
144
+ });
145
+ }
146
+
147
+ // Verify the entry belongs to this service/account
148
+ if (entry.service !== service || entry.account_name !== accountName) {
149
+ return res.status(404).json({
150
+ error: 'Not found',
151
+ message: `No queue entry with id "${id}" for ${service}/${accountName}`
152
+ });
153
+ }
154
+
155
+ // Build response based on status
156
+ const response = {
157
+ id: entry.id,
158
+ status: entry.status,
159
+ submitted_at: entry.submitted_at
160
+ };
161
+
162
+ if (entry.status === 'rejected') {
163
+ response.rejection_reason = entry.rejection_reason;
164
+ response.reviewed_at = entry.reviewed_at;
165
+ }
166
+
167
+ if (entry.status === 'completed' || entry.status === 'failed') {
168
+ response.results = entry.results;
169
+ response.completed_at = entry.completed_at;
170
+ }
171
+
172
+ if (entry.status === 'executing') {
173
+ response.message = 'Request is currently being executed';
174
+ }
175
+
176
+ res.json(response);
177
+
178
+ } catch (error) {
179
+ res.status(500).json({
180
+ error: 'Failed to get status',
181
+ message: error.message
182
+ });
183
+ }
184
+ });
185
+
186
+ export default router;