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
|
@@ -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;
|