agentgate 0.4.0 → 0.5.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.
@@ -21,6 +21,30 @@ export function renderMarkdownLinks(str) {
21
21
  return escaped;
22
22
  }
23
23
 
24
+ // Generate a consistent color from a string (for avatar fallback)
25
+ export function stringToColor(str) {
26
+ if (!str) return '#6b7280';
27
+ let hash = 0;
28
+ for (let i = 0; i < str.length; i++) {
29
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
30
+ }
31
+ const hue = Math.abs(hash % 360);
32
+ return `hsl(${hue}, 60%, 45%)`;
33
+ }
34
+
35
+ // Render agent avatar with fallback to initials
36
+ export function renderAvatar(agentName, { size = 32, className = '' } = {}) {
37
+ if (!agentName) return '';
38
+ const safeName = escapeHtml(agentName);
39
+ const initial = agentName.charAt(0).toUpperCase();
40
+ const color = stringToColor(agentName);
41
+
42
+ return `<span class="avatar ${className}" style="width: ${size}px; height: ${size}px; background-color: ${color};" data-agent="${safeName}">
43
+ <img src="/ui/keys/avatar/${encodeURIComponent(agentName)}" alt="" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
44
+ <span class="avatar-initials" style="display: none;">${initial}</span>
45
+ </span>`;
46
+ }
47
+
24
48
  // Status badge HTML
