@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,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
+ }