Files
Ricardo 2c0cfffd54 feat: add Mastodon Client API layer for Phanpy/Elk compatibility
Implement the Mastodon Client REST API (/api/v1/*, /api/v2/*) and OAuth2
server within the ActivityPub plugin, enabling Mastodon-compatible clients
to connect to the Fedify-based server.

Core features:
- OAuth2 with PKCE (S256) — app registration, authorization, token exchange
- Instance info + nodeinfo for client discovery
- Account lookup, verification, relationships, follow/unfollow/mute/block
- Home/public/hashtag timelines with cursor-based pagination
- Status viewing, creation, deletion, thread context
- Favourite, boost, bookmark interactions with AP federation
- Notifications with type filtering and pagination
- Search across accounts, statuses, and hashtags
- Markers for read position tracking
- Bookmarks and favourites collection lists
- 25+ stub endpoints preventing client errors on unimplemented features

Architecture:
- 24 new files under lib/mastodon/ (entities, helpers, middleware, routes)
- Virtual endpoint at "/" via Indiekit.addEndpoint() for domain-root access
- CORS + JSON error handling for browser-based clients
- Six-layer mute/block filtering reusing existing moderation infrastructure

BREAKING CHANGE: bumps to v3.0.0 — adds new MongoDB collections
(ap_oauth_apps, ap_oauth_tokens, ap_markers) and new route registrations

Confab-Link: http://localhost:8080/sessions/5360e3f5-b3cc-4bf3-8c31-5448e2b23947
2026-03-18 12:50:52 +01:00

87 lines
2.3 KiB
JavaScript

/**
* Scope enforcement middleware for Mastodon Client API.
*
* Supports scope hierarchy: parent scope covers all children.
* "read" grants "read:accounts", "read:statuses", etc.
* "write" grants "write:statuses", "write:favourites", etc.
*
* Legacy "follow" scope maps to read/write for blocks, follows, and mutes.
*/
/**
* Scopes that the legacy "follow" scope grants access to.
*/
const FOLLOW_SCOPE_EXPANSION = [
"read:blocks",
"write:blocks",
"read:follows",
"write:follows",
"read:mutes",
"write:mutes",
];
/**
* Create middleware that checks if the token has the required scope.
*
* @param {...string} requiredScopes - One or more scopes (any match = pass)
* @returns {Function} Express middleware
*/
export function scopeRequired(...requiredScopes) {
return (req, res, next) => {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({
error: "The access token is invalid",
});
}
const grantedScopes = token.scopes || [];
const hasScope = requiredScopes.some((required) =>
checkScope(grantedScopes, required),
);
if (!hasScope) {
return res.status(403).json({
error: `This action is outside the authorized scopes. Required: ${requiredScopes.join(" or ")}`,
});
}
next();
};
}
/**
* Check if granted scopes satisfy a required scope.
*
* Rules:
* - Exact match: "read:accounts" satisfies "read:accounts"
* - Parent match: "read" satisfies "read:accounts"
* - "follow" expands to read/write for blocks, follows, mutes
* - "profile" satisfies "read:accounts" (for verify_credentials)
*
* @param {string[]} granted - Scopes on the token
* @param {string} required - Scope being checked
* @returns {boolean}
*/
function checkScope(granted, required) {
// Exact match
if (granted.includes(required)) return true;
// Parent scope: "read" covers "read:*", "write" covers "write:*"
const [parent] = required.split(":");
if (parent && granted.includes(parent)) return true;
// Legacy "follow" scope expansion
if (granted.includes("follow") && FOLLOW_SCOPE_EXPANSION.includes(required)) {
return true;
}
// "profile" scope can satisfy "read:accounts"
if (required === "read:accounts" && granted.includes("profile")) {
return true;
}
return false;
}