Skip to content

Commit ebc9d95

Browse files
Merge pull request JavaScriptSolidServer#159 from JavaScriptSolidServer/feature/mastodon-api
Add Mastodon-compatible API endpoints
2 parents ff5ec84 + 1066da2 commit ebc9d95

3 files changed

Lines changed: 162 additions & 1 deletion

File tree

src/ap/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { createInboxHandler } from './routes/inbox.js'
1010
import { createOutboxHandler, createOutboxPostHandler } from './routes/outbox.js'
1111
import { createCollectionsHandler } from './routes/collections.js'
1212
import { createActorHandler } from './routes/actor.js'
13+
import { createAppsHandler, createVerifyCredentialsHandler, createInstanceHandler } from './routes/mastodon.js'
1314

1415
// Shared state for actor handler (accessed by server.js)
1516
let sharedActorHandler = null
@@ -173,6 +174,11 @@ export async function activityPubPlugin(fastify, options = {}) {
173174
const collectionsHandler = createCollectionsHandler(config)
174175
fastify.get('/profile/card/followers', (req, reply) => collectionsHandler(req, reply, 'followers'))
175176
fastify.get('/profile/card/following', (req, reply) => collectionsHandler(req, reply, 'following'))
177+
178+
// Mastodon-compatible API endpoints
179+
fastify.post('/api/v1/apps', createAppsHandler())
180+
fastify.get('/api/v1/accounts/verify_credentials', createVerifyCredentialsHandler(config))
181+
fastify.get('/api/v1/instance', createInstanceHandler(config))
176182
}
177183

178184
export default activityPubPlugin

src/ap/routes/mastodon.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* Mastodon-compatible API endpoints
3+
* Allows Mastodon clients (Elk, Phanpy, Ice Cubes) to connect to JSS
4+
*
5+
* Step 1: Dynamic client registration + account verification
6+
* Refs: https://docs.joinmastodon.org/methods/apps/
7+
* https://docs.joinmastodon.org/methods/accounts/#verify_credentials
8+
*/
9+
10+
// In-memory client store (replace with persistent storage later)
11+
const clients = new Map()
12+
13+
// Stable instance start time (used for created_at)
14+
const startedAt = new Date().toISOString()
15+
16+
/**
17+
* Parse request body — handles both JSON and form-urlencoded
18+
* (JSS uses raw buffer parser for all content types)
19+
*/
20+
function parseBody (request) {
21+
if (request.body && typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
22+
return request.body
23+
}
24+
const raw = Buffer.isBuffer(request.body) ? request.body.toString() : String(request.body || '')
25+
const ct = request.headers['content-type'] || ''
26+
if (ct.includes('application/json')) {
27+
try { return JSON.parse(raw) } catch { return {} }
28+
}
29+
// Default: parse as form-urlencoded
30+
return Object.fromEntries(new URLSearchParams(raw))
31+
}
32+
33+
/**
34+
* POST /api/v1/apps — Dynamic client registration
35+
* Mastodon clients call this to register before OAuth
36+
*/
37+
export function createAppsHandler () {
38+
return async (request, reply) => {
39+
const body = parseBody(request)
40+
const { client_name, redirect_uris, scopes, website } = body
41+
42+
if (!client_name || !redirect_uris) {
43+
return reply.code(422).send({ error: 'client_name and redirect_uris are required' })
44+
}
45+
46+
const clientId = crypto.randomUUID()
47+
const clientSecret = crypto.randomUUID()
48+
49+
const client = {
50+
id: clientId,
51+
name: client_name,
52+
redirect_uri: redirect_uris,
53+
client_id: clientId,
54+
client_secret: clientSecret,
55+
scopes: scopes || 'read',
56+
website: website || null
57+
}
58+
59+
clients.set(clientId, client)
60+
61+
return reply.send(client)
62+
}
63+
}
64+
65+
/**
66+
* GET /api/v1/accounts/verify_credentials — Who am I?
67+
* Returns the authenticated user's profile as a Mastodon Account object
68+
*/
69+
export function createVerifyCredentialsHandler (config) {
70+
return async (request, reply) => {
71+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
72+
const host = request.headers['x-forwarded-host'] || request.hostname
73+
const baseUrl = `${protocol}://${host}`
74+
75+
const account = {
76+
id: '1',
77+
username: config.username,
78+
acct: config.username,
79+
display_name: config.displayName,
80+
note: config.summary ? `<p>${escapeHtml(config.summary)}</p>` : '',
81+
url: `${baseUrl}/profile/card`,
82+
uri: `${baseUrl}/profile/card#me`,
83+
avatar: `${baseUrl}/profile/avatar.png`,
84+
header: '',
85+
locked: false,
86+
bot: false,
87+
created_at: startedAt,
88+
followers_count: 0,
89+
following_count: 0,
90+
statuses_count: 0,
91+
source: {
92+
privacy: 'public',
93+
sensitive: false,
94+
language: 'en',
95+
note: config.summary || ''
96+
}
97+
}
98+
99+
return reply.send(account)
100+
}
101+
}
102+
103+
/**
104+
* GET /api/v1/instance — Instance information
105+
* Required by most Mastodon clients before login
106+
*/
107+
export function createInstanceHandler (config) {
108+
return async (request, reply) => {
109+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
110+
const host = request.headers['x-forwarded-host'] || request.hostname
111+
const wsProtocol = protocol === 'https' ? 'wss' : 'ws'
112+
113+
return reply.send({
114+
uri: host,
115+
title: config.displayName || 'JSS',
116+
description: 'SAND Stack: Solid + ActivityPub + Nostr + DID',
117+
short_description: 'Solid pod with Mastodon-compatible API',
118+
version: '4.0.0 (compatible; JSS 0.0.67)',
119+
urls: {
120+
streaming_api: `${wsProtocol}://${host}`
121+
},
122+
stats: {
123+
user_count: 1,
124+
status_count: 0,
125+
domain_count: 1
126+
},
127+
languages: ['en'],
128+
registrations: false,
129+
approval_required: false,
130+
configuration: {
131+
statuses: { max_characters: 5000 },
132+
media_attachments: { supported_mime_types: [] }
133+
}
134+
})
135+
}
136+
}
137+
138+
/**
139+
* Look up a registered client
140+
*/
141+
export function getClient (clientId) {
142+
return clients.get(clientId) || null
143+
}
144+
145+
function escapeHtml (str) {
146+
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
147+
}
148+
149+
export default {
150+
createAppsHandler,
151+
createVerifyCredentialsHandler,
152+
createInstanceHandler,
153+
getClient
154+
}

src/server.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,8 @@ export function createServer(options = {}) {
353353
fastify.addHook('preHandler', async (request, reply) => {
354354
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, solidos-ui, well-known, notifications, nostr, git, and AP
355355
const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
356-
const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following'];
356+
const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following',
357+
'/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials'];
357358
// Check if request wants ActivityPub content for profile
358359
const accept = request.headers.accept || '';
359360
const wantsAP = accept.includes('activity+json') || accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');

0 commit comments

Comments
 (0)