first commit

This commit is contained in:
Chris Troutner
2025-11-09 16:16:37 -08:00
commit ba2afd47a4
61 changed files with 13847 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
logs/
docs/
coverage/
+8
View File
@@ -0,0 +1,8 @@
The MIT License (MIT)
Copyright (c) 2025 Christopher Troutner
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
# psf-bch-api
This is a REST API for communicating with Bitcoin Cash infrastructure. It replaces [bch-api](https://github.com/Permissionless-Software-Foundation/bch-api), and it implements [x402-bch protocol](https://github.com/x402-bch/x402-bch) to handle payments to access the API.
## License
[MIT](./LICENSE.md)
+9
View File
@@ -0,0 +1,9 @@
{
"name": "REST2NOSTR Proxy API",
"version": "1.0.0",
"description": "REST API proxy for Nostr WebSocket protocol",
"title": "REST2NOSTR Proxy API",
"url": "https://nostr-relay-api.psfoundation.info",
"sampleUrl": "https://nostr-relay-api.psfoundation.info"
}
+183
View File
@@ -0,0 +1,183 @@
/*
Express server for REST2NOSTR Proxy API.
The architecture of the code follows the Clean Architecture pattern.
*/
// npm libraries
import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
// Local libraries
import config from '../src/config/index.js'
import Controllers from '../src/controllers/index.js'
import wlogger from '../src/adapters/wlogger.js'
// Load environment variables
dotenv.config()
// Set up global error handlers to prevent server crashes
// These must be set up before the server starts to catch any unhandled errors
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason)
wlogger.error('Unhandled Rejection:', {
promise: promise.toString(),
reason: reason instanceof Error ? reason.stack : String(reason)
})
// Don't exit the process - log and continue
// The server should remain running to handle other requests
})
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error)
wlogger.error('Uncaught Exception:', {
message: error.message,
stack: error.stack
})
// For uncaught exceptions, we should exit gracefully
// but give time for the process manager to restart
console.log('Exiting after 5 seconds due to uncaught exception. Process manager should restart.')
setTimeout(() => {
process.exit(1)
}, 5000)
})
class Server {
constructor () {
// Encapsulate dependencies
this.controllers = new Controllers()
this.config = config
this.process = process
}
async startServer () {
try {
// Create an Express instance.
const app = express()
// MIDDLEWARE START
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use(cors({
origin: true, // Allow all origins (more reliable than '*')
credentials: false, // Set to true if you need to support credentials
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
}))
// Endpoint logging middleware
app.use((req, res, next) => {
console.log(`Endpoint called: ${req.method} ${req.path}`)
res.on('finish', () => {
console.log(`Endpoint responded: ${req.method} ${req.path} - ${res.statusCode}`)
})
next()
})
// Request logging middleware
app.use((req, res, next) => {
wlogger.info(`${req.method} ${req.path}`)
next()
})
// Error handling middleware
app.use((err, req, res, next) => {
wlogger.error('Express error:', err)
// Handle JSON parsing errors
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
return res.status(400).json({
error: 'Invalid JSON in request body'
})
}
// Default to 500 for other errors
res.status(500).json({
error: err.message || 'Internal server error'
})
})
// Wait for any adapters to initialize.
await this.controllers.initAdapters()
// Wait for any use-libraries to initialize.
await this.controllers.initUseCases()
// Attach REST API controllers to the app.
this.controllers.attachRESTControllers(app)
// Initialize any other controller libraries.
this.controllers.initControllers()
// Serve static assets from docs directory
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
app.use('/assets', express.static(join(__dirname, '..', 'docs', 'assets')))
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
service: 'rest2nostr',
version: config.version
})
})
// Root endpoint
app.get('/', (req, res) => {
const docsPath = join(__dirname, '..', 'docs', 'index.html')
res.sendFile(docsPath)
})
// MIDDLEWARE END
console.log(`Running server in environment: ${this.config.env}`)
wlogger.info(`Running server in environment: ${this.config.env}`)
this.server = app.listen(this.config.port, () => {
console.log(`Server started on port ${this.config.port}`)
wlogger.info(`Server started on port ${this.config.port}`)
})
this.server.on('error', (err) => {
console.error('Server error:', err)
wlogger.error('Server error:', err)
})
this.server.on('close', () => {
console.log('Server closed')
wlogger.info('Server closed')
})
return app
} catch (err) {
console.error('Could not start server. Error: ', err)
wlogger.error('Could not start server. Error: ', err)
console.log(
'Exiting after 5 seconds. Depending on process manager to restart.'
)
await this.sleep(5000)
this.process.exit(1)
}
}
sleep (ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
// Start the server if this file is run directly
const __filename = fileURLToPath(import.meta.url)
if (process.argv[1] === __filename) {
const server = new Server()
server.startServer().catch(err => {
console.error('Failed to start server:', err)
process.exit(1)
})
}
export default Server
+4
View File
@@ -0,0 +1,4 @@
# Developer Documentation
- [prompt.md](./prompt.md) - This is the original prompt [trout](https://github.com/christroutner) gave the AI to build the app. He uses Cursor 2.0 in Planning mode and the Composer 1 LLM.
- [plan](./rest2nostr-poxy-api.plan.md) - This is the plan created by the Cursor AI from the prompt and support files it was given. This is the plan it used to build the app.
+34
View File
@@ -0,0 +1,34 @@
## Build a REST API implementing REST2NOSTR Proxy
Your task is to plan out the building of a REST API server that implements the idea for a REST2NOSTR proxy. The code for this REST API server app should be placed in the `/app` directory. You can edit any files in the `/app` directory. The REST API server should be built using node.js JavaScript and the express.js library. It should also use dotenv to manage the use of environment variables. It should use [api-doc](https://www.npmjs.com/package/api-doc) to generate API documentation for the REST API.
### Nostr
Nostr is a social media protocol. It is defined by nip-01.md in the `nostr/` directory. This is the primary specification that defines Nostr, but there are many other NIPS for other features. If you need to dig deeper into the Nostr standards you can explore the [NIPs on Github](https://github.com/nostr-protocol/nips/tree/master).
The `nostr-sandbox/` directory contains a series of small example code snippets for common social media use-cases using Nostr. You can study this code understand how a Nostr Client would interact with a Nostr Relay.
[This discussion thread on GitHub](https://github.com/nostr-protocol/nips/issues/1549) introduces a concept called REST2NOSTR. It solves an common issue experienced by JavaScript web developers: a high preference for REST APIs over Websockets. The proposal is to create a REST API server that operates as a proxy for the Websockets protocol used by Nostr Relays. This would allow Client developers to interact with a Nostr Relay over a familiar REST API, instead of using Websockets. Here is a summary of the API endpoints the discussion proposes:
| REST Method | Endpoint | Nostr WebSocket Equivalent | Purpose |
| --- | --- | --- | --- |
| `POST` | `/event` | `["EVENT", <event>]` | Publish a signed Nostr event to the relay. |
| `GET` | `/req/` | `["REQ", <sub_id>, <filters>]` | Retrieve a list of events based on filters (stateless query). |
| `POST`/`PUT` | `/req/` | `["REQ", <sub_id>, <filters>]` | Establish a subscription (for long-polling or SSE). |
| `DELETE` | `/req/` | `["CLOSE", <sub_id>]` | Close an existing subscription. |
Your task is to plan out the building of a REST API server that implements the idea for a REST2NOSTR proxy. All code examples in the `nostr-sandbox/` directory that interact with a Relay over Websockets should be able to be implemented using the new REST API. The code examples are a good benchmark to use as a frame of reference as to weather the REST API has been implemented correctly. It would be a good idea to create an `examples/` directory that contain many of these code example, refactored for use with the new REST API.
There is a similar implementation of REST2NOSTR available at [https://nostr-api.com/](https://nostr-api.com/). While the API and documentation are available, the source code for that implementation is not available. However, it is good to study as an example of the kind of output we are looking for.
### Follow the Clean Architecture code pattern
As you plan out the code layout, you should follow the Clean Architecture patterns. Here is background information you can follow to ensure you follow the Clean Architecture pattern:
* [Clean Architecture Summary](https://raw.githubusercontent.com/christroutner/trouts-blog/refs/heads/master/blog/2021-07-06-clean-architecture/index.md) - This is a markdown document that summarizes the Clean Architecture pattern, and links to additional support information.
* [ipfs-service-provider](https://github.com/Permissionless-Software-Foundation/ipfs-service-provider) - This is a node.js JavaScript code base that follows the Clean Architecture patterns. Notice that within the `src` directory, the sub-directories are split up according to the guidance in the Clean Architecture Summary article. This is the primary pattern you should follow.
* A copy of the ipfs-service-provider repository has been copied to the `clean-architecture/` directory, so that you can study the code locally.
### Summary
Your task is to plan out the building of a REST API server that implements the idea for a REST2NOSTR proxy. The code for this REST API server app should be placed in the `/app` directory. You can edit any files in the `/app` directory. The REST API server should be built using node.js JavaScript and the express.js library. It should also use dotenv to manage the use of environment variables. It should use [api-doc](https://www.npmjs.com/package/api-doc) to generate API documentation for the REST API.
+163
View File
@@ -0,0 +1,163 @@
# REST2NOSTR Proxy API Implementation Plan
## Overview
Build a REST API server in `/app` that proxies Nostr WebSocket protocol to REST endpoints, enabling JavaScript developers to interact with Nostr relays via familiar REST APIs instead of WebSockets.
## Architecture
Follow Clean Architecture pattern with these layers:
- **Entities** (`src/entities/`): Domain models (Event, Subscription, etc.)
- **Use Cases** (`src/use-cases/`): Business logic (PublishEvent, QueryEvents, ManageSubscription)
- **Adapters** (`src/adapters/`): External interfaces (NostrRelay WebSocket client, logger)
- **Controllers** (`src/controllers/rest-api/`): Express.js route handlers
## Directory Structure
```
/app
├── src/
│ ├── entities/
│ │ └── event.js
│ ├── use-cases/
│ │ ├── index.js
│ │ ├── publish-event.js
│ │ ├── query-events.js
│ │ └── manage-subscription.js
│ ├── adapters/
│ │ ├── index.js
│ │ ├── nostr-relay.js
│ │ └── wlogger.js
│ ├── controllers/
│ │ ├── index.js
│ │ └── rest-api/
│ │ ├── index.js
│ │ ├── event/
│ │ │ ├── controller.js
│ │ │ └── index.js
│ │ └── req/
│ │ ├── controller.js
│ │ └── index.js
│ └── config/
│ ├── index.js
│ └── env/
│ ├── common.js
│ ├── development.js
│ └── production.js
├── examples/
│ └── [refactored sandbox examples]
├── bin/
│ └── server.js
├── .env.example
├── .env
├── index.js
├── package.json
└── apidoc.json
```
## Core Endpoints
### POST /event
- Maps to: `["EVENT", <event>]`
- Publish a signed Nostr event to relay
- Body: JSON event object
- Response: `{"accepted": true/false, "message": ""}` (maps to `["OK", ...]`)
### GET /req/:subId
- Maps to: `["REQ", <sub_id>, <filters>]`
- Stateless query - returns events immediately
- Query params: filters (JSON encoded or separate params)
- Response: Array of events
### POST /req/:subId
- Maps to: `["REQ", <sub_id>, <filters>]`
- Establish subscription for Server-Sent Events (SSE)
- Body: filters object
- Response: SSE stream of events
### DELETE /req/:subId
- Maps to: `["CLOSE", <sub_id>]`
- Close an existing subscription
- Response: Confirmation
## Implementation Details
### 1. Package Dependencies
- `express`: REST API framework
- `dotenv`: Environment variable management
- `apidoc`: API documentation generation
- `ws` or `nostr-tools`: WebSocket client for Nostr relays
- `winston`: Logging (following example pattern)
### 2. Adapter Layer (`src/adapters/`)
- **nostr-relay.js**: WebSocket client wrapper
- Connect to configured relay(s)
- Send `EVENT`, `REQ`, `CLOSE` messages
- Handle relay responses (`EVENT`, `OK`, `EOSE`, `CLOSED`, `NOTICE`)
- Manage connection pooling for multiple relays
- **wlogger.js**: Winston-based logger
### 3. Use Cases (`src/use-cases/`)
- **publish-event.js**: Validate event, send to relay, return OK response
- **query-events.js**: Stateless query - send REQ, collect events until EOSE, return results
- **manage-subscription.js**: Create/close subscriptions, handle SSE streaming
### 4. Controllers (`src/controllers/rest-api/`)
- **event/controller.js**: Handle POST /event
- **req/controller.js**: Handle GET/POST/DELETE /req/:subId
- Express middleware for validation, error handling
- SSE support for subscription endpoint
### 5. Configuration (`src/config/`)
- Environment-based config (development, production, test)
- Default relay URL(s) from environment variables
- Port, logging level, etc.
### 6. Subscription Management
- Store active subscriptions in memory (Map with subId as key)
- Map subscription IDs to WebSocket connections
- Handle cleanup on DELETE /req/:subId
- Support SSE streaming for POST /req/:subId
### 7. Examples (`examples/`)
Refactor sandbox examples to use REST API:
- `01-create-account/` - Use REST API to publish kind 0 event
- `02-read-posts/` - Use GET /req/:subId for stateless query
- `03-write-post/` - Use POST /event
- `04-read-alice-posts/` - Use GET /req/:subId with author filter
- `14-get-follow-list/` - Use GET /req/:subId with kind 3 filter
- Additional examples as needed
### 8. API Documentation
- Use api-doc annotations in controller files
- Generate docs with `npm run docs`
- Follow pattern from clean-architecture example
## Environment Variables
- `NOSTR_RELAY_URL`: Default relay WebSocket URL (e.g., `wss://nostr-relay.psfoundation.info`)
- `PORT`: Server port (default: 3000)
- `NODE_ENV`: Environment (development, production, test)
- `LOG_LEVEL`: Logging level (info, debug, error)
## Key Implementation Notes
- WebSocket connections: Maintain persistent connections to relay(s) in adapter
- Error handling: Map Nostr relay errors to appropriate HTTP status codes
- Validation: Validate Nostr events before forwarding (event structure, signature)
- SSE: Use Express response.write() for Server-Sent Events in subscription endpoint
- Stateless queries: For GET /req/:subId, collect events until EOSE, then close subscription automatically
+161
View File
@@ -0,0 +1,161 @@
# Test Plan for REST2NOSTR Application
## Overview
Create unit and integration tests for the REST2NOSTR Express.js application following patterns from `tests/testing-example-code/`. Tests will use mocha, chai, and sinon, and cover all code paths exercised by the examples in `/app/examples/`.
## Test Structure
Create test directory structure:
- `app/test/unit/` - Unit tests with mocked dependencies
- `app/test/integration/` - Integration tests with real dependencies
- `app/test/unit/mocks/` - Mock data for unit tests
## Unit Tests
### 1. Entity Tests (`test/unit/entities/`)
- **event-unit.js**: Test Event entity validation and serialization
- Test `isValid()` with valid events
- Test `isValid()` with invalid events (missing fields, wrong types, wrong lengths)
- Test `toJSON()` serialization
- Use mock event data from `mocks/event-mocks.js`
### 2. Use Case Tests (`test/unit/use-cases/`)
- **publish-event-unit.js**: Test PublishEventUseCase
- Mock NostrRelayAdapter.sendEvent()
- Test successful event publishing
- Test invalid event rejection
- Test adapter error handling
- Use mocks from `mocks/nostr-relay-mocks.js`
- **query-events-unit.js**: Test QueryEventsUseCase
- Mock NostrRelayAdapter.sendReq() and sendClose()
- Test successful query with events returned
- Test query timeout handling
- Test CLOSED message handling
- Test EOSE handling
- **manage-subscription-unit.js**: Test ManageSubscriptionUseCase
- Mock NostrRelayAdapter.sendReq() and sendClose()
- Test subscription creation
- Test subscription closure
- Test duplicate subscription prevention
- Test handler callbacks (onEvent, onEose, onClosed)
### 3. Controller Tests (`test/unit/controllers/`)
- **event-controller-unit.js**: Test EventRESTControllerLib
- Mock UseCases.publishEvent.execute()
- Test POST /event with valid event data
- Test POST /event with missing event data
- Test error handling
- Use Express request/response mocks
- **req-controller-unit.js**: Test ReqRESTControllerLib
- Mock UseCases.queryEvents.execute() and manageSubscription methods
- Test GET /req/:subId with filters (JSON string and parsed)
- Test GET /req/:subId with individual query params
- Test POST /req/:subId for SSE subscription
- Test DELETE /req/:subId for closing subscription
- Test error handling for invalid filters
- Test missing subscription ID validation
### 4. Adapter Tests (`test/unit/adapters/`)
- **nostr-relay-unit.js**: Test NostrRelayAdapter
- Mock WebSocket connections
- Test connection establishment
- Test sendEvent() and OK response handling
- Test sendReq() and EVENT/EOSE/CLOSED message handling
- Test sendClose()
- Test message queuing when disconnected
- Test reconnection logic
- Test error handling
### 5. Server Tests (`test/unit/bin/`)
- **server-unit.js**: Test Server class
- Mock Express app, Controllers, and adapters
- Test server initialization
- Test middleware attachment
- Test route attachment
- Test error handling
- Test health check endpoint
## Integration Tests
### 1. API Endpoint Tests (`test/integration/api/`)
- **event-integration.js**: Test POST /event endpoint
- Create a test server instance
- Test publishing kind 0 event (profile metadata) - covers example 01
- Test publishing kind 1 event (text post) - covers example 03
- Test publishing kind 3 event (follow list) - covers example 06
- Test publishing kind 7 event (reaction/like) - covers example 07
- Test invalid event rejection
- Use real Nostr relay connection (may need test relay or mock relay)
- **req-integration.js**: Test GET /req/:subId endpoint
- Create a test server instance
- Test querying kind 1 events (posts) - covers examples 02, 04
- Test querying kind 3 events (follow list) - covers example 05
- Test with various filter combinations
- Test with filters as JSON string query param
- Test with individual query params
- Test error handling
- **subscription-integration.js**: Test POST /req/:subId SSE subscription
- Create a test server instance
- Test SSE subscription creation
- Test event streaming
- Test EOSE handling
- Test subscription closure
- Test DELETE /req/:subId endpoint
### 2. Use Case Integration Tests (`test/integration/use-cases/`)
- **publish-event-integration.js**: Test PublishEventUseCase with real adapter
- **query-events-integration.js**: Test QueryEventsUseCase with real adapter
- **manage-subscription-integration.js**: Test ManageSubscriptionUseCase with real adapter
## Mock Data Files
Create mock data files in `test/unit/mocks/`:
- **event-mocks.js**: Mock event data for various event kinds (0, 1, 3, 7)
- **nostr-relay-mocks.js**: Mock responses from Nostr relay (OK, EVENT, EOSE, CLOSED)
- **controller-mocks.js**: Mock Express request/response objects
## Key Considerations
1. **ES Modules**: The app uses ES modules (import/export) while the example uses CommonJS. Tests should use ES modules with `.js` extension and proper import syntax.
2. **Test Environment**: Integration tests will need a running Nostr relay. Consider:
- Using a test relay URL from config
- Or creating a mock WebSocket server for integration tests
- Or documenting that tests require a relay URL in environment
3. **Test Coverage**: Ensure tests cover:
- All endpoints used by examples (POST /event, GET /req/:subId)
- All event kinds (0, 1, 3, 7)
- Error paths and edge cases
- Validation logic
4. **Test Data**: Use the same test keys and data patterns from examples where applicable (e.g., Alice's private key, Bob's public key)
5. **Async Handling**: Properly handle async/await in tests, especially for WebSocket operations and SSE streams
6. **Cleanup**: Ensure proper cleanup of WebSocket connections and subscriptions in tests
## Test Execution
Tests will run using existing npm scripts:
- `npm test` - Runs unit tests with linting and coverage
- `npm run test:integration` - Runs integration tests with extended timeout
- `npm run coverage` - Generates coverage report
+13
View File
@@ -0,0 +1,13 @@
The express.js app in the `app/` directory needs unit and integration tests. The scope of this task is to create a plan for adding these
When considering the creation of tests, follow the patters provided in the example available in the `testing/testing-example-code/` directory. This is simple node.js application which uses the following testing libraries:
- mocha as a test runner
- chai as an assertion library
- sinon as a stubing library
The code example show common patterns for writing both unit and integration tests.
The `/app/examples/` directory contains working code that I've tested and verified works correctly. The unit and integration tests you create should cover the same code paths exercised by these examples.
The testing dependencies are already installed and listed in the package.json file under the `app/` directory. The package.json file also contains the `test`, `test:integration`, and `coverage` scripts that we'll use to run tests and measure test coverage.
+67
View File
@@ -0,0 +1,67 @@
/*
Example script creating a key-pair for a Nostr account and publishing profile metadata.
Refactored to use REST API instead of WebSocket.
Run the server with `npm start` in the main directory, before running this example.
*/
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'
import * as nip19 from 'nostr-tools/nip19'
import { bytesToHex } from '@noble/hashes/utils.js'
const API_URL = process.env.API_URL || 'http://localhost:5942'
// Generate keys
const sk = generateSecretKey() // `sk` is a Uint8Array
const nsec = nip19.nsecEncode(sk)
const skHex = bytesToHex(sk)
const pk = getPublicKey(sk) // `pk` is a hex string
const npub = nip19.npubEncode(pk)
console.log('private key:', skHex)
console.log('encoded private key:', nsec)
console.log()
console.log('public key:', pk)
console.log('encoded public key:', npub)
console.log()
// Create profile metadata event (kind 0)
const profileMetadata = {
name: 'Alice',
about: 'Hello, I am Alice!',
picture: 'https://example.com/alice.jpg'
}
const eventTemplate = {
kind: 0,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: JSON.stringify(profileMetadata)
}
// Sign the event
const signedEvent = finalizeEvent(eventTemplate, sk)
console.log('Signed event:', JSON.stringify(signedEvent, null, 2))
// Publish to REST API
try {
const response = await fetch(`${API_URL}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
})
const result = await response.json()
console.log('Publish result:', result)
if (result.accepted) {
console.log('Profile metadata published successfully!')
} else {
console.error('Failed to publish:', result.message)
}
} catch (err) {
console.error('Error publishing event:', err)
}
+44
View File
@@ -0,0 +1,44 @@
/*
Example script for reading posts (kind 1 events) from a relay.
Refactored to use REST API instead of WebSocket.
Run the server with `npm start` in the main directory, before running this example.
*/
const API_URL = process.env.API_URL || 'http://localhost:5942'
// JB55's public key
const jb55 = '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
// Create subscription ID
const subId = 'read-posts-' + Date.now()
// Create filters - read posts from JB55
const filters = {
limit: 2,
kinds: [1],
authors: [jb55]
}
try {
// Query events using GET /req/:subId
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
const url = `${API_URL}/req/${subId}?filters=${filtersJson}`
console.log(`Querying events from ${API_URL}`)
console.log('Filters:', JSON.stringify(filters, null, 2))
const response = await fetch(url)
const events = await response.json()
console.log(`\nReceived ${events.length} events:`)
events.forEach((ev, index) => {
console.log(`\nEvent ${index + 1}:`)
console.log(' ID:', ev.id)
console.log(' Author:', ev.pubkey)
console.log(' Created:', new Date(ev.created_at * 1000).toISOString())
console.log(' Content:', ev.content)
})
} catch (err) {
console.error('Error reading posts:', err)
}
+55
View File
@@ -0,0 +1,55 @@
/*
Example script for writing a post to a relay.
Refactored to use REST API instead of WebSocket.
Run the server with `npm start` in the main directory, before running this example.
*/
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils.js'
const API_URL = process.env.API_URL || 'http://localhost:5942'
// Alice is our user making the post.
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
const alicePubKey = getPublicKey(alicePrivKeyBin)
console.log(`Alice Public Key: ${alicePubKey}`)
const now = new Date()
// Generate a post.
const eventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: `This is a test message posted at ${now.toLocaleString()}`
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin)
console.log('signedEvent:', JSON.stringify(signedEvent, null, 2))
// Publish to REST API
try {
const response = await fetch(`${API_URL}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
})
const result = await response.json()
console.log('result:', result)
if (result.accepted) {
console.log('Post published successfully!')
console.log('Event ID:', result.eventId)
} else {
console.error('Failed to publish:', result.message)
}
} catch (err) {
console.error('Error publishing post:', err)
}
+49
View File
@@ -0,0 +1,49 @@
/*
Example script for reading posts from user Alice.
Refactored to use REST API instead of WebSocket.
Run the server with `npm start` in the main directory, before running this example.
*/
import { getPublicKey } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils.js'
const API_URL = process.env.API_URL || 'http://localhost:5942'
// Alice is our user.
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
const alicePubKey = getPublicKey(alicePrivKeyBin)
console.log(`Alice Public Key: ${alicePubKey}`)
// Create subscription ID
const subId = 'read-alice-posts-' + Date.now()
// Create filters - read posts from Alice
const filters = {
limit: 2,
kinds: [1],
authors: [alicePubKey]
}
try {
// Query events using GET /req/:subId
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
const url = `${API_URL}/req/${subId}?filters=${filtersJson}`
console.log(`Querying events from ${API_URL}`)
console.log('Filters:', JSON.stringify(filters, null, 2))
const response = await fetch(url)
const events = await response.json()
console.log(`\nReceived ${events.length} events from Alice:`)
events.forEach((ev, index) => {
console.log(`\nEvent ${index + 1}:`)
console.log(' ID:', ev.id)
console.log(' Created:', new Date(ev.created_at * 1000).toISOString())
console.log(' Content:', ev.content)
})
} catch (err) {
console.error('Error reading Alice posts:', err)
}
+53
View File
@@ -0,0 +1,53 @@
/*
Example script for getting a follow list (kind 3 events).
Refactored to use REST API instead of WebSocket.
Run the server with `npm start` in the main directory, before running this example.
*/
import { getPublicKey } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils.js'
const API_URL = process.env.API_URL || 'http://localhost:5942'
// Alice is our user to get the follow list.
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
const alicePubKey = getPublicKey(alicePrivKeyBin)
console.log(`Alice Public Key: ${alicePubKey}`)
// Create subscription ID
const subId = 'get-follow-list-' + Date.now()
// Create filters - get follow list (kind 3) from Alice
const filters = {
limit: 5,
kinds: [3],
authors: [alicePubKey]
}
try {
// Query events using GET /req/:subId
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
const url = `${API_URL}/req/${subId}?filters=${filtersJson}`
console.log(`Querying follow list from ${API_URL}`)
console.log('Filters:', JSON.stringify(filters, null, 2))
const response = await fetch(url)
const events = await response.json()
if (events.length > 0) {
// Get the most recent follow list (kind 3 events are replaceable)
const followListEvent = events[0]
const aliceFollowList = followListEvent.tags.filter(tag => tag[0] === 'p')
console.log(`\nAlice Follow list (${aliceFollowList.length} followed users):`)
aliceFollowList.forEach((tag, index) => {
console.log(` ${index + 1}. ${tag[1]}${tag[3] ? ` (${tag[3]})` : ''}`)
})
} else {
console.log('\nNo follow list found for Alice')
}
} catch (err) {
console.error('Error getting follow list:', err)
}
+63
View File
@@ -0,0 +1,63 @@
/*
Example to update the follow list with a new list of people to follow.
Refactored to use REST API instead of WebSocket.
Run the server with `npm start` in the main directory, before running this example.
*/
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils.js'
const API_URL = process.env.API_URL || 'http://localhost:5942'
// Alice wants to update her follow list
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
const alicePubKey = getPublicKey(alicePrivKeyBin)
console.log(`Alice Public Key: ${alicePubKey}`)
// Bob is the person to be added to the new follow list
const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f'
const bobPrivKeyBin = hexToBytes(bobPrivKeyHex)
const bobPubKey = getPublicKey(bobPrivKeyBin)
console.log(`Bob Public Key: ${bobPubKey}`)
const psf = 'wss://nostr-relay.psfoundation.info'
const followList = [
['p', bobPubKey, psf, 'bob']
]
// Generate a follow list event (kind 3)
const eventTemplate = {
kind: 3,
created_at: Math.floor(Date.now() / 1000),
tags: followList,
content: ''
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the event
const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin)
// Publish to REST API
try {
const response = await fetch(`${API_URL}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
})
const result = await response.json()
console.log('Publish result:', result)
if (result.accepted) {
console.log('Follow list updated successfully!')
} else {
console.error('Failed to update follow list:', result.message)
}
} catch (err) {
console.error('Error updating follow list:', err)
}
+59
View File
@@ -0,0 +1,59 @@
/*
Example script for adding a reaction (like) to an event.
Refactored to use REST API instead of WebSocket.
https://github.com/nostr-protocol/nips/blob/master/25.md
Run the server with `npm start` in the main directory, before running this example.
*/
import { hexToBytes } from '@noble/hashes/utils.js'
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure'
const API_URL = process.env.API_URL || 'http://localhost:5942'
const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f'
const bobPrivKeyBin = hexToBytes(bobPrivKeyHex)
const bobPubKey = getPublicKey(bobPrivKeyBin)
const psf = 'wss://nostr-relay.psfoundation.info'
const evIdToLike = 'd09b4c5da59be3cd2768aa53fa78b77bf4859084c94f3bf26d401f004a9c8167'
const evIdAuthorPubKey = '2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e92'
// Generate like event (kind 7)
const likeEventTemplate = {
kind: 7,
created_at: Math.floor(Date.now() / 1000),
pubkey: bobPubKey,
tags: [
['e', evIdToLike, psf], // "e" tag includes event id, relay reference
['p', evIdAuthorPubKey, psf] // "p" tag includes author pubkey, relay reference
],
content: '+'
}
// Sign the event
const signedEvent = finalizeEvent(likeEventTemplate, bobPrivKeyBin)
console.log('signedEvent:', JSON.stringify(signedEvent, null, 2))
// Publish to REST API
try {
const response = await fetch(`${API_URL}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
})
const result = await response.json()
console.log('result:', result)
if (result.accepted) {
console.log('Like published successfully!')
} else {
console.error('Failed to publish like:', result.message)
}
} catch (err) {
console.error('Error publishing like:', err)
}
+90
View File
@@ -0,0 +1,90 @@
# REST2NOSTR Examples
This directory contains examples refactored from the `nostr-sandbox/` directory to use the REST API instead of WebSocket connections.
## Prerequisites
1. Install dependencies:
```bash
npm install nostr-tools @noble/hashes
```
2. Start the REST2NOSTR proxy server:
```bash
npm start
```
3. Set the API_URL environment variable if the server is not running on localhost:3000:
```bash
export API_URL=http://localhost:3000
```
## Examples
### 01-create-account.js
Creates a new Nostr account keypair and publishes profile metadata (kind 0 event).
```bash
node examples/01-create-account.js
```
### 02-read-posts.js
Reads posts (kind 1 events) from a specific author using GET /req/:subId.
```bash
node examples/02-read-posts.js
```
### 03-write-post.js
Publishes a text post (kind 1 event) using POST /event.
```bash
node examples/03-write-post.js
```
### 04-read-alice-posts.js
Reads posts from Alice's account using GET /req/:subId with author filter.
```bash
node examples/04-read-alice-posts.js
```
### 14-get-follow-list.js
Retrieves a user's follow list (kind 3 event) using GET /req/:subId.
```bash
node examples/05-get-follow-list.js
```
### 15-update-follow-list.js
Updates a user's follow list (kind 3 event) using POST /event.
```bash
node examples/06-update-follow-list.js
```
### 17-liking-event.js
Adds a reaction/like to an event (kind 7 event) using POST /event.
```bash
node examples/07-liking-event.js
```
## API Endpoints Used
- **POST /event**: Publish events to the relay
- **GET /req/:subId**: Stateless query for events (returns immediately)
## Differences from WebSocket Examples
1. **No WebSocket connections**: All communication is via HTTP REST API
2. **Stateless queries**: GET /req/:subId returns events immediately rather than streaming
3. **Event publishing**: POST /event returns immediately with acceptance status
4. **No subscription management**: For stateless queries, subscriptions are automatically closed after EOSE
## Notes
- These examples use the same private keys as the original sandbox examples for consistency
- The REST API handles WebSocket connections to relays internally
- For real-time streaming, use POST /req/:subId which supports Server-Sent Events (SSE)
+11
View File
@@ -0,0 +1,11 @@
/*
Main entry point for REST2NOSTR Proxy API
*/
import Server from './bin/server.js'
const server = new Server()
server.startServer().catch(err => {
console.error('Failed to start server:', err)
process.exit(1)
})
+7990
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "psf-bch-api",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node bin/server.js",
"docs": "./node_modules/.bin/apidoc -i src/ -o docs",
"lint": "standard --env mocha --fix",
"test": "npm run lint && TEST=unit c8 mocha 'test/unit/**/*.js' --exit",
"test:integration": "mocha --timeout 25000 'test/integration/**/*.js' --exit",
"coverage": "c8 --reporter=html mocha 'test/unit/**/*.js' --exit"
},
"author": "Chris Troutner <chris.troutner@gmail.com>",
"license": "MIT",
"description": "REST API proxy to Bitcoin Cash infrastructure",
"dependencies": {
"cors": "2.8.5",
"dotenv": "16.3.1",
"express": "5.1.0",
"winston": "3.11.0",
"winston-daily-rotate-file": "4.7.1"
},
"devDependencies": {
"apidoc": "1.2.0",
"c8": "10.1.3",
"chai": "6.2.0",
"mocha": "11.7.4",
"sinon": "21.0.0",
"standard": "17.1.2"
},
"apidoc": {
"title": "psf-bch-api",
"url": "http://localhost:3070"
}
}
+85
View File
@@ -0,0 +1,85 @@
# Create a Dockerized API server
#
#IMAGE BUILD COMMANDS
# ct-base-ubuntu = ubuntu 18.04 + nodejs v10 LTS
#FROM christroutner/ct-base-ubuntu
FROM ubuntu:22.04
MAINTAINER Chris Troutner <chris.troutner@gmail.com>
#Update the OS and install any OS packages needed.
RUN apt-get update
RUN apt-get install -y sudo git curl nano gnupg wget zip unzip python3
#Install Node and NPM
RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh
RUN bash nodesource_setup.sh
RUN apt-get install -y nodejs build-essential
#Create the user 'safeuser' and add them to the sudo group.
RUN useradd -ms /bin/bash safeuser
RUN adduser safeuser sudo
#Set password to 'password' change value below if you want a different password
RUN echo safeuser:password | chpasswd
#Set the working directory to be the home directory
WORKDIR /home/safeuser
#Setup NPM for non-root global install
RUN mkdir /home/safeuser/.npm-global
RUN chown -R safeuser .npm-global
RUN echo "export PATH=~/.npm-global/bin:$PATH" >> /home/safeuser/.profile
RUN runuser -l safeuser -c "npm config set prefix '~/.npm-global'"
# Update to the latest version of npm.
#RUN npm install -g npm@8.3.0
# npm mirror to prevent direct dependency on npm.
#RUN npm set registry http://94.130.170.209:4873/
# Switch to user account.
#USER safeuser
# Prep 'sudo' commands.
#RUN echo 'abcd8765' | sudo -S pwd
#RUN npm install -g node-gyp
# Clone the rest.bitcoin.com repository
WORKDIR /home/safeuser
RUN git clone https://github.com/christroutner/REST2NOSTR
# Switch to the desired branch. `master` is usually stable,
# and `stage` has the most up-to-date changes.
WORKDIR /home/safeuser/REST2NOSTR
# For development: switch to unstable branch
#RUN git checkout pin-ipfs
# Install dependencies
RUN npm install
# Generate the API docs
RUN npm run docs
#VOLUME /home/safeuser/keys
# Make leveldb folders
#RUN mkdir leveldb
#WORKDIR /home/safeuser/psf-slp-indexer/leveldb
#RUN mkdir current
#RUN mkdir zips
#RUN mkdir backup
#WORKDIR /home/safeuser/psf-slp-indexer/leveldb/zips
#COPY restore-auto.sh restore-auto.sh
#WORKDIR /home/safeuser/psf-slp-indexer
# Expose the port the API will be served on.
#EXPOSE 5011
# Start the application.
#COPY start-production.sh start-production.sh
VOLUME start-rest2nostr.sh
CMD ["./start-rest2nostr.sh"]
#CMD ["npm", "start"]
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Remove all untagged docker images.
docker rmi $(docker images | grep "^<none>" | awk '{print $3}')
+19
View File
@@ -0,0 +1,19 @@
# Start the service with the command 'docker-compose up -d'
services:
rest2nostr:
build: .
container_name: rest2nostr
logging:
driver: 'json-file'
options:
max-size: '10m'
max-file: '10'
#mem_limit: 500mb
#links:
# - mongo-slp-indexer
ports:
- '5942:5942' # <host port>:<container port>
volumes:
- ./start-rest2nostr.sh:/home/safeuser/REST2NOSTR/start-rest2nostr.sh
restart: always
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
npm start
+214
View File
@@ -0,0 +1,214 @@
/*
This is a top-level library that encapsulates all the additional Adapters.
The concept of Adapters comes from Clean Architecture:
https://troutsblog.com/blog/clean-architecture
*/
// Load individual adapter libraries.
// import NostrRelayAdapter from './nostr-relay.js'
import config from '../config/index.js'
class Adapters {
constructor (localConfig = {}) {
// Encapsulate dependencies
this.config = config
// Determine relay URLs: prefer localConfig, fall back to config
// let relayUrls = []
// if (localConfig.relayUrls && Array.isArray(localConfig.relayUrls)) {
// relayUrls = localConfig.relayUrls
// } else if (localConfig.relayUrl) {
// // Backward compatibility: single relay URL
// relayUrls = [localConfig.relayUrl]
// } else {
// relayUrls = config.nostrRelayUrls
// }
// Create one adapter per relay URL
// this.nostrRelays = relayUrls.map(relayUrl => new NostrRelayAdapter({ relayUrl }))
// Maintain backward compatibility: expose first relay as nostrRelay
// This allows existing code to work during transition
// this.nostrRelay = this.nostrRelays[0]
}
async start () {
// try {
// Connect to all Nostr relays concurrently
// const connectPromises = this.nostrRelays.map(async (relay, index) => {
// try {
// await relay.connect()
// console.log(`Nostr relay adapter ${index + 1}/${this.nostrRelays.length} started: ${relay.relayUrl}`)
// return { success: true, relay }
// } catch (err) {
// console.error(`Failed to connect to relay ${relay.relayUrl}:`, err.message)
// return { success: false, relay, error: err }
// }
// })
// const results = await Promise.allSettled(connectPromises)
// const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length
// const failed = results.length - successful
// if (successful === 0) {
// throw new Error('Failed to connect to any Nostr relay')
// }
// if (failed > 0) {
// console.warn(`Connected to ${successful}/${this.nostrRelays.length} relays. Some relays failed to connect.`)
// } else {
// console.log(`All ${successful} Nostr relay adapters started successfully.`)
// }
return true
// } catch (err) {
// console.error('Error in adapters/index.js/start()')
// throw err
// }
}
/**
* Get all relay adapters
* @returns {Array<NostrRelayAdapter>}
*/
getRelays () {
return this.nostrRelays
}
/**
* Broadcast an event to all relays
* @param {Object} event - Event object to broadcast
* @returns {Promise<Array>} Array of results from each relay: { accepted, message, relayUrl }
*/
async broadcastEvent (event) {
const broadcastPromises = this.nostrRelays.map(async (relay) => {
try {
const result = await relay.sendEvent(event)
return {
accepted: result.accepted,
message: result.message || '',
relayUrl: relay.relayUrl,
success: true
}
} catch (err) {
return {
accepted: false,
message: err.message || 'Error broadcasting to relay',
relayUrl: relay.relayUrl,
success: false,
error: err
}
}
})
return Promise.allSettled(broadcastPromises).then(results => {
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value
} else {
return {
accepted: false,
message: result.reason?.message || 'Unknown error',
relayUrl: this.nostrRelays[index].relayUrl,
success: false,
error: result.reason
}
}
})
})
}
/**
* Query all relays concurrently and merge results
* @param {Array} filters - Array of filter objects
* @param {string} subscriptionId - Unique subscription ID (will be modified per relay)
* @returns {Promise<Array>} Merged and de-duplicated array of events
*/
async queryAllRelays (filters, subscriptionId) {
// Create unique subscription IDs for each relay
const subscriptionIds = this.nostrRelays.map((relay, index) =>
`${subscriptionId}-relay-${index}`
)
// Collect events from all relays
const allEvents = []
const relayStatuses = this.nostrRelays.map(() => ({
eoseReceived: false,
closedReceived: false,
closedMessage: ''
}))
// Query all relays concurrently
const queryPromises = this.nostrRelays.map(async (relay, index) => {
const subscriptionIdForRelay = subscriptionIds[index]
const status = relayStatuses[index]
// Create handlers for this relay
const handlers = {
onEvent: (event) => {
allEvents.push(event)
},
onEose: () => {
status.eoseReceived = true
},
onClosed: (message) => {
status.closedReceived = true
status.closedMessage = message
}
}
try {
await relay.sendReq(subscriptionIdForRelay, filters, handlers)
// Wait for EOSE or CLOSED with timeout
const timeout = 30000 // 30 seconds
const startTime = Date.now()
while (!status.eoseReceived && !status.closedReceived && (Date.now() - startTime) < timeout) {
await new Promise(resolve => setTimeout(resolve, 100))
}
// Clean up subscription - always try to close, even if EOSE didn't come
try {
await relay.sendClose(subscriptionIdForRelay)
} catch (err) {
// Ignore close errors
}
if (status.closedReceived) {
throw new Error(`Subscription closed on ${relay.relayUrl}: ${status.closedMessage}`)
}
// If EOSE never came but timeout expired, that's OK - we proceed with what we got
if (!status.eoseReceived && (Date.now() - startTime) >= timeout) {
// Timeout reached without EOSE - proceed anyway with events collected so far
}
} catch (err) {
// Ensure cleanup even on error
try {
await relay.sendClose(subscriptionIdForRelay)
} catch (closeErr) {
// Ignore close errors
}
// Log error but don't fail the entire query
console.warn(`Query failed for relay ${relay.relayUrl}:`, err.message)
}
})
// Wait for all queries to complete
await Promise.allSettled(queryPromises)
// Merge and de-duplicate events by event ID
const eventMap = new Map()
allEvents.forEach(event => {
if (event && event.id && !eventMap.has(event.id)) {
eventMap.set(event.id, event)
}
})
return Array.from(eventMap.values())
}
}
export default Adapters
+241
View File
@@ -0,0 +1,241 @@
/*
Nostr Relay WebSocket adapter.
Handles WebSocket connections to Nostr relays and manages message sending/receiving.
*/
import WebSocket from 'ws'
import config from '../config/index.js'
import wlogger from './wlogger.js'
class NostrRelayAdapter {
constructor (localConfig = {}) {
this.config = config
this.relayUrl = localConfig.relayUrl || config.nostrRelayUrl
this.ws = null
this.isConnected = false
this.reconnectAttempts = 0
this.maxReconnectAttempts = 5
this.reconnectDelay = 5000 // 5 seconds
this.messageHandlers = new Map() // Map subscription_id to handlers
this.pendingMessages = [] // Queue messages while disconnected
this.eventResolvers = new Map() // Map event_id to promise resolvers for OK responses
this.subscriptionHandlers = new Map() // Map subscription_id to event handlers
// Bind methods
this.connect = this.connect.bind(this)
this.disconnect = this.disconnect.bind(this)
this.sendEvent = this.sendEvent.bind(this)
this.sendReq = this.sendReq.bind(this)
this.sendClose = this.sendClose.bind(this)
this.handleMessage = this.handleMessage.bind(this)
this.handleError = this.handleError.bind(this)
this.handleClose = this.handleClose.bind(this)
}
async connect () {
if (this.ws && this.isConnected) {
return true
}
return new Promise((resolve, reject) => {
try {
wlogger.info(`Connecting to Nostr relay: ${this.relayUrl}`)
this.ws = new WebSocket(this.relayUrl)
this.ws.on('open', () => {
wlogger.info('Connected to Nostr relay')
this.isConnected = true
this.reconnectAttempts = 0
// Send any pending messages
while (this.pendingMessages.length > 0) {
const message = this.pendingMessages.shift()
this.ws.send(JSON.stringify(message))
}
resolve(true)
})
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString())
this.handleMessage(message)
} catch (err) {
wlogger.error('Error parsing relay message:', err)
}
})
this.ws.on('error', this.handleError)
this.ws.on('close', this.handleClose)
// Timeout after 10 seconds
setTimeout(() => {
if (!this.isConnected) {
reject(new Error('Connection timeout'))
}
}, 10000)
} catch (err) {
wlogger.error('Error connecting to relay:', err)
reject(err)
}
})
}
async disconnect () {
if (this.ws) {
this.ws.close()
this.ws = null
this.isConnected = false
wlogger.info('Disconnected from Nostr relay')
}
}
handleMessage (message) {
if (!Array.isArray(message) || message.length === 0) {
return
}
const [type, ...args] = message
switch (type) {
case 'EVENT':
// ["EVENT", <subscription_id>, <event>]
if (args.length >= 2) {
const subscriptionId = args[0]
const event = args[1]
const handler = this.subscriptionHandlers.get(subscriptionId)
if (handler) {
handler.onEvent(event)
}
}
break
case 'OK':
// ["OK", <event_id>, <true|false>, <message>]
if (args.length >= 2) {
const eventId = args[0]
const accepted = args[1]
const message = args[2] || ''
const resolver = this.eventResolvers.get(eventId)
if (resolver) {
resolver({ accepted, message })
this.eventResolvers.delete(eventId)
}
}
break
case 'EOSE':
// ["EOSE", <subscription_id>]
if (args.length >= 1) {
const subscriptionId = args[0]
const handler = this.subscriptionHandlers.get(subscriptionId)
if (handler) {
handler.onEose()
}
}
break
case 'CLOSED':
// ["CLOSED", <subscription_id>, <message>]
if (args.length >= 1) {
const subscriptionId = args[0]
const message = args[1] || ''
const handler = this.subscriptionHandlers.get(subscriptionId)
if (handler) {
handler.onClosed(message)
}
}
break
case 'NOTICE':
// ["NOTICE", <message>]
if (args.length >= 1) {
const message = args[0]
wlogger.warn('Relay notice:', message)
}
break
default:
wlogger.warn('Unknown message type from relay:', type)
}
}
handleError (error) {
wlogger.error('WebSocket error:', error)
this.isConnected = false
}
handleClose () {
const now = new Date()
wlogger.warn(`WebSocket connection closed at ${now.toLocaleString()}`)
this.isConnected = false
// Attempt to reconnect
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++
wlogger.info(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`)
setTimeout(() => {
this.connect().catch(err => {
wlogger.error('Reconnection failed:', err)
})
}, this.reconnectDelay)
}
}
async sendMessage (message) {
if (!this.isConnected || !this.ws) {
// Queue message for when connection is established
this.pendingMessages.push(message)
await this.connect()
return
}
try {
this.ws.send(JSON.stringify(message))
} catch (err) {
wlogger.error('Error sending message:', err)
throw err
}
}
async sendEvent (event) {
// ["EVENT", <event>]
const message = ['EVENT', event]
await this.sendMessage(message)
// Return a promise that resolves when we get the OK response
return new Promise((resolve, reject) => {
this.eventResolvers.set(event.id, resolve)
// Timeout after 30 seconds
setTimeout(() => {
if (this.eventResolvers.has(event.id)) {
this.eventResolvers.delete(event.id)
reject(new Error('Timeout waiting for OK response'))
}
}, 30000)
})
}
async sendReq (subscriptionId, filters, handlers) {
// ["REQ", <subscription_id>, <filters>]
await this.connect()
// Store handlers for this subscription
this.subscriptionHandlers.set(subscriptionId, handlers)
const message = ['REQ', subscriptionId, ...filters]
await this.sendMessage(message)
}
async sendClose (subscriptionId) {
// ["CLOSE", <subscription_id>]
const message = ['CLOSE', subscriptionId]
await this.sendMessage(message)
// Clean up handlers
this.subscriptionHandlers.delete(subscriptionId)
this.messageHandlers.delete(subscriptionId)
}
}
export default NostrRelayAdapter
+79
View File
@@ -0,0 +1,79 @@
/*
Instantiates and configures the Winston logging library. This utility library
can be called by other parts of the application to conveniently tap into the
logging library.
*/
// Global npm libraries
import winston from 'winston'
import 'winston-daily-rotate-file'
// Local libraries
import config from '../config/index.js'
// Hack to get __dirname back.
// https://blog.logrocket.com/alternatives-dirname-node-js-es-modules/
import * as url from 'url'
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
let _this = null
class Wlogger {
constructor (localConfig = {}) {
this.config = config
// Configure daily-rotation transport.
this.transport = new winston.transports.DailyRotateFile({
filename: `${__dirname.toString()}/../../logs/rest2nostr-${
this.config.env
}-%DATE%.log`,
datePattern: 'YYYY-MM-DD',
zippedArchive: false,
maxSize: '1m', // 1 megabyte
maxFiles: '5d', // 5 days
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
)
})
this.transport.on('rotate', this.notifyRotation)
// This controls what goes into the log FILES
this.wlogger = winston.createLogger({
level: this.config.logLevel || 'info',
format: winston.format.json(),
transports: [
this.transport
]
})
// Bind 'this' object to all methods
this.notifyRotation = this.notifyRotation.bind(this)
this.outputToConsole = this.outputToConsole.bind(this)
_this = this
}
notifyRotation (oldFilename, newFilename) {
_this.wlogger.info('Rotating log files')
}
outputToConsole () {
this.wlogger.add(
new winston.transports.Console({
format: winston.format.simple(),
level: this.config.logLevel || 'info'
})
)
}
}
const logger = new Wlogger()
// Allow the logger to write to the console.
logger.outputToConsole()
const wlogger = logger.wlogger
export { wlogger as default, Wlogger }
+52
View File
@@ -0,0 +1,52 @@
/*
This file is used to store unsecure, application-specific data common to all
environments.
*/
// Hack to get __dirname back.
// https://blog.logrocket.com/alternatives-dirname-node-js-es-modules/
import * as url from 'url'
import { readFileSync } from 'fs'
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
const pkgInfo = JSON.parse(readFileSync(`${__dirname.toString()}/../../../package.json`))
const version = pkgInfo.version
export default {
// Server port
port: process.env.PORT || 5942,
// Environment
env: process.env.NODE_ENV || 'development',
// Logging level
logLevel: process.env.LOG_LEVEL || 'info',
// Nostr relay configuration (array of relay URLs)
nostrRelayUrls: (() => {
// Support NOSTR_RELAY_URLS (plural) as comma-separated string or JSON array
if (process.env.NOSTR_RELAY_URLS) {
try {
// Try parsing as JSON array first
const parsed = JSON.parse(process.env.NOSTR_RELAY_URLS)
if (Array.isArray(parsed)) {
return parsed.filter(url => url && typeof url === 'string')
}
} catch (e) {
// Not JSON, treat as comma-separated string
return process.env.NOSTR_RELAY_URLS.split(',').map(url => url.trim()).filter(url => url.length > 0)
}
}
// Backward compatibility: support NOSTR_RELAY_URL (singular)
if (process.env.NOSTR_RELAY_URL) {
return [process.env.NOSTR_RELAY_URL]
}
// Default
return ['wss://nostr-relay.psfoundation.info', 'wss://relay.damus.io']
})(),
// Version
version
}
+7
View File
@@ -0,0 +1,7 @@
/*
These are the environment settings for the DEVELOPMENT environment.
*/
export default {
env: 'development'
}
+7
View File
@@ -0,0 +1,7 @@
/*
These are the environment settings for the PRODUCTION environment.
*/
export default {
env: 'production'
}
+14
View File
@@ -0,0 +1,14 @@
import common from './env/common.js'
import development from './env/development.js'
import production from './env/production.js'
const env = process.env.NODE_ENV || 'development'
console.log(`Loading config for this environment: ${env}`)
let config = development
if (env === 'production') {
config = production
}
export default Object.assign({}, common, config)
+56
View File
@@ -0,0 +1,56 @@
/*
This is a top-level library that encapsulates all the additional Controllers.
The concept of Controllers comes from Clean Architecture:
https://troutsblog.com/blog/clean-architecture
*/
// Local libraries
import Adapters from '../adapters/index.js'
import UseCases from '../use-cases/index.js'
import RESTControllers from './rest-api/index.js'
import TimerController from './timer-controller.js'
import config from '../config/index.js'
class Controllers {
constructor (localConfig = {}) {
// Encapsulate dependencies
this.adapters = new Adapters(localConfig)
this.useCases = new UseCases({ adapters: this.adapters })
this.config = config
this.timerController = new TimerController({ adapters: this.adapters, useCases: this.useCases })
// Bind 'this' object to all subfunctions
this.initAdapters = this.initAdapters.bind(this)
this.initUseCases = this.initUseCases.bind(this)
this.attachRESTControllers = this.attachRESTControllers.bind(this)
}
// Spin up any adapter libraries that have async startup needs.
async initAdapters () {
await this.adapters.start()
}
// Run any Use Cases to startup the app.
async initUseCases () {
await this.useCases.start()
}
// Initialize all the controllers.
async initControllers () {
this.timerController.startTimerControllers()
}
// Top-level function for this library.
// Start the various Controllers and attach them to the app.
attachRESTControllers (app) {
const restControllers = new RESTControllers({
adapters: this.adapters,
useCases: this.useCases
})
// Attach the REST API Controllers to the Express app.
restControllers.attachRESTControllers(app)
}
}
export default Controllers
@@ -0,0 +1,99 @@
/*
REST API Controller library for the /event route
*/
// Local libraries
import wlogger from '../../../adapters/wlogger.js'
class EventRESTControllerLib {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating /event REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating /event REST Controller.'
)
}
// Bind 'this' object to all subfunctions
this.publishEvent = this.publishEvent.bind(this)
this.handleError = this.handleError.bind(this)
}
/**
* @api {post} /event Publish a Nostr event
* @apiPermission public
* @apiName PublishEvent
* @apiGroup Event
*
* @apiDescription Publish a signed Nostr event to the relay. Maps to the Nostr WebSocket protocol message: ["EVENT", <event>]
*
* @apiParam {String} id Event ID (32-bytes lowercase hex-encoded sha256)
* @apiParam {String} pubkey Public key of event creator (32-bytes lowercase hex-encoded)
* @apiParam {Number} created_at Unix timestamp in seconds
* @apiParam {Number} kind Integer between 0 and 65535
* @apiParam {Array} tags Array of tag arrays
* @apiParam {String} content Event content (arbitrary string)
* @apiParam {String} sig Signature (64-bytes lowercase hex)
*
* @apiExample {json} Example usage:
* {
* "id": "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
* "pubkey": "2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e9",
* "created_at": 1672531200,
* "kind": 1,
* "tags": [],
* "content": "Hello, Nostr!",
* "sig": "abc123..."
* }
*
* @apiSuccess {Boolean} accepted Whether the event was accepted by the relay
* @apiSuccess {String} message Optional message from the relay
* @apiSuccess {String} eventId The event ID
*
* @apiError {String} error Error message
*/
async publishEvent (req, res) {
try {
const eventData = req.body
// Check if eventData is missing or empty
if (!eventData || (typeof eventData === 'object' && Object.keys(eventData).length === 0)) {
return res.status(400).json({
error: 'Event data is required'
})
}
const result = await this.useCases.publishEvent.execute(eventData)
if (result.accepted) {
return res.status(200).json(result)
} else {
return res.status(400).json(result)
}
} catch (err) {
return this.handleError(err, req, res)
}
}
handleError (err, req, res) {
wlogger.error('Error in EventRESTController:', err)
// Return 400 for validation errors, 500 for other errors
// Validation errors indicate the client sent bad data
const isValidationError = err.message && err.message.includes('Invalid event structure')
const statusCode = isValidationError ? 400 : 500
return res.status(statusCode).json({
error: err.message || 'Internal server error'
})
}
}
export default EventRESTControllerLib
+55
View File
@@ -0,0 +1,55 @@
/*
REST API library for the /event route.
*/
// Public npm libraries.
import express from 'express'
// Local libraries.
import EventRESTControllerLib from './controller.js'
class EventRouter {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Event REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Event REST Controller.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
// Encapsulate dependencies.
this.eventRESTController = new EventRESTControllerLib(dependencies)
// Instantiate the router and set the base route.
this.baseUrl = '/event'
this.router = express.Router()
}
attach (app) {
if (!app) {
throw new Error(
'Must pass app object when attaching REST API controllers.'
)
}
// Define the routes and attach the controller.
this.router.post('/', this.eventRESTController.publishEvent)
// Attach the Controller routes to the Express app.
app.use(this.baseUrl, this.router)
}
}
export default EventRouter
+51
View File
@@ -0,0 +1,51 @@
/*
This index file for the Clean Architecture Controllers loads dependencies,
creates instances, and attaches the controller to REST API endpoints for
Express.
*/
// Local libraries
// import EventRouter from './event/index.js'
// import ReqRouter from './req/index.js'
import config from '../../config/index.js'
class RESTControllers {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating REST Controller libraries.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating REST Controller libraries.'
)
}
// Bind 'this' object to all subfunctions.
this.attachRESTControllers = this.attachRESTControllers.bind(this)
// Encapsulate dependencies
this.config = config
}
attachRESTControllers (app) {
// const dependencies = {
// adapters: this.adapters,
// useCases: this.useCases
// }
// Attach the REST API Controllers associated with the /event route
// const eventRouter = new EventRouter(dependencies)
// eventRouter.attach(app)
// Attach the REST API Controllers associated with the /req route
// const reqRouter = new ReqRouter(dependencies)
// reqRouter.attach(app)
}
}
export default RESTControllers
+296
View File
@@ -0,0 +1,296 @@
/*
REST API Controller library for the /req route
*/
// Local libraries
import wlogger from '../../../adapters/wlogger.js'
class ReqRESTControllerLib {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating /req REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating /req REST Controller.'
)
}
// Bind 'this' object to all subfunctions
this.queryEvents = this.queryEvents.bind(this)
this.createSubscription = this.createSubscription.bind(this)
this.closeSubscription = this.closeSubscription.bind(this)
this.handleError = this.handleError.bind(this)
}
/**
* @api {get} /req/:subId Query events (stateless)
* @apiPermission public
* @apiName QueryEvents
* @apiGroup Request
*
* @apiDescription Query events from the relay with filters. Returns events immediately. Maps to: ["REQ", <sub_id>, <filters>]
*
* @apiParam {String} subId Subscription ID (unique identifier)
* @apiParam {String} filters JSON-encoded filters object (query parameter)
*
* @apiExample {curl} Example usage:
* curl -X GET "http://localhost:3000/req/sub1?filters=[{\"kinds\":[1],\"limit\":10}]"
*
* @apiSuccess {Array} events Array of Nostr events
*
* @apiError {String} error Error message
*/
async queryEvents (req, res) {
try {
const { subId } = req.params
let filters = req.query.filters
if (!subId) {
return res.status(400).json({
error: 'Subscription ID is required'
})
}
// Parse filters from query string
if (typeof filters === 'string') {
try {
filters = JSON.parse(filters)
} catch (err) {
return res.status(400).json({
error: 'Invalid filters JSON'
})
}
} else if (!filters) {
// If no filters provided, accept filters from query params
filters = {}
if (req.query.kinds) {
filters.kinds = JSON.parse(req.query.kinds)
}
if (req.query.authors) {
filters.authors = JSON.parse(req.query.authors)
}
if (req.query.ids) {
filters.ids = JSON.parse(req.query.ids)
}
if (req.query.limit) {
filters.limit = parseInt(req.query.limit)
}
if (req.query.since) {
filters.since = parseInt(req.query.since)
}
if (req.query.until) {
filters.until = parseInt(req.query.until)
}
}
// Ensure filters is an array (Nostr protocol expects array of filters)
const filtersArray = Array.isArray(filters) ? filters : [filters]
const events = await this.useCases.queryEvents.execute(filtersArray, subId)
return res.status(200).json(events)
} catch (err) {
return this.handleError(err, req, res)
}
}
/**
* @api {post} /req/:subId Create subscription (SSE)
* @apiPermission public
* @apiName CreateSubscription
* @apiGroup Request
*
* @apiDescription Create a subscription for Server-Sent Events. Maps to: ["REQ", <sub_id>, <filters>]
*
* @apiParam {String} subId Subscription ID (unique identifier)
* @apiParam {Object} filters Filters object in request body
*
* @apiExample {json} Example usage:
* {
* "kinds": [1],
* "authors": ["2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e9"]
* }
*
* @apiSuccess {String} message Success message
*
* @apiError {String} error Error message
*/
async createSubscription (req, res) {
try {
const { subId } = req.params
const filters = req.body
if (!subId) {
return res.status(400).json({
error: 'Subscription ID is required'
})
}
if (!filters || (typeof filters === 'object' && Object.keys(filters).length === 0)) {
return res.status(400).json({
error: 'Filters are required'
})
}
// Ensure filters is an array
const filtersArray = Array.isArray(filters) ? filters : [filters]
// Set up Server-Sent Events
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no') // Disable buffering in nginx
// Track if response is still writable
let isResponseWritable = true
// Helper function to safely write to SSE stream
const safeWrite = (data) => {
if (!isResponseWritable) {
return false
}
try {
if (!res.writable || res.destroyed || res.closed) {
isResponseWritable = false
return false
}
return res.write(data)
} catch (err) {
wlogger.warn(`Error writing to SSE stream for subscription ${subId}:`, err.message)
isResponseWritable = false
return false
}
}
// Handle response stream errors
res.on('error', (err) => {
wlogger.warn(`Response stream error for subscription ${subId}:`, err.message)
isResponseWritable = false
// Clean up subscription on stream error
this.useCases.manageSubscription.closeSubscription(subId).catch(closeErr => {
wlogger.error('Error closing subscription on stream error:', closeErr)
})
})
// Send initial connection message
if (!safeWrite(`data: ${JSON.stringify({ type: 'connected', subscriptionId: subId })}\n\n`)) {
wlogger.warn(`Failed to send initial connection message for subscription ${subId}`)
return res.status(500).json({ error: 'Failed to establish SSE connection' })
}
// Handle events
const onEvent = (event) => {
if (!safeWrite(`data: ${JSON.stringify({ type: 'event', data: event })}\n\n`)) {
wlogger.debug(`Cannot write event to SSE stream for subscription ${subId} - connection may be closed`)
}
}
// Handle EOSE
const onEose = () => {
if (!safeWrite(`data: ${JSON.stringify({ type: 'eose' })}\n\n`)) {
wlogger.debug(`Cannot write EOSE to SSE stream for subscription ${subId} - connection may be closed`)
}
}
// Handle CLOSED
const onClosed = (message) => {
if (safeWrite(`data: ${JSON.stringify({ type: 'closed', message })}\n\n`)) {
try {
if (!res.destroyed && !res.closed) {
res.end()
}
} catch (err) {
wlogger.warn(`Error ending SSE stream for subscription ${subId}:`, err.message)
}
}
isResponseWritable = false
}
// Create subscription
await this.useCases.manageSubscription.createSubscription(
subId,
filtersArray,
onEvent,
onEose,
onClosed
)
// Handle client disconnect
req.on('close', () => {
wlogger.info(`Client disconnected from subscription ${subId}`)
isResponseWritable = false
this.useCases.manageSubscription.closeSubscription(subId).catch(err => {
wlogger.error('Error closing subscription on disconnect:', err)
})
})
// Handle response finish
res.on('finish', () => {
isResponseWritable = false
})
} catch (err) {
return this.handleError(err, req, res)
}
}
/**
* @api {put} /req/:subId Create subscription (SSE) - alternative method
* @apiPermission public
* @apiName CreateSubscriptionPut
* @apiGroup Request
*
* @apiDescription Same as POST /req/:subId - create a subscription for Server-Sent Events
*/
async createSubscriptionPut (req, res) {
return this.createSubscription(req, res)
}
/**
* @api {delete} /req/:subId Close subscription
* @apiPermission public
* @apiName CloseSubscription
* @apiGroup Request
*
* @apiDescription Close an existing subscription. Maps to: ["CLOSE", <sub_id>]
*
* @apiParam {String} subId Subscription ID to close
*
* @apiSuccess {String} message Success message
*
* @apiError {String} error Error message
*/
async closeSubscription (req, res) {
try {
const { subId } = req.params
if (!subId) {
return res.status(400).json({
error: 'Subscription ID is required'
})
}
await this.useCases.manageSubscription.closeSubscription(subId)
return res.status(200).json({
message: `Subscription ${subId} closed successfully`
})
} catch (err) {
return this.handleError(err, req, res)
}
}
handleError (err, req, res) {
wlogger.error('Error in ReqRESTController:', err)
return res.status(500).json({
error: err.message || 'Internal server error'
})
}
}
export default ReqRESTControllerLib
+75
View File
@@ -0,0 +1,75 @@
/*
REST API library for the /req route.
*/
// Public npm libraries.
import express from 'express'
// Local libraries.
import ReqRESTControllerLib from './controller.js'
class ReqRouter {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Req REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Req REST Controller.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
// Encapsulate dependencies.
this.reqRESTController = new ReqRESTControllerLib(dependencies)
// Instantiate the router and set the base route.
this.router = express.Router()
}
attach (app) {
if (!app) {
throw new Error(
'Must pass app object when attaching REST API controllers.'
)
}
// Define the routes and attach the controller.
// Handle empty subId case first
this.router.get('/', (req, res) => {
res.status(400).json({
error: 'Subscription ID is required'
})
})
this.router.post('/', (req, res) => {
res.status(400).json({
error: 'Subscription ID is required'
})
})
this.router.delete('/', (req, res) => {
res.status(400).json({
error: 'Subscription ID is required'
})
})
// Routes with subId parameter
this.router.get('/:subId', this.reqRESTController.queryEvents)
this.router.post('/:subId', this.reqRESTController.createSubscription)
this.router.put('/:subId', this.reqRESTController.createSubscriptionPut)
this.router.delete('/:subId', this.reqRESTController.closeSubscription)
// Attach the Controller routes to the Express app.
app.use('/req', this.router)
}
}
export default ReqRouter
+72
View File
@@ -0,0 +1,72 @@
/*
Timer-based controller functions.
This controller handles scheduled tasks and timers.
*/
// Local libraries
import wlogger from '../adapters/wlogger.js'
class TimerController {
constructor (localConfig = {}) {
// Dependency Injection
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating TimerController.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating TimerController.'
)
}
// Constants
this.SHUTDOWN_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes in milliseconds
this.LIVENESS_CHECK_INTERVAL_MS = 1 * 60 * 1000 // 1 minute in milliseconds
// Handlers
this.shutdownHandler = null
this.livenessCheckHandler = null
// Bind 'this' object to all subfunctions
this.startTimerControllers = this.startTimerControllers.bind(this)
this.stopTimerControllers = this.stopTimerControllers.bind(this)
this.shutdown = this.shutdown.bind(this)
this.livenessCheck = this.livenessCheck.bind(this)
}
startTimerControllers () {
console.log('Starting Timer Controllers.')
// this.shutdownHandler = setInterval(() => {
// this.shutdown()
// }, this.SHUTDOWN_INTERVAL_MS)
// this.livenessCheckHandler = setInterval(() => {
// this.livenessCheck()
// }, this.LIVENESS_CHECK_INTERVAL_MS)
}
stopTimerControllers () {
console.log('Stopping Timer Controllers.')
clearInterval(this.shutdownHandler)
this.shutdownHandler = null
clearInterval(this.livenessCheckHandler)
this.livenessCheckHandler = null
}
// Execute the shutdown callback
shutdown () {
wlogger.info(`TimerController: Shutting down application at ${new Date().toISOString()}, depending on process manager to restart application.`)
process.exit(1)
}
livenessCheck () {
wlogger.info(`TimerController: Liveness check at ${new Date().toISOString()}`)
}
}
export default TimerController
+71
View File
@@ -0,0 +1,71 @@
/*
Event entity - represents a Nostr event.
This is a domain model following Clean Architecture principles.
*/
class Event {
constructor (data) {
this.id = data.id
this.pubkey = data.pubkey
this.created_at = data.created_at
this.kind = data.kind
this.tags = data.tags || []
this.content = data.content
this.sig = data.sig
}
/**
* Validates the event structure
* @returns {boolean} True if valid
*/
isValid () {
if (!this.id || !this.pubkey || !this.created_at || this.kind === undefined || !this.sig) {
return false
}
// Basic type checks
if (typeof this.id !== 'string' || this.id.length !== 64) {
return false
}
if (typeof this.pubkey !== 'string' || this.pubkey.length !== 64) {
return false
}
if (typeof this.created_at !== 'number') {
return false
}
if (typeof this.kind !== 'number' || this.kind < 0 || this.kind > 65535) {
return false
}
if (typeof this.sig !== 'string' || this.sig.length !== 128) {
return false
}
if (!Array.isArray(this.tags)) {
return false
}
return true
}
/**
* Convert to plain object
* @returns {Object} Plain event object
*/
toJSON () {
return {
id: this.id,
pubkey: this.pubkey,
created_at: this.created_at,
kind: this.kind,
tags: this.tags,
content: this.content,
sig: this.sig
}
}
}
export default Event
+33
View File
@@ -0,0 +1,33 @@
/*
This is a top-level library that encapsulates all the additional Use Cases.
The concept of Use Cases comes from Clean Architecture:
https://troutsblog.com/blog/clean-architecture
*/
// Local libraries
// import PublishEventUseCase from './publish-event.js'
// import QueryEventsUseCase from './query-events.js'
// import ManageSubscriptionUseCase from './manage-subscription.js'
class UseCases {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of adapters must be passed in when instantiating Use Cases library.'
)
}
// this.publishEvent = new PublishEventUseCase({ adapters: this.adapters })
// this.queryEvents = new QueryEventsUseCase({ adapters: this.adapters })
// this.manageSubscription = new ManageSubscriptionUseCase({ adapters: this.adapters })
}
// Run any startup Use Cases at the start of the app.
async start () {
console.log('Use Cases have been started.')
return true
}
}
export default UseCases
+216
View File
@@ -0,0 +1,216 @@
/*
Use case: Manage subscriptions for Server-Sent Events (SSE).
This encapsulates the business logic for creating and managing subscriptions.
*/
import wlogger from '../adapters/wlogger.js'
class ManageSubscriptionUseCase {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters instance required')
}
if (!this.adapters.nostrRelays || !Array.isArray(this.adapters.nostrRelays) || this.adapters.nostrRelays.length === 0) {
throw new Error('NostrRelay adapters array required')
}
// Map subscriptionId to { relaySubscriptions: Map<relayIndex, subscriptionId>, handlers, seenEventIds }
this.activeSubscriptions = new Map()
}
/**
* Create a subscription for SSE streaming across all relays
* @param {string} subscriptionId - Unique subscription ID
* @param {Array} filters - Array of filter objects
* @param {Function} onEvent - Callback for events
* @param {Function} onEose - Callback for EOSE
* @param {Function} onClosed - Callback for CLOSED
* @returns {Promise<void>}
*/
async createSubscription (subscriptionId, filters, onEvent, onEose, onClosed) {
try {
if (this.activeSubscriptions.has(subscriptionId)) {
throw new Error(`Subscription ${subscriptionId} already exists`)
}
wlogger.info(`Creating subscription ${subscriptionId} across ${this.adapters.nostrRelays.length} relay(s)`)
// Track seen event IDs to de-duplicate across relays
const seenEventIds = new Set()
// Track EOSE and CLOSED status per relay
const relayStatuses = this.adapters.nostrRelays.map(() => ({
eoseReceived: false,
closedReceived: false
}))
// Create unified handlers that merge events from all relays
const handlers = {
onEvent: (event) => {
// De-duplicate events by ID across all relays
if (event && event.id && !seenEventIds.has(event.id)) {
seenEventIds.add(event.id)
if (onEvent) {
onEvent(event)
}
}
},
onEose: () => {
// Call onEose only once when all relays have sent EOSE
// This is called from the per-relay handler only when all relays have EOSE
if (onEose) {
onEose()
}
},
onClosed: (message) => {
if (onClosed) {
onClosed(message)
}
// Clean up subscription if any relay closes it
const subscriptionInfo = this.activeSubscriptions.get(subscriptionId)
if (subscriptionInfo && subscriptionInfo.eoseTimeoutId) {
clearTimeout(subscriptionInfo.eoseTimeoutId)
}
this.activeSubscriptions.delete(subscriptionId)
}
}
// Create subscription per relay with unique subscription IDs
const relaySubscriptions = new Map()
const subscriptionPromises = this.adapters.nostrRelays.map(async (relay, index) => {
const relaySubscriptionId = `${subscriptionId}-relay-${index}`
relaySubscriptions.set(index, relaySubscriptionId)
// Create per-relay handlers that update shared state
const relayHandlers = {
onEvent: (event) => {
handlers.onEvent(event)
},
onEose: () => {
relayStatuses[index].eoseReceived = true
// Check if all relays have sent EOSE
if (relayStatuses.every(s => s.eoseReceived)) {
// Clear the timeout since we got EOSE from all relays
const subscriptionInfo = this.activeSubscriptions.get(subscriptionId)
if (subscriptionInfo && subscriptionInfo.eoseTimeoutId) {
clearTimeout(subscriptionInfo.eoseTimeoutId)
subscriptionInfo.eoseTimeoutId = null
}
handlers.onEose()
}
},
onClosed: (message) => {
relayStatuses[index].closedReceived = true
handlers.onClosed(message)
}
}
await relay.sendReq(relaySubscriptionId, filters, relayHandlers)
})
// Store subscription info
this.activeSubscriptions.set(subscriptionId, {
relaySubscriptions,
handlers,
seenEventIds,
relayStatuses,
eoseTimeoutId: null
})
// Subscribe to all relays concurrently
const results = await Promise.allSettled(subscriptionPromises)
// Check if any relay subscription failed and clean up if so
const hasFailures = results.some(result => result.status === 'rejected')
if (hasFailures) {
const subscriptionInfo = this.activeSubscriptions.get(subscriptionId)
if (subscriptionInfo && subscriptionInfo.eoseTimeoutId) {
clearTimeout(subscriptionInfo.eoseTimeoutId)
}
this.activeSubscriptions.delete(subscriptionId)
const errors = results
.filter(result => result.status === 'rejected')
.map(result => result.reason)
throw new Error(`Failed to create subscription on some relays: ${errors.map(e => e.message).join(', ')}`)
}
// Set up EOSE timeout fallback - if not all relays send EOSE within 10 seconds, call onEose anyway
const subscriptionInfo = this.activeSubscriptions.get(subscriptionId)
const EOSE_TIMEOUT_MS = 10000 // 10 seconds
subscriptionInfo.eoseTimeoutId = setTimeout(() => {
// Check if subscription still exists and if all relays have sent EOSE
if (this.activeSubscriptions.has(subscriptionId)) {
const currentInfo = this.activeSubscriptions.get(subscriptionId)
const allEoseReceived = currentInfo.relayStatuses.every(s => s.eoseReceived)
if (!allEoseReceived) {
wlogger.warn(`EOSE timeout reached for subscription ${subscriptionId} - calling onEose callback anyway`)
if (handlers.onEose) {
handlers.onEose()
}
}
}
}, EOSE_TIMEOUT_MS)
} catch (err) {
wlogger.error('Error creating subscription:', err)
const subscriptionInfo = this.activeSubscriptions.get(subscriptionId)
if (subscriptionInfo && subscriptionInfo.eoseTimeoutId) {
clearTimeout(subscriptionInfo.eoseTimeoutId)
}
this.activeSubscriptions.delete(subscriptionId)
throw err
}
}
/**
* Close a subscription across all relays
* @param {string} subscriptionId - Subscription ID to close
* @returns {Promise<void>}
*/
async closeSubscription (subscriptionId) {
try {
if (!this.activeSubscriptions.has(subscriptionId)) {
// Subscription doesn't exist - already closed, treat as success (idempotent)
wlogger.info(`Subscription ${subscriptionId} already closed or does not exist`)
return
}
wlogger.info(`Closing subscription ${subscriptionId} across all relays`)
const subscriptionInfo = this.activeSubscriptions.get(subscriptionId)
const { relaySubscriptions } = subscriptionInfo
// Clear EOSE timeout if it exists
if (subscriptionInfo.eoseTimeoutId) {
clearTimeout(subscriptionInfo.eoseTimeoutId)
}
// Close subscriptions on all relays concurrently
const closePromises = Array.from(relaySubscriptions.entries()).map(async ([relayIndex, relaySubscriptionId]) => {
try {
await this.adapters.nostrRelays[relayIndex].sendClose(relaySubscriptionId)
} catch (err) {
wlogger.warn(`Error closing subscription on relay ${relayIndex}:`, err.message)
}
})
await Promise.allSettled(closePromises)
this.activeSubscriptions.delete(subscriptionId)
} catch (err) {
wlogger.error('Error closing subscription:', err)
// Clean up even if there's an error
this.activeSubscriptions.delete(subscriptionId)
throw err
}
}
/**
* Check if a subscription exists
* @param {string} subscriptionId - Subscription ID
* @returns {boolean}
*/
hasSubscription (subscriptionId) {
return this.activeSubscriptions.has(subscriptionId)
}
}
export default ManageSubscriptionUseCase
+87
View File
@@ -0,0 +1,87 @@
/*
Use case: Publish a Nostr event to the relay.
This encapsulates the business logic for publishing events.
*/
import Event from '../entities/event.js'
import wlogger from '../adapters/wlogger.js'
class PublishEventUseCase {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters instance required')
}
if (!this.adapters.nostrRelays || !Array.isArray(this.adapters.nostrRelays) || this.adapters.nostrRelays.length === 0) {
throw new Error('NostrRelay adapters array required')
}
}
/**
* Publish an event to all Nostr relays (broadcast)
* @param {Object} eventData - Event data (must be signed)
* @returns {Promise<Object>} Result with accepted status, message, and relay results
*/
async execute (eventData) {
try {
// Create event entity
const event = new Event(eventData)
// Validate event
if (!event.isValid()) {
throw new Error('Invalid event structure')
}
wlogger.info(`Publishing event ${event.id} (kind ${event.kind}) to ${this.adapters.nostrRelays.length} relay(s)`)
// Broadcast event to all relays
const results = await this.adapters.broadcastEvent(event.toJSON())
// Aggregate results
const acceptedRelays = results.filter(r => r.accepted)
const rejectedRelays = results.filter(r => !r.accepted)
const failedRelays = results.filter(r => !r.success)
const atLeastOneAccepted = acceptedRelays.length > 0
const allAccepted = acceptedRelays.length === results.length && failedRelays.length === 0
// Build aggregated message
let message = ''
if (allAccepted) {
message = `Accepted by all ${acceptedRelays.length} relay(s)`
} else if (atLeastOneAccepted) {
message = `Accepted by ${acceptedRelays.length}/${results.length} relay(s)`
if (rejectedRelays.length > 0) {
message += `, rejected by ${rejectedRelays.length} relay(s)`
}
if (failedRelays.length > 0) {
message += `, failed to reach ${failedRelays.length} relay(s)`
}
} else {
message = `Rejected or failed by all ${results.length} relay(s)`
if (rejectedRelays.length > 0) {
const rejectionMessages = rejectedRelays.map(r => r.message).filter(m => m).join('; ')
if (rejectionMessages) {
message += `: ${rejectionMessages}`
}
}
}
wlogger.info(`Event ${event.id} ${atLeastOneAccepted ? 'accepted' : 'rejected/failed'}: ${message}`)
return {
accepted: atLeastOneAccepted,
message,
eventId: event.id,
relayResults: results,
acceptedCount: acceptedRelays.length,
totalRelays: results.length
}
} catch (err) {
wlogger.error('Error in PublishEventUseCase:', err)
throw err
}
}
}
export default PublishEventUseCase
+41
View File
@@ -0,0 +1,41 @@
/*
Use case: Query events from the relay (stateless).
This encapsulates the business logic for querying events.
*/
import wlogger from '../adapters/wlogger.js'
class QueryEventsUseCase {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters instance required')
}
if (!this.adapters.nostrRelays || !Array.isArray(this.adapters.nostrRelays) || this.adapters.nostrRelays.length === 0) {
throw new Error('NostrRelay adapters array required')
}
}
/**
* Query events with filters from all relays (stateless - returns immediately)
* @param {Array} filters - Array of filter objects
* @param {string} subscriptionId - Unique subscription ID
* @returns {Promise<Array>} Array of events (merged and de-duplicated from all relays)
*/
async execute (filters, subscriptionId) {
try {
wlogger.info(`Querying events with subscription ${subscriptionId} from ${this.adapters.nostrRelays.length} relay(s)`)
// Query all relays concurrently and merge results
const events = await this.adapters.queryAllRelays(filters, subscriptionId)
wlogger.info(`Query returned ${events.length} events from ${this.adapters.nostrRelays.length} relay(s)`)
return events
} catch (err) {
wlogger.error('Error in QueryEventsUseCase:', err)
throw err
}
}
}
export default QueryEventsUseCase
+250
View File
@@ -0,0 +1,250 @@
/*
Integration tests for POST /event endpoint.
These tests require a running Nostr relay.
*/
// npm libraries
import { assert } from 'chai'
// Unit under test
import Server from '../../../bin/server.js'
import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils.js'
describe('#event-integration.js', () => {
let server
const baseUrl = 'http://localhost:3001' // Use different port for tests
before(async () => {
// Start test server
server = new Server()
server.config.port = 3001
await server.startServer()
// Wait for server to be ready
await new Promise(resolve => setTimeout(resolve, 1000))
})
after(async () => {
// Stop server
if (server && server.server) {
await new Promise((resolve) => {
server.server.close(() => {
resolve()
})
})
}
})
describe('POST /event', () => {
it('should publish kind 0 event (profile metadata) - covers example 01', async () => {
// Generate keys
const sk = generateSecretKey()
// Create profile metadata event (kind 0)
const profileMetadata = {
name: 'Test User',
about: 'Integration test user',
picture: 'https://example.com/test.jpg'
}
const eventTemplate = {
kind: 0,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: JSON.stringify(profileMetadata)
}
// Sign the event
const signedEvent = finalizeEvent(eventTemplate, sk)
// Publish to REST API
const response = await fetch(`${baseUrl}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
})
const result = await response.json()
// Assert response
assert.equal(response.status, 200)
assert.property(result, 'accepted')
assert.property(result, 'eventId')
assert.equal(result.eventId, signedEvent.id)
})
it('should publish kind 1 event (text post) - covers example 03', async () => {
// Alice's private key from examples
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
const alicePubKey = getPublicKey(alicePrivKeyBin)
// Generate a post
const eventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Integration test post'
}
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin)
// Publish to REST API
const response = await fetch(`${baseUrl}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
})
const result = await response.json()
// Assert response
assert.equal(response.status, 200)
assert.property(result, 'accepted')
assert.property(result, 'eventId')
assert.equal(result.eventId, signedEvent.id)
assert.equal(signedEvent.pubkey, alicePubKey)
})
it('should publish kind 3 event (follow list) - covers example 06', async () => {
// Alice's private key
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
// Bob's public key
const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f'
const bobPrivKeyBin = hexToBytes(bobPrivKeyHex)
const bobPubKey = getPublicKey(bobPrivKeyBin)
const psf = 'wss://nostr-relay.psfoundation.info'
const followList = [
['p', bobPubKey, psf, 'bob']
]
// Generate a follow list event (kind 3)
const eventTemplate = {
kind: 3,
created_at: Math.floor(Date.now() / 1000),
tags: followList,
content: ''
}
// Sign the event
const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin)
// Publish to REST API
const response = await fetch(`${baseUrl}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
})
const result = await response.json()
// Assert response
assert.equal(response.status, 200)
assert.property(result, 'accepted')
assert.property(result, 'eventId')
assert.equal(result.eventId, signedEvent.id)
})
it('should publish kind 7 event (reaction/like) - covers example 07', async () => {
// Bob's private key
const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f'
const bobPrivKeyBin = hexToBytes(bobPrivKeyHex)
const bobPubKey = getPublicKey(bobPrivKeyBin)
const psf = 'wss://nostr-relay.psfoundation.info'
// Use a test event ID
const evIdToLike = 'd09b4c5da59be3cd2768aa53fa78b77bf4859084c94f3bf26d401f004a9c8167'
const evIdAuthorPubKey = '2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e92'
// Generate like event (kind 7)
const likeEventTemplate = {
kind: 7,
created_at: Math.floor(Date.now() / 1000),
pubkey: bobPubKey,
tags: [
['e', evIdToLike, psf],
['p', evIdAuthorPubKey, psf]
],
content: '+'
}
// Sign the event
const signedEvent = finalizeEvent(likeEventTemplate, bobPrivKeyBin)
// Publish to REST API
const response = await fetch(`${baseUrl}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
})
const result = await response.json()
// Assert response
assert.equal(response.status, 200)
assert.property(result, 'accepted')
assert.property(result, 'eventId')
assert.equal(result.eventId, signedEvent.id)
})
it('should reject invalid event', async () => {
const invalidEvent = {
id: 'invalid',
pubkey: 'invalid',
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'Test',
sig: 'invalid'
}
const response = await fetch(`${baseUrl}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(invalidEvent)
})
const result = await response.json()
// Should reject invalid event - error response format
assert.equal(response.status, 400)
assert.property(result, 'error')
assert.include(result.error, 'Invalid event structure')
})
it('should return 400 when event data is missing', async () => {
// Send empty body - Express will parse as undefined, controller should handle it
const response = await fetch(`${baseUrl}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: ''
})
// Empty body should be parsed as undefined by Express
const result = await response.json()
assert.equal(response.status, 400)
assert.property(result, 'error')
assert.include(result.error, 'Event data is required')
})
})
})
+173
View File
@@ -0,0 +1,173 @@
/*
Integration tests for GET /req/:subId endpoint.
These tests require a running Nostr relay.
*/
// npm libraries
import { assert } from 'chai'
// Unit under test
import Server from '../../../bin/server.js'
import { getPublicKey } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils.js'
describe('#req-integration.js', () => {
let server
const baseUrl = 'http://localhost:3002' // Use different port for tests
before(async () => {
// Start test server
server = new Server()
server.config.port = 3002
await server.startServer()
// Wait for server to be ready
await new Promise(resolve => setTimeout(resolve, 1000))
})
after(async () => {
// Stop server
if (server && server.server) {
await new Promise((resolve) => {
server.server.close(() => {
resolve()
})
})
}
})
describe('GET /req/:subId', () => {
it('should query kind 1 events (posts) - covers examples 02, 04', async () => {
// JB55's public key from example 02
const jb55 = '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
// Create subscription ID
const subId = 'read-posts-' + Date.now()
// Create filters - read posts from JB55
const filters = {
limit: 2,
kinds: [1],
authors: [jb55]
}
// Query events using GET /req/:subId
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
const url = `${baseUrl}/req/${subId}?filters=${filtersJson}`
const response = await fetch(url)
const events = await response.json()
// Assert response
assert.equal(response.status, 200)
assert.isArray(events)
// May be empty if no events exist, but structure should be correct
if (events.length > 0) {
assert.property(events[0], 'id')
assert.property(events[0], 'pubkey')
assert.property(events[0], 'created_at')
assert.property(events[0], 'kind')
assert.property(events[0], 'content')
assert.equal(events[0].kind, 1)
}
})
it('should query Alice posts - covers example 04', async () => {
// Alice's public key
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
const alicePubKey = getPublicKey(alicePrivKeyBin)
// Create subscription ID
const subId = 'read-alice-posts-' + Date.now()
// Create filters - read posts from Alice
const filters = {
limit: 2,
kinds: [1],
authors: [alicePubKey]
}
// Query events using GET /req/:subId
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
const url = `${baseUrl}/req/${subId}?filters=${filtersJson}`
const response = await fetch(url)
const events = await response.json()
// Assert response
assert.equal(response.status, 200)
assert.isArray(events)
if (events.length > 0) {
assert.equal(events[0].pubkey, alicePubKey)
assert.equal(events[0].kind, 1)
}
})
it('should query kind 3 events (follow list) - covers example 05', async () => {
// Alice's public key
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
const alicePubKey = getPublicKey(alicePrivKeyBin)
// Create subscription ID
const subId = 'get-follow-list-' + Date.now()
// Create filters - get follow list (kind 3) from Alice
const filters = {
limit: 5,
kinds: [3],
authors: [alicePubKey]
}
// Query events using GET /req/:subId
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
const url = `${baseUrl}/req/${subId}?filters=${filtersJson}`
const response = await fetch(url)
const events = await response.json()
// Assert response
assert.equal(response.status, 200)
assert.isArray(events)
if (events.length > 0) {
assert.equal(events[0].kind, 3)
assert.equal(events[0].pubkey, alicePubKey)
assert.isArray(events[0].tags)
}
})
it('should handle filters as individual query params', async () => {
const subId = 'test-sub-' + Date.now()
const url = `${baseUrl}/req/${subId}?kinds=[1]&limit=10`
const response = await fetch(url)
const events = await response.json()
assert.equal(response.status, 200)
assert.isArray(events)
})
it('should return 400 when subscription ID is missing', async () => {
const url = `${baseUrl}/req/?filters=${encodeURIComponent(JSON.stringify([{ kinds: [1] }]))}`
const response = await fetch(url)
await response.json()
// Should return 404 or 400
assert.isAtLeast(response.status, 400)
})
it('should return 400 when filters JSON is invalid', async () => {
const subId = 'test-sub-' + Date.now()
const url = `${baseUrl}/req/${subId}?filters=invalid-json{`
const response = await fetch(url)
const result = await response.json()
assert.equal(response.status, 400)
assert.property(result, 'error')
assert.include(result.error, 'Invalid filters JSON')
})
})
})
@@ -0,0 +1,198 @@
/*
Integration tests for POST /req/:subId SSE subscription and DELETE /req/:subId.
These tests require a running Nostr relay.
*/
// npm libraries
import { assert } from 'chai'
// Unit under test
import Server from '../../../bin/server.js'
describe('#subscription-integration.js', () => {
let server
const baseUrl = 'http://localhost:3003' // Use different port for tests
before(async () => {
// Start test server
server = new Server()
server.config.port = 3003
await server.startServer()
// Wait for server to be ready
await new Promise(resolve => setTimeout(resolve, 1000))
})
after(async function () {
this.timeout(10000) // Increase timeout for cleanup
// Stop server
if (server && server.server) {
// Close all connections forcefully if available
if (server.server.closeAllConnections) {
server.server.closeAllConnections()
}
await new Promise((resolve) => {
const timeout = setTimeout(() => {
resolve() // Force resolve after 2 seconds
}, 2000)
server.server.close(() => {
clearTimeout(timeout)
resolve()
})
})
}
})
describe('POST /req/:subId', () => {
it('should create SSE subscription', async () => {
const subId = 'test-sub-' + Date.now()
const filters = { kinds: [1], limit: 10 }
const response = await fetch(`${baseUrl}/req/${subId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(filters)
})
// Assert SSE headers
assert.equal(response.headers.get('content-type'), 'text/event-stream')
assert.equal(response.headers.get('cache-control'), 'no-cache')
assert.equal(response.headers.get('connection'), 'keep-alive')
// Read initial connection message
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
const { value } = await reader.read()
const text = decoder.decode(value)
assert.include(text, 'connected')
assert.include(text, subId)
} finally {
reader.releaseLock()
}
})
it('should return 400 when subscription ID is missing', async () => {
const filters = { kinds: [1] }
const response = await fetch(`${baseUrl}/req/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(filters)
})
// Should return 404 or 400
assert.isAtLeast(response.status, 400)
})
it('should return 400 when filters are missing', async () => {
const subId = 'test-sub-' + Date.now()
const response = await fetch(`${baseUrl}/req/${subId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
})
const result = await response.json()
assert.equal(response.status, 400)
assert.property(result, 'error')
assert.include(result.error, 'Filters are required')
})
})
describe('DELETE /req/:subId', () => {
it('should close a subscription', async () => {
const subId = 'test-sub-' + Date.now()
const response = await fetch(`${baseUrl}/req/${subId}`, {
method: 'DELETE'
})
// May return 200 if subscription exists, or 500 if it doesn't
// The important thing is it doesn't crash
assert.isAtMost(response.status, 500)
})
it('should return 400 when subscription ID is missing', async () => {
const response = await fetch(`${baseUrl}/req/`, {
method: 'DELETE'
})
// Should return 404 or 400
assert.isAtLeast(response.status, 400)
})
})
describe('PUT /req/:subId', () => {
it('should create SSE subscription (alternative method)', async function () {
this.timeout(10000) // Increase timeout for this test
const subId = 'test-sub-' + Date.now()
const filters = { kinds: [1], limit: 10 }
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 8000)
let response
try {
response = await fetch(`${baseUrl}/req/${subId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(filters),
signal: controller.signal
})
// Assert SSE headers
assert.equal(response.headers.get('content-type'), 'text/event-stream')
clearTimeout(timeoutId)
// Consume the stream to prevent hanging
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
// Read initial connection message with timeout
const readPromise = reader.read()
const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve({ value: null, done: true }), 2000))
const { value, done } = await Promise.race([readPromise, timeoutPromise])
if (value && !done) {
const text = decoder.decode(value)
assert.include(text, 'connected')
}
} finally {
reader.releaseLock()
}
} catch (err) {
// AbortController aborted - this is expected
if (err.name !== 'AbortError') {
throw err
}
} finally {
clearTimeout(timeoutId)
// Always close the subscription
try {
await fetch(`${baseUrl}/req/${subId}`, {
method: 'DELETE'
})
} catch (err) {
// Ignore errors when closing
}
}
})
})
})
@@ -0,0 +1,163 @@
/*
Integration tests for ManageSubscriptionUseCase with real adapter.
These tests require a running Nostr relay.
*/
// npm libraries
import { assert } from 'chai'
// Unit under test
import Adapters from '../../../src/adapters/index.js'
import ManageSubscriptionUseCase from '../../../src/use-cases/manage-subscription.js'
describe('#manage-subscription-integration.js', () => {
let adapters
let uut
before(async () => {
// Initialize adapters (will connect to real relay)
adapters = new Adapters()
await adapters.start()
uut = new ManageSubscriptionUseCase({ adapters })
})
after(async () => {
// Clean up all subscriptions and disconnect from all relays
// Note: This is a simplified cleanup - in production you'd track all subscriptions
if (adapters && adapters.nostrRelays) {
await Promise.allSettled(
adapters.nostrRelays.map(relay => relay.disconnect())
)
}
})
describe('#createSubscription()', () => {
it('should successfully create a subscription', async () => {
const subscriptionId = 'test-sub-' + Date.now()
const filters = [{ kinds: [1], limit: 5 }]
let eventReceived = false
let eoseReceived = false
const onEvent = (event) => {
eventReceived = true
assert.property(event, 'id')
assert.property(event, 'kind')
}
const onEose = () => {
eoseReceived = true
}
const onClosed = () => {
// Handler for closed events
}
await uut.createSubscription(subscriptionId, filters, onEvent, onEose, onClosed)
// Assert subscription exists
assert.isTrue(uut.hasSubscription(subscriptionId))
// Wait a bit for events/EOSE
await new Promise(resolve => setTimeout(resolve, 2000))
// EOSE should be received (or events)
// Note: May not receive events if none exist, but EOSE should come
assert.isTrue(eoseReceived || eventReceived)
// Clean up
if (uut.hasSubscription(subscriptionId)) {
await uut.closeSubscription(subscriptionId)
}
})
it('should prevent duplicate subscriptions', async () => {
const subscriptionId = 'test-dup-' + Date.now()
const filters = [{ kinds: [1] }]
await uut.createSubscription(subscriptionId, filters)
try {
await uut.createSubscription(subscriptionId, filters)
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'already exists')
}
// Clean up
if (uut.hasSubscription(subscriptionId)) {
await uut.closeSubscription(subscriptionId)
}
})
it('should handle subscription with no events', async () => {
const subscriptionId = 'test-empty-' + Date.now()
const filters = [{ kinds: [99999], limit: 1 }] // Unlikely to have events
let eoseReceived = false
const onEose = () => {
eoseReceived = true
}
await uut.createSubscription(subscriptionId, filters, null, onEose, null)
// Wait for EOSE
await new Promise(resolve => setTimeout(resolve, 2000))
// Should receive EOSE even with no events
assert.isTrue(eoseReceived)
// Clean up
if (uut.hasSubscription(subscriptionId)) {
await uut.closeSubscription(subscriptionId)
}
})
})
describe('#closeSubscription()', () => {
it('should successfully close a subscription', async () => {
const subscriptionId = 'test-close-' + Date.now()
const filters = [{ kinds: [1] }]
await uut.createSubscription(subscriptionId, filters)
assert.isTrue(uut.hasSubscription(subscriptionId))
await uut.closeSubscription(subscriptionId)
// Assert subscription is removed
assert.isFalse(uut.hasSubscription(subscriptionId))
})
it('should return successfully when closing non-existent subscription (idempotent)', async () => {
const subscriptionId = 'non-existent-sub'
// Should not throw - idempotent operation
await uut.closeSubscription(subscriptionId)
// Should return successfully without error
assert.isTrue(true, 'closeSubscription should succeed for non-existent subscription')
})
})
describe('#hasSubscription()', () => {
it('should return false for non-existent subscription', () => {
assert.isFalse(uut.hasSubscription('non-existent'))
})
it('should return true for existing subscription', async () => {
const subscriptionId = 'test-has-' + Date.now()
const filters = [{ kinds: [1] }]
assert.isFalse(uut.hasSubscription(subscriptionId))
await uut.createSubscription(subscriptionId, filters)
assert.isTrue(uut.hasSubscription(subscriptionId))
// Clean up
if (uut.hasSubscription(subscriptionId)) {
await uut.closeSubscription(subscriptionId)
}
})
})
})
@@ -0,0 +1,104 @@
/*
Integration tests for PublishEventUseCase with real adapter.
These tests require a running Nostr relay.
*/
// npm libraries
import { assert } from 'chai'
// Unit under test
import Adapters from '../../../src/adapters/index.js'
import PublishEventUseCase from '../../../src/use-cases/publish-event.js'
import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure'
describe('#publish-event-integration.js', () => {
let adapters
let uut
before(async () => {
// Initialize adapters (will connect to real relay)
adapters = new Adapters()
await adapters.start()
uut = new PublishEventUseCase({ adapters })
})
after(async () => {
// Clean up adapters - disconnect from all relays
if (adapters && adapters.nostrRelays) {
await Promise.allSettled(
adapters.nostrRelays.map(relay => relay.disconnect())
)
}
})
describe('#execute()', () => {
it('should successfully publish a valid event', async () => {
// Generate keys
const sk = generateSecretKey()
// Create event template
const eventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Integration test post from use case'
}
// Sign the event
const signedEvent = finalizeEvent(eventTemplate, sk)
// Execute use case
const result = await uut.execute(signedEvent)
// Assert result
assert.property(result, 'accepted')
assert.property(result, 'message')
assert.property(result, 'eventId')
assert.equal(result.eventId, signedEvent.id)
})
it('should reject invalid event structure', async () => {
const invalidEvent = {
id: 'invalid',
pubkey: 'invalid',
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'Test',
sig: 'invalid'
}
try {
await uut.execute(invalidEvent)
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Invalid event structure')
}
})
it('should handle relay rejection', async () => {
// Generate keys
const sk = generateSecretKey()
// Create a duplicate event (if we send same event twice)
const eventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Duplicate test post'
}
const signedEvent = finalizeEvent(eventTemplate, sk)
// Publish first time
const result1 = await uut.execute(signedEvent)
assert.property(result1, 'accepted')
// Try to publish again (may be rejected as duplicate)
const result2 = await uut.execute(signedEvent)
assert.property(result2, 'accepted')
// Result may be accepted or rejected depending on relay
})
})
})
@@ -0,0 +1,95 @@
/*
Integration tests for QueryEventsUseCase with real adapter.
These tests require a running Nostr relay.
*/
// npm libraries
import { assert } from 'chai'
// Unit under test
import Adapters from '../../../src/adapters/index.js'
import QueryEventsUseCase from '../../../src/use-cases/query-events.js'
describe('#query-events-integration.js', () => {
let adapters
let uut
before(async () => {
// Initialize adapters (will connect to real relay)
adapters = new Adapters()
await adapters.start()
uut = new QueryEventsUseCase({ adapters })
})
after(async () => {
// Clean up adapters - disconnect from all relays
if (adapters && adapters.nostrRelays) {
await Promise.allSettled(
adapters.nostrRelays.map(relay => relay.disconnect())
)
}
})
describe('#execute()', () => {
it('should successfully query events', async () => {
const filters = [{ kinds: [1], limit: 5 }]
const subscriptionId = 'test-query-' + Date.now()
const events = await uut.execute(filters, subscriptionId)
// Assert result is an array
assert.isArray(events)
// If events are returned, verify structure
if (events.length > 0) {
assert.property(events[0], 'id')
assert.property(events[0], 'pubkey')
assert.property(events[0], 'created_at')
assert.property(events[0], 'kind')
assert.property(events[0], 'content')
assert.equal(events[0].kind, 1)
}
})
it('should handle empty results', async () => {
// Query for events that likely don't exist
const filters = [{ kinds: [99999], limit: 1 }]
const subscriptionId = 'test-empty-' + Date.now()
const events = await uut.execute(filters, subscriptionId)
// Should return empty array, not throw
assert.isArray(events)
assert.equal(events.length, 0)
})
it('should handle multiple filters', async function () {
// Increase timeout for this test - needs to be longer than use case timeout (30s)
this.timeout(35000)
const filters = [
{ kinds: [1], limit: 2 },
{ kinds: [3], limit: 2 }
]
const subscriptionId = 'test-multi-' + Date.now()
const events = await uut.execute(filters, subscriptionId)
// Should return array (may be empty)
assert.isArray(events)
})
it('should timeout if EOSE not received', async () => {
// This test may take up to 30 seconds
// Use a filter that might not return EOSE quickly
const filters = [{ kinds: [1] }] // No limit, might timeout
const subscriptionId = 'test-timeout-' + Date.now()
// Should eventually return (even if empty)
const events = await uut.execute(filters, subscriptionId)
assert.isArray(events)
})
})
})
+304
View File
@@ -0,0 +1,304 @@
/*
Unit tests for NostrRelayAdapter.
*/
/*
// npm libraries
import { assert } from 'chai'
import sinon from 'sinon'
// Mocking data libraries
import {
mockKind1Event,
validEventId
} from '../mocks/event-mocks.js'
import {
mockOkAccepted,
mockEventMessage,
mockEoseMessage,
mockClosedMessage
} from '../mocks/nostr-relay-mocks.js'
// Unit under test
// Note: WebSocket mocking for ES modules is complex. These tests focus on
// testing the adapter's logic that can be tested without full WebSocket mocking.
import NostrRelayAdapter from '../../../src/adapters/nostr-relay.js'
describe('#nostr-relay.js', () => {
let sandbox
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
uut = new NostrRelayAdapter({
relayUrl: 'wss://test-relay.example.com'
})
})
afterEach(() => {
sandbox.restore()
})
describe('#connect()', () => {
it('should return immediately if already connected', async () => {
// Manually set connection state
uut.isConnected = true
uut.ws = { close: sandbox.stub() }
await uut.connect()
// Should not create new connection
assert.isTrue(uut.isConnected)
})
// Note: Full WebSocket connection testing requires integration tests
// due to ES module import limitations
})
describe('#sendEvent()', () => {
it('should queue message when disconnected', async () => {
uut.isConnected = false
uut.ws = null
// Mock connect to resolve immediately
uut.connect = sandbox.stub().resolves()
// Start sending (will queue)
uut.sendEvent(mockKind1Event).catch(() => {
// Expected to fail or timeout without real WebSocket
})
// Should queue message and attempt connection
// Wait a bit for async operations
await new Promise(resolve => setTimeout(resolve, 10))
assert.isTrue(uut.pendingMessages.length > 0 || uut.connect.called)
})
it('should set up event resolver', async () => {
uut.isConnected = true
uut.ws = { send: sandbox.stub() }
// Mock sendMessage to resolve immediately
uut.sendMessage = sandbox.stub().resolves()
// Start sending
const sendPromise = uut.sendEvent(mockKind1Event).catch(() => {
// Expected without real WebSocket response
})
// Wait a tick for Promise constructor to run
await new Promise(resolve => setImmediate(resolve))
// Verify resolver was set up
assert.isTrue(uut.eventResolvers.has(mockKind1Event.id))
// Clean up
uut.eventResolvers.delete(mockKind1Event.id)
// Prevent timeout error
sendPromise.catch(() => {})
})
// Note: Full sendEvent testing with WebSocket responses requires integration tests
})
describe('#sendReq()', () => {
it('should store handlers for subscription', async () => {
uut.isConnected = true
uut.ws = { send: sandbox.stub() }
uut.connect = sandbox.stub().resolves()
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
const handlers = {
onEvent: sandbox.stub(),
onEose: sandbox.stub(),
onClosed: sandbox.stub()
}
await uut.sendReq(subscriptionId, filters, handlers)
// Assert handlers were stored
assert.isTrue(uut.subscriptionHandlers.has(subscriptionId))
assert.deepEqual(uut.subscriptionHandlers.get(subscriptionId), handlers)
})
it('should connect before sending if disconnected', async () => {
uut.isConnected = false
uut.connect = sandbox.stub().resolves()
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
const handlers = {}
await uut.sendReq(subscriptionId, filters, handlers)
assert.isTrue(uut.connect.called)
})
})
describe('#sendClose()', () => {
it('should clean up handlers for subscription', async () => {
uut.isConnected = true
uut.ws = { send: sandbox.stub() }
const subscriptionId = 'test-sub-123'
uut.subscriptionHandlers.set(subscriptionId, {})
uut.messageHandlers.set(subscriptionId, {})
await uut.sendClose(subscriptionId)
// Assert handlers were cleaned up
assert.isFalse(uut.subscriptionHandlers.has(subscriptionId))
assert.isFalse(uut.messageHandlers.has(subscriptionId))
})
})
describe('#handleMessage()', () => {
it('should handle EVENT message', () => {
// Use the subscription ID from the mock message
const subscriptionId = 'subscription-id-123'
const onEventHandler = sandbox.stub()
uut.subscriptionHandlers.set(subscriptionId, {
onEvent: onEventHandler
})
const message = mockEventMessage
uut.handleMessage(message)
assert.isTrue(onEventHandler.calledOnce)
assert.deepEqual(onEventHandler.getCall(0).args[0], mockKind1Event)
})
it('should handle EOSE message', () => {
// Use the subscription ID from the mock message
const subscriptionId = 'subscription-id-123'
const onEoseHandler = sandbox.stub()
uut.subscriptionHandlers.set(subscriptionId, {
onEose: onEoseHandler
})
const message = mockEoseMessage
uut.handleMessage(message)
assert.isTrue(onEoseHandler.calledOnce)
})
it('should handle CLOSED message', () => {
// Use the subscription ID from the mock message
const subscriptionId = 'subscription-id-123'
const onClosedHandler = sandbox.stub()
uut.subscriptionHandlers.set(subscriptionId, {
onClosed: onClosedHandler
})
const message = mockClosedMessage
uut.handleMessage(message)
assert.isTrue(onClosedHandler.calledOnce)
assert.equal(onClosedHandler.getCall(0).args[0], 'subscription closed')
})
it('should handle OK message', () => {
const eventId = validEventId
let resolver = null
uut.eventResolvers.set(eventId, (result) => {
resolver = result
})
const message = mockOkAccepted
uut.handleMessage(message)
assert.isNotNull(resolver)
assert.isTrue(resolver.accepted)
assert.isFalse(uut.eventResolvers.has(eventId))
})
it('should handle NOTICE message', () => {
const message = ['NOTICE', 'rate limited']
// Should not throw
uut.handleMessage(message)
})
it('should ignore invalid message format', () => {
const message = 'invalid'
// Should not throw
uut.handleMessage(message)
})
it('should ignore empty messages', () => {
const message = []
// Should not throw
uut.handleMessage(message)
})
})
describe('#disconnect()', () => {
it('should disconnect from relay', async () => {
const mockWs = { close: sandbox.stub() }
uut.isConnected = true
uut.ws = mockWs
await uut.disconnect()
assert.isTrue(mockWs.close.called)
assert.isFalse(uut.isConnected)
assert.isNull(uut.ws)
})
it('should handle disconnect when already disconnected', async () => {
uut.isConnected = false
uut.ws = null
await uut.disconnect()
assert.isFalse(uut.isConnected)
})
})
describe('#handleError()', () => {
it('should handle WebSocket errors', () => {
uut.isConnected = true
const error = new Error('WebSocket error')
uut.handleError(error)
assert.isFalse(uut.isConnected)
})
})
describe('#handleClose()', () => {
it('should attempt reconnection on close', async () => {
uut.isConnected = true
uut.reconnectAttempts = 0
uut.maxReconnectAttempts = 5
// Mock connect to avoid actual connection
uut.connect = sandbox.stub().resolves()
uut.handleClose()
// Wait for reconnection attempt
await new Promise(resolve => setTimeout(resolve, 110))
// Should attempt reconnection
assert.equal(uut.reconnectAttempts, 1)
})
it('should stop reconnecting after max attempts', async () => {
uut.isConnected = true
uut.reconnectAttempts = 5
uut.maxReconnectAttempts = 5
uut.connect = sandbox.stub().resolves()
uut.handleClose()
await new Promise(resolve => setTimeout(resolve, 110))
// Should not increment beyond max
assert.equal(uut.reconnectAttempts, 5)
})
})
})
*/
+63
View File
@@ -0,0 +1,63 @@
/*
Unit tests for Server class.
Note: Full server testing requires integration tests due to ES module limitations.
These tests focus on testable logic.
*/
// npm libraries
import { assert } from 'chai'
import sinon from 'sinon'
// Unit under test
import Server from '../../../bin/server.js'
describe('#server.js', () => {
let sandbox
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
uut = new Server()
})
afterEach(() => {
sandbox.restore()
})
describe('#startServer()', () => {
// Note: Full server startup testing requires integration tests
// due to ES module import limitations with Express and Controllers
it('should have startServer method', () => {
assert.isFunction(uut.startServer)
})
it('should have controllers property', () => {
assert.property(uut, 'controllers')
})
it('should have config property', () => {
assert.property(uut, 'config')
})
})
describe('#sleep()', () => {
it('should sleep for specified milliseconds', async () => {
const start = Date.now()
await uut.sleep(50)
const end = Date.now()
// Should have slept at least 50ms (allowing some margin)
assert.isAtLeast(end - start, 40)
})
})
describe('#constructor()', () => {
it('should initialize with controllers and config', () => {
const server = new Server()
assert.property(server, 'controllers')
assert.property(server, 'config')
assert.property(server, 'process')
})
})
})
@@ -0,0 +1,187 @@
/*
Unit tests for EventRESTControllerLib.
*/
// npm libraries
import { assert } from 'chai'
import sinon from 'sinon'
// Mocking data libraries
import {
mockKind1Event,
mockKind0Event
} from '../mocks/event-mocks.js'
import {
createMockRequestWithBody,
createMockResponse
} from '../mocks/controller-mocks.js'
// Unit under test
import EventRESTControllerLib from '../../../src/controllers/rest-api/event/controller.js'
describe('#event-controller.js', () => {
let sandbox
let mockUseCases
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create mock use cases
mockUseCases = {
publishEvent: {
execute: sandbox.stub()
}
}
uut = new EventRESTControllerLib({
adapters: {},
useCases: mockUseCases
})
})
afterEach(() => {
sandbox.restore()
})
describe('#publishEvent()', () => {
it('should successfully publish an event', async () => {
const req = createMockRequestWithBody(mockKind1Event)
const res = createMockResponse()
mockUseCases.publishEvent.execute.resolves({
accepted: true,
message: 'event saved',
eventId: mockKind1Event.id
})
await uut.publishEvent(req, res)
// Assert use case was called
assert.isTrue(mockUseCases.publishEvent.execute.calledOnce)
assert.deepEqual(mockUseCases.publishEvent.execute.getCall(0).args[0], mockKind1Event)
// Assert response
assert.equal(res.statusValue, 200)
assert.property(res.jsonData, 'accepted')
assert.isTrue(res.jsonData.accepted)
assert.equal(res.jsonData.eventId, mockKind1Event.id)
})
it('should return 400 when event is rejected', async () => {
const req = createMockRequestWithBody(mockKind1Event)
const res = createMockResponse()
mockUseCases.publishEvent.execute.resolves({
accepted: false,
message: 'duplicate: event already exists',
eventId: mockKind1Event.id
})
await uut.publishEvent(req, res)
// Assert response status is 400
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'accepted')
assert.isFalse(res.jsonData.accepted)
})
it('should return 400 when event data is missing', async () => {
const req = createMockRequestWithBody(null)
const res = createMockResponse()
await uut.publishEvent(req, res)
// Assert use case was not called
assert.isFalse(mockUseCases.publishEvent.execute.called)
// Assert error response
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Event data is required')
})
it('should handle use case errors', async () => {
const req = createMockRequestWithBody(mockKind1Event)
const res = createMockResponse()
mockUseCases.publishEvent.execute.rejects(new Error('Network error'))
await uut.publishEvent(req, res)
// Assert error response
assert.equal(res.statusValue, 500)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Network error')
})
it('should return 400 for validation errors', async () => {
const req = createMockRequestWithBody(mockKind1Event)
const res = createMockResponse()
mockUseCases.publishEvent.execute.rejects(new Error('Invalid event structure'))
await uut.publishEvent(req, res)
// Assert validation error returns 400
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Invalid event structure')
})
it('should handle errors with missing message', async () => {
const req = createMockRequestWithBody(mockKind1Event)
const res = createMockResponse()
const error = new Error()
error.message = undefined
mockUseCases.publishEvent.execute.rejects(error)
await uut.publishEvent(req, res)
// Assert error response with default message
assert.equal(res.statusValue, 500)
assert.property(res.jsonData, 'error')
assert.equal(res.jsonData.error, 'Internal server error')
})
it('should publish different event kinds', async () => {
const req = createMockRequestWithBody(mockKind0Event)
const res = createMockResponse()
mockUseCases.publishEvent.execute.resolves({
accepted: true,
message: 'event saved',
eventId: mockKind0Event.id
})
await uut.publishEvent(req, res)
assert.isTrue(mockUseCases.publishEvent.execute.calledOnce)
assert.equal(res.statusValue, 200)
assert.isTrue(res.jsonData.accepted)
})
})
describe('#constructor()', () => {
it('should require adapters instance', () => {
try {
// eslint-disable-next-line no-new
new EventRESTControllerLib({ useCases: mockUseCases })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Adapters library required')
}
})
it('should require useCases instance', () => {
try {
// eslint-disable-next-line no-new
new EventRESTControllerLib({ adapters: {} })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Use Cases library required')
}
})
})
})
@@ -0,0 +1,344 @@
/*
Unit tests for ReqRESTControllerLib.
*/
// npm libraries
import { assert } from 'chai'
import sinon from 'sinon'
// Mocking data libraries
import { mockEventsArray } from '../mocks/nostr-relay-mocks.js'
import {
createMockRequestWithParams,
createMockResponse
} from '../mocks/controller-mocks.js'
// Unit under test
import ReqRESTControllerLib from '../../../src/controllers/rest-api/req/controller.js'
describe('#req-controller.js', () => {
let sandbox
let mockUseCases
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create mock use cases
mockUseCases = {
queryEvents: {
execute: sandbox.stub()
},
manageSubscription: {
createSubscription: sandbox.stub(),
closeSubscription: sandbox.stub()
}
}
uut = new ReqRESTControllerLib({
adapters: {},
useCases: mockUseCases
})
})
afterEach(() => {
sandbox.restore()
})
describe('#queryEvents()', () => {
it('should successfully query events with filters as JSON string', async () => {
const filters = [{ kinds: [1], limit: 10 }]
const filtersJson = JSON.stringify(filters)
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.query = { filters: filtersJson }
const res = createMockResponse()
mockUseCases.queryEvents.execute.resolves(mockEventsArray)
await uut.queryEvents(req, res)
// Assert use case was called with parsed filters
assert.isTrue(mockUseCases.queryEvents.execute.calledOnce)
const executeArgs = mockUseCases.queryEvents.execute.getCall(0).args
assert.deepEqual(executeArgs[0], filters)
assert.equal(executeArgs[1], 'test-sub-123')
// Assert response
assert.equal(res.statusValue, 200)
assert.isArray(res.jsonData)
assert.equal(res.jsonData.length, mockEventsArray.length)
})
it('should successfully query events with individual query params', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.query = {
kinds: JSON.stringify([1]),
authors: JSON.stringify(['abc123']),
limit: '10'
}
const res = createMockResponse()
mockUseCases.queryEvents.execute.resolves(mockEventsArray)
await uut.queryEvents(req, res)
// Assert use case was called
assert.isTrue(mockUseCases.queryEvents.execute.calledOnce)
const executeArgs = mockUseCases.queryEvents.execute.getCall(0).args
assert.isArray(executeArgs[0])
assert.equal(executeArgs[0][0].kinds[0], 1)
assert.equal(executeArgs[0][0].authors[0], 'abc123')
assert.equal(executeArgs[0][0].limit, 10)
})
it('should handle empty filters', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.query = {}
const res = createMockResponse()
mockUseCases.queryEvents.execute.resolves([])
await uut.queryEvents(req, res)
// Assert use case was called with empty filters array
assert.isTrue(mockUseCases.queryEvents.execute.calledOnce)
const executeArgs = mockUseCases.queryEvents.execute.getCall(0).args
assert.deepEqual(executeArgs[0], [{}])
})
it('should return 400 when subscription ID is missing', async () => {
const req = createMockRequestWithParams({})
const res = createMockResponse()
await uut.queryEvents(req, res)
// Assert use case was not called
assert.isFalse(mockUseCases.queryEvents.execute.called)
// Assert error response
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Subscription ID is required')
})
it('should return 400 when filters JSON is invalid', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.query = { filters: 'invalid-json{' }
const res = createMockResponse()
await uut.queryEvents(req, res)
// Assert error response
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Invalid filters JSON')
})
it('should handle use case errors', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.query = { filters: JSON.stringify([{ kinds: [1] }]) }
const res = createMockResponse()
mockUseCases.queryEvents.execute.rejects(new Error('Query failed'))
await uut.queryEvents(req, res)
// Assert error response
assert.equal(res.statusValue, 500)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Query failed')
})
})
describe('#createSubscription()', () => {
it('should successfully create SSE subscription', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.body = { kinds: [1] }
const res = createMockResponse()
mockUseCases.manageSubscription.createSubscription.resolves()
await uut.createSubscription(req, res)
// Assert use case was called
assert.isTrue(mockUseCases.manageSubscription.createSubscription.calledOnce)
const createArgs = mockUseCases.manageSubscription.createSubscription.getCall(0).args
assert.equal(createArgs[0], 'test-sub-123')
assert.isArray(createArgs[1])
assert.equal(createArgs[1][0].kinds[0], 1)
assert.isFunction(createArgs[2]) // onEvent
assert.isFunction(createArgs[3]) // onEose
assert.isFunction(createArgs[4]) // onClosed
// Assert SSE headers
assert.equal(res.headers['Content-Type'], 'text/event-stream')
assert.equal(res.headers['Cache-Control'], 'no-cache')
assert.equal(res.headers.Connection, 'keep-alive')
// Assert initial connection message was written
assert.isTrue(res.writeData.length > 0)
})
it('should return 400 when subscription ID is missing', async () => {
const req = createMockRequestWithParams({})
req.body = { kinds: [1] }
const res = createMockResponse()
await uut.createSubscription(req, res)
// Assert error response
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Subscription ID is required')
})
it('should return 400 when filters are missing', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.body = {}
const res = createMockResponse()
await uut.createSubscription(req, res)
// Assert error response
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Filters are required')
})
it('should handle filters as array', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.body = [{ kinds: [1] }, { kinds: [3] }]
const res = createMockResponse()
mockUseCases.manageSubscription.createSubscription.resolves()
await uut.createSubscription(req, res)
// Assert filters array was passed correctly
const createArgs = mockUseCases.manageSubscription.createSubscription.getCall(0).args
assert.isArray(createArgs[1])
assert.equal(createArgs[1].length, 2)
})
it('should handle client disconnect', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.body = { kinds: [1] }
req.on = sinon.stub()
const res = createMockResponse()
mockUseCases.manageSubscription.createSubscription.resolves()
mockUseCases.manageSubscription.closeSubscription.resolves()
await uut.createSubscription(req, res)
// Assert close handler was set up
assert.isTrue(req.on.calledWith('close'))
// Simulate client disconnect
const closeCallback = req.on.getCall(0).args[1]
await closeCallback()
// Assert closeSubscription was called
assert.isTrue(mockUseCases.manageSubscription.closeSubscription.calledOnce)
assert.equal(mockUseCases.manageSubscription.closeSubscription.getCall(0).args[0], 'test-sub-123')
})
})
describe('#closeSubscription()', () => {
it('should successfully close a subscription', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
const res = createMockResponse()
mockUseCases.manageSubscription.closeSubscription.resolves()
await uut.closeSubscription(req, res)
// Assert use case was called
assert.isTrue(mockUseCases.manageSubscription.closeSubscription.calledOnce)
assert.equal(mockUseCases.manageSubscription.closeSubscription.getCall(0).args[0], 'test-sub-123')
// Assert response
assert.equal(res.statusValue, 200)
assert.property(res.jsonData, 'message')
assert.include(res.jsonData.message, 'closed successfully')
})
it('should return 400 when subscription ID is missing', async () => {
const req = createMockRequestWithParams({})
const res = createMockResponse()
await uut.closeSubscription(req, res)
// Assert error response
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Subscription ID is required')
})
it('should handle use case errors', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
const res = createMockResponse()
mockUseCases.manageSubscription.closeSubscription.rejects(new Error('Relay connection error'))
await uut.closeSubscription(req, res)
// Assert error response
assert.equal(res.statusValue, 500)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Relay connection error')
})
it('should handle idempotent close (subscription already closed)', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
const res = createMockResponse()
// closeSubscription resolves successfully even if subscription doesn't exist
mockUseCases.manageSubscription.closeSubscription.resolves()
await uut.closeSubscription(req, res)
// Assert success response even for already-closed subscription
assert.equal(res.statusValue, 200)
assert.property(res.jsonData, 'message')
assert.include(res.jsonData.message, 'closed successfully')
})
})
describe('#createSubscriptionPut()', () => {
it('should call createSubscription', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.body = { kinds: [1] }
const res = createMockResponse()
mockUseCases.manageSubscription.createSubscription.resolves()
await uut.createSubscriptionPut(req, res)
// Assert createSubscription was called
assert.isTrue(mockUseCases.manageSubscription.createSubscription.calledOnce)
})
})
describe('#constructor()', () => {
it('should require adapters instance', () => {
try {
// eslint-disable-next-line no-new
new ReqRESTControllerLib({ useCases: mockUseCases })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Adapters library required')
}
})
it('should require useCases instance', () => {
try {
// eslint-disable-next-line no-new
new ReqRESTControllerLib({ adapters: {} })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Use Cases library required')
}
})
})
})
+139
View File
@@ -0,0 +1,139 @@
/*
Unit tests for the Event entity.
*/
// npm libraries
import { assert } from 'chai'
// Mocking data libraries
import {
mockKind0Event,
mockKind1Event,
mockKind3Event,
mockKind7Event,
mockInvalidEventMissingId,
mockInvalidEventWrongIdLength,
mockInvalidEventMissingPubkey,
mockInvalidEventWrongPubkeyLength,
mockInvalidEventMissingCreatedAt,
mockInvalidEventWrongCreatedAtType,
mockInvalidEventMissingKind,
mockInvalidEventKindOutOfRange,
mockInvalidEventMissingSig,
mockInvalidEventWrongSigLength,
mockInvalidEventTagsNotArray
} from '../mocks/event-mocks.js'
// Unit under test
import Event from '../../../src/entities/event.js'
describe('#event.js', () => {
describe('#isValid()', () => {
it('should return true for valid kind 0 event', () => {
const event = new Event(mockKind0Event)
assert.isTrue(event.isValid())
})
it('should return true for valid kind 1 event', () => {
const event = new Event(mockKind1Event)
assert.isTrue(event.isValid())
})
it('should return true for valid kind 3 event', () => {
const event = new Event(mockKind3Event)
assert.isTrue(event.isValid())
})
it('should return true for valid kind 7 event', () => {
const event = new Event(mockKind7Event)
assert.isTrue(event.isValid())
})
it('should return false for event missing id', () => {
const event = new Event(mockInvalidEventMissingId)
assert.isFalse(event.isValid())
})
it('should return false for event with wrong id length', () => {
const event = new Event(mockInvalidEventWrongIdLength)
assert.isFalse(event.isValid())
})
it('should return false for event missing pubkey', () => {
const event = new Event(mockInvalidEventMissingPubkey)
assert.isFalse(event.isValid())
})
it('should return false for event with wrong pubkey length', () => {
const event = new Event(mockInvalidEventWrongPubkeyLength)
assert.isFalse(event.isValid())
})
it('should return false for event missing created_at', () => {
const event = new Event(mockInvalidEventMissingCreatedAt)
assert.isFalse(event.isValid())
})
it('should return false for event with wrong created_at type', () => {
const event = new Event(mockInvalidEventWrongCreatedAtType)
assert.isFalse(event.isValid())
})
it('should return false for event missing kind', () => {
const event = new Event(mockInvalidEventMissingKind)
assert.isFalse(event.isValid())
})
it('should return false for event with kind out of range', () => {
const event = new Event(mockInvalidEventKindOutOfRange)
assert.isFalse(event.isValid())
})
it('should return false for event missing sig', () => {
const event = new Event(mockInvalidEventMissingSig)
assert.isFalse(event.isValid())
})
it('should return false for event with wrong sig length', () => {
const event = new Event(mockInvalidEventWrongSigLength)
assert.isFalse(event.isValid())
})
it('should return false for event with tags not an array', () => {
const event = new Event(mockInvalidEventTagsNotArray)
assert.isFalse(event.isValid())
})
})
describe('#toJSON()', () => {
it('should serialize event to JSON correctly', () => {
const event = new Event(mockKind1Event)
const json = event.toJSON()
assert.property(json, 'id')
assert.property(json, 'pubkey')
assert.property(json, 'created_at')
assert.property(json, 'kind')
assert.property(json, 'tags')
assert.property(json, 'content')
assert.property(json, 'sig')
assert.equal(json.id, mockKind1Event.id)
assert.equal(json.pubkey, mockKind1Event.pubkey)
assert.equal(json.created_at, mockKind1Event.created_at)
assert.equal(json.kind, mockKind1Event.kind)
assert.deepEqual(json.tags, mockKind1Event.tags)
assert.equal(json.content, mockKind1Event.content)
assert.equal(json.sig, mockKind1Event.sig)
})
it('should serialize event with tags correctly', () => {
const event = new Event(mockKind3Event)
const json = event.toJSON()
assert.isArray(json.tags)
assert.equal(json.tags.length, 1)
assert.deepEqual(json.tags, mockKind3Event.tags)
})
})
})
+98
View File
@@ -0,0 +1,98 @@
/*
Mock Express request/response objects for controller unit tests.
*/
// Mock Express request object
export function createMockRequest (overrides = {}) {
return {
body: {},
params: {},
query: {},
method: 'GET',
path: '/',
...overrides
}
}
// Mock Express response object
export function createMockResponse () {
const res = {
statusCode: 200,
jsonData: null,
statusValue: null,
headers: {},
writeData: [],
endCalled: false,
writable: true, // Stream is writable by default
destroyed: false, // Stream is not destroyed by default
closed: false, // Stream is not closed by default
eventHandlers: {} // Store event handlers
}
res.status = function (code) {
res.statusCode = code
res.statusValue = code
return res
}
res.json = function (data) {
res.jsonData = data
return res
}
res.setHeader = function (name, value) {
res.headers[name] = value
return res
}
res.write = function (data) {
res.writeData.push(data)
return true
}
res.end = function () {
res.endCalled = true
return res
}
res.on = function (event, callback) {
// Store event handlers for different event types
if (!res.eventHandlers[event]) {
res.eventHandlers[event] = []
}
res.eventHandlers[event].push(callback)
// For backward compatibility with existing tests
if (event === 'close') {
res.closeCallback = callback
}
return res
}
// Helper to trigger an event (useful for testing)
res.trigger = function (event, ...args) {
if (res.eventHandlers[event]) {
for (const handler of res.eventHandlers[event]) {
handler(...args)
}
}
}
return res
}
// Helper to create a mock request with body
export function createMockRequestWithBody (body) {
return createMockRequest({ body })
}
// Helper to create a mock request with params
export function createMockRequestWithParams (params) {
return createMockRequest({ params })
}
// Helper to create a mock request with query
export function createMockRequestWithQuery (query) {
return createMockRequest({ query })
}
+194
View File
@@ -0,0 +1,194 @@
/*
Mock event data for unit tests.
Contains mock Nostr events for various event kinds.
*/
// Alice's public key from examples
const alicePubKey = '2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e92'
const bobPubKey = 'b'.repeat(64)
// Valid event ID (64 hex chars)
const validEventId = 'd09b4c5da59be3cd2768aa53fa78b77bf4859084c94f3bf26d401f004a9c8167'
// Valid signature (128 hex chars)
const validSig = 'a'.repeat(128)
// Kind 0: Profile metadata event
const mockKind0Event = {
id: validEventId,
pubkey: alicePubKey,
created_at: Math.floor(Date.now() / 1000),
kind: 0,
tags: [],
content: JSON.stringify({
name: 'Alice',
about: 'Hello, I am Alice!',
picture: 'https://example.com/alice.jpg'
}),
sig: validSig
}
// Kind 1: Text post event
const mockKind1Event = {
id: validEventId,
pubkey: alicePubKey,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'This is a test message',
sig: validSig
}
// Kind 3: Follow list event
const mockKind3Event = {
id: validEventId,
pubkey: alicePubKey,
created_at: Math.floor(Date.now() / 1000),
kind: 3,
tags: [
['p', bobPubKey, 'wss://nostr-relay.psfoundation.info', 'bob']
],
content: '',
sig: validSig
}
// Kind 7: Reaction/like event
const mockKind7Event = {
id: validEventId,
pubkey: bobPubKey,
created_at: Math.floor(Date.now() / 1000),
kind: 7,
tags: [
['e', validEventId, 'wss://nostr-relay.psfoundation.info'],
['p', alicePubKey, 'wss://nostr-relay.psfoundation.info']
],
content: '+',
sig: validSig
}
// Invalid events for testing validation
const mockInvalidEventMissingId = {
pubkey: alicePubKey,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'Test',
sig: validSig
}
const mockInvalidEventWrongIdLength = {
id: 'short',
pubkey: alicePubKey,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'Test',
sig: validSig
}
const mockInvalidEventMissingPubkey = {
id: validEventId,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'Test',
sig: validSig
}
const mockInvalidEventWrongPubkeyLength = {
id: validEventId,
pubkey: 'short',
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'Test',
sig: validSig
}
const mockInvalidEventMissingCreatedAt = {
id: validEventId,
pubkey: alicePubKey,
kind: 1,
tags: [],
content: 'Test',
sig: validSig
}
const mockInvalidEventWrongCreatedAtType = {
id: validEventId,
pubkey: alicePubKey,
created_at: 'not-a-number',
kind: 1,
tags: [],
content: 'Test',
sig: validSig
}
const mockInvalidEventMissingKind = {
id: validEventId,
pubkey: alicePubKey,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Test',
sig: validSig
}
const mockInvalidEventKindOutOfRange = {
id: validEventId,
pubkey: alicePubKey,
created_at: Math.floor(Date.now() / 1000),
kind: 70000, // Out of range (0-65535)
tags: [],
content: 'Test',
sig: validSig
}
const mockInvalidEventMissingSig = {
id: validEventId,
pubkey: alicePubKey,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'Test'
}
const mockInvalidEventWrongSigLength = {
id: validEventId,
pubkey: alicePubKey,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'Test',
sig: 'short'
}
const mockInvalidEventTagsNotArray = {
id: validEventId,
pubkey: alicePubKey,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: 'not-an-array',
content: 'Test',
sig: validSig
}
export {
mockKind0Event,
mockKind1Event,
mockKind3Event,
mockKind7Event,
mockInvalidEventMissingId,
mockInvalidEventWrongIdLength,
mockInvalidEventMissingPubkey,
mockInvalidEventWrongPubkeyLength,
mockInvalidEventMissingCreatedAt,
mockInvalidEventWrongCreatedAtType,
mockInvalidEventMissingKind,
mockInvalidEventKindOutOfRange,
mockInvalidEventMissingSig,
mockInvalidEventWrongSigLength,
mockInvalidEventTagsNotArray,
alicePubKey,
bobPubKey,
validEventId,
validSig
}
+58
View File
@@ -0,0 +1,58 @@
/*
Mock responses from Nostr relay for unit tests.
Contains mock messages that would come from a Nostr relay WebSocket.
*/
import { mockKind1Event, validEventId } from './event-mocks.js'
// Mock OK response (event accepted)
const mockOkAccepted = ['OK', validEventId, true, 'event saved']
// Mock OK response (event rejected)
const mockOkRejected = ['OK', validEventId, false, 'duplicate: event already exists']
// Mock EVENT message (from relay)
const mockEventMessage = ['EVENT', 'subscription-id-123', mockKind1Event]
// Mock EOSE message (end of stored events)
const mockEoseMessage = ['EOSE', 'subscription-id-123']
// Mock CLOSED message
const mockClosedMessage = ['CLOSED', 'subscription-id-123', 'subscription closed']
// Mock NOTICE message
const mockNoticeMessage = ['NOTICE', 'rate limited: slow down']
// Mock successful sendEvent response
const mockSendEventSuccess = {
accepted: true,
message: 'event saved'
}
// Mock failed sendEvent response
const mockSendEventFailure = {
accepted: false,
message: 'duplicate: event already exists'
}
// Mock events array for query tests
const mockEventsArray = [
mockKind1Event,
{
...mockKind1Event,
id: 'b'.repeat(64),
content: 'Another test message'
}
]
export {
mockOkAccepted,
mockOkRejected,
mockEventMessage,
mockEoseMessage,
mockClosedMessage,
mockNoticeMessage,
mockSendEventSuccess,
mockSendEventFailure,
mockEventsArray
}
@@ -0,0 +1,242 @@
/*
Unit tests for ManageSubscriptionUseCase.
*/
// npm libraries
import { assert } from 'chai'
import sinon from 'sinon'
// Mocking data libraries
import { mockKind1Event } from '../mocks/event-mocks.js'
// Unit under test
import ManageSubscriptionUseCase from '../../../src/use-cases/manage-subscription.js'
describe('#manage-subscription.js', () => {
let sandbox
let mockAdapters
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create mock adapters with multiple relays support
const mockRelay1 = {
relayUrl: 'wss://relay1.example.com',
sendReq: sandbox.stub(),
sendClose: sandbox.stub()
}
const mockRelay2 = {
relayUrl: 'wss://relay2.example.com',
sendReq: sandbox.stub(),
sendClose: sandbox.stub()
}
mockAdapters = {
nostrRelays: [mockRelay1, mockRelay2]
}
uut = new ManageSubscriptionUseCase({ adapters: mockAdapters })
})
afterEach(() => {
sandbox.restore()
})
describe('#createSubscription()', () => {
it('should successfully create a subscription across all relays', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
let onEventCalled = false
let onEoseCalled = false
let onClosedCalled = false
const onEvent = (event) => {
onEventCalled = true
}
const onEose = () => {
onEoseCalled = true
}
const onClosed = (message) => {
onClosedCalled = true
}
// Mock adapters to resolve
mockAdapters.nostrRelays[0].sendReq.resolves()
mockAdapters.nostrRelays[1].sendReq.resolves()
await uut.createSubscription(subscriptionId, filters, onEvent, onEose, onClosed)
// Assert adapters were called for both relays
assert.isTrue(mockAdapters.nostrRelays[0].sendReq.calledOnce)
assert.isTrue(mockAdapters.nostrRelays[1].sendReq.calledOnce)
// Assert subscription is tracked
assert.isTrue(uut.hasSubscription(subscriptionId))
// Test handlers - get them from the subscription info
const subscriptionInfo = uut.activeSubscriptions.get(subscriptionId)
const handlers = subscriptionInfo.handlers
// Test event handler (should de-duplicate)
handlers.onEvent(mockKind1Event)
assert.isTrue(onEventCalled)
// Simulate EOSE from both relays
const relayStatuses = subscriptionInfo.relayStatuses
relayStatuses[0].eoseReceived = true
relayStatuses[1].eoseReceived = true
handlers.onEose()
assert.isTrue(onEoseCalled)
handlers.onClosed('test message')
assert.isTrue(onClosedCalled)
assert.isFalse(uut.hasSubscription(subscriptionId))
})
it('should prevent duplicate subscriptions', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
mockAdapters.nostrRelays[0].sendReq.resolves()
mockAdapters.nostrRelays[1].sendReq.resolves()
await uut.createSubscription(subscriptionId, filters)
try {
await uut.createSubscription(subscriptionId, filters)
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'already exists')
}
})
it('should clean up subscription on error', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
mockAdapters.nostrRelays[0].sendReq.rejects(new Error('Connection error'))
mockAdapters.nostrRelays[1].sendReq.resolves()
try {
await uut.createSubscription(subscriptionId, filters)
assert.equal(true, false, 'unexpected result')
} catch (err) {
// Should clean up even if some relays fail
assert.isFalse(uut.hasSubscription(subscriptionId))
}
})
it('should handle missing callbacks gracefully', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
mockAdapters.nostrRelays[0].sendReq.resolves()
mockAdapters.nostrRelays[1].sendReq.resolves()
await uut.createSubscription(subscriptionId, filters, null, null, null)
// Should not throw when handlers are null
const subscriptionInfo = uut.activeSubscriptions.get(subscriptionId)
const handlers = subscriptionInfo.handlers
handlers.onEvent(mockKind1Event)
handlers.onEose()
handlers.onClosed('test')
})
})
describe('#closeSubscription()', () => {
it('should successfully close a subscription across all relays', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
mockAdapters.nostrRelays[0].sendReq.resolves()
mockAdapters.nostrRelays[1].sendReq.resolves()
mockAdapters.nostrRelays[0].sendClose.resolves()
mockAdapters.nostrRelays[1].sendClose.resolves()
// Create subscription first
await uut.createSubscription(subscriptionId, filters)
assert.isTrue(uut.hasSubscription(subscriptionId))
// Close subscription
await uut.closeSubscription(subscriptionId)
// Assert adapters were called for both relays
assert.isTrue(mockAdapters.nostrRelays[0].sendClose.calledOnce)
assert.isTrue(mockAdapters.nostrRelays[1].sendClose.calledOnce)
// Assert subscription is removed
assert.isFalse(uut.hasSubscription(subscriptionId))
})
it('should return successfully when closing non-existent subscription (idempotent)', async () => {
const subscriptionId = 'non-existent-sub'
// Should not throw - idempotent operation
await uut.closeSubscription(subscriptionId)
// Should return successfully without error
assert.isTrue(true, 'closeSubscription should succeed for non-existent subscription')
})
it('should clean up subscription even on error', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
mockAdapters.nostrRelays[0].sendReq.resolves()
mockAdapters.nostrRelays[1].sendReq.resolves()
mockAdapters.nostrRelays[0].sendClose.rejects(new Error('Close error'))
mockAdapters.nostrRelays[1].sendClose.resolves()
// Create subscription first
await uut.createSubscription(subscriptionId, filters)
// Close should succeed even if one relay fails
await uut.closeSubscription(subscriptionId)
// Should still clean up
assert.isFalse(uut.hasSubscription(subscriptionId))
})
})
describe('#hasSubscription()', () => {
it('should return false for non-existent subscription', () => {
assert.isFalse(uut.hasSubscription('non-existent'))
})
it('should return true for existing subscription', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
mockAdapters.nostrRelays[0].sendReq.resolves()
mockAdapters.nostrRelays[1].sendReq.resolves()
assert.isFalse(uut.hasSubscription(subscriptionId))
await uut.createSubscription(subscriptionId, filters)
assert.isTrue(uut.hasSubscription(subscriptionId))
})
})
describe('#constructor()', () => {
it('should require adapters instance', () => {
try {
// eslint-disable-next-line no-new
new ManageSubscriptionUseCase()
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Adapters instance required')
}
})
it('should require NostrRelay adapters array', () => {
try {
// eslint-disable-next-line no-new
new ManageSubscriptionUseCase({ adapters: {} })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'NostrRelay adapters array required')
}
})
})
})
+146
View File
@@ -0,0 +1,146 @@
/*
Unit tests for PublishEventUseCase.
*/
// npm libraries
import { assert } from 'chai'
import sinon from 'sinon'
// Mocking data libraries
import {
mockKind1Event,
mockInvalidEventMissingId
} from '../mocks/event-mocks.js'
// Unit under test
import PublishEventUseCase from '../../../src/use-cases/publish-event.js'
describe('#publish-event.js', () => {
let sandbox
let mockAdapters
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create mock adapters with multiple relays support
mockAdapters = {
nostrRelays: [
{ relayUrl: 'wss://relay1.example.com' },
{ relayUrl: 'wss://relay2.example.com' }
],
broadcastEvent: sandbox.stub()
}
uut = new PublishEventUseCase({ adapters: mockAdapters })
})
afterEach(() => {
sandbox.restore()
})
describe('#execute()', () => {
it('should successfully publish a valid event to all relays', async () => {
// Mock broadcast response - at least one relay accepts
mockAdapters.broadcastEvent.resolves([
{ accepted: true, message: 'event saved', relayUrl: 'wss://relay1.example.com', success: true },
{ accepted: true, message: 'event saved', relayUrl: 'wss://relay2.example.com', success: true }
])
const result = await uut.execute(mockKind1Event)
// Assert adapter was called correctly
assert.isTrue(mockAdapters.broadcastEvent.calledOnce)
const callArgs = mockAdapters.broadcastEvent.getCall(0).args[0]
assert.equal(callArgs.id, mockKind1Event.id)
assert.equal(callArgs.kind, mockKind1Event.kind)
// Assert result
assert.property(result, 'accepted')
assert.property(result, 'message')
assert.property(result, 'eventId')
assert.property(result, 'relayResults')
assert.property(result, 'acceptedCount')
assert.property(result, 'totalRelays')
assert.isTrue(result.accepted)
assert.equal(result.eventId, mockKind1Event.id)
assert.equal(result.acceptedCount, 2)
assert.equal(result.totalRelays, 2)
})
it('should handle event rejection from all relays', async () => {
// Mock broadcast response - all relays reject
mockAdapters.broadcastEvent.resolves([
{ accepted: false, message: 'duplicate', relayUrl: 'wss://relay1.example.com', success: true },
{ accepted: false, message: 'duplicate', relayUrl: 'wss://relay2.example.com', success: true }
])
const result = await uut.execute(mockKind1Event)
// Assert result shows rejection
assert.isFalse(result.accepted)
assert.property(result, 'message')
assert.equal(result.eventId, mockKind1Event.id)
assert.equal(result.acceptedCount, 0)
})
it('should succeed if at least one relay accepts', async () => {
// Mock broadcast response - one accepts, one rejects
mockAdapters.broadcastEvent.resolves([
{ accepted: true, message: 'event saved', relayUrl: 'wss://relay1.example.com', success: true },
{ accepted: false, message: 'duplicate', relayUrl: 'wss://relay2.example.com', success: true }
])
const result = await uut.execute(mockKind1Event)
// Should succeed if at least one accepts
assert.isTrue(result.accepted)
assert.equal(result.acceptedCount, 1)
assert.equal(result.totalRelays, 2)
})
it('should throw error for invalid event structure', async () => {
try {
await uut.execute(mockInvalidEventMissingId)
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Invalid event structure')
assert.isFalse(mockAdapters.broadcastEvent.called)
}
})
it('should handle adapter errors', async () => {
// Mock adapter error
const adapterError = new Error('Network error')
mockAdapters.broadcastEvent.rejects(adapterError)
try {
await uut.execute(mockKind1Event)
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.equal(err.message, 'Network error')
assert.isTrue(mockAdapters.broadcastEvent.calledOnce)
}
})
it('should require adapters instance', () => {
try {
// eslint-disable-next-line no-new
new PublishEventUseCase()
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Adapters instance required')
}
})
it('should require NostrRelay adapters array', () => {
try {
// eslint-disable-next-line no-new
new PublishEventUseCase({ adapters: {} })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'NostrRelay adapters array required')
}
})
})
})
+106
View File
@@ -0,0 +1,106 @@
/*
Unit tests for QueryEventsUseCase.
*/
// npm libraries
import { assert } from 'chai'
import sinon from 'sinon'
// Mocking data libraries
import { mockEventsArray } from '../mocks/nostr-relay-mocks.js'
// Unit under test
import QueryEventsUseCase from '../../../src/use-cases/query-events.js'
describe('#query-events.js', () => {
let sandbox
let mockAdapters
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create mock adapters with multiple relays support
mockAdapters = {
nostrRelays: [
{ relayUrl: 'wss://relay1.example.com' },
{ relayUrl: 'wss://relay2.example.com' }
],
queryAllRelays: sandbox.stub()
}
uut = new QueryEventsUseCase({ adapters: mockAdapters })
})
afterEach(() => {
sandbox.restore()
})
describe('#execute()', () => {
it('should successfully query events from all relays and return merged results', async () => {
const filters = [{ kinds: [1], limit: 10 }]
const subscriptionId = 'test-sub-123'
// Mock queryAllRelays to return events
mockAdapters.queryAllRelays.resolves(mockEventsArray)
const result = await uut.execute(filters, subscriptionId)
// Assert adapter was called correctly
assert.isTrue(mockAdapters.queryAllRelays.calledOnce)
const callArgs = mockAdapters.queryAllRelays.getCall(0).args
assert.deepEqual(callArgs[0], filters)
assert.equal(callArgs[1], subscriptionId)
// Assert result contains events
assert.isArray(result)
assert.equal(result.length, mockEventsArray.length)
})
it('should handle errors from queryAllRelays', async () => {
const filters = [{ kinds: [1] }]
const subscriptionId = 'test-sub-123'
mockAdapters.queryAllRelays.rejects(new Error('Query failed'))
try {
await uut.execute(filters, subscriptionId)
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Query failed')
}
})
it('should return empty array when no events found', async () => {
const filters = [{ kinds: [1] }]
const subscriptionId = 'test-sub-123'
mockAdapters.queryAllRelays.resolves([])
const result = await uut.execute(filters, subscriptionId)
assert.isArray(result)
assert.equal(result.length, 0)
})
it('should require adapters instance', () => {
try {
// eslint-disable-next-line no-new
new QueryEventsUseCase()
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Adapters instance required')
}
})
it('should require NostrRelay adapters array', () => {
try {
// eslint-disable-next-line no-new
new QueryEventsUseCase({ adapters: {} })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'NostrRelay adapters array required')
}
})
})
})