@evanp/activitypub-bot 0.8.0

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.
Files changed (67) hide show
  1. package/.github/dependabot.yml +11 -0
  2. package/.github/workflows/main-docker.yml +45 -0
  3. package/.github/workflows/tag-docker.yml +54 -0
  4. package/Dockerfile +23 -0
  5. package/LICENSE +661 -0
  6. package/README.md +82 -0
  7. package/bots/index.js +7 -0
  8. package/docs/activitypub.bot.drawio +110 -0
  9. package/index.js +23 -0
  10. package/lib/activitydistributor.js +263 -0
  11. package/lib/activityhandler.js +999 -0
  12. package/lib/activitypubclient.js +126 -0
  13. package/lib/activitystreams.js +41 -0
  14. package/lib/actorstorage.js +300 -0
  15. package/lib/app.js +173 -0
  16. package/lib/authorizer.js +133 -0
  17. package/lib/bot.js +44 -0
  18. package/lib/botcontext.js +520 -0
  19. package/lib/botdatastorage.js +87 -0
  20. package/lib/bots/donothing.js +11 -0
  21. package/lib/bots/ok.js +41 -0
  22. package/lib/digester.js +23 -0
  23. package/lib/httpsignature.js +195 -0
  24. package/lib/httpsignatureauthenticator.js +81 -0
  25. package/lib/keystorage.js +113 -0
  26. package/lib/microsyntax.js +140 -0
  27. package/lib/objectcache.js +48 -0
  28. package/lib/objectstorage.js +319 -0
  29. package/lib/remotekeystorage.js +116 -0
  30. package/lib/routes/collection.js +92 -0
  31. package/lib/routes/health.js +24 -0
  32. package/lib/routes/inbox.js +83 -0
  33. package/lib/routes/object.js +69 -0
  34. package/lib/routes/server.js +47 -0
  35. package/lib/routes/user.js +63 -0
  36. package/lib/routes/webfinger.js +36 -0
  37. package/lib/urlformatter.js +97 -0
  38. package/package.json +51 -0
  39. package/tests/activitydistributor.test.js +606 -0
  40. package/tests/activityhandler.test.js +2185 -0
  41. package/tests/activitypubclient.test.js +225 -0
  42. package/tests/actorstorage.test.js +261 -0
  43. package/tests/app.test.js +17 -0
  44. package/tests/authorizer.test.js +306 -0
  45. package/tests/bot.donothing.test.js +30 -0
  46. package/tests/bot.ok.test.js +101 -0
  47. package/tests/botcontext.test.js +674 -0
  48. package/tests/botdatastorage.test.js +87 -0
  49. package/tests/digester.test.js +56 -0
  50. package/tests/fixtures/bots.js +15 -0
  51. package/tests/httpsignature.test.js +200 -0
  52. package/tests/httpsignatureauthenticator.test.js +463 -0
  53. package/tests/keystorage.test.js +89 -0
  54. package/tests/microsyntax.test.js +122 -0
  55. package/tests/objectcache.test.js +133 -0
  56. package/tests/objectstorage.test.js +148 -0
  57. package/tests/remotekeystorage.test.js +76 -0
  58. package/tests/routes.actor.test.js +207 -0
  59. package/tests/routes.collection.test.js +434 -0
  60. package/tests/routes.health.test.js +41 -0
  61. package/tests/routes.inbox.test.js +135 -0
  62. package/tests/routes.object.test.js +519 -0
  63. package/tests/routes.server.test.js +69 -0
  64. package/tests/routes.webfinger.test.js +41 -0
  65. package/tests/urlformatter.test.js +164 -0
  66. package/tests/utils/digest.js +7 -0
  67. package/tests/utils/nock.js +276 -0
