@rmdes/indiekit-endpoint-activitypub 2.15.4 → 3.2.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.
@@ -0,0 +1,545 @@
1
+ /**
2
+ * OAuth2 routes for Mastodon Client API.
3
+ *
4
+ * Handles app registration, authorization, token exchange, and revocation.
5
+ */
6
+ import crypto from "node:crypto";
7
+ import express from "express";
8
+
9
+ const router = express.Router(); // eslint-disable-line new-cap
10
+
11
+ /**
12
+ * Generate cryptographically random hex string.
13
+ * @param {number} bytes - Number of random bytes
14
+ * @returns {string} Hex-encoded random string
15
+ */
16
+ function randomHex(bytes) {
17
+ return crypto.randomBytes(bytes).toString("hex");
18
+ }
19
+
20
+ /**
21
+ * Parse redirect_uris from request — accepts space-separated string or array.
22
+ * @param {string|string[]} value
23
+ * @returns {string[]}
24
+ */
25
+ function parseRedirectUris(value) {
26
+ if (!value) return ["urn:ietf:wg:oauth:2.0:oob"];
27
+ if (Array.isArray(value)) return value.map((v) => v.trim());
28
+ return value
29
+ .trim()
30
+ .split(/\s+/)
31
+ .filter(Boolean);
32
+ }
33
+
34
+ /**
35
+ * Parse scopes from request — accepts space-separated string.
36
+ * @param {string} value
37
+ * @returns {string[]}
38
+ */
39
+ function parseScopes(value) {
40
+ if (!value) return ["read"];
41
+ return value
42
+ .trim()
43
+ .split(/\s+/)
44
+ .filter(Boolean);
45
+ }
46
+
47
+ // ─── POST /api/v1/apps — Register client application ────────────────────────
48
+
49
+ router.post("/api/v1/apps", async (req, res, next) => {
50
+ try {
51
+ const { client_name, redirect_uris, scopes, website } = req.body;
52
+
53
+ const clientId = randomHex(16);
54
+ const clientSecret = randomHex(32);
55
+ const redirectUris = parseRedirectUris(redirect_uris);
56
+ const parsedScopes = parseScopes(scopes);
57
+
58
+ const doc = {
59
+ clientId,
60
+ clientSecret,
61
+ name: client_name || "",
62
+ redirectUris,
63
+ scopes: parsedScopes,
64
+ website: website || null,
65
+ confidential: true,
66
+ createdAt: new Date(),
67
+ };
68
+
69
+ const collections = req.app.locals.mastodonCollections;
70
+ await collections.ap_oauth_apps.insertOne(doc);
71
+
72
+ res.json({
73
+ id: doc._id?.toString() || clientId,
74
+ name: doc.name,
75
+ website: doc.website,
76
+ redirect_uris: redirectUris,
77
+ redirect_uri: redirectUris.join(" "),
78
+ client_id: clientId,
79
+ client_secret: clientSecret,
80
+ client_secret_expires_at: 0,
81
+ vapid_key: "",
82
+ });
83
+ } catch (error) {
84
+ next(error);
85
+ }
86
+ });
87
+
88
+ // ─── GET /api/v1/apps/verify_credentials ─────────────────────────────────────
89
+
90
+ router.get("/api/v1/apps/verify_credentials", async (req, res, next) => {
91
+ try {
92
+ const token = req.mastodonToken;
93
+ if (!token) {
94
+ return res.status(401).json({ error: "The access token is invalid" });
95
+ }
96
+
97
+ const collections = req.app.locals.mastodonCollections;
98
+ const app = await collections.ap_oauth_apps.findOne({
99
+ clientId: token.clientId,
100
+ });
101
+
102
+ if (!app) {
103
+ return res.status(404).json({ error: "Application not found" });
104
+ }
105
+
106
+ res.json({
107
+ id: app._id.toString(),
108
+ name: app.name,
109
+ website: app.website,
110
+ scopes: app.scopes,
111
+ redirect_uris: app.redirectUris,
112
+ redirect_uri: app.redirectUris.join(" "),
113
+ });
114
+ } catch (error) {
115
+ next(error);
116
+ }
117
+ });
118
+
119
+ // ─── GET /.well-known/oauth-authorization-server ─────────────────────────────
120
+
121
+ router.get("/.well-known/oauth-authorization-server", (req, res) => {
122
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
123
+
124
+ res.json({
125
+ issuer: baseUrl,
126
+ authorization_endpoint: `${baseUrl}/oauth/authorize`,
127
+ token_endpoint: `${baseUrl}/oauth/token`,
128
+ revocation_endpoint: `${baseUrl}/oauth/revoke`,
129
+ scopes_supported: [
130
+ "read",
131
+ "write",
132
+ "follow",
133
+ "push",
134
+ "profile",
135
+ "read:accounts",
136
+ "read:blocks",
137
+ "read:bookmarks",
138
+ "read:favourites",
139
+ "read:filters",
140
+ "read:follows",
141
+ "read:lists",
142
+ "read:mutes",
143
+ "read:notifications",
144
+ "read:search",
145
+ "read:statuses",
146
+ "write:accounts",
147
+ "write:blocks",
148
+ "write:bookmarks",
149
+ "write:conversations",
150
+ "write:favourites",
151
+ "write:filters",
152
+ "write:follows",
153
+ "write:lists",
154
+ "write:media",
155
+ "write:mutes",
156
+ "write:notifications",
157
+ "write:reports",
158
+ "write:statuses",
159
+ ],
160
+ response_types_supported: ["code"],
161
+ grant_types_supported: ["authorization_code", "client_credentials"],
162
+ token_endpoint_auth_methods_supported: [
163
+ "client_secret_basic",
164
+ "client_secret_post",
165
+ "none",
166
+ ],
167
+ code_challenge_methods_supported: ["S256"],
168
+ service_documentation: "https://docs.joinmastodon.org/api/",
169
+ app_registration_endpoint: `${baseUrl}/api/v1/apps`,
170
+ });
171
+ });
172
+
173
+ // ─── GET /oauth/authorize — Show authorization page ──────────────────────────
174
+
175
+ router.get("/oauth/authorize", async (req, res, next) => {
176
+ try {
177
+ const {
178
+ client_id,
179
+ redirect_uri,
180
+ response_type,
181
+ scope,
182
+ code_challenge,
183
+ code_challenge_method,
184
+ force_login,
185
+ } = req.query;
186
+
187
+ if (response_type !== "code") {
188
+ return res.status(400).json({
189
+ error: "unsupported_response_type",
190
+ error_description: "Only response_type=code is supported",
191
+ });
192
+ }
193
+
194
+ const collections = req.app.locals.mastodonCollections;
195
+ const app = await collections.ap_oauth_apps.findOne({ clientId: client_id });
196
+
197
+ if (!app) {
198
+ return res.status(400).json({
199
+ error: "invalid_client",
200
+ error_description: "Client application not found",
201
+ });
202
+ }
203
+
204
+ // Determine redirect URI — use provided or default to first registered
205
+ const resolvedRedirectUri =
206
+ redirect_uri || app.redirectUris[0] || "urn:ietf:wg:oauth:2.0:oob";
207
+
208
+ // Validate redirect_uri is registered
209
+ if (!app.redirectUris.includes(resolvedRedirectUri)) {
210
+ return res.status(400).json({
211
+ error: "invalid_redirect_uri",
212
+ error_description: "Redirect URI not registered for this application",
213
+ });
214
+ }
215
+
216
+ // Validate requested scopes are subset of app scopes
217
+ const requestedScopes = scope ? scope.split(/\s+/) : app.scopes;
218
+
219
+ // Check if user is logged in via IndieAuth session
220
+ const session = req.session;
221
+ if (!session?.access_token && !force_login) {
222
+ // Not logged in — redirect to Indiekit login, then back here
223
+ const returnUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
224
+ return res.redirect(
225
+ `/auth?redirect=${encodeURIComponent(returnUrl)}`,
226
+ );
227
+ }
228
+
229
+ // Render simple authorization page
230
+ const appName = app.name || "An application";
231
+ res.type("html").send(`<!DOCTYPE html>
232
+ <html lang="en">
233
+ <head>
234
+ <meta charset="utf-8">
235
+ <meta name="viewport" content="width=device-width, initial-scale=1">
236
+ <title>Authorize ${appName}</title>
237
+ <style>
238
+ body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
239
+ h1 { font-size: 1.4rem; }
240
+ .scopes { background: #f5f5f5; padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0; }
241
+ .scopes code { display: block; margin: 0.25rem 0; }
242
+ .actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
243
+ button { padding: 0.6rem 1.5rem; border-radius: 6px; font-size: 1rem; cursor: pointer; border: 1px solid #ccc; }
244
+ .approve { background: #2b90d9; color: white; border-color: #2b90d9; }
245
+ .deny { background: white; }
246
+ </style>
247
+ </head>
248
+ <body>
249
+ <h1>Authorize ${appName}</h1>
250
+ <p>${appName} wants to access your account with these permissions:</p>
251
+ <div class="scopes">
252
+ ${requestedScopes.map((s) => `<code>${s}</code>`).join("")}
253
+ </div>
254
+ <form method="POST" action="/oauth/authorize">
255
+ <input type="hidden" name="client_id" value="${client_id}">
256
+ <input type="hidden" name="redirect_uri" value="${resolvedRedirectUri}">
257
+ <input type="hidden" name="scope" value="${requestedScopes.join(" ")}">
258
+ <input type="hidden" name="code_challenge" value="${code_challenge || ""}">
259
+ <input type="hidden" name="code_challenge_method" value="${code_challenge_method || ""}">
260
+ <input type="hidden" name="response_type" value="code">
261
+ <div class="actions">
262
+ <button type="submit" name="decision" value="approve" class="approve">Authorize</button>
263
+ <button type="submit" name="decision" value="deny" class="deny">Deny</button>
264
+ </div>
265
+ </form>
266
+ </body>
267
+ </html>`);
268
+ } catch (error) {
269
+ next(error);
270
+ }
271
+ });
272
+
273
+ // ─── POST /oauth/authorize — Process authorization decision ──────────────────
274
+
275
+ router.post("/oauth/authorize", async (req, res, next) => {
276
+ try {
277
+ const {
278
+ client_id,
279
+ redirect_uri,
280
+ scope,
281
+ code_challenge,
282
+ code_challenge_method,
283
+ decision,
284
+ } = req.body;
285
+
286
+ // User denied
287
+ if (decision === "deny") {
288
+ if (redirect_uri && redirect_uri !== "urn:ietf:wg:oauth:2.0:oob") {
289
+ const url = new URL(redirect_uri);
290
+ url.searchParams.set("error", "access_denied");
291
+ url.searchParams.set(
292
+ "error_description",
293
+ "The resource owner denied the request",
294
+ );
295
+ return res.redirect(url.toString());
296
+ }
297
+ return res.status(403).json({
298
+ error: "access_denied",
299
+ error_description: "The resource owner denied the request",
300
+ });
301
+ }
302
+
303
+ // Generate authorization code
304
+ const code = randomHex(32);
305
+ const collections = req.app.locals.mastodonCollections;
306
+
307
+ await collections.ap_oauth_tokens.insertOne({
308
+ code,
309
+ clientId: client_id,
310
+ scopes: scope ? scope.split(/\s+/) : ["read"],
311
+ redirectUri: redirect_uri,
312
+ codeChallenge: code_challenge || null,
313
+ codeChallengeMethod: code_challenge_method || null,
314
+ accessToken: null,
315
+ createdAt: new Date(),
316
+ expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
317
+ usedAt: null,
318
+ revokedAt: null,
319
+ });
320
+
321
+ // Out-of-band: show code on page
322
+ if (!redirect_uri || redirect_uri === "urn:ietf:wg:oauth:2.0:oob") {
323
+ return res.type("html").send(`<!DOCTYPE html>
324
+ <html lang="en">
325
+ <head>
326
+ <meta charset="utf-8">
327
+ <meta name="viewport" content="width=device-width, initial-scale=1">
328
+ <title>Authorization Code</title>
329
+ <style>
330
+ body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
331
+ code { display: block; background: #f5f5f5; padding: 1rem; border-radius: 6px; word-break: break-all; margin: 1rem 0; }
332
+ </style>
333
+ </head>
334
+ <body>
335
+ <h1>Authorization Code</h1>
336
+ <p>Copy this code and paste it into the application:</p>
337
+ <code>${code}</code>
338
+ </body>
339
+ </html>`);
340
+ }
341
+
342
+ // Redirect with code
343
+ const url = new URL(redirect_uri);
344
+ url.searchParams.set("code", code);
345
+ res.redirect(url.toString());
346
+ } catch (error) {
347
+ next(error);
348
+ }
349
+ });
350
+
351
+ // ─── POST /oauth/token — Exchange code for access token ──────────────────────
352
+
353
+ router.post("/oauth/token", async (req, res, next) => {
354
+ try {
355
+ const { grant_type, code, redirect_uri, code_verifier } = req.body;
356
+
357
+ // Extract client credentials from request (3 methods)
358
+ const { clientId, clientSecret } = extractClientCredentials(req);
359
+
360
+ const collections = req.app.locals.mastodonCollections;
361
+
362
+ if (grant_type === "client_credentials") {
363
+ // Client credentials grant — limited access for pre-login API calls
364
+ if (!clientId || !clientSecret) {
365
+ return res.status(401).json({
366
+ error: "invalid_client",
367
+ error_description: "Client authentication required",
368
+ });
369
+ }
370
+
371
+ const app = await collections.ap_oauth_apps.findOne({
372
+ clientId,
373
+ clientSecret,
374
+ confidential: true,
375
+ });
376
+
377
+ if (!app) {
378
+ return res.status(401).json({
379
+ error: "invalid_client",
380
+ error_description: "Invalid client credentials",
381
+ });
382
+ }
383
+
384
+ const accessToken = randomHex(64);
385
+ await collections.ap_oauth_tokens.insertOne({
386
+ code: null,
387
+ clientId,
388
+ scopes: ["read"],
389
+ redirectUri: null,
390
+ codeChallenge: null,
391
+ codeChallengeMethod: null,
392
+ accessToken,
393
+ createdAt: new Date(),
394
+ expiresAt: null,
395
+ usedAt: null,
396
+ revokedAt: null,
397
+ grantType: "client_credentials",
398
+ });
399
+
400
+ return res.json({
401
+ access_token: accessToken,
402
+ token_type: "Bearer",
403
+ scope: "read",
404
+ created_at: Math.floor(Date.now() / 1000),
405
+ });
406
+ }
407
+
408
+ if (grant_type !== "authorization_code") {
409
+ return res.status(400).json({
410
+ error: "unsupported_grant_type",
411
+ error_description: "Only authorization_code and client_credentials are supported",
412
+ });
413
+ }
414
+
415
+ if (!code) {
416
+ return res.status(400).json({
417
+ error: "invalid_request",
418
+ error_description: "Missing authorization code",
419
+ });
420
+ }
421
+
422
+ // Atomic claim-or-fail: find the code and mark it used in one operation
423
+ const grant = await collections.ap_oauth_tokens.findOneAndUpdate(
424
+ {
425
+ code,
426
+ usedAt: null,
427
+ revokedAt: null,
428
+ expiresAt: { $gt: new Date() },
429
+ },
430
+ { $set: { usedAt: new Date() } },
431
+ { returnDocument: "before" },
432
+ );
433
+
434
+ if (!grant) {
435
+ return res.status(400).json({
436
+ error: "invalid_grant",
437
+ error_description:
438
+ "Authorization code is invalid, expired, or already used",
439
+ });
440
+ }
441
+
442
+ // Validate redirect_uri matches
443
+ if (redirect_uri && grant.redirectUri && redirect_uri !== grant.redirectUri) {
444
+ return res.status(400).json({
445
+ error: "invalid_grant",
446
+ error_description: "Redirect URI mismatch",
447
+ });
448
+ }
449
+
450
+ // Verify PKCE code_verifier if code_challenge was stored
451
+ if (grant.codeChallenge) {
452
+ if (!code_verifier) {
453
+ return res.status(400).json({
454
+ error: "invalid_grant",
455
+ error_description: "Missing code_verifier for PKCE",
456
+ });
457
+ }
458
+
459
+ const expectedChallenge = crypto
460
+ .createHash("sha256")
461
+ .update(code_verifier)
462
+ .digest("base64url");
463
+
464
+ if (expectedChallenge !== grant.codeChallenge) {
465
+ return res.status(400).json({
466
+ error: "invalid_grant",
467
+ error_description: "Invalid code_verifier",
468
+ });
469
+ }
470
+ }
471
+
472
+ // Generate access token
473
+ const accessToken = randomHex(64);
474
+ await collections.ap_oauth_tokens.updateOne(
475
+ { _id: grant._id },
476
+ { $set: { accessToken } },
477
+ );
478
+
479
+ res.json({
480
+ access_token: accessToken,
481
+ token_type: "Bearer",
482
+ scope: grant.scopes.join(" "),
483
+ created_at: Math.floor(grant.createdAt.getTime() / 1000),
484
+ });
485
+ } catch (error) {
486
+ next(error);
487
+ }
488
+ });
489
+
490
+ // ─── POST /oauth/revoke — Revoke a token ────────────────────────────────────
491
+
492
+ router.post("/oauth/revoke", async (req, res, next) => {
493
+ try {
494
+ const { token } = req.body;
495
+
496
+ if (!token) {
497
+ return res.status(400).json({
498
+ error: "invalid_request",
499
+ error_description: "Missing token parameter",
500
+ });
501
+ }
502
+
503
+ const collections = req.app.locals.mastodonCollections;
504
+ await collections.ap_oauth_tokens.updateOne(
505
+ { accessToken: token },
506
+ { $set: { revokedAt: new Date() } },
507
+ );
508
+
509
+ // RFC 7009: always return 200 even if token wasn't found
510
+ res.json({});
511
+ } catch (error) {
512
+ next(error);
513
+ }
514
+ });
515
+
516
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
517
+
518
+ /**
519
+ * Extract client credentials from request using 3 methods:
520
+ * 1. HTTP Basic Auth (client_secret_basic)
521
+ * 2. POST body (client_secret_post)
522
+ * 3. client_id only (none — public clients)
523
+ */
524
+ function extractClientCredentials(req) {
525
+ // Method 1: HTTP Basic Auth
526
+ const authHeader = req.get("authorization");
527
+ if (authHeader?.startsWith("Basic ")) {
528
+ const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
529
+ const colonIndex = decoded.indexOf(":");
530
+ if (colonIndex > 0) {
531
+ return {
532
+ clientId: decoded.slice(0, colonIndex),
533
+ clientSecret: decoded.slice(colonIndex + 1),
534
+ };
535
+ }
536
+ }
537
+
538
+ // Method 2 & 3: POST body
539
+ return {
540
+ clientId: req.body.client_id || null,
541
+ clientSecret: req.body.client_secret || null,
542
+ };
543
+ }
544
+
545
+ export default router;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Search endpoint for Mastodon Client API.
3
+ *
4
+ * GET /api/v2/search — search accounts, statuses, and hashtags
5
+ */
6
+ import express from "express";
7
+ import { serializeStatus } from "../entities/status.js";
8
+ import { serializeAccount } from "../entities/account.js";
9
+ import { parseLimit } from "../helpers/pagination.js";
10
+
11
+ const router = express.Router(); // eslint-disable-line new-cap
12
+
13
+ // ─── GET /api/v2/search ─────────────────────────────────────────────────────
14
+
15
+ router.get("/api/v2/search", async (req, res, next) => {
16
+ try {
17
+ const collections = req.app.locals.mastodonCollections;
18
+ const baseUrl = `${req.protocol}://${req.get("host")}`;
19
+ const query = (req.query.q || "").trim();
20
+ const type = req.query.type; // "accounts", "statuses", "hashtags", or undefined (all)
21
+ const limit = parseLimit(req.query.limit);
22
+ const offset = Math.max(0, Number.parseInt(req.query.offset, 10) || 0);
23
+
24
+ if (!query) {
25
+ return res.json({ accounts: [], statuses: [], hashtags: [] });
26
+ }
27
+
28
+ const results = { accounts: [], statuses: [], hashtags: [] };
29
+
30
+ // ─── Account search ──────────────────────────────────────────────────
31
+ if (!type || type === "accounts") {
32
+ const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+ const nameRegex = new RegExp(escapedQuery, "i");
34
+
35
+ // Search followers and following by display name or handle
36
+ const accountDocs = [];
37
+
38
+ if (collections.ap_followers) {
39
+ const followers = await collections.ap_followers
40
+ .find({
41
+ $or: [
42
+ { name: nameRegex },
43
+ { preferredUsername: nameRegex },
44
+ { url: nameRegex },
45
+ ],
46
+ })
47
+ .limit(limit)
48
+ .toArray();
49
+ accountDocs.push(...followers);
50
+ }
51
+
52
+ if (collections.ap_following) {
53
+ const following = await collections.ap_following
54
+ .find({
55
+ $or: [
56
+ { name: nameRegex },
57
+ { preferredUsername: nameRegex },
58
+ { url: nameRegex },
59
+ ],
60
+ })
61
+ .limit(limit)
62
+ .toArray();
63
+ accountDocs.push(...following);
64
+ }
65
+
66
+ // Deduplicate by URL
67
+ const seen = new Set();
68
+ for (const doc of accountDocs) {
69
+ const url = doc.url || doc.id;
70
+ if (url && !seen.has(url)) {
71
+ seen.add(url);
72
+ results.accounts.push(
73
+ serializeAccount(doc, { baseUrl, isRemote: true }),
74
+ );
75
+ }
76
+ if (results.accounts.length >= limit) break;
77
+ }
78
+ }
79
+
80
+ // ─── Status search ───────────────────────────────────────────────────
81
+ if (!type || type === "statuses") {
82
+ const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
83
+ const contentRegex = new RegExp(escapedQuery, "i");
84
+
85
+ const items = await collections.ap_timeline
86
+ .find({
87
+ isContext: { $ne: true },
88
+ $or: [
89
+ { "content.text": contentRegex },
90
+ { "content.html": contentRegex },
91
+ ],
92
+ })
93
+ .sort({ _id: -1 })
94
+ .skip(offset)
95
+ .limit(limit)
96
+ .toArray();
97
+
98
+ results.statuses = items.map((item) =>
99
+ serializeStatus(item, {
100
+ baseUrl,
101
+ favouritedIds: new Set(),
102
+ rebloggedIds: new Set(),
103
+ bookmarkedIds: new Set(),
104
+ pinnedIds: new Set(),
105
+ }),
106
+ );
107
+ }
108
+
109
+ // ─── Hashtag search ──────────────────────────────────────────────────
110
+ if (!type || type === "hashtags") {
111
+ const escapedQuery = query
112
+ .replace(/^#/, "")
113
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
114
+ const tagRegex = new RegExp(escapedQuery, "i");
115
+
116
+ // Find distinct category values matching the query
117
+ const allCategories = await collections.ap_timeline.distinct("category", {
118
+ category: tagRegex,
119
+ });
120
+
121
+ // Flatten and deduplicate (category can be string or array)
122
+ const tagSet = new Set();
123
+ for (const cat of allCategories) {
124
+ if (Array.isArray(cat)) {
125
+ for (const c of cat) {
126
+ if (typeof c === "string" && tagRegex.test(c)) tagSet.add(c);
127
+ }
128
+ } else if (typeof cat === "string" && tagRegex.test(cat)) {
129
+ tagSet.add(cat);
130
+ }
131
+ }
132
+
133
+ results.hashtags = [...tagSet].slice(0, limit).map((name) => ({
134
+ name,
135
+ url: `${baseUrl}/tags/${encodeURIComponent(name)}`,
136
+ history: [],
137
+ }));
138
+ }
139
+
140
+ res.json(results);
141
+ } catch (error) {
142
+ next(error);
143
+ }
144
+ });
145
+
146
+ export default router;