25
49
  export function statusBadge(status) {
26
50
  const colors = {
@@ -30,16 +54,34 @@ export function statusBadge(status) {
30
54
  completed: 'background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3);',
31
55
  failed: 'background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3);',
32
56
  rejected: 'background: rgba(156, 163, 175, 0.15); color: #9ca3af; border: 1px solid rgba(156, 163, 175, 0.3);',
57
+ withdrawn: 'background: rgba(168, 85, 247, 0.15); color: #c084fc; border: 1px solid rgba(168, 85, 247, 0.3);',
33
58
  delivered: 'background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3);'
34
59
  };
35
60
  return `<span class="status" style="${colors[status] || ''}">${status}</span>`;
36
61
  }
37
62
 
38
- // Format date for display
63
+ // Format date for display - outputs span with data-utc for client-side localization
39
64
  export function formatDate(dateStr) {
40
65
  if (!dateStr) return '';
41
- const d = new Date(dateStr);
42
- return d.toLocaleString();
66
+ // Return span with UTC timestamp - client JS will localize
67
+ return `<span class="local-time" data-utc="${dateStr}"></span>`;
68
+ }
69
+
70
+ // Client-side script to localize all dates to browser timezone
71
+ export function localizeScript() {
72
+ return `
73
+ <script>
74
+ document.addEventListener('DOMContentLoaded', function() {
75
+ document.querySelectorAll('.local-time[data-utc]').forEach(function(el) {
76
+ const utc = el.getAttribute('data-utc');
77
+ if (utc) {
78
+ const d = new Date(utc);
79
+ el.textContent = d.toLocaleString();
80
+ el.title = utc + ' UTC';
81
+ }
82
+ });
83
+ });
84
+ </script>`;
43
85
  }
44
86
 
45
87
  // Shared HTML head with common styles/scripts
@@ -48,13 +90,15 @@ export function htmlHead(title, { includeSocket = false } = {}) {
48
90
  <html>
49
91
  <head>
50
92
  <title>agentgate - ${title}</title>
93
+ <meta name="viewport" content="width=device-width, initial-scale=1">
51
94
  <link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
52
95
  <link rel="stylesheet" href="/public/style.css">
96
+ <link rel="stylesheet" href="/public/mobile.css">
53
97
  ${includeSocket ? '<script src="/socket.io/socket.io.js"></script>' : ''}
54
98
  </head>`;
55
99
  }
56
100
 
57
- // Navigation header
101
+ // Navigation header with real-time badge support
58
102
  export function navHeader({ pendingQueueCount = 0, pendingMessagesCount = 0, messagingMode = 'off' } = {}) {
59
103
  return `
60
104
  <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
@@ -63,18 +107,21 @@ export function navHeader({ pendingQueueCount = 0, pendingMessagesCount = 0, mes
63
107
  <h1 style="margin: 0;">agentgate</h1>
64
108
  </div>
65
109
  <div style="display: flex; gap: 12px; align-items: center;">
66
- <a href="/ui/keys" class="nav-btn nav-btn-default">API Keys</a>
110
+ <a href="/ui/keys" class="nav-btn nav-btn-default">Agents</a>
111
+ <a href="/ui/access" class="nav-btn nav-btn-default">Access</a>
67
112
  <a href="/ui/queue" class="nav-btn nav-btn-default" style="position: relative;">
68
- Write Queue
113
+ Queue
69
114
  <span id="queue-badge" class="badge" ${pendingQueueCount > 0 ? '' : 'style="display:none"'}>${pendingQueueCount}</span>
70
115
  </a>
116
+ <a href="/ui/mementos" class="nav-btn nav-btn-default">Mementos</a>
71
117
  <a href="/ui/messages" id="messages-nav" class="nav-btn nav-btn-default" style="position: relative;${messagingMode === 'off' ? ' display:none;' : ''}">
72
118
  Messages
73
119
  <span id="messages-badge" class="badge" ${pendingMessagesCount > 0 ? '' : 'style="display:none"'}>${pendingMessagesCount}</span>
74
120
  </a>
75
121
  <div class="nav-divider"></div>
122
+ <a href="/ui#settings" class="nav-btn nav-btn-default" title="Settings">⚙️</a>
76
123
  <form method="POST" action="/ui/logout" style="margin: 0;">
77
- <button type="submit" class="nav-btn nav-btn-default" style="color: #f87171;">Logout</button>
124
+ <button type="submit" class="nav-btn nav-btn-danger">Logout</button>
78
125
  </form>
79
126
  </div>
80
127
  </div>`;
@@ -137,3 +184,86 @@ export function copyScript() {
137
184
  }
138
185
  </script>`;
139
186
  }
187
+
188
+ // Styled error page for OAuth callbacks and other errors
189
+ export function renderErrorPage(title, message, { backUrl = '/ui', backText = 'Back to Settings' } = {}) {
190
+ return `<!DOCTYPE html>
191
+ <html>
192
+ <head>
193
+ <title>agentgate - ${escapeHtml(title)}</title>
194
+ <meta name="viewport" content="width=device-width, initial-scale=1">
195
+ <link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
196
+ <link rel="stylesheet" href="/public/style.css">
197
+ <style>
198
+ .error-container {
199
+ max-width: 500px;
200
+ margin: 80px auto;
201
+ padding: 32px;
202
+ text-align: center;
203
+ }
204
+ .error-icon {
205
+ font-size: 64px;
206
+ margin-bottom: 16px;
207
+ }
208
+ .error-title {
209
+ color: #f87171;
210
+ margin-bottom: 16px;
211
+ }
212
+ .error-message {
213
+ color: #9ca3af;
214
+ margin-bottom: 24px;
215
+ line-height: 1.6;
216
+ word-break: break-word;
217
+ }
218
+ .error-actions {
219
+ display: flex;
220
+ gap: 12px;
221
+ justify-content: center;
222
+ }
223
+ </style>
224
+ </head>
225
+ <body>
226
+ <div class="container">
227
+ <div class="error-container">
228
+ <div class="error-icon">⚠️</div>
229
+ <h1 class="error-title">${escapeHtml(title)}</h1>
230
+ <p class="error-message">${escapeHtml(message)}</p>
231
+ <div class="error-actions">
232
+ <a href="${escapeHtml(backUrl)}" class="btn btn-primary">${escapeHtml(backText)}</a>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ </body>
237
+ </html>`;
238
+ }
239
+
240
+ // Simple navigation header for sub-pages (includes badge elements for socket.io updates)
241
+ export function simpleNavHeader({ pendingQueueCount = 0, pendingMessagesCount = 0, messagingMode = 'off' } = {}) {
242
+ return `
243
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
244
+ <div style="display: flex; align-items: center; gap: 12px;">
245
+ <a href="/ui" style="display: flex; align-items: center; gap: 12px; text-decoration: none; color: inherit;">
246
+ <img src="/public/favicon.svg" alt="agentgate" style="height: 48px;">
247
+ <h1 style="margin: 0;">agentgate</h1>
248
+ </a>
249
+ </div>
250
+ <div style="display: flex; gap: 12px; align-items: center;">
251
+ <a href="/ui/keys" class="nav-btn nav-btn-default">Agents</a>
252
+ <a href="/ui/access" class="nav-btn nav-btn-default">Access</a>
253
+ <a href="/ui/queue" class="nav-btn nav-btn-default" style="position: relative;">
254
+ Queue
255
+ <span id="queue-badge" class="badge" ${pendingQueueCount > 0 ? '' : 'style="display:none"'}>${pendingQueueCount}</span>
256
+ </a>
257
+ <a href="/ui/mementos" class="nav-btn nav-btn-default">Mementos</a>
258
+ <a href="/ui/messages" id="messages-nav" class="nav-btn nav-btn-default" style="position: relative;${messagingMode === 'off' ? ' display:none;' : ''}">
259
+ Messages
260
+ <span id="messages-badge" class="badge" ${pendingMessagesCount > 0 ? '' : 'style="display:none"'}>${pendingMessagesCount}</span>
261
+ </a>
262
+ <div class="nav-divider"></div>
263
+ <a href="/ui#settings" class="nav-btn nav-btn-default" title="Settings">⚙️</a>
264
+ <form method="POST" action="/ui/logout" style="margin: 0;">
265
+ <button type="submit" class="nav-btn nav-btn-danger">Logout</button>
266
+ </form>
267
+ </div>
268
+ </div>`;
269
+ }
@@ -1,4 +1,5 @@
1
1
  import { setAccountCredentials, deleteAccount, getAccountCredentials } from '../../lib/db.js';
2
+ import { renderErrorPage } from './shared.js';
2
3
 
3
4
  export function registerRoutes(router, baseUrl) {
4
5
  router.post('/youtube/setup', (req, res) => {
@@ -22,14 +23,19 @@ export function registerRoutes(router, baseUrl) {
22
23
 
23
24
  router.get('/youtube/callback', async (req, res) => {
24
25
  const { code, error, state } = req.query;
26
+ const accountName = state?.replace('agentgate_youtube_', '') || 'default';
27
+
25
28
  if (error) {
26
- return res.status(400).send(`YouTube OAuth error: ${error}`);
29
+ const creds = getAccountCredentials('youtube', accountName);
30
+ if (creds) {
31
+ setAccountCredentials('youtube', accountName, { ...creds, authStatus: 'failed', authError: error });
32
+ }
33
+ return res.status(400).send(renderErrorPage('YouTube OAuth Error', `YouTube returned an error: ${error}`));
27
34
  }
28
35
 
29
- const accountName = state?.replace('agentgate_youtube_', '') || 'default';
30
36
  const creds = getAccountCredentials('youtube', accountName);
31
37
  if (!creds) {
32
- return res.status(400).send('YouTube account not found. Please try setup again.');
38
+ return res.status(400).send(renderErrorPage('Setup Error', 'YouTube account not found. Please try setup again.'));
33
39
  }
34
40
 
35
41
  try {
@@ -51,19 +57,22 @@ export function registerRoutes(router, baseUrl) {
51
57
 
52
58
  const tokens = await response.json();
53
59
  if (tokens.error) {
54
- return res.status(400).send(`YouTube token error: ${tokens.error}`);
60
+ setAccountCredentials('youtube', accountName, { ...creds, authStatus: 'failed', authError: tokens.error });
61
+ return res.status(400).send(renderErrorPage('Token Error', `YouTube token exchange failed: ${tokens.error}`));
55
62
  }
56
63
 
57
64
  setAccountCredentials('youtube', accountName, {
58
65
  ...creds,
59
66
  accessToken: tokens.access_token,
60
67
  refreshToken: tokens.refresh_token,
61
- expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
68
+ expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000,
69
+ authStatus: 'success'
62
70
  });
63
71
 
64
72
  res.redirect('/ui');
65
73
  } catch (err) {
66
- res.status(500).send(`YouTube OAuth failed: ${err.message}`);
74
+ setAccountCredentials('youtube', accountName, { ...creds, authStatus: 'failed', authError: err.message });
75
+ res.status(500).send(renderErrorPage('Connection Error', `YouTube OAuth failed: ${err.message}`));
67
76
  }
68
77
  });
69
78
 
@@ -72,6 +81,25 @@ export function registerRoutes(router, baseUrl) {
72
81
  deleteAccount('youtube', accountName);
73
82
  res.redirect('/ui');
74
83
  });
84
+
85
+ router.post('/youtube/retry', (req, res) => {
86
+ const { accountName } = req.body;
87
+ const creds = getAccountCredentials('youtube', accountName);
88
+ if (!creds || !creds.clientId || !creds.clientSecret) {
89
+ return res.status(400).send(renderErrorPage('Retry Error', 'Account credentials not found. Please set up the account again.'));
90
+ }
91
+
92
+ const redirectUri = `${baseUrl}/ui/youtube/callback`;
93
+ const scope = 'https://www.googleapis.com/auth/youtube.readonly';
94
+ const state = `agentgate_youtube_${accountName}`;
95
+
96
+ const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' +
97
+ `client_id=${creds.clientId}&response_type=code&` +
98
+ `state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}&` +
99
+ `scope=${encodeURIComponent(scope)}&access_type=offline&prompt=consent`;
100
+
101
+ res.redirect(authUrl);
102
+ });
75
103
  }
76
104
 
77
105
  export function renderCard(accounts, baseUrl) {
@@ -79,15 +107,36 @@ export function renderCard(accounts, baseUrl) {
79
107
 
80
108
  const renderAccounts = () => {
81
109
  if (serviceAccounts.length === 0) return '';
82
- return serviceAccounts.map(acc => `
83
- <div class="account-item">
84
- <span><strong>${acc.name}</strong></span>
85
- <form method="POST" action="/ui/youtube/delete" style="margin:0;">
110
+ return serviceAccounts.map(acc => {
111
+ const { hasToken, hasCredentials, authStatus } = acc.status || {};
112
+
113
+ let statusBadge = '';
114
+ if (hasToken) {
115
+ statusBadge = '<span class="badge-success" style="margin-left: 8px;">✓ Connected</span>';
116
+ } else if (authStatus === 'failed') {
117
+ statusBadge = '<span class="badge-error" style="margin-left: 8px;">✗ Auth Failed</span>';
118
+ } else if (hasCredentials) {
119
+ statusBadge = '<span class="badge-warning" style="margin-left: 8px;">⏳ Pending</span>';
120
+ }
121
+
122
+ const retryBtn = (!hasToken && hasCredentials) ? `
123
+ <form method="POST" action="/ui/youtube/retry" style="margin:0;">
86
124
  <input type="hidden" name="accountName" value="${acc.name}">
87
- <button type="submit" class="btn-sm btn-danger">Remove</button>
88
- </form>
89
- </div>
90
- `).join('');
125
+ <button type="submit" class="btn-sm btn-primary">Retry Auth</button>
126
+ </form>` : '';
127
+
128
+ return `
129
+ <div class="account-item">
130
+ <span><strong>${acc.name}</strong>${statusBadge}</span>
131
+ <div style="display: flex; gap: 8px;">
132
+ ${retryBtn}
133
+ <form method="POST" action="/ui/youtube/delete" style="margin:0;">
134
+ <input type="hidden" name="accountName" value="${acc.name}">
135
+ <button type="submit" class="btn-sm btn-danger">Remove</button>
136
+ </form>
137
+ </div>
138
+ </div>`;
139
+ }).join('');
91
140
  };
92
141
 
93
142
  return `
@@ -0,0 +1,247 @@
1
+ import { Router } from 'express';
2
+ import crypto from 'crypto';
3
+ import { getWebhookSecret, listApiKeys } from '../lib/db.js';
4
+
5
+ const router = Router();
6
+
7
+ /**
8
+ * Verify GitHub webhook signature (HMAC SHA256)
9
+ * @param {string} payload - Raw request body
10
+ * @param {string} signature - X-Hub-Signature-256 header
11
+ * @param {string} secret - Webhook secret
12
+ * @returns {boolean}
13
+ */
14
+ function verifyGitHubSignature(payload, signature, secret) {
15
+ if (!signature || !secret) return false;
16
+
17
+ const expected = 'sha256=' + crypto
18
+ .createHmac('sha256', secret)
19
+ .update(payload)
20
+ .digest('hex');
21
+
22
+ try {
23
+ return crypto.timingSafeEqual(
24
+ Buffer.from(signature),
25
+ Buffer.from(expected)
26
+ );
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Parse GitHub event into normalized format
34
+ */
35
+ function parseGitHubEvent(eventType, payload) {
36
+ const base = {
37
+ service: 'github',
38
+ event: eventType,
39
+ received_at: new Date().toISOString()
40
+ };
41
+
42
+ if (payload.repository) {
43
+ base.repo = payload.repository.full_name;
44
+ }
45
+
46
+ switch (eventType) {
47
+ case 'push':
48
+ return {
49
+ ...base,
50
+ data: {
51
+ ref: payload.ref,
52
+ commits: payload.commits?.length || 0,
53
+ pusher: payload.pusher?.name,
54
+ compare_url: payload.compare
55
+ }
56
+ };
57
+
58
+ case 'pull_request':
59
+ return {
60
+ ...base,
61
+ event: `pull_request.${payload.action}`,
62
+ data: {
63
+ number: payload.pull_request?.number,
64
+ title: payload.pull_request?.title,
65
+ action: payload.action,
66
+ url: payload.pull_request?.html_url,
67
+ user: payload.pull_request?.user?.login,
68
+ merged: payload.pull_request?.merged || false
69
+ }
70
+ };
71
+
72
+ case 'issues':
73
+ return {
74
+ ...base,
75
+ event: `issues.${payload.action}`,
76
+ data: {
77
+ number: payload.issue?.number,
78
+ title: payload.issue?.title,
79
+ action: payload.action,
80
+ url: payload.issue?.html_url,
81
+ user: payload.issue?.user?.login
82
+ }
83
+ };
84
+
85
+ case 'issue_comment':
86
+ return {
87
+ ...base,
88
+ event: `issue_comment.${payload.action}`,
89
+ data: {
90
+ issue_number: payload.issue?.number,
91
+ issue_title: payload.issue?.title,
92
+ comment_id: payload.comment?.id,
93
+ action: payload.action,
94
+ url: payload.comment?.html_url,
95
+ user: payload.comment?.user?.login,
96
+ is_pr: !!payload.issue?.pull_request
97
+ }
98
+ };
99
+
100
+ case 'check_suite':
101
+ return {
102
+ ...base,
103
+ event: `check_suite.${payload.action}`,
104
+ data: {
105
+ status: payload.check_suite?.status,
106
+ conclusion: payload.check_suite?.conclusion,
107
+ head_sha: payload.check_suite?.head_sha?.substring(0, 7)
108
+ }
109
+ };
110
+
111
+ case 'check_run':
112
+ return {
113
+ ...base,
114
+ event: `check_run.${payload.action}`,
115
+ data: {
116
+ name: payload.check_run?.name,
117
+ status: payload.check_run?.status,
118
+ conclusion: payload.check_run?.conclusion,
119
+ head_sha: payload.check_run?.head_sha?.substring(0, 7)
120
+ }
121
+ };
122
+
123
+ case 'release':
124
+ return {
125
+ ...base,
126
+ event: `release.${payload.action}`,
127
+ data: {
128
+ tag: payload.release?.tag_name,
129
+ name: payload.release?.name,
130
+ url: payload.release?.html_url,
131
+ prerelease: payload.release?.prerelease
132
+ }
133
+ };
134
+
135
+ case 'workflow_run':
136
+ return {
137
+ ...base,
138
+ event: `workflow_run.${payload.action}`,
139
+ data: {
140
+ name: payload.workflow_run?.name,
141
+ status: payload.workflow_run?.status,
142
+ conclusion: payload.workflow_run?.conclusion,
143
+ head_sha: payload.workflow_run?.head_sha?.substring(0, 7),
144
+ url: payload.workflow_run?.html_url
145
+ }
146
+ };
147
+
148
+ default:
149
+ return {
150
+ ...base,
151
+ data: { action: payload.action }
152
+ };
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Broadcast webhook event to all agents with webhooks configured
158
+ */
159
+ async function broadcastToAgents(event) {
160
+ const agents = listApiKeys();
161
+ const results = { delivered: [], failed: [] };
162
+
163
+ for (const agent of agents) {
164
+ if (!agent.webhook_url) continue;
165
+
166
+ try {
167
+ const response = await fetch(agent.webhook_url, {
168
+ method: 'POST',
169
+ headers: {
170
+ 'Content-Type': 'application/json',
171
+ ...(agent.webhook_token ? { 'Authorization': `Bearer ${agent.webhook_token}` } : {})
172
+ },
173
+ body: JSON.stringify({
174
+ type: 'service_webhook',
175
+ ...event
176
+ })
177
+ });
178
+
179
+ if (response.ok) {
180
+ results.delivered.push(agent.name);
181
+ } else {
182
+ results.failed.push({ name: agent.name, status: response.status });
183
+ }
184
+ } catch (err) {
185
+ results.failed.push({ name: agent.name, error: err.message });
186
+ }
187
+ }
188
+
189
+ return results;
190
+ }
191
+
192
+ // POST /webhooks/github - Receive GitHub webhooks
193
+ // NOTE: This route does NOT use apiKeyAuth - it uses signature verification instead
194
+ router.post('/github', async (req, res) => {
195
+ const signature = req.headers['x-hub-signature-256'];
196
+ const eventType = req.headers['x-github-event'];
197
+ const deliveryId = req.headers['x-github-delivery'];
198
+
199
+ if (!eventType) {
200
+ return res.status(400).json({ error: 'Missing X-GitHub-Event header' });
201
+ }
202
+
203
+ // Get stored webhook secret
204
+ const secret = getWebhookSecret('github');
205
+
206
+ // Verify signature if secret is configured
207
+ if (secret) {
208
+ // Use raw body captured by express.json verify callback
209
+ const rawBody = req.rawBody;
210
+ if (!rawBody) {
211
+ console.error('GitHub webhook missing raw body - cannot verify signature');
212
+ return res.status(500).json({ error: 'Internal error: raw body not captured' });
213
+ }
214
+ if (!verifyGitHubSignature(rawBody, signature, secret)) {
215
+ console.error('GitHub webhook signature verification failed');
216
+ return res.status(401).json({ error: 'Invalid signature' });
217
+ }
218
+ }
219
+
220
+ // Handle ping event (GitHub sends this when webhook is first configured)
221
+ if (eventType === 'ping') {
222
+ console.log('GitHub webhook ping received:', req.body.zen);
223
+ return res.json({ ok: true, message: 'pong', zen: req.body.zen });
224
+ }
225
+
226
+ // Parse event
227
+ const event = parseGitHubEvent(eventType, req.body);
228
+ console.log('GitHub webhook received:', event.event, event.repo || '');
229
+
230
+ // Broadcast to agents
231
+ const results = await broadcastToAgents(event);
232
+ console.log('Webhook broadcast:', results.delivered.length, 'delivered,', results.failed.length, 'failed');
233
+
234
+ return res.json({
235
+ ok: true,
236
+ delivery_id: deliveryId,
237
+ event: event.event,
238
+ repo: event.repo,
239
+ broadcast: {
240
+ delivered: results.delivered.length,
241
+ failed: results.failed.length
242
+ }
243
+ });
244
+ });
245
+
246
+ export default router;
247
+