javascript-solid-server 0.0.61 → 0.0.64

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.
@@ -215,7 +215,9 @@
215
215
  "WebFetch(domain:fonstr.com)",
216
216
  "Bash(node -e \"import\\(''nostr-tools''\\).then\\(m => console.log\\(Object.keys\\(m\\).join\\(''\\\\n''\\)\\)\\)\":*)",
217
217
  "Bash(gh repo list:*)",
218
- "Bash(gh search:*)"
218
+ "Bash(gh search:*)",
219
+ "Bash(__NEW_LINE__ echo \"\")",
220
+ "WebFetch(domain:webfinger.net)"
219
221
  ]
220
222
  }
221
223
  }
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.61",
3
+ "version": "0.0.64",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -36,7 +36,8 @@
36
36
  "microfed": "^0.0.14",
37
37
  "n3": "^1.26.0",
38
38
  "nostr-tools": "^2.19.4",
39
- "oidc-provider": "^9.6.0"
39
+ "oidc-provider": "^9.6.0",
40
+ "sql.js": "^1.13.0"
40
41
  },
41
42
  "engines": {
42
43
  "node": ">=18.0.0"
package/src/ap/index.js CHANGED
@@ -11,6 +11,10 @@ import { createOutboxHandler } from './routes/outbox.js'
11
11
  import { createCollectionsHandler } from './routes/collections.js'
12
12
  import { createActorHandler } from './routes/actor.js'
13
13
 
14
+ // Shared state for actor handler (accessed by server.js)
15
+ let sharedActorHandler = null
16
+ export function getActorHandler() { return sharedActorHandler }
17
+
14
18
  /**
15
19
  * ActivityPub Fastify plugin
16
20
  * @param {FastifyInstance} fastify
@@ -23,7 +27,7 @@ import { createActorHandler } from './routes/actor.js'
23
27
  export async function activityPubPlugin(fastify, options = {}) {
24
28
  // Initialize storage and keypair
25
29
  const keypair = loadOrCreateKeypair()
26
- initStore()
30
+ await initStore()
27
31
 
28
32
  // Store config for handlers
29
33
  const config = {
@@ -37,16 +41,37 @@ export async function activityPubPlugin(fastify, options = {}) {
37
41
  // Decorate fastify with AP config
38
42
  fastify.decorate('apConfig', config)
39
43
 
44
+ // Helper to detect protocol from proxy headers
45
+ const getProtocol = (request) => {
46
+ // Check X-Forwarded-Proto first
47
+ let protocol = request.headers['x-forwarded-proto']
48
+ if (!protocol) {
49
+ // Cloudflare uses cf-visitor: {"scheme":"https"}
50
+ const cfVisitor = request.headers['cf-visitor']
51
+ if (cfVisitor) {
52
+ try {
53
+ const parsed = JSON.parse(cfVisitor)
54
+ protocol = parsed.scheme
55
+ } catch { /* ignore */ }
56
+ }
57
+ }
58
+ // If still no protocol and hostname looks like a public domain, assume https
59
+ if (!protocol && request.hostname && !request.hostname.match(/^(localhost|127\.|192\.168\.|10\.)/)) {
60
+ protocol = 'https'
61
+ }
62
+ return protocol || request.protocol
63
+ }
64
+
40
65
  // Helper to build actor ID from request
41
66
  const getActorId = (request) => {
42
- const protocol = request.headers['x-forwarded-proto'] || request.protocol
67
+ const protocol = getProtocol(request)
43
68
  const host = request.headers['x-forwarded-host'] || request.hostname
44
69
  return `${protocol}://${host}/profile/card#me`
45
70
  }
46
71
 
47
72
  // Helper to get base URL
48
73
  const getBaseUrl = (request) => {
49
- const protocol = request.headers['x-forwarded-proto'] || request.protocol
74
+ const protocol = getProtocol(request)
50
75
  const host = request.headers['x-forwarded-host'] || request.hostname
51
76
  return `${protocol}://${host}`
52
77
  }
@@ -110,7 +135,7 @@ export async function activityPubPlugin(fastify, options = {}) {
110
135
  version: '2.1',
111
136
  software: {
112
137
  name: 'jss',
113
- version: '0.0.61',
138
+ version: '0.0.62',
114
139
  repository: 'https://github.com/JavaScriptSolidServer/JavaScriptSolidServer'
115
140
  },
116
141
  protocols: ['activitypub', 'solid'],
@@ -127,37 +152,11 @@ export async function activityPubPlugin(fastify, options = {}) {
127
152
  })
128
153
  })
129
154
 
130
- // Actor endpoint - handle AP content negotiation for /profile/card
155
+ // Actor endpoint - expose handler for profile/card AP requests
131
156
  const actorHandler = createActorHandler(config, keypair)
