Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/ap/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createInboxHandler } from './routes/inbox.js'
import { createOutboxHandler, createOutboxPostHandler } from './routes/outbox.js'
import { createCollectionsHandler } from './routes/collections.js'
import { createActorHandler } from './routes/actor.js'
import { createAppsHandler, createVerifyCredentialsHandler, createInstanceHandler } from './routes/mastodon.js'

// Shared state for actor handler (accessed by server.js)
let sharedActorHandler = null
Expand Down Expand Up @@ -173,6 +174,11 @@ export async function activityPubPlugin(fastify, options = {}) {
const collectionsHandler = createCollectionsHandler(config)
fastify.get('/profile/card/followers', (req, reply) => collectionsHandler(req, reply, 'followers'))
fastify.get('/profile/card/following', (req, reply) => collectionsHandler(req, reply, 'following'))

// 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))
Comment on lines +178 to +181

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}

export default activityPubPlugin
154 changes: 154 additions & 0 deletions src/ap/routes/mastodon.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Mastodon-compatible API endpoints
* Allows Mastodon clients (Elk, Phanpy, Ice Cubes) to connect to JSS
*
* Step 1: Dynamic client registration + account verification
* Refs: https://docs.joinmastodon.org/methods/apps/
* https://docs.joinmastodon.org/methods/accounts/#verify_credentials
*/

// In-memory client store (replace with persistent storage later)
const clients = new Map()

Comment on lines +10 to +12

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
// Stable instance start time (used for created_at)
const startedAt = new Date().toISOString()
Comment on lines +10 to +14

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

/**
* Parse request body — handles both JSON and form-urlencoded
* (JSS uses raw buffer parser for all content types)
*/
function parseBody (request) {
if (request.body && typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
return request.body
}
const raw = Buffer.isBuffer(request.body) ? request.body.toString() : String(request.body || '')
const ct = request.headers['content-type'] || ''
if (ct.includes('application/json')) {
try { return JSON.parse(raw) } catch { return {} }
}
// Default: parse as form-urlencoded
return Object.fromEntries(new URLSearchParams(raw))
}

/**
* POST /api/v1/apps — Dynamic client registration
* Mastodon clients call this to register before OAuth
*/
export function createAppsHandler () {
return async (request, reply) => {
const body = parseBody(request)
const { client_name, redirect_uris, scopes, website } = body

if (!client_name || !redirect_uris) {
return reply.code(422).send({ error: 'client_name and redirect_uris are required' })
}
Comment on lines +38 to +44

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/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.

Copilot uses AI. Check for mistakes.

const clientId = crypto.randomUUID()
const clientSecret = crypto.randomUUID()

Comment on lines +45 to +48

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
const client = {
id: clientId,
name: client_name,
redirect_uri: redirect_uris,
client_id: clientId,
client_secret: clientSecret,
scopes: scopes || 'read',
website: website || null
}

clients.set(clientId, client)

return reply.send(client)
}
}

/**
* 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}`

Comment on lines +66 to +74

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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`,
Comment on lines +79 to +81

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>.

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +81

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
uri: `${baseUrl}/profile/card#me`,
avatar: `${baseUrl}/profile/avatar.png`,
header: '',
locked: false,
bot: false,
created_at: startedAt,
followers_count: 0,
Comment on lines +86 to +88

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
following_count: 0,
statuses_count: 0,
source: {
privacy: 'public',
sensitive: false,
language: 'en',
note: config.summary || ''
}
}

return reply.send(account)
}
}

/**
* GET /api/v1/instance — Instance information
* Required by most Mastodon clients before login
*/
export function createInstanceHandler (config) {
return async (request, reply) => {
const protocol = request.headers['x-forwarded-proto'] || request.protocol
const host = request.headers['x-forwarded-host'] || request.hostname
const wsProtocol = protocol === 'https' ? 'wss' : 'ws'

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: `${wsProtocol}://${host}`
},
Comment on lines +109 to +121

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
stats: {
user_count: 1,
status_count: 0,
domain_count: 1
},
languages: ['en'],
registrations: false,
approval_required: false,
configuration: {
statuses: { max_characters: 5000 },
media_attachments: { supported_mime_types: [] }
}
})
}
}

/**
* Look up a registered client
*/
export function getClient (clientId) {
return clients.get(clientId) || null
}

function escapeHtml (str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

export default {
createAppsHandler,
createVerifyCredentialsHandler,
createInstanceHandler,
getClient
}
3 changes: 2 additions & 1 deletion src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,8 @@ export function createServer(options = {}) {
fastify.addHook('preHandler', async (request, reply) => {
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, solidos-ui, well-known, notifications, nostr, git, and AP
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'];

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/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.

Suggested change
'/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials'];
'/api/v1/apps', '/api/v1/instance'];

Copilot uses AI. Check for mistakes.
// Check if request wants ActivityPub content for profile
const accept = request.headers.accept || '';
const wantsAP = accept.includes('activity+json') || accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');
Expand Down