microfed 0.0.11 → 0.0.13

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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Microfed WebFinger Module
3
+ * Actor discovery via WebFinger protocol
4
+ */
5
+
6
+ const WEBFINGER_PATH = '/.well-known/webfinger'
7
+ const ACTIVITYPUB_TYPE = 'application/activity+json'
8
+
9
+ /**
10
+ * Create WebFinger response for an actor
11
+ * @param {string} account - Account identifier (user@domain)
12
+ * @param {string} actorUrl - Actor's ActivityPub URL
13
+ * @param {Object} [options] - Additional options
14
+ * @param {string} [options.profileUrl] - HTML profile page URL
15
+ * @param {Array} [options.aliases] - Alternative identifiers
16
+ * @returns {Object} WebFinger JRD response
17
+ */
18
+ export function createResponse(account, actorUrl, options = {}) {
19
+ const response = {
20
+ subject: `acct:${account}`,
21
+ links: [
22
+ {
23
+ rel: 'self',
24
+ type: ACTIVITYPUB_TYPE,
25
+ href: actorUrl
26
+ }
27
+ ]
28
+ }
29
+
30
+ // Add HTML profile link
31
+ if (options.profileUrl) {
32
+ response.links.push({
33
+ rel: 'http://webfinger.net/rel/profile-page',
34
+ type: 'text/html',
35
+ href: options.profileUrl
36
+ })
37
+ }
38
+
39
+ // Add aliases
40
+ if (options.aliases) {
41
+ response.aliases = options.aliases
42
+ }
43
+
44
+ return response
45
+ }
46
+
47
+ /**
48
+ * Parse WebFinger resource query
49
+ * @param {string} resource - Resource query (acct:user@domain or URL)
50
+ * @returns {Object|null} { username, domain } or null
51
+ */
52
+ export function parseResource(resource) {
53
+ if (!resource) return null
54
+
55
+ // Handle acct: URI
56
+ if (resource.startsWith('acct:')) {
57
+ const account = resource.slice(5)
58
+ const [username, domain] = account.split('@')
59
+ if (username && domain) {
60
+ return { username, domain }
61
+ }
62
+ }
63
+
64
+ // Handle URL
65
+ if (resource.startsWith('https://') || resource.startsWith('http://')) {
66
+ try {
67
+ const url = new URL(resource)
68
+ const match = url.pathname.match(/\/users\/([^/]+)/)
69
+ if (match) {
70
+ return { username: match[1], domain: url.host }
71
+ }
72
+ } catch {
73
+ return null
74
+ }
75
+ }
76
+
77
+ return null
78
+ }
79
+
80
+ /**
81
+ * Lookup actor via WebFinger
82
+ * @param {string} account - Account to lookup (user@domain)
83
+ * @returns {Promise<Object>} WebFinger response
84
+ */
85
+ export async function lookup(account) {
86
+ // Extract domain
87
+ const [, domain] = account.includes('@') ? account.split('@') : [null, account]
88
+ if (!domain) throw new Error('Invalid account format')
89
+
90
+ const resource = account.includes('@') ? `acct:${account}` : account
91
+ const url = `https://${domain}${WEBFINGER_PATH}?resource=${encodeURIComponent(resource)}`
92
+
93
+ const response = await fetch(url, {
94
+ headers: { 'Accept': 'application/jrd+json, application/json' }
95
+ })
96
+
97
+ if (!response.ok) {
98
+ throw new Error(`WebFinger lookup failed: ${response.status}`)
99
+ }
100
+
101
+ return response.json()
102
+ }
103
+
104
+ /**
105
+ * Get actor URL from WebFinger response
106
+ * @param {Object} webfinger - WebFinger JRD response
107
+ * @returns {string|null} Actor URL or null
108
+ */
109
+ export function getActorUrl(webfinger) {
110
+ if (!webfinger.links) return null
111
+
112
+ const link = webfinger.links.find(l =>
113
+ l.rel === 'self' && l.type === ACTIVITYPUB_TYPE
114
+ )
115
+
116
+ return link?.href || null
117
+ }
118
+
119
+ /**
120
+ * Resolve account to actor object
121
+ * @param {string} account - Account (user@domain)
122
+ * @returns {Promise<Object>} Actor object
123
+ */
124
+ export async function resolve(account) {
125
+ const webfinger = await lookup(account)
126
+ const actorUrl = getActorUrl(webfinger)
127
+
128
+ if (!actorUrl) {
129
+ throw new Error('No ActivityPub actor found')
130
+ }
131
+
132
+ const response = await fetch(actorUrl, {
133
+ headers: { 'Accept': ACTIVITYPUB_TYPE }
134
+ })
135
+
136
+ if (!response.ok) {
137
+ throw new Error(`Actor fetch failed: ${response.status}`)
138
+ }
139
+
140
+ return response.json()
141
+ }
142
+
143
+ /**
144
+ * Create WebFinger request handler
145
+ * @param {Function} findActor - Function(username) => { actorUrl, profileUrl? }
146
+ * @returns {Function} Handler(req) => Response
147
+ */
148
+ export function createHandler(findActor) {
149
+ return async (req) => {
150
+ const url = new URL(req.url)
151
+ const resource = url.searchParams.get('resource')
152
+
153
+ if (!resource) {
154
+ return new Response('Missing resource parameter', { status: 400 })
155
+ }
156
+
157
+ const parsed = parseResource(resource)
158
+ if (!parsed) {
159
+ return new Response('Invalid resource format', { status: 400 })
160
+ }
161
+
162
+ const actor = await findActor(parsed.username)
163
+ if (!actor) {
164
+ return new Response('Not found', { status: 404 })
165
+ }
166
+
167
+ const response = createResponse(
168
+ `${parsed.username}@${parsed.domain}`,
169
+ actor.actorUrl,
170
+ { profileUrl: actor.profileUrl }
171
+ )
172
+
173
+ return new Response(JSON.stringify(response), {
174
+ headers: {
175
+ 'Content-Type': 'application/jrd+json',
176
+ 'Access-Control-Allow-Origin': '*'
177
+ }
178
+ })
179
+ }
180
+ }
181
+
182
+ export default { createResponse, parseResource, lookup, getActorUrl, resolve, createHandler }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Microfed Auth Module Tests
3
+ * Run: node --test test/auth.test.js
4
+ */
5
+
6
+ import { test, describe } from 'node:test'
7
+ import assert from 'node:assert'
8
+ import { generateKeypair, sign, verify, parseSignatureHeader, digest, verifyDigest } from '../src/auth.js'
9
+
10
+ describe('generateKeypair', () => {
11
+ test('generates valid RSA keypair', () => {
12
+ const { publicKey, privateKey } = generateKeypair()
13
+
14
+ assert.ok(publicKey.includes('-----BEGIN PUBLIC KEY-----'))
15
+ assert.ok(publicKey.includes('-----END PUBLIC KEY-----'))
16
+ assert.ok(privateKey.includes('-----BEGIN PRIVATE KEY-----'))
17
+ assert.ok(privateKey.includes('-----END PRIVATE KEY-----'))
18
+ })
19
+
20
+ test('generates unique keypairs', () => {
21
+ const pair1 = generateKeypair()
22
+ const pair2 = generateKeypair()
23
+
24
+ assert.notStrictEqual(pair1.publicKey, pair2.publicKey)
25
+ assert.notStrictEqual(pair1.privateKey, pair2.privateKey)
26
+ })
27
+ })
28
+
29
+ describe('sign', () => {
30
+ test('returns required headers', () => {
31
+ const { privateKey } = generateKeypair()
32
+
33
+ const headers = sign({
34
+ privateKey,
35
+ keyId: 'https://example.com/users/alice#main-key',
36
+ method: 'POST',
37
+ url: 'https://remote.example/users/bob/inbox',
38
+ body: '{"type":"Follow"}'
39
+ })
40
+
41
+ assert.ok(headers.Date)
42
+ assert.ok(headers.Signature)
43
+ assert.ok(headers.Digest)
44
+ assert.ok(headers.Digest.startsWith('SHA-256='))
45
+ })
46
+
47
+ test('signature header has required parts', () => {
48
+ const { privateKey } = generateKeypair()
49
+
50
+ const headers = sign({
51
+ privateKey,
52
+ keyId: 'https://example.com/users/alice#main-key',
53
+ method: 'POST',
54
+ url: 'https://remote.example/users/bob/inbox',
55
+ body: '{}'
56
+ })
57
+
58
+ const parsed = parseSignatureHeader(headers.Signature)
59
+ assert.ok(parsed.keyId)
60
+ assert.ok(parsed.algorithm)
61
+ assert.ok(parsed.headers)
62
+ assert.ok(parsed.signature)
63
+ })
64
+
65
+ test('GET request without body omits digest', () => {
66
+ const { privateKey } = generateKeypair()
67
+
68
+ const headers = sign({
69
+ privateKey,
70
+ keyId: 'https://example.com/users/alice#main-key',
71
+ method: 'GET',
72
+ url: 'https://remote.example/users/bob'
73
+ })
74
+
75
+ assert.ok(headers.Date)
76
+ assert.ok(headers.Signature)
77
+ assert.strictEqual(headers.Digest, undefined)
78
+ })
79
+ })
80
+
81
+ describe('verify', () => {
82
+ test('verifies valid signature', () => {
83
+ const { publicKey, privateKey } = generateKeypair()
84
+ const url = 'https://remote.example/users/bob/inbox'
85
+ const body = '{"type":"Follow","actor":"https://example.com/users/alice"}'
86
+
87
+ const headers = sign({
88
+ privateKey,
89
+ keyId: 'https://example.com/users/alice#main-key',
90
+ method: 'POST',
91
+ url,
92
+ body
93
+ })
94
+
95
+ const valid = verify({
96
+ publicKey,
97
+ signature: headers.Signature,
98
+ method: 'POST',
99
+ path: '/users/bob/inbox',
100
+ headers: {
101
+ host: 'remote.example',
102
+ date: headers.Date,
103
+ digest: headers.Digest
104
+ }
105
+ })
106
+
107
+ assert.strictEqual(valid, true)
108
+ })
109
+
110
+ test('rejects tampered signature', () => {
111
+ const { publicKey, privateKey } = generateKeypair()
112
+
113
+ const headers = sign({
114
+ privateKey,
115
+ keyId: 'https://example.com/users/alice#main-key',
116
+ method: 'POST',
117
+ url: 'https://remote.example/inbox',
118
+ body: '{}'
119
+ })
120
+
121
+ // Tamper with signature
122
+ const tampered = headers.Signature.replace(/signature="[^"]+"/, 'signature="dGFtcGVyZWQ="')
123
+
124
+ const valid = verify({
125
+ publicKey,
126
+ signature: tampered,
127
+ method: 'POST',
128
+ path: '/inbox',
129
+ headers: {
130
+ host: 'remote.example',
131
+ date: headers.Date,
132
+ digest: headers.Digest
133
+ }
134
+ })
135
+
136
+ assert.strictEqual(valid, false)
137
+ })
138
+
139
+ test('rejects wrong public key', () => {
140
+ const sender = generateKeypair()
141
+ const other = generateKeypair()
142
+
143
+ const headers = sign({
144
+ privateKey: sender.privateKey,
145
+ keyId: 'https://example.com/users/alice#main-key',
146
+ method: 'POST',
147
+ url: 'https://remote.example/inbox',
148
+ body: '{}'
149
+ })
150
+
151
+ const valid = verify({
152
+ publicKey: other.publicKey, // Wrong key
153
+ signature: headers.Signature,
154
+ method: 'POST',
155
+ path: '/inbox',
156
+ headers: {
157
+ host: 'remote.example',
158
+ date: headers.Date,
159
+ digest: headers.Digest
160
+ }
161
+ })
162
+
163
+ assert.strictEqual(valid, false)
164
+ })
165
+ })
166
+
167
+ describe('parseSignatureHeader', () => {
168
+ test('parses valid signature header', () => {
169
+ const header = 'keyId="https://example.com/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="abc123"'
170
+
171
+ const parsed = parseSignatureHeader(header)
172
+
173
+ assert.strictEqual(parsed.keyId, 'https://example.com/users/alice#main-key')
174
+ assert.strictEqual(parsed.algorithm, 'rsa-sha256')
175
+ assert.strictEqual(parsed.headers, '(request-target) host date')
176
+ assert.strictEqual(parsed.signature, 'abc123')
177
+ })
178
+
179
+ test('returns null for invalid header', () => {
180
+ assert.strictEqual(parseSignatureHeader('invalid'), null)
181
+ assert.strictEqual(parseSignatureHeader(''), null)
182
+ })
183
+ })
184
+
185
+ describe('digest', () => {
186
+ test('creates SHA-256 digest', () => {
187
+ const result = digest('{"test":true}')
188
+
189
+ assert.ok(result.startsWith('SHA-256='))
190
+ assert.strictEqual(result.length, 8 + 44) // "SHA-256=" + base64
191
+ })
192
+
193
+ test('same input produces same digest', () => {
194
+ const body = '{"type":"Create"}'
195
+
196
+ assert.strictEqual(digest(body), digest(body))
197
+ })
198
+
199
+ test('different input produces different digest', () => {
200
+ assert.notStrictEqual(digest('a'), digest('b'))
201
+ })
202
+ })
203
+
204
+ describe('verifyDigest', () => {
205
+ test('verifies matching digest', () => {
206
+ const body = '{"type":"Follow"}'
207
+ const digestHeader = digest(body)
208
+
209
+ assert.strictEqual(verifyDigest(body, digestHeader), true)
210
+ })
211
+
212
+ test('rejects non-matching digest', () => {
213
+ const body = '{"type":"Follow"}'
214
+ const wrongDigest = digest('{"type":"Create"}')
215
+
216
+ assert.strictEqual(verifyDigest(body, wrongDigest), false)
217
+ })
218
+ })
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Microfed Inbox Module Tests
3
+ * Run: node --test test/inbox.test.js
4
+ */
5
+
6
+ import { test, describe } from 'node:test'
7
+ import assert from 'node:assert'
8
+ import { getActor, getObject, isAddressedTo, isPublic } from '../src/inbox.js'
9
+
10
+ describe('getActor', () => {
11
+ test('extracts actor string', () => {
12
+ const activity = {
13
+ type: 'Create',
14
+ actor: 'https://example.com/users/alice'
15
+ }
16
+
17
+ assert.strictEqual(getActor(activity), 'https://example.com/users/alice')
18
+ })
19
+
20
+ test('extracts actor from object', () => {
21
+ const activity = {
22
+ type: 'Create',
23
+ actor: { id: 'https://example.com/users/alice' }
24
+ }
25
+
26
+ assert.strictEqual(getActor(activity), 'https://example.com/users/alice')
27
+ })
28
+
29
+ test('throws when actor missing', () => {
30
+ assert.throws(() => {
31
+ getActor({ type: 'Create' })
32
+ }, /Cannot extract actor/)
33
+ })
34
+ })
35
+
36
+ describe('getObject', () => {
37
+ test('returns object directly', () => {
38
+ const activity = {
39
+ type: 'Create',
40
+ object: { type: 'Note', content: 'Hello' }
41
+ }
42
+
43
+ assert.deepStrictEqual(getObject(activity), { type: 'Note', content: 'Hello' })
44
+ })
45
+
46
+ test('returns object ID string', () => {
47
+ const activity = {
48
+ type: 'Like',
49
+ object: 'https://example.com/notes/123'
50
+ }
51
+
52
+ assert.strictEqual(getObject(activity), 'https://example.com/notes/123')
53
+ })
54
+ })
55
+
56
+ describe('isAddressedTo', () => {
57
+ test('returns true when in to field', () => {
58
+ const activity = {
59
+ type: 'Create',
60
+ to: ['https://example.com/users/bob']
61
+ }
62
+
63
+ assert.strictEqual(isAddressedTo(activity, 'https://example.com/users/bob'), true)
64
+ })
65
+
66
+ test('returns true when in cc field', () => {
67
+ const activity = {
68
+ type: 'Create',
69
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
70
+ cc: ['https://example.com/users/bob']
71
+ }
72
+
73
+ assert.strictEqual(isAddressedTo(activity, 'https://example.com/users/bob'), true)
74
+ })
75
+
76
+ test('returns false when not addressed', () => {
77
+ const activity = {
78
+ type: 'Create',
79
+ to: ['https://example.com/users/alice']
80
+ }
81
+
82
+ assert.strictEqual(isAddressedTo(activity, 'https://example.com/users/bob'), false)
83
+ })
84
+
85
+ test('handles missing to/cc fields', () => {
86
+ const activity = { type: 'Create' }
87
+
88
+ assert.strictEqual(isAddressedTo(activity, 'https://example.com/users/bob'), false)
89
+ })
90
+ })
91
+
92
+ describe('isPublic', () => {
93
+ test('returns true for Public in to', () => {
94
+ const activity = {
95
+ type: 'Create',
96
+ to: ['https://www.w3.org/ns/activitystreams#Public']
97
+ }
98
+
99
+ assert.strictEqual(isPublic(activity), true)
100
+ })
101
+
102
+ test('returns true for Public in cc', () => {
103
+ const activity = {
104
+ type: 'Create',
105
+ to: ['https://example.com/users/alice/followers'],
106
+ cc: ['https://www.w3.org/ns/activitystreams#Public']
107
+ }
108
+
109
+ assert.strictEqual(isPublic(activity), true)
110
+ })
111
+
112
+ test('returns true for short Public form', () => {
113
+ const activity = {
114
+ type: 'Create',
115
+ to: ['Public']
116
+ }
117
+
118
+ assert.strictEqual(isPublic(activity), true)
119
+ })
120
+
121
+ test('returns false for non-public', () => {
122
+ const activity = {
123
+ type: 'Create',
124
+ to: ['https://example.com/users/bob']
125
+ }
126
+
127
+ assert.strictEqual(isPublic(activity), false)
128
+ })
129
+
130
+ test('handles missing to/cc fields', () => {
131
+ const activity = { type: 'Create' }
132
+
133
+ assert.strictEqual(isPublic(activity), false)
134
+ })
135
+ })
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Live Federation Tests
3
+ * Tests against real Mastodon instances
4
+ *
5
+ * Run: node --test test/live.test.js
6
+ *
7
+ * Note: Requires network access. May fail if instances are down.
8
+ */
9
+
10
+ import { test, describe } from 'node:test'
11
+ import assert from 'node:assert'
12
+ import { lookup, resolve, getActorUrl } from '../src/webfinger.js'
13
+ import { parseActor, getActorId } from '../src/profile.js'
14
+
15
+ describe('Live WebFinger Lookup', { timeout: 10000 }, () => {
16
+ test('lookup mastodon.social account', async () => {
17
+ // Using Gargron (Mastodon creator) as a stable test account
18
+ const webfinger = await lookup('Gargron@mastodon.social')
19
+
20
+ assert.ok(webfinger.subject, 'missing subject')
21
+ assert.ok(webfinger.links, 'missing links')
22
+
23
+ const actorUrl = getActorUrl(webfinger)
24
+ assert.ok(actorUrl, 'no actor URL found')
25
+ assert.ok(actorUrl.includes('mastodon.social'), 'unexpected actor URL')
26
+ })
27
+
28
+ test('lookup returns valid JRD structure', async () => {
29
+ const webfinger = await lookup('Gargron@mastodon.social')
30
+
31
+ // Validate JRD structure
32
+ assert.ok(webfinger.subject.startsWith('acct:'))
33
+
34
+ const selfLink = webfinger.links.find(l =>
35
+ l.rel === 'self' && l.type === 'application/activity+json'
36
+ )
37
+ assert.ok(selfLink, 'missing ActivityPub self link')
38
+ assert.ok(selfLink.href.startsWith('https://'))
39
+ })
40
+ })
41
+
42
+ describe('Live Actor Fetch', { timeout: 10000 }, () => {
43
+ test('resolve mastodon.social actor', async () => {
44
+ const actor = await resolve('Gargron@mastodon.social')
45
+
46
+ // Validate required ActivityPub fields
47
+ assert.ok(actor.id, 'missing id')
48
+ assert.ok(actor.type, 'missing type')
49
+ assert.ok(actor.inbox, 'missing inbox')
50
+ assert.ok(actor.outbox, 'missing outbox')
51
+ assert.ok(actor.preferredUsername, 'missing preferredUsername')
52
+ })
53
+
54
+ test('actor has publicKey for signatures', async () => {
55
+ const actor = await resolve('Gargron@mastodon.social')
56
+
57
+ assert.ok(actor.publicKey, 'missing publicKey')
58
+ assert.ok(actor.publicKey.id, 'missing publicKey.id')
59
+ assert.ok(actor.publicKey.publicKeyPem, 'missing publicKeyPem')
60
+ assert.ok(
61
+ actor.publicKey.publicKeyPem.includes('BEGIN PUBLIC KEY'),
62
+ 'publicKeyPem not in PEM format'
63
+ )
64
+ })
65
+
66
+ test('actor type is Person', async () => {
67
+ const actor = await resolve('Gargron@mastodon.social')
68
+ assert.strictEqual(actor.type, 'Person')
69
+ })
70
+
71
+ test('can extract actor ID', async () => {
72
+ const actor = await resolve('Gargron@mastodon.social')
73
+ const id = getActorId(actor)
74
+
75
+ assert.ok(id.startsWith('https://'))
76
+ assert.ok(id.includes('mastodon.social'))
77
+ })
78
+ })
79
+
80
+ describe('Live Outbox Fetch', { timeout: 10000 }, () => {
81
+ test('can fetch actor outbox', async () => {
82
+ const actor = await resolve('Gargron@mastodon.social')
83
+
84
+ const response = await fetch(actor.outbox, {
85
+ headers: { 'Accept': 'application/activity+json' }
86
+ })
87
+
88
+ assert.strictEqual(response.status, 200)
89
+
90
+ const outbox = await response.json()
91
+ assert.ok(
92
+ outbox.type === 'OrderedCollection' || outbox.type === 'OrderedCollectionPage',
93
+ 'outbox must be OrderedCollection'
94
+ )
95
+ assert.ok(typeof outbox.totalItems === 'number', 'missing totalItems')
96
+ })
97
+ })
98
+
99
+ describe('Cross-instance Federation', { timeout: 15000 }, () => {
100
+ test('can lookup account on hachyderm.io', async () => {
101
+ // Test a different instance to verify federation lookup works
102
+ // Using a known active account
103
+ const webfinger = await lookup('nova@hachyderm.io')
104
+
105
+ assert.ok(webfinger.subject)
106
+ const actorUrl = getActorUrl(webfinger)
107
+ assert.ok(actorUrl.includes('hachyderm.io'))
108
+ })
109
+ })