javascript-solid-server 0.0.60 → 0.0.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -213,7 +213,9 @@
213
213
  "Bash(ssh phone:*)",
214
214
  "Bash(dig:*)",
215
215
  "WebFetch(domain:fonstr.com)",
216
- "Bash(node -e \"import\\(''nostr-tools''\\).then\\(m => console.log\\(Object.keys\\(m\\).join\\(''\\\\n''\\)\\)\\)\":*)"
216
+ "Bash(node -e \"import\\(''nostr-tools''\\).then\\(m => console.log\\(Object.keys\\(m\\).join\\(''\\\\n''\\)\\)\\)\":*)",
217
+ "Bash(gh repo list:*)",
218
+ "Bash(gh search:*)"
217
219
  ]
218
220
  }
219
221
  }
package/README.md CHANGED
@@ -6,8 +6,9 @@ A minimal, fast, JSON-LD native Solid server.
6
6
 
7
7
  ## Features
8
8
 
9
- ### Implemented (v0.0.60)
9
+ ### Implemented (v0.0.61)
10
10
 
11
+ - **ActivityPub Federation** - Fediverse integration with WebFinger, inbox/outbox, HTTP signatures
11
12
  - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
12
13
  - **N3 Patch** - Solid's native patch format for RDF updates
13
14
  - **SPARQL Update** - Standard SPARQL UPDATE protocol for PATCH
@@ -126,6 +127,11 @@ jss --help # Show help
126
127
  | `--nostr-max-events <n>` | Max events in relay memory | 1000 |
127
128
  | `--invite-only` | Require invite code for registration | false |
128
129
  | `--default-quota <size>` | Default storage quota per pod (e.g., 50MB) | 50MB |
130
+ | `--activitypub` | Enable ActivityPub federation | false |
131
+ | `--ap-username <name>` | ActivityPub username | me |
132
+ | `--ap-display-name <name>` | ActivityPub display name | (username) |
133
+ | `--ap-summary <text>` | ActivityPub bio/summary | - |
134
+ | `--ap-nostr-pubkey <hex>` | Nostr pubkey for identity linking | - |
129
135
  | `-q, --quiet` | Suppress logs | false |
130
136
 
131
137
  ### Environment Variables
@@ -143,6 +149,8 @@ export JSS_MASHLIB=true
143
149
  export JSS_NOSTR=true
144
150
  export JSS_INVITE_ONLY=true
145
151
  export JSS_DEFAULT_QUOTA=100MB
152
+ export JSS_ACTIVITYPUB=true
153
+ export JSS_AP_USERNAME=alice
146
154
  jss start
147
155
  ```
148
156
 
@@ -409,6 +417,72 @@ git add .acl && git commit -m "Add ACL"
409
417
 
410
418
  See [git-credential-nostr](https://github.com/JavaScriptSolidServer/git-credential-nostr) for more details.
411
419
 
420
+ ## ActivityPub Federation
421
+
422
+ Enable ActivityPub to federate with Mastodon, Pleroma, Misskey, and other Fediverse servers:
423
+
424
+ ```bash
425
+ jss start --activitypub --ap-username alice --ap-display-name "Alice" --ap-summary "Hello from JSS!"
426
+ ```
427
+
428
+ ### Endpoints
429
+
430
+ | Endpoint | Description |
431
+ |----------|-------------|
432
+ | `/.well-known/webfinger` | Actor discovery (Mastodon searches here) |
433
+ | `/.well-known/nodeinfo` | NodeInfo discovery |
434
+ | `/profile/card` | Actor (returns JSON-LD when `Accept: application/activity+json`) |
435
+ | `/inbox` | Shared inbox for receiving activities |
436
+ | `/profile/card/inbox` | Personal inbox |
437
+ | `/profile/card/outbox` | User's activities |
438
+ | `/profile/card/followers` | Followers collection |
439
+ | `/profile/card/following` | Following collection |
440
+
441
+ ### How It Works
442
+
443
+ 1. **Discovery**: Mastodon looks up `@alice@your.server` via WebFinger
444
+ 2. **Actor**: Returns ActivityPub Actor JSON-LD with public key
445
+ 3. **Follow**: Remote servers POST Follow activities to inbox
446
+ 4. **Accept**: JSS auto-accepts follows and sends Accept back
447
+ 5. **Delivery**: Posts are signed with HTTP Signatures and delivered to follower inboxes
448
+
449
+ ### Identity Linking
450
+
451
+ Your WebID (`/profile/card#me`) becomes your ActivityPub Actor. Link to Nostr identity:
452
+
453
+ ```bash
454
+ jss start --activitypub --ap-nostr-pubkey <64-char-hex-pubkey>
455
+ ```
456
+
457
+ This adds `alsoKnownAs: ["did:nostr:<pubkey>"]` to your Actor profile, creating a verifiable link between your Solid, ActivityPub, and Nostr identities (the SAND stack).
458
+
459
+ ### Programmatic Usage
460
+
461
+ ```javascript
462
+ import { createServer } from 'javascript-solid-server';
463
+
464
+ const server = createServer({
465
+ activitypub: true,
466
+ apUsername: 'alice',
467
+ apDisplayName: 'Alice',
468
+ apSummary: 'Building the decentralized web!',
469
+ apNostrPubkey: 'abc123...' // Optional: links to did:nostr
470
+ });
471
+ ```
472
+
473
+ ### Testing Federation
474
+
475
+ ```bash
476
+ # Check WebFinger
477
+ curl "http://localhost:3000/.well-known/webfinger?resource=acct:alice@localhost:3000"
478
+
479
+ # Get Actor (AP format)
480
+ curl -H "Accept: application/activity+json" http://localhost:3000/profile/card
481
+
482
+ # Check NodeInfo
483
+ curl http://localhost:3000/.well-known/nodeinfo/2.1
484
+ ```
485
+
412
486
  ### Linking Nostr to WebID (did:nostr)
