javascript-solid-server 0.0.62 → 0.0.65

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.62",
3
+ "version": "0.0.65",
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.62',
138
+ version: '0.0.65',
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`
@@ -17,9 +17,10 @@ import { getKeyId } from '../keys.js'
17
17
  /**
18
18
  * Fetch remote actor (with caching)
19
19
  * @param {string} id - Actor URL
20
+ * @param {object} log - Logger instance (optional)
20
21
  * @returns {Promise<object|null>} Actor object or null
21
22
  */
22
- async function fetchActor(id) {
23
+ async function fetchActor(id, log) {
23
24
  // Strip fragment for fetching
24
25
  const fetchUrl = id.replace(/#.*$/, '')
25
26
  const cached = getCachedActor(id)
@@ -27,14 +28,21 @@ async function fetchActor(id) {
27
28
 
28
29
  try {
29
30
  const response = await fetch(fetchUrl, {
30
- headers: { 'Accept': 'application/activity+json' }
31
+ headers: {
32
+ 'Accept': 'application/activity+json',
33
+ 'User-Agent': 'JSS/1.0 (+https://github.com/JavaScriptSolidServer/JavaScriptSolidServer)'
34
+ }
31
35
  })
32
- if (!response.ok) return null
36
+ if (!response.ok) {
37
+ if (log) log.warn(`Actor fetch failed: ${response.status} ${response.statusText} for ${fetchUrl}`)
38
+ return null
39
+ }
33
40
 
34
41
  const actor = await response.json()
35
42
  cacheActor(actor)
36
43
  return actor
37
- } catch {
44
+ } catch (err) {
45
+ if (log) log.error(`Actor fetch error for ${fetchUrl}: ${err.message}`)
38
46
  return null
39
47
  }
40
48
  }
@@ -66,7 +74,7 @@ async function verifySignature(request, body) {
66
74
  const actorUrl = keyId.replace(/#.*$/, '')
67
75
 
68
76
  // Fetch the actor to get their public key
69
- const remoteActor = await fetchActor(actorUrl)
77
+ const remoteActor = await fetchActor(actorUrl, request.log)
70
78
  if (!remoteActor) {
71
79
  return { valid: false, reason: `Could not fetch actor: ${actorUrl}` }
72
80
  }
@@ -185,7 +193,7 @@ export function createInboxHandler(config, keypair) {
185
193
  * Handle Follow activity
186
194
  */
187
195
  async function handleFollow(activity, actorId, profileUrl, keypair, log) {
188
- const followerActor = await fetchActor(activity.actor)
196
+ const followerActor = await fetchActor(activity.actor, log)
189
197
  if (!followerActor) {
190
198
  log.warn('Could not fetch follower actor')
191
199
  return
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/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