microfed 0.0.12 → 0.0.14

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,268 @@
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
+ * Supports two call signatures:
45
+ * - createNote(actor, content, options) - positional
46
+ * - createNote({ actor, content, ...options }) - object
47
+ *
48
+ * @param {string|Object} actorOrOptions - Actor ID or options object
49
+ * @param {string} [content] - HTML content (if using positional args)
50
+ * @param {Object} [options] - Additional options (if using positional args)
51
+ * @param {string} [options.id] - Note ID (auto-generated if not provided)
52
+ * @param {Array} [options.to] - Primary recipients
53
+ * @param {Array} [options.cc] - Secondary recipients
54
+ * @param {string} [options.inReplyTo] - ID of post being replied to
55
+ * @returns {Object} Note object
56
+ */
57
+ export function createNote(actorOrOptions, content, options = {}) {
58
+ // Support both call signatures
59
+ let actor, noteContent, noteOptions
60
+ if (typeof actorOrOptions === 'object') {
61
+ // Object signature: createNote({ actor, content, ... })
62
+ actor = actorOrOptions.actor
63
+ noteContent = actorOrOptions.content
64
+ noteOptions = actorOrOptions
65
+ } else {
66
+ // Positional signature: createNote(actor, content, options)
67
+ actor = actorOrOptions
68
+ noteContent = content
69
+ noteOptions = options
70
+ }
71
+
72
+ // Handle public/private addressing
73
+ const isPublic = noteOptions.public !== false
74
+ const defaultTo = isPublic ? [PUBLIC] : []
75
+ const defaultCc = isPublic ? [`${actor}/followers`] : []
76
+
77
+ const {
78
+ id,
79
+ to = defaultTo,
80
+ cc = defaultCc,
81
+ inReplyTo
82
+ } = noteOptions
83
+
84
+ const note = {
85
+ type: 'Note',
86
+ id: id || `${actor}/notes/${Date.now()}`,
87
+ attributedTo: actor,
88
+ content: noteContent,
89
+ published: new Date().toISOString(),
90
+ to,
91
+ cc
92
+ }
93
+
94
+ if (inReplyTo) note.inReplyTo = inReplyTo
95
+
96
+ return note
97
+ }
98
+
99
+ /**
100
+ * Wrap object in Create activity
101
+ * @param {string} actor - Actor ID
102
+ * @param {Object} object - Object to wrap
103
+ * @returns {Object} Create activity
104
+ */
105
+ export function wrapCreate(actor, object) {
106
+ return createActivity({
107
+ type: 'Create',
108
+ actor,
109
+ object,
110
+ to: object.to,
111
+ cc: object.cc
112
+ })
113
+ }
114
+
115
+ /**
116
+ * Create Follow activity
117
+ * @param {string} actor - Follower's actor ID
118
+ * @param {string} target - Target actor ID to follow
119
+ * @returns {Object} Follow activity
120
+ */
121
+ export function createFollow(actor, target) {
122
+ return createActivity({
123
+ type: 'Follow',
124
+ actor,
125
+ object: target,
126
+ to: [target]
127
+ })
128
+ }
129
+
130
+ /**
131
+ * Create Undo activity
132
+ * @param {string} actor - Actor ID
133
+ * @param {Object|string} activity - Activity to undo
134
+ * @returns {Object} Undo activity
135
+ */
136
+ export function createUndo(actor, activity) {
137
+ const activityId = typeof activity === 'string' ? activity : activity.id
138
+ return createActivity({
139
+ type: 'Undo',
140
+ actor,
141
+ object: activity,
142
+ to: activity.to || [],
143
+ cc: activity.cc || []
144
+ })
145
+ }
146
+
147
+ /**
148
+ * Create Accept activity (for follow requests)
149
+ * @param {string} actor - Actor accepting
150
+ * @param {Object} follow - Follow activity being accepted
151
+ * @returns {Object} Accept activity
152
+ */
153
+ export function createAccept(actor, follow) {
154
+ return createActivity({
155
+ type: 'Accept',
156
+ actor,
157
+ object: follow,
158
+ to: [follow.actor]
159
+ })
160
+ }
161
+
162
+ /**
163
+ * Send activity to inbox
164
+ * @param {Object} options - Send options
165
+ * @param {Object} options.activity - Activity to send
166
+ * @param {string} options.inbox - Target inbox URL
167
+ * @param {string} options.privateKey - Sender's private key (PEM)
168
+ * @param {string} options.keyId - Sender's key ID
169
+ * @returns {Promise<Response>} Fetch response
170
+ */
171
+ export async function send(options) {
172
+ const { activity, inbox, privateKey, keyId } = options
173
+
174
+ const body = JSON.stringify(activity)
175
+
176
+ const headers = sign({
177
+ privateKey,
178
+ keyId,
179
+ method: 'POST',
180
+ url: inbox,
181
+ body,
182
+ headers: {
183
+ 'Content-Type': ACTIVITYPUB_TYPE,
184
+ 'Accept': ACTIVITYPUB_TYPE
185
+ }
186
+ })
187
+
188
+ return fetch(inbox, {
189
+ method: 'POST',
190
+ headers: {
191
+ ...headers,
192
+ 'Content-Type': ACTIVITYPUB_TYPE
193
+ },
194
+ body
195
+ })
196
+ }
197
+
198
+ /**
199
+ * Deliver activity to multiple inboxes
200
+ * @param {Object} options - Delivery options
201
+ * @param {Object} options.activity - Activity to deliver
202
+ * @param {Array<string>} options.inboxes - Target inbox URLs
203
+ * @param {string} options.privateKey - Sender's private key
204
+ * @param {string} options.keyId - Sender's key ID
205
+ * @returns {Promise<Array>} Array of { inbox, success, error? }
206
+ */
207
+ export async function deliver(options) {
208
+ const { activity, inboxes, privateKey, keyId } = options
209
+
210
+ const results = await Promise.allSettled(
211
+ inboxes.map(async (inbox) => {
212
+ const response = await send({ activity, inbox, privateKey, keyId })
213
+ return { inbox, status: response.status }
214
+ })
215
+ )
216
+
217
+ return results.map((result, i) => ({
218
+ inbox: inboxes[i],
219
+ success: result.status === 'fulfilled' && result.value.status < 300,
220
+ error: result.status === 'rejected' ? result.reason.message : null
221
+ }))
222
+ }
223
+
224
+ /**
225
+ * Resolve recipients and get their inboxes
226
+ * @param {Array<string>} recipients - Actor IDs or accounts
227
+ * @returns {Promise<Array<string>>} Inbox URLs
228
+ */
229
+ export async function resolveInboxes(recipients) {
230
+ const inboxes = new Set()
231
+
232
+ for (const recipient of recipients) {
233
+ // Skip public addressing
234
+ if (recipient === PUBLIC || recipient.endsWith('/followers')) continue
235
+
236
+ try {
237
+ let actor
238
+ if (recipient.includes('@') && !recipient.startsWith('http')) {
239
+ actor = await resolve(recipient)
240
+ } else {
241
+ const response = await fetch(recipient, {
242
+ headers: { 'Accept': ACTIVITYPUB_TYPE }
243
+ })
244
+ actor = await response.json()
245
+ }
246
+
247
+ if (actor.inbox) {
248
+ inboxes.add(actor.endpoints?.sharedInbox || actor.inbox)
249
+ }
250
+ } catch (err) {
251
+ console.error(`Failed to resolve ${recipient}:`, err.message)
252
+ }
253
+ }
254
+
255
+ return [...inboxes]
256
+ }
257
+
258
+ export default {
259
+ createActivity,
260
+ createNote,
261
+ wrapCreate,
262
+ createFollow,
263
+ createUndo,
264
+ createAccept,
265
+ send,
266
+ deliver,
267
+ resolveInboxes
268
+ }