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,57 @@
1
+ import { createConnection } from 'hsync';
2
+ import { getSetting } from './db.js';
3
+
4
+ let currentConnection = null;
5
+ let currentUrl = null;
6
+
7
+ export async function connectHsync(port) {
8
+ const config = getSetting('hsync');
9
+ if (!config?.enabled) {
10
+ return null;
11
+ }
12
+
13
+ // Disconnect existing connection first
14
+ await disconnectHsync();
15
+
16
+ try {
17
+ const options = {
18
+ port,
19
+ hsyncServer: config.url,
20
+ hsyncSecret: config.token || undefined
21
+ };
22
+
23
+ currentConnection = await createConnection(options);
24
+ currentUrl = currentConnection.publicUrl || currentConnection.url || config.url || null;
25
+ console.log(`hsync connected: ${currentUrl || 'connected'}`);
26
+ return currentConnection;
27
+ } catch (err) {
28
+ console.error('hsync connection failed:', err.message);
29
+ currentConnection = null;
30
+ currentUrl = null;
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export async function disconnectHsync() {
36
+ if (currentConnection) {
37
+ try {
38
+ if (typeof currentConnection.close === 'function') {
39
+ await currentConnection.close();
40
+ } else if (typeof currentConnection.disconnect === 'function') {
41
+ await currentConnection.disconnect();
42
+ }
43
+ } catch {
44
+ // Disconnect errors are non-fatal
45
+ }
46
+ currentConnection = null;
47
+ currentUrl = null;
48
+ }
49
+ }
50
+
51
+ export function getHsyncUrl() {
52
+ return currentUrl;
53
+ }
54
+
55
+ export function isHsyncConnected() {
56
+ return currentConnection !== null;
57
+ }
@@ -0,0 +1,362 @@
1
+ import { getAccountCredentials, setAccountCredentials, updateQueueStatus } from './db.js';
2
+
3
+ // Service base URLs
4
+ const SERVICE_URLS = {
5
+ github: 'https://api.github.com',
6
+ bluesky: 'https://bsky.social/xrpc',
7
+ reddit: 'https://oauth.reddit.com',
8
+ mastodon: null, // Dynamic: https://{instance}
9
+ calendar: 'https://www.googleapis.com/calendar/v3',
10
+ google_calendar: 'https://www.googleapis.com/calendar/v3',
11
+ youtube: 'https://www.googleapis.com/youtube/v3',
12
+ linkedin: 'https://api.linkedin.com/v2',
13
+ jira: null, // Dynamic: https://{domain}/rest/api/3
14
+ fitbit: 'https://api.fitbit.com'
15
+ };
16
+
17
+ // Get access token for a service, refreshing if needed
18
+ async function getAccessToken(service, accountName) {
19
+ const creds = getAccountCredentials(service, accountName);
20
+ if (!creds) return null;
21
+
22
+ switch (service) {
23
+ case 'github':
24
+ return creds.token || null;
25
+
26
+ case 'bluesky':
27
+ return await getBlueskyToken(accountName, creds);
28
+
29
+ case 'reddit':
30
+ return await getOAuthToken(accountName, creds, 'reddit', refreshRedditToken);
31
+
32
+ case 'calendar':
33
+ case 'google_calendar':
34
+ return await getOAuthToken(accountName, creds, 'google_calendar', refreshGoogleToken);
35
+
36
+ case 'youtube':
37
+ return await getOAuthToken(accountName, creds, 'youtube', refreshGoogleToken);
38
+
39
+ case 'linkedin':
40
+ return await getOAuthToken(accountName, creds, 'linkedin', refreshLinkedInToken);
41
+
42
+ case 'mastodon':
43
+ return creds.accessToken || null;
44
+
45
+ case 'jira':
46
+ // Jira uses basic auth, return the creds object
47
+ return creds;
48
+
49
+ case 'fitbit':
50
+ return await getOAuthToken(accountName, creds, 'fitbit', refreshFitbitToken);
51
+
52
+ default:
53
+ return null;
54
+ }
55
+ }
56
+
57
+ // Generic OAuth token getter with refresh
58
+ async function getOAuthToken(accountName, creds, service, refreshFn) {
59
+ if (creds.accessToken && creds.expiresAt && Date.now() < creds.expiresAt) {
60
+ return creds.accessToken;
61
+ }
62
+
63
+ if (!creds.refreshToken) return null;
64
+
65
+ const newToken = await refreshFn(accountName, creds, service);
66
+ return newToken;
67
+ }
68
+
69
+ // Bluesky session token
70
+ async function getBlueskyToken(accountName, creds) {
71
+ if (creds.accessJwt && creds.expiresAt && Date.now() < creds.expiresAt) {
72
+ return creds.accessJwt;
73
+ }
74
+
75
+ try {
76
+ const response = await fetch('https://bsky.social/xrpc/com.atproto.server.createSession', {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({
80
+ identifier: creds.identifier,
81
+ password: creds.appPassword
82
+ })
83
+ });
84
+
85
+ if (!response.ok) return null;
86
+
87
+ const session = await response.json();
88
+ setAccountCredentials('bluesky', accountName, {
89
+ identifier: creds.identifier,
90
+ appPassword: creds.appPassword,
91
+ accessJwt: session.accessJwt,
92
+ refreshJwt: session.refreshJwt,
93
+ did: session.did,
94
+ expiresAt: Date.now() + (90 * 60 * 1000)
95
+ });
96
+
97
+ return session.accessJwt;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ // Refresh Google OAuth token (Calendar/YouTube)
104
+ async function refreshGoogleToken(accountName, creds, service) {
105
+ try {
106
+ const response = await fetch('https://oauth2.googleapis.com/token', {
107
+ method: 'POST',
108
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
109
+ body: new URLSearchParams({
110
+ client_id: creds.clientId,
111
+ client_secret: creds.clientSecret,
112
+ refresh_token: creds.refreshToken,
113
+ grant_type: 'refresh_token'
114
+ })
115
+ });
116
+
117
+ if (!response.ok) return null;
118
+
119
+ const tokens = await response.json();
120
+ setAccountCredentials(service, accountName, {
121
+ ...creds,
122
+ accessToken: tokens.access_token,
123
+ expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
124
+ });
125
+
126
+ return tokens.access_token;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ // Refresh Reddit OAuth token
133
+ async function refreshRedditToken(accountName, creds) {
134
+ try {
135
+ const basicAuth = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString('base64');
136
+
137
+ const response = await fetch('https://www.reddit.com/api/v1/access_token', {
138
+ method: 'POST',
139
+ headers: {
140
+ 'Authorization': `Basic ${basicAuth}`,
141
+ 'Content-Type': 'application/x-www-form-urlencoded'
142
+ },
143
+ body: new URLSearchParams({
144
+ grant_type: 'refresh_token',
145
+ refresh_token: creds.refreshToken
146
+ })
147
+ });
148
+
149
+ if (!response.ok) return null;
150
+
151
+ const tokens = await response.json();
152
+ setAccountCredentials('reddit', accountName, {
153
+ ...creds,
154
+ accessToken: tokens.access_token,
155
+ expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
156
+ });
157
+
158
+ return tokens.access_token;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ // Refresh LinkedIn OAuth token
165
+ async function refreshLinkedInToken(accountName, creds) {
166
+ try {
167
+ const response = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
170
+ body: new URLSearchParams({
171
+ grant_type: 'refresh_token',
172
+ refresh_token: creds.refreshToken,
173
+ client_id: creds.clientId,
174
+ client_secret: creds.clientSecret
175
+ })
176
+ });
177
+
178
+ if (!response.ok) return null;
179
+
180
+ const tokens = await response.json();
181
+ setAccountCredentials('linkedin', accountName, {
182
+ ...creds,
183
+ accessToken: tokens.access_token,
184
+ refreshToken: tokens.refresh_token || creds.refreshToken,
185
+ expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
186
+ });
187
+
188
+ return tokens.access_token;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ // Refresh Fitbit OAuth token
195
+ async function refreshFitbitToken(accountName, creds) {
196
+ try {
197
+ const basicAuth = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString('base64');
198
+
199
+ const response = await fetch('https://api.fitbit.com/oauth2/token', {
200
+ method: 'POST',
201
+ headers: {
202
+ 'Authorization': `Basic ${basicAuth}`,
203
+ 'Content-Type': 'application/x-www-form-urlencoded'
204
+ },
205
+ body: new URLSearchParams({
206
+ grant_type: 'refresh_token',
207
+ refresh_token: creds.refreshToken
208
+ })
209
+ });
210
+
211
+ if (!response.ok) return null;
212
+
213
+ const tokens = await response.json();
214
+ setAccountCredentials('fitbit', accountName, {
215
+ ...creds,
216
+ accessToken: tokens.access_token,
217
+ refreshToken: tokens.refresh_token || creds.refreshToken,
218
+ expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
219
+ });
220
+
221
+ return tokens.access_token;
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+
227
+ // Build the full URL for a service request
228
+ function buildUrl(service, accountName, path) {
229
+ const creds = getAccountCredentials(service, accountName);
230
+
231
+ // Handle dynamic URLs
232
+ if (service === 'mastodon' && creds?.instance) {
233
+ return `https://${creds.instance}/${path.replace(/^\//, '')}`;
234
+ }
235
+ if (service === 'jira' && creds?.domain) {
236
+ return `https://${creds.domain}/rest/api/3/${path.replace(/^\//, '')}`;
237
+ }
238
+
239
+ const baseUrl = SERVICE_URLS[service];
240
+ if (!baseUrl) return null;
241
+
242
+ return `${baseUrl}/${path.replace(/^\//, '')}`;
243
+ }
244
+
245
+ // Build headers for a service request
246
+ function buildHeaders(service, token, customHeaders = {}) {
247
+ const headers = {
248
+ 'Accept': 'application/json',
249
+ 'Content-Type': 'application/json',
250
+ ...customHeaders
251
+ };
252
+
253
+ if (service === 'jira' && token?.email && token?.apiToken) {
254
+ // Jira uses basic auth
255
+ const basicAuth = Buffer.from(`${token.email}:${token.apiToken}`).toString('base64');
256
+ headers['Authorization'] = `Basic ${basicAuth}`;
257
+ } else if (token && typeof token === 'string') {
258
+ headers['Authorization'] = `Bearer ${token}`;
259
+ }
260
+
261
+ // Service-specific headers
262
+ if (service === 'github') {
263
+ headers['Accept'] = 'application/vnd.github+json';
264
+ headers['User-Agent'] = 'agentgate-gateway';
265
+ }
266
+ if (service === 'reddit') {
267
+ headers['User-Agent'] = 'agentgate-gateway/1.0';
268
+ }
269
+
270
+ return headers;
271
+ }
272
+
273
+ // Execute a single queued entry (batch of requests)
274
+ export async function executeQueueEntry(entry) {
275
+ const results = [];
276
+ const { service, account_name, requests } = entry;
277
+
278
+ // Mark as executing
279
+ updateQueueStatus(entry.id, 'executing');
280
+
281
+ for (let i = 0; i < requests.length; i++) {
282
+ const req = requests[i];
283
+
284
+ try {
285
+ // Get fresh token for each request (in case of expiry during batch)
286
+ const token = await getAccessToken(service, account_name);
287
+ if (!token) {
288
+ results.push({
289
+ index: i,
290
+ ok: false,
291
+ error: `Failed to get access token for ${service}/${account_name}`
292
+ });
293
+ updateQueueStatus(entry.id, 'failed', { results });
294
+ return { success: false, results };
295
+ }
296
+
297
+ // Build URL
298
+ const url = buildUrl(service, account_name, req.path);
299
+ if (!url) {
300
+ results.push({
301
+ index: i,
302
+ ok: false,
303
+ error: `Unknown service or invalid configuration: ${service}`
304
+ });
305
+ updateQueueStatus(entry.id, 'failed', { results });
306
+ return { success: false, results };
307
+ }
308
+
309
+ // Build headers
310
+ const headers = buildHeaders(service, token, req.headers);
311
+
312
+ // Make the request
313
+ const fetchOptions = {
314
+ method: req.method,
315
+ headers
316
+ };
317
+
318
+ if (req.body && ['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase())) {
319
+ fetchOptions.body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
320
+ }
321
+
322
+ const response = await fetch(url, fetchOptions);
323
+
324
+ // Parse response
325
+ let responseBody;
326
+ const contentType = response.headers.get('content-type') || '';
327
+ if (contentType.includes('application/json')) {
328
+ responseBody = await response.json();
329
+ } else {
330
+ responseBody = await response.text();
331
+ }
332
+
333
+ const result = {
334
+ index: i,
335
+ ok: response.ok,
336
+ status: response.status,
337
+ body: responseBody
338
+ };
339
+
340
+ results.push(result);
341
+
342
+ // Stop on first failure
343
+ if (!response.ok) {
344
+ updateQueueStatus(entry.id, 'failed', { results });
345
+ return { success: false, results };
346
+ }
347
+
348
+ } catch (err) {
349
+ results.push({
350
+ index: i,
351
+ ok: false,
352
+ error: err.message
353
+ });
354
+ updateQueueStatus(entry.id, 'failed', { results });
355
+ return { success: false, results };
356
+ }
357
+ }
358
+
359
+ // All requests succeeded
360
+ updateQueueStatus(entry.id, 'completed', { results });
361
+ return { success: true, results };
362
+ }
@@ -0,0 +1,130 @@
1
+ import { Router } from 'express';
2
+ import { getAccountCredentials, setAccountCredentials } from '../lib/db.js';
3
+
4
+ const router = Router();
5
+ const BSKY_API = 'https://bsky.social/xrpc';
6
+
7
+ // Service metadata - exported for /api/readme and /api/skill
8
+ export const serviceInfo = {
9
+ key: 'bluesky',
10
+ name: 'Bluesky',
11
+ shortDesc: 'Timeline, posts, profile (DMs blocked)',
12
+ description: 'Bluesky/AT Protocol proxy (DMs blocked)',
13
+ authType: 'app password',
14
+ docs: 'https://docs.bsky.app/docs/api/',
15
+ examples: [
16
+ 'GET /api/bluesky/{accountName}/app.bsky.feed.getTimeline',
17
+ 'GET /api/bluesky/{accountName}/app.bsky.feed.getAuthorFeed?actor={handle}',
18
+ 'GET /api/bluesky/{accountName}/app.bsky.actor.getProfile?actor={handle}'
19
+ ]
20
+ };
21
+
22
+ // Blocked routes - no DMs/chat
23
+ const BLOCKED_PATTERNS = [
24
+ /^chat\./, // all chat.bsky.* endpoints
25
+ /^com\.atproto\.admin/ // admin endpoints
26
+ ];
27
+
28
+ // Get a valid access token, refreshing if needed
29
+ async function getAccessToken(accountName) {
30
+ const creds = getAccountCredentials('bluesky', accountName);
31
+ if (!creds) {
32
+ return null;
33
+ }
34
+
35
+ // If we have an access token and it's not expired, use it
36
+ if (creds.accessJwt && creds.expiresAt && Date.now() < creds.expiresAt) {
37
+ return creds.accessJwt;
38
+ }
39
+
40
+ // Need to create a new session with app password
41
+ try {
42
+ const response = await fetch(`${BSKY_API}/com.atproto.server.createSession`, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({
46
+ identifier: creds.identifier,
47
+ password: creds.appPassword
48
+ })
49
+ });
50
+
51
+ if (!response.ok) {
52
+ console.error('Bluesky auth failed:', await response.text());
53
+ return null;
54
+ }
55
+
56
+ const session = await response.json();
57
+
58
+ // Store the new tokens (access token valid for ~2 hours)
59
+ setAccountCredentials('bluesky', accountName, {
60
+ identifier: creds.identifier,
61
+ appPassword: creds.appPassword,
62
+ accessJwt: session.accessJwt,
63
+ refreshJwt: session.refreshJwt,
64
+ did: session.did,
65
+ expiresAt: Date.now() + (90 * 60 * 1000) // 90 minutes to be safe
66
+ });
67
+
68
+ return session.accessJwt;
69
+ } catch (error) {
70
+ console.error('Bluesky session creation failed:', error);
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // Proxy GET requests to Bluesky API
76
+ // Route: /api/bluesky/:accountName/*
77
+ router.get('/:accountName/*', async (req, res) => {
78
+ try {
79
+ const { accountName } = req.params;
80
+ const accessToken = await getAccessToken(accountName);
81
+ if (!accessToken) {
82
+ return res.status(401).json({
83
+ error: 'Bluesky account not configured',
84
+ message: `Set up Bluesky account "${accountName}" in the admin UI`
85
+ });
86
+ }
87
+
88
+ const path = req.params[0] || '';
89
+
90
+ // Check blocked routes
91
+ for (const pattern of BLOCKED_PATTERNS) {
92
+ if (pattern.test(path)) {
93
+ return res.status(403).json({
94
+ error: 'Route blocked',
95
+ message: 'This endpoint is blocked for privacy (DMs/chat)'
96
+ });
97
+ }
98
+ }
99
+
100
+ const queryString = new URLSearchParams(req.query).toString();
101
+ const url = `${BSKY_API}/${path}${queryString ? '?' + queryString : ''}`;
102
+
103
+ const response = await fetch(url, {
104
+ headers: {
105
+ 'Authorization': `Bearer ${accessToken}`,
106
+ 'Accept': 'application/json'
107
+ }
108
+ });
109
+
110
+ const data = await response.json();
111
+ res.status(response.status).json(data);
112
+ } catch (error) {
113
+ res.status(500).json({ error: 'Bluesky API request failed', message: error.message });
114
+ }
115
+ });
116
+
117
+ // Handle root path for account
118
+ router.get('/:accountName', async (req, res) => {
119
+ res.json({
120
+ service: 'bluesky',
121
+ account: req.params.accountName,
122
+ description: 'Bluesky/AT Protocol proxy (DMs blocked). Append XRPC method after account name.',
123
+ examples: [
124
+ `GET /api/bluesky/${req.params.accountName}/app.bsky.feed.getTimeline`,
125
+ `GET /api/bluesky/${req.params.accountName}/app.bsky.actor.getProfile?actor=handle.bsky.social`
126
+ ]
127
+ });
128
+ });
129
+
130
+ export default router;
@@ -0,0 +1,120 @@
1
+ import { Router } from 'express';
2
+ import { getAccountCredentials, setAccountCredentials } from '../lib/db.js';
3
+
4
+ const router = Router();
5
+ const GOOGLE_API = 'https://www.googleapis.com/calendar/v3';
6
+ const GOOGLE_AUTH = 'https://oauth2.googleapis.com';
7
+
8
+ // Service metadata - exported for /api/readme and /api/skill
9
+ export const serviceInfo = {
10
+ key: 'calendar',
11
+ name: 'Google Calendar',
12
+ shortDesc: 'Events, calendars',
13
+ description: 'Google Calendar API proxy',
14
+ authType: 'oauth',
15
+ dbKey: 'google_calendar',
16
+ docs: 'https://developers.google.com/calendar/api/v3/reference',
17
+ examples: [
18
+ 'GET /api/calendar/{accountName}/users/me/calendarList',
19
+ 'GET /api/calendar/{accountName}/calendars/primary/events',
20
+ 'GET /api/calendar/{accountName}/calendars/{calendarId}/events?timeMin={ISO8601}&timeMax={ISO8601}'
21
+ ]
22
+ };
23
+
24
+ // Get a valid access token, refreshing if needed
25
+ async function getAccessToken(accountName) {
26
+ const creds = getAccountCredentials('google_calendar', 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 response = await fetch(`${GOOGLE_AUTH}/token`, {
43
+ method: 'POST',
44
+ headers: {
45
+ 'Content-Type': 'application/x-www-form-urlencoded'
46
+ },
47
+ body: new URLSearchParams({
48
+ client_id: creds.clientId,
49
+ client_secret: creds.clientSecret,
50
+ refresh_token: creds.refreshToken,
51
+ grant_type: 'refresh_token'
52
+ })
53
+ });
54
+
55
+ if (!response.ok) {
56
+ console.error('Google token refresh failed:', await response.text());
57
+ return null;
58
+ }
59
+
60
+ const tokens = await response.json();
61
+
62
+ // Store the new tokens
63
+ setAccountCredentials('google_calendar', accountName, {
64
+ ...creds,
65
+ accessToken: tokens.access_token,
66
+ expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000 // 1 min buffer
67
+ });
68
+
69
+ return tokens.access_token;
70
+ } catch (error) {
71
+ console.error('Google token refresh failed:', error);
72
+ return null;
73
+ }
74
+ }
75
+
76
+ // Proxy GET requests to Google Calendar API
77
+ // Route: /api/calendar/:accountName/*
78
+ router.get('/:accountName/*', async (req, res) => {
79
+ try {
80
+ const { accountName } = req.params;
81
+ const accessToken = await getAccessToken(accountName);
82
+ if (!accessToken) {
83
+ return res.status(401).json({
84
+ error: 'Google Calendar account not configured',
85
+ message: `Set up Google Calendar account "${accountName}" in the admin UI`
86
+ });
87
+ }
88
+
89
+ const path = req.params[0] || '';
90
+ const queryString = new URLSearchParams(req.query).toString();
91
+ const url = `${GOOGLE_API}/${path}${queryString ? '?' + queryString : ''}`;
92
+
93
+ const response = await fetch(url, {
94
+ headers: {
95
+ 'Authorization': `Bearer ${accessToken}`,
96
+ 'Accept': 'application/json'
97
+ }
98
+ });
99
+
100
+ const data = await response.json();
101
+ res.status(response.status).json(data);
102
+ } catch (error) {
103
+ res.status(500).json({ error: 'Google Calendar API request failed', message: error.message });
104
+ }
105
+ });
106
+
107
+ // Handle root path for account
108
+ router.get('/:accountName', async (req, res) => {
109
+ res.json({
110
+ service: 'google_calendar',
111
+ account: req.params.accountName,
112
+ description: 'Google Calendar API proxy. Append API path after account name.',
113
+ examples: [
114
+ `GET /api/calendar/${req.params.accountName}/users/me/calendarList`,
115
+ `GET /api/calendar/${req.params.accountName}/calendars/primary/events`
116
+ ]
117
+ });
118
+ });
119
+
120
+ export default router;