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.
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 }