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.
- package/.claude/settings.local.json +3 -1
- package/README.md +86 -1
- package/bin/jss.js +12 -0
- package/package.json +8 -2
- package/scripts/install-termux.sh +81 -0
- package/src/ap/index.js +177 -0
- package/src/ap/keys.js +64 -0
- package/src/ap/routes/actor.js +54 -0
- package/src/ap/routes/collections.js +46 -0
- package/src/ap/routes/inbox.js +239 -0
- package/src/ap/routes/outbox.js +52 -0
- package/src/ap/store.js +228 -0
- package/src/config.js +12 -0
- package/src/server.js +30 -1
|
@@ -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.
|
|
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.
|
|
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 ""
|
package/src/ap/index.js
ADDED
|
@@ -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 }
|
package/src/ap/store.js
ADDED
|
@@ -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
|
|
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
|
}
|