Add Mastodon-compatible API endpoints - #159
Conversation
Adds /api/v1/apps (dynamic client registration), /api/v1/accounts/verify_credentials, and /api/v1/instance so Mastodon clients can connect to JSS. Refs #158
There was a problem hiding this comment.
Pull request overview
Adds a minimal set of Mastodon-compatible API endpoints under the existing ActivityPub Fastify plugin so Mastodon clients can attempt to connect to JSS.
Changes:
- Introduces new Mastodon route handlers for
/api/v1/apps,/api/v1/accounts/verify_credentials, and/api/v1/instance. - Registers these new routes in the ActivityPub plugin.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| src/ap/routes/mastodon.js | Implements Mastodon-compatible handlers (app registration, verify credentials, instance info) plus an in-memory client store. |
| src/ap/index.js | Wires the new Mastodon endpoints into the ActivityPub Fastify plugin routes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Mastodon-compatible API endpoints | ||
| fastify.post('/api/v1/apps', createAppsHandler()) | ||
| fastify.get('/api/v1/accounts/verify_credentials', createVerifyCredentialsHandler(config)) | ||
| fastify.get('/api/v1/instance', createInstanceHandler(config)) |
There was a problem hiding this comment.
These Mastodon endpoints will still be subject to the global WAC preHandler authorization hook. In default root ACL generation, public read is not inherited to child paths, so unauthenticated clients are likely to get 401 on /api/v1/instance and /api/v1/apps (both need to be public for Mastodon login/registration). Consider marking these routes as public (e.g., route config: { public: true }) or adding them to the auth-skip list.
| return async (request, reply) => { | ||
| const { client_name, redirect_uris, scopes, website } = request.body || {} | ||
|
|
||
| if (!client_name || !redirect_uris) { | ||
| return reply.code(422).send({ error: 'client_name and redirect_uris are required' }) | ||
| } |
There was a problem hiding this comment.
/api/v1/apps will reject clients that POST application/x-www-form-urlencoded (common for Mastodon client registration), because request.body won’t be a decoded object without a form-body parser. Consider adding @fastify/formbody (or parsing Buffer bodies for form-encoded requests) so client_name / redirect_uris are actually read.
| // In-memory client store (replace with persistent storage later) | ||
| const clients = new Map() | ||
|
|
There was a problem hiding this comment.
This in-memory clients Map grows without bounds and has no TTL/eviction. If /api/v1/apps is made publicly accessible (as Mastodon expects), it can be spammed to cause unbounded memory growth. Consider adding a max size + eviction/expiry, persisting to storage, and/or applying per-IP rate limiting on the route.
| display_name: config.displayName, | ||
| note: config.summary ? `<p>${config.summary}</p>` : '', | ||
| url: `${baseUrl}/profile/card`, |
There was a problem hiding this comment.
note is HTML in the Mastodon API, but this interpolates config.summary directly into HTML without escaping/sanitization. If summary can contain user-provided text, Mastodon clients may render it and become vulnerable to XSS/markup injection. Consider HTML-escaping or sanitizing config.summary before wrapping it in <p>…</p>.
| bot: false, | ||
| created_at: new Date().toISOString(), | ||
| followers_count: 0, |
There was a problem hiding this comment.
created_at is being set to the current time on every verify_credentials call, which makes the account appear newly created each request and can confuse clients/caching. Consider using a stable creation timestamp (from config/storage) instead of new Date() per request.
| const protocol = request.headers['x-forwarded-proto'] || request.protocol | ||
| const host = request.headers['x-forwarded-host'] || request.hostname | ||
| const baseUrl = `${protocol}://${host}` | ||
|
|
||
| return reply.send({ | ||
| uri: host, | ||
| title: config.displayName || 'JSS', | ||
| description: 'SAND Stack: Solid + ActivityPub + Nostr + DID', | ||
| short_description: 'Solid pod with Mastodon-compatible API', | ||
| version: '4.0.0 (compatible; JSS 0.0.67)', | ||
| urls: { | ||
| streaming_api: `wss://${host}` | ||
| }, |
There was a problem hiding this comment.
baseUrl is computed but unused, and streaming_api is hard-coded to wss:// regardless of the actual request protocol. Either remove the unused baseUrl or use it to derive streaming_api (e.g., ws vs wss) to avoid incorrect URLs in non-HTTPS/dev setups.
- Skip WAC auth for /api/v1/* routes (clients need public access) - Parse form-urlencoded bodies (Mastodon clients use form encoding) - Use stable created_at timestamp instead of per-request new Date() - Derive ws/wss protocol from request instead of hardcoding wss - HTML-escape summary in verify_credentials response
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| request.url.startsWith('/.well-known/') || | ||
| request.url.startsWith('/solidos-ui/') || | ||
| (nostrEnabled && request.url.startsWith(nostrPath)) || | ||
| (gitEnabled && isGitRequest(request.url)) || | ||
| (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) || | ||
| (activitypubEnabled && (apPaths.some(p => request.url === p || request.url.startsWith(p + '?')) || request.url.startsWith('/api/v1/'))) || | ||
| isProfileAP || |
There was a problem hiding this comment.
The auth-skip condition request.url.startsWith('/api/v1/') applies before routing and will also bypass WAC for the catch-all /* resource routes. That makes any Solid resource created under /api/v1/... publicly accessible when ActivityPub is enabled. Restrict the skip to the exact Mastodon endpoints (e.g. /api/v1/apps, /api/v1/instance, /api/v1/accounts/verify_credentials) or key off request.routeOptions.url/request.routerPath so only those Fastify routes bypass auth.
| * GET /api/v1/accounts/verify_credentials — Who am I? | ||
| * Returns the authenticated user's profile as a Mastodon Account object | ||
| */ | ||
| export function createVerifyCredentialsHandler (config) { | ||
| return async (request, reply) => { | ||
| const protocol = request.headers['x-forwarded-proto'] || request.protocol | ||
| const host = request.headers['x-forwarded-host'] || request.hostname | ||
| const baseUrl = `${protocol}://${host}` | ||
|
|
There was a problem hiding this comment.
verify_credentials currently returns an account for any caller, even without an Authorization header, so it isn't actually verifying credentials (and can confuse Mastodon clients that expect 401 when unauthenticated). Consider requiring a valid token (e.g., via existing auth/token utilities) and returning 401 when no/invalid auth is provided.
| // In-memory client store (replace with persistent storage later) | ||
| const clients = new Map() | ||
|
|
||
| // Stable instance start time (used for created_at) | ||
| const startedAt = new Date().toISOString() |
There was a problem hiding this comment.
clients is a module-level Map with no size/TTL limits. Because /api/v1/apps is unauthenticated, this can be abused to grow memory unbounded (and the Map will also be shared across multiple Fastify server instances in the same process). Consider scoping client storage to the Fastify instance and enforcing an eviction policy (TTL/LRU) and/or rate limiting on registration.
|
|
||
| const clientId = crypto.randomUUID() | ||
| const clientSecret = crypto.randomUUID() | ||
|
|
There was a problem hiding this comment.
This file uses crypto.randomUUID() but doesn't import crypto. Elsewhere in the repo UUID generation is done via import crypto from 'crypto' or import { randomUUID } from 'crypto'; relying on a global crypto object is less consistent and can break depending on runtime configuration. Please import from Node's crypto module for consistency.
Instead of skipping WAC for all /api/v1/* paths, enumerate the specific Mastodon endpoints that need public access.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js']; | ||
| const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following']; | ||
| const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following', | ||
| '/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials']; |
There was a problem hiding this comment.
/api/v1/accounts/verify_credentials is included in the auth-skip allowlist, which makes the endpoint callable without any authentication/WAC checks. For a Mastodon-compatible verify_credentials, this should require a valid access token (or at minimum a verified WebID). Suggestion: remove this path from the skip list and/or enforce auth inside the handler so unauthenticated requests get a 401.
| '/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials']; | |
| '/api/v1/apps', '/api/v1/instance']; |
| export function createVerifyCredentialsHandler (config) { | ||
| return async (request, reply) => { | ||
| const protocol = request.headers['x-forwarded-proto'] || request.protocol | ||
| const host = request.headers['x-forwarded-host'] || request.hostname | ||
| const baseUrl = `${protocol}://${host}` | ||
|
|
||
| const account = { | ||
| id: '1', | ||
| username: config.username, | ||
| acct: config.username, | ||
| display_name: config.displayName, | ||
| note: config.summary ? `<p>${escapeHtml(config.summary)}</p>` : '', | ||
| url: `${baseUrl}/profile/card`, |
There was a problem hiding this comment.
GET /api/v1/accounts/verify_credentials returns an account object purely from server config and does not check for an access token / authenticated identity. Combined with the server auth bypass, this effectively makes the endpoint public and allows any caller to obtain user metadata. This handler should validate authentication (e.g., require a verified WebID/access token and return 401 when missing/invalid).
Summary
POST /api/v1/apps— dynamic client registration for Mastodon clientsGET /api/v1/accounts/verify_credentials— returns WebID profile as Mastodon AccountGET /api/v1/instance— instance metadata for client discoveryThis is Step 1 of Mastodon API compatibility. After this, clients like Elk, Phanpy, and Ice Cubes can connect to a JSS instance and identify the user.
Motivation
The fediverse has ~10 million active users. JSS already speaks ActivityPub. Adding the Mastodon-compatible API surface lets existing Mastodon clients connect to JSS without users installing anything new — 10 million potential users, zero onboarding friction.
OIDC already provides the OAuth 2.0 layer. These endpoints add the Mastodon-specific API surface on top.
Next steps
GET /api/v1/timelines/home— pod content as a Mastodon feedPOST /api/v1/statuses— write to pod, federate via ActivityPubCloses #158
Test plan
POST /api/v1/appsreturns client_id and client_secretGET /api/v1/instancereturns valid instance metadataGET /api/v1/accounts/verify_credentialsreturns account object/api/v1/instance