@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.
- package/index.js +47 -0
- package/lib/mastodon/entities/account.js +200 -0
- package/lib/mastodon/entities/instance.js +1 -0
- package/lib/mastodon/entities/media.js +38 -0
- package/lib/mastodon/entities/notification.js +118 -0
- package/lib/mastodon/entities/relationship.js +38 -0
- package/lib/mastodon/entities/sanitize.js +111 -0
- package/lib/mastodon/entities/status.js +289 -0
- package/lib/mastodon/helpers/id-mapping.js +32 -0
- package/lib/mastodon/helpers/interactions.js +278 -0
- package/lib/mastodon/helpers/pagination.js +130 -0
- package/lib/mastodon/middleware/cors.js +25 -0
- package/lib/mastodon/middleware/error-handler.js +37 -0
- package/lib/mastodon/middleware/scope-required.js +86 -0
- package/lib/mastodon/middleware/token-required.js +57 -0
- package/lib/mastodon/router.js +96 -0
- package/lib/mastodon/routes/accounts.js +740 -0
- package/lib/mastodon/routes/instance.js +207 -0
- package/lib/mastodon/routes/media.js +43 -0
- package/lib/mastodon/routes/notifications.js +257 -0
- package/lib/mastodon/routes/oauth.js +545 -0
- package/lib/mastodon/routes/search.js +146 -0
- package/lib/mastodon/routes/statuses.js +605 -0
- package/lib/mastodon/routes/stubs.js +380 -0
- package/lib/mastodon/routes/timelines.js +296 -0
- package/package.json +2 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mastodon-compatible cursor pagination helpers.
|
|
3
|
+
*
|
|
4
|
+
* Uses MongoDB ObjectId as cursor (chronologically ordered).
|
|
5
|
+
* Emits RFC 8288 Link headers that masto.js / Phanpy parse.
|
|
6
|
+
*/
|
|
7
|
+
import { ObjectId } from "mongodb";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_LIMIT = 20;
|
|
10
|
+
const MAX_LIMIT = 40;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse and clamp the limit parameter.
|
|
14
|
+
*
|
|
15
|
+
* @param {string|number} raw - Raw limit value from query string
|
|
16
|
+
* @returns {number}
|
|
17
|
+
*/
|
|
18
|
+
export function parseLimit(raw) {
|
|
19
|
+
const n = Number.parseInt(String(raw), 10);
|
|
20
|
+
if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT;
|
|
21
|
+
return Math.min(n, MAX_LIMIT);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a MongoDB filter object for cursor-based pagination.
|
|
26
|
+
*
|
|
27
|
+
* Mastodon cursor params (all optional, applied to `_id`):
|
|
28
|
+
* max_id — return items older than this ID (exclusive)
|
|
29
|
+
* min_id — return items newer than this ID (exclusive), closest first
|
|
30
|
+
* since_id — return items newer than this ID (exclusive), most recent first
|
|
31
|
+
*
|
|
32
|
+
* @param {object} baseFilter - Existing MongoDB filter to extend
|
|
33
|
+
* @param {object} cursors
|
|
34
|
+
* @param {string} [cursors.max_id]
|
|
35
|
+
* @param {string} [cursors.min_id]
|
|
36
|
+
* @param {string} [cursors.since_id]
|
|
37
|
+
* @returns {{ filter: object, sort: object, reverse: boolean }}
|
|
38
|
+
*/
|
|
39
|
+
export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } = {}) {
|
|
40
|
+
const filter = { ...baseFilter };
|
|
41
|
+
let sort = { _id: -1 }; // newest first (default)
|
|
42
|
+
let reverse = false;
|
|
43
|
+
|
|
44
|
+
if (max_id) {
|
|
45
|
+
try {
|
|
46
|
+
filter._id = { ...filter._id, $lt: new ObjectId(max_id) };
|
|
47
|
+
} catch {
|
|
48
|
+
// Invalid ObjectId — ignore
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (since_id) {
|
|
53
|
+
try {
|
|
54
|
+
filter._id = { ...filter._id, $gt: new ObjectId(since_id) };
|
|
55
|
+
} catch {
|
|
56
|
+
// Invalid ObjectId — ignore
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (min_id) {
|
|
61
|
+
try {
|
|
62
|
+
filter._id = { ...filter._id, $gt: new ObjectId(min_id) };
|
|
63
|
+
// min_id returns results closest to the cursor, so sort ascending
|
|
64
|
+
// then reverse the results before returning
|
|
65
|
+
sort = { _id: 1 };
|
|
66
|
+
reverse = true;
|
|
67
|
+
} catch {
|
|
68
|
+
// Invalid ObjectId — ignore
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { filter, sort, reverse };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Set the Link pagination header on an Express response.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} res - Express response object
|
|
79
|
+
* @param {object} req - Express request object (for building URLs)
|
|
80
|
+
* @param {Array} items - Result items (must have `_id` or `id`)
|
|
81
|
+
* @param {number} limit - The limit used for the query
|
|
82
|
+
*/
|
|
83
|
+
export function setPaginationHeaders(res, req, items, limit) {
|
|
84
|
+
if (!items?.length) return;
|
|
85
|
+
|
|
86
|
+
// Only emit Link if we got a full page (may have more)
|
|
87
|
+
if (items.length < limit) return;
|
|
88
|
+
|
|
89
|
+
const firstId = itemId(items[0]);
|
|
90
|
+
const lastId = itemId(items[items.length - 1]);
|
|
91
|
+
|
|
92
|
+
if (!firstId || !lastId) return;
|
|
93
|
+
|
|
94
|
+
const baseUrl = `${req.protocol}://${req.get("host")}${req.path}`;
|
|
95
|
+
|
|
96
|
+
// Preserve existing query params (like types[] for notifications)
|
|
97
|
+
const existingParams = new URLSearchParams();
|
|
98
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
99
|
+
if (key === "max_id" || key === "min_id" || key === "since_id") continue;
|
|
100
|
+
if (Array.isArray(value)) {
|
|
101
|
+
for (const v of value) existingParams.append(key, v);
|
|
102
|
+
} else {
|
|
103
|
+
existingParams.set(key, String(value));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const links = [];
|
|
108
|
+
|
|
109
|
+
// rel="next" — older items (max_id = last item's ID)
|
|
110
|
+
const nextParams = new URLSearchParams(existingParams);
|
|
111
|
+
nextParams.set("max_id", lastId);
|
|
112
|
+
links.push(`<${baseUrl}?${nextParams.toString()}>; rel="next"`);
|
|
113
|
+
|
|
114
|
+
// rel="prev" — newer items (min_id = first item's ID)
|
|
115
|
+
const prevParams = new URLSearchParams(existingParams);
|
|
116
|
+
prevParams.set("min_id", firstId);
|
|
117
|
+
links.push(`<${baseUrl}?${prevParams.toString()}>; rel="prev"`);
|
|
118
|
+
|
|
119
|
+
res.set("Link", links.join(", "));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Extract the string ID from an item.
|
|
124
|
+
*/
|
|
125
|
+
function itemId(item) {
|
|
126
|
+
if (!item) return null;
|
|
127
|
+
if (item._id) return item._id.toString();
|
|
128
|
+
if (item.id) return String(item.id);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS middleware for Mastodon Client API routes.
|
|
3
|
+
*
|
|
4
|
+
* Mandatory for browser-based SPA clients like Phanpy that make
|
|
5
|
+
* cross-origin requests. Without this, the browser's Same-Origin
|
|
6
|
+
* Policy blocks all API calls.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const ALLOWED_METHODS = "GET, HEAD, POST, PUT, DELETE, PATCH";
|
|
10
|
+
const ALLOWED_HEADERS = "Authorization, Content-Type, Idempotency-Key";
|
|
11
|
+
const EXPOSED_HEADERS = "Link";
|
|
12
|
+
|
|
13
|
+
export function corsMiddleware(req, res, next) {
|
|
14
|
+
res.set("Access-Control-Allow-Origin", "*");
|
|
15
|
+
res.set("Access-Control-Allow-Methods", ALLOWED_METHODS);
|
|
16
|
+
res.set("Access-Control-Allow-Headers", ALLOWED_HEADERS);
|
|
17
|
+
res.set("Access-Control-Expose-Headers", EXPOSED_HEADERS);
|
|
18
|
+
|
|
19
|
+
// Handle preflight requests
|
|
20
|
+
if (req.method === "OPTIONS") {
|
|
21
|
+
return res.status(204).end();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
next();
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error handling middleware for Mastodon Client API routes.
|
|
3
|
+
*
|
|
4
|
+
* Ensures all errors return JSON in Mastodon's expected format
|
|
5
|
+
* instead of HTML error pages that masto.js cannot parse.
|
|
6
|
+
*
|
|
7
|
+
* Standard format: { "error": "description" }
|
|
8
|
+
* OAuth format: { "error": "error_type", "error_description": "..." }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// eslint-disable-next-line no-unused-vars
|
|
12
|
+
export function errorHandler(err, req, res, _next) {
|
|
13
|
+
const status = err.status || err.statusCode || 500;
|
|
14
|
+
|
|
15
|
+
// OAuth errors use RFC 6749 format
|
|
16
|
+
if (err.oauthError) {
|
|
17
|
+
return res.status(status).json({
|
|
18
|
+
error: err.oauthError,
|
|
19
|
+
error_description: err.message || "An error occurred",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Standard Mastodon error format
|
|
24
|
+
res.status(status).json({
|
|
25
|
+
error: err.message || "An unexpected error occurred",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 501 catch-all for unimplemented API endpoints.
|
|
31
|
+
* Must be mounted AFTER all implemented routes.
|
|
32
|
+
*/
|
|
33
|
+
export function notImplementedHandler(req, res) {
|
|
34
|
+
res.status(501).json({
|
|
35
|
+
error: "Not implemented",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope enforcement middleware for Mastodon Client API.
|
|
3
|
+
*
|
|
4
|
+
* Supports scope hierarchy: parent scope covers all children.
|
|
5
|
+
* "read" grants "read:accounts", "read:statuses", etc.
|
|
6
|
+
* "write" grants "write:statuses", "write:favourites", etc.
|
|
7
|
+
*
|
|
8
|
+
* Legacy "follow" scope maps to read/write for blocks, follows, and mutes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Scopes that the legacy "follow" scope grants access to.
|
|
13
|
+
*/
|
|
14
|
+
const FOLLOW_SCOPE_EXPANSION = [
|
|
15
|
+
"read:blocks",
|
|
16
|
+
"write:blocks",
|
|
17
|
+
"read:follows",
|
|
18
|
+
"write:follows",
|
|
19
|
+
"read:mutes",
|
|
20
|
+
"write:mutes",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create middleware that checks if the token has the required scope.
|
|
25
|
+
*
|
|
26
|
+
* @param {...string} requiredScopes - One or more scopes (any match = pass)
|
|
27
|
+
* @returns {Function} Express middleware
|
|
28
|
+
*/
|
|
29
|
+
export function scopeRequired(...requiredScopes) {
|
|
30
|
+
return (req, res, next) => {
|
|
31
|
+
const token = req.mastodonToken;
|
|
32
|
+
if (!token) {
|
|
33
|
+
return res.status(401).json({
|
|
34
|
+
error: "The access token is invalid",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const grantedScopes = token.scopes || [];
|
|
39
|
+
|
|
40
|
+
const hasScope = requiredScopes.some((required) =>
|
|
41
|
+
checkScope(grantedScopes, required),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (!hasScope) {
|
|
45
|
+
return res.status(403).json({
|
|
46
|
+
error: `This action is outside the authorized scopes. Required: ${requiredScopes.join(" or ")}`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
next();
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if granted scopes satisfy a required scope.
|
|
56
|
+
*
|
|
57
|
+
* Rules:
|
|
58
|
+
* - Exact match: "read:accounts" satisfies "read:accounts"
|
|
59
|
+
* - Parent match: "read" satisfies "read:accounts"
|
|
60
|
+
* - "follow" expands to read/write for blocks, follows, mutes
|
|
61
|
+
* - "profile" satisfies "read:accounts" (for verify_credentials)
|
|
62
|
+
*
|
|
63
|
+
* @param {string[]} granted - Scopes on the token
|
|
64
|
+
* @param {string} required - Scope being checked
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
function checkScope(granted, required) {
|
|
68
|
+
// Exact match
|
|
69
|
+
if (granted.includes(required)) return true;
|
|
70
|
+
|
|
71
|
+
// Parent scope: "read" covers "read:*", "write" covers "write:*"
|
|
72
|
+
const [parent] = required.split(":");
|
|
73
|
+
if (parent && granted.includes(parent)) return true;
|
|
74
|
+
|
|
75
|
+
// Legacy "follow" scope expansion
|
|
76
|
+
if (granted.includes("follow") && FOLLOW_SCOPE_EXPANSION.includes(required)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// "profile" scope can satisfy "read:accounts"
|
|
81
|
+
if (required === "read:accounts" && granted.includes("profile")) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer token validation middleware for Mastodon Client API.
|
|
3
|
+
*
|
|
4
|
+
* Extracts the Bearer token from the Authorization header,
|
|
5
|
+
* validates it against the ap_oauth_tokens collection,
|
|
6
|
+
* and attaches token data to `req.mastodonToken`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Require a valid Bearer token. Returns 401 if invalid/missing.
|
|
11
|
+
*/
|
|
12
|
+
export async function tokenRequired(req, res, next) {
|
|
13
|
+
const token = await resolveToken(req);
|
|
14
|
+
|
|
15
|
+
if (!token) {
|
|
16
|
+
return res.status(401).json({
|
|
17
|
+
error: "The access token is invalid",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
req.mastodonToken = token;
|
|
22
|
+
next();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Optional token — sets req.mastodonToken to null if absent.
|
|
27
|
+
* For public endpoints that personalize when authenticated.
|
|
28
|
+
*/
|
|
29
|
+
export async function optionalToken(req, res, next) {
|
|
30
|
+
req.mastodonToken = await resolveToken(req);
|
|
31
|
+
next();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract and validate Bearer token from request.
|
|
36
|
+
* @returns {object|null} Token document or null
|
|
37
|
+
*/
|
|
38
|
+
async function resolveToken(req) {
|
|
39
|
+
const authHeader = req.get("authorization");
|
|
40
|
+
if (!authHeader?.startsWith("Bearer ")) return null;
|
|
41
|
+
|
|
42
|
+
const accessToken = authHeader.slice(7);
|
|
43
|
+
if (!accessToken) return null;
|
|
44
|
+
|
|
45
|
+
const collections = req.app.locals.mastodonCollections;
|
|
46
|
+
const token = await collections.ap_oauth_tokens.findOne({
|
|
47
|
+
accessToken,
|
|
48
|
+
revokedAt: null,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!token) return null;
|
|
52
|
+
|
|
53
|
+
// Check expiry if set
|
|
54
|
+
if (token.expiresAt && token.expiresAt < new Date()) return null;
|
|
55
|
+
|
|
56
|
+
return token;
|
|
57
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mastodon Client API — main router.
|
|
3
|
+
*
|
|
4
|
+
* Combines all sub-routers, applies CORS and error handling middleware.
|
|
5
|
+
* Mounted at "/" via Indiekit.addEndpoint() so Mastodon clients can access
|
|
6
|
+
* /api/v1/*, /api/v2/*, /oauth/* at the domain root.
|
|
7
|
+
*/
|
|
8
|
+
import express from "express";
|
|
9
|
+
import { corsMiddleware } from "./middleware/cors.js";
|
|
10
|
+
import { tokenRequired, optionalToken } from "./middleware/token-required.js";
|
|
11
|
+
import { errorHandler, notImplementedHandler } from "./middleware/error-handler.js";
|
|
12
|
+
|
|
13
|
+
// Route modules
|
|
14
|
+
import oauthRouter from "./routes/oauth.js";
|
|
15
|
+
import instanceRouter from "./routes/instance.js";
|
|
16
|
+
import accountsRouter from "./routes/accounts.js";
|
|
17
|
+
import statusesRouter from "./routes/statuses.js";
|
|
18
|
+
import timelinesRouter from "./routes/timelines.js";
|
|
19
|
+
import notificationsRouter from "./routes/notifications.js";
|
|
20
|
+
import searchRouter from "./routes/search.js";
|
|
21
|
+
import mediaRouter from "./routes/media.js";
|
|
22
|
+
import stubsRouter from "./routes/stubs.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create the combined Mastodon API router.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} options
|
|
28
|
+
* @param {object} options.collections - MongoDB collections object
|
|
29
|
+
* @param {object} [options.pluginOptions] - Plugin options (handle, etc.)
|
|
30
|
+
* @returns {import("express").Router} Express router
|
|
31
|
+
*/
|
|
32
|
+
export function createMastodonRouter({ collections, pluginOptions = {} }) {
|
|
33
|
+
const router = express.Router(); // eslint-disable-line new-cap
|
|
34
|
+
|
|
35
|
+
// ─── Body parsers ───────────────────────────────────────────────────────
|
|
36
|
+
// Mastodon clients send JSON, form-urlencoded, and occasionally text/plain.
|
|
37
|
+
// These must be applied before route handlers.
|
|
38
|
+
router.use("/api", express.json());
|
|
39
|
+
router.use("/api", express.urlencoded({ extended: true }));
|
|
40
|
+
router.use("/oauth", express.json());
|
|
41
|
+
router.use("/oauth", express.urlencoded({ extended: true }));
|
|
42
|
+
|
|
43
|
+
// ─── CORS ───────────────────────────────────────────────────────────────
|
|
44
|
+
router.use("/api", corsMiddleware);
|
|
45
|
+
router.use("/oauth/token", corsMiddleware);
|
|
46
|
+
router.use("/oauth/revoke", corsMiddleware);
|
|
47
|
+
router.use("/.well-known/oauth-authorization-server", corsMiddleware);
|
|
48
|
+
|
|
49
|
+
// ─── Inject collections + plugin options into req ───────────────────────
|
|
50
|
+
router.use("/api", (req, res, next) => {
|
|
51
|
+
req.app.locals.mastodonCollections = collections;
|
|
52
|
+
req.app.locals.mastodonPluginOptions = pluginOptions;
|
|
53
|
+
next();
|
|
54
|
+
});
|
|
55
|
+
router.use("/oauth", (req, res, next) => {
|
|
56
|
+
req.app.locals.mastodonCollections = collections;
|
|
57
|
+
req.app.locals.mastodonPluginOptions = pluginOptions;
|
|
58
|
+
next();
|
|
59
|
+
});
|
|
60
|
+
router.use("/.well-known/oauth-authorization-server", (req, res, next) => {
|
|
61
|
+
req.app.locals.mastodonCollections = collections;
|
|
62
|
+
req.app.locals.mastodonPluginOptions = pluginOptions;
|
|
63
|
+
next();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ─── Token resolution ───────────────────────────────────────────────────
|
|
67
|
+
// Apply optional token resolution to all API routes so handlers can check
|
|
68
|
+
// req.mastodonToken. Specific routes that require auth use tokenRequired.
|
|
69
|
+
router.use("/api", optionalToken);
|
|
70
|
+
|
|
71
|
+
// ─── OAuth routes (no token required for most) ──────────────────────────
|
|
72
|
+
router.use(oauthRouter);
|
|
73
|
+
|
|
74
|
+
// ─── Public API routes (no auth required) ───────────────────────────────
|
|
75
|
+
router.use(instanceRouter);
|
|
76
|
+
|
|
77
|
+
// ─── Authenticated API routes ───────────────────────────────────────────
|
|
78
|
+
router.use(accountsRouter);
|
|
79
|
+
router.use(statusesRouter);
|
|
80
|
+
router.use(timelinesRouter);
|
|
81
|
+
router.use(notificationsRouter);
|
|
82
|
+
router.use(searchRouter);
|
|
83
|
+
router.use(mediaRouter);
|
|
84
|
+
router.use(stubsRouter);
|
|
85
|
+
|
|
86
|
+
// ─── Catch-all for unimplemented endpoints ──────────────────────────────
|
|
87
|
+
// Express 5 path-to-regexp v8: use {*name} for wildcard
|
|
88
|
+
router.all("/api/v1/{*rest}", notImplementedHandler);
|
|
89
|
+
router.all("/api/v2/{*rest}", notImplementedHandler);
|
|
90
|
+
|
|
91
|
+
// ─── Error handler ──────────────────────────────────────────────────────
|
|
92
|
+
router.use("/api", errorHandler);
|
|
93
|
+
router.use("/oauth", errorHandler);
|
|
94
|
+
|
|
95
|
+
return router;
|
|
96
|
+
}
|