psf-bch-api 1.1.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 (53) hide show
  1. package/.env-local +4 -0
  2. package/LICENSE.md +8 -0
  3. package/README.md +8 -0
  4. package/apidoc.json +9 -0
  5. package/bin/server.js +183 -0
  6. package/dev-docs/README.md +4 -0
  7. package/dev-docs/creation-prompt.md +34 -0
  8. package/dev-docs/rest2nostr-poxy-api.plan.md +163 -0
  9. package/dev-docs/test-plan-for-rest2nostr.plan.md +161 -0
  10. package/dev-docs/unit-test-prompt.md +13 -0
  11. package/examples/01-create-account.js +67 -0
  12. package/examples/02-read-posts.js +44 -0
  13. package/examples/03-write-post.js +55 -0
  14. package/examples/04-read-alice-posts.js +49 -0
  15. package/examples/05-get-follow-list.js +53 -0
  16. package/examples/06-update-follow-list.js +63 -0
  17. package/examples/07-liking-event.js +59 -0
  18. package/examples/README.md +90 -0
  19. package/index.js +11 -0
  20. package/package.json +37 -0
  21. package/production/docker/Dockerfile +85 -0
  22. package/production/docker/cleanup-images.sh +5 -0
  23. package/production/docker/docker-compose.yml +19 -0
  24. package/production/docker/start-rest2nostr.sh +3 -0
  25. package/src/adapters/full-node-rpc.js +133 -0
  26. package/src/adapters/index.js +217 -0
  27. package/src/adapters/wlogger.js +79 -0
  28. package/src/config/env/common.js +64 -0
  29. package/src/config/env/development.js +7 -0
  30. package/src/config/env/production.js +7 -0
  31. package/src/config/index.js +14 -0
  32. package/src/controllers/index.js +56 -0
  33. package/src/controllers/rest-api/full-node/blockchain/controller.js +553 -0
  34. package/src/controllers/rest-api/full-node/blockchain/index.js +66 -0
  35. package/src/controllers/rest-api/index.js +55 -0
  36. package/src/controllers/timer-controller.js +72 -0
  37. package/src/entities/event.js +71 -0
  38. package/src/use-cases/full-node-blockchain-use-cases.js +134 -0
  39. package/src/use-cases/index.js +29 -0
  40. package/test/integration/api/event-integration.js +250 -0
  41. package/test/integration/api/req-integration.js +173 -0
  42. package/test/integration/api/subscription-integration.js +198 -0
  43. package/test/integration/use-cases/manage-subscription-integration.js +163 -0
  44. package/test/integration/use-cases/publish-event-integration.js +104 -0
  45. package/test/integration/use-cases/query-events-integration.js +95 -0
  46. package/test/unit/adapters/full-node-rpc-unit.js +122 -0
  47. package/test/unit/bin/server-unit.js +63 -0
  48. package/test/unit/controllers/blockchain-controller-unit.js +215 -0
  49. package/test/unit/controllers/rest-api-index-unit.js +85 -0
  50. package/test/unit/entities/event-unit.js +139 -0
  51. package/test/unit/mocks/controller-mocks.js +98 -0
  52. package/test/unit/mocks/event-mocks.js +194 -0
  53. package/test/unit/use-cases/full-node-blockchain-use-cases-unit.js +137 -0
