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/auth.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microfed Auth Module
|
|
3
|
+
* Keypair generation and HTTP Signatures
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { generateKeyPairSync, createSign, createVerify, createHash } from 'crypto'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate RSA keypair for signing
|
|
10
|
+
* @param {number} [modulusLength=2048] - Key size in bits
|
|
11
|
+
* @returns {Object} { publicKey, privateKey } in PEM format
|
|
12
|
+
*/
|
|
13
|
+
export function generateKeypair(modulusLength = 2048) {
|
|
14
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
|
15
|
+
modulusLength,
|
|
16
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
17
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
|
|
18
|
+
})
|
|
19
|
+
return { publicKey, privateKey }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create HTTP Signature headers for a request
|
|
24
|
+
* @param {Object} options - Signing options
|
|
25
|
+
* @param {string} options.privateKey - PEM private key
|
|
26
|
+
* @param {string} options.keyId - Key identifier URL (actor#main-key)
|
|
27
|
+
* @param {string} options.method - HTTP method (POST, GET)
|
|
28
|
+
* @param {string} options.url - Target URL
|
|
29
|
+
* @param {string} [options.body] - Request body (for POST)
|
|
30
|
+
* @param {Object} [options.headers] - Additional headers to sign
|
|
31
|
+
* @returns {Object} Headers object with Signature, Date, Digest
|
|
32
|
+
*/
|
|
33
|
+
export function sign(options) {
|
|
34
|
+
const { privateKey, keyId, method, url, body } = options
|
|
35
|
+
|
|
36
|
+
const parsedUrl = new URL(url)
|
|
37
|
+
const date = new Date().toUTCString()
|
|
38
|
+
const headers = { ...options.headers }
|
|
39
|
+
|
|
40
|
+
// Build headers to sign
|
|
41
|
+
const signedHeaders = ['(request-target)', 'host', 'date']
|
|
42
|
+
const headerLines = [
|
|
43
|
+
`(request-target): ${method.toLowerCase()} ${parsedUrl.pathname}`,
|
|
44
|
+
`host: ${parsedUrl.host}`,
|
|
45
|
+
`date: ${date}`
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
// Add digest for POST requests with body
|
|
49
|
+
if (body) {
|
|
50
|
+
const digest = createHash('sha256').update(body).digest('base64')
|
|
51
|
+
headers['Digest'] = `SHA-256=${digest}`
|
|
52
|
+
signedHeaders.push('digest')
|
|
53
|
+
headerLines.push(`digest: SHA-256=${digest}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Create signature
|
|
57
|
+
const signingString = headerLines.join('\n')
|
|
58
|
+
const signer = createSign('RSA-SHA256')
|
|
59
|
+
signer.update(signingString)
|
|
60
|
+
const signature = signer.sign(privateKey, 'base64')
|
|
61
|
+
|
|
62
|
+
// Build Signature header
|
|
63
|
+
headers['Date'] = date
|
|
64
|
+
headers['Signature'] = [
|
|
65
|
+
`keyId="${keyId}"`,
|
|
66
|
+
`algorithm="rsa-sha256"`,
|
|
67
|
+
`headers="${signedHeaders.join(' ')}"`,
|
|
68
|
+
`signature="${signature}"`
|
|
69
|
+
].join(',')
|
|
70
|
+
|
|
71
|
+
return headers
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Verify HTTP Signature from request
|
|
76
|
+
* @param {Object} options - Verification options
|
|
77
|
+
* @param {string} options.publicKey - PEM public key
|
|
78
|
+
* @param {string} options.signature - Signature header value
|
|
79
|
+
* @param {string} options.method - HTTP method
|
|
80
|
+
* @param {string} options.path - Request path
|
|
81
|
+
* @param {Object} options.headers - Request headers
|
|
82
|
+
* @returns {boolean} True if valid
|
|
83
|
+
*/
|
|
84
|
+
export function verify(options) {
|
|
85
|
+
const { publicKey, signature, method, path, headers } = options
|
|
86
|
+
|
|
87
|
+
// Parse Signature header
|
|
88
|
+
const sigParts = parseSignatureHeader(signature)
|
|
89
|
+
if (!sigParts) return false
|
|
90
|
+
|
|
91
|
+
// Rebuild signing string
|
|
92
|
+
const signedHeaders = sigParts.headers.split(' ')
|
|
93
|
+
const headerLines = signedHeaders.map(name => {
|
|
94
|
+
if (name === '(request-target)') {
|
|
95
|
+
return `(request-target): ${method.toLowerCase()} ${path}`
|
|
96
|
+
}
|
|
97
|
+
const value = headers[name] || headers[name.toLowerCase()]
|
|
98
|
+
return `${name}: ${value}`
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const signingString = headerLines.join('\n')
|
|
102
|
+
|
|
103
|
+
// Verify
|
|
104
|
+
const verifier = createVerify('RSA-SHA256')
|
|
105
|
+
verifier.update(signingString)
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
return verifier.verify(publicKey, sigParts.signature, 'base64')
|
|
109
|
+
} catch {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parse Signature header into components
|
|
116
|
+
* @param {string} header - Signature header value
|
|
117
|
+
* @returns {Object|null} { keyId, algorithm, headers, signature }
|
|
118
|
+
*/
|
|
119
|
+
export function parseSignatureHeader(header) {
|
|
120
|
+
const parts = {}
|
|
121
|
+
const regex = /(\w+)="([^"]+)"/g
|
|
122
|
+
let match
|
|
123
|
+
|
|
124
|
+
while ((match = regex.exec(header)) !== null) {
|
|
125
|
+
parts[match[1]] = match[2]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!parts.keyId || !parts.signature) return null
|
|
129
|
+
return parts
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create SHA-256 digest of content
|
|
134
|
+
* @param {string} content - Content to hash
|
|
135
|
+
* @returns {string} Digest header value
|
|
136
|
+
*/
|
|
137
|
+
export function digest(content) {
|
|
138
|
+
const hash = createHash('sha256').update(content).digest('base64')
|
|
139
|
+
return `SHA-256=${hash}`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Verify digest header matches body
|
|
144
|
+
* @param {string} body - Request body
|
|
145
|
+
* @param {string} digestHeader - Digest header value
|
|
146
|
+
* @returns {boolean} True if matches
|
|
147
|
+
*/
|
|
148
|
+
export function verifyDigest(body, digestHeader) {
|
|
149
|
+
return digest(body) === digestHeader
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default { generateKeypair, sign, verify, parseSignatureHeader, digest, verifyDigest }
|
package/src/inbox.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microfed Inbox Module
|
|
3
|
+
* Receive and process incoming activities
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { verify, parseSignatureHeader, verifyDigest } from './auth.js'
|
|
7
|
+
|
|
8
|
+
const ACTIVITYPUB_TYPE = 'application/activity+json'
|
|
9
|
+
const LD_TYPE = 'application/ld+json'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create inbox handler
|
|
13
|
+
* @param {Object} options - Handler options
|
|
14
|
+
* @param {Function} options.getPublicKey - async (keyId) => publicKeyPem
|
|
15
|
+
* @param {Object} [options.handlers] - Activity type handlers { Follow, Create, ... }
|
|
16
|
+
* @param {boolean} [options.verifySignatures=true] - Require valid signatures
|
|
17
|
+
* @returns {Function} Handler(req) => Response
|
|
18
|
+
*/
|
|
19
|
+
export function createHandler(options) {
|
|
20
|
+
const { getPublicKey, handlers = {}, verifySignatures = true } = options
|
|
21
|
+
|
|
22
|
+
return async (req) => {
|
|
23
|
+
// Must be POST
|
|
24
|
+
if (req.method !== 'POST') {
|
|
25
|
+
return new Response('Method not allowed', { status: 405 })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check content type
|
|
29
|
+
const contentType = req.headers.get('content-type') || ''
|
|
30
|
+
if (!contentType.includes('json')) {
|
|
31
|
+
return new Response('Invalid content type', { status: 415 })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Parse body
|
|
35
|
+
let activity
|
|
36
|
+
let body
|
|
37
|
+
try {
|
|
38
|
+
body = await req.text()
|
|
39
|
+
activity = JSON.parse(body)
|
|
40
|
+
} catch {
|
|
41
|
+
return new Response('Invalid JSON', { status: 400 })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Verify signature
|
|
45
|
+
if (verifySignatures) {
|
|
46
|
+
const signatureHeader = req.headers.get('signature')
|
|
47
|
+
if (!signatureHeader) {
|
|
48
|
+
return new Response('Missing signature', { status: 401 })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const sigParts = parseSignatureHeader(signatureHeader)
|
|
52
|
+
if (!sigParts) {
|
|
53
|
+
return new Response('Invalid signature format', { status: 401 })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Verify digest if present
|
|
57
|
+
const digestHeader = req.headers.get('digest')
|
|
58
|
+
if (digestHeader && !verifyDigest(body, digestHeader)) {
|
|
59
|
+
return new Response('Digest mismatch', { status: 401 })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fetch public key and verify
|
|
63
|
+
try {
|
|
64
|
+
const publicKey = await getPublicKey(sigParts.keyId)
|
|
65
|
+
if (!publicKey) {
|
|
66
|
+
return new Response('Unknown key', { status: 401 })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const url = new URL(req.url)
|
|
70
|
+
const valid = verify({
|
|
71
|
+
publicKey,
|
|
72
|
+
signature: signatureHeader,
|
|
73
|
+
method: req.method,
|
|
74
|
+
path: url.pathname,
|
|
75
|
+
headers: Object.fromEntries(req.headers)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
if (!valid) {
|
|
79
|
+
return new Response('Invalid signature', { status: 401 })
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return new Response('Signature verification failed', { status: 401 })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Validate activity
|
|
87
|
+
if (!activity.type) {
|
|
88
|
+
return new Response('Missing activity type', { status: 400 })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Route to handler
|
|
92
|
+
const handler = handlers[activity.type]
|
|
93
|
+
if (handler) {
|
|
94
|
+
try {
|
|
95
|
+
await handler(activity)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error(`Error handling ${activity.type}:`, err)
|
|
98
|
+
return new Response('Processing error', { status: 500 })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Accept activity
|
|
103
|
+
return new Response('', { status: 202 })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse activity from request
|
|
109
|
+
* @param {Request} req - Incoming request
|
|
110
|
+
* @returns {Promise<Object>} Parsed activity
|
|
111
|
+
*/
|
|
112
|
+
export async function parseActivity(req) {
|
|
113
|
+
const body = await req.text()
|
|
114
|
+
return JSON.parse(body)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get actor ID from activity
|
|
119
|
+
* @param {Object} activity - Activity object
|
|
120
|
+
* @returns {string} Actor ID
|
|
121
|
+
*/
|
|
122
|
+
export function getActor(activity) {
|
|
123
|
+
const actor = activity.actor
|
|
124
|
+
if (typeof actor === 'string') return actor
|
|
125
|
+
if (actor?.id) return actor.id
|
|
126
|
+
throw new Error('Cannot extract actor from activity')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get object from activity
|
|
131
|
+
* @param {Object} activity - Activity object
|
|
132
|
+
* @returns {Object|string} Object or object ID
|
|
133
|
+
*/
|
|
134
|
+
export function getObject(activity) {
|
|
135
|
+
return activity.object
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check if activity is addressed to actor
|
|
140
|
+
* @param {Object} activity - Activity object
|
|
141
|
+
* @param {string} actorId - Actor to check
|
|
142
|
+
* @returns {boolean} True if addressed to actor
|
|
143
|
+
*/
|
|
144
|
+
export function isAddressedTo(activity, actorId) {
|
|
145
|
+
const recipients = [
|
|
146
|
+
...(activity.to || []),
|
|
147
|
+
...(activity.cc || []),
|
|
148
|
+
...(activity.bto || []),
|
|
149
|
+
...(activity.bcc || [])
|
|
150
|
+
]
|
|
151
|
+
return recipients.includes(actorId)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if activity is public
|
|
156
|
+
* @param {Object} activity - Activity object
|
|
157
|
+
* @returns {boolean} True if public
|
|
158
|
+
*/
|
|
159
|
+
export function isPublic(activity) {
|
|
160
|
+
const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'
|
|
161
|
+
const recipients = [...(activity.to || []), ...(activity.cc || [])]
|
|
162
|
+
return recipients.includes(PUBLIC) || recipients.includes('Public')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Standard activity type handlers (stubs)
|
|
167
|
+
*/
|
|
168
|
+
export const standardHandlers = {
|
|
169
|
+
Follow: async (activity) => { /* Accept/Reject follow */ },
|
|
170
|
+
Undo: async (activity) => { /* Reverse previous activity */ },
|
|
171
|
+
Create: async (activity) => { /* Store content */ },
|
|
172
|
+
Update: async (activity) => { /* Update content */ },
|
|
173
|
+
Delete: async (activity) => { /* Delete content */ },
|
|
174
|
+
Like: async (activity) => { /* Record like */ },
|
|
175
|
+
Announce: async (activity) => { /* Record boost */ },
|
|
176
|
+
Accept: async (activity) => { /* Confirm follow */ },
|
|
177
|
+
Reject: async (activity) => { /* Deny follow */ }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export default {
|
|
181
|
+
createHandler,
|
|
182
|
+
parseActivity,
|
|
183
|
+
getActor,
|
|
184
|
+
getObject,
|
|
185
|
+
isAddressedTo,
|
|
186
|
+
isPublic,
|
|
187
|
+
standardHandlers
|
|
188
|
+
}
|
package/src/outbox.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microfed Outbox Module
|
|
3
|
+
* Create and send activities to remote inboxes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { sign } from './auth.js'
|
|
7
|
+
import { resolve } from './webfinger.js'
|
|
8
|
+
|
|
9
|
+
const ACTIVITYPUB_TYPE = 'application/activity+json'
|
|
10
|
+
const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create an activity
|
|
14
|
+
* @param {Object} options - Activity options
|
|
15
|
+
* @param {string} options.type - Activity type (Create, Follow, Like, etc.)
|
|
16
|
+
* @param {string} options.actor - Actor ID URL
|
|
17
|
+
* @param {Object|string} options.object - Activity object
|
|
18
|
+
* @param {Array} [options.to] - Primary recipients
|
|
19
|
+
* @param {Array} [options.cc] - Secondary recipients
|
|
20
|
+
* @param {string} [options.id] - Activity ID (auto-generated if not provided)
|
|
21
|
+
* @returns {Object} Activity object
|
|
22
|
+
*/
|
|
23
|
+
export function createActivity(options) {
|
|
24
|
+
const { type, actor, object, to = [], cc = [] } = options
|
|
25
|
+
|
|
26
|
+
if (!type) throw new Error('Activity type is required')
|
|
27
|
+
if (!actor) throw new Error('Activity actor is required')
|
|
28
|
+
if (!object) throw new Error('Activity object is required')
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
32
|
+
type,
|
|
33
|
+
id: options.id || `${actor}/activities/${Date.now()}`,
|
|
34
|
+
actor,
|
|
35
|
+
object,
|
|
36
|
+
to,
|
|
37
|
+
cc,
|
|
38
|
+
published: new Date().toISOString()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a Note (post)
|
|
44
|
+
* @param {Object} options - Note options
|
|
45
|
+
* @param {string} options.actor - Author's actor ID
|
|
46
|
+
* @param {string} options.content - HTML content
|
|
47
|
+
* @param {string} [options.id] - Note ID (auto-generated if not provided)
|
|
48
|
+
* @param {boolean} [options.public=true] - Make post public
|
|
49
|
+
* @param {string} [options.inReplyTo] - ID of post being replied to
|
|
50
|
+
* @returns {Object} Note object
|
|
51
|
+
*/
|
|
52
|
+
export function createNote(options) {
|
|
53
|
+
const { actor, content, public: isPublic = true, inReplyTo } = options
|
|
54
|
+
|
|
55
|
+
const note = {
|
|
56
|
+
type: 'Note',
|
|
57
|
+
id: options.id || `${actor}/notes/${Date.now()}`,
|
|
58
|
+
attributedTo: actor,
|
|
59
|
+
content,
|
|
60
|
+
published: new Date().toISOString(),
|
|
61
|
+
to: isPublic ? [PUBLIC] : [],
|
|
62
|
+
cc: isPublic ? [`${actor}/followers`] : []
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (inReplyTo) note.inReplyTo = inReplyTo
|
|
66
|
+
|
|
67
|
+
return note
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wrap object in Create activity
|
|
72
|
+
* @param {string} actor - Actor ID
|
|
73
|
+
* @param {Object} object - Object to wrap
|
|
74
|
+
* @returns {Object} Create activity
|
|
75
|
+
*/
|
|
76
|
+
export function wrapCreate(actor, object) {
|
|
77
|
+
return createActivity({
|
|
78
|
+
type: 'Create',
|
|
79
|
+
actor,
|
|
80
|
+
object,
|
|
81
|
+
to: object.to,
|
|
82
|
+
cc: object.cc
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create Follow activity
|
|
88
|
+
* @param {string} actor - Follower's actor ID
|
|
89
|
+
* @param {string} target - Target actor ID to follow
|
|
90
|
+
* @returns {Object} Follow activity
|
|
91
|
+
*/
|
|
92
|
+
export function createFollow(actor, target) {
|
|
93
|
+
return createActivity({
|
|
94
|
+
type: 'Follow',
|
|
95
|
+
actor,
|
|
96
|
+
object: target,
|
|
97
|
+
to: [target]
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create Undo activity
|
|
103
|
+
* @param {string} actor - Actor ID
|
|
104
|
+
* @param {Object|string} activity - Activity to undo
|
|
105
|
+
* @returns {Object} Undo activity
|
|
106
|
+
*/
|
|
107
|
+
export function createUndo(actor, activity) {
|
|
108
|
+
const activityId = typeof activity === 'string' ? activity : activity.id
|
|
109
|
+
return createActivity({
|
|
110
|
+
type: 'Undo',
|
|
111
|
+
actor,
|
|
112
|
+
object: activity,
|
|
113
|
+
to: activity.to || [],
|
|
114
|
+
cc: activity.cc || []
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create Accept activity (for follow requests)
|
|
120
|
+
* @param {string} actor - Actor accepting
|
|
121
|
+
* @param {Object} follow - Follow activity being accepted
|
|
122
|
+
* @returns {Object} Accept activity
|
|
123
|
+
*/
|
|
124
|
+
export function createAccept(actor, follow) {
|
|
125
|
+
return createActivity({
|
|
126
|
+
type: 'Accept',
|
|
127
|
+
actor,
|
|
128
|
+
object: follow,
|
|
129
|
+
to: [follow.actor]
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Send activity to inbox
|
|
135
|
+
* @param {Object} options - Send options
|
|
136
|
+
* @param {Object} options.activity - Activity to send
|
|
137
|
+
* @param {string} options.inbox - Target inbox URL
|
|
138
|
+
* @param {string} options.privateKey - Sender's private key (PEM)
|
|
139
|
+
* @param {string} options.keyId - Sender's key ID
|
|
140
|
+
* @returns {Promise<Response>} Fetch response
|
|
141
|
+
*/
|
|
142
|
+
export async function send(options) {
|
|
143
|
+
const { activity, inbox, privateKey, keyId } = options
|
|
144
|
+
|
|
145
|
+
const body = JSON.stringify(activity)
|
|
146
|
+
|
|
147
|
+
const headers = sign({
|
|
148
|
+
privateKey,
|
|
149
|
+
keyId,
|
|
150
|
+
method: 'POST',
|
|
151
|
+
url: inbox,
|
|
152
|
+
body,
|
|
153
|
+
headers: {
|
|
154
|
+
'Content-Type': ACTIVITYPUB_TYPE,
|
|
155
|
+
'Accept': ACTIVITYPUB_TYPE
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
return fetch(inbox, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: {
|
|
162
|
+
...headers,
|
|
163
|
+
'Content-Type': ACTIVITYPUB_TYPE
|
|
164
|
+
},
|
|
165
|
+
body
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Deliver activity to multiple inboxes
|
|
171
|
+
* @param {Object} options - Delivery options
|
|
172
|
+
* @param {Object} options.activity - Activity to deliver
|
|
173
|
+
* @param {Array<string>} options.inboxes - Target inbox URLs
|
|
174
|
+
* @param {string} options.privateKey - Sender's private key
|
|
175
|
+
* @param {string} options.keyId - Sender's key ID
|
|
176
|
+
* @returns {Promise<Array>} Array of { inbox, success, error? }
|
|
177
|
+
*/
|
|
178
|
+
export async function deliver(options) {
|
|
179
|
+
const { activity, inboxes, privateKey, keyId } = options
|
|
180
|
+
|
|
181
|
+
const results = await Promise.allSettled(
|
|
182
|
+
inboxes.map(async (inbox) => {
|
|
183
|
+
const response = await send({ activity, inbox, privateKey, keyId })
|
|
184
|
+
return { inbox, status: response.status }
|
|
185
|
+
})
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return results.map((result, i) => ({
|
|
189
|
+
inbox: inboxes[i],
|
|
190
|
+
success: result.status === 'fulfilled' && result.value.status < 300,
|
|
191
|
+
error: result.status === 'rejected' ? result.reason.message : null
|
|
192
|
+
}))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Resolve recipients and get their inboxes
|
|
197
|
+
* @param {Array<string>} recipients - Actor IDs or accounts
|
|
198
|
+
* @returns {Promise<Array<string>>} Inbox URLs
|
|
199
|
+
*/
|
|
200
|
+
export async function resolveInboxes(recipients) {
|
|
201
|
+
const inboxes = new Set()
|
|
202
|
+
|
|
203
|
+
for (const recipient of recipients) {
|
|
204
|
+
// Skip public addressing
|
|
205
|
+
if (recipient === PUBLIC || recipient.endsWith('/followers')) continue
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
let actor
|
|
209
|
+
if (recipient.includes('@') && !recipient.startsWith('http')) {
|
|
210
|
+
actor = await resolve(recipient)
|
|
211
|
+
} else {
|
|
212
|
+
const response = await fetch(recipient, {
|
|
213
|
+
headers: { 'Accept': ACTIVITYPUB_TYPE }
|
|
214
|
+
})
|
|
215
|
+
actor = await response.json()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (actor.inbox) {
|
|
219
|
+
inboxes.add(actor.endpoints?.sharedInbox || actor.inbox)
|
|
220
|
+
}
|
|
221
|
+
} catch (err) {
|
|
222
|
+
console.error(`Failed to resolve ${recipient}:`, err.message)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return [...inboxes]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default {
|
|
230
|
+
createActivity,
|
|
231
|
+
createNote,
|
|
232
|
+
wrapCreate,
|
|
233
|
+
createFollow,
|
|
234
|
+
createUndo,
|
|
235
|
+
createAccept,
|
|
236
|
+
send,
|
|
237
|
+
deliver,
|
|
238
|
+
resolveInboxes
|
|
239
|
+
}
|
package/src/profile.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microfed Profile Module
|
|
3
|
+
* Minimal ActivityPub actor/profile generation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const AS_CONTEXT = 'https://www.w3.org/ns/activitystreams'
|
|
7
|
+
const SECURITY_CONTEXT = 'https://w3id.org/security/v1'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create an ActivityPub actor profile
|
|
11
|
+
* @param {Object} options - Actor configuration
|
|
12
|
+
* @param {string} options.id - Actor's unique URL (required)
|
|
13
|
+
* @param {string} options.type - Actor type: Person, Service, Group, Application, Organization
|
|
14
|
+
* @param {string} options.username - preferredUsername (handle)
|
|
15
|
+
* @param {string} [options.name] - Display name
|
|
16
|
+
* @param {string} [options.summary] - Bio/description (HTML allowed)
|
|
17
|
+
* @param {string} [options.inbox] - Inbox URL (defaults to {id}/inbox)
|
|
18
|
+
* @param {string} [options.outbox] - Outbox URL (defaults to {id}/outbox)
|
|
19
|
+
* @param {string} [options.publicKey] - PEM-encoded public key
|
|
20
|
+
* @param {string} [options.icon] - Avatar URL
|
|
21
|
+
* @param {string} [options.image] - Header/banner URL
|
|
22
|
+
* @returns {Object} ActivityPub Actor object
|
|
23
|
+
*/
|
|
24
|
+
export function createActor(options) {
|
|
25
|
+
const { id, type = 'Person', username } = options
|
|
26
|
+
|
|
27
|
+
if (!id) throw new Error('Actor id is required')
|
|
28
|
+
if (!username) throw new Error('Actor username is required')
|
|
29
|
+
|
|
30
|
+
const actor = {
|
|
31
|
+
'@context': [AS_CONTEXT, SECURITY_CONTEXT],
|
|
32
|
+
type,
|
|
33
|
+
id,
|
|
34
|
+
preferredUsername: username,
|
|
35
|
+
inbox: options.inbox || `${id}/inbox`,
|
|
36
|
+
outbox: options.outbox || `${id}/outbox`,
|
|
37
|
+
followers: options.followers || `${id}/followers`,
|
|
38
|
+
following: options.following || `${id}/following`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Optional fields
|
|
42
|
+
if (options.name) actor.name = options.name
|
|
43
|
+
if (options.summary) actor.summary = options.summary
|
|
44
|
+
if (options.url) actor.url = options.url
|
|
45
|
+
if (options.icon) actor.icon = { type: 'Image', url: options.icon }
|
|
46
|
+
if (options.image) actor.image = { type: 'Image', url: options.image }
|
|
47
|
+
|
|
48
|
+
// Public key for HTTP signatures
|
|
49
|
+
if (options.publicKey) {
|
|
50
|
+
actor.publicKey = {
|
|
51
|
+
id: `${id}#main-key`,
|
|
52
|
+
owner: id,
|
|
53
|
+
publicKeyPem: options.publicKey
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Shared inbox for efficient delivery
|
|
58
|
+
if (options.sharedInbox) {
|
|
59
|
+
actor.endpoints = { sharedInbox: options.sharedInbox }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return actor
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a minimal actor (just the essentials)
|
|
67
|
+
* @param {string} id - Actor URL
|
|
68
|
+
* @param {string} username - Handle
|
|
69
|
+
* @returns {Object} Minimal actor object
|
|
70
|
+
*/
|
|
71
|
+
export function createMinimalActor(id, username) {
|
|
72
|
+
return createActor({ id, username })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse an actor from JSON
|
|
77
|
+
* @param {string|Object} json - JSON string or object
|
|
78
|
+
* @returns {Object} Parsed actor
|
|
79
|
+
*/
|
|
80
|
+
export function parseActor(json) {
|
|
81
|
+
const actor = typeof json === 'string' ? JSON.parse(json) : json
|
|
82
|
+
|
|
83
|
+
// Validate required fields
|
|
84
|
+
if (!actor.id) throw new Error('Actor missing id')
|
|
85
|
+
if (!actor.inbox) throw new Error('Actor missing inbox')
|
|
86
|
+
|
|
87
|
+
return actor
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Extract actor ID from various formats
|
|
92
|
+
* @param {string|Object} actor - Actor URL, object, or activity
|
|
93
|
+
* @returns {string} Actor ID URL
|
|
94
|
+
*/
|
|
95
|
+
export function getActorId(actor) {
|
|
96
|
+
if (typeof actor === 'string') return actor
|
|
97
|
+
if (actor.id) return actor.id
|
|
98
|
+
if (actor.actor) return getActorId(actor.actor)
|
|
99
|
+
throw new Error('Cannot extract actor ID')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build actor URL from domain and username
|
|
104
|
+
* @param {string} domain - Domain (e.g., example.com)
|
|
105
|
+
* @param {string} username - Username/handle
|
|
106
|
+
* @param {string} [path] - URL path pattern (default: /users/{username})
|
|
107
|
+
* @returns {string} Full actor URL
|
|
108
|
+
*/
|
|
109
|
+
export function buildActorUrl(domain, username, path = '/users/{username}') {
|
|
110
|
+
const actorPath = path.replace('{username}', username)
|
|
111
|
+
return `https://${domain}${actorPath}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default { createActor, createMinimalActor, parseActor, getActorId, buildActorUrl }
|