@@ -0,0 +1,87 @@
1
+ import { describe, before, after, it } from 'node:test'
2
+ import { BotDataStorage, NoSuchValueError } from '../lib/botdatastorage.js'
3
+ import assert from 'node:assert'
4
+ import { Sequelize } from 'sequelize'
5
+
6
+ describe('BotDataStorage', async () => {
7
+ let connection = null
8
+ let storage = null
9
+ before(async () => {
10
+ connection = new Sequelize('sqlite::memory:', { logging: false })
11
+ await connection.authenticate()
12
+ })
13
+ after(async () => {
14
+ await connection.close()
15
+ })
16
+ it('can initialize', async () => {
17
+ storage = new BotDataStorage(connection)
18
+ await storage.initialize()
19
+ })
20
+ it('can set a value', async () => {
21
+ await storage.set('test', 'key1', 'value1')
22
+ })
23
+ it('can get a value', async () => {
24
+ const value = await storage.get('test', 'key1')
25
+ assert.equal(value, 'value1')
26
+ })
27
+ it('knows if a value exists', async () => {
28
+ const flag = await storage.has('test', 'key1')
29
+ assert.ok(flag)
30
+ })
31
+ it('knows if a value does not exist', async () => {
32
+ const flag = await storage.has('test', 'nonexistent1')
33
+ assert.ok(!flag)
34
+ })
35
+ it('raises an error on a non-existent value', async () => {
36
+ try {
37
+ await storage.get('test', 'nonexistent2')
38
+ assert.fail('Did not raise an exception getting a nonexistent key')
39
+ } catch (e) {
40
+ assert.ok(e instanceof NoSuchValueError)
41
+ }
42
+ })
43
+ it('can delete a value', async () => {
44
+ await storage.delete('test', 'key1')
45
+ })
46
+ it('knows if a value has been deleted', async () => {
47
+ const flag = await storage.has('test', 'key1')
48
+ assert.ok(!flag)
49
+ })
50
+ it('raises an error on a deleted value', async () => {
51
+ try {
52
+ await storage.get('test', 'key1')
53
+ assert.fail('Did not raise an exception getting a deleted key')
54
+ } catch (e) {
55
+ assert.ok(e instanceof NoSuchValueError)
56
+ }
57
+ })
58
+ it('stores different data at different keys for the same bot', async () => {
59
+ await storage.set('test', 'key2', 'value2')
60
+ await storage.set('test', 'key3', 'value3')
61
+ const value2 = await storage.get('test', 'key2')
62
+ const value3 = await storage.get('test', 'key3')
63
+ assert.notEqual(value2, value3)
64
+ })
65
+ it('stores different data at the same key for different bots', async () => {
66
+ await storage.set('test2', 'key4', 'value4')
67
+ await storage.set('test3', 'key4', 'value5')
68
+ const value4 = await storage.get('test2', 'key4')
69
+ const value5 = await storage.get('test3', 'key4')
70
+ assert.notEqual(value4, value5)
71
+ })
72
+ it('can store numbers', async () => {
73
+ await storage.set('test', 'numberkey1', 23)
74
+ const value = await storage.get('test', 'numberkey1')
75
+ assert.equal(value, 23)
76
+ })
77
+ it('can store arrays', async () => {
78
+ await storage.set('test', 'arraykey1', [1, 2, 3])
79
+ const value = await storage.get('test', 'arraykey1')
80
+ assert.deepEqual(value, [1, 2, 3])
81
+ })
82
+ it('can store objects', async () => {
83
+ await storage.set('test', 'objectkey1', { a: 1, b: 2, c: 3 })
84
+ const value = await storage.get('test', 'objectkey1')
85
+ assert.deepEqual(value, { a: 1, b: 2, c: 3 })
86
+ })
87
+ })
@@ -0,0 +1,56 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import Logger from 'pino'
4
+ import { Digester } from '../lib/digester.js'
5
+
6
+ describe('Digester', () => {
7
+ let digester = null
8
+ let logger = null
9
+
10
+ before(() => {
11
+ logger = new Logger({
12
+ level: 'silent'
13
+ })
14
+ })
15
+
16
+ after(async () => {
17
+ logger = null
18
+ })
19
+
20
+ it('can initialize', async () => {
21
+ digester = new Digester(logger)
22
+ assert.ok(digester)
23
+ })
24
+
25
+ it('can digest a string', async () => {
26
+ const text = 'Hello, world!'
27
+ const digest = await digester.digest(text)
28
+ assert.ok(digest)
29
+ assert.equal(digest, 'sha-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=')
30
+ })
31
+
32
+ it('can compare two equal digests', async () => {
33
+ const text = 'Hello, world!'
34
+ const digest1 = await digester.digest(text)
35
+ const digest2 = await digester.digest(text)
36
+ const result = await digester.equals(digest1, digest2)
37
+ assert.ok(result)
38
+ })
39
+
40
+ it('can compare two different digests', async () => {
41
+ const text1 = 'Hello, world!'
42
+ const text2 = 'Hello, world!!'
43
+ const digest1 = await digester.digest(text1)
44
+ const digest2 = await digester.digest(text2)
45
+ const result = await digester.equals(digest1, digest2)
46
+ assert.ok(!result)
47
+ })
48
+
49
+ it('can compare two digests that differ only in case of the algorithm', async () => {
50
+ const text = 'Hello, world!'
51
+ const digest1 = await digester.digest(text)
52
+ const digest2 = digest1.replace('sha-256', 'SHA-256')
53
+ const result = await digester.equals(digest1, digest2)
54
+ assert.ok(result)
55
+ })
56
+ })
@@ -0,0 +1,15 @@
1
+ import DoNothingBot from '../../lib/bots/donothing.js'
2
+ import OKBot from '../../lib/bots/ok.js'
3
+
4
+ export default {
5
+ ok: new OKBot('ok'),
6
+ null: new DoNothingBot('null'),
7
+ test0: new DoNothingBot('test0'),
8
+ test1: new DoNothingBot('test1'),
9
+ test2: new DoNothingBot('test2'),
10
+ test3: new DoNothingBot('test3'),
11
+ test4: new DoNothingBot('test4'),
12
+ test5: new DoNothingBot('test5'),
13
+ test6: new DoNothingBot('test6'),
14
+ test7: new DoNothingBot('test7')
15
+ }
@@ -0,0 +1,200 @@
1
+ import { describe, before, after, it } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import { Sequelize } from 'sequelize'
4
+ import { KeyStorage } from '../lib/keystorage.js'
5
+ import { nockSetup, nockSignature, nockKeyRotate, getPublicKey, getPrivateKey, nockFormat } from './utils/nock.js'
6
+ import { HTTPSignature } from '../lib/httpsignature.js'
7
+ import Logger from 'pino'
8
+ import { Digester } from '../lib/digester.js'
9
+
10
+ describe('HTTPSignature', async () => {
11
+ const domain = 'activitypubbot.example'
12
+ const origin = `https://${domain}`
13
+ let connection = null
14
+ let httpSignature = null
15
+ let logger = null
16
+ let digester = null
17
+ before(async () => {
18
+ logger = Logger({
19
+ level: 'silent'
20
+ })
21
+ connection = new Sequelize('sqlite::memory:', { logging: false })
22
+ await connection.authenticate()
23
+ const keyStorage = new KeyStorage(connection, logger)
24
+ await keyStorage.initialize()
25
+ nockSetup('social.example')
26
+ digester = new Digester(logger)
27
+ })
28
+
29
+ after(async () => {
30
+ await connection.close()
31
+ digester = null
32
+ })
33
+
34
+ it('can initialize', async () => {
35
+ httpSignature = new HTTPSignature(logger)
36
+ assert.ok(httpSignature)
37
+ })
38
+
39
+ it('can validate a signature', async () => {
40
+ const username = 'test'
41
+ const date = new Date().toUTCString()
42
+ const signature = await nockSignature({
43
+ url: `${origin}/user/ok/outbox`,
44
+ date,
45
+ username
46
+ })
47
+ const headers = {
48
+ date,
49
+ signature,
50
+ host: URL.parse(origin).host
51
+ }
52
+ const publicKeyPem = await getPublicKey(username)
53
+ const method = 'GET'
54
+ const path = '/user/ok/outbox'
55
+ const result = await httpSignature.validate(publicKeyPem, signature, method, path, headers)
56
+ assert.ok(result)
57
+ })
58
+
59
+ it('can validate a signature on an URL with parameters', async () => {
60
+ const username = 'test'
61
+ const lname = 'ok'
62
+ const date = new Date().toUTCString()
63
+ const signature = await nockSignature({
64
+ url: `${origin}/.well-known/webfinger?resource=acct:${lname}@${domain}`,
65
+ date,
66
+ username
67
+ })
68
+ const headers = {
69
+ date,
70
+ signature,
71
+ host: URL.parse(origin).host
72
+ }
73
+ const publicKeyPem = await getPublicKey(username)
74
+ const method = 'GET'
75
+ const path = `/.well-known/webfinger?resource=acct:${lname}@${domain}`
76
+ const result = await httpSignature.validate(publicKeyPem, signature, method, path, headers)
77
+ assert.ok(result)
78
+ })
79
+
80
+ it('can handle key rotation', async () => {
81
+ const username = 'rotate'
82
+ const date = new Date().toUTCString()
83
+ const signature = await nockSignature({
84
+ url: `${origin}/user/ok/outbox`,
85
+ date,
86
+ username
87
+ })
88
+ const headers = {
89
+ date,
90
+ signature,
91
+ host: URL.parse(origin).host
92
+ }
93
+ const publicKeyPem = await getPublicKey(username)
94
+ const method = 'GET'
95
+ const path = '/user/ok/outbox'
96
+ await httpSignature.validate(publicKeyPem, signature, method, path, headers)
97
+ await nockKeyRotate(username)
98
+ const signature2 = await nockSignature({
99
+ url: `${origin}/user/ok/outbox`,
100
+ date,
101
+ username
102
+ })
103
+ const headers2 = {
104
+ date,
105
+ signature,
106
+ host: URL.parse(origin).host
107
+ }
108
+ const publicKeyPem2 = await getPublicKey(username)
109
+ assert.notStrictEqual(publicKeyPem, publicKeyPem2)
110
+ const result2 = await httpSignature.validate(publicKeyPem2, signature2, method, path, headers2)
111
+ assert.ok(result2)
112
+ })
113
+
114
+ it('can sign a GET request', async () => {
115
+ const date = new Date().toUTCString()
116
+ const headers = {
117
+ Date: date,
118
+ Host: URL.parse(origin).host,
119
+ 'X-Unused-Header': 'test',
120
+ Accept: 'application/activity+json',
121
+ 'User-Agent': 'activitypubbot-test/0.0.1'
122
+ }
123
+ const privateKey = await getPrivateKey('test')
124
+ const method = 'GET'
125
+ const url = nockFormat({ username: 'test', obj: 'outbox' })
126
+ const keyId = nockFormat({ username: 'test', key: true })
127
+ const signature = await httpSignature.sign({ privateKey, keyId, url, method, headers })
128
+ assert.ok(signature)
129
+ assert.match(signature, /^keyId="https:\/\/social\.example\/user\/test\/publickey",headers="\(request-target\) host date user-agent accept",signature=".*",algorithm="rsa-sha256"$/)
130
+ })
131
+
132
+ it('can sign a POST request', async () => {
133
+ const body = JSON.stringify({
134
+ '@context': 'https://www.w3.org/ns/activitystreams',
135
+ type: 'Create',
136
+ actor: nockFormat({ username: 'test' }),
137
+ object: nockFormat({ username: 'test', obj: 'note', num: 1 })
138
+ })
139
+ const headers = {
140
+ date: new Date().toUTCString(),
141
+ host: URL.parse(origin).host,
142
+ digest: await digester.digest(body),
143
+ 'content-type': 'application/activity+json',
144
+ 'User-Agent': 'activitypubbot-test/0.0.1'
145
+ }
146
+ const privateKey = await getPrivateKey('test')
147
+ const method = 'POST'
148
+ const url = nockFormat({ username: 'test', obj: 'outbox' })
149
+ const keyId = nockFormat({ username: 'test', key: true })
150
+ const signature = await httpSignature.sign({ privateKey, keyId, url, method, headers })
151
+ assert.ok(signature)
152
+ assert.match(signature, /^keyId="https:\/\/social\.example\/user\/test\/publickey",headers="\(request-target\) host date user-agent content-type digest",signature=".*",algorithm="rsa-sha256"$/)
153
+ })
154
+
155
+ it('errors if required GET headers not present', async () => {
156
+ const date = new Date().toUTCString()
157
+ const headers = {
158
+ Date: date,
159
+ Host: URL.parse(origin).host,
160
+ 'User-Agent': 'activitypubbot-test/0.0.1'
161
+ }
162
+ const privateKey = await getPrivateKey('test')
163
+ const method = 'GET'
164
+ const url = nockFormat({ username: 'test', obj: 'outbox' })
165
+ const keyId = nockFormat({ username: 'test', key: true })
166
+ try {
167
+ await httpSignature.sign({ privateKey, keyId, url, method, headers })
168
+ assert.fail('Expected error not thrown')
169
+ } catch (err) {
170
+ assert.equal(err.name, 'Error')
171
+ assert.equal(err.message, 'Missing header: accept')
172
+ }
173
+ })
174
+
175
+ it('errors if required POST headers not present', async () => {
176
+ const body = JSON.stringify({
177
+ '@context': 'https://www.w3.org/ns/activitystreams',
178
+ type: 'Create',
179
+ actor: nockFormat({ username: 'test' }),
180
+ object: nockFormat({ username: 'test', obj: 'note', num: 1 })
181
+ })
182
+ const headers = {
183
+ date: new Date().toUTCString(),
184
+ host: URL.parse(origin).host,
185
+ digest: await digester.digest(body),
186
+ 'User-Agent': 'activitypubbot-test/0.0.1'
187
+ }
188
+ const privateKey = await getPrivateKey('test')
189
+ const method = 'POST'
190
+ const url = nockFormat({ username: 'test', obj: 'outbox' })
191
+ const keyId = nockFormat({ username: 'test', key: true })
192
+ try {
193
+ await httpSignature.sign({ privateKey, keyId, url, method, headers })
194
+ assert.fail('Expected error not thrown')
195
+ } catch (err) {
196
+ assert.equal(err.name, 'Error')
197
+ assert.equal(err.message, 'Missing header: content-type')
198
+ }
199
+ })
200
+ })