You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This document describes how JSS (JavaScriptSolidServer) implements ActivityPub federation while maintaining full Solid protocol compatibility. This represents a working bridge between two decentralized web standards.
Status: ✅ Production-tested federation with Mastodon (Jan 5, 2026)
Tested servers:
solid.social
phone.solid.live
melvincarvalho.com
Architecture
The Core Insight: Content Negotiation
The key architectural decision is using content negotiation to serve both Solid and ActivityPub from the same identity URL:
flowchart LR
A["GET /profile/card"] --> B{"Accept header?"}
B -->|"text/html"| C["HTML profile page<br>(Mashlib)"]
B -->|"text/turtle"| D["Solid WebID<br>(Turtle RDF)"]
B -->|"application/ld+json"| E["Solid WebID<br>(JSON-LD)"]
B -->|"application/activity+json"| F["ActivityPub Actor"]
Loading
This means:
Same URL for WebID and AP Actor: https://example.com/profile/card#me
No duplication of identity data
Protocol-aware responses based on client needs
Backwards compatible with existing Solid apps
Component Architecture
flowchart TB
subgraph JSS["JSS Server (Fastify)"]
subgraph Protocols["Protocol Handlers"]
LDP["🗄️ <b>Solid LDP</b><br>Resources<br>Containers<br>ACLs<br>PATCH"]
AP["📡 <b>ActivityPub</b><br>Actor<br>Inbox<br>Outbox<br>WebFinger"]
Nostr["⚡ <b>Nostr Relay</b><br>NIP-01 events<br>Subscriptions<br>WebSocket"]
end
DB[("💾 <b>SQLite Storage</b><br>better-sqlite3 or sql.js")]
end
LDP --> DB
AP --> DB
Nostr --> DB
Key insight: The publicKey is generated on server start and injected into the Actor response. It never touches the actual WebID file - it's "virtual" via content negotiation.
3. Follow Handshake
sequenceDiagram
participant M as 🐘 Mastodon
participant J as 🔷 JSS
M->>J: POST /inbox (Follow)
Note over M,J: HTTP Signature
Note over J: 1. Parse signature header<br>2. Fetch sender's actor<br>3. Verify signature<br>4. Store follower
J-->>M: 202 Accepted
J->>M: POST /inbox (Accept)
Note over M,J: HTTP Signature
Loading
4. Posting to Followers
POST /profile/card/outboxContent-Type: application/json
{"content": "Hello from my Solid pod!"}
Server:
Creates Note activity with unique ID
Stores in local SQLite
Delivers to all follower inboxes with HTTP signatures
Returns 201 with activity JSON
Bugs & Workarounds Discovered
1. User-Agent Header Required (Critical)
Problem: Mastodon blocks requests without User-Agent header, returning empty responses.
Symptom: fetchActor() returned null, signature verification failed.
Lesson: SQLite uses single quotes for string literals, double quotes for identifiers.
3. Content-Type Header Setting
Problem: Using reply.header('Content-Type', ...) in Fastify hooks didn't work reliably.
Symptom: Responses returned application/octet-stream instead of application/activity+json.
Fix: Use dedicated route with reply.type():
fastify.route({method: 'GET',url: '/profile/card',handler: async(request,reply)=>{if(wantsActivityPub(request)){returnreply.type('application/activity+json').send(actorHandler(request))}// Fall through to LDP handler}})
Symptom: Actor URLs showed http:// instead of https://.
Fix (src/ap/routes/actor.js):
letprotocol=request.headers['x-forwarded-proto']if(!protocol){// Cloudflare uses cf-visitor headerconstcfVisitor=request.headers['cf-visitor']if(cfVisitor){constparsed=JSON.parse(cfVisitor)protocol=parsed.scheme}}// Assume HTTPS for public domainsif(!protocol&&!host.match(/^(localhost|127\.|192\.168\.|10\.)/)){protocol='https'}
5. Scoped Plugin Decorations
Problem: Fastify decorations in plugins aren't accessible from parent routes.
Symptom: actorHandler not found when trying to use in server.js hook.
Fix: Export handler via module-level variable:
// src/ap/index.jsletsharedActorHandler=nullexportfunctiongetActorHandler(){returnsharedActorHandler}// In pluginsharedActorHandler=createActorHandler(config,keypair)
6. Android/Termux Compatibility
Problem: better-sqlite3 requires native compilation that fails on Android.
Fix: Fallback to sql.js (WASM):
try{constDatabase=(awaitimport('better-sqlite3')).defaultdb=newDatabase(path)}catch(e){// Fall back to sql.jsconstinitSqlJs=(awaitimport('sql.js')).defaultconstSQL=awaitinitSqlJs()db=newSQL.Database()}
Current Limitations
No authentication on outbox POST - Anyone can post (should require Solid-OIDC)
No pagination on collections
No DELETE for posts (Tombstone activity)
No reply handling - Incoming replies not stored/displayed
No media attachments - Text-only notes
No avatar/banner - Actor lacks icon and image fields
Single-user only - Multi-user would need path-based actors
Areas for Further Work
High Priority
Authentication for posting
Require Solid-OIDC token for outbox POST
Verify token matches actor
Receive and display replies
Store incoming Create activities with inReplyTo
Expose via API or dashboard
Avatar support
Pull from Solid WebID foaf:img or vcard:hasPhoto
Add icon field to Actor
Medium Priority
Nostr ↔ ActivityPub bridge
kind:1 events → AP Notes
AP Notes → kind:1 events
Unified identity via alsoKnownAs
Likes and boosts
Handle incoming Like/Announce
Allow creating Like/Announce activities
Collection pagination
OrderedCollectionPage for outbox/followers/following
Overview
This document describes how JSS (JavaScriptSolidServer) implements ActivityPub federation while maintaining full Solid protocol compatibility. This represents a working bridge between two decentralized web standards.
Status: ✅ Production-tested federation with Mastodon (Jan 5, 2026)
Tested servers:
Architecture
The Core Insight: Content Negotiation
The key architectural decision is using content negotiation to serve both Solid and ActivityPub from the same identity URL:
flowchart LR A["GET /profile/card"] --> B{"Accept header?"} B -->|"text/html"| C["HTML profile page<br>(Mashlib)"] B -->|"text/turtle"| D["Solid WebID<br>(Turtle RDF)"] B -->|"application/ld+json"| E["Solid WebID<br>(JSON-LD)"] B -->|"application/activity+json"| F["ActivityPub Actor"]This means:
https://example.com/profile/card#meComponent Architecture
flowchart TB subgraph JSS["JSS Server (Fastify)"] subgraph Protocols["Protocol Handlers"] LDP["🗄️ <b>Solid LDP</b><br>Resources<br>Containers<br>ACLs<br>PATCH"] AP["📡 <b>ActivityPub</b><br>Actor<br>Inbox<br>Outbox<br>WebFinger"] Nostr["⚡ <b>Nostr Relay</b><br>NIP-01 events<br>Subscriptions<br>WebSocket"] end DB[("💾 <b>SQLite Storage</b><br>better-sqlite3 or sql.js")] end LDP --> DB AP --> DB Nostr --> DBKey Files
src/ap/index.jssrc/ap/routes/actor.jssrc/ap/routes/inbox.jssrc/ap/routes/outbox.jssrc/ap/routes/collections.jssrc/ap/store.jssrc/ap/keys.jssrc/server.jsHow It Works
1. Discovery (WebFinger)
Response:
{ "subject": "acct:melvin@solid.social", "links": [ { "rel": "self", "type": "application/activity+json", "href": "https://solid.social/profile/card#me" } ] }2. Actor Retrieval
When Mastodon fetches the actor URL with
Accept: application/activity+json:{ "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"], "type": "Person", "id": "https://solid.social/profile/card#me", "preferredUsername": "melvin", "inbox": "https://solid.social/profile/card/inbox", "outbox": "https://solid.social/profile/card/outbox", "publicKey": { "id": "https://solid.social/profile/card#main-key", "owner": "https://solid.social/profile/card#me", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n..." } }Key insight: The publicKey is generated on server start and injected into the Actor response. It never touches the actual WebID file - it's "virtual" via content negotiation.
3. Follow Handshake
sequenceDiagram participant M as 🐘 Mastodon participant J as 🔷 JSS M->>J: POST /inbox (Follow) Note over M,J: HTTP Signature Note over J: 1. Parse signature header<br>2. Fetch sender's actor<br>3. Verify signature<br>4. Store follower J-->>M: 202 Accepted J->>M: POST /inbox (Accept) Note over M,J: HTTP Signature4. Posting to Followers
Server:
Bugs & Workarounds Discovered
1. User-Agent Header Required (Critical)
Problem: Mastodon blocks requests without User-Agent header, returning empty responses.
Symptom:
fetchActor()returned null, signature verification failed.Fix (
src/ap/routes/inbox.js):Lesson: Always include User-Agent in ActivityPub fetches.
2. SQLite datetime() Syntax
Problem:
datetime("now")with double quotes fails - SQLite interprets it as column name.Symptom:
no such column: "now"error when caching actors.Fix (
src/ap/store.js):Lesson: SQLite uses single quotes for string literals, double quotes for identifiers.
3. Content-Type Header Setting
Problem: Using
reply.header('Content-Type', ...)in Fastify hooks didn't work reliably.Symptom: Responses returned
application/octet-streaminstead ofapplication/activity+json.Fix: Use dedicated route with
reply.type():4. Protocol Detection Behind Proxies
Problem: Cloudflare tunnels don't always pass
X-Forwarded-Proto.Symptom: Actor URLs showed
http://instead ofhttps://.Fix (
src/ap/routes/actor.js):5. Scoped Plugin Decorations
Problem: Fastify decorations in plugins aren't accessible from parent routes.
Symptom:
actorHandlernot found when trying to use in server.js hook.Fix: Export handler via module-level variable:
6. Android/Termux Compatibility
Problem:
better-sqlite3requires native compilation that fails on Android.Fix: Fallback to
sql.js(WASM):Current Limitations
iconandimagefieldsAreas for Further Work
High Priority
Authentication for posting
Receive and display replies
Avatar support
foaf:imgorvcard:hasPhotoiconfield to ActorMedium Priority
Nostr ↔ ActivityPub bridge
alsoKnownAsLikes and boosts
Collection pagination
Lower Priority
Dashboard UI
Multi-user support
/~username/profile/card#meas actorShared inbox optimization
Configuration
Enable ActivityPub with CLI flags:
jss start --activitypub --ap-username melvin --ap-display-name "Melvin Carvalho"Or in config:
{ "activitypub": true, "apUsername": "melvin", "apDisplayName": "Melvin Carvalho" }Testing Checklist
References
Version History
This spec is LLM-friendly and designed to provide full context for future development sessions.