Skip to content

Commit 0928e63

Browse files
v0.0.61 - Add ActivityPub federation support
Implements ActivityPub protocol using microfed library: - WebFinger discovery (/.well-known/webfinger) - NodeInfo 2.1 (/.well-known/nodeinfo) - Actor endpoint with content negotiation - Inbox for receiving Follow, Like, Announce, etc. - Outbox for activities - Followers/Following collections - HTTP Signature verification - Nostr identity linking via alsoKnownAs Enable with: createServer({ activitypub: true }) Closes JavaScriptSolidServer#49
1 parent 07b08bb commit 0928e63

10 files changed

Lines changed: 1545 additions & 8 deletions

File tree

package-lock.json

Lines changed: 647 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "javascript-solid-server",
3-
"version": "0.0.60",
3+
"version": "0.0.61",
44
"description": "A minimal, fast Solid server",
55
"main": "src/index.js",
66
"type": "module",
@@ -27,10 +27,13 @@
2727
"@fastify/rate-limit": "^9.1.0",
2828
"@fastify/websocket": "^8.3.1",
2929
"bcrypt": "^6.0.0",
30+
"bcryptjs": "^3.0.3",
31+
"better-sqlite3": "^12.5.0",
3032
"commander": "^14.0.2",
3133
"fastify": "^4.29.1",
3234
"fs-extra": "^11.2.0",
3335
"jose": "^6.1.3",
36+
"microfed": "^0.0.14",
3437
"n3": "^1.26.0",
3538
"nostr-tools": "^2.19.4",
3639
"oidc-provider": "^9.6.0"
@@ -42,7 +45,10 @@
4245
"solid",
4346
"ldp",
4447
"linked-data",
45-
"decentralized"
48+
"decentralized",
49+
"activitypub",
50+
"fediverse",
51+
"nostr"
4652
],
4753
"license": "AGPL-3.0-only",
4854
"devDependencies": {

src/ap/index.js

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* ActivityPub Plugin for JSS
3+
* Adds federation support via the ActivityPub protocol
4+
*/
5+
6+
import { webfinger } from 'microfed'
7+
import { loadOrCreateKeypair, getKeyId } from './keys.js'
8+
import { initStore } from './store.js'
9+
import { createInboxHandler } from './routes/inbox.js'
10+
import { createOutboxHandler } from './routes/outbox.js'
11+
import { createCollectionsHandler } from './routes/collections.js'
12+
import { createActorHandler } from './routes/actor.js'
13+
14+
/**
15+
* ActivityPub Fastify plugin
16+
* @param {FastifyInstance} fastify
17+
* @param {object} options
18+
* @param {string} options.username - Default username for single-user mode
19+
* @param {string} options.displayName - Display name
20+
* @param {string} options.summary - Bio/description
21+
* @param {string} options.nostrPubkey - Nostr public key (hex) for identity linking
22+
*/
23+
export async function activityPubPlugin(fastify, options = {}) {
24+
// Initialize storage and keypair
25+
const keypair = loadOrCreateKeypair()
26+
initStore()
27+
28+
// Store config for handlers
29+
const config = {
30+
keypair,
31+
username: options.username || 'me',
32+
displayName: options.displayName || options.username || 'Anonymous',
33+
summary: options.summary || '',
34+
nostrPubkey: options.nostrPubkey || null
35+
}
36+
37+
// Decorate fastify with AP config
38+
fastify.decorate('apConfig', config)
39+
40+
// Helper to build actor ID from request
41+
const getActorId = (request) => {
42+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
43+
const host = request.headers['x-forwarded-host'] || request.hostname
44+
return `${protocol}://${host}/profile/card#me`
45+
}
46+
47+
// Helper to get base URL
48+
const getBaseUrl = (request) => {
49+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
50+
const host = request.headers['x-forwarded-host'] || request.hostname
51+
return `${protocol}://${host}`
52+
}
53+
54+
// WebFinger endpoint
55+
fastify.get('/.well-known/webfinger', async (request, reply) => {
56+
const resource = request.query.resource
57+
if (!resource) {
58+
return reply.code(400).send({ error: 'Missing resource parameter' })
59+
}
60+
61+
const parsed = webfinger.parseResource(resource)
62+
if (!parsed) {
63+
return reply.code(400).send({ error: 'Invalid resource format' })
64+
}
65+
66+
// Check if this is our user
67+
const host = request.headers['x-forwarded-host'] || request.hostname
68+
if (parsed.domain !== host) {
69+
return reply.code(404).send({ error: 'Not found' })
70+
}
71+
72+
// For now, accept any username and map to /profile/card#me
73+
// In multi-user mode, we'd look up the user
74+
const baseUrl = getBaseUrl(request)
75+
const actorUrl = `${baseUrl}/profile/card#me`
76+
const profileUrl = `${baseUrl}/profile/card`
77+
78+
const response = webfinger.createResponse(
79+
`${parsed.username}@${parsed.domain}`,
80+
actorUrl,
81+
{ profileUrl }
82+
)
83+
84+
return reply
85+
.header('Content-Type', 'application/jrd+json')
86+
.header('Access-Control-Allow-Origin', '*')
87+
.send(response)
88+
})
89+
90+
// NodeInfo discovery (for Mastodon compatibility)
91+
fastify.get('/.well-known/nodeinfo', async (request, reply) => {
92+
const baseUrl = getBaseUrl(request)
93+
return reply
94+
.header('Content-Type', 'application/json')
95+
.send({
96+
links: [
97+
{
98+
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
99+
href: `${baseUrl}/.well-known/nodeinfo/2.1`
100+
}
101+
]
102+
})
103+
})
104+
105+
fastify.get('/.well-known/nodeinfo/2.1', async (request, reply) => {
106+
const { getPostCount } = await import('./store.js')
107+
return reply
108+
.header('Content-Type', 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"')
109+
.send({
110+
version: '2.1',
111+
software: {
112+
name: 'jss',
113+
version: '0.0.61',
114+
repository: 'https://github.com/JavaScriptSolidServer/JavaScriptSolidServer'
115+
},
116+
protocols: ['activitypub', 'solid'],
117+
services: { inbound: [], outbound: [] },
118+
usage: {
119+
users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
120+
localPosts: getPostCount()
121+
},
122+
openRegistrations: true,
123+
metadata: {
124+
nodeName: config.displayName,
125+
nodeDescription: 'SAND Stack: Solid + ActivityPub + Nostr + DID'
126+
}
127+
})
128+
})
129+
130+
// Actor endpoint - handle AP content negotiation for /profile/card
131+
const actorHandler = createActorHandler(config, keypair)
132+
133+
// Decorate request to track AP handling
134+
fastify.decorateRequest('apHandled', false)
135+
136+
// Register dedicated GET route for /profile/card with AP content negotiation
137+
// This needs to run BEFORE the wildcard LDP routes
138+
fastify.get('/profile/card', {
139+
// Run this handler first, before wildcard routes
140+
preHandler: async (request, reply) => {
141+
const accept = request.headers.accept || ''
142+
const wantsAP = accept.includes('activity+json') ||
143+
accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"')
144+
145+
if (wantsAP) {
146+
const actor = actorHandler(request)
147+
request.apHandled = true
148+
return reply
149+
.header('Content-Type', 'application/activity+json')
150+
.send(actor)
151+
}
152+
// If not AP, skip and let the request continue (but this route won't have a main handler)
153+
// We return early - the request will 404 on this route but get caught by wildcard
154+
}
155+
}, async (request, reply) => {
156+
// This handler won't be reached if AP was handled
157+
// For non-AP requests, we need to pass through to LDP
158+
// But we can't easily do that here, so we'll handle it differently
159+
reply.callNotFound()
160+
})
161+
162+
// Inbox endpoint
163+
const inboxHandler = createInboxHandler(config, keypair)
164+
fastify.post('/inbox', inboxHandler)
165+
fastify.post('/profile/card/inbox', inboxHandler)
166+
167+
// Outbox endpoint
168+
const outboxHandler = createOutboxHandler(config, keypair)
169+
fastify.get('/profile/card/outbox', outboxHandler)
170+
171+
// Followers/Following collections
172+
const collectionsHandler = createCollectionsHandler(config)
173+
fastify.get('/profile/card/followers', (req, reply) => collectionsHandler(req, reply, 'followers'))
174+
fastify.get('/profile/card/following', (req, reply) => collectionsHandler(req, reply, 'following'))
175+
}
176+
177+
export default activityPubPlugin

src/ap/keys.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* ActivityPub RSA Keypair Management
3+
* Generate and persist keypairs for HTTP Signatures
4+
*/
5+
6+
import { generateKeyPairSync } from 'crypto'
7+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
8+
import { dirname, join } from 'path'
9+
10+
const DEFAULT_KEY_PATH = 'data/ap-keys.json'
11+
12+
/**
13+
* Generate RSA keypair
14+
* @param {number} modulusLength - Key size in bits (default 2048)
15+
* @returns {{ publicKey: string, privateKey: string }}
16+
*/
17+
export function generateKeypair(modulusLength = 2048) {
18+
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
19+
modulusLength,
20+
publicKeyEncoding: { type: 'spki', format: 'pem' },
21+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
22+
})
23+
return { publicKey, privateKey }
24+
}
25+
26+
/**
27+
* Load keypair from disk, generate if not exists
28+
* @param {string} path - Path to keys file
29+
* @returns {{ publicKey: string, privateKey: string }}
30+
*/
31+
export function loadOrCreateKeypair(path = DEFAULT_KEY_PATH) {
32+
if (existsSync(path)) {
33+
const data = JSON.parse(readFileSync(path, 'utf8'))
34+
return data
35+
}
36+
37+
// Generate new keypair
38+
const keypair = generateKeypair()
39+
40+
// Ensure directory exists
41+
const dir = dirname(path)
42+
if (!existsSync(dir)) {
43+
mkdirSync(dir, { recursive: true })
44+
}
45+
46+
// Save to disk
47+
writeFileSync(path, JSON.stringify(keypair, null, 2))
48+
console.log(`Generated new ActivityPub keypair: ${path}`)
49+
50+
return keypair
51+
}
52+
53+
/**
54+
* Get key ID for HTTP Signatures
55+
* @param {string} actorId - Actor URL (e.g., https://example.com/profile/card#me)
56+
* @returns {string} Key ID (e.g., https://example.com/profile/card#main-key)
57+
*/
58+
export function getKeyId(actorId) {
59+
// Strip fragment and add #main-key
60+
const base = actorId.replace(/#.*$/, '')
61+
return `${base}#main-key`
62+
}
63+
64+
export default { generateKeypair, loadOrCreateKeypair, getKeyId }

src/ap/routes/actor.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Actor endpoint handler
3+
* Returns ActivityPub Actor JSON-LD for content negotiation
4+
*/
5+
6+
/**
7+
* Create actor handler
8+
* @param {object} config - AP configuration
9+
* @param {object} keypair - RSA keypair
10+
* @returns {Function} Handler function
11+
*/
12+
export function createActorHandler(config, keypair) {
13+
return (request) => {
14+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
15+
const host = request.headers['x-forwarded-host'] || request.hostname
16+
const baseUrl = `${protocol}://${host}`
17+
const profileUrl = `${baseUrl}/profile/card`
18+
const actorId = `${profileUrl}#me`
19+
20+
const actor = {
21+
'@context': [
22+
'https://www.w3.org/ns/activitystreams',
23+
'https://w3id.org/security/v1'
24+
],
25+
type: 'Person',
26+
id: actorId,
27+
url: profileUrl,
28+
preferredUsername: config.username,
29+
name: config.displayName,
30+
summary: config.summary ? `<p>${config.summary}</p>` : '',
31+
inbox: `${profileUrl}/inbox`,
32+
outbox: `${profileUrl}/outbox`,
33+
followers: `${profileUrl}/followers`,
34+
following: `${profileUrl}/following`,
35+
endpoints: {
36+
sharedInbox: `${baseUrl}/inbox`
37+
},
38+
publicKey: {
39+
id: `${profileUrl}#main-key`,
40+
owner: actorId,
41+
publicKeyPem: keypair.publicKey
42+
}
43+
}
44+
45+
// Add Nostr identity linking via alsoKnownAs
46+
if (config.nostrPubkey) {
47+
actor.alsoKnownAs = [`did:nostr:${config.nostrPubkey}`]
48+
}
49+
50+
return actor
51+
}
52+
}
53+
54+
export default { createActorHandler }

src/ap/routes/collections.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Collections endpoint handler
3+
* Returns followers/following as OrderedCollection
4+
*/
5+
6+
import { getFollowers, getFollowing, getFollowerCount, getFollowingCount } from '../store.js'
7+
8+
/**
9+
* Create collections handler
10+
* @param {object} config - AP configuration
11+
* @returns {Function} Fastify handler
12+
*/
13+
export function createCollectionsHandler(config) {
14+
return async (request, reply, collectionType) => {
15+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
16+
const host = request.headers['x-forwarded-host'] || request.hostname
17+
const baseUrl = `${protocol}://${host}`
18+
const profileUrl = `${baseUrl}/profile/card`
19+
20+
let items, totalItems
21+
22+
if (collectionType === 'followers') {
23+
const followers = getFollowers()
24+
items = followers.map(f => f.actor)
25+
totalItems = getFollowerCount()
26+
} else {
27+
const following = getFollowing()
28+
items = following.map(f => f.actor)
29+
totalItems = getFollowingCount()
30+
}
31+
32+
const collection = {
33+
'@context': 'https://www.w3.org/ns/activitystreams',
34+
type: 'OrderedCollection',
35+
id: `${profileUrl}/${collectionType}`,
36+
totalItems,
37+
orderedItems: items
38+
}
39+
40+
return reply
41+
.header('Content-Type', 'application/activity+json')
42+
.send(collection)
43+
}
44+
}
45+
46+
export default { createCollectionsHandler }

0 commit comments

Comments
 (0)