@@ -0,0 +1,72 @@
1
+ /*
2
+ Timer-based controller functions.
3
+ This controller handles scheduled tasks and timers.
4
+ */
5
+
6
+ // Local libraries
7
+ import wlogger from '../adapters/wlogger.js'
8
+
9
+ class TimerController {
10
+ constructor (localConfig = {}) {
11
+ // Dependency Injection
12
+ this.adapters = localConfig.adapters
13
+ if (!this.adapters) {
14
+ throw new Error(
15
+ 'Instance of Adapters library required when instantiating TimerController.'
16
+ )
17
+ }
18
+ this.useCases = localConfig.useCases
19
+ if (!this.useCases) {
20
+ throw new Error(
21
+ 'Instance of Use Cases library required when instantiating TimerController.'
22
+ )
23
+ }
24
+
25
+ // Constants
26
+ this.SHUTDOWN_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes in milliseconds
27
+ this.LIVENESS_CHECK_INTERVAL_MS = 1 * 60 * 1000 // 1 minute in milliseconds
28
+
29
+ // Handlers
30
+ this.shutdownHandler = null
31
+ this.livenessCheckHandler = null
32
+
33
+ // Bind 'this' object to all subfunctions
34
+ this.startTimerControllers = this.startTimerControllers.bind(this)
35
+ this.stopTimerControllers = this.stopTimerControllers.bind(this)
36
+ this.shutdown = this.shutdown.bind(this)
37
+ this.livenessCheck = this.livenessCheck.bind(this)
38
+ }
39
+
40
+ startTimerControllers () {
41
+ console.log('Starting Timer Controllers.')
42
+
43
+ // this.shutdownHandler = setInterval(() => {
44
+ // this.shutdown()
45
+ // }, this.SHUTDOWN_INTERVAL_MS)
46
+
47
+ // this.livenessCheckHandler = setInterval(() => {
48
+ // this.livenessCheck()
49
+ // }, this.LIVENESS_CHECK_INTERVAL_MS)
50
+ }
51
+
52
+ stopTimerControllers () {
53
+ console.log('Stopping Timer Controllers.')
54
+
55
+ clearInterval(this.shutdownHandler)
56
+ this.shutdownHandler = null
57
+ clearInterval(this.livenessCheckHandler)
58
+ this.livenessCheckHandler = null
59
+ }
60
+
61
+ // Execute the shutdown callback
62
+ shutdown () {
63
+ wlogger.info(`TimerController: Shutting down application at ${new Date().toISOString()}, depending on process manager to restart application.`)
64
+ process.exit(1)
65
+ }
66
+
67
+ livenessCheck () {
68
+ wlogger.info(`TimerController: Liveness check at ${new Date().toISOString()}`)
69
+ }
70
+ }
71
+
72
+ export default TimerController
@@ -0,0 +1,71 @@
1
+ /*
2
+ Event entity - represents a Nostr event.
3
+ This is a domain model following Clean Architecture principles.
4
+ */
5
+
6
+ class Event {
7
+ constructor (data) {
8
+ this.id = data.id
9
+ this.pubkey = data.pubkey
10
+ this.created_at = data.created_at
11
+ this.kind = data.kind
12
+ this.tags = data.tags || []
13
+ this.content = data.content
14
+ this.sig = data.sig
15
+ }
16
+
17
+ /**
18
+ * Validates the event structure
19
+ * @returns {boolean} True if valid
20
+ */
21
+ isValid () {
22
+ if (!this.id || !this.pubkey || !this.created_at || this.kind === undefined || !this.sig) {
23
+ return false
24
+ }
25
+
26
+ // Basic type checks
27
+ if (typeof this.id !== 'string' || this.id.length !== 64) {
28
+ return false
29
+ }
30
+
31
+ if (typeof this.pubkey !== 'string' || this.pubkey.length !== 64) {
32
+ return false
33
+ }
34
+
35
+ if (typeof this.created_at !== 'number') {
36
+ return false
37
+ }
38
+
39
+ if (typeof this.kind !== 'number' || this.kind < 0 || this.kind > 65535) {
40
+ return false
41
+ }
42
+
43
+ if (typeof this.sig !== 'string' || this.sig.length !== 128) {
44
+ return false
45
+ }
46
+
47
+ if (!Array.isArray(this.tags)) {
48
+ return false
49
+ }
50
+
51
+ return true
52
+ }
53
+
54
+ /**
55
+ * Convert to plain object
56
+ * @returns {Object} Plain event object
57
+ */
58
+ toJSON () {
59
+ return {
60
+ id: this.id,
61
+ pubkey: this.pubkey,
62
+ created_at: this.created_at,
63
+ kind: this.kind,
64
+ tags: this.tags,
65
+ content: this.content,
66
+ sig: this.sig
67
+ }
68
+ }
69
+ }
70
+
71
+ export default Event
@@ -0,0 +1,134 @@
1
+ /*
2
+ Use cases for interacting with the BCH full node blockchain RPC interface.
3
+ */
4
+
5
+ import wlogger from '../adapters/wlogger.js'
6
+
7
+ class BlockchainUseCases {
8
+ constructor (localConfig = {}) {
9
+ this.adapters = localConfig.adapters
10
+
11
+ if (!this.adapters) {
12
+ throw new Error('Adapters instance required when instantiating Blockchain use cases.')
13
+ }
14
+
15
+ this.fullNode = this.adapters.fullNode
16
+ if (!this.fullNode) {
17
+ throw new Error('Full node adapter required when instantiating Blockchain use cases.')
18
+ }
19
+ }
20
+
21
+ async getBestBlockHash () {
22
+ return this.fullNode.call('getbestblockhash')
23
+ }
24
+
25
+ async getBlockchainInfo () {
26
+ return this.fullNode.call('getblockchaininfo')
27
+ }
28
+
29
+ async getBlockCount () {
30
+ return this.fullNode.call('getblockcount')
31
+ }
32
+
33
+ async getBlockHeader ({ hash, verbose = false }) {
34
+ return this.fullNode.call('getblockheader', [hash, verbose])
35
+ }
36
+
37
+ async getBlockHeaders ({ hashes, verbose = false }) {
38
+ try {
39
+ const promises = hashes.map(hash =>
40
+ this.fullNode.call('getblockheader', [hash, verbose], `getblockheader-${hash}`)
41
+ )
42
+
43
+ return await Promise.all(promises)
44
+ } catch (err) {
45
+ wlogger.error('Error in BlockchainUseCases.getBlockHeaders()', err)
46
+ throw err
47
+ }
48
+ }
49
+
50
+ async getChainTips () {
51
+ return this.fullNode.call('getchaintips')
52
+ }
53
+
54
+ async getDifficulty () {
55
+ return this.fullNode.call('getdifficulty')
56
+ }
57
+
58
+ async getMempoolEntry ({ txid }) {
59
+ return this.fullNode.call('getmempoolentry', [txid])
60
+ }
61
+
62
+ async getMempoolEntries ({ txids }) {
63
+ try {
64
+ const promises = txids.map(txid =>
65
+ this.fullNode.call('getmempoolentry', [txid], `getmempoolentry-${txid}`)
66
+ )
67
+
68
+ return await Promise.all(promises)
69
+ } catch (err) {
70
+ wlogger.error('Error in BlockchainUseCases.getMempoolEntries()', err)
71
+ throw err
72
+ }
73
+ }
74
+
75
+ async getMempoolAncestors ({ txid, verbose = false }) {
76
+ return this.fullNode.call('getmempoolancestors', [txid, verbose])
77
+ }
78
+
79
+ async getMempoolInfo () {
80
+ return this.fullNode.call('getmempoolinfo')
81
+ }
82
+
83
+ async getRawMempool ({ verbose = false }) {
84
+ return this.fullNode.call('getrawmempool', [verbose])
85
+ }
86
+
87
+ async getTxOut ({ txid, n, includeMempool }) {
88
+ return this.fullNode.call('gettxout', [txid, n, includeMempool])
89
+ }
90
+
91
+ async getTxOutProof ({ txid }) {
92
+ return this.fullNode.call('gettxoutproof', [[txid]])
93
+ }
94
+
95
+ async getTxOutProofs ({ txids }) {
96
+ try {
97
+ const promises = txids.map(txid =>
98
+ this.fullNode.call('gettxoutproof', [[txid]], `gettxoutproof-${txid}`)
99
+ )
100
+
101
+ return await Promise.all(promises)
102
+ } catch (err) {
103
+ wlogger.error('Error in BlockchainUseCases.getTxOutProofs()', err)
104
+ throw err
105
+ }
106
+ }
107
+
108
+ async verifyTxOutProof ({ proof }) {
109
+ return this.fullNode.call('verifytxoutproof', [proof])
110
+ }
111
+
112
+ async verifyTxOutProofs ({ proofs }) {
113
+ try {
114
+ const promises = proofs.map(proof =>
115
+ this.fullNode.call('verifytxoutproof', [proof], `verifytxoutproof-${proof.slice(0, 16)}`)
116
+ )
117
+
118
+ return await Promise.all(promises)
119
+ } catch (err) {
120
+ wlogger.error('Error in BlockchainUseCases.verifyTxOutProofs()', err)
121
+ throw err
122
+ }
123
+ }
124
+
125
+ async getBlock ({ blockhash, verbosity }) {
126
+ return this.fullNode.call('getblock', [blockhash, verbosity])
127
+ }
128
+
129
+ async getBlockHash ({ height }) {
130
+ return this.fullNode.call('getblockhash', [height])
131
+ }
132
+ }
133
+
134
+ export default BlockchainUseCases
@@ -0,0 +1,29 @@
1
+ /*
2
+ This is a top-level library that encapsulates all the additional Use Cases.
3
+ The concept of Use Cases comes from Clean Architecture:
4
+ https://troutsblog.com/blog/clean-architecture
5
+ */
6
+
7
+ // Local libraries
8
+ import BlockchainUseCases from './full-node-blockchain-use-cases.js'
9
+
10
+ class UseCases {
11
+ constructor (localConfig = {}) {
12
+ this.adapters = localConfig.adapters
13
+ if (!this.adapters) {
14
+ throw new Error(
15
+ 'Instance of adapters must be passed in when instantiating Use Cases library.'
16
+ )
17
+ }
18
+
19
+ this.blockchain = new BlockchainUseCases({ adapters: this.adapters })
20
+ }
21
+
22
+ // Run any startup Use Cases at the start of the app.
23
+ async start () {
24
+ console.log('Use Cases have been started.')
25
+ return true
26
+ }
27
+ }
28
+
29
+ export default UseCases
@@ -0,0 +1,250 @@
1
+ /*
2
+ Integration tests for POST /event endpoint.
3
+ These tests require a running Nostr relay.
4
+ */
5
+
6
+ // npm libraries
7
+ import { assert } from 'chai'
8
+
9
+ // Unit under test
10
+ import Server from '../../../bin/server.js'
11
+ import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools/pure'
12
+ import { hexToBytes } from '@noble/hashes/utils.js'
13
+
14
+ describe('#event-integration.js', () => {
15
+ let server
16
+ const baseUrl = 'http://localhost:3001' // Use different port for tests
17
+
18
+ before(async () => {
19
+ // Start test server
20
+ server = new Server()
21
+ server.config.port = 3001
22
+ await server.startServer()
23
+
24
+ // Wait for server to be ready
25
+ await new Promise(resolve => setTimeout(resolve, 1000))
26
+ })
27
+
28
+ after(async () => {
29
+ // Stop server
30
+ if (server && server.server) {
31
+ await new Promise((resolve) => {
32
+ server.server.close(() => {
33
+ resolve()
34
+ })
35
+ })
36
+ }
37
+ })
38
+
39
+ describe('POST /event', () => {
40
+ it('should publish kind 0 event (profile metadata) - covers example 01', async () => {
41
+ // Generate keys
42
+ const sk = generateSecretKey()
43
+
44
+ // Create profile metadata event (kind 0)
45
+ const profileMetadata = {
46
+ name: 'Test User',
47
+ about: 'Integration test user',
48
+ picture: 'https://example.com/test.jpg'
49
+ }
50
+
51
+ const eventTemplate = {
52
+ kind: 0,
53
+ created_at: Math.floor(Date.now() / 1000),
54
+ tags: [],
55
+ content: JSON.stringify(profileMetadata)
56
+ }
57
+
58
+ // Sign the event
59
+ const signedEvent = finalizeEvent(eventTemplate, sk)
60
+
61
+ // Publish to REST API
62
+ const response = await fetch(`${baseUrl}/event`, {
63
+ method: 'POST',
64
+ headers: {
65
+ 'Content-Type': 'application/json'
66
+ },
67
+ body: JSON.stringify(signedEvent)
68
+ })
69
+
70
+ const result = await response.json()
71
+
72
+ // Assert response
73
+ assert.equal(response.status, 200)
74
+ assert.property(result, 'accepted')
75
+ assert.property(result, 'eventId')
76
+ assert.equal(result.eventId, signedEvent.id)
77
+ })
78
+
79
+ it('should publish kind 1 event (text post) - covers example 03', async () => {
80
+ // Alice's private key from examples
81
+ const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
82
+ const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
83
+ const alicePubKey = getPublicKey(alicePrivKeyBin)
84
+
85
+ // Generate a post
86
+ const eventTemplate = {
87
+ kind: 1,
88
+ created_at: Math.floor(Date.now() / 1000),
89
+ tags: [],
90
+ content: 'Integration test post'
91
+ }
92
+
93
+ // Sign the post
94
+ const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin)
95
+
96
+ // Publish to REST API
97
+ const response = await fetch(`${baseUrl}/event`, {
98
+ method: 'POST',
99
+ headers: {
100
+ 'Content-Type': 'application/json'
101
+ },
102
+ body: JSON.stringify(signedEvent)
103
+ })
104
+
105
+ const result = await response.json()
106
+
107
+ // Assert response
108
+ assert.equal(response.status, 200)
109
+ assert.property(result, 'accepted')
110
+ assert.property(result, 'eventId')
111
+ assert.equal(result.eventId, signedEvent.id)
112
+ assert.equal(signedEvent.pubkey, alicePubKey)
113
+ })
114
+
115
+ it('should publish kind 3 event (follow list) - covers example 06', async () => {
116
+ // Alice's private key
117
+ const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
118
+ const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
119
+
120
+ // Bob's public key
121
+ const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f'
122
+ const bobPrivKeyBin = hexToBytes(bobPrivKeyHex)
123
+ const bobPubKey = getPublicKey(bobPrivKeyBin)
124
+
125
+ const psf = 'wss://nostr-relay.psfoundation.info'
126
+
127
+ const followList = [
128
+ ['p', bobPubKey, psf, 'bob']
129
+ ]
130
+
131
+ // Generate a follow list event (kind 3)
132
+ const eventTemplate = {
133
+ kind: 3,
134
+ created_at: Math.floor(Date.now() / 1000),
135
+ tags: followList,
136
+ content: ''
137
+ }
138
+
139
+ // Sign the event
140
+ const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin)
141
+
142
+ // Publish to REST API
143
+ const response = await fetch(`${baseUrl}/event`, {
144
+ method: 'POST',
145
+ headers: {
146
+ 'Content-Type': 'application/json'
147
+ },
148
+ body: JSON.stringify(signedEvent)
149
+ })
150
+
151
+ const result = await response.json()
152
+
153
+ // Assert response
154
+ assert.equal(response.status, 200)
155
+ assert.property(result, 'accepted')
156
+ assert.property(result, 'eventId')
157
+ assert.equal(result.eventId, signedEvent.id)
158
+ })
159
+
160
+ it('should publish kind 7 event (reaction/like) - covers example 07', async () => {
161
+ // Bob's private key
162
+ const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f'
163
+ const bobPrivKeyBin = hexToBytes(bobPrivKeyHex)
164
+ const bobPubKey = getPublicKey(bobPrivKeyBin)
165
+
166
+ const psf = 'wss://nostr-relay.psfoundation.info'
167
+
168
+ // Use a test event ID
169
+ const evIdToLike = 'd09b4c5da59be3cd2768aa53fa78b77bf4859084c94f3bf26d401f004a9c8167'
170
+ const evIdAuthorPubKey = '2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e92'
171
+
172
+ // Generate like event (kind 7)
173
+ const likeEventTemplate = {
174
+ kind: 7,
175
+ created_at: Math.floor(Date.now() / 1000),
176
+ pubkey: bobPubKey,
177
+ tags: [
178
+ ['e', evIdToLike, psf],
179
+ ['p', evIdAuthorPubKey, psf]
180
+ ],
181
+ content: '+'
182
+ }
183
+
184
+ // Sign the event
185
+ const signedEvent = finalizeEvent(likeEventTemplate, bobPrivKeyBin)
186
+
187
+ // Publish to REST API
188
+ const response = await fetch(`${baseUrl}/event`, {
189
+ method: 'POST',
190
+ headers: {
191
+ 'Content-Type': 'application/json'
192
+ },
193
+ body: JSON.stringify(signedEvent)
194
+ })
195
+
196
+ const result = await response.json()
197
+
198
+ // Assert response
199
+ assert.equal(response.status, 200)
200
+ assert.property(result, 'accepted')
201
+ assert.property(result, 'eventId')
202
+ assert.equal(result.eventId, signedEvent.id)
203
+ })
204
+
205
+ it('should reject invalid event', async () => {
206
+ const invalidEvent = {
207
+ id: 'invalid',
208
+ pubkey: 'invalid',
209
+ created_at: Math.floor(Date.now() / 1000),
210
+ kind: 1,
211
+ tags: [],
212
+ content: 'Test',
213
+ sig: 'invalid'
214
+ }
215
+
216
+ const response = await fetch(`${baseUrl}/event`, {
217
+ method: 'POST',
218
+ headers: {
219
+ 'Content-Type': 'application/json'
220
+ },
221
+ body: JSON.stringify(invalidEvent)
222
+ })
223
+
224
+ const result = await response.json()
225
+
226
+ // Should reject invalid event - error response format
227
+ assert.equal(response.status, 400)
228
+ assert.property(result, 'error')
229
+ assert.include(result.error, 'Invalid event structure')
230
+ })
231
+
232
+ it('should return 400 when event data is missing', async () => {
233
+ // Send empty body - Express will parse as undefined, controller should handle it
234
+ const response = await fetch(`${baseUrl}/event`, {
235
+ method: 'POST',
236
+ headers: {
237
+ 'Content-Type': 'application/json'
238
+ },
239
+ body: ''
240
+ })
241
+
242
+ // Empty body should be parsed as undefined by Express
243
+ const result = await response.json()
244
+
245
+ assert.equal(response.status, 400)
246
+ assert.property(result, 'error')
247
+ assert.include(result.error, 'Event data is required')
248
+ })
249
+ })
250
+ })