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,133 @@
1
+ /*
2
+ Adapter library for interacting with a BCH full node over JSON-RPC.
3
+ */
4
+
5
+ import axios from 'axios'
6
+ import wlogger from './wlogger.js'
7
+ import config from '../config/index.js'
8
+
9
+ class FullNodeRPCAdapter {
10
+ constructor (localConfig = {}) {
11
+ this.config = localConfig.config || config
12
+
13
+ if (!this.config.fullNode || !this.config.fullNode.rpcBaseUrl) {
14
+ throw new Error('Full node RPC configuration is required')
15
+ }
16
+
17
+ const {
18
+ rpcBaseUrl,
19
+ rpcUsername,
20
+ rpcPassword,
21
+ rpcTimeoutMs = 15000
22
+ } = this.config.fullNode
23
+
24
+ this.requestIdPrefix = this.config.fullNode.rpcRequestIdPrefix || 'psf-bch-api'
25
+
26
+ this.http = axios.create({
27
+ baseURL: rpcBaseUrl,
28
+ timeout: rpcTimeoutMs,
29
+ auth: {
30
+ username: rpcUsername,
31
+ password: rpcPassword
32
+ }
33
+ })
34
+
35
+ this.defaultRequestPayload = {
36
+ jsonrpc: '1.0'
37
+ }
38
+ }
39
+
40
+ async call (method, params = [], requestId) {
41
+ const id = requestId || `${this.requestIdPrefix}-${method}`
42
+
43
+ try {
44
+ const response = await this.http.post('', {
45
+ ...this.defaultRequestPayload,
46
+ id,
47
+ method,
48
+ params
49
+ })
50
+
51
+ if (response.data && response.data.error) {
52
+ const rpcError = this._formatError(response.data.error.message, 400)
53
+ throw rpcError
54
+ }
55
+
56
+ return response.data.result
57
+ } catch (err) {
58
+ throw this._handleError(err)
59
+ }
60
+ }
61
+
62
+ _handleError (err) {
63
+ const { status, message } = this.decodeError(err)
64
+ const error = new Error(message)
65
+ error.status = status
66
+ error.originalError = err
67
+ return error
68
+ }
69
+
70
+ decodeError (err) {
71
+ try {
72
+ if (
73
+ err.response &&
74
+ err.response.data &&
75
+ err.response.data.error &&
76
+ err.response.data.error.message
77
+ ) {
78
+ return this._formatError(err.response.data.error.message, 400)
79
+ }
80
+
81
+ if (err.response && err.response.data) {
82
+ return this._formatError(err.response.data, err.response.status || 500)
83
+ }
84
+
85
+ if (err.message) {
86
+ if (err.message.includes('ENOTFOUND') || err.message.includes('ENETUNREACH') || err.message.includes('EAI_AGAIN')) {
87
+ return this._formatError(
88
+ 'Network error: Could not communicate with full node or other external service.',
89
+ 503
90
+ )
91
+ }
92
+ }
93
+
94
+ if (err.code && (err.code === 'ECONNABORTED' || err.code === 'ECONNREFUSED')) {
95
+ return this._formatError(
96
+ 'Network error: Could not communicate with full node or other external service.',
97
+ 503
98
+ )
99
+ }
100
+
101
+ if (err.error && typeof err.error === 'string' && err.error.includes('429')) {
102
+ return this._formatError('429 Too Many Requests', 429)
103
+ }
104
+
105
+ if (err.message) {
106
+ return this._formatError(err.message, err.status || 422)
107
+ }
108
+
109
+ return this._formatError('Unhandled full node error', 500)
110
+ } catch (decodeError) {
111
+ wlogger.error('Unhandled error in FullNodeRPCAdapter.decodeError()', decodeError)
112
+ return this._formatError('Internal server error', 500)
113
+ }
114
+ }
115
+
116
+ validateArraySize (length, options = {}) {
117
+ const { isProUser = false } = options
118
+ const freemiumLimit = Number(this.config.fullNode?.freemiumArrayLimit || 20)
119
+ const proLimit = Number(this.config.fullNode?.proArrayLimit || freemiumLimit)
120
+
121
+ const limit = isProUser ? proLimit : freemiumLimit
122
+ return length <= limit
123
+ }
124
+
125
+ _formatError (message, status = 500) {
126
+ return {
127
+ message: message || 'Internal server error',
128
+ status: status || 500
129
+ }
130
+ }
131
+ }
132
+
133
+ export default FullNodeRPCAdapter
@@ -0,0 +1,217 @@
1
+ /*
2
+ This is a top-level library that encapsulates all the additional Adapters.
3
+ The concept of Adapters comes from Clean Architecture:
4
+ https://troutsblog.com/blog/clean-architecture
5
+ */
6
+
7
+ // Load individual adapter libraries.
8
+ // import NostrRelayAdapter from './nostr-relay.js'
9
+ import FullNodeRPCAdapter from './full-node-rpc.js'
10
+ import config from '../config/index.js'
11
+
12
+ class Adapters {
13
+ constructor (localConfig = {}) {
14
+ // Encapsulate dependencies
15
+ this.config = config
16
+
17
+ // Determine relay URLs: prefer localConfig, fall back to config
18
+ // let relayUrls = []
19
+ // if (localConfig.relayUrls && Array.isArray(localConfig.relayUrls)) {
20
+ // relayUrls = localConfig.relayUrls
21
+ // } else if (localConfig.relayUrl) {
22
+ // // Backward compatibility: single relay URL
23
+ // relayUrls = [localConfig.relayUrl]
24
+ // } else {
25
+ // relayUrls = config.nostrRelayUrls
26
+ // }
27
+
28
+ // Create one adapter per relay URL
29
+ // this.nostrRelays = relayUrls.map(relayUrl => new NostrRelayAdapter({ relayUrl }))
30
+
31
+ // Maintain backward compatibility: expose first relay as nostrRelay
32
+ // This allows existing code to work during transition
33
+ // this.nostrRelay = this.nostrRelays[0]
34
+
35
+ this.fullNode = new FullNodeRPCAdapter({ config: this.config })
36
+ }
37
+
38
+ async start () {
39
+ // try {
40
+ // Connect to all Nostr relays concurrently
41
+ // const connectPromises = this.nostrRelays.map(async (relay, index) => {
42
+ // try {
43
+ // await relay.connect()
44
+ // console.log(`Nostr relay adapter ${index + 1}/${this.nostrRelays.length} started: ${relay.relayUrl}`)
45
+ // return { success: true, relay }
46
+ // } catch (err) {
47
+ // console.error(`Failed to connect to relay ${relay.relayUrl}:`, err.message)
48
+ // return { success: false, relay, error: err }
49
+ // }
50
+ // })
51
+
52
+ // const results = await Promise.allSettled(connectPromises)
53
+
54
+ // const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length
55
+ // const failed = results.length - successful
56
+
57
+ // if (successful === 0) {
58
+ // throw new Error('Failed to connect to any Nostr relay')
59
+ // }
60
+
61
+ // if (failed > 0) {
62
+ // console.warn(`Connected to ${successful}/${this.nostrRelays.length} relays. Some relays failed to connect.`)
63
+ // } else {
64
+ // console.log(`All ${successful} Nostr relay adapters started successfully.`)
65
+ // }
66
+
67
+ return true
68
+ // } catch (err) {
69
+ // console.error('Error in adapters/index.js/start()')
70
+ // throw err
71
+ // }
72
+ }
73
+
74
+ /**
75
+ * Get all relay adapters
76
+ * @returns {Array<NostrRelayAdapter>}
77
+ */
78
+ getRelays () {
79
+ return this.nostrRelays
80
+ }
81
+
82
+ /**
83
+ * Broadcast an event to all relays
84
+ * @param {Object} event - Event object to broadcast
85
+ * @returns {Promise<Array>} Array of results from each relay: { accepted, message, relayUrl }
86
+ */
87
+ async broadcastEvent (event) {
88
+ const broadcastPromises = this.nostrRelays.map(async (relay) => {
89
+ try {
90
+ const result = await relay.sendEvent(event)
91
+ return {
92
+ accepted: result.accepted,
93
+ message: result.message || '',
94
+ relayUrl: relay.relayUrl,
95
+ success: true
96
+ }
97
+ } catch (err) {
98
+ return {
99
+ accepted: false,
100
+ message: err.message || 'Error broadcasting to relay',
101
+ relayUrl: relay.relayUrl,
102
+ success: false,
103
+ error: err
104
+ }
105
+ }
106
+ })
107
+
108
+ return Promise.allSettled(broadcastPromises).then(results => {
109
+ return results.map((result, index) => {
110
+ if (result.status === 'fulfilled') {
111
+ return result.value
112
+ } else {
113
+ return {
114
+ accepted: false,
115
+ message: result.reason?.message || 'Unknown error',
116
+ relayUrl: this.nostrRelays[index].relayUrl,
117
+ success: false,
118
+ error: result.reason
119
+ }
120
+ }
121
+ })
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Query all relays concurrently and merge results
127
+ * @param {Array} filters - Array of filter objects
128
+ * @param {string} subscriptionId - Unique subscription ID (will be modified per relay)
129
+ * @returns {Promise<Array>} Merged and de-duplicated array of events
130
+ */
131
+ async queryAllRelays (filters, subscriptionId) {
132
+ // Create unique subscription IDs for each relay
133
+ const subscriptionIds = this.nostrRelays.map((relay, index) =>
134
+ `${subscriptionId}-relay-${index}`
135
+ )
136
+
137
+ // Collect events from all relays
138
+ const allEvents = []
139
+ const relayStatuses = this.nostrRelays.map(() => ({
140
+ eoseReceived: false,
141
+ closedReceived: false,
142
+ closedMessage: ''
143
+ }))
144
+
145
+ // Query all relays concurrently
146
+ const queryPromises = this.nostrRelays.map(async (relay, index) => {
147
+ const subscriptionIdForRelay = subscriptionIds[index]
148
+ const status = relayStatuses[index]
149
+
150
+ // Create handlers for this relay
151
+ const handlers = {
152
+ onEvent: (event) => {
153
+ allEvents.push(event)
154
+ },
155
+ onEose: () => {
156
+ status.eoseReceived = true
157
+ },
158
+ onClosed: (message) => {
159
+ status.closedReceived = true
160
+ status.closedMessage = message
161
+ }
162
+ }
163
+
164
+ try {
165
+ await relay.sendReq(subscriptionIdForRelay, filters, handlers)
166
+
167
+ // Wait for EOSE or CLOSED with timeout
168
+ const timeout = 30000 // 30 seconds
169
+ const startTime = Date.now()
170
+
171
+ while (!status.eoseReceived && !status.closedReceived && (Date.now() - startTime) < timeout) {
172
+ await new Promise(resolve => setTimeout(resolve, 100))
173
+ }
174
+
175
+ // Clean up subscription - always try to close, even if EOSE didn't come
176
+ try {
177
+ await relay.sendClose(subscriptionIdForRelay)
178
+ } catch (err) {
179
+ // Ignore close errors
180
+ }
181
+
182
+ if (status.closedReceived) {
183
+ throw new Error(`Subscription closed on ${relay.relayUrl}: ${status.closedMessage}`)
184
+ }
185
+
186
+ // If EOSE never came but timeout expired, that's OK - we proceed with what we got
187
+ if (!status.eoseReceived && (Date.now() - startTime) >= timeout) {
188
+ // Timeout reached without EOSE - proceed anyway with events collected so far
189
+ }
190
+ } catch (err) {
191
+ // Ensure cleanup even on error
192
+ try {
193
+ await relay.sendClose(subscriptionIdForRelay)
194
+ } catch (closeErr) {
195
+ // Ignore close errors
196
+ }
197
+ // Log error but don't fail the entire query
198
+ console.warn(`Query failed for relay ${relay.relayUrl}:`, err.message)
199
+ }
200
+ })
201
+
202
+ // Wait for all queries to complete
203
+ await Promise.allSettled(queryPromises)
204
+
205
+ // Merge and de-duplicate events by event ID
206
+ const eventMap = new Map()
207
+ allEvents.forEach(event => {
208
+ if (event && event.id && !eventMap.has(event.id)) {
209
+ eventMap.set(event.id, event)
210
+ }
211
+ })
212
+
213
+ return Array.from(eventMap.values())
214
+ }
215
+ }
216
+
217
+ export default Adapters
@@ -0,0 +1,79 @@
1
+ /*
2
+ Instantiates and configures the Winston logging library. This utility library
3
+ can be called by other parts of the application to conveniently tap into the
4
+ logging library.
5
+ */
6
+
7
+ // Global npm libraries
8
+ import winston from 'winston'
9
+ import 'winston-daily-rotate-file'
10
+
11
+ // Local libraries
12
+ import config from '../config/index.js'
13
+
14
+ // Hack to get __dirname back.
15
+ // https://blog.logrocket.com/alternatives-dirname-node-js-es-modules/
16
+ import * as url from 'url'
17
+ const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
18
+
19
+ let _this = null
20
+
21
+ class Wlogger {
22
+ constructor (localConfig = {}) {
23
+ this.config = config
24
+
25
+ // Configure daily-rotation transport.
26
+ this.transport = new winston.transports.DailyRotateFile({
27
+ filename: `${__dirname.toString()}/../../logs/rest2nostr-${
28
+ this.config.env
29
+ }-%DATE%.log`,
30
+ datePattern: 'YYYY-MM-DD',
31
+ zippedArchive: false,
32
+ maxSize: '1m', // 1 megabyte
33
+ maxFiles: '5d', // 5 days
34
+ format: winston.format.combine(
35
+ winston.format.timestamp(),
36
+ winston.format.json()
37
+ )
38
+ })
39
+
40
+ this.transport.on('rotate', this.notifyRotation)
41
+
42
+ // This controls what goes into the log FILES
43
+ this.wlogger = winston.createLogger({
44
+ level: this.config.logLevel || 'info',
45
+ format: winston.format.json(),
46
+ transports: [
47
+ this.transport
48
+ ]
49
+ })
50
+
51
+ // Bind 'this' object to all methods
52
+ this.notifyRotation = this.notifyRotation.bind(this)
53
+ this.outputToConsole = this.outputToConsole.bind(this)
54
+
55
+ _this = this
56
+ }
57
+
58
+ notifyRotation (oldFilename, newFilename) {
59
+ _this.wlogger.info('Rotating log files')
60
+ }
61
+
62
+ outputToConsole () {
63
+ this.wlogger.add(
64
+ new winston.transports.Console({
65
+ format: winston.format.simple(),
66
+ level: this.config.logLevel || 'info'
67
+ })
68
+ )
69
+ }
70
+ }
71
+
72
+ const logger = new Wlogger()
73
+
74
+ // Allow the logger to write to the console.
75
+ logger.outputToConsole()
76
+
77
+ const wlogger = logger.wlogger
78
+
79
+ export { wlogger as default, Wlogger }
@@ -0,0 +1,64 @@
1
+ /*
2
+ This file is used to store unsecure, application-specific data common to all
3
+ environments.
4
+ */
5
+
6
+ import dotenv from 'dotenv'
7
+
8
+ // Hack to get __dirname back.
9
+ // https://blog.logrocket.com/alternatives-dirname-node-js-es-modules/
10
+ import * as url from 'url'
11
+ import { readFileSync } from 'fs'
12
+ dotenv.config()
13
+
14
+ const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
15
+ const pkgInfo = JSON.parse(readFileSync(`${__dirname.toString()}/../../../package.json`))
16
+
17
+ const version = pkgInfo.version
18
+
19
+ export default {
20
+ // Server port
21
+ port: process.env.PORT || 5942,
22
+
23
+ // Environment
24
+ env: process.env.NODE_ENV || 'development',
25
+
26
+ // Logging level
27
+ logLevel: process.env.LOG_LEVEL || 'info',
28
+
29
+ // Nostr relay configuration (array of relay URLs)
30
+ nostrRelayUrls: (() => {
31
+ // Support NOSTR_RELAY_URLS (plural) as comma-separated string or JSON array
32
+ if (process.env.NOSTR_RELAY_URLS) {
33
+ try {
34
+ // Try parsing as JSON array first
35
+ const parsed = JSON.parse(process.env.NOSTR_RELAY_URLS)
36
+ if (Array.isArray(parsed)) {
37
+ return parsed.filter(url => url && typeof url === 'string')
38
+ }
39
+ } catch (e) {
40
+ // Not JSON, treat as comma-separated string
41
+ return process.env.NOSTR_RELAY_URLS.split(',').map(url => url.trim()).filter(url => url.length > 0)
42
+ }
43
+ }
44
+ // Backward compatibility: support NOSTR_RELAY_URL (singular)
45
+ if (process.env.NOSTR_RELAY_URL) {
46
+ return [process.env.NOSTR_RELAY_URL]
47
+ }
48
+
49
+ // Default
50
+ return ['wss://nostr-relay.psfoundation.info', 'wss://relay.damus.io']
51
+ })(),
52
+
53
+ // Full node RPC configuration
54
+ fullNode: {
55
+ rpcBaseUrl: process.env.RPC_BASEURL || 'http://127.0.0.1:8332',
56
+ rpcUsername: process.env.RPC_USERNAME || '',
57
+ rpcPassword: process.env.RPC_PASSWORD || '',
58
+ rpcTimeoutMs: Number(process.env.RPC_TIMEOUT_MS || 15000),
59
+ rpcRequestIdPrefix: process.env.RPC_REQUEST_ID_PREFIX || 'psf-bch-api'
60
+ },
61
+
62
+ // Version
63
+ version
64
+ }
@@ -0,0 +1,7 @@
1
+ /*
2
+ These are the environment settings for the DEVELOPMENT environment.
3
+ */
4
+
5
+ export default {
6
+ env: 'development'
7
+ }
@@ -0,0 +1,7 @@
1
+ /*
2
+ These are the environment settings for the PRODUCTION environment.
3
+ */
4
+
5
+ export default {
6
+ env: 'production'
7
+ }
@@ -0,0 +1,14 @@
1
+ import common from './env/common.js'
2
+
3
+ import development from './env/development.js'
4
+ import production from './env/production.js'
5
+
6
+ const env = process.env.NODE_ENV || 'development'
7
+ console.log(`Loading config for this environment: ${env}`)
8
+
9
+ let config = development
10
+ if (env === 'production') {
11
+ config = production
12
+ }
13
+
14
+ export default Object.assign({}, common, config)
@@ -0,0 +1,56 @@
1
+ /*
2
+ This is a top-level library that encapsulates all the additional Controllers.
3
+ The concept of Controllers comes from Clean Architecture:
4
+ https://troutsblog.com/blog/clean-architecture
5
+ */
6
+
7
+ // Local libraries
8
+ import Adapters from '../adapters/index.js'
9
+ import UseCases from '../use-cases/index.js'
10
+ import RESTControllers from './rest-api/index.js'
11
+ import TimerController from './timer-controller.js'
12
+ import config from '../config/index.js'
13
+
14
+ class Controllers {
15
+ constructor (localConfig = {}) {
16
+ // Encapsulate dependencies
17
+ this.adapters = new Adapters(localConfig)
18
+ this.useCases = new UseCases({ adapters: this.adapters })
19
+ this.config = config
20
+ this.timerController = new TimerController({ adapters: this.adapters, useCases: this.useCases })
21
+
22
+ // Bind 'this' object to all subfunctions
23
+ this.initAdapters = this.initAdapters.bind(this)
24
+ this.initUseCases = this.initUseCases.bind(this)
25
+ this.attachRESTControllers = this.attachRESTControllers.bind(this)
26
+ }
27
+
28
+ // Spin up any adapter libraries that have async startup needs.
29
+ async initAdapters () {
30
+ await this.adapters.start()
31
+ }
32
+
33
+ // Run any Use Cases to startup the app.
34
+ async initUseCases () {
35
+ await this.useCases.start()
36
+ }
37
+
38
+ // Initialize all the controllers.
39
+ async initControllers () {
40
+ this.timerController.startTimerControllers()
41
+ }
42
+
43
+ // Top-level function for this library.
44
+ // Start the various Controllers and attach them to the app.
45
+ attachRESTControllers (app) {
46
+ const restControllers = new RESTControllers({
47
+ adapters: this.adapters,
48
+ useCases: this.useCases
49
+ })
50
+
51
+ // Attach the REST API Controllers to the Express app.
52
+ restControllers.attachRESTControllers(app)
53
+ }
54
+ }
55
+
56
+ export default Controllers