microfed 0.0.12 → 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.
- package/README.md +194 -65
- package/example/server.js +252 -0
- package/index.js +36 -0
- package/package.json +22 -4
- package/src/auth.js +152 -0
- package/src/inbox.js +188 -0
- package/src/outbox.js +239 -0
- package/src/profile.js +114 -0
- package/src/webfinger.js +182 -0
- package/test/auth.test.js +218 -0
- package/test/inbox.test.js +135 -0
- package/test/live.test.js +109 -0
- package/test/mastodon.test.js +243 -0
- package/test/outbox.test.js +195 -0
- package/test/profile.test.js +213 -0
- package/test/webfinger.test.js +139 -0
package/src/webfinger.js
ADDED
|
@@ -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
|
+
})
|