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/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 }
@@ -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
+ })