132
157
 
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
- })
158
+ // Store actorHandler in shared state for use by server-level hook
159
+ sharedActorHandler = actorHandler
161
160
 
162
161
  // Inbox endpoint
163
162
  const inboxHandler = createInboxHandler(config, keypair)
@@ -11,8 +11,24 @@
11
11
  */
12
12
  export function createActorHandler(config, keypair) {
13
13
  return (request) => {
14
- const protocol = request.headers['x-forwarded-proto'] || request.protocol
14
+ // Check various proxy headers for protocol detection
15
+ let protocol = request.headers['x-forwarded-proto']
16
+ if (!protocol) {
17
+ // Cloudflare uses cf-visitor: {"scheme":"https"}
18
+ const cfVisitor = request.headers['cf-visitor']
19
+ if (cfVisitor) {
20
+ try {
21
+ const parsed = JSON.parse(cfVisitor)
22
+ protocol = parsed.scheme
23
+ } catch { /* ignore */ }
24
+ }
25
+ }
26
+ // If still no protocol and hostname looks like a public domain, assume https
15
27
  const host = request.headers['x-forwarded-host'] || request.hostname
28
+ if (!protocol && host && !host.match(/^(localhost|127\.|192\.168\.|10\.)/)) {
29
+ protocol = 'https'
30
+ }
31
+ protocol = protocol || request.protocol
16
32
  const baseUrl = `${protocol}://${host}`
17
33
  const profileUrl = `${baseUrl}/profile/card`
18
34
  const actorId = `${profileUrl}#me`
package/src/ap/store.js CHANGED
@@ -1,72 +1,117 @@
1
1
  /**
2
2
  * ActivityPub SQLite Storage
3
3
  * Persistence layer for federation data
4
+ *
5
+ * Uses better-sqlite3 when available (native, fast)
6
+ * Falls back to sql.js on Android/platforms without native builds
4
7
  */
5
8
 
6
- import Database from 'better-sqlite3'
7
- import { existsSync, mkdirSync } from 'fs'
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
8
10
  import { dirname } from 'path'
9
11
 
10
12
  let db = null
13
+ let dbPath = null
14
+ let usingSqlJs = false
15
+
16
+ // SQL schema
17
+ const SCHEMA = `
18
+ -- Followers (people following us)
19
+ CREATE TABLE IF NOT EXISTS followers (
20
+ id TEXT PRIMARY KEY,
21
+ actor TEXT NOT NULL,
22
+ inbox TEXT,
23
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
24
+ );
25
+
26
+ -- Following (people we follow)
27
+ CREATE TABLE IF NOT EXISTS following (
28
+ id TEXT PRIMARY KEY,
29
+ actor TEXT NOT NULL,
30
+ accepted INTEGER DEFAULT 0,
31
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
32
+ );
33
+
34
+ -- Activities (inbox)
35
+ CREATE TABLE IF NOT EXISTS activities (
36
+ id TEXT PRIMARY KEY,
37
+ type TEXT NOT NULL,
38
+ actor TEXT,
39
+ object TEXT,
40
+ raw TEXT NOT NULL,
41
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
42
+ );
43
+
44
+ -- Posts (our outbox)
45
+ CREATE TABLE IF NOT EXISTS posts (
46
+ id TEXT PRIMARY KEY,
47
+ content TEXT NOT NULL,
48
+ in_reply_to TEXT,
49
+ published TEXT DEFAULT CURRENT_TIMESTAMP
50
+ );
51
+
52
+ -- Known actors (cache)
53
+ CREATE TABLE IF NOT EXISTS actors (
54
+ id TEXT PRIMARY KEY,
55
+ data TEXT NOT NULL,
56
+ fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
57
+ );
58
+ `
11
59
 
12
60
  /**
13
61
  * Initialize the database
62
+ * Tries better-sqlite3 first, falls back to sql.js
14
63
  * @param {string} path - Path to SQLite file
15
64
  */
16
- export function initStore(path = 'data/activitypub.db') {
65
+ export async function initStore(path = 'data/activitypub.db') {
17
66
  // Ensure directory exists
18
67
  const dir = dirname(path)
19
68
  if (!existsSync(dir)) {
20
69
  mkdirSync(dir, { recursive: true })
21
70
  }
22
71
 
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
- `)
72
+ dbPath = path
73
+
74
+ // Try better-sqlite3 first (fast, native)
75
+ try {
76
+ const Database = (await import('better-sqlite3')).default
77
+ db = new Database(path)
78
+ db.exec(SCHEMA)
79
+ usingSqlJs = false
80
+ return db
81
+ } catch (e) {
82
+ // Fall back to sql.js (WASM, works everywhere)
83
+ console.log('ActivityPub: Using sql.js (WASM) for SQLite storage')
84
+
85
+ const initSqlJs = (await import('sql.js')).default
86
+ const SQL = await initSqlJs()
87
+
88
+ // Load existing database if it exists
89
+ if (existsSync(path)) {
90
+ const buffer = readFileSync(path)
91
+ db = new SQL.Database(buffer)
92
+ } else {
93
+ db = new SQL.Database()
94
+ }
95
+
96
+ db.run(SCHEMA)
97
+ usingSqlJs = true
98
+
99
+ // Save initial database
100
+ saveDatabase()
101
+
102
+ return db
103
+ }
104
+ }
68
105
 
69
- return db
106
+ /**
107
+ * Save sql.js database to disk
108
+ */
109
+ function saveDatabase() {
110
+ if (usingSqlJs && db && dbPath) {
111
+ const data = db.export()
112
+ const buffer = Buffer.from(data)
113
+ writeFileSync(dbPath, buffer)
114
+ }
70
115
  }
71
116
 
72
117
  /**
@@ -79,128 +124,156 @@ export function getStore() {
79
124
  return db
80
125
  }
81
126
 
127
+ // Helper to run prepared statements across both implementations
128
+ function runStmt(sql, params = []) {
129
+ if (usingSqlJs) {
130
+ db.run(sql, params)
131
+ saveDatabase()
132
+ } else {
133
+ db.prepare(sql).run(...params)
134
+ }
135
+ }
136
+
137
+ function getOne(sql, params = []) {
138
+ if (usingSqlJs) {
139
+ const stmt = db.prepare(sql)
140
+ stmt.bind(params)
141
+ if (stmt.step()) {
142
+ const row = stmt.getAsObject()
143
+ stmt.free()
144
+ return row
145
+ }
146
+ stmt.free()
147
+ return null
148
+ } else {
149
+ return db.prepare(sql).get(...params)
150
+ }
151
+ }
152
+
153
+ function getAll(sql, params = []) {
154
+ if (usingSqlJs) {
155
+ const results = []
156
+ const stmt = db.prepare(sql)
157
+ stmt.bind(params)
158
+ while (stmt.step()) {
159
+ results.push(stmt.getAsObject())
160
+ }
161
+ stmt.free()
162
+ return results
163
+ } else {
164
+ return db.prepare(sql).all(...params)
165
+ }
166
+ }
167
+
82
168
  // Followers
83
169
 
84
170
  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)
171
+ runStmt(
172
+ 'INSERT OR REPLACE INTO followers (id, actor, inbox) VALUES (?, ?, ?)',
173
+ [actorId, actorId, inbox]
174
+ )
90
175
  }
91
176
 
92
177
  export function removeFollower(actorId) {
93
- const stmt = db.prepare('DELETE FROM followers WHERE id = ?')
94
- stmt.run(actorId)
178
+ runStmt('DELETE FROM followers WHERE id = ?', [actorId])
95
179
  }
96
180
 
97
181
  export function getFollowers() {
98
- const stmt = db.prepare('SELECT * FROM followers ORDER BY created_at DESC')
99
- return stmt.all()
182
+ return getAll('SELECT * FROM followers ORDER BY created_at DESC')
100
183
  }
101
184
 
102
185
  export function getFollowerCount() {
103
- const stmt = db.prepare('SELECT COUNT(*) as count FROM followers')
104
- return stmt.get().count
186
+ const row = getOne('SELECT COUNT(*) as count FROM followers')
187
+ return row ? row.count : 0
105
188
  }
106
189
 
107
190
  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)
191
+ return getAll('SELECT DISTINCT inbox FROM followers WHERE inbox IS NOT NULL')
192
+ .map(row => row.inbox)
110
193
  }
111
194
 
112
195
  // Following
113
196
 
114
197
  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)
198
+ runStmt(
199
+ 'INSERT OR REPLACE INTO following (id, actor, accepted) VALUES (?, ?, ?)',
200
+ [actorId, actorId, accepted ? 1 : 0]
201
+ )
120
202
  }
121
203
 
122
204
  export function acceptFollowing(actorId) {
123
- const stmt = db.prepare('UPDATE following SET accepted = 1 WHERE id = ?')
124
- stmt.run(actorId)
205
+ runStmt('UPDATE following SET accepted = 1 WHERE id = ?', [actorId])
125
206
  }
126
207
 
127
208
  export function removeFollowing(actorId) {
128
- const stmt = db.prepare('DELETE FROM following WHERE id = ?')
129
- stmt.run(actorId)
209
+ runStmt('DELETE FROM following WHERE id = ?', [actorId])
130
210
  }
131
211
 
132
212
  export function getFollowing() {
133
- const stmt = db.prepare('SELECT * FROM following WHERE accepted = 1 ORDER BY created_at DESC')
134
- return stmt.all()
213
+ return getAll('SELECT * FROM following WHERE accepted = 1 ORDER BY created_at DESC')
135
214
  }
136
215
 
137
216
  export function getFollowingCount() {
138
- const stmt = db.prepare('SELECT COUNT(*) as count FROM following WHERE accepted = 1')
139
- return stmt.get().count
217
+ const row = getOne('SELECT COUNT(*) as count FROM following WHERE accepted = 1')
218
+ return row ? row.count : 0
140
219
  }
141
220
 
142
221
  // Activities
143
222
 
144
223
  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)
224
+ runStmt(
225
+ 'INSERT OR REPLACE INTO activities (id, type, actor, object, raw) VALUES (?, ?, ?, ?, ?)',
226
+ [
227
+ activity.id,
228
+ activity.type,
229
+ typeof activity.actor === 'string' ? activity.actor : activity.actor?.id,
230
+ typeof activity.object === 'string' ? activity.object : JSON.stringify(activity.object),
231
+ JSON.stringify(activity)
232
+ ]
155
233
  )
156
234
  }
157
235
 
158
236
  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
- }))
237
+ return getAll('SELECT * FROM activities ORDER BY created_at DESC LIMIT ?', [limit])
238
+ .map(row => ({
239
+ ...row,
240
+ raw: JSON.parse(row.raw)
241
+ }))
164
242
  }
165
243
 
166
244
  // Posts
167
245
 
168
246
  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)
247
+ runStmt(
248
+ 'INSERT INTO posts (id, content, in_reply_to) VALUES (?, ?, ?)',
249
+ [id, content, inReplyTo]
250
+ )
174
251
  }
175
252
 
176
253
  export function getPosts(limit = 20) {
177
- const stmt = db.prepare('SELECT * FROM posts ORDER BY published DESC LIMIT ?')
178
- return stmt.all(limit)
254
+ return getAll('SELECT * FROM posts ORDER BY published DESC LIMIT ?', [limit])
179
255
  }
180
256
 
181
257
  export function getPost(id) {
182
- const stmt = db.prepare('SELECT * FROM posts WHERE id = ?')
183
- return stmt.get(id)
258
+ return getOne('SELECT * FROM posts WHERE id = ?', [id])
184
259
  }
185
260
 
186
261
  export function getPostCount() {
187
- const stmt = db.prepare('SELECT COUNT(*) as count FROM posts')
188
- return stmt.get().count
262
+ const row = getOne('SELECT COUNT(*) as count FROM posts')
263
+ return row ? row.count : 0
189
264
  }
190
265
 
191
266
  // Actor cache
192
267
 
193
268
  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))
269
+ runStmt(
270
+ 'INSERT OR REPLACE INTO actors (id, data, fetched_at) VALUES (?, ?, datetime("now"))',
271
+ [actor.id, JSON.stringify(actor)]
272
+ )
199
273
  }
200
274
 
201
275
  export function getCachedActor(id) {
202
- const stmt = db.prepare('SELECT * FROM actors WHERE id = ?')
203
- const row = stmt.get(id)
276
+ const row = getOne('SELECT * FROM actors WHERE id = ?', [id])
204
277
  return row ? JSON.parse(row.data) : null
205
278
  }
206
279
 
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,7 +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
+ import { activityPubPlugin, getActorHandler } from './ap/index.js';
16
16
 
17
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
18
 
@@ -202,6 +202,33 @@ export function createServer(options = {}) {
202
202
  // Note: OPTIONS requests are handled by handleOptions to include Accept-* headers
203
203
  });
204
204
 
205
+ // ActivityPub actor endpoint - dedicated route for /profile/card with AP Accept header
206
+ // Registered before wildcard routes to take priority
207
+ if (activitypubEnabled) {
208
+ fastify.route({
209
+ method: 'GET',
210
+ url: '/profile/card',
211
+ handler: async (request, reply) => {
212
+ const accept = request.headers.accept || '';
213
+ const wantsAP = accept.includes('activity+json') ||
214
+ accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');
215
+
216
+ const actorHandler = getActorHandler();
217
+ if (wantsAP && actorHandler) {
218
+ const actor = actorHandler(request);
219
+ return reply
220
+ .type('application/activity+json')
221
+ .send(actor);
222
+ }
223
+
224
+ // Not AP request - serve the HTML profile from disk
225
+ // This is handled by importing the resource handler
226
+ const { handleGet } = await import('./handlers/resource.js');
227
+ return handleGet(request, reply);
228
+ }
229
+ });
230
+ }
231
+
205
232
  // Security: Block access to dotfiles except allowed Solid-specific ones
206
233
  // This prevents exposure of .git/, .env, .htpasswd, etc.
207
234
  // Git protocol requests bypass this check when git is enabled