Skip to content

Spec: Solid + ActivityPub Federation Architecture #51

@melvincarvalho

Description

@melvincarvalho

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:

  • 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
Loading

Key Files

File Purpose
src/ap/index.js Fastify plugin, routes, WebFinger, NodeInfo
src/ap/routes/actor.js Actor JSON-LD generator with publicKey
src/ap/routes/inbox.js Receive activities, verify signatures, handle Follow/Accept
src/ap/routes/outbox.js GET collection, POST to create/deliver notes
src/ap/routes/collections.js Followers/Following collections
src/ap/store.js SQLite storage (followers, posts, activities, actor cache)
src/ap/keys.js RSA keypair generation and persistence
src/server.js Content negotiation route for /profile/card

How It Works

1. Discovery (WebFinger)

GET /.well-known/webfinger?resource=acct:melvin@solid.social

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 Signature
Loading

4. Posting to Followers

POST /profile/card/outbox
Content-Type: application/json

{"content": "Hello from my Solid pod!"}

Server:

  1. Creates Note activity with unique ID
  2. Stores in local SQLite
  3. Delivers to all follower inboxes with HTTP signatures
  4. 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.

Fix (src/ap/routes/inbox.js):

const response = await fetch(fetchUrl, {
  headers: {
    'Accept': 'application/activity+json',
    'User-Agent': 'JSS/1.0 (+https://github.com/JavaScriptSolidServer/JavaScriptSolidServer)'
  }
})

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

// Wrong
'INSERT INTO actors (id, data, fetched_at) VALUES (?, ?, datetime("now"))'

// Correct  
"INSERT INTO actors (id, data, fetched_at) VALUES (?, ?, datetime('now'))"

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)) {
      return reply
        .type('application/activity+json')
        .send(actorHandler(request))
    }
    // Fall through to LDP handler
  }
})

4. Protocol Detection Behind Proxies

Problem: Cloudflare tunnels don't always pass X-Forwarded-Proto.

Symptom: Actor URLs showed http:// instead of https://.

Fix (src/ap/routes/actor.js):

let protocol = request.headers['x-forwarded-proto']
if (!protocol) {
  // Cloudflare uses cf-visitor header
  const cfVisitor = request.headers['cf-visitor']
  if (cfVisitor) {
    const parsed = JSON.parse(cfVisitor)
    protocol = parsed.scheme
  }
}
// Assume HTTPS for public domains
if (!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.js
let sharedActorHandler = null
export function getActorHandler() { return sharedActorHandler }

// In plugin
sharedActorHandler = createActorHandler(config, keypair)

6. Android/Termux Compatibility

Problem: better-sqlite3 requires native compilation that fails on Android.

Fix: Fallback to sql.js (WASM):

try {
  const Database = (await import('better-sqlite3')).default
  db = new Database(path)
} catch (e) {
  // Fall back to sql.js
  const initSqlJs = (await import('sql.js')).default
  const SQL = await initSqlJs()
  db = new SQL.Database()
}

Current Limitations

  1. No authentication on outbox POST - Anyone can post (should require Solid-OIDC)
  2. No pagination on collections
  3. No DELETE for posts (Tombstone activity)
  4. No reply handling - Incoming replies not stored/displayed
  5. No media attachments - Text-only notes
  6. No avatar/banner - Actor lacks icon and image fields
  7. Single-user only - Multi-user would need path-based actors

Areas for Further Work

High Priority

  1. Authentication for posting

    • Require Solid-OIDC token for outbox POST
    • Verify token matches actor
  2. Receive and display replies

    • Store incoming Create activities with inReplyTo
    • Expose via API or dashboard
  3. Avatar support

    • Pull from Solid WebID foaf:img or vcard:hasPhoto
    • Add icon field to Actor

Medium Priority

  1. Nostr ↔ ActivityPub bridge

    • kind:1 events → AP Notes
    • AP Notes → kind:1 events
    • Unified identity via alsoKnownAs
  2. Likes and boosts

    • Handle incoming Like/Announce
    • Allow creating Like/Announce activities
  3. Collection pagination

    • OrderedCollectionPage for outbox/followers/following
    • Cursor-based or page-based

Lower Priority

  1. Dashboard UI

    • View followers, posts, stats
    • Compose and send posts
    • Require authentication
  2. Multi-user support

    • /~username/profile/card#me as actor
    • Per-user keypairs and storage
  3. Shared inbox optimization

    • Deduplicate deliveries to same instance
    • Use shared inbox endpoint

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

  • WebFinger returns correct actor URL
  • Actor endpoint returns valid JSON-LD with publicKey
  • NodeInfo endpoint returns server info
  • Follow from Mastodon triggers Accept response
  • Follower appears in /profile/card/followers
  • POST to outbox creates and delivers Note
  • Post appears in follower's timeline
  • Unfollow removes from followers collection

References


Version History

Version Changes
0.0.64 Initial AP infrastructure
0.0.65 Add User-Agent header, error logging
0.0.66 Fix SQLite datetime syntax
0.0.67 Add outbox POST for creating/delivering posts

This spec is LLM-friendly and designed to provide full context for future development sessions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationnostrNostr relay, did:nostr auth, NIP-related

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions