fedbox 0.0.2 โ†’ 0.0.5

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/QUICKSTART.md ADDED
@@ -0,0 +1,90 @@
1
+ # Fedbox Quickstart
2
+
3
+ Clean slate setup with ngrok federation.
4
+
5
+ ## 1. Clean existing data
6
+
7
+ ```bash
8
+ # Remove database only
9
+ fedbox clean
10
+
11
+ # Remove everything (database + config + keys)
12
+ fedbox clean --all
13
+ ```
14
+
15
+ ## 2. Initialize
16
+
17
+ ```bash
18
+ fedbox init
19
+ ```
20
+
21
+ Prompts for: username, display name, bio, port (default 3000).
22
+
23
+ Creates `fedbox.json` with generated keypair.
24
+
25
+ ## 3. Start server
26
+
27
+ ```bash
28
+ fedbox start
29
+ ```
30
+
31
+ Server runs at `http://localhost:3000/{username}`
32
+
33
+ ## 4. Expose with ngrok
34
+
35
+ In another terminal:
36
+
37
+ ```bash
38
+ ngrok http 3000
39
+ ```
40
+
41
+ Copy the https URL (e.g., `https://abc123.ngrok-free.app`)
42
+
43
+ ## 5. Configure domain
44
+
45
+ Edit `fedbox.json`, add domain (without https://):
46
+
47
+ ```json
48
+ {
49
+ "domain": "abc123.ngrok-free.app",
50
+ ...
51
+ }
52
+ ```
53
+
54
+ Restart server (`Ctrl+C`, then `fedbox start`).
55
+
56
+ ## 6. Test federation
57
+
58
+ From Mastodon, search for `@{username}@{domain}`
59
+
60
+ ## 7. Post something
61
+
62
+ ```bash
63
+ fedbox post "Hello, Fediverse!"
64
+ ```
65
+
66
+ ## URI Structure (Solid-compatible)
67
+
68
+ | URI | Purpose |
69
+ |-----|---------|
70
+ | `/{username}` | Profile (HTML + JSON-LD) |
71
+ | `/{username}#me` | WebID (Actor ID) |
72
+ | `/{username}/inbox` | Inbox |
73
+ | `/{username}/outbox` | Outbox |
74
+ | `/{username}/posts/{id}` | Individual post |
75
+ | `/{username}#main-key` | Public key |
76
+
77
+ ## All Commands
78
+
79
+ ```
80
+ fedbox init # Setup identity
81
+ fedbox start # Start server
82
+ fedbox status # Show config
83
+ fedbox post "text" # Post to followers
84
+ fedbox follow @user@dom # Follow someone
85
+ fedbox timeline # View feed
86
+ fedbox reply <url> "text"# Reply to post
87
+ fedbox posts # View own posts
88
+ fedbox clean # Remove database
89
+ fedbox clean --all # Remove everything
90
+ ```
package/bin/cli.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { createInterface } from 'readline'
9
- import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'fs'
9
+ import { existsSync, writeFileSync, mkdirSync, readFileSync, unlinkSync, rmSync } from 'fs'
10
10
  import { generateKeypair } from 'microfed/auth'
11
11
 
12
12
  const rl = createInterface({
@@ -34,6 +34,7 @@ const COMMANDS = {
34
34
  timeline: runTimeline,
35
35
  reply: runReply,
36
36
  posts: runPosts,
37
+ clean: runClean,
37
38
  help: runHelp
38
39
  }
39
40
 
@@ -328,6 +329,42 @@ Create a post with: fedbox post "Hello, Fediverse!"
328
329
  rl.close()
329
330
  }
330
331
 
332
+ async function runClean() {
333
+ const all = process.argv[3] === '--all'
334
+
335
+ console.log('๐Ÿงน Cleaning up...\n')
336
+
337
+ // Remove database
338
+ if (existsSync('data/fedbox.db')) {
339
+ unlinkSync('data/fedbox.db')
340
+ console.log(' โœ“ Removed data/fedbox.db')
341
+ }
342
+
343
+ // Remove data directory if empty
344
+ if (existsSync('data')) {
345
+ try {
346
+ rmSync('data', { recursive: false })
347
+ console.log(' โœ“ Removed data/')
348
+ } catch {
349
+ // Directory not empty, that's ok
350
+ }
351
+ }
352
+
353
+ // Remove config if --all
354
+ if (all && existsSync('fedbox.json')) {
355
+ unlinkSync('fedbox.json')
356
+ console.log(' โœ“ Removed fedbox.json')
357
+ }
358
+
359
+ console.log('\nโœ… Clean complete!')
360
+
361
+ if (!all) {
362
+ console.log('\n Tip: Use "fedbox clean --all" to also remove config')
363
+ }
364
+
365
+ rl.close()
366
+ }
367
+
331
368
  function runHelp() {
332
369
  console.log(`
333
370
  ${BANNER}
@@ -346,6 +383,7 @@ Social:
346
383
  posts View your own posts
347
384
 
348
385
  Other:
386
+ clean Remove database (add --all to also remove config)
349
387
  help Show this help
350
388
 
351
389
  Quick start:
package/lib/actions.js CHANGED
@@ -29,12 +29,26 @@ function loadConfig() {
29
29
  }
30
30
 
31
31
  /**
32
- * Get actor URL
32
+ * Get base URL
33
33
  */
34
- function getActorUrl() {
34
+ function getBaseUrl() {
35
35
  const domain = config.domain || `localhost:${config.port}`
36
36
  const protocol = config.domain ? 'https' : 'http'
37
- return `${protocol}://${domain}/users/${config.username}`
37
+ return `${protocol}://${domain}`
38
+ }
39
+
40
+ /**
41
+ * Get profile URL (the document)
42
+ */
43
+ function getProfileUrl() {
44
+ return `${getBaseUrl()}/${config.username}`
45
+ }
46
+
47
+ /**
48
+ * Get actor URL (WebID with #me fragment)
49
+ */
50
+ function getActorUrl() {
51
+ return `${getProfileUrl()}#me`
38
52
  }
39
53
 
40
54
  /**
@@ -65,14 +79,15 @@ export async function post(content, inReplyTo = null) {
65
79
  initStore()
66
80
 
67
81
  const actorUrl = getActorUrl()
68
- const noteId = `${actorUrl}/posts/${Date.now()}`
82
+ const profileUrl = getProfileUrl()
83
+ const noteId = `${profileUrl}/posts/${Date.now()}`
69
84
 
70
85
  // Create the Note
71
86
  const note = outbox.createNote(actorUrl, content, {
72
87
  id: noteId,
73
88
  inReplyTo,
74
89
  to: ['https://www.w3.org/ns/activitystreams#Public'],
75
- cc: [`${actorUrl}/followers`]
90
+ cc: [`${profileUrl}/followers`]
76
91
  })
77
92
 
78
93
  // Wrap in Create activity
@@ -101,7 +116,7 @@ export async function post(content, inReplyTo = null) {
101
116
  activity: create,
102
117
  inbox: follower.inbox,
103
118
  privateKey: config.privateKey,
104
- keyId: `${actorUrl}#main-key`
119
+ keyId: `${profileUrl}#main-key`
105
120
  })
106
121
  results.success++
107
122
  } catch (err) {
@@ -144,6 +159,7 @@ export async function follow(handle) {
144
159
  }
145
160
 
146
161
  const actorUrl = getActorUrl()
162
+ const profileUrl = getProfileUrl()
147
163
  const inbox = remoteActor.inbox
148
164
 
149
165
  // Create Follow activity
@@ -155,7 +171,7 @@ export async function follow(handle) {
155
171
  activity: followActivity,
156
172
  inbox,
157
173
  privateKey: config.privateKey,
158
- keyId: `${actorUrl}#main-key`
174
+ keyId: `${profileUrl}#main-key`
159
175
  })
160
176
 
161
177
  // Save to following (pending acceptance)
@@ -189,14 +205,15 @@ export async function reply(postUrl, content) {
189
205
  }
190
206
 
191
207
  const actorUrl = getActorUrl()
192
- const noteId = `${actorUrl}/posts/${Date.now()}`
208
+ const profileUrl = getProfileUrl()
209
+ const noteId = `${profileUrl}/posts/${Date.now()}`
193
210
 
194
211
  // Create the reply Note
195
212
  const note = outbox.createNote(actorUrl, content, {
196
213
  id: noteId,
197
214
  inReplyTo: postUrl,
198
215
  to: ['https://www.w3.org/ns/activitystreams#Public'],
199
- cc: [`${actorUrl}/followers`, originalAuthor].filter(Boolean)
216
+ cc: [`${profileUrl}/followers`, originalAuthor].filter(Boolean)
200
217
  })
201
218
 
202
219
  // Wrap in Create activity
@@ -239,7 +256,7 @@ export async function reply(postUrl, content) {
239
256
  activity: create,
240
257
  inbox,
241
258
  privateKey: config.privateKey,
242
- keyId: `${actorUrl}#main-key`
259
+ keyId: `${profileUrl}#main-key`
243
260
  })
244
261
  results.success++
245
262
  } catch {
package/lib/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Fedbox Server
3
3
  * ActivityPub server using microfed
4
+ * Solid-compatible URI structure
4
5
  */
5
6
 
6
7
  import { createServer } from 'http'
@@ -58,21 +59,47 @@ function getProtocol() {
58
59
  }
59
60
 
60
61
  /**
61
- * Build actor object
62
+ * Get base URL
62
63
  */
63
- function buildActor() {
64
- const domain = getDomain()
65
- const protocol = getProtocol()
66
- const baseUrl = `${protocol}://${domain}`
64
+ function getBaseUrl() {
65
+ return `${getProtocol()}://${getDomain()}`
66
+ }
67
67
 
68
- return profile.createActor({
69
- id: `${baseUrl}/users/${config.username}`,
70
- username: config.username,
68
+ /**
69
+ * Build actor object with Solid-compatible URIs
70
+ * /alice is the profile document
71
+ * /alice#me is the WebID (the Person)
72
+ */
73
+ function buildActor() {
74
+ const baseUrl = getBaseUrl()
75
+ const profileUrl = `${baseUrl}/${config.username}`
76
+ const actorId = `${profileUrl}#me`
77
+
78
+ // Build actor manually for more control over structure
79
+ return {
80
+ '@context': [
81
+ 'https://www.w3.org/ns/activitystreams',
82
+ 'https://w3id.org/security/v1'
83
+ ],
84
+ type: 'Person',
85
+ id: actorId,
86
+ url: profileUrl,
87
+ preferredUsername: config.username,
71
88
  name: config.displayName,
72
89
  summary: config.summary ? `<p>${config.summary}</p>` : '',
73
- publicKey: config.publicKey,
74
- sharedInbox: `${baseUrl}/inbox`
75
- })
90
+ inbox: `${profileUrl}/inbox`,
91
+ outbox: `${profileUrl}/outbox`,
92
+ followers: `${profileUrl}/followers`,
93
+ following: `${profileUrl}/following`,
94
+ endpoints: {
95
+ sharedInbox: `${baseUrl}/inbox`
96
+ },
97
+ publicKey: {
98
+ id: `${profileUrl}#main-key`,
99
+ owner: actorId,
100
+ publicKeyPem: config.publicKey
101
+ }
102
+ }
76
103
  }
77
104
 
78
105
  /**
@@ -114,11 +141,13 @@ setInterval(cleanRateLimits, RATE_LIMIT_WINDOW)
114
141
  * Fetch remote actor (with caching)
115
142
  */
116
143
  async function fetchActor(id) {
144
+ // Strip fragment for fetching
145
+ const fetchUrl = id.replace(/#.*$/, '')
117
146
  const cached = getCachedActor(id)
118
147
  if (cached) return cached
119
148
 
120
149
  try {
121
- const response = await fetch(id, {
150
+ const response = await fetch(fetchUrl, {
122
151
  headers: { 'Accept': 'application/activity+json' }
123
152
  })
124
153
  if (!response.ok) return null
@@ -152,13 +181,13 @@ async function verifySignature(req, body) {
152
181
  return { valid: false, reason: 'No keyId in signature' }
153
182
  }
154
183
 
155
- // Extract actor ID from keyId (usually actorId#main-key)
156
- const actorId = keyId.replace(/#.*$/, '')
184
+ // Extract actor URL from keyId (strip fragment like #main-key)
185
+ const actorUrl = keyId.replace(/#.*$/, '')
157
186
 
158
187
  // Fetch the actor to get their public key
159
- const remoteActor = await fetchActor(actorId)
188
+ const remoteActor = await fetchActor(actorUrl)
160
189
  if (!remoteActor) {
161
- return { valid: false, reason: `Could not fetch actor: ${actorId}` }
190
+ return { valid: false, reason: `Could not fetch actor: ${actorUrl}` }
162
191
  }
163
192
 
164
193
  const publicKeyPem = remoteActor.publicKey?.publicKeyPem
@@ -173,8 +202,7 @@ async function verifySignature(req, body) {
173
202
  return `(request-target): ${req.method.toLowerCase()} ${req.url}`
174
203
  }
175
204
  if (header === 'digest' && body) {
176
- // Recalculate digest for comparison
177
- const crypto = await import('crypto')
205
+ const crypto = require('crypto')
178
206
  const digest = crypto.createHash('sha256').update(body).digest('base64')
179
207
  return `digest: SHA-256=${digest}`
180
208
  }
@@ -242,7 +270,7 @@ async function handleFollow(activity) {
242
270
  activity: accept,
243
271
  inbox: followerActor.inbox,
244
272
  privateKey: config.privateKey,
245
- keyId: `${actor.id}#main-key`
273
+ keyId: `${getBaseUrl()}/${config.username}#main-key`
246
274
  })
247
275
  console.log(` ๐Ÿ“ค Sent Accept to ${followerActor.inbox}`)
248
276
  } catch (err) {
@@ -286,7 +314,7 @@ async function handleRequest(req, res) {
286
314
  return res.end('Too many requests')
287
315
  }
288
316
 
289
- const url = new URL(req.url, `${getProtocol()}://${getDomain()}`)
317
+ const url = new URL(req.url, getBaseUrl())
290
318
  const path = url.pathname
291
319
  const accept = req.headers.accept || ''
292
320
  const isAP = accept.includes('activity+json') || accept.includes('ld+json')
@@ -313,102 +341,111 @@ async function handleRequest(req, res) {
313
341
  return res.end('Not found')
314
342
  }
315
343
 
316
- const response = webfinger.createResponse(
317
- `${config.username}@${getDomain()}`,
318
- actor.id,
319
- { profileUrl: `${getProtocol()}://${getDomain()}/@${config.username}` }
320
- )
344
+ // Return /alice#me as the actor (Solid-compatible WebID)
345
+ const profileUrl = `${getBaseUrl()}/${config.username}`
346
+ const response = {
347
+ subject: `acct:${config.username}@${getDomain()}`,
348
+ links: [
349
+ {
350
+ rel: 'self',
351
+ type: 'application/activity+json',
352
+ href: `${profileUrl}#me`
353
+ },
354
+ {
355
+ rel: 'http://webfinger.net/rel/profile-page',
356
+ type: 'text/html',
357
+ href: profileUrl
358
+ }
359
+ ]
360
+ }
321
361
 
322
362
  res.setHeader('Content-Type', 'application/jrd+json')
323
363
  return res.end(JSON.stringify(response, null, 2))
324
364
  }
325
365
 
326
- // Actor
327
- if (path === `/users/${config.username}`) {
328
- res.setHeader('Content-Type', 'application/activity+json')
329
- return res.end(JSON.stringify(actor, null, 2))
330
- }
331
-
332
- // Individual post
333
- const postMatch = path.match(/^\/users\/[^/]+\/posts\/(\d+)$/)
334
- if (postMatch) {
335
- const postId = `${getProtocol()}://${getDomain()}${path}`
336
- const post = getPost(postId)
337
- if (!post) {
338
- res.writeHead(404)
339
- return res.end('Not found')
340
- }
341
-
342
- const note = {
343
- '@context': 'https://www.w3.org/ns/activitystreams',
344
- type: 'Note',
345
- id: post.id,
346
- attributedTo: actor.id,
347
- content: post.content,
348
- published: post.published,
349
- to: ['https://www.w3.org/ns/activitystreams#Public'],
350
- cc: [`${actor.id}/followers`]
351
- }
352
- if (post.in_reply_to) {
353
- note.inReplyTo = post.in_reply_to
366
+ // Nodeinfo discovery
367
+ if (path === '/.well-known/nodeinfo') {
368
+ const response = {
369
+ links: [
370
+ {
371
+ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
372
+ href: `${getBaseUrl()}/nodeinfo/2.1`
373
+ }
374
+ ]
354
375
  }
355
-
356
- res.setHeader('Content-Type', 'application/activity+json')
357
- return res.end(JSON.stringify(note, null, 2))
376
+ res.setHeader('Content-Type', 'application/json')
377
+ return res.end(JSON.stringify(response, null, 2))
358
378
  }
359
379
 
360
- // Inbox
361
- if (path === `/users/${config.username}/inbox` || path === '/inbox') {
362
- if (req.method !== 'POST') {
363
- res.writeHead(405)
364
- return res.end('Method not allowed')
380
+ // Nodeinfo 2.1
381
+ if (path === '/nodeinfo/2.1') {
382
+ const response = {
383
+ version: '2.1',
384
+ software: {
385
+ name: 'fedbox',
386
+ version: '0.0.5',
387
+ repository: 'https://github.com/micro-fed/fedbox'
388
+ },
389
+ protocols: ['activitypub'],
390
+ services: { inbound: [], outbound: [] },
391
+ usage: {
392
+ users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
393
+ localPosts: getPosts(1000).length
394
+ },
395
+ openRegistrations: false,
396
+ metadata: {
397
+ nodeName: config.displayName || config.username,
398
+ nodeDescription: config.summary || 'A Fedbox instance'
399
+ }
365
400
  }
401
+ res.setHeader('Content-Type', 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"')
402
+ return res.end(JSON.stringify(response, null, 2))
403
+ }
366
404
 
367
- const chunks = []
368
- for await (const chunk of req) chunks.push(chunk)
369
- const body = Buffer.concat(chunks).toString()
405
+ // Shared inbox
406
+ if (path === '/inbox') {
407
+ return handleInbox(req, res)
408
+ }
370
409
 
371
- // Verify signature (log warning but don't reject for now - some servers don't sign)
372
- const sigResult = await verifySignature(req, body)
373
- if (!sigResult.valid) {
374
- console.log(` โš ๏ธ Signature: ${sigResult.reason}`)
375
- // Continue anyway - strict mode would return 401 here
410
+ // Profile routes: /alice, /alice/inbox, /alice/outbox, etc.
411
+ if (path === `/${config.username}`) {
412
+ if (isAP) {
413
+ // Return Actor JSON-LD
414
+ res.setHeader('Content-Type', 'application/activity+json')
415
+ return res.end(JSON.stringify(actor, null, 2))
376
416
  } else {
377
- console.log(` ๐Ÿ” Signature verified`)
417
+ // Return HTML with embedded JSON-LD
418
+ res.setHeader('Content-Type', 'text/html')
419
+ return res.end(renderProfile())
378
420
  }
421
+ }
379
422
 
380
- try {
381
- const activity = JSON.parse(body)
382
- await handleActivity(activity)
383
- res.writeHead(202)
384
- return res.end()
385
- } catch (err) {
386
- console.error('Inbox error:', err)
387
- res.writeHead(400)
388
- return res.end('Bad request')
389
- }
423
+ // Inbox
424
+ if (path === `/${config.username}/inbox`) {
425
+ return handleInbox(req, res)
390
426
  }
391
427
 
392
428
  // Outbox
393
- if (path === `/users/${config.username}/outbox`) {
429
+ if (path === `/${config.username}/outbox`) {
394
430
  const posts = getPosts(20)
431
+ const profileUrl = `${getBaseUrl()}/${config.username}`
395
432
  const collection = {
396
433
  '@context': 'https://www.w3.org/ns/activitystreams',
397
434
  type: 'OrderedCollection',
398
- id: `${actor.id}/outbox`,
435
+ id: `${profileUrl}/outbox`,
399
436
  totalItems: posts.length,
400
437
  orderedItems: posts.map(p => ({
401
438
  type: 'Create',
402
- actor: actor.id,
439
+ actor: `${profileUrl}#me`,
403
440
  published: p.published,
404
441
  object: {
405
442
  type: 'Note',
406
443
  id: p.id,
407
444
  content: p.content,
408
445
  published: p.published,
409
- attributedTo: actor.id,
446
+ attributedTo: `${profileUrl}#me`,
410
447
  to: ['https://www.w3.org/ns/activitystreams#Public'],
411
- cc: [`${actor.id}/followers`],
448
+ cc: [`${profileUrl}/followers`],
412
449
  ...(p.in_reply_to ? { inReplyTo: p.in_reply_to } : {})
413
450
  }
414
451
  }))
@@ -418,12 +455,13 @@ async function handleRequest(req, res) {
418
455
  }
419
456
 
420
457
  // Followers
421
- if (path === `/users/${config.username}/followers`) {
458
+ if (path === `/${config.username}/followers`) {
422
459
  const followers = getFollowers()
460
+ const profileUrl = `${getBaseUrl()}/${config.username}`
423
461
  const collection = {
424
462
  '@context': 'https://www.w3.org/ns/activitystreams',
425
463
  type: 'OrderedCollection',
426
- id: `${actor.id}/followers`,
464
+ id: `${profileUrl}/followers`,
427
465
  totalItems: followers.length,
428
466
  orderedItems: followers.map(f => f.actor)
429
467
  }
@@ -432,11 +470,12 @@ async function handleRequest(req, res) {
432
470
  }
433
471
 
434
472
  // Following
435
- if (path === `/users/${config.username}/following`) {
473
+ if (path === `/${config.username}/following`) {
474
+ const profileUrl = `${getBaseUrl()}/${config.username}`
436
475
  const collection = {
437
476
  '@context': 'https://www.w3.org/ns/activitystreams',
438
477
  type: 'OrderedCollection',
439
- id: `${actor.id}/following`,
478
+ id: `${profileUrl}/following`,
440
479
  totalItems: getFollowingCount(),
441
480
  orderedItems: []
442
481
  }
@@ -444,10 +483,38 @@ async function handleRequest(req, res) {
444
483
  return res.end(JSON.stringify(collection, null, 2))
445
484
  }
446
485
 
447
- // HTML Profile
448
- if (path === `/@${config.username}` || (path === `/users/${config.username}` && !isAP)) {
449
- res.setHeader('Content-Type', 'text/html')
450
- return res.end(renderProfile())
486
+ // Individual post: /alice/posts/123
487
+ const postMatch = path.match(new RegExp(`^/${config.username}/posts/(\\d+)$`))
488
+ if (postMatch) {
489
+ const postId = `${getBaseUrl()}${path}`
490
+ const post = getPost(postId)
491
+ if (!post) {
492
+ res.writeHead(404)
493
+ return res.end('Not found')
494
+ }
495
+
496
+ const profileUrl = `${getBaseUrl()}/${config.username}`
497
+ const note = {
498
+ '@context': 'https://www.w3.org/ns/activitystreams',
499
+ type: 'Note',
500
+ id: post.id,
501
+ attributedTo: `${profileUrl}#me`,
502
+ content: post.content,
503
+ published: post.published,
504
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
505
+ cc: [`${profileUrl}/followers`]
506
+ }
507
+ if (post.in_reply_to) {
508
+ note.inReplyTo = post.in_reply_to
509
+ }
510
+
511
+ if (isAP) {
512
+ res.setHeader('Content-Type', 'application/activity+json')
513
+ return res.end(JSON.stringify(note, null, 2))
514
+ } else {
515
+ res.setHeader('Content-Type', 'text/html')
516
+ return res.end(renderPost(post, note))
517
+ }
451
518
  }
452
519
 
453
520
  // Home
@@ -461,12 +528,46 @@ async function handleRequest(req, res) {
461
528
  }
462
529
 
463
530
  /**
464
- * Render HTML profile
531
+ * Handle inbox POST
532
+ */
533
+ async function handleInbox(req, res) {
534
+ if (req.method !== 'POST') {
535
+ res.writeHead(405)
536
+ return res.end('Method not allowed')
537
+ }
538
+
539
+ const chunks = []
540
+ for await (const chunk of req) chunks.push(chunk)
541
+ const body = Buffer.concat(chunks).toString()
542
+
543
+ // Verify signature
544
+ const sigResult = await verifySignature(req, body)
545
+ if (!sigResult.valid) {
546
+ console.log(` โš ๏ธ Signature: ${sigResult.reason}`)
547
+ } else {
548
+ console.log(` ๐Ÿ” Signature verified`)
549
+ }
550
+
551
+ try {
552
+ const activity = JSON.parse(body)
553
+ await handleActivity(activity)
554
+ res.writeHead(202)
555
+ return res.end()
556
+ } catch (err) {
557
+ console.error('Inbox error:', err)
558
+ res.writeHead(400)
559
+ return res.end('Bad request')
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Render HTML profile with embedded JSON-LD
465
565
  */
466
566
  function renderProfile() {
467
567
  const followers = getFollowerCount()
468
568
  const following = getFollowingCount()
469
569
  const posts = getPosts(10)
570
+ const profileUrl = `${getBaseUrl()}/${config.username}`
470
571
 
471
572
  return `<!DOCTYPE html>
472
573
  <html>
@@ -474,6 +575,10 @@ function renderProfile() {
474
575
  <meta charset="utf-8">
475
576
  <title>${config.displayName} (@${config.username}@${getDomain()})</title>
476
577
  <meta name="viewport" content="width=device-width, initial-scale=1">
578
+ <link rel="alternate" type="application/activity+json" href="${profileUrl}">
579
+ <script type="application/ld+json">
580
+ ${JSON.stringify(actor, null, 2)}
581
+ </script>
477
582
  <style>
478
583
  * { box-sizing: border-box; }
479
584
  body {
@@ -528,6 +633,7 @@ function renderProfile() {
528
633
  }
529
634
  .post-content { margin-bottom: 0.5rem; }
530
635
  .post-meta { color: #666; font-size: 0.8rem; }
636
+ .post a { color: #667eea; text-decoration: none; }
531
637
  </style>
532
638
  </head>
533
639
  <body>
@@ -558,7 +664,9 @@ function renderProfile() {
558
664
  ${posts.map(p => `
559
665
  <div class="post">
560
666
  <div class="post-content">${p.content}</div>
561
- <div class="post-meta">${new Date(p.published).toLocaleString()}</div>
667
+ <div class="post-meta">
668
+ <a href="${p.id}">${new Date(p.published).toLocaleString()}</a>
669
+ </div>
562
670
  </div>
563
671
  `).join('')}
564
672
  </div>
@@ -567,10 +675,79 @@ function renderProfile() {
567
675
  </html>`
568
676
  }
569
677
 
678
+ /**
679
+ * Render individual post with embedded JSON-LD
680
+ */
681
+ function renderPost(post, note) {
682
+ return `<!DOCTYPE html>
683
+ <html>
684
+ <head>
685
+ <meta charset="utf-8">
686
+ <title>Post by ${config.displayName}</title>
687
+ <meta name="viewport" content="width=device-width, initial-scale=1">
688
+ <link rel="alternate" type="application/activity+json" href="${post.id}">
689
+ <script type="application/ld+json">
690
+ ${JSON.stringify(note, null, 2)}
691
+ </script>
692
+ <style>
693
+ * { box-sizing: border-box; }
694
+ body {
695
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
696
+ max-width: 600px;
697
+ margin: 0 auto;
698
+ padding: 2rem;
699
+ background: #1a1a2e;
700
+ color: #eee;
701
+ min-height: 100vh;
702
+ }
703
+ .post {
704
+ background: #16213e;
705
+ border-radius: 16px;
706
+ padding: 2rem;
707
+ }
708
+ .author {
709
+ display: flex;
710
+ align-items: center;
711
+ gap: 1rem;
712
+ margin-bottom: 1rem;
713
+ }
714
+ .avatar {
715
+ width: 48px;
716
+ height: 48px;
717
+ border-radius: 50%;
718
+ background: linear-gradient(135deg, #667eea, #764ba2);
719
+ display: flex;
720
+ align-items: center;
721
+ justify-content: center;
722
+ font-size: 1.5rem;
723
+ }
724
+ .author-info a { color: #667eea; text-decoration: none; }
725
+ .author-info p { margin: 0; color: #888; font-size: 0.9rem; }
726
+ .content { font-size: 1.2rem; line-height: 1.6; margin: 1rem 0; }
727
+ .meta { color: #666; font-size: 0.9rem; }
728
+ </style>
729
+ </head>
730
+ <body>
731
+ <div class="post">
732
+ <div class="author">
733
+ <div class="avatar">๐Ÿ“ฆ</div>
734
+ <div class="author-info">
735
+ <a href="/${config.username}">${config.displayName}</a>
736
+ <p>@${config.username}@${getDomain()}</p>
737
+ </div>
738
+ </div>
739
+ <div class="content">${post.content}</div>
740
+ <div class="meta">${new Date(post.published).toLocaleString()}</div>
741
+ </div>
742
+ </body>
743
+ </html>`
744
+ }
745
+
570
746
  /**
571
747
  * Render home page
572
748
  */
573
749
  function renderHome() {
750
+ const profileUrl = `${getBaseUrl()}/${config.username}`
574
751
  return `<!DOCTYPE html>
575
752
  <html>
576
753
  <head>
@@ -598,15 +775,16 @@ function renderHome() {
598
775
  <p>Your Fediverse server is running!</p>
599
776
 
600
777
  <h2>Your Profile</h2>
601
- <p><a href="/@${config.username}">@${config.username}@${getDomain()}</a></p>
778
+ <p><a href="/${config.username}">@${config.username}@${getDomain()}</a></p>
602
779
 
603
780
  <h2>Endpoints</h2>
604
781
  <ul class="endpoints">
605
782
  <li><code>/.well-known/webfinger</code> - Discovery</li>
606
- <li><code>/users/${config.username}</code> - Actor</li>
607
- <li><code>/users/${config.username}/inbox</code> - Inbox</li>
608
- <li><code>/users/${config.username}/outbox</code> - Outbox</li>
609
- <li><code>/users/${config.username}/followers</code> - Followers</li>
783
+ <li><code>/${config.username}</code> - Profile (HTML + JSON-LD)</li>
784
+ <li><code>/${config.username}#me</code> - WebID (Actor)</li>
785
+ <li><code>/${config.username}/inbox</code> - Inbox</li>
786
+ <li><code>/${config.username}/outbox</code> - Outbox</li>
787
+ <li><code>/${config.username}/followers</code> - Followers</li>
610
788
  </ul>
611
789
 
612
790
  <h2>Federation</h2>
@@ -631,10 +809,10 @@ export async function startServer() {
631
809
  console.log(`
632
810
  ๐Ÿ“ฆ Fedbox is running!
633
811
 
634
- Profile: http://localhost:${config.port}/@${config.username}
635
- Actor: http://localhost:${config.port}/users/${config.username}
812
+ Profile: http://localhost:${config.port}/${config.username}
813
+ WebID: http://localhost:${config.port}/${config.username}#me
636
814
 
637
- ${config.domain ? ` Federated: https://${config.domain}/@${config.username}` : ' โš ๏ธ Set "domain" in fedbox.json for federation'}
815
+ ${config.domain ? ` Federated: https://${config.domain}/${config.username}` : ' โš ๏ธ Set "domain" in fedbox.json for federation'}
638
816
 
639
817
  Press Ctrl+C to stop
640
818
  `)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedbox",
3
- "version": "0.0.2",
3
+ "version": "0.0.5",
4
4
  "description": "Zero to Fediverse in 60 seconds",
5
5
  "type": "module",
6
6
  "main": "lib/server.js",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node lib/server.js",
12
- "test": "node --test test/"
12
+ "test": "node --test 'test/*.test.js'"
13
13
  },
14
14
  "dependencies": {
15
15
  "microfed": "^0.0.13",
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Actions tests
3
+ */
4
+
5
+ import { test, describe, before, after } from 'node:test'
6
+ import assert from 'node:assert'
7
+ import { writeFileSync, unlinkSync, existsSync, mkdirSync, rmSync } from 'fs'
8
+ import { generateKeypair } from 'microfed/auth'
9
+
10
+ function setupConfig(domain = null) {
11
+ const { publicKey, privateKey } = generateKeypair()
12
+ const config = {
13
+ username: 'alice',
14
+ displayName: 'Alice',
15
+ summary: 'Test user',
16
+ port: 3000,
17
+ publicKey,
18
+ privateKey
19
+ }
20
+ if (domain) config.domain = domain
21
+ writeFileSync('fedbox.json', JSON.stringify(config, null, 2))
22
+ }
23
+
24
+ function cleanup() {
25
+ if (existsSync('fedbox.json')) unlinkSync('fedbox.json')
26
+ if (existsSync('data/fedbox.db')) unlinkSync('data/fedbox.db')
27
+ try { rmSync('data', { recursive: true }) } catch {}
28
+ }
29
+
30
+ describe('Actions', () => {
31
+ before(() => {
32
+ cleanup()
33
+ if (!existsSync('data')) mkdirSync('data')
34
+ setupConfig()
35
+ })
36
+
37
+ after(() => {
38
+ cleanup()
39
+ })
40
+
41
+ test('post creates note with Solid-compatible noteId', async () => {
42
+ const { post } = await import('../lib/actions.js')
43
+ const result = await post('Hello world!')
44
+
45
+ // noteId should be /alice/posts/xxx (not /alice#me/posts/xxx)
46
+ assert.ok(result.noteId.includes('/alice/posts/'), 'noteId should include /alice/posts/')
47
+ assert.ok(!result.noteId.includes('#me/posts'), 'noteId should not have #me before /posts')
48
+ assert.ok(!result.noteId.includes('#me'), 'noteId should not contain #me at all')
49
+ })
50
+
51
+ test('post returns delivery results', async () => {
52
+ const { post } = await import('../lib/actions.js')
53
+ const result = await post('Test delivery')
54
+
55
+ assert.ok('delivered' in result, 'result should have delivered stats')
56
+ assert.ok('success' in result.delivered)
57
+ assert.ok('failed' in result.delivered)
58
+ })
59
+
60
+ test('myPosts returns saved posts', async () => {
61
+ const { myPosts } = await import('../lib/actions.js')
62
+ const posts = myPosts(10)
63
+ // Should have posts from previous tests
64
+ assert.ok(posts.length >= 1, 'should have at least 1 post')
65
+ assert.ok(posts[0].content, 'post should have content')
66
+ assert.ok(posts[0].id, 'post should have id')
67
+ })
68
+
69
+ test('myPosts posts have Solid-compatible URLs', async () => {
70
+ const { myPosts } = await import('../lib/actions.js')
71
+ const posts = myPosts(10)
72
+
73
+ for (const post of posts) {
74
+ assert.ok(post.id.includes('/alice/posts/'), 'post id should include /alice/posts/')
75
+ assert.ok(!post.id.includes('#me'), 'post id should not contain #me')
76
+ }
77
+ })
78
+ })
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Store tests
3
+ */
4
+
5
+ import { test, describe, beforeEach, afterEach } from 'node:test'
6
+ import assert from 'node:assert'
7
+ import { unlinkSync, existsSync, mkdirSync } from 'fs'
8
+ import {
9
+ initStore,
10
+ getStore,
11
+ addFollower,
12
+ removeFollower,
13
+ getFollowers,
14
+ getFollowerCount,
15
+ addFollowing,
16
+ acceptFollowing,
17
+ getFollowing,
18
+ getFollowingCount,
19
+ savePost,
20
+ getPosts,
21
+ getPost,
22
+ cacheActor,
23
+ getCachedActor,
24
+ saveActivity,
25
+ getActivities
26
+ } from '../lib/store.js'
27
+
28
+ const TEST_DB = 'test/test.db'
29
+
30
+ function cleanup() {
31
+ if (existsSync(TEST_DB)) {
32
+ unlinkSync(TEST_DB)
33
+ }
34
+ }
35
+
36
+ describe('Store', () => {
37
+ beforeEach(() => {
38
+ cleanup()
39
+ if (!existsSync('test')) mkdirSync('test', { recursive: true })
40
+ initStore(TEST_DB)
41
+ })
42
+
43
+ afterEach(() => {
44
+ const db = getStore()
45
+ db.close()
46
+ cleanup()
47
+ })
48
+
49
+ test('initStore creates database', () => {
50
+ assert.ok(existsSync(TEST_DB))
51
+ })
52
+
53
+ test('getStore returns database instance', () => {
54
+ const db = getStore()
55
+ assert.ok(db)
56
+ })
57
+ })
58
+
59
+ describe('Followers', () => {
60
+ beforeEach(() => {
61
+ cleanup()
62
+ initStore(TEST_DB)
63
+ })
64
+
65
+ afterEach(() => {
66
+ const db = getStore()
67
+ db.close()
68
+ cleanup()
69
+ })
70
+
71
+ test('addFollower and getFollowers', () => {
72
+ addFollower('https://example.com/user/alice', 'https://example.com/user/alice/inbox')
73
+ const followers = getFollowers()
74
+ assert.strictEqual(followers.length, 1)
75
+ assert.strictEqual(followers[0].actor, 'https://example.com/user/alice')
76
+ assert.strictEqual(followers[0].inbox, 'https://example.com/user/alice/inbox')
77
+ })
78
+
79
+ test('getFollowerCount', () => {
80
+ assert.strictEqual(getFollowerCount(), 0)
81
+ addFollower('https://example.com/user/alice', 'https://example.com/inbox')
82
+ assert.strictEqual(getFollowerCount(), 1)
83
+ addFollower('https://example.com/user/bob', 'https://example.com/inbox')
84
+ assert.strictEqual(getFollowerCount(), 2)
85
+ })
86
+
87
+ test('removeFollower', () => {
88
+ addFollower('https://example.com/user/alice', 'https://example.com/inbox')
89
+ assert.strictEqual(getFollowerCount(), 1)
90
+ removeFollower('https://example.com/user/alice')
91
+ assert.strictEqual(getFollowerCount(), 0)
92
+ })
93
+ })
94
+
95
+ describe('Following', () => {
96
+ beforeEach(() => {
97
+ cleanup()
98
+ initStore(TEST_DB)
99
+ })
100
+
101
+ afterEach(() => {
102
+ const db = getStore()
103
+ db.close()
104
+ cleanup()
105
+ })
106
+
107
+ test('addFollowing with pending status', () => {
108
+ addFollowing('https://example.com/user/bob', false)
109
+ assert.strictEqual(getFollowingCount(), 0) // Not accepted yet
110
+ })
111
+
112
+ test('acceptFollowing', () => {
113
+ addFollowing('https://example.com/user/bob', false)
114
+ assert.strictEqual(getFollowingCount(), 0)
115
+ acceptFollowing('https://example.com/user/bob')
116
+ assert.strictEqual(getFollowingCount(), 1)
117
+ })
118
+
119
+ test('getFollowing returns only accepted', () => {
120
+ addFollowing('https://example.com/user/bob', false)
121
+ addFollowing('https://example.com/user/charlie', true)
122
+ const following = getFollowing()
123
+ assert.strictEqual(following.length, 1)
124
+ assert.strictEqual(following[0].actor, 'https://example.com/user/charlie')
125
+ })
126
+ })
127
+
128
+ describe('Posts', () => {
129
+ beforeEach(() => {
130
+ cleanup()
131
+ initStore(TEST_DB)
132
+ })
133
+
134
+ afterEach(() => {
135
+ const db = getStore()
136
+ db.close()
137
+ cleanup()
138
+ })
139
+
140
+ test('savePost and getPost', () => {
141
+ const id = 'https://example.com/alice/posts/123'
142
+ savePost(id, 'Hello world!')
143
+ const post = getPost(id)
144
+ assert.ok(post)
145
+ assert.strictEqual(post.id, id)
146
+ assert.strictEqual(post.content, 'Hello world!')
147
+ })
148
+
149
+ test('savePost with inReplyTo', () => {
150
+ const id = 'https://example.com/alice/posts/456'
151
+ const replyTo = 'https://other.com/posts/789'
152
+ savePost(id, 'This is a reply', replyTo)
153
+ const post = getPost(id)
154
+ assert.strictEqual(post.in_reply_to, replyTo)
155
+ })
156
+
157
+ test('getPosts returns posts', () => {
158
+ savePost('https://example.com/posts/1', 'First')
159
+ savePost('https://example.com/posts/2', 'Second')
160
+ savePost('https://example.com/posts/3', 'Third')
161
+ const posts = getPosts(10)
162
+ assert.strictEqual(posts.length, 3)
163
+ // All posts should be present
164
+ const contents = posts.map(p => p.content)
165
+ assert.ok(contents.includes('First'))
166
+ assert.ok(contents.includes('Second'))
167
+ assert.ok(contents.includes('Third'))
168
+ })
169
+
170
+ test('getPosts respects limit', () => {
171
+ for (let i = 0; i < 10; i++) {
172
+ savePost(`https://example.com/posts/${i}`, `Post ${i}`)
173
+ }
174
+ const posts = getPosts(5)
175
+ assert.strictEqual(posts.length, 5)
176
+ })
177
+ })
178
+
179
+ describe('Actor Cache', () => {
180
+ beforeEach(() => {
181
+ cleanup()
182
+ initStore(TEST_DB)
183
+ })
184
+
185
+ afterEach(() => {
186
+ const db = getStore()
187
+ db.close()
188
+ cleanup()
189
+ })
190
+
191
+ test('cacheActor and getCachedActor', () => {
192
+ const actor = {
193
+ id: 'https://example.com/user/alice',
194
+ type: 'Person',
195
+ preferredUsername: 'alice'
196
+ }
197
+ cacheActor(actor)
198
+ const cached = getCachedActor(actor.id)
199
+ assert.deepStrictEqual(cached, actor)
200
+ })
201
+
202
+ test('getCachedActor returns null for unknown', () => {
203
+ const cached = getCachedActor('https://example.com/unknown')
204
+ assert.strictEqual(cached, null)
205
+ })
206
+ })
207
+
208
+ describe('Activities', () => {
209
+ beforeEach(() => {
210
+ cleanup()
211
+ initStore(TEST_DB)
212
+ })
213
+
214
+ afterEach(() => {
215
+ const db = getStore()
216
+ db.close()
217
+ cleanup()
218
+ })
219
+
220
+ test('saveActivity and getActivities', () => {
221
+ const activity = {
222
+ id: 'https://example.com/activity/1',
223
+ type: 'Create',
224
+ actor: 'https://example.com/user/alice',
225
+ object: { type: 'Note', content: 'Hello' }
226
+ }
227
+ saveActivity(activity)
228
+ const activities = getActivities(10)
229
+ assert.strictEqual(activities.length, 1)
230
+ assert.strictEqual(activities[0].type, 'Create')
231
+ assert.deepStrictEqual(activities[0].raw, activity)
232
+ })
233
+ })