413
487
 
414
488
  Bridge your Nostr identity to a Solid WebID for seamless authentication:
@@ -812,6 +886,15 @@ src/
812
886
  │ ├── interactions.js # Login/consent handlers
813
887
  │ ├── views.js # HTML templates
814
888
  │ └── invites.js # Invite code management
889
+ ├── ap/
890
+ │ ├── index.js # ActivityPub plugin
891
+ │ ├── keys.js # RSA keypair management
892
+ │ ├── store.js # SQLite storage (followers, activities)
893
+ │ └── routes/
894
+ │ ├── actor.js # Actor JSON-LD
895
+ │ ├── inbox.js # Receive activities
896
+ │ ├── outbox.js # User's activities
897
+ │ └── collections.js # Followers/following
815
898
  ├── rdf/
816
899
  │ ├── turtle.js # Turtle <-> JSON-LD
817
900
  │ └── conneg.js # Content negotiation
@@ -831,6 +914,8 @@ Minimal dependencies for a fast, secure server:
831
914
  - **n3** - Turtle parsing (only used when conneg enabled)
832
915
  - **oidc-provider** - OpenID Connect Identity Provider (only when IdP enabled)
833
916
  - **bcrypt** - Password hashing (only when IdP enabled)
917
+ - **microfed** - ActivityPub primitives (only when activitypub enabled)
918
+ - **better-sqlite3** - SQLite storage for federation data
834
919
 
835
920
  ## License
836
921
 
package/bin/jss.js CHANGED
@@ -63,6 +63,12 @@ program
63
63
  .option('--no-nostr', 'Disable Nostr relay')
64
64
  .option('--nostr-path <path>', 'Nostr relay WebSocket path (default: /relay)')
65
65
  .option('--nostr-max-events <n>', 'Max events in relay memory (default: 1000)', parseInt)
66
+ .option('--activitypub', 'Enable ActivityPub federation')
67
+ .option('--no-activitypub', 'Disable ActivityPub federation')
68
+ .option('--ap-username <name>', 'ActivityPub username (default: me)')
69
+ .option('--ap-display-name <name>', 'ActivityPub display name')
70
+ .option('--ap-summary <text>', 'ActivityPub bio/summary')
71
+ .option('--ap-nostr-pubkey <hex>', 'Nostr pubkey for identity linking')
66
72
  .option('--invite-only', 'Require invite code for registration')
67
73
  .option('--no-invite-only', 'Allow open registration')
68
74
  .option('-q, --quiet', 'Suppress log output')
@@ -110,6 +116,11 @@ program
110
116
  nostr: config.nostr,
111
117
  nostrPath: config.nostrPath,
112
118
  nostrMaxEvents: config.nostrMaxEvents,
119
+ activitypub: config.activitypub,
120
+ apUsername: config.apUsername,
121
+ apDisplayName: config.apDisplayName,
122
+ apSummary: config.apSummary,
123
+ apNostrPubkey: config.apNostrPubkey,
113
124
  inviteOnly: config.inviteOnly,
114
125
  });
115
126
 
@@ -131,6 +142,7 @@ program
131
142
  }
132
143
  if (config.git) console.log(' Git: enabled (clone/push support)');
133
144
  if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
145
+ if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
134
146
  if (config.inviteOnly) console.log(' Registration: invite-only');
135
147
  console.log('\n Press Ctrl+C to stop\n');
136
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.60",
3
+ "version": "0.0.62",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -27,10 +27,13 @@
27
27
  "@fastify/rate-limit": "^9.1.0",
28
28
  "@fastify/websocket": "^8.3.1",
29
29
  "bcrypt": "^6.0.0",
30
+ "bcryptjs": "^3.0.3",
31
+ "better-sqlite3": "^12.5.0",
30
32
  "commander": "^14.0.2",
31
33
  "fastify": "^4.29.1",
32
34
  "fs-extra": "^11.2.0",
33
35
  "jose": "^6.1.3",
36
+ "microfed": "^0.0.14",
34
37
  "n3": "^1.26.0",
35
38
  "nostr-tools": "^2.19.4",
36
39
  "oidc-provider": "^9.6.0"
@@ -42,7 +45,10 @@
42
45
  "solid",
43
46
  "ldp",
44
47
  "linked-data",
45
- "decentralized"
48
+ "decentralized",
49
+ "activitypub",
50
+ "fediverse",
51
+ "nostr"
46
52
  ],
47
53
  "license": "AGPL-3.0-only",
48
54
  "devDependencies": {
@@ -0,0 +1,81 @@
1
+ #!/data/data/com.termux/files/usr/bin/bash
2
+ #
3
+ # PhonePod Installer for Termux
4
+ #
5
+ # Usage:
6
+ # curl -sL https://raw.githubusercontent.com/JavaScriptSolidServer/JavaScriptSolidServer/gh-pages/scripts/install-termux.sh | bash
7
+ #
8
+
9
+ set -e
10
+
11
+ echo ""
12
+ echo " ╔═══════════════════════════════════════╗"
13
+ echo " ║ PhonePod Installer ║"
14
+ echo " ║ Solid + Nostr + Git on your phone ║"
15
+ echo " ╚═══════════════════════════════════════╝"
16
+ echo ""
17
+
18
+ # Check we're in Termux
19
+ if [ ! -d "/data/data/com.termux" ]; then
20
+ echo "✗ This script is for Termux on Android"
21
+ echo " Install Termux from F-Droid: https://f-droid.org/packages/com.termux/"
22
+ exit 1
23
+ fi
24
+
25
+ echo "→ Installing dependencies..."
26
+ pkg update -y
27
+ pkg install -y nodejs-lts openssh autossh git
28
+
29
+ echo "→ Installing PM2 and JSS..."
30
+ npm install -g pm2 javascript-solid-server
31
+
32
+ # Fix PATH for npm global bins
33
+ NPM_BIN="$(npm config get prefix)/bin"
34
+ if [[ ":$PATH:" != *":$NPM_BIN:"* ]]; then
35
+ echo "export PATH=\"\$PATH:$NPM_BIN\"" >> ~/.bashrc
36
+ export PATH="$PATH:$NPM_BIN"
37
+ fi
38
+
39
+ echo "→ Setting up boot persistence..."
40
+ mkdir -p ~/.termux/boot
41
+ cat > ~/.termux/boot/start-pod.sh << 'BOOT'
42
+ #!/data/data/com.termux/files/usr/bin/bash
43
+ # Start PhonePod on boot
44
+ termux-wake-lock
45
+ export PATH="$PATH:$(npm config get prefix)/bin"
46
+ pm2 resurrect
47
+ BOOT
48
+ chmod +x ~/.termux/boot/start-pod.sh
49
+
50
+ echo "→ Starting JSS..."
51
+ pm2 start jss -- start --port 8080 --nostr --git
52
+ pm2 save
53
+
54
+ # Get local IP
55
+ LOCAL_IP=$(ip route get 1 2>/dev/null | awk '{print $7}' | head -1)
56
+
57
+ echo ""
58
+ echo " ╔═══════════════════════════════════════╗"
59
+ echo " ║ ✓ PhonePod Installed! ║"
60
+ echo " ╚═══════════════════════════════════════╝"
61
+ echo ""
62
+ echo " Local: http://localhost:8080"
63
+ if [ -n "$LOCAL_IP" ]; then
64
+ echo " Network: http://$LOCAL_IP:8080"
65
+ fi
66
+ echo ""
67
+ echo " Features enabled:"
68
+ echo " • Solid pod (LDP, WAC, WebID)"
69
+ echo " • Nostr relay (wss://localhost:8080/relay)"
70
+ echo " • Git server (git clone http://localhost:8080/)"
71
+ echo ""
72
+ echo " Commands:"
73
+ echo " pm2 status - check status"
74
+ echo " pm2 logs jss - view logs"
75
+ echo " pm2 restart jss - restart server"
76
+ echo ""
77
+ echo " For public access, setup a tunnel:"
78
+ echo " https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/46"
79
+ echo ""
80
+ echo " NOTE: Install Termux:Boot from F-Droid for auto-start on reboot"
81
+ echo ""
@@ -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.62',
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
package/src/ap/keys.js ADDED
@@ -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 }
@@ -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 }
@@ -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,0 +1,239 @@
1
+ /**
2
+ * Inbox endpoint handler
3
+ * Receives and processes incoming ActivityPub activities
4
+ */
5
+
6
+ import { auth, outbox } from 'microfed'
7
+ import {
8
+ saveActivity,
9
+ addFollower,
10
+ removeFollower,
11
+ acceptFollowing,
12
+ cacheActor,
13
+ getCachedActor
14
+ } from '../store.js'
15
+ import { getKeyId } from '../keys.js'
16
+
17
+ /**
18
+ * Fetch remote actor (with caching)
19
+ * @param {string} id - Actor URL
20
+ * @returns {Promise<object|null>} Actor object or null
21
+ */
22
+ async function fetchActor(id) {
23
+ // Strip fragment for fetching
24
+ const fetchUrl = id.replace(/#.*$/, '')
25
+ const cached = getCachedActor(id)
26
+ if (cached) return cached
27
+
28
+ try {
29
+ const response = await fetch(fetchUrl, {
30
+ headers: { 'Accept': 'application/activity+json' }
31
+ })
32
+ if (!response.ok) return null
33
+
34
+ const actor = await response.json()
35
+ cacheActor(actor)
36
+ return actor
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Verify HTTP signature on incoming request
44
+ * @param {object} request - Fastify request
45
+ * @param {string} body - Request body
46
+ * @returns {Promise<{valid: boolean, actor?: object, reason?: string}>}
47
+ */
48
+ async function verifySignature(request, body) {
49
+ const signature = request.headers['signature']
50
+ if (!signature) {
51
+ return { valid: false, reason: 'No signature header' }
52
+ }
53
+
54
+ // Parse signature header
55
+ const sigParts = auth.parseSignatureHeader(signature)
56
+ if (!sigParts) {
57
+ return { valid: false, reason: 'Invalid signature format' }
58
+ }
59
+
60
+ const keyId = sigParts.keyId
61
+ if (!keyId) {
62
+ return { valid: false, reason: 'No keyId in signature' }
63
+ }
64
+
65
+ // Extract actor URL from keyId (strip fragment like #main-key)
66
+ const actorUrl = keyId.replace(/#.*$/, '')
67
+
68
+ // Fetch the actor to get their public key
69
+ const remoteActor = await fetchActor(actorUrl)
70
+ if (!remoteActor) {
71
+ return { valid: false, reason: `Could not fetch actor: ${actorUrl}` }
72
+ }
73
+
74
+ const publicKeyPem = remoteActor.publicKey?.publicKeyPem
75
+ if (!publicKeyPem) {
76
+ return { valid: false, reason: 'Actor has no public key' }
77
+ }
78
+
79
+ // Verify digest if present
80
+ const digestHeader = request.headers['digest']
81
+ if (digestHeader && !auth.verifyDigest(body, digestHeader)) {
82
+ return { valid: false, reason: 'Digest mismatch' }
83
+ }
84
+
85
+ // Build path from URL
86
+ const url = new URL(request.url, `http://${request.hostname}`)
87
+
88
+ // Verify signature
89
+ try {
90
+ const valid = auth.verify({
91
+ publicKey: publicKeyPem,
92
+ signature,
93
+ method: request.method,
94
+ path: url.pathname,
95
+ headers: request.headers
96
+ })
97
+ return { valid, actor: remoteActor }
98
+ } catch (err) {
99
+ return { valid: false, reason: `Verification error: ${err.message}` }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Create inbox handler
105
+ * @param {object} config - AP configuration
106
+ * @param {object} keypair - RSA keypair
107
+ * @returns {Function} Fastify handler
108
+ */
109
+ export function createInboxHandler(config, keypair) {
110
+ return async (request, reply) => {
111
+ // Parse body
112
+ let activity
113
+ let body
114
+ try {
115
+ body = typeof request.body === 'string'
116
+ ? request.body
117
+ : request.body.toString()
118
+ activity = JSON.parse(body)
119
+ } catch {
120
+ return reply.code(400).send({ error: 'Invalid JSON' })
121
+ }
122
+
123
+ // Verify signature (log but don't reject for now - many servers have issues)
124
+ const sigResult = await verifySignature(request, body)
125
+ if (!sigResult.valid) {
126
+ request.log.warn(`Signature verification failed: ${sigResult.reason}`)
127
+ } else {
128
+ request.log.info(`Signature verified for ${activity.actor}`)
129
+ }
130
+
131
+ // Validate activity
132
+ if (!activity.type) {
133
+ return reply.code(400).send({ error: 'Missing activity type' })
134
+ }
135
+
136
+ // Save activity
137
+ if (activity.id) {
138
+ saveActivity(activity)
139
+ }
140
+
141
+ // Handle activity by type
142
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
143
+ const host = request.headers['x-forwarded-host'] || request.hostname
144
+ const baseUrl = `${protocol}://${host}`
145
+ const profileUrl = `${baseUrl}/profile/card`
146
+ const actorId = `${profileUrl}#me`
147
+
148
+ request.log.info(`Received ${activity.type} from ${activity.actor}`)
149
+
150
+ switch (activity.type) {
151
+ case 'Follow':
152
+ await handleFollow(activity, actorId, profileUrl, keypair, request.log)
153
+ break
154
+
155
+ case 'Undo':
156
+ await handleUndo(activity, request.log)
157
+ break
158
+
159
+ case 'Accept':
160
+ handleAccept(activity, request.log)
161
+ break
162
+
163
+ case 'Create':
164
+ request.log.info(`New post: ${activity.object?.content?.slice(0, 50)}...`)
165
+ break
166
+
167
+ case 'Like':
168
+ request.log.info(`Liked: ${activity.object}`)
169
+ break
170
+
171
+ case 'Announce':
172
+ request.log.info(`Boosted: ${activity.object}`)
173
+ break
174
+
175
+ default:
176
+ request.log.info(`Unhandled activity type: ${activity.type}`)
177
+ }
178
+
179
+ // Accept the activity
180
+ return reply.code(202).send()
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Handle Follow activity
186
+ */
187
+ async function handleFollow(activity, actorId, profileUrl, keypair, log) {
188
+ const followerActor = await fetchActor(activity.actor)
189
+ if (!followerActor) {
190
+ log.warn('Could not fetch follower actor')
191
+ return
192
+ }
193
+
194
+ // Add to followers
195
+ addFollower(activity.actor, followerActor.inbox)
196
+ log.info(`New follower: ${followerActor.preferredUsername || activity.actor}`)
197
+
198
+ // Send Accept
199
+ const accept = outbox.createAccept(actorId, activity)
200
+
201
+ try {
202
+ await outbox.send({
203
+ activity: accept,
204
+ inbox: followerActor.inbox,
205
+ privateKey: keypair.privateKey,
206
+ keyId: `${profileUrl}#main-key`
207
+ })
208
+ log.info(`Sent Accept to ${followerActor.inbox}`)
209
+ } catch (err) {
210
+ log.error(`Failed to send Accept: ${err.message}`)
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Handle Undo activity
216
+ */
217
+ async function handleUndo(activity, log) {
218
+ if (activity.object?.type === 'Follow') {
219
+ removeFollower(activity.actor)
220
+ log.info(`Unfollowed by ${activity.actor}`)
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Handle Accept activity (our follow was accepted)
226
+ */
227
+ function handleAccept(activity, log) {
228
+ if (activity.object?.type === 'Follow') {
229
+ const target = typeof activity.object.object === 'string'
230
+ ? activity.object.object
231
+ : activity.object.object?.id
232
+ if (target) {
233
+ acceptFollowing(target)
234
+ log.info('Follow accepted!')
235
+ }
236
+ }
237
+ }
238
+
239
+ export default { createInboxHandler }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Outbox endpoint handler
3
+ * Returns user's activities as OrderedCollection
4
+ */
5
+
6
+ import { getPosts } from '../store.js'
7
+
8
+ /**
9
+ * Create outbox handler
10
+ * @param {object} config - AP configuration
11
+ * @param {object} keypair - RSA keypair
12
+ * @returns {Function} Fastify handler
13
+ */
14
+ export function createOutboxHandler(config, keypair) {
15
+ return async (request, reply) => {
16
+ const protocol = request.headers['x-forwarded-proto'] || request.protocol
17
+ const host = request.headers['x-forwarded-host'] || request.hostname
18
+ const baseUrl = `${protocol}://${host}`
19
+ const profileUrl = `${baseUrl}/profile/card`
20
+ const actorId = `${profileUrl}#me`
21
+
22
+ const posts = getPosts(20)
23
+
24
+ const collection = {
25
+ '@context': 'https://www.w3.org/ns/activitystreams',
26
+ type: 'OrderedCollection',
27
+ id: `${profileUrl}/outbox`,
28
+ totalItems: posts.length,
29
+ orderedItems: posts.map(p => ({
30
+ type: 'Create',
31
+ actor: actorId,
32
+ published: p.published,
33
+ object: {
34
+ type: 'Note',
35
+ id: p.id,
36
+ content: p.content,
37
+ published: p.published,
38
+ attributedTo: actorId,
39
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
40
+ cc: [`${profileUrl}/followers`],
41
+ ...(p.in_reply_to ? { inReplyTo: p.in_reply_to } : {})
42
+ }
43
+ }))
44
+ }
45
+
46
+ return reply
47
+ .header('Content-Type', 'application/activity+json')
48
+ .send(collection)
49
+ }
50
+ }
51
+
52
+ export default { createOutboxHandler }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * ActivityPub SQLite Storage
3
+ * Persistence layer for federation data
4
+ */
5
+
6
+ import Database from 'better-sqlite3'
7
+ import { existsSync, mkdirSync } from 'fs'
8
+ import { dirname } from 'path'
9
+
10
+ let db = null
11
+
12
+ /**
13
+ * Initialize the database
14
+ * @param {string} path - Path to SQLite file
15
+ */
16
+ export function initStore(path = 'data/activitypub.db') {
17
+ // Ensure directory exists
18
+ const dir = dirname(path)
19
+ if (!existsSync(dir)) {
20
+ mkdirSync(dir, { recursive: true })
21
+ }
22
+
23
+ db = new Database(path)
24
+
25
+ // Create tables
26
+ db.exec(`
27
+ -- Followers (people following us)
28
+ CREATE TABLE IF NOT EXISTS followers (
29
+ id TEXT PRIMARY KEY,
30
+ actor TEXT NOT NULL,
31
+ inbox TEXT,
32
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
33
+ );
34
+
35
+ -- Following (people we follow)
36
+ CREATE TABLE IF NOT EXISTS following (
37
+ id TEXT PRIMARY KEY,
38
+ actor TEXT NOT NULL,
39
+ accepted INTEGER DEFAULT 0,
40
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
41
+ );
42
+
43
+ -- Activities (inbox)
44
+ CREATE TABLE IF NOT EXISTS activities (
45
+ id TEXT PRIMARY KEY,
46
+ type TEXT NOT NULL,
47
+ actor TEXT,
48
+ object TEXT,
49
+ raw TEXT NOT NULL,
50
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
51
+ );
52
+
53
+ -- Posts (our outbox)
54
+ CREATE TABLE IF NOT EXISTS posts (
55
+ id TEXT PRIMARY KEY,
56
+ content TEXT NOT NULL,
57
+ in_reply_to TEXT,
58
+ published TEXT DEFAULT CURRENT_TIMESTAMP
59
+ );
60
+
61
+ -- Known actors (cache)
62
+ CREATE TABLE IF NOT EXISTS actors (
63
+ id TEXT PRIMARY KEY,
64
+ data TEXT NOT NULL,
65
+ fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
66
+ );
67
+ `)
68
+
69
+ return db
70
+ }
71
+
72
+ /**
73
+ * Get database instance
74
+ */
75
+ export function getStore() {
76
+ if (!db) {
77
+ throw new Error('Store not initialized. Call initStore() first.')
78
+ }
79
+ return db
80
+ }
81
+
82
+ // Followers
83
+
84
+ export function addFollower(actorId, inbox) {
85
+ const stmt = db.prepare(`
86
+ INSERT OR REPLACE INTO followers (id, actor, inbox)
87
+ VALUES (?, ?, ?)
88
+ `)
89
+ stmt.run(actorId, actorId, inbox)
90
+ }
91
+
92
+ export function removeFollower(actorId) {
93
+ const stmt = db.prepare('DELETE FROM followers WHERE id = ?')
94
+ stmt.run(actorId)
95
+ }
96
+
97
+ export function getFollowers() {
98
+ const stmt = db.prepare('SELECT * FROM followers ORDER BY created_at DESC')
99
+ return stmt.all()
100
+ }
101
+
102
+ export function getFollowerCount() {
103
+ const stmt = db.prepare('SELECT COUNT(*) as count FROM followers')
104
+ return stmt.get().count
105
+ }
106
+
107
+ export function getFollowerInboxes() {
108
+ const stmt = db.prepare('SELECT DISTINCT inbox FROM followers WHERE inbox IS NOT NULL')
109
+ return stmt.all().map(row => row.inbox)
110
+ }
111
+
112
+ // Following
113
+
114
+ export function addFollowing(actorId, accepted = false) {
115
+ const stmt = db.prepare(`
116
+ INSERT OR REPLACE INTO following (id, actor, accepted)
117
+ VALUES (?, ?, ?)
118
+ `)
119
+ stmt.run(actorId, actorId, accepted ? 1 : 0)
120
+ }
121
+
122
+ export function acceptFollowing(actorId) {
123
+ const stmt = db.prepare('UPDATE following SET accepted = 1 WHERE id = ?')
124
+ stmt.run(actorId)
125
+ }
126
+
127
+ export function removeFollowing(actorId) {
128
+ const stmt = db.prepare('DELETE FROM following WHERE id = ?')
129
+ stmt.run(actorId)
130
+ }
131
+
132
+ export function getFollowing() {
133
+ const stmt = db.prepare('SELECT * FROM following WHERE accepted = 1 ORDER BY created_at DESC')
134
+ return stmt.all()
135
+ }
136
+
137
+ export function getFollowingCount() {
138
+ const stmt = db.prepare('SELECT COUNT(*) as count FROM following WHERE accepted = 1')
139
+ return stmt.get().count
140
+ }
141
+
142
+ // Activities
143
+
144
+ export function saveActivity(activity) {
145
+ const stmt = db.prepare(`
146
+ INSERT OR REPLACE INTO activities (id, type, actor, object, raw)
147
+ VALUES (?, ?, ?, ?, ?)
148
+ `)
149
+ stmt.run(
150
+ activity.id,
151
+ activity.type,
152
+ typeof activity.actor === 'string' ? activity.actor : activity.actor?.id,
153
+ typeof activity.object === 'string' ? activity.object : JSON.stringify(activity.object),
154
+ JSON.stringify(activity)
155
+ )
156
+ }
157
+
158
+ export function getActivities(limit = 20) {
159
+ const stmt = db.prepare('SELECT * FROM activities ORDER BY created_at DESC LIMIT ?')
160
+ return stmt.all(limit).map(row => ({
161
+ ...row,
162
+ raw: JSON.parse(row.raw)
163
+ }))
164
+ }
165
+
166
+ // Posts
167
+
168
+ export function savePost(id, content, inReplyTo = null) {
169
+ const stmt = db.prepare(`
170
+ INSERT INTO posts (id, content, in_reply_to)
171
+ VALUES (?, ?, ?)
172
+ `)
173
+ stmt.run(id, content, inReplyTo)
174
+ }
175
+
176
+ export function getPosts(limit = 20) {
177
+ const stmt = db.prepare('SELECT * FROM posts ORDER BY published DESC LIMIT ?')
178
+ return stmt.all(limit)
179
+ }
180
+
181
+ export function getPost(id) {
182
+ const stmt = db.prepare('SELECT * FROM posts WHERE id = ?')
183
+ return stmt.get(id)
184
+ }
185
+
186
+ export function getPostCount() {
187
+ const stmt = db.prepare('SELECT COUNT(*) as count FROM posts')
188
+ return stmt.get().count
189
+ }
190
+
191
+ // Actor cache
192
+
193
+ export function cacheActor(actor) {
194
+ const stmt = db.prepare(`
195
+ INSERT OR REPLACE INTO actors (id, data, fetched_at)
196
+ VALUES (?, ?, CURRENT_TIMESTAMP)
197
+ `)
198
+ stmt.run(actor.id, JSON.stringify(actor))
199
+ }
200
+
201
+ export function getCachedActor(id) {
202
+ const stmt = db.prepare('SELECT * FROM actors WHERE id = ?')
203
+ const row = stmt.get(id)
204
+ return row ? JSON.parse(row.data) : null
205
+ }
206
+
207
+ export default {
208
+ initStore,
209
+ getStore,
210
+ addFollower,
211
+ removeFollower,
212
+ getFollowers,
213
+ getFollowerCount,
214
+ getFollowerInboxes,
215
+ addFollowing,
216
+ acceptFollowing,
217
+ removeFollowing,
218
+ getFollowing,
219
+ getFollowingCount,
220
+ saveActivity,
221
+ getActivities,
222
+ savePost,
223
+ getPosts,
224
+ getPost,
225
+ getPostCount,
226
+ cacheActor,
227
+ getCachedActor
228
+ }
package/src/config.js CHANGED
@@ -50,6 +50,13 @@ export const defaults = {
50
50
  nostrPath: '/relay',
51
51
  nostrMaxEvents: 1000,
52
52
 
53
+ // ActivityPub federation
54
+ activitypub: false,
55
+ apUsername: 'me',
56
+ apDisplayName: null,
57
+ apSummary: null,
58
+ apNostrPubkey: null,
59
+
53
60
  // Invite-only registration
54
61
  inviteOnly: false,
55
62
 
@@ -89,6 +96,11 @@ const envMap = {
89
96
  JSS_NOSTR: 'nostr',
90
97
  JSS_NOSTR_PATH: 'nostrPath',
91
98
  JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
99
+ JSS_ACTIVITYPUB: 'activitypub',
100
+ JSS_AP_USERNAME: 'apUsername',
101
+ JSS_AP_DISPLAY_NAME: 'apDisplayName',
102
+ JSS_AP_SUMMARY: 'apSummary',
103
+ JSS_AP_NOSTR_PUBKEY: 'apNostrPubkey',
92
104
  JSS_INVITE_ONLY: 'inviteOnly',
93
105
  JSS_DEFAULT_QUOTA: 'defaultQuota',
94
106
  };
package/src/server.js CHANGED
@@ -12,6 +12,7 @@ import { idpPlugin } from './idp/index.js';
12
12
  import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
13
13
  import { AccessMode } from './wac/parser.js';
14
14
  import { registerNostrRelay } from './nostr/relay.js';
15
+ import { activityPubPlugin } from './ap/index.js';
15
16
 
16
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
18
 
@@ -31,6 +32,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
31
32
  * @param {boolean} options.nostr - Enable Nostr relay (default false)
32
33
  * @param {string} options.nostrPath - Nostr relay WebSocket path (default '/relay')
33
34
  * @param {number} options.nostrMaxEvents - Max events in relay memory (default 1000)
35
+ * @param {boolean} options.activitypub - Enable ActivityPub federation (default false)
36
+ * @param {string} options.apUsername - ActivityPub username (default 'me')
37
+ * @param {string} options.apDisplayName - ActivityPub display name
38
+ * @param {string} options.apSummary - ActivityPub bio/summary
39
+ * @param {string} options.apNostrPubkey - Nostr pubkey for identity linking
34
40
  */
35
41
  export function createServer(options = {}) {
36
42
  // Content negotiation is OFF by default - we're a JSON-LD native server
@@ -54,6 +60,12 @@ export function createServer(options = {}) {
54
60
  const nostrEnabled = options.nostr ?? false;
55
61
  const nostrPath = options.nostrPath ?? '/relay';
56
62
  const nostrMaxEvents = options.nostrMaxEvents ?? 1000;
63
+ // ActivityPub federation is OFF by default
64
+ const activitypubEnabled = options.activitypub ?? false;
65
+ const apUsername = options.apUsername ?? 'me';
66
+ const apDisplayName = options.apDisplayName ?? options.apUsername ?? 'Anonymous';
67
+ const apSummary = options.apSummary ?? '';
68
+ const apNostrPubkey = options.apNostrPubkey ?? null;
57
69
  // Invite-only registration is OFF by default - open registration
58
70
  const inviteOnly = options.inviteOnly ?? false;
59
71
  // Default storage quota per pod (50MB default, 0 = unlimited)
@@ -152,6 +164,16 @@ export function createServer(options = {}) {
152
164
  });
153
165
  }
154
166
 
167
+ // Register ActivityPub plugin if enabled
168
+ if (activitypubEnabled) {
169
+ fastify.register(activityPubPlugin, {
170
+ username: apUsername,
171
+ displayName: apDisplayName,
172
+ summary: apSummary,
173
+ nostrPubkey: apNostrPubkey
174
+ });
175
+ }
176
+
155
177
  // Register rate limiting plugin
156
178
  // Protects against brute force attacks and resource exhaustion
157
179
  fastify.register(rateLimit, {
@@ -237,8 +259,13 @@ export function createServer(options = {}) {
237
259
  // Authorization hook - check WAC permissions
238
260
  // Skip for pod creation endpoint (needs special handling)
239
261
  fastify.addHook('preHandler', async (request, reply) => {
240
- // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, and git
262
+ // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, git, and AP
241
263
  const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
264
+ const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following'];
265
+ // Check if request wants ActivityPub content for profile
266
+ const accept = request.headers.accept || '';
267
+ const wantsAP = accept.includes('activity+json') || accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');
268
+ const isProfileAP = activitypubEnabled && wantsAP && (request.url === '/profile/card' || request.url.startsWith('/profile/card?'));
242
269
  if (request.url === '/.pods' ||
243
270
  request.url === '/.notifications' ||
244
271
  request.method === 'OPTIONS' ||
@@ -246,6 +273,8 @@ export function createServer(options = {}) {
246
273
  request.url.startsWith('/.well-known/') ||
247
274
  (nostrEnabled && request.url.startsWith(nostrPath)) ||
248
275
  (gitEnabled && isGitRequest(request.url)) ||
276
+ (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
277
+ isProfileAP ||
249
278
  mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
250
279
  return;
251
280
  }