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.
- package/.env-local +4 -0
- package/LICENSE.md +8 -0
- package/README.md +8 -0
- package/apidoc.json +9 -0
- package/bin/server.js +183 -0
- package/dev-docs/README.md +4 -0
- package/dev-docs/creation-prompt.md +34 -0
- package/dev-docs/rest2nostr-poxy-api.plan.md +163 -0
- package/dev-docs/test-plan-for-rest2nostr.plan.md +161 -0
- package/dev-docs/unit-test-prompt.md +13 -0
- package/examples/01-create-account.js +67 -0
- package/examples/02-read-posts.js +44 -0
- package/examples/03-write-post.js +55 -0
- package/examples/04-read-alice-posts.js +49 -0
- package/examples/05-get-follow-list.js +53 -0
- package/examples/06-update-follow-list.js +63 -0
- package/examples/07-liking-event.js +59 -0
- package/examples/README.md +90 -0
- package/index.js +11 -0
- package/package.json +37 -0
- package/production/docker/Dockerfile +85 -0
- package/production/docker/cleanup-images.sh +5 -0
- package/production/docker/docker-compose.yml +19 -0
- package/production/docker/start-rest2nostr.sh +3 -0
- package/src/adapters/full-node-rpc.js +133 -0
- package/src/adapters/index.js +217 -0
- package/src/adapters/wlogger.js +79 -0
- package/src/config/env/common.js +64 -0
- package/src/config/env/development.js +7 -0
- package/src/config/env/production.js +7 -0
- package/src/config/index.js +14 -0
- package/src/controllers/index.js +56 -0
- package/src/controllers/rest-api/full-node/blockchain/controller.js +553 -0
- package/src/controllers/rest-api/full-node/blockchain/index.js +66 -0
- package/src/controllers/rest-api/index.js +55 -0
- package/src/controllers/timer-controller.js +72 -0
- package/src/entities/event.js +71 -0
- package/src/use-cases/full-node-blockchain-use-cases.js +134 -0
- package/src/use-cases/index.js +29 -0
- package/test/integration/api/event-integration.js +250 -0
- package/test/integration/api/req-integration.js +173 -0
- package/test/integration/api/subscription-integration.js +198 -0
- package/test/integration/use-cases/manage-subscription-integration.js +163 -0
- package/test/integration/use-cases/publish-event-integration.js +104 -0
- package/test/integration/use-cases/query-events-integration.js +95 -0
- package/test/unit/adapters/full-node-rpc-unit.js +122 -0
- package/test/unit/bin/server-unit.js +63 -0
- package/test/unit/controllers/blockchain-controller-unit.js +215 -0
- package/test/unit/controllers/rest-api-index-unit.js +85 -0
- package/test/unit/entities/event-unit.js +139 -0
- package/test/unit/mocks/controller-mocks.js +98 -0
- package/test/unit/mocks/event-mocks.js +194 -0
- 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
|
